Skip to main content

sequoia_layout/
masonry.rs

1//! Masonry (waterfall) column layout.
2//!
3//! A masonry container places equally sized columns side by side and appends
4//! every item to the currently shortest column. Unlike a grid, rows do not
5//! share a common height, so cards with varying content keep their natural
6//! size.
7
8use crate::container::Align;
9use crate::dock::sanitize_gap;
10
11/// Layout style for a masonry container.
12#[derive(Clone, Copy, Debug, PartialEq)]
13pub struct MasonryStyle {
14    /// Number of columns. Values below one are clamped to one.
15    pub columns: usize,
16    /// Gap between adjacent columns.
17    pub column_gap: f32,
18    /// Gap between adjacent items in the same column.
19    pub row_gap: f32,
20    /// Default horizontal alignment of items inside their column.
21    pub align: Align,
22}
23
24impl Default for MasonryStyle {
25    fn default() -> Self {
26        Self {
27            columns: 2,
28            column_gap: 0.0,
29            row_gap: 0.0,
30            align: Align::Stretch,
31        }
32    }
33}
34
35impl MasonryStyle {
36    /// Creates a style with the given number of columns.
37    pub fn new(columns: usize) -> Self {
38        Self {
39            columns: columns.max(1),
40            ..Self::default()
41        }
42    }
43
44    /// Sets the number of columns, clamped to at least one.
45    pub fn columns(mut self, columns: usize) -> Self {
46        self.columns = columns.max(1);
47        self
48    }
49
50    /// Sets both the column and row gap.
51    pub fn gap(mut self, gap: f32) -> Self {
52        let gap = sanitize_gap(gap);
53        self.column_gap = gap;
54        self.row_gap = gap;
55        self
56    }
57
58    /// Sets the gap between adjacent columns.
59    pub fn column_gap(mut self, gap: f32) -> Self {
60        self.column_gap = sanitize_gap(gap);
61        self
62    }
63
64    /// Sets the gap between adjacent items in the same column.
65    pub fn row_gap(mut self, gap: f32) -> Self {
66        self.row_gap = sanitize_gap(gap);
67        self
68    }
69
70    /// Sets the default horizontal alignment of items inside their column.
71    pub fn align_items(mut self, align: Align) -> Self {
72        self.align = align;
73        self
74    }
75}
76
77/// Placement of a single masonry item.
78#[derive(Clone, Copy, Debug, Default, PartialEq)]
79pub struct MasonryPlacement {
80    /// Zero-based column index.
81    pub column: usize,
82    /// Top offset relative to the content box.
83    pub top: f32,
84}
85
86/// Resolves the width of one column in a masonry container.
87///
88/// Returns zero when `available` is not finite; callers handling an unbounded
89/// axis derive the column width from measured content instead.
90pub fn masonry_column_width(available: f32, columns: usize, gap: f32) -> f32 {
91    let columns = columns.max(1);
92    let gap = sanitize_gap(gap);
93    if !available.is_finite() {
94        return 0.0;
95    }
96    let total_gap = gap * columns.saturating_sub(1) as f32;
97    ((available.max(0.0) - total_gap) / columns as f32).max(0.0)
98}
99
100/// Assigns item heights to columns and returns their placements.
101///
102/// Items are appended in order to the shortest column, with ties resolved in
103/// favour of the lowest column index. The returned height is the bottom edge of
104/// the tallest column. Invalid heights and gaps are treated as zero.
105pub fn masonry_assign(
106    heights: &[f32],
107    columns: usize,
108    row_gap: f32,
109) -> (Vec<MasonryPlacement>, f32) {
110    let columns = columns.max(1);
111    let row_gap = sanitize_gap(row_gap);
112    let mut bottoms = vec![0.0_f32; columns];
113    let mut counts = vec![0usize; columns];
114    let mut placements = Vec::with_capacity(heights.len());
115
116    for &raw_height in heights {
117        let height = if raw_height.is_finite() {
118            raw_height.max(0.0)
119        } else {
120            0.0
121        };
122        let mut column = 0usize;
123        for candidate in 1..columns {
124            if bottoms[candidate] < bottoms[column] {
125                column = candidate;
126            }
127        }
128        let top = if counts[column] == 0 {
129            0.0
130        } else {
131            bottoms[column] + row_gap
132        };
133        bottoms[column] = top + height;
134        counts[column] += 1;
135        placements.push(MasonryPlacement { column, top });
136    }
137
138    let height = bottoms.into_iter().fold(0.0_f32, f32::max);
139    (placements, height)
140}
141
142#[cfg(test)]
143mod tests {
144    use super::*;
145
146    fn approx(a: f32, b: f32) -> bool {
147        (a - b).abs() < 0.01
148    }
149
150    #[test]
151    fn column_width_accounts_for_gaps() {
152        assert!(approx(masonry_column_width(110.0, 2, 10.0), 50.0));
153        assert!(approx(masonry_column_width(100.0, 3, 10.0), 80.0 / 3.0));
154        assert_eq!(masonry_column_width(100.0, 0, 10.0), 100.0);
155    }
156
157    #[test]
158    fn column_width_is_zero_when_unbounded_or_too_small() {
159        assert_eq!(masonry_column_width(f32::INFINITY, 2, 10.0), 0.0);
160        assert_eq!(masonry_column_width(10.0, 3, 10.0), 0.0);
161    }
162
163    #[test]
164    fn items_fill_the_shortest_column_in_order() {
165        let (placements, height) = masonry_assign(&[100.0, 50.0, 80.0, 120.0, 30.0], 2, 10.0);
166        assert_eq!(
167            placements,
168            vec![
169                MasonryPlacement {
170                    column: 0,
171                    top: 0.0
172                },
173                MasonryPlacement {
174                    column: 1,
175                    top: 0.0
176                },
177                MasonryPlacement {
178                    column: 1,
179                    top: 60.0
180                },
181                MasonryPlacement {
182                    column: 0,
183                    top: 110.0
184                },
185                MasonryPlacement {
186                    column: 1,
187                    top: 150.0
188                },
189            ]
190        );
191        assert!(approx(height, 230.0));
192    }
193
194    #[test]
195    fn ties_use_the_lowest_column_index() {
196        let (placements, height) = masonry_assign(&[10.0, 10.0, 10.0, 10.0], 3, 0.0);
197        assert_eq!(
198            placements
199                .iter()
200                .map(|placement| placement.column)
201                .collect::<Vec<_>>(),
202            vec![0, 1, 2, 0]
203        );
204        assert!(approx(height, 20.0));
205    }
206
207    #[test]
208    fn first_item_in_each_column_has_no_leading_gap() {
209        let (placements, height) = masonry_assign(&[20.0, 20.0, 20.0], 2, 6.0);
210        assert_eq!(placements[0].top, 0.0);
211        assert_eq!(placements[1].top, 0.0);
212        assert_eq!(placements[2].top, 26.0);
213        assert!(approx(height, 46.0));
214    }
215
216    #[test]
217    fn invalid_values_are_sanitized() {
218        let (placements, height) = masonry_assign(&[f32::NAN, -5.0, 10.0], 0, f32::NAN);
219        assert_eq!(placements.len(), 3);
220        assert!(placements.iter().all(|placement| placement.column == 0));
221        assert_eq!(placements[0].top, 0.0);
222        assert_eq!(placements[1].top, 0.0);
223        assert_eq!(placements[2].top, 0.0);
224        assert!(approx(height, 10.0));
225    }
226
227    #[test]
228    fn empty_input_has_no_placements_or_height() {
229        let (placements, height) = masonry_assign(&[], 3, 8.0);
230        assert!(placements.is_empty());
231        assert_eq!(height, 0.0);
232    }
233
234    #[test]
235    fn style_builders_sanitize_values() {
236        let style = MasonryStyle::new(0)
237            .gap(f32::NAN)
238            .column_gap(-1.0)
239            .row_gap(4.0)
240            .columns(3);
241        assert_eq!(style.columns, 3);
242        assert_eq!(style.column_gap, 0.0);
243        assert_eq!(style.row_gap, 4.0);
244    }
245}