tests.rs
raw
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<u8> {
static FONT: &[u8] = include_bytes!("../../../../guiduck-samples/fonts/DejaVuSans.ttf");
static BLOB: std::sync::OnceLock<guiduck_scene::paint::Blob<u8>> = 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<WidgetId>) {
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<WidgetId> = (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::<ScrollArea>(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::<ScrollArea>(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::<ScrollArea>(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::<ScrollArea>(inner).unwrap().offset().y, 30.0);
assert_eq!(tree.widget::<ScrollArea>(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::<ScrollArea>(inner).unwrap().offset().y, 60.0);
scroll(&mut tree, over_inner, Vec2::new(0.0, -25.0));
assert_eq!(tree.widget::<ScrollArea>(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<std::cell::RefCell<Vec<usize>>> = 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<std::cell::RefCell<Option<Point>>> = 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::<ScrollArea>(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::<ScrollArea>(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::<ScrollArea>(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<WidgetId> = (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::<ScrollArea>(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::<ScrollArea>(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<WidgetId> = (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::<ScrollArea>(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::<ScrollArea>(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"
);
}