use std::path::PathBuf;
use crate::signal::Signal;
use crate::event::{
CursorShape, Event, KeyEvent, MenuItem, MenuRequest, MouseButton, PointerEvent, PointerKind,
WindowOp,
};
use crate::geometry::{Color, Insets, Point, Rect, Size};
use crate::render::{Canvas, Paint};
use crate::spec::{Align, Axis, Dimension, MeasureMode, MeasureSpec};
use crate::style::Style;
use crate::text::TextEngine;
pub type ClickFn = Box<dyn FnMut(&mut EventCtx)>;
pub type DropFn = Box<dyn FnMut(&mut EventCtx, &[PathBuf])>;
pub type MenuFn = Box<dyn FnMut() -> Vec<crate::event::MenuItem>>;
const DAMAGE_MARGIN: i32 = 2;
pub trait ClipboardProvider {
fn get_text(&self) -> Option<String>;
fn set_text(&self, text: &str);
}
#[derive(Clone, Copy, PartialEq, Eq, Debug)]
pub struct NodeId {
index: u32,
generation: u32,
}
pub trait Widget {
fn measure(&self, _avail: Size, _style: &Style, _text: &mut dyn TextEngine) -> Size {
Size::ZERO
}
fn paint(
&self,
_bounds: Rect,
_content: Rect,
_focused: bool,
_enabled: bool,
_canvas: &mut dyn Canvas,
_style: &Style,
) {
}
fn on_event(&mut self, _ctx: &mut EventCtx, _ev: &Event) -> bool {
false
}
fn focusable(&self) -> bool {
false
}
fn take_click(&mut self, _f: ClickFn) {}
fn as_any_mut(&mut self) -> Option<&mut dyn std::any::Any> {
None
}
fn ime_caret(&self) -> Option<(i32, i32, i32)> {
None
}
fn wants_right_click(&self) -> bool {
false
}
fn cursor(&self) -> CursorShape {
CursorShape::Arrow
}
}
pub struct EmptyWidget;
impl Widget for EmptyWidget {}
impl Node {
pub fn effective_visible(&self) -> bool {
self.visible && self.vis_cond.as_ref().map(|f| f()).unwrap_or(true)
}
pub fn own_enabled(&self) -> bool {
self.enabled.as_ref().is_none_or(|c| c.get())
}
}
#[derive(Clone, Copy)]
pub enum Layout {
None,
Linear {
axis: Axis,
spacing: i32,
cross: Align,
},
Frame,
Scroll,
}
pub struct Node {
pub parent: Option<NodeId>,
pub children: Vec<NodeId>,
pub bounds: Rect,
pub measured: Size,
pub width: Dimension,
pub height: Dimension,
pub padding: Insets,
pub margin: Insets,
pub align: Option<Align>,
pub layout: Layout,
pub widget: Box<dyn Widget>,
pub style: Style,
pub visible: bool,
pub vis_cond: Option<Box<dyn Fn() -> bool>>,
pub enabled: Option<Signal<bool>>,
pub on_drop: Option<DropFn>,
pub context_menu: Option<MenuFn>,
pub window_drag: bool,
pub tooltip: Option<String>,
pub focused: bool,
pub clip_children: bool,
pub scroll_y: i32,
pub content_h: i32,
pub over_scroll: i32,
}
struct Slot {
generation: u32,
node: Option<Node>,
}
pub struct Tree {
slots: Vec<Slot>,
free: Vec<u32>,
pub root: Option<NodeId>,
pub focus_ring_visible: bool,
pub clipboard: Option<Box<dyn ClipboardProvider>>,
}
impl Default for Tree {
fn default() -> Self {
Self::new()
}
}
impl Tree {
pub fn new() -> Self {
Self {
slots: Vec::new(),
free: Vec::new(),
root: None,
focus_ring_visible: false,
clipboard: None,
}
}
pub fn insert(&mut self, node: Node) -> NodeId {
if let Some(idx) = self.free.pop() {
let slot = &mut self.slots[idx as usize];
slot.node = Some(node);
NodeId {
index: idx,
generation: slot.generation,
}
} else {
let idx = self.slots.len() as u32;
self.slots.push(Slot {
generation: 0,
node: Some(node),
});
NodeId {
index: idx,
generation: 0,
}
}
}
pub fn get(&self, id: NodeId) -> Option<&Node> {
let slot = self.slots.get(id.index as usize)?;
if slot.generation == id.generation {
slot.node.as_ref()
} else {
None
}
}
pub fn get_mut(&mut self, id: NodeId) -> Option<&mut Node> {
let slot = self.slots.get_mut(id.index as usize)?;
if slot.generation == id.generation {
slot.node.as_mut()
} else {
None
}
}
pub fn remove(&mut self, id: NodeId) {
let children = match self.get(id) {
Some(n) => n.children.clone(),
None => return,
};
for c in children {
self.remove(c);
}
if let Some(slot) = self.slots.get_mut(id.index as usize) {
if slot.generation == id.generation {
slot.node = None;
slot.generation = slot.generation.wrapping_add(1);
self.free.push(id.index);
}
}
}
pub fn add_child(&mut self, parent: NodeId, child: NodeId) {
if let Some(p) = self.get_mut(parent) {
p.children.push(child);
}
if let Some(c) = self.get_mut(child) {
c.parent = Some(parent);
}
}
fn visible_children(&self, id: NodeId) -> Vec<NodeId> {
match self.get(id) {
Some(n) => n
.children
.iter()
.copied()
.filter(|c| self.get(*c).map(|n| n.effective_visible()).unwrap_or(false))
.collect(),
None => Vec::new(),
}
}
fn measured_of(&self, id: NodeId) -> Size {
self.get(id).map(|n| n.measured).unwrap_or(Size::ZERO)
}
fn margin_of(&self, id: NodeId) -> Insets {
self.get(id).map(|n| n.margin).unwrap_or_default()
}
pub fn layout_root(&mut self, size: Size, text: &mut dyn TextEngine) {
if let Some(root) = self.root {
self.measure(
root,
MeasureSpec::exactly(size.w),
MeasureSpec::exactly(size.h),
text,
);
self.arrange(root, Rect::from_size(size));
}
}
fn measure(
&mut self,
id: NodeId,
wspec: MeasureSpec,
hspec: MeasureSpec,
text: &mut dyn TextEngine,
) -> Size {
let (layout, padding, visible) = match self.get(id) {
Some(n) => (n.layout, n.padding, n.effective_visible()),
None => return Size::ZERO,
};
if !visible {
if let Some(n) = self.get_mut(id) {
n.measured = Size::ZERO;
}
return Size::ZERO;
}
let avail_w = (wspec.avail() - padding.horizontal()).max(0);
let avail_h = (hspec.avail() - padding.vertical()).max(0);
let content = match layout {
Layout::None => {
let n = self.get(id).unwrap();
crate::text::set_weight(n.style.font_weight);
let m = n
.widget
.measure(Size::new(avail_w, avail_h), &n.style, text);
crate::text::set_weight(crate::text::WEIGHT_NORMAL);
m
}
Layout::Linear { axis, spacing, .. } => {
self.measure_linear(id, axis, spacing, wspec, hspec, avail_w, avail_h, text)
}
Layout::Frame => self.measure_frame(id, wspec, hspec, avail_w, avail_h, text),
Layout::Scroll => self.measure_scroll(id, avail_w, text),
};
let desired_w = content.w + padding.horizontal();
let desired_h = content.h + padding.vertical();
let size = Size::new(wspec.resolve(desired_w), hspec.resolve(desired_h));
if let Some(n) = self.get_mut(id) {
n.measured = size;
}
size
}
#[allow(clippy::too_many_arguments)]
fn measure_linear(
&mut self,
id: NodeId,
axis: Axis,
spacing: i32,
wspec: MeasureSpec,
hspec: MeasureSpec,
avail_w: i32,
avail_h: i32,
text: &mut dyn TextEngine,
) -> Size {
let horizontal = axis == Axis::Horizontal;
let (main_spec, cross_spec) = if horizontal {
(wspec, hspec)
} else {
(hspec, wspec)
};
let main_avail = if horizontal { avail_w } else { avail_h };
let cross_avail = if horizontal { avail_h } else { avail_w };
let main_unbounded = main_spec.mode == MeasureMode::Unbounded;
let cross_unbounded = cross_spec.mode == MeasureMode::Unbounded;
let children = self.visible_children(id);
let mut used_main = 0;
let mut max_cross = 0;
let mut total_weight = 0.0f32;
let mut weighted: Vec<NodeId> = Vec::new();
for &c in &children {
let (cw, ch, cm) = {
let n = self.get(c).unwrap();
(n.width, n.height, n.margin)
};
let main_dim = if horizontal { cw } else { ch };
let cross_dim = if horizontal { ch } else { cw };
let (cm_main, cm_cross) = main_cross_insets(horizontal, cm);
if main_dim.is_weight() {
total_weight += main_dim.weight();
used_main += cm_main; weighted.push(c);
continue;
}
let main_eff = if matches!(main_dim, Dimension::Match) {
Dimension::Wrap
} else {
main_dim
};
let main_child = child_spec(main_eff, main_avail, main_unbounded);
let cross_child = child_spec(cross_dim, cross_avail, cross_unbounded);
let (cwspec, chspec) = if horizontal {
(main_child, cross_child)
} else {
(cross_child, main_child)
};
let s = self.measure(c, cwspec, chspec, text);
let (s_main, s_cross) = main_cross(horizontal, s);
used_main += s_main + cm_main;
max_cross = max_cross.max(s_cross + cm_cross);
}
let gaps = spacing * (children.len() as i32 - 1).max(0);
used_main += gaps;
if total_weight > 0.0 && !main_unbounded {
let remaining = (main_avail - used_main).max(0);
let mut allocated = 0;
let last = weighted.len().saturating_sub(1);
for (i, &c) in weighted.iter().enumerate() {
let (cw, ch, cm) = {
let n = self.get(c).unwrap();
(n.width, n.height, n.margin)
};
let w = if horizontal { cw.weight() } else { ch.weight() };
let portion = if i == last {
(remaining - allocated).max(0)
} else {
(remaining as f32 * w / total_weight) as i32
};
allocated += portion;
let main_child = MeasureSpec::exactly(portion);
let cross_child = child_spec(
if horizontal { ch } else { cw },
cross_avail,
cross_unbounded,
);
let (cwspec, chspec) = if horizontal {
(main_child, cross_child)
} else {
(cross_child, main_child)
};
let s = self.measure(c, cwspec, chspec, text);
let (_, cm_cross) = main_cross_insets(horizontal, cm);
let (s_main, s_cross) = main_cross(horizontal, s);
used_main += s_main; max_cross = max_cross.max(s_cross + cm_cross);
}
}
if horizontal {
Size::new(used_main, max_cross)
} else {
Size::new(max_cross, used_main)
}
}
fn measure_scroll(&mut self, id: NodeId, avail_w: i32, text: &mut dyn TextEngine) -> Size {
let children = self.visible_children(id);
let mut total_h = 0;
let mut max_w = 0;
for &c in &children {
let (cw, ch, cm) = {
let n = self.get(c).unwrap();
(n.width, n.height, n.margin)
};
let cwspec = child_spec(cw, avail_w, false);
let chspec = child_spec(ch, 0, true);
let s = self.measure(c, cwspec, chspec, text);
total_h += s.h + cm.vertical();
max_w = max_w.max(s.w + cm.horizontal());
}
if let Some(n) = self.get_mut(id) {
n.content_h = total_h;
}
Size::new(max_w, total_h)
}
fn measure_frame(
&mut self,
id: NodeId,
wspec: MeasureSpec,
hspec: MeasureSpec,
avail_w: i32,
avail_h: i32,
text: &mut dyn TextEngine,
) -> Size {
let children = self.visible_children(id);
let mut mw = 0;
let mut mh = 0;
for &c in &children {
let (cw, ch, cm) = {
let n = self.get(c).unwrap();
(n.width, n.height, n.margin)
};
let cwspec = child_spec(cw, avail_w, wspec.mode == MeasureMode::Unbounded);
let chspec = child_spec(ch, avail_h, hspec.mode == MeasureMode::Unbounded);
let s = self.measure(c, cwspec, chspec, text);
mw = mw.max(s.w + cm.horizontal());
mh = mh.max(s.h + cm.vertical());
}
Size::new(mw, mh)
}
fn arrange(&mut self, id: NodeId, bounds: Rect) {
let (layout, padding, visible) = match self.get(id) {
Some(n) => (n.layout, n.padding, n.effective_visible()),
None => return,
};
if let Some(n) = self.get_mut(id) {
n.bounds = bounds;
}
if !visible {
return;
}
let inner = Rect::new(
padding.left,
padding.top,
(bounds.w - padding.horizontal()).max(0),
(bounds.h - padding.vertical()).max(0),
);
match layout {
Layout::None => {}
Layout::Linear {
axis,
spacing,
cross,
} => self.arrange_linear(id, inner, axis, spacing, cross),
Layout::Frame => self.arrange_frame(id, inner),
Layout::Scroll => self.arrange_scroll(id, inner),
}
}
fn arrange_scroll(&mut self, id: NodeId, inner: Rect) {
let (content_h, mut scroll_y) = {
let n = self.get(id).unwrap();
(n.content_h, n.scroll_y)
};
let max_scroll = (content_h - inner.h).max(0);
scroll_y = scroll_y.clamp(0, max_scroll);
let over = self.get(id).map(|n| n.over_scroll).unwrap_or(0);
if let Some(n) = self.get_mut(id) {
n.scroll_y = scroll_y;
}
let scrollbar_w = if content_h > inner.h { 8 } else { 0 };
let children = self.visible_children(id);
let mut y = inner.y - scroll_y + over;
for c in children {
let (cs, cm) = (self.measured_of(c), self.margin_of(c));
let cw = (inner.w - scrollbar_w - cm.horizontal()).max(0);
let bounds = Rect::new(inner.x + cm.left, y + cm.top, cw, cs.h);
self.arrange(c, bounds);
y += cs.h + cm.vertical();
}
}
fn arrange_linear(&mut self, id: NodeId, inner: Rect, axis: Axis, spacing: i32, cross: Align) {
let horizontal = axis == Axis::Horizontal;
let children = self.visible_children(id);
let mut cursor = if horizontal { inner.x } else { inner.y };
let cross_start = if horizontal { inner.y } else { inner.x };
let cross_avail_full = if horizontal { inner.h } else { inner.w };
for c in children {
let cs = self.measured_of(c);
let cm = self.margin_of(c);
let (s_main, s_cross) = main_cross(horizontal, cs);
let (cm_main_start, cm_cross_start) = if horizontal {
(cm.left, cm.top)
} else {
(cm.top, cm.left)
};
let cm_cross_total = if horizontal {
cm.vertical()
} else {
cm.horizontal()
};
let cm_main_end = if horizontal { cm.right } else { cm.bottom };
let cross_avail = (cross_avail_full - cm_cross_total).max(0);
let eff_align = self.get(c).and_then(|n| n.align).unwrap_or(cross);
let cross_size = if eff_align == Align::Stretch {
cross_avail
} else {
s_cross
};
let cross_off = align_offset(eff_align, cross_avail, cross_size);
let main_pos = cursor + cm_main_start;
let cross_pos = cross_start + cm_cross_start + cross_off;
let child_bounds = if horizontal {
Rect::new(main_pos, cross_pos, s_main, cross_size)
} else {
Rect::new(cross_pos, main_pos, cross_size, s_main)
};
self.arrange(c, child_bounds);
cursor = main_pos + s_main + cm_main_end + spacing;
}
}
fn arrange_frame(&mut self, id: NodeId, inner: Rect) {
let children = self.visible_children(id);
for c in children {
let cs = self.measured_of(c);
let cm = self.margin_of(c);
let align = self.get(c).and_then(|n| n.align).unwrap_or(Align::Start);
let avail_w = (inner.w - cm.horizontal()).max(0);
let avail_h = (inner.h - cm.vertical()).max(0);
let (cw, ch) = if align == Align::Stretch {
(avail_w, avail_h)
} else {
(cs.w, cs.h)
};
let x = inner.x + cm.left + align_offset(align, avail_w, cw);
let y = inner.y + cm.top + align_offset(align, avail_h, ch);
self.arrange(c, Rect::new(x, y, cw, ch));
}
}
pub fn paint(&self, canvas: &mut dyn Canvas) {
if let Some(root) = self.root {
self.paint_node(canvas, root, Point::new(0, 0), true);
}
}
fn paint_node(&self, canvas: &mut dyn Canvas, id: NodeId, origin: Point, parent_enabled: bool) {
let n = match self.get(id) {
Some(n) if n.effective_visible() => n,
_ => return,
};
let enabled = parent_enabled && n.own_enabled();
let abs = Rect::new(
origin.x + n.bounds.x,
origin.y + n.bounds.y,
n.bounds.w,
n.bounds.h,
);
if abs.is_empty() {
return;
}
let (fx, fy, fw, fh) = (abs.x as f32, abs.y as f32, abs.w as f32, abs.h as f32);
let radius = n.style.corner_radius;
let use_layer = n.style.opacity < 1.0;
if use_layer {
canvas.push_layer(n.style.opacity);
}
let theme = crate::theme::current();
if let Some(sh) = &n.style.shadow {
if sh.color.a > 0 {
let sp = sh.spread;
canvas.draw_shadow(
fx - sp + sh.dx,
fy - sp + sh.dy,
fw + 2.0 * sp,
fh + 2.0 * sp,
(radius + sp).max(0.0),
sh.blur,
sh.color,
);
}
}
if let Some(bg) = &n.style.bg {
canvas.fill_round_rect(fx, fy, fw, fh, radius, &bg.resolve_paint(&theme));
}
if let Some((bc, bw)) = &n.style.border {
if *bw > 0 {
let bp = Paint::fill(bc.solid_color(&theme));
canvas.stroke_round_rect(fx, fy, fw, fh, radius, *bw as f32, &bp);
}
}
let content = abs.inset(n.padding);
crate::anim::set_paint_rect(Some(abs));
crate::text::set_weight(n.style.font_weight);
n.widget
.paint(abs, content, n.focused, enabled, canvas, &n.style);
crate::text::set_weight(crate::text::WEIGHT_NORMAL);
crate::anim::set_paint_rect(None);
if n.focused && self.focus_ring_visible {
let ring = crate::theme::current().palette.accent;
canvas.stroke_round_rect(
fx - 1.0,
fy - 1.0,
fw + 2.0,
fh + 2.0,
radius + 1.0,
2.0,
&Paint::fill(ring),
);
}
let child_origin = Point::new(abs.x, abs.y);
if n.clip_children {
canvas.save();
canvas.clip_rect(content);
for &c in &n.children {
self.paint_node(canvas, c, child_origin, enabled);
}
canvas.restore();
} else {
for &c in &n.children {
self.paint_node(canvas, c, child_origin, enabled);
}
}
if matches!(n.layout, Layout::Scroll) && n.content_h > content.h {
let track_w = 6.0;
let tx = (abs.right() - track_w as i32 - 2) as f32;
let ty = content.y as f32;
let th = content.h as f32;
let ratio = content.h as f32 / n.content_h as f32;
let thumb_h = (th * ratio).max(24.0);
let max_scroll = (n.content_h - content.h).max(1) as f32;
let thumb_y = ty + (th - thumb_h) * (n.scroll_y as f32 / max_scroll);
let thumb = crate::theme::current().palette.border;
canvas.fill_round_rect(
tx,
thumb_y,
track_w,
thumb_h,
track_w / 2.0,
&Paint::fill(thumb),
);
}
if use_layer {
canvas.pop_layer();
}
}
}
#[derive(Clone, Copy, Default, PartialEq, Eq, Debug)]
pub enum DamageReq {
#[default]
None,
Rect(Rect),
Layout(Rect),
Full,
}
impl DamageReq {
fn rank(self) -> u8 {
match self {
DamageReq::None => 0,
DamageReq::Rect(_) => 1,
DamageReq::Layout(_) => 2,
DamageReq::Full => 3,
}
}
pub fn merge(self, o: DamageReq) -> DamageReq {
use DamageReq::*;
match (self, o) {
(Full, _) | (_, Full) => Full,
(Layout(a), Layout(b)) => Layout(a.union(&b)),
(Layout(a), Rect(b)) | (Rect(b), Layout(a)) => Layout(a.union(&b)),
(Rect(a), Rect(b)) => Rect(a.union(&b)),
(a, b) => {
if a.rank() >= b.rank() {
a
} else {
b
}
}
}
}
fn merge_with(&mut self, o: DamageReq) {
*self = (*self).merge(o);
}
}
#[derive(Default)]
pub(crate) struct EventOutcome {
repaint: bool,
damage: DamageReq,
capture: Option<Option<NodeId>>,
close: bool,
focus: Option<NodeId>,
menu: Option<MenuRequest>,
open_url: Option<String>,
window_op: Option<WindowOp>,
}
pub struct EventCtx<'a> {
tree: &'a mut Tree,
self_id: NodeId,
out: EventOutcome,
}
impl EventCtx<'_> {
pub fn id(&self) -> NodeId {
self.self_id
}
pub fn mark_dirty(&mut self) {
let r = self.tree.visual_bounds(self.self_id);
self.out.damage.merge_with(DamageReq::Rect(r));
self.out.repaint = true;
}
pub fn mark_dirty_rect(&mut self, r: Rect) {
self.out.damage.merge_with(DamageReq::Rect(r));
self.out.repaint = true;
}
pub fn mark_layout_dirty(&mut self) {
let r = self.tree.visual_bounds(self.self_id);
self.out.damage.merge_with(DamageReq::Layout(r));
self.out.repaint = true;
}
pub fn mark_dirty_all(&mut self) {
self.out.damage.merge_with(DamageReq::Full);
self.out.repaint = true;
}
pub fn set_bg(&mut self, c: Color) {
if let Some(n) = self.tree.get_mut(self.self_id) {
n.style.bg = Some(crate::style::Brush::Solid(c));
}
self.mark_dirty();
}
pub fn capture(&mut self) {
self.out.capture = Some(Some(self.self_id));
}
pub fn release_capture(&mut self) {
self.out.capture = Some(None);
}
pub fn request_close(&mut self) {
self.out.close = true;
}
pub fn request_focus(&mut self) {
self.out.focus = Some(self.self_id);
}
pub fn bounds(&self) -> Rect {
self.tree.abs_bounds(self.self_id)
}
pub fn scroll_by(&mut self, dy: i32) {
if let Some(n) = self.tree.get_mut(self.self_id) {
n.scroll_y += dy;
}
self.mark_layout_dirty();
}
pub fn scroll_metrics(&self) -> (i32, i32, i32) {
if let Some(n) = self.tree.get(self.self_id) {
let view_h = (n.bounds.h - n.padding.vertical()).max(0);
(n.scroll_y, n.content_h, view_h)
} else {
(0, 0, 0)
}
}
pub fn set_scroll(&mut self, y: i32) {
if let Some(n) = self.tree.get_mut(self.self_id) {
n.scroll_y = y;
}
self.mark_layout_dirty();
}
pub fn clipboard_get(&self) -> Option<String> {
self.tree.clipboard.as_ref().and_then(|c| c.get_text())
}
pub fn clipboard_set(&self, text: &str) {
if let Some(c) = self.tree.clipboard.as_ref() {
c.set_text(text);
}
}
pub fn show_menu(&mut self, pos: Point, items: Vec<MenuItem>, min_width: i32) {
self.out.menu = Some(MenuRequest {
pos,
items,
min_width,
});
self.out.repaint = true;
}
pub fn show_context_menu(&mut self, pos: Point, items: Vec<MenuItem>) {
self.show_menu(pos, items, 0);
}
pub fn open_url(&mut self, url: &str) {
self.out.open_url = Some(url.to_string());
}
pub fn minimize(&mut self) {
self.out.window_op = Some(WindowOp::Minimize);
}
pub fn toggle_maximize(&mut self) {
self.out.window_op = Some(WindowOp::ToggleMaximize);
}
}
#[derive(Default, Clone)]
pub struct DispatchResult {
pub repaint: bool,
pub damage: DamageReq,
pub close: bool,
pub focus: Option<NodeId>,
pub consumed: bool,
pub menu: Option<MenuRequest>,
pub open_url: Option<String>,
pub window_op: Option<WindowOp>,
}
impl Tree {
pub fn node_enabled(&self, id: NodeId) -> bool {
let mut cur = Some(id);
while let Some(i) = cur {
match self.get(i) {
Some(n) => {
if !n.own_enabled() {
return false;
}
cur = n.parent;
}
None => break,
}
}
true
}
pub fn cursor_at(&self, id: NodeId) -> CursorShape {
self.get(id)
.map(|n| n.widget.cursor())
.unwrap_or(CursorShape::Arrow)
}
pub fn node_tooltip(&self, id: NodeId) -> Option<String> {
self.get(id).and_then(|n| n.tooltip.clone())
}
pub fn interactive_hit_at(&self, pos: Point) -> bool {
let Some(hit) = self.hit_test(pos) else {
return false;
};
self.get(hit).map(|n| n.widget.focusable()).unwrap_or(false)
}
pub fn drag_hit_at(&self, pos: Point) -> bool {
let Some(hit) = self.hit_test(pos) else {
return false;
};
if self.get(hit).map(|n| n.widget.focusable()).unwrap_or(false) {
return false;
}
self.ancestor_chain(hit)
.iter()
.any(|&id| self.get(id).map(|n| n.window_drag).unwrap_or(false))
}
pub fn abs_bounds(&self, id: NodeId) -> Rect {
let mut r = match self.get(id) {
Some(n) => n.bounds,
None => return Rect::default(),
};
let mut cur = self.get(id).and_then(|n| n.parent);
while let Some(p) = cur {
match self.get(p) {
Some(pn) => {
r.x += pn.bounds.x;
r.y += pn.bounds.y;
cur = pn.parent;
}
None => break,
}
}
r
}
pub fn visual_bounds(&self, id: NodeId) -> Rect {
let abs = self.abs_bounds(id);
let n = match self.get(id) {
Some(n) => n,
None => return abs,
};
let mut pad = if n.focused { 3 } else { DAMAGE_MARGIN };
if let Some(sh) = &n.style.shadow {
if sh.color.a > 0 {
let ext = (sh.spread + sh.blur).ceil() as i32
+ (sh.dx.abs().max(sh.dy.abs())).ceil() as i32;
pad = pad.max(ext);
}
}
abs.inflate(pad)
}
pub fn layout_signature(&self) -> u64 {
use std::hash::{Hash, Hasher};
let mut h = std::collections::hash_map::DefaultHasher::new();
for (i, slot) in self.slots.iter().enumerate() {
if let Some(n) = &slot.node {
(i as u32).hash(&mut h);
slot.generation.hash(&mut h);
n.effective_visible().hash(&mut h);
let b = n.bounds;
(b.x, b.y, b.w, b.h).hash(&mut h);
}
}
h.finish()
}
pub fn caret_of(&self, id: NodeId) -> Option<(Point, i32)> {
let n = self.get(id)?;
let (lx, ly, h) = n.widget.ime_caret()?;
let abs = self.abs_bounds(id);
Some((Point::new(abs.x + lx, abs.y + ly), h))
}
pub fn scroll_node_at(&self, p: Point) -> Option<NodeId> {
let mut cur = self.hit_test(p);
while let Some(id) = cur {
let n = self.get(id)?;
if matches!(n.layout, Layout::Scroll) {
return Some(id);
}
cur = n.parent;
}
None
}
pub fn scroll_range(&self, id: NodeId) -> Option<(i32, i32)> {
let n = self.get(id)?;
if !matches!(n.layout, Layout::Scroll) {
return None;
}
let view_h = (n.bounds.h - n.padding.vertical()).max(0);
Some((n.scroll_y, (n.content_h - view_h).max(0)))
}
pub fn set_scroll_y(&mut self, id: NodeId, y: i32) -> bool {
match self.get_mut(id) {
Some(n) if matches!(n.layout, Layout::Scroll) => {
n.scroll_y = y;
true
}
_ => false,
}
}
pub fn set_over_scroll(&mut self, id: NodeId, over: i32) {
if let Some(n) = self.get_mut(id) {
n.over_scroll = over;
}
}
pub fn pan_scroll(&mut self, p: Point, dy: i32) -> bool {
if let Some(id) = self.scroll_node_at(p) {
if let Some(n) = self.get_mut(id) {
n.scroll_y -= dy;
}
return true;
}
false
}
pub fn hit_test(&self, p: Point) -> Option<NodeId> {
let root = self.root?;
self.hit_node(root, p, Point::new(0, 0))
}
fn hit_node(&self, id: NodeId, p: Point, origin: Point) -> Option<NodeId> {
let n = self.get(id)?;
if !n.effective_visible() {
return None;
}
let abs = Rect::new(
origin.x + n.bounds.x,
origin.y + n.bounds.y,
n.bounds.w,
n.bounds.h,
);
if !abs.contains(p) {
return None;
}
if matches!(n.layout, Layout::Scroll) {
let content = abs.inset(n.padding);
if n.content_h > content.h && p.x >= abs.right() - 10 {
return Some(id);
}
}
let in_content = if n.clip_children {
abs.inset(n.padding).contains(p)
} else {
true
};
if in_content {
let child_origin = Point::new(abs.x, abs.y);
for &c in n.children.iter().rev() {
if let Some(hit) = self.hit_node(c, p, child_origin) {
return Some(hit);
}
}
}
Some(id)
}
fn ancestor_chain(&self, id: NodeId) -> Vec<NodeId> {
let mut chain = vec![id];
let mut cur = self.get(id).and_then(|n| n.parent);
while let Some(p) = cur {
chain.push(p);
cur = self.get(p).and_then(|n| n.parent);
}
chain
}
pub fn focusable_order(&self) -> Vec<NodeId> {
let mut out = Vec::new();
if let Some(root) = self.root {
self.collect_focusable(root, &mut out);
}
out
}
fn collect_focusable(&self, id: NodeId, out: &mut Vec<NodeId>) {
if let Some(n) = self.get(id) {
if !n.effective_visible() || !n.own_enabled() {
return;
}
if n.widget.focusable() {
out.push(id);
}
for &c in &n.children {
self.collect_focusable(c, out);
}
}
}
fn call_on_event(&mut self, id: NodeId, ev: &Event) -> (bool, EventOutcome) {
if !self.node_enabled(id) {
return (false, EventOutcome::default());
}
let mut widget = match self.get_mut(id) {
Some(n) => std::mem::replace(&mut n.widget, Box::new(EmptyWidget)),
None => return (false, EventOutcome::default()),
};
let mut ctx = EventCtx {
tree: self,
self_id: id,
out: EventOutcome::default(),
};
crate::signal::begin_event();
let consumed = widget.on_event(&mut ctx, ev);
let mut out = ctx.out;
if crate::signal::end_event() {
let r = self.visual_bounds(id);
out.damage = out.damage.merge(DamageReq::Rect(r));
out.repaint = true;
}
match self.get_mut(id) {
Some(n) => n.widget = widget,
None => debug_assert!(
false,
"on_event 回调内删除了 self 节点,违反 call_on_event 契约"
),
}
(consumed, out)
}
pub fn dispatch_pointer(
&mut self,
ev: PointerEvent,
hover: &mut Option<NodeId>,
capture: &mut Option<NodeId>,
) -> DispatchResult {
let mut res = DispatchResult::default();
if matches!(ev.kind, PointerKind::Move) && capture.is_none() {
let target = self.hit_test(ev.pos);
if *hover != target {
if let Some(old) = *hover {
let (_, o) = self.call_on_event(
old,
&Event::Pointer(PointerEvent {
kind: PointerKind::Leave,
..ev
}),
);
res.repaint |= o.repaint;
res.damage = res.damage.merge(o.damage);
}
if let Some(new) = target {
let (_, o) = self.call_on_event(
new,
&Event::Pointer(PointerEvent {
kind: PointerKind::Enter,
..ev
}),
);
res.repaint |= o.repaint;
res.damage = res.damage.merge(o.damage);
}
*hover = target;
}
}
let secondary = matches!(ev.kind, PointerKind::Down | PointerKind::Up)
&& ev.button != MouseButton::Left;
let had_capture = capture.is_some();
let target = capture.or_else(|| self.hit_test(ev.pos));
if let Some(t) = target {
for id in self.ancestor_chain(t) {
if secondary
&& !self
.get(id)
.map(|n| n.widget.wants_right_click() || n.context_menu.is_some())
.unwrap_or(false)
{
continue;
}
let (consumed, o) = self.call_on_event(id, &Event::Pointer(ev));
res.repaint |= o.repaint;
res.damage = res.damage.merge(o.damage);
res.close |= o.close;
res.consumed |= consumed;
if o.focus.is_some() {
res.focus = o.focus;
}
if let Some(cap) = o.capture {
*capture = cap;
}
if o.menu.is_some() {
res.menu = o.menu;
}
if o.open_url.is_some() {
res.open_url = o.open_url;
}
if o.window_op.is_some() {
res.window_op = o.window_op;
}
if secondary && matches!(ev.kind, PointerKind::Down) && res.menu.is_none() {
if let Some(mut cb) = self.get_mut(id).and_then(|n| n.context_menu.take()) {
let items = cb();
if let Some(n) = self.get_mut(id) {
n.context_menu = Some(cb);
}
if !items.is_empty() {
res.menu = Some(crate::event::MenuRequest {
pos: ev.pos,
items,
min_width: 0,
});
res.consumed = true;
}
}
}
if consumed || res.consumed {
break;
}
}
}
if had_capture && capture.is_none() {
let target = self.hit_test(ev.pos);
if *hover != target {
if let Some(old) = *hover {
let (_, o) = self.call_on_event(
old,
&Event::Pointer(PointerEvent {
kind: PointerKind::Leave,
..ev
}),
);
res.repaint |= o.repaint;
res.damage = res.damage.merge(o.damage);
}
if let Some(new) = target {
let (_, o) = self.call_on_event(
new,
&Event::Pointer(PointerEvent {
kind: PointerKind::Enter,
..ev
}),
);
res.repaint |= o.repaint;
res.damage = res.damage.merge(o.damage);
}
*hover = target;
}
}
res
}
pub fn dispatch_key(&mut self, ev: KeyEvent, focus: Option<NodeId>) -> DispatchResult {
let mut res = DispatchResult::default();
if let Some(f) = focus {
let (consumed, o) = self.call_on_event(f, &Event::Key(ev));
res.repaint = o.repaint;
res.damage = o.damage;
res.close = o.close;
res.focus = o.focus;
res.consumed = consumed;
res.menu = o.menu;
res.open_url = o.open_url;
res.window_op = o.window_op;
}
res
}
pub fn dispatch_files(&mut self, pos: Point, paths: Vec<PathBuf>) -> DispatchResult {
let mut res = DispatchResult::default();
let Some(target) = self.hit_test(pos) else {
return res;
};
for id in self.ancestor_chain(target) {
if !self.node_enabled(id) {
continue;
}
let mut cb = match self.get_mut(id).and_then(|n| n.on_drop.take()) {
Some(cb) => cb,
None => continue,
};
let mut ctx = EventCtx {
tree: self,
self_id: id,
out: EventOutcome::default(),
};
cb(&mut ctx, &paths);
let out = ctx.out;
if let Some(n) = self.get_mut(id) {
n.on_drop = Some(cb); }
res.repaint |= out.repaint;
res.damage = res.damage.merge(out.damage);
res.close |= out.close;
res.consumed = true;
if out.focus.is_some() {
res.focus = out.focus;
}
if out.open_url.is_some() {
res.open_url = out.open_url;
}
break; }
res
}
pub fn set_focused(&mut self, id: Option<NodeId>, old: Option<NodeId>) {
if let Some(o) = old {
if let Some(n) = self.get_mut(o) {
n.focused = false;
}
}
if let Some(i) = id {
if let Some(n) = self.get_mut(i) {
n.focused = true;
}
}
}
}
fn child_spec(dim: Dimension, avail: i32, parent_unbounded: bool) -> MeasureSpec {
match dim {
Dimension::Px(v) => MeasureSpec::exactly(v.max(0)),
Dimension::Match => {
if parent_unbounded {
MeasureSpec::unbounded()
} else {
MeasureSpec::exactly(avail.max(0))
}
}
Dimension::Wrap | Dimension::Weight(_) => {
if parent_unbounded {
MeasureSpec::unbounded()
} else {
MeasureSpec::at_most(avail.max(0))
}
}
}
}
fn main_cross(horizontal: bool, s: Size) -> (i32, i32) {
if horizontal {
(s.w, s.h)
} else {
(s.h, s.w)
}
}
fn main_cross_insets(horizontal: bool, i: Insets) -> (i32, i32) {
if horizontal {
(i.horizontal(), i.vertical())
} else {
(i.vertical(), i.horizontal())
}
}
fn align_offset(a: Align, avail: i32, size: i32) -> i32 {
match a {
Align::Start | Align::Stretch => 0,
Align::Center => ((avail - size) / 2).max(0),
Align::End => (avail - size).max(0),
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::event::{Key, KeyEvent, MouseButton, PointerEvent, PointerKind};
use crate::geometry::{Point, Size};
use crate::signal::signal;
use crate::ui::Element;
use std::cell::RefCell;
use std::rc::Rc;
fn layout(root: Element, w: i32, h: i32) -> Tree {
let mut tree = Tree::new();
let id = root.build(&mut tree);
tree.root = Some(id);
let mut te = crate::text::NullTextEngine;
tree.layout_root(Size::new(w, h), &mut te);
tree
}
#[test]
fn weighted_children_with_margin_dont_overflow() {
let tree = layout(
Element::row()
.width(200)
.height(50)
.child(Element::leaf().height(20).margin(10).weight(1.0))
.child(Element::leaf().height(20).margin(10).weight(1.0)),
200,
50,
);
let root = tree.root.unwrap();
let kids = tree.get(root).unwrap().children.clone();
let b0 = tree.get(kids[0]).unwrap().bounds;
let b1 = tree.get(kids[1]).unwrap().bounds;
assert_eq!(b0.w, 80, "首个权重子宽应为 80");
assert_eq!(b1.w, 80, "次个权重子宽应为 80");
assert_eq!(b0.x, 10, "首子左边界=margin");
assert!(
b1.x + b1.w + 10 <= 200,
"右边界 {} 超出 200",
b1.x + b1.w + 10
);
}
#[test]
fn weight_ratio_split_is_pixel_exact() {
let tree = layout(
Element::row()
.width(300)
.height(30)
.child(Element::leaf().weight(1.0))
.child(Element::leaf().weight(2.0)),
300,
30,
);
let root = tree.root.unwrap();
let kids = tree.get(root).unwrap().children.clone();
let b0 = tree.get(kids[0]).unwrap().bounds;
let b1 = tree.get(kids[1]).unwrap().bounds;
assert_eq!(b0.w, 100);
assert_eq!(b1.w, 200);
assert_eq!(b0.w + b1.w, 300, "像素精确:和应等于容器宽");
}
#[test]
fn explicit_start_overrides_container_center() {
let tree = layout(
Element::row()
.width(200)
.height(100)
.cross(Align::Center)
.child(Element::leaf().size(20, 20).align(Align::Start)),
200,
100,
);
let root = tree.root.unwrap();
let kid = tree.get(root).unwrap().children[0];
let b = tree.get(kid).unwrap().bounds;
assert_eq!(b.y, 0, "显式 Start 应贴顶,y=0");
}
fn ptr(kind: PointerKind, p: Point) -> PointerEvent {
PointerEvent::single(kind, p, MouseButton::Left)
}
fn rptr(kind: PointerKind, p: Point) -> PointerEvent {
PointerEvent::single(kind, p, MouseButton::Right)
}
#[test]
fn right_click_does_not_activate_button() {
let clicks = signal(0);
let (mut tree, btn) = button_tree(clicks);
let b = tree.abs_bounds(btn);
let c = Point::new(b.x + b.w / 2, b.y + b.h / 2);
let (mut hover, mut cap) = (None, None);
tree.dispatch_pointer(rptr(PointerKind::Down, c), &mut hover, &mut cap);
tree.dispatch_pointer(rptr(PointerKind::Up, c), &mut hover, &mut cap);
assert_eq!(clicks.get(), 0, "右键不应触发按钮点击");
assert_eq!(cap, None, "右键不应捕获指针");
}
#[test]
fn right_click_does_not_toggle_checkbox() {
let state = signal(false);
let root = Element::col()
.width(200)
.height(40)
.child(Element::checkbox("x", state));
let mut tree = Tree::new();
let id = root.build(&mut tree);
tree.root = Some(id);
let mut te = crate::text::NullTextEngine;
tree.layout_root(Size::new(200, 40), &mut te);
let cb = tree.get(id).unwrap().children[0];
let b = tree.abs_bounds(cb);
let c = Point::new(b.x + 5, b.y + b.h / 2);
let (mut h, mut cap) = (None, None);
tree.dispatch_pointer(rptr(PointerKind::Up, c), &mut h, &mut cap);
assert!(!state.get(), "右键不应切换复选框");
}
fn button_tree(clicks: Signal<i32>) -> (Tree, NodeId) {
let c = clicks;
let root = Element::col()
.width(200)
.height(100)
.padding(10)
.child(Element::button("OK").on_click(move |_| c.set(c.get() + 1)));
let mut tree = Tree::new();
let id = root.build(&mut tree);
tree.root = Some(id);
let mut te = crate::text::NullTextEngine;
tree.layout_root(Size::new(200, 100), &mut te);
let btn = tree.get(id).unwrap().children[0];
(tree, btn)
}
#[test]
fn button_click_fires_callback_and_captures() {
let clicks = signal(0);
let (mut tree, btn) = button_tree(clicks);
let b = tree.abs_bounds(btn);
let center = Point::new(b.x + b.w / 2, b.y + b.h / 2);
let (mut hover, mut cap) = (None, None);
tree.dispatch_pointer(ptr(PointerKind::Down, center), &mut hover, &mut cap);
assert_eq!(cap, Some(btn), "按下应捕获按钮");
assert_eq!(clicks.get(), 0, "按下不触发点击");
tree.dispatch_pointer(ptr(PointerKind::Up, center), &mut hover, &mut cap);
assert_eq!(clicks.get(), 1, "在按钮内释放应触发一次点击");
assert_eq!(cap, None, "释放应取消捕获");
}
#[test]
fn damage_req_merge_precedence() {
let r1 = Rect::new(0, 0, 10, 10);
let r2 = Rect::new(20, 20, 10, 10);
assert_eq!(
DamageReq::None.merge(DamageReq::Rect(r1)),
DamageReq::Rect(r1)
);
assert_eq!(
DamageReq::Rect(r1).merge(DamageReq::Rect(r2)),
DamageReq::Rect(r1.union(&r2))
);
assert_eq!(
DamageReq::Rect(r1).merge(DamageReq::Layout(r2)),
DamageReq::Layout(r1.union(&r2))
);
assert_eq!(
DamageReq::Layout(r1).merge(DamageReq::Full),
DamageReq::Full
);
assert_eq!(DamageReq::Full.merge(DamageReq::Rect(r1)), DamageReq::Full);
}
#[test]
fn button_press_reports_visual_rect_damage() {
let clicks = signal(0);
let (mut tree, btn) = button_tree(clicks);
let b = tree.abs_bounds(btn);
let center = Point::new(b.x + b.w / 2, b.y + b.h / 2);
let (mut hover, mut cap) = (None, None);
let res = tree.dispatch_pointer(ptr(PointerKind::Down, center), &mut hover, &mut cap);
match res.damage {
DamageReq::Rect(r) => {
assert_eq!(r, tree.visual_bounds(btn), "应为按钮视觉矩形")
}
other => panic!("按下应上报 Rect 失效,实得 {other:?}"),
}
}
#[test]
fn release_outside_does_not_click() {
let clicks = signal(0);
let (mut tree, btn) = button_tree(clicks);
let b = tree.abs_bounds(btn);
let center = Point::new(b.x + b.w / 2, b.y + b.h / 2);
let outside = Point::new(b.x + b.w + 60, b.y);
let (mut hover, mut cap) = (None, None);
tree.dispatch_pointer(ptr(PointerKind::Down, center), &mut hover, &mut cap);
tree.dispatch_pointer(ptr(PointerKind::Up, outside), &mut hover, &mut cap);
assert_eq!(clicks.get(), 0, "按钮外释放不应触发点击");
assert_eq!(cap, None);
}
#[test]
fn hover_tracks_pointer() {
let clicks = signal(0);
let (mut tree, btn) = button_tree(clicks);
let b = tree.abs_bounds(btn);
let center = Point::new(b.x + b.w / 2, b.y + b.h / 2);
let outside = Point::new(b.x + b.w + 60, b.y + b.h + 60);
let (mut hover, mut cap) = (None, None);
tree.dispatch_pointer(ptr(PointerKind::Move, center), &mut hover, &mut cap);
assert_eq!(hover, Some(btn), "移入按钮应记录 hover");
tree.dispatch_pointer(ptr(PointerKind::Move, outside), &mut hover, &mut cap);
assert_eq!(hover, None, "移出按钮应清除 hover");
}
#[test]
fn focusable_order_collects_buttons() {
let root = Element::row()
.child(Element::label("x"))
.child(Element::button("A"))
.child(Element::button("B"));
let tree = layout(root, 300, 50);
assert_eq!(tree.focusable_order().len(), 2, "应收集到 2 个可聚焦按钮");
}
#[test]
fn disabled_button_ignores_click_and_skips_focus() {
let clicks = signal(0);
let c = clicks;
let root = Element::col().width(200).height(100).padding(10).child(
Element::button("OK")
.on_click(move |_| c.set(c.get() + 1))
.disabled(true),
);
let mut tree = Tree::new();
let id = root.build(&mut tree);
tree.root = Some(id);
let mut te = crate::text::NullTextEngine;
tree.layout_root(Size::new(200, 100), &mut te);
let btn = tree.get(id).unwrap().children[0];
let b = tree.abs_bounds(btn);
let center = Point::new(b.x + b.w / 2, b.y + b.h / 2);
let (mut hover, mut cap) = (None, None);
tree.dispatch_pointer(ptr(PointerKind::Down, center), &mut hover, &mut cap);
tree.dispatch_pointer(ptr(PointerKind::Up, center), &mut hover, &mut cap);
assert_eq!(clicks.get(), 0, "禁用按钮不应触发点击");
assert!(
!tree.focusable_order().contains(&btn),
"禁用按钮不应进入焦点链"
);
assert!(!tree.node_enabled(btn), "node_enabled 应为 false");
}
#[test]
fn disabled_container_propagates_to_children() {
let root = Element::col()
.disabled(true)
.child(Element::button("A"))
.child(Element::button("B"));
let tree = layout(root, 200, 100);
assert_eq!(tree.focusable_order().len(), 0, "禁用容器内按钮均不可聚焦");
}
fn click(tree: &mut Tree, id: NodeId) {
let b = tree.abs_bounds(id);
let c = Point::new(b.x + b.w / 2, b.y + b.h / 2);
let (mut h, mut cap) = (None, None);
tree.dispatch_pointer(ptr(PointerKind::Down, c), &mut h, &mut cap);
tree.dispatch_pointer(ptr(PointerKind::Up, c), &mut h, &mut cap);
}
#[test]
fn checkbox_binds_and_toggles() {
let st = signal(false);
let root = Element::col()
.width(200)
.height(60)
.padding(5)
.child(Element::checkbox("启用", st));
let mut tree = Tree::new();
let id = root.build(&mut tree);
tree.root = Some(id);
let mut te = crate::text::NullTextEngine;
tree.layout_root(Size::new(200, 60), &mut te);
let cb = tree.get(id).unwrap().children[0];
click(&mut tree, cb);
assert!(st.get(), "点击应选中");
click(&mut tree, cb);
assert!(!st.get(), "再次点击应取消");
}
#[test]
fn checkbox_on_toggle_intercepts_and_is_controlled() {
let st = signal(false);
let fired = signal(0u32);
let f = fired;
let root = Element::col()
.width(200)
.height(60)
.padding(5)
.child(Element::checkbox("启用", st).on_toggle(move |_| f.set(f.get() + 1)));
let mut tree = Tree::new();
let id = root.build(&mut tree);
tree.root = Some(id);
let mut te = crate::text::NullTextEngine;
tree.layout_root(Size::new(200, 60), &mut te);
let cb = tree.get(id).unwrap().children[0];
click(&mut tree, cb);
assert_eq!(fired.get(), 1, "点击应触发 on_toggle 回调");
assert!(!st.get(), "受控:设了 on_toggle 后点击不应自动翻转 state");
st.set(true);
click(&mut tree, cb);
assert_eq!(fired.get(), 2, "再次点击再次回调");
assert!(st.get(), "state 完全由 app 控制");
}
#[test]
fn radio_group_is_exclusive() {
let g = signal(0usize);
let root = Element::row()
.width(360)
.height(40)
.padding(5)
.spacing(20)
.child(Element::radio("A", g, 0))
.child(Element::radio("B", g, 1));
let mut tree = Tree::new();
let id = root.build(&mut tree);
tree.root = Some(id);
let mut te = crate::text::NullTextEngine;
tree.layout_root(Size::new(360, 40), &mut te);
let b1 = tree.get(id).unwrap().children[1];
click(&mut tree, b1);
assert_eq!(g.get(), 1, "点击第二项应使组值为 1");
}
#[test]
fn slider_sets_value_on_press() {
let v = signal(0.0f32);
let root = Element::col()
.width(200)
.height(40)
.child(Element::slider(v).width(100));
let mut tree = Tree::new();
let id = root.build(&mut tree);
tree.root = Some(id);
let mut te = crate::text::NullTextEngine;
tree.layout_root(Size::new(200, 40), &mut te);
let sl = tree.get(id).unwrap().children[0];
let b = tree.abs_bounds(sl);
let right = Point::new(b.x + b.w - 1, b.y + b.h / 2);
let (mut h, mut cap) = (None, None);
tree.dispatch_pointer(ptr(PointerKind::Down, right), &mut h, &mut cap);
assert!(v.get() > 0.9, "在最右端按下应使值接近 1,实际 {}", v.get());
}
#[test]
fn scroll_wheel_offsets_and_clamps() {
let mut sc = Element::scroll().width(100).height(100);
for _ in 0..10 {
sc = sc.child(Element::leaf().width_match().height(30));
}
let mut tree = Tree::new();
let id = sc.build(&mut tree);
tree.root = Some(id);
let mut te = crate::text::NullTextEngine;
tree.layout_root(Size::new(100, 100), &mut te);
assert_eq!(tree.get(id).unwrap().content_h, 300);
let wheel = |d: i32| {
PointerEvent::single(PointerKind::Wheel(d), Point::new(50, 50), MouseButton::Left)
};
let (mut h, mut cap) = (None, None);
tree.dispatch_pointer(wheel(-120), &mut h, &mut cap);
tree.layout_root(Size::new(100, 100), &mut te); assert!(tree.get(id).unwrap().scroll_y > 0, "向下滚应增加偏移");
for _ in 0..20 {
tree.dispatch_pointer(wheel(-120), &mut h, &mut cap);
}
tree.layout_root(Size::new(100, 100), &mut te);
assert_eq!(tree.get(id).unwrap().scroll_y, 200, "应钳制到最大滚动量");
}
#[test]
fn pan_scroll_scrolls_container() {
let mut sc = Element::scroll().width(100).height(100);
for _ in 0..10 {
sc = sc.child(Element::leaf().width_match().height(30));
}
let mut tree = Tree::new();
let id = sc.build(&mut tree);
tree.root = Some(id);
let mut te = crate::text::NullTextEngine;
tree.layout_root(Size::new(100, 100), &mut te); assert!(tree.pan_scroll(Point::new(50, 50), -40), "命中滚动容器");
tree.layout_root(Size::new(100, 100), &mut te); assert_eq!(
tree.get(id).unwrap().scroll_y,
40,
"上滑 40px 应增加 scroll_y"
);
assert!(
!tree.pan_scroll(Point::new(-100, -100), 10),
"命中外返回 false"
);
}
#[test]
fn scroll_range_and_set_for_fling() {
let mut sc = Element::scroll().width(100).height(100);
for _ in 0..10 {
sc = sc.child(Element::leaf().width_match().height(30));
}
let mut tree = Tree::new();
let id = sc.build(&mut tree);
tree.root = Some(id);
let mut te = crate::text::NullTextEngine;
tree.layout_root(Size::new(100, 100), &mut te); assert_eq!(tree.scroll_node_at(Point::new(50, 50)), Some(id));
let (cur, max) = tree.scroll_range(id).expect("滚动节点应有范围");
assert_eq!((cur, max), (0, 200), "初始偏移 0、最大 200");
assert!(tree.set_scroll_y(id, 500), "设置滚动偏移成功");
tree.layout_root(Size::new(100, 100), &mut te);
assert_eq!(tree.scroll_range(id).unwrap().0, 200, "越界应钳制到 max");
let leaf = tree.get(id).unwrap().children[0];
assert!(tree.scroll_range(leaf).is_none(), "非滚动节点无范围");
assert!(!tree.set_scroll_y(leaf, 10), "非滚动节点不可设置滚动");
}
#[test]
fn over_scroll_shifts_content_without_clamping() {
let mut sc = Element::scroll().width(100).height(100);
for _ in 0..10 {
sc = sc.child(Element::leaf().width_match().height(30));
}
let mut tree = Tree::new();
let id = sc.build(&mut tree);
tree.root = Some(id);
let mut te = crate::text::NullTextEngine;
tree.layout_root(Size::new(100, 100), &mut te);
let child0 = tree.get(id).unwrap().children[0];
let y0 = tree.abs_bounds(child0).y;
tree.set_over_scroll(id, 12);
tree.layout_root(Size::new(100, 100), &mut te);
assert_eq!(
tree.get(id).unwrap().over_scroll,
12,
"over_scroll 不参与钳制"
);
assert_eq!(
tree.abs_bounds(child0).y,
y0 + 12,
"内容随 over_scroll 整体偏移"
);
}
#[test]
fn scrollbar_drag_changes_offset() {
let mut sc = Element::scroll().width(100).height(100);
for _ in 0..10 {
sc = sc.child(Element::leaf().width_match().height(30));
}
let mut tree = Tree::new();
let id = sc.build(&mut tree);
tree.root = Some(id);
let mut te = crate::text::NullTextEngine;
tree.layout_root(Size::new(100, 100), &mut te); let (mut h, mut cap) = (None, None);
let down = PointerEvent::single(PointerKind::Down, Point::new(95, 10), MouseButton::Left);
tree.dispatch_pointer(down, &mut h, &mut cap);
assert_eq!(cap, Some(id), "滚动条区域按下应捕获滚动容器");
let mv = PointerEvent::single(PointerKind::Move, Point::new(95, 40), MouseButton::Left);
tree.dispatch_pointer(mv, &mut h, &mut cap);
tree.layout_root(Size::new(100, 100), &mut te);
assert!(tree.get(id).unwrap().scroll_y > 0, "拖动滚动条应增加偏移");
}
#[test]
fn vis_cond_toggles_visibility() {
let flag = signal(false);
let f2 = flag;
let root = Element::col()
.width(100)
.height(100)
.child(Element::button("X").visible_when(move || f2.get()));
let tree = layout(root, 100, 100);
assert_eq!(tree.focusable_order().len(), 0, "隐藏时不可聚焦");
flag.set(true);
assert_eq!(tree.focusable_order().len(), 1, "显示后可聚焦");
}
#[test]
fn text_input_edits_via_keys() {
let txt = signal(String::new());
let root = Element::col()
.width(200)
.height(40)
.child(Element::text_input(txt, "ph"));
let mut tree = Tree::new();
let id = root.build(&mut tree);
tree.root = Some(id);
let mut te = crate::text::NullTextEngine;
tree.layout_root(Size::new(200, 40), &mut te);
let input = tree.get(id).unwrap().children[0];
let key = |k: Key| KeyEvent {
key: k,
pressed: true,
shift: false,
ctrl: false,
};
tree.dispatch_key(key(Key::Char('a')), Some(input));
tree.dispatch_key(key(Key::Char('中')), Some(input));
assert_eq!(txt.get(), "a中", "应插入字符");
tree.dispatch_key(key(Key::Backspace), Some(input));
assert_eq!(txt.get(), "a", "退格应删除一个字符");
}
fn input_tree(initial: &str) -> (Tree, NodeId, Signal<String>) {
let txt = signal(String::from(initial));
let root = Element::col()
.width(200)
.height(40)
.child(Element::text_input(txt, "ph"));
let mut tree = Tree::new();
let id = root.build(&mut tree);
tree.root = Some(id);
let mut te = crate::text::NullTextEngine;
tree.layout_root(Size::new(200, 40), &mut te);
let input = tree.get(id).unwrap().children[0];
(tree, input, txt)
}
#[test]
fn text_input_select_all_and_replace() {
let (mut tree, input, txt) = input_tree("hello");
let k = |key, ctrl| KeyEvent {
key,
pressed: true,
shift: false,
ctrl,
};
tree.dispatch_key(k(Key::Other(0x41), true), Some(input)); tree.dispatch_key(k(Key::Char('X'), false), Some(input));
assert_eq!(txt.get(), "X", "全选后输入应替换全部");
}
#[test]
fn text_input_home_and_delete() {
let (mut tree, input, txt) = input_tree("abc");
let k = |key| KeyEvent {
key,
pressed: true,
shift: false,
ctrl: false,
};
tree.dispatch_key(k(Key::Home), Some(input)); tree.dispatch_key(k(Key::Delete), Some(input)); assert_eq!(txt.get(), "bc", "Home 后 Delete 应删除首字符");
}
#[test]
fn text_input_shift_select_then_backspace() {
let (mut tree, input, txt) = input_tree("abc");
let shift_left = KeyEvent {
key: Key::Left,
pressed: true,
shift: true,
ctrl: false,
};
tree.dispatch_key(shift_left, Some(input));
let bs = KeyEvent {
key: Key::Backspace,
pressed: true,
shift: false,
ctrl: false,
};
tree.dispatch_key(bs, Some(input));
assert_eq!(txt.get(), "ab", "Shift 选区后退格应删除选区");
}
struct SharedClip(Rc<RefCell<String>>);
impl ClipboardProvider for SharedClip {
fn get_text(&self) -> Option<String> {
Some(self.0.borrow().clone())
}
fn set_text(&self, t: &str) {
*self.0.borrow_mut() = t.to_string();
}
}
#[test]
fn text_input_copy_and_paste() {
let clip = Rc::new(RefCell::new(String::new()));
let (mut tree, input, txt) = input_tree("hello");
tree.clipboard = Some(Box::new(SharedClip(clip.clone())));
let k = |key, ctrl| KeyEvent {
key,
pressed: true,
shift: false,
ctrl,
};
tree.dispatch_key(k(Key::Other(0x41), true), Some(input)); tree.dispatch_key(k(Key::Other(0x43), true), Some(input)); assert_eq!(&*clip.borrow(), "hello", "复制应写入剪贴板");
tree.dispatch_key(k(Key::End, false), Some(input)); tree.dispatch_key(k(Key::Other(0x56), true), Some(input)); assert_eq!(txt.get(), "hellohello", "粘贴应在光标处插入剪贴板文本");
}
#[test]
fn password_input_blocks_copy_allows_paste() {
let clip = Rc::new(RefCell::new(String::from("seed")));
let txt = signal(String::from("secret"));
let root = Element::col()
.width(200)
.height(40)
.child(Element::text_input(txt, "ph").password());
let mut tree = Tree::new();
let id = root.build(&mut tree);
tree.root = Some(id);
let mut te = crate::text::NullTextEngine;
tree.layout_root(Size::new(200, 40), &mut te);
let input = tree.get(id).unwrap().children[0];
tree.clipboard = Some(Box::new(SharedClip(clip.clone())));
let k = |key, ctrl| KeyEvent {
key,
pressed: true,
shift: false,
ctrl,
};
tree.dispatch_key(k(Key::Other(0x41), true), Some(input)); tree.dispatch_key(k(Key::Other(0x43), true), Some(input)); assert_eq!(&*clip.borrow(), "seed", "密码模式 Ctrl+C 不得写出明文");
tree.dispatch_key(k(Key::Other(0x56), true), Some(input)); assert_eq!(txt.get(), "seed", "密码模式仍允许粘贴");
}
#[test]
fn triple_click_selects_all() {
let (mut tree, input, txt) = input_tree("hello world");
let b = tree.abs_bounds(input);
let center = Point::new(b.x + b.w / 2, b.y + b.h / 2);
let (mut h, mut cap) = (None, None);
let down = PointerEvent {
kind: PointerKind::Down,
pos: center,
button: MouseButton::Left,
click_count: 3,
};
tree.dispatch_pointer(down, &mut h, &mut cap);
let key = KeyEvent {
key: Key::Char('Z'),
pressed: true,
shift: false,
ctrl: false,
};
tree.dispatch_key(key, Some(input));
assert_eq!(txt.get(), "Z", "三击全选后输入应替换全部");
}
fn multiline_tree(initial: &str) -> (Tree, NodeId, Signal<String>) {
let txt = signal(String::from(initial));
let root = Element::col()
.width(200)
.height(120)
.child(Element::text_input(txt, "ph").multiline().height(120));
let mut tree = Tree::new();
let id = root.build(&mut tree);
tree.root = Some(id);
let mut te = crate::text::NullTextEngine;
tree.layout_root(Size::new(200, 120), &mut te);
let input = tree.get(id).unwrap().children[0];
(tree, input, txt)
}
#[test]
fn multiline_enter_inserts_newline() {
let (mut tree, input, txt) = multiline_tree("ab");
let k = |key| KeyEvent {
key,
pressed: true,
shift: false,
ctrl: false,
};
tree.dispatch_key(k(Key::Enter), Some(input));
tree.dispatch_key(k(Key::Char('c')), Some(input));
assert_eq!(txt.get(), "ab\nc", "多行 Enter 应插入换行符");
}
#[test]
fn singleline_enter_not_consumed() {
let (mut tree, input, txt) = input_tree("ab");
let res = tree.dispatch_key(
KeyEvent {
key: Key::Enter,
pressed: true,
shift: false,
ctrl: false,
},
Some(input),
);
assert!(!res.consumed, "单行 Enter 不应被消费(冒泡给默认行为)");
assert_eq!(txt.get(), "ab", "单行 Enter 不改文本");
}
#[test]
fn multiline_paste_preserves_newlines() {
let clip = Rc::new(RefCell::new(String::from("x\r\ny")));
let (mut tree, input, txt) = multiline_tree("");
tree.clipboard = Some(Box::new(SharedClip(clip)));
tree.dispatch_key(
KeyEvent {
key: Key::Other(0x56),
pressed: true,
shift: false,
ctrl: true,
},
Some(input),
);
assert_eq!(txt.get(), "x\ny", "多行粘贴应保留换行(\\r\\n 归一为 \\n)");
}
#[test]
fn password_multiline_order_still_single_line() {
let txt = signal(String::from("pw"));
let root = Element::col()
.width(200)
.height(40)
.child(Element::text_input(txt, "ph").password().multiline());
let mut tree = Tree::new();
let id = root.build(&mut tree);
tree.root = Some(id);
let mut te = crate::text::NullTextEngine;
tree.layout_root(Size::new(200, 40), &mut te);
let input = tree.get(id).unwrap().children[0];
let res = tree.dispatch_key(
KeyEvent {
key: Key::Enter,
pressed: true,
shift: false,
ctrl: false,
},
Some(input),
);
assert!(!res.consumed, "密码框 Enter 不应被消费");
assert_eq!(txt.get(), "pw", "密码框 Enter 不得插入换行");
}
#[test]
fn caret_of_tracks_cursor_after_paint() {
let (mut tree, input, _txt) = input_tree("hello");
let mut pm = tiny_skia::Pixmap::new(200, 40).unwrap();
let mut eng = crate::text::NullTextEngine;
{
let mut canvas = crate::render::SkiaCanvas::with_text(&mut pm, &mut eng, 1.0);
tree.paint(&mut canvas);
}
let end_caret = tree.caret_of(input).expect("paint 后应有光标位置");
tree.dispatch_key(
KeyEvent {
key: Key::Home,
pressed: true,
shift: false,
ctrl: false,
},
Some(input),
);
{
let mut canvas = crate::render::SkiaCanvas::with_text(&mut pm, &mut eng, 1.0);
tree.paint(&mut canvas);
}
let home_caret = tree.caret_of(input).unwrap();
assert!(home_caret.0.x < end_caret.0.x, "行首光标应在末尾光标左侧");
assert!(home_caret.1 > 0, "光标高度应为正");
}
#[test]
fn caret_of_none_for_non_text() {
let (tree, btn) = button_tree(signal(0));
assert!(
tree.caret_of(btn).is_none(),
"非文本控件 caret_of 应为 None"
);
}
fn paint_once(tree: &Tree) {
let mut pm = tiny_skia::Pixmap::new(200, 60).unwrap();
let mut eng = crate::text::NullTextEngine;
let mut canvas = crate::render::SkiaCanvas::with_text(&mut pm, &mut eng, 1.0);
tree.paint(&mut canvas);
}
#[test]
fn list_click_selects_row() {
let sel = signal(0usize);
let root = Element::col().width(200).height(200).child(
Element::list(vec!["A", "B", "C"], sel)
.width_match()
.height(200),
);
let mut tree = Tree::new();
let id = root.build(&mut tree);
tree.root = Some(id);
let mut te = crate::text::NullTextEngine;
tree.layout_root(Size::new(200, 200), &mut te);
let scroll = tree.get(id).unwrap().children[0];
let rows = tree.get(scroll).unwrap().children.clone();
assert_eq!(rows.len(), 3, "三行");
let b = tree.abs_bounds(rows[1]);
let c = Point::new(b.x + b.w / 2, b.y + b.h / 2);
let (mut h, mut cap) = (None, None);
tree.dispatch_pointer(ptr(PointerKind::Down, c), &mut h, &mut cap);
tree.dispatch_pointer(ptr(PointerKind::Up, c), &mut h, &mut cap);
assert_eq!(sel.get(), 1, "点击第二行应选中索引 1");
}
#[test]
fn stepper_buttons_adjust_and_clamp() {
let v = signal(2.0f64);
let root = Element::col()
.width(120)
.height(40)
.child(Element::stepper(v, 0.0, 3.0, 1.0).width(120));
let mut tree = Tree::new();
let id = root.build(&mut tree);
tree.root = Some(id);
let mut te = crate::text::NullTextEngine;
tree.layout_root(Size::new(120, 40), &mut te);
let st = tree.get(id).unwrap().children[0];
let b = tree.abs_bounds(st);
let cy = b.y + b.h / 2;
let plus = Point::new(b.right() - 5, cy);
let minus = Point::new(b.x + 5, cy);
let (mut h, mut cap) = (None, None);
tree.dispatch_pointer(ptr(PointerKind::Down, plus), &mut h, &mut cap);
assert_eq!(v.get(), 3.0);
tree.dispatch_pointer(ptr(PointerKind::Down, plus), &mut h, &mut cap);
assert_eq!(v.get(), 3.0, "上限钳制");
for _ in 0..4 {
tree.dispatch_pointer(ptr(PointerKind::Down, minus), &mut h, &mut cap);
}
assert_eq!(v.get(), 0.0, "下限钳制");
}
#[test]
fn stepper_degenerate_inputs_no_panic() {
let v = signal(5.0f64);
let root = Element::col()
.width(120)
.height(40)
.child(Element::stepper(v, 10.0, 0.0, 0.0).width(120));
let mut tree = Tree::new();
let id = root.build(&mut tree);
tree.root = Some(id);
let mut te = crate::text::NullTextEngine;
tree.layout_root(Size::new(120, 40), &mut te);
let st = tree.get(id).unwrap().children[0];
let b = tree.abs_bounds(st);
let plus = Point::new(b.right() - 5, b.y + b.h / 2);
let (mut h, mut cap) = (None, None);
tree.dispatch_pointer(ptr(PointerKind::Down, plus), &mut h, &mut cap);
assert_eq!(v.get(), 6.0, "归一后 step=1,5→6");
}
#[test]
fn indeterminate_progress_requests_animation() {
crate::anim::reset_request();
let root = Element::col()
.width(200)
.height(20)
.child(Element::progress_indeterminate().width_match());
let mut tree = Tree::new();
let id = root.build(&mut tree);
tree.root = Some(id);
let mut te = crate::text::NullTextEngine;
tree.layout_root(Size::new(200, 20), &mut te);
paint_once(&tree);
assert!(crate::anim::animation_requested(), "不确定进度应请求动画");
}
#[test]
fn determinate_progress_no_animation() {
crate::anim::reset_request();
let v = signal(0.5f32);
let root = Element::col()
.width(200)
.height(20)
.child(Element::progress(v).width_match());
let mut tree = Tree::new();
let id = root.build(&mut tree);
tree.root = Some(id);
let mut te = crate::text::NullTextEngine;
tree.layout_root(Size::new(200, 20), &mut te);
paint_once(&tree);
assert!(!crate::anim::animation_requested(), "确定进度不应请求动画");
}
#[test]
fn dropdown_click_opens_menu_and_selects() {
let sel = signal(0usize);
let root = Element::col()
.width(220)
.height(40)
.child(Element::dropdown(vec!["A", "B", "C"], sel).width(220));
let mut tree = Tree::new();
let id = root.build(&mut tree);
tree.root = Some(id);
let mut te = crate::text::NullTextEngine;
tree.layout_root(Size::new(220, 40), &mut te);
let dd = tree.get(id).unwrap().children[0];
let b = tree.abs_bounds(dd);
let pos = Point::new(b.x + b.w / 2, b.y + b.h / 2);
let (mut h, mut cap) = (None, None);
tree.dispatch_pointer(ptr(PointerKind::Down, pos), &mut h, &mut cap);
let res = tree.dispatch_pointer(ptr(PointerKind::Up, pos), &mut h, &mut cap);
let menu = res.menu.expect("下拉单击应弹出菜单");
assert_eq!(menu.items.len(), 3, "三个选项");
assert!(menu.items[0].checked, "当前项 A 应勾选");
assert!(!menu.items[1].checked);
if let crate::event::MenuAction::Run(f) = &menu.items[2].action {
f();
} else {
panic!("下拉项应为 Run 动作");
}
assert_eq!(sel.get(), 2, "运行选项动作应设置选中索引");
}
#[test]
fn right_click_requests_context_menu() {
let (mut tree, input, _txt) = input_tree("hello");
let b = tree.abs_bounds(input);
let center = Point::new(b.x + b.w / 2, b.y + b.h / 2);
let (mut h, mut cap) = (None, None);
let down = PointerEvent {
kind: PointerKind::Down,
pos: center,
button: MouseButton::Right,
click_count: 1,
};
let res = tree.dispatch_pointer(down, &mut h, &mut cap);
let menu = res.menu.expect("右键应请求上下文菜单");
let labels: Vec<_> = menu
.items
.iter()
.map(|i| (i.label.as_str(), i.enabled))
.collect();
assert_eq!(
labels,
vec![
("剪切", false),
("复制", false),
("粘贴", true),
("全选", true)
]
);
}
#[test]
fn on_context_menu_opens_cascading_menu_on_right_click() {
use crate::event::MenuItem;
use crate::ui::Element;
let tree_el = Element::col().fill().on_context_menu(|| {
vec![
MenuItem::run("剪切", || {}, false).with_icon("✂"),
MenuItem::separator(),
MenuItem::submenu("更多", vec![MenuItem::run("子项", || {}, false)]).with_icon("⋯"),
]
});
let mut tree = layout(tree_el, 200, 200);
let (mut h, mut cap) = (None, None);
let down = PointerEvent {
kind: PointerKind::Down,
pos: Point::new(100, 100),
button: MouseButton::Right,
click_count: 1,
};
let res = tree.dispatch_pointer(down, &mut h, &mut cap);
let menu = res.menu.expect("右键容器应请求上下文菜单");
assert_eq!(menu.pos, Point::new(100, 100));
assert_eq!(menu.items.len(), 3);
assert_eq!(menu.items[0].icon.as_deref(), Some("✂"));
assert!(menu.items[1].separator);
assert_eq!(menu.items[2].submenu.len(), 1, "子菜单项应携带级联项");
assert!(!menu.items[2].is_actionable(), "子菜单父项不可直接执行");
}
#[test]
fn right_click_menu_enables_cut_copy_with_selection() {
let (mut tree, input, _txt) = input_tree("hello");
let k = |key, ctrl| KeyEvent {
key,
pressed: true,
shift: false,
ctrl,
};
tree.dispatch_key(k(Key::Other(0x41), true), Some(input)); let b = tree.abs_bounds(input);
let pos = Point::new(b.x + 5, b.y + b.h / 2);
let (mut h, mut cap) = (None, None);
let down = PointerEvent {
kind: PointerKind::Down,
pos,
button: MouseButton::Right,
click_count: 1,
};
let res = tree.dispatch_pointer(down, &mut h, &mut cap);
let menu = res.menu.expect("右键应请求上下文菜单");
assert!(
menu.items[0].enabled && menu.items[1].enabled,
"有选区时剪切/复制应启用"
);
}
#[test]
fn double_click_selects_word() {
let (mut tree, input, txt) = input_tree("hello world");
let b = tree.abs_bounds(input);
let center = Point::new(b.x + b.w / 2, b.y + b.h / 2);
let (mut h, mut cap) = (None, None);
let down = PointerEvent {
kind: PointerKind::Down,
pos: center,
button: MouseButton::Left,
click_count: 2,
};
tree.dispatch_pointer(down, &mut h, &mut cap);
let key = KeyEvent {
key: Key::Char('Z'),
pressed: true,
shift: false,
ctrl: false,
};
tree.dispatch_key(key, Some(input));
assert_eq!(txt.get(), "Z world", "双击应选中首词并被输入替换");
}
}