Skip to main content

waterui_layout/collections/
grid.rs

1//! A two-dimensional layout that arranges views in columns and rows.
2
3use alloc::{vec, vec::Vec};
4use core::num::NonZeroUsize;
5use nami::{Computed, SignalExt};
6use num_traits::ToPrimitive;
7use waterui_core::{
8    AnyView, Environment, IntoSignalF32, View, layout::LayoutInvalidationCallback, view::TupleViews,
9};
10
11use crate::{
12    Layout, PlacedSubview, Point, ProposalSize, Rect, Size, SubView, SubviewPlacement,
13    ViewDimensions,
14    container::FixedContainer,
15    stack::{Alignment, HorizontalAlignment, VerticalAlignment},
16};
17
18/// Cached measurement for a child during layout
19struct ChildMeasurement {
20    dimensions: ViewDimensions,
21    /// The proposal that produced `dimensions`; it is the proposal the child
22    /// is recursively placed with.
23    proposal: ProposalSize,
24}
25
26struct GridMeasurement {
27    measurements: Vec<ChildMeasurement>,
28    column_widths: Vec<f32>,
29    row_heights: Vec<f32>,
30    total_width: f32,
31    total_height: f32,
32}
33
34/// The core layout engine for a `Grid`.
35#[derive(Debug, Clone)]
36pub struct GridLayout {
37    columns: NonZeroUsize,
38    spacing: Computed<Size>,
39    alignment: Alignment,
40}
41
42impl GridLayout {
43    /// Creates a new `GridLayout` with the specified columns, spacing, and alignment.
44    #[must_use]
45    pub fn new(columns: NonZeroUsize, spacing: Size, alignment: Alignment) -> Self {
46        Self {
47            columns,
48            spacing: Computed::constant(spacing),
49            alignment,
50        }
51    }
52
53    fn measure_grid(
54        &self,
55        proposed_width: Option<f32>,
56        children: &[&dyn SubView],
57    ) -> GridMeasurement {
58        let num_columns = self.columns.get();
59        let num_rows = children.len().div_ceil(num_columns);
60        let spacing = nami::Signal::get(&self.spacing);
61
62        let constrained_width = proposed_width
63            .filter(|width| width.is_finite())
64            .map(|width| width.max(0.0));
65
66        let mut column_widths = constrained_width.map_or_else(
67            || vec![0.0; num_columns],
68            |width| {
69                let total_spacing = spacing.width * usize_to_f32(num_columns.saturating_sub(1));
70                let width_per_column =
71                    ((width - total_spacing) / usize_to_f32(num_columns)).max(0.0);
72                vec![width_per_column; num_columns]
73            },
74        );
75
76        if constrained_width.is_none() {
77            for (index, child) in children.iter().enumerate() {
78                let intrinsic_size = child.measure(ProposalSize::new(None, None)).size;
79                if intrinsic_size.width.is_finite() {
80                    let column_index = index % num_columns;
81                    column_widths[column_index] =
82                        column_widths[column_index].max(intrinsic_size.width.max(0.0));
83                }
84            }
85        }
86
87        let measurements: Vec<ChildMeasurement> = children
88            .iter()
89            .enumerate()
90            .map(|(index, child)| {
91                let column_index = index % num_columns;
92                let child_proposal = ProposalSize::new(Some(column_widths[column_index]), None);
93                ChildMeasurement {
94                    dimensions: child.measure(child_proposal),
95                    proposal: child_proposal,
96                }
97            })
98            .collect();
99
100        let row_heights: Vec<f32> = measurements
101            .chunks(num_columns)
102            .map(|row_children| {
103                row_children
104                    .iter()
105                    .map(|measurement| measurement.dimensions.size.height)
106                    .filter(|height| height.is_finite())
107                    .fold(0.0, f32::max)
108            })
109            .collect();
110
111        let total_height = row_heights.iter().sum::<f32>()
112            + spacing.height * usize_to_f32(num_rows.saturating_sub(1));
113
114        let total_width = constrained_width.map_or_else(
115            || {
116                let used_columns = children.len().min(num_columns);
117                let total_spacing = spacing.width * usize_to_f32(used_columns.saturating_sub(1));
118                column_widths.iter().take(used_columns).sum::<f32>() + total_spacing
119            },
120            |width| width.max(0.0),
121        );
122
123        GridMeasurement {
124            measurements,
125            column_widths,
126            row_heights,
127            total_width,
128            total_height,
129        }
130    }
131}
132
133#[allow(clippy::cast_precision_loss)]
134impl Layout for GridLayout {
135    fn size_that_fits(&self, proposal: ProposalSize, children: &[&dyn SubView]) -> Size {
136        if children.is_empty() {
137            return Size::zero();
138        }
139
140        let measurement = self.measure_grid(proposal.width, children);
141        Size::new(measurement.total_width, measurement.total_height)
142    }
143
144    fn place(
145        &self,
146        bounds: Rect,
147        proposal: ProposalSize,
148        children: &[&dyn SubView],
149    ) -> Vec<SubviewPlacement> {
150        if children.is_empty() {
151            return vec![];
152        }
153
154        let num_columns = self.columns.get();
155        let spacing = nami::Signal::get(&self.spacing);
156        let measurement = self.measure_grid(proposal.width, children);
157
158        let mut placements = Vec::with_capacity(children.len());
159        let mut cursor_y = bounds.y();
160
161        for (row_index, row_measurements) in
162            measurement.measurements.chunks(num_columns).enumerate()
163        {
164            let row_height = measurement
165                .row_heights
166                .get(row_index)
167                .copied()
168                .unwrap_or(0.0);
169            let mut cursor_x = bounds.x();
170
171            for (column_index, child_measurement) in row_measurements.iter().enumerate() {
172                let column_width = measurement
173                    .column_widths
174                    .get(column_index)
175                    .copied()
176                    .unwrap_or(0.0);
177                let cell_frame = Rect::new(
178                    Point::new(cursor_x, cursor_y),
179                    Size::new(column_width, row_height),
180                );
181
182                // Handle infinite dimensions
183                let child_width = if child_measurement.dimensions.size.width.is_infinite() {
184                    column_width
185                } else {
186                    child_measurement.dimensions.size.width
187                };
188
189                let child_height = if child_measurement.dimensions.size.height.is_infinite() {
190                    row_height
191                } else {
192                    child_measurement.dimensions.size.height
193                };
194
195                let child_size = Size::new(child_width, child_height);
196                let mut adjusted_dimensions = child_measurement.dimensions.clone();
197                adjusted_dimensions.size = child_size;
198
199                // Align the child within its cell
200                let horizontal = self.alignment.horizontal();
201                let target_x = if horizontal == HorizontalAlignment::Leading {
202                    0.0
203                } else if horizontal == HorizontalAlignment::Trailing {
204                    cell_frame.width()
205                } else if horizontal == HorizontalAlignment::Center {
206                    cell_frame.width() * 0.5
207                } else {
208                    adjusted_dimensions
209                        .horizontal(horizontal)
210                        .clamp(0.0, child_size.width)
211                };
212                let child_x = cell_frame.x() + target_x
213                    - adjusted_dimensions
214                        .horizontal(horizontal)
215                        .clamp(0.0, child_size.width);
216
217                let vertical = self.alignment.vertical();
218                let target_y = if vertical == VerticalAlignment::Top {
219                    0.0
220                } else if vertical == VerticalAlignment::Bottom {
221                    cell_frame.height()
222                } else if vertical == VerticalAlignment::Center {
223                    cell_frame.height() * 0.5
224                } else {
225                    adjusted_dimensions
226                        .vertical(vertical)
227                        .clamp(0.0, child_size.height)
228                };
229                let child_y = cell_frame.y() + target_y
230                    - adjusted_dimensions
231                        .vertical(vertical)
232                        .clamp(0.0, child_size.height);
233
234                placements.push(SubviewPlacement::new(
235                    Rect::new(Point::new(child_x, child_y), child_size),
236                    child_measurement.proposal,
237                ));
238
239                cursor_x += column_width + spacing.width;
240            }
241
242            cursor_y += row_height + spacing.height;
243        }
244
245        placements
246    }
247
248    fn explicit_horizontal(
249        &self,
250        alignment: HorizontalAlignment,
251        _bounds: Rect,
252        children: &[PlacedSubview<'_>],
253    ) -> Option<f32> {
254        if alignment == self.alignment.horizontal() {
255            return children
256                .iter()
257                .filter_map(|child| child.explicit_horizontal(alignment))
258                .min_by(f32::total_cmp);
259        }
260        None
261    }
262
263    fn explicit_vertical(
264        &self,
265        alignment: VerticalAlignment,
266        _bounds: Rect,
267        children: &[PlacedSubview<'_>],
268    ) -> Option<f32> {
269        if alignment == VerticalAlignment::LastBaseline {
270            return children
271                .iter()
272                .filter_map(|child| child.explicit_vertical(alignment))
273                .max_by(f32::total_cmp);
274        }
275        if alignment == VerticalAlignment::FirstBaseline || alignment == self.alignment.vertical() {
276            return children
277                .iter()
278                .filter_map(|child| child.explicit_vertical(alignment))
279                .min_by(f32::total_cmp);
280        }
281        None
282    }
283
284    fn watch_invalidation(
285        &self,
286        invalidate: LayoutInvalidationCallback,
287    ) -> Vec<nami::watcher::BoxWatcherGuard> {
288        vec![nami::Signal::watch(&self.spacing, move |_| invalidate())]
289    }
290}
291
292fn usize_to_f32(value: usize) -> f32 {
293    value
294        .to_f32()
295        .expect("GridLayout: index must be representable as f32")
296}
297
298//=============================================================================
299// 2. View DSL (Grid and GridRow)
300//=============================================================================
301
302/// A data-carrying struct that represents a single row in a `Grid`.
303/// It does not implement `View` itself; it is consumed by the `Grid`.
304#[derive(Debug)]
305pub struct GridRow {
306    pub(crate) contents: Vec<AnyView>,
307}
308
309impl GridRow {
310    /// Creates a new `GridRow` with the given contents.
311    pub fn new(contents: impl TupleViews) -> Self {
312        Self {
313            contents: contents.into_views(),
314        }
315    }
316}
317
318/// A view that arranges content in rows and columns.
319///
320/// ![Grid](https://raw.githubusercontent.com/water-rs/waterui/dev/docs/illustrations/grid.svg)
321///
322/// Use a `Grid` to create a two-dimensional layout. You define the number of columns,
323/// and the grid automatically arranges your content into rows.
324///
325/// ```rust
326/// # use waterui::layout::grid::{grid, row};
327/// # use waterui::prelude::*;
328/// # fn table() -> impl View {
329/// grid(2, [
330///     row((text("A1"), text("A2"))),
331///     row((text("B1"), text("B2"))),
332/// ])
333/// # }
334/// ```
335///
336/// Customize spacing and alignment:
337///
338/// ```rust
339/// # use waterui::layout::grid::{Grid, GridRow};
340/// # use waterui::prelude::*;
341/// # fn gallery(rows: Vec<GridRow>) -> impl View {
342/// Grid::new(3, rows)
343///     .spacing(16.0)
344///     .alignment(Alignment::Leading)
345/// # }
346/// ```
347///
348/// The grid sizes columns equally based on available width, and row heights
349/// are determined by the tallest item in each row.
350#[derive(Debug)]
351pub struct Grid {
352    layout: GridLayout,
353    rows: Vec<GridRow>,
354}
355
356impl Grid {
357    /// Creates a new Grid.
358    ///
359    /// - `columns`: The number of columns in the grid. Must be greater than 0.
360    /// - `rows`: A tuple of `GridRow` views.
361    ///
362    /// # Panics
363    ///
364    /// Panics if `columns` is 0.
365    pub fn new(columns: usize, rows: impl IntoIterator<Item = GridRow>) -> Self {
366        Self {
367            layout: GridLayout::new(
368                NonZeroUsize::new(columns).expect("Grid columns must be greater than 0"),
369                Size::new(8.0, 8.0), // Default spacing
370                Alignment::Center,   // Default alignment
371            ),
372            rows: rows.into_iter().collect(),
373        }
374    }
375
376    /// Sets the horizontal and vertical spacing for the grid.
377    ///
378    /// Accepts any numeric literal or signal of f32. Signal changes invalidate
379    /// only this grid's native layout.
380    #[must_use]
381    pub fn spacing(mut self, spacing: impl IntoSignalF32 + 'static) -> Self {
382        self.layout.spacing = spacing
383            .into_signal_f32()
384            .map(|spacing| Size::new(spacing, spacing))
385            .computed();
386        self
387    }
388
389    /// Sets the alignment for children within their cells. An [`Alignment`] or one of the
390    /// tokens (`Leading`, `TopTrailing`, `Center`, …).
391    #[must_use]
392    pub fn alignment(mut self, alignment: impl Into<Alignment>) -> Self {
393        self.layout.alignment = alignment.into();
394        self
395    }
396
397    crate::alignment::two_dimensional_alignment_methods!();
398}
399
400impl View for Grid {
401    fn body(self, _env: &Environment) -> impl View {
402        // Flatten the children from all GridRows into a single Vec<AnyView>.
403        // This is the list that the GridLayout engine will operate on.
404        let flattened_children = self
405            .rows
406            .into_iter()
407            .flat_map(|row| row.contents)
408            .collect::<Vec<AnyView>>();
409
410        FixedContainer::new(self.layout, flattened_children)
411    }
412
413    /// Resolves to `FixedContainer` over the same layout and the flattened
414    /// row contents; reports what that container would.
415    fn stretch_axis(&self) -> crate::StretchAxis {
416        let child_axes = self
417            .rows
418            .iter()
419            .flat_map(|row| row.contents.iter().map(View::stretch_axis))
420            .collect::<Vec<_>>();
421        self.layout.stretch_axis(&child_axes)
422    }
423}
424
425/// Creates a new grid with the specified number of columns and rows.
426///
427/// This is a convenience function that creates a `Grid` with default spacing and alignment.
428///
429/// # Panics
430///
431/// Panics if `columns` is 0.
432pub fn grid(columns: usize, rows: impl IntoIterator<Item = GridRow>) -> Grid {
433    Grid::new(columns, rows)
434}
435
436/// Creates a new grid row containing the specified views.
437///
438/// This is a convenience function for creating `GridRow` instances.
439pub fn row(contents: impl TupleViews) -> GridRow {
440    GridRow::new(contents)
441}
442
443#[cfg(test)]
444mod tests {
445    use super::*;
446    use crate::StretchAxis;
447    use core::num::NonZeroUsize;
448
449    struct MockSubView {
450        size: Size,
451    }
452
453    impl SubView for MockSubView {
454        fn measure(&self, _proposal: ProposalSize) -> ViewDimensions {
455            ViewDimensions::new(self.size)
456        }
457        fn stretch_axis(&self) -> StretchAxis {
458            StretchAxis::None
459        }
460        fn priority(&self) -> i32 {
461            0
462        }
463    }
464
465    #[test]
466    fn test_grid_size_2x2() {
467        let layout = GridLayout::new(
468            NonZeroUsize::new(2).unwrap(),
469            Size::new(10.0, 10.0),
470            Alignment::Center,
471        );
472
473        let mut child1 = MockSubView {
474            size: Size::new(50.0, 30.0),
475        };
476        let mut child2 = MockSubView {
477            size: Size::new(50.0, 40.0),
478        };
479        let mut child3 = MockSubView {
480            size: Size::new(50.0, 20.0),
481        };
482        let mut child4 = MockSubView {
483            size: Size::new(50.0, 50.0),
484        };
485
486        let children: Vec<&dyn SubView> = vec![&mut child1, &mut child2, &mut child3, &mut child4];
487
488        let size = layout.size_that_fits(ProposalSize::new(Some(200.0), None), &children);
489
490        // Width is parent-proposed
491        assert!((size.width - 200.0).abs() < f32::EPSILON);
492        // Height: row1 max(30, 40) + spacing + row2 max(20, 50) = 40 + 10 + 50 = 100
493        assert!((size.height - 100.0).abs() < f32::EPSILON);
494    }
495
496    #[test]
497    fn test_grid_placement_bounded_proposal() {
498        let layout = GridLayout::new(
499            NonZeroUsize::new(2).unwrap(),
500            Size::new(10.0, 10.0),
501            Alignment::TopLeading,
502        );
503
504        let mut child1 = MockSubView {
505            size: Size::new(40.0, 30.0),
506        };
507        let mut child2 = MockSubView {
508            size: Size::new(40.0, 30.0),
509        };
510
511        let children: Vec<&dyn SubView> = vec![&mut child1, &mut child2];
512
513        let bounds = Rect::new(Point::new(0.0, 0.0), Size::new(100.0, 100.0));
514        let proposal = ProposalSize::new(Some(bounds.width()), Some(bounds.height()));
515        let placements = layout.place(bounds, proposal, &children);
516
517        // Column width: (100 - 10) / 2 = 45
518        // Child 1 at (0, 0)
519        assert!((placements[0].frame.x() - 0.0).abs() < f32::EPSILON);
520        assert!((placements[0].frame.y() - 0.0).abs() < f32::EPSILON);
521
522        // Child 2 at (45 + 10, 0) = (55, 0)
523        assert!((placements[1].frame.x() - 55.0).abs() < f32::EPSILON);
524        assert!((placements[1].frame.y() - 0.0).abs() < f32::EPSILON);
525    }
526
527    #[test]
528    fn test_grid_intrinsic_width_when_unconstrained() {
529        let layout = GridLayout::new(
530            NonZeroUsize::new(2).unwrap(),
531            Size::new(10.0, 10.0),
532            Alignment::Center,
533        );
534
535        let mut child1 = MockSubView {
536            size: Size::new(30.0, 20.0),
537        };
538        let mut child2 = MockSubView {
539            size: Size::new(50.0, 25.0),
540        };
541        let mut child3 = MockSubView {
542            size: Size::new(40.0, 35.0),
543        };
544        let mut child4 = MockSubView {
545            size: Size::new(20.0, 30.0),
546        };
547
548        let children: Vec<&dyn SubView> = vec![&mut child1, &mut child2, &mut child3, &mut child4];
549
550        let size = layout.size_that_fits(ProposalSize::new(None, None), &children);
551
552        // Column 0 max width: max(30, 40) = 40
553        // Column 1 max width: max(50, 20) = 50
554        // Total width = 40 + 10 + 50 = 100
555        assert!((size.width - 100.0).abs() < f32::EPSILON);
556        // Row 0 max height = 25, row 1 max height = 35, plus one vertical spacing (10)
557        assert!((size.height - 70.0).abs() < f32::EPSILON);
558    }
559
560    #[test]
561    fn test_grid_placement_with_infinite_bounds_uses_intrinsic_width() {
562        let layout = GridLayout::new(
563            NonZeroUsize::new(2).unwrap(),
564            Size::new(10.0, 10.0),
565            Alignment::TopLeading,
566        );
567
568        let mut child1 = MockSubView {
569            size: Size::new(40.0, 20.0),
570        };
571        let mut child2 = MockSubView {
572            size: Size::new(20.0, 20.0),
573        };
574
575        let children: Vec<&dyn SubView> = vec![&mut child1, &mut child2];
576
577        let bounds = Rect::new(Point::new(5.0, 7.0), Size::new(f32::INFINITY, 100.0));
578        let proposal = ProposalSize::new(Some(f32::INFINITY), Some(100.0));
579        let placements = layout.place(bounds, proposal, &children);
580
581        assert_eq!(placements.len(), 2);
582        assert!((placements[0].frame.x() - 5.0).abs() < f32::EPSILON);
583        assert!((placements[0].frame.y() - 7.0).abs() < f32::EPSILON);
584        assert!((placements[0].frame.width() - 40.0).abs() < f32::EPSILON);
585        assert!((placements[1].frame.x() - 55.0).abs() < f32::EPSILON);
586        assert!((placements[1].frame.y() - 7.0).abs() < f32::EPSILON);
587        assert!((placements[1].frame.width() - 20.0).abs() < f32::EPSILON);
588    }
589}