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::{Cell, RefCell};
18use std::rc::Rc;
19
20use teksilo_canvas::EdgeInsets;
21
22use super::columns::{ColumnGeometry, ColumnOptions, geometry_for, with_options};
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: Cell<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    /// Apply the column-count bounds and the leftover alignment; `sizing` is
150    /// the one this strategy was built from.
151    pub(crate) fn with_columns(self, sizing: GridSizing, options: ColumnOptions) -> Self {
152        self.columns
153            .set(with_options(self.columns.get(), sizing, options));
154        self
155    }
156
157    pub(crate) fn new(
158        sizing: GridSizing,
159        col_gap: f32,
160        row_gap: f32,
161        inset: EdgeInsets,
162        estimated: f32,
163        exact_height: Option<ExactHeightFn>,
164    ) -> Self {
165        let estimated = if estimated > 0.0 {
166            estimated
167        } else {
168            sizing.tile_height().max(1.0)
169        };
170        Self {
171            columns: Cell::new(geometry_for(sizing, col_gap, inset)),
172            exact_height,
173            placement: RefCell::new(Placement::new(
174                estimated,
175                row_gap.max(0.0),
176                inset.top,
177                inset.bottom,
178            )),
179            estimated,
180        }
181    }
182
183    fn reseed_exact(&self) {
184        let Some(ref ef) = self.exact_height else {
185            return;
186        };
187        let mut p = self.placement.borrow_mut();
188        let n = p.len();
189        for i in 0..n {
190            let h = ef(i);
191            p.set_height(i, h);
192        }
193    }
194
195    /// Keep the placement's item count / column count in sync.
196    fn sync(&self, item_count: usize, viewport_width: f32) {
197        let cols = self.columns.get().column_count(viewport_width).max(1);
198        {
199            let mut p = self.placement.borrow_mut();
200            p.set_count(item_count);
201            p.set_cols(cols);
202        }
203        if self.exact_height.is_some() {
204            self.reseed_exact();
205        }
206        self.placement.borrow_mut().rebuild();
207    }
208}
209
210impl GridLayoutStrategy for VirtualizedMasonry {
211    fn set_rtl(&self, rtl: bool) {
212        self.columns.set(self.columns.get().with_rtl(rtl));
213    }
214
215    // `index_at_point` intentionally keeps the trait's O(n) `tile_rect` scan
216    // default: unlike the row-major strategies, item order here isn't
217    // visually monotonic in `y` (each item drops into the currently-
218    // shortest column), so there's no closed-form inverse of `tile_rect` to
219    // exploit. Acceptable for the hundreds-to-low-thousands of items a
220    // waterfall gallery holds (see the module doc comment).
221
222    fn column_count(&self, viewport_width: f32) -> usize {
223        self.columns.get().column_count(viewport_width)
224    }
225
226    fn column_x(&self, col: usize, viewport_width: f32) -> (f32, f32) {
227        self.columns.get().column_x(col, viewport_width)
228    }
229
230    fn total_content_height(&self, item_count: usize, viewport_width: f32) -> f32 {
231        self.sync(item_count, viewport_width);
232        self.placement.borrow().total_height
233    }
234
235    fn visible_range(
236        &self,
237        scroll_y: f32,
238        viewport_height: f32,
239        viewport_width: f32,
240        item_count: usize,
241    ) -> VisibleTileRange {
242        self.sync(item_count, viewport_width);
243        if item_count == 0 {
244            return VisibleTileRange { start: 0, end: 0 };
245        }
246        let p = self.placement.borrow();
247        let cols = p.cols.max(1);
248        let top = scroll_y;
249        let bot = scroll_y + viewport_height;
250        // Items intersecting the viewport. Because column tops aren't strictly
251        // monotonic in index, scan for the min/max intersecting index and
252        // realize that contiguous span (a superset; the buffer absorbs slack).
253        let mut min_i = None;
254        let mut max_i = None;
255        for i in 0..item_count {
256            let t = p.top_of[i];
257            let b = t + p.heights[i];
258            if b >= top && t <= bot {
259                min_i.get_or_insert(i);
260                max_i = Some(i);
261            }
262        }
263        match (min_i, max_i) {
264            (Some(lo), Some(hi)) => {
265                let buf = BUFFER_ROWS * cols;
266                let start = lo.saturating_sub(buf);
267                let end = (hi + 1 + buf).min(item_count);
268                VisibleTileRange { start, end }
269            }
270            _ => VisibleTileRange { start: 0, end: 0 },
271        }
272    }
273
274    fn tile_rect(&self, index: usize, viewport_width: f32) -> TileRect {
275        self.placement.borrow_mut().rebuild();
276        let p = self.placement.borrow();
277        let col = p.col_of.get(index).copied().unwrap_or(0);
278        let (x, width) = self.columns.get().column_x(col, viewport_width);
279        let y = p.top_of.get(index).copied().unwrap_or(0.0);
280        let height = p.heights.get(index).copied().unwrap_or(self.estimated);
281        TileRect {
282            x,
283            y,
284            width,
285            height,
286        }
287    }
288
289    fn estimated_row_height(&self) -> f32 {
290        self.estimated
291    }
292
293    fn measures_tiles(&self) -> bool {
294        self.exact_height.is_none()
295    }
296
297    fn observe_measured(
298        &self,
299        measured: &[(usize, f32)],
300        _scroll_y: f32,
301        viewport_width: f32,
302    ) -> f32 {
303        if self.exact_height.is_some() {
304            return 0.0;
305        }
306        // Item count is set by the layout pass before this runs; just feed
307        // per-item heights. No scroll anchoring (items reflow across columns).
308        let _ = viewport_width;
309        let mut p = self.placement.borrow_mut();
310        for &(i, h) in measured {
311            p.set_height(i, h);
312        }
313        0.0
314    }
315
316    fn invalidate_rows(&self, item_range: std::ops::Range<usize>) {
317        let end = if item_range.end == usize::MAX {
318            self.placement.borrow().len()
319        } else {
320            item_range.end
321        };
322        self.placement
323            .borrow_mut()
324            .invalidate(item_range.start, end);
325    }
326
327    fn resize(&self, item_count: usize) {
328        self.placement.borrow_mut().set_count(item_count);
329        if self.exact_height.is_some() {
330            self.reseed_exact();
331        }
332    }
333}