Skip to main content

teksilo_widgets/grid_view/layout/
masonry.rs

1// SPDX-License-Identifier: MPL-2.0
2// SPDX-FileCopyrightText: 2026 FernTech
3
4//! Virtualized waterfall (Pinterest-style) grid strategy.
5//!
6//! Fixed/adaptive column count, per-item variable height, shortest-column
7//! placement: each item drops into the currently-shortest column, so columns
8//! stay balanced. Placement is index-order but depends on every prior item's
9//! height, so the placement map is rebuilt (O(n)) whenever a height changes —
10//! fine for the hundreds-to-low-thousands of items a waterfall gallery holds.
11//!
12//! Heights come from the same two paths as [`VariableRowGrid`](super::variable_row::VariableRowGrid):
13//! exact `item_height(index)` or auto-measure. Unlike the row grid, the
14//! waterfall does **not** scroll-anchor on late measurement (items reflow
15//! across columns); a good estimate keeps the typical top-down scroll smooth.
16
17use std::cell::RefCell;
18use std::rc::Rc;
19
20use teksilo_canvas::EdgeInsets;
21
22use super::columns::{ColumnGeometry, geometry_for};
23use super::strategy::{BUFFER_ROWS, GridLayoutStrategy, GridSizing, TileRect, VisibleTileRange};
24
25type ExactHeightFn = Rc<dyn Fn(usize) -> f32>;
26
27/// Per-item placement map for the waterfall layout.
28#[derive(Debug)]
29struct Placement {
30    heights: Vec<f32>,
31    measured: Vec<bool>,
32    col_of: Vec<usize>,
33    top_of: Vec<f32>,
34    total_height: f32,
35    cols: usize,
36    gap: f32,
37    inset_top: f32,
38    inset_bottom: f32,
39    estimated: f32,
40    dirty: bool,
41}
42
43impl Placement {
44    fn new(estimated: f32, gap: f32, inset_top: f32, inset_bottom: f32) -> Self {
45        Self {
46            heights: Vec::new(),
47            measured: Vec::new(),
48            col_of: Vec::new(),
49            top_of: Vec::new(),
50            total_height: 0.0,
51            cols: 1,
52            gap,
53            inset_top,
54            inset_bottom,
55            estimated,
56            dirty: true,
57        }
58    }
59
60    fn len(&self) -> usize {
61        self.heights.len()
62    }
63
64    fn set_count(&mut self, n: usize) {
65        if n != self.heights.len() {
66            self.heights.resize(n, self.estimated);
67            self.measured.resize(n, false);
68            self.dirty = true;
69        }
70    }
71
72    fn set_cols(&mut self, cols: usize) {
73        if cols != self.cols {
74            self.cols = cols.max(1);
75            self.dirty = true;
76        }
77    }
78
79    fn set_height(&mut self, i: usize, h: f32) {
80        if i < self.heights.len() && (self.heights[i] - h).abs() > 0.01 {
81            self.heights[i] = h;
82            self.measured[i] = true;
83            self.dirty = true;
84        } else if i < self.measured.len() {
85            self.measured[i] = true;
86        }
87    }
88
89    fn invalidate(&mut self, start: usize, end: usize) {
90        let end = end.min(self.heights.len());
91        for i in start..end {
92            self.heights[i] = self.estimated;
93            self.measured[i] = false;
94        }
95        if start < end {
96            self.dirty = true;
97        }
98    }
99
100    fn rebuild(&mut self) {
101        if !self.dirty {
102            return;
103        }
104        let n = self.heights.len();
105        self.col_of.resize(n, 0);
106        self.top_of.resize(n, 0.0);
107        let cols = self.cols.max(1);
108        let mut bottoms = vec![self.inset_top; cols];
109        for i in 0..n {
110            // Shortest column (lowest current bottom), leftmost on ties.
111            let c = bottoms
112                .iter()
113                .enumerate()
114                .min_by(|(_, a), (_, b)| a.partial_cmp(b).unwrap_or(std::cmp::Ordering::Equal))
115                .map(|(i, _)| i)
116                .unwrap_or(0);
117            self.col_of[i] = c;
118            self.top_of[i] = bottoms[c];
119            bottoms[c] += self.heights[i] + self.gap;
120        }
121        let max_bottom = bottoms.iter().cloned().fold(self.inset_top, f32::max);
122        self.total_height = if n == 0 {
123            0.0
124        } else {
125            (max_bottom - self.gap).max(self.inset_top) + self.inset_bottom
126        };
127        self.dirty = false;
128    }
129}
130
131/// A virtualized waterfall grid.
132pub struct VirtualizedMasonry {
133    columns: ColumnGeometry,
134    exact_height: Option<ExactHeightFn>,
135    placement: RefCell<Placement>,
136    estimated: f32,
137}
138
139impl std::fmt::Debug for VirtualizedMasonry {
140    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
141        f.debug_struct("VirtualizedMasonry")
142            .field("items", &self.placement.borrow().len())
143            .field("exact", &self.exact_height.is_some())
144            .finish()
145    }
146}
147
148impl VirtualizedMasonry {
149    pub(crate) fn new(
150        sizing: GridSizing,
151        col_gap: f32,
152        row_gap: f32,
153        inset: EdgeInsets,
154        estimated: f32,
155        exact_height: Option<ExactHeightFn>,
156    ) -> Self {
157        let estimated = if estimated > 0.0 {
158            estimated
159        } else {
160            sizing.tile_height().max(1.0)
161        };
162        Self {
163            columns: geometry_for(sizing, col_gap, inset),
164            exact_height,
165            placement: RefCell::new(Placement::new(
166                estimated,
167                row_gap.max(0.0),
168                inset.top,
169                inset.bottom,
170            )),
171            estimated,
172        }
173    }
174
175    fn reseed_exact(&self) {
176        let Some(ref ef) = self.exact_height else {
177            return;
178        };
179        let mut p = self.placement.borrow_mut();
180        let n = p.len();
181        for i in 0..n {
182            let h = ef(i);
183            p.set_height(i, h);
184        }
185    }
186
187    /// Keep the placement's item count / column count in sync.
188    fn sync(&self, item_count: usize, viewport_width: f32) {
189        let cols = self.columns.column_count(viewport_width).max(1);
190        {
191            let mut p = self.placement.borrow_mut();
192            p.set_count(item_count);
193            p.set_cols(cols);
194        }
195        if self.exact_height.is_some() {
196            self.reseed_exact();
197        }
198        self.placement.borrow_mut().rebuild();
199    }
200}
201
202impl GridLayoutStrategy for VirtualizedMasonry {
203    // `index_at_point` intentionally keeps the trait's O(n) `tile_rect` scan
204    // default: unlike the row-major strategies, item order here isn't
205    // visually monotonic in `y` (each item drops into the currently-
206    // shortest column), so there's no closed-form inverse of `tile_rect` to
207    // exploit. Acceptable for the hundreds-to-low-thousands of items a
208    // waterfall gallery holds (see the module doc comment).
209
210    fn column_count(&self, viewport_width: f32) -> usize {
211        self.columns.column_count(viewport_width)
212    }
213
214    fn column_x(&self, col: usize, viewport_width: f32) -> (f32, f32) {
215        self.columns.column_x(col, viewport_width)
216    }
217
218    fn total_content_height(&self, item_count: usize, viewport_width: f32) -> f32 {
219        self.sync(item_count, viewport_width);
220        self.placement.borrow().total_height
221    }
222
223    fn visible_range(
224        &self,
225        scroll_y: f32,
226        viewport_height: f32,
227        viewport_width: f32,
228        item_count: usize,
229    ) -> VisibleTileRange {
230        self.sync(item_count, viewport_width);
231        if item_count == 0 {
232            return VisibleTileRange { start: 0, end: 0 };
233        }
234        let p = self.placement.borrow();
235        let cols = p.cols.max(1);
236        let top = scroll_y;
237        let bot = scroll_y + viewport_height;
238        // Items intersecting the viewport. Because column tops aren't strictly
239        // monotonic in index, scan for the min/max intersecting index and
240        // realize that contiguous span (a superset; the buffer absorbs slack).
241        let mut min_i = None;
242        let mut max_i = None;
243        for i in 0..item_count {
244            let t = p.top_of[i];
245            let b = t + p.heights[i];
246            if b >= top && t <= bot {
247                min_i.get_or_insert(i);
248                max_i = Some(i);
249            }
250        }
251        match (min_i, max_i) {
252            (Some(lo), Some(hi)) => {
253                let buf = BUFFER_ROWS * cols;
254                let start = lo.saturating_sub(buf);
255                let end = (hi + 1 + buf).min(item_count);
256                VisibleTileRange { start, end }
257            }
258            _ => VisibleTileRange { start: 0, end: 0 },
259        }
260    }
261
262    fn tile_rect(&self, index: usize, viewport_width: f32) -> TileRect {
263        self.placement.borrow_mut().rebuild();
264        let p = self.placement.borrow();
265        let col = p.col_of.get(index).copied().unwrap_or(0);
266        let (x, width) = self.columns.column_x(col, viewport_width);
267        let y = p.top_of.get(index).copied().unwrap_or(0.0);
268        let height = p.heights.get(index).copied().unwrap_or(self.estimated);
269        TileRect {
270            x,
271            y,
272            width,
273            height,
274        }
275    }
276
277    fn estimated_row_height(&self) -> f32 {
278        self.estimated
279    }
280
281    fn measures_tiles(&self) -> bool {
282        self.exact_height.is_none()
283    }
284
285    fn observe_measured(
286        &self,
287        measured: &[(usize, f32)],
288        _scroll_y: f32,
289        viewport_width: f32,
290    ) -> f32 {
291        if self.exact_height.is_some() {
292            return 0.0;
293        }
294        // Item count is set by the layout pass before this runs; just feed
295        // per-item heights. No scroll anchoring (items reflow across columns).
296        let _ = viewport_width;
297        let mut p = self.placement.borrow_mut();
298        for &(i, h) in measured {
299            p.set_height(i, h);
300        }
301        0.0
302    }
303
304    fn invalidate_rows(&self, item_range: std::ops::Range<usize>) {
305        let end = if item_range.end == usize::MAX {
306            self.placement.borrow().len()
307        } else {
308            item_range.end
309        };
310        self.placement
311            .borrow_mut()
312            .invalidate(item_range.start, end);
313    }
314
315    fn resize(&self, item_count: usize) {
316        self.placement.borrow_mut().set_count(item_count);
317        if self.exact_height.is_some() {
318            self.reseed_exact();
319        }
320    }
321}