Skip to main content

frus_widgets/
pageview.rs

1//! [`PageView`]: a scrollable that comes to rest **on a page**, never between two.
2//!
3//! Onboarding screens, an image gallery, the tabs of a phone home screen: the
4//! content is a sequence of full-viewport panels, and the only resting positions
5//! are the panels themselves. Everything else — the drag, the edges, the glow —
6//! is the ordinary scrollable machinery; what a page view changes is the single
7//! moment the finger lifts, where a fling is replaced by a spring to one page.
8//!
9//! Like [`crate::ListView`], it is **virtualised**: pages are built by an
10//! `index → widget` closure and only the ones on screen exist. A hundred-page
11//! walkthrough costs the same per frame as a two-page one, and — the other side
12//! of the same coin — a page has **no retained state** while it is off screen.
13
14use frus_core::{Rect, Scene};
15use frus_layout::{Dimension, Style};
16
17use crate::interaction::Status;
18use crate::physics::ScrollPhysics;
19use crate::scroll::Axis;
20use crate::theme::Theme;
21use crate::widget::Widget;
22
23/// How a scrollable snaps to pages, as the shell and the runtime see it.
24///
25/// This is what turns an ordinary scroll region into a paged one: it travels on
26/// the region's [`crate::Scrollable`] description, so the release path can ask
27/// "is this area paged?" without knowing anything about the widget.
28#[derive(Copy, Clone, Debug, PartialEq)]
29pub struct PageSnap {
30    /// Distance from one page to the next, in logical pixels.
31    pub extent: f32,
32    /// How many pages there are.
33    pub count: usize,
34    /// The page the application is asking for, applied on the first frame and on
35    /// every change; see [`PageView::page`].
36    pub requested: usize,
37    /// Does the view page sideways?
38    pub horizontal: bool,
39    /// Does a release come to rest **on** a page?
40    ///
41    /// False and the region flings like any other scroll, which is what a
42    /// free-running carousel wants. The rest of the paged machinery stays: the
43    /// page a reader would name is still reported, and a requested page still
44    /// glides. Only the release changes. See [`PageView::page_snapping`].
45    pub snapping: bool,
46}
47
48impl PageSnap {
49    /// The offset at which `index` rests.
50    pub fn offset_of(&self, index: usize) -> f32 {
51        index as f32 * self.extent
52    }
53
54    /// The page an `offset` reads as — the one a reader would say they are on.
55    pub fn page_at(&self, offset: f32) -> usize {
56        let last = self.count.saturating_sub(1);
57        (crate::physics::page_of(offset, self.extent)
58            .round()
59            .max(0.0) as usize)
60            .min(last)
61    }
62}
63
64/// The description of a paged view, exposed to the render driver.
65pub struct PagedView<'a, Msg = crate::callback::Callback> {
66    /// Total number of pages.
67    pub count: usize,
68    /// Which way the pages are laid out.
69    pub axis: Axis,
70    /// The fraction of the viewport one page occupies along that axis.
71    pub viewport_fraction: f32,
72    /// A page extent in **pixels**, overriding the fraction when set.
73    ///
74    /// A page view states a fraction because a page is a share of the window it is read
75    /// in. A [`crate::ListWheel`] states pixels, because a row of a wheel is a line of
76    /// text and a line of text is not a share of anything.
77    pub extent: Option<f32>,
78    /// The cylinder the pages are laid on, when they are laid on one at all — a
79    /// [`crate::ListWheel`] and nothing else. See [`crate::WheelGeometry`].
80    pub wheel: Option<crate::wheel::WheelGeometry>,
81    /// The page asked for by the application.
82    pub requested: usize,
83    /// Room at both ends, so a page narrower than the viewport rests centred in
84    /// it; see [`PageView::pad_ends`].
85    pub pad_ends: bool,
86    /// Does a release come to rest on a page? See [`PageView::page_snapping`].
87    pub snapping: bool,
88    /// Builds one page per index.
89    pub build: &'a dyn Fn(usize) -> Box<dyn Widget<Msg>>,
90}
91
92impl<Msg> PagedView<'_, Msg> {
93    /// The snapping description for a viewport of this size.
94    pub fn snap(&self, viewport: Rect) -> PageSnap {
95        let horizontal = !matches!(self.axis, Axis::Vertical);
96        let along = if horizontal {
97            viewport.width
98        } else {
99            viewport.height
100        };
101        PageSnap {
102            extent: self
103                .extent
104                .unwrap_or(along * self.viewport_fraction)
105                .max(1.0),
106            count: self.count,
107            requested: self.requested,
108            horizontal,
109            snapping: self.snapping,
110        }
111    }
112
113    /// The room at each end of the content, in logical pixels.
114    ///
115    /// Zero at the default fraction of one, where a page already fills the
116    /// viewport and there is nothing to centre.
117    pub fn end_padding(&self, viewport: Rect) -> f32 {
118        if !self.pad_ends {
119            return 0.0;
120        }
121        let snap = self.snap(viewport);
122        let along = match snap.horizontal {
123            true => viewport.width,
124            false => viewport.height,
125        };
126        ((along - snap.extent) / 2.0).max(0.0)
127    }
128}
129
130/// A scrollable that rests only on page boundaries.
131pub struct PageView<Msg = crate::callback::Callback> {
132    count: usize,
133    axis: Axis,
134    viewport_fraction: f32,
135    requested: usize,
136    /// Room at both ends; see [`PageView::pad_ends`].
137    pad_ends: bool,
138    /// Does a release rest on a page? See [`PageView::page_snapping`].
139    snapping: bool,
140    /// Does page 0 sit at the end the axis finishes at? See [`PageView::reverse`].
141    reverse: bool,
142    width: Dimension,
143    height: Dimension,
144    /// Was the width **set** explicitly? In flex mode an unset dimension on the
145    /// paging axis must not serve as a basis; see [`PageView::style`].
146    width_explicit: bool,
147    /// Was the height **set** explicitly? The same question for the other axis.
148    height_explicit: bool,
149    flex_grow: f32,
150    physics: Option<ScrollPhysics>,
151    build: Box<dyn Fn(usize) -> Box<dyn Widget<Msg>>>,
152    on_page_changed: Option<Box<dyn Fn(usize) -> Msg>>,
153}
154
155impl<Msg> PageView<Msg> {
156    /// Creates a view of `count` pages, each built on demand by `build(index)`.
157    ///
158    /// Pages run **horizontally** and fill the viewport, which is what a page view
159    /// is nine times out of ten; [`PageView::axis`] and
160    /// [`PageView::viewport_fraction`] change both.
161    pub fn new<W: Widget<Msg> + 'static>(
162        count: usize,
163        build: impl Fn(usize) -> W + 'static,
164    ) -> Self {
165        Self {
166            count,
167            axis: Axis::Horizontal,
168            viewport_fraction: 1.0,
169            requested: 0,
170            pad_ends: true,
171            snapping: true,
172            reverse: false,
173            width: Dimension::Auto,
174            height: Dimension::Length(200.0),
175            width_explicit: false,
176            height_explicit: false,
177            flex_grow: 0.0,
178            physics: None,
179            build: Box::new(move |index| Box::new(build(index)) as Box<dyn Widget<Msg>>),
180            on_page_changed: None,
181        }
182    }
183
184    /// Lays the pages out along `axis`. Only [`Axis::Horizontal`] and
185    /// [`Axis::Vertical`] mean anything here: a page view pages one way.
186    pub fn axis(mut self, axis: Axis) -> Self {
187        self.axis = axis;
188        self
189    }
190
191    /// The fraction of the viewport one page occupies; `1.0` — the default — makes
192    /// each page fill it.
193    ///
194    /// Below one, the neighbouring pages show at the edges, which is how a carousel
195    /// says "there is more this way" without a hint or an arrow. The value is what
196    /// the pages are measured *and* snapped by, so the two cannot disagree.
197    pub fn viewport_fraction(mut self, fraction: f32) -> Self {
198        self.viewport_fraction = fraction.max(0.05);
199        self
200    }
201
202    /// Whether there is room at **both ends** of the content, so that the first and
203    /// last pages come to rest centred in the viewport. On by default, and nil at the
204    /// default [`viewport_fraction`](PageView::viewport_fraction) of one, where a page
205    /// already fills the viewport and there is nothing to centre.
206    ///
207    /// It is not only a matter of looks. Without it the travel stops with the last
208    /// page's trailing edge on the viewport's, which for a carousel is **short of
209    /// where the last page rests**: nine pages at 0.8 of a 300 px viewport give 1620 px
210    /// of travel and the ninth page rests at 1728. It would never quite arrive, and a
211    /// snap towards it would be pulled back by the edge every time.
212    ///
213    /// Padded, the travel is exactly `extent × (count - 1)`, which is the last
214    /// page's own resting offset. Every page is reachable and the arithmetic says so.
215    ///
216    /// Turn it off for a carousel that starts flush against its leading edge, with the
217    /// following pages peeking in from the other side.
218    pub fn pad_ends(mut self, pad: bool) -> Self {
219        self.pad_ends = pad;
220        self
221    }
222
223    /// Whether a release comes to rest **on** a page. On by default — it is what
224    /// makes this a page view rather than a scroll.
225    ///
226    /// Off, the region flings like any other scrollable and stops wherever the
227    /// physics leaves it, which is what a free-running carousel wants. Everything else
228    /// stays: [`on_page_changed`](PageView::on_page_changed) still reports the page a
229    /// reader would name, and [`page`](PageView::page) still glides across to what the
230    /// application asks for. Only the release changes.
231    pub fn page_snapping(mut self, snapping: bool) -> Self {
232        self.snapping = snapping;
233        self
234    }
235
236    /// Pages **from the far end**: page 0 sits at the edge the axis finishes at — the
237    /// right of a horizontal view, the bottom of a vertical one — page 1 before it,
238    /// and the view opens there.
239    ///
240    /// The same idea as [`crate::ListView::reverse`]: not a mirrored picture, but a
241    /// different answer to *which end is index 0*.
242    pub fn reverse(mut self) -> Self {
243        self.reverse = true;
244        self
245    }
246
247    /// The page to show.
248    ///
249    /// Applied when the view first appears — so this is also the initial page — and
250    /// again **whenever the value changes**, gliding across to it. Between changes
251    /// the finger has the offset: a view that re-asserted its page every frame
252    /// could not be swiped at all. An application that never sets it gets an
253    /// uncontrolled view starting at page 0.
254    pub fn page(mut self, page: usize) -> Self {
255        self.requested = page;
256        self
257    }
258
259    /// The message sent when the page a reader would name changes — mid-drag, as
260    /// soon as the rounding tips, not once the motion has settled. A title above a
261    /// gallery should follow the picture, not trail it.
262    pub fn on_page_changed<R: crate::callback::IntoMsg<Msg>>(
263        mut self,
264        message: impl Fn(usize) -> R + 'static,
265    ) -> Self {
266        self.on_page_changed = Some(Box::new(crate::callback::handler1(message)));
267        self
268    }
269
270    /// Overrides how the view behaves at its edges; see [`crate::SingleChildScrollView::physics`].
271    /// The **fling** is a page view's own business either way.
272    pub fn physics(mut self, physics: ScrollPhysics) -> Self {
273        self.physics = Some(physics);
274        self
275    }
276
277    /// Sets the viewport width, in logical pixels.
278    pub fn width(mut self, width: f32) -> Self {
279        self.width = Dimension::Length(width);
280        self.width_explicit = true;
281        self
282    }
283
284    /// Sets the viewport height, in logical pixels.
285    pub fn height(mut self, height: f32) -> Self {
286        self.height = Dimension::Length(height);
287        self.height_explicit = true;
288        self
289    }
290
291    /// Flex growth factor along the parent's main axis.
292    pub fn flex(mut self, grow: f32) -> Self {
293        self.flex_grow = grow;
294        self
295    }
296}
297
298impl<Msg> Widget<Msg> for PageView<Msg> {
299    fn style(&self) -> Style {
300        // Filling then paging: a default dimension that was never asked for must not
301        // stand as a flex **basis**, or the view would need `+200` of free room
302        // before it grew at all. The same trap as [`crate::SingleChildScrollView::style`], and the
303        // same answer: an `Auto` basis of 0, and `flex_grow` does the filling.
304        let filling = self.flex_grow > 0.0;
305        let width = if filling && !self.width_explicit {
306            Dimension::Auto
307        } else {
308            self.width
309        };
310        let height = if filling && !self.height_explicit {
311            Dimension::Auto
312        } else {
313            self.height
314        };
315        Style {
316            width,
317            height,
318            flex_grow: self.flex_grow,
319            ..Default::default()
320        }
321    }
322
323    fn children(&self) -> &[Box<dyn Widget<Msg>>] {
324        &[]
325    }
326
327    fn paint(&self, _bounds: Rect, _status: Status, _theme: &Theme, _scene: &mut Scene) {
328        // The viewport is transparent: only the pages are drawn.
329    }
330
331    fn on_click(&self) -> Option<Msg> {
332        None
333    }
334
335    fn page_view(&self) -> Option<PagedView<'_, Msg>> {
336        Some(PagedView {
337            count: self.count,
338            axis: self.axis,
339            viewport_fraction: self.viewport_fraction,
340            extent: None,
341            wheel: None,
342            requested: self.requested,
343            pad_ends: self.pad_ends,
344            snapping: self.snapping,
345            build: &*self.build,
346        })
347    }
348
349    fn on_page_changed(&self, page: usize) -> Option<Msg> {
350        self.on_page_changed.as_ref().map(|make| make(page))
351    }
352
353    fn scroll_axis(&self) -> Axis {
354        self.axis
355    }
356
357    fn scroll_reverse(&self) -> bool {
358        self.reverse
359    }
360
361    fn scroll_physics(&self) -> Option<ScrollPhysics> {
362        self.physics
363    }
364}
365
366#[cfg(test)]
367mod tests {
368    use super::*;
369    use crate::{build_ui, Container, Runtime, Size};
370    use frus_core::{Color, Primitive};
371    use std::cell::Cell;
372    use std::rc::Rc;
373
374    fn view(built: Rc<Cell<usize>>) -> PageView<()> {
375        PageView::new(50, move |index| {
376            built.set(built.get() + 1);
377            Container::new().color(Color::rgb(index as f32 / 50.0, 0.0, 0.0))
378        })
379        .width(300.0)
380        .height(400.0)
381    }
382
383    fn ui_of(widget: PageView<()>, runtime: &Runtime) -> crate::Ui<()> {
384        build_ui(&widget, Size::new(300.0, 400.0), runtime, &Theme::default())
385    }
386
387    #[test]
388    fn only_the_pages_on_screen_are_built() {
389        let built = Rc::new(Cell::new(0));
390        let runtime = Runtime::default();
391        ui_of(view(built.clone()), &runtime);
392        // Page 0 fills the viewport; page 1 starts exactly at its right edge, so it
393        // is not on screen yet. Fifty pages, one built.
394        assert_eq!(built.get(), 1);
395    }
396
397    #[test]
398    fn a_half_scrolled_view_shows_both_neighbours() {
399        let built = Rc::new(Cell::new(0));
400        let mut runtime = Runtime::default();
401        let widget = view(built.clone());
402        let id = crate::interaction::WidgetId::ROOT;
403        runtime.scroll.insert(id, (150.0, 0.0));
404        ui_of(widget, &runtime);
405        assert_eq!(built.get(), 2);
406    }
407
408    #[test]
409    fn the_content_is_as_long_as_the_pages_put_together() {
410        let runtime = Runtime::default();
411        let ui = ui_of(view(Rc::new(Cell::new(0))), &runtime);
412        let area = ui
413            .scroll_regions()
414            .first()
415            .copied()
416            .expect("a scroll region");
417        // 50 pages of 300 px in a 300 px viewport: 49 pages' worth of travel.
418        assert_eq!(area.max_x, 49.0 * 300.0);
419        assert_eq!(area.max_y, 0.0);
420        let snap = area.page.expect("a paged region");
421        assert_eq!(snap.extent, 300.0);
422        assert_eq!(snap.count, 50);
423        assert!(snap.horizontal);
424    }
425
426    #[test]
427    fn narrower_pages_let_the_next_one_show() {
428        let runtime = Runtime::default();
429        let ui = ui_of(view(Rc::new(Cell::new(0))).viewport_fraction(0.8), &runtime);
430        let area = ui
431            .scroll_regions()
432            .first()
433            .copied()
434            .expect("a scroll region");
435        let snap = area.page.expect("a paged region");
436        assert_eq!(snap.extent, 240.0);
437        // Padded ends: the travel is exactly the last page's resting offset, so the
438        // fiftieth page can be reached. Unpadded it would stop 60 px short of it.
439        assert_eq!(area.max_x, 49.0 * 240.0);
440        assert_eq!(snap.offset_of(49), area.max_x);
441    }
442
443    #[test]
444    fn a_vertical_view_pages_downwards() {
445        let runtime = Runtime::default();
446        let ui = ui_of(view(Rc::new(Cell::new(0))).axis(Axis::Vertical), &runtime);
447        let area = ui
448            .scroll_regions()
449            .first()
450            .copied()
451            .expect("a scroll region");
452        let snap = area.page.expect("a paged region");
453        assert!(!snap.horizontal);
454        assert_eq!(snap.extent, 400.0);
455        assert_eq!(area.max_x, 0.0);
456        assert_eq!(area.max_y, 49.0 * 400.0);
457    }
458
459    #[test]
460    fn a_page_is_painted_where_the_offset_says() {
461        let mut runtime = Runtime::default();
462        let widget = PageView::new(3, |_| Container::new().color(Color::rgb(1.0, 0.0, 0.0)))
463            .width(300.0)
464            .height(400.0);
465        let id = crate::interaction::WidgetId::ROOT;
466        runtime.scroll.insert(id, (300.0, 0.0));
467        let ui = ui_of(widget, &runtime);
468        // Page 1 is scrolled exactly into place: its box starts at the viewport's
469        // left edge, not at 300.
470        let left = ui
471            .scene()
472            .primitives()
473            .iter()
474            .filter_map(|p| match p {
475                Primitive::Rect { rect, .. } if rect.width > 100.0 => Some(rect.x),
476                _ => None,
477            })
478            .next()
479            .expect("a page");
480        assert!(left.abs() < 0.5, "page 1 drawn at {left}");
481    }
482
483    /// Where the pages were actually drawn, in build order.
484    fn page_lefts(ui: &crate::Ui<()>) -> Vec<f32> {
485        ui.scene()
486            .primitives()
487            .iter()
488            .filter_map(|p| match p {
489                Primitive::Rect { rect, .. } if rect.width > 100.0 => Some(rect.x),
490                _ => None,
491            })
492            .collect()
493    }
494
495    /// A carousel opens with its first page **centred**, not jammed against the
496    /// leading edge. Half the slack either side, which is what the room at the ends
497    /// is for.
498    #[test]
499    fn a_carousel_rests_its_first_page_in_the_middle() {
500        let runtime = Runtime::default();
501        let ui = ui_of(view(Rc::new(Cell::new(0))).viewport_fraction(0.8), &runtime);
502        // 300 px viewport, 240 px page: 60 px of slack, 30 either side.
503        let lefts = page_lefts(&ui);
504        assert!((lefts[0] - 30.0).abs() < 0.5, "page 0 drawn at {lefts:?}");
505        assert!((lefts[1] - 270.0).abs() < 0.5, "page 1 drawn at {lefts:?}");
506    }
507
508    /// Without the room at the ends the first page starts flush and the following
509    /// ones peek in from the other side — and the travel is 60 px shorter, which is
510    /// 60 px short of where the last page rests.
511    #[test]
512    fn unpadded_ends_start_flush_and_stop_early() {
513        let runtime = Runtime::default();
514        let ui = ui_of(
515            view(Rc::new(Cell::new(0)))
516                .viewport_fraction(0.8)
517                .pad_ends(false),
518            &runtime,
519        );
520        assert!(page_lefts(&ui)[0].abs() < 0.5);
521        let area = ui.scroll_regions()[0];
522        assert_eq!(area.max_x, 50.0 * 240.0 - 300.0);
523        // The last page's resting offset is past the end of the travel: it can be
524        // snapped at but never reached, which is why the ends are padded by default.
525        let snap = area.page.expect("a paged region");
526        assert!(snap.offset_of(49) > area.max_x);
527    }
528
529    /// A reversed view puts page 0 at the end the axis finishes at.
530    #[test]
531    fn a_reversed_view_starts_at_the_far_end() {
532        let runtime = Runtime::default();
533        let ui = ui_of(
534            view(Rc::new(Cell::new(0)))
535                .viewport_fraction(0.8)
536                .pad_ends(false)
537                .reverse(),
538            &runtime,
539        );
540        // 300 px viewport, 240 px page: page 0's right edge is the viewport's.
541        let lefts = page_lefts(&ui);
542        assert!((lefts[0] - 60.0).abs() < 0.5, "page 0 drawn at {lefts:?}");
543        // Page 1 is before it, off the leading edge.
544        assert!(lefts[1] < lefts[0], "page 1 drawn at {lefts:?}");
545        let area = ui.scroll_regions()[0];
546        assert!(area.reverse_x, "the region says which way its offsets run");
547        assert!(!area.reverse_y);
548        // The travel is unchanged: only which end index 0 is at has moved.
549        assert_eq!(area.max_x, 50.0 * 240.0 - 300.0);
550    }
551
552    /// Snapping off, a release is an ordinary fling: a view left between two pages
553    /// with no speed behind it **stays** there.
554    #[test]
555    fn a_view_that_does_not_snap_is_released_like_a_scroll() {
556        let id = crate::interaction::WidgetId::ROOT;
557        let physics = ScrollPhysics::Clamping;
558        let mut runtime = Runtime::default();
559
560        let free = {
561            let ui = ui_of(view(Rc::new(Cell::new(0))).page_snapping(false), &runtime);
562            ui.scroll_regions()[0]
563        };
564        let snapping = {
565            let ui = ui_of(view(Rc::new(Cell::new(0))), &runtime);
566            ui.scroll_regions()[0]
567        };
568        assert!(!free.page.expect("still a paged region").snapping);
569
570        // A fifth of a page along, released without a throw.
571        runtime.scroll.insert(id, (60.0, 0.0));
572        assert!(
573            !runtime.fling_scroll(free, physics, (0.0, 0.0)),
574            "nothing to do: it is in range and going nowhere"
575        );
576        assert_eq!(runtime.scroll[&id], (60.0, 0.0));
577        // The same release on a snapping view springs back to page 0.
578        assert!(runtime.fling_scroll(snapping, physics, (0.0, 0.0)));
579    }
580
581    /// Everything but the release stays: the page a reader would name is still
582    /// reported, and the page the application asks for is still honoured.
583    #[test]
584    fn a_view_that_does_not_snap_still_knows_its_pages() {
585        let id = crate::interaction::WidgetId::ROOT;
586        let mut runtime = Runtime::default();
587        let area = {
588            let ui = ui_of(view(Rc::new(Cell::new(0))).page_snapping(false), &runtime);
589            ui.scroll_regions()[0]
590        };
591        assert!(
592            runtime.page_changes(&[area]).is_empty(),
593            "opening is not a change"
594        );
595        runtime.scroll.insert(id, (300.0, 0.0));
596        assert_eq!(runtime.page_changes(&[area]), vec![(id, 1)]);
597
598        // And a request still moves it: first sighting, so straight there.
599        let mut fresh = Runtime::default();
600        let asked = {
601            let ui = ui_of(
602                view(Rc::new(Cell::new(0))).page_snapping(false).page(3),
603                &fresh,
604            );
605            ui.scroll_regions()[0]
606        };
607        fresh.sync_pages(&[asked]);
608        assert_eq!(fresh.scroll[&id], (900.0, 0.0));
609    }
610
611    /// The whole loop, as the shell drives it: build, drag, release, settle.
612    ///
613    /// Everything below the shell's pointer plumbing is exercised here — the extent
614    /// read off the viewport, the release, the spring, and the page reported — which
615    /// is as close to the gesture as a test without a window gets.
616    #[test]
617    fn a_short_flick_turns_the_page_and_says_so() {
618        let mut runtime = Runtime::default();
619        let id = crate::interaction::WidgetId::ROOT;
620        let physics = ScrollPhysics::Clamping;
621
622        let area = {
623            let ui = ui_of(view(Rc::new(Cell::new(0))), &runtime);
624            ui.scroll_regions()[0]
625        };
626        // Opening on page 0 is not a change to report.
627        assert!(runtime.page_changes(&[area]).is_empty());
628
629        // The finger drags a fifth of a page across and throws.
630        runtime.scroll.insert(id, (60.0, 0.0));
631        assert!(runtime.fling_scroll(area, physics, (700.0, 0.0)));
632        let mut reported = Vec::new();
633        for _ in 0..240 {
634            runtime.advance_scroll(&[area], physics, 1.0 / 60.0);
635            reported.extend(runtime.page_changes(&[area]));
636        }
637
638        assert_eq!(reported, vec![(id, 1)], "reported once, on the way");
639        let (x, _) = runtime.scroll[&id];
640        assert!((x - 300.0).abs() < 1.0, "settled between pages, at {x}");
641    }
642
643    #[test]
644    fn the_page_a_reader_would_name_is_the_nearest_one() {
645        let snap = PageSnap {
646            extent: 300.0,
647            count: 3,
648            requested: 0,
649            horizontal: true,
650            snapping: true,
651        };
652        assert_eq!(snap.page_at(0.0), 0);
653        assert_eq!(snap.page_at(149.0), 0);
654        assert_eq!(snap.page_at(151.0), 1);
655        assert_eq!(snap.offset_of(2), 600.0);
656        // An overscroll is still the page it is pulling away from.
657        assert_eq!(snap.page_at(-40.0), 0);
658        assert_eq!(snap.page_at(9_000.0), 2);
659    }
660}