Skip to main content

frus_widgets/
scroll.rs

1//! [`SingleChildScrollView`]: a vertically scrollable container.
2//!
3//! It occupies a fixed-size viewport; its single child is laid out at free height by
4//! the driver, then clipped to the viewport and translated by the scroll offset, which
5//! the runtime retains, keyed by identity.
6
7use frus_core::{Insets, Rect, Scene};
8use frus_layout::{Dimension, Style};
9
10use crate::interaction::Status;
11use crate::physics::ScrollPhysics;
12use crate::theme::Theme;
13use crate::widget::Widget;
14
15/// A [`SingleChildScrollView`]'s scrolling axis, or axes.
16#[derive(Copy, Clone, Debug, PartialEq, Eq)]
17pub enum Axis {
18    /// Up and down.
19    Vertical,
20    /// Left and right.
21    Horizontal,
22    /// Both, which is a **convenience** rather than a third kind of area: a scrollable
23    /// elsewhere has one axis and one only, and two of them means one nested inside the
24    /// other. This is that pair collapsed into a single node, and a gesture reads it as
25    /// the pair: one drag moves one axis, decided by the direction the finger went in and
26    /// held to the end of the gesture. A page that scrolls down does not drift sideways
27    /// while it does it.
28    Both,
29}
30
31impl Axis {
32    pub(crate) fn free_x(self) -> bool {
33        matches!(self, Axis::Horizontal | Axis::Both)
34    }
35    pub(crate) fn free_y(self) -> bool {
36        matches!(self, Axis::Vertical | Axis::Both)
37    }
38}
39
40/// A scrollable container.
41pub struct SingleChildScrollView<Msg = crate::callback::Callback> {
42    width: Dimension,
43    height: Dimension,
44    /// Was the width **set** explicitly? If not, in flex mode the width must not
45    /// serve as a basis; see [`SingleChildScrollView::style`].
46    width_explicit: bool,
47    /// Was the height **set** explicitly? The same question for the vertical axis.
48    height_explicit: bool,
49    flex_grow: f32,
50    axis: Axis,
51    /// Edge and fling behaviour, when this area wants one of its own; `None`
52    /// follows the application, which follows the platform.
53    physics: Option<ScrollPhysics>,
54    /// Whether this area draws a scrollbar, when it wants its own answer; `None`
55    /// follows the application, which follows the platform.
56    scrollbars: Option<crate::physics::Scrollbars>,
57    /// Whether the bar stays put instead of fading; `None` lets it fade.
58    thumb_visibility: Option<bool>,
59    reverse: bool,
60    /// Room around the content, inside the viewport; see [`SingleChildScrollView::padding`].
61    padding: Insets,
62    /// Told where this area is whenever it moves; see [`SingleChildScrollView::on_scroll`].
63    on_scroll: Option<Box<dyn Fn(crate::ScrollPosition) -> Msg>>,
64    /// How far it must move before it says so again; see
65    /// [`SingleChildScrollView::notify_every`].
66    grain: f32,
67    content: Vec<Box<dyn Widget<Msg>>>,
68}
69
70impl<Msg> SingleChildScrollView<Msg> {
71    /// Creates a scrollable area, its viewport 200 px tall by default.
72    pub fn new() -> Self {
73        Self {
74            width: Dimension::Auto,
75            height: Dimension::Length(200.0),
76            width_explicit: false,
77            height_explicit: false,
78            flex_grow: 0.0,
79            axis: Axis::Vertical,
80            physics: None,
81            scrollbars: None,
82            thumb_visibility: None,
83            reverse: false,
84            padding: Insets::ZERO,
85            on_scroll: None,
86            grain: 0.0,
87            content: Vec::new(),
88        }
89    }
90
91    /// Chooses the scrolling axis or axes; vertical by default.
92    pub fn axis(mut self, axis: Axis) -> Self {
93        self.axis = axis;
94        self
95    }
96
97    /// Scrolls **from the far end**: the content is anchored to the bottom of a
98    /// vertical viewport (the right of a horizontal one) and the area starts there.
99    ///
100    /// It is what a conversation wants. Two things follow from it that nothing else
101    /// gives you:
102    ///
103    /// - content **shorter** than the viewport sits at the bottom rather than the top;
104    /// - the area **stays** at the end when content arrives, because offsets are
105    ///   measured from the end. A view resting at the newest message goes on resting
106    ///   there, however many messages are appended.
107    ///
108    /// Nothing changes for the user's hand: a finger pushes the content the way it
109    /// moves, in either direction, and the scrollbar's thumb rests where the content is.
110    pub fn reverse(mut self) -> Self {
111        self.reverse = true;
112        self
113    }
114
115    /// Overrides how this area behaves at its edges and after a fling.
116    ///
117    /// Left unset, it follows the application's choice, which itself defaults to
118    /// what the running platform does — so setting this is for the cases where the
119    /// content wants a particular feel, not for making an app feel native.
120    pub fn physics(mut self, physics: ScrollPhysics) -> Self {
121        self.physics = Some(physics);
122        self
123    }
124
125    /// **Whether this area draws a scrollbar**, over the application's answer.
126    ///
127    /// Left unset it follows the application, which itself follows the platform — so
128    /// setting this is for the one area that wants a bar where the platform gives none,
129    /// or none where it gives one, not for making an app feel native.
130    pub fn scrollbars(mut self, scrollbars: crate::physics::Scrollbars) -> Self {
131        self.scrollbars = Some(scrollbars);
132        self
133    }
134
135    /// **Keeps the scrollbar on screen** rather than letting it fade out when this area
136    /// stops moving (`scrollbar.dart:214`).
137    ///
138    /// A bar over content earns its place by going away again; pin it for an area whose
139    /// content does not look scrollable, or one a reader should be able to aim at
140    /// without scrolling first to make the bar appear.
141    pub fn thumb_visibility(mut self, visible: bool) -> Self {
142        self.thumb_visibility = Some(visible);
143        self
144    }
145
146    /// Sets the viewport width, in logical pixels.
147    pub fn width(mut self, width: f32) -> Self {
148        self.width = Dimension::Length(width);
149        self.width_explicit = true;
150        self
151    }
152
153    /// Sets the viewport height, in logical pixels.
154    pub fn height(mut self, height: f32) -> Self {
155        self.height = Dimension::Length(height);
156        self.height_explicit = true;
157        self
158    }
159
160    /// Flex growth factor along the parent's main axis.
161    pub fn flex(mut self, grow: f32) -> Self {
162        self.flex_grow = grow;
163        self
164    }
165
166    /// Insets the content, **inside** the viewport: the padding scrolls with what it
167    /// surrounds rather than shrinking the window onto it.
168    ///
169    /// That is the whole distinction, and it is the reference's: a scroll area padded at
170    /// the bottom has that room *at the end of its content*, reachable only by scrolling
171    /// to it, which is what a floating button hovering over the last row needs. Room
172    /// taken out of the viewport instead would sit there permanently and the last row
173    /// would still slide under the button.
174    ///
175    /// Along the cross axis it simply insets the content, which stays the width of the
176    /// viewport less the two sides.
177    ///
178    /// ```
179    /// # use frus_widgets::SingleChildScrollView;
180    /// # let feed = frus_widgets::Container::<()>::new();
181    /// SingleChildScrollView::<()>::new().padding_each(0.0, 16.0, 88.0, 16.0).child(feed);
182    /// ```
183    pub fn padding(mut self, padding: f32) -> Self {
184        self.padding = Insets::uniform(padding);
185        self
186    }
187
188    /// The same, one side at a time.
189    pub fn padding_each(mut self, top: f32, right: f32, bottom: f32, left: f32) -> Self {
190        self.padding = Insets::new(top, right, bottom, left);
191        self
192    }
193
194    /// **Says where this area is** whenever it moves — a "back to top" button that
195    /// appears once the page has gone down, a rule that fills as it goes, a load-more
196    /// when the end comes into view.
197    ///
198    /// The area has to be reachable by name for a request to come back the other way:
199    /// wrap it in `keyed(k, …)` and address `k`, exactly as a focus request does.
200    ///
201    /// A message per frame while it moves, which is what a bar fading in needs and what
202    /// a load-more does not; [`SingleChildScrollView::notify_every`] is for the second.
203    pub fn on_scroll<R: crate::callback::IntoMsg<Msg>>(
204        mut self,
205        on_scroll: impl Fn(crate::ScrollPosition) -> R + 'static,
206    ) -> Self {
207        self.on_scroll = Some(Box::new(crate::callback::handler1(on_scroll)));
208        self
209    }
210
211    /// Reports no more often than every `px` of movement — and **always** reports where
212    /// it comes to rest, however short the last step was.
213    pub fn notify_every(mut self, px: f32) -> Self {
214        self.grain = px.max(0.0);
215        self
216    }
217
218    /// Sets the scrollable content.
219    pub fn child(mut self, content: impl Widget<Msg> + 'static) -> Self {
220        self.content.clear();
221        self.content.push(Box::new(content));
222        self
223    }
224}
225
226impl<Msg> Default for SingleChildScrollView<Msg> {
227    fn default() -> Self {
228        Self::new()
229    }
230}
231
232impl<Msg: Clone> Widget<Msg> for SingleChildScrollView<Msg> {
233    fn style(&self) -> Style {
234        // In **flex** mode (fill then scroll), an axis dimension that was not set
235        // explicitly must not serve as a basis: the default height of 200 would
236        // otherwise stay a flexible basis and the viewport would not fill the parent's
237        // remaining space — it would take `+200` of free room to grow. So we set it to
238        // `Auto`, a basis of 0, and let `flex_grow` **fill**.
239        let filling = self.flex_grow > 0.0;
240        let height = if filling && !self.height_explicit && self.axis.free_y() {
241            Dimension::Auto
242        } else {
243            self.height
244        };
245        let width = if filling && !self.width_explicit && self.axis.free_x() {
246            Dimension::Auto
247        } else {
248            self.width
249        };
250        // **A size nobody asked for, on an axis that does not scroll, is not ours to
251        // invent.** The 200 is the default *viewport* — the height of a window onto
252        // something taller — and it belongs to the axis that scrolls. A horizontal strip
253        // took it too, and claimed two hundred pixels for a bar forty-eight tall (#65).
254        // `Auto` here is the signal the layout reads to size this axis from the content;
255        // see the scroll branch of `build_layout_scoped`.
256        let height = if !self.height_explicit && !self.axis.free_y() {
257            Dimension::Auto
258        } else {
259            height
260        };
261        let width = if !self.width_explicit && !self.axis.free_x() {
262            Dimension::Auto
263        } else {
264            width
265        };
266        Style {
267            width,
268            height,
269            flex_grow: self.flex_grow,
270            ..Default::default()
271        }
272    }
273
274    fn children(&self) -> &[Box<dyn Widget<Msg>>] {
275        &self.content
276    }
277
278    fn paint(&self, _bounds: Rect, _status: Status, _theme: &Theme, _scene: &mut Scene) {
279        // The viewport itself is transparent: only the content is drawn.
280    }
281
282    fn on_click(&self) -> Option<Msg> {
283        None
284    }
285
286    fn scrollbars(&self) -> Option<crate::physics::Scrollbars> {
287        self.scrollbars
288    }
289
290    fn thumb_visibility(&self) -> Option<bool> {
291        self.thumb_visibility
292    }
293
294    fn scroll_content(&self) -> Option<&dyn Widget<Msg>> {
295        self.content.first().map(|child| child.as_ref())
296    }
297
298    fn scroll_axis(&self) -> Axis {
299        self.axis
300    }
301
302    fn scroll_physics(&self) -> Option<ScrollPhysics> {
303        self.physics
304    }
305
306    fn scroll_reverse(&self) -> bool {
307        self.reverse
308    }
309
310    fn scroll_padding(&self) -> Insets {
311        self.padding
312    }
313
314    fn on_scroll(&self, position: crate::ScrollPosition) -> Option<Msg> {
315        self.on_scroll.as_ref().map(|report| report(position))
316    }
317
318    fn scroll_grain(&self) -> f32 {
319        self.grain
320    }
321}
322
323#[cfg(test)]
324mod tests {
325    use super::*;
326    use crate::{build_ui, Container, Runtime, Size};
327    use frus_core::{Color, Primitive};
328
329    const MARK: Color = Color::rgb(1.0, 0.0, 0.0);
330
331    /// **A viewport is as big as its content on the axis it does not scroll.**
332    ///
333    /// The two hundred pixel default is the height of a *window onto something taller*, and
334    /// it belongs to the axis that scrolls. A horizontal area took it as well, which is how
335    /// a tab bar forty-eight pixels tall came to claim two hundred and push its panel down
336    /// the page (#65).
337    ///
338    /// It cannot be answered by this widget alone: a scroll host lays its content out
339    /// against the viewport on the axis that does not scroll, so an `Auto` here would hand
340    /// the content nothing and get nothing back. `Auto` is the *signal*; the measurement
341    /// happens in the layout, where the content can be laid out on its own first.
342    #[test]
343    fn a_viewport_takes_its_content_s_size_on_the_axis_it_does_not_scroll() {
344        let strip = SingleChildScrollView::<()>::new()
345            .axis(Axis::Horizontal)
346            .child(Container::new().width(900.0).height(48.0).color(MARK));
347        let ui = build_ui(
348            &strip,
349            Size::new(300.0, 400.0),
350            &Runtime::default(),
351            &Theme::default(),
352        );
353        let box_ = ui.scene().primitives().iter().find_map(|p| match p {
354            Primitive::Rect { rect, .. } => Some(*rect),
355            _ => None,
356        });
357        assert_eq!(
358            box_.map(|r| r.height),
359            Some(48.0),
360            "the strip is its content's height, not the vertical default"
361        );
362
363        // And a caller's own number is still a caller's own number.
364        let told = SingleChildScrollView::<()>::new()
365            .axis(Axis::Horizontal)
366            .height(120.0)
367            .child(Container::new().width(900.0).height(48.0).color(MARK));
368        assert_eq!(
369            Widget::<()>::style(&told).height,
370            Dimension::Length(120.0),
371            "an explicit height is not measured away"
372        );
373    }
374
375    /// The vertical case of the same rule: an area that scrolls **down** takes its width
376    /// from what is in it. In a row it used to come out nothing wide, a leaf's content
377    /// being nothing at all.
378    #[test]
379    fn a_vertical_area_is_as_wide_as_its_content() {
380        let row = crate::Flex::<()>::row().child(
381            SingleChildScrollView::<()>::new()
382                .height(100.0)
383                .child(Container::new().width(160.0).height(400.0).color(MARK)),
384        );
385        let ui = build_ui(
386            &row,
387            Size::new(400.0, 400.0),
388            &Runtime::default(),
389            &Theme::default(),
390        );
391        let drawn = ui.scene().primitives().iter().find_map(|p| match p {
392            Primitive::Rect { rect, .. } => Some(*rect),
393            _ => None,
394        });
395        assert_eq!(drawn.map(|r| r.width), Some(160.0));
396    }
397
398    /// A scroll 100 tall holding `content_h` of content, at `offset`; returns the
399    /// rectangle of the marker drawn at the very end of that content.
400    fn last_mark(scroll: SingleChildScrollView<()>, offset: f32) -> Rect {
401        let mut runtime = Runtime::default();
402        let ui = build_ui(
403            &scroll,
404            Size::new(100.0, 100.0),
405            &runtime,
406            &Theme::default(),
407        );
408        let id = ui.scroll_regions()[0].id;
409        runtime.scroll.insert(id, (0.0, offset));
410        let ui = build_ui(
411            &scroll,
412            Size::new(100.0, 100.0),
413            &runtime,
414            &Theme::default(),
415        );
416        ui.scene()
417            .primitives()
418            .iter()
419            .filter_map(|p| match p {
420                Primitive::Rect { color, rect, .. } if *color == MARK => Some(*rect),
421                _ => None,
422            })
423            .next_back()
424            .expect("the marker is painted")
425    }
426
427    /// A column of `n` 40 px rows, the last one marked.
428    fn content(n: usize) -> Container<()> {
429        let mut column = crate::Flex::<()>::column();
430        for i in 0..n {
431            let colour = if i + 1 == n {
432                MARK
433            } else {
434                Color::rgb(0.0, 0.0, 1.0)
435            };
436            column = column.child(Container::<()>::new().height(40.0).color(colour));
437        }
438        Container::<()>::new().child(column)
439    }
440
441    /// Content shorter than the viewport sits at the **top** normally and at the
442    /// **bottom** reversed — the first of the two things reversing is for.
443    #[test]
444    fn short_content_sits_at_the_end_when_reversed() {
445        let plain = SingleChildScrollView::<()>::new()
446            .width(100.0)
447            .height(100.0)
448            .child(content(1));
449        assert_eq!(last_mark(plain, 0.0).y, 0.0, "at the top");
450
451        let reversed = SingleChildScrollView::<()>::new()
452            .width(100.0)
453            .height(100.0)
454            .reverse()
455            .child(content(1));
456        let r = last_mark(reversed, 0.0);
457        assert_eq!(r.y + r.height, 100.0, "against the bottom: {r:?}");
458    }
459
460    /// Offset 0 is the **end** of the content when reversed, and it stays the end
461    /// however much content there is — which is what keeps a conversation resting on
462    /// its newest message as messages arrive.
463    #[test]
464    fn offset_zero_is_the_end_and_stays_there() {
465        for rows in [4, 10, 40] {
466            let reversed = SingleChildScrollView::<()>::new()
467                .width(100.0)
468                .height(100.0)
469                .reverse()
470                .child(content(rows));
471            let r = last_mark(reversed, 0.0);
472            assert_eq!(
473                r.y + r.height,
474                100.0,
475                "{rows} rows, still resting at the end: {r:?}"
476            );
477        }
478    }
479
480    /// Scrolling away from zero walks **back** through the content, which is up the
481    /// screen for the newest-last layout a reversed view has.
482    #[test]
483    fn a_positive_offset_moves_back_through_the_content() {
484        let scroll = || {
485            SingleChildScrollView::<()>::new()
486                .width(100.0)
487                .height(100.0)
488                .reverse()
489                .child(content(10))
490        };
491        let at_rest = last_mark(scroll(), 0.0);
492        let scrolled = last_mark(scroll(), 60.0);
493        assert_eq!(
494            scrolled.y - at_rest.y,
495            60.0,
496            "the end has moved down and out of view"
497        );
498    }
499
500    /// The end of the content is the end of the *axis*, so a refusal there glows at the
501    /// bottom of a reversed area rather than the top — where a conversation's oldest
502    /// message is, and where the user is actually pressing.
503    #[test]
504    fn the_glow_follows_the_axis_and_not_the_screen() {
505        use frus_core::Rect as R;
506        let area = |reverse: bool| crate::Scrollable {
507            id: crate::WidgetId::ROOT,
508            viewport: R::new(0.0, 0.0, 100.0, 100.0),
509            max_x: 0.0,
510            max_y: 300.0,
511            physics: None,
512            refresh: None,
513            page: None,
514            reverse_x: false,
515            reverse_y: reverse,
516            host: None,
517            keep_visible: None,
518        };
519        // A refusal towards the axis's start (a negative offset).
520        assert_eq!(area(false).refused_edge(true, -1.0), crate::GlowEdge::Top);
521        assert_eq!(
522            area(true).refused_edge(true, -1.0),
523            crate::GlowEdge::Bottom,
524            "the start of a reversed axis is the bottom of the screen"
525        );
526        // And a pull-to-refresh listens at whichever of the two that is.
527        assert_eq!(area(true).start_edge(true), crate::GlowEdge::Bottom);
528    }
529
530    /// The one thing that is *not* reversed: what the user's hand does. A push in a
531    /// direction moves the content that way in both, and only the arithmetic between
532    /// the push and the number changes sign.
533    #[test]
534    fn a_push_moves_the_content_the_way_it_pushes() {
535        let region = |reverse: bool| {
536            let mut s = SingleChildScrollView::<()>::new()
537                .width(100.0)
538                .height(100.0);
539            if reverse {
540                s = s.reverse();
541            }
542            let s = s.child(content(10));
543            let ui = build_ui(
544                &s,
545                Size::new(100.0, 100.0),
546                &Runtime::default(),
547                &Theme::default(),
548            );
549            ui.scroll_regions()[0]
550        };
551        // A finger pushing the content **down** by 10 px.
552        assert_eq!(region(false).offset_delta((0.0, 10.0)).1, -10.0);
553        assert_eq!(region(true).offset_delta((0.0, 10.0)).1, 10.0);
554        // Opposite numbers, and in both cases "the content went down": one counts from
555        // the top and the other from the bottom.
556    }
557}
558
559#[cfg(test)]
560mod padding_tests {
561    use super::*;
562    use crate::{build_ui, Container, Runtime, Size};
563    use frus_core::{Color, Primitive};
564
565    const MARK: Color = Color::rgb(1.0, 0.0, 0.0);
566
567    /// The box the content painted.
568    fn content_rect(scroll: &SingleChildScrollView<()>) -> Rect {
569        build_ui(
570            scroll,
571            Size::new(100.0, 100.0),
572            &Runtime::default(),
573            &Theme::default(),
574        )
575        .scene()
576        .primitives()
577        .iter()
578        .find_map(|p| match p {
579            Primitive::Rect { rect, color, .. } if *color == MARK => Some(*rect),
580            _ => None,
581        })
582        .expect("the content is painted")
583    }
584
585    fn area(height: f32) -> SingleChildScrollView<()> {
586        SingleChildScrollView::<()>::new()
587            .width(100.0)
588            .height(100.0)
589            .child(Container::<()>::new().height(height).color(MARK))
590    }
591
592    /// The leading insets move the content in; the sides come off its width, which is
593    /// otherwise the viewport's.
594    #[test]
595    fn the_content_starts_inside_the_insets() {
596        let bare = content_rect(&area(50.0));
597        assert_eq!((bare.x, bare.y, bare.width), (0.0, 0.0, 100.0));
598
599        let padded = content_rect(&area(50.0).padding_each(12.0, 8.0, 24.0, 8.0));
600        assert_eq!((padded.x, padded.y), (8.0, 12.0));
601        assert_eq!(padded.width, 84.0, "the sides come off the content");
602    }
603
604    /// The room is inside the viewport and scrolls with the content: it joins what there
605    /// is to scroll rather than being taken out of the window.
606    #[test]
607    fn the_far_inset_is_reachable_rather_than_lost() {
608        let region = |scroll: &SingleChildScrollView<()>| {
609            build_ui(
610                scroll,
611                Size::new(100.0, 100.0),
612                &Runtime::default(),
613                &Theme::default(),
614            )
615            .scroll_regions()[0]
616                .max_y
617        };
618        assert_eq!(
619            region(&area(100.0)),
620            0.0,
621            "exactly fills: nothing to scroll"
622        );
623        assert_eq!(
624            region(&area(100.0).padding_each(12.0, 0.0, 24.0, 0.0)),
625            36.0,
626            "both insets joined the content"
627        );
628    }
629
630    /// A reversed area rests at the end, and the end is one bottom inset up.
631    #[test]
632    fn a_reversed_area_rests_above_its_bottom_inset() {
633        let padded = content_rect(&area(50.0).reverse().padding_each(12.0, 0.0, 24.0, 0.0));
634        assert_eq!(
635            padded.y + padded.height,
636            76.0,
637            "the content ends one bottom inset above the viewport's edge"
638        );
639    }
640}
641
642/// Milestone 533: content that shrinks under an offset it no longer reaches. Seen on a
643/// phone as a row of filters that overflowed at a large text size, was swiped to its end,
644/// and stayed swiped — its first label cut at the left, empty room at the right — once the
645/// text was small again and the row fitted.
646#[cfg(test)]
647mod shrunk_content_tests {
648    use super::*;
649    use crate::{build_ui, Container, ListView, PageView, Runtime, Size, Ui};
650    use frus_core::{Color, Primitive};
651
652    const MARK: Color = Color::rgb(1.0, 0.0, 0.0);
653    const FRAME: f32 = 1.0 / 60.0;
654
655    /// A strip 100 wide and 40 tall holding one marked box `width` wide: the demo's filter
656    /// row, reduced to its geometry.
657    fn strip(width: f32) -> SingleChildScrollView<()> {
658        SingleChildScrollView::<()>::new()
659            .axis(Axis::Horizontal)
660            .width(100.0)
661            .height(40.0)
662            .child(Container::<()>::new().width(width).height(40.0).color(MARK))
663    }
664
665    /// A list 200 tall of `count` marked rows, 40 each.
666    fn rows(count: usize) -> ListView<()> {
667        ListView::<()>::new(count, 40.0, |_| {
668            Container::<()>::new().height(40.0).color(MARK)
669        })
670        .width(100.0)
671        .height(200.0)
672    }
673
674    fn frame(widget: &dyn Widget<()>, runtime: &Runtime) -> Ui<()> {
675        build_ui(widget, Size::new(300.0, 400.0), runtime, &Theme::default())
676    }
677
678    /// Every rectangle painted in `color`, in paint order.
679    fn painted(ui: &Ui<()>, color: Color) -> Vec<Rect> {
680        ui.scene()
681            .primitives()
682            .iter()
683            .filter_map(|p| match p {
684                Primitive::Rect { rect, color: c, .. } if *c == color => Some(*rect),
685                _ => None,
686            })
687            .collect()
688    }
689
690    /// One turn of the shell's loop over `ui`'s regions.
691    fn step(runtime: &mut Runtime, ui: &Ui<()>) {
692        let regions = ui.scroll_regions().to_vec();
693        runtime.advance_scroll(&regions, ScrollPhysics::Clamping, FRAME);
694    }
695
696    /// The phone's report: the row fits again and is drawn from its start, in the frame it
697    /// fits — and the runtime keeps it there.
698    #[test]
699    fn a_row_that_fits_again_is_drawn_from_its_start() {
700        let mut runtime = Runtime::default();
701        let wide = strip(300.0);
702        let id = frame(&wide, &runtime).scroll_regions()[0].id;
703        // Swiped to the end, and the swipe has settled: nothing owns the offset now.
704        runtime.scroll.insert(id, (200.0, 0.0));
705        let ui = frame(&wide, &runtime);
706        assert_eq!(painted(&ui, MARK)[0].x, -200.0, "the end of the row shows");
707        step(&mut runtime, &ui);
708
709        let narrow = strip(80.0);
710        let ui = frame(&narrow, &runtime);
711        assert_eq!(ui.scroll_regions()[0].max_x, 0.0, "nothing left to scroll");
712        assert_eq!(
713            painted(&ui, MARK)[0].x,
714            0.0,
715            "drawn from its start in the very frame it fits"
716        );
717        step(&mut runtime, &ui);
718        assert_eq!(runtime.scroll.get(&id), Some(&(0.0, 0.0)));
719        assert_eq!(painted(&frame(&narrow, &runtime), MARK)[0].x, 0.0);
720    }
721
722    /// Shrunk, but still too wide: it rests at its new end, not at its start.
723    #[test]
724    fn a_row_that_still_overflows_rests_at_its_new_end() {
725        let mut runtime = Runtime::default();
726        let wide = strip(300.0);
727        let id = frame(&wide, &runtime).scroll_regions()[0].id;
728        runtime.scroll.insert(id, (200.0, 0.0));
729        // A few frames at the end first, so the scrollbar's fade has seen the offset.
730        for _ in 0..3 {
731            let ui = frame(&wide, &runtime);
732            step(&mut runtime, &ui);
733        }
734
735        let narrower = strip(150.0);
736        let ui = frame(&narrower, &runtime);
737        let mark = painted(&ui, MARK)[0];
738        assert_eq!(
739            (mark.x, mark.x + mark.width),
740            (-50.0, 100.0),
741            "its last pixel on the viewport's right edge"
742        );
743        for _ in 0..10 {
744            let ui = frame(&narrower, &runtime);
745            step(&mut runtime, &ui);
746        }
747        assert_eq!(runtime.scroll.get(&id), Some(&(50.0, 0.0)));
748        // Nothing scrolled: the content changed. A bar that arrived for it would say
749        // otherwise.
750        assert_eq!(
751            runtime.scrollbar_fade_of(id).opacity,
752            0.0,
753            "no bar summoned"
754        );
755    }
756
757    /// The same down the screen: a list scrolled to its bottom that loses rows.
758    #[test]
759    fn a_list_that_loses_rows_keeps_its_last_row_on_the_floor() {
760        let mut runtime = Runtime::default();
761        let id = frame(&rows(10), &runtime).scroll_regions()[0].id;
762        runtime.scroll.insert(id, (0.0, 200.0));
763
764        // Six rows, 240 of content: the bottom is 40 down.
765        let ui = frame(&rows(6), &runtime);
766        let last = painted(&ui, MARK)
767            .into_iter()
768            .max_by(|a, b| a.y.total_cmp(&b.y))
769            .expect("a row is painted");
770        assert_eq!(last.y + last.height, 200.0, "the last row on the floor");
771        step(&mut runtime, &ui);
772        assert_eq!(runtime.scroll.get(&id), Some(&(0.0, 40.0)));
773
774        // Three rows fit: back to the top.
775        let ui = frame(&rows(3), &runtime);
776        let first = painted(&ui, MARK)
777            .into_iter()
778            .min_by(|a, b| a.y.total_cmp(&b.y))
779            .expect("a row is painted");
780        assert_eq!(first.y, 0.0);
781        assert_eq!(painted(&ui, MARK).len(), 3, "every row shows");
782        step(&mut runtime, &ui);
783        assert_eq!(runtime.scroll.get(&id), Some(&(0.0, 0.0)));
784    }
785
786    /// A paged view that loses pages while on its last one shows its new last page.
787    #[test]
788    fn a_paged_view_that_loses_pages_shows_its_new_last_page() {
789        let shade = |index: usize| Color::rgb(0.0, 0.0, (index + 1) as f32 / 10.0);
790        let pages = |count: usize| {
791            PageView::<()>::new(count, move |index| {
792                Container::<()>::new().color(shade(index))
793            })
794            .width(300.0)
795            .height(400.0)
796        };
797        let mut runtime = Runtime::default();
798        let id = frame(&pages(5), &runtime).scroll_regions()[0].id;
799        runtime.scroll.insert(id, (1200.0, 0.0));
800        assert_eq!(painted(&frame(&pages(5), &runtime), shade(4))[0].x, 0.0);
801
802        let ui = frame(&pages(2), &runtime);
803        assert_eq!(
804            painted(&ui, shade(1)).first().map(|r| r.x),
805            Some(0.0),
806            "page 1 fills the viewport"
807        );
808        assert!(painted(&ui, shade(4)).is_empty(), "no page past the end");
809        step(&mut runtime, &ui);
810        assert_eq!(runtime.scroll.get(&id), Some(&(300.0, 0.0)));
811    }
812
813    /// A reversed strip counts its offset from the right. The range is the same, so the
814    /// correction is too — and the content lands against the left edge, not off to the right.
815    #[test]
816    fn a_reversed_row_is_brought_back_the_same_way() {
817        let mut runtime = Runtime::default();
818        let wide = strip(300.0).reverse();
819        let id = frame(&wide, &runtime).scroll_regions()[0].id;
820        runtime.scroll.insert(id, (200.0, 0.0));
821        assert_eq!(
822            painted(&frame(&wide, &runtime), MARK)[0].x,
823            0.0,
824            "its start shows"
825        );
826
827        let narrower = strip(150.0).reverse();
828        let ui = frame(&narrower, &runtime);
829        assert_eq!(
830            painted(&ui, MARK)[0].x,
831            0.0,
832            "its start against the left edge: {:?}",
833            painted(&ui, MARK)
834        );
835        step(&mut runtime, &ui);
836        assert_eq!(runtime.scroll.get(&id), Some(&(50.0, 0.0)));
837    }
838
839    /// A finger owns the offset: the content shrinking under it moves nothing, in the
840    /// picture or the runtime, until it lets go — and then the release brings it home.
841    #[test]
842    fn a_held_row_is_not_yanked_from_under_the_finger() {
843        let mut runtime = Runtime::default();
844        let wide = strip(300.0);
845        let id = frame(&wide, &runtime).scroll_regions()[0].id;
846        runtime.scroll.insert(id, (200.0, 0.0));
847        runtime.hold_scroll(id);
848
849        let narrower = strip(150.0);
850        for _ in 0..10 {
851            let ui = frame(&narrower, &runtime);
852            assert_eq!(painted(&ui, MARK)[0].x, -200.0, "where the finger left it");
853            step(&mut runtime, &ui);
854        }
855        assert_eq!(runtime.scroll.get(&id), Some(&(200.0, 0.0)));
856
857        runtime.release_scroll(id);
858        let area = frame(&narrower, &runtime).scroll_regions()[0];
859        assert!(
860            runtime.fling_scroll(area, ScrollPhysics::Clamping, (0.0, 0.0)),
861            "a release out of range springs"
862        );
863        for _ in 0..600 {
864            let ui = frame(&narrower, &runtime);
865            step(&mut runtime, &ui);
866        }
867        let rest = runtime.scroll.get(&id).copied().unwrap_or_default();
868        assert!((rest.0 - 50.0).abs() < 1.0, "home at the new end: {rest:?}");
869    }
870}