makepad/examples/scroll_containment/tests/ui.rs
Kevin Boos 888ca924bf
Scroll: keep each gesture with the scroller it began on, and let nested scrollers share a drag (#1317)
* StackNavigation: add `push_without_animation()` to show a view right away

Pushing a view always slid it in, so an app couldn't restore a screen it was already showing
in another layout (e.g., after switching from a wide layout to a narrow one) without animating it.
* `push_view()` takes an `Animate`; with `Animate::No`, the view is shown at rest right away
  and the outgoing view is released, leaving no transition in progress
* It redraws the view being replaced first, since a never-drawn view can't trigger a redraw itself

* Scroll: nested scroll views keep their scrolls, and a gesture stays with its scroller

Scrolling inside a nested scroll view also scrolled its ancestors: PortalList never marked a
scroll handled, and a ScrollBar at its limit passed scrolls and momentum up.
* The innermost view that can scroll on an axis owns that axis, even at its limits, so
  nothing around it moves, momentum included; a view whose content fits passes scrolls on
* Ownership is decided when a gesture starts (a trackpad `Began`, or a wheel after a 500 ms
  pause, as in Chromium) and kept for the gesture, so content moving under a still pointer
  can't capture it; moving the pointer off a wheel's owner after 100 ms at rest frees it,
  as in Firefox
* Each axis has its own owner, and an owner that disappears frees only its axis
* Scrollables ask `cx.is_scroll_owner()`; containers wrap their children's dispatch in
  `begin_scroll_container()`/`end_scroll_container()` so a list that fits defers to them
* An open DropDown, DropDown2 or ComboBox keeps scrolls from what's under it, and a scroller
  partly clipped by a modal still takes its own
* Touch drags go to the inner scroller too: StackNavigation's swipe-back and ExpandablePanel
  yield to it, and TabBar, Chart and Map pans claim their drags
* Remote `/m?k=scroll&phase=` sends trackpad phases and `/m?k=touch` drives a touch;
  makepad_test gains `scroll_at`, touch drags and drags that rest before letting go

* Examples: scroll_containment, UI tests for nested scrolling

Lists, scroll views, a multiline TextInput, a code block beside a horizontal strip, a
DropDown2, a StackNavigation, an ExpandablePanel and a Modal nested in each other, with UI
tests that drive wheels, trackpad gestures with momentum and touch drags over them.

* Turtle: `Base::Unused` under a growing parent, and `turtle_visible_y_range`

A `Fit{max: Unused}` view inside a Fit parent skipped past it and read the space left in some
turtle further up, so a body under a pinned header couldn't size to what's left of its parent.
* `Base::Unused` of a Fit parent with a max is that max minus what the parent has used
* `Cx2d::turtle_visible_y_range()` gives what the clips around a turtle can show, stopping at
  a turtle that can still move what it holds (a positioned list item, or a row or column that
  aligns its children on y)

* Scroll: nested scrollers share a drag, and a shared gesture keeps to the way it started

A View's drag scroll never saw a press a child had captured, so dragging on something that only
scrolls sideways (like code in a page) could never scroll the page.
* Drag scrollers hold the presses they get, and a scroller around them can join a press only they
  hold (along an axis they didn't take); their holds don't lock the mouse against each other
* A drag or trackpad gesture two scrollers share keeps to the axis it started along, or goes both
  ways (each owning its axis) if it started diagonally; PortalList and TouchGesture honor it too
* A second finger no longer restarts a drag, a claimed press still stops a fling, and
  `set_scroll_pos` stops any motion in progress

* CodeEditor: touch drags scroll, and a Fit editor draws every line in view

A touch drag always selected text, and a Fit editor with no max only drew its first 10,000px.
* With drag scrolling on, a touch drag scrolls and a tap places the cursor; a mouse press that
  selects claims its drag, so nothing scrolls under the selection
* A Fit editor with no max is as tall as its text, and draws only the lines the clips around it show

* Examples: scroll_containment code modal, and touch drags that turn

A modal like Robrix's event source one (a pinned title over a page holding code that only scrolls
sideways) plus a scroller drawn over it, with UI tests for touch, mouse, trackpad and wheel input,
and for drags that start out diagonally or turn.
* makepad_test gains `touch_drag_through_and_hold`, for a finger that turns

* Scroll: a new scroll at a nested scroller's limit goes on to the one around it

A scroll over a nested scroller resting at its limit never reached the scroller around it, so a page
couldn't be scrolled from over a list or code block it holds. Browsers hand such a scroll on, while a
gesture still stays with the scroller it began on.
* A new wheel or trackpad gesture goes to the innermost scroller under the pointer that can move its
  way, or to the innermost one if nothing around it can (to rubber-band and count travel)
* A trackpad gesture picks its scrollers when it first moves, since its `Began` can carry no delta
* `contain_scroll` on a `ScrollBar`, `PortalList` or `TextInput` keeps even a new scroll at its
  limit, like CSS `overscroll-behavior: contain`
2026-10-21 16:01:19 +02:00

613 lines
27 KiB
Rust

//! Scroll containment: a scroll gesture goes to the innermost scrollable under the pointer that can
//! move its way (or contains its scrolls), and stays with it, so nothing around it moves along.
use makepad_test::{makepad_test, KeyCode, RemoteMouseMove, Selector, StudioToApp, TestApp};
use std::thread;
use std::time::{Duration, Instant};
/// Longer than the wheel latch timeout, so the next wheel event starts a new scroll.
const NEW_SCROLL: Duration = Duration::from_millis(700);
/// The status line once the offsets stop changing (flings and bounces settle).
fn settled(app: &TestApp) -> String {
let deadline = Instant::now() + Duration::from_secs(5);
let mut last = String::new();
loop {
let text = app
.locator(Selector::id("status"))
.snapshot()
.text
.unwrap_or_default();
if text == last || Instant::now() > deadline {
return text;
}
last = text;
thread::sleep(Duration::from_millis(100));
}
}
fn offset(status: &str, key: &str) -> f64 {
status
.split_whitespace()
.find_map(|pair| pair.strip_prefix(key)?.strip_prefix('='))
.and_then(|value| value.parse().ok())
.unwrap_or_else(|| panic!("no `{key}` in status `{status}`"))
}
/// Settled offsets for `keys`.
fn offsets<const N: usize>(app: &TestApp, keys: [&str; N]) -> [f64; N] {
let status = settled(app);
keys.map(|key| offset(&status, key))
}
/// Waits for `key` to reach `value`, which an animation that doesn't move any offset can take a while to do.
fn wait_for(app: &TestApp, key: &str, value: f64) {
let deadline = Instant::now() + Duration::from_secs(3);
loop {
let [now] = offsets(app, [key]);
if now == value || Instant::now() > deadline {
assert_eq!(now, value, "`{key}`");
return;
}
thread::sleep(Duration::from_millis(100));
}
}
/// The middle of a widget, found by id or by its text.
fn center(app: &TestApp, selector: Selector) -> (f64, f64) {
let widget = app.locator(selector).snapshot();
(
widget.x as f64 + widget.width as f64 / 2.0,
widget.y as f64 + widget.height as f64 / 2.0,
)
}
fn wheel(app: &TestApp, (x, y): (f64, f64), dx: f64, dy: f64) {
app.scroll_at(x, y, dx, dy, None);
}
/// One trackpad gesture at a point: `deltas` while the fingers move, then `momentum`.
fn trackpad(app: &TestApp, (x, y): (f64, f64), deltas: &[f64], momentum: &[f64]) {
app.scroll_at(x, y, 0.0, 0.0, Some("began"));
for &dy in deltas {
app.scroll_at(x, y, 0.0, dy, Some("changed"));
}
app.scroll_at(x, y, 0.0, 0.0, Some("ended"));
for &dy in momentum {
app.scroll_at(x, y, 0.0, dy, Some("momentum"));
}
app.scroll_at(x, y, 0.0, 0.0, Some("momentum_ended"));
}
#[makepad_test]
fn wheel_over_a_nested_list_scrolls_the_page_only_with_a_new_scroll_at_its_end(app: TestApp) {
let [page, log] = offsets(&app, ["page", "log"]);
let over_log = center(&app, Selector::id("log_list"));
wheel(&app, over_log, 0.0, 60.0);
assert_eq!(offsets(&app, ["page", "log"]), [page, log + 60.0]);
// Past the end: the list stops there and the page still doesn't move.
wheel(&app, over_log, 0.0, 5000.0);
let [_, log_end] = offsets(&app, ["page", "log"]);
assert_eq!(offsets(&app, ["page"]), [page]);
// A new scroll that starts at the end goes on to the page, as in browsers.
thread::sleep(NEW_SCROLL);
wheel(&app, over_log, 0.0, 200.0);
assert_eq!(offsets(&app, ["page", "log"]), [page + 200.0, log_end]);
}
#[makepad_test]
fn a_nested_list_with_nothing_to_scroll_leaves_the_wheel_to_the_page(app: TestApp) {
let [page, short] = offsets(&app, ["page", "short"]);
wheel(&app, center(&app, Selector::id("short_list")), 0.0, 50.0);
assert_eq!(offsets(&app, ["page", "short"]), [page + 50.0, short]);
}
#[makepad_test]
fn a_list_with_nothing_to_scroll_still_takes_scrolls_when_nothing_around_it_can(app: TestApp) {
// So it still counts the travel (which apps use to load more) and rubber-bands.
let over_fit = center(&app, Selector::id("fit_list"));
for _ in 0..3 {
thread::sleep(NEW_SCROLL);
wheel(&app, over_fit, 0.0, -40.0);
}
assert_eq!(offsets(&app, ["fit", "fit_travel"]), [0.0, 120.0]);
let (x, y) = over_fit;
app.scroll_at(x, y, 0.0, 0.0, Some("began"));
app.scroll_at(x, y, 0.0, -30.0, Some("changed"));
app.scroll_at(x, y, 0.0, -30.0, Some("changed"));
let [stretched] = offsets(&app, ["fit"]);
assert!(stretched < -1.0, "the list stretched past its start ({stretched})");
app.scroll_at(x, y, 0.0, 0.0, Some("ended"));
assert_eq!(offsets(&app, ["fit"]), [0.0]);
}
#[makepad_test]
fn wheel_over_a_contained_scroll_view_never_scrolls_the_page(app: TestApp) {
let [page, inner] = offsets(&app, ["page", "inner"]);
let over_inner = center(&app, Selector::id("inner_view"));
wheel(&app, over_inner, 0.0, 1000.0);
let [_, inner_end] = offsets(&app, ["page", "inner"]);
assert!(inner_end > inner, "the nested view scrolled");
// `contain_scroll` keeps even a new scroll that starts at its end.
thread::sleep(NEW_SCROLL);
wheel(&app, over_inner, 0.0, 100.0);
assert_eq!(offsets(&app, ["page", "inner"]), [page, inner_end]);
}
#[makepad_test]
fn a_new_wheel_scroll_at_a_text_inputs_end_scrolls_the_page(app: TestApp) {
let [page] = offsets(&app, ["page"]);
let over_notes = center(&app, Selector::id("notes"));
wheel(&app, over_notes, 0.0, 2000.0);
assert_eq!(offsets(&app, ["page"]), [page]);
thread::sleep(NEW_SCROLL);
wheel(&app, over_notes, 0.0, 100.0);
assert_eq!(offsets(&app, ["page"]), [page + 100.0]);
}
#[makepad_test]
fn a_horizontal_scroller_leaves_vertical_wheels_to_the_page(app: TestApp) {
wheel(&app, center(&app, Selector::id("page_top")), 0.0, 300.0);
let [page, carousel] = offsets(&app, ["page", "carousel"]);
assert!(page > 250.0, "the page scrolled the carousel into view");
thread::sleep(NEW_SCROLL);
let over_carousel = center(&app, Selector::id("carousel"));
wheel(&app, over_carousel, 0.0, 40.0);
assert_eq!(offsets(&app, ["page", "carousel"]), [page + 40.0, carousel]);
thread::sleep(NEW_SCROLL);
let over_carousel = center(&app, Selector::id("carousel"));
wheel(&app, over_carousel, 100.0, 0.0);
assert_eq!(offsets(&app, ["page", "carousel"]), [page + 40.0, carousel + 100.0]);
// A multiline input whose text fits has nothing to scroll either.
thread::sleep(NEW_SCROLL);
wheel(&app, center(&app, Selector::id("short_notes")), 0.0, 40.0);
assert_eq!(offsets(&app, ["page"]), [page + 80.0]);
}
#[makepad_test]
fn a_wheel_scroll_stays_with_the_scrollable_it_began_on(app: TestApp) {
let [page, log] = offsets(&app, ["page", "log"]);
// The first notch scrolls the list up under the pointer; the rest still scroll the page,
// also while the hand drifts.
let (x, y) = center(&app, Selector::id("page_top"));
for notch in 0..6 {
wheel(&app, (x, y + 4.0 * notch as f64), 0.0, 20.0);
}
assert_eq!(offsets(&app, ["page", "log"]), [page + 120.0, log]);
// After a pause, the wheel moves whatever is under the pointer now.
thread::sleep(NEW_SCROLL);
wheel(&app, (x, y), 0.0, 20.0);
assert_eq!(offsets(&app, ["page", "log"]), [page + 120.0, log + 20.0]);
}
#[makepad_test]
fn moving_the_pointer_off_a_scrollable_once_the_wheel_rests_lets_the_next_notch_go_elsewhere(app: TestApp) {
let [page, log] = offsets(&app, ["page", "log"]);
wheel(&app, center(&app, Selector::id("log_list")), 0.0, 40.0);
// Well within the wheel's 500 ms scroll, but after a rest.
thread::sleep(Duration::from_millis(200));
let (x, y) = center(&app, Selector::id("page_top"));
app.forward(vec![StudioToApp::MouseMove(RemoteMouseMove { x, y, ..Default::default() })]);
wheel(&app, (x, y), 0.0, 20.0);
assert_eq!(offsets(&app, ["page", "log"]), [page + 20.0, log + 40.0]);
}
#[makepad_test]
fn a_trackpad_gesture_stays_with_the_scrollable_it_began_on(app: TestApp) {
let [page, log] = offsets(&app, ["page", "log"]);
let over_page = center(&app, Selector::id("page_top"));
trackpad(&app, over_page, &[20.0; 6], &[10.0; 3]);
assert_eq!(offsets(&app, ["page", "log"]), [page + 150.0, log]);
// The next gesture goes to the list now under the pointer.
trackpad(&app, over_page, &[20.0; 2], &[]);
assert_eq!(offsets(&app, ["page", "log"]), [page + 150.0, log + 40.0]);
}
#[makepad_test]
fn a_trackpad_gesture_that_starts_at_a_nested_lists_limit_scrolls_the_page(app: TestApp) {
let [page] = offsets(&app, ["page"]);
let over_log = center(&app, Selector::id("log_list"));
wheel(&app, over_log, 0.0, 5000.0);
let [_, log_end] = offsets(&app, ["page", "log"]);
thread::sleep(NEW_SCROLL);
// Its momentum too.
trackpad(&app, over_log, &[40.0; 3], &[30.0; 3]);
assert_eq!(offsets(&app, ["page", "log"]), [page + 210.0, log_end]);
}
#[makepad_test]
fn a_trackpad_gesture_at_a_contained_views_limit_never_reaches_the_page(app: TestApp) {
let [page] = offsets(&app, ["page"]);
let over_inner = center(&app, Selector::id("inner_view"));
wheel(&app, over_inner, 0.0, 1000.0);
let [_, inner_end] = offsets(&app, ["page", "inner"]);
thread::sleep(NEW_SCROLL);
// Its rubber band stretches and springs back, and its momentum goes nowhere.
trackpad(&app, over_inner, &[40.0; 3], &[30.0; 3]);
assert_eq!(offsets(&app, ["page", "inner"]), [page, inner_end]);
}
#[makepad_test]
fn a_list_nested_in_a_list_keeps_its_wheel_scroll_until_a_new_one_starts_at_its_end(app: TestApp) {
let [outer, nested] = offsets(&app, ["outer", "nested"]);
let over_nested = center(&app, Selector::id("nested"));
wheel(&app, over_nested, 0.0, 3000.0);
let [outer_after, nested_end] = offsets(&app, ["outer", "nested"]);
assert!(nested_end > nested, "the nested list scrolled");
assert_eq!(outer_after, outer);
thread::sleep(NEW_SCROLL);
wheel(&app, over_nested, 0.0, 100.0);
assert_eq!(offsets(&app, ["outer", "nested"]), [outer + 100.0, nested_end]);
// One with nothing to scroll leaves the wheel to the outer list.
thread::sleep(NEW_SCROLL);
wheel(&app, center(&app, Selector::id("nested_short")), 0.0, 30.0);
assert_eq!(offsets(&app, ["outer", "nested_short"]), [outer + 130.0, 0.0]);
}
/// Scrolls the outer list until its far strip sits near the bottom, with a code block above it,
/// and returns the strip's middle.
fn far_strip_near_bottom(app: &TestApp) -> (f64, f64) {
let list = app.locator(Selector::id("lists")).snapshot();
// 1000 down the list, so this puts it 600 below the list's top.
wheel(app, center(app, Selector::all().text_exact("Outer 10")), 0.0, 400.0);
thread::sleep(NEW_SCROLL);
let strip = app.locator(Selector::id("far_strip")).snapshot();
assert!(strip.y + strip.height < list.y + list.height, "the far strip is in view");
center(app, Selector::id("far_strip"))
}
/// The gesture starts on the far strip, which takes x while the list takes y. The strip scrolls
/// out of view and the code block moves under the pointer, but the list keeps scrolling.
fn check_a_gesture_keeps_its_list_when_the_strip_scrolls_away(app: &TestApp, is_trackpad: bool) {
let over_strip = far_strip_near_bottom(app);
let [outer, code] = offsets(app, ["outer", "code"]);
if is_trackpad {
trackpad(app, over_strip, &[-30.0; 6], &[]);
} else {
for _ in 0..6 {
wheel(app, over_strip, 0.0, -30.0);
}
}
assert_eq!(offsets(app, ["outer", "code"]), [outer - 180.0, code]);
}
#[makepad_test]
fn a_trackpad_gesture_keeps_its_list_when_the_strip_holding_its_other_axis_scrolls_away(app: TestApp) {
check_a_gesture_keeps_its_list_when_the_strip_scrolls_away(&app, true);
}
#[makepad_test]
fn a_wheel_scroll_keeps_its_list_when_the_strip_holding_its_other_axis_scrolls_away(app: TestApp) {
check_a_gesture_keeps_its_list_when_the_strip_scrolls_away(&app, false);
}
#[makepad_test]
fn dragging_a_list_nested_in_a_list_never_drags_the_outer_one(app: TestApp) {
let [outer, nested] = offsets(&app, ["outer", "nested"]);
app.locator(Selector::id("nested")).drag_by(0.0, -80.0);
let [outer_after, nested_after] = offsets(&app, ["outer", "nested"]);
assert!(nested_after >= nested + 80.0, "the nested list followed the drag");
assert_eq!(outer_after, outer);
// One with nothing to scroll lets the outer list take the drag.
app.locator(Selector::id("nested_short")).drag_by(0.0, -60.0);
let [outer_after, nested_short] = offsets(&app, ["outer", "nested_short"]);
assert!(outer_after >= outer + 60.0, "the outer list followed the drag");
assert_eq!(nested_short, 0.0);
}
#[makepad_test]
fn dragging_a_horizontal_strip_in_a_list_scrolls_each_its_own_way(app: TestApp) {
let [outer, strip] = offsets(&app, ["outer", "strip"]);
app.locator(Selector::id("strip")).drag_by(-100.0, 0.0);
let [outer_after, strip_after] = offsets(&app, ["outer", "strip"]);
assert!(strip_after >= strip + 100.0, "the strip followed the drag");
assert_eq!(outer_after, outer);
// Held, so the list doesn't fling the strip's row out of view.
app.locator(Selector::id("strip")).drag_by_and_hold(0.0, -60.0);
let [outer_after, strip_after_vertical] = offsets(&app, ["outer", "strip"]);
assert!(outer_after >= outer + 60.0, "the outer list followed the drag");
assert_eq!(strip_after_vertical, strip_after);
}
#[makepad_test]
fn touch_dragging_a_nested_scrollable_never_drags_the_page(app: TestApp) {
let [page, log, inner] = offsets(&app, ["page", "log", "inner"]);
app.locator(Selector::id("log_list")).touch_drag_by(0.0, -80.0);
let [page_after, log_after] = offsets(&app, ["page", "log"]);
assert!(log_after >= log + 80.0, "the list followed the finger");
assert_eq!(page_after, page);
app.locator(Selector::id("inner_view")).touch_drag_by(0.0, -60.0);
let [page_after, inner_after] = offsets(&app, ["page", "inner"]);
assert!(inner_after >= inner + 60.0, "the nested view followed the finger");
assert_eq!(page_after, page);
}
#[makepad_test]
fn touch_dragging_a_list_nested_in_a_list_never_drags_the_outer_one(app: TestApp) {
let [outer, nested] = offsets(&app, ["outer", "nested"]);
app.locator(Selector::id("nested")).touch_drag_by(0.0, -80.0);
let [outer_after, nested_after] = offsets(&app, ["outer", "nested"]);
assert!(nested_after >= nested + 80.0, "the nested list followed the finger");
assert_eq!(outer_after, outer);
// One with nothing to scroll lets the outer list take the drag.
app.locator(Selector::id("nested_short")).touch_drag_by(0.0, -60.0);
let [outer_after, nested_short] = offsets(&app, ["outer", "nested_short"]);
assert!(outer_after >= outer + 60.0, "the outer list followed the finger");
assert_eq!(nested_short, 0.0);
}
#[makepad_test]
fn touch_dragging_a_horizontal_strip_in_a_list_scrolls_each_its_own_way(app: TestApp) {
let [outer, strip] = offsets(&app, ["outer", "strip"]);
app.locator(Selector::id("strip")).touch_drag_by(-100.0, 0.0);
let [outer_after, strip_after] = offsets(&app, ["outer", "strip"]);
assert!(strip_after >= strip + 100.0, "the strip followed the finger");
assert_eq!(outer_after, outer);
app.locator(Selector::id("strip")).touch_drag_by_and_hold(0.0, -60.0);
let [outer_after, strip_after_vertical] = offsets(&app, ["outer", "strip"]);
assert!(outer_after > outer, "the outer list followed the finger");
assert_eq!(strip_after_vertical, strip_after);
}
#[makepad_test]
fn an_open_dropdown_keeps_scrolls_from_what_is_under_it(app: TestApp) {
let [under, page] = offsets(&app, ["under", "page"]);
app.locator(Selector::id("dd")).click();
thread::sleep(Duration::from_millis(300));
// Over the open list where it covers the view after it, beside the list, and on the page.
let under_popup = app.locator(Selector::id("under_popup")).snapshot();
let (x, y) = center(&app, Selector::id("under_popup"));
wheel(&app, (under_popup.x as f64 + 20.0, y), 0.0, 60.0);
thread::sleep(NEW_SCROLL);
wheel(&app, (x, y), 0.0, 60.0);
thread::sleep(NEW_SCROLL);
wheel(&app, center(&app, Selector::id("page_top")), 0.0, 60.0);
assert_eq!(offsets(&app, ["under", "page"]), [under, page]);
app.press_key(KeyCode::Escape);
thread::sleep(NEW_SCROLL);
wheel(&app, center(&app, Selector::id("under_popup")), 0.0, 60.0);
assert_eq!(offsets(&app, ["under"]), [under + 60.0]);
}
#[makepad_test]
fn swiping_a_scroller_in_a_stack_view_scrolls_it_instead_of_going_back(app: TestApp) {
let [start] = offsets(&app, ["swipe"]);
wheel(&app, center(&app, Selector::id("swipe_strip")), 300.0, 0.0);
let [swipe, depth] = offsets(&app, ["swipe", "stack_depth"]);
assert_eq!((swipe, depth), (start + 300.0, 1.0));
app.locator(Selector::id("swipe_strip")).touch_drag_by_and_hold(80.0, 0.0);
let [swipe_after, depth_after] = offsets(&app, ["swipe", "stack_depth"]);
assert!(swipe_after <= swipe - 70.0, "the strip followed the finger ({swipe_after})");
assert_eq!(depth_after, 1.0, "the view stayed");
// A swipe on something that doesn't scroll sideways still goes back.
app.locator(Selector::all().text_exact("BelowSwipeStrip")).touch_drag_by(120.0, 0.0);
wait_for(&app, "stack_depth", 0.0);
}
#[makepad_test]
fn dragging_a_list_in_a_sliding_panel_never_slides_the_panel(app: TestApp) {
let [panel, list] = offsets(&app, ["panel", "panel_list"]);
app.locator(Selector::id("panel_list")).touch_drag_by_and_hold(0.0, -100.0);
let [panel_after, list_after] = offsets(&app, ["panel", "panel_list"]);
assert!(list_after >= list + 90.0, "the list followed the finger ({list_after})");
assert_eq!(panel_after, panel);
}
#[makepad_test]
fn a_list_partly_scrolled_out_of_a_modal_still_takes_its_scrolls(app: TestApp) {
app.locator(Selector::id("open_modal")).click();
thread::sleep(Duration::from_millis(300));
// Scroll the modal until the top of the list in it, 150 down, is hidden.
let modal_page = app.locator(Selector::id("modal_page")).snapshot();
wheel(&app, center(&app, Selector::id("modal_top")), 0.0, 500.0);
let [modal, list] = offsets(&app, ["modal", "modal_list"]);
assert!(modal > 160.0, "the list's top is scrolled out of the modal ({modal})");
// Over the visible rest of the list.
thread::sleep(NEW_SCROLL);
let visible_list = 350.0 - modal;
let over_list = (modal_page.x as f64 + 40.0, modal_page.y as f64 + visible_list / 2.0);
wheel(&app, over_list, 0.0, 30.0);
assert_eq!(offsets(&app, ["modal", "modal_list"]), [modal, list + 30.0]);
}
/// Scrolls the page so the carousel and the view that fits below it are in view.
fn reveal_carousel(app: &TestApp) -> f64 {
wheel(app, center(app, Selector::id("page_top")), 0.0, 300.0);
thread::sleep(NEW_SCROLL);
let [page] = offsets(app, ["page"]);
assert!(page > 250.0, "the page scrolled the carousel into view");
page
}
#[makepad_test]
fn touch_dragging_a_horizontal_strip_in_a_page_scrolls_each_its_own_way(app: TestApp) {
let page = reveal_carousel(&app);
let [carousel] = offsets(&app, ["carousel"]);
app.locator(Selector::id("carousel")).touch_drag_by_and_hold(-100.0, -10.0);
let [page_after, carousel_after] = offsets(&app, ["page", "carousel"]);
assert!(carousel_after > carousel + 80.0, "the strip followed the finger ({carousel} -> {carousel_after})");
assert_eq!(page_after, page);
app.locator(Selector::id("carousel")).touch_drag_by_and_hold(10.0, -60.0);
let [page_after, carousel_after_vertical] = offsets(&app, ["page", "carousel"]);
assert!(page_after > page + 40.0, "the page followed the finger ({page} -> {page_after})");
assert_eq!(carousel_after_vertical, carousel_after);
// A drag that starts out mostly sideways moves only the strip.
app.locator(Selector::id("carousel")).touch_drag_by_and_hold(-60.0, -20.0);
let [page_after_diagonal, carousel_after_diagonal] = offsets(&app, ["page", "carousel"]);
assert_eq!(page_after_diagonal, page_after);
assert!(carousel_after_diagonal > carousel_after_vertical + 40.0);
}
#[makepad_test]
fn mouse_dragging_a_horizontal_strip_in_a_page_scrolls_each_its_own_way(app: TestApp) {
let page = reveal_carousel(&app);
app.locator(Selector::id("carousel")).drag_by_and_hold(0.0, -60.0);
let [page_after] = offsets(&app, ["page"]);
assert!(page_after > page + 40.0, "the page followed the mouse ({page} -> {page_after})");
}
#[makepad_test]
fn touch_dragging_a_nested_scroll_view_with_nothing_to_scroll_drags_the_page(app: TestApp) {
let page = reveal_carousel(&app);
app.locator(Selector::id("fit_view")).touch_drag_by_and_hold(0.0, -60.0);
let [page_after] = offsets(&app, ["page"]);
assert!(page_after > page + 40.0, "the page followed the finger ({page} -> {page_after})");
}
#[makepad_test]
fn touch_dragging_a_multiline_input_never_drags_the_page(app: TestApp) {
let page = reveal_carousel(&app);
app.locator(Selector::id("short_notes")).touch_drag_by_and_hold(0.0, -60.0);
assert_eq!(offsets(&app, ["page"]), [page]);
}
fn open_code_modal(app: &TestApp) {
app.locator(Selector::id("open_code_modal")).click();
app.locator(Selector::id("code_view")).wait_visible();
thread::sleep(Duration::from_millis(300));
}
#[makepad_test]
fn touch_dragging_wide_code_scrolls_it_sideways_and_its_page_down(app: TestApp) {
open_code_modal(&app);
let [page, x] = offsets(&app, ["code_page", "code_x"]);
// A little off each axis, so only the axis each drag mostly went along moves.
app.locator(Selector::id("code_view")).touch_drag_by_and_hold(-120.0, -12.0);
let [page_after, x_after] = offsets(&app, ["code_page", "code_x"]);
assert!(x_after >= x + 110.0, "the code followed the finger sideways ({x_after})");
assert_eq!(page_after, page);
app.locator(Selector::id("code_view")).touch_drag_by_and_hold(12.0, -80.0);
let [page_after, x_after_vertical] = offsets(&app, ["code_page", "code_x"]);
assert!(page_after >= page + 70.0, "the page followed the finger ({page_after})");
assert_eq!(x_after_vertical, x_after);
// A drag that starts out diagonally moves both.
app.locator(Selector::id("code_view")).touch_drag_by_and_hold(-60.0, -40.0);
let [page_diagonal, x_diagonal] = offsets(&app, ["code_page", "code_x"]);
assert!(page_diagonal >= page_after + 30.0, "the page followed the finger ({page_diagonal})");
assert!(x_diagonal >= x_after + 50.0, "the code followed the finger ({x_diagonal})");
}
#[makepad_test]
fn touch_dragging_code_never_selects_it(app: TestApp) {
open_code_modal(&app);
// Toward both scroll limits, so neither scroller can keep the text under the finger.
app.locator(Selector::id("code_view")).touch_drag_by(60.0, 60.0);
assert_eq!(offsets(&app, ["code_sel"]), [0.0]);
}
#[makepad_test]
fn mouse_dragging_code_selects_it_and_scrolls_nothing(app: TestApp) {
open_code_modal(&app);
let [page, x] = offsets(&app, ["code_page", "code_x"]);
app.locator(Selector::id("code_view")).drag_by(-120.0, -40.0);
assert_eq!(offsets(&app, ["code_sel", "code_page", "code_x"]), [1.0, page, x]);
}
#[makepad_test]
fn touch_dragging_from_a_button_above_code_never_clicks_it(app: TestApp) {
open_code_modal(&app);
let [page] = offsets(&app, ["code_page"]);
app.locator(Selector::id("code_button")).touch_drag_by_and_hold(0.0, -60.0);
assert_eq!(offsets(&app, ["code_clicks", "code_page"]), [0.0, page]);
// A touch that stays put is still a tap.
app.locator(Selector::id("code_button")).touch_drag_by(0.0, 0.0);
wait_for(&app, "code_clicks", 1.0);
}
#[makepad_test]
fn a_scroll_view_never_joins_a_drag_on_a_scroller_drawn_over_it(app: TestApp) {
open_code_modal(&app);
let [page] = offsets(&app, ["code_page"]);
app.locator(Selector::id("code_floating")).touch_drag_by_and_hold(0.0, -60.0);
assert_eq!(offsets(&app, ["code_page"]), [page]);
}
#[makepad_test]
fn a_diagonal_touch_drag_on_a_strip_in_a_list_moves_only_one_of_them(app: TestApp) {
let [outer, strip] = offsets(&app, ["outer", "strip"]);
// Farther along x than y, so only the strip follows.
app.locator(Selector::id("strip")).touch_drag_by_and_hold(-60.0, -20.0);
let [outer_after, strip_after] = offsets(&app, ["outer", "strip"]);
assert_eq!(outer_after, outer);
assert!(strip_after >= strip + 40.0, "the strip followed the finger ({strip} -> {strip_after})");
}
#[makepad_test]
fn touch_dragging_code_that_fits_drags_its_page(app: TestApp) {
open_code_modal(&app);
let [page] = offsets(&app, ["code_page"]);
app.locator(Selector::id("narrow_code_view")).touch_drag_by_and_hold(8.0, -80.0);
let [page_after] = offsets(&app, ["code_page"]);
assert!(page_after >= page + 70.0, "the page followed the finger ({page_after})");
}
#[makepad_test]
fn wheel_over_code_scrolls_its_page_down_and_the_code_sideways(app: TestApp) {
open_code_modal(&app);
let over_code = center(&app, Selector::id("code_view"));
let [page, x] = offsets(&app, ["code_page", "code_x"]);
wheel(&app, over_code, 0.0, 60.0);
assert_eq!(offsets(&app, ["code_page", "code_x"]), [page + 60.0, x]);
thread::sleep(NEW_SCROLL);
wheel(&app, over_code, 60.0, 0.0);
assert_eq!(offsets(&app, ["code_page", "code_x"]), [page + 60.0, x + 60.0]);
}
#[makepad_test]
fn a_touch_drag_on_code_keeps_to_the_way_it_started(app: TestApp) {
open_code_modal(&app);
let [page, x] = offsets(&app, ["code_page", "code_x"]);
// Sideways first, then down without lifting the finger: only the code moves.
app.locator(Selector::id("code_view")).touch_drag_through_and_hold(&[(-60.0, 0.0), (-60.0, -100.0)]);
let [page_after, x_after] = offsets(&app, ["code_page", "code_x"]);
assert_eq!(page_after, page);
assert!(x_after >= x + 50.0, "the code followed the finger sideways ({x_after})");
// Down first, then sideways: only the page moves.
app.locator(Selector::id("code_view")).touch_drag_through_and_hold(&[(0.0, -60.0), (-80.0, -60.0)]);
let [page_last, x_last] = offsets(&app, ["code_page", "code_x"]);
assert!(page_last >= page_after + 50.0, "the page followed the finger ({page_last})");
assert_eq!(x_last, x_after);
}
#[makepad_test]
fn a_trackpad_gesture_on_code_keeps_to_the_way_it_started(app: TestApp) {
open_code_modal(&app);
let (x, y) = center(&app, Selector::id("code_view"));
let gesture = |deltas: &[(f64, f64)]| {
app.scroll_at(x, y, deltas[0].0, deltas[0].1, Some("began"));
for &(dx, dy) in &deltas[1..] {
app.scroll_at(x, y, dx, dy, Some("changed"));
}
app.scroll_at(x, y, 0.0, 0.0, Some("ended"));
};
let [page, code_x] = offsets(&app, ["code_page", "code_x"]);
// Sideways first, then down without lifting the fingers: only the code moves.
gesture(&[(4.0, 0.0), (4.0, 0.0), (0.0, 40.0)]);
assert_eq!(offsets(&app, ["code_page", "code_x"]), [page, code_x + 8.0]);
// A diagonal start scrolls both ways.
gesture(&[(4.0, 4.0), (4.0, 4.0), (0.0, 40.0)]);
assert_eq!(offsets(&app, ["code_page", "code_x"]), [page + 48.0, code_x + 16.0]);
}