use guiduck_scene::geom::{Rect, Size}; use guiduck_scene::paint::color::palette::css; use guiduck_scene::{DisplayItem, FragmentStore}; use taffy::prelude::{auto, length, percent}; use super::*; use crate::text::TextContext; fn tree() -> WidgetTree { // Layout tests must not depend on machine fonts. WidgetTree::with_text_context(TextContext::hermetic([sample_font()])) } fn sample_font() -> guiduck_scene::paint::Blob { static FONT: &[u8] = include_bytes!("../../../guiduck-samples/fonts/DejaVuSans.ttf"); guiduck_scene::paint::Blob::new(std::sync::Arc::new(FONT)) } fn row_style(gap: f32, padding: f32) -> taffy::Style { taffy::Style { size: taffy::Size { width: percent(1.0_f32), height: percent(1.0_f32), }, flex_direction: taffy::FlexDirection::Row, gap: taffy::Size { width: length(gap), height: length(gap), }, padding: taffy::Rect::length(padding), ..Default::default() } } #[test] fn flex_row_with_gap_and_padding() { let mut tree = tree(); let root = tree.insert(Container::new(), row_style(10.0, 8.0), None); let a = tree.insert( Container::new(), taffy::Style { size: taffy::Size { width: length(100.0_f32), height: length(50.0_f32), }, ..Default::default() }, Some(root), ); let b = tree.insert( Container::new(), taffy::Style { size: taffy::Size { width: length(60.0_f32), height: auto(), }, flex_grow: 0.0, align_self: Some(taffy::AlignItems::STRETCH), ..Default::default() }, Some(root), ); tree.compute_layout(Size::new(400.0, 300.0)); assert_eq!(tree.layout(root), Rect::new(0.0, 0.0, 400.0, 300.0)); assert_eq!(tree.layout(a), Rect::new(8.0, 8.0, 108.0, 58.0)); // After a and the 10px gap; stretches to the padded height. assert_eq!(tree.layout(b), Rect::new(118.0, 8.0, 178.0, 292.0)); } #[test] fn text_measures_and_wraps() { let mut tree = tree(); // The root is the viewport; what this needs from it is only that it not // stretch the text, so the height measured is the text's own. let root = tree.insert( Container::new(), taffy::Style { align_items: Some(taffy::AlignItems::FLEX_START), ..Default::default() }, None, ); let text = tree.insert( Text::new("one two three four five six seven eight nine ten") .font_size(16.0) .family(SAMPLE_FAMILY), taffy::Style::default(), Some(root), ); tree.compute_layout(Size::new(500.0, 400.0)); let wide = tree.layout(text); let mut narrow_tree = self::tree(); let root2 = narrow_tree.insert( Container::new(), taffy::Style { align_items: Some(taffy::AlignItems::FLEX_START), ..Default::default() }, None, ); let text2 = narrow_tree.insert( Text::new("one two three four five six seven eight nine ten") .font_size(16.0) .family(SAMPLE_FAMILY), taffy::Style::default(), Some(root2), ); narrow_tree.compute_layout(Size::new(120.0, 400.0)); let narrow = narrow_tree.layout(text2); assert!(wide.height() > 0.0); assert!( narrow.height() > wide.height() * 1.5, "narrow viewport should wrap to more lines: wide {wide:?} narrow {narrow:?}" ); assert!(narrow.width() <= 120.0 + f64::EPSILON); } const SAMPLE_FAMILY: &str = "DejaVu Sans"; #[test] fn image_keeps_aspect_ratio() { let mut tree = tree(); let image = guiduck_scene::paint::ImageBrush::from(guiduck_scene::paint::ImageData { data: guiduck_scene::paint::Blob::new(std::sync::Arc::new(vec![0u8; 40 * 20 * 4])), format: guiduck_scene::paint::ImageFormat::Rgba8, alpha_type: guiduck_scene::paint::ImageAlphaType::Alpha, width: 40, height: 20, }); let root = tree.insert( Container::new(), taffy::Style { size: taffy::Size { width: percent(1.0_f32), height: percent(1.0_f32), }, ..Default::default() }, None, ); let img = tree.insert( Image::new(crate::graphic::Graphic::Image(image)), taffy::Style { size: taffy::Size { width: length(80.0_f32), height: auto(), }, // Without this, flexbox's default stretch overrides the measured // height — correct CSS behavior, but not what this test checks. align_self: Some(taffy::AlignItems::FLEX_START), ..Default::default() }, Some(root), ); tree.compute_layout(Size::new(200.0, 200.0)); let rect = tree.layout(img); assert_eq!(rect.width(), 80.0); assert_eq!(rect.height(), 40.0, "2:1 aspect preserved"); } #[test] fn paint_composes_child_fragments_at_layout_positions() { let mut tree = tree(); let root = tree.insert( Container::new().background(css::WHITE), row_style(0.0, 20.0), None, ); tree.insert( Container::new().background(css::RED), taffy::Style { size: taffy::Size { width: length(50.0_f32), height: length(50.0_f32), }, ..Default::default() }, Some(root), ); tree.compute_layout(Size::new(300.0, 100.0)); let mut store = FragmentStore::new(); let frame = tree.paint_fresh(&mut store).expect("tree has a root"); // The frame fragment wraps the base root (and any overlays) as child // references; with no overlays it holds exactly one, at identity. let frame_fragment = store.get(frame).unwrap(); let root_fragment = match frame_fragment.items.as_slice() { [ DisplayItem::Child { transform, fragment, }, ] => { assert_eq!(*transform, Affine::IDENTITY); *fragment } items => panic!("frame should hold one child reference, got {items:?}"), }; let fragment = store.get(root_fragment).unwrap(); assert!(fragment.is_balanced()); // Background fill plus one child reference placed at the padded origin. let child = fragment .items .iter() .find_map(|item| match item { DisplayItem::Child { transform, fragment, } => Some((*transform, *fragment)), _ => None, }) .expect("root has a child item"); assert_eq!( child.0 * guiduck_scene::geom::Point::ORIGIN, guiduck_scene::geom::Point::new(20.0, 20.0) ); let child_fragment = store.get(child.1).unwrap(); assert!( child_fragment .items .iter() .any(|i| matches!(i, DisplayItem::Fill { .. })) ); } #[test] fn accessibility_tree_has_roles_and_absolute_bounds() { let mut tree = tree(); let root = tree.insert(Container::new(), row_style(0.0, 10.0), None); let label = tree.insert( Text::new("hello").family(SAMPLE_FAMILY), taffy::Style::default(), Some(root), ); tree.compute_layout(Size::new(200.0, 100.0)); let update = tree.accessibility_tree(); let get = |id: accesskit::NodeId| update.nodes.iter().find(|(n, _)| *n == id).map(|(_, n)| n); let window = get(accesskit::NodeId(u64::MAX)).expect("window root present"); assert_eq!(window.role(), accesskit::Role::Window); let label_node = get(super::access_id(label)).expect("label node present"); assert_eq!(label_node.role(), accesskit::Role::Label); let bounds = label_node.bounds().expect("label has bounds"); // Absolute coordinates include the parent's 10px padding. assert_eq!(bounds.x0, 10.0); assert_eq!(bounds.y0, 10.0); assert_eq!(label_node.value(), Some("hello")); let root_node = get(super::access_id(root)).expect("root node present"); assert_eq!(root_node.role(), accesskit::Role::GenericContainer); assert_eq!(root_node.children(), &[super::access_id(label)]); } #[test] fn tree_without_focusables_has_no_focus() { let mut tree = tree(); let root = tree.insert(Container::new(), row_style(0.0, 0.0), None); tree.insert(Container::new(), taffy::Style::default(), Some(root)); tree.render_frame(Size::new(100.0, 100.0)); assert_eq!(tree.focus(), None); } /// A focusable widget with a chosen cursor shape, for hover/cursor and /// focus-restyle tests. struct StateProbe { shape: CursorShape, } impl StateProbe { fn new(shape: CursorShape) -> Self { Self { shape } } } impl Widget for StateProbe { fn cursor(&self, _local: Point) -> CursorShape { self.shape } fn focusable(&self) -> bool { true } } fn probe_fixture() -> (WidgetTree, WidgetId) { let mut tree = tree(); let root = tree.insert(Container::new(), row_style(0.0, 10.0), None); let probe = tree.insert( StateProbe::new(CursorShape::Pointer), taffy::Style { size: taffy::Size { width: length(40.0_f32), height: length(40.0_f32), }, ..Default::default() }, Some(root), ); (tree, probe) } #[test] fn focus_moves_mark_both_ends_style_dirty() { let mut tree = tree(); let root = tree.insert(Container::new(), row_style(0.0, 0.0), None); let style = || taffy::Style { size: taffy::Size { width: length(40.0_f32), height: length(40.0_f32), }, ..Default::default() }; let a = tree.insert(StateProbe::new(CursorShape::Default), style(), Some(root)); let b = tree.insert(StateProbe::new(CursorShape::Default), style(), Some(root)); tree.render_frame(Size::new(100.0, 50.0)); assert_eq!(tree.focus(), Some(a)); // Moving focus must restyle both the old and new focus holders so a // theme's `:focus` rule can apply and unapply. The style pass drains // `style_dirty`, so it is empty here; set_focus repopulates it. assert!(tree.style_dirty.is_empty()); tree.set_focus(Some(b)); assert!(tree.style_dirty.contains(&a), "old focus holder restyled"); assert!(tree.style_dirty.contains(&b), "new focus holder restyled"); } #[test] fn cursor_shape_comes_from_the_hovered_widget() { let (mut tree, probe) = probe_fixture(); tree.render_frame(Size::new(200.0, 100.0)); assert_eq!(tree.cursor_shape(), CursorShape::Default); tree.dispatch_pointer( crate::event::PointerInput::Moved(Point::new(30.0, 30.0)), &mut crate::clipboard::NoClipboard, ); assert_eq!(tree.cursor_shape(), CursorShape::Pointer); // Disabling retargets hover to the enabled ancestor, so the disabled // control shows the default cursor, not its own. tree.set_enabled(probe, false); tree.dispatch_pointer( crate::event::PointerInput::Moved(Point::new(31.0, 30.0)), &mut crate::clipboard::NoClipboard, ); assert_eq!(tree.cursor_shape(), CursorShape::Default); tree.dispatch_pointer( crate::event::PointerInput::Moved(Point::new(190.0, 90.0)), &mut crate::clipboard::NoClipboard, ); assert_eq!(tree.cursor_shape(), CursorShape::Default); } /// A widget outside the framework's own vocabulary, standing in for one an /// app registers: the case `bind_dyn` exists for is precisely a widget whose /// type the code installing the binding cannot name. Its two setters /// classify their dirt differently, so a test can tell which one ran and /// with what class. #[derive(Default)] struct BindProbe { label: String, highlight: bool, dirt: Dirt, } impl BindProbe { /// Layout-classed, as text content is. fn set_label(&mut self, label: String) { if self.label != label { self.label = label; self.dirt.mark_layout(); } } /// Paint-classed: appearance within the same geometry. fn set_highlight(&mut self, highlight: bool) { if self.highlight != highlight { self.highlight = highlight; self.dirt.mark_paint(); } } } impl Widget for BindProbe { fn take_dirt(&mut self) -> Dirt { std::mem::take(&mut self.dirt) } } /// The thunks a generated setter table would hold for [`BindProbe`]: the /// widget's type and the value's are both recovered inside the closure. fn label_thunk() -> SetterThunk { Rc::new(|widget, value| { if let (Some(widget), Ok(label)) = ( widget.downcast_mut::(), value.downcast::(), ) { widget.set_label(*label); } }) } fn highlight_thunk() -> SetterThunk { Rc::new(|widget, value| { if let (Some(widget), Ok(highlight)) = (widget.downcast_mut::(), value.downcast::()) { widget.set_highlight(*highlight); } }) } /// Run the reactive and command halves of a frame — the two stages a binding /// travels — and return the dirt they raised, leaving the tree clean for the /// next observation. Stopping short of `render_frame` is what lets a test /// see the dirt itself rather than its consequences. fn settle(tree: &mut WidgetTree) -> Vec<(WidgetId, Dirt)> { guiduck_signals::flush_effects(); tree.drain_commands(); std::mem::take(&mut tree.dirt) } fn bind_probe_fixture() -> (WidgetTree, WidgetId) { let mut tree = tree(); let root = tree.insert(Container::new(), row_style(0.0, 0.0), None); let probe = tree.insert(BindProbe::default(), taffy::Style::default(), Some(root)); // Insertion's own structural dirt is not what these tests are about. settle(&mut tree); (tree, probe) } #[test] fn bind_dyn_applies_value_and_raises_the_setters_dirt() { let (mut tree, probe) = bind_probe_fixture(); let label = guiduck_signals::Signal::new(String::from("first")); tree.bind_dyn( probe, move || Box::new(label.get()) as Box, label_thunk(), ); assert_eq!(settle(&mut tree), [(probe, Dirt::LAYOUT)]); assert_eq!(tree.widget::(probe).unwrap().label, "first"); label.set(String::from("second")); assert_eq!(settle(&mut tree), [(probe, Dirt::LAYOUT)]); assert_eq!(tree.widget::(probe).unwrap().label, "second"); // The setter compares and sets, so a mutation that changes nothing marks // nothing — the erased borrow must not manufacture dirt of its own. tree.widget_dyn_mut(probe) .expect("probe exists") .downcast_mut::() .expect("probe is a BindProbe") .set_label(String::from("second")); assert_eq!(settle(&mut tree), []); } #[test] fn bind_dyn_carries_the_paint_class_too() { let (mut tree, probe) = bind_probe_fixture(); let highlight = guiduck_signals::Signal::new(false); tree.bind_dyn( probe, move || Box::new(highlight.get()) as Box, highlight_thunk(), ); // The first run applies `false` over the default `false`: the setter // marks nothing, so the binding raises nothing. assert_eq!(settle(&mut tree), []); highlight.set(true); assert_eq!(settle(&mut tree), [(probe, Dirt::PAINT)]); assert!(tree.widget::(probe).unwrap().highlight); } #[test] fn bind_dyn_matches_typed_bind_in_state_and_dirt() { let mut tree = tree(); let root = tree.insert(Container::new(), row_style(0.0, 0.0), None); let typed = tree.insert(BindProbe::default(), taffy::Style::default(), Some(root)); let erased = tree.insert(BindProbe::default(), taffy::Style::default(), Some(root)); settle(&mut tree); let label = guiduck_signals::Signal::new(String::from("first")); let highlight = guiduck_signals::Signal::new(false); tree.bind::(typed, move || label.get(), |w, v| w.set_label(v)); tree.bind_dyn( erased, move || Box::new(label.get()) as Box, label_thunk(), ); tree.bind::(typed, move || highlight.get(), |w, v| w.set_highlight(v)); tree.bind_dyn( erased, move || Box::new(highlight.get()) as Box, highlight_thunk(), ); // Each write drives both widgets through their respective path; the two // must agree on the value that lands and on the dirt it raises. for (label_value, highlight_value, expected) in [ // A new label alone: layout-classed. ("first", false, Dirt::LAYOUT), // A new label and a new highlight in one settle: their classes // union, and layout subsumes paint. ("second", true, Dirt::LAYOUT), // The label is unchanged (so its effect never re-runs); only the // highlight moves, and its class is paint. ("second", false, Dirt::PAINT), ("third", false, Dirt::LAYOUT), ] { label.set(String::from(label_value)); highlight.set(highlight_value); let dirt = settle(&mut tree); let dirt_for = |id| { dirt.iter() .filter(|(dirty, _)| *dirty == id) .fold(Dirt::CLEAN, |acc, (_, d)| acc.union(*d)) }; assert_eq!( dirt_for(typed), dirt_for(erased), "same mutation, same dirt ({label_value}, {highlight_value})" ); assert_eq!( dirt_for(erased), expected, "the class the setter marked reaches the tree ({label_value}, {highlight_value})" ); let typed_probe = tree.widget::(typed).unwrap(); let erased_probe = tree.widget::(erased).unwrap(); assert_eq!(typed_probe.label, erased_probe.label); assert_eq!(typed_probe.highlight, erased_probe.highlight); assert_eq!(typed_probe.label, label_value); assert_eq!(typed_probe.highlight, highlight_value); } } #[test] fn bind_dyn_is_inert_when_the_thunk_does_not_recognize_its_target() { let (mut tree, probe) = bind_probe_fixture(); let root = tree.parent(probe).expect("probe has a parent"); let value = guiduck_signals::Signal::new(String::from("first")); // A thunk aimed at a widget of another type, and one handed a value of // another type: both apply nothing, exactly as a `mutate::` whose // downcast fails does, and neither raises dirt. tree.bind_dyn( root, move || Box::new(value.get()) as Box, label_thunk(), ); tree.bind_dyn( probe, move || Box::new(value.get().len()) as Box, label_thunk(), ); assert_eq!(settle(&mut tree), []); assert_eq!(tree.widget::(probe).unwrap().label, ""); value.set(String::from("second")); assert_eq!(settle(&mut tree), []); assert_eq!(tree.widget::(probe).unwrap().label, ""); } #[test] fn bind_dyn_on_a_removed_widget_is_a_no_op() { let (mut tree, probe) = bind_probe_fixture(); let value = guiduck_signals::Signal::new(String::from("first")); tree.bind_dyn( probe, move || Box::new(value.get()) as Box, label_thunk(), ); settle(&mut tree); tree.remove(probe); settle(&mut tree); value.set(String::from("second")); // The command finds no node; as with `mutate`, a vanished widget makes // the update evaporate rather than panic. settle(&mut tree); assert!(tree.widget::(probe).is_none()); }