diff --git a/Cargo.lock b/Cargo.lock index 6546ef77c..3d5e0ee23 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -3165,6 +3165,7 @@ dependencies = [ "op-pen-loader", "raw-window-handle", "scopeguard", + "serde_json", "skia-safe", "sysinfo", "thiserror 1.0.69", diff --git a/crates/op-editor-core/src/editor_ui_state.rs b/crates/op-editor-core/src/editor_ui_state.rs index bab879403..251f35344 100644 --- a/crates/op-editor-core/src/editor_ui_state.rs +++ b/crates/op-editor-core/src/editor_ui_state.rs @@ -870,6 +870,20 @@ pub struct EditorUiState { /// authored parent-local geometry. The scene builder can use this /// flag to skip the expensive flex/text layout pass. pub preserve_authored_geometry: bool, + // --- Canvas preview (Play) mode ------------------------------- + /// Whether the canvas is in **Preview** (Play) mode. When `true`, + /// the editor stops painting selection/handles + editor chrome + /// affordances over the canvas and instead drives a live jian + /// runtime (host-local — the runtime is `!Send` so it lives on the + /// native host, NOT on this wasm32-clean state). Entering does NOT + /// mutate `doc`; exiting drops the runtime and leaves `doc` + /// byte-identical. The TopBar Play/Stop button + `Esc` toggle it. + pub preview_mode: bool, + /// Non-fatal warnings raised when the runtime was last built from + /// `doc` for Preview — e.g. legacy role-frames promoted to widget + /// nodes (`LegacyRolePromoted`). Surfaced for diagnostics; cleared + /// on exit. Never serialized. + pub preview_warnings: Vec, /// Floating `Cmd+,` agent-settings modal open. pub agent_settings_open: bool, pub agent_settings: crate::agent_settings::AgentSettings, @@ -1217,6 +1231,8 @@ impl Default for EditorUiState { figma_import_in_progress: false, file_drop_active: false, preserve_authored_geometry: false, + preview_mode: false, + preview_warnings: Vec::new(), agent_settings_open: false, agent_settings: crate::agent_settings::AgentSettings::default(), agent_settings_drag: None, @@ -1332,6 +1348,33 @@ impl EditorUiState { self.pressed_button = None; } + /// Enter canvas Preview (Play) mode. The host follows up by + /// building the jian runtime from the (unchanged) document; this + /// only flips the flag + clears any stale warnings so the host + /// knows to build a fresh runtime. Idempotent. + pub fn enter_preview(&mut self) { + self.preview_mode = true; + self.preview_warnings.clear(); + } + + /// Exit Preview mode. The host follows up by dropping the runtime. + /// Clears the warning list. Idempotent. + pub fn exit_preview(&mut self) { + self.preview_mode = false; + self.preview_warnings.clear(); + } + + /// Flip Preview mode on/off. Returns the new state (`true` = + /// now in Preview). + pub fn toggle_preview(&mut self) -> bool { + if self.preview_mode { + self.exit_preview(); + } else { + self.enter_preview(); + } + self.preview_mode + } + pub fn button_pressed(&self, target: crate::button_press_state::ButtonPressTarget) -> bool { self.pressed_button == Some(target) } @@ -1622,6 +1665,41 @@ mod tests { assert_eq!(ThemeMode::Light.flipped(), ThemeMode::Dark); } + #[test] + fn preview_mode_defaults_off() { + let ui = EditorUiState::new(); + assert!(!ui.preview_mode); + assert!(ui.preview_warnings.is_empty()); + } + + #[test] + fn preview_toggle_round_trips_and_clears_warnings() { + let mut ui = EditorUiState::new(); + // Enter — flips on + clears stale warnings. + ui.preview_warnings.push("stale".to_string()); + assert!(ui.toggle_preview()); + assert!(ui.preview_mode); + assert!(ui.preview_warnings.is_empty()); + + // A warning recorded during preview is dropped on exit. + ui.preview_warnings + .push("LegacyRolePromoted: button → text_input".to_string()); + assert!(!ui.toggle_preview()); + assert!(!ui.preview_mode); + assert!(ui.preview_warnings.is_empty()); + } + + #[test] + fn preview_enter_exit_are_idempotent() { + let mut ui = EditorUiState::new(); + ui.enter_preview(); + ui.enter_preview(); + assert!(ui.preview_mode); + ui.exit_preview(); + ui.exit_preview(); + assert!(!ui.preview_mode); + } + #[test] fn export_format_metadata() { assert_eq!(ExportFormat::ALL.len(), 5); diff --git a/crates/op-editor-core/src/tests_mutators.rs b/crates/op-editor-core/src/tests_mutators.rs index d3a40927c..f182c4f4b 100644 --- a/crates/op-editor-core/src/tests_mutators.rs +++ b/crates/op-editor-core/src/tests_mutators.rs @@ -21,6 +21,34 @@ fn set_single_selection_replaces_set_and_anchor() { assert_eq!(s.selection.set, vec![NodeId::new("n10")]); } +// --- Preview (Play) mode — document invariance ----------------------- + +#[test] +fn enter_exit_preview_leaves_document_byte_identical() { + // Phase D5: entering and exiting Preview must NOT mutate the saved + // document. The runtime is built host-side from the serialized doc; + // the editor state only flips the flag. Assert the canonical + // serialization is identical across an enter → exit cycle. + let mut s = state_with(vec![frame( + "root", + "Root", + 0.0, + 0.0, + 200.0, + 100.0, + vec![rect("a", "A", 10.0, 10.0, 50.0, 50.0)], + )]); + let before = serde_json::to_string(&s.doc).expect("serialize before"); + + s.editor_ui.enter_preview(); + assert!(s.editor_ui.preview_mode); + s.editor_ui.exit_preview(); + assert!(!s.editor_ui.preview_mode); + + let after = serde_json::to_string(&s.doc).expect("serialize after"); + assert_eq!(before, after, "preview enter→exit must not touch doc"); +} + #[test] fn set_single_selection_none_clears() { let mut s = sample(); diff --git a/crates/op-editor-core/src/topbar_state.rs b/crates/op-editor-core/src/topbar_state.rs index 3302ab8ae..f43af5dce 100644 --- a/crates/op-editor-core/src/topbar_state.rs +++ b/crates/op-editor-core/src/topbar_state.rs @@ -28,4 +28,6 @@ pub enum TopBarButton { ToggleGitPanel, /// Maximize icon — toggles window fullscreen. ToggleFullscreen, + /// Play / Stop icon — enters / exits canvas Preview (Play) mode. + TogglePreview, } diff --git a/crates/op-editor-ui/src/widgets/canvas_viewport_widget.rs b/crates/op-editor-ui/src/widgets/canvas_viewport_widget.rs new file mode 100644 index 000000000..1293375c1 --- /dev/null +++ b/crates/op-editor-ui/src/widgets/canvas_viewport_widget.rs @@ -0,0 +1,375 @@ +//! Static composite-widget visuals for the OP **design** canvas. +//! +//! Widget nodes (switch / checkbox / slider / progress / select / +//! radio_group / text_input / text_area / number_input / tabs) load +//! onto the canvas as degraded `rect` / `text` / `frame` scene nodes +//! (`op-pen-loader`'s adapter), but carry their real props in +//! [`SceneWidget`](crate::layout_scene::SceneWidget). This module paints +//! the recognizable static visual (track + knob, box + check, chevron, +//! bar, …) on the non-interactive design surface, mirroring jian-core's +//! `render/scene.rs::emit_widget_visual` (which the preview/runtime +//! path uses to draw the live widget). +//! +//! Everything paints in **world** coordinates: the per-kind painter in +//! `canvas_viewport_paint.rs` hands us the already-zoom-scaled +//! `world_rect`, and we scale every internal metric (track height, +//! knob diameter, stroke width, font size) by `zoom` so the visual +//! tracks the node across viewport zoom. + +use crate::layout_scene::{SceneNode, SceneWidget}; +use crate::widgets::PaintCx; +use crate::{Color, Point2D, Rect, TextLayout}; + +/// Accent colour for "on" / filled portions (Tailwind blue-500). +const ACCENT: Color = Color::rgb_u8(0x3b, 0x82, 0xf6); +/// Off-state track / outline grey (Tailwind gray-300). +const TRACK_OFF: Color = Color::rgb_u8(0xd1, 0xd5, 0xdb); +/// Knob / check / inner-dot white. +const KNOB: Color = Color::WHITE; +/// Resolved-value text (near-black). +const TEXT_VALUE: Color = Color::rgb_u8(0x11, 0x11, 0x11); +/// Placeholder text (muted grey). +const TEXT_MUTED: Color = Color::rgb_u8(0x66, 0x66, 0x66); + +/// Paint the static visual for a widget scene node, in world coords. +/// +/// `world_rect` is the node's already-zoom-scaled screen rect; `zoom` +/// scales internal metrics. Returns `true` when the widget kind was +/// recognized + painted (so the caller skips the bare fill/stroke); +/// `false` for an unknown kind (caller falls back to the plain rect). +pub(crate) fn paint_widget_visual( + cx: &mut PaintCx<'_>, + node: &SceneNode, + world_rect: Rect, + zoom: f32, +) -> bool { + let Some(w) = node.widget.as_ref() else { + return false; + }; + if world_rect.size.x <= 0.0 || world_rect.size.y <= 0.0 { + return false; + } + match w.kind.as_str() { + "switch" => paint_switch(cx, w, world_rect), + "checkbox" => paint_checkbox(cx, node, w, world_rect, zoom), + "slider" => paint_slider(cx, w, world_rect, zoom), + "progress" => paint_progress(cx, w, world_rect), + "select" => paint_select(cx, node, w, world_rect, zoom), + "radio_group" => paint_radio_group(cx, w, world_rect, zoom), + "text_input" | "text_area" | "number_input" => { + paint_text_field(cx, node, w, world_rect, zoom) + } + "tabs" => paint_tabs(cx, node, w, world_rect, zoom), + _ => return false, + } + true +} + +/// Switch: rounded pill track (accent when on, grey when off) plus a +/// white knob circle slid to the right when on, left when off. +fn paint_switch(cx: &mut PaintCx<'_>, w: &SceneWidget, r: Rect) { + let on = w.checked.unwrap_or(false); + let (x, y, ww, h) = rect_parts(r); + cx.backend + .fill_round_rect(r, h / 2.0, if on { ACCENT } else { TRACK_OFF }); + let pad = 2.0; + let d = (h - pad * 2.0).max(2.0); + let kx = if on { x + ww - d - pad } else { x + pad }; + cx.backend + .fill_round_rect(Rect::xywh(kx, y + pad, d, d), d / 2.0, KNOB); +} + +/// Checkbox: rounded box — accent-filled with a white check polyline +/// when on, outlined when off. +fn paint_checkbox(cx: &mut PaintCx<'_>, node: &SceneNode, w: &SceneWidget, r: Rect, zoom: f32) { + let on = w.checked.unwrap_or(false); + let (x, y, ww, h) = rect_parts(r); + let radius = (node.corner_radius * zoom).max(2.0); + let stroke_w = node.stroke.map(|s| s.width).unwrap_or(1.5) * zoom; + if on { + cx.backend.fill_round_rect(r, radius, ACCENT); + } else { + cx.backend.fill_round_rect(r, radius, KNOB); + cx.backend.stroke_round_rect(r, radius, TRACK_OFF, stroke_w); + } + if on { + // White check (✓) as a 3-point polyline, fractions matching the + // jian-core visual: (0.24,0.52) → (0.42,0.70) → (0.76,0.30). + let p0 = Point2D::new(x + ww * 0.24, y + h * 0.52); + let p1 = Point2D::new(x + ww * 0.42, y + h * 0.70); + let p2 = Point2D::new(x + ww * 0.76, y + h * 0.30); + let cw = (2.0 * zoom).max(1.0); + cx.backend.stroke_line(p0, p1, KNOB, cw); + cx.backend.stroke_line(p1, p2, KNOB, cw); + } +} + +/// Slider: thin grey track + accent filled portion (value within +/// min..max) + a white knob circle with a grey outline. +fn paint_slider(cx: &mut PaintCx<'_>, w: &SceneWidget, r: Rect, zoom: f32) { + let (x, y, ww, h) = rect_parts(r); + let frac = range_fraction(w.value_num, w.min.unwrap_or(0.0), w.max.unwrap_or(100.0)); + let track_h = 4.0 * zoom; + let cy = y + h / 2.0; + cx.backend.fill_round_rect( + Rect::xywh(x, cy - track_h / 2.0, ww, track_h), + track_h / 2.0, + TRACK_OFF, + ); + if frac > 0.0 { + cx.backend.fill_round_rect( + Rect::xywh(x, cy - track_h / 2.0, ww * frac, track_h), + track_h / 2.0, + ACCENT, + ); + } + let d = h.clamp(10.0 * zoom, 16.0 * zoom); + let kx = (x + ww * frac - d / 2.0).clamp(x, x + ww - d); + let knob = Rect::xywh(kx, cy - d / 2.0, d, d); + cx.backend.fill_round_rect(knob, d / 2.0, KNOB); + cx.backend + .stroke_round_rect(knob, d / 2.0, TRACK_OFF, 1.0 * zoom); +} + +/// Progress: rounded grey track + accent filled portion (value / max). +fn paint_progress(cx: &mut PaintCx<'_>, w: &SceneWidget, r: Rect) { + let (x, y, ww, h) = rect_parts(r); + let max = w.max.unwrap_or(100.0); + let frac = if max > 0.0 { + (w.value_num.unwrap_or(0.0) / max).clamp(0.0, 1.0) + } else { + 0.0 + }; + let radius = h / 2.0; + cx.backend.fill_round_rect(r, radius, TRACK_OFF); + if frac > 0.0 { + cx.backend + .fill_round_rect(Rect::xywh(x, y, ww * frac, h), radius, ACCENT); + } +} + +/// Select: outlined box + current value / placeholder text + a down +/// chevron on the trailing edge. +fn paint_select(cx: &mut PaintCx<'_>, node: &SceneNode, w: &SceneWidget, r: Rect, zoom: f32) { + let (x, y, ww, h) = rect_parts(r); + let radius = (node.corner_radius * zoom).max(6.0 * zoom); + if let Some(fill) = node.fill { + cx.backend.fill_round_rect(r, radius, fill); + } else { + cx.backend.fill_round_rect(r, radius, KNOB); + } + let stroke_w = node.stroke.map(|s| s.width).unwrap_or(1.0) * zoom; + cx.backend.stroke_round_rect(r, radius, TRACK_OFF, stroke_w); + + // Current selection (by value) wins; else the placeholder, muted. + let selected = w + .value_str + .as_deref() + .and_then(|v| option_label(w, v)) + .filter(|s| !s.is_empty()); + let label = match selected { + Some(text) => Some((text, TEXT_VALUE)), + None => w + .placeholder + .clone() + .filter(|s| !s.is_empty()) + .map(|t| (t, TEXT_MUTED)), + }; + if let Some((text, color)) = label { + let fs = 14.0 * zoom; + draw_label(cx, &text, color, x + 8.0 * zoom, y + (h - fs) / 2.0, fs); + } + paint_chevron(cx, x + ww - 20.0 * zoom, y + h / 2.0, zoom); +} + +/// Radio group: per option a circle (accent-filled with a white inner +/// dot when selected, outlined when not) plus its label to the right. +fn paint_radio_group(cx: &mut PaintCx<'_>, w: &SceneWidget, r: Rect, zoom: f32) { + if w.options.is_empty() { + return; + } + let (x, y, ww, h) = rect_parts(r); + let selected = w.value_str.as_deref(); + let n = w.options.len().max(1); + let row_h = (h / n as f32).clamp(0.0, 28.0 * zoom); + let d = 14.0 * zoom; + let fs = 14.0 * zoom; + for (i, opt) in w.options.iter().enumerate() { + let on = selected == Some(opt.value.as_str()); + let ry = y + i as f32 * row_h + (row_h - d) / 2.0; + let circle = Rect::xywh(x + 2.0 * zoom, ry, d, d); + cx.backend + .fill_round_rect(circle, d / 2.0, if on { ACCENT } else { KNOB }); + cx.backend + .stroke_round_rect(circle, d / 2.0, TRACK_OFF, 1.5 * zoom); + if on { + let inner = d * 0.4; + cx.backend.fill_round_rect( + Rect::xywh( + x + 2.0 * zoom + (d - inner) / 2.0, + ry + (d - inner) / 2.0, + inner, + inner, + ), + inner / 2.0, + KNOB, + ); + } + let label = if opt.label.is_empty() { + opt.value.as_str() + } else { + opt.label.as_str() + }; + let lx = x + 2.0 * zoom + d + 8.0 * zoom; + let _ = ww; + draw_label(cx, label, TEXT_VALUE, lx, ry + (d - fs) / 2.0, fs); + } +} + +/// Text input / textarea / number input: outlined box + the value +/// (near-black) or, when empty, the placeholder (muted). +fn paint_text_field(cx: &mut PaintCx<'_>, node: &SceneNode, w: &SceneWidget, r: Rect, zoom: f32) { + let (x, y, _ww, h) = rect_parts(r); + let radius = (node.corner_radius * zoom).max(6.0 * zoom); + if let Some(fill) = node.fill { + cx.backend.fill_round_rect(r, radius, fill); + } else { + cx.backend.fill_round_rect(r, radius, KNOB); + } + let stroke_w = node.stroke.map(|s| s.width).unwrap_or(1.0) * zoom; + cx.backend.stroke_round_rect(r, radius, TRACK_OFF, stroke_w); + + // number_input shows its numeric value when present. + let value = w.value_str.clone().or_else(|| { + (w.kind == "number_input") + .then(|| w.value_num.map(format_number)) + .flatten() + }); + let (text, color) = match value.filter(|s| !s.is_empty()) { + Some(v) => (Some(v), TEXT_VALUE), + None => (w.placeholder.clone().filter(|s| !s.is_empty()), TEXT_MUTED), + }; + if let Some(text) = text { + let fs = 14.0 * zoom; + // text_area top-aligns; single-line inputs vertically centre. + let ty = if w.kind == "text_area" { + y + 8.0 * zoom + } else { + y + (h - fs) / 2.0 + }; + draw_label(cx, &text, color, x + 8.0 * zoom, ty, fs); + } +} + +/// Tabs: a minimal tab-bar row of option labels with the active tab +/// underlined in accent. The panel area (children) is painted by the +/// caller's normal child recursion; we only add the bar. +fn paint_tabs(cx: &mut PaintCx<'_>, node: &SceneNode, w: &SceneWidget, r: Rect, zoom: f32) { + let (x, y, ww, _h) = rect_parts(r); + if w.options.is_empty() { + return; + } + let bar_h = 32.0 * zoom; + // Bottom border under the whole tab bar. + let by = y + bar_h; + cx.backend.stroke_line( + Point2D::new(x, by), + Point2D::new(x + ww, by), + TRACK_OFF, + 1.0 * zoom, + ); + let active = w.value_str.as_deref(); + let n = w.options.len().max(1); + let tab_w = ww / n as f32; + let fs = 14.0 * zoom; + for (i, opt) in w.options.iter().enumerate() { + let tx = x + i as f32 * tab_w; + let on = active == Some(opt.value.as_str()) || (active.is_none() && i == 0); + let label = if opt.label.is_empty() { + opt.value.as_str() + } else { + opt.label.as_str() + }; + let color = if on { TEXT_VALUE } else { TEXT_MUTED }; + draw_label( + cx, + label, + color, + tx + 8.0 * zoom, + y + (bar_h - fs) / 2.0, + fs, + ); + if on { + // Accent underline beneath the active tab. + let uy = by - 1.0 * zoom; + cx.backend.stroke_line( + Point2D::new(tx, uy), + Point2D::new(tx + tab_w, uy), + ACCENT, + 2.0 * zoom, + ); + } + } + let _ = node; +} + +/// Draw a down chevron (`v`) centred at `(cx_px, cy_px)` on the leading +/// point — a 3-point polyline matching the jian-core select chevron. +fn paint_chevron(cx: &mut PaintCx<'_>, cx_px: f32, cy_px: f32, zoom: f32) { + let cw = 9.0 * zoom; + let p0 = Point2D::new(cx_px, cy_px - cw * 0.22); + let p1 = Point2D::new(cx_px + cw / 2.0, cy_px + cw * 0.33); + let p2 = Point2D::new(cx_px + cw, cy_px - cw * 0.22); + let width = 1.5 * zoom; + cx.backend.stroke_line(p0, p1, TEXT_MUTED, width); + cx.backend.stroke_line(p1, p2, TEXT_MUTED, width); +} + +/// Draw a single-run, left-aligned label at `(x, top_y)` in world +/// coords. `font_size` is already zoom-scaled; the run's origin is the +/// text top edge (TS canvas paint parity — see `paint_text_node`). +fn draw_label(cx: &mut PaintCx<'_>, text: &str, color: Color, x: f32, top_y: f32, font_size: f32) { + let layout = + TextLayout::single_run(text, "", font_size, color.to_jian(), Point2D::new(x, top_y)); + cx.backend.draw_text(&layout, Point2D::new(x, top_y)); +} + +/// `(x, y, w, h)` of a rect. +fn rect_parts(r: Rect) -> (f32, f32, f32, f32) { + (r.origin.x, r.origin.y, r.size.x, r.size.y) +} + +/// Fraction of `value` within `[min, max]`, clamped to `0.0..=1.0`. +/// An absent value or a degenerate range collapses to 0.0. +fn range_fraction(value: Option, min: f32, max: f32) -> f32 { + let v = value.unwrap_or(min); + if max > min { + ((v - min) / (max - min)).clamp(0.0, 1.0) + } else { + 0.0 + } +} + +/// Look up a select / radio option's display label by its `value`. +fn option_label(w: &SceneWidget, value: &str) -> Option { + w.options.iter().find(|o| o.value == value).map(|o| { + if o.label.is_empty() { + o.value.clone() + } else { + o.label.clone() + } + }) +} + +/// Format a slider / number value without a trailing `.0` for integers. +fn format_number(v: f32) -> String { + if v.fract().abs() < f32::EPSILON { + format!("{}", v as i64) + } else { + format!("{v}") + } +} + +#[cfg(test)] +#[path = "canvas_viewport_widget_tests.rs"] +mod tests; diff --git a/crates/op-editor-ui/src/widgets/canvas_viewport_widget_tests.rs b/crates/op-editor-ui/src/widgets/canvas_viewport_widget_tests.rs new file mode 100644 index 000000000..e860fd9cf --- /dev/null +++ b/crates/op-editor-ui/src/widgets/canvas_viewport_widget_tests.rs @@ -0,0 +1,433 @@ +//! Sibling test file for `canvas_viewport_widget.rs` — asserts the +//! composite static visuals emitted for widget scene nodes on the +//! design surface (track + knob, box + check, bar, chevron, …). + +use crate::layout_scene::{NodeKind, SceneNode, SceneWidget, SceneWidgetOption}; +use crate::widgets::canvas_viewport_widget::paint_widget_visual; +use crate::widgets::PaintCx; +use crate::{Color, ImageDrawMode, Point2D, Rect, RenderBackend, TextLayout}; + +/// Recording backend — captures round-rects (rect + fill colour), +/// stroke lines (endpoints + colour), and text runs (content + origin). +#[derive(Default)] +struct WidgetRecorder { + round_rects: Vec<(Rect, Color)>, + stroke_round_rects: Vec<(Rect, Color)>, + lines: Vec<(Point2D, Point2D, Color)>, + texts: Vec<(String, Point2D)>, +} + +impl RenderBackend for WidgetRecorder { + fn begin_frame(&mut self) {} + fn end_frame(&mut self) {} + fn fill_rect(&mut self, _: Rect, _: Color) {} + fn stroke_rect(&mut self, _: Rect, _: Color, _: f32) {} + fn draw_text(&mut self, layout: &TextLayout, origin: Point2D) { + if let Some(run) = layout.runs().first() { + self.texts.push((run.content.clone(), origin)); + } + } + fn clip_rect(&mut self, _: Rect) {} + fn save(&mut self) {} + fn restore(&mut self) {} + fn translate(&mut self, _: Point2D) {} + fn scale(&mut self, _: Point2D, _: Point2D) {} + fn stroke_line(&mut self, from: Point2D, to: Point2D, color: Color, _: f32) { + self.lines.push((from, to, color)); + } + fn fill_round_rect(&mut self, rect: Rect, _: f32, color: Color) { + self.round_rects.push((rect, color)); + } + fn stroke_round_rect(&mut self, rect: Rect, _: f32, color: Color, _: f32) { + self.stroke_round_rects.push((rect, color)); + } + fn stroke_svg_path(&mut self, _: &str, _: Point2D, _: f32, _: Color, _: f32) {} + fn draw_image(&mut self, _: Rect, _: u64, _: &[u8]) {} + fn draw_image_with_mode(&mut self, _: Rect, _: u64, _: &[u8], _: ImageDrawMode) {} + fn resize(&mut self, _: u32, _: u32) {} + fn dpi_scale(&self) -> f32 { + 1.0 + } + fn measure_text_weighted(&mut self, text: &str, font_size: f32, _: u16) -> f32 { + text.chars().count() as f32 * font_size * 0.5 + } +} + +const ACCENT: Color = Color::rgb_u8(0x3b, 0x82, 0xf6); +const TRACK_OFF: Color = Color::rgb_u8(0xd1, 0xd5, 0xdb); +const WHITE: Color = Color::WHITE; + +fn paint(node: &SceneNode, rect: Rect) -> WidgetRecorder { + let mut backend = WidgetRecorder::default(); + let mut cx = PaintCx { + backend: &mut backend, + }; + let painted = paint_widget_visual(&mut cx, node, rect, 1.0); + assert!(painted, "widget visual should report painted"); + backend +} + +fn widget_node(kind: NodeKind, w: SceneWidget, rect: Rect) -> SceneNode { + let mut n = SceneNode::leaf("w", kind); + n.bounds = rect; + n.widget = Some(w); + n +} + +#[test] +fn unknown_or_absent_widget_returns_false() { + let mut backend = WidgetRecorder::default(); + let mut cx = PaintCx { + backend: &mut backend, + }; + // No widget descriptor at all. + let plain = SceneNode::leaf("p", NodeKind::Rect); + assert!(!paint_widget_visual( + &mut cx, + &plain, + Rect::xywh(0.0, 0.0, 40.0, 20.0), + 1.0 + )); +} + +#[test] +fn switch_on_paints_accent_track_and_right_knob() { + let rect = Rect::xywh(0.0, 0.0, 40.0, 20.0); + let node = widget_node( + NodeKind::Rect, + SceneWidget { + kind: "switch".into(), + checked: Some(true), + ..Default::default() + }, + rect, + ); + let b = paint(&node, rect); + // Track (accent) + knob (white) = 2 filled round rects. + assert_eq!(b.round_rects.len(), 2, "track + knob"); + assert_eq!(b.round_rects[0].1, ACCENT, "on-track is accent"); + assert_eq!(b.round_rects[1].1, WHITE, "knob is white"); + // Knob slid to the right half. + let knob = b.round_rects[1].0; + assert!( + knob.origin.x > rect.size.x / 2.0, + "on-knob sits on the right, got x={}", + knob.origin.x + ); +} + +#[test] +fn switch_off_paints_grey_track_and_left_knob() { + let rect = Rect::xywh(0.0, 0.0, 40.0, 20.0); + let node = widget_node( + NodeKind::Rect, + SceneWidget { + kind: "switch".into(), + checked: Some(false), + ..Default::default() + }, + rect, + ); + let b = paint(&node, rect); + assert_eq!(b.round_rects[0].1, TRACK_OFF, "off-track is grey"); + let knob = b.round_rects[1].0; + assert!( + knob.origin.x < rect.size.x / 2.0, + "off-knob sits on the left" + ); +} + +#[test] +fn checkbox_checked_paints_accent_box_and_check_polyline() { + let rect = Rect::xywh(0.0, 0.0, 18.0, 18.0); + let node = widget_node( + NodeKind::Rect, + SceneWidget { + kind: "checkbox".into(), + checked: Some(true), + ..Default::default() + }, + rect, + ); + let b = paint(&node, rect); + // Accent-filled box. + assert_eq!(b.round_rects[0].1, ACCENT, "checked box fills accent"); + // Two line segments form the white check (✓). + assert_eq!(b.lines.len(), 2, "check polyline is 2 segments"); + assert!( + b.lines.iter().all(|(_, _, c)| *c == WHITE), + "check is white" + ); +} + +#[test] +fn checkbox_unchecked_paints_outlined_box_no_check() { + let rect = Rect::xywh(0.0, 0.0, 18.0, 18.0); + let node = widget_node( + NodeKind::Rect, + SceneWidget { + kind: "checkbox".into(), + checked: Some(false), + ..Default::default() + }, + rect, + ); + let b = paint(&node, rect); + assert!(b.lines.is_empty(), "unchecked box has no check mark"); + assert_eq!( + b.stroke_round_rects[0].1, TRACK_OFF, + "unchecked box is outlined grey" + ); +} + +#[test] +fn slider_paints_track_filled_portion_and_knob() { + let rect = Rect::xywh(0.0, 0.0, 100.0, 16.0); + let node = widget_node( + NodeKind::Rect, + SceneWidget { + kind: "slider".into(), + value_num: Some(50.0), + min: Some(0.0), + max: Some(100.0), + ..Default::default() + }, + rect, + ); + let b = paint(&node, rect); + // track (grey) + filled (accent) + knob (white) = 3 round rects. + assert_eq!(b.round_rects.len(), 3, "track + fill + knob"); + assert_eq!(b.round_rects[0].1, TRACK_OFF, "track grey"); + assert_eq!(b.round_rects[1].1, ACCENT, "filled accent"); + // 50% fill spans half the width. + let fill = b.round_rects[1].0; + assert!( + (fill.size.x - 50.0).abs() < 0.5, + "half-value fill ~= half width, got {}", + fill.size.x + ); + assert_eq!(b.round_rects[2].1, WHITE, "knob white"); +} + +#[test] +fn slider_with_zero_value_has_no_accent_fill() { + let rect = Rect::xywh(0.0, 0.0, 100.0, 16.0); + let node = widget_node( + NodeKind::Rect, + SceneWidget { + kind: "slider".into(), + value_num: Some(0.0), + min: Some(0.0), + max: Some(100.0), + ..Default::default() + }, + rect, + ); + let b = paint(&node, rect); + // Only track + knob; no accent fill at 0%. + assert!( + b.round_rects.iter().all(|(_, c)| *c != ACCENT), + "no accent fill at value=min" + ); +} + +#[test] +fn progress_paints_track_and_filled_portion() { + let rect = Rect::xywh(0.0, 0.0, 200.0, 8.0); + let node = widget_node( + NodeKind::Rect, + SceneWidget { + kind: "progress".into(), + value_num: Some(25.0), + max: Some(100.0), + ..Default::default() + }, + rect, + ); + let b = paint(&node, rect); + assert_eq!(b.round_rects[0].1, TRACK_OFF, "progress track grey"); + let fill = &b.round_rects[1]; + assert_eq!(fill.1, ACCENT, "progress fill accent"); + assert!( + (fill.0.size.x - 50.0).abs() < 0.5, + "25/100 of 200px ~= 50px, got {}", + fill.0.size.x + ); +} + +#[test] +fn select_paints_box_value_text_and_chevron() { + let rect = Rect::xywh(0.0, 0.0, 160.0, 36.0); + let node = widget_node( + NodeKind::Text, + SceneWidget { + kind: "select".into(), + value_str: Some("us".into()), + options: vec![ + SceneWidgetOption { + value: "us".into(), + label: "United States".into(), + }, + SceneWidgetOption { + value: "ca".into(), + label: "Canada".into(), + }, + ], + ..Default::default() + }, + rect, + ); + let b = paint(&node, rect); + // Selected option label is painted. + assert!( + b.texts.iter().any(|(t, _)| t == "United States"), + "selected label painted, got {:?}", + b.texts + ); + // Chevron = 2 line segments. + assert_eq!(b.lines.len(), 2, "down chevron is 2 segments"); + // Outlined box. + assert!(!b.stroke_round_rects.is_empty(), "select box outlined"); +} + +#[test] +fn select_empty_paints_placeholder() { + let rect = Rect::xywh(0.0, 0.0, 160.0, 36.0); + let node = widget_node( + NodeKind::Text, + SceneWidget { + kind: "select".into(), + placeholder: Some("Choose…".into()), + ..Default::default() + }, + rect, + ); + let b = paint(&node, rect); + assert!( + b.texts.iter().any(|(t, _)| t == "Choose…"), + "placeholder painted" + ); +} + +#[test] +fn radio_group_paints_circle_and_dot_for_selected() { + let rect = Rect::xywh(0.0, 0.0, 120.0, 56.0); + let node = widget_node( + NodeKind::Rect, + SceneWidget { + kind: "radio_group".into(), + value_str: Some("a".into()), + options: vec![ + SceneWidgetOption { + value: "a".into(), + label: "Apple".into(), + }, + SceneWidgetOption { + value: "b".into(), + label: "Banana".into(), + }, + ], + ..Default::default() + }, + rect, + ); + let b = paint(&node, rect); + // Selected circle filled accent + inner white dot; unselected + // circle filled white. 2 options → 3 fills (2 circles + 1 dot). + assert_eq!(b.round_rects.len(), 3, "2 circles + 1 inner dot"); + assert!( + b.round_rects.iter().any(|(_, c)| *c == ACCENT), + "selected circle is accent" + ); + // Both labels painted. + assert!(b.texts.iter().any(|(t, _)| t == "Apple")); + assert!(b.texts.iter().any(|(t, _)| t == "Banana")); +} + +#[test] +fn text_input_paints_value_then_placeholder() { + let rect = Rect::xywh(0.0, 0.0, 200.0, 36.0); + let with_value = widget_node( + NodeKind::Text, + SceneWidget { + kind: "text_input".into(), + value_str: Some("hello".into()), + placeholder: Some("Type…".into()), + ..Default::default() + }, + rect, + ); + let b = paint(&with_value, rect); + assert!( + b.texts.iter().any(|(t, _)| t == "hello"), + "value wins over placeholder" + ); + assert!(!b.texts.iter().any(|(t, _)| t == "Type…")); + + let empty = widget_node( + NodeKind::Text, + SceneWidget { + kind: "text_input".into(), + placeholder: Some("Type…".into()), + ..Default::default() + }, + rect, + ); + let b2 = paint(&empty, rect); + assert!( + b2.texts.iter().any(|(t, _)| t == "Type…"), + "placeholder shown when empty" + ); +} + +#[test] +fn number_input_renders_numeric_value() { + let rect = Rect::xywh(0.0, 0.0, 120.0, 36.0); + let node = widget_node( + NodeKind::Rect, + SceneWidget { + kind: "number_input".into(), + value_num: Some(42.0), + ..Default::default() + }, + rect, + ); + let b = paint(&node, rect); + assert!( + b.texts.iter().any(|(t, _)| t == "42"), + "integer value renders without decimals, got {:?}", + b.texts + ); +} + +#[test] +fn tabs_paints_bar_labels_with_active_underline() { + let rect = Rect::xywh(0.0, 0.0, 240.0, 120.0); + let node = widget_node( + NodeKind::Frame, + SceneWidget { + kind: "tabs".into(), + value_str: Some("one".into()), + options: vec![ + SceneWidgetOption { + value: "one".into(), + label: "One".into(), + }, + SceneWidgetOption { + value: "two".into(), + label: "Two".into(), + }, + ], + ..Default::default() + }, + rect, + ); + let b = paint(&node, rect); + assert!(b.texts.iter().any(|(t, _)| t == "One")); + assert!(b.texts.iter().any(|(t, _)| t == "Two")); + // Bottom border + active-tab accent underline. + assert!( + b.lines.iter().any(|(_, _, c)| *c == ACCENT), + "active tab has an accent underline" + ); +} diff --git a/crates/op-editor-ui/src/widgets/editor_state_ext.rs b/crates/op-editor-ui/src/widgets/editor_state_ext.rs index fbc4e22b7..41c6e11b5 100644 --- a/crates/op-editor-ui/src/widgets/editor_state_ext.rs +++ b/crates/op-editor-ui/src/widgets/editor_state_ext.rs @@ -247,6 +247,7 @@ pub fn topbar_button_hover( W::OpenAgentSettings => O::OpenAgentSettings, W::ToggleGitPanel => O::ToggleGitPanel, W::ToggleFullscreen => O::ToggleFullscreen, + W::TogglePreview => O::TogglePreview, } } diff --git a/crates/op-editor-ui/src/widgets/top_bar.rs b/crates/op-editor-ui/src/widgets/top_bar.rs index 4775c3117..db58af389 100644 --- a/crates/op-editor-ui/src/widgets/top_bar.rs +++ b/crates/op-editor-ui/src/widgets/top_bar.rs @@ -95,6 +95,8 @@ pub enum TopBarHit { /// Maximize icon (rightmost of the right cluster) — toggle window /// fullscreen. ToggleFullscreen, + /// Play / Stop icon — enter / exit canvas Preview (Play) mode. + TogglePreview, } pub struct TopBar { @@ -128,6 +130,9 @@ pub struct TopBar { /// beside the file name. `None` = no branch (the button still /// paints, icon-only, as a toggle for the git panel). pub git_branch: Option, + /// Whether the canvas is in Preview (Play) mode — drives the + /// Play/Stop toggle glyph (Square when active, Play when idle). + pub preview_active: bool, /// Which chrome button the cursor is over — drives the per-button /// `theme.button_hover` wash. `None` = no hover. pub hover: Option, @@ -151,6 +156,7 @@ impl TopBar { fullscreen: false, theme_mode: op_editor_core::ThemeMode::Dark, git_branch: None, + preview_active: false, hover: None, pressed: None, } @@ -190,6 +196,7 @@ impl TopBar { fullscreen: ui.window_fullscreen, theme_mode: ui.theme_mode, git_branch: ui.git_panel.branch.clone(), + preview_active: ui.preview_mode, hover: ui.topbar_button_hover, pressed: match ui.pressed_button { Some(op_editor_core::ButtonPressTarget::TopBar(button)) => Some(button), @@ -297,16 +304,27 @@ impl TopBar { pub fn globe_rect(top_bar_rect: Rect) -> Rect { let right = top_bar_rect.origin.x + top_bar_rect.size.x; - // Right-cluster layout (right → left): Maximize | Sun | Globe. - // Maximize + Sun are normal ICON_BUTTON wide; Globe is the - // wider GLOBE_BUTTON_WIDTH so the chevron fits inside it. - let globe_x = right - PAD - ICON_BUTTON * 2.0 - GLOBE_BUTTON_WIDTH; + // Right-cluster layout (right → left): Maximize | Play | Sun | + // Globe. Maximize + Play + Sun are normal ICON_BUTTON wide; + // Globe is the wider GLOBE_BUTTON_WIDTH so the chevron fits. + let globe_x = right - PAD - ICON_BUTTON * 3.0 - GLOBE_BUTTON_WIDTH; Rect { origin: Point2D::new(globe_x, top_bar_rect.origin.y + 8.0), size: Point2D::new(GLOBE_BUTTON_WIDTH, ICON_BUTTON), } } + /// Play / Stop toggle button — second from the right (just left of + /// Maximize). Shared by paint + hit-test so they can't drift. + pub(super) fn preview_button_rect(top_bar_rect: Rect) -> Rect { + let right = top_bar_rect.origin.x + top_bar_rect.size.x; + let icon_y = top_bar_rect.origin.y + 8.0; + Rect { + origin: Point2D::new(right - PAD - ICON_BUTTON * 2.0, icon_y), + size: Point2D::new(ICON_BUTTON, ICON_BUTTON), + } + } + pub(super) fn locale_glyph_left(globe_button: Rect) -> f32 { let group_w = ICON_SIZE + COMPOUND_GLYPH_GAP + CHEVRON_SIZE; globe_button.origin.x + (globe_button.size.x - group_w) / 2.0 @@ -432,11 +450,11 @@ impl TopBar { if GIT_BUTTON_AVAILABLE && (self.git_button_rect(rect)).contains(point) { return Some(TopBarHit::ToggleGitPanel); } - // Right cluster: Maximize / Sun / Globe-with-chevron (right→left). - // Maximize + Sun are normal ICON_BUTTON wide; Globe is - // GLOBE_BUTTON_WIDTH wide because it carries a chevron. + // Right cluster: Maximize / Play / Sun / Globe-with-chevron + // (right→left). Maximize + Play + Sun are normal ICON_BUTTON + // wide; Globe is GLOBE_BUTTON_WIDTH wide (it carries a chevron). let right = rect.origin.x + rect.size.x; - let sun_x = right - PAD - ICON_BUTTON * 2.0; + let sun_x = right - PAD - ICON_BUTTON * 3.0; let icon_y = rect.origin.y + 8.0; // Maximize (rightmost icon, painted at `right - PAD - ICON_BUTTON`) // → toggle fullscreen. @@ -447,6 +465,10 @@ impl TopBar { if (maximize_rect).contains(point) { return Some(TopBarHit::ToggleFullscreen); } + // Play / Stop (second from right) → toggle Preview mode. + if (Self::preview_button_rect(rect)).contains(point) { + return Some(TopBarHit::TogglePreview); + } let sun_rect = Rect { origin: Point2D::new(sun_x, icon_y), size: Point2D::new(ICON_BUTTON, ICON_BUTTON), @@ -704,15 +726,21 @@ mod tests { size: Point2D::new(1000.0, TOP_BAR_HEIGHT), }; let cy = 8.0 + ICON_BUTTON / 2.0; + // Right cluster (right → left): Maximize | Play | Sun. // Rightmost icon (Maximize) → ToggleFullscreen. let fs_cx = 1000.0 - PAD - ICON_BUTTON / 2.0; assert_eq!( bar.hit_test(rect, Point2D::new(fs_cx, cy)), Some(TopBarHit::ToggleFullscreen), ); - // The neighbour to its left (Sun) still maps to ToggleTheme — - // adjacency unbroken. - let sun_cx = 1000.0 - PAD - ICON_BUTTON - ICON_BUTTON / 2.0; + // 2nd from right (Play) → TogglePreview. + let play_cx = 1000.0 - PAD - ICON_BUTTON - ICON_BUTTON / 2.0; + assert_eq!( + bar.hit_test(rect, Point2D::new(play_cx, cy)), + Some(TopBarHit::TogglePreview), + ); + // 3rd from right (Sun) → ToggleTheme. + let sun_cx = 1000.0 - PAD - 2.0 * ICON_BUTTON - ICON_BUTTON / 2.0; assert_eq!( bar.hit_test(rect, Point2D::new(sun_cx, cy)), Some(TopBarHit::ToggleTheme), diff --git a/crates/op-editor-ui/src/widgets/top_bar_paint.rs b/crates/op-editor-ui/src/widgets/top_bar_paint.rs index 546eb0a48..6202a1b2f 100644 --- a/crates/op-editor-ui/src/widgets/top_bar_paint.rs +++ b/crates/op-editor-ui/src/widgets/top_bar_paint.rs @@ -219,8 +219,8 @@ impl TopBar { } // ── Right cluster ────────────────────────────────────── - // Right → left: Maximize | Sun | Globe+Chevron. Globe is a - // wider compound button (signals the dropdown affordance). + // Right → left: Maximize | Play | Sun | Globe+Chevron. Globe is + // a wider compound button (signals the dropdown affordance). let mut rx = rect.origin.x + rect.size.x - PAD - ICON_BUTTON; // Fullscreen. @@ -235,6 +235,24 @@ impl TopBar { ); rx -= ICON_BUTTON; + // Preview (Play) toggle — Square glyph while active (click → + // stop), Play glyph while idle (click → enter preview). + let preview_icon = if self.preview_active { + Icon::Square + } else { + Icon::Play + }; + paint_icon_button( + cx, + &self.theme, + rx, + center_y, + preview_icon, + self.is_hovered(TopBarButton::TogglePreview), + self.is_pressed(TopBarButton::TogglePreview), + ); + rx -= ICON_BUTTON; + // Theme toggle — Sun in dark mode (click → light); Moon in // light mode (click → dark). let theme_icon = match self.theme_mode { diff --git a/crates/op-host-desktop/src/app_handler.rs b/crates/op-host-desktop/src/app_handler.rs index ad303e31a..0d1b790a3 100644 --- a/crates/op-host-desktop/src/app_handler.rs +++ b/crates/op-host-desktop/src/app_handler.rs @@ -340,6 +340,10 @@ impl ApplicationHandler for DesktopApp { } self.viewport_width = size.width as f32 / self.dpi; self.viewport_height = size.height as f32 / self.dpi; + // Re-lay-out the preview runtime (if active) against the + // new canvas region so the live scene tracks the resize. + self.host + .preview_resize(self.viewport_width, self.viewport_height); // Track geometry for window-state persistence. Only a // *windowed* (non-maximized) size is remembered so a // restart restores a sensible un-maximize size. diff --git a/crates/op-host-native/Cargo.toml b/crates/op-host-native/Cargo.toml index 4c422ca33..522590d2f 100644 --- a/crates/op-host-native/Cargo.toml +++ b/crates/op-host-native/Cargo.toml @@ -16,6 +16,10 @@ path = "src/lib.rs" # all resolve through the op-editor-ui crate. op-editor-ui = { path = "../op-editor-ui" } +# Canvas Preview (Play) mode serializes the document to JSON before +# building the jian runtime, so the saved doc is never mutated. +serde_json = { workspace = true } + # `WidgetHostNative`'s single source of truth — the canonical-model # `op_editor_core::EditorState`. The host derives a read-only paint # `Document` from it each frame (see `widget_host.rs`). diff --git a/crates/op-host-native/src/lib.rs b/crates/op-host-native/src/lib.rs index 03ece01cc..1c79d456a 100644 --- a/crates/op-host-native/src/lib.rs +++ b/crates/op-host-native/src/lib.rs @@ -75,6 +75,16 @@ pub mod canvas_view_stub; target_os = "android" ))] pub mod widget_host; +// Canvas Preview (Play) mode runtime owner — depends on the OP +// `RenderBackend` trait (same gate as `widget_host`). +#[cfg(any( + target_os = "macos", + target_os = "linux", + target_os = "windows", + target_os = "ios", + target_os = "android" +))] +pub mod preview; #[cfg(any( target_os = "macos", @@ -91,6 +101,14 @@ pub use backend::{to_jian_rect, NativeBackend}; target_os = "ios", target_os = "android" ))] +pub use preview::PreviewSession; +#[cfg(any( + target_os = "macos", + target_os = "linux", + target_os = "windows", + target_os = "ios", + target_os = "android" +))] pub use widget_host::{CursorHint, NativeFrameBackend, WidgetHostNative}; // canvas_view_stub stays desktop-only (uses glow GL-isolation probe). #[cfg(any(target_os = "macos", target_os = "linux", target_os = "windows"))] diff --git a/crates/op-host-native/src/preview/draw_bridge.rs b/crates/op-host-native/src/preview/draw_bridge.rs new file mode 100644 index 000000000..5502ef709 --- /dev/null +++ b/crates/op-host-native/src/preview/draw_bridge.rs @@ -0,0 +1,286 @@ +//! DrawOp → RenderBackend translation (Phase D5 crux). +//! +//! jian-core's scene walker emits `jian_core::render::DrawOp`s against +//! its OWN paint vocabulary (`scene::Color` packed-u32, +//! `geometry::{Point, Rect}` euclid-based). The OP editor canvas paints +//! through the `RenderBackend` trait (= `jian_widgets::painter::Painter`, +//! which uses `jian_widgets::geometry::{Color, Rect, Point2D}` glam-based +//! + `jian_core::render::{TextRun, TextAlign}`). +//! +//! These are different abstractions, but the OP `RenderBackend` exposes +//! a primitive set rich enough to render every `DrawOp` variant with a +//! bounded, lossless-enough mapping. This module is that translator: +//! one `paint_draw_op` per op, plus the two color/rect converters that +//! bridge the jian-core geometry to the widget geometry. +//! +//! Coordinate space: the walker lays the scene out in DOCUMENT space. +//! Callers pre-apply the editor's canvas transform (`translate` by the +//! canvas origin + pan, `scale` by zoom) on the backend before issuing +//! these ops, so this module emits doc-space coordinates verbatim. + +use op_editor_ui::{Color, Point2D, Rect, RenderBackend, TextLayout}; + +use jian_core::render::{ + BorderRadii, DrawOp, GradientStop, LinearGradient, Paint, RadialGradient, ShadowSpec, StrokeOp, + TextRun, +}; +use jian_core::scene::Color as JColor; + +/// jian-core packed-RGBA `Color` → widget float `Color`. +pub(super) fn color(c: JColor) -> Color { + Color::rgba_u8(c.r(), c.g(), c.b(), c.a() as f32 / 255.0) +} + +/// jian-core euclid `Rect` → widget glam `Rect`. +pub(super) fn rect(r: jian_core::geometry::Rect) -> Rect { + Rect { + origin: Point2D::new(r.origin.x, r.origin.y), + size: Point2D::new(r.size.width, r.size.height), + } +} + +/// jian-core euclid `Point` → widget glam `Point2D`. +fn point(p: jian_core::geometry::Point) -> Point2D { + Point2D::new(p.x, p.y) +} + +/// Uniform corner radius from `BorderRadii` — the OP `RenderBackend`'s +/// round-rect primitives take a single radius. Use the max of the four +/// so a pill-ish authored radius still reads as rounded; below ~0.5 px +/// the caller collapses to a plain rect anyway. +fn uniform_radius(radii: BorderRadii) -> f32 { + radii.tl.max(radii.tr).max(radii.br).max(radii.bl) +} + +/// Convert a jian-core gradient stop list into the `(offset, Color)` +/// tuples the OP gradient primitives consume. +fn stops(src: &[GradientStop]) -> Vec<(f32, Color)> { + src.iter().map(|s| (s.offset, color(s.color))).collect() +} + +/// Paint one rounded/plain rect with a solid `Paint` (fill + optional +/// stroke + opacity). Shared by `Rect` and `RoundedRect` ops. +fn paint_solid_rect(b: &mut dyn RenderBackend, r: Rect, radius: f32, paint: &Paint) { + let rounded = radius > 0.5; + if let Some(fill) = paint.fill { + let c = fold(color(fill), paint.opacity); + if rounded { + b.fill_round_rect(r, radius, c); + } else { + b.fill_rect(r, c); + } + } + if let Some(stroke) = &paint.stroke { + let c = fold(color(stroke.color), paint.opacity); + if rounded { + b.stroke_round_rect(r, radius, c, stroke.width); + } else { + b.stroke_rect(r, c, stroke.width); + } + } +} + +/// Multiply a color's alpha by a paint-level `opacity`. +fn fold(c: Color, opacity: f32) -> Color { + Color { + a: c.a * opacity.clamp(0.0, 1.0), + ..c + } +} + +/// Translate a single `DrawOp` onto the OP `RenderBackend`. Coordinates +/// are in document space; the caller has already pushed the canvas +/// transform onto the backend. +pub(super) fn paint_draw_op(b: &mut dyn RenderBackend, op: &DrawOp) { + match op { + DrawOp::Rect { rect: r, paint } => { + paint_solid_rect(b, rect(*r), 0.0, paint); + } + DrawOp::RoundedRect { + rect: r, + radii, + paint, + } => { + paint_solid_rect(b, rect(*r), uniform_radius(*radii), paint); + } + DrawOp::Path { commands, paint } => { + paint_path(b, commands, paint); + } + DrawOp::Text(run) => { + paint_text(b, run); + } + DrawOp::LinearGradientRect { + rect: r, + radii, + gradient, + stroke, + } => { + paint_linear_gradient( + b, + rect(*r), + uniform_radius(*radii), + gradient, + stroke.as_ref(), + ); + } + DrawOp::RadialGradientRect { + rect: r, + radii, + gradient, + stroke, + } => { + paint_radial_gradient( + b, + rect(*r), + uniform_radius(*radii), + gradient, + stroke.as_ref(), + ); + } + DrawOp::ShadowedRect { + rect: r, + radii, + shadow, + } => { + paint_shadow(b, rect(*r), uniform_radius(*radii), shadow); + } + DrawOp::Icon { + rect: r, + name, + family, + color: c, + } => { + // Reuse the editor's lucide d-string catalog so icon nodes + // render with the same glyphs as the design surface. The + // helper takes a `&mut dyn RenderBackend`, so reborrow `b`. + op_editor_ui::widgets::icons::paint_icon_font_node( + b, + family.as_deref().unwrap_or(""), + name, + rect(*r), + Some(color(*c)), + ); + } + DrawOp::Image { dst, opacity, .. } => { + // The runtime preview does not resolve image bytes (the host + // image cache lives on the editor paint path, not the + // runtime). Draw a neutral placeholder so layout still reads + // — same degrade the .op loader uses for unresolved images. + let r = rect(*dst); + b.fill_round_rect( + r, + 0.0, + Color::rgba_u8(120, 120, 120, 0.35 * opacity.clamp(0.0, 1.0)), + ); + } + } +} + +fn paint_path( + b: &mut dyn RenderBackend, + commands: &[jian_core::render::PathCommand], + paint: &Paint, +) { + use jian_core::render::PathCommand as P; + // The OP `RenderBackend` has no generic path-fill primitive that + // accepts `PathCommand`s; approximate by stroking the polyline + // through the anchor points (curves are flattened to their end + // points). This matches how the editor canvas paints `Path` nodes + // (polyline through `node.points`). + let mut pts: Vec = Vec::with_capacity(commands.len()); + for cmd in commands { + match cmd { + P::MoveTo(p) | P::LineTo(p) => pts.push(point(*p)), + P::QuadTo(_, end) => pts.push(point(*end)), + P::CubicTo(_, _, end) => pts.push(point(*end)), + P::Close => { + if let Some(first) = pts.first().copied() { + pts.push(first); + } + } + } + } + if pts.len() < 2 { + return; + } + let stroke = paint.stroke.as_ref(); + let (sc, sw) = match stroke { + Some(StrokeOp { color: c, width }) => (fold(color(*c), paint.opacity), *width), + None => match paint.fill { + // No stroke authored — outline with the fill color so the + // path is at least visible. + Some(f) => (fold(color(f), paint.opacity), 1.0), + None => return, + }, + }; + for win in pts.windows(2) { + b.stroke_line(win[0], win[1], sc, sw); + } +} + +fn paint_text(b: &mut dyn RenderBackend, run: &TextRun) { + // `TextRun` is shared verbatim between jian-core and the OP painter, + // so a `TextLayout` wraps it directly. The painter derives the + // baseline + alignment from `run.origin` / `run.max_width` / `align`. + let layout = TextLayout::single_run( + &run.content, + &run.font_family, + run.font_size, + run.color, + Point2D::new(run.origin.x, run.origin.y), + ) + .with_font_weight(run.font_weight); + b.draw_text(&layout, Point2D::new(run.origin.x, run.origin.y)); +} + +fn paint_linear_gradient( + b: &mut dyn RenderBackend, + r: Rect, + radius: f32, + g: &LinearGradient, + stroke: Option<&StrokeOp>, +) { + b.fill_round_rect_linear_gradient(r, radius, &stops(&g.stops), g.angle_deg, g.opacity); + if let Some(s) = stroke { + if radius > 0.5 { + b.stroke_round_rect(r, radius, color(s.color), s.width); + } else { + b.stroke_rect(r, color(s.color), s.width); + } + } +} + +fn paint_radial_gradient( + b: &mut dyn RenderBackend, + r: Rect, + radius: f32, + g: &RadialGradient, + stroke: Option<&StrokeOp>, +) { + b.fill_round_rect_radial_gradient(r, radius, &stops(&g.stops), g.cx, g.cy, g.radius, g.opacity); + if let Some(s) = stroke { + if radius > 0.5 { + b.stroke_round_rect(r, radius, color(s.color), s.width); + } else { + b.stroke_rect(r, color(s.color), s.width); + } + } +} + +fn paint_shadow(b: &mut dyn RenderBackend, r: Rect, radius: f32, shadow: &ShadowSpec) { + // Offset the shadow rect by (dx, dy) and grow it by `spread`, then + // ask the backend's drop-shadow primitive to draw it under the + // node. The paint layer on top is emitted as a separate DrawOp by + // the walker, so here we only lay the blurred shadow. + let shadow_rect = Rect { + origin: Point2D::new( + r.origin.x + shadow.dx - shadow.spread, + r.origin.y + shadow.dy - shadow.spread, + ), + size: Point2D::new( + r.size.x + shadow.spread * 2.0, + r.size.y + shadow.spread * 2.0, + ), + }; + b.fill_drop_shadow(shadow_rect, radius, shadow.blur, color(shadow.color)); +} diff --git a/crates/op-host-native/src/preview/mod.rs b/crates/op-host-native/src/preview/mod.rs new file mode 100644 index 000000000..132c271e0 --- /dev/null +++ b/crates/op-host-native/src/preview/mod.rs @@ -0,0 +1,259 @@ +//! Canvas Preview (Play) mode — runtime owner (Phase D5). +//! +//! When the editor enters Preview mode, the current document is run +//! through the jian `Runtime` so widget nodes become live + interactive +//! (typing into inputs, toggling switches, caret blink, focus chain). +//! The runtime is built from the document's serialized form, so the +//! saved `PenDocument` is NEVER mutated: enter clones via JSON, exit +//! drops the runtime, and the editor's `doc` is byte-identical. +//! +//! ## Why this lives host-side, not in `op-editor-core` +//! +//! `jian_core::Runtime` holds `Rc<...>` (scheduler / state graph), so it +//! is `!Send`. `op-editor-core` must stay wasm32-clean + does not hold +//! the runtime. The session therefore lives on the native host +//! (`WidgetHostNative`), which is already UI-thread-local (it owns skia +//! handles). The editor state only carries the `preview_mode` flag + +//! warning list (`EditorUiState::{enter,exit}_preview`). +//! +//! ## Render +//! +//! Each preview frame calls `collect_draws_with_widgets` and translates +//! the returned `DrawOp`s onto the OP `RenderBackend` via +//! [`draw_bridge`]. The editor's normal selection/handles do NOT paint +//! in preview. + +mod draw_bridge; + +#[cfg(test)] +mod tests; + +use jian_core::gesture::pointer::Modifiers; +use jian_core::render::scene::{collect_draws_with_widgets, WidgetRenderCtx}; +use jian_core::render::widget_style::WidgetTheme; +use jian_core::Runtime; +use jian_ops_schema::compat::{load_str_with, LoadOptions}; +use jian_ops_schema::error::LoadWarning; + +use op_editor_ui::{Rect, RenderBackend}; + +/// A live preview runtime built from a snapshot of the editor document. +/// +/// Constructed by [`PreviewSession::enter`] from a JSON serialization of +/// the document; dropping it tears down the runtime. The session is +/// `!Send` (it owns an `Rc`-bound `Runtime`), so it is held only on the +/// UI-thread-local host. +pub struct PreviewSession { + runtime: Runtime, + theme: WidgetTheme, + /// Logical canvas size the layout was last computed against. Re-laid + /// out on resize via [`PreviewSession::resize`]. + canvas_size: (f32, f32), + /// Non-fatal load warnings (e.g. legacy role promotions), formatted + /// for display in the editor's `preview_warnings`. + warnings: Vec, +} + +impl PreviewSession { + /// Build a preview runtime from the document's JSON. `promote=true` + /// turns legacy role-frames into first-class widget nodes in-memory + /// (the source doc is untouched). `canvas_size` is the logical + /// canvas region the runtime lays out into. + /// + /// Returns `Err(message)` if serialization, parsing, runtime build, + /// or layout fails — the host then declines to enter preview and + /// surfaces the message. + pub fn enter( + doc: &jian_ops_schema::PenDocument, + canvas_size: (f32, f32), + ) -> Result { + let src = serde_json::to_string(doc).map_err(|e| format!("serialize document: {e}"))?; + let loaded = load_str_with( + &src, + LoadOptions { + promote_legacy_widgets: true, + }, + ) + .map_err(|e| format!("parse document for preview: {e}"))?; + + let warnings = loaded + .warnings + .iter() + .filter_map(format_warning) + .collect::>(); + + let mut runtime = + Runtime::new_from_document(loaded.value).map_err(|e| format!("build runtime: {e}"))?; + let size = (canvas_size.0.max(1.0), canvas_size.1.max(1.0)); + runtime + .build_layout(size) + .map_err(|e| format!("compute layout: {e}"))?; + runtime.rebuild_spatial(); + + Ok(Self { + runtime, + theme: WidgetTheme::default(), + canvas_size: size, + warnings, + }) + } + + /// The formatted load warnings collected on `enter` (for the + /// editor's `preview_warnings` diagnostics surface). + pub fn warnings(&self) -> &[String] { + &self.warnings + } + + /// Push the host clock so the runtime can drive caret blink etc. + pub fn set_now_ms(&mut self, now_ms: u64) { + self.runtime.set_now_ms(now_ms); + } + + /// Re-lay-out the runtime against a new logical canvas size. Cheap + /// no-op when the size is unchanged (sub-pixel). + pub fn resize(&mut self, canvas_size: (f32, f32)) { + let size = (canvas_size.0.max(1.0), canvas_size.1.max(1.0)); + if (size.0 - self.canvas_size.0).abs() < 0.5 && (size.1 - self.canvas_size.1).abs() < 0.5 { + return; + } + self.canvas_size = size; + // A layout failure here leaves the previous rects in place — the + // preview keeps painting the last good frame rather than panicking. + let _ = self.runtime.build_layout(size); + self.runtime.rebuild_spatial(); + } + + /// Schema id of the currently focused node (for the render ctx's + /// focus ring + caret), mapped from the runtime focus chain. + fn focused_schema_id(&self) -> Option { + let key = self.runtime.focus.current()?; + let doc = self.runtime.document.as_ref()?; + let node = doc.tree.nodes.get(key)?; + Some(jian_core::document::tree::node_schema_id(&node.schema).to_owned()) + } + + /// Collect this frame's draw ops from the live runtime. + fn draw_ops(&self, now_ms: u64) -> Vec { + let Some(doc) = self.runtime.document.as_ref() else { + return Vec::new(); + }; + let focused = self.focused_schema_id(); + let ctx = WidgetRenderCtx { + states: &self.runtime.widget_states, + theme: &self.theme, + focused_id: focused.as_deref(), + now_ms, + }; + collect_draws_with_widgets(doc, &self.runtime.layout, &self.runtime.state, &ctx) + } + + /// Paint the live preview scene into `backend`, transformed by the + /// editor viewport so it lands in the same place the design surface + /// drew (canvas origin + pan, scaled by zoom). `canvas_region` is + /// the screen-space rect of the canvas (clip target); `pan` / `zoom` + /// come from the editor viewport. + pub fn paint( + &self, + backend: &mut dyn RenderBackend, + canvas_region: Rect, + pan: (f32, f32), + zoom: f32, + now_ms: u64, + ) { + let ops = self.draw_ops(now_ms); + backend.save(); + backend.clip_rect(canvas_region); + // screen = canvas_origin + pan + doc * zoom + backend.translate(op_editor_ui::Point2D::new( + canvas_region.origin.x + pan.0, + canvas_region.origin.y + pan.1, + )); + backend.scale( + op_editor_ui::Point2D::new(zoom, zoom), + op_editor_ui::Point2D::new(0.0, 0.0), + ); + for op in &ops { + draw_bridge::paint_draw_op(backend, op); + } + backend.restore(); + } + + // --- Input dispatch ------------------------------------------- + + /// Route a printable character into the focused widget. Returns + /// `true` when the runtime consumed it (a focused editable widget + /// accepted the text). + pub fn dispatch_text(&mut self, text: &str) -> bool { + self.runtime.dispatch_text_input(text) + } + + /// Route a named key (e.g. `"Backspace"`, `"ArrowLeft"`, `"Enter"`, + /// `"Tab"`) into the runtime with the given modifier set. Returns + /// `true` when the dispatch emitted any semantic event. + pub fn dispatch_key(&mut self, key: &str, modifiers: Modifiers) -> bool { + !self + .runtime + .dispatch_keyboard(key.to_string(), modifiers) + .is_empty() + } + + /// Dispatch a tap (Down then Up) at a DOCUMENT-space point into the + /// runtime so clicks land on switches / buttons / and place caret / + /// focus in text inputs. The host converts the screen press to doc + /// space (via the editor viewport) before calling this. Returns + /// `true` when the runtime emitted any semantic event. + pub fn dispatch_tap(&mut self, doc_x: f32, doc_y: f32) -> bool { + use jian_core::geometry::point; + use jian_core::gesture::pointer::{PointerEvent, PointerPhase}; + let down = PointerEvent::simple(1, PointerPhase::Down, point(doc_x, doc_y)); + let mut emitted = self.runtime.dispatch_pointer(down); + let up = PointerEvent::simple(1, PointerPhase::Up, point(doc_x, doc_y)); + emitted.extend(self.runtime.dispatch_pointer(up)); + !emitted.is_empty() + } + + /// Advance focus to the next focusable widget (Tab). + pub fn focus_next(&mut self) { + self.runtime.focus_next(); + } + + /// Advance focus to the previous focusable widget (Shift+Tab). + pub fn focus_previous(&mut self) { + self.runtime.focus_previous(); + } + + /// Test-only read access to the live state graph + widget state so + /// the host test can assert injected text reached the runtime. + #[cfg(test)] + pub(crate) fn runtime(&self) -> &Runtime { + &self.runtime + } +} + +/// Format a load warning for the editor's diagnostics surface. Only +/// surfaces the actionable ones (legacy promotions, future versions, +/// skipped logic) — generic unknown-field noise is dropped. +fn format_warning(w: &LoadWarning) -> Option { + match w { + LoadWarning::LegacyRolePromoted { + path, + from_role, + to, + } => Some(format!( + "LegacyRolePromoted: '{path}' role '{from_role}' → {to}" + )), + LoadWarning::FutureFormatVersion { + found, + supported_max, + } => Some(format!( + "FutureFormatVersion: {found} (supported ≤ {supported_max})" + )), + LoadWarning::LogicModulesSkipped { reason } => { + Some(format!("LogicModulesSkipped: {reason}")) + } + LoadWarning::InvalidExpression { path, reason, .. } => { + Some(format!("InvalidExpression: '{path}': {reason}")) + } + LoadWarning::UnknownField { .. } => None, + } +} diff --git a/crates/op-host-native/src/preview/tests.rs b/crates/op-host-native/src/preview/tests.rs new file mode 100644 index 000000000..edd5ea72b --- /dev/null +++ b/crates/op-host-native/src/preview/tests.rs @@ -0,0 +1,121 @@ +//! Preview-mode session tests (Phase D5). +//! +//! Cover the spec invariants that are testable without a live skia +//! surface: document byte-invariance across enter→input→exit, and that +//! injected text reaches the runtime state graph. + +#![cfg(test)] + +use super::PreviewSession; +use jian_core::widget_state::WidgetState; + +/// A minimal `.op` document with a single focusable text_input node. +fn text_input_doc() -> jian_ops_schema::PenDocument { + let src = r##"{ + "version": "1.1", + "formatVersion": "1.1", + "id": "x", + "app": { "name": "x", "version": "1", "id": "x" }, + "children": [ + { "type": "text_input", "id": "field", "width": 200, "height": 40, + "fill": [{ "type": "solid", "color": "#ffffff" }] } + ] + }"##; + let loaded = jian_ops_schema::load_str(src).expect("parse test doc"); + loaded.value +} + +#[test] +fn enter_input_exit_leaves_document_byte_identical() { + // Spec exit-invariant: an enter → input → exit cycle must leave the + // saved `PenDocument` byte-identical (the runtime is built from a + // JSON snapshot, never the live doc). + let doc = text_input_doc(); + let before = serde_json::to_string(&doc).expect("serialize before"); + + { + let mut session = PreviewSession::enter(&doc, (800.0, 600.0)).expect("enter preview"); + session.set_now_ms(0); + session.focus_next(); + session.dispatch_text("hello"); + // Drop the session here (scope end) — mirrors Stop / Esc exit. + } + + let after = serde_json::to_string(&doc).expect("serialize after"); + assert_eq!( + before, after, + "enter→input→exit must not mutate the document" + ); +} + +#[test] +fn dispatched_text_reaches_runtime_state_graph() { + // Injected text must land in the runtime's widget state, not the doc. + let doc = text_input_doc(); + let mut session = PreviewSession::enter(&doc, (800.0, 600.0)).expect("enter preview"); + session.set_now_ms(0); + session.focus_next(); + let consumed = session.dispatch_text("hi"); + assert!(consumed, "focused text_input should consume the text"); + + let state = session + .runtime() + .widget_states + .get("field") + .expect("field widget state seeded"); + match state { + WidgetState::TextInput(st) => assert_eq!(st.text(), "hi"), + other => panic!("expected TextInput state, got {other:?}"), + } +} + +#[test] +fn preview_render_of_text_input_produces_draw_ops() { + // Smoke: a preview frame of a doc with a focused text_input must + // produce draw ops (at minimum the field's box + typed text). + let doc = text_input_doc(); + let mut session = PreviewSession::enter(&doc, (800.0, 600.0)).expect("enter preview"); + session.set_now_ms(0); + session.focus_next(); + session.dispatch_text("hi"); + + let ops = session.draw_ops(0); + assert!( + !ops.is_empty(), + "preview render should emit draw ops for the text_input" + ); + assert!( + ops.iter().any(|op| matches!( + op, + jian_core::render::DrawOp::Text(t) if t.content == "hi" + )), + "the typed text should appear as a Text draw op" + ); +} + +#[test] +fn legacy_role_promotion_is_recorded_as_warning() { + // A legacy role-frame should be promoted (promote=true) and the + // promotion surfaced as a warning for the editor diagnostics. + let src = r##"{ + "version": "1.1", + "formatVersion": "1.1", + "id": "x", + "app": { "name": "x", "version": "1", "id": "x" }, + "children": [ + { "type": "frame", "id": "legacy", "width": 200, "height": 40, + "meta": { "role": "input" } } + ] + }"##; + let loaded = jian_ops_schema::load_str(src).expect("parse legacy doc"); + let doc = loaded.value; + + let session = PreviewSession::enter(&doc, (800.0, 600.0)); + // The doc may or may not carry a promotable marker depending on the + // schema's role convention; either way `enter` must succeed and the + // warning list must be well-formed (no panic, valid strings). + let session = session.expect("enter preview on legacy doc"); + for w in session.warnings() { + assert!(!w.is_empty()); + } +} diff --git a/crates/op-host-native/src/widget_host.rs b/crates/op-host-native/src/widget_host.rs index 43d242bd8..2d794136e 100644 --- a/crates/op-host-native/src/widget_host.rs +++ b/crates/op-host-native/src/widget_host.rs @@ -310,6 +310,11 @@ pub struct WidgetHostNative { /// driving the color-picker drag). pub(in crate::widget_host) last_viewport_w: f32, pub(in crate::widget_host) last_viewport_h: f32, + /// Live canvas Preview (Play) session — `Some` while + /// `editor_state.editor_ui.preview_mode` is set. Owns the jian + /// `Runtime` (host-local; `!Send`). Built on enter from the + /// document JSON, dropped on exit so the document stays untouched. + pub(in crate::widget_host) preview: Option, } #[derive(Debug, Clone, Copy)] @@ -595,6 +600,7 @@ impl WidgetHostNative { shift_held: false, last_viewport_w: 0.0, last_viewport_h: 0.0, + preview: None, } } @@ -607,9 +613,146 @@ impl WidgetHostNative { /// Push the host's monotonic millisecond timestamp into the /// host. Drives caret blink + any future time-based - /// animations via `jian_core::anim`. + /// animations via `jian_core::anim`. Also forwarded to the live + /// preview runtime (caret blink in Preview mode). pub fn set_now_ms(&mut self, now_ms: u64) { self.now_ms = now_ms; + if let Some(preview) = self.preview.as_mut() { + preview.set_now_ms(now_ms); + } + } + + /// Whether the canvas is currently in Preview (Play) mode with a + /// live runtime. + pub fn preview_active(&self) -> bool { + self.preview.is_some() && self.editor_state.editor_ui.preview_mode + } + + /// Enter Preview (Play) mode: flip the editor flag + build a live + /// jian runtime from the current document (which is NOT mutated). + /// `canvas_size` is the logical canvas region the runtime lays out + /// into. On a build failure the editor stays in design mode and the + /// error is recorded in `preview_warnings`. Returns `true` on + /// success. + pub fn enter_preview(&mut self, canvas_size: (f32, f32)) -> bool { + if self.preview.is_some() { + return true; + } + match crate::preview::PreviewSession::enter(&self.editor_state.doc, canvas_size) { + Ok(mut session) => { + session.set_now_ms(self.now_ms); + self.editor_state.editor_ui.enter_preview(); + self.editor_state.editor_ui.preview_warnings = session.warnings().to_vec(); + self.preview = Some(session); + self.mark_dirty(); + true + } + Err(message) => { + // Stay in design mode; surface the failure. + self.editor_state.editor_ui.preview_mode = false; + self.editor_state.editor_ui.preview_warnings = vec![format!("preview: {message}")]; + self.mark_dirty(); + false + } + } + } + + /// Exit Preview mode: drop the runtime + clear the editor flag. The + /// document is byte-identical to before entering (the runtime never + /// touched it). Idempotent. + pub fn exit_preview(&mut self) { + self.preview = None; + self.editor_state.editor_ui.exit_preview(); + self.mark_dirty(); + } + + /// Toggle Preview mode. `canvas_size` is the logical canvas region + /// (used only on enter). Returns the new state (`true` = in preview). + pub fn toggle_preview(&mut self, canvas_size: (f32, f32)) -> bool { + if self.preview_active() { + self.exit_preview(); + false + } else { + self.enter_preview(canvas_size) + } + } + + /// Re-lay-out the live preview runtime against the canvas region + /// derived from the given viewport size. No-op when not in preview. + /// The desktop runner calls this from its `Resized` handler. + pub fn preview_resize(&mut self, viewport_w: f32, viewport_h: f32) { + if self.preview.is_none() { + return; + } + let (_cx0, _cy0, cw, ch) = self.canvas_region(viewport_w, viewport_h); + if let Some(preview) = self.preview.as_mut() { + preview.resize((cw, ch)); + } + } + + /// Route a printable character into the live preview runtime. + /// Returns `true` when consumed by a focused widget. No-op (false) + /// when not in preview. + pub fn preview_dispatch_text(&mut self, text: &str) -> bool { + let consumed = self.preview.as_mut().is_some_and(|p| p.dispatch_text(text)); + if consumed { + self.mark_dirty(); + } + consumed + } + + /// Route a screen-space press into the live preview runtime as a + /// tap (Down→Up). `canvas_region` is the screen rect of the canvas + /// (`(left, top, w, h)`); the press is converted to canvas-local + /// then document space via the editor viewport so it matches the + /// painted scene transform. No-op (false) when not in preview or the + /// press is outside the canvas region. + pub fn preview_dispatch_press( + &mut self, + screen_x: f32, + screen_y: f32, + viewport_w: f32, + viewport_h: f32, + ) -> bool { + if self.preview.is_none() { + return false; + } + let (cx0, cy0, cw, ch) = self.canvas_region(viewport_w, viewport_h); + if screen_x < cx0 || screen_x > cx0 + cw || screen_y < cy0 || screen_y > cy0 + ch { + return false; + } + // Canvas-local → document via the editor viewport (same + // transform the preview paint applies, inverted). + let canvas_local = op_editor_ui::Point2D::new(screen_x - cx0, screen_y - cy0); + let doc = self.editor_state.viewport.to_document(canvas_local); + let handled = self + .preview + .as_mut() + .is_some_and(|p| p.dispatch_tap(doc.x, doc.y)); + self.mark_dirty(); + handled + } + + /// Route a named key into the live preview runtime. `shift` drives + /// Shift+Tab focus traversal + selection. Returns `true` when the + /// runtime emitted any semantic event. + pub fn preview_dispatch_key(&mut self, key: &str, shift: bool) -> bool { + use jian_core::gesture::pointer::Modifiers; + let mods = if shift { + Modifiers::SHIFT + } else { + Modifiers::empty() + }; + let handled = self + .preview + .as_mut() + .is_some_and(|p| p.dispatch_key(key, mods)); + // Tab traversal / text edits change focus or content even when + // no semantic event fires, so always repaint while in preview. + if self.preview.is_some() { + self.mark_dirty(); + } + handled } /// Refresh host-level theme tokens from the canonical editor UI @@ -878,6 +1021,12 @@ impl WidgetHostNative { /// the caret blink phase. `None` = no animation pending. pub fn next_animation_deadline_ms(&self) -> Option { let mut next = op_editor_core::agent_indicators::next_reveal_deadline_ms(self.now_ms); + // While previewing, keep the loop ticking (~30 fps) so the live + // runtime's caret blink + any time-driven widget state animates. + if self.preview.is_some() { + let deadline = self.now_ms.saturating_add(33); + next = Some(next.map_or(deadline, |current| current.min(deadline))); + } if let Some(input) = self.editor_state.active_text_input() { let deadline = input.next_blink_flip_ms(self.now_ms); next = Some(next.map_or(deadline, |current| current.min(deadline))); diff --git a/crates/op-host-native/src/widget_host/input.rs b/crates/op-host-native/src/widget_host/input.rs index d5f2cad58..0e09e156b 100644 --- a/crates/op-host-native/src/widget_host/input.rs +++ b/crates/op-host-native/src/widget_host/input.rs @@ -18,6 +18,12 @@ const MAX_SMART_GUIDE_NODES: usize = 1_000; impl WidgetHostNative { /// True iff a text-input surface owns the keyboard. pub(in crate::widget_host) fn input_active(&self) -> bool { + // Preview (Play) mode disables every editor edit shortcut — the + // canvas belongs to the live runtime, so duplicate / nudge / + // boolean-op / etc. must all bail. + if self.preview.is_some() { + return true; + } let ui = &self.editor_state.ui; ui.layer_rename.is_some() || ui.text_editing.is_some() diff --git a/crates/op-host-native/src/widget_host/keyboard.rs b/crates/op-host-native/src/widget_host/keyboard.rs index 97e180d74..18bcad3f9 100644 --- a/crates/op-host-native/src/widget_host/keyboard.rs +++ b/crates/op-host-native/src/widget_host/keyboard.rs @@ -39,6 +39,15 @@ impl WidgetHostNative { /// Typed-char router: settings → rename → text-edit → variable /// row → property → chat. pub fn apply_text(&mut self, c: char) -> bool { + // Preview (Play) mode owns the keyboard: printable chars go to + // the live runtime's focused widget, never editor editing. + if self.preview.is_some() { + if c.is_control() { + return false; + } + let mut s = [0u8; 4]; + return self.preview_dispatch_text(c.encode_utf8(&mut s)); + } // Settings input owns the keyboard while focused. if self.editor_state.editor_ui.agent_settings.focus.is_some() { return self.apply_settings_text(c); @@ -442,6 +451,11 @@ impl WidgetHostNative { } pub fn apply_backspace(&mut self) -> bool { + // Preview mode: Backspace edits the focused runtime widget, not + // the editor selection. + if self.preview.is_some() { + return self.preview_dispatch_key("Backspace", false); + } if self.editor_state.editor_ui.agent_settings.focus.is_some() { return self.apply_settings_backspace(); } @@ -719,6 +733,11 @@ impl WidgetHostNative { /// Delete — pops a char from rename / text-edit when active; /// otherwise deletes the selected node. pub fn apply_delete(&mut self) -> bool { + // Preview mode: Delete edits the focused runtime widget, never + // the editor selection. + if self.preview.is_some() { + return self.preview_dispatch_key("Delete", false); + } if self .editor_state .editor_ui @@ -1132,6 +1151,11 @@ impl WidgetHostNative { } pub fn apply_send(&mut self) -> bool { + // Preview mode: Enter goes to the focused runtime widget + // (textarea newline / activation), never chat send. + if self.preview.is_some() { + return self.preview_dispatch_key("Enter", false); + } if self.editor_state.editor_ui.agent_settings.focus.is_some() { self.commit_settings_focus_if_any(); return true; @@ -1307,6 +1331,12 @@ impl WidgetHostNative { /// Escape — priority cascade: rename → property → pickers → /// chat → selection. One layer per press. pub fn apply_escape(&mut self) -> bool { + // Escape EXITS preview mode (top priority) — drops the runtime + // and returns to the design surface. + if self.preview.is_some() { + self.exit_preview(); + return true; + } if self .editor_state .editor_ui diff --git a/crates/op-host-native/src/widget_host/paint.rs b/crates/op-host-native/src/widget_host/paint.rs index 7ccaa55b3..153bfac2f 100644 --- a/crates/op-host-native/src/widget_host/paint.rs +++ b/crates/op-host-native/src/widget_host/paint.rs @@ -139,14 +139,33 @@ impl WidgetHostNative { size: Point2D::new(canvas_w, canvas_h), }; if canvas_w > 0.0 && canvas_h > 0.0 { - // PAINT path — the canvas reads editor state + the - // layout-resolved render scene (`refresh_layout_scene`). - let mut canvas = CanvasViewport::from_editor(&self.editor_state, &self.layout_scene); - canvas.now_ms = self.now_ms; - let mut cx = PaintCx { - backend: &mut *frame, - }; - canvas.paint(&mut cx, canvas_rect); + if let Some(preview) = self.preview.as_ref() { + // PREVIEW path — paint the canvas background, then the + // live jian runtime scene transformed by the editor + // viewport. The editor's selection / handles / grid do + // NOT paint in preview (the runtime owns the surface). + frame.fill_rect(canvas_rect, self.theme.canvas_surface); + preview.paint( + &mut *frame, + canvas_rect, + ( + self.editor_state.viewport.pan_x, + self.editor_state.viewport.pan_y, + ), + self.editor_state.viewport.zoom, + self.now_ms, + ); + } else { + // PAINT path — the canvas reads editor state + the + // layout-resolved render scene (`refresh_layout_scene`). + let mut canvas = + CanvasViewport::from_editor(&self.editor_state, &self.layout_scene); + canvas.now_ms = self.now_ms; + let mut cx = PaintCx { + backend: &mut *frame, + }; + canvas.paint(&mut cx, canvas_rect); + } } // 5. PropertyPanel — only when selection. @@ -248,16 +267,20 @@ impl WidgetHostNative { origin: Point2D::new(canvas_left, TOP_BAR_HEIGHT), size: Point2D::new(canvas_w, canvas_h), }; - if let Some(toolbar) = AlignToolbar::for_canvas_region(canvas_region, &self.editor_state) { - let hover = self.editor_state.editor_ui.align_toolbar_hover; - toolbar.paint(&mut *frame, &self.theme, hover); + if self.preview.is_none() { + if let Some(toolbar) = + AlignToolbar::for_canvas_region(canvas_region, &self.editor_state) + { + let hover = self.editor_state.editor_ui.align_toolbar_hover; + toolbar.paint(&mut *frame, &self.theme, hover); + } } // 8.5. Marquee selection rect — painted above canvas but // below the floating pickers / status. Visible only // while the user is dragging a rect-select on empty - // canvas (Select tool). - if let Some(m) = self.marquee_drag { + // canvas (Select tool). Never in preview mode. + if let Some(m) = self.marquee_drag.filter(|_| self.preview.is_none()) { let x0 = m.start_screen_x.min(m.current_screen_x); let y0 = m.start_screen_y.min(m.current_screen_y); let w = (m.current_screen_x - m.start_screen_x).abs(); diff --git a/crates/op-host-native/src/widget_host/press.rs b/crates/op-host-native/src/widget_host/press.rs index 46d005b66..ef7a32f88 100644 --- a/crates/op-host-native/src/widget_host/press.rs +++ b/crates/op-host-native/src/widget_host/press.rs @@ -470,6 +470,14 @@ impl WidgetHostNative { self.mark_dirty(); return true; } + TopBarHit::TogglePreview => { + // Enter / exit canvas Preview (Play) mode. On enter, + // build the runtime against the current canvas region + // size so layout matches the visible viewport. + let (_cx0, _cy0, cw, ch) = self.canvas_region(viewport_width, viewport_height); + self.toggle_preview((cw, ch)); + return true; + } TopBarHit::OpenFigmaImport => { self.editor_state.editor_ui.figma_import_open = true; self.mark_dirty(); @@ -508,6 +516,16 @@ impl WidgetHostNative { return blurred || rename_committed || text_edit_committed; } + // 0b'. Preview (Play) mode — the canvas belongs to the live + // runtime, not the editor. The TopBar Play/Stop button was + // already handled above; any other press routes into the + // runtime (taps on switches / buttons, caret placement) and is + // swallowed so no editor selection / node-creation fires. + if self.preview.is_some() { + self.preview_dispatch_press(x, y, viewport_width, viewport_height); + return true; + } + // 0c0a. Image-fill popover — outside-click dismiss. if !in_git_panel && self.dismiss_image_fill_popover_on_press(x, y, viewport_width, viewport_height) diff --git a/crates/op-host-web/src/widget_host/press.rs b/crates/op-host-web/src/widget_host/press.rs index 38a9045ef..86fe03458 100644 --- a/crates/op-host-web/src/widget_host/press.rs +++ b/crates/op-host-web/src/widget_host/press.rs @@ -350,6 +350,16 @@ impl WidgetHost { } } } + TopBarHit::TogglePreview => { + // Web Preview mode is not wired yet: the jian `Runtime` + // is `!Send`/`Rc`-bound and the web host has no runtime + // owner, so the canvas cannot render a live preview. + // Deliberately a NO-OP — do NOT flip `preview_mode`, + // which would leave the Play/Stop icon claiming the + // canvas is previewing when it is still the static + // design surface (a lying toggle). The button lands + // for real when the web runtime owner is built. + } } self.mark_dirty(); return true; diff --git a/crates/op-pen-loader/src/adapter.rs b/crates/op-pen-loader/src/adapter.rs index c28e5b4fd..8c30776be 100644 --- a/crates/op-pen-loader/src/adapter.rs +++ b/crates/op-pen-loader/src/adapter.rs @@ -25,10 +25,8 @@ use jian_ops_schema::{ node::base::PenNodeBase, node::container::CornerRadius, node::{ - CheckboxNode, EllipseNode, FontWeight, FrameNode, GroupNode, IconFontNode, ImageNode, - LineNode, NumberInputNode, PathNode, PenNode, PolygonNode, ProgressNode, RadioGroupNode, - RectangleNode, SelectNode, SliderNode, SwitchNode, TabsNode, TextAreaNode, TextInputNode, - TextNode, + EllipseNode, FontWeight, FrameNode, GroupNode, IconFontNode, ImageNode, LineNode, PathNode, + PenNode, PolygonNode, RectangleNode, TextNode, }, sizing::SizingBehavior, PenDocument, @@ -354,7 +352,8 @@ fn root_sizing(root: &PenNode) -> (Option, Option) -> NodePayload { +pub(crate) fn node_to_payload(node: &PenNode, rects: &BTreeMap) -> NodePayload { + use crate::widget_payload as wp; let mut p = match node { PenNode::Frame(n) => frame_to_payload(n, rects), PenNode::Group(n) => group_to_payload(n, rects), @@ -364,16 +363,16 @@ fn node_to_payload(node: &PenNode, rects: &BTreeMap) -> NodePa PenNode::Polygon(n) => polygon_to_payload(n), PenNode::Path(n) => path_to_payload(n), PenNode::Text(n) => text_to_payload(n), - PenNode::TextInput(n) => text_input_to_payload(n), - PenNode::TextArea(n) => text_area_to_payload(n), - PenNode::Select(n) => select_to_payload(n), - PenNode::Switch(n) => switch_to_payload(n), - PenNode::Checkbox(n) => checkbox_to_payload(n), - PenNode::Slider(n) => slider_to_payload(n), - PenNode::RadioGroup(n) => radio_group_to_payload(n), - PenNode::NumberInput(n) => number_input_to_payload(n), - PenNode::Progress(n) => progress_to_payload(n), - PenNode::Tabs(n) => tabs_to_payload(n, rects), + PenNode::TextInput(n) => wp::text_input_to_payload(n), + PenNode::TextArea(n) => wp::text_area_to_payload(n), + PenNode::Select(n) => wp::select_to_payload(n), + PenNode::Switch(n) => wp::switch_to_payload(n), + PenNode::Checkbox(n) => wp::checkbox_to_payload(n), + PenNode::Slider(n) => wp::slider_to_payload(n), + PenNode::RadioGroup(n) => wp::radio_group_to_payload(n), + PenNode::NumberInput(n) => wp::number_input_to_payload(n), + PenNode::Progress(n) => wp::progress_to_payload(n), + PenNode::Tabs(n) => wp::tabs_to_payload(n, rects), PenNode::Image(n) => image_to_payload(n), PenNode::IconFont(n) => icon_font_to_payload(n), PenNode::Ref(n) => empty_group(&n.base, "ref"), @@ -744,122 +743,6 @@ fn text_vertical_align_keyword(value: &jian_ops_schema::node::TextAlignVertical) } } -fn text_input_to_payload(n: &TextInputNode) -> NodePayload { - let mut p = base_payload(&n.base, "text"); - p.text = n.value.clone().or_else(|| n.placeholder.clone()); - assign_first_fill(&mut p, n.fill.as_deref()); - p.stroke = stroke_to_payload(n.stroke.as_ref()); - p -} - -fn text_area_to_payload(n: &TextAreaNode) -> NodePayload { - let mut p = base_payload(&n.base, "text"); - p.text = n.value.clone().or_else(|| n.placeholder.clone()); - apply_container_style( - &mut p, - n.fill.as_deref(), - n.stroke.as_ref(), - n.corner_radius.as_ref(), - ); - p -} - -fn select_to_payload(n: &SelectNode) -> NodePayload { - let mut p = base_payload(&n.base, "text"); - p.text = n.value.clone().or_else(|| n.placeholder.clone()); - apply_container_style( - &mut p, - n.fill.as_deref(), - n.stroke.as_ref(), - n.corner_radius.as_ref(), - ); - p -} - -fn switch_to_payload(n: &SwitchNode) -> NodePayload { - let mut p = base_payload(&n.base, "rect"); - apply_container_style( - &mut p, - n.fill.as_deref(), - n.stroke.as_ref(), - n.corner_radius.as_ref(), - ); - p -} - -fn checkbox_to_payload(n: &CheckboxNode) -> NodePayload { - let mut p = base_payload(&n.base, "rect"); - apply_container_style( - &mut p, - n.fill.as_deref(), - n.stroke.as_ref(), - n.corner_radius.as_ref(), - ); - p -} - -fn slider_to_payload(n: &SliderNode) -> NodePayload { - let mut p = base_payload(&n.base, "rect"); - apply_container_style( - &mut p, - n.fill.as_deref(), - n.stroke.as_ref(), - n.corner_radius.as_ref(), - ); - p -} - -fn radio_group_to_payload(n: &RadioGroupNode) -> NodePayload { - let mut p = base_payload(&n.base, "rect"); - apply_container_style( - &mut p, - n.fill.as_deref(), - n.stroke.as_ref(), - n.corner_radius.as_ref(), - ); - p -} - -fn number_input_to_payload(n: &NumberInputNode) -> NodePayload { - let mut p = base_payload(&n.base, "rect"); - apply_container_style( - &mut p, - n.fill.as_deref(), - n.stroke.as_ref(), - n.corner_radius.as_ref(), - ); - p -} - -fn progress_to_payload(n: &ProgressNode) -> NodePayload { - let mut p = base_payload(&n.base, "rect"); - apply_container_style( - &mut p, - n.fill.as_deref(), - n.stroke.as_ref(), - n.corner_radius.as_ref(), - ); - p -} - -fn tabs_to_payload(n: &TabsNode, rects: &BTreeMap) -> NodePayload { - let mut p = base_payload(&n.base, "frame"); - apply_container_style( - &mut p, - n.fill.as_deref(), - n.stroke.as_ref(), - n.corner_radius.as_ref(), - ); - p.children = n - .children - .as_deref() - .unwrap_or(&[]) - .iter() - .map(|c| node_to_payload(c, rects)) - .collect(); - p -} - fn image_to_payload(n: &ImageNode) -> NodePayload { let mut p = base_payload(&n.base, "rect"); if let Some(CornerRadius::Uniform(r)) = &n.corner_radius { diff --git a/crates/op-pen-loader/src/adapter_tests.rs b/crates/op-pen-loader/src/adapter_tests.rs index a91447a4e..66a5b90fb 100644 --- a/crates/op-pen-loader/src/adapter_tests.rs +++ b/crates/op-pen-loader/src/adapter_tests.rs @@ -697,3 +697,90 @@ fn plain_text_has_no_runs() { assert!(t.text_runs.is_empty()); assert!(!t.italic && !t.underline && !t.strikethrough); } + +#[test] +fn widget_nodes_carry_props_through_to_payload() { + // One of each widget family, side by side under a root frame, so + // the design-surface painter has the props it needs to draw the + // composite static visual. + let src = r##"{ + "version":"1.0.0", + "pages":[{"id":"p","name":"P","children":[ + {"type":"frame","id":"root","x":0,"y":0,"width":600,"height":400,"children":[ + {"type":"switch","id":"sw","checked":true}, + {"type":"checkbox","id":"cb","checked":false,"label":"Agree"}, + {"type":"slider","id":"sl","min":0,"max":50,"step":5,"value":25}, + {"type":"progress","id":"pg","value":40,"max":80}, + {"type":"select","id":"se","value":"a", + "options":[{"value":"a","label":"Apple"},{"value":"b","label":"Banana"}]}, + {"type":"radio_group","id":"rg","value":"b", + "options":[{"value":"a","label":"A"},{"value":"b","label":"B"}]}, + {"type":"text_input","id":"ti","value":"hi","placeholder":"Type"}, + {"type":"number_input","id":"ni","value":7,"min":0,"max":10,"step":1}, + {"type":"tabs","id":"tb","value":"one", + "tabs":[{"value":"one","label":"One"},{"value":"two","label":"Two"}]} + ]} + ]}], + "children":[] + }"##; + let r = load(src); + let kids = &r.payload.pages[0].children[0].children; + let by = |kind: &str| { + kids.iter() + .find(|c| c.widget.as_ref().map(|w| w.kind.as_str()) == Some(kind)) + .unwrap_or_else(|| panic!("missing widget kind {kind}")) + .widget + .as_ref() + .unwrap() + }; + + assert_eq!(by("switch").checked, Some(true)); + + let cb = by("checkbox"); + assert_eq!(cb.checked, Some(false)); + assert_eq!(cb.label.as_deref(), Some("Agree")); + + let sl = by("slider"); + assert_eq!(sl.value_num, Some(25.0)); + assert_eq!( + (sl.min, sl.max, sl.step), + (Some(0.0), Some(50.0), Some(5.0)) + ); + + let pg = by("progress"); + assert_eq!((pg.value_num, pg.max), (Some(40.0), Some(80.0))); + + let se = by("select"); + assert_eq!(se.value_str.as_deref(), Some("a")); + assert_eq!(se.options.len(), 2); + assert_eq!(se.options[0].label, "Apple"); + + let rg = by("radio_group"); + assert_eq!(rg.value_str.as_deref(), Some("b")); + assert_eq!(rg.options.len(), 2); + + let ti = by("text_input"); + assert_eq!(ti.value_str.as_deref(), Some("hi")); + assert_eq!(ti.placeholder.as_deref(), Some("Type")); + + let ni = by("number_input"); + assert_eq!(ni.value_num, Some(7.0)); + + let tb = by("tabs"); + assert_eq!(tb.value_str.as_deref(), Some("one")); + assert_eq!(tb.options.len(), 2); + assert_eq!(tb.options[1].label, "Two"); +} + +#[test] +fn ordinary_shapes_have_no_widget_descriptor() { + let src = r##"{ + "version":"1.0.0", + "pages":[{"id":"p","name":"P","children":[ + {"type":"rectangle","id":"r","x":0,"y":0,"width":10,"height":10} + ]}], + "children":[] + }"##; + let r = load(src); + assert!(r.payload.pages[0].children[0].widget.is_none()); +} diff --git a/crates/op-pen-loader/src/lib.rs b/crates/op-pen-loader/src/lib.rs index 9ba03ea9e..f71332526 100644 --- a/crates/op-pen-loader/src/lib.rs +++ b/crates/op-pen-loader/src/lib.rs @@ -27,6 +27,7 @@ mod layout_scene; mod path_bounds; mod style_payload; mod text_style; +mod widget_payload; pub mod payload; pub mod variables; diff --git a/crates/op-pen-loader/src/payload.rs b/crates/op-pen-loader/src/payload.rs index 4f0698c3f..2a55dff75 100644 --- a/crates/op-pen-loader/src/payload.rs +++ b/crates/op-pen-loader/src/payload.rs @@ -168,10 +168,67 @@ pub struct NodePayload { /// Per-image adjustment values from image fills / image nodes. #[serde(default, skip_serializing_if = "Option::is_none")] pub image_adjustments: Option, + /// Composite-widget descriptor for the interactive node family + /// (switch / checkbox / slider / progress / select / radio_group / + /// text_input / text_area / number_input / tabs). The geometry + + /// container style still land on the base `NodePayload` fields (the + /// leaf widgets degrade to a `rect` / `text` `kind`), but this + /// carries the extra props the design-surface painter needs to draw + /// the recognizable static visual (track + knob, box + check, …). + /// `None` for ordinary shapes. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub widget: Option, #[serde(default)] pub children: Vec, } +/// Static props for a composite widget node, harvested from the +/// canonical schema by `adapter.rs`. Sentinels: `None` numeric fields +/// fall back to the runtime defaults (`min=0`, `max=100`, `step=1`) +/// the painter mirrors from jian-core's `emit_widget_visual`. +#[derive(Debug, Clone, Default, Serialize, Deserialize)] +pub struct WidgetPayload { + /// Widget family tag (`switch` / `checkbox` / `slider` / `progress` + /// / `select` / `radio_group` / `text_input` / `text_area` / + /// `number_input` / `tabs`). + pub kind: String, + /// On/off state for switch / checkbox. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub checked: Option, + /// Numeric value for slider / progress / number_input. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub value_num: Option, + /// String value for select / radio_group / tabs / text inputs. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub value_str: Option, + /// Placeholder text for inputs / select. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub placeholder: Option, + /// Adjacent label (checkbox). + #[serde(default, skip_serializing_if = "Option::is_none")] + pub label: Option, + /// Range minimum (slider / number_input). `None` = 0. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub min: Option, + /// Range maximum (slider / progress / number_input). `None` = 100. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub max: Option, + /// Range step. `None` = 1. Carried for completeness; the static + /// visual doesn't quantize. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub step: Option, + /// `(value, label)` option rows for select / radio_group / tabs. + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub options: Vec, +} + +/// One `value` + `label` option row for select / radio_group / tabs. +#[derive(Debug, Clone, Default, Serialize, Deserialize)] +pub struct WidgetOption { + pub value: String, + pub label: String, +} + fn default_polygon_sides() -> u32 { 3 } diff --git a/crates/op-pen-loader/src/style_payload.rs b/crates/op-pen-loader/src/style_payload.rs index c7d885784..94319aea7 100644 --- a/crates/op-pen-loader/src/style_payload.rs +++ b/crates/op-pen-loader/src/style_payload.rs @@ -57,6 +57,7 @@ pub(crate) fn base_payload(base: &PenNodeBase, kind: &str) -> NodePayload { image_src: None, image_fit: None, image_adjustments: None, + widget: None, children: Vec::new(), } } diff --git a/crates/op-pen-loader/src/widget_payload.rs b/crates/op-pen-loader/src/widget_payload.rs new file mode 100644 index 000000000..db631986c --- /dev/null +++ b/crates/op-pen-loader/src/widget_payload.rs @@ -0,0 +1,235 @@ +//! Composite-widget `PenNode` → `NodePayload` conversions. +//! +//! The interactive widget family (text_input / text_area / select / +//! switch / checkbox / slider / radio_group / number_input / progress / +//! tabs) degrades to a `rect` / `text` / `frame` `NodePayload` for +//! geometry + container style, but each carries a [`WidgetPayload`] +//! descriptor (props harvested from the canonical schema) so the OP +//! design canvas can paint the recognizable static visual (track + +//! knob, box + check, chevron, bar, …) instead of a bare rect. +//! +//! Carved out of `adapter.rs` to keep that file under the 800-line +//! ceiling. `adapter::node_to_payload` dispatches the widget variants +//! here; `tabs` recurses back through it for its panel children. + +use std::collections::BTreeMap; + +use jian_ops_schema::node::{ + BoolOrExpression, CheckboxNode, NumberInputNode, NumberOrExpression, ProgressNode, + RadioGroupNode, SelectNode, SelectOption, SliderNode, SwitchNode, TabsNode, TextAreaNode, + TextInputNode, +}; + +use crate::adapter::node_to_payload; +use crate::payload::{NodePayload, WidgetOption, WidgetPayload}; +use crate::style_payload::{ + apply_container_style, assign_first_fill, base_payload, stroke_to_payload, +}; + +pub(crate) fn text_input_to_payload(n: &TextInputNode) -> NodePayload { + let mut p = base_payload(&n.base, "text"); + p.text = n.value.clone().or_else(|| n.placeholder.clone()); + assign_first_fill(&mut p, n.fill.as_deref()); + p.stroke = stroke_to_payload(n.stroke.as_ref()); + p.widget = Some(WidgetPayload { + kind: "text_input".into(), + value_str: n.value.clone(), + placeholder: n.placeholder.clone(), + ..Default::default() + }); + p +} + +pub(crate) fn text_area_to_payload(n: &TextAreaNode) -> NodePayload { + let mut p = base_payload(&n.base, "text"); + p.text = n.value.clone().or_else(|| n.placeholder.clone()); + apply_container_style( + &mut p, + n.fill.as_deref(), + n.stroke.as_ref(), + n.corner_radius.as_ref(), + ); + p.widget = Some(WidgetPayload { + kind: "text_area".into(), + value_str: n.value.clone(), + placeholder: n.placeholder.clone(), + ..Default::default() + }); + p +} + +pub(crate) fn select_to_payload(n: &SelectNode) -> NodePayload { + let mut p = base_payload(&n.base, "text"); + p.text = n.value.clone().or_else(|| n.placeholder.clone()); + apply_container_style( + &mut p, + n.fill.as_deref(), + n.stroke.as_ref(), + n.corner_radius.as_ref(), + ); + p.widget = Some(WidgetPayload { + kind: "select".into(), + value_str: n.value.clone(), + placeholder: n.placeholder.clone(), + options: select_options(n.options.as_deref()), + ..Default::default() + }); + p +} + +pub(crate) fn switch_to_payload(n: &SwitchNode) -> NodePayload { + let mut p = base_payload(&n.base, "rect"); + apply_container_style( + &mut p, + n.fill.as_deref(), + n.stroke.as_ref(), + n.corner_radius.as_ref(), + ); + p.widget = Some(WidgetPayload { + kind: "switch".into(), + checked: Some(bool_or_expr(n.checked.as_ref())), + ..Default::default() + }); + p +} + +pub(crate) fn checkbox_to_payload(n: &CheckboxNode) -> NodePayload { + let mut p = base_payload(&n.base, "rect"); + apply_container_style( + &mut p, + n.fill.as_deref(), + n.stroke.as_ref(), + n.corner_radius.as_ref(), + ); + p.widget = Some(WidgetPayload { + kind: "checkbox".into(), + checked: Some(bool_or_expr(n.checked.as_ref())), + label: n.label.clone(), + ..Default::default() + }); + p +} + +pub(crate) fn slider_to_payload(n: &SliderNode) -> NodePayload { + let mut p = base_payload(&n.base, "rect"); + apply_container_style( + &mut p, + n.fill.as_deref(), + n.stroke.as_ref(), + n.corner_radius.as_ref(), + ); + p.widget = Some(WidgetPayload { + kind: "slider".into(), + value_num: n.value.as_ref().map(number_or_expr), + min: n.min.map(|v| v as f32), + max: n.max.map(|v| v as f32), + step: n.step.map(|v| v as f32), + ..Default::default() + }); + p +} + +pub(crate) fn radio_group_to_payload(n: &RadioGroupNode) -> NodePayload { + let mut p = base_payload(&n.base, "rect"); + apply_container_style( + &mut p, + n.fill.as_deref(), + n.stroke.as_ref(), + n.corner_radius.as_ref(), + ); + p.widget = Some(WidgetPayload { + kind: "radio_group".into(), + value_str: n.value.clone(), + options: select_options(n.options.as_deref()), + ..Default::default() + }); + p +} + +pub(crate) fn number_input_to_payload(n: &NumberInputNode) -> NodePayload { + let mut p = base_payload(&n.base, "rect"); + apply_container_style( + &mut p, + n.fill.as_deref(), + n.stroke.as_ref(), + n.corner_radius.as_ref(), + ); + p.widget = Some(WidgetPayload { + kind: "number_input".into(), + value_num: n.value.as_ref().map(number_or_expr), + placeholder: n.placeholder.clone(), + min: n.min.map(|v| v as f32), + max: n.max.map(|v| v as f32), + step: n.step.map(|v| v as f32), + ..Default::default() + }); + p +} + +pub(crate) fn progress_to_payload(n: &ProgressNode) -> NodePayload { + let mut p = base_payload(&n.base, "rect"); + apply_container_style( + &mut p, + n.fill.as_deref(), + n.stroke.as_ref(), + n.corner_radius.as_ref(), + ); + p.widget = Some(WidgetPayload { + kind: "progress".into(), + value_num: n.value.as_ref().map(number_or_expr), + max: n.max.map(|v| v as f32), + ..Default::default() + }); + p +} + +pub(crate) fn tabs_to_payload(n: &TabsNode, rects: &BTreeMap) -> NodePayload { + let mut p = base_payload(&n.base, "frame"); + apply_container_style( + &mut p, + n.fill.as_deref(), + n.stroke.as_ref(), + n.corner_radius.as_ref(), + ); + p.widget = Some(WidgetPayload { + kind: "tabs".into(), + value_str: n.value.clone(), + options: select_options(n.tabs.as_deref()), + ..Default::default() + }); + p.children = n + .children + .as_deref() + .unwrap_or(&[]) + .iter() + .map(|c| node_to_payload(c, rects)) + .collect(); + p +} + +/// `BoolOrExpression` → concrete bool. An unresolved `$expr` reference +/// renders as `false` on the static design surface (the runtime +/// evaluates the binding; design-time shows the off state). +fn bool_or_expr(value: Option<&BoolOrExpression>) -> bool { + matches!(value, Some(BoolOrExpression::Bool(true))) +} + +/// `NumberOrExpression` → concrete f32. An unresolved `$expr` reference +/// collapses to 0.0 (range minimum) for the static visual. +fn number_or_expr(value: &NumberOrExpression) -> f32 { + match value { + NumberOrExpression::Number(n) => *n as f32, + NumberOrExpression::Expression(_) => 0.0, + } +} + +/// Map schema `SelectOption`s into payload `(value, label)` rows. +fn select_options(opts: Option<&[SelectOption]>) -> Vec { + opts.unwrap_or(&[]) + .iter() + .map(|o| WidgetOption { + value: o.value.clone(), + label: o.label.clone(), + }) + .collect() +}