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}