fix(editor): render interactive widgets with authored design tokens

This commit is contained in:
Fini 2026-08-07 01:57:25 +08:00
parent 63198ac4f8
commit cf49ab88c8
10 changed files with 1151 additions and 201 deletions

View file

@ -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;

View file

@ -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");
}

View file

@ -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<f32>, 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)),
}
}

View file

@ -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<f32>,
stroke_round_rects: Vec<(Rect, Color)>,
stroke_round_radii: Vec<f32>,
stroke_round_widths: Vec<f32>,
lines: Vec<(Point2D, Point2D, Color)>,
texts: Vec<(String, Point2D)>,
text_colors: Vec<(String, jian_core::scene::Color)>,
clips: Vec<Rect>,
}
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;

View file

@ -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"
);
}
}

View file

@ -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<String> {
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`

View file

@ -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),

View file

@ -81,3 +81,27 @@ pub(super) fn format_warning(w: &LoadWarning) -> Option<String> {
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"));
}
}

View file

@ -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<Color>,
}
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);
}

View file

@ -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()]
);
}