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"
    );
}