From cf49ab88c84765ad3a698d3dd46d9c1c3d8012bd Mon Sep 17 00:00:00 2001 From: Fini Date: Fri, 7 Aug 2026 01:57:25 +0800 Subject: [PATCH] fix(editor): render interactive widgets with authored design tokens --- .../src/widgets/canvas_viewport_paint.rs | 34 +- .../src/widgets/canvas_viewport_tabs_tests.rs | 43 ++ .../src/widgets/canvas_viewport_widget.rs | 463 ++++++++++++------ .../widgets/canvas_viewport_widget_tests.rs | 360 ++++++++++++-- .../contract_closure.rs | 213 ++++++++ crates/op-host-native/src/preview/input.rs | 35 +- crates/op-host-native/src/preview/mod.rs | 33 +- .../src/preview/scene_helpers.rs | 24 + .../op-host-native/src/preview/tests_caret.rs | 92 ++++ .../op-host-native/src/preview/tests_tabs.rs | 55 +++ 10 files changed, 1151 insertions(+), 201 deletions(-) create mode 100644 crates/op-editor-ui/src/widgets/canvas_viewport_tabs_tests.rs create mode 100644 crates/op-editor-ui/src/widgets/canvas_viewport_widget_tests/contract_closure.rs create mode 100644 crates/op-host-native/src/preview/tests_caret.rs create mode 100644 crates/op-host-native/src/preview/tests_tabs.rs diff --git a/crates/op-editor-ui/src/widgets/canvas_viewport_paint.rs b/crates/op-editor-ui/src/widgets/canvas_viewport_paint.rs index 9fe4db374..515939b2c 100644 --- a/crates/op-editor-ui/src/widgets/canvas_viewport_paint.rs +++ b/crates/op-editor-ui/src/widgets/canvas_viewport_paint.rs @@ -85,6 +85,29 @@ const MIN_VISIBLE_EFFECT_DEVICE_PX: f32 = 0.3; use super::canvas_overlay_transform::OverlayTransform; +/// Resolve the active tab option by authored/live value. Missing or stale +/// values deterministically fall back to the first tab/panel. +pub fn tabs_active_index(widget: &crate::layout_scene::SceneWidget) -> usize { + widget + .value_str + .as_deref() + .and_then(|value| widget.options.iter().position(|tab| tab.value == value)) + .unwrap_or(0) +} + +/// Tabs are the only first-class widget whose children are alternative +/// panels rather than ordinary descendants. `tabs[i]` maps to `children[i]`. +fn widget_children_to_paint(node: &SceneNode) -> &[SceneNode] { + let Some(widget) = node.widget.as_ref().filter(|widget| widget.kind == "tabs") else { + return &node.children; + }; + let active = tabs_active_index(widget); + node.children + .get(active) + .map(std::slice::from_ref) + .unwrap_or_default() +} + fn paint_node_inner<'a>( cx: &mut PaintCx<'_>, node: &'a SceneNode, @@ -361,9 +384,8 @@ fn paint_node_inner<'a>( } else { paint_fill_then_stroke(cx, node, world_rect, zoom, node.fill); } - // `tabs` degrades to a `frame` whose children are the tab - // panels; paint the minimal tab-bar visual over the frame - // fill, then the children render normally below. + // `tabs` degrades to a `frame`; retain its tab bar while only + // the authored/live active panel participates in paint + hits. paint_widget_visual(cx, node, world_rect, zoom); if let Some(accent) = options.generation_accent { let visually_empty = super::canvas_generation_scan::is_placeholder_section(node) @@ -400,7 +422,7 @@ fn paint_node_inner<'a>( let clipped = push_clip_content(cx, node, world_rect, zoom); paint_child_siblings( cx, - &node.children, + widget_children_to_paint(node), options, transforms, is_hovered, @@ -696,3 +718,7 @@ mod layered_shape_tests; #[cfg(test)] #[path = "canvas_viewport_node_blend_tests.rs"] mod node_blend_tests; + +#[cfg(test)] +#[path = "canvas_viewport_tabs_tests.rs"] +mod tabs_tests; diff --git a/crates/op-editor-ui/src/widgets/canvas_viewport_tabs_tests.rs b/crates/op-editor-ui/src/widgets/canvas_viewport_tabs_tests.rs new file mode 100644 index 000000000..ed85455da --- /dev/null +++ b/crates/op-editor-ui/src/widgets/canvas_viewport_tabs_tests.rs @@ -0,0 +1,43 @@ +use super::widget_children_to_paint; +use crate::layout_scene::{NodeKind, SceneNode, SceneWidget, SceneWidgetOption}; + +fn tabs_node(value: Option<&str>) -> SceneNode { + let mut node = SceneNode::leaf("tabs", NodeKind::Frame); + node.widget = Some(SceneWidget { + kind: "tabs".into(), + value_str: value.map(str::to_owned), + options: vec![ + SceneWidgetOption { + value: "overview".into(), + label: "Overview".into(), + }, + SceneWidgetOption { + value: "details".into(), + label: "Details".into(), + }, + ], + ..Default::default() + }); + node.children = vec![ + SceneNode::leaf("overview-panel", NodeKind::Frame), + SceneNode::leaf("details-panel", NodeKind::Frame), + ]; + node +} + +#[test] +fn tabs_paint_only_the_authored_active_panel() { + let node = tabs_node(Some("details")); + let visible = widget_children_to_paint(&node); + assert_eq!(visible.len(), 1); + assert_eq!(visible[0].id, "details-panel"); +} + +#[test] +fn tabs_live_value_switches_panel_and_invalid_value_falls_back_first() { + let mut node = tabs_node(Some("missing")); + assert_eq!(widget_children_to_paint(&node)[0].id, "overview-panel"); + + node.widget.as_mut().unwrap().value_str = Some("details".into()); + assert_eq!(widget_children_to_paint(&node)[0].id, "details-panel"); +} diff --git a/crates/op-editor-ui/src/widgets/canvas_viewport_widget.rs b/crates/op-editor-ui/src/widgets/canvas_viewport_widget.rs index 91c1f9bcf..f66f192bc 100644 --- a/crates/op-editor-ui/src/widgets/canvas_viewport_widget.rs +++ b/crates/op-editor-ui/src/widgets/canvas_viewport_widget.rs @@ -19,19 +19,11 @@ use crate::layout_scene::{SceneNode, SceneWidget}; use crate::widgets::PaintCx; use crate::{Color, Point2D, Rect, TextLayout}; +use jian_core::render::widget_style::{ + resolve_authored_widget_visual, with_visual_opacity, AuthoredWidgetVisual, +}; use std::borrow::Cow; -/// 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); - /// Base horizontal text padding inside an input (doc px, pre-zoom). pub(crate) const INPUT_PAD_X: f32 = 8.0; /// Leading/trailing icon glyph box inside an input (doc px, pre-zoom). @@ -50,11 +42,6 @@ pub fn widget_text_inset_left(w: &SceneWidget) -> f32 { } } -/// Perceived luminance (0..1) of an opaque scene color. -fn color_luminance(c: Color) -> f32 { - 0.299 * c.r + 0.587 * c.g + 0.114 * c.b -} - /// Paint the static visual for a widget scene node, in world coords. /// /// `world_rect` is the node's already-zoom-scaled screen rect; `zoom` @@ -73,90 +60,200 @@ pub(crate) fn paint_widget_visual( if world_rect.size.x <= 0.0 || world_rect.size.y <= 0.0 { return false; } + let visual = authored_widget_visual(node); 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), + "switch" => paint_switch(cx, node, w, &visual, world_rect, zoom), + "checkbox" => paint_checkbox(cx, node, w, &visual, world_rect, zoom), + "slider" => paint_slider(cx, node, w, &visual, world_rect, zoom), + "progress" => paint_progress(cx, node, w, &visual, world_rect, zoom), + "select" => paint_select(cx, node, w, &visual, world_rect, zoom), + "radio_group" => paint_radio_group(cx, node, w, &visual, world_rect, zoom), "text_input" | "text_area" | "number_input" => { - paint_text_field(cx, node, w, world_rect, zoom) + paint_text_field(cx, node, w, &visual, world_rect, zoom) } - "tabs" => paint_tabs(cx, node, w, world_rect, zoom), + "tabs" => paint_tabs(cx, node, w, &visual, 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) { +fn authored_widget_visual(node: &SceneNode) -> AuthoredWidgetVisual { + let mut visual = resolve_authored_widget_visual( + node.fill.map(Color::to_jian), + node.stroke.map(|stroke| stroke.color.to_jian()), + ); + // Scene fill/stroke alpha already contains direct-paint node opacity. Only + // contrast-derived internal colours need it folded in here; applying it to + // `active`/`inactive`/`surface`/`border` again would square translucency. + // Resolver legacy fallbacks have no authored paint carrying scene opacity. + // Fold opacity into only those fallback tracks; a fill-derived inactive + // track already inherited fill alpha and must not be multiplied again. + if node.fill.is_none() { + visual.active = with_visual_opacity(visual.active, node.opacity); + if node.stroke.is_none() { + visual.inactive = with_visual_opacity(visual.inactive, node.opacity); + } + } + visual.active_foreground = with_visual_opacity(visual.active_foreground, node.opacity); + visual.inactive_foreground = with_visual_opacity(visual.inactive_foreground, node.opacity); + visual.foreground = with_visual_opacity(visual.foreground, node.opacity); + visual.muted_foreground = with_visual_opacity(visual.muted_foreground, node.opacity); + visual.label_foreground = with_visual_opacity(visual.label_foreground, node.opacity); + visual.muted_label_foreground = + with_visual_opacity(visual.muted_label_foreground, node.opacity); + visual +} + +fn ui_color(color: jian_core::scene::Color) -> Color { + Color::rgba_u8( + color.r(), + color.g(), + color.b(), + f32::from(color.a()) / 255.0, + ) +} + +/// Switch: authored active / inactive track plus a contrast-derived knob. +fn paint_switch( + cx: &mut PaintCx<'_>, + node: &SceneNode, + w: &SceneWidget, + visual: &AuthoredWidgetVisual, + r: Rect, + zoom: f32, +) { 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 track = if on { visual.active } else { visual.inactive }; + let foreground = if on { + visual.active_foreground + } else { + visual.inactive_foreground + }; + let radius = authored_radius_or(node, w, h / 2.0, zoom); + cx.backend.fill_round_rect(r, radius, ui_color(track)); 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); + .fill_round_rect(Rect::xywh(kx, y + pad, d, d), d / 2.0, ui_color(foreground)); } -/// 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) { +/// Checkbox: authored active fill with a contrast-derived check when on, +/// authored inactive outline when off. +fn paint_checkbox( + cx: &mut PaintCx<'_>, + node: &SceneNode, + w: &SceneWidget, + visual: &AuthoredWidgetVisual, + 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 label = w.label.as_deref().filter(|label| !label.is_empty()); + // A labelled checkbox's authored width is the whole interactive control, + // not the box alone. Keep legacy label-less documents unchanged, while a + // labelled control gets a square box and an in-bounds label region. + let box_rect = if label.is_some() { + let side = ww.min(h); + Rect::xywh(x, y + (h - side) / 2.0, side, side) + } else { + r + }; + let (box_x, box_y, box_w, box_h) = rect_parts(box_rect); + let radius = authored_radius_or(node, w, 2.0 * zoom, zoom); 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); + cx.backend + .fill_round_rect(box_rect, radius, ui_color(visual.active)); } + cx.backend + .stroke_round_rect(box_rect, radius, ui_color(visual.inactive), 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 p0 = Point2D::new(box_x + box_w * 0.24, box_y + box_h * 0.52); + let p1 = Point2D::new(box_x + box_w * 0.42, box_y + box_h * 0.70); + let p2 = Point2D::new(box_x + box_w * 0.76, box_y + box_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); + let check = ui_color(visual.active_foreground); + cx.backend.stroke_line(p0, p1, check, cw); + cx.backend.stroke_line(p1, p2, check, cw); + } + if let Some(label) = label { + let fs = 14.0 * zoom; + let label_x = box_x + box_w + 8.0 * zoom; + let label_width = (x + ww - label_x).max(0.0); + if label_width > 0.0 { + cx.backend.save(); + cx.backend.clip_rect(Rect::xywh(label_x, y, label_width, h)); + draw_label( + cx, + label, + ui_color(visual.label_foreground), + label_x, + y + (h - fs) / 2.0, + fs, + ); + cx.backend.restore(); + } } } -/// 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) { +/// Slider: authored inactive track + active filled portion (value within +/// min..max) + a contrast-derived knob. +fn paint_slider( + cx: &mut PaintCx<'_>, + node: &SceneNode, + w: &SceneWidget, + visual: &AuthoredWidgetVisual, + 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 track_radius = authored_radius_or(node, w, track_h / 2.0, zoom).min(track_h / 2.0); 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, + track_radius, + ui_color(visual.inactive), ); 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, + track_radius, + ui_color(visual.active), ); } 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); + let knob_color = if frac > 0.0 { + visual.active_foreground + } else { + visual.inactive_foreground + }; cx.backend - .stroke_round_rect(knob, d / 2.0, TRACK_OFF, 1.0 * zoom); + .fill_round_rect(knob, d / 2.0, ui_color(knob_color)); + let knob_stroke_width = node.stroke.map(|stroke| stroke.width).unwrap_or(1.0) * zoom; + if knob_stroke_width > 0.0 { + cx.backend + .stroke_round_rect(knob, d / 2.0, ui_color(visual.inactive), knob_stroke_width); + } } -/// Progress: rounded grey track + accent filled portion (value / max). -fn paint_progress(cx: &mut PaintCx<'_>, w: &SceneWidget, r: Rect) { +/// Progress: authored inactive track + active filled portion (value / max). +fn paint_progress( + cx: &mut PaintCx<'_>, + node: &SceneNode, + w: &SceneWidget, + visual: &AuthoredWidgetVisual, + r: Rect, + zoom: f32, +) { let (x, y, ww, h) = rect_parts(r); let max = w.max.unwrap_or(100.0); let frac = if max > 0.0 { @@ -164,26 +261,44 @@ fn paint_progress(cx: &mut PaintCx<'_>, w: &SceneWidget, r: Rect) { } 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); + let radius = authored_radius_or(node, w, h / 2.0, zoom); + cx.backend + .fill_round_rect(r, radius, ui_color(visual.inactive)); + let (segment_x, segment_width) = if w.indeterminate { + (x + ww * 0.325, ww * 0.35) + } else { + (x, ww * frac) + }; + if segment_width > 0.0 { + cx.backend.fill_round_rect( + Rect::xywh(segment_x, y, segment_width, h), + radius, + ui_color(visual.active), + ); } } /// 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) { +fn paint_select( + cx: &mut PaintCx<'_>, + node: &SceneNode, + w: &SceneWidget, + visual: &AuthoredWidgetVisual, + 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 radius = authored_radius_or(node, w, 6.0 * zoom, zoom); + if let Some(fill) = visual.surface { + cx.backend.fill_round_rect(r, radius, ui_color(fill)); + } + if let (Some(border), Some(stroke)) = (visual.border, node.stroke) { + if stroke.width > 0.0 { + cx.backend + .stroke_round_rect(r, radius, ui_color(border), stroke.width * zoom); + } } - 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 @@ -192,23 +307,43 @@ fn paint_select(cx: &mut PaintCx<'_>, node: &SceneNode, w: &SceneWidget, r: Rect .and_then(|v| option_label(w, v)) .filter(|s| !s.is_empty()); let label = match selected { - Some(text) => Some((text, TEXT_VALUE)), + Some(text) => Some((text, visual.foreground)), None => w .placeholder .as_deref() .filter(|s| !s.is_empty()) - .map(|t| (t, TEXT_MUTED)), + .map(|text| (text, visual.muted_foreground)), }; 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); + draw_label( + cx, + text, + ui_color(color), + x + 8.0 * zoom, + y + (h - fs) / 2.0, + fs, + ); } - paint_chevron(cx, x + ww - 20.0 * zoom, y + h / 2.0, zoom); + paint_chevron( + cx, + x + ww - 20.0 * zoom, + y + h / 2.0, + ui_color(visual.muted_foreground), + 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) { +/// Radio group: per option an authored active circle with a contrast-derived +/// inner dot when selected, authored inactive outline when not. +fn paint_radio_group( + cx: &mut PaintCx<'_>, + node: &SceneNode, + w: &SceneWidget, + visual: &AuthoredWidgetVisual, + r: Rect, + zoom: f32, +) { if w.options.is_empty() { return; } @@ -218,14 +353,17 @@ fn paint_radio_group(cx: &mut PaintCx<'_>, w: &SceneWidget, r: Rect, zoom: f32) let row_h = (h / n as f32).clamp(0.0, 28.0 * zoom); let d = 14.0 * zoom; let fs = 14.0 * zoom; + let stroke_width = node.stroke.map(|stroke| stroke.width).unwrap_or(1.5) * 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); + if on { + cx.backend + .fill_round_rect(circle, d / 2.0, ui_color(visual.active)); + } 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); + .stroke_round_rect(circle, d / 2.0, ui_color(visual.inactive), stroke_width); if on { let inner = d * 0.4; cx.backend.fill_round_rect( @@ -236,7 +374,7 @@ fn paint_radio_group(cx: &mut PaintCx<'_>, w: &SceneWidget, r: Rect, zoom: f32) inner, ), inner / 2.0, - KNOB, + ui_color(visual.active_foreground), ); } let label = if opt.label.is_empty() { @@ -246,28 +384,39 @@ fn paint_radio_group(cx: &mut PaintCx<'_>, w: &SceneWidget, r: Rect, zoom: f32) }; 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); + draw_label( + cx, + label, + ui_color(visual.label_foreground), + 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) { +/// Text input / textarea / number input: authored box + contrast-derived value +/// text or, when empty, the shared muted foreground. +fn paint_text_field( + cx: &mut PaintCx<'_>, + node: &SceneNode, + w: &SceneWidget, + visual: &AuthoredWidgetVisual, + r: Rect, + zoom: f32, +) { let (x, y, ww, h) = rect_parts(r); - // Respect the AUTHORED box style. A model embedding an input into its own - // styled wrapper zeroes everything out (`fill: []`, `stroke.thickness: 0`, - // `cornerRadius: 0`) — the old unconditional white fill + grey border + - // 6px-radius floor painted a glaring white pill on top of a dark themed - // wrapper (measured on a dark dashboard's search bar). No fill → paint no - // box; no stroke → draw no border; radius as authored. - let radius = node.corner_radius * zoom; - if let Some(fill) = node.fill { - cx.backend.fill_round_rect(r, radius, fill); + // Respect the authored box style. Explicit square `cornerRadius: 0` stays + // square, while older documents that omitted the field retain the 6px + // intrinsic input radius. No fill means no box; no stroke means no border. + let radius = authored_radius_or(node, w, 6.0 * zoom, zoom); + if let Some(fill) = visual.surface { + cx.backend.fill_round_rect(r, radius, ui_color(fill)); } - if let Some(stroke) = node.stroke { + if let (Some(border), Some(stroke)) = (visual.border, node.stroke) { if stroke.width > 0.0 { cx.backend - .stroke_round_rect(r, radius, stroke.color, stroke.width * zoom); + .stroke_round_rect(r, radius, ui_color(border), stroke.width * zoom); } } @@ -282,7 +431,7 @@ fn paint_text_field(cx: &mut PaintCx<'_>, node: &SceneNode, w: &SceneWidget, r: "", name, Rect::xywh(x + INPUT_PAD_X * zoom, iy, icon, icon), - Some(TEXT_MUTED), + Some(ui_color(visual.muted_foreground)), ); } if let Some(name) = w.trailing_icon.as_deref() { @@ -296,24 +445,16 @@ fn paint_text_field(cx: &mut PaintCx<'_>, node: &SceneNode, w: &SceneWidget, r: icon, icon, ), - Some(TEXT_MUTED), + Some(ui_color(visual.muted_foreground)), ); } - if let Some((text, color)) = text_field_display_text(w) { - // The default value color (#111) assumed the old white box. On an - // authored DARK fill — or no box at all (transparent, blending into a - // dark wrapper) — flip it light; the muted placeholder grey reads on - // both. Only the VALUE color adapts. - let color = if color == TEXT_VALUE { - match node.fill { - Some(bg) if color_luminance(bg) >= 0.5 => TEXT_VALUE, - Some(_) => Color::rgb_u8(0xF5, 0xF5, 0xF5), - None => Color::rgb_u8(0x9C, 0xA3, 0xAF), - } + if let Some((text, is_placeholder)) = text_field_display_text(w) { + let color = ui_color(if is_placeholder { + visual.muted_foreground } else { - color - }; + visual.foreground + }); let fs = 14.0 * zoom; // text_area top-aligns; single-line inputs vertically centre. let ty = if w.kind == "text_area" { @@ -332,68 +473,88 @@ fn paint_text_field(cx: &mut PaintCx<'_>, node: &SceneNode, w: &SceneWidget, r: } } -/// 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); +/// Tabs: the same authored segmented control used by Jian preview/runtime. +/// 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, + visual: &AuthoredWidgetVisual, + 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 bar_h = h.min(32.0 * zoom); + let bar = Rect::xywh(x, y, ww, bar_h); + let bar_radius = authored_radius_or(node, w, 6.0 * zoom, zoom); + cx.backend + .fill_round_rect(bar, bar_radius, ui_color(visual.inactive)); + if let Some(stroke) = node.stroke { + cx.backend.stroke_round_rect( + bar, + bar_radius, + ui_color(visual.border.unwrap_or(visual.inactive)), + stroke.width * zoom, + ); + } + let active = super::canvas_viewport_paint::tabs_active_index(w); let n = w.options.len().max(1); let tab_w = ww / n as f32; + let inset = (2.0 * zoom).min(bar_h / 4.0); + let active_h = (bar_h - inset * 2.0).max(0.0); + let active_w = (tab_w - inset * 2.0).max(0.0); + if active_w > 0.0 && active_h > 0.0 { + cx.backend.fill_round_rect( + Rect::xywh( + x + active as f32 * tab_w + inset, + y + inset, + active_w, + active_h, + ), + active_h.min(active_w) / 2.0, + ui_color(visual.active), + ); + } 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 on = i == active; 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 }; + let color = if on { + ui_color(visual.active_foreground) + } else { + ui_color(visual.muted_label_foreground) + }; + let label_w = cx.backend.measure_text_weighted(label, fs, 400); draw_label( cx, label, color, - tx + 8.0 * zoom, + tx + (tab_w - label_w).max(0.0) / 2.0, 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) { +fn paint_chevron(cx: &mut PaintCx<'_>, cx_px: f32, cy_px: f32, color: Color, 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); + cx.backend.stroke_line(p0, p1, color, width); + cx.backend.stroke_line(p1, p2, color, width); } /// Draw a single-run, left-aligned label at `(x, top_y)` in world @@ -420,6 +581,22 @@ fn rect_parts(r: Rect) -> (f32, f32, f32, f32) { (r.origin.x, r.origin.y, r.size.x, r.size.y) } +/// Old widget documents deserialize an absent `cornerRadius` as zero. Preserve +/// each control's recognizable intrinsic geometry in that case, while any +/// authored radius — including square zero — wins exactly. +fn authored_radius_or( + node: &SceneNode, + widget: &SceneWidget, + fallback_world: f32, + zoom: f32, +) -> f32 { + if widget.corner_radius_authored { + node.corner_radius.max(0.0) * zoom + } else { + fallback_world + } +} + /// 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 { @@ -442,7 +619,7 @@ pub(crate) fn option_label<'a>(w: &'a SceneWidget, value: &str) -> Option<&'a st }) } -pub(crate) fn text_field_display_text(w: &SceneWidget) -> Option<(Cow<'_, str>, Color)> { +pub(crate) fn text_field_display_text(w: &SceneWidget) -> Option<(Cow<'_, str>, bool)> { let value = match w.value_str.as_deref() { Some(text) => (!text.is_empty()).then_some(Cow::Borrowed(text)), None if w.kind == "number_input" => w @@ -453,12 +630,12 @@ pub(crate) fn text_field_display_text(w: &SceneWidget) -> Option<(Cow<'_, str>, None => None, }; match value { - Some(text) => Some((text, TEXT_VALUE)), + Some(text) => Some((text, false)), None => w .placeholder .as_deref() .filter(|text| !text.is_empty()) - .map(|text| (Cow::Borrowed(text), TEXT_MUTED)), + .map(|text| (Cow::Borrowed(text), true)), } } 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 index 24437ef0d..1a3edf21c 100644 --- a/crates/op-editor-ui/src/widgets/canvas_viewport_widget_tests.rs +++ b/crates/op-editor-ui/src/widgets/canvas_viewport_widget_tests.rs @@ -2,7 +2,9 @@ //! 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::layout_scene::{ + NodeKind, SceneNode, SceneStroke, SceneStrokeAlign, SceneWidget, SceneWidgetOption, +}; use crate::widgets::canvas_viewport_widget::{ option_label, paint_widget_visual, text_field_display_text, widget_text_inset_left, }; @@ -15,9 +17,14 @@ use std::borrow::Cow; #[derive(Default)] struct WidgetRecorder { round_rects: Vec<(Rect, Color)>, + round_radii: Vec, stroke_round_rects: Vec<(Rect, Color)>, + stroke_round_radii: Vec, + stroke_round_widths: Vec, lines: Vec<(Point2D, Point2D, Color)>, texts: Vec<(String, Point2D)>, + text_colors: Vec<(String, jian_core::scene::Color)>, + clips: Vec, } impl RenderBackend for WidgetRecorder { @@ -28,9 +35,12 @@ impl RenderBackend for WidgetRecorder { fn draw_text(&mut self, layout: &TextLayout, origin: Point2D) { if let Some(run) = layout.runs().first() { self.texts.push((run.content.clone(), origin)); + self.text_colors.push((run.content.clone(), run.color)); } } - fn clip_rect(&mut self, _: Rect) {} + fn clip_rect(&mut self, rect: Rect) { + self.clips.push(rect); + } fn save(&mut self) {} fn restore(&mut self) {} fn translate(&mut self, _: Point2D) {} @@ -38,11 +48,14 @@ impl RenderBackend for WidgetRecorder { 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) { + fn fill_round_rect(&mut self, rect: Rect, radius: f32, color: Color) { self.round_rects.push((rect, color)); + self.round_radii.push(radius); } - fn stroke_round_rect(&mut self, rect: Rect, _: f32, color: Color, _: f32) { + fn stroke_round_rect(&mut self, rect: Rect, radius: f32, color: Color, width: f32) { self.stroke_round_rects.push((rect, color)); + self.stroke_round_radii.push(radius); + self.stroke_round_widths.push(width); } fn stroke_svg_path(&mut self, _: &str, _: Point2D, _: f32, _: Color, _: f32) {} fn draw_image(&mut self, _: Rect, _: u64, _: &[u8]) {} @@ -56,9 +69,9 @@ impl RenderBackend for WidgetRecorder { } } -const ACCENT: Color = Color::rgb_u8(0x3b, 0x82, 0xf6); -const TRACK_OFF: Color = Color::rgb_u8(0xd1, 0xd5, 0xdb); const WHITE: Color = Color::WHITE; +const DARK_PURPLE: Color = Color::rgb_u8(0x18, 0x0b, 0x2a); +const PURPLE_BORDER: Color = Color::rgb_u8(0x72, 0x4a, 0xa0); fn paint(node: &SceneNode, rect: Rect) -> WidgetRecorder { let mut backend = WidgetRecorder::default(); @@ -77,6 +90,20 @@ fn widget_node(kind: NodeKind, w: SceneWidget, rect: Rect) -> SceneNode { n } +fn authored_widget_node(kind: NodeKind, mut w: SceneWidget, rect: Rect, radius: f32) -> SceneNode { + w.corner_radius_authored = true; + let mut node = widget_node(kind, w, rect); + node.fill = Some(DARK_PURPLE); + node.stroke = Some(SceneStroke { + color: PURPLE_BORDER, + width: 2.0, + sides: None, + align: SceneStrokeAlign::Center, + }); + node.corner_radius = radius; + node +} + #[test] fn unknown_or_absent_widget_returns_false() { let mut backend = WidgetRecorder::default(); @@ -94,9 +121,9 @@ fn unknown_or_absent_widget_returns_false() { } #[test] -fn switch_on_paints_accent_track_and_right_knob() { +fn switch_on_paints_authored_track_and_right_knob() { let rect = Rect::xywh(0.0, 0.0, 40.0, 20.0); - let node = widget_node( + let node = authored_widget_node( NodeKind::Rect, SceneWidget { kind: "switch".into(), @@ -104,12 +131,14 @@ fn switch_on_paints_accent_track_and_right_knob() { ..Default::default() }, rect, + 7.0, ); let b = paint(&node, rect); - // Track (accent) + knob (white) = 2 filled round rects. + // Track + contrast-derived knob = 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"); + assert_eq!(b.round_rects[0].1, DARK_PURPLE, "authored on-track"); + assert_eq!(b.round_rects[1].1, WHITE, "dark track gets white knob"); + assert_eq!(b.round_radii[0], 7.0, "authored switch radius"); // Knob slid to the right half. let knob = b.round_rects[1].0; assert!( @@ -120,9 +149,9 @@ fn switch_on_paints_accent_track_and_right_knob() { } #[test] -fn switch_off_paints_grey_track_and_left_knob() { +fn switch_off_paints_authored_inactive_track_and_left_knob() { let rect = Rect::xywh(0.0, 0.0, 40.0, 20.0); - let node = widget_node( + let node = authored_widget_node( NodeKind::Rect, SceneWidget { kind: "switch".into(), @@ -130,9 +159,13 @@ fn switch_off_paints_grey_track_and_left_knob() { ..Default::default() }, rect, + 7.0, ); let b = paint(&node, rect); - assert_eq!(b.round_rects[0].1, TRACK_OFF, "off-track is grey"); + assert_eq!( + b.round_rects[0].1, PURPLE_BORDER, + "authored stroke is the inactive track" + ); let knob = b.round_rects[1].0; assert!( knob.origin.x < rect.size.x / 2.0, @@ -140,10 +173,92 @@ fn switch_off_paints_grey_track_and_left_knob() { ); } +#[test] +fn zero_radius_legacy_widgets_keep_intrinsic_rounding() { + let switch_rect = Rect::xywh(0.0, 0.0, 40.0, 20.0); + let switch = widget_node( + NodeKind::Rect, + SceneWidget { + kind: "switch".into(), + ..Default::default() + }, + switch_rect, + ); + assert_eq!(paint(&switch, switch_rect).round_radii[0], 10.0); + + let slider_rect = Rect::xywh(0.0, 0.0, 100.0, 16.0); + let slider = widget_node( + NodeKind::Rect, + SceneWidget { + kind: "slider".into(), + ..Default::default() + }, + slider_rect, + ); + assert_eq!(paint(&slider, slider_rect).round_radii[0], 2.0); + + let progress_rect = Rect::xywh(0.0, 0.0, 100.0, 8.0); + let progress = widget_node( + NodeKind::Rect, + SceneWidget { + kind: "progress".into(), + ..Default::default() + }, + progress_rect, + ); + assert_eq!(paint(&progress, progress_rect).round_radii[0], 4.0); + + let checkbox_rect = Rect::xywh(0.0, 0.0, 18.0, 18.0); + let checkbox = widget_node( + NodeKind::Rect, + SceneWidget { + kind: "checkbox".into(), + ..Default::default() + }, + checkbox_rect, + ); + assert_eq!(paint(&checkbox, checkbox_rect).stroke_round_radii[0], 2.0); +} + +#[test] +fn corner_radius_distinguishes_absent_explicit_zero_and_positive() { + let rect = Rect::xywh(0.0, 0.0, 40.0, 20.0); + let absent = widget_node( + NodeKind::Rect, + SceneWidget { + kind: "switch".into(), + ..Default::default() + }, + rect, + ); + let explicit_zero = authored_widget_node( + NodeKind::Rect, + SceneWidget { + kind: "switch".into(), + ..Default::default() + }, + rect, + 0.0, + ); + let positive = authored_widget_node( + NodeKind::Rect, + SceneWidget { + kind: "switch".into(), + ..Default::default() + }, + rect, + 7.0, + ); + + assert_eq!(paint(&absent, rect).round_radii[0], 10.0); + assert_eq!(paint(&explicit_zero, rect).round_radii[0], 0.0); + assert_eq!(paint(&positive, rect).round_radii[0], 7.0); +} + #[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( + let node = authored_widget_node( NodeKind::Rect, SceneWidget { kind: "checkbox".into(), @@ -151,22 +266,87 @@ fn checkbox_checked_paints_accent_box_and_check_polyline() { ..Default::default() }, rect, + 5.0, ); let b = paint(&node, rect); // Accent-filled box. - assert_eq!(b.round_rects[0].1, ACCENT, "checked box fills accent"); + assert_eq!( + b.round_rects[0].1, DARK_PURPLE, + "checked box uses authored fill" + ); // Two line segments form the white check (✓). assert_eq!(b.lines.len(), 2, "check polyline is 2 segments"); + assert_eq!( + b.stroke_round_rects[0].1, PURPLE_BORDER, + "checked box retains authored outline" + ); assert!( b.lines.iter().all(|(_, _, c)| *c == WHITE), "check is white" ); } +#[test] +fn checkbox_180x24_label_uses_in_bounds_control_geometry_and_label_role() { + let rect = Rect::xywh(10.0, 20.0, 180.0, 24.0); + let mut node = authored_widget_node( + NodeKind::Rect, + SceneWidget { + kind: "checkbox".into(), + checked: Some(true), + label: Some("Agree".into()), + ..Default::default() + }, + rect, + 4.0, + ); + // Dark fill would produce white field foreground, while a light authored + // stroke produces black external-label foreground. This catches accidental + // reuse of the fill/surface contrast role for adjacent labels. + node.stroke.as_mut().unwrap().color = Color::rgb_u8(0xf4, 0xf4, 0xf5); + + let b = paint(&node, rect); + let (label_color, origin) = b + .text_colors + .iter() + .find(|(text, _)| text == "Agree") + .map(|(_, color)| *color) + .zip( + b.texts + .iter() + .find(|(text, _)| text == "Agree") + .map(|(_, origin)| *origin), + ) + .expect("checkbox label paint"); + assert_eq!(label_color, jian_core::scene::Color::rgb(0x00, 0x00, 0x00)); + assert_eq!( + b.round_rects[0].0, + Rect::xywh(10.0, 20.0, 24.0, 24.0), + "labelled checkbox paints a square box inside the whole control" + ); + assert_eq!( + b.stroke_round_rects[0].0, + Rect::xywh(10.0, 20.0, 24.0, 24.0) + ); + assert_eq!(origin.x, 42.0, "label starts box-right + 8px"); + assert_eq!( + b.clips, + vec![Rect::xywh(42.0, 20.0, 148.0, 24.0)], + "label paint is clipped to the remaining authored control width" + ); + assert!( + b.lines + .iter() + .flat_map(|(from, to, _)| [from, to]) + .all(|point| rect.contains(*point)), + "check geometry stays within the full hit bounds" + ); +} + #[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( + let node = authored_widget_node( NodeKind::Rect, SceneWidget { kind: "checkbox".into(), @@ -174,19 +354,20 @@ fn checkbox_unchecked_paints_outlined_box_no_check() { ..Default::default() }, rect, + 5.0, ); 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" + b.stroke_round_rects[0].1, PURPLE_BORDER, + "unchecked box uses authored stroke" ); } #[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( + let node = authored_widget_node( NodeKind::Rect, SceneWidget { kind: "slider".into(), @@ -196,12 +377,13 @@ fn slider_paints_track_filled_portion_and_knob() { ..Default::default() }, rect, + 10.0, ); let b = paint(&node, rect); - // track (grey) + filled (accent) + knob (white) = 3 round rects. + // authored inactive track + active fill + contrast knob. 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"); + assert_eq!(b.round_rects[0].1, PURPLE_BORDER, "authored track"); + assert_eq!(b.round_rects[1].1, DARK_PURPLE, "authored fill"); // 50% fill spans half the width. let fill = b.round_rects[1].0; assert!( @@ -215,7 +397,7 @@ fn slider_paints_track_filled_portion_and_knob() { #[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( + let node = authored_widget_node( NodeKind::Rect, SceneWidget { kind: "slider".into(), @@ -225,19 +407,20 @@ fn slider_with_zero_value_has_no_accent_fill() { ..Default::default() }, rect, + 10.0, ); 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" + b.round_rects.iter().all(|(_, c)| *c != DARK_PURPLE), + "no authored active 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( + let node = authored_widget_node( NodeKind::Rect, SceneWidget { kind: "progress".into(), @@ -246,11 +429,12 @@ fn progress_paints_track_and_filled_portion() { ..Default::default() }, rect, + 4.0, ); let b = paint(&node, rect); - assert_eq!(b.round_rects[0].1, TRACK_OFF, "progress track grey"); + assert_eq!(b.round_rects[0].1, PURPLE_BORDER, "authored progress track"); let fill = &b.round_rects[1]; - assert_eq!(fill.1, ACCENT, "progress fill accent"); + assert_eq!(fill.1, DARK_PURPLE, "authored progress fill"); assert!( (fill.0.size.x - 50.0).abs() < 0.5, "25/100 of 200px ~= 50px, got {}", @@ -261,7 +445,7 @@ fn progress_paints_track_and_filled_portion() { #[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( + let node = authored_widget_node( NodeKind::Text, SceneWidget { kind: "select".into(), @@ -279,6 +463,7 @@ fn select_paints_box_value_text_and_chevron() { ..Default::default() }, rect, + 11.0, ); let b = paint(&node, rect); // Selected option label is painted. @@ -289,8 +474,16 @@ fn select_paints_box_value_text_and_chevron() { ); // 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"); + assert_eq!(b.round_rects[0].1, DARK_PURPLE, "authored select surface"); + assert_eq!( + b.stroke_round_rects[0].1, PURPLE_BORDER, + "authored select border" + ); + assert_eq!(b.round_radii[0], 11.0, "authored select radius"); + assert_eq!( + b.stroke_round_radii[0], 11.0, + "fill and border share authored radius" + ); } #[test] @@ -310,6 +503,61 @@ fn select_empty_paints_placeholder() { b.texts.iter().any(|(t, _)| t == "Choose…"), "placeholder painted" ); + assert!( + b.round_rects.is_empty(), + "unstyled select must not fabricate a white surface" + ); + assert!( + b.stroke_round_rects.is_empty(), + "unstyled select must not fabricate a grey border" + ); +} + +#[test] +fn select_placeholder_is_muted_but_value_uses_authored_contrast_foreground() { + let rect = Rect::xywh(0.0, 0.0, 160.0, 36.0); + let placeholder = authored_widget_node( + NodeKind::Text, + SceneWidget { + kind: "select".into(), + placeholder: Some("Choose…".into()), + ..Default::default() + }, + rect, + 8.0, + ); + let value = authored_widget_node( + NodeKind::Text, + SceneWidget { + kind: "select".into(), + value_str: Some("night".into()), + options: vec![SceneWidgetOption { + value: "night".into(), + label: "Night mode".into(), + }], + ..Default::default() + }, + rect, + 8.0, + ); + + let placeholder_paint = paint(&placeholder, rect); + let value_paint = paint(&value, rect); + let placeholder_color = placeholder_paint + .text_colors + .iter() + .find(|(text, _)| text == "Choose…") + .map(|(_, color)| *color) + .expect("placeholder color"); + let value_color = value_paint + .text_colors + .iter() + .find(|(text, _)| text == "Night mode") + .map(|(_, color)| *color) + .expect("value color"); + + assert_ne!(placeholder_color, value_color, "placeholder stays muted"); + assert!(placeholder_color.a() < value_color.a()); } #[test] @@ -370,7 +618,7 @@ fn text_field_display_text_borrows_value_and_placeholder() { #[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( + let node = authored_widget_node( NodeKind::Rect, SceneWidget { kind: "radio_group".into(), @@ -388,15 +636,16 @@ fn radio_group_paints_circle_and_dot_for_selected() { ..Default::default() }, rect, + 7.0, ); 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"); + // Selected circle + contrast inner dot; unselected is border-only. + assert_eq!(b.round_rects.len(), 2, "selected circle + inner dot"); assert!( - b.round_rects.iter().any(|(_, c)| *c == ACCENT), - "selected circle is accent" + b.round_rects.iter().any(|(_, c)| *c == DARK_PURPLE), + "selected circle uses authored fill" ); + assert_eq!(b.stroke_round_widths[0], 2.0, "authored radio stroke width"); // Both labels painted. assert!(b.texts.iter().any(|(t, _)| t == "Apple")); assert!(b.texts.iter().any(|(t, _)| t == "Banana")); @@ -496,13 +745,13 @@ fn number_input_renders_numeric_value() { } #[test] -fn tabs_paints_bar_labels_with_active_underline() { +fn tabs_matches_segmented_preview_and_stale_value_falls_back_first() { let rect = Rect::xywh(0.0, 0.0, 240.0, 120.0); - let node = widget_node( + let node = authored_widget_node( NodeKind::Frame, SceneWidget { kind: "tabs".into(), - value_str: Some("one".into()), + value_str: Some("stale".into()), options: vec![ SceneWidgetOption { value: "one".into(), @@ -516,13 +765,36 @@ fn tabs_paints_bar_labels_with_active_underline() { ..Default::default() }, rect, + 0.0, ); 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. + // Authored inactive bar + active authored segment. + assert_eq!(b.round_rects.len(), 2); + assert_eq!(b.round_rects[0].1, PURPLE_BORDER); assert!( - b.lines.iter().any(|(_, _, c)| *c == ACCENT), - "active tab has an accent underline" + b.round_rects.iter().any(|(_, c)| *c == DARK_PURPLE), + "active tab uses authored fill" ); + let active = b + .text_colors + .iter() + .find(|(text, _)| text == "One") + .map(|(_, color)| *color) + .expect("active tab color"); + let inactive = b + .text_colors + .iter() + .find(|(text, _)| text == "Two") + .map(|(_, color)| *color) + .expect("inactive tab color"); + assert_eq!( + (inactive.r(), inactive.g(), inactive.b()), + (active.r(), active.g(), active.b()) + ); + assert!(inactive.a() < active.a(), "inactive tab label is muted"); } + +#[path = "canvas_viewport_widget_tests/contract_closure.rs"] +mod contract_closure; diff --git a/crates/op-editor-ui/src/widgets/canvas_viewport_widget_tests/contract_closure.rs b/crates/op-editor-ui/src/widgets/canvas_viewport_widget_tests/contract_closure.rs new file mode 100644 index 000000000..43e928ba3 --- /dev/null +++ b/crates/op-editor-ui/src/widgets/canvas_viewport_widget_tests/contract_closure.rs @@ -0,0 +1,213 @@ +//! Cross-role contract closure for the design-canvas widget painter. + +use super::*; + +fn fold_direct_scene_opacity(mut node: SceneNode, opacity: f32) -> SceneNode { + node.opacity = opacity; + if let Some(fill) = node.fill.as_mut() { + fill.a *= opacity; + } + if let Some(stroke) = node.stroke.as_mut() { + stroke.color.a *= opacity; + } + node +} + +fn text_alpha(recorder: &WidgetRecorder, text: &str) -> u8 { + recorder + .text_colors + .iter() + .find(|(content, _)| content == text) + .map(|(_, color)| color.a()) + .unwrap_or_else(|| panic!("missing text paint for {text}")) +} + +#[test] +fn indeterminate_progress_ignores_value_and_paints_stable_segment() { + let rect = Rect::xywh(10.0, 20.0, 200.0, 8.0); + let node = authored_widget_node( + NodeKind::Rect, + SceneWidget { + kind: "progress".into(), + value_num: Some(99.0), + max: Some(100.0), + indeterminate: true, + ..Default::default() + }, + rect, + 4.0, + ); + + let recorder = paint(&node, rect); + + assert_eq!(recorder.round_rects.len(), 2, "track plus one segment"); + assert_eq!(recorder.round_rects[0].0, rect, "full inactive track"); + assert_eq!( + recorder.round_rects[1].0, + Rect::xywh(75.0, 20.0, 70.0, 8.0), + "segment is x=32.5%, width=35% regardless of value" + ); +} + +#[test] +fn unstyled_switch_fallback_tracks_follow_scene_opacity() { + let rect = Rect::xywh(0.0, 0.0, 40.0, 20.0); + for (opacity, expected_alpha) in [(0.0, 0), (0.5, 128)] { + for checked in [false, true] { + let mut node = widget_node( + NodeKind::Rect, + SceneWidget { + kind: "switch".into(), + checked: Some(checked), + ..Default::default() + }, + rect, + ); + node.opacity = opacity; + let recorder = paint(&node, rect); + assert!( + recorder + .round_rects + .iter() + .all(|(_, color)| color.to_jian().a() == expected_alpha), + "legacy track and derived knob must both follow opacity={opacity}" + ); + } + } +} + +#[test] +fn styled_text_field_distinguishes_absent_and_explicit_zero_radius() { + let rect = Rect::xywh(0.0, 0.0, 160.0, 36.0); + let widget = SceneWidget { + kind: "text_input".into(), + value_str: Some("hello".into()), + ..Default::default() + }; + let mut absent = widget_node(NodeKind::Text, widget.clone(), rect); + absent.fill = Some(DARK_PURPLE); + absent.stroke = Some(SceneStroke { + color: PURPLE_BORDER, + width: 2.0, + sides: None, + align: SceneStrokeAlign::Center, + }); + let explicit_zero = authored_widget_node(NodeKind::Text, widget, rect, 0.0); + + let absent = paint(&absent, rect); + let explicit_zero = paint(&explicit_zero, rect); + assert_eq!( + (absent.round_radii[0], absent.stroke_round_radii[0]), + (6.0, 6.0) + ); + assert_eq!( + ( + explicit_zero.round_radii[0], + explicit_zero.stroke_round_radii[0] + ), + (0.0, 0.0) + ); +} + +#[test] +fn every_derived_widget_role_applies_scene_opacity_once() { + for (opacity, full_alpha, muted_alpha) in [(0.0, 0, 0), (0.5, 128, 83)] { + let checkbox_rect = Rect::xywh(0.0, 0.0, 180.0, 24.0); + let mut checkbox = authored_widget_node( + NodeKind::Rect, + SceneWidget { + kind: "checkbox".into(), + checked: Some(true), + label: Some("Agree".into()), + ..Default::default() + }, + checkbox_rect, + 4.0, + ); + checkbox.stroke.as_mut().unwrap().color = Color::rgb_u8(0xf4, 0xf4, 0xf5); + let checkbox = paint(&fold_direct_scene_opacity(checkbox, opacity), checkbox_rect); + assert_eq!(checkbox.round_rects[0].1.to_jian().a(), full_alpha); + assert_eq!(checkbox.stroke_round_rects[0].1.to_jian().a(), full_alpha); + assert!(checkbox + .lines + .iter() + .all(|(_, _, color)| color.to_jian().a() == full_alpha)); + assert_eq!(text_alpha(&checkbox, "Agree"), full_alpha); + + let switch_rect = Rect::xywh(0.0, 0.0, 40.0, 20.0); + let switch = authored_widget_node( + NodeKind::Rect, + SceneWidget { + kind: "switch".into(), + checked: Some(false), + ..Default::default() + }, + switch_rect, + 10.0, + ); + let switch = paint(&fold_direct_scene_opacity(switch, opacity), switch_rect); + assert_eq!( + switch.round_rects[1].1.to_jian().a(), + full_alpha, + "inactive-derived knob" + ); + + let select_rect = Rect::xywh(0.0, 0.0, 160.0, 36.0); + let select = authored_widget_node( + NodeKind::Text, + SceneWidget { + kind: "select".into(), + value_str: Some("night".into()), + options: vec![SceneWidgetOption { + value: "night".into(), + label: "Night".into(), + }], + ..Default::default() + }, + select_rect, + 6.0, + ); + let select = paint(&fold_direct_scene_opacity(select, opacity), select_rect); + assert_eq!(text_alpha(&select, "Night"), full_alpha, "field foreground"); + assert!( + select + .lines + .iter() + .all(|(_, _, color)| color.to_jian().a() == muted_alpha), + "select chevron uses muted field foreground" + ); + + let tabs_rect = Rect::xywh(0.0, 0.0, 240.0, 120.0); + let tabs = authored_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() + }, + tabs_rect, + 0.0, + ); + let tabs = paint(&fold_direct_scene_opacity(tabs, opacity), tabs_rect); + assert_eq!( + text_alpha(&tabs, "One"), + full_alpha, + "active external label" + ); + assert_eq!( + text_alpha(&tabs, "Two"), + muted_alpha, + "muted external label" + ); + } +} diff --git a/crates/op-host-native/src/preview/input.rs b/crates/op-host-native/src/preview/input.rs index 70b5c6165..336c1db21 100644 --- a/crates/op-host-native/src/preview/input.rs +++ b/crates/op-host-native/src/preview/input.rs @@ -24,10 +24,11 @@ //! outright while `transition_active()` — see that method's doc //! (`crate::preview::transition`) for why discard, not queue. -use super::PreviewSession; +use super::{apply_widget_state, PreviewSession}; use jian_core::gesture::pointer::{Modifiers, PointerPhase}; use op_editor_ui::layout_scene::SceneNode; +use op_editor_ui::widgets::canvas_viewport_paint::tabs_active_index; use op_editor_ui::{Point2D, Rect}; impl PreviewSession { @@ -219,7 +220,7 @@ impl PreviewSession { { return None; } - for child in node.children.iter().rev() { + for child in self.mapped_children(node).iter().rev() { if let Some(hit) = self.deepest_mapped_in(child, x, y) { return Some(hit); } @@ -227,6 +228,23 @@ impl PreviewSession { self.runtime_rect(&node.id).map(|r| (b, r)) } + /// Match the design/preview painter's tabs rule when choosing a scene + /// mapping anchor. Runtime state overlays the authored active value first, + /// so switching tabs cannot leave an invisible panel hittable here. + fn mapped_children<'a>(&self, node: &'a SceneNode) -> &'a [SceneNode] { + let Some(authored) = node.widget.as_ref().filter(|widget| widget.kind == "tabs") else { + return &node.children; + }; + let mut effective = authored.clone(); + if let Some(state) = self.runtime.widget_states.get(&node.id) { + apply_widget_state(&mut effective, state); + } + node.children + .get(tabs_active_index(&effective)) + .map(std::slice::from_ref) + .unwrap_or_default() + } + /// The runtime layout rect for the node with schema `id`, in the /// runtime's hit-test space, or `None` when the id has no live /// runtime node (e.g. a child a promotion dropped from the tree). @@ -348,6 +366,19 @@ impl PreviewSession { self.gesture_mapping = Some((scene, runtime)); } + /// Test-only: ids the scene→runtime mapper can descend into for a + /// container after applying live widget state. + #[cfg(all(test, not(target_os = "windows")))] + pub(crate) fn mapped_child_ids_for_test(&self, id: &str) -> Vec { + let Some(node) = self.scene.active_page().and_then(|page| page.find(id)) else { + return Vec::new(); + }; + self.mapped_children(node) + .iter() + .map(|child| child.id.clone()) + .collect() + } + /// Test-only: focus a node by schema `id` directly (skips the /// Tab-ring walk `focus_next`/`focus_previous` use), then seed its /// widget runtime state the same way those two do. Returns `true` diff --git a/crates/op-host-native/src/preview/mod.rs b/crates/op-host-native/src/preview/mod.rs index 8cfebd977..3afa660dc 100644 --- a/crates/op-host-native/src/preview/mod.rs +++ b/crates/op-host-native/src/preview/mod.rs @@ -77,10 +77,14 @@ mod tests_app_mode; #[cfg(all(test, not(target_os = "windows")))] mod tests_bindings; #[cfg(all(test, not(target_os = "windows")))] +mod tests_caret; +#[cfg(all(test, not(target_os = "windows")))] mod tests_device_frame; #[cfg(all(test, not(target_os = "windows")))] mod tests_geometry_parity; #[cfg(all(test, not(target_os = "windows")))] +mod tests_tabs; +#[cfg(all(test, not(target_os = "windows")))] mod tests_transition; use app_mode::AppMode; @@ -94,6 +98,7 @@ pub(crate) use mode_transition::{lerp_color, ModeTransition, ModeTransitionKind} pub(crate) use present::PinnedPaint; use jian_core::action::services::Router; +use jian_core::render::widget_style::{resolve_authored_widget_visual, with_visual_opacity}; use jian_core::widget_state::WidgetState; use jian_core::Runtime; use jian_ops_schema::compat::{load_str_with, LoadOptions}; @@ -180,6 +185,22 @@ pub struct PreviewSession { last_now_ms: u64, } +fn widget_field_foreground(node: &SceneNode) -> Color { + let visual = resolve_authored_widget_visual( + node.fill.map(Color::to_jian), + node.stroke.map(|stroke| stroke.color.to_jian()), + ); + // Scene fill/stroke already carry direct-paint opacity; the contrast-derived + // caret does not, so fold it exactly once through the shared widget policy. + let color = with_visual_opacity(visual.foreground, node.opacity); + Color::rgba_u8( + color.r(), + color.g(), + color.b(), + f32::from(color.a()) / 255.0, + ) +} + impl PreviewSession { /// Build a preview runtime from the document's JSON. `promote=true` /// turns legacy role-frames into first-class widget nodes in-memory @@ -619,14 +640,10 @@ impl PreviewSession { let advance = backend.measure_text_weighted(&text[..caret_byte], fs_world, 400); let caret_x = text_x + advance; - // Near-black caret (matches the field's value text colour) at a - // crisp ≥1px width regardless of zoom. - let color = Color { - r: 0.067, - g: 0.067, - b: 0.067, - a: 1.0, - }; + // Match the field's value foreground. The shared resolver derives a + // readable caret from the authored surface/stroke, so a dark input no + // longer receives the old hard-coded near-black caret. + let color = widget_field_foreground(node); backend.stroke_line( Point2D::new(caret_x, top_y), Point2D::new(caret_x, top_y + fs_world), diff --git a/crates/op-host-native/src/preview/scene_helpers.rs b/crates/op-host-native/src/preview/scene_helpers.rs index 107d3cdb2..41e70faf8 100644 --- a/crates/op-host-native/src/preview/scene_helpers.rs +++ b/crates/op-host-native/src/preview/scene_helpers.rs @@ -81,3 +81,27 @@ pub(super) fn format_warning(w: &LoadWarning) -> Option { LoadWarning::UnknownField { .. } => None, } } + +#[cfg(test)] +mod tests { + use super::apply_widget_state; + use jian_core::widget_state::WidgetState; + use op_editor_ui::layout_scene::SceneWidget; + + #[test] + fn tabs_runtime_state_overlays_the_scene_active_value() { + let mut widget = SceneWidget { + kind: "tabs".into(), + value_str: Some("overview".into()), + ..Default::default() + }; + apply_widget_state( + &mut widget, + &WidgetState::Tabs { + active: Some("details".into()), + hover_index: None, + }, + ); + assert_eq!(widget.value_str.as_deref(), Some("details")); + } +} diff --git a/crates/op-host-native/src/preview/tests_caret.rs b/crates/op-host-native/src/preview/tests_caret.rs new file mode 100644 index 000000000..3c6f2c258 --- /dev/null +++ b/crates/op-host-native/src/preview/tests_caret.rs @@ -0,0 +1,92 @@ +//! Focus-caret visual regressions kept separate from `tests.rs` so both files +//! remain below the repository's 800-line limit. + +#![cfg(test)] + +use super::PreviewSession; +use op_editor_ui::{Color, ImageDrawMode, Point2D, Rect, RenderBackend, TextLayout}; + +#[derive(Default)] +struct CaretRecorder { + lines: Vec, +} + +impl RenderBackend for CaretRecorder { + 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, _: &TextLayout, _: Point2D) {} + 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, _: Point2D, _: Point2D, color: Color, _: f32) { + self.lines.push(color); + } + fn fill_round_rect(&mut self, _: Rect, _: f32, _: Color) {} + fn stroke_round_rect(&mut self, _: Rect, _: f32, _: Color, _: f32) {} + 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, _: &str, _: f32, _: u16) -> f32 { + 0.0 + } +} + +fn dark_input_doc(opacity: f32) -> jian_ops_schema::PenDocument { + let source = format!( + 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, + "value": "hello", + "opacity": {opacity}, + "fill": [{{ "type": "solid", "color": "#180b2a" }}], + "stroke": {{ + "thickness": 1, + "fill": [{{ "type": "solid", "color": "#724aa0" }}] + }} + }}] + }}"## + ); + jian_ops_schema::load_str(&source) + .expect("parse dark input") + .value +} + +fn paint_caret(opacity: f32) -> Color { + let doc = dark_input_doc(opacity); + let mut session = + PreviewSession::enter(&doc, (400.0, 200.0), &Default::default(), 0, false, false) + .expect("enter preview"); + session.set_now_ms(0); + session.focus_next(); + let scene = session.preview_scene_for_test(); + let mut recorder = CaretRecorder::default(); + session.paint_focus_caret(&mut recorder, &scene, Point2D::ZERO, 1.0, 0); + assert_eq!(recorder.lines.len(), 1, "one focused caret line"); + recorder.lines[0] +} + +#[test] +fn dark_authored_input_gets_light_focused_caret() { + assert_eq!(paint_caret(1.0), Color::WHITE); +} + +#[test] +fn focused_caret_applies_scene_opacity_exactly_once() { + assert_eq!(paint_caret(0.5).to_jian().a(), 128); + assert_eq!(paint_caret(0.0).to_jian().a(), 0); +} diff --git a/crates/op-host-native/src/preview/tests_tabs.rs b/crates/op-host-native/src/preview/tests_tabs.rs new file mode 100644 index 000000000..1163b45e6 --- /dev/null +++ b/crates/op-host-native/src/preview/tests_tabs.rs @@ -0,0 +1,55 @@ +use super::PreviewSession; +use jian_core::gesture::pointer::Modifiers; +use op_editor_ui::widgets::canvas_viewport_paint::tabs_active_index; + +fn tabs_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":"tabs","id":"tabs","width":240,"height":180,"value":"overview", + "tabs":[ + {"value":"overview","label":"Overview"}, + {"value":"details","label":"Details"} + ], + "children":[ + {"type":"frame","id":"overview-panel","width":240,"height":148}, + {"type":"frame","id":"details-panel","width":240,"height":148} + ] + }] + }"##; + jian_ops_schema::load_str(src) + .expect("parse tabs doc") + .value +} + +#[test] +fn runtime_tab_switch_keeps_visual_and_hit_mapping_on_the_same_panel() { + let mut session = PreviewSession::enter( + &tabs_doc(), + (800.0, 600.0), + &std::collections::BTreeMap::new(), + 0, + false, + false, + ) + .expect("enter tabs preview"); + + assert_eq!( + session.mapped_child_ids_for_test("tabs"), + vec!["overview-panel".to_owned()] + ); + assert!(session.focus_node_for_test("tabs")); + // Widget-action keys update state without emitting an authored semantic + // event, so the host's bool may be false even though the tab switched. + let _ = session.dispatch_key("ArrowRight", Modifiers::default()); + + let scene = session.preview_scene_for_test(); + let tabs = scene.active_page().unwrap().find("tabs").unwrap(); + let active = tabs_active_index(tabs.widget.as_ref().unwrap()); + assert_eq!(tabs.children[active].id, "details-panel"); + assert_eq!( + session.mapped_child_ids_for_test("tabs"), + vec!["details-panel".to_owned()] + ); +}