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