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            // Exact, not the measurement setter: `item_height` is the
105            // authority here, and the noise epsilon would keep the placeholder
106            // estimate for any declared height within 0.01 px of it.
107            off.set_row_height_exact(r, h);
108        }
109    }
110
111    /// Ensure the prefix sum matches the current `(item_count, cols)`.
112    /// Cheap (early-returns) when nothing changed. A column-count change
113    /// fully reseeds (rows regroup); an item-count change resizes in place,
114    /// preserving prior measurements.
115    fn sync(&self, viewport_width: f32) {
116        let cols = self.columns.column_count(viewport_width).max(1);
117        let n = self.item_count.get();
118        let rows = n.div_ceil(cols);
119
120        if cols != self.stored_cols.get() {
121            self.offsets.borrow_mut().reset(rows);
122            self.stored_cols.set(cols);
123            self.reseed_exact(cols);
124        } else if rows != self.offsets.borrow().rows() {
125            self.offsets.borrow_mut().resize(rows);
126            self.reseed_exact(cols);
127        }
128    }
129}
130
131impl GridLayoutStrategy for VariableRowGrid {
132    fn column_count(&self, viewport_width: f32) -> usize {
133        self.columns.column_count(viewport_width)
134    }
135
136    fn column_x(&self, col: usize, viewport_width: f32) -> (f32, f32) {
137        self.columns.column_x(col, viewport_width)
138    }
139
140    fn total_content_height(&self, item_count: usize, viewport_width: f32) -> f32 {
141        self.item_count.set(item_count);
142        self.sync(viewport_width);
143        self.offsets.borrow_mut().total()
144    }
145
146    fn visible_range(
147        &self,
148        scroll_y: f32,
149        viewport_height: f32,
150        viewport_width: f32,
151        item_count: usize,
152    ) -> VisibleTileRange {
153        self.item_count.set(item_count);
154        self.sync(viewport_width);
155        if item_count == 0 {
156            return VisibleTileRange { start: 0, end: 0 };
157        }
158        let cols = self.stored_cols.get().max(1);
159        let mut off = self.offsets.borrow_mut();
160        let first_row = off.row_at(scroll_y);
161        let last_row = off.row_at(scroll_y + viewport_height);
162        let start_row = first_row.saturating_sub(BUFFER_ROWS);
163        let end_row = last_row + BUFFER_ROWS;
164        let start = (start_row * cols).min(item_count);
165        let end = (end_row.saturating_add(1).saturating_mul(cols)).min(item_count);
166        VisibleTileRange { start, end }
167    }
168
169    fn tile_rect(&self, index: usize, viewport_width: f32) -> TileRect {
170        self.sync(viewport_width);
171        let cols = self.stored_cols.get().max(1);
172        let row = index / cols;
173        let col = index % cols;
174        let (x, width) = self.columns.column_x(col, viewport_width);
175        let mut off = self.offsets.borrow_mut();
176        let y = off.row_top(row);
177        let height = off.row_height(row);
178        TileRect {
179            x,
180            y,
181            width,
182            height,
183        }
184    }
185
186    fn estimated_row_height(&self) -> f32 {
187        self.estimated
188    }
189
190    fn measures_tiles(&self) -> bool {
191        // Only the auto-measure path needs tile measurement; the exact-
192        // height fast-path seeds rows deterministically.
193        self.exact_height.is_none()
194    }
195
196    fn observe_measured(
197        &self,
198        measured: &[(usize, f32)],
199        scroll_y: f32,
200        viewport_width: f32,
201    ) -> f32 {
202        if self.exact_height.is_some() {
203            return 0.0;
204        }
205        self.sync(viewport_width);
206        let cols = self.stored_cols.get().max(1);
207
208        // Fold per-tile measurements into a per-row max.
209        let mut row_max: HashMap<usize, f32> = HashMap::new();
210        for &(idx, h) in measured {
211            let r = idx / cols;
212            let e = row_max.entry(r).or_insert(0.0);
213            if h > *e {
214                *e = h;
215            }
216        }
217
218        let mut off = self.offsets.borrow_mut();
219        // Read every affected row's pre-change top while the table is clean,
220        // so the anchor decision doesn't churn the lazy rebuild.
221        off.total();
222        let tops: Vec<(usize, f32, f32)> = row_max
223            .iter()
224            .map(|(&r, &h)| (r, off.row_top(r), h))
225            .collect();
226        let mut anchor_delta = 0.0_f32;
227        for (r, top_before, h) in tops {
228            let delta = off.set_row_height(r, h);
229            // Rows strictly above the viewport top shift the content the user
230            // is pinned to; correct the scroll to keep it visually stationary.
231            // A row whose top is exactly at `scroll_y` is the topmost visible
232            // row — its top doesn't move when it grows, so no correction.
233            if delta.abs() > 0.01 && top_before < scroll_y {
234                anchor_delta += delta;
235            }
236        }
237        anchor_delta
238    }
239
240    fn invalidate_rows(&self, item_range: std::ops::Range<usize>) {
241        let cols = self.stored_cols.get().max(1);
242        let start_row = item_range.start / cols;
243        let end_row = if item_range.end == usize::MAX {
244            self.offsets.borrow().rows()
245        } else {
246            item_range.end.div_ceil(cols)
247        };
248        self.offsets.borrow_mut().invalidate(start_row, end_row);
249    }
250
251    fn resize(&self, item_count: usize) {
252        self.item_count.set(item_count);
253        let cols = self.stored_cols.get().max(1);
254        let rows = item_count.div_ceil(cols);
255        self.offsets.borrow_mut().resize(rows);
256        self.reseed_exact(cols);
257    }
258
259    fn index_at_point(
260        &self,
261        content_point: Point,
262        item_count: usize,
263        viewport_width: f32,
264    ) -> Option<usize> {
265        if item_count == 0 {
266            return None;
267        }
268        self.item_count.set(item_count);
269        self.sync(viewport_width);
270        let cols = self.stored_cols.get().max(1);
271        let (row, row_top, row_h) = {
272            let mut off = self.offsets.borrow_mut();
273            let row = off.row_at(content_point.y);
274            (row, off.row_top(row), off.row_height(row))
275        };
276        // `row_at` clamps to a valid row even when the point is above the
277        // first row or below the last — the explicit span check below is
278        // what actually rejects those (and any row-gap in between).
279        if content_point.y < row_top || content_point.y > row_top + row_h {
280            return None;
281        }
282        let col = column_at(&self.columns, content_point.x, viewport_width)?;
283        let idx = row * cols + col;
284        (idx < item_count).then_some(idx)
285    }
286}
287
288#[cfg(test)]
289mod tests {
290    use super::*;
291
292    fn grid() -> VariableRowGrid {
293        // 100-wide tiles, 10px gaps → 2 columns in 210px. Exact 40px item
294        // height (no measurement pass needed): row_step = 40 + 10 = 50.
295        VariableRowGrid::new(
296            GridSizing::Fixed {
297                width: 100.0,
298                height: 40.0,
299            },
300            10.0,
301            10.0,
302            EdgeInsets::ZERO,
303            40.0,
304            Some(Rc::new(|_i| 40.0)),
305        )
306    }
307
308    #[test]
309    fn index_at_point_closed_form_matches_measured_rows() {
310        let g = grid();
311        // 6 items, 2 cols → 3 rows. Row 0 spans y 0..40; row 1 spans
312        // 50..90 (the row-gap band is 40..50).
313        assert_eq!(g.index_at_point(Point::new(0.0, 0.0), 6, 210.0), Some(0));
314        assert_eq!(g.index_at_point(Point::new(0.0, 50.0), 6, 210.0), Some(2));
315    }
316
317    #[test]
318    fn index_at_point_closed_form_returns_none_in_gaps() {
319        let g = grid();
320        // Row-gap band.
321        assert_eq!(g.index_at_point(Point::new(0.0, 45.0), 6, 210.0), None);
322        // Column-gap band (x 100..110).
323        assert_eq!(g.index_at_point(Point::new(105.0, 10.0), 6, 210.0), None);
324        // Past the last row.
325        assert_eq!(g.index_at_point(Point::new(0.0, 9000.0), 6, 210.0), None);
326    }
327}