1use 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
17const COORD_LIMIT: f32 = 1.0e15;
20
21fn sane(value: f32) -> f32 {
23 if value.is_finite() {
24 value.clamp(-COORD_LIMIT, COORD_LIMIT)
25 } else {
26 0.0
27 }
28}
29
30fn fit(value: f32, min: f32, max: f32) -> f32 {
34 sane(value.max(min).min(max)).max(0.0)
35}
36
37fn sane_upper(max: f32) -> f32 {
40 if max.is_nan() {
41 f32::INFINITY
42 } else {
43 max.max(0.0)
44 }
45}
46
47fn sane_gap(gap: f32) -> f32 {
49 sane(gap).max(0.0)
50}
51
52fn 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#[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#[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#[derive(Serialize, Deserialize)]
127pub struct Grid {
128 columns: usize,
130 gap: f32,
132 row_height: Option<f32>,
134 #[serde(skip)]
136 children: Vec<Box<dyn Widget>>,
137 test_id_value: Option<String>,
139 #[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 #[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 #[must_use]
166 pub fn columns(mut self, columns: usize) -> Self {
167 self.columns = columns.max(1);
168 self
169 }
170
171 #[must_use]
174 pub fn gap(mut self, gap: f32) -> Self {
175 self.gap = gap;
176 self
177 }
178
179 #[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 pub fn child(mut self, widget: impl Widget + 'static) -> Self {
189 self.children.push(Box::new(widget));
190 self
191 }
192
193 #[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 #[must_use]
202 pub fn get_columns(&self) -> usize {
203 self.columns.max(1)
204 }
205
206 #[must_use]
208 pub fn get_gap(&self) -> f32 {
209 sane_gap(self.gap)
210 }
211
212 #[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 #[must_use]
221 pub fn row_count(&self) -> usize {
222 self.children.len().div_ceil(self.columns.max(1))
223 }
224
225 #[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 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 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 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
331impl 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 #[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 #[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 #[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 #[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 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 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 #[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 assert_eq!(paint_len(&g), 6);
733 }
734
735 #[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 #[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 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 #[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 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 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 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 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 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 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 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 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}