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