Skip to main content

frus_widgets/
grid.rs

1//! [`GridView`]: a **grid** container of equal columns. Cells place themselves
2//! automatically, row by row.
3//!
4//! Unlike the composites, `GridView` is a **plain container**: the layout is
5//! done by the layout engine (taffy's CSS Grid), so
6//! `cell()` is nothing more than adding a child.
7//!
8//! **How tall is a row?** By default the tallest cell in it, which is what a grid of
9//! forms or of labels wants. A grid of *tiles* — photos, cards, a colour swatch board —
10//! wants every tile the same shape instead, and says so with [`GridView::aspect`] (a
11//! `width / height` ratio, `1.0` for squares) or [`GridView::tile_height`] (an exact number
12//! of pixels). The reference draws the same distinction and puts the choice on the grid,
13//! not on the tile: a tile cannot know how wide its column came out.
14//!
15//! **How many columns?** [`GridView::new`] takes the number. [`GridView::extent`] takes a
16//! *maximum tile width* instead and works the count out from the room there is, which is
17//! what a photo grid actually wants: four columns on a phone, nine on a desktop, without
18//! the application computing breakpoints. That one cannot be a plain container — the
19//! count depends on a width nobody has until the layout runs — so it builds its cells
20//! late, from a factory, and the caveats of building late apply (see [`GridView::extent`]).
21
22use std::cell::OnceCell;
23use std::rc::Rc;
24
25use frus_core::{Rect, Scene, Size};
26use frus_layout::{Dimension, Style};
27
28use crate::interaction::Status;
29use crate::theme::Theme;
30use crate::widget::Widget;
31use frus_layout::Align;
32
33/// A cell factory: the widget at an index, boxed. Shared rather than owned because the
34/// composition below has to outlive the borrow that reads it.
35type CellAt<Msg> = Rc<dyn Fn(usize) -> Box<dyn Widget<Msg>>>;
36
37/// The `size → widget` composition a late-building grid hands the walk: given the box it
38/// was allotted, the grid it puts its cells in.
39type Composed<Msg> = Box<dyn Fn(Size) -> Box<dyn Widget<Msg>>>;
40/// Builds the widget for one **row** of a windowed grid.
41type RowAt<Msg> = Box<dyn Fn(usize) -> Box<dyn Widget<Msg>>>;
42
43/// Where a grid's cells come from: given, or built once the width is known.
44enum Cells<Msg> {
45    /// Added one at a time with [`GridView::cell`]. The grid is a plain container and the
46    /// layout engine places them.
47    Given(Vec<Box<dyn Widget<Msg>>>),
48    /// Built from an index, with the column count derived from `max` and the width the
49    /// grid is actually given ([`GridView::extent`]).
50    Built {
51        max: f32,
52        count: usize,
53        build: CellAt<Msg>,
54    },
55    /// Built from an index like `Built`, but only the **visible rows**
56    /// ([`GridView::builder`]).
57    Windowed {
58        columns: usize,
59        count: usize,
60        build: CellAt<Msg>,
61    },
62}
63
64/// A grid of `columns` equal columns.
65pub struct GridView<Msg = crate::callback::Callback> {
66    columns: usize,
67    gap: f32,
68    row_gap: Option<f32>,
69    column_gap: Option<f32>,
70    aspect: Option<f32>,
71    tile_height: Option<f32>,
72    width: Dimension,
73    height: Dimension,
74    flex_grow: f32,
75    cells: Cells<Msg>,
76    /// The `size → widget` closure a [`GridView::extent`] hands the walk, composed on first
77    /// use: the builder methods may still be changing the spacing and the shape when the
78    /// grid is constructed, so it cannot be composed before it is asked for.
79    composed: OnceCell<Composed<Msg>>,
80    /// The `row → widget` closure a windowed grid hands the list machinery, composed on
81    /// first use for the same reason as `composed` above.
82    rows: OnceCell<RowAt<Msg>>,
83    /// How it behaves at its edges and after a fling; see [`GridView::physics`].
84    physics: Option<crate::physics::ScrollPhysics>,
85    /// Whether it is the page's primary scroll view; see [`GridView::primary`].
86    primary: Option<bool>,
87    /// Told where this grid is whenever it moves; see [`GridView::on_scroll`].
88    on_scroll: Option<Box<dyn Fn(crate::ScrollPosition) -> Msg>>,
89    /// How far it must move before it says so again; see [`GridView::notify_every`].
90    grain: f32,
91}
92
93impl<Msg> GridView<Msg> {
94    /// Creates a grid of `columns` columns, at least one.
95    pub fn new(columns: usize) -> Self {
96        Self {
97            columns: columns.max(1),
98            gap: 0.0,
99            row_gap: None,
100            column_gap: None,
101            aspect: None,
102            tile_height: None,
103            width: Dimension::Auto,
104            height: Dimension::Auto,
105            flex_grow: 0.0,
106            cells: Cells::Given(Vec::new()),
107            composed: OnceCell::new(),
108            rows: OnceCell::new(),
109            physics: None,
110            primary: None,
111            on_scroll: None,
112            grain: 0.0,
113        }
114    }
115
116    /// Creates a grid whose columns are **as many as fit**, each at most
117    /// `max_tile_width` logical pixels across — the reference's max-cross-axis-extent
118    /// delegate, and the one a responsive photo or card grid wants.
119    ///
120    /// The count is the reference's arithmetic exactly: `ceil(width / (max + column
121    /// gap))`, at least one, and the room left over is then divided equally, so the
122    /// tiles come out *at most* `max_tile_width` and never leave a ragged edge. A 500 px
123    /// grid at `extent(150.0)` is four columns of 125, not three of 166.
124    ///
125    /// The cells are **built from their index**, because there is no grid to put them in
126    /// until the width is known:
127    ///
128    /// ```ignore
129    /// GridView::extent(160.0, photos.len(), move |i| photo_tile(&photos[i]))
130    ///     .gap(8.0)
131    ///     .aspect(1.0)
132    /// ```
133    ///
134    /// Two consequences of building late, both shared with [`crate::LayoutBuilder`],
135    /// which is the mechanism underneath:
136    ///
137    /// - the factory runs **more than once a frame**, so it must be cheap and free of
138    ///   side effects;
139    /// - the cells have **no retained state** — hover and clicks work, persistent
140    ///   keyboard focus and deferred overlays do not. A grid of images or of buttons is
141    ///   fine; a grid of text fields is not, and wants [`GridView::new`] with a count the
142    ///   application chose.
143    pub fn extent<W: Widget<Msg> + 'static>(
144        max_tile_width: f32,
145        count: usize,
146        build: impl Fn(usize) -> W + 'static,
147    ) -> Self {
148        Self {
149            columns: 1,
150            gap: 0.0,
151            row_gap: None,
152            column_gap: None,
153            aspect: None,
154            tile_height: None,
155            width: Dimension::Auto,
156            height: Dimension::Auto,
157            flex_grow: 0.0,
158            cells: Cells::Built {
159                max: max_tile_width.max(f32::MIN_POSITIVE),
160                count,
161                build: Rc::new(move |i| Box::new(build(i)) as Box<dyn Widget<Msg>>),
162            },
163            composed: OnceCell::new(),
164            rows: OnceCell::new(),
165            physics: None,
166            primary: None,
167            on_scroll: None,
168            grain: 0.0,
169        }
170    }
171
172    /// A grid of `count` tiles in `columns` columns, of which only the **visible rows**
173    /// are built, laid out and painted.
174    ///
175    /// The form a photo grid wants. [`GridView::new`] and [`GridView::extent`] both build
176    /// every cell every frame — fine for a dozen swatches, and the wrong shape entirely
177    /// for two thousand photographs, which is the same argument milestone 375 made about
178    /// lists and which does not stop being true because the cells are in rows.
179    ///
180    /// ```ignore
181    /// GridView::builder(3, photos.len(), |i| photo_tile(&photos[i]))
182    ///     .aspect(1.0)
183    ///     .gap(8.0)
184    ///     .flex(1.0)
185    /// ```
186    ///
187    /// **A virtualised grid is a list of rows**, and that is how this is built: the row
188    /// height comes out of the tile shape and the width the grid was given, and from
189    /// there it is a [`crate::ListView`] whose item is a row of cells. Nothing new
190    /// windows, measures or scrolls — it is the list's machinery, which is why a
191    /// windowed grid scrolls, flings, reverses and shows a scrollbar without any of that
192    /// being written a second time.
193    ///
194    /// The shape of a tile is [`GridView::aspect`] or [`GridView::tile_height`], as in
195    /// the other two forms. With neither, tiles are **square**: the window is found by
196    /// division and division needs a number, so there has to be a default, and a square
197    /// is the one a grid of photographs means when it says nothing.
198    ///
199    /// The caveats of building late apply, and here they are the list's: cells have **no
200    /// retained state**, since a cell that does not exist off screen cannot keep any.
201    /// Clicks and hover work; persistent keyboard focus and deferred overlays do not. A
202    /// grid of images or buttons is right; a grid of text fields wants
203    /// [`GridView::new`].
204    pub fn builder<W: Widget<Msg> + 'static>(
205        columns: usize,
206        count: usize,
207        build: impl Fn(usize) -> W + 'static,
208    ) -> Self {
209        Self {
210            columns: columns.max(1),
211            gap: 0.0,
212            row_gap: None,
213            column_gap: None,
214            aspect: None,
215            tile_height: None,
216            width: Dimension::Auto,
217            height: Dimension::Auto,
218            flex_grow: 0.0,
219            cells: Cells::Windowed {
220                columns: columns.max(1),
221                count,
222                build: Rc::new(move |i| Box::new(build(i)) as Box<dyn Widget<Msg>>),
223            },
224            composed: OnceCell::new(),
225            rows: OnceCell::new(),
226            physics: None,
227            primary: None,
228            on_scroll: None,
229            grain: 0.0,
230        }
231    }
232
233    /// How many columns of at most `max` fit across `width`, and what is left for each —
234    /// the reference's delegate, transcribed.
235    fn columns_across(max: f32, cross_gap: f32, width: f32) -> usize {
236        let count = (width / (max + cross_gap)).ceil();
237        if count.is_finite() && count >= 1.0 {
238            count as usize
239        } else {
240            1
241        }
242    }
243
244    /// Spacing between cells (rows and columns), in logical pixels.
245    pub fn gap(mut self, gap: f32) -> Self {
246        self.gap = gap;
247        self
248    }
249
250    /// Spacing **between rows** only, in logical pixels. Overrides [`GridView::gap`] down
251    /// the grid and leaves it alone across.
252    pub fn row_gap(mut self, gap: f32) -> Self {
253        self.row_gap = Some(gap.max(0.0));
254        self
255    }
256
257    /// Spacing **between columns** only, in logical pixels. Overrides [`GridView::gap`]
258    /// across the grid and leaves it alone down.
259    pub fn column_gap(mut self, gap: f32) -> Self {
260        self.column_gap = Some(gap.max(0.0));
261        self
262    }
263
264    /// Gives every cell the same **shape**: a `width / height` ratio, so `1.0` is a
265    /// square tile, `1.5` a landscape one and `0.75` a portrait one.
266    ///
267    /// The height follows from the column's width, which means it follows the grid's:
268    /// the same board is square on a phone and square on a desktop, with wider tiles.
269    /// A cell that has chosen a size of its own keeps it.
270    ///
271    /// [`GridView::tile_height`] wins over this, as the exact number wins over the ratio in
272    /// the reference.
273    pub fn aspect(mut self, ratio: f32) -> Self {
274        self.aspect = Some(ratio.max(f32::MIN_POSITIVE));
275        self
276    }
277
278    /// Gives every row the same **height**, in logical pixels, whatever its content —
279    /// the reference's per-tile main-axis extent.
280    ///
281    /// Unlike [`GridView::aspect`] this is a fixed number rather than one derived from the
282    /// width, which is what a row of fixed-height cards wants. It wins over `aspect`.
283    pub fn tile_height(mut self, height: f32) -> Self {
284        self.tile_height = Some(height.max(0.0));
285        self
286    }
287
288    /// Sets the width, in logical pixels.
289    pub fn width(mut self, width: f32) -> Self {
290        self.width = Dimension::Length(width);
291        self
292    }
293
294    /// Sets the height, in logical pixels.
295    pub fn height(mut self, height: f32) -> Self {
296        self.height = Dimension::Length(height);
297        self
298    }
299
300    /// Flex growth factor along the parent's main axis.
301    pub fn flex(mut self, grow: f32) -> Self {
302        self.flex_grow = grow;
303        self
304    }
305
306    /// Overrides how the grid behaves at its edges and after a fling (milestone 655); see
307    /// [`crate::scroll::SingleChildScrollView::physics`].
308    pub fn physics(mut self, physics: crate::physics::ScrollPhysics) -> Self {
309        self.physics = Some(physics);
310        self
311    }
312
313    /// **Whether this is the page's primary scroll view** — the reference's `primary`
314    /// (milestone 655). A primary view is always scrollable unless [`physics`](Self::physics)
315    /// says otherwise: a drag moves it, and bounces or glows at its edge, even when everything
316    /// is in sight, so a page that fits still answers the finger as a page.
317    ///
318    /// Unset, it is, as the reference's vertical scroll views are when they are given no
319    /// controller.
320    pub fn primary(mut self, primary: bool) -> Self {
321        self.primary = Some(primary);
322        self
323    }
324
325    /// **Says where this grid is** whenever it moves. A gallery that fetches the next
326    /// page when the end comes into view is what a grid wants it for; see
327    /// [`crate::ScrollPosition::remaining_y`], and [`GridView::notify_every`] for how
328    /// often that question is worth asking.
329    ///
330    /// A windowed grid only builds the tiles on screen, so what this reports is where the
331    /// window is over the whole content — not over what happens to be built.
332    pub fn on_scroll<R: crate::callback::IntoMsg<Msg>>(
333        mut self,
334        on_scroll: impl Fn(crate::ScrollPosition) -> R + 'static,
335    ) -> Self {
336        self.on_scroll = Some(Box::new(crate::callback::handler1(on_scroll)));
337        self
338    }
339
340    /// Reports no more often than every `px` of movement — and **always** reports where
341    /// it comes to rest, however short the last step was.
342    pub fn notify_every(mut self, px: f32) -> Self {
343        self.grain = px.max(0.0);
344        self
345    }
346
347    /// Adds a cell, placed automatically in the grid.
348    ///
349    /// A grid built by [`GridView::extent`] gets its cells from its factory and ignores this.
350    pub fn cell(mut self, cell: impl Widget<Msg> + 'static) -> Self {
351        if let Cells::Given(cells) = &mut self.cells {
352            cells.push(Box::new(cell));
353        }
354        self
355    }
356}
357
358impl<Msg: Clone + 'static> Widget<Msg> for GridView<Msg> {
359    fn style(&self) -> Style {
360        // A grid that builds late is a **leaf** to the layout: its cells live in a tree
361        // of their own, laid out inside the box this style asks for. Declaring the grid
362        // properties here as well would make the engine place children it does not have.
363        if matches!(self.cells, Cells::Built { .. } | Cells::Windowed { .. }) {
364            return Style {
365                width: self.width,
366                height: self.height,
367                flex_grow: self.flex_grow,
368                ..Default::default()
369            };
370        }
371        Style {
372            width: self.width,
373            height: self.height,
374            flex_grow: self.flex_grow,
375            gap: self.gap,
376            row_gap: self.row_gap,
377            column_gap: self.column_gap,
378            grid_columns: Some(self.columns),
379            grid_row_height: self.tile_height,
380            ..Default::default()
381        }
382    }
383
384    fn tile_shape(&self) -> Option<f32> {
385        // An exact height is an exact height: a ratio on top of it would be asking for
386        // two heights at once, and the reference resolves the same clash the same way.
387        // A grid that builds late says neither: the grid it builds says both.
388        if self.tile_height.is_some()
389            || matches!(self.cells, Cells::Built { .. } | Cells::Windowed { .. })
390        {
391            return None;
392        }
393        self.aspect
394    }
395
396    fn layout_builder(&self) -> Option<&dyn Fn(Size) -> Box<dyn Widget<Msg>>> {
397        let Cells::Built { max, count, build } = &self.cells else {
398            return None;
399        };
400        let (max, count, build) = (*max, *count, build.clone());
401        let (gap, row_gap, column_gap) = (self.gap, self.row_gap, self.column_gap);
402        let (aspect, tile_height) = (self.aspect, self.tile_height);
403        Some(&**self.composed.get_or_init(|| {
404            Box::new(move |size: Size| {
405                let columns = Self::columns_across(max, column_gap.unwrap_or(gap), size.width);
406                let mut grid = GridView::new(columns).gap(gap);
407                if let Some(g) = row_gap {
408                    grid = grid.row_gap(g);
409                }
410                if let Some(g) = column_gap {
411                    grid = grid.column_gap(g);
412                }
413                if let Some(r) = aspect {
414                    grid = grid.aspect(r);
415                }
416                if let Some(h) = tile_height {
417                    grid = grid.tile_height(h);
418                }
419                if let Cells::Given(cells) = &mut grid.cells {
420                    cells.extend((0..count).map(|i| build(i)));
421                }
422                Box::new(grid) as Box<dyn Widget<Msg>>
423            })
424        }))
425    }
426
427    fn virtual_list(&self, viewport: Size) -> Option<crate::list::VirtualList<'_, Msg>> {
428        let Cells::Windowed {
429            columns,
430            count,
431            build,
432        } = &self.cells
433        else {
434            return None;
435        };
436        let (columns, count) = (*columns, *count);
437        let across = self.column_gap.unwrap_or(self.gap);
438        let down = self.row_gap.unwrap_or(self.gap);
439        // What one tile gets across, once the gaps between the columns are taken out.
440        // The same arithmetic the layout engine would do -- done here because the row
441        // height comes from it, and the **window** comes from the row height. That is
442        // the whole reason this hook is handed the box it was given: a grid cannot say
443        // how many rows fit until it knows how wide its columns came out.
444        let tile_w = ((viewport.width - across * columns.saturating_sub(1) as f32)
445            / columns as f32)
446            .max(0.0);
447        let tile_h = self.tile_height.unwrap_or_else(|| match self.aspect {
448            Some(ratio) if ratio > 0.0 => tile_w / ratio,
449            _ => tile_w,
450        });
451
452        // The row factory needs the column count, the spacing and the cells -- none of
453        // which depend on the width -- so it is composed **once**. The row height does
454        // depend on the width, and it is not in here: it is the item extent above, and
455        // the gap below the row is the row's own bottom padding, which is what keeps
456        // the two apart. A factory that had to know the height would have to be rebuilt
457        // on every resize, and a `OnceCell` cannot be.
458        let rows = self.rows.get_or_init(|| {
459            let cells = build.clone();
460            Box::new(move |row: usize| {
461                let mut strip = crate::Flex::row()
462                    .align(Align::Stretch)
463                    .gap(across)
464                    .padding_each(0.0, 0.0, down, 0.0);
465                for column in 0..columns {
466                    let index = row * columns + column;
467                    // The last row can be short. Its tiles keep the width every other
468                    // row's have -- a half-full row of double-width photographs is not
469                    // what "three columns" meant -- so the missing ones are empties
470                    // that take their share and draw nothing.
471                    strip = match index < count {
472                        true => strip.child(crate::Expanded::new(cells(index))),
473                        false => strip.child(crate::Expanded::new(crate::Flex::<Msg>::row())),
474                    };
475                }
476                Box::new(strip) as Box<dyn Widget<Msg>>
477            })
478        });
479
480        Some(crate::list::VirtualList {
481            count: count.div_ceil(columns.max(1)),
482            item_extent: tile_h + down,
483            axis: crate::scroll::Axis::Vertical,
484            build: &**rows,
485        })
486    }
487
488    fn scroll_physics(&self) -> Option<crate::physics::ScrollPhysics> {
489        self.physics.or_else(|| {
490            self.primary
491                .unwrap_or(true)
492                .then_some(crate::physics::ScrollPhysics::ALWAYS)
493        })
494    }
495
496    fn on_scroll(&self, position: crate::ScrollPosition) -> Option<Msg> {
497        self.on_scroll.as_ref().map(|report| report(position))
498    }
499
500    fn scroll_grain(&self) -> f32 {
501        self.grain
502    }
503
504    fn children(&self) -> &[Box<dyn Widget<Msg>>] {
505        match &self.cells {
506            Cells::Given(cells) => cells,
507            Cells::Built { .. } | Cells::Windowed { .. } => &[],
508        }
509    }
510
511    fn paint(&self, _bounds: Rect, _status: Status, _theme: &Theme, _scene: &mut Scene) {}
512
513    fn on_click(&self) -> Option<Msg> {
514        None
515    }
516}
517
518#[cfg(test)]
519mod tests {
520    use super::*;
521    use crate::{build_ui, Container, Runtime, Size};
522    use frus_core::{Color, Primitive};
523
524    fn tile(c: Color) -> Container<()> {
525        Container::<()>::new().height(20.0).color(c)
526    }
527
528    /// **A cell could widen its own column, and take the grid off the screen with it.**
529    ///
530    /// A grid track written `1fr` is `minmax(auto, 1fr)`, and that `auto` floor is the
531    /// item's min-content width: one card holding a long unbreakable title widened its
532    /// own column, the others followed, and the grid came out wider than the box it was
533    /// given. Reported from a real application whose product grid ran off the side of a
534    /// phone — two columns of 315 in a window of 492.
535    ///
536    /// A share of a width is not a negotiation with the contents. The floor is zero.
537    #[test]
538    fn a_long_title_cannot_widen_its_column() {
539        // Two columns in 300 px, one of them holding a title far too long for its share.
540        let marker = Color::rgb(0.5, 0.25, 0.75);
541        let grid: GridView<()> =
542            GridView::new(2)
543                .width(300.0)
544                .gap(12.0)
545                .cell(Container::new().height(40.0).color(marker).child(
546                    crate::text("Espresso Creme de la Maison, Torrefaction Lente").size(15.0),
547                ))
548                .cell(Container::new().height(40.0));
549        let marker_width = |ui: &crate::Ui<()>| {
550            ui.scene()
551                .primitives()
552                .iter()
553                .find_map(|p| match p {
554                    Primitive::Rect { color, rect, .. } if *color == marker => Some(rect.width),
555                    _ => None,
556                })
557                .expect("the cell is drawn")
558        };
559
560        let ui = build_ui(
561            &grid,
562            Size::new(300.0, 400.0),
563            &Runtime::default(),
564            &Theme::default(),
565        );
566        // Every box the frame drew, and none of them may leave the grid.
567        let widest = ui
568            .scene()
569            .primitives()
570            .iter()
571            .filter_map(|p| match p {
572                Primitive::Rect { rect, .. } => Some(rect.x + rect.width),
573                Primitive::Text { position, .. } => Some(position.x),
574                _ => None,
575            })
576            .fold(0.0_f32, f32::max);
577        assert!(widest <= 300.5, "nothing runs off the grid: {widest}");
578        // And the cell holding it keeps to its share: (300 - 12) / 2.
579        let w = marker_width(&ui);
580        assert!(
581            (w - 144.0).abs() < 1.0,
582            "a cell takes its share and no more: {w}"
583        );
584    }
585
586    #[test]
587    fn cells_flow_into_rows_and_columns() {
588        let a = Color::rgb(1.0, 0.0, 0.0);
589        let b = Color::rgb(0.0, 1.0, 0.0);
590        let c = Color::rgb(0.0, 0.0, 1.0);
591        let d = Color::rgb(1.0, 1.0, 0.0);
592        // A 2-column grid: [a b] / [c d].
593        let grid = GridView::<()>::new(2)
594            .gap(10.0)
595            .width(220.0)
596            .cell(tile(a))
597            .cell(tile(b))
598            .cell(tile(c))
599            .cell(tile(d));
600        let ui = build_ui(
601            &grid,
602            Size::new(220.0, 200.0),
603            &Runtime::default(),
604            &Theme::default(),
605        );
606
607        let rect_of = |col: Color| {
608            ui.scene()
609                .primitives()
610                .iter()
611                .find_map(|p| match p {
612                    Primitive::Rect { color, rect, .. } if *color == col => Some(*rect),
613                    _ => None,
614                })
615                .expect("the cell is present")
616        };
617        let (ra, rb, rc, rd) = (rect_of(a), rect_of(b), rect_of(c), rect_of(d));
618
619        // a and b: the same row, same y, in different columns, b right of a.
620        assert_eq!(ra.y, rb.y);
621        assert!(rb.x > ra.x);
622        // c sits under a, on the next row, in the same column, same x.
623        assert!(rc.y > ra.y);
624        assert_eq!(rc.x, ra.x);
625        // d lines up with b in x and with c in y.
626        assert_eq!(rd.x, rb.x);
627        assert_eq!(rd.y, rc.y);
628        // Equal columns: a's width equals b's.
629        assert_eq!(ra.width, rb.width);
630    }
631
632    /// The rectangle the first (red) cell paints, in a grid laid out 220 wide.
633    fn first_cell(grid: GridView<()>) -> Rect {
634        let ui = build_ui(
635            &grid,
636            Size::new(220.0, 400.0),
637            &Runtime::default(),
638            &Theme::default(),
639        );
640        ui.scene()
641            .primitives()
642            .iter()
643            .find_map(|p| match p {
644                Primitive::Rect { color, rect, .. } if color.r > 0.9 && color.g < 0.1 => {
645                    Some(*rect)
646                }
647                _ => None,
648            })
649            .expect("the first cell is painted")
650    }
651
652    fn filled() -> Container<()> {
653        Container::<()>::new().color(Color::rgb(1.0, 0.0, 0.0))
654    }
655
656    /// Without a shape a row is as tall as its content: two labels make a 19 px band,
657    /// not a grid.
658    #[test]
659    fn a_row_follows_its_content_by_default() {
660        let rect = first_cell(
661            GridView::<()>::new(2)
662                .width(220.0)
663                .cell(filled().child(crate::Text::new("x")))
664                .cell(filled().child(crate::Text::new("x"))),
665        );
666        assert_eq!(rect.width, 110.0);
667        assert!(rect.height < 40.0, "content height, not a tile: {rect:?}");
668    }
669
670    /// `aspect(1.0)`: the tile is as tall as the column is wide, and the column's width
671    /// is the grid's — so the same board is square at any size.
672    #[test]
673    fn a_ratio_derives_the_tile_height_from_the_column() {
674        let square = first_cell(
675            GridView::<()>::new(2)
676                .width(220.0)
677                .aspect(1.0)
678                .cell(filled())
679                .cell(filled()),
680        );
681        assert_eq!((square.width, square.height), (110.0, 110.0));
682
683        // Twice as wide as it is tall, and the gap comes out of the column first.
684        let wide = first_cell(
685            GridView::<()>::new(2)
686                .width(220.0)
687                .gap(10.0)
688                .aspect(2.0)
689                .cell(filled())
690                .cell(filled()),
691        );
692        assert_eq!(wide.width, 105.0);
693        assert!(
694            (wide.height - 52.5).abs() <= 0.5,
695            "half the width: {wide:?}"
696        );
697    }
698
699    /// An exact height is an exact height, whatever the grid's width, and it wins over
700    /// a ratio rather than fighting it.
701    #[test]
702    fn an_exact_tile_height_wins_over_a_ratio() {
703        let rect = first_cell(
704            GridView::<()>::new(2)
705                .width(220.0)
706                .aspect(1.0)
707                .tile_height(60.0)
708                .cell(filled())
709                .cell(filled()),
710        );
711        assert_eq!((rect.width, rect.height), (110.0, 60.0));
712    }
713
714    /// A cell that has chosen a size of its own keeps it: the grid's shape is a default
715    /// for the cells that did not say, not an override of the ones that did.
716    #[test]
717    fn a_cell_that_chose_its_size_keeps_it() {
718        let rect = first_cell(
719            GridView::<()>::new(2)
720                .width(220.0)
721                .aspect(1.0)
722                .cell(filled().height(20.0))
723                .cell(filled().height(20.0)),
724        );
725        assert_eq!(rect.height, 20.0);
726    }
727
728    /// Every tile a `GridView::extent` painted, in a grid laid out `width` across.
729    fn extent_tiles(width: f32, grid: GridView<()>) -> Vec<Rect> {
730        let root = crate::Flex::<()>::column()
731            .width(width)
732            .height(2000.0)
733            .child(grid);
734        let ui = build_ui(
735            &root,
736            Size::new(width, 2000.0),
737            &Runtime::default(),
738            &Theme::default(),
739        );
740        ui.scene()
741            .primitives()
742            .iter()
743            .filter_map(|p| match p {
744                Primitive::Rect { color, rect, .. } if color.r > 0.9 && color.g < 0.1 => {
745                    Some(*rect)
746                }
747                _ => None,
748            })
749            .collect()
750    }
751
752    /// The reference's example, transcribed: a 500 px grid at a maximum tile of 150 is
753    /// **four columns of 125**, not three of 166. That is the difference between the
754    /// reference's arithmetic — as many columns as it takes for none to exceed the
755    /// maximum — and the CSS `auto-fill` that looks like it and is not.
756    #[test]
757    fn the_column_count_comes_from_the_tile_width() {
758        let tiles = extent_tiles(500.0, GridView::extent(150.0, 4, |_| filled().height(20.0)));
759        assert_eq!(tiles.len(), 4);
760        for t in &tiles {
761            assert_eq!(t.width, 125.0, "four equal columns of 125: {tiles:?}");
762        }
763        // All four on one row.
764        assert!(tiles.iter().all(|t| t.y == tiles[0].y), "{tiles:?}");
765    }
766
767    /// The same grid at three widths: the count follows the room, which is the whole
768    /// point of the delegate.
769    #[test]
770    fn the_count_follows_the_room() {
771        let columns_at = |width: f32| {
772            let tiles = extent_tiles(
773                width,
774                GridView::extent(150.0, 12, |_| filled().height(20.0)),
775            );
776            let top = tiles[0].y;
777            tiles.iter().filter(|t| t.y == top).count()
778        };
779        assert_eq!(columns_at(300.0), 2);
780        assert_eq!(columns_at(500.0), 4);
781        assert_eq!(columns_at(900.0), 6);
782    }
783
784    /// The column gap comes out of the arithmetic, exactly as it does in the reference:
785    /// the count divides `max + gap`, then the gaps come out of the room before the
786    /// columns share it.
787    #[test]
788    fn the_gap_is_part_of_the_arithmetic() {
789        let tiles = extent_tiles(
790            500.0,
791            GridView::extent(150.0, 4, |_| filled().height(20.0)).gap(10.0),
792        );
793        // ceil(500 / 160) = 4 columns; (500 - 3×10) / 4 = 117.5 — which the layout then
794        // rounds to whole pixels, alternating 118 and 117 so that the row still ends
795        // exactly on the edge rather than drifting half a pixel per column.
796        let top = tiles[0].y;
797        assert_eq!(tiles.iter().filter(|t| t.y == top).count(), 4);
798        for t in &tiles {
799            assert!(
800                (t.width - 117.5).abs() <= 0.5,
801                "each column is 117.5 to the pixel: {tiles:?}"
802            );
803        }
804        let last = tiles.last().expect("four tiles");
805        assert_eq!(last.x + last.width, 500.0, "and the row ends on the edge");
806    }
807
808    /// The shape and the spacings travel into the grid it builds — they are set on the
809    /// outer one, which never lays anything out itself.
810    #[test]
811    fn the_tile_shape_reaches_the_built_grid() {
812        let tiles = extent_tiles(500.0, GridView::extent(150.0, 4, |_| filled()).aspect(1.0));
813        assert_eq!((tiles[0].width, tiles[0].height), (125.0, 125.0));
814    }
815
816    /// A grid that builds late is as tall as what it built (milestone 355), so a sibling
817    /// below it starts where the tiles end rather than on top of them.
818    #[test]
819    fn it_is_as_tall_as_the_tiles_it_built() {
820        let root = crate::Flex::<()>::column()
821            .width(500.0)
822            .height(2000.0)
823            .child(GridView::extent(150.0, 8, |_| filled()).aspect(1.0))
824            .child(
825                Container::<()>::new()
826                    .height(10.0)
827                    .color(Color::rgb(0.0, 1.0, 0.0)),
828            );
829        let ui = build_ui(
830            &root,
831            Size::new(500.0, 2000.0),
832            &Runtime::default(),
833            &Theme::default(),
834        );
835        let sibling = ui
836            .scene()
837            .primitives()
838            .iter()
839            .find_map(|p| match p {
840                Primitive::Rect { rect, color, .. } if color.g > 0.9 && color.r < 0.1 => {
841                    Some(*rect)
842                }
843                _ => None,
844            })
845            .expect("the sibling is painted");
846        // Eight tiles, four to a row, each 125 square: two rows.
847        assert_eq!(sibling.y, 250.0, "below both rows of tiles");
848    }
849
850    /// The two spacings are separate: 4 across, 20 down.
851    #[test]
852    fn rows_and_columns_space_apart_separately() {
853        let grid = GridView::<()>::new(2)
854            .width(220.0)
855            .column_gap(4.0)
856            .row_gap(20.0)
857            .tile_height(30.0)
858            .cell(filled())
859            .cell(filled())
860            .cell(filled());
861        let ui = build_ui(
862            &grid,
863            Size::new(220.0, 400.0),
864            &Runtime::default(),
865            &Theme::default(),
866        );
867        let cells: Vec<Rect> = ui
868            .scene()
869            .primitives()
870            .iter()
871            .filter_map(|p| match p {
872                Primitive::Rect { color, rect, .. } if color.r > 0.9 && color.g < 0.1 => {
873                    Some(*rect)
874                }
875                _ => None,
876            })
877            .collect();
878        assert_eq!(cells.len(), 3);
879        // Across: 220 = 108 + 4 + 108.
880        assert_eq!(cells[0].width, 108.0);
881        assert_eq!(cells[1].x - (cells[0].x + cells[0].width), 4.0);
882        // Down: a 30 px row, then 20 px of gap.
883        assert_eq!(cells[2].y - (cells[0].y + cells[0].height), 20.0);
884    }
885}
886
887/// The windowed form: a grid that builds only the rows you can see.
888#[cfg(test)]
889mod windowed_tests {
890    use super::*;
891    use crate::{build_ui, Container, Runtime};
892    use frus_core::{Color, Primitive, Rect};
893    use std::cell::RefCell;
894    use std::rc::Rc;
895
896    const TILE: Color = Color::rgb(1.0, 0.0, 0.0);
897
898    /// The tiles painted, and which indices the factory was asked for.
899    fn painted(grid: GridView<()>, size: Size, runtime: &Runtime) -> Vec<Rect> {
900        build_ui(&grid, size, runtime, &Theme::default())
901            .scene()
902            .primitives()
903            .iter()
904            .filter_map(|p| match p {
905                Primitive::Rect { rect, color, .. } if *color == TILE => Some(*rect),
906                _ => None,
907            })
908            .collect()
909    }
910
911    /// A grid of `count` square tiles, 3 across, in a 300x200 box.
912    fn grid_of(count: usize, asked: Rc<RefCell<Vec<usize>>>) -> GridView<()> {
913        GridView::<()>::builder(3, count, move |i| {
914            asked.borrow_mut().push(i);
915            Container::<()>::new().color(TILE)
916        })
917        .width(300.0)
918        .height(200.0)
919    }
920
921    /// The point of the whole thing: two thousand tiles, and the factory is asked for
922    /// the handful that show. `GridView::new` and `GridView::extent` both build every
923    /// cell every frame, which is the shape this form exists to avoid.
924    #[test]
925    fn only_the_visible_rows_are_built() {
926        let asked = Rc::new(RefCell::new(Vec::new()));
927        let grid = grid_of(2000, asked.clone());
928        let _ = painted(grid, Size::new(300.0, 200.0), &Runtime::default());
929        let count = asked.borrow().len();
930        assert!(
931            count > 0 && count <= 12,
932            "asked for {count} of 2000 tiles; a 300x200 box at 100px square holds nine"
933        );
934        assert_eq!(asked.borrow()[0], 0, "and it starts at the beginning");
935    }
936
937    /// Square by default, three across a 300 wide box: 100 each, and the second row a
938    /// hundred down.
939    #[test]
940    fn tiles_are_square_and_rows_follow_them() {
941        let asked = Rc::new(RefCell::new(Vec::new()));
942        let tiles = painted(
943            grid_of(9, asked),
944            Size::new(300.0, 200.0),
945            &Runtime::default(),
946        );
947        assert_eq!((tiles[0].x, tiles[0].y), (0.0, 0.0));
948        assert_eq!((tiles[0].width, tiles[0].height), (100.0, 100.0));
949        assert_eq!(tiles[1].x, 100.0, "the second is beside the first");
950        assert_eq!(tiles[3].y, 100.0, "and the fourth is on the next row");
951    }
952
953    /// `aspect` reshapes the row, because the row height is *derived* from the tile
954    /// shape and the width the grid was given -- which is why this form composes once
955    /// the width is known rather than at construction.
956    #[test]
957    fn the_aspect_ratio_sets_the_row_height() {
958        let asked = Rc::new(RefCell::new(Vec::new()));
959        let tiles = painted(
960            grid_of(6, asked).aspect(2.0),
961            Size::new(300.0, 200.0),
962            &Runtime::default(),
963        );
964        assert_eq!(tiles[0].height, 50.0, "twice as wide as tall");
965        assert_eq!(tiles[3].y, 50.0, "so the rows are half as far apart");
966    }
967
968    /// The gap goes **between** the tiles across, and below the row down. Getting the
969    /// second one wrong is invisible in a single row and wrong in every grid.
970    #[test]
971    fn the_gap_separates_columns_and_rows() {
972        let asked = Rc::new(RefCell::new(Vec::new()));
973        let tiles = painted(
974            grid_of(6, asked).gap(10.0),
975            Size::new(300.0, 200.0),
976            &Runtime::default(),
977        );
978        // Three tiles and two 10px gaps across 300 leaves 280/3 each.
979        let tile_w = 280.0 / 3.0;
980        assert!((tiles[0].width - tile_w).abs() < 0.5, "{}", tiles[0].width);
981        assert!((tiles[1].x - (tile_w + 10.0)).abs() < 0.5, "{}", tiles[1].x);
982        assert!(
983            (tiles[3].y - (tiles[0].height + 10.0)).abs() < 0.5,
984            "the row below clears the gap: {}",
985            tiles[3].y
986        );
987    }
988
989    /// A short last row keeps its columns. Four tiles across three columns is a full row
990    /// and one lonely tile -- and that tile is a third of the width, not the whole of it,
991    /// because "three columns" did not stop meaning three.
992    #[test]
993    fn a_short_last_row_keeps_the_column_width() {
994        let asked = Rc::new(RefCell::new(Vec::new()));
995        let tiles = painted(
996            grid_of(4, asked),
997            Size::new(300.0, 200.0),
998            &Runtime::default(),
999        );
1000        assert_eq!(
1001            tiles.len(),
1002            4,
1003            "four tiles, not four plus two empties drawn"
1004        );
1005        assert_eq!(tiles[3].width, 100.0, "the lonely one keeps its column");
1006        assert_eq!(tiles[3].x, 0.0, "at the start of its row");
1007    }
1008
1009    /// And it **scrolls**, without a line of scrolling being written here: a windowed
1010    /// grid is a list of rows, so it is the list that windows, measures and moves.
1011    #[test]
1012    fn it_scrolls_like_the_list_it_is_made_of() {
1013        let asked = Rc::new(RefCell::new(Vec::new()));
1014        let ui = build_ui(
1015            &grid_of(30, asked.clone()),
1016            Size::new(300.0, 200.0),
1017            &Runtime::default(),
1018            &Theme::default(),
1019        );
1020        // Ten rows of 100 is 1000, in a 200 box.
1021        let maxes = ui.scrollable_maxes();
1022        assert_eq!(maxes.len(), 1, "one scrollable, and it is the grid");
1023        assert_eq!(maxes[0].2, 800.0);
1024
1025        let mut scrolled = Runtime::default();
1026        scrolled
1027            .scroll
1028            .insert(crate::interaction::WidgetId::ROOT, (0.0, 250.0));
1029        asked.borrow_mut().clear();
1030        let tiles = painted(
1031            grid_of(30, asked.clone()),
1032            Size::new(300.0, 200.0),
1033            &scrolled,
1034        );
1035        assert_eq!(tiles[0].y, -50.0, "row 2 starts 50px above the window");
1036        assert_eq!(
1037            asked.borrow()[0],
1038            6,
1039            "and the rows before it were never built"
1040        );
1041    }
1042}
1043
1044/// The page's primary scroll view (milestone 655).
1045#[cfg(test)]
1046mod primary_tests {
1047    use super::*;
1048    use crate::physics::ScrollPhysics;
1049
1050    /// **A grid is the page's scroll view** unless told otherwise, as the reference's
1051    /// vertical grids are; physics given are kept, and `physics` is new to it.
1052    #[test]
1053    fn a_grid_is_always_scrollable_unless_told() {
1054        let grid = || GridView::<()>::new(2).width(220.0);
1055        assert_eq!(
1056            Widget::<()>::scroll_physics(&grid()),
1057            Some(ScrollPhysics::ALWAYS)
1058        );
1059        assert_eq!(Widget::<()>::scroll_physics(&grid().primary(false)), None);
1060        assert_eq!(
1061            Widget::<()>::scroll_physics(&grid().physics(ScrollPhysics::BOUNCING)),
1062            Some(ScrollPhysics::BOUNCING)
1063        );
1064        assert_eq!(
1065            Widget::<()>::scroll_physics(&grid().primary(false).physics(ScrollPhysics::NEVER)),
1066            Some(ScrollPhysics::NEVER)
1067        );
1068    }
1069}