waterui_layout/
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 waterui_core::{AnyView, Environment, View, view::TupleViews};
6
7use crate::{
8    Layout, Point, ProposalSize, Rect, Size, SubView,
9    container::FixedContainer,
10    stack::{Alignment, HorizontalAlignment, VerticalAlignment},
11};
12
13/// Cached measurement for a child during layout
14struct ChildMeasurement {
15    size: Size,
16}
17
18/// The core layout engine for a `Grid`.
19#[derive(Debug, Clone, PartialEq, PartialOrd)]
20pub struct GridLayout {
21    columns: NonZeroUsize,
22    spacing: Size, // (horizontal, vertical)
23    alignment: Alignment,
24}
25
26impl GridLayout {
27    /// Creates a new `GridLayout` with the specified columns, spacing, and alignment.
28    #[must_use]
29    pub const fn new(columns: NonZeroUsize, spacing: Size, alignment: Alignment) -> Self {
30        Self {
31            columns,
32            spacing,
33            alignment,
34        }
35    }
36}
37
38#[allow(clippy::cast_precision_loss)]
39impl Layout for GridLayout {
40    fn size_that_fits(&self, proposal: ProposalSize, children: &[&dyn SubView]) -> Size {
41        if children.is_empty() {
42            return Size::zero();
43        }
44
45        let num_columns = self.columns.get();
46        let num_rows = children.len().div_ceil(num_columns);
47
48        // Calculate the width available for each column.
49        // A Grid requires a defined width from its parent to function correctly.
50        let child_width = proposal.width.map(|w| {
51            let total_spacing = self.spacing.width * (num_columns - 1) as f32;
52            ((w - total_spacing) / num_columns as f32).max(0.0)
53        });
54
55        // Grids are vertically unconstrained during the proposal phase.
56        // Each child is asked for its ideal height given the calculated column width.
57        let child_proposal = ProposalSize::new(child_width, None);
58
59        let measurements: Vec<ChildMeasurement> = children
60            .iter()
61            .map(|child| ChildMeasurement {
62                size: child.size_that_fits(child_proposal),
63            })
64            .collect();
65
66        // The grid's height is the sum of the tallest item in each row, plus vertical spacing.
67        let mut total_height = 0.0;
68        for row_children in measurements.chunks(num_columns) {
69            let row_height = row_children
70                .iter()
71                .map(|m| m.size.height)
72                .filter(|h| h.is_finite())
73                .fold(0.0, f32::max);
74            total_height += row_height;
75        }
76
77        total_height += self.spacing.height * (num_rows.saturating_sub(1) as f32);
78
79        // A Grid's width is defined by its parent. If not, it has no intrinsic width.
80        let final_width = proposal.width.unwrap_or(0.0);
81
82        Size::new(final_width, total_height)
83    }
84
85    fn place(&self, bounds: Rect, children: &[&dyn SubView]) -> Vec<Rect> {
86        if children.is_empty() || !bounds.width().is_finite() {
87            // A grid cannot be placed in an infinitely wide space. Return zero-rects.
88            return vec![Rect::new(Point::zero(), Size::zero()); children.len()];
89        }
90
91        let num_columns = self.columns.get();
92
93        // Calculate column width
94        let total_h_spacing = self.spacing.width * (num_columns - 1) as f32;
95        let column_width = ((bounds.width() - total_h_spacing) / num_columns as f32).max(0.0);
96
97        // Measure all children with the column width constraint
98        let child_proposal = ProposalSize::new(Some(column_width), None);
99
100        let measurements: Vec<ChildMeasurement> = children
101            .iter()
102            .map(|child| ChildMeasurement {
103                size: child.size_that_fits(child_proposal),
104            })
105            .collect();
106
107        // Pre-calculate the height of each row by finding the tallest child in that row.
108        let row_heights: Vec<f32> = measurements
109            .chunks(num_columns)
110            .map(|row_children| {
111                row_children
112                    .iter()
113                    .map(|m| m.size.height)
114                    .filter(|h| h.is_finite())
115                    .fold(0.0, f32::max)
116            })
117            .collect();
118
119        let mut placements = Vec::with_capacity(children.len());
120        let mut cursor_y = bounds.y();
121
122        for (row_index, row_measurements) in measurements.chunks(num_columns).enumerate() {
123            let row_height = row_heights.get(row_index).copied().unwrap_or(0.0);
124            let mut cursor_x = bounds.x();
125
126            for measurement in row_measurements {
127                let cell_frame = Rect::new(
128                    Point::new(cursor_x, cursor_y),
129                    Size::new(column_width, row_height),
130                );
131
132                // Handle infinite dimensions
133                let child_width = if measurement.size.width.is_infinite() {
134                    column_width
135                } else {
136                    measurement.size.width
137                };
138
139                let child_height = if measurement.size.height.is_infinite() {
140                    row_height
141                } else {
142                    measurement.size.height
143                };
144
145                let child_size = Size::new(child_width, child_height);
146
147                // Align the child within its cell
148                let child_x = match self.alignment.horizontal() {
149                    HorizontalAlignment::Leading => cell_frame.x(),
150                    HorizontalAlignment::Center => {
151                        cell_frame.x() + (cell_frame.width() - child_size.width) / 2.0
152                    }
153                    HorizontalAlignment::Trailing => cell_frame.max_x() - child_size.width,
154                };
155
156                let child_y = match self.alignment.vertical() {
157                    VerticalAlignment::Top => cell_frame.y(),
158                    VerticalAlignment::Center => {
159                        cell_frame.y() + (cell_frame.height() - child_size.height) / 2.0
160                    }
161                    VerticalAlignment::Bottom => cell_frame.max_y() - child_size.height,
162                };
163
164                placements.push(Rect::new(Point::new(child_x, child_y), child_size));
165
166                cursor_x += column_width + self.spacing.width;
167            }
168
169            cursor_y += row_height + self.spacing.height;
170        }
171
172        placements
173    }
174}
175
176//=============================================================================
177// 2. View DSL (Grid and GridRow)
178//=============================================================================
179
180/// A data-carrying struct that represents a single row in a `Grid`.
181/// It does not implement `View` itself; it is consumed by the `Grid`.
182#[derive(Debug)]
183pub struct GridRow {
184    pub(crate) contents: Vec<AnyView>,
185}
186
187impl GridRow {
188    /// Creates a new `GridRow` with the given contents.
189    pub fn new(contents: impl TupleViews) -> Self {
190        Self {
191            contents: contents.into_views(),
192        }
193    }
194}
195
196/// A view that arranges content in rows and columns.
197///
198/// ![Grid](https://raw.githubusercontent.com/water-rs/waterui/dev/docs/illustrations/grid.svg)
199///
200/// Use a `Grid` to create a two-dimensional layout. You define the number of columns,
201/// and the grid automatically arranges your content into rows.
202///
203/// ```ignore
204/// grid(2, [
205///     row((text("A1"), text("A2"))),
206///     row((text("B1"), text("B2"))),
207/// ])
208/// ```
209///
210/// Customize spacing and alignment:
211///
212/// ```ignore
213/// Grid::new(3, rows)
214///     .spacing(16.0)
215///     .alignment(Alignment::Leading)
216/// ```
217///
218/// The grid sizes columns equally based on available width, and row heights
219/// are determined by the tallest item in each row.
220#[derive(Debug)]
221pub struct Grid {
222    layout: GridLayout,
223    rows: Vec<GridRow>,
224}
225
226impl Grid {
227    /// Creates a new Grid.
228    ///
229    /// - `columns`: The number of columns in the grid. Must be greater than 0.
230    /// - `rows`: A tuple of `GridRow` views.
231    ///
232    /// # Panics
233    ///
234    /// Panics if `columns` is 0.
235    pub fn new(columns: usize, rows: impl IntoIterator<Item = GridRow>) -> Self {
236        Self {
237            layout: GridLayout::new(
238                NonZeroUsize::new(columns).expect("Grid columns must be greater than 0"),
239                Size::new(8.0, 8.0), // Default spacing
240                Alignment::Center,   // Default alignment
241            ),
242            rows: rows.into_iter().collect(),
243        }
244    }
245
246    /// Sets the horizontal and vertical spacing for the grid.
247    #[must_use]
248    pub const fn spacing(mut self, spacing: f32) -> Self {
249        self.layout.spacing = Size::new(spacing, spacing);
250        self
251    }
252
253    /// Sets the alignment for children within their cells.
254    #[must_use]
255    pub const fn alignment(mut self, alignment: Alignment) -> Self {
256        self.layout.alignment = alignment;
257        self
258    }
259}
260
261impl View for Grid {
262    fn body(self, _env: &Environment) -> impl View {
263        // Flatten the children from all GridRows into a single Vec<AnyView>.
264        // This is the list that the GridLayout engine will operate on.
265        let flattened_children = self
266            .rows
267            .into_iter()
268            .flat_map(|row| row.contents)
269            .collect::<Vec<AnyView>>();
270
271        FixedContainer::new(self.layout, flattened_children)
272    }
273}
274
275/// Creates a new grid with the specified number of columns and rows.
276///
277/// This is a convenience function that creates a `Grid` with default spacing and alignment.
278///
279/// # Panics
280///
281/// Panics if `columns` is 0.
282pub fn grid(columns: usize, rows: impl IntoIterator<Item = GridRow>) -> Grid {
283    Grid::new(columns, rows)
284}
285
286/// Creates a new grid row containing the specified views.
287///
288/// This is a convenience function for creating `GridRow` instances.
289pub fn row(contents: impl TupleViews) -> GridRow {
290    GridRow::new(contents)
291}
292
293#[cfg(test)]
294mod tests {
295    use super::*;
296    use crate::StretchAxis;
297    use core::num::NonZeroUsize;
298
299    struct MockSubView {
300        size: Size,
301    }
302
303    impl SubView for MockSubView {
304        fn size_that_fits(&self, _proposal: ProposalSize) -> Size {
305            self.size
306        }
307        fn stretch_axis(&self) -> StretchAxis {
308            StretchAxis::None
309        }
310        fn priority(&self) -> i32 {
311            0
312        }
313    }
314
315    #[test]
316    fn test_grid_size_2x2() {
317        let layout = GridLayout::new(
318            NonZeroUsize::new(2).unwrap(),
319            Size::new(10.0, 10.0),
320            Alignment::Center,
321        );
322
323        let mut child1 = MockSubView {
324            size: Size::new(50.0, 30.0),
325        };
326        let mut child2 = MockSubView {
327            size: Size::new(50.0, 40.0),
328        };
329        let mut child3 = MockSubView {
330            size: Size::new(50.0, 20.0),
331        };
332        let mut child4 = MockSubView {
333            size: Size::new(50.0, 50.0),
334        };
335
336        let children: Vec<&dyn SubView> = vec![&mut child1, &mut child2, &mut child3, &mut child4];
337
338        let size = layout.size_that_fits(ProposalSize::new(Some(200.0), None), &children);
339
340        // Width is parent-proposed
341        assert!((size.width - 200.0).abs() < f32::EPSILON);
342        // Height: row1 max(30, 40) + spacing + row2 max(20, 50) = 40 + 10 + 50 = 100
343        assert!((size.height - 100.0).abs() < f32::EPSILON);
344    }
345
346    #[test]
347    fn test_grid_placement() {
348        let layout = GridLayout::new(
349            NonZeroUsize::new(2).unwrap(),
350            Size::new(10.0, 10.0),
351            Alignment::TopLeading,
352        );
353
354        let mut child1 = MockSubView {
355            size: Size::new(40.0, 30.0),
356        };
357        let mut child2 = MockSubView {
358            size: Size::new(40.0, 30.0),
359        };
360
361        let children: Vec<&dyn SubView> = vec![&mut child1, &mut child2];
362
363        let bounds = Rect::new(Point::new(0.0, 0.0), Size::new(100.0, 100.0));
364        let rects = layout.place(bounds, &children);
365
366        // Column width: (100 - 10) / 2 = 45
367        // Child 1 at (0, 0)
368        assert!((rects[0].x() - 0.0).abs() < f32::EPSILON);
369        assert!((rects[0].y() - 0.0).abs() < f32::EPSILON);
370
371        // Child 2 at (45 + 10, 0) = (55, 0)
372        assert!((rects[1].x() - 55.0).abs() < f32::EPSILON);
373        assert!((rects[1].y() - 0.0).abs() < f32::EPSILON);
374    }
375}