Skip to main content

frus_widgets/
list.rs

1//! [`ListView`]: a **virtualised** list — only the **visible** items are built, laid
2//! out and painted. Essential for large lists (thousands of rows): the per-frame
3//! cost is proportional to the visible items, not to the total.
4//!
5//! Contract: a **fixed** item extent along the axis the list runs — down the screen by
6//! default, across it with [`ListView::axis`]. Items are built
7//! on demand by an `index → widget` closure, so they have **no retained state**
8//! and take no keyboard focus (you cannot retain the state of an item that does
9//! not exist off screen) — perfect for display (logs, tables, long lists), and
10//! still clickable and hoverable while visible.
11
12use frus_core::{Insets, Rect, Scene};
13use frus_layout::{Dimension, Style};
14
15use crate::interaction::Status;
16use crate::physics::ScrollPhysics;
17use crate::scroll::Axis;
18use crate::theme::Theme;
19use crate::widget::Widget;
20
21/// The description of a virtualised list, exposed to the render driver.
22pub struct VirtualList<'a, Msg = crate::callback::Callback> {
23    /// Total number of items.
24    pub count: usize,
25    /// The (logical) extent of one item **along the axis the list scrolls**: its
26    /// height down a vertical list, its width across a horizontal one.
27    pub item_extent: f32,
28    /// Which way the list runs.
29    pub axis: Axis,
30    /// Builds one item per index.
31    pub build: &'a dyn Fn(usize) -> Box<dyn Widget<Msg>>,
32}
33
34/// A virtualised list with a fixed item extent.
35pub struct ListView<Msg = crate::callback::Callback> {
36    count: usize,
37    item_extent: f32,
38    axis: Axis,
39    width: Dimension,
40    height: Dimension,
41    /// Whether the width and the height were given; an axis that was not takes the room on
42    /// offer, as the reference's does (milestone 590).
43    sized: (bool, bool),
44    flex_grow: f32,
45    physics: Option<ScrollPhysics>,
46    /// Whether it is the page's primary scroll view; see [`ListView::primary`].
47    primary: Option<bool>,
48    reverse: bool,
49    /// Room around the items, inside the viewport; see [`ListView::padding`].
50    padding: Insets,
51    /// Told where this list is whenever it moves; see [`ListView::on_scroll`].
52    on_scroll: Option<Box<dyn Fn(crate::ScrollPosition) -> Msg>>,
53    /// How far it must move before it says so again; see [`ListView::notify_every`].
54    grain: f32,
55    build: Box<dyn Fn(usize) -> Box<dyn Widget<Msg>>>,
56}
57
58impl<Msg> ListView<Msg> {
59    /// Creates a list of `count` items of extent `item_extent`, each item being
60    /// built on demand by `build(index)`.
61    ///
62    /// The extent is measured **along the axis the list scrolls** — a height by
63    /// default, a width once [`ListView::axis`] says so. The cross axis is handed to
64    /// the item whole.
65    pub fn new<W: Widget<Msg> + 'static>(
66        count: usize,
67        item_extent: f32,
68        build: impl Fn(usize) -> W + 'static,
69    ) -> Self {
70        Self {
71            count,
72            item_extent,
73            axis: Axis::Vertical,
74            width: Dimension::Auto,
75            height: Dimension::Length(200.0),
76            sized: (false, false),
77            flex_grow: 0.0,
78            physics: None,
79            primary: None,
80            reverse: false,
81            padding: Insets::ZERO,
82            on_scroll: None,
83            grain: 0.0,
84            build: Box::new(move |index| Box::new(build(index)) as Box<dyn Widget<Msg>>),
85        }
86    }
87
88    /// Which way the list runs: down the screen (the default) or across it.
89    ///
90    /// A row of cards, a strip of thumbnails, a shelf of covers — all of them are this
91    /// list turned on its side, and all of them want the same virtualisation, since a
92    /// shelf of two hundred covers is as much work as a column of two hundred rows.
93    ///
94    /// A list virtualises along **one** axis: it knows where item `n` starts because
95    /// every item is the same extent in that one direction. [`Axis::Both`] has no
96    /// meaning here and is read as vertical; a surface that scrolls both ways is
97    /// [`crate::SingleChildScrollView`], which does not virtualise and does not need to.
98    ///
99    /// The extent given to [`ListView::new`] follows the axis — a height down, a width
100    /// across — and so does [`ListView::reverse`], which is why it is the same builder
101    /// rather than two.
102    pub fn axis(mut self, axis: Axis) -> Self {
103        self.axis = axis;
104        self
105    }
106
107    /// Builds **from the far end**: item 0 sits at the end the axis finishes at — the
108    /// bottom of a vertical list, the trailing edge of a horizontal one — item 1 before
109    /// it, and the list starts resting there.
110    ///
111    /// The other half of a conversation, and the half [`crate::SingleChildScrollView::reverse`] cannot
112    /// give you: a scroll can anchor its content to the end, but only a list decides
113    /// which end an *index* is. With index 0 the newest message, adding one keeps every
114    /// other item exactly where it was — the view does not jump, and nothing has to be
115    /// renumbered.
116    pub fn reverse(mut self) -> Self {
117        self.reverse = true;
118        self
119    }
120
121    /// Insets the content, **inside** the viewport: the padding scrolls with what it
122    /// surrounds rather than shrinking the window onto it.
123    ///
124    /// That is the whole distinction, and it is the reference's: a scroll area padded at
125    /// the bottom has that room *at the end of its content*, reachable only by scrolling
126    /// to it, which is what a floating button hovering over the last row needs. Room
127    /// taken out of the viewport instead would sit there permanently and the last row
128    /// would still slide under the button.
129    ///
130    /// Along the cross axis it simply insets the content, which stays the width of the
131    /// viewport less the two sides.
132    ///
133    /// A **reversed** list keeps the sides where they look: the bottom inset is at the
134    /// bottom, which is also the end the items start from, so it is the one the first
135    /// item clears.
136    ///
137    /// ```
138    /// # use frus_widgets::{Container, ListView};
139    /// ListView::<()>::new(200, 56.0, |_| Container::<()>::new()).padding(16.0);
140    /// ```
141    pub fn padding(mut self, padding: f32) -> Self {
142        self.padding = Insets::uniform(padding);
143        self
144    }
145
146    /// The same, one side at a time.
147    pub fn padding_each(mut self, top: f32, right: f32, bottom: f32, left: f32) -> Self {
148        self.padding = Insets::new(top, right, bottom, left);
149        self
150    }
151
152    /// Overrides how the list behaves at its edges and after a fling; see
153    /// [`crate::scroll::SingleChildScrollView::physics`].
154    pub fn physics(mut self, physics: ScrollPhysics) -> Self {
155        self.physics = Some(physics);
156        self
157    }
158
159    /// **Whether this is the page's primary scroll view** — the reference's `primary`
160    /// (milestone 655). A primary view is always scrollable unless [`physics`](Self::physics)
161    /// says otherwise: a drag moves it, and bounces or glows at its edge, even when everything
162    /// is in sight, so a page that fits still answers the finger as a page.
163    ///
164    /// Unset, a vertical list is primary and a horizontal one is not, as the reference's
165    /// scroll views are when they are given no controller.
166    pub fn primary(mut self, primary: bool) -> Self {
167        self.primary = Some(primary);
168        self
169    }
170
171    /// Sets the viewport width, in logical pixels.
172    pub fn width(mut self, width: f32) -> Self {
173        self.width = Dimension::Length(width);
174        self.sized.0 = true;
175        self
176    }
177
178    /// Sets the viewport height, in logical pixels.
179    pub fn height(mut self, height: f32) -> Self {
180        self.height = Dimension::Length(height);
181        self.sized.1 = true;
182        self
183    }
184
185    /// Flex growth factor along the parent's main axis.
186    pub fn flex(mut self, grow: f32) -> Self {
187        self.flex_grow = grow;
188        self
189    }
190
191    /// **Says where this list is** whenever it moves — a "back to top" button that
192    /// appears once it has gone down, a "row 1 240 of 5 000", a load-more when the end
193    /// comes into view.
194    ///
195    /// The list has to be reachable by name for a request to come back the other way:
196    /// wrap it in `keyed(k, …)` and address `k`, exactly as a focus request does.
197    ///
198    /// A message per frame while it moves. For a list of five thousand rows that is a
199    /// rebuild per frame of a fling, which is the honest cost of a view that reacts to
200    /// the offset — and [`ListView::notify_every`] is how a list that only wants to know
201    /// roughly where it is stops paying it.
202    pub fn on_scroll<R: crate::callback::IntoMsg<Msg>>(
203        mut self,
204        on_scroll: impl Fn(crate::ScrollPosition) -> R + 'static,
205    ) -> Self {
206        self.on_scroll = Some(Box::new(crate::callback::handler1(on_scroll)));
207        self
208    }
209
210    /// Reports no more often than every `px` of movement — and **always** reports where
211    /// it comes to rest, however short the last step was.
212    pub fn notify_every(mut self, px: f32) -> Self {
213        self.grain = px.max(0.0);
214        self
215    }
216}
217
218impl<Msg> Widget<Msg> for ListView<Msg> {
219    fn style(&self) -> Style {
220        Style {
221            width: self.width,
222            height: self.height,
223            flex_grow: self.flex_grow,
224            ..Default::default()
225        }
226    }
227
228    fn children(&self) -> &[Box<dyn Widget<Msg>>] {
229        &[]
230    }
231
232    fn paint(&self, _bounds: Rect, _status: Status, _theme: &Theme, _scene: &mut Scene) {}
233
234    fn on_click(&self) -> Option<Msg> {
235        None
236    }
237
238    /// **All the room on offer** on an axis nobody sized, as the reference's takes
239    /// (milestone 590) — unless it was told to grow, which says the same thing already.
240    fn fill_axes(&self, _theme: &Theme) -> crate::widget::FillAxes {
241        if self.flex_grow > 0.0 {
242            return crate::widget::FillAxes::NONE;
243        }
244        crate::widget::FillAxes {
245            horizontal: !self.sized.0,
246            vertical: !self.sized.1,
247        }
248    }
249
250    /// The default size is only a default: the room replaces it wherever the layout can
251    /// give the room, and it stands where the reference would have nothing to give.
252    fn soft_extent(&self) -> crate::widget::FillAxes {
253        crate::widget::FillAxes {
254            horizontal: false,
255            vertical: !self.sized.1,
256        }
257    }
258
259    fn virtual_list(&self, _viewport: frus_core::Size) -> Option<VirtualList<'_, Msg>> {
260        Some(VirtualList {
261            count: self.count,
262            item_extent: self.item_extent,
263            axis: self.axis,
264            build: &*self.build,
265        })
266    }
267
268    fn scroll_axis(&self) -> Axis {
269        self.axis
270    }
271
272    fn scroll_physics(&self) -> Option<ScrollPhysics> {
273        self.physics.or_else(|| {
274            self.primary
275                .unwrap_or(self.axis == Axis::Vertical)
276                .then_some(ScrollPhysics::ALWAYS)
277        })
278    }
279
280    fn scroll_reverse(&self) -> bool {
281        self.reverse
282    }
283
284    fn scroll_padding(&self) -> Insets {
285        self.padding
286    }
287
288    fn on_scroll(&self, position: crate::ScrollPosition) -> Option<Msg> {
289        self.on_scroll.as_ref().map(|report| report(position))
290    }
291
292    fn scroll_grain(&self) -> f32 {
293        self.grain
294    }
295}
296
297#[cfg(test)]
298mod tests {
299    use super::*;
300    use crate::{build_ui, Container, Runtime, Size};
301    use frus_core::{Color, Primitive};
302
303    #[test]
304    fn only_visible_items_are_built() {
305        use std::cell::Cell;
306        use std::rc::Rc;
307
308        // Counts how many items the list builds.
309        let built = Rc::new(Cell::new(0usize));
310        let counter = built.clone();
311        let list = ListView::<()>::new(5000, 40.0, move |_i| {
312            counter.set(counter.get() + 1);
313            Container::<()>::new()
314                .width(180.0)
315                .height(40.0)
316                .color(Color::rgb(1.0, 0.0, 0.0))
317        })
318        .width(200.0)
319        .height(200.0);
320
321        let _ui = build_ui(
322            &list,
323            Size::new(200.0, 200.0),
324            &Runtime::default(),
325            &Theme::default(),
326        );
327        // Viewport 200 / item 40 = 5 visible (+ maybe 1 of margin) — never 5000.
328        assert!(
329            built.get() <= 8,
330            "only the visible items are built: {}",
331            built.get()
332        );
333        assert!(
334            built.get() >= 5,
335            "at least the visible window: {}",
336            built.get()
337        );
338    }
339
340    /// An item that sets no width **fills the list**, as the reference's does: a list
341    /// hands its children a box rather than asking them how big they would like to be.
342    ///
343    /// Asked instead, a row hugs whatever is in it, and a list of coloured rows paints a
344    /// column of chips down the left rather than rows across the list. A device showed
345    /// exactly that in milestone 349.
346    #[test]
347    fn an_item_is_handed_the_list_s_width() {
348        let list = ListView::<()>::new(20, 40.0, |i| {
349            Container::<()>::new()
350                .height(40.0)
351                .color(Color::rgb(1.0, 0.0, 0.0))
352                .child(crate::Text::new(format!("Row {i}")))
353        })
354        .width(200.0)
355        .height(200.0);
356        let ui = build_ui(
357            &list,
358            Size::new(200.0, 200.0),
359            &Runtime::default(),
360            &Theme::default(),
361        );
362        let row = ui
363            .scene()
364            .primitives()
365            .iter()
366            .find_map(|p| match p {
367                Primitive::Rect { rect, color, .. } if color.r > 0.9 && color.g < 0.1 => {
368                    Some(*rect)
369                }
370                _ => None,
371            })
372            .expect("a row is painted");
373        assert_eq!(row.width, 200.0, "the row is as wide as the list: {row:?}");
374    }
375
376    /// Item 0 sits at the **bottom** of a reversed list, and item 1 above it — which is
377    /// what lets index 0 be the newest message.
378    #[test]
379    fn a_reversed_list_builds_from_the_bottom() {
380        let colour = |i: usize| {
381            if i == 0 {
382                Color::rgb(1.0, 0.0, 0.0)
383            } else {
384                Color::rgb(0.0, 0.0, 1.0)
385            }
386        };
387        let list = ListView::<()>::new(3, 40.0, move |i| {
388            Container::<()>::new().height(40.0).color(colour(i))
389        })
390        .reverse()
391        .width(100.0)
392        .height(200.0);
393        let ui = build_ui(
394            &list,
395            Size::new(100.0, 200.0),
396            &Runtime::default(),
397            &Theme::default(),
398        );
399        let rects: Vec<Rect> = ui
400            .scene()
401            .primitives()
402            .iter()
403            .filter_map(|p| match p {
404                Primitive::Rect { rect, color, .. } if color.b > 0.9 || color.r > 0.9 => {
405                    Some(*rect)
406                }
407                _ => None,
408            })
409            .collect();
410        let first = ui
411            .scene()
412            .primitives()
413            .iter()
414            .find_map(|p| match p {
415                Primitive::Rect { rect, color, .. } if color.r > 0.9 && color.b < 0.1 => {
416                    Some(*rect)
417                }
418                _ => None,
419            })
420            .expect("item 0 is painted");
421        assert_eq!(
422            first.y + first.height,
423            200.0,
424            "item 0 at the bottom: {first:?}"
425        );
426        assert_eq!(rects.len(), 3, "all three fit");
427        // And item 1 is directly above it.
428        let above = rects
429            .iter()
430            .filter(|r| r.y < first.y)
431            .max_by(|a, b| a.y.total_cmp(&b.y))
432            .expect("something above");
433        assert_eq!(above.y + above.height, first.y);
434    }
435
436    /// Three items of 40 in a viewport of 200 do not fill it, and a reversed list puts
437    /// them against the bottom rather than leaving them at the top.
438    #[test]
439    fn a_short_reversed_list_rests_at_the_bottom() {
440        let plain = ListView::<()>::new(3, 40.0, |_| {
441            Container::<()>::new()
442                .height(40.0)
443                .color(Color::rgb(1.0, 0.0, 0.0))
444        })
445        .width(100.0)
446        .height(200.0);
447        let top_of = |list: &ListView<()>| {
448            let ui = build_ui(
449                list,
450                Size::new(100.0, 200.0),
451                &Runtime::default(),
452                &Theme::default(),
453            );
454            ui.scene()
455                .primitives()
456                .iter()
457                .filter_map(|p| match p {
458                    Primitive::Rect { rect, color, .. } if color.r > 0.9 => Some(rect.y),
459                    _ => None,
460                })
461                .fold(f32::MAX, f32::min)
462        };
463        assert_eq!(top_of(&plain), 0.0);
464
465        let reversed = ListView::<()>::new(3, 40.0, |_| {
466            Container::<()>::new()
467                .height(40.0)
468                .color(Color::rgb(1.0, 0.0, 0.0))
469        })
470        .reverse()
471        .width(100.0)
472        .height(200.0);
473        assert_eq!(top_of(&reversed), 80.0, "200 - 3 x 40");
474    }
475
476    #[test]
477    fn scroll_max_covers_full_content() {
478        let list = ListView::<()>::new(100, 40.0, |_i| Container::<()>::new().height(40.0))
479            .width(200.0)
480            .height(200.0);
481        let ui = build_ui(
482            &list,
483            Size::new(200.0, 200.0),
484            &Runtime::default(),
485            &Theme::default(),
486        );
487        // Content = 100×40 = 4000; viewport 200 → max vertical scroll 3800.
488        let maxes = ui.scrollable_maxes();
489        assert_eq!(maxes.len(), 1);
490        assert_eq!(maxes[0].2, 3800.0);
491    }
492
493    #[test]
494    fn builds_a_scene() {
495        let list = ListView::<()>::new(50, 30.0, |i| {
496            Container::<()>::new().height(30.0).color(if i % 2 == 0 {
497                Color::rgb(0.2, 0.2, 0.2)
498            } else {
499                Color::rgb(0.3, 0.3, 0.3)
500            })
501        })
502        .width(200.0)
503        .height(120.0);
504        let ui = build_ui(
505            &list,
506            Size::new(200.0, 120.0),
507            &Runtime::default(),
508            &Theme::default(),
509        );
510        let rects = ui
511            .scene()
512            .primitives()
513            .iter()
514            .filter(|p| matches!(p, Primitive::Rect { .. }))
515            .count();
516        assert!(rects > 0);
517    }
518}
519
520#[cfg(test)]
521mod padding_tests {
522    use super::*;
523    use crate::{build_ui, Container, Runtime, Size};
524    use frus_core::{Color, Primitive};
525
526    const MARK: Color = Color::rgb(1.0, 0.0, 0.0);
527
528    /// The rectangles the list painted for its items, in the order they came out.
529    fn items(list: &ListView<()>, size: Size) -> Vec<Rect> {
530        build_ui(list, size, &Runtime::default(), &Theme::default())
531            .scene()
532            .primitives()
533            .iter()
534            .filter_map(|p| match p {
535                Primitive::Rect { rect, color, .. } if *color == MARK => Some(*rect),
536                _ => None,
537            })
538            .collect()
539    }
540
541    fn list(count: usize) -> ListView<()> {
542        ListView::<()>::new(count, 40.0, |_| {
543            Container::<()>::new().height(40.0).color(MARK)
544        })
545        .width(100.0)
546        .height(200.0)
547    }
548
549    /// The leading inset pushes item 0 in, and the sides inset every item.
550    #[test]
551    fn the_first_item_clears_the_leading_inset() {
552        let plain = items(&list(5), Size::new(100.0, 200.0));
553        assert_eq!((plain[0].y, plain[0].x, plain[0].width), (0.0, 0.0, 100.0));
554
555        let padded = items(
556            &list(5).padding_each(12.0, 8.0, 24.0, 8.0),
557            Size::new(100.0, 200.0),
558        );
559        assert_eq!(padded[0].y, 12.0, "item 0 starts after the top inset");
560        assert_eq!(padded[0].x, 8.0, "and inside the left one");
561        assert_eq!(padded[0].width, 84.0, "the sides come off the width");
562        assert_eq!(padded[1].y, 52.0, "the pitch is unchanged");
563    }
564
565    /// The room is **inside** the viewport and scrolls with the items, so it is added to
566    /// what there is to scroll rather than taken out of the window.
567    #[test]
568    fn the_far_inset_is_reachable_rather_than_lost() {
569        let bare = build_ui(
570            &list(5),
571            Size::new(100.0, 200.0),
572            &Runtime::default(),
573            &Theme::default(),
574        );
575        // 5 x 40 = 200 of content in a 200 viewport: nothing to scroll.
576        assert_eq!(bare.scroll_regions()[0].max_y, 0.0);
577
578        let padded = build_ui(
579            &list(5).padding_each(12.0, 0.0, 24.0, 0.0),
580            Size::new(100.0, 200.0),
581            &Runtime::default(),
582            &Theme::default(),
583        );
584        assert_eq!(
585            padded.scroll_regions()[0].max_y,
586            36.0,
587            "both insets joined the content"
588        );
589    }
590
591    /// A reversed list starts at the bottom, so the **bottom** inset is the one item 0
592    /// clears — and it is still at the bottom, where it looks.
593    #[test]
594    fn a_reversed_list_clears_its_bottom_inset() {
595        let padded = items(
596            &list(3).reverse().padding_each(12.0, 0.0, 24.0, 0.0),
597            Size::new(100.0, 200.0),
598        );
599        let first = padded
600            .iter()
601            .copied()
602            .max_by(|a, b| a.y.total_cmp(&b.y))
603            .expect("item 0 is painted");
604        assert_eq!(
605            first.y + first.height,
606            176.0,
607            "the bottom of item 0 sits one bottom inset above the viewport's"
608        );
609    }
610
611    /// The window is still only what fits: a leading inset moves it, it does not widen it.
612    #[test]
613    fn the_padding_does_not_widen_the_window() {
614        let built = items(&list(5000).padding(16.0), Size::new(100.0, 200.0));
615        assert!(built.len() <= 8, "only the visible items: {}", built.len());
616    }
617}
618
619/// A list turned on its side. Everything a vertical list does, across.
620#[cfg(test)]
621mod across_tests {
622    use super::*;
623    use crate::{build_ui, Container, Runtime, Size};
624    use frus_core::{Color, Primitive, Rect};
625
626    const MARK: Color = Color::rgb(0.0, 1.0, 0.0);
627
628    /// The rectangles a horizontal list painted, in the order they came out.
629    fn shelf(list: &ListView<()>, size: Size) -> Vec<Rect> {
630        build_ui(list, size, &Runtime::default(), &Theme::default())
631            .scene()
632            .primitives()
633            .iter()
634            .filter_map(|p| match p {
635                Primitive::Rect { rect, color, .. } if *color == MARK => Some(*rect),
636                _ => None,
637            })
638            .collect()
639    }
640
641    /// A shelf of `count` cards, 50 wide, in a 200x100 viewport.
642    fn shelf_of(count: usize) -> ListView<()> {
643        ListView::<()>::new(count, 50.0, |_| Container::<()>::new().color(MARK))
644            .axis(Axis::Horizontal)
645            .width(200.0)
646            .height(100.0)
647    }
648
649    /// Cards run **across**, each one item-extent wide, side by side from the left.
650    #[test]
651    fn the_items_run_across_rather_than_down() {
652        let cards = shelf(&shelf_of(3), Size::new(200.0, 100.0));
653        assert_eq!(cards.len(), 3);
654        for (i, card) in cards.iter().enumerate() {
655            assert_eq!(card.x, i as f32 * 50.0, "card {i} sits along the row");
656            assert_eq!(card.y, 0.0, "and none of them moves down");
657            assert_eq!(card.width, 50.0, "the extent is a width here");
658        }
659    }
660
661    /// The **cross** axis is handed over whole, exactly as a vertical list hands over
662    /// its width. A card whose height nobody set is as tall as the shelf, rather than
663    /// hugging whatever is inside it -- which is milestone 351's finding, in the other
664    /// direction.
665    #[test]
666    fn an_item_is_handed_the_shelf_s_height() {
667        let cards = shelf(&shelf_of(2), Size::new(200.0, 100.0));
668        assert_eq!(cards[0].height, 100.0);
669    }
670
671    /// **Virtualised**, which is the whole point of doing this here rather than in a
672    /// `Flex` inside a scroll: a shelf of two hundred covers builds the four that show.
673    #[test]
674    fn only_the_visible_cards_are_built() {
675        use std::cell::RefCell;
676        use std::rc::Rc;
677
678        let built = Rc::new(RefCell::new(Vec::new()));
679        let seen = built.clone();
680        let list = ListView::<()>::new(200, 50.0, move |i| {
681            seen.borrow_mut().push(i);
682            Container::<()>::new().color(MARK)
683        })
684        .axis(Axis::Horizontal)
685        .width(200.0)
686        .height(100.0);
687        let _ = build_ui(
688            &list,
689            Size::new(200.0, 100.0),
690            &Runtime::default(),
691            &Theme::default(),
692        );
693        assert_eq!(
694            *built.borrow(),
695            vec![0, 1, 2, 3],
696            "four fit, four are built"
697        );
698    }
699
700    /// The scrollable it registers runs along **x**, and leaves **y** alone. Getting
701    /// this the wrong way round is the bug that would let a shelf scroll vertically and
702    /// not horizontally, which is worse than not scrolling at all.
703    #[test]
704    fn it_scrolls_along_x_and_not_along_y() {
705        let ui = build_ui(
706            &shelf_of(10),
707            Size::new(200.0, 100.0),
708            &Runtime::default(),
709            &Theme::default(),
710        );
711        // Ten cards of 50 is 500, in a 200-wide window.
712        let maxes = ui.scrollable_maxes();
713        assert_eq!(maxes.len(), 1);
714        assert_eq!(maxes[0].1, 300.0, "it scrolls along x");
715        assert_eq!(maxes[0].2, 0.0, "and not along y");
716    }
717
718    /// Padding follows the axis: the leading inset pushes the first card along, and the
719    /// cross insets take height off every card rather than width.
720    #[test]
721    fn the_insets_follow_the_axis() {
722        let padded = shelf(
723            &shelf_of(3).padding_each(10.0, 4.0, 6.0, 8.0),
724            Size::new(200.0, 100.0),
725        );
726        assert_eq!(padded[0].x, 8.0, "the left inset leads");
727        assert_eq!(padded[0].y, 10.0, "the top inset is a cross inset here");
728        assert_eq!(
729            padded[0].height,
730            100.0 - 10.0 - 6.0,
731            "top and bottom come off"
732        );
733        assert_eq!(padded[0].width, 50.0, "the extent is untouched by padding");
734    }
735
736    /// Reversed, a shelf starts at its **trailing** edge: card 0 sits at the right of
737    /// the viewport, card 1 to its left. The same conversation a reversed column has
738    /// with the bottom of the screen.
739    #[test]
740    fn a_reversed_shelf_starts_at_the_far_edge() {
741        let cards = shelf(&shelf_of(3).reverse(), Size::new(200.0, 100.0));
742        assert_eq!(
743            cards[0].x + cards[0].width,
744            200.0,
745            "card 0 ends at the edge"
746        );
747        assert_eq!(cards[1].x + cards[1].width, 150.0, "card 1 is before it");
748    }
749
750    /// It actually **scrolls**. Everything above is layout at rest; this drives the
751    /// offset the shell would set and watches the window move.
752    ///
753    /// A shelf that lays out correctly and does not move is the failure worth guarding
754    /// against, because every assertion up to here would still pass.
755    #[test]
756    fn scrolling_it_moves_the_window_along_x() {
757        let mut runtime = Runtime::default();
758        runtime
759            .scroll
760            .insert(crate::interaction::WidgetId::ROOT, (120.0, 0.0));
761        let list = shelf_of(10);
762        let cards: Vec<Rect> =
763            build_ui(&list, Size::new(200.0, 100.0), &runtime, &Theme::default())
764                .scene()
765                .primitives()
766                .iter()
767                .filter_map(|p| match p {
768                    Primitive::Rect { rect, color, .. } if *color == MARK => Some(*rect),
769                    _ => None,
770                })
771                .collect();
772        // 120 in, at 50 a card: cards 2..=5 are the ones on screen, and card 2 starts
773        // 20 px before the left edge.
774        assert_eq!(cards.first().map(|c| c.x), Some(-20.0));
775        assert!(
776            cards.iter().all(|c| c.y == 0.0),
777            "and nothing drifted down the cross axis"
778        );
779    }
780
781    /// And a vertical list is untouched by any of it -- the default is what it was.
782    #[test]
783    fn a_list_that_says_nothing_still_runs_down() {
784        let rows = shelf(
785            &ListView::<()>::new(3, 50.0, |_| Container::<()>::new().color(MARK))
786                .width(200.0)
787                .height(100.0),
788            Size::new(200.0, 100.0),
789        );
790        assert_eq!(rows[0].y, 0.0);
791        assert_eq!(rows[1].y, 50.0, "the second is below, not beside");
792        assert_eq!(rows[0].width, 200.0, "and the width is handed over whole");
793    }
794}
795
796/// The page's primary scroll view (milestone 655).
797#[cfg(test)]
798mod primary_tests {
799    use super::*;
800    use crate::physics::UserScroll;
801    use crate::{build_ui, Runtime, Size, Theme};
802
803    /// Three rows of 20 in a list 200 tall: everything is in sight.
804    fn short() -> ListView<()> {
805        ListView::new(3, 20.0, |_| crate::Container::<()>::new().height(20.0))
806            .width(200.0)
807            .height(200.0)
808    }
809
810    /// The area the list registers, laid out alone in a box of 200.
811    fn area(list: &ListView<()>) -> crate::ui::Scrollable {
812        let ui = build_ui(
813            list,
814            Size::new(200.0, 200.0),
815            &Runtime::default(),
816            &Theme::default(),
817        );
818        ui.scroll_regions()[0]
819    }
820
821    /// **A vertical list is the page's scroll view**, as the reference's is without a
822    /// controller: a drag moves it with everything in sight. A horizontal one is not.
823    #[test]
824    fn a_vertical_list_is_always_scrollable() {
825        assert_eq!(
826            Widget::<()>::scroll_physics(&short()),
827            Some(ScrollPhysics::ALWAYS)
828        );
829        let page = area(&short());
830        assert_eq!(page.physics.user(), UserScroll::Always);
831        assert!(
832            page.takes_user_scroll(true),
833            "a drag along it, with nothing to scroll"
834        );
835        assert!(!page.takes_user_scroll(false), "not across it");
836
837        let across = short().axis(Axis::Horizontal);
838        assert_eq!(Widget::<()>::scroll_physics(&across), None);
839        assert!(
840            !area(&across).takes_user_scroll(false),
841            "a short row takes no drag"
842        );
843    }
844
845    /// **`primary` says otherwise either way**, and physics given are kept as given.
846    #[test]
847    fn primary_and_physics_say_otherwise() {
848        let quiet = short().primary(false);
849        assert_eq!(Widget::<()>::scroll_physics(&quiet), None);
850        assert!(!area(&quiet).takes_user_scroll(true));
851
852        let row = short().axis(Axis::Horizontal).primary(true);
853        assert_eq!(
854            Widget::<()>::scroll_physics(&row),
855            Some(ScrollPhysics::ALWAYS)
856        );
857
858        let clamped = short().physics(ScrollPhysics::CLAMPING);
859        assert_eq!(
860            Widget::<()>::scroll_physics(&clamped),
861            Some(ScrollPhysics::CLAMPING)
862        );
863        assert!(
864            !area(&clamped).takes_user_scroll(true),
865            "the physics given decide"
866        );
867        let never = short().primary(true).physics(ScrollPhysics::NEVER);
868        assert_eq!(
869            Widget::<()>::scroll_physics(&never),
870            Some(ScrollPhysics::NEVER)
871        );
872    }
873}