Skip to main content

teksilo_widgets/grid_view/layout/
variable_row.rs

1// SPDX-License-Identifier: MPL-2.0
2// SPDX-FileCopyrightText: 2026 FernTech
3
4//! Variable-row-height grid: each row is sized to its tallest tile.
5//!
6//! Columns are uniform (same policy as [`UniformGrid`](super::uniform::UniformGrid)),
7//! but every row takes the height of its tallest tile — the SwiftUI
8//! `LazyVGrid` model. Because off-screen tiles aren't built, heights are
9//! learned one of two ways:
10//!
11//! * **Auto-measure** (default): the body pane measures each realized tile
12//!   and feeds the heights back via [`observe_measured`]; unmeasured rows
13//!   use an estimate and the scroll position is anchored when an estimate is
14//!   corrected (see the anchor-delta return value).
15//! * **Exact** (`item_height(index)` supplied): row heights are computed
16//!   exactly as `max(item_height(i))` over the row — no measurement, no
17//!   anchoring, an exact scrollbar.
18//!
19//! [`observe_measured`]: super::strategy::GridLayoutStrategy::observe_measured
20
21use std::cell::{Cell, RefCell};
22use std::collections::HashMap;
23use std::rc::Rc;
24
25use teksilo_canvas::{EdgeInsets, Point};
26
27use super::columns::{ColumnGeometry, column_at, geometry_for};
28use super::offsets::PrefixSumOffsets;
29use super::strategy::{BUFFER_ROWS, GridLayoutStrategy, GridSizing, TileRect, VisibleTileRange};
30
31type ExactHeightFn = Rc<dyn Fn(usize) -> f32>;
32
33/// A grid whose rows are each sized to their tallest tile.
34pub struct VariableRowGrid {
35    columns: ColumnGeometry,
36    row_gap: f32,
37    estimated: f32,
38    /// Optional exact per-item natural height. When present, rows are seeded
39    /// exactly (no measurement / anchoring).
40    exact_height: Option<ExactHeightFn>,
41    offsets: RefCell<PrefixSumOffsets>,
42    /// Current logical item count, kept in sync by `resize` / the
43    /// `item_count`-bearing trait methods.
44    item_count: Cell<usize>,
45    /// Column count the prefix sum was last built for (a change forces a
46    /// full reseed — a width reflow regroups items into different rows).
47    stored_cols: Cell<usize>,
48}
49
50impl std::fmt::Debug for VariableRowGrid {
51    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
52        f.debug_struct("VariableRowGrid")
53            .field("rows", &self.offsets.borrow().rows())
54            .field("exact", &self.exact_height.is_some())
55            .finish()
56    }
57}
58
59impl VariableRowGrid {
60    pub(crate) fn new(
61        sizing: GridSizing,
62        col_gap: f32,
63        row_gap: f32,
64        inset: EdgeInsets,
65        estimated: f32,
66        exact_height: Option<ExactHeightFn>,
67    ) -> Self {
68        let estimated = if estimated > 0.0 {
69            estimated
70        } else {
71            sizing.tile_height().max(1.0)
72        };
73        Self {
74            columns: geometry_for(sizing, col_gap, inset),
75            row_gap: row_gap.max(0.0),
76            estimated,
77            exact_height,
78            offsets: RefCell::new(PrefixSumOffsets::new(
79                0,
80                estimated,
81                row_gap.max(0.0),
82                inset.top,
83                inset.bottom,
84            )),
85            item_count: Cell::new(0),
86            stored_cols: Cell::new(0),
87        }
88    }
89
90    /// Re-seed exactly from `item_height` for every row (only when exact
91    /// heights are supplied). O(item_count) — called on structural changes.
92    fn reseed_exact(&self, cols: usize) {
93        let Some(ref ef) = self.exact_height else {
94            return;
95        };
96        let n = self.item_count.get();
97        let mut off = self.offsets.borrow_mut();
98        let rows = off.rows();
99        for r in 0..rows {
100            let mut h = 0.0_f32;
101            for i in (r * cols)..((r + 1) * cols).min(n) {
102                h = h.max(ef(i));
103            }
104            off.set_row_height(r, h);
105        }
106    }
107
108    /// Ensure the prefix sum matches the current `(item_count, cols)`.
109    /// Cheap (early-returns) when nothing changed. A column-count change
110    /// fully reseeds (rows regroup); an item-count change resizes in place,
111    /// preserving prior measurements.
112    fn sync(&self, viewport_width: f32) {
113        let cols = self.columns.column_count(viewport_width).max(1);
114        let n = self.item_count.get();
115        let rows = n.div_ceil(cols);
116
117        if cols != self.stored_cols.get() {
118            self.offsets.borrow_mut().reset(rows);
119            self.stored_cols.set(cols);
120            self.reseed_exact(cols);
121        } else if rows != self.offsets.borrow().rows() {
122            self.offsets.borrow_mut().resize(rows);
123            self.reseed_exact(cols);
124        }
125    }
126}
127
128impl GridLayoutStrategy for VariableRowGrid {
129    fn column_count(&self, viewport_width: f32) -> usize {
130        self.columns.column_count(viewport_width)
131    }
132
133    fn column_x(&self, col: usize, viewport_width: f32) -> (f32, f32) {
134        self.columns.column_x(col, viewport_width)
135    }
136
137    fn total_content_height(&self, item_count: usize, viewport_width: f32) -> f32 {
138        self.item_count.set(item_count);
139        self.sync(viewport_width);
140        self.offsets.borrow_mut().total()
141    }
142
143    fn visible_range(
144        &self,
145        scroll_y: f32,
146        viewport_height: f32,
147        viewport_width: f32,
148        item_count: usize,
149    ) -> VisibleTileRange {
150        self.item_count.set(item_count);
151        self.sync(viewport_width);
152        if item_count == 0 {
153            return VisibleTileRange { start: 0, end: 0 };
154        }
155        let cols = self.stored_cols.get().max(1);
156        let mut off = self.offsets.borrow_mut();
157        let first_row = off.row_at(scroll_y);
158        let last_row = off.row_at(scroll_y + viewport_height);
159        let start_row = first_row.saturating_sub(BUFFER_ROWS);
160        let end_row = last_row + BUFFER_ROWS;
161        let start = (start_row * cols).min(item_count);
162        let end = (end_row.saturating_add(1).saturating_mul(cols)).min(item_count);
163        VisibleTileRange { start, end }
164    }
165
166    fn tile_rect(&self, index: usize, viewport_width: f32) -> TileRect {
167        self.sync(viewport_width);
168        let cols = self.stored_cols.get().max(1);
169        let row = index / cols;
170        let col = index % cols;
171        let (x, width) = self.columns.column_x(col, viewport_width);
172        let mut off = self.offsets.borrow_mut();
173        let y = off.row_top(row);
174        let height = off.row_height(row);
175        TileRect {
176            x,
177            y,
178            width,
179            height,
180        }
181    }
182
183    fn estimated_row_height(&self) -> f32 {
184        self.estimated
185    }
186
187    fn measures_tiles(&self) -> bool {
188        // Only the auto-measure path needs tile measurement; the exact-
189        // height fast-path seeds rows deterministically.
190        self.exact_height.is_none()
191    }
192
193    fn observe_measured(
194        &self,
195        measured: &[(usize, f32)],
196        scroll_y: f32,
197        viewport_width: f32,
198    ) -> f32 {
199        if self.exact_height.is_some() {
200            return 0.0;
201        }
202        self.sync(viewport_width);
203        let cols = self.stored_cols.get().max(1);
204
205        // Fold per-tile measurements into a per-row max.
206        let mut row_max: HashMap<usize, f32> = HashMap::new();
207        for &(idx, h) in measured {
208            let r = idx / cols;
209            let e = row_max.entry(r).or_insert(0.0);
210            if h > *e {
211                *e = h;
212            }
213        }
214
215        let mut off = self.offsets.borrow_mut();
216        // Read every affected row's pre-change top while the table is clean,
217        // so the anchor decision doesn't churn the lazy rebuild.
218        off.total();
219        let tops: Vec<(usize, f32, f32)> = row_max
220            .iter()
221            .map(|(&r, &h)| (r, off.row_top(r), h))
222            .collect();
223        let mut anchor_delta = 0.0_f32;
224        for (r, top_before, h) in tops {
225            let delta = off.set_row_height(r, h);
226            // Rows strictly above the viewport top shift the content the user
227            // is pinned to; correct the scroll to keep it visually stationary.
228            // A row whose top is exactly at `scroll_y` is the topmost visible
229            // row — its top doesn't move when it grows, so no correction.
230            if delta.abs() > 0.01 && top_before < scroll_y {
231                anchor_delta += delta;
232            }
233        }
234        anchor_delta
235    }
236
237    fn invalidate_rows(&self, item_range: std::ops::Range<usize>) {
238        let cols = self.stored_cols.get().max(1);
239        let start_row = item_range.start / cols;
240        let end_row = if item_range.end == usize::MAX {
241            self.offsets.borrow().rows()
242        } else {
243            item_range.end.div_ceil(cols)
244        };
245        self.offsets.borrow_mut().invalidate(start_row, end_row);
246    }
247
248    fn resize(&self, item_count: usize) {
249        self.item_count.set(item_count);
250        let cols = self.stored_cols.get().max(1);
251        let rows = item_count.div_ceil(cols);
252        self.offsets.borrow_mut().resize(rows);
253        self.reseed_exact(cols);
254    }
255
256    fn index_at_point(
257        &self,
258        content_point: Point,
259        item_count: usize,
260        viewport_width: f32,
261    ) -> Option<usize> {
262        if item_count == 0 {
263            return None;
264        }
265        self.item_count.set(item_count);
266        self.sync(viewport_width);
267        let cols = self.stored_cols.get().max(1);
268        let (row, row_top, row_h) = {
269            let mut off = self.offsets.borrow_mut();
270            let row = off.row_at(content_point.y);
271            (row, off.row_top(row), off.row_height(row))
272        };
273        // `row_at` clamps to a valid row even when the point is above the
274        // first row or below the last — the explicit span check below is
275        // what actually rejects those (and any row-gap in between).
276        if content_point.y < row_top || content_point.y > row_top + row_h {
277            return None;
278        }
279        let col = column_at(&self.columns, content_point.x, viewport_width)?;
280        let idx = row * cols + col;
281        (idx < item_count).then_some(idx)
282    }
283}
284
285#[cfg(test)]
286mod tests {
287    use super::*;
288
289    fn grid() -> VariableRowGrid {
290        // 100-wide tiles, 10px gaps → 2 columns in 210px. Exact 40px item
291        // height (no measurement pass needed): row_step = 40 + 10 = 50.
292        VariableRowGrid::new(
293            GridSizing::Fixed {
294                width: 100.0,
295                height: 40.0,
296            },
297            10.0,
298            10.0,
299            EdgeInsets::ZERO,
300            40.0,
301            Some(Rc::new(|_i| 40.0)),
302        )
303    }
304
305    #[test]
306    fn index_at_point_closed_form_matches_measured_rows() {
307        let g = grid();
308        // 6 items, 2 cols → 3 rows. Row 0 spans y 0..40; row 1 spans
309        // 50..90 (the row-gap band is 40..50).
310        assert_eq!(g.index_at_point(Point::new(0.0, 0.0), 6, 210.0), Some(0));
311        assert_eq!(g.index_at_point(Point::new(0.0, 50.0), 6, 210.0), Some(2));
312    }
313
314    #[test]
315    fn index_at_point_closed_form_returns_none_in_gaps() {
316        let g = grid();
317        // Row-gap band.
318        assert_eq!(g.index_at_point(Point::new(0.0, 45.0), 6, 210.0), None);
319        // Column-gap band (x 100..110).
320        assert_eq!(g.index_at_point(Point::new(105.0, 10.0), 6, 210.0), None);
321        // Past the last row.
322        assert_eq!(g.index_at_point(Point::new(0.0, 9000.0), 6, 210.0), None);
323    }
324}