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