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}