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