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}