use guiduck_scene::geom::{Point, Rect, Size}; use guiduck_scene::paint::color::palette::css; use guiduck_scene::{DisplayItem, FragmentStore}; use taffy::prelude::{length, percent}; use super::*; use crate::clipboard::NoClipboard; use crate::event::{EventKind, PointerButton, PointerInput}; use crate::text::TextContext; use crate::widget::{Text, TextInput}; const VIEWPORT: Size = Size::new(400.0, 300.0); 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 tree() -> WidgetTree { WidgetTree::with_text_context(TextContext::hermetic([sample_font()])) } fn full_size() -> taffy::Style { taffy::Style { size: taffy::Size { width: percent(1.0_f32), height: percent(1.0_f32), }, ..Default::default() } } fn fixed(width: f32, height: f32) -> taffy::Style { taffy::Style { size: taffy::Size { width: length(width), height: length(height), }, flex_shrink: 0.0, ..Default::default() } } /// A base tree with a white background and one 100×40 button-ish child at /// the top-left (padding 10). fn base_tree() -> (WidgetTree, WidgetId) { let mut tree = tree(); let root = tree.insert( Container::new().background(css::WHITE), taffy::Style { padding: taffy::Rect::length(10.0_f32), ..full_size() }, None, ); let button = tree.insert( Container::new().background(css::STEEL_BLUE), fixed(100.0, 40.0), Some(root), ); (tree, button) } fn press(tree: &mut WidgetTree, pos: Point) { tree.dispatch_pointer( PointerInput::Down { time: std::time::Instant::now(), pos, button: PointerButton::Left, }, &mut NoClipboard, ); tree.dispatch_pointer( PointerInput::Up { pos, button: PointerButton::Left, }, &mut NoClipboard, ); } #[test] fn frame_composes_overlays_above_the_base_at_their_positions() { let (mut tree, _) = base_tree(); let overlay = tree.open_overlay(fixed(80.0, 60.0), OverlayOptions::dialog()); tree.insert( Container::new().background(css::MISTY_ROSE), full_size(), Some(overlay), ); let frame = tree.render_frame(VIEWPORT).expect("frame"); let items = &tree.fragments().get(frame).expect("frame exists").items; let children: Vec<_> = items .iter() .filter_map(|item| match item { DisplayItem::Child { transform, fragment, } => Some((*transform, *fragment)), _ => None, }) .collect(); assert_eq!(children.len(), 2, "base plus one overlay"); assert_eq!( children[0].0, guiduck_scene::geom::Affine::IDENTITY, "base draws first, at identity" ); // An 80×60 dialog centers in 400×300. assert_eq!( children[1].0, guiduck_scene::geom::Affine::translate((160.0, 120.0)) ); // The retained frame stays structurally identical to the oracle. let mut fresh = FragmentStore::new(); let fresh_frame = tree.paint_fresh(&mut fresh).expect("root"); assert!(tree.fragments().trees_equal(frame, &fresh, fresh_frame)); } #[test] fn hit_testing_prefers_the_topmost_overlay_and_modals_block() { let (mut tree, button) = base_tree(); tree.render_frame(VIEWPORT); // The button is hittable before any overlay exists. assert_eq!(tree.hit_test(Point::new(30.0, 20.0)), Some(button)); let overlay = tree.open_overlay(fixed(80.0, 60.0), OverlayOptions::dialog()); let content = tree.insert( Container::new().background(css::MISTY_ROSE), full_size(), Some(overlay), ); tree.render_frame(VIEWPORT); // Inside the centered dialog (160..240 × 120..180): the overlay wins. assert_eq!(tree.hit_test(Point::new(200.0, 150.0)), Some(content)); // Outside it: the modal is a barrier, nothing beneath is hittable. assert_eq!(tree.hit_test(Point::new(30.0, 20.0)), None); tree.close_overlay(overlay); tree.render_frame(VIEWPORT); assert_eq!(tree.hit_test(Point::new(30.0, 20.0)), Some(button)); } #[test] fn outside_press_dismisses_a_popup_and_is_consumed() { let (mut tree, button) = base_tree(); let clicked = std::rc::Rc::new(std::cell::Cell::new(0)); { let clicked = clicked.clone(); tree.on_event(button, EventKind::Click, move |_ctx, _ev| { clicked.set(clicked.get() + 1); }); } let closed = std::rc::Rc::new(std::cell::Cell::new(false)); let popup = { let closed = closed.clone(); tree.open_overlay( fixed(80.0, 60.0), OverlayOptions::popup_at(Point::new(250.0, 100.0)).on_close(move || closed.set(true)), ) }; tree.render_frame(VIEWPORT); // Press on the button, outside the popup: the popup closes and the // press is consumed — the button must NOT click. press(&mut tree, Point::new(30.0, 20.0)); assert!(closed.get(), "popup dismissed by the outside press"); assert!(!tree.is_overlay(popup)); assert_eq!(clicked.get(), 0, "the dismissing press activates nothing"); // The next press, with the popup gone, clicks normally. tree.render_frame(VIEWPORT); press(&mut tree, Point::new(30.0, 20.0)); assert_eq!(clicked.get(), 1); } #[test] fn escape_dismisses_the_topmost_dismissable_overlay() { let (mut tree, _) = base_tree(); let dialog = tree.open_overlay(fixed(80.0, 60.0), OverlayOptions::dialog()); tree.render_frame(VIEWPORT); let consumed = tree.dispatch_key( &crate::event::KeyInput { key: crate::event::Key::Escape, modifiers: Default::default(), pressed: true, }, &mut NoClipboard, ); assert!(consumed); assert!(!tree.is_overlay(dialog)); } #[test] fn a_modal_traps_focus_and_restores_it_on_close() { let mut tree = tree(); let root = tree.insert(Container::new(), full_size(), None); let base_input = tree.insert( TextInput::new(13.0).family("DejaVu Sans"), fixed(120.0, 24.0), Some(root), ); tree.render_frame(VIEWPORT); assert_eq!(tree.focus(), Some(base_input), "focus invariant"); let dialog = tree.open_overlay(fixed(200.0, 100.0), OverlayOptions::dialog()); let dialog_input = tree.insert( TextInput::new(13.0).family("DejaVu Sans"), fixed(120.0, 24.0), Some(dialog), ); tree.render_frame(VIEWPORT); assert_eq!( tree.focus(), Some(dialog_input), "opening a modal moves focus into it" ); // Tab cycles within the modal (one focusable: back to itself), never // out to the base input. tree.focus_step(false); assert_eq!(tree.focus(), Some(dialog_input)); tree.close_overlay(dialog); tree.render_frame(VIEWPORT); assert_eq!( tree.focus(), Some(base_input), "closing the modal restores the prior focus" ); } #[test] fn a_modal_without_focusables_parks_focus() { let mut tree = tree(); let root = tree.insert(Container::new(), full_size(), None); let base_input = tree.insert( TextInput::new(13.0).family("DejaVu Sans"), fixed(120.0, 24.0), Some(root), ); tree.render_frame(VIEWPORT); assert_eq!(tree.focus(), Some(base_input)); let dialog = tree.open_overlay(fixed(200.0, 100.0), OverlayOptions::dialog()); tree.insert( Text::new("sure?").family("DejaVu Sans"), taffy::Style::default(), Some(dialog), ); tree.render_frame(VIEWPORT); assert_eq!( tree.focus(), None, "keys must not leak beneath the modal while it is open" ); tree.close_overlay(dialog); tree.render_frame(VIEWPORT); assert_eq!(tree.focus(), Some(base_input)); } #[test] fn anchored_placement_flips_at_the_viewport_edge() { let (mut tree, _button) = base_tree(); // An anchor near the bottom of the viewport. let root = tree.root().expect("root"); let anchor = tree.insert( Container::new(), taffy::Style { margin: taffy::Rect { top: length(220.0_f32), ..taffy::Rect::length(0.0_f32) }, ..fixed(100.0, 40.0) }, Some(root), ); tree.render_frame(VIEWPORT); let anchor_rect = Rect::from_origin_size(tree.absolute_origin(anchor), tree.layout(anchor).size()); let popup = tree.open_overlay( fixed(80.0, 60.0), OverlayOptions::popup(anchor, AnchorSide::Below), ); tree.render_frame(VIEWPORT); let position = tree .overlays .iter() .find(|o| o.root == popup) .expect("open") .position; // Below would end at y ≈ anchor.y1 + 60 > 300, so it flips above. assert_eq!( position, Point::new(anchor_rect.x0, anchor_rect.y0 - 60.0), "flipped above the anchor (anchor at {anchor_rect:?})" ); } #[test] fn overlay_widgets_report_layered_absolute_origins() { let (mut tree, _) = base_tree(); let overlay = tree.open_overlay( fixed(80.0, 60.0), OverlayOptions::popup_at(Point::new(250.0, 100.0)), ); let content = tree.insert( Container::new(), taffy::Style { margin: taffy::Rect { left: length(5.0_f32), top: length(7.0_f32), ..taffy::Rect::length(0.0_f32) }, ..fixed(20.0, 20.0) }, Some(overlay), ); tree.render_frame(VIEWPORT); assert_eq!( tree.absolute_origin(content), Point::new(255.0, 107.0), "overlay position plus local layout" ); // And the hit test agrees with the reported origin. assert_eq!(tree.hit_test(Point::new(256.0, 108.0)), Some(content)); }