Skip to main content

teksilo_widgets/grid_view/layout/
strategy.rs

1// SPDX-License-Identifier: MPL-2.0
2// SPDX-FileCopyrightText: 2026 FernTech
3
4//! The pluggable layout strategy that drives `GridView<T>`'s virtualization.
5//!
6//! A `GridLayoutStrategy` answers every geometric question the
7//! virtualization engine needs — how many columns fit a viewport, the
8//! content-space rect of any item, the flat index range to realize for a
9//! scroll offset, and the total content height. Three concrete strategies
10//! ship:
11//!
12//! * [`UniformGrid`](super::uniform::UniformGrid) — fixed tile size /
13//!   fixed column count / adaptive min-width. Exact O(1) positions.
14//! * `VariableRowGrid` — each row sized to its tallest tile (auto-measure
15//!   + scroll-anchoring, or an exact `item_height(index)` fast-path).
16//! * `VirtualizedMasonry` — Pinterest-style column-balanced waterfall.
17//!
18//! Keeping the engine behind this trait means the body pane, scrollbar
19//! wiring, keyboard nav, and accessibility never need to know which layout
20//! is active.
21
22use std::ops::Range;
23
24use teksilo_canvas::Rect;
25
26/// The default over-realization window: this many extra rows are built
27/// above and below the viewport so a small scroll doesn't trigger a
28/// rebuild (only a relayout). Mirrors `ListView`/`TableView`.
29pub(crate) const BUFFER_ROWS: usize = 5;
30
31/// Where a programmatically-revealed item should land in the viewport.
32#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
33pub enum ScrollAnchor {
34    /// Minimum scroll that makes the item fully visible (no-op if already
35    /// visible). The default for keyboard navigation.
36    #[default]
37    Auto,
38    /// The item's leading edge aligns with the viewport top.
39    Start,
40    /// The item is centered in the viewport.
41    Center,
42    /// The item's trailing edge aligns with the viewport bottom.
43    End,
44}
45
46/// Tile sizing policy. Names mirror SwiftUI `GridItem`, Flutter's
47/// `SliverGridDelegate`, and WinUI `MinItemWidth`.
48#[derive(Debug, Clone, Copy, PartialEq)]
49pub enum GridSizing {
50    /// Every tile is exactly `width` × `height`. The column count is
51    /// derived: as many `width`-wide tiles as fit the viewport. Tiles are
52    /// NOT stretched — leftover space trails after the last column.
53    Fixed { width: f32, height: f32 },
54    /// Exactly `count` columns, each stretched to an equal share of the
55    /// viewport width. Tile height is fixed at `height`.
56    FixedColumnCount { count: usize, height: f32 },
57    /// Fit as many columns as possible such that each tile is at least
58    /// `min_width` wide; tiles stretch to fill, clamped to `max_width`
59    /// when set (Flutter `maxCrossAxisExtent`). Tile height is `height`.
60    Adaptive {
61        min_width: f32,
62        max_width: Option<f32>,
63        height: f32,
64    },
65}
66
67impl GridSizing {
68    /// The fixed tile height carried by every variant.
69    pub(crate) fn tile_height(&self) -> f32 {
70        match *self {
71            GridSizing::Fixed { height, .. }
72            | GridSizing::FixedColumnCount { height, .. }
73            | GridSizing::Adaptive { height, .. } => height,
74        }
75    }
76}
77
78/// The content-space rect of one tile (before the scroll offset is
79/// subtracted). `x`/`y` are relative to the scrollable content origin.
80#[derive(Debug, Clone, Copy, PartialEq)]
81pub struct TileRect {
82    pub x: f32,
83    pub y: f32,
84    pub width: f32,
85    pub height: f32,
86}
87
88/// The flat model-index range `[start, end)` to realize for a given scroll
89/// + viewport, including the buffer.
90#[derive(Debug, Clone, Copy, PartialEq, Eq)]
91pub struct VisibleTileRange {
92    pub start: usize,
93    pub end: usize,
94}
95
96/// The geometry + virtualization contract every grid layout implements.
97///
98/// Object-safe (`Rc<dyn GridLayoutStrategy>`). Strategies use interior
99/// mutability for their height caches so the `&self` `place_children`
100/// pass can feed measured heights back (see [`observe_measured`]).
101///
102/// [`observe_measured`]: GridLayoutStrategy::observe_measured
103pub(crate) trait GridLayoutStrategy: std::fmt::Debug + 'static {
104    /// Number of columns for `viewport_width`. Must be O(1) — called every
105    /// frame in `place_children`.
106    fn column_count(&self, viewport_width: f32) -> usize;
107
108    /// The `(x, width)` of column `col` within `viewport_width`. `col` must
109    /// be `< column_count(viewport_width)`.
110    fn column_x(&self, col: usize, viewport_width: f32) -> (f32, f32);
111
112    /// Total scrollable content height for `item_count` items. May be an
113    /// estimate for variable-height strategies before every row has been
114    /// measured. Drives `max_scroll_y` and the scrollbar thumb ratio.
115    fn total_content_height(&self, item_count: usize, viewport_width: f32) -> f32;
116
117    /// Total content width. The default (fill the viewport, no horizontal
118    /// overflow) is correct for every vertical-scroll strategy.
119    fn total_content_width(&self, viewport_width: f32) -> f32 {
120        viewport_width
121    }
122
123    /// Flat index range `[start, end)` to realize, including the buffer.
124    fn visible_range(
125        &self,
126        scroll_y: f32,
127        viewport_height: f32,
128        viewport_width: f32,
129        item_count: usize,
130    ) -> VisibleTileRange;
131
132    /// Content-space rect of item `index`. `index` must be `< item_count`.
133    fn tile_rect(&self, index: usize, viewport_width: f32) -> TileRect;
134
135    /// The height used for rows that have not been measured yet (also the
136    /// fixed height for uniform strategies). Used to size the buffer.
137    fn estimated_row_height(&self) -> f32;
138
139    // ── Variable-height hooks (no-ops for `UniformGrid`) ────────────────
140
141    /// Whether the body pane should measure each realized tile's
142    /// height-for-width and feed it back via [`observe_measured`]. Uniform
143    /// strategies return `false` (heights are fixed).
144    ///
145    /// [`observe_measured`]: GridLayoutStrategy::observe_measured
146    fn measures_tiles(&self) -> bool {
147        false
148    }
149
150    /// Feed back the measured `(flat_index, height)` of every realized tile
151    /// for one layout pass. The strategy folds them into its height cache
152    /// (row-max for variable rows, per-column for waterfall) and returns
153    /// the **scroll-anchor delta**: how far `scroll_y` must move to keep the
154    /// content at/above the viewport top visually stationary. Returns `0.0`
155    /// when nothing changed.
156    fn observe_measured(
157        &self,
158        _measured: &[(usize, f32)],
159        _scroll_y: f32,
160        _viewport_width: f32,
161    ) -> f32 {
162        0.0
163    }
164
165    /// Invalidate cached heights for the flat item range (back to the
166    /// estimate). Called on data changes; for inserts/removes pass
167    /// `start..usize::MAX` because the grid reflows from the edit point.
168    fn invalidate_rows(&self, _item_range: Range<usize>) {}
169
170    /// Resize the internal height cache to `item_count` items (after an
171    /// insert/remove/reset). No-op for uniform strategies.
172    fn resize(&self, _item_count: usize) {}
173
174    // ── Scroll-into-view + marquee ──────────────────────────────────────
175
176    /// The signed scroll delta needed to satisfy `anchor` for `index`.
177    /// `Auto` returns `0.0` when the item is already fully visible. The
178    /// default implementation works for every strategy via `tile_rect`.
179    fn scroll_delta_to_reveal(
180        &self,
181        index: usize,
182        scroll_y: f32,
183        viewport_height: f32,
184        viewport_width: f32,
185        anchor: ScrollAnchor,
186    ) -> f32 {
187        let r = self.tile_rect(index, viewport_width);
188        let tile_top = r.y;
189        let tile_bot = r.y + r.height;
190        match anchor {
191            ScrollAnchor::Start => tile_top - scroll_y,
192            ScrollAnchor::End => tile_bot - viewport_height - scroll_y,
193            ScrollAnchor::Center => (tile_top + r.height * 0.5) - viewport_height * 0.5 - scroll_y,
194            ScrollAnchor::Auto => {
195                if tile_top < scroll_y {
196                    tile_top - scroll_y
197                } else if tile_bot > scroll_y + viewport_height {
198                    tile_bot - (scroll_y + viewport_height)
199                } else {
200                    0.0
201                }
202            }
203        }
204    }
205
206    /// Flat indices whose tile rect intersects `content_rect` (a rubber-band
207    /// rectangle in content space). Geometric — tests items outside the
208    /// realized window too. The default scans every item via `tile_rect`,
209    /// which is correct but O(n); strategies with a cheap row/column index
210    /// may override for large datasets.
211    fn hit_indices_in_rect(
212        &self,
213        content_rect: Rect,
214        item_count: usize,
215        viewport_width: f32,
216    ) -> Vec<usize> {
217        let mut hits = Vec::new();
218        for i in 0..item_count {
219            let r = self.tile_rect(i, viewport_width);
220            let tile = Rect::new(r.x, r.y, r.width, r.height);
221            if rects_intersect(content_rect, tile) {
222                hits.push(i);
223            }
224        }
225        hits
226    }
227
228    /// The flat index of the tile whose rect contains `content_point` (a
229    /// point in content space), or `None` for an inter-tile gap / empty
230    /// background. Used to decide whether a press should start an item drag
231    /// (on a tile) or a marquee (on the background). Default scans via
232    /// `tile_rect` — O(n); `UniformGrid`/`VariableRowGrid`/`SectionedGrid`
233    /// override with a closed-form lookup since this runs on every
234    /// `on_drag_hover` move. `VirtualizedMasonry` keeps this default: its
235    /// placement isn't row-major (items drop into the currently-shortest
236    /// column), so there's no O(1) inverse — acceptable for the
237    /// hundreds-to-low-thousands of items a waterfall gallery holds.
238    fn index_at_point(
239        &self,
240        content_point: teksilo_canvas::Point,
241        item_count: usize,
242        viewport_width: f32,
243    ) -> Option<usize> {
244        for i in 0..item_count {
245            let r = self.tile_rect(i, viewport_width);
246            if Rect::new(r.x, r.y, r.width, r.height).contains(content_point) {
247                return Some(i);
248            }
249        }
250        None
251    }
252
253    /// The flat index a drag-reorder drop at `content_point` should insert
254    /// *before* — the counterpart to [`index_at_point`](Self::index_at_point)
255    /// for drop resolution. Unlike `index_at_point` (which must return `None`
256    /// for a background point so marquee-vs-drag disambiguation works),
257    /// this ALWAYS resolves to a real insertion point: a point over a tile
258    /// lands on its leading or trailing edge (by which half of the tile's
259    /// width it falls in); a point in a gap (row-gap, column-gap, or before
260    /// the first row) resolves to the nearest tile by row proximity first,
261    /// then column proximity, and applies the same edge rule to it — so a
262    /// row-gap point never silently falls through to "append at end" the
263    /// way naively delegating to `index_at_point` would. Only a point at or
264    /// past the bottom of the very last tile yields `item_count` (append).
265    fn insertion_index_at(
266        &self,
267        content_point: teksilo_canvas::Point,
268        item_count: usize,
269        viewport_width: f32,
270    ) -> usize {
271        if item_count == 0 {
272            return 0;
273        }
274        let last = self.tile_rect(item_count - 1, viewport_width);
275        if content_point.y >= last.y + last.height {
276            return item_count;
277        }
278        if let Some(i) = self.index_at_point(content_point, item_count, viewport_width) {
279            let r = self.tile_rect(i, viewport_width);
280            return if content_point.x > r.x + r.width * 0.5 {
281                (i + 1).min(item_count)
282            } else {
283                i
284            };
285        }
286        // Gap: the nearest tile by (vertical, then horizontal) edge
287        // distance — 0 when the point is already within the tile's span on
288        // that axis. Locking onto the nearest ROW first (not just the
289        // nearest tile overall) is what makes a row-gap point resolve to
290        // the adjacent row instead of an arbitrary far-away tile.
291        let mut best = 0usize;
292        let mut best_dy = f32::MAX;
293        let mut best_dx = f32::MAX;
294        for i in 0..item_count {
295            let r = self.tile_rect(i, viewport_width);
296            let dy = edge_gap(content_point.y, r.y, r.height);
297            let dx = edge_gap(content_point.x, r.x, r.width);
298            if dy < best_dy - 0.01 || ((dy - best_dy).abs() <= 0.01 && dx < best_dx) {
299                best = i;
300                best_dy = dy;
301                best_dx = dx;
302            }
303        }
304        let r = self.tile_rect(best, viewport_width);
305        if content_point.x > r.x + r.width * 0.5 {
306            (best + 1).min(item_count)
307        } else {
308            best
309        }
310    }
311
312    /// `(row, col)` of item `index`, 0-based — the tile's ARIA grid
313    /// coordinates and the values handed to the delegate via
314    /// [`TileContext`](super::super::TileContext). Default is global
315    /// row-major math (`index / cols`, `index % cols`), correct for every
316    /// strategy whose flat index order matches its visual row order
317    /// (uniform, variable-row, waterfall-as-appropriate). `SectionedGrid`
318    /// overrides with SECTION-LOCAL numbering, since each section starts a
319    /// fresh row band (see `SectionedGrid::tile_rect`) — the global index
320    /// misreports row/col whenever an earlier section's count isn't a
321    /// column multiple.
322    fn tile_row_col(&self, index: usize, viewport_width: f32) -> (usize, usize) {
323        let cols = self.column_count(viewport_width).max(1);
324        (index / cols, index % cols)
325    }
326
327    // ── Section headers (only the sectioned strategy implements these) ──
328
329    /// `(section_index, header_rect)` for every section header in the visible
330    /// vertical range. Empty for non-sectioned strategies. The body pane
331    /// realizes these header widgets alongside tiles.
332    fn headers_in_range(
333        &self,
334        _scroll_y: f32,
335        _viewport_height: f32,
336        _viewport_width: f32,
337    ) -> Vec<(usize, TileRect)> {
338        Vec::new()
339    }
340
341    /// The section whose band the viewport top currently sits in (drives the
342    /// sticky pinned header). `None` for non-sectioned strategies.
343    fn current_section(&self, _scroll_y: f32, _viewport_width: f32) -> Option<usize> {
344        None
345    }
346
347    /// The header rect for a single section (used to size the pinned slot).
348    fn header_rect(&self, _section: usize, _viewport_width: f32) -> Option<TileRect> {
349        None
350    }
351}
352
353/// Axis-aligned rectangle intersection test (touching edges don't count).
354pub(crate) fn rects_intersect(a: Rect, b: Rect) -> bool {
355    a.x < b.right() && b.x < a.right() && a.y < b.bottom() && b.y < a.bottom()
356}
357
358/// Distance from `p` to the nearest edge of the span `[origin, origin +
359/// extent]`; `0.0` when `p` falls inside it. The building block for
360/// [`GridLayoutStrategy::insertion_index_at`]'s gap-resolution scan.
361fn edge_gap(p: f32, origin: f32, extent: f32) -> f32 {
362    if p < origin {
363        origin - p
364    } else if p > origin + extent {
365        p - (origin + extent)
366    } else {
367        0.0
368    }
369}
370
371#[cfg(test)]
372mod tests {
373    use super::*;
374    use crate::grid_view::layout::uniform::UniformGrid;
375    use teksilo_canvas::{EdgeInsets, Point};
376
377    fn grid() -> UniformGrid {
378        // 100×50 tiles, 0 gaps, no insets → 4 columns in 400px.
379        UniformGrid::new(
380            GridSizing::Fixed {
381                width: 100.0,
382                height: 50.0,
383            },
384            0.0,
385            0.0,
386            EdgeInsets::ZERO,
387        )
388    }
389
390    fn gapped_grid() -> UniformGrid {
391        // 100×50 tiles, 10px gaps, no insets → 4 columns in 430px
392        // (4*100 + 3*10 = 430), row_step = 50 + 10 = 60.
393        UniformGrid::new(
394            GridSizing::Fixed {
395                width: 100.0,
396                height: 50.0,
397            },
398            10.0,
399            10.0,
400            EdgeInsets::ZERO,
401        )
402    }
403
404    #[test]
405    fn hit_indices_in_rect_selects_intersecting_tiles() {
406        let g = grid();
407        // Rect over the first two columns of the first two rows: tiles
408        // (0,1) in row 0 and (4,5) in row 1.
409        let rect = Rect::new(10.0, 10.0, 150.0, 60.0);
410        let mut hits = g.hit_indices_in_rect(rect, 40, 400.0);
411        hits.sort();
412        assert_eq!(hits, vec![0, 1, 4, 5]);
413    }
414
415    #[test]
416    fn index_at_point_finds_tile_and_gap() {
417        let g = grid();
418        // Point inside tile 2 (x 200..300, y 0..50).
419        assert_eq!(
420            g.index_at_point(Point::new(250.0, 25.0), 40, 400.0),
421            Some(2)
422        );
423        // Point in row 1, column 0 → index 4.
424        assert_eq!(g.index_at_point(Point::new(10.0, 60.0), 40, 400.0), Some(4));
425        // Point beyond the last item.
426        assert_eq!(g.index_at_point(Point::new(10.0, 9000.0), 40, 400.0), None);
427    }
428
429    #[test]
430    fn insertion_index_at_row_gap_does_not_fall_through_to_len() {
431        // 12 items, 4 cols → 3 rows. y=53 sits in the row-gap between row 0
432        // (0..50) and row 1 (60..110), closer to row 0; x=50 is inside
433        // column 0. Before the fix this always fell through to `len` (12)
434        // because `index_at_point` returns None for any non-tile point.
435        let g = gapped_grid();
436        let idx = g.insertion_index_at(Point::new(50.0, 53.0), 12, 430.0);
437        assert!(
438            idx < 12,
439            "a mid-grid row-gap point must not fall through to len, got {idx}"
440        );
441    }
442
443    #[test]
444    fn insertion_index_at_col_gap_yields_next_tile() {
445        // Row 0: tile 0 spans x 0..100, the gap spans 100..110, tile 1
446        // spans 110..210. A point in the gap (x=105) must insert BEFORE
447        // tile 1 — i.e. resolve to index 1 — not fall through to `len`.
448        let g = gapped_grid();
449        let idx = g.insertion_index_at(Point::new(105.0, 25.0), 12, 430.0);
450        assert_eq!(
451            idx, 1,
452            "a point in the col-gap between tiles 0 and 1 should insert before tile 1"
453        );
454    }
455
456    #[test]
457    fn insertion_index_at_past_last_tile_yields_len() {
458        let g = gapped_grid();
459        let idx = g.insertion_index_at(Point::new(50.0, 9000.0), 12, 430.0);
460        assert_eq!(
461            idx, 12,
462            "a point past the last tile should append at the end"
463        );
464    }
465}