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}