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