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    /// **All the room on offer** on an axis nobody sized, as the reference's takes
324    /// (milestone 590) — told to grow or not: a flex factor is only heard by a row or a
325    /// column, and a page view in a stack's layer still takes the stack's room
326    /// (milestone 639).
327    fn fill_axes(&self, _theme: &Theme) -> crate::widget::FillAxes {
328        crate::widget::FillAxes {
329            horizontal: !self.width_explicit,
330            vertical: !self.height_explicit,
331        }
332    }
333
334    /// The default size is only a default: the room replaces it wherever the layout can
335    /// give the room, and it stands where the reference would have nothing to give.
336    fn soft_extent(&self) -> crate::widget::FillAxes {
337        crate::widget::FillAxes {
338            horizontal: false,
339            vertical: !self.height_explicit,
340        }
341    }
342
343    fn children(&self) -> &[Box<dyn Widget<Msg>>] {
344        &[]
345    }
346
347    fn paint(&self, _bounds: Rect, _status: Status, _theme: &Theme, _scene: &mut Scene) {
348        // The viewport is transparent: only the pages are drawn.
349    }
350
351    fn on_click(&self) -> Option<Msg> {
352        None
353    }
354
355    fn page_view(&self) -> Option<PagedView<'_, Msg>> {
356        Some(PagedView {
357            count: self.count,
358            axis: self.axis,
359            viewport_fraction: self.viewport_fraction,
360            extent: None,
361            wheel: None,
362            requested: self.requested,
363            pad_ends: self.pad_ends,
364            snapping: self.snapping,
365            build: &*self.build,
366        })
367    }
368
369    fn on_page_changed(&self, page: usize) -> Option<Msg> {
370        self.on_page_changed.as_ref().map(|make| make(page))
371    }
372
373    fn scroll_axis(&self) -> Axis {
374        self.axis
375    }
376
377    fn scroll_reverse(&self) -> bool {
378        self.reverse
379    }
380
381    fn scroll_physics(&self) -> Option<ScrollPhysics> {
382        self.physics
383    }
384}
385
386#[cfg(test)]
387mod tests {
388    use super::*;
389    use crate::{build_ui, Container, Runtime, Size};
390    use frus_core::{Color, Primitive};
391    use std::cell::Cell;
392    use std::rc::Rc;
393
394    fn view(built: Rc<Cell<usize>>) -> PageView<()> {
395        PageView::new(50, move |index| {
396            built.set(built.get() + 1);
397            Container::new().color(Color::rgb(index as f32 / 50.0, 0.0, 0.0))
398        })
399        .width(300.0)
400        .height(400.0)
401    }
402
403    fn ui_of(widget: PageView<()>, runtime: &Runtime) -> crate::Ui<()> {
404        build_ui(&widget, Size::new(300.0, 400.0), runtime, &Theme::default())
405    }
406
407    #[test]
408    fn only_the_pages_on_screen_are_built() {
409        let built = Rc::new(Cell::new(0));
410        let runtime = Runtime::default();
411        ui_of(view(built.clone()), &runtime);
412        // Page 0 fills the viewport; page 1 starts exactly at its right edge, so it
413        // is not on screen yet. Fifty pages, one built.
414        assert_eq!(built.get(), 1);
415    }
416
417    #[test]
418    fn a_half_scrolled_view_shows_both_neighbours() {
419        let built = Rc::new(Cell::new(0));
420        let mut runtime = Runtime::default();
421        let widget = view(built.clone());
422        let id = crate::interaction::WidgetId::ROOT;
423        runtime.scroll.insert(id, (150.0, 0.0));
424        ui_of(widget, &runtime);
425        assert_eq!(built.get(), 2);
426    }
427
428    #[test]
429    fn the_content_is_as_long_as_the_pages_put_together() {
430        let runtime = Runtime::default();
431        let ui = ui_of(view(Rc::new(Cell::new(0))), &runtime);
432        let area = ui
433            .scroll_regions()
434            .first()
435            .copied()
436            .expect("a scroll region");
437        // 50 pages of 300 px in a 300 px viewport: 49 pages' worth of travel.
438        assert_eq!(area.max_x, 49.0 * 300.0);
439        assert_eq!(area.max_y, 0.0);
440        let snap = area.page.expect("a paged region");
441        assert_eq!(snap.extent, 300.0);
442        assert_eq!(snap.count, 50);
443        assert!(snap.horizontal);
444    }
445
446    #[test]
447    fn narrower_pages_let_the_next_one_show() {
448        let runtime = Runtime::default();
449        let ui = ui_of(view(Rc::new(Cell::new(0))).viewport_fraction(0.8), &runtime);
450        let area = ui
451            .scroll_regions()
452            .first()
453            .copied()
454            .expect("a scroll region");
455        let snap = area.page.expect("a paged region");
456        assert_eq!(snap.extent, 240.0);
457        // Padded ends: the travel is exactly the last page's resting offset, so the
458        // fiftieth page can be reached. Unpadded it would stop 60 px short of it.
459        assert_eq!(area.max_x, 49.0 * 240.0);
460        assert_eq!(snap.offset_of(49), area.max_x);
461    }
462
463    #[test]
464    fn a_vertical_view_pages_downwards() {
465        let runtime = Runtime::default();
466        let ui = ui_of(view(Rc::new(Cell::new(0))).axis(Axis::Vertical), &runtime);
467        let area = ui
468            .scroll_regions()
469            .first()
470            .copied()
471            .expect("a scroll region");
472        let snap = area.page.expect("a paged region");
473        assert!(!snap.horizontal);
474        assert_eq!(snap.extent, 400.0);
475        assert_eq!(area.max_x, 0.0);
476        assert_eq!(area.max_y, 49.0 * 400.0);
477    }
478
479    #[test]
480    fn a_page_is_painted_where_the_offset_says() {
481        let mut runtime = Runtime::default();
482        let widget = PageView::new(3, |_| Container::new().color(Color::rgb(1.0, 0.0, 0.0)))
483            .width(300.0)
484            .height(400.0);
485        let id = crate::interaction::WidgetId::ROOT;
486        runtime.scroll.insert(id, (300.0, 0.0));
487        let ui = ui_of(widget, &runtime);
488        // Page 1 is scrolled exactly into place: its box starts at the viewport's
489        // left edge, not at 300.
490        let left = ui
491            .scene()
492            .primitives()
493            .iter()
494            .filter_map(|p| match p {
495                Primitive::Rect { rect, .. } if rect.width > 100.0 => Some(rect.x),
496                _ => None,
497            })
498            .next()
499            .expect("a page");
500        assert!(left.abs() < 0.5, "page 1 drawn at {left}");
501    }
502
503    /// Where the pages were actually drawn, in build order.
504    fn page_lefts(ui: &crate::Ui<()>) -> Vec<f32> {
505        ui.scene()
506            .primitives()
507            .iter()
508            .filter_map(|p| match p {
509                Primitive::Rect { rect, .. } if rect.width > 100.0 => Some(rect.x),
510                _ => None,
511            })
512            .collect()
513    }
514
515    /// A carousel opens with its first page **centred**, not jammed against the
516    /// leading edge. Half the slack either side, which is what the room at the ends
517    /// is for.
518    #[test]
519    fn a_carousel_rests_its_first_page_in_the_middle() {
520        let runtime = Runtime::default();
521        let ui = ui_of(view(Rc::new(Cell::new(0))).viewport_fraction(0.8), &runtime);
522        // 300 px viewport, 240 px page: 60 px of slack, 30 either side.
523        let lefts = page_lefts(&ui);
524        assert!((lefts[0] - 30.0).abs() < 0.5, "page 0 drawn at {lefts:?}");
525        assert!((lefts[1] - 270.0).abs() < 0.5, "page 1 drawn at {lefts:?}");
526    }
527
528    /// Without the room at the ends the first page starts flush and the following
529    /// ones peek in from the other side — and the travel is 60 px shorter, which is
530    /// 60 px short of where the last page rests.
531    #[test]
532    fn unpadded_ends_start_flush_and_stop_early() {
533        let runtime = Runtime::default();
534        let ui = ui_of(
535            view(Rc::new(Cell::new(0)))
536                .viewport_fraction(0.8)
537                .pad_ends(false),
538            &runtime,
539        );
540        assert!(page_lefts(&ui)[0].abs() < 0.5);
541        let area = ui.scroll_regions()[0];
542        assert_eq!(area.max_x, 50.0 * 240.0 - 300.0);
543        // The last page's resting offset is past the end of the travel: it can be
544        // snapped at but never reached, which is why the ends are padded by default.
545        let snap = area.page.expect("a paged region");
546        assert!(snap.offset_of(49) > area.max_x);
547    }
548
549    /// A reversed view puts page 0 at the end the axis finishes at.
550    #[test]
551    fn a_reversed_view_starts_at_the_far_end() {
552        let runtime = Runtime::default();
553        let ui = ui_of(
554            view(Rc::new(Cell::new(0)))
555                .viewport_fraction(0.8)
556                .pad_ends(false)
557                .reverse(),
558            &runtime,
559        );
560        // 300 px viewport, 240 px page: page 0's right edge is the viewport's.
561        let lefts = page_lefts(&ui);
562        assert!((lefts[0] - 60.0).abs() < 0.5, "page 0 drawn at {lefts:?}");
563        // Page 1 is before it, off the leading edge.
564        assert!(lefts[1] < lefts[0], "page 1 drawn at {lefts:?}");
565        let area = ui.scroll_regions()[0];
566        assert!(area.reverse_x, "the region says which way its offsets run");
567        assert!(!area.reverse_y);
568        // The travel is unchanged: only which end index 0 is at has moved.
569        assert_eq!(area.max_x, 50.0 * 240.0 - 300.0);
570    }
571
572    /// Snapping off, a release is an ordinary fling: a view left between two pages
573    /// with no speed behind it **stays** there.
574    #[test]
575    fn a_view_that_does_not_snap_is_released_like_a_scroll() {
576        let id = crate::interaction::WidgetId::ROOT;
577        let physics = ScrollPhysics::CLAMPING;
578        let mut runtime = Runtime::default();
579
580        let free = {
581            let ui = ui_of(view(Rc::new(Cell::new(0))).page_snapping(false), &runtime);
582            ui.scroll_regions()[0]
583        };
584        let snapping = {
585            let ui = ui_of(view(Rc::new(Cell::new(0))), &runtime);
586            ui.scroll_regions()[0]
587        };
588        assert!(!free.page.expect("still a paged region").snapping);
589
590        // A fifth of a page along, released without a throw.
591        runtime.scroll.insert(id, (60.0, 0.0));
592        assert!(
593            !runtime.fling_scroll(free, physics, (0.0, 0.0)),
594            "nothing to do: it is in range and going nowhere"
595        );
596        assert_eq!(runtime.scroll[&id], (60.0, 0.0));
597        // The same release on a snapping view springs back to page 0.
598        assert!(runtime.fling_scroll(snapping, physics, (0.0, 0.0)));
599    }
600
601    /// Everything but the release stays: the page a reader would name is still
602    /// reported, and the page the application asks for is still honoured.
603    #[test]
604    fn a_view_that_does_not_snap_still_knows_its_pages() {
605        let id = crate::interaction::WidgetId::ROOT;
606        let mut runtime = Runtime::default();
607        let area = {
608            let ui = ui_of(view(Rc::new(Cell::new(0))).page_snapping(false), &runtime);
609            ui.scroll_regions()[0]
610        };
611        assert!(
612            runtime.page_changes(&[area]).is_empty(),
613            "opening is not a change"
614        );
615        runtime.scroll.insert(id, (300.0, 0.0));
616        assert_eq!(runtime.page_changes(&[area]), vec![(id, 1)]);
617
618        // And a request still moves it: first sighting, so straight there.
619        let mut fresh = Runtime::default();
620        let asked = {
621            let ui = ui_of(
622                view(Rc::new(Cell::new(0))).page_snapping(false).page(3),
623                &fresh,
624            );
625            ui.scroll_regions()[0]
626        };
627        fresh.sync_pages(&[asked]);
628        assert_eq!(fresh.scroll[&id], (900.0, 0.0));
629    }
630
631    /// The whole loop, as the shell drives it: build, drag, release, settle.
632    ///
633    /// Everything below the shell's pointer plumbing is exercised here — the extent
634    /// read off the viewport, the release, the spring, and the page reported — which
635    /// is as close to the gesture as a test without a window gets.
636    #[test]
637    fn a_short_flick_turns_the_page_and_says_so() {
638        let mut runtime = Runtime::default();
639        let id = crate::interaction::WidgetId::ROOT;
640        let physics = ScrollPhysics::CLAMPING;
641
642        let area = crate::Scrollable {
643            physics,
644            ..{
645                let ui = ui_of(view(Rc::new(Cell::new(0))), &runtime);
646                ui.scroll_regions()[0]
647            }
648        };
649        // Opening on page 0 is not a change to report.
650        assert!(runtime.page_changes(&[area]).is_empty());
651
652        // The finger drags a fifth of a page across and throws.
653        runtime.scroll.insert(id, (60.0, 0.0));
654        assert!(runtime.fling_scroll(area, physics, (700.0, 0.0)));
655        let mut reported = Vec::new();
656        for _ in 0..240 {
657            runtime.advance_scroll(&[area], 1.0 / 60.0);
658            reported.extend(runtime.page_changes(&[area]));
659        }
660
661        assert_eq!(reported, vec![(id, 1)], "reported once, on the way");
662        let (x, _) = runtime.scroll[&id];
663        assert!((x - 300.0).abs() < 1.0, "settled between pages, at {x}");
664    }
665
666    #[test]
667    fn the_page_a_reader_would_name_is_the_nearest_one() {
668        let snap = PageSnap {
669            extent: 300.0,
670            count: 3,
671            requested: 0,
672            horizontal: true,
673            snapping: true,
674        };
675        assert_eq!(snap.page_at(0.0), 0);
676        assert_eq!(snap.page_at(149.0), 0);
677        assert_eq!(snap.page_at(151.0), 1);
678        assert_eq!(snap.offset_of(2), 600.0);
679        // An overscroll is still the page it is pulling away from.
680        assert_eq!(snap.page_at(-40.0), 0);
681        assert_eq!(snap.page_at(9_000.0), 2);
682    }
683}