Skip to main content

teksilo_widgets/primitives/
masonry.rs

1// SPDX-License-Identifier: MPL-2.0
2// SPDX-FileCopyrightText: 2026 FernTech
3
4//! MasonryLayout — a variable-height grid that packs children into the
5//! shortest column (Pinterest-style).
6//!
7//! Each child is measured at the shared column width and placed into
8//! whichever column currently has the lowest accumulated height.
9//! Ties between equal-height columns are broken by column index
10//! (leftmost wins). All columns share the same width; column and item
11//! spacing are independently configurable. RTL layout mirrors the
12//! column order so the first logical child still goes to the leading edge.
13//!
14//! ```rust
15//! # use teksilo_widgets::primitives::masonry::MasonryLayout;
16//! # use teksilo_widgets::primitives::TextWidget;
17//! # use teksilo_i18n::lit;
18//! let _grid = MasonryLayout::new(3)
19//!     .column_spacing(8.0)
20//!     .item_spacing(8.0)
21//!     .child(TextWidget::new(lit!("Tall card")))
22//!     .child(TextWidget::new(lit!("Short card")))
23//!     .child(TextWidget::new(lit!("Another card")));
24//! ```
25
26use teksilo_canvas::{Point, Rect, Size, SizeProposal};
27use teksilo_core::accessibility::AccessNodeBuilder;
28use teksilo_core::widget::{LayoutContext, PaintContext, PendingChild, Widget, WidgetPlacement};
29use teksilo_core::widget_id::WidgetId;
30
31/// A masonry (Pinterest-style) layout that packs children into the shortest
32/// column.
33///
34/// Children are placed left-to-right into whichever column is currently
35/// shortest. All children receive the same column width; their heights are
36/// determined by each child's intrinsic size at that width.
37///
38/// ```text
39/// ┌──────┐ ┌──────┐ ┌──────┐
40/// │  A   │ │  B   │ │  C   │
41/// │      │ │      │ └──────┘
42/// │      │ └──────┘ ┌──────┐
43/// └──────┘ ┌──────┐ │  F   │
44/// ┌──────┐ │  E   │ │      │
45/// │  D   │ └──────┘ └──────┘
46/// └──────┘
47/// ```
48#[derive(Debug)]
49pub struct MasonryLayout {
50    column_count: usize,
51    column_spacing: f32,
52    item_spacing: f32,
53    child_ids: Vec<WidgetId>,
54    pending: Vec<PendingChild>,
55}
56
57impl MasonryLayout {
58    /// Create a masonry layout with the given number of columns.
59    ///
60    /// The count is clamped to a minimum of 1.
61    pub fn new(column_count: usize) -> Self {
62        Self {
63            column_count: column_count.max(1),
64            column_spacing: 0.0,
65            item_spacing: 0.0,
66            child_ids: Vec::new(),
67            pending: Vec::new(),
68        }
69    }
70
71    /// Horizontal gap between columns.
72    pub fn column_spacing(mut self, spacing: f32) -> Self {
73        self.column_spacing = spacing;
74        self
75    }
76
77    /// Vertical gap between items within the same column.
78    pub fn item_spacing(mut self, spacing: f32) -> Self {
79        self.item_spacing = spacing;
80        self
81    }
82
83    /// Add a pre-registered child by ID.
84    pub fn add_child(mut self, id: WidgetId) -> Self {
85        self.pending.push(PendingChild::Id(id));
86        self
87    }
88
89    /// Add an inline child widget (deferred insertion).
90    pub fn child(mut self, widget: impl Widget + 'static) -> Self {
91        self.pending.push(PendingChild::Deferred(Box::new(widget)));
92        self
93    }
94
95    /// Add multiple inline children from an iterator.
96    pub fn children(mut self, iter: impl IntoIterator<Item = impl Widget + 'static>) -> Self {
97        for widget in iter {
98            self.pending.push(PendingChild::Deferred(Box::new(widget)));
99        }
100        self
101    }
102
103    /// Conditionally add a child. No-op if `None`.
104    pub fn child_opt(mut self, widget: Option<impl Widget + 'static>) -> Self {
105        if let Some(w) = widget {
106            self.pending.push(PendingChild::Deferred(Box::new(w)));
107        }
108        self
109    }
110
111    /// Width of each column given the total available width.
112    fn column_width(&self, available_width: f32) -> f32 {
113        let gaps = self.column_spacing * (self.column_count as f32 - 1.0).max(0.0);
114        ((available_width - gaps) / self.column_count as f32).max(0.0)
115    }
116
117    /// Index of the shortest column (lowest accumulated height).
118    /// Ties are broken by lowest index (leftmost column first).
119    fn shortest_column(col_heights: &[f32]) -> usize {
120        col_heights
121            .iter()
122            .enumerate()
123            .min_by(|(_, a), (_, b)| a.partial_cmp(b).unwrap_or(std::cmp::Ordering::Equal))
124            .map(|(i, _)| i)
125            .unwrap_or(0)
126    }
127}
128
129impl Default for MasonryLayout {
130    fn default() -> Self {
131        Self::new(2)
132    }
133}
134
135impl Widget for MasonryLayout {
136    fn build(&mut self, ctx: &mut teksilo_core::build_context::BuildContext) -> Vec<WidgetId> {
137        let pending = std::mem::take(&mut self.pending);
138        if !pending.is_empty() {
139            self.child_ids = pending
140                .into_iter()
141                .map(|child| match child {
142                    PendingChild::Id(id) => id,
143                    PendingChild::Deferred(w) => ctx.add_boxed(w),
144                })
145                .collect();
146        }
147        self.child_ids.clone()
148    }
149
150    fn layout_response(
151        &self,
152        proposal: SizeProposal,
153        ctx: &LayoutContext,
154    ) -> teksilo_core::widget::LayoutResponse {
155        if self.child_ids.is_empty() {
156            return (proposal.resolve(0.0, 0.0)).into();
157        }
158
159        let (total_width, col_width) = if let Some(w) = proposal.width {
160            (w, self.column_width(w))
161        } else {
162            // Unbounded: use the widest child's intrinsic width as column width.
163            let mut max_w = 0.0_f32;
164            for &child_id in &self.child_ids {
165                if let Some(s) = ctx.child_size(child_id, SizeProposal::unspecified()) {
166                    max_w = max_w.max(s.width);
167                }
168            }
169            let gaps = self.column_spacing * (self.column_count as f32 - 1.0).max(0.0);
170            let total = max_w * self.column_count as f32 + gaps;
171            (total, max_w)
172        };
173
174        // Measure each child at column width, simulate placement.
175        let child_proposal = SizeProposal::with_width(col_width);
176        let mut col_heights = vec![0.0_f32; self.column_count];
177
178        for &child_id in &self.child_ids {
179            if let Some(child_size) = ctx.child_size(child_id, child_proposal) {
180                let col = Self::shortest_column(&col_heights);
181                if col_heights[col] > 0.0 {
182                    col_heights[col] += self.item_spacing;
183                }
184                col_heights[col] += child_size.height;
185            }
186        }
187
188        let total_height = col_heights.iter().copied().fold(0.0_f32, f32::max);
189        Size::new(total_width, total_height).into()
190    }
191
192    fn place_children(
193        &self,
194        bounds: Rect,
195        _proposal: SizeProposal,
196        children: &mut [WidgetPlacement],
197        ctx: &LayoutContext,
198    ) {
199        if children.is_empty() {
200            return;
201        }
202
203        let col_width = self.column_width(bounds.width);
204        let rtl = ctx.is_rtl();
205
206        // Column X origins (mirrored for RTL).
207        let col_x: Vec<f32> = (0..self.column_count)
208            .map(|i| {
209                let physical_col = if rtl { self.column_count - 1 - i } else { i };
210                bounds.x + physical_col as f32 * (col_width + self.column_spacing)
211            })
212            .collect();
213
214        let mut col_y = vec![bounds.y; self.column_count];
215
216        let child_proposal = SizeProposal::with_width(col_width);
217        for child in children.iter_mut() {
218            let child_size = ctx
219                .child_size(child.id, child_proposal)
220                .unwrap_or(Size::ZERO);
221
222            let col = Self::shortest_column(&col_y);
223
224            if col_y[col] > bounds.y {
225                col_y[col] += self.item_spacing;
226            }
227
228            child.origin = Point::new(col_x[col], col_y[col]);
229            child.size = Size::new(col_width, child_size.height);
230            col_y[col] += child_size.height;
231        }
232    }
233
234    fn paint(&self, _bounds: Rect, _canvas: &mut teksilo_canvas::Canvas, _ctx: &PaintContext) {}
235
236    fn accessibility(&self, builder: &mut AccessNodeBuilder) {
237        builder.set_role(teksilo_core::accesskit::Role::GenericContainer);
238    }
239
240    fn children(&self) -> Vec<WidgetId> {
241        self.child_ids.clone()
242    }
243}
244
245#[cfg(test)]
246mod tests {
247    use super::*;
248    use teksilo_core::widget_tree::WidgetTree;
249
250    #[derive(Debug)]
251    struct FixedLeaf(f32, f32);
252    impl Widget for FixedLeaf {
253        fn layout_response(
254            &self,
255            _proposal: SizeProposal,
256            _ctx: &LayoutContext,
257        ) -> teksilo_core::widget::LayoutResponse {
258            Size::new(self.0, self.1).into()
259        }
260    }
261
262    #[test]
263    fn equal_height_items_fill_columns_evenly() {
264        let mut tree = WidgetTree::new();
265        let items: Vec<_> = (0..6).map(|_| tree.add(FixedLeaf(50.0, 40.0))).collect();
266        let _m = tree.add(
267            MasonryLayout::new(3)
268                .add_child(items[0])
269                .add_child(items[1])
270                .add_child(items[2])
271                .add_child(items[3])
272                .add_child(items[4])
273                .add_child(items[5]),
274        );
275        // 3 columns in 300px: col_width = 100.0
276        tree.layout(SizeProposal::exact(300.0, 400.0));
277
278        // First row: items 0,1,2 → cols 0,1,2 at y=0
279        assert!((tree.bounds(items[0]).y - 0.0).abs() < 0.01);
280        assert!((tree.bounds(items[1]).y - 0.0).abs() < 0.01);
281        assert!((tree.bounds(items[2]).y - 0.0).abs() < 0.01);
282        // Second row: items 3,4,5 → cols 0,1,2 at y=40
283        assert!((tree.bounds(items[3]).y - 40.0).abs() < 0.01);
284        assert!((tree.bounds(items[4]).y - 40.0).abs() < 0.01);
285        assert!((tree.bounds(items[5]).y - 40.0).abs() < 0.01);
286    }
287
288    #[test]
289    fn variable_height_items_go_to_shortest_column() {
290        let mut tree = WidgetTree::new();
291        // Item 0 is tall, items 1-3 are short.
292        let a = tree.add(FixedLeaf(50.0, 100.0));
293        let b = tree.add(FixedLeaf(50.0, 30.0));
294        let c = tree.add(FixedLeaf(50.0, 30.0));
295        let d = tree.add(FixedLeaf(50.0, 20.0));
296        let _m = tree.add(
297            MasonryLayout::new(3)
298                .add_child(a)
299                .add_child(b)
300                .add_child(c)
301                .add_child(d),
302        );
303        tree.layout(SizeProposal::exact(300.0, 400.0));
304
305        // a → col 0 (all at 0), b → col 1, c → col 2
306        // Heights: [100, 30, 30]. d → col 1 (tied at 30, lowest index wins).
307        assert!((tree.bounds(d).x - 100.0).abs() < 0.01); // col 1 starts at 100
308        assert!((tree.bounds(d).y - 30.0).abs() < 0.01); // below b
309    }
310
311    #[test]
312    fn column_spacing_applied() {
313        let mut tree = WidgetTree::new();
314        let a = tree.add(FixedLeaf(50.0, 40.0));
315        let b = tree.add(FixedLeaf(50.0, 40.0));
316        let c = tree.add(FixedLeaf(50.0, 40.0));
317        let _m = tree.add(
318            MasonryLayout::new(3)
319                .column_spacing(10.0)
320                .add_child(a)
321                .add_child(b)
322                .add_child(c),
323        );
324        // 3 cols, spacing 10: col_width = (320 - 2*10) / 3 = 100
325        tree.layout(SizeProposal::exact(320.0, 200.0));
326
327        assert!((tree.bounds(a).x - 0.0).abs() < 0.01);
328        assert!((tree.bounds(b).x - 110.0).abs() < 0.01); // 100 + 10
329        assert!((tree.bounds(c).x - 220.0).abs() < 0.01); // 200 + 20
330    }
331
332    #[test]
333    fn item_spacing_applied() {
334        let mut tree = WidgetTree::new();
335        let a = tree.add(FixedLeaf(50.0, 40.0));
336        let b = tree.add(FixedLeaf(50.0, 50.0));
337        let c = tree.add(FixedLeaf(50.0, 20.0));
338        let d = tree.add(FixedLeaf(50.0, 20.0));
339        let _m = tree.add(
340            MasonryLayout::new(2)
341                .item_spacing(8.0)
342                .add_child(a)
343                .add_child(b)
344                .add_child(c)
345                .add_child(d),
346        );
347        tree.layout(SizeProposal::exact(200.0, 400.0));
348
349        // a → col 0 at y=0, b → col 1 at y=0
350        // Heights: [40, 50]. c → col 0 (shorter), y = 40 + 8 = 48
351        assert!((tree.bounds(c).y - 48.0).abs() < 0.01);
352        // Heights: [40+8+20=68, 50]. d → col 1 (shorter), y = 50 + 8 = 58
353        assert!((tree.bounds(d).y - 58.0).abs() < 0.01);
354    }
355
356    #[test]
357    fn intrinsic_height_is_tallest_column() {
358        let mut tree = WidgetTree::new();
359        let a = tree.add(FixedLeaf(50.0, 100.0));
360        let b = tree.add(FixedLeaf(50.0, 30.0));
361        let c = tree.add(FixedLeaf(50.0, 30.0));
362        let m = tree.add(MasonryLayout::new(2).add_child(a).add_child(b).add_child(c));
363        tree.layout(SizeProposal {
364            width: Some(200.0),
365            height: None,
366        });
367
368        // a → col 0 (height 100), b → col 1 (height 30), c → col 1 (height 60)
369        // Tallest column = col 0 at 100.
370        assert!((tree.bounds(m).height - 100.0).abs() < 0.01);
371    }
372
373    #[test]
374    fn single_child_goes_to_first_column() {
375        let mut tree = WidgetTree::new();
376        let a = tree.add(FixedLeaf(50.0, 40.0));
377        let _m = tree.add(MasonryLayout::new(3).add_child(a));
378        tree.layout(SizeProposal::exact(300.0, 200.0));
379
380        assert!((tree.bounds(a).x - 0.0).abs() < 0.01);
381        assert!((tree.bounds(a).y - 0.0).abs() < 0.01);
382    }
383
384    #[test]
385    fn empty_masonry_has_zero_size() {
386        let mut tree = WidgetTree::new();
387        let m = tree.add(MasonryLayout::new(3));
388        tree.layout(SizeProposal {
389            width: Some(300.0),
390            height: None,
391        });
392
393        assert!((tree.bounds(m).height - 0.0).abs() < 0.01);
394    }
395
396    #[test]
397    fn dormant_child_excluded_from_layout() {
398        let mut tree = WidgetTree::new();
399        let a = tree.add(FixedLeaf(50.0, 40.0));
400        let b = tree.add(FixedLeaf(50.0, 30.0));
401        let c = tree.add(FixedLeaf(50.0, 20.0));
402        let _m = tree.add(MasonryLayout::new(2).add_child(a).add_child(b).add_child(c));
403        tree.layout(SizeProposal::exact(200.0, 200.0));
404
405        // a → col 0, b → col 1, c → col 1 (shorter at 30 vs 40)
406        assert!((tree.bounds(c).x - 100.0).abs() < 0.01); // col 1
407
408        // Make b dormant: remaining are a and c
409        tree.set_dormant(b);
410        tree.layout(SizeProposal::exact(200.0, 200.0));
411
412        // a → col 0, c → col 1 (both start at 0)
413        assert!((tree.bounds(a).x - 0.0).abs() < 0.01);
414        assert!((tree.bounds(c).x - 100.0).abs() < 0.01);
415        assert!((tree.bounds(c).y - 0.0).abs() < 0.01);
416    }
417
418    #[test]
419    fn children_receive_column_width() {
420        let mut tree = WidgetTree::new();
421        let a = tree.add(FixedLeaf(50.0, 40.0));
422        let b = tree.add(FixedLeaf(50.0, 40.0));
423        let _m = tree.add(MasonryLayout::new(2).add_child(a).add_child(b));
424        // 2 cols in 200px: col_width = 100
425        tree.layout(SizeProposal::exact(200.0, 200.0));
426
427        // Placed width should be col_width (100), not intrinsic (50).
428        assert!((tree.bounds(a).width - 100.0).abs() < 0.01);
429        assert!((tree.bounds(b).width - 100.0).abs() < 0.01);
430    }
431
432    #[test]
433    fn fewer_children_than_columns() {
434        let mut tree = WidgetTree::new();
435        let a = tree.add(FixedLeaf(50.0, 40.0));
436        let b = tree.add(FixedLeaf(50.0, 30.0));
437        let _m = tree.add(MasonryLayout::new(4).add_child(a).add_child(b));
438        tree.layout(SizeProposal::exact(400.0, 200.0));
439
440        // 4 cols, col_width = 100. a → col 0, b → col 1
441        assert!((tree.bounds(a).x - 0.0).abs() < 0.01);
442        assert!((tree.bounds(b).x - 100.0).abs() < 0.01);
443    }
444
445    #[test]
446    fn rtl_mirrors_column_order() {
447        let mut tree = WidgetTree::new();
448        tree.set_layout_direction(teksilo_core::environment::LayoutDirection::RightToLeft);
449        let a = tree.add(FixedLeaf(50.0, 40.0));
450        let b = tree.add(FixedLeaf(50.0, 30.0));
451        let c = tree.add(FixedLeaf(50.0, 20.0));
452        let _m = tree.add(MasonryLayout::new(3).add_child(a).add_child(b).add_child(c));
453        // 3 cols in 300px: col_width = 100
454        tree.layout(SizeProposal::exact(300.0, 200.0));
455
456        // In RTL, logical col 0 maps to rightmost physical position.
457        // a → logical col 0 → physical x = 200
458        // b → logical col 1 → physical x = 100
459        // c → logical col 2 → physical x = 0
460        assert!((tree.bounds(a).x - 200.0).abs() < 0.01);
461        assert!((tree.bounds(b).x - 100.0).abs() < 0.01);
462        assert!((tree.bounds(c).x - 0.0).abs() < 0.01);
463    }
464
465    #[test]
466    fn unbounded_width_uses_intrinsic() {
467        let mut tree = WidgetTree::new();
468        let a = tree.add(FixedLeaf(80.0, 40.0));
469        let b = tree.add(FixedLeaf(60.0, 30.0));
470        let m = tree.add(MasonryLayout::new(3).add_child(a).add_child(b));
471        tree.layout(SizeProposal {
472            width: None,
473            height: Some(200.0),
474        });
475
476        // Max intrinsic width = 80. Total = 3 * 80 + 0 gaps = 240.
477        assert!((tree.bounds(m).width - 240.0).abs() < 0.01);
478    }
479}