use guiduck_scene::geom::{Point, Size, Vec2}; use taffy::prelude::{length, percent}; use super::*; use crate::clipboard::NoClipboard; use crate::event::PointerInput; use crate::widget::{Button, Container, TextInput, WidgetId, WidgetTree}; fn sample_font() -> guiduck_scene::paint::Blob { static FONT: &[u8] = include_bytes!("../../../../guiduck-samples/fonts/DejaVuSans.ttf"); static BLOB: std::sync::OnceLock> = std::sync::OnceLock::new(); BLOB.get_or_init(|| guiduck_scene::paint::Blob::new(std::sync::Arc::new(FONT))) .clone() } const VIEWPORT: Size = Size::new(200.0, 100.0); /// A 200×100 scroll area holding ten 200×40 rows (400px of content). fn fixture() -> (WidgetTree, WidgetId, Vec) { let mut tree = WidgetTree::new(); let scroll = tree.insert( ScrollArea::new(), taffy::Style { size: taffy::Size { width: percent(1.0_f32), height: percent(1.0_f32), }, flex_direction: taffy::FlexDirection::Column, ..Default::default() }, None, ); let rows: Vec = (0..10) .map(|_| { tree.insert( Container::new(), taffy::Style { size: taffy::Size { width: percent(1.0_f32), height: length(40.0_f32), }, flex_shrink: 0.0, ..Default::default() }, Some(scroll), ) }) .collect(); tree.render_frame(VIEWPORT); (tree, scroll, rows) } fn scroll(tree: &mut WidgetTree, pos: Point, delta: Vec2) { tree.dispatch_pointer(PointerInput::Scroll { pos, delta }, &mut NoClipboard); } #[test] fn wheel_scrolls_and_clamps() { let (mut tree, scroll_id, _) = fixture(); let inside = Point::new(100.0, 50.0); // Wheel down (negative delta) advances the offset. scroll(&mut tree, inside, Vec2::new(0.0, -40.0)); assert_eq!( tree.widget::(scroll_id).unwrap().offset(), Vec2::new(0.0, 40.0) ); assert!(tree.needs_frame(), "scrolling repaints"); // Content is 400 in a 100 viewport: max offset 300; a huge scroll // clamps there, and scrolling past the start clamps at zero. scroll(&mut tree, inside, Vec2::new(0.0, -10_000.0)); assert_eq!( tree.widget::(scroll_id).unwrap().offset(), Vec2::new(0.0, 300.0) ); scroll(&mut tree, inside, Vec2::new(0.0, 10_000.0)); assert_eq!( tree.widget::(scroll_id).unwrap().offset(), Vec2::ZERO ); } #[test] fn scroll_at_end_bubbles_to_ancestor_scrollable() { // An outer scroll area holding an inner one plus extra content. let mut tree = WidgetTree::new(); let outer = tree.insert( ScrollArea::new(), taffy::Style { size: taffy::Size { width: percent(1.0_f32), height: percent(1.0_f32), }, flex_direction: taffy::FlexDirection::Column, ..Default::default() }, None, ); let inner = tree.insert( ScrollArea::new(), taffy::Style { size: taffy::Size { width: percent(1.0_f32), height: length(60.0_f32), }, flex_direction: taffy::FlexDirection::Column, flex_shrink: 0.0, ..Default::default() }, Some(outer), ); tree.insert( Container::new(), taffy::Style { size: taffy::Size { width: percent(1.0_f32), height: length(120.0_f32), }, flex_shrink: 0.0, ..Default::default() }, Some(inner), ); tree.insert( Container::new(), taffy::Style { size: taffy::Size { width: percent(1.0_f32), height: length(300.0_f32), }, flex_shrink: 0.0, ..Default::default() }, Some(outer), ); tree.render_frame(VIEWPORT); // Over the inner area: the inner one consumes while it can move. let over_inner = Point::new(100.0, 30.0); scroll(&mut tree, over_inner, Vec2::new(0.0, -30.0)); assert_eq!(tree.widget::(inner).unwrap().offset().y, 30.0); assert_eq!(tree.widget::(outer).unwrap().offset().y, 0.0); // Exhaust the inner range (120 content in 60 viewport → max 60); the // next scroll bubbles to the outer area. scroll(&mut tree, over_inner, Vec2::new(0.0, -10_000.0)); assert_eq!(tree.widget::(inner).unwrap().offset().y, 60.0); scroll(&mut tree, over_inner, Vec2::new(0.0, -25.0)); assert_eq!(tree.widget::(outer).unwrap().offset().y, 25.0); } #[test] fn hit_testing_follows_the_scroll_offset() { let (mut tree, _scroll_id, rows) = fixture(); let log: std::rc::Rc>> = Default::default(); for (i, row) in rows.iter().enumerate() { let log = log.clone(); tree.on_event(*row, crate::event::EventKind::Click, move |_ctx, _ev| { log.borrow_mut().push(i); }); } let click = |tree: &mut WidgetTree, pos: Point| { tree.dispatch_pointer( PointerInput::Down { pos, button: crate::event::PointerButton::Left, time: std::time::Instant::now(), }, &mut NoClipboard, ); tree.dispatch_pointer( PointerInput::Up { pos, button: crate::event::PointerButton::Left, }, &mut NoClipboard, ); }; // Unscrolled: y=50 is inside row 1 (40..80). click(&mut tree, Point::new(50.0, 50.0)); assert_eq!(*log.borrow(), vec![1]); // Scrolled by 120: the same point now lands on row 4 (160..200 shifted // to 40..80). scroll(&mut tree, Point::new(50.0, 50.0), Vec2::new(0.0, -120.0)); click(&mut tree, Point::new(50.0, 50.0)); assert_eq!(*log.borrow(), vec![1, 4]); } #[test] fn pointer_events_carry_offset_corrected_local_coordinates() { let (mut tree, _scroll_id, rows) = fixture(); let seen: std::rc::Rc>> = Default::default(); let seen2 = seen.clone(); tree.on_event( rows[4], crate::event::EventKind::PointerDown, move |_ctx, ev| { *seen2.borrow_mut() = Some(ev.pointer().unwrap().local); }, ); scroll(&mut tree, Point::new(50.0, 50.0), Vec2::new(0.0, -120.0)); tree.dispatch_pointer( PointerInput::Down { pos: Point::new(50.0, 50.0), button: crate::event::PointerButton::Left, time: std::time::Instant::now(), }, &mut NoClipboard, ); // Row 4 spans content 160..200, scrolled to screen 40..80; a press at // window y=50 is 10 into the row. assert_eq!(seen.borrow().unwrap(), Point::new(50.0, 10.0)); } #[test] fn thumb_drag_scrolls_proportionally() { let (mut tree, scroll_id, _) = fixture(); // Thumb geometry: viewport 100 of 400 content → length 25, track 75, // range 300. It sits at the right edge (x 192..198), top at offset 0. let thumb = Point::new(195.0, 10.0); tree.dispatch_pointer( PointerInput::Down { pos: thumb, button: crate::event::PointerButton::Left, time: std::time::Instant::now(), }, &mut NoClipboard, ); // Drag down 37.5 px of the 75 px track → half the 300 px range. tree.dispatch_pointer( PointerInput::Moved(Point::new(195.0, 47.5)), &mut NoClipboard, ); tree.dispatch_pointer( PointerInput::Up { pos: Point::new(195.0, 47.5), button: crate::event::PointerButton::Left, }, &mut NoClipboard, ); assert_eq!( tree.widget::(scroll_id).unwrap().offset(), Vec2::new(0.0, 150.0) ); } #[test] fn scroll_is_paint_only() { let (mut tree, _scroll_id, rows) = fixture(); // Scrolling must never invalidate layout: after a // scroll-triggered frame, every row keeps its laid-out geometry. let before: Vec<_> = rows.iter().map(|r| tree.layout(*r)).collect(); scroll(&mut tree, Point::new(100.0, 50.0), Vec2::new(0.0, -80.0)); tree.render_frame(VIEWPORT); let after: Vec<_> = rows.iter().map(|r| tree.layout(*r)).collect(); assert_eq!(before, after, "layout untouched by scrolling"); } fn press(tree: &mut WidgetTree, key: crate::event::Key) -> bool { let used = tree.dispatch_key( &crate::event::KeyInput { key, modifiers: crate::event::Modifiers::default(), pressed: true, }, &mut NoClipboard, ); tree.render_frame(VIEWPORT); used } #[test] fn a_scroll_area_is_click_focusable_but_not_a_tab_stop() { // A viewport should take focus so the keyboard can scroll it, without // interrupting a Tab walk between the controls inside it. let (mut tree, scroll, _rows) = fixture(); tree.render_frame(VIEWPORT); let area = tree.widget::(scroll).expect("the area"); assert!(crate::widget::Widget::focusable(area)); assert!(!crate::widget::Widget::tab_stop(area)); // Nothing inside is focusable and the area is not a tab stop, so the // focus invariant leaves focus alone rather than landing on a viewport. assert_eq!(tree.focus(), None); } #[test] fn the_keyboard_scrolls_a_focused_area() { let (mut tree, scroll, _rows) = fixture(); tree.render_frame(VIEWPORT); tree.set_focus(Some(scroll)); let at = |tree: &WidgetTree| tree.widget::(scroll).unwrap().offset().y; assert_eq!(at(&tree), 0.0); assert!(press(&mut tree, crate::event::Key::Down)); assert_eq!(at(&tree), LINE); assert!(press(&mut tree, crate::event::Key::Up)); assert_eq!(at(&tree), 0.0); // A page keeps a little of the old view. assert!(press(&mut tree, crate::event::Key::PageDown)); assert_eq!(at(&tree), VIEWPORT.height - PAGE_OVERLAP); // Home and End are the ends of the range, which the clamp already knows. assert!(press(&mut tree, crate::event::Key::End)); assert_eq!(at(&tree), 300.0, "400px of content in a 100px viewport"); assert!(press(&mut tree, crate::event::Key::Home)); assert_eq!(at(&tree), 0.0); } #[test] fn focusing_a_control_below_the_fold_reveals_it() { // A 200×100 viewport over ten 40px buttons (400px of content). Focusing a // control that is scrolled off — a Tab into a long form — scrolls the area // until it shows, the same reveal a moved caret asks for. let mut tree = WidgetTree::new(); let scroll = tree.insert( ScrollArea::new(), taffy::Style { size: taffy::Size { width: percent(1.0_f32), height: percent(1.0_f32), }, flex_direction: taffy::FlexDirection::Column, ..Default::default() }, None, ); let buttons: Vec = (0..10) .map(|i| { tree.insert( Button::new(format!("b{i}")), taffy::Style { size: taffy::Size { width: percent(1.0_f32), height: length(40.0_f32), }, flex_shrink: 0.0, ..Default::default() }, Some(scroll), ) }) .collect(); tree.render_frame(VIEWPORT); let at = |tree: &WidgetTree| tree.widget::(scroll).unwrap().offset().y; // The focus invariant put focus on the first button, already visible. assert_eq!(tree.focus(), Some(buttons[0])); assert_eq!(at(&tree), 0.0); // Button 6 sits at y = 240..280, below the 100px viewport. Focusing it // scrolls until its bottom meets the viewport bottom: 280 − 100 = 180. tree.set_focus(Some(buttons[6])); tree.render_frame(VIEWPORT); assert_eq!(at(&tree), 180.0); // Focusing the first button again scrolls back up so its top shows. tree.set_focus(Some(buttons[0])); tree.render_frame(VIEWPORT); assert_eq!(at(&tree), 0.0); } #[test] fn the_caret_of_a_tall_field_scrolls_into_view() { // A multi-line field taller than its scroll viewport: the caret must stay // visible as it moves, which the field cannot do alone — it has no // viewport offset of its own. let mut tree = WidgetTree::with_text_context(crate::text::TextContext::hermetic([sample_font()])); let scroll = tree.insert( ScrollArea::new(), taffy::Style { size: taffy::Size { width: percent(1.0_f32), height: percent(1.0_f32), }, flex_direction: taffy::FlexDirection::Column, ..Default::default() }, None, ); let field = tree.insert( TextInput::new(14.0).family("DejaVu Sans").multiline(true), taffy::Style { size: taffy::Size { width: percent(1.0_f32), height: taffy::prelude::auto(), }, flex_shrink: 0.0, ..Default::default() }, Some(scroll), ); tree.render_frame(VIEWPORT); assert_eq!(tree.focus(), Some(field), "the invariant focuses the field"); let at = |tree: &WidgetTree| tree.widget::(scroll).unwrap().offset().y; assert_eq!(at(&tree), 0.0); // Enter a dozen lines. The field grows past the 100px viewport and the // caret rides the bottom, so the area scrolls down to keep it in view. for i in 0..12 { for c in format!("line{i}").chars() { press_char(&mut tree, c); } press(&mut tree, crate::event::Key::Enter); } let scrolled = at(&tree); assert!( scrolled > 50.0, "the area followed the caret down, offset was {scrolled}" ); // Walk the caret back to the top; the area scrolls up to follow it. for _ in 0..20 { press(&mut tree, crate::event::Key::Up); } assert!( at(&tree) < 20.0, "the caret at the top brought the view back to the start, offset {}", at(&tree) ); } fn press_char(tree: &mut WidgetTree, c: char) { tree.dispatch_key( &crate::event::KeyInput { key: crate::event::Key::Character(c.to_string()), modifiers: crate::event::Modifiers::default(), pressed: true, }, &mut NoClipboard, ); tree.render_frame(VIEWPORT); } #[test] fn a_key_that_moves_nothing_is_not_consumed() { // Up at the top must not swallow the keystroke: an unconsumed key still // has somewhere to go (Tab traversal, an accelerator), and a viewport // that ate them at its limits would be a black hole. let (mut tree, scroll, _rows) = fixture(); tree.render_frame(VIEWPORT); tree.set_focus(Some(scroll)); assert!( !press(&mut tree, crate::event::Key::Up), "already at the top" ); assert!(press(&mut tree, crate::event::Key::End)); assert!( !press(&mut tree, crate::event::Key::Down), "already at the end" ); } /// A scroll area does not squash its content across a scrolling axis. /// /// `stretch` is the flex default, and it *resizes* a child to the container's /// cross size. Across a scrolling axis that is exactly backwards: shrink the /// window past the content's height and the child's box is clipped to the /// viewport while it still paints in full. Painting is not bounded by the box /// and hit testing is, so everything past the fold keeps drawing and stops /// responding. /// /// The default flex direction is row, so a plain `(scroll-area …)` with no /// `:direction` is the case that meets this — which is why the sample /// components, which all say `:direction column`, never did. #[test] fn a_shrinking_viewport_does_not_clip_the_content_box() { let mut tree = WidgetTree::new(); // No `flex_direction`: the default is row, so the vertical scroll axis is // the *cross* axis, which is where stretch applies. let scroll = tree.insert( ScrollArea::new(), taffy::Style { size: taffy::Size { width: percent(1.0_f32), height: percent(1.0_f32), }, ..Default::default() }, None, ); // One tall child whose height is its *own* content's — `stretch` only // resizes an auto cross size, so a child with a declared height would // never have shown this. A document sizes itself, which is the case that // matters. let content = tree.insert( Container::new(), taffy::Style { flex_direction: taffy::FlexDirection::Column, ..Default::default() }, Some(scroll), ); for _ in 0..10 { tree.insert( Container::new(), taffy::Style { size: taffy::Size { width: length(150.0_f32), height: length(40.0_f32), }, flex_shrink: 0.0, ..Default::default() }, Some(content), ); } for viewport in [Size::new(200.0, 500.0), Size::new(200.0, 100.0)] { tree.render_frame(viewport); assert_eq!( tree.layout(content).height(), 400.0, "the content keeps its own height at viewport {viewport:?}; a box \ smaller than what it paints is unreachable to the pointer" ); } } /// An author who asks for a placement that does not resize keeps it: `center` /// and `end` position a child at its natural size, so they were never the /// problem and are not overridden. #[test] fn a_non_resizing_alignment_is_left_alone() { let mut style = taffy::Style { align_items: Some(taffy::AlignItems::CENTER), ..Default::default() }; ScrollArea::new().adjust_style(&mut style); assert_eq!(style.align_items, Some(taffy::AlignItems::CENTER)); // …while the resizing default is replaced. let mut style = taffy::Style::default(); ScrollArea::new().adjust_style(&mut style); assert_eq!(style.align_items, Some(taffy::AlignItems::FLEX_START)); // A scroll area that does not scroll the cross axis is untouched: a // column of rows filling the width is the ordinary, working case. let mut style = taffy::Style { flex_direction: taffy::FlexDirection::Column, ..Default::default() }; ScrollArea::new().adjust_style(&mut style); assert_eq!( style.align_items, None, "rows still stretch across the width" ); } /// Content that shrinks under a scrolled viewport repaints the same frame. /// /// `finalize_layout` clamps the offset when the content it was scrolled /// through disappears — navigating from the bottom of a long page to a short /// one. That clamp changes *what is painted*: a scroll area carries its /// children under its own fragment's transform, so a new offset means that /// fragment has to be re-encoded. /// /// It runs inside the layout pass, though, which is after the frame's dirty /// set was taken — so the dirt it raises used to be dropped, the stale /// fragment was reused, and the result was a page that had scrolled itself /// somewhere its content no longer reached. Nothing scheduled another frame, /// so it stayed that way until something unrelated (a resize) forced a full /// relayout. #[test] fn content_shrinking_under_a_scrolled_viewport_repaints_now() { // A container between the scroll area and the rows, as a document view // has: removing rows dirties *it*, not the scroll area whose fragment // carries the offset. With rows as direct children the scroll area is // dirtied by the removal itself and the bug cannot show. let mut tree = WidgetTree::new(); let scroll = tree.insert( ScrollArea::new(), taffy::Style { size: taffy::Size { width: percent(1.0_f32), height: percent(1.0_f32), }, ..Default::default() }, None, ); let column = tree.insert( Container::new(), taffy::Style { flex_direction: taffy::FlexDirection::Column, ..Default::default() }, Some(scroll), ); let rows: Vec = (0..10) .map(|_| { tree.insert( Container::new(), taffy::Style { size: taffy::Size { width: length(150.0_f32), height: length(40.0_f32), }, flex_shrink: 0.0, ..Default::default() }, Some(column), ) }) .collect(); tree.render_frame(VIEWPORT); // Scroll to the end of the long content. tree.dispatch_pointer( PointerInput::Scroll { pos: Point::new(100.0, 50.0), delta: Vec2::new(0.0, -1000.0), }, &mut NoClipboard, ); tree.render_frame(VIEWPORT); assert!( tree.widget::(scroll).unwrap().offset().y > 0.0, "the fixture must actually be scrolled, or this proves nothing" ); // The destination: nearly all of the content goes away, exactly as // replacing a document's blocks does. for row in &rows[1..] { tree.remove(*row); } tree.render_frame(VIEWPORT); assert_eq!( tree.widget::(scroll).unwrap().offset().y, 0.0, "the offset clamps to content that no longer overflows" ); // The standing invariant: what was retained is what a fresh paint makes. let mut fresh = guiduck_scene::FragmentStore::new(); let fresh_root = tree.paint_fresh(&mut fresh).expect("a fresh root"); let retained = tree.render_frame(VIEWPORT).expect("a retained root"); assert!( tree.fragments().trees_equal(retained, &fresh, fresh_root), "the retained scene still holds the pre-clamp offset: the page is \ drawn scrolled past its own content" ); }