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}