Skip to main content

presentar_widgets/
grid.rs

1//! Grid widget: children placed left to right, top to bottom in equal cells.
2//!
3//! The cell arithmetic lives in the pure function [`grid_cells`], which never
4//! panics and never returns a non-finite coordinate, whatever the bounds,
5//! column count or gap. [`Grid`] lays each child out with its cell as bounds.
6//!
7//! Contract: `contracts/present-grid-widget-v1.yaml`.
8
9use presentar_core::{
10    widget::{BrickAssertion, BrickBudget, BrickVerification, LayoutResult},
11    Brick, Canvas, Constraints, Event, Rect, Size, TypeId, Widget,
12};
13use serde::{Deserialize, Serialize};
14use std::any::Any;
15use std::time::Duration;
16
17/// Largest magnitude accepted for any coordinate or extent. Larger finite
18/// values are clamped so that `x + width` can never overflow to infinity.
19const COORD_LIMIT: f32 = 1.0e15;
20
21/// A finite coordinate: NaN and infinities become 0, huge values are clamped.
22fn sane(value: f32) -> f32 {
23    if value.is_finite() {
24        value.clamp(-COORD_LIMIT, COORD_LIMIT)
25    } else {
26        0.0
27    }
28}
29
30/// Fit `value` between `min` and `max`, finite and not negative. Unlike
31/// `f32::clamp` this never panics, whatever the constraints (NaN, infinite or
32/// `min > max`); a lower bound that exceeds the upper one loses.
33fn fit(value: f32, min: f32, max: f32) -> f32 {
34    sane(value.max(min).min(max)).max(0.0)
35}
36
37/// An upper bound a child can be measured against: NaN means "no bound",
38/// and a bound below 0 is 0.
39fn sane_upper(max: f32) -> f32 {
40    if max.is_nan() {
41        f32::INFINITY
42    } else {
43        max.max(0.0)
44    }
45}
46
47/// A usable gap: finite and not negative, otherwise 0.
48fn sane_gap(gap: f32) -> f32 {
49    sane(gap).max(0.0)
50}
51
52/// Gap actually used between `tracks` tracks inside `extent`: the requested
53/// gap, shrunk so that the gaps alone never exceed the extent.
54fn squeezed_gap(gap: f32, extent: f32, tracks: usize) -> f32 {
55    if tracks <= 1 {
56        0.0
57    } else {
58        gap.min(extent / (tracks - 1) as f32)
59    }
60}
61
62/// Cells of a grid with `columns` equal-width columns inside `bounds`.
63///
64/// Cell `i` is in column `i % columns` and row `i / columns`. The rows share
65/// the height equally. `columns` below 1 counts as 1; a gap that is not finite
66/// or is negative counts as 0; non-finite bounds components count as 0 and a
67/// negative size as 0. Gaps that would not fit are shrunk, so every cell lies
68/// inside `bounds` and no two cells overlap.
69#[must_use]
70pub fn grid_cells(bounds: Rect, columns: usize, gap: f32, count: usize) -> Vec<Rect> {
71    grid_cells_with(bounds, columns, gap, None, count)
72}
73
74/// Like [`grid_cells`], with an optional fixed row height.
75///
76/// With `Some(height)` every row is `height` tall (a negative or non-finite
77/// value counts as 0) and rows follow each other separated by `gap`; the cells
78/// of a tall grid then extend below `bounds`. With `None` the rows share the
79/// height of `bounds` equally.
80#[must_use]
81pub fn grid_cells_with(
82    bounds: Rect,
83    columns: usize,
84    gap: f32,
85    row_height: Option<f32>,
86    count: usize,
87) -> Vec<Rect> {
88    if count == 0 {
89        return Vec::new();
90    }
91    let columns = columns.max(1);
92    let gap = sane_gap(gap);
93    let (x, y) = (sane(bounds.x), sane(bounds.y));
94    let width = sane(bounds.width).max(0.0);
95    let height = sane(bounds.height).max(0.0);
96    let rows = count.div_ceil(columns);
97
98    let gap_x = squeezed_gap(gap, width, columns);
99    let cell_width = (gap_x.mul_add(-((columns - 1) as f32), width) / columns as f32).max(0.0);
100    let (cell_height, row_step) = match row_height {
101        Some(fixed) => {
102            let fixed = sane(fixed).max(0.0);
103            (fixed, fixed + gap)
104        }
105        None => {
106            let gap_y = squeezed_gap(gap, height, rows);
107            let share = (gap_y.mul_add(-((rows - 1) as f32), height) / rows as f32).max(0.0);
108            (share, share + gap_y)
109        }
110    };
111    let column_step = cell_width + gap_x;
112
113    (0..count)
114        .map(|i| {
115            Rect::new(
116                (i % columns) as f32 * column_step + x,
117                (i / columns) as f32 * row_step + y,
118                cell_width,
119                cell_height,
120            )
121        })
122        .collect()
123}
124
125/// Grid widget: a fixed number of equal-width columns.
126#[derive(Serialize, Deserialize)]
127pub struct Grid {
128    /// Number of columns (at least 1)
129    columns: usize,
130    /// Gap between cells, both directions
131    gap: f32,
132    /// Fixed row height; `None` divides the available height equally
133    row_height: Option<f32>,
134    /// Children widgets, one per cell
135    #[serde(skip)]
136    children: Vec<Box<dyn Widget>>,
137    /// Test ID
138    test_id_value: Option<String>,
139    /// Cached bounds
140    #[serde(skip)]
141    bounds: Rect,
142}
143
144impl Default for Grid {
145    fn default() -> Self {
146        Self::new(1)
147    }
148}
149
150impl Grid {
151    /// Create an empty grid with `columns` columns (0 is stored as 1).
152    #[must_use]
153    pub fn new(columns: usize) -> Self {
154        Self {
155            columns: columns.max(1),
156            gap: 0.0,
157            row_height: None,
158            children: Vec::new(),
159            test_id_value: None,
160            bounds: Rect::default(),
161        }
162    }
163
164    /// Set the number of columns (0 is stored as 1).
165    #[must_use]
166    pub fn columns(mut self, columns: usize) -> Self {
167        self.columns = columns.max(1);
168        self
169    }
170
171    /// Set the gap between cells. A gap that is not finite or is negative is
172    /// treated as 0.
173    #[must_use]
174    pub fn gap(mut self, gap: f32) -> Self {
175        self.gap = gap;
176        self
177    }
178
179    /// Set a fixed row height. A negative or non-finite height clears it, so
180    /// the rows share the available height equally.
181    #[must_use]
182    pub fn row_height(mut self, height: f32) -> Self {
183        self.row_height = (height.is_finite() && height >= 0.0).then(|| height.min(COORD_LIMIT));
184        self
185    }
186
187    /// Add a child widget; it takes the next free cell.
188    pub fn child(mut self, widget: impl Widget + 'static) -> Self {
189        self.children.push(Box::new(widget));
190        self
191    }
192
193    /// Set test ID.
194    #[must_use]
195    pub fn with_test_id(mut self, id: impl Into<String>) -> Self {
196        self.test_id_value = Some(id.into());
197        self
198    }
199
200    /// Number of columns.
201    #[must_use]
202    pub fn get_columns(&self) -> usize {
203        self.columns.max(1)
204    }
205
206    /// The gap in use (0 when the stored gap is not finite or is negative).
207    #[must_use]
208    pub fn get_gap(&self) -> f32 {
209        sane_gap(self.gap)
210    }
211
212    /// The fixed row height, if one is set.
213    #[must_use]
214    pub fn get_row_height(&self) -> Option<f32> {
215        self.row_height
216            .and_then(|h| (h.is_finite() && h >= 0.0).then(|| h.min(COORD_LIMIT)))
217    }
218
219    /// Number of rows needed for the current children.
220    #[must_use]
221    pub fn row_count(&self) -> usize {
222        self.children.len().div_ceil(self.columns.max(1))
223    }
224
225    /// The cell of each child for the given bounds.
226    #[must_use]
227    pub fn cells(&self, bounds: Rect) -> Vec<Rect> {
228        grid_cells_with(
229            bounds,
230            self.get_columns(),
231            self.gap,
232            self.get_row_height(),
233            self.children.len(),
234        )
235    }
236}
237
238impl Widget for Grid {
239    fn type_id(&self) -> TypeId {
240        TypeId::of::<Self>()
241    }
242
243    fn measure(&self, constraints: Constraints) -> Size {
244        if self.children.is_empty() {
245            return Size::ZERO;
246        }
247
248        let columns = self.columns.max(1);
249        let gap = self.get_gap();
250        let used_columns = columns.min(self.children.len());
251        let rows = self.row_count();
252
253        // Width offered to each child: the available width minus the gaps,
254        // split between the columns (unbounded stays unbounded).
255        let max_w = sane_upper(constraints.max_width);
256        let slot_width = if max_w.is_finite() {
257            ((max_w - gap * (columns - 1) as f32) / columns as f32).max(0.0)
258        } else {
259            f32::INFINITY
260        };
261        let child_constraints =
262            Constraints::new(0.0, slot_width, 0.0, sane_upper(constraints.max_height));
263
264        let mut cell_width = 0.0f32;
265        let mut cell_height = 0.0f32;
266        for child in &self.children {
267            let size = child.measure(child_constraints);
268            cell_width = cell_width.max(sane(size.width).max(0.0));
269            cell_height = cell_height.max(sane(size.height).max(0.0));
270        }
271        let cell_height = self.get_row_height().unwrap_or(cell_height);
272
273        // Products can overflow to infinity; saturate rather than collapse to 0.
274        let width = cell_width
275            .mul_add(used_columns as f32, gap * (used_columns - 1) as f32)
276            .min(COORD_LIMIT);
277        let height = cell_height
278            .mul_add(rows as f32, gap * (rows - 1) as f32)
279            .min(COORD_LIMIT);
280        // Not `Constraints::constrain`: its `f32::clamp` panics on NaN or
281        // inverted constraints.
282        Size::new(
283            fit(width, constraints.min_width, constraints.max_width),
284            fit(height, constraints.min_height, constraints.max_height),
285        )
286    }
287
288    fn layout(&mut self, bounds: Rect) -> LayoutResult {
289        self.bounds = bounds;
290        let cells = self.cells(bounds);
291        for (child, cell) in self.children.iter_mut().zip(cells) {
292            child.layout(cell);
293        }
294        LayoutResult {
295            size: Size::new(sane(bounds.width).max(0.0), sane(bounds.height).max(0.0)),
296        }
297    }
298
299    fn paint(&self, canvas: &mut dyn Canvas) {
300        for child in &self.children {
301            child.paint(canvas);
302        }
303    }
304
305    fn event(&mut self, event: &Event) -> Option<Box<dyn Any + Send>> {
306        for child in &mut self.children {
307            if let Some(msg) = child.event(event) {
308                return Some(msg);
309            }
310        }
311        None
312    }
313
314    fn children(&self) -> &[Box<dyn Widget>] {
315        &self.children
316    }
317
318    fn children_mut(&mut self) -> &mut [Box<dyn Widget>] {
319        &mut self.children
320    }
321
322    fn bounds(&self) -> Rect {
323        self.bounds
324    }
325
326    fn test_id(&self) -> Option<&str> {
327        self.test_id_value.as_deref()
328    }
329}
330
331// PROBAR-SPEC-009: Brick Architecture - Tests define interface
332impl Brick for Grid {
333    fn brick_name(&self) -> &'static str {
334        "Grid"
335    }
336
337    fn assertions(&self) -> &[BrickAssertion] {
338        &[BrickAssertion::MaxLatencyMs(16)]
339    }
340
341    fn budget(&self) -> BrickBudget {
342        BrickBudget::uniform(16)
343    }
344
345    fn verify(&self) -> BrickVerification {
346        let mut passed = self.assertions().to_vec();
347        let mut failed = Vec::new();
348
349        let b = self.bounds;
350        let valid = [b.x, b.y, b.width, b.height].iter().all(|v| v.is_finite())
351            && b.width >= 0.0
352            && b.height >= 0.0;
353        let assertion = BrickAssertion::ElementPresent("valid-bounds".to_string());
354        if valid {
355            passed.push(assertion);
356        } else {
357            failed.push((assertion, "Grid bounds are not finite and >= 0".to_string()));
358        }
359
360        BrickVerification {
361            passed,
362            failed,
363            verification_time: Duration::from_micros(10),
364        }
365    }
366
367    fn to_html(&self) -> String {
368        format!(
369            r#"<div class="brick-grid" style="grid-template-columns: repeat({}, 1fr);"></div>"#,
370            self.columns.max(1)
371        )
372    }
373
374    fn to_css(&self) -> String {
375        format!(
376            ".brick-grid {{ display: grid; gap: {}px; }}",
377            self.get_gap()
378        )
379    }
380
381    fn test_id(&self) -> Option<&str> {
382        self.test_id_value.as_deref()
383    }
384}
385
386#[cfg(test)]
387#[allow(clippy::unwrap_used, clippy::disallowed_methods)]
388mod tests {
389    use super::*;
390    use crate::modal::Modal;
391    use crate::video::{Video, VideoToggled};
392    use crate::Text;
393    use presentar_core::draw::DrawCommand;
394    use presentar_core::{MouseButton, Point, RecordingCanvas};
395    use proptest::prelude::*;
396
397    const EPS: f32 = 0.01;
398
399    fn tile(n: usize) -> Video {
400        Video::new(format!("tile-{n}.mp4")).accessible_name(format!("Tile {n}"))
401    }
402
403    fn grid_of(columns: usize, tiles: usize) -> Grid {
404        (0..tiles).fold(Grid::new(columns), |g, n| g.child(tile(n)))
405    }
406
407    fn right(r: &Rect) -> f32 {
408        r.x + r.width
409    }
410
411    fn bottom(r: &Rect) -> f32 {
412        r.y + r.height
413    }
414
415    fn inside(outer: &Rect, inner: &Rect) -> bool {
416        inner.x >= outer.x - EPS
417            && inner.y >= outer.y - EPS
418            && right(inner) <= right(outer) + EPS
419            && bottom(inner) <= bottom(outer) + EPS
420    }
421
422    fn overlap(a: &Rect, b: &Rect) -> bool {
423        let across = right(a).min(right(b)) - a.x.max(b.x);
424        let down = bottom(a).min(bottom(b)) - a.y.max(b.y);
425        across > EPS && down > EPS
426    }
427
428    fn all_finite(cells: &[Rect]) -> bool {
429        cells
430            .iter()
431            .all(|c| [c.x, c.y, c.width, c.height].iter().all(|v| v.is_finite()))
432    }
433
434    fn click(grid: &mut Grid, at: Point) -> Option<Box<dyn Any + Send>> {
435        grid.event(&Event::MouseDown {
436            position: at,
437            button: MouseButton::Left,
438        });
439        grid.event(&Event::MouseUp {
440            position: at,
441            button: MouseButton::Left,
442        })
443    }
444
445    fn paint_len(widget: &dyn Widget) -> usize {
446        let mut canvas = RecordingCanvas::new();
447        widget.paint(&mut canvas);
448        canvas.commands().len()
449    }
450
451    // ===== Cell arithmetic =====
452
453    #[test]
454    fn grid_cells_two_by_two_with_gap_has_exact_geometry() {
455        let cells = grid_cells(Rect::new(10.0, 20.0, 210.0, 110.0), 2, 10.0, 4);
456        assert_eq!(
457            cells,
458            vec![
459                Rect::new(10.0, 20.0, 100.0, 50.0),
460                Rect::new(120.0, 20.0, 100.0, 50.0),
461                Rect::new(10.0, 80.0, 100.0, 50.0),
462                Rect::new(120.0, 80.0, 100.0, 50.0),
463            ]
464        );
465    }
466
467    #[test]
468    fn grid_cells_empty_grid_has_no_cells() {
469        assert!(grid_cells(Rect::new(0.0, 0.0, 100.0, 100.0), 3, 5.0, 0).is_empty());
470    }
471
472    #[test]
473    fn grid_cells_one_column_stacks_cells() {
474        let cells = grid_cells(Rect::new(0.0, 0.0, 100.0, 90.0), 1, 0.0, 3);
475        assert_eq!(cells.len(), 3);
476        for (i, c) in cells.iter().enumerate() {
477            assert_eq!(c.x, 0.0);
478            assert_eq!(c.width, 100.0);
479            assert_eq!(c.height, 30.0);
480            assert_eq!(c.y, 30.0 * i as f32);
481        }
482    }
483
484    #[test]
485    fn grid_cells_more_columns_than_children_keeps_column_width() {
486        let cells = grid_cells(Rect::new(0.0, 0.0, 100.0, 40.0), 5, 0.0, 2);
487        assert_eq!(cells.len(), 2);
488        assert_eq!(cells[0], Rect::new(0.0, 0.0, 20.0, 40.0));
489        assert_eq!(cells[1], Rect::new(20.0, 0.0, 20.0, 40.0));
490    }
491
492    #[test]
493    fn grid_cells_zero_columns_counts_as_one() {
494        let bounds = Rect::new(0.0, 0.0, 60.0, 60.0);
495        assert_eq!(grid_cells(bounds, 0, 0.0, 3), grid_cells(bounds, 1, 0.0, 3));
496    }
497
498    #[test]
499    fn grid_cells_fixed_row_height_steps_by_height_plus_gap() {
500        let cells = grid_cells_with(Rect::new(5.0, 6.0, 100.0, 10.0), 2, 4.0, Some(30.0), 5);
501        assert_eq!(cells.len(), 5);
502        assert_eq!(cells[0], Rect::new(5.0, 6.0, 48.0, 30.0));
503        assert_eq!(cells[1], Rect::new(57.0, 6.0, 48.0, 30.0));
504        assert_eq!(cells[2], Rect::new(5.0, 40.0, 48.0, 30.0));
505        assert_eq!(cells[4], Rect::new(5.0, 74.0, 48.0, 30.0));
506    }
507
508    #[test]
509    fn grid_cells_oversized_gap_is_squeezed_to_fit() {
510        let bounds = Rect::new(0.0, 0.0, 100.0, 100.0);
511        let cells = grid_cells(bounds, 3, 1000.0, 9);
512        assert_eq!(cells.len(), 9);
513        for c in &cells {
514            assert!(inside(&bounds, c));
515            assert!(c.width >= 0.0 && c.height >= 0.0);
516        }
517    }
518
519    #[test]
520    fn grid_cells_bad_inputs_never_panic_or_go_non_finite() {
521        let nan = f32::NAN;
522        let inf = f32::INFINITY;
523        let cases = [
524            Rect::new(nan, nan, nan, nan),
525            Rect::new(inf, -inf, inf, -inf),
526            Rect::new(0.0, 0.0, -50.0, -50.0),
527            Rect::new(0.0, 0.0, 0.0, 0.0),
528            Rect::new(f32::MAX, f32::MAX, f32::MAX, f32::MAX),
529            Rect::new(f32::MIN, f32::MIN, f32::MAX, f32::MAX),
530        ];
531        for bounds in cases {
532            for gap in [nan, inf, -inf, -3.0, 0.0, 4.0, f32::MAX] {
533                for columns in [0usize, 1, 3, usize::MAX] {
534                    for row_height in [None, Some(nan), Some(-1.0), Some(f32::MAX), Some(9.0)] {
535                        let cells = grid_cells_with(bounds, columns, gap, row_height, 7);
536                        assert_eq!(cells.len(), 7);
537                        assert!(all_finite(&cells), "{bounds:?} {gap} {columns}");
538                        assert!(cells.iter().all(|c| c.width >= 0.0 && c.height >= 0.0));
539                    }
540                }
541            }
542        }
543    }
544
545    proptest! {
546        #[test]
547        fn grid_cells_well_formed_inputs_tile_the_bounds(
548            x in -1000.0f32..1000.0,
549            y in -1000.0f32..1000.0,
550            w in 0.0f32..2000.0,
551            h in 0.0f32..2000.0,
552            columns in 1usize..8,
553            gap in -10.0f32..200.0,
554            count in 0usize..40,
555        ) {
556            let bounds = Rect::new(x, y, w, h);
557            let cells = grid_cells(bounds, columns, gap, count);
558            prop_assert_eq!(cells.len(), count);
559            prop_assert!(all_finite(&cells));
560            for c in &cells {
561                prop_assert!(inside(&bounds, c), "{:?} outside {:?}", c, bounds);
562            }
563            for (i, a) in cells.iter().enumerate() {
564                prop_assert_eq!(a.width, cells[0].width);
565                let row_start = &cells[i - i % columns];
566                prop_assert_eq!(a.y, row_start.y);
567                prop_assert_eq!(a.height, row_start.height);
568                for b in &cells[i + 1..] {
569                    prop_assert!(!overlap(a, b), "{:?} overlaps {:?}", a, b);
570                }
571            }
572        }
573
574        #[test]
575        fn grid_cells_arbitrary_inputs_never_panic_or_go_non_finite(
576            x in prop_oneof![-1000.0f32..1000.0, Just(f32::NAN), Just(f32::INFINITY)],
577            y in prop_oneof![-1000.0f32..1000.0, Just(f32::NAN), Just(f32::NEG_INFINITY)],
578            w in prop_oneof![-500.0f32..2000.0, Just(0.0), Just(f32::NAN), Just(f32::INFINITY)],
579            h in prop_oneof![-500.0f32..2000.0, Just(0.0), Just(f32::NAN), Just(f32::INFINITY)],
580            columns in 0usize..8,
581            gap in prop_oneof![-50.0f32..200.0, Just(f32::NAN), Just(f32::INFINITY)],
582            count in 0usize..40,
583        ) {
584            let cells = grid_cells(Rect::new(x, y, w, h), columns, gap, count);
585            prop_assert_eq!(cells.len(), count);
586            prop_assert!(all_finite(&cells));
587            prop_assert!(cells.iter().all(|c| c.width >= 0.0 && c.height >= 0.0));
588        }
589    }
590
591    // ===== Construction =====
592
593    #[test]
594    fn grid_new_zero_columns_is_stored_as_one() {
595        assert_eq!(Grid::new(0).get_columns(), 1);
596        assert_eq!(Grid::new(4).columns(0).get_columns(), 1);
597        assert_eq!(Grid::default().get_columns(), 1);
598        assert_eq!(Grid::new(3).columns(5).get_columns(), 5);
599    }
600
601    #[test]
602    fn grid_builder_sets_gap_row_height_and_test_id() {
603        let g = Grid::new(2).gap(8.0).row_height(40.0).with_test_id("tiles");
604        assert_eq!(g.get_gap(), 8.0);
605        assert_eq!(g.get_row_height(), Some(40.0));
606        assert_eq!(Widget::test_id(&g), Some("tiles"));
607        assert_eq!(Brick::test_id(&g), Some("tiles"));
608        assert_eq!(Widget::type_id(&g), TypeId::of::<Grid>());
609    }
610
611    #[test]
612    fn grid_bad_gap_and_row_height_are_ignored() {
613        assert_eq!(Grid::new(2).gap(f32::NAN).get_gap(), 0.0);
614        assert_eq!(Grid::new(2).gap(-5.0).get_gap(), 0.0);
615        assert_eq!(Grid::new(2).row_height(-1.0).get_row_height(), None);
616        assert_eq!(Grid::new(2).row_height(f32::NAN).get_row_height(), None);
617        assert_eq!(Grid::new(2).row_height(0.0).get_row_height(), Some(0.0));
618    }
619
620    #[test]
621    fn grid_row_count_rounds_up() {
622        assert_eq!(grid_of(3, 0).row_count(), 0);
623        assert_eq!(grid_of(3, 1).row_count(), 1);
624        assert_eq!(grid_of(3, 3).row_count(), 1);
625        assert_eq!(grid_of(3, 4).row_count(), 2);
626    }
627
628    #[test]
629    fn grid_serde_keeps_settings_and_skips_children() {
630        let g = grid_of(3, 2).gap(6.0).row_height(20.0).with_test_id("g");
631        let json = serde_json::to_string(&g).unwrap();
632        let back: Grid = serde_json::from_str(&json).unwrap();
633        assert_eq!(back.get_columns(), 3);
634        assert_eq!(back.get_gap(), 6.0);
635        assert_eq!(back.get_row_height(), Some(20.0));
636        assert!(back.children().is_empty());
637    }
638
639    // ===== Layout =====
640
641    #[test]
642    fn grid_empty_and_degenerate_grids_lay_out_without_panic() {
643        let mut empty = Grid::new(3);
644        let result = empty.layout(Rect::new(0.0, 0.0, 100.0, 100.0));
645        assert_eq!(result.size, Size::new(100.0, 100.0));
646        assert!(empty.children().is_empty());
647
648        let mut one_column = grid_of(1, 3);
649        one_column.layout(Rect::new(0.0, 0.0, 90.0, 90.0));
650        assert_eq!(
651            one_column.children()[2].bounds(),
652            Rect::new(0.0, 60.0, 90.0, 30.0)
653        );
654
655        let mut wide = grid_of(10, 2);
656        wide.layout(Rect::new(0.0, 0.0, 100.0, 50.0));
657        assert_eq!(
658            wide.children()[1].bounds(),
659            Rect::new(10.0, 0.0, 10.0, 50.0)
660        );
661    }
662
663    #[test]
664    fn grid_layout_gives_each_child_its_cell() {
665        let bounds = Rect::new(10.0, 20.0, 210.0, 110.0);
666        let mut g = grid_of(2, 4).gap(10.0);
667        let result = g.layout(bounds);
668        assert_eq!(result.size, bounds.size());
669        assert_eq!(Widget::bounds(&g), bounds);
670        let cells = grid_cells(bounds, 2, 10.0, 4);
671        for (child, cell) in g.children().iter().zip(&cells) {
672            assert_eq!(child.bounds(), *cell);
673        }
674    }
675
676    #[test]
677    fn grid_relayout_replaces_cells() {
678        let mut g = grid_of(2, 2);
679        g.layout(Rect::new(0.0, 0.0, 100.0, 100.0));
680        g.layout(Rect::new(0.0, 0.0, 40.0, 20.0));
681        assert_eq!(g.children()[1].bounds(), Rect::new(20.0, 0.0, 20.0, 20.0));
682    }
683
684    #[test]
685    fn grid_children_mut_reaches_the_same_children() {
686        let mut g = grid_of(2, 3);
687        assert_eq!(g.children_mut().len(), 3);
688        assert_eq!(g.cells(Rect::new(0.0, 0.0, 10.0, 10.0)).len(), 3);
689    }
690
691    // ===== Measure =====
692
693    #[test]
694    fn grid_measure_empty_is_zero() {
695        let g = Grid::new(3);
696        assert_eq!(
697            g.measure(Constraints::loose(Size::new(100.0, 100.0))),
698            Size::ZERO
699        );
700    }
701
702    #[test]
703    fn grid_measure_sums_cells_and_gaps() {
704        // Videos prefer 320x180; plenty of room, 2 columns x 2 rows, gap 10.
705        let g = grid_of(2, 3).gap(10.0);
706        let size = g.measure(Constraints::loose(Size::new(2000.0, 2000.0)));
707        assert_eq!(size, Size::new(650.0, 370.0));
708    }
709
710    #[test]
711    fn grid_measure_shares_width_and_honours_row_height() {
712        let g = grid_of(2, 4).gap(10.0).row_height(50.0);
713        let size = g.measure(Constraints::loose(Size::new(210.0, 2000.0)));
714        // Each child is offered (210 - 10) / 2 = 100 wide; rows are 50 tall.
715        assert_eq!(size, Size::new(210.0, 110.0));
716    }
717
718    #[test]
719    fn grid_measure_with_unbounded_width_uses_preferred_sizes() {
720        let g = grid_of(2, 1);
721        let size = g.measure(Constraints::unbounded());
722        assert_eq!(size, Size::new(320.0, 180.0));
723    }
724
725    // ===== Paint =====
726
727    #[test]
728    fn grid_paint_draws_every_child() {
729        let mut g = grid_of(2, 3);
730        g.layout(Rect::new(0.0, 0.0, 200.0, 100.0));
731        // Each paused Video draws a backdrop rect and a glyph.
732        assert_eq!(paint_len(&g), 6);
733    }
734
735    // ===== Verify =====
736
737    #[test]
738    fn grid_verify_passes_with_valid_bounds() {
739        let mut g = grid_of(2, 2);
740        assert!(g.verify().failed.is_empty());
741        assert!(g.verify().is_valid());
742        g.layout(Rect::new(0.0, 0.0, 200.0, 100.0));
743        assert!(g.verify().is_valid());
744        assert!(g.assertions().contains(&BrickAssertion::MaxLatencyMs(16)));
745    }
746
747    #[test]
748    fn grid_verify_fails_with_invalid_bounds() {
749        for bad in [
750            Rect::new(f32::NAN, 0.0, 10.0, 10.0),
751            Rect::new(0.0, 0.0, -1.0, 10.0),
752            Rect::new(0.0, 0.0, 10.0, -1.0),
753            Rect::new(0.0, 0.0, f32::INFINITY, 10.0),
754        ] {
755            let mut g = Grid::new(2);
756            g.layout(bad);
757            let v = g.verify();
758            assert!(!v.is_valid(), "{bad:?}");
759            assert_eq!(v.failed.len(), 1);
760        }
761    }
762
763    #[test]
764    fn grid_brick_metadata() {
765        let g = Grid::new(3).gap(4.0);
766        assert_eq!(g.brick_name(), "Grid");
767        assert_eq!(g.budget(), BrickBudget::uniform(16));
768        assert!(g.to_html().contains("repeat(3, 1fr)"));
769        assert!(g.to_css().contains("display: grid"));
770        assert!(g.to_css().contains("4px"));
771    }
772
773    // ===== Events =====
774
775    #[test]
776    fn grid_click_in_second_cell_reaches_only_the_second_child() {
777        let mut g = grid_of(2, 2);
778        g.layout(Rect::new(0.0, 0.0, 200.0, 100.0));
779        let msg = click(&mut g, Point::new(150.0, 50.0));
780        let toggled = msg
781            .as_ref()
782            .and_then(|m| m.downcast_ref::<VideoToggled>())
783            .cloned();
784        assert_eq!(toggled, Some(VideoToggled { playing: true }));
785        // First tile still paused: backdrop + glyph. Second playing: frame only.
786        assert_eq!(paint_len(g.children()[0].as_ref()), 2);
787        assert_eq!(paint_len(g.children()[1].as_ref()), 1);
788    }
789
790    #[test]
791    fn grid_click_outside_every_cell_does_nothing() {
792        let mut g = grid_of(2, 2);
793        g.layout(Rect::new(0.0, 0.0, 200.0, 100.0));
794        assert!(click(&mut g, Point::new(500.0, 500.0)).is_none());
795        assert_eq!(paint_len(g.children()[0].as_ref()), 2);
796        assert_eq!(paint_len(g.children()[1].as_ref()), 2);
797    }
798
799    // ===== Composition =====
800
801    #[test]
802    fn grid_of_six_videos_inside_modal_paints_inside_content_bounds() {
803        let viewport = Rect::new(0.0, 0.0, 800.0, 600.0);
804
805        // Commands the modal paints on its own, without content.
806        let mut bare = Modal::new();
807        bare.show();
808        bare.layout(viewport);
809        let mut bare_canvas = RecordingCanvas::new();
810        bare.paint(&mut bare_canvas);
811        let own = bare_canvas.commands().len();
812
813        let mut modal = Modal::new().content(grid_of(3, 6).gap(8.0));
814        modal.show();
815        modal.layout(viewport);
816        let mut canvas = RecordingCanvas::new();
817        modal.paint(&mut canvas);
818        let content = modal.content_bounds();
819
820        let tiles = &canvas.commands()[own..];
821        // Six tiles, each a backdrop rect and a glyph path.
822        assert_eq!(tiles.len(), 12);
823        let rects: Vec<Rect> = tiles
824            .iter()
825            .filter_map(|c| match c {
826                DrawCommand::Rect { bounds, .. } => Some(*bounds),
827                _ => None,
828            })
829            .collect();
830        let points: Vec<Point> = tiles
831            .iter()
832            .filter_map(|c| match c {
833                DrawCommand::Path { points, .. } => Some(points.clone()),
834                _ => None,
835            })
836            .flatten()
837            .collect();
838        assert_eq!(rects.len(), 6);
839        assert_eq!(points.len(), 18);
840        for r in &rects {
841            assert!(inside(&content, r), "{r:?} outside {content:?}");
842        }
843        for p in &points {
844            assert!(inside(&content, &Rect::new(p.x, p.y, 0.0, 0.0)));
845        }
846        for (i, a) in rects.iter().enumerate() {
847            for b in &rects[i + 1..] {
848                assert!(!overlap(a, b));
849            }
850        }
851    }
852
853    // ----- measure under hostile constraints (review FAIL @08e8ce3) -----
854
855    fn finite_and_not_negative(size: Size) -> bool {
856        size.width.is_finite() && size.height.is_finite() && size.width >= 0.0 && size.height >= 0.0
857    }
858
859    #[test]
860    fn grid_measure_survives_inverted_constraints() {
861        // min > max: `f32::clamp` would panic here. The upper bound wins.
862        let size = grid_of(2, 1).measure(Constraints::new(10.0, 5.0, 8.0, 3.0));
863        assert!(finite_and_not_negative(size), "{size:?}");
864        assert!(size.width <= 5.0 + EPS, "{size:?}");
865        assert!(size.height <= 3.0 + EPS, "{size:?}");
866    }
867
868    #[test]
869    fn grid_measure_survives_non_finite_and_negative_constraints() {
870        let bad = [f32::NAN, f32::INFINITY, f32::NEG_INFINITY, f32::MAX, -1.0];
871        for &v in &bad {
872            for slot in 0..4 {
873                let mut c = [0.0, 300.0, 0.0, 300.0];
874                c[slot] = v;
875                let size = grid_of(3, 5).measure(Constraints::new(c[0], c[1], c[2], c[3]));
876                assert!(finite_and_not_negative(size), "slot {slot} = {v}: {size:?}");
877            }
878        }
879    }
880
881    proptest! {
882        #[test]
883        fn grid_measure_never_panics_and_stays_finite(
884            min_w in proptest::num::f32::ANY,
885            max_w in proptest::num::f32::ANY,
886            min_h in proptest::num::f32::ANY,
887            max_h in proptest::num::f32::ANY,
888            columns in 0usize..6,
889            tiles in 0usize..9,
890        ) {
891            let size = grid_of(columns, tiles).measure(Constraints::new(min_w, max_w, min_h, max_h));
892            prop_assert!(finite_and_not_negative(size), "{size:?}");
893        }
894    }
895
896    // ===== Class property: every public entry point, any f32 (PR #6897, round 3) =====
897    //
898    // Entry point                       covered by
899    // grid_cells                        class_grid_free_functions_are_total_and_sane
900    // grid_cells_with                   class_grid_free_functions_are_total_and_sane
901    // new / columns / gap / row_height / child / with_test_id
902    //                                   class_grid_builders_getters_and_cells_are_sane
903    // get_columns / get_gap / get_row_height / row_count / cells
904    //                                   class_grid_builders_getters_and_cells_are_sane
905    // measure                           class_grid_measure_is_finite_and_not_negative
906    // layout / bounds / verify          class_grid_layout_size_is_sane_and_verify_judges_raw_bounds
907    // paint                             class_grid_paint_is_sane_for_any_bounds
908    // event                             class_grid_event_is_total
909
910    /// Any f32, with the awkward ones over-represented.
911    fn wild() -> impl Strategy<Value = f32> {
912        prop_oneof![
913            proptest::num::f32::ANY,
914            Just(f32::NAN),
915            Just(f32::INFINITY),
916            Just(f32::NEG_INFINITY),
917            Just(f32::MAX),
918            Just(f32::MIN),
919            Just(f32::MIN_POSITIVE),
920            Just(1.0e-40_f32),
921            Just(-0.0_f32),
922            Just(-10.0_f32),
923            -1000.0_f32..1000.0,
924        ]
925    }
926
927    fn any_columns() -> impl Strategy<Value = usize> {
928        prop_oneof![0_usize..8, Just(usize::MAX), any::<usize>()]
929    }
930
931    fn finite_non_negative(v: f32) -> bool {
932        v.is_finite() && v >= 0.0
933    }
934
935    fn rect_sane(r: &Rect) -> bool {
936        r.x.is_finite()
937            && r.y.is_finite()
938            && finite_non_negative(r.width)
939            && finite_non_negative(r.height)
940    }
941
942    /// Every coordinate finite; every width, radius and blur finite and not negative.
943    fn command_widths_sane(cmd: &DrawCommand) -> bool {
944        match cmd {
945            DrawCommand::Rect {
946                bounds,
947                radius,
948                style,
949            } => {
950                rect_sane(bounds)
951                    && [
952                        radius.top_left,
953                        radius.top_right,
954                        radius.bottom_right,
955                        radius.bottom_left,
956                    ]
957                    .into_iter()
958                    .all(finite_non_negative)
959                    && style
960                        .stroke
961                        .as_ref()
962                        .is_none_or(|s| finite_non_negative(s.width))
963            }
964            DrawCommand::Path { points, style, .. } => {
965                finite_non_negative(style.width)
966                    && points.iter().all(|p| p.x.is_finite() && p.y.is_finite())
967            }
968            _ => false,
969        }
970    }
971
972    fn raw_bounds_valid(b: Rect) -> bool {
973        [b.x, b.y, b.width, b.height].iter().all(|v| v.is_finite())
974            && b.width >= 0.0
975            && b.height >= 0.0
976    }
977
978    fn bits(r: Rect) -> [u32; 4] {
979        [
980            r.x.to_bits(),
981            r.y.to_bits(),
982            r.width.to_bits(),
983            r.height.to_bits(),
984        ]
985    }
986
987    #[test]
988    fn reviewer_repro_grid_clause_8_upper_limits_mean_what_the_contract_says() {
989        let g = grid_of(2, 2);
990        let wide = |max_w: f32, max_h: f32| g.measure(Constraints::new(0.0, max_w, 0.0, max_h));
991        // NaN and +inf upper limits impose no limit: the same size as unbounded.
992        let unbounded = wide(f32::INFINITY, f32::INFINITY);
993        assert_eq!(wide(f32::NAN, f32::NAN), unbounded);
994        assert_eq!(unbounded, Size::new(640.0, 180.0));
995        // Negative and -inf upper limits count as 0.
996        for limit in [-1.0, -1.0e9, f32::NEG_INFINITY] {
997            assert_eq!(wide(limit, limit), Size::ZERO, "{limit}");
998        }
999    }
1000
1001    #[test]
1002    fn reviewer_repro_grid_layout_with_bad_bounds_returns_a_sane_size() {
1003        let mut g = grid_of(2, 2);
1004        let result = g.layout(Rect::new(f32::NAN, 0.0, -5.0, f32::INFINITY));
1005        assert_eq!(result.size, Size::ZERO);
1006        assert!(!g.verify().is_valid());
1007        let mut g = grid_of(2, 2);
1008        assert_eq!(
1009            g.layout(Rect::new(0.0, 0.0, 30.0, 20.0)).size,
1010            Size::new(30.0, 20.0)
1011        );
1012    }
1013
1014    #[test]
1015    fn class_grid_helpers_reject_what_they_should() {
1016        assert!(!command_widths_sane(&DrawCommand::filled_rect(
1017            Rect::new(0.0, 0.0, -1.0, 1.0),
1018            presentar_core::Color::WHITE
1019        )));
1020        assert!(!command_widths_sane(&DrawCommand::rounded_rect(
1021            Rect::new(0.0, 0.0, 1.0, 1.0),
1022            f32::NAN,
1023            presentar_core::Color::WHITE
1024        )));
1025        assert!(!raw_bounds_valid(Rect::new(f32::NAN, 0.0, 1.0, 1.0)));
1026    }
1027
1028    proptest! {
1029        #[test]
1030        fn class_grid_free_functions_are_total_and_sane(
1031            x in wild(),
1032            y in wild(),
1033            w in wild(),
1034            h in wild(),
1035            columns in any_columns(),
1036            gap in wild(),
1037            row_height in proptest::option::of(wild()),
1038            count in 0_usize..40,
1039        ) {
1040            let b = Rect::new(x, y, w, h);
1041            let plain = grid_cells(b, columns, gap, count);
1042            prop_assert_eq!(plain.len(), count);
1043            prop_assert!(plain.iter().all(rect_sane), "{plain:?}");
1044            let with = grid_cells_with(b, columns, gap, row_height, count);
1045            prop_assert_eq!(with.len(), count);
1046            prop_assert!(with.iter().all(rect_sane), "{with:?}");
1047        }
1048
1049        #[test]
1050        fn class_grid_builders_getters_and_cells_are_sane(
1051            columns in any_columns(),
1052            gap in wild(),
1053            row_height in wild(),
1054            tiles in 0_usize..9,
1055            x in wild(),
1056            y in wild(),
1057            w in wild(),
1058            h in wild(),
1059        ) {
1060            let g = grid_of(columns, tiles)
1061                .columns(columns)
1062                .gap(gap)
1063                .row_height(row_height)
1064                .with_test_id("g");
1065            prop_assert!(g.get_columns() >= 1);
1066            prop_assert!(finite_non_negative(g.get_gap()), "{}", g.get_gap());
1067            prop_assert!(g.get_row_height().is_none_or(finite_non_negative));
1068            prop_assert_eq!(g.row_count(), tiles.div_ceil(g.get_columns()));
1069            let css = g.to_css();
1070            prop_assert!(!css.contains("NaN") && !css.contains("inf"), "{css}");
1071            let cells = g.cells(Rect::new(x, y, w, h));
1072            prop_assert_eq!(cells.len(), tiles);
1073            prop_assert!(cells.iter().all(rect_sane), "{cells:?}");
1074        }
1075
1076        #[test]
1077        fn class_grid_measure_is_finite_and_not_negative(
1078            columns in any_columns(),
1079            gap in wild(),
1080            row_height in proptest::option::of(wild()),
1081            tiles in 0_usize..9,
1082            a in wild(),
1083            b in wild(),
1084            c in wild(),
1085            d in wild(),
1086        ) {
1087            let mut g = grid_of(columns, tiles).gap(gap);
1088            if let Some(height) = row_height {
1089                g = g.row_height(height);
1090            }
1091            let s = g.measure(Constraints::new(a, b, c, d));
1092            prop_assert!(finite_non_negative(s.width) && finite_non_negative(s.height), "{s:?}");
1093        }
1094
1095        #[test]
1096        fn class_grid_layout_size_is_sane_and_verify_judges_raw_bounds(
1097            columns in any_columns(),
1098            gap in wild(),
1099            tiles in 0_usize..9,
1100            x in wild(),
1101            y in wild(),
1102            w in wild(),
1103            h in wild(),
1104        ) {
1105            let bounds = Rect::new(x, y, w, h);
1106            let mut g = grid_of(columns, tiles).gap(gap);
1107            let result = g.layout(bounds);
1108            prop_assert!(
1109                finite_non_negative(result.size.width) && finite_non_negative(result.size.height),
1110                "{:?}", result.size
1111            );
1112            prop_assert_eq!(bits(Widget::bounds(&g)), bits(bounds));
1113            prop_assert_eq!(g.verify().is_valid(), raw_bounds_valid(bounds));
1114            let cells = g.cells(bounds);
1115            for (child, cell) in g.children().iter().zip(&cells) {
1116                prop_assert_eq!(bits(child.bounds()), bits(*cell));
1117                prop_assert!(rect_sane(&child.bounds()));
1118            }
1119        }
1120
1121        #[test]
1122        fn class_grid_paint_is_sane_for_any_bounds(
1123            columns in any_columns(),
1124            gap in wild(),
1125            row_height in proptest::option::of(wild()),
1126            tiles in 0_usize..9,
1127            x in wild(),
1128            y in wild(),
1129            w in wild(),
1130            h in wild(),
1131            playing in any::<bool>(),
1132        ) {
1133            let mut g = grid_of(columns, tiles).gap(gap);
1134            if let Some(height) = row_height {
1135                g = g.row_height(height);
1136            }
1137            g.layout(Rect::new(x, y, w, h));
1138            if playing {
1139                for child in g.children_mut() {
1140                    child.event(&Event::key_up(presentar_core::Key::Space));
1141                }
1142            }
1143            let mut canvas = RecordingCanvas::new();
1144            g.paint(&mut canvas);
1145            prop_assert!(canvas.commands().iter().all(command_widths_sane));
1146            prop_assert_eq!(canvas.commands().len(), tiles * if playing { 1 } else { 2 });
1147        }
1148
1149        #[test]
1150        fn class_grid_event_is_total(
1151            columns in any_columns(),
1152            tiles in 0_usize..9,
1153            w in wild(),
1154            h in wild(),
1155            px in wild(),
1156            py in wild(),
1157        ) {
1158            let mut g = grid_of(columns, tiles);
1159            g.layout(Rect::new(0.0, 0.0, w, h));
1160            let _ = click(&mut g, Point::new(px, py));
1161            prop_assert!(g.event(&Event::MouseLeave).is_none());
1162        }
1163    }
1164
1165    #[test]
1166    fn neg_inf_max_width_counts_as_zero() {
1167        let g = Grid::new(1).child(Text::new("hello").max_width(200.0));
1168        let c = |w| Constraints::new(0.0, w, 0.0, f32::INFINITY);
1169        assert_eq!(g.measure(c(f32::NEG_INFINITY)), g.measure(c(-1.0)));
1170        assert_eq!(
1171            g.measure(c(f32::NEG_INFINITY)).height,
1172            g.measure(c(0.0)).height
1173        );
1174    }
1175
1176    #[test]
1177    fn huge_row_height_saturates_not_collapses() {
1178        let one = Grid::new(1).row_height(f32::MAX).child(Video::new("a"));
1179        let two = Grid::new(1)
1180            .row_height(f32::MAX)
1181            .child(Video::new("a"))
1182            .child(Video::new("b"));
1183        let h1 = one.measure(Constraints::unbounded()).height;
1184        let h2 = two.measure(Constraints::unbounded()).height;
1185        assert!(h1 > 0.0 && h2 >= h1);
1186    }
1187
1188    #[test]
1189    fn deserialized_invariants_are_sanitized() {
1190        let g: Grid = serde_json::from_str(
1191            r#"{"columns":0,"gap":-5.0,"row_height":-10.0,"test_id_value":null}"#,
1192        )
1193        .unwrap();
1194        assert_eq!(g.get_columns(), 1);
1195        assert_eq!(g.get_row_height(), None);
1196    }
1197}