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    /// Tell the strategy which way the layout runs, so that
113    /// `GridView::column_alignment`'s leading and trailing resolve to the
114    /// right side. Called every layout pass before any geometry is read; the
115    /// default is for a strategy that places nothing by alignment.
116    fn set_rtl(&self, _rtl: bool) {}
117
118    /// Total scrollable content height for `item_count` items. May be an
119    /// estimate for variable-height strategies before every row has been
120    /// measured. Drives `max_scroll_y` and the scrollbar thumb ratio.
121    fn total_content_height(&self, item_count: usize, viewport_width: f32) -> f32;
122
123    /// Total content width. The default (fill the viewport, no horizontal
124    /// overflow) is correct for every vertical-scroll strategy.
125    fn total_content_width(&self, viewport_width: f32) -> f32 {
126        viewport_width
127    }
128
129    /// Flat index range `[start, end)` to realize, including the buffer.
130    fn visible_range(
131        &self,
132        scroll_y: f32,
133        viewport_height: f32,
134        viewport_width: f32,
135        item_count: usize,
136    ) -> VisibleTileRange;
137
138    /// Content-space rect of item `index`. `index` must be `< item_count`.
139    fn tile_rect(&self, index: usize, viewport_width: f32) -> TileRect;
140
141    /// The height used for rows that have not been measured yet (also the
142    /// fixed height for uniform strategies). Used to size the buffer.
143    fn estimated_row_height(&self) -> f32;
144
145    // ── Variable-height hooks (no-ops for `UniformGrid`) ────────────────
146
147    /// Whether the body pane should measure each realized tile's
148    /// height-for-width and feed it back via [`observe_measured`]. Uniform
149    /// strategies return `false` (heights are fixed).
150    ///
151    /// [`observe_measured`]: GridLayoutStrategy::observe_measured
152    fn measures_tiles(&self) -> bool {
153        false
154    }
155
156    /// Feed back the measured `(flat_index, height)` of every realized tile
157    /// for one layout pass. The strategy folds them into its height cache
158    /// (row-max for variable rows, per-column for waterfall) and returns
159    /// the **scroll-anchor delta**: how far `scroll_y` must move to keep the
160    /// content at/above the viewport top visually stationary. Returns `0.0`
161    /// when nothing changed.
162    fn observe_measured(
163        &self,
164        _measured: &[(usize, f32)],
165        _scroll_y: f32,
166        _viewport_width: f32,
167    ) -> f32 {
168        0.0
169    }
170
171    /// Invalidate cached heights for the flat item range (back to the
172    /// estimate). Called on data changes; for inserts/removes pass
173    /// `start..usize::MAX` because the grid reflows from the edit point.
174    fn invalidate_rows(&self, _item_range: Range<usize>) {}
175
176    /// Resize the internal height cache to `item_count` items (after an
177    /// insert/remove/reset). No-op for uniform strategies.
178    fn resize(&self, _item_count: usize) {}
179
180    // ── Scroll-into-view + marquee ──────────────────────────────────────
181
182    /// The signed scroll delta needed to satisfy `anchor` for `index`.
183    /// `Auto` returns `0.0` when the item is already fully visible. The
184    /// default implementation works for every strategy via `tile_rect`.
185    fn scroll_delta_to_reveal(
186        &self,
187        index: usize,
188        scroll_y: f32,
189        viewport_height: f32,
190        viewport_width: f32,
191        anchor: ScrollAnchor,
192    ) -> f32 {
193        let r = self.tile_rect(index, viewport_width);
194        let tile_top = r.y;
195        let tile_bot = r.y + r.height;
196        match anchor {
197            ScrollAnchor::Start => tile_top - scroll_y,
198            ScrollAnchor::End => tile_bot - viewport_height - scroll_y,
199            ScrollAnchor::Center => (tile_top + r.height * 0.5) - viewport_height * 0.5 - scroll_y,
200            ScrollAnchor::Auto => {
201                if tile_top < scroll_y {
202                    tile_top - scroll_y
203                } else if tile_bot > scroll_y + viewport_height {
204                    tile_bot - (scroll_y + viewport_height)
205                } else {
206                    0.0
207                }
208            }
209        }
210    }
211
212    /// Flat indices whose tile rect intersects `content_rect` (a rubber-band
213    /// rectangle in content space). Geometric — tests items outside the
214    /// realized window too. The default scans every item via `tile_rect`,
215    /// which is correct but O(n); strategies with a cheap row/column index
216    /// may override for large datasets.
217    fn hit_indices_in_rect(
218        &self,
219        content_rect: Rect,
220        item_count: usize,
221        viewport_width: f32,
222    ) -> Vec<usize> {
223        let mut hits = Vec::new();
224        for i in 0..item_count {
225            let r = self.tile_rect(i, viewport_width);
226            let tile = Rect::new(r.x, r.y, r.width, r.height);
227            if rects_intersect(content_rect, tile) {
228                hits.push(i);
229            }
230        }
231        hits
232    }
233
234    /// The flat index of the tile whose rect contains `content_point` (a
235    /// point in content space), or `None` for an inter-tile gap / empty
236    /// background. Used to decide whether a press should start an item drag
237    /// (on a tile) or a marquee (on the background). Default scans via
238    /// `tile_rect` — O(n); `UniformGrid`/`VariableRowGrid`/`SectionedGrid`
239    /// override with a closed-form lookup since this runs on every
240    /// `on_drag_hover` move. `VirtualizedMasonry` keeps this default: its
241    /// placement isn't row-major (items drop into the currently-shortest
242    /// column), so there's no O(1) inverse — acceptable for the
243    /// hundreds-to-low-thousands of items a waterfall gallery holds.
244    fn index_at_point(
245        &self,
246        content_point: teksilo_canvas::Point,
247        item_count: usize,
248        viewport_width: f32,
249    ) -> Option<usize> {
250        for i in 0..item_count {
251            let r = self.tile_rect(i, viewport_width);
252            if Rect::new(r.x, r.y, r.width, r.height).contains(content_point) {
253                return Some(i);
254            }
255        }
256        None
257    }
258
259    /// The flat index a drag-reorder drop at `content_point` should insert
260    /// *before* — the counterpart to [`index_at_point`](Self::index_at_point)
261    /// for drop resolution. Unlike `index_at_point` (which must return `None`
262    /// for a background point so marquee-vs-drag disambiguation works),
263    /// this ALWAYS resolves to a real insertion point: a point over a tile
264    /// lands on its leading or trailing edge (by which half of the tile's
265    /// width it falls in); a point in a gap (row-gap, column-gap, or before
266    /// the first row) resolves to the nearest tile by row proximity first,
267    /// then column proximity, and applies the same edge rule to it — so a
268    /// row-gap point never silently falls through to "append at end" the
269    /// way naively delegating to `index_at_point` would. Only a point at or
270    /// past the bottom of the very last tile yields `item_count` (append).
271    fn insertion_index_at(
272        &self,
273        content_point: teksilo_canvas::Point,
274        item_count: usize,
275        viewport_width: f32,
276    ) -> usize {
277        if item_count == 0 {
278            return 0;
279        }
280        let last = self.tile_rect(item_count - 1, viewport_width);
281        if content_point.y >= last.y + last.height {
282            return item_count;
283        }
284        if let Some(i) = self.index_at_point(content_point, item_count, viewport_width) {
285            let r = self.tile_rect(i, viewport_width);
286            return if content_point.x > r.x + r.width * 0.5 {
287                (i + 1).min(item_count)
288            } else {
289                i
290            };
291        }
292        // Gap: the nearest tile by (vertical, then horizontal) edge
293        // distance — 0 when the point is already within the tile's span on
294        // that axis. Locking onto the nearest ROW first (not just the
295        // nearest tile overall) is what makes a row-gap point resolve to
296        // the adjacent row instead of an arbitrary far-away tile.
297        let mut best = 0usize;
298        let mut best_dy = f32::MAX;
299        let mut best_dx = f32::MAX;
300        for i in 0..item_count {
301            let r = self.tile_rect(i, viewport_width);
302            let dy = edge_gap(content_point.y, r.y, r.height);
303            let dx = edge_gap(content_point.x, r.x, r.width);
304            if dy < best_dy - 0.01 || ((dy - best_dy).abs() <= 0.01 && dx < best_dx) {
305                best = i;
306                best_dy = dy;
307                best_dx = dx;
308            }
309        }
310        let r = self.tile_rect(best, viewport_width);
311        if content_point.x > r.x + r.width * 0.5 {
312            (best + 1).min(item_count)
313        } else {
314            best
315        }
316    }
317
318    /// `(row, col)` of item `index`, 0-based — the tile's ARIA grid
319    /// coordinates and the values handed to the delegate via
320    /// [`TileContext`](super::super::TileContext). Default is global
321    /// row-major math (`index / cols`, `index % cols`), correct for every
322    /// strategy whose flat index order matches its visual row order
323    /// (uniform, variable-row, waterfall-as-appropriate). `SectionedGrid`
324    /// overrides with SECTION-LOCAL numbering, since each section starts a
325    /// fresh row band (see `SectionedGrid::tile_rect`) — the global index
326    /// misreports row/col whenever an earlier section's count isn't a
327    /// column multiple.
328    fn tile_row_col(&self, index: usize, viewport_width: f32) -> (usize, usize) {
329        let cols = self.column_count(viewport_width).max(1);
330        (index / cols, index % cols)
331    }
332
333    // ── Section headers (only the sectioned strategy implements these) ──
334
335    /// `(section_index, header_rect)` for every section header in the visible
336    /// vertical range. Empty for non-sectioned strategies. The body pane
337    /// realizes these header widgets alongside tiles.
338    fn headers_in_range(
339        &self,
340        _scroll_y: f32,
341        _viewport_height: f32,
342        _viewport_width: f32,
343    ) -> Vec<(usize, TileRect)> {
344        Vec::new()
345    }
346
347    /// The section whose band the viewport top currently sits in (drives the
348    /// sticky pinned header). `None` for non-sectioned strategies.
349    fn current_section(&self, _scroll_y: f32, _viewport_width: f32) -> Option<usize> {
350        None
351    }
352
353    /// The header rect for a single section (used to size the pinned slot).
354    fn header_rect(&self, _section: usize, _viewport_width: f32) -> Option<TileRect> {
355        None
356    }
357}
358
359/// Axis-aligned rectangle intersection test (touching edges don't count).
360pub(crate) fn rects_intersect(a: Rect, b: Rect) -> bool {
361    a.x < b.right() && b.x < a.right() && a.y < b.bottom() && b.y < a.bottom()
362}
363
364/// Distance from `p` to the nearest edge of the span `[origin, origin +
365/// extent]`; `0.0` when `p` falls inside it. The building block for
366/// [`GridLayoutStrategy::insertion_index_at`]'s gap-resolution scan.
367fn edge_gap(p: f32, origin: f32, extent: f32) -> f32 {
368    if p < origin {
369        origin - p
370    } else if p > origin + extent {
371        p - (origin + extent)
372    } else {
373        0.0
374    }
375}
376
377#[cfg(test)]
378mod tests {
379    use super::*;
380    use crate::grid_view::layout::uniform::UniformGrid;
381    use teksilo_canvas::{EdgeInsets, Point};
382
383    fn grid() -> UniformGrid {
384        // 100×50 tiles, 0 gaps, no insets → 4 columns in 400px.
385        UniformGrid::new(
386            GridSizing::Fixed {
387                width: 100.0,
388                height: 50.0,
389            },
390            0.0,
391            0.0,
392            EdgeInsets::ZERO,
393        )
394    }
395
396    fn gapped_grid() -> UniformGrid {
397        // 100×50 tiles, 10px gaps, no insets → 4 columns in 430px
398        // (4*100 + 3*10 = 430), row_step = 50 + 10 = 60.
399        UniformGrid::new(
400            GridSizing::Fixed {
401                width: 100.0,
402                height: 50.0,
403            },
404            10.0,
405            10.0,
406            EdgeInsets::ZERO,
407        )
408    }
409
410    #[test]
411    fn hit_indices_in_rect_selects_intersecting_tiles() {
412        let g = grid();
413        // Rect over the first two columns of the first two rows: tiles
414        // (0,1) in row 0 and (4,5) in row 1.
415        let rect = Rect::new(10.0, 10.0, 150.0, 60.0);
416        let mut hits = g.hit_indices_in_rect(rect, 40, 400.0);
417        hits.sort();
418        assert_eq!(hits, vec![0, 1, 4, 5]);
419    }
420
421    #[test]
422    fn index_at_point_finds_tile_and_gap() {
423        let g = grid();
424        // Point inside tile 2 (x 200..300, y 0..50).
425        assert_eq!(
426            g.index_at_point(Point::new(250.0, 25.0), 40, 400.0),
427            Some(2)
428        );
429        // Point in row 1, column 0 → index 4.
430        assert_eq!(g.index_at_point(Point::new(10.0, 60.0), 40, 400.0), Some(4));
431        // Point beyond the last item.
432        assert_eq!(g.index_at_point(Point::new(10.0, 9000.0), 40, 400.0), None);
433    }
434
435    #[test]
436    fn insertion_index_at_row_gap_does_not_fall_through_to_len() {
437        // 12 items, 4 cols → 3 rows. y=53 sits in the row-gap between row 0
438        // (0..50) and row 1 (60..110), closer to row 0; x=50 is inside
439        // column 0. Before the fix this always fell through to `len` (12)
440        // because `index_at_point` returns None for any non-tile point.
441        let g = gapped_grid();
442        let idx = g.insertion_index_at(Point::new(50.0, 53.0), 12, 430.0);
443        assert!(
444            idx < 12,
445            "a mid-grid row-gap point must not fall through to len, got {idx}"
446        );
447    }
448
449    #[test]
450    fn insertion_index_at_col_gap_yields_next_tile() {
451        // Row 0: tile 0 spans x 0..100, the gap spans 100..110, tile 1
452        // spans 110..210. A point in the gap (x=105) must insert BEFORE
453        // tile 1 — i.e. resolve to index 1 — not fall through to `len`.
454        let g = gapped_grid();
455        let idx = g.insertion_index_at(Point::new(105.0, 25.0), 12, 430.0);
456        assert_eq!(
457            idx, 1,
458            "a point in the col-gap between tiles 0 and 1 should insert before tile 1"
459        );
460    }
461
462    #[test]
463    fn insertion_index_at_past_last_tile_yields_len() {
464        let g = gapped_grid();
465        let idx = g.insertion_index_at(Point::new(50.0, 9000.0), 12, 430.0);
466        assert_eq!(
467            idx, 12,
468            "a point past the last tile should append at the end"
469        );
470    }
471}