fix(editor): render interactive widgets with authored design tokens
This commit is contained in:
parent
63198ac4f8
commit
cf49ab88c8
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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");
|
||||
}
|
||||
|
|
@ -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)),
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
@ -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`
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
|
|
|
|||
|
|
@ -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"));
|
||||
}
|
||||
}
|
||||
|
|
|
|||
92
crates/op-host-native/src/preview/tests_caret.rs
Normal file
92
crates/op-host-native/src/preview/tests_caret.rs
Normal 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);
|
||||
}
|
||||
55
crates/op-host-native/src/preview/tests_tabs.rs
Normal file
55
crates/op-host-native/src/preview/tests_tabs.rs
Normal 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()]
|
||||
);
|
||||
}
|
||||
Loading…
Reference in a new issue