use lieui_geom::{Color, Point, Rect, Size};
use lieui_text::{TextEngine, TextSpec};
use crate::cmd::CmdBuf;
use crate::event::{Event, EventKind, EventView, KeyCode, NamedKey, PointerButton, Routing};
use crate::render::scene::{Cull, Op, Scene, TextCache};
use crate::track::{
FocusState, Kind, KindTag, Node, NodeId, ScrollDrag, Track, clamp_to_char_boundary,
};
use crate::transform::Affine;
pub fn handle_route(track: &mut Track, path: &[NodeId], ev: &Event, cmd: &mut CmdBuf) {
match ev.kind().routing() {
Routing::Tunnel => {
for id in path {
handle(track, *id, ev, cmd);
}
}
Routing::Bubble => {
for id in path.iter().rev() {
handle(track, *id, ev, cmd);
}
}
Routing::Direct => {
if let Some(t) = path.last() {
handle(track, *t, ev, cmd);
}
}
}
}
pub fn handle(track: &mut Track, id: NodeId, ev: &Event, cmd: &mut CmdBuf) {
let Some(node) = track.get(id) else {
return;
};
if !node.interaction.enabled {
return;
}
if node.layout.overflow_scroll && node.layout.show_scrollbar {
scroll_handle(track, id, ev, cmd);
if track.get(id).is_none() {
return; }
}
let Some(node) = track.get(id) else {
return;
};
if !node.interaction.enabled {
return;
}
match node.kind.tag() {
KindTag::Slider => slider_handle(track, id, &ev.summary(), cmd),
KindTag::Checkbox => checkbox_handle(track, id, &ev.summary()),
KindTag::Switch => switch_handle(track, id, &ev.summary()),
KindTag::Radio => radio_handle(track, id, &ev.summary()),
KindTag::Input => input_handle(track, id, ev, cmd),
KindTag::Custom => {
if let Some(Kind::Custom(cell)) = track.get(id).map(|n| n.kind.clone())
&& let Some(mut inst) = cell.try_borrow_mut()
{
inst.on_event(id, &ev.summary(), cmd);
}
}
_ => {}
}
}
fn slider_handle(track: &mut Track, id: NodeId, ev: &EventView, cmd: &mut CmdBuf) {
let bound = track
.get(id)
.map(|n| n.bindings.value.is_some())
.unwrap_or(false);
if !bound {
return;
}
match ev.kind {
EventKind::PointerPressed => {
if ev.button != PointerButton::Left {
return;
}
track.set_dragging(id, true);
cmd.capture(ev.pointer, id);
track.slider_drag_to(id, ev.pos.x);
}
EventKind::PointerMoved => {
let dragging = matches!(
track.get(id).map(|n| &n.kind),
Some(Kind::Slider { dragging: true, .. })
);
if dragging {
track.slider_drag_to(id, ev.pos.x);
}
}
EventKind::PointerReleased
| EventKind::PointerCaptureLost
| EventKind::PointerCanceled => {
track.set_dragging(id, false);
cmd.release(ev.pointer);
}
_ => {}
}
}
fn checkbox_handle(track: &mut Track, id: NodeId, ev: &EventView) {
if ev.kind != EventKind::Tapped {
return;
}
let bound = track
.get(id)
.map(|n| n.bindings.checked.is_some())
.unwrap_or(false);
if !bound {
return; }
track.toggle_checked(id);
}
fn switch_handle(track: &mut Track, id: NodeId, ev: &EventView) {
if ev.kind != EventKind::Tapped {
return;
}
let bound = track
.get(id)
.map(|n| n.bindings.checked.is_some())
.unwrap_or(false);
if !bound {
return;
}
track.toggle_switch(id);
}
fn radio_handle(track: &mut Track, id: NodeId, ev: &EventView) {
if ev.kind != EventKind::Tapped {
return;
}
let bound = track
.get(id)
.map(|n| n.bindings.text.is_some())
.unwrap_or(false);
if !bound {
return;
}
track.select_radio(id);
}
fn input_handle(track: &mut Track, id: NodeId, ev: &Event, cmd: &mut CmdBuf) {
let bound = track
.get(id)
.map(|n| n.bindings.text.is_some())
.unwrap_or(false);
let view = ev.summary();
match view.kind {
EventKind::PointerPressed => {
if view.button != PointerButton::Left {
return;
}
cmd.focus(id, FocusState::Pointer);
cmd.capture(view.pointer, id);
if bound {
let caret = caret_at_x(track, id, view.pos.x);
track.input_set_caret(id, caret, false);
}
}
EventKind::PointerMoved => {
if !bound || track.captured_by(view.pointer) != Some(id) {
return;
}
let caret = caret_at_x(track, id, view.pos.x);
track.input_set_caret(id, caret, true);
}
EventKind::PointerReleased | EventKind::PointerCanceled => {
cmd.release(view.pointer);
}
EventKind::LostFocus => {
track.input_set_preedit(id, String::new());
}
EventKind::KeyDown => {
if bound {
input_key(track, id, &view);
}
}
EventKind::CharacterReceived => {
if !bound || !track.input_is_active(id) {
return;
}
let text = match ev {
Event::Key {
text: Some(t), ..
} => t.clone(),
_ => match view.key {
Some(KeyCode::Char(c)) => c.to_string(),
_ => return,
},
};
track.input_insert(id, &text);
}
EventKind::TextCompositionChanged => {
if !bound {
return;
}
if let Event::ImePreedit { text, .. } = ev {
track.input_set_preedit(id, text.clone());
}
}
_ => {}
}
}
pub const SCROLLBAR_WIDTH: f32 = 8.0;
const SCROLLBAR_INSET: f32 = 2.0;
const MIN_THUMB_LEN: f32 = 24.0;
const SCROLLBAR_HIT_INFLATE: f32 = 6.0;
pub(crate) fn vscroll_parts(view: Rect, content: Size, offset: f32) -> Option<(Rect, Rect)> {
scroll_parts(view, content.height, offset, view.height, true)
}
pub(crate) fn hscroll_parts(view: Rect, content: Size, offset: f32) -> Option<(Rect, Rect)> {
scroll_parts(view, content.width, offset, view.width, false)
}
fn scroll_parts(
view: Rect,
content_len: f32,
offset: f32,
view_len: f32,
vertical: bool,
) -> Option<(Rect, Rect)> {
if content_len <= view_len + 0.5 {
return None; }
let (track_len, base, across) = if vertical {
(view.height - SCROLLBAR_INSET * 2.0, view.y + SCROLLBAR_INSET, view.right() - SCROLLBAR_WIDTH - SCROLLBAR_INSET)
} else {
(view.width - SCROLLBAR_INSET * 2.0, view.x + SCROLLBAR_INSET, view.bottom() - SCROLLBAR_WIDTH - SCROLLBAR_INSET)
};
let max_scroll = (content_len - view_len).max(0.0);
let thumb_len = (track_len * view_len / content_len).clamp(MIN_THUMB_LEN, track_len);
let travel = (track_len - thumb_len).max(0.0);
let pos = base + travel * if max_scroll > 0.0 { (offset / max_scroll).clamp(0.0, 1.0) } else { 0.0 };
let track_rect = if vertical {
Rect::new(across, base, SCROLLBAR_WIDTH, track_len)
} else {
Rect::new(base, across, track_len, SCROLLBAR_WIDTH)
};
let thumb = if vertical {
Rect::new(across, pos, SCROLLBAR_WIDTH, thumb_len)
} else {
Rect::new(pos, across, thumb_len, SCROLLBAR_WIDTH)
};
Some((track_rect, thumb))
}
fn scroll_handle(track: &mut Track, id: NodeId, ev: &Event, cmd: &mut CmdBuf) {
let view = ev.summary();
let Some((rect, content, drag, (ox, oy))) =
track.get(id).map(|n| (n.rect(), n.content_size, n.scroll_drag, n.scroll_offset))
else {
return;
};
match view.kind {
EventKind::PointerPressed => {
if view.button != PointerButton::Left {
return;
}
if let Some((track_r, thumb)) = vscroll_parts(rect, content, oy)
&& view.pos.x >= track_r.x - SCROLLBAR_HIT_INFLATE
&& view.pos.x <= track_r.right() + SCROLLBAR_HIT_INFLATE
&& view.pos.y >= thumb.y - SCROLLBAR_HIT_INFLATE
&& view.pos.y <= thumb.bottom() + SCROLLBAR_HIT_INFLATE
{
track.get_mut(id).unwrap().scroll_drag =
Some(ScrollDrag { pointer: view.pointer, vertical: true, grab: view.pos.y - thumb.y });
cmd.capture(view.pointer, id);
cmd.damage(id);
return;
}
if let Some((track_r, thumb)) = hscroll_parts(rect, content, ox)
&& view.pos.y >= track_r.y - SCROLLBAR_HIT_INFLATE
&& view.pos.y <= track_r.bottom() + SCROLLBAR_HIT_INFLATE
&& view.pos.x >= thumb.x - SCROLLBAR_HIT_INFLATE
&& view.pos.x <= thumb.right() + SCROLLBAR_HIT_INFLATE
{
track.get_mut(id).unwrap().scroll_drag =
Some(ScrollDrag { pointer: view.pointer, vertical: false, grab: view.pos.x - thumb.x });
cmd.capture(view.pointer, id);
cmd.damage(id);
}
}
EventKind::PointerMoved => {
let Some(d) = drag else { return };
if d.pointer != view.pointer || track.captured_by(view.pointer) != Some(id) {
return;
}
if d.vertical {
let Some((track_r, thumb)) = vscroll_parts(rect, content, oy) else { return };
let max_scroll = (content.height - rect.height).max(0.0);
let travel = (track_r.height - thumb.height).max(0.0);
let pos = (view.pos.y - d.grab - track_r.y).clamp(0.0, travel);
let ny = if travel > 0.0 { pos / travel * max_scroll } else { 0.0 };
track.set_scroll_offset(id, (ox, ny));
} else {
let Some((track_r, thumb)) = hscroll_parts(rect, content, ox) else { return };
let max_scroll = (content.width - rect.width).max(0.0);
let travel = (track_r.width - thumb.width).max(0.0);
let pos = (view.pos.x - d.grab - track_r.x).clamp(0.0, travel);
let nx = if travel > 0.0 { pos / travel * max_scroll } else { 0.0 };
track.set_scroll_offset(id, (nx, oy));
}
}
EventKind::PointerReleased | EventKind::PointerCanceled => {
if let Some(d) = drag
&& d.pointer == view.pointer
{
track.get_mut(id).unwrap().scroll_drag = None;
cmd.release(view.pointer);
cmd.damage(id);
}
}
_ => {}
}
}
fn draw_scrollbars(
out: &mut Scene,
cull: &Cull,
n: &Node,
rect: Rect,
transform: Affine,
theme: &crate::theme::Theme,
) {
if !n.layout.show_scrollbar {
return;
}
let dragging = n.scroll_drag.is_some();
let color = if dragging {
theme.scrollbar_thumb_drag
} else {
theme.scrollbar_thumb
};
if let Some((_, thumb)) = vscroll_parts(rect, n.content_size, n.scroll_offset.1) {
push(
out,
cull,
transform,
thumb,
Op::Rect { rect: thumb, radius: SCROLLBAR_WIDTH * 0.5, color, transform },
);
}
if let Some((_, thumb)) = hscroll_parts(rect, n.content_size, n.scroll_offset.0) {
push(
out,
cull,
transform,
thumb,
Op::Rect { rect: thumb, radius: SCROLLBAR_WIDTH * 0.5, color, transform },
);
}
}
fn input_key(track: &mut Track, id: NodeId, view: &EventView) {
let Some(code) = view.key else {
return;
};
let extend = view.modifiers.shift();
match code {
KeyCode::Named(NamedKey::Backspace) => {
track.input_backspace(id);
}
KeyCode::Named(NamedKey::Delete) => {
track.input_delete(id);
}
KeyCode::Named(NamedKey::Left) => {
track.input_move_caret(id, -1, extend);
}
KeyCode::Named(NamedKey::Right) => {
track.input_move_caret(id, 1, extend);
}
KeyCode::Named(NamedKey::Home) => {
track.input_set_caret(id, 0, extend);
}
KeyCode::Named(NamedKey::End) => {
track.input_set_caret(id, usize::MAX, extend);
}
KeyCode::Char('a') | KeyCode::Char('A') if view.modifiers.ctrl() => {
track.input_select_all(id);
}
_ => {}
}
}
fn display_text(text: &str, preedit: &str, caret: usize) -> String {
if preedit.is_empty() {
return text.to_string();
}
let caret = clamp_to_char_boundary(text, caret);
format!("{}{}{}", &text[..caret], preedit, &text[caret..])
}
fn display_to_text(text: &str, preedit: &str, caret: usize, offset: usize) -> usize {
if preedit.is_empty() {
return clamp_to_char_boundary(text, offset);
}
let caret = clamp_to_char_boundary(text, caret);
if offset <= caret {
offset
} else if offset <= caret + preedit.len() {
caret
} else {
clamp_to_char_boundary(text, offset - preedit.len())
}
}
fn caret_at_x(track: &Track, id: NodeId, x: f32) -> usize {
let Some(n) = track.get(id) else {
return 0;
};
let Kind::Input {
text,
preedit,
caret,
scroll,
..
} = &n.kind
else {
return 0;
};
let display = display_text(text, preedit, *caret);
let local = (x - n.rect().x - pad_left(n) + *scroll).max(0.0) as f64;
let spec = &n.text.spec;
let mut best = 0usize;
let mut best_d = f64::MAX;
let mut probe = |offset: usize| {
let w = TextEngine::measure_text(&display[..offset], spec).0;
let d = (w - local).abs();
if d < best_d {
best_d = d;
best = offset;
}
};
for (i, _) in display.char_indices() {
probe(i);
}
probe(display.len());
display_to_text(text, preedit, *caret, best)
}
fn pad_left(n: &Node) -> f32 {
n.layout.padding[lieui_layout::CSSDirection::Left as usize]
}
fn pad_top(n: &Node) -> f32 {
n.layout.padding[lieui_layout::CSSDirection::Top as usize]
}
fn content_rect(n: &Node, rect: Rect) -> Rect {
use lieui_layout::CSSDirection::{Bottom, Left, Right, Top};
let pad = |d: lieui_layout::CSSDirection| n.layout.padding[d as usize];
let (l, t, r, b) = (pad(Left), pad(Top), pad(Right), pad(Bottom));
Rect::new(
rect.x + l,
rect.y + t,
(rect.width - l - r).max(0.0),
(rect.height - t - b).max(0.0),
)
}
fn selection_of(caret: usize, anchor: usize) -> Option<(usize, usize)> {
if caret == anchor {
None
} else {
Some((caret.min(anchor), caret.max(anchor)))
}
}
fn measure(display: &str, spec: &TextSpec, offset: usize) -> f32 {
let offset = clamp_to_char_boundary(display, offset);
TextEngine::measure_text(&display[..offset], spec).0 as f32
}
#[allow(clippy::too_many_arguments)]
fn push_input_text(
cache: &mut TextCache,
out: &mut Scene,
cull: &Cull,
text: &str,
spec: &TextSpec,
color: Color,
origin: Point,
transform: Affine,
) {
let (layout, hit) = cache.get_or_build_counted(text, spec, color);
if hit {
out.stats.text_cache_hits += 1;
} else {
out.stats.text_layouts_built += 1;
}
let op = Op::Text {
layout,
origin,
color,
transform,
};
let bounds = op
.screen_bounds()
.unwrap_or(Rect::new(origin.x, origin.y, 1.0, 1.0));
if cull.hit(&bounds) {
out.push(op);
} else {
out.stats.prims_culled += 1;
}
}
pub(crate) fn draw(
cache: &mut TextCache,
track: &Track,
id: NodeId,
transform: Affine,
cull: &Cull,
out: &mut Scene,
theme: &crate::theme::Theme,
) {
let Some(n) = track.get(id) else {
return;
};
let rect = n.rect();
if rect.is_empty() {
return;
}
if let Some(sh) = n.paint.shadow {
let r = Rect::new(
rect.x + sh.offset_x,
rect.y + sh.offset_y,
rect.width,
rect.height,
)
.inflate(sh.spread);
push(
out,
cull,
transform,
r,
Op::Shadow {
rect: r,
radius: n.paint.border_radius,
std_dev: (sh.blur * 0.5).max(0.0),
color: dim_if_disabled(sh.color, n),
transform,
},
);
}
let radius = n
.paint
.border_radius
.min(rect.width * 0.5)
.min(rect.height * 0.5);
if let Some(color) = resolve_background(n) {
push(
out,
cull,
transform,
rect,
Op::Rect {
rect,
radius,
color: dim_if_disabled(color, n),
transform,
},
);
}
if n.paint.border_width > 0.0
&& let Some(color) = n.paint.border_color
{
push(
out,
cull,
transform,
rect,
Op::Border {
rect,
radius,
width: n.paint.border_width,
color: dim_if_disabled(color, n),
transform,
},
);
}
let text_color = dim_if_disabled(resolve_text_color(n), n);
match &n.kind {
Kind::Box => {}
Kind::Image(img) => {
let bounds = transform.bounding_box(rect);
if cull.hit(&bounds) {
out.push(Op::Image {
image: img.clone(),
rect,
transform,
});
} else {
out.stats.prims_culled += 1;
}
}
Kind::Custom(cell) => {
let inst = cell.borrow();
inst.draw(out, rect, transform);
}
Kind::Text(_) => {
if let Kind::Text(s) = &n.kind {
let spec = draw_spec(n);
let r = if spec.optical_align {
content_rect(n, rect)
} else {
rect
};
push_text(cache, out, cull, s, &spec, text_color, r, false, transform);
}
}
Kind::Button { label } => {
let spec = draw_spec(n);
push_text(cache, out, cull, label, &spec, text_color, rect, true, transform);
}
Kind::Input {
text,
placeholder,
caret,
anchor,
preedit,
scroll,
} => {
let x0 = rect.x + pad_left(n) - scroll;
let top = rect.y + pad_top(n);
let display = display_text(text, preedit, *caret);
let empty = text.is_empty() && preedit.is_empty();
if let Some((a, b)) = selection_of(*caret, *anchor)
&& n.interaction.focused
{
let sx = x0 + measure(&display, &n.text.spec, a);
let ex = x0 + measure(&display, &n.text.spec, b);
let line_h = TextEngine::measure_text("x", &n.text.spec).1 as f32;
let sel = Rect::new(sx, top, (ex - sx).max(1.0), line_h.max(rect.height - pad_top(n) * 2.0));
push(
out,
cull,
transform,
sel,
Op::Rect {
rect: sel,
radius: 2.0,
color: theme.selection,
transform,
},
);
}
let (shown, color) = if empty {
(placeholder.as_str(), theme.text_secondary)
} else {
(display.as_str(), text_color)
};
if !shown.is_empty() {
let (_, th) = TextEngine::measure_text("x", &n.text.spec);
let origin = Point::new(x0, rect.y + (rect.height - th as f32) * 0.5);
push_input_text(cache, out, cull, shown, &n.text.spec, color, origin, transform);
}
if !preedit.is_empty() {
let px = x0 + measure(&display, &n.text.spec, *caret);
let pw = TextEngine::measure_text(preedit, &n.text.spec).0 as f32;
let (_, th) = TextEngine::measure_text("x", &n.text.spec);
let y = rect.y + (rect.height + th as f32) * 0.5;
let underline = Rect::new(px, y, pw.max(1.0), 1.0);
push(
out,
cull,
transform,
underline,
Op::Rect {
rect: underline,
radius: 0.0,
color: text_color,
transform,
},
);
}
if n.interaction.focused && n.interaction.enabled && track.blink_on {
let cx = x0 + measure(&display, &n.text.spec, *caret);
let caret_rect = Rect::new(
cx,
rect.y + pad_top(n),
1.5,
(rect.height - pad_top(n) * 2.0).max(4.0),
);
push(
out,
cull,
transform,
caret_rect,
Op::Rect {
rect: caret_rect,
radius: 0.0,
color: theme.caret,
transform,
},
);
}
}
Kind::Checkbox { checked } => {
let box_size = rect.height.min(18.0);
let b = Rect::new(
rect.x,
rect.y + (rect.height - box_size) * 0.5,
box_size,
box_size,
);
let border = n.paint.border_color.unwrap_or(text_color);
push(
out,
cull,
transform,
b,
Op::Border {
rect: b,
radius: 3.0,
width: 1.5,
color: border,
transform,
},
);
if *checked {
let inner = b.inflate(-4.0);
push(
out,
cull,
transform,
inner,
Op::Rect {
rect: inner,
radius: 1.5,
color: border,
transform,
},
);
}
}
Kind::Slider {
value, min, max, ..
} => {
let track_h = 4.0;
let track = Rect::new(
rect.x,
rect.y + (rect.height - track_h) * 0.5,
rect.width,
track_h,
);
let accent = n.paint.background_color.unwrap_or(theme.accent);
let knob = 12.0;
let t = if *max > *min {
((value - *min) / (*max - *min)).clamp(0.0, 1.0)
} else {
0.0
};
let x = rect.x + (rect.width - knob).max(0.0) * t;
push(
out,
cull,
transform,
track,
Op::Rect {
rect: track,
radius: track_h * 0.5,
color: theme.control_border,
transform,
},
);
let filled = Rect::new(track.x, track.y, (x + knob * 0.5) - track.x, track_h);
push(
out,
cull,
transform,
filled,
Op::Rect {
rect: filled,
radius: track_h * 0.5,
color: accent,
transform,
},
);
let k = Rect::new(
x,
rect.y + (rect.height - knob) * 0.5,
knob,
knob,
);
push(
out,
cull,
transform,
k,
Op::Rect {
rect: k,
radius: knob * 0.5,
color: accent,
transform,
},
);
}
Kind::Switch { on } => {
let track_w = 40.0_f32.min(rect.width);
let track_h = 20.0_f32.min(rect.height);
let t = Rect::new(rect.x, rect.y + (rect.height - track_h) * 0.5, track_w, track_h);
let accent = n.paint.background_color.unwrap_or(theme.accent);
let (fill, thumb_c) = if *on {
(accent, Color::WHITE)
} else {
(theme.control_border, theme.control)
};
push(
out,
cull,
transform,
t,
Op::Rect { rect: t, radius: track_h * 0.5, color: fill, transform },
);
let r = track_h - 4.0;
let tx = if *on {
t.right() - r - 2.0
} else {
t.x + 2.0
};
let thumb = Rect::new(tx, t.y + 2.0, r, r);
push(
out,
cull,
transform,
thumb,
Op::Rect { rect: thumb, radius: r * 0.5, color: thumb_c, transform },
);
}
Kind::Radio { selected, .. } => {
let d = rect.height.min(18.0);
let b = Rect::new(
rect.x,
rect.y + (rect.height - d) * 0.5,
d,
d,
);
let border = n.paint.border_color.unwrap_or(theme.control_border);
let accent = n.paint.background_color.unwrap_or(theme.accent);
push(
out,
cull,
transform,
b,
Op::Border {
rect: b,
radius: d * 0.5,
width: 1.5,
color: if *selected { accent } else { border },
transform,
},
);
if *selected {
let inner = b.inflate(-4.0);
push(
out,
cull,
transform,
inner,
Op::Rect {
rect: inner,
radius: inner.width * 0.5,
color: accent,
transform,
},
);
}
}
Kind::Progress { value } => {
let filled = Rect::new(
rect.x,
rect.y,
rect.width * value.clamp(0.0, 1.0),
rect.height,
);
let accent = n.paint.background_color.unwrap_or(theme.accent);
push(
out,
cull,
transform,
filled,
Op::Rect {
rect: filled,
radius,
color: accent,
transform,
},
);
}
}
}
pub(crate) fn draw_scrollbar_overlay(
out: &mut Scene,
cull: &Cull,
track: &crate::track::Track,
id: crate::track::NodeId,
transform: Affine,
theme: &crate::theme::Theme,
) {
let Some(n) = track.get(id) else {
return;
};
if !(n.layout.overflow_scroll && n.layout.show_scrollbar) {
return;
}
let rect = n.rect();
draw_scrollbars(out, cull, n, rect, transform, theme);
}
pub fn resolve_background(n: &Node) -> Option<Color> {
if n.interaction.pressed
&& let Some(c) = n.paint.pressed_background
{
return Some(c);
}
if n.interaction.pointer_over
&& let Some(c) = n.paint.hover_background
{
return Some(c);
}
n.paint.background_color
}
pub fn resolve_text_color(n: &Node) -> Color {
if n.interaction.pressed
&& let Some(c) = n.text.pressed_color
{
return c;
}
if n.interaction.pointer_over
&& let Some(c) = n.text.hover_color
{
return c;
}
n.text.color
}
fn draw_spec(n: &Node) -> TextSpec {
let mut spec = n.text.spec.clone();
if spec.wrap
&& spec.max_width.is_none()
&& let Some(w) = n.text_wrap
{
spec.max_width = Some(f64::from(w));
}
spec
}
fn dim_if_disabled(c: Color, n: &Node) -> Color {
if n.interaction.enabled {
c
} else {
Color::rgba(c.r, c.g, c.b, c.a / 2)
}
}
fn push(out: &mut Scene, cull: &Cull, _transform: Affine, bounds: Rect, op: Op) {
let screen = op.screen_bounds().unwrap_or(bounds);
if cull.hit(&screen) {
out.push(op);
} else {
out.stats.prims_culled += 1;
}
}
#[allow(clippy::too_many_arguments)]
fn push_text(
cache: &mut TextCache,
out: &mut Scene,
cull: &Cull,
text: &str,
spec: &TextSpec,
color: Color,
rect: Rect,
center: bool,
transform: Affine,
) {
if text.is_empty() || rect.is_empty() {
return;
}
let (layout, ink, hit) = cache.get_full(text, spec, color);
if hit {
out.stats.text_cache_hits += 1;
} else {
out.stats.text_layouts_built += 1;
}
let ink = ink.filter(|b| !b.is_empty());
let origin = match (ink, center) {
(Some(b), false) => Point::new(rect.x, rect.y - b.top),
(Some(b), true) => Point::new(
rect.x + (rect.width - layout.width()) * 0.5,
rect.y + (rect.height - b.height()) * 0.5 - b.top,
),
(None, true) => Point::new(
rect.x + (rect.width - layout.width()) * 0.5,
rect.y + (rect.height - layout.height()) * 0.5,
),
(None, false) => Point::new(rect.x, rect.y),
};
let op = Op::Text {
layout,
origin,
color,
transform,
};
let bounds = op.screen_bounds().unwrap_or(rect);
if cull.hit(&bounds) {
out.push(op);
} else {
out.stats.prims_culled += 1;
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::cmd::apply_cmds;
use crate::event::PointerId;
use crate::layout::layout;
use crate::render::scene::{SceneBuilder, SceneOptions};
use crate::style::{PaintStyle, TextStyle};
use crate::track::{InteractionState, Layer};
use lieui_geom::Size;
fn opts() -> SceneOptions {
SceneOptions {
window: Size::new(200.0, 200.0),
background: Color::new(255, 255, 255),
focus_ring: false,
theme: crate::theme::Theme::light(),
}
}
fn node(t: &mut Track, kind: Kind, w: f32, h: f32) -> NodeId {
let id = t.create(kind, None);
t.get_mut(id).unwrap().layout.dim = [w, h];
id
}
fn build(t: &Track) -> Scene {
SceneBuilder::new().build(t, &opts(), &[], true)
}
fn green() -> Color {
Color::new(0, 128, 0)
}
fn red() -> Color {
Color::new(255, 0, 0)
}
#[test]
fn text_node_emits_a_text_op() {
let mut t = Track::new();
let root = t.create(Kind::Box, None);
t.add_root(Layer::Content, None, root);
let id = node(&mut t, Kind::Text("hello".into()), 60.0, 20.0);
t.get_mut(id).unwrap().text = TextStyle::new().font_size(16.0).color(green());
t.append_child(root, id);
layout(&mut t, Size::new(200.0, 200.0));
let scene = build(&t);
let text_ops: Vec<_> = scene
.ops()
.iter()
.filter(|op| matches!(op, Op::Text { .. }))
.collect();
assert_eq!(text_ops.len(), 1);
match text_ops[0] {
Op::Text { color, origin, .. } => {
assert_eq!(*color, green());
assert_eq!(origin.x, 0.0);
}
_ => unreachable!(),
}
assert_eq!(scene.stats.text_layouts_built, 1);
}
#[test]
fn optical_align_puts_the_ink_top_at_the_rect_top() {
let mut t = Track::new();
let root = t.create(Kind::Box, None);
t.add_root(Layer::Content, None, root);
let id = t.create(Kind::Text("abc".into()), None);
{
let n = t.get_mut(id).unwrap();
n.text = TextStyle::new().font_size(13.0);
n.text.spec.optical_align = true;
}
t.append_child(root, id);
layout(&mut t, Size::new(200.0, 200.0));
let rect = crate::layout::rect_of(&t, id);
let ink = lieui_text::TextEngine::ink_bounds("abc", &t.get(id).unwrap().text.spec)
.expect("abc 有墨迹");
let scene = build(&t);
let Op::Text { origin, .. } = scene
.ops()
.iter()
.find(|op| matches!(op, Op::Text { .. }))
.unwrap()
else {
unreachable!()
};
assert!(
(rect.height - ink.height()).abs() < 0.01,
"节点高度 = 墨迹高度:{} vs {}",
rect.height,
ink.height()
);
assert!(
(origin.y - (rect.y - ink.top)).abs() < 0.01,
"绘制原点上移到墨迹上缘:{} vs {}",
origin.y,
rect.y - ink.top
);
}
#[test]
fn optical_align_keeps_the_padding_around_the_ink() {
let mut t = Track::new();
let root = t.create(Kind::Box, None);
t.add_root(Layer::Content, None, root);
let id = t.create(Kind::Text("tooltip".into()), None);
{
let n = t.get_mut(id).unwrap();
n.text = TextStyle::new().font_size(12.0);
n.text.spec.optical_align = true;
n.text.spec.wrap = false;
n.layout = n
.layout
.clone()
.padding_top(6.0)
.padding_bottom(6.0)
.padding_left(10.0)
.padding_right(10.0);
}
t.append_child(root, id);
layout(&mut t, Size::new(200.0, 200.0));
let rect = crate::layout::rect_of(&t, id);
let ink = lieui_text::TextEngine::ink_bounds("tooltip", &t.get(id).unwrap().text.spec)
.expect("tooltip 有墨迹");
assert!(
(rect.height - (ink.height() + 12.0)).abs() < 0.01,
"盒高 = 墨迹高 + 上下 padding:{} vs {}",
rect.height,
ink.height()
);
let scene = build(&t);
let Op::Text { origin, .. } = scene
.ops()
.iter()
.find(|op| matches!(op, Op::Text { .. }))
.unwrap()
else {
unreachable!()
};
let ink_top = origin.y + ink.top;
let ink_left = origin.x;
assert!(
(ink_top - (rect.y + 6.0)).abs() < 0.01,
"墨迹上缘落在内容盒上缘(rect.y + 6):{ink_top} vs {}",
rect.y + 6.0
);
assert!(
(ink_left - (rect.x + 10.0)).abs() < 0.01,
"墨迹左缘落在内容盒左缘(rect.x + 10):{ink_left} vs {}",
rect.x + 10.0
);
let bottom_gap = rect.bottom() - (ink_top + ink.height());
assert!(
((ink_top - rect.y) - bottom_gap).abs() < 0.01,
"上下 padding 对称:上 {} vs 下 {}",
ink_top - rect.y,
bottom_gap
);
}
#[test]
fn empty_text_is_skipped() {
let mut t = Track::new();
let root = t.create(Kind::Box, None);
t.add_root(Layer::Content, None, root);
let id = node(&mut t, Kind::Text(String::new()), 60.0, 20.0);
t.append_child(root, id);
layout(&mut t, Size::new(200.0, 200.0));
let scene = build(&t);
assert!(!scene.ops().iter().any(|op| matches!(op, Op::Text { .. })));
assert_eq!(scene.stats.text_layouts_built, 0);
}
#[test]
fn button_label_is_centered() {
let mut t = Track::new();
let root = t.create(Kind::Box, None);
t.add_root(Layer::Content, None, root);
let id = node(&mut t, Kind::Button { label: "OK".into() }, 80.0, 30.0);
t.get_mut(id).unwrap().text = TextStyle::new().font_size(14.0);
t.append_child(root, id);
layout(&mut t, Size::new(200.0, 200.0));
let scene = build(&t);
let Op::Text { layout, origin, .. } = scene
.ops()
.iter()
.find(|op| matches!(op, Op::Text { .. }))
.unwrap()
else {
unreachable!()
};
let left = origin.x;
let right = 80.0 - origin.x - layout.width();
assert!((left - right).abs() < 1.0, "left={left} right={right}");
let top = origin.y;
let bottom = 30.0 - origin.y - layout.height();
assert!((top - bottom).abs() < 1.0, "top={top} bottom={bottom}");
}
#[test]
fn icon_button_glyph_is_centered_in_the_button() {
let mut t = Track::new();
let root = t.create(Kind::Box, None);
t.add_root(Layer::Content, None, root);
let id = node(
&mut t,
Kind::Button {
label: "\u{e5cd}".into(), },
40.0,
32.0,
);
t.get_mut(id).unwrap().text = TextStyle::new().font_size(20.0);
t.append_child(root, id);
layout(&mut t, Size::new(200.0, 200.0));
let scene = build(&t);
let Op::Text { layout, origin, .. } = scene
.ops()
.iter()
.find(|op| matches!(op, Op::Text { .. }))
.unwrap()
else {
unreachable!()
};
let top = origin.y;
let bottom = 32.0 - origin.y - layout.height();
let left = origin.x;
let right = 40.0 - origin.x - layout.width();
assert!((top - bottom).abs() < 1.0, "top={top} bottom={bottom}");
assert!((left - right).abs() < 1.0, "left={left} right={right}");
}
#[test]
fn checkbox_draws_a_border_and_a_mark() {
let mut t = Track::new();
let root = t.create(Kind::Box, None);
t.add_root(Layer::Content, None, root);
let id = node(&mut t, Kind::Checkbox { checked: false }, 20.0, 20.0);
t.append_child(root, id);
layout(&mut t, Size::new(200.0, 200.0));
let scene = build(&t);
assert!(scene.ops().iter().any(|op| matches!(op, Op::Border { .. })));
assert!(!scene.ops().iter().any(|op| matches!(
op,
Op::Rect { radius, .. } if (*radius - 1.5).abs() < 1e-6
)));
t.get_mut(id).unwrap().kind = Kind::Checkbox { checked: true };
let scene = build(&t);
assert!(
scene.ops().iter().any(|op| matches!(
op,
Op::Rect { radius, .. } if (*radius - 1.5).abs() < 1e-6
)),
"勾选后应画出标记"
);
}
#[test]
fn slider_draws_track_and_knob_at_the_value_position() {
let mut t = Track::new();
let root = t.create(Kind::Box, None);
t.add_root(Layer::Content, None, root);
let id = node(
&mut t,
Kind::Slider {
value: 0.5,
min: 0.0,
max: 1.0,
dragging: false,
},
100.0,
20.0,
);
t.append_child(root, id);
layout(&mut t, Size::new(200.0, 200.0));
let scene = build(&t);
let rects = scene
.ops()
.iter()
.filter(|op| matches!(op, Op::Rect { rect, .. } if rect.width <= 100.0))
.count();
assert_eq!(rects, 3);
let knob = scene
.ops()
.iter()
.find_map(|op| match op {
Op::Rect { rect, radius, .. } if (*radius - 6.0).abs() < 1e-6 => Some(*rect),
_ => None,
})
.unwrap();
assert!((knob.x - (100.0 - 12.0) * 0.5).abs() < 0.01, "滑块在中点:{knob:?}");
}
#[test]
fn slider_respects_a_non_unit_range() {
let mut t = Track::new();
let root = t.create(Kind::Box, None);
t.add_root(Layer::Content, None, root);
let id = node(
&mut t,
Kind::Slider {
value: 5.0,
min: 0.0,
max: 10.0,
dragging: false,
},
100.0,
20.0,
);
t.append_child(root, id);
layout(&mut t, Size::new(200.0, 200.0));
let knob = build(&t)
.ops()
.iter()
.find_map(|op| match op {
Op::Rect { rect, radius, .. } if (*radius - 6.0).abs() < 1e-6 => Some(*rect),
_ => None,
})
.unwrap();
assert!((knob.x - (100.0 - 12.0) * 0.5).abs() < 0.01, "值 5/10 ⇒ 滑块在中点:{knob:?}");
}
#[test]
fn progress_fills_proportionally() {
let mut t = Track::new();
let root = t.create(Kind::Box, None);
t.add_root(Layer::Content, None, root);
let id = node(
&mut t,
Kind::Progress { value: 0.25 },
100.0,
8.0,
);
t.get_mut(id).unwrap().paint = PaintStyle::new().background(green());
t.append_child(root, id);
layout(&mut t, Size::new(200.0, 200.0));
let scene = build(&t);
let filled = scene
.ops()
.iter()
.find_map(|op| match op {
Op::Rect { rect, color, .. } if *color == green() && rect.width < 100.0 => Some(*rect),
_ => None,
})
.unwrap();
assert_eq!(filled.width, 25.0);
assert_eq!(filled.height, 8.0);
}
#[test]
fn disabled_content_is_dimmed() {
let mut t = Track::new();
let root = t.create(Kind::Box, None);
t.add_root(Layer::Content, None, root);
let id = node(&mut t, Kind::Box, 20.0, 20.0);
t.get_mut(id).unwrap().paint = PaintStyle::new().background(red());
t.get_mut(id).unwrap().interaction = InteractionState {
enabled: false,
..InteractionState::default()
};
t.append_child(root, id);
layout(&mut t, Size::new(200.0, 200.0));
let scene = build(&t);
let c = scene
.ops()
.iter()
.find_map(|op| match op {
Op::Rect { rect, color, .. } if rect.width == 20.0 => Some(*color),
_ => None,
})
.unwrap();
assert_eq!(c.a, 127, "禁用 ⇒ alpha 减半");
}
fn input_node(t: &mut Track, text: &str, placeholder: &str) -> NodeId {
let id = t.create(
Kind::Input {
text: text.to_string(),
placeholder: placeholder.to_string(),
caret: text.len(),
anchor: text.len(),
preedit: String::new(),
scroll: 0.0,
},
None,
);
{
let n = t.get_mut(id).unwrap();
n.layout = n
.layout
.clone()
.width(200.0)
.height(28.0)
.padding_left(8.0)
.padding_top(4.0);
n.paint = PaintStyle::new().background(Color::WHITE);
}
id
}
fn text_ops(scene: &Scene) -> Vec<(Point, Color)> {
scene
.ops()
.iter()
.filter_map(|op| match op {
Op::Text { origin, color, .. } => Some((*origin, *color)),
_ => None,
})
.collect()
}
#[test]
fn input_draws_placeholder_when_empty() {
let mut t = Track::new();
let root = t.create(Kind::Box, None);
t.add_root(Layer::Content, None, root);
let id = input_node(&mut t, "", "姓名");
t.append_child(root, id);
layout(&mut t, Size::new(300.0, 100.0));
let scene = build(&t);
let texts = text_ops(&scene);
assert_eq!(texts.len(), 1);
assert_eq!(texts[0].0.x, 8.0, "文本从左内边距开始");
assert!(!scene.ops().iter().any(|op| matches!(
op,
Op::Rect { rect, .. } if rect.width < 2.0 && rect.height > 10.0
)));
}
#[test]
fn input_draws_the_text_and_a_caret_when_focused() {
let mut t = Track::new();
let root = t.create(Kind::Box, None);
t.add_root(Layer::Content, None, root);
let id = input_node(&mut t, "hi", "");
t.append_child(root, id);
layout(&mut t, Size::new(300.0, 100.0));
let scene = build(&t);
let texts = text_ops(&scene);
assert_eq!(texts.len(), 1);
assert_eq!(texts[0].0.x, 8.0);
t.set_focused(id, crate::track::FocusState::Keyboard);
t.blink_on = true;
let scene = build(&t);
let caret = scene.ops().iter().find_map(|op| match op {
Op::Rect { rect, .. } if rect.width < 2.0 && rect.height > 10.0 => Some(*rect),
_ => None,
});
let caret = caret.expect("有焦点 ⇒ 画光标");
assert!(caret.x >= 8.0, "光标在左内边距之后:{caret:?}");
}
#[test]
fn input_draws_a_selection_rect() {
let mut t = Track::new();
let root = t.create(Kind::Box, None);
t.add_root(Layer::Content, None, root);
let id = input_node(&mut t, "abcdef", "");
t.append_child(root, id);
layout(&mut t, Size::new(300.0, 100.0));
{
let n = t.get_mut(id).unwrap();
if let Kind::Input { caret, anchor, .. } = &mut n.kind {
*caret = 2;
*anchor = 0;
}
n.interaction.focused = true;
}
let scene = build(&t);
let sel = scene.ops().iter().find_map(|op| match op {
Op::Rect { rect, color, .. } if color.a > 0 && color.a < 200 && rect.width > 1.0 => {
Some(*rect)
}
_ => None,
});
let sel = sel.expect("有选区 ⇒ 画高亮");
assert!(sel.width > 2.0, "选区有宽度:{sel:?}");
assert!(sel.x >= 8.0);
}
#[test]
fn input_draws_a_preedit_underline() {
let mut t = Track::new();
let root = t.create(Kind::Box, None);
t.add_root(Layer::Content, None, root);
let id = input_node(&mut t, "", "");
t.append_child(root, id);
layout(&mut t, Size::new(300.0, 100.0));
let before = build(&t).ops().len();
t.input_set_preedit(id, "zhong".to_string());
let scene = build(&t);
assert!(scene.ops().len() > before, "组合串 ⇒ 文本 + 下划线");
}
const SCROLL_WIN: Size = Size::new(200.0, 100.0);
fn scroll_tree() -> (Track, NodeId, NodeId) {
let mut t = Track::new();
let sc = t.create(Kind::Box, None);
{
let n = t.get_mut(sc).unwrap();
n.layout.dim = [200.0, 100.0];
n.layout.overflow_scroll = true;
n.layout.show_scrollbar = true;
}
t.add_root(Layer::Content, None, sc);
let content = t.create(Kind::Box, None);
t.get_mut(content).unwrap().layout.dim = [200.0, 300.0];
t.append_child(sc, content);
crate::layout::layout(&mut t, SCROLL_WIN);
(t, sc, content)
}
fn pointer(kind: EventKind, pos: Point) -> Event {
Event::pointer(kind, PointerId(0), pos, PointerButton::Left)
}
#[test]
fn thumb_geometry_is_proportional_with_a_minimum_length() {
let (t, sc, _) = scroll_tree();
let rect = t.get(sc).unwrap().rect();
let content = t.get(sc).unwrap().content_size;
assert!((content.height - 300.0).abs() < 0.5, "内容尺寸被布局写回:{content:?}");
let (track_r, thumb) = vscroll_parts(rect, content, 0.0).expect("溢出 ⇒ 有竖直 thumb");
assert!((track_r.x + track_r.width - rect.right()).abs() < 3.0, "贴右缘");
assert!((thumb.height - (100.0 - 4.0) * 100.0 / 300.0).abs() < 0.5, "长度按比例");
assert!((thumb.y - (rect.y + 2.0)).abs() < 0.5, "offset=0 ⇒ 顶端");
let mut t2 = Track::new();
let sc2 = t2.create(Kind::Box, None);
{
let n = t2.get_mut(sc2).unwrap();
n.layout.dim = [200.0, 100.0];
n.layout.overflow_scroll = true;
n.layout.show_scrollbar = true;
}
t2.add_root(Layer::Content, None, sc2);
let small = t2.create(Kind::Box, None);
t2.get_mut(small).unwrap().layout.dim = [200.0, 50.0];
t2.append_child(sc2, small);
crate::layout::layout(&mut t2, SCROLL_WIN);
assert!(vscroll_parts(t2.get(sc2).unwrap().rect(), t2.get(sc2).unwrap().content_size, 0.0).is_none());
}
#[test]
fn thumb_drag_maps_position_to_offset_and_releases() {
let (mut t, sc, _) = scroll_tree();
let rect = t.get(sc).unwrap().rect();
let content = t.get(sc).unwrap().content_size;
let (_, thumb) = vscroll_parts(rect, content, 0.0).unwrap();
let grab = Point::new(thumb.center().x, thumb.y + 4.0);
let mut cmds = CmdBuf::new();
handle(&mut t, sc, &pointer(EventKind::PointerPressed, grab), &mut cmds);
apply_cmds(&mut t, cmds.as_slice());
let drag = t.get(sc).unwrap().scroll_drag.expect("按下 thumb ⇒ 进入拖拽");
assert!(drag.vertical);
assert_eq!(t.captured_by(PointerId(0)), Some(sc), "拖拽期间捕获指针");
let travel = rect.height - 4.0 - thumb.height;
let mid_y = rect.y + 2.0 + travel * 0.5;
let mut cmds = CmdBuf::new();
handle(&mut t, sc, &pointer(EventKind::PointerMoved, Point::new(grab.x, mid_y + 4.0)), &mut cmds);
apply_cmds(&mut t, cmds.as_slice());
let oy = t.scroll_offset(sc).1;
let max_scroll = 200.0;
assert!((oy - max_scroll * 0.5).abs() < 1.5, "拖到中点 ⇒ offset≈一半:{oy}");
let mut cmds = CmdBuf::new();
handle(&mut t, sc, &pointer(EventKind::PointerReleased, Point::new(grab.x, mid_y)), &mut cmds);
apply_cmds(&mut t, cmds.as_slice());
assert!(t.get(sc).unwrap().scroll_drag.is_none());
assert_eq!(t.captured_by(PointerId(0)), None);
assert!(t.scroll_offset(sc).1 > 0.0, "松手后滚动位置保留");
}
#[test]
fn grabbing_the_thumb_top_does_not_jump_the_scroll() {
let (mut t, sc, _) = scroll_tree();
let rect = t.get(sc).unwrap().rect();
let content = t.get(sc).unwrap().content_size;
t.set_scroll_offset(sc, (0.0, 100.0));
let (_, thumb) = vscroll_parts(rect, content, 100.0).unwrap();
let mut cmds = CmdBuf::new();
handle(&mut t, sc, &pointer(EventKind::PointerPressed, Point::new(thumb.center().x, thumb.y + 0.5)), &mut cmds);
apply_cmds(&mut t, cmds.as_slice());
let oy_before = t.scroll_offset(sc).1;
handle(&mut t, sc, &pointer(EventKind::PointerMoved, Point::new(thumb.center().x, thumb.y + 1.5)), &mut cmds);
apply_cmds(&mut t, cmds.as_slice());
let oy = t.scroll_offset(sc).1;
let step = 200.0 / (rect.height - 4.0 - thumb.height);
assert!(
(oy - (oy_before + step)).abs() < 1.0,
"抓取点不跳:{oy_before} → {oy}(一步应≈{step})"
);
}
#[test]
fn draw_emits_thumb_when_overflowing() {
let (t, sc, _) = scroll_tree();
let scene = build(&t);
let rect = t.get(sc).unwrap().rect();
let thumb_x_hit = scene.ops().iter().any(|op| match op {
Op::Rect { rect: r, color, .. } =>
*color == crate::theme::Theme::light().scrollbar_thumb
&& r.width <= SCROLLBAR_WIDTH + 0.5
&& r.x + r.width <= rect.right() + 0.5,
_ => false,
});
assert!(thumb_x_hit, "溢出 ⇒ 场景里有 thumb 矩形");
}
#[test]
fn scrollbar_is_drawn_above_the_content() {
let (mut t, sc, _) = scroll_tree();
let content = t.children(sc)[0];
t.get_mut(content).unwrap().paint.background_color = Some(Color::RED);
let scene = build(&t);
let thumb = scene.ops().iter().position(|op| {
matches!(op, Op::Rect { rect, color, .. }
if *color == crate::theme::Theme::light().scrollbar_thumb
&& rect.width <= SCROLLBAR_WIDTH + 0.5)
});
let item = scene
.ops()
.iter()
.position(|op| matches!(op, Op::Rect { color, .. } if *color == Color::RED));
let (thumb, item) = (thumb.expect("有 thumb"), item.expect("有内容底色"));
assert!(
thumb > item,
"滚动条(op #{thumb})必须在内容(op #{item})之后绘制"
);
}
#[test]
fn switch_draws_pill_track_with_accent_when_on() {
let mut t = Track::new();
let root = t.create(Kind::Box, None);
t.add_root(Layer::Content, None, root);
let on = node(&mut t, Kind::Switch { on: true }, 40.0, 20.0);
let off = node(&mut t, Kind::Switch { on: false }, 40.0, 20.0);
t.append_child(root, on);
t.append_child(root, off);
layout(&mut t, Size::new(200.0, 100.0));
let light = crate::theme::Theme::light();
let accent = light.accent;
let scene = build(&t);
let tracks: Vec<(Rect, Color)> = scene
.ops()
.iter()
.filter_map(|op| match op {
Op::Rect { rect, color, radius, .. } if *radius >= 9.0 => Some((*rect, *color)),
_ => None,
})
.collect();
assert_eq!(tracks.len(), 2, "两个药丸轨道:{tracks:?}");
assert!(tracks.iter().any(|(r, c)| *c == accent && (r.width - 40.0).abs() < 0.5), "on ⇒ accent 轨道");
assert!(
tracks
.iter()
.any(|(r, c)| *c == light.control_border && (r.width - 40.0).abs() < 0.5),
"off ⇒ 边框色轨道"
);
}
#[test]
fn radio_draws_a_dot_only_when_selected() {
let mut t = Track::new();
let root = t.create(Kind::Box, None);
t.add_root(Layer::Content, None, root);
let sel = node(&mut t, Kind::Radio { selected: true, value: "a".into() }, 18.0, 18.0);
let unsel = node(&mut t, Kind::Radio { selected: false, value: "b".into() }, 18.0, 18.0);
t.append_child(root, sel);
t.append_child(root, unsel);
layout(&mut t, Size::new(200.0, 100.0));
let accent = crate::theme::Theme::light().accent;
let scene = build(&t);
let dots = scene.ops().iter().filter(|op| matches!(op,
Op::Rect { color, .. } if *color == accent));
assert_eq!(dots.count(), 1, "只有选中的那项画圆点");
}
#[test]
fn focus_ring_uses_the_theme_token() {
let mut t = Track::new();
let root = t.create(Kind::Box, None);
t.add_root(Layer::Content, None, root);
let id = node(&mut t, Kind::Button { label: "ok".into() }, 60.0, 24.0);
{
let n = t.get_mut(id).unwrap();
n.interaction.focused = true;
n.focus_state = crate::track::FocusState::Keyboard;
}
t.append_child(root, id);
layout(&mut t, Size::new(200.0, 200.0));
let dark = crate::theme::Theme::dark();
let mut o = opts();
o.focus_ring = true;
o.theme = dark;
let scene = SceneBuilder::new().build(&t, &o, &[], true);
let borders: Vec<Color> = scene
.ops()
.iter()
.filter_map(|op| match op {
Op::Border { color, .. } => Some(*color),
_ => None,
})
.collect();
assert!(
borders.contains(&dark.focus_ring),
"焦点框用主题 token:{borders:?}"
);
}
#[test]
fn shadow_is_offset_and_expanded() {
let mut t = Track::new();
let root = t.create(Kind::Box, None);
t.add_root(Layer::Content, None, root);
let id = node(&mut t, Kind::Box, 40.0, 40.0);
t.get_mut(id).unwrap().paint = PaintStyle::new().shadow(crate::style::ShadowSpec::new(
4.0,
6.0,
8.0,
2.0,
Color::rgba(0, 0, 0, 60),
));
t.append_child(root, id);
layout(&mut t, Size::new(200.0, 200.0));
let scene = build(&t);
let Op::Shadow {
rect,
std_dev,
color,
..
} = scene
.ops()
.iter()
.find(|op| matches!(op, Op::Shadow { .. }))
.unwrap()
else {
unreachable!()
};
assert_eq!(*rect, Rect::new(4.0 - 2.0, 6.0 - 2.0, 44.0, 44.0));
assert_eq!(*std_dev, 4.0);
assert_eq!(color.a, 60);
}
}