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