teksilo_widgets/grid_view.rs
1// SPDX-License-Identifier: MPL-2.0
2// SPDX-FileCopyrightText: 2026 FernTech
3
4//! Virtualized 2D tile grid bound to a `ListModel<T>` / `ListDataSource`.
5//!
6//! `GridView` is the photo-gallery / icon-view / file-manager-grid /
7//! collection-view widget — the 2D sibling of [`ListView`](crate::list_view::ListView)
8//! and [`TableView`](crate::table_view::TableView). It realizes only the
9//! tiles currently visible (plus a buffer), reflows on resize, supports
10//! single / multi selection with 2D keyboard navigation, and is fully
11//! accessible (`Role::Grid` → `Role::GridCell`).
12//!
13//! The layout is pluggable via `GridLayoutStrategy`;
14//! The layout is pluggable via `GridLayoutStrategy`: the stock
15//! [`UniformGrid`] gives fixed tile size / fixed column count /
16//! adaptive min-width grids, [`VariableRowGrid`] sizes each row to its
17//! tallest tile, and [`VirtualizedMasonry`] flows items into the
18//! currently-shortest column. Marquee selection, drag-reorder, sections
19//! and sticky headers are layered over whichever one is in force.
20//!
21//! ```ignore
22//! GridView::new(model, |tc| {
23//! Box::new(Card::new().child(TextWidget::new(lit!(&tc.item.name))))
24//! })
25//! .sizing(GridSizing::Adaptive { min_width: 120.0, max_width: None, height: 140.0 })
26//! .spacing(8.0)
27//! .selection(selection_model)
28//! ```
29//!
30//! ## Pan to scroll
31//!
32//! The view installs [`common::scrollable::ScrollableBehavior`](crate::common::scrollable::ScrollableBehavior),
33//! which gives it the shared wheel arithmetic, a finger's pan and the
34//! `PanClaim` that puts it on a pan's claimant chain. A pan scrolls it, the
35//! release coasts, and a pan it cannot absorb hands the **whole** event to the
36//! container outside — never a residual. Vertical only, despite the grid: this
37//! view owns no horizontal offset, so a horizontal pan is declined and chains
38//! outward. A pan that starts on a tile scrolls rather than activating it.
39//!
40//! ## The rubber band, and why it is not on this node
41//!
42//! In [`teksilo_data::SelectionMode::Multi`] a drag on the empty background
43//! sweeps a selection rectangle. That drag deliberately does **not** live on
44//! this view's own node, which is the one carrying the `PanClaim`.
45//!
46//! The reason it *was* structural has since been fixed in the framework: a node
47//! that both declares a claim and carries `on_drag` is now given a say through
48//! `PointerSequence::defer_own_drag`, which resolves its
49//! [`teksilo_tokens::DragActivation`] against the claim it is competing with.
50//! Before that arm existed the claim holder's own drag latched at `drag_slop`
51//! and decided the sequence before the claim could win at `pan_slop`, so a
52//! `Multi`-selection grid did not scroll under a finger from anywhere at all,
53//! and a finger on the background swept a band immediately rather than after a
54//! hold.
55//!
56//! What the surface still buys is **scope**, and that is why it stays. This
57//! root's children are the body pane, the scrollbar, the pinned section header,
58//! the empty view, the loading view and the focus overlay — all siblings, most
59//! of them filling the same rectangle. A marquee hung here would be a drag
60//! ancestor of every one of them; hung on a `DragSurface` that encloses the body
61//! pane alone, it sweeps the background and nothing else. The wrapper is also
62//! not this view's to remove in any case: the four row views wrap each **row**
63//! in one, and a row is not a claimant, so `defer_own_drag` — which refuses
64//! anything but a live `MemberRole::Pan` member — cannot reach it. See
65//! `crate::data_views::DragSurface`.
66//!
67//! So the body pane carries a no-op tap that gives it an arena of its own (the
68//! press is captured *inside* the surface, not by it) and the marquee's drag
69//! hangs on a `DragSurface` that strictly encloses the pane — the shape the tree
70//! arms `DragActivation` for through its ancestor walk, which is what makes the
71//! marquee wait for a long press under a finger and latch at 5 dp under a mouse.
72//!
73//! Every **tile** carries that same no-op tap as well, for a reason with nothing
74//! to do with dragging. With the pane holding one, a tile without an arena of
75//! its own leaves the *pane* as the press captor — and a release is dispatched
76//! to the captor and then bubbled target→root, which never reaches a tile
77//! beneath it. A plain selectable grid lost both its finger tap and, under a
78//! mouse, the release that collapses a multi-selection that way.
79
80pub(crate) mod a11y;
81pub(crate) mod body_pane;
82pub(crate) mod drag;
83pub(crate) mod keyboard;
84pub mod layout;
85pub mod sections;
86pub(crate) mod selection;
87#[cfg(test)]
88mod tests;
89
90use std::cell::{Cell, RefCell};
91use std::collections::BTreeSet;
92use std::rc::Rc;
93
94use teksilo_canvas::{EdgeInsets, Point, Rect, Size, SizeProposal};
95use teksilo_core::accessibility::{AccessNodeBuilder, widget_id_to_node_id};
96use teksilo_core::binding::BindingLevel;
97use teksilo_core::build_context::BuildContext;
98use teksilo_core::drag_payload::DragPayload;
99use teksilo_core::event::{EventResponse, WidgetEvent};
100use teksilo_core::kinetic::KineticScroller;
101use teksilo_core::pointer::touch_action::PanAxes;
102use teksilo_core::signal::{Prop, Signal};
103use teksilo_core::styles::GridViewStyle;
104use teksilo_core::widget::{LayoutContext, PaintContext, Widget, WidgetPlacement};
105use teksilo_core::widget_builder::HandlerSet;
106use teksilo_core::widget_id::WidgetId;
107use teksilo_data::{
108 DataChange, DropPosition, DropResponse, ListModel, SelectionMode, SelectionModel,
109};
110use teksilo_tokens::{OverscrollStyle, SurfaceRole};
111
112use std::time::Duration;
113
114use crate::common::scroll::OverscrollBehavior;
115use crate::data_views::{DragTransferMode, RowDragData, ViewId, ViewKind, flat_insertion_target};
116use crate::list_source::ListSource;
117use crate::primitives::TextWidget;
118use crate::scroll_area::ScrollBarMode;
119use crate::scroll_bar::{ScrollBar, ScrollBarOrientation, ScrollBarVisual};
120
121use body_pane::{GridBodyPane, TileDelegate};
122use keyboard::{GridKeyConfig, build_grid_key_handler};
123use layout::masonry::VirtualizedMasonry;
124use layout::sectioned::SectionedGrid;
125use layout::strategy::{GridLayoutStrategy, TileRect};
126use layout::uniform::UniformGrid;
127use layout::variable_row::VariableRowGrid;
128use sections::{SectionData, SectionProvider};
129use selection::{MarqueeConfig, MarqueeState, build_marquee_handler};
130
131pub use sections::{GroupingSections, SectionProvider as GridSectionProvider, grouping_sections};
132
133/// Which layout strategy `GridView` builds.
134#[derive(Debug, Clone, Copy)]
135enum StrategyKind {
136 /// Fixed row height (the default).
137 Uniform,
138 /// Each row sized to its tallest tile; `estimated` seeds unmeasured rows.
139 VariableRow { estimated: f32 },
140 /// Pinterest-style column-balanced waterfall; per-item variable height.
141 Waterfall { estimated: f32 },
142}
143
144pub use keyboard::GridTabTraversal;
145pub use layout::{GridSizing, ScrollAnchor};
146
147/// The erased `can_accept` closure type carried by the grid's source.
148type CanAcceptFn = Rc<dyn Fn(&DragPayload, usize, DropPosition, ViewId) -> DropResponse>;
149
150/// Whether a drop at flat insertion `idx` is allowed: the source accepts it
151/// (same-view reorder, or a source that handles the foreign payload directly),
152/// the grid accepts foreign exported tiles via `accept_foreign_rows`, OR it is
153/// a foreign payload and the grid carries an app-level `on_item_drop` handler.
154fn drop_allowed<T: 'static>(
155 can_accept: &CanAcceptFn,
156 payload: &DragPayload,
157 idx: usize,
158 len: usize,
159 view_id: ViewId,
160 has_drop_cb: bool,
161 export: &crate::data_views::RowExport<T>,
162) -> bool {
163 match flat_insertion_target(idx, len) {
164 Some((target, position)) => match (can_accept)(payload, target, position, view_id) {
165 DropResponse::Accept | DropResponse::Redirect(_) => true,
166 DropResponse::Reject => {
167 let foreign = is_foreign::<T>(payload, view_id);
168 foreign && (has_drop_cb || export.accepts_foreign_export(payload, view_id))
169 }
170 },
171 None => false,
172 }
173}
174
175/// A payload is foreign to this grid when it is not a `RowDragData<T>`
176/// originating here (an external app/OS drop, or a tile dragged from
177/// another view).
178fn is_foreign<T: 'static>(payload: &DragPayload, view_id: ViewId) -> bool {
179 payload
180 .get_typed::<RowDragData<T>>()
181 .is_none_or(|rd| rd.source != view_id)
182}
183
184/// Scrollbar thickness, matching `ListView` / `TableView`.
185const SCROLLBAR_THICKNESS: f32 = 12.0;
186
187/// Context passed to the tile delegate for each realized tile.
188///
189/// Richer than `ListView`'s `(index, &item, selected)` — carries the 2D
190/// grid coordinates and focus state (mirrors `TableView`'s `CellContext`).
191/// There is intentionally **no** `is_hovered`: hover changes on every
192/// mouse-move and is handled per-tile inside the delegate's own widget
193/// (its interaction signal), never by rebuilding the grid.
194pub struct TileContext<'a, T: 'static> {
195 /// Flat model index.
196 pub index: usize,
197 /// Row in the logical grid (0-based).
198 pub row: usize,
199 /// Column in the logical grid (0-based).
200 pub col: usize,
201 /// Borrow of the item.
202 pub item: &'a T,
203 /// Whether this tile is in the selection set.
204 pub is_selected: bool,
205 /// Whether this tile is the keyboard-focus current item. A build-time
206 /// snapshot — the canonical focus indicator is the grid's painted focus
207 /// ring (it does not rebuild tiles), so a delegate reading this for
208 /// custom styling accepts a one-rebuild lag.
209 pub is_focused: bool,
210}
211
212/// A virtualized 2D tile grid backed by a `ListModel<T>`.
213pub struct GridView<T: 'static> {
214 source: ListSource<T>,
215 delegate: TileDelegate<T>,
216
217 // Layout configuration (consumed when the strategy is first built).
218 /// The resolved tile sizing. When `sizing_signal` is set (a reactive
219 /// `.sizing(signal)`), `build()` refreshes this from the signal and rebuilds
220 /// the cached strategy on change — the slider-driven live-resize path.
221 sizing: GridSizing,
222 /// Reactive tile sizing, if bound via `.sizing(impl Into<Prop<GridSizing>>)`.
223 /// `None` for the static `.sizing(GridSizing::…)` / `.tile_size` / `.column_count`
224 /// sugar. Mirrors `TabWidget`'s `sizing: Option<Signal<TabSizing>>`.
225 sizing_signal: Option<Signal<GridSizing>>,
226 col_gap: f32,
227 row_gap: f32,
228 inset: EdgeInsets,
229 strategy_kind: StrategyKind,
230 /// Exact per-item natural height (the variable-height fast-path).
231 #[allow(clippy::type_complexity)]
232 exact_item_height: Option<Rc<dyn Fn(usize) -> f32>>,
233 /// Lazily built on first `build()` and cached so variable-height
234 /// strategies keep their measurement caches across rebuilds.
235 strategy: Option<Rc<dyn GridLayoutStrategy>>,
236
237 // Selection / focus
238 selection: Option<SelectionModel>,
239 #[allow(clippy::type_complexity)]
240 on_selection_changed: Option<Rc<dyn Fn(&BTreeSet<usize>)>>,
241 focused_index: Signal<Option<usize>>,
242 /// Enable rubber-band marquee (default true; only active in Multi mode).
243 marquee_selection: bool,
244 marquee: Signal<Option<MarqueeState>>,
245
246 // Keyboard
247 wrap_navigation: bool,
248 tab_traversal: GridTabTraversal,
249
250 // Scroll
251 show_scrollbar: bool,
252 overscroll_behavior: OverscrollBehavior,
253 /// Animate wheel scrolling instead of snapping to the new offset.
254 /// Enabled by default — mirrors `ScrollArea`.
255 smooth_scrolling: bool,
256 /// Duration of the smooth scroll animation.
257 smooth_scroll_duration: Duration,
258 /// How the scroll bar is displayed. Defaults to `Permanent` (reserves
259 /// a layout column); `Overlay` / `Thin` float over the content.
260 scroll_bar_style: ScrollBarMode,
261 scroll_y: Signal<f32>,
262 max_scroll_y: Signal<f32>,
263 viewport_ratio_y: Signal<f32>,
264 /// Live column count for the current viewport width. Written in
265 /// `place_children`; drives the body pane's reflow rebuild on resize and
266 /// is read by the keyboard handler.
267 column_count: Signal<usize>,
268
269 // Drag-to-reorder + drop
270 reorderable: bool,
271 #[allow(clippy::type_complexity)]
272 on_item_drop: Option<
273 Rc<
274 dyn Fn(
275 teksilo_core::drag_payload::DragPayload,
276 usize,
277 &mut teksilo_core::widget::EventContext,
278 ) -> bool,
279 >,
280 >,
281 /// Insertion index during a reorder drag (painted by `GridOverlay`).
282 insertion: Signal<Option<usize>>,
283 /// Stable, kind-tagged ID for this GridView instance (identifies its own
284 /// reorder vs. a foreign drop, even across widget kinds / windows).
285 model_id: ViewId,
286
287 /// Cross-widget export / foreign-receive machinery — the builders
288 /// (`.exportable`, `.export_external`, `.accept_foreign_rows`,
289 /// `.on_rows_received`, `.on_rows_transferred_out`), the drag-start payload
290 /// build, and the move-out completion, shared by all five data views.
291 export: crate::data_views::RowExport<T>,
292
293 // Activation / context menu / type-ahead
294 #[allow(clippy::type_complexity)]
295 on_tile_activate: Option<Rc<dyn Fn(usize, &mut teksilo_core::widget::EventContext)>>,
296 /// Whether tile activation is a single or double click (default
297 /// `DoubleClick`). Enter always activates.
298 activate_on: crate::data_views::ActivateOn,
299 #[allow(clippy::type_complexity)]
300 tile_context_menu: Option<
301 Rc<
302 dyn Fn(
303 usize,
304 Point,
305 &mut teksilo_core::widget::EventContext,
306 ) -> Option<Box<dyn Widget>>,
307 >,
308 >,
309 type_ahead_timeout: std::time::Duration,
310 /// Accumulate-and-search state for type-ahead. A widget field, not a
311 /// handler local: the buffer has to survive the rebuild a selection or
312 /// scroll change triggers, or a two-key search resets between the
313 /// keystrokes. `common::type_ahead`'s module doc says as much, and
314 /// `ListView` has always held it this way.
315 type_ahead: Rc<crate::common::type_ahead::TypeAheadState>,
316 #[allow(clippy::type_complexity)]
317 type_ahead_label: Option<Rc<dyn Fn(usize) -> String>>,
318 /// Per-tile accessible name — sets each `GridCell`'s `Node::label` so a
319 /// screen reader announces a concise item name ("Title, Type") instead of
320 /// only the grid coordinates. `None` leaves the cell name to its contents.
321 #[allow(clippy::type_complexity)]
322 tile_a11y_label: Option<Rc<dyn Fn(usize) -> String>>,
323
324 // Empty / loading state
325 #[allow(clippy::type_complexity)]
326 empty_view: Option<Rc<dyn Fn() -> Box<dyn Widget>>>,
327 #[allow(clippy::type_complexity)]
328 loading_view: Option<Rc<dyn Fn() -> Box<dyn Widget>>>,
329 is_loading: Option<Prop<bool>>,
330 loading_id: Option<WidgetId>,
331
332 // Sections
333 section_data: Option<SectionData>,
334 #[allow(clippy::type_complexity)]
335 header_delegate: Option<Rc<dyn Fn(usize, &str) -> Box<dyn Widget>>>,
336 header_height: f32,
337 pinned_section_headers: bool,
338 current_section: Signal<usize>,
339 pinned_header_id: Option<WidgetId>,
340
341 // Accessibility
342 a11y_label: Option<String>,
343 /// Shared map (flat index → tile wrapper id), written by the body pane,
344 /// read by `accessibility` for `active_descendant` roving focus.
345 tile_map: Rc<std::cell::RefCell<Vec<(usize, WidgetId)>>>,
346
347 /// Per-call Tier-3 decoration style override (focus ring / marquee /
348 /// insertion bar / pinned header). `None` → theme slot → stock default.
349 style: Option<Rc<dyn GridViewStyle>>,
350
351 // Geometry (synchronous cells, read within the layout pass)
352 viewport_width: Rc<Cell<f32>>,
353 viewport_height: Rc<Cell<f32>>,
354 /// This surface's pan physics: the range a finger's pan is clamped to and
355 /// the offset it is currently holding. Owned by the view rather than by
356 /// the [`ScrollableBehavior`](crate::common::scrollable::ScrollableBehavior)
357 /// so it survives a rebuild, and so `place_children` — the only pass that
358 /// knows the viewport extent — can publish into it.
359 scroller: Rc<RefCell<KineticScroller>>,
360 /// The grid body pane's absolute (window) origin, published by
361 /// `GridBodyPane::place_children` (`None` until laid out). Shared into the
362 /// keyboard handler so it can chase the focused tile into any enclosing
363 /// scroll area (`ctx.ensure_visible`).
364 viewport_origin: Rc<Cell<Option<Point>>>,
365 /// Remembered scrollbar decision so each layout queries the strategy at a
366 /// single, stable body width — querying at two widths per frame would
367 /// thrash a variable strategy's per-row measurement cache.
368 last_needs_scrollbar: Cell<bool>,
369
370 // Build state
371 body_pane_id: Option<WidgetId>,
372 empty_id: Option<WidgetId>,
373 scrollbar_id: Option<WidgetId>,
374 overlay_id: Option<WidgetId>,
375
376 /// Whole-view enabled state, statically or reactively. Forwarded to the
377 /// arena via `ctx.enabled_when(self_id, self.enabled.clone())` at build
378 /// time; a disabled view greys out and stops accepting focus /
379 /// selection / keyboard input (arena-gated).
380 enabled: Prop<bool>,
381}
382
383impl<T: 'static> GridView<T> {
384 /// Create a grid backed by a `ListModel<T>`. The `delegate` builds the
385 /// widget for each tile from a [`TileContext`].
386 pub fn new(
387 model: ListModel<T>,
388 delegate: impl Fn(&TileContext<'_, T>) -> Box<dyn Widget> + 'static,
389 ) -> Self {
390 Self::create(ListSource::from_model(model), delegate)
391 }
392
393 /// Create a grid backed by any `ListDataSource` (large / external data).
394 pub fn from_source<S: teksilo_data::ListDataSource<Item = T>>(
395 source: S,
396 delegate: impl Fn(&TileContext<'_, T>) -> Box<dyn Widget> + 'static,
397 ) -> Self {
398 Self::create(ListSource::from_data_source(source), delegate)
399 }
400
401 fn create(
402 source: ListSource<T>,
403 delegate: impl Fn(&TileContext<'_, T>) -> Box<dyn Widget> + 'static,
404 ) -> Self {
405 Self {
406 source,
407 delegate: Rc::new(delegate),
408 sizing: GridSizing::Adaptive {
409 min_width: 120.0,
410 max_width: None,
411 height: 120.0,
412 },
413 sizing_signal: None,
414 col_gap: 8.0,
415 row_gap: 8.0,
416 inset: EdgeInsets::ZERO,
417 strategy_kind: StrategyKind::Uniform,
418 exact_item_height: None,
419 strategy: None,
420 selection: None,
421 on_selection_changed: None,
422 focused_index: Signal::new(None),
423 marquee_selection: true,
424 marquee: Signal::new(None),
425 wrap_navigation: false,
426 tab_traversal: GridTabTraversal::OutOfGrid,
427 show_scrollbar: true,
428 overscroll_behavior: OverscrollBehavior::default(),
429 smooth_scrolling: true,
430 smooth_scroll_duration: Duration::from_millis(150),
431 scroll_bar_style: ScrollBarMode::Permanent,
432 scroll_y: Signal::new_animated(0.0),
433 max_scroll_y: Signal::new(0.0),
434 viewport_ratio_y: Signal::new(1.0),
435 column_count: Signal::new(1),
436 reorderable: false,
437 on_item_drop: None,
438 insertion: Signal::new(None),
439 model_id: ViewId::next(ViewKind::Grid),
440 export: crate::data_views::RowExport::default(),
441 on_tile_activate: None,
442 activate_on: crate::data_views::ActivateOn::default(),
443 tile_context_menu: None,
444 type_ahead_timeout: std::time::Duration::from_millis(500),
445 type_ahead: crate::common::type_ahead::TypeAheadState::new(),
446 type_ahead_label: None,
447 tile_a11y_label: None,
448 empty_view: None,
449 loading_view: None,
450 is_loading: None,
451 loading_id: None,
452 section_data: None,
453 header_delegate: None,
454 header_height: 28.0,
455 pinned_section_headers: false,
456 current_section: Signal::new(0),
457 pinned_header_id: None,
458 a11y_label: None,
459 tile_map: Rc::new(std::cell::RefCell::new(Vec::new())),
460 style: None,
461 viewport_width: Rc::new(Cell::new(400.0)),
462 viewport_height: Rc::new(Cell::new(400.0)),
463 scroller: Rc::new(RefCell::new(KineticScroller::new(OverscrollStyle::Clamp))),
464 viewport_origin: Rc::new(Cell::new(None)),
465 last_needs_scrollbar: Cell::new(false),
466 body_pane_id: None,
467 empty_id: None,
468 scrollbar_id: None,
469 overlay_id: None,
470 enabled: Prop::Static(true),
471 }
472 }
473
474 /// Enable or disable the whole view. A disabled view greys out and stops
475 /// accepting focus / selection / keyboard input (arena-gated).
476 pub fn enabled(mut self, enabled: impl Into<Prop<bool>>) -> Self {
477 self.enabled = enabled.into();
478 self
479 }
480
481 // ── Tile sizing & layout ────────────────────────────────────────────
482
483 /// Set the tile sizing / column-count policy.
484 ///
485 /// Accepts a plain [`GridSizing`] (static) **or** a `Signal<GridSizing>`
486 /// (reactive). A bound signal is observed at [`BindingLevel::Rebuild`]: when
487 /// it changes, `build()` rebuilds the cached layout strategy and reflows —
488 /// the internal `scroll_y` / `focused_index` / selection are field signals on
489 /// the same widget instance, so they survive the rebuild (no scroll jump).
490 /// This is the card-size-slider path; mirrors
491 /// [`TabWidget::sizing`](crate::TabWidget::sizing).
492 pub fn sizing(mut self, sizing: impl Into<Prop<GridSizing>>) -> Self {
493 let sig = sizing.into().as_signal();
494 self.sizing = sig.get();
495 self.sizing_signal = Some(sig);
496 self
497 }
498
499 /// Sugar for [`GridSizing::Fixed`] — every tile is exactly `width` × `height`.
500 pub fn tile_size(mut self, width: f32, height: f32) -> Self {
501 self.sizing = GridSizing::Fixed { width, height };
502 self.sizing_signal = None;
503 self
504 }
505
506 /// Sugar for [`GridSizing::FixedColumnCount`] — exactly `count` columns.
507 pub fn column_count(mut self, count: usize, tile_height: f32) -> Self {
508 self.sizing = GridSizing::FixedColumnCount {
509 count,
510 height: tile_height,
511 };
512 self.sizing_signal = None;
513 self
514 }
515
516 /// Switch to variable row heights: each row is sized to its tallest
517 /// tile (SwiftUI `LazyVGrid` semantics). `estimated` seeds rows that
518 /// haven't been measured yet; the scroll position is anchored when an
519 /// estimate is later corrected. Combine with
520 /// [`item_height`](Self::item_height) for exact heights.
521 pub fn variable_row_heights(mut self, estimated: f32) -> Self {
522 self.strategy_kind = StrategyKind::VariableRow {
523 estimated: estimated.max(1.0),
524 };
525 self
526 }
527
528 /// Supply an exact per-**item** natural height. Width-independent, so it
529 /// doesn't depend on the runtime column count: `VariableRowGrid` sizes
530 /// each row to `max(item_height(i))` over its items. Implies variable row
531 /// heights, gives an exact scrollbar, and removes anchoring jitter.
532 pub fn item_height(mut self, f: impl Fn(usize) -> f32 + 'static) -> Self {
533 self.exact_item_height = Some(Rc::new(f));
534 if matches!(self.strategy_kind, StrategyKind::Uniform) {
535 self.strategy_kind = StrategyKind::VariableRow {
536 estimated: self.sizing.tile_height().max(1.0),
537 };
538 }
539 self
540 }
541
542 /// Switch to a Pinterest-style waterfall: per-item variable heights flow
543 /// into the currently-shortest column. Column count comes from the
544 /// configured [`sizing`](Self::sizing); heights are auto-measured (or
545 /// exact via [`item_height`](Self::item_height)). `estimated` seeds
546 /// unmeasured items.
547 pub fn waterfall(mut self, estimated: f32) -> Self {
548 self.strategy_kind = StrategyKind::Waterfall {
549 estimated: estimated.max(1.0),
550 };
551 self
552 }
553
554 // ── Spacing & insets ────────────────────────────────────────────────
555
556 /// Horizontal gap between tiles (default 8).
557 pub fn column_spacing(mut self, spacing: f32) -> Self {
558 self.col_gap = spacing.max(0.0);
559 self
560 }
561
562 /// Vertical gap between tile rows (default 8).
563 pub fn row_spacing(mut self, spacing: f32) -> Self {
564 self.row_gap = spacing.max(0.0);
565 self
566 }
567
568 /// Set both column and row spacing.
569 pub fn spacing(mut self, spacing: f32) -> Self {
570 self.col_gap = spacing.max(0.0);
571 self.row_gap = spacing.max(0.0);
572 self
573 }
574
575 /// Inset from the scroll-content edge to the tiles.
576 pub fn content_inset(mut self, inset: EdgeInsets) -> Self {
577 self.inset = inset;
578 self
579 }
580
581 // ── Selection ───────────────────────────────────────────────────────
582
583 /// Set the selection model (modes `None` / `Single` / `Multi`).
584 pub fn selection(mut self, sel: SelectionModel) -> Self {
585 self.selection = Some(sel);
586 self
587 }
588
589 /// Called whenever the selection set changes — including programmatic
590 /// changes — with the new set of selected indices.
591 pub fn on_selection_changed(mut self, f: impl Fn(&BTreeSet<usize>) + 'static) -> Self {
592 self.on_selection_changed = Some(Rc::new(f));
593 self
594 }
595
596 /// Enable / disable rubber-band marquee selection (default enabled; only
597 /// active when the selection model is in `Multi` mode).
598 pub fn marquee_selection(mut self, enabled: bool) -> Self {
599 self.marquee_selection = enabled;
600 self
601 }
602
603 // ── Keyboard ────────────────────────────────────────────────────────
604
605 /// Whether arrow navigation wraps across row/grid edges (default false).
606 pub fn wrap_navigation(mut self, enabled: bool) -> Self {
607 self.wrap_navigation = enabled;
608 self
609 }
610
611 /// How Tab moves out of (or within) the grid (default `OutOfGrid`).
612 pub fn tab_traversal(mut self, traversal: GridTabTraversal) -> Self {
613 self.tab_traversal = traversal;
614 self
615 }
616
617 // ── Scrolling ───────────────────────────────────────────────────────
618
619 /// Suppress the internal scrollbar (mount your own via the signal
620 /// accessors so it survives rebuilds).
621 pub fn show_scrollbar(mut self, show: bool) -> Self {
622 self.show_scrollbar = show;
623 self
624 }
625
626 /// Scroll-chaining behavior at the boundary (default `Chain`).
627 pub fn overscroll_behavior(mut self, behavior: OverscrollBehavior) -> Self {
628 self.overscroll_behavior = behavior;
629 self
630 }
631
632 /// Enable or disable animated wheel scrolling (enabled by default).
633 pub fn smooth_scrolling(mut self, enabled: bool) -> Self {
634 self.smooth_scrolling = enabled;
635 self
636 }
637
638 /// Duration of the smooth scroll animation (default 150 ms).
639 pub fn smooth_scroll_duration(mut self, duration: Duration) -> Self {
640 self.smooth_scroll_duration = duration;
641 self
642 }
643
644 /// How the scroll bar is displayed (default `Permanent`). `Overlay`
645 /// and `Thin` float the bar over the content instead of reserving a
646 /// layout column, mirroring `ScrollArea::scroll_bar_style`.
647 pub fn scroll_bar_style(mut self, style: ScrollBarMode) -> Self {
648 self.scroll_bar_style = style;
649 self
650 }
651
652 /// The vertical scroll offset signal.
653 pub fn scroll_y_signal(&self) -> &Signal<f32> {
654 &self.scroll_y
655 }
656
657 /// The maximum scroll offset signal (`content_height - viewport_height`).
658 pub fn max_scroll_y_signal(&self) -> &Signal<f32> {
659 &self.max_scroll_y
660 }
661
662 /// The vertical viewport-to-content ratio signal (drives the thumb size).
663 pub fn viewport_ratio_y_signal(&self) -> &Signal<f32> {
664 &self.viewport_ratio_y
665 }
666
667 /// Scroll the minimum distance to bring `index` into view per `anchor`.
668 pub fn ensure_index_visible(&self, index: usize, anchor: ScrollAnchor) {
669 let Some(ref strategy) = self.strategy else {
670 return;
671 };
672 if let Some(target) = scroll_for_ensure_visible(
673 strategy.as_ref(),
674 index,
675 self.scroll_y.get(),
676 self.viewport_height.get(),
677 self.viewport_width.get(),
678 self.max_scroll_y.get(),
679 anchor,
680 ) {
681 self.scroll_y.set(target);
682 }
683 }
684
685 /// Scroll to `index`, forcing the viewport position per `anchor`
686 /// (`Auto` behaves like [`ensure_index_visible`](Self::ensure_index_visible)).
687 pub fn scroll_to_index(&self, index: usize, anchor: ScrollAnchor) {
688 self.ensure_index_visible(index, anchor);
689 }
690
691 /// Scroll the tile the keyboard is on into view when this grid takes
692 /// focus.
693 ///
694 /// Only the tiles near the viewport are realized, so on a grid taller than
695 /// the window the current tile frequently has no widget. Everything that
696 /// speaks for it then has nothing to speak about: no node carries
697 /// `selected`, no tile id is in `tile_map` for `accessibility` to nominate
698 /// as the `active_descendant`, and a screen reader taking focus here is
699 /// told nothing at all. The first arrow press steps *past* that tile as
700 /// well, because the cursor was somewhere the user was never shown.
701 ///
702 /// `ScrollAnchor::Auto` (`scroll_delta_to_reveal` returns 0 for a tile
703 /// already fully visible, `grid_view/layout/strategy.rs:194-202`) rather
704 /// than a forced anchor: a tile already on screen must not jump under
705 /// somebody who can see it. `Center` would, and by a lot: a fully visible
706 /// tile on the fifth row of a 300px viewport gets dragged 107px to reach
707 /// the middle. It is also the anchor arrow navigation already scrolls with
708 /// (`grid_view/keyboard.rs:348-355`), so taking focus and then stepping
709 /// move the viewport by the same rule.
710 ///
711 /// The cursor is `focused_index` else the first selected tile, the same
712 /// one the keyboard steps from (`grid_view/keyboard.rs:113-121`) and the
713 /// same one the context-menu key targets, so focus reveals the tile the
714 /// next keystroke will act on.
715 ///
716 /// Index to offset is asked of the layout strategy rather than computed
717 /// here, because a grid wraps and the wrap point is not this widget's to
718 /// know: `UniformGrid::tile_rect` (`grid_view/layout/uniform.rs:87-99`)
719 /// puts item `i` on row `i / column_count(viewport_width)` at
720 /// `inset.top + row * row_step()`, so the offset depends on the width the
721 /// last layout settled on, and a waterfall strategy has no row formula at
722 /// all, its items dropping into whichever column is shortest.
723 /// `viewport_width` is the body width `place_children` published
724 /// (`grid_view.rs:1789`) and asked the strategy for its column count
725 /// (`:1791`), the scrollbar column already subtracted, so the reveal and
726 /// the layout wrap at the same place.
727 ///
728 /// The handles are cloned into the effect rather than reaching through
729 /// `self`, which the closure cannot borrow. `strategy` comes from the
730 /// caller because `ensure_strategy` has just built it there and takes
731 /// `&mut self`.
732 fn reveal_focused_tile_on_focus(
733 &self,
734 ctx: &mut BuildContext,
735 strategy: Rc<dyn GridLayoutStrategy>,
736 ) {
737 // Keyed on this grid's own id, not on the enclosing scope: a grid
738 // nested inside another data view's rows would otherwise read that
739 // view's focus, since `view_focus_active` prefers whatever scope is
740 // open on the build stack (`build_context.rs:576-585`). Begin/end
741 // around nothing leaves the stack as it was
742 // (`widget_tree/focus_impl.rs:751-760`).
743 let view_focused = ctx.begin_view_focus();
744 ctx.end_view_focus();
745
746 let scroll_y = self.scroll_y.clone();
747 let max_scroll_y = self.max_scroll_y.clone();
748 let viewport_height = self.viewport_height.clone();
749 let viewport_width = self.viewport_width.clone();
750 let focused_index = self.focused_index.clone();
751 let selection = self.selection.clone();
752
753 ctx.effect(&view_focused, move |focused| {
754 if !*focused {
755 return;
756 }
757 let Some(index) = focused_index.get().or_else(|| {
758 selection
759 .as_ref()
760 .and_then(|s| s.selected_indices().first().copied())
761 }) else {
762 return;
763 };
764 if let Some(target) = scroll_for_ensure_visible(
765 strategy.as_ref(),
766 index,
767 scroll_y.get(),
768 viewport_height.get(),
769 viewport_width.get(),
770 max_scroll_y.get(),
771 ScrollAnchor::Auto,
772 ) {
773 scroll_y.set(target);
774 }
775 });
776 }
777
778 // ── Accessibility / empty state ─────────────────────────────────────
779
780 // ── Sections ────────────────────────────────────────────────────────
781
782 /// Group the flat model into sections, rendering a header above each
783 /// section's tile band. Sections compose with the uniform tile layout.
784 pub fn sections<P: SectionProvider>(mut self, provider: P) -> Self {
785 let provider = Rc::new(provider);
786 let counts_provider = provider.clone();
787 let title_provider = provider.clone();
788 self.section_data = Some(SectionData {
789 counts_fn: Rc::new(move || counts_provider.section_counts()),
790 title_fn: Rc::new(move |s| title_provider.section_title(s)),
791 });
792 self
793 }
794
795 /// Custom section-header widget builder `(section_index, title)`. Without
796 /// it a default bold-text header is used.
797 pub fn section_header_delegate(
798 mut self,
799 f: impl Fn(usize, &str) -> Box<dyn Widget> + 'static,
800 ) -> Self {
801 self.header_delegate = Some(Rc::new(f));
802 self
803 }
804
805 /// Height of each section header row (default 28).
806 pub fn section_header_height(mut self, height: f32) -> Self {
807 self.header_height = height.max(0.0);
808 self
809 }
810
811 /// Keep the current section's header pinned to the top while scrolling
812 /// through it (SwiftUI `pinnedViews:[.sectionHeaders]`).
813 pub fn pinned_section_headers(mut self, enabled: bool) -> Self {
814 self.pinned_section_headers = enabled;
815 self
816 }
817
818 /// Accessible label for the grid container.
819 pub fn a11y_label(mut self, label: impl Into<String>) -> Self {
820 self.a11y_label = Some(label.into());
821 self
822 }
823
824 /// Per-call Tier-3 decoration style override (focus ring, marquee,
825 /// insertion bar, pinned-header surface). Precedence: this override →
826 /// `theme.style_slots.grid_view` → the stock `RecipeGridViewStyle`.
827 pub fn style(mut self, style: impl GridViewStyle) -> Self {
828 self.style = Some(Rc::new(style));
829 self
830 }
831
832 /// Build the header-widget factory (section → widget) shared by the body
833 /// pane and the pinned slot, falling back to a default bold-text header.
834 #[allow(clippy::type_complexity)]
835 fn header_factory(&self) -> Option<Rc<dyn Fn(usize) -> Box<dyn Widget>>> {
836 let data = self.section_data.as_ref()?;
837 let title_fn = data.title_fn.clone();
838 let delegate = self.header_delegate.clone();
839 Some(Rc::new(move |section| {
840 let title = title_fn(section);
841 match &delegate {
842 Some(d) => d(section, &title),
843 None => Box::new(TextWidget::new(teksilo_i18n::lit!(title))) as Box<dyn Widget>,
844 }
845 }))
846 }
847
848 /// Widget shown when the model is empty.
849 pub fn empty_view(mut self, f: impl Fn() -> Box<dyn Widget> + 'static) -> Self {
850 self.empty_view = Some(Rc::new(f));
851 self
852 }
853
854 /// Widget overlaid while `is_loading` reads `true`.
855 pub fn loading_view(mut self, f: impl Fn() -> Box<dyn Widget> + 'static) -> Self {
856 self.loading_view = Some(Rc::new(f));
857 self
858 }
859
860 /// Reactive loading flag; when `true` the [`loading_view`](Self::loading_view)
861 /// is shown above the grid.
862 pub fn is_loading(mut self, flag: impl Into<Prop<bool>>) -> Self {
863 self.is_loading = Some(flag.into());
864 self
865 }
866
867 // ── Drag-to-reorder ─────────────────────────────────────────────────
868
869 /// Enable intra-grid drag reordering (and keyboard Alt+Arrow). The move is
870 /// routed through the source's `accept_drop` (a built-in `ListModel`
871 /// reorders via `move_item`; an external source applies its own command).
872 pub fn reorderable(mut self, enabled: bool) -> Self {
873 self.reorderable = enabled;
874 self
875 }
876
877 /// Make tiles **droppable outside this view** — on a
878 /// [`DropTarget`](crate::DropTarget), another data view, or the OS.
879 ///
880 /// A dragged tile (or the whole selection, when the pressed tile is part of
881 /// a multi-selection) carries clones of its items in a public
882 /// [`RowDragData<T>`](crate::RowDragData), so a foreign receiver can pull
883 /// them out with `payload.get_typed::<RowDragData<T>>()` /
884 /// `DropTarget::on_drop_typed::<RowDragData<T>>()` — no serialization. This
885 /// also makes tiles a drag source even without [`reorderable`](Self::reorderable).
886 ///
887 /// `mode` chooses what happens to the origin rows once a *foreign* target
888 /// accepts them: [`DragTransferMode::Move`] removes them (via the source's
889 /// `on_drag_out`, or [`on_rows_transferred_out`](Self::on_rows_transferred_out)),
890 /// [`DragTransferMode::Copy`] leaves them. A same-view reorder is never a
891 /// transfer, so `mode` never affects it. Requires `T: Clone`.
892 pub fn exportable(mut self, mode: DragTransferMode) -> Self
893 where
894 T: Clone,
895 {
896 self.export.set_exportable(mode);
897 self
898 }
899
900 /// Additionally advertise the dragged tiles as MIME data so they can be
901 /// dropped on a [`DropZone`](crate::DropZone) or exported to another
902 /// application / window via the OS. `f` maps the dragged items to
903 /// `(mime_type, bytes)` pairs (e.g. `text/plain`, `text/uri-list`, an
904 /// app-specific `application/x-…`). Implies [`exportable`](Self::exportable)
905 /// (defaulting to [`DragTransferMode::Move`] if not already set). Requires
906 /// `T: Clone`.
907 pub fn export_external(mut self, f: impl Fn(&[T]) -> Vec<(String, Vec<u8>)> + 'static) -> Self
908 where
909 T: Clone,
910 {
911 self.export.set_export_external(f);
912 self
913 }
914
915 /// Override how rows moved out to a foreign target are removed from this
916 /// view. Receives the dragged rows' indices (descending-safe) and the live
917 /// context. Without this, an [`exportable`](Self::exportable)
918 /// [`Move`](DragTransferMode::Move) drag removes them through the source's
919 /// `on_drag_out` (works out of the box for a `ListModel`).
920 pub fn on_rows_transferred_out(
921 mut self,
922 f: impl Fn(&[usize], &mut teksilo_core::widget::EventContext) + 'static,
923 ) -> Self {
924 self.export.set_on_rows_transferred_out(f);
925 self
926 }
927
928 /// Accept exported rows dropped from a **different** view or source without
929 /// writing a custom `ListDataSource`. Pair with
930 /// [`on_rows_received`](Self::on_rows_received), which is handed the dropped
931 /// items and the insertion index. (Same-view reorder is
932 /// [`reorderable`](Self::reorderable); a custom `ListDataSource` can still
933 /// accept foreign drops through its `can_accept`/`accept_drop` instead.)
934 pub fn accept_foreign_rows(mut self, accept: bool) -> Self {
935 self.export.accept_foreign_rows = accept;
936 self
937 }
938
939 /// Handler for rows accepted via [`accept_foreign_rows`](Self::accept_foreign_rows):
940 /// `(items, insertion_index, ctx)`. Insert them into your model at the
941 /// index.
942 pub fn on_rows_received(
943 mut self,
944 f: impl Fn(Vec<T>, usize, &mut teksilo_core::widget::EventContext) + 'static,
945 ) -> Self {
946 self.export.set_on_rows_received(f);
947 self
948 }
949
950 /// Accept external drops at a flat insertion index. Returns `true` when
951 /// the drop is accepted.
952 pub fn on_item_drop(
953 mut self,
954 f: impl Fn(
955 teksilo_core::drag_payload::DragPayload,
956 usize,
957 &mut teksilo_core::widget::EventContext,
958 ) -> bool
959 + 'static,
960 ) -> Self {
961 self.on_item_drop = Some(Rc::new(f));
962 self
963 }
964
965 // ── Activation / context menu / type-ahead / loading ────────────────
966
967 /// Called when a tile is activated (a click per [`activate_on`](Self::activate_on),
968 /// or Enter on the focused tile) — the "open / default action", distinct
969 /// from selection.
970 pub fn on_tile_activate(
971 mut self,
972 f: impl Fn(usize, &mut teksilo_core::widget::EventContext) + 'static,
973 ) -> Self {
974 self.on_tile_activate = Some(Rc::new(f));
975 self
976 }
977
978 /// Choose single- vs double-click tile activation (default
979 /// [`ActivateOn::DoubleClick`](crate::ActivateOn)). Enter activates in either
980 /// mode.
981 pub fn activate_on(mut self, mode: crate::data_views::ActivateOn) -> Self {
982 self.activate_on = mode;
983 self
984 }
985
986 /// Per-tile context-menu factory: `(index, pointer_position, ctx)` →
987 /// optional menu widget.
988 pub fn tile_context_menu(
989 mut self,
990 f: impl Fn(usize, Point, &mut teksilo_core::widget::EventContext) -> Option<Box<dyn Widget>>
991 + 'static,
992 ) -> Self {
993 self.tile_context_menu = Some(Rc::new(f));
994 self
995 }
996
997 /// Supply a per-item label for type-ahead navigation (typing letters
998 /// jumps to the first matching item). Required to enable type-ahead.
999 pub fn type_ahead_label(mut self, f: impl Fn(usize) -> String + 'static) -> Self {
1000 self.type_ahead_label = Some(Rc::new(f));
1001 self
1002 }
1003
1004 /// Supply a per-item accessible name applied to each tile's `GridCell`
1005 /// (`Node::label`), so a screen reader announces a concise item name in
1006 /// addition to the row/column position. Without it, the cell's name is left
1007 /// to its contents.
1008 pub fn tile_a11y_label(mut self, f: impl Fn(usize) -> String + 'static) -> Self {
1009 self.tile_a11y_label = Some(Rc::new(f));
1010 self
1011 }
1012
1013 /// Type-ahead reset timeout (default 500 ms; `ZERO` disables).
1014 pub fn type_ahead_timeout(mut self, timeout: std::time::Duration) -> Self {
1015 self.type_ahead_timeout = timeout;
1016 self
1017 }
1018
1019 // ── Internals ───────────────────────────────────────────────────────
1020
1021 /// Build (once) and return the layout strategy. Cached so variable
1022 /// strategies keep their measurement caches across rebuilds.
1023 fn ensure_strategy(&mut self) -> Rc<dyn GridLayoutStrategy> {
1024 if self.strategy.is_none() {
1025 // Sections override the strategy kind (uniform tiles + headers).
1026 if let Some(ref data) = self.section_data {
1027 let s: Rc<dyn GridLayoutStrategy> = Rc::new(SectionedGrid::new(
1028 self.sizing,
1029 self.col_gap,
1030 self.row_gap,
1031 self.inset,
1032 self.header_height,
1033 data.counts_fn.clone(),
1034 ));
1035 self.strategy = Some(s);
1036 return self.strategy.as_ref().unwrap().clone();
1037 }
1038 let s: Rc<dyn GridLayoutStrategy> = match self.strategy_kind {
1039 StrategyKind::Uniform => Rc::new(UniformGrid::new(
1040 self.sizing,
1041 self.col_gap,
1042 self.row_gap,
1043 self.inset,
1044 )),
1045 StrategyKind::VariableRow { estimated } => Rc::new(VariableRowGrid::new(
1046 self.sizing,
1047 self.col_gap,
1048 self.row_gap,
1049 self.inset,
1050 estimated,
1051 self.exact_item_height.clone(),
1052 )),
1053 StrategyKind::Waterfall { estimated } => Rc::new(VirtualizedMasonry::new(
1054 self.sizing,
1055 self.col_gap,
1056 self.row_gap,
1057 self.inset,
1058 estimated,
1059 self.exact_item_height.clone(),
1060 )),
1061 };
1062 self.strategy = Some(s);
1063 }
1064 self.strategy.as_ref().unwrap().clone()
1065 }
1066}
1067
1068impl<T: 'static> std::fmt::Debug for GridView<T> {
1069 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
1070 f.debug_struct("GridView")
1071 .field("items", &self.source.len())
1072 .field("scroll_bar_style", &self.scroll_bar_style)
1073 .field("scroll_y", &self.scroll_y.get())
1074 .finish()
1075 }
1076}
1077
1078/// The scroll offset that brings tile `index` into view per `anchor`, or
1079/// `None` when the offset already satisfies it.
1080///
1081/// Split out of [`GridView::ensure_index_visible`] so the reveal-on-focus
1082/// effect runs the same arithmetic as the public scroll-into-view API rather
1083/// than a second copy of it: the effect outlives any borrow of `self` and
1084/// holds cloned handles instead of the widget, so it cannot call the method.
1085/// Takes the geometry by value for the same reason. The keyboard has its own
1086/// call into `scroll_delta_to_reveal` (`grid_view/keyboard.rs:496-502`, in
1087/// `reveal_tile`), because it applies the delta to an enclosing scroll area as
1088/// well.
1089fn scroll_for_ensure_visible(
1090 strategy: &dyn GridLayoutStrategy,
1091 index: usize,
1092 scroll_y: f32,
1093 viewport_height: f32,
1094 viewport_width: f32,
1095 max_scroll_y: f32,
1096 anchor: ScrollAnchor,
1097) -> Option<f32> {
1098 let delta =
1099 strategy.scroll_delta_to_reveal(index, scroll_y, viewport_height, viewport_width, anchor);
1100 if delta.abs() <= 0.01 {
1101 return None;
1102 }
1103 Some((scroll_y + delta).clamp(0.0, max_scroll_y))
1104}
1105
1106impl<T: 'static> Widget for GridView<T> {
1107 fn build(&mut self, ctx: &mut BuildContext) -> Vec<WidgetId> {
1108 let self_id = ctx.self_id();
1109 ctx.enabled_when(self_id, self.enabled.clone());
1110
1111 // Reactive tile sizing (the card-size slider): observe the bound signal
1112 // at Rebuild, and when its value changes, drop the cached strategy so
1113 // `ensure_strategy` rebuilds it with the new sizing and the grid reflows.
1114 // Done before `ensure_strategy` so this build already uses the new value.
1115 if let Some(ref sig) = self.sizing_signal {
1116 sig.bind_to(self_id, ctx.binding_registry(), BindingLevel::Rebuild);
1117 let next = sig.get();
1118 if self.sizing != next {
1119 self.sizing = next;
1120 self.strategy = None;
1121 }
1122 }
1123
1124 let strategy = self.ensure_strategy();
1125
1126 // Rebuild trigger (data changes, empty/non-empty transition).
1127 let version = ctx.signal(0_u64);
1128 version.bind_to(ctx.self_id(), ctx.binding_registry(), BindingLevel::Rebuild);
1129
1130 // scroll_y at Relayout so place_children re-writes max_scroll/ratio.
1131 self.scroll_y.bind_to(
1132 ctx.self_id(),
1133 ctx.binding_registry(),
1134 BindingLevel::Relayout,
1135 );
1136 ctx.register_animated_signal(&self.scroll_y);
1137
1138 // Re-walk container a11y when selection / focus changes.
1139 if let Some(ref sel) = self.selection {
1140 sel.selection_signal().bind_to(
1141 ctx.self_id(),
1142 ctx.binding_registry(),
1143 BindingLevel::AccessibilityOnly,
1144 );
1145 }
1146 self.focused_index.bind_to(
1147 ctx.self_id(),
1148 ctx.binding_registry(),
1149 BindingLevel::AccessibilityOnly,
1150 );
1151
1152 // Taking focus scrolls the current tile into the realized window,
1153 // which is what gives `accessibility` a tile id to nominate.
1154 self.reveal_focused_tile_on_focus(ctx, strategy.clone());
1155
1156 // Observe model changes.
1157 {
1158 let v = version.clone();
1159 let counter = Rc::new(Cell::new(0_u64));
1160 let strategy_obs = strategy.clone();
1161 let selection_obs = self.selection.clone();
1162 let len_fn = self.source.len_fn.clone();
1163 let scroll_reset = self.scroll_y.clone();
1164 let focused_obs = self.focused_index.clone();
1165 let handle = (self.source.observe_fn)(Box::new(move |change| {
1166 match change {
1167 DataChange::ItemsInserted { range } => {
1168 strategy_obs.invalidate_rows(range.start..usize::MAX);
1169 strategy_obs.resize((len_fn)());
1170 if let Some(ref s) = selection_obs {
1171 s.adjust_for_insert(range.start, range.end - range.start);
1172 }
1173 }
1174 DataChange::ItemsRemoved { range } => {
1175 strategy_obs.invalidate_rows(range.start..usize::MAX);
1176 strategy_obs.resize((len_fn)());
1177 if let Some(ref s) = selection_obs {
1178 s.adjust_for_remove(range.start, range.end - range.start);
1179 }
1180 }
1181 DataChange::ItemsMoved { from, to, count } => {
1182 strategy_obs.invalidate_rows(0..usize::MAX);
1183 if let Some(ref s) = selection_obs {
1184 s.adjust_for_move(*from, *to, *count);
1185 }
1186 }
1187 DataChange::ItemUpdated { index } => {
1188 strategy_obs.invalidate_rows(*index..index + 1);
1189 }
1190 DataChange::WindowLoaded { range } => {
1191 strategy_obs.invalidate_rows(range.start..range.end);
1192 }
1193 DataChange::Reset => {
1194 strategy_obs.invalidate_rows(0..usize::MAX);
1195 strategy_obs.resize(0);
1196 if let Some(ref s) = selection_obs {
1197 s.clear();
1198 }
1199 scroll_reset.set(0.0);
1200 }
1201 }
1202 // Keep the keyboard-focus anchor in step too — otherwise it
1203 // silently points at the wrong tile after an insert / remove
1204 // / move (reachable not just from local edits but from a
1205 // live watcher pushing in a peer process's write), and the
1206 // next Enter/Space acts on the wrong item. Mirrors
1207 // `ListView`'s `focused_index` adjustment.
1208 if let Some(current) = focused_obs.get() {
1209 focused_obs.set(teksilo_data::data_change::adjust_single_index_for_change(
1210 current, change,
1211 ));
1212 }
1213 let next = counter.get() + 1;
1214 counter.set(next);
1215 v.set(next);
1216 }));
1217 ctx.own_handle(handle);
1218 }
1219
1220 // Fire on_selection_changed on every selection change (interactive
1221 // or programmatic). The framework's reactive observers don't carry
1222 // an EventContext, so the callback receives only the selection set.
1223 if let (Some(sel), Some(cb)) = (&self.selection, &self.on_selection_changed) {
1224 let cb = cb.clone();
1225 ctx.effect(&sel.selection_signal(), move |set| cb(set));
1226 }
1227
1228 // Rebuild when the loading flag toggles (shows/hides the overlay).
1229 if let Some(flag) = &self.is_loading {
1230 let v = version.clone();
1231 let c = Rc::new(Cell::new(0_u64));
1232 ctx.effect(&flag.as_signal(), move |_| {
1233 c.set(c.get() + 1);
1234 v.set(c.get());
1235 });
1236 }
1237
1238 // Self handlers: scroll wheel + keyboard.
1239 let mut handlers = HandlerSet::new().clips_children(true).focusable(true);
1240 {
1241 // The wheel arithmetic, the pan and the claim that puts this node
1242 // on a finger's claimant chain all come from `common::scrollable`.
1243 // A wheel still takes the path it always did —
1244 // `handle_scroll_event` branches on the scroll *source*, not the
1245 // phase.
1246 //
1247 // The notch size is snapshotted from the strategy in force at
1248 // build, which is what the hand-rolled handler did: a strategy
1249 // swapped after mount keeps the notch it was built with until the
1250 // next rebuild.
1251 let behavior = crate::common::scrollable::ScrollableBehavior::new(
1252 crate::common::scrollable::ScrollableAxes::vertical(
1253 self.scroll_y.clone(),
1254 self.max_scroll_y.clone(),
1255 ),
1256 )
1257 .with_scroller(self.scroller.clone())
1258 // Vertical only: this view owns no horizontal offset, so a
1259 // horizontal pan is declined and chains outward.
1260 .axes(PanAxes::Y)
1261 .overscroll(self.overscroll_behavior)
1262 .smooth(self.smooth_scrolling)
1263 .smooth_duration(self.smooth_scroll_duration)
1264 .line_height(strategy.estimated_row_height().max(1.0))
1265 .reduced_motion(ctx.prefers_reduced_motion())
1266 .physics(ctx.theme().input.scroll_physics);
1267 handlers = behavior.install(handlers);
1268 }
1269 // --- The non-drag reorder, all four routes at once ---
1270 //
1271 // SC 2.5.7 wants the tile drag reachable without a drag. One closure
1272 // performs the move; the chord below, the tile's context menu and the
1273 // tile's AccessKit custom actions all call it, so the routes cannot
1274 // reach different end states. The move travels the source's own
1275 // drop-accept path — see `common::ordered_move`.
1276 #[allow(clippy::type_complexity)]
1277 let reorder_perform: Option<
1278 Rc<dyn Fn(usize, usize, &mut teksilo_core::widget::EventContext)>,
1279 > = self.reorderable.then(|| {
1280 let mover = Rc::new(crate::common::ordered_move::RowMover {
1281 len: self.source.len_fn.clone(),
1282 stash: self.source.dnd.stash_drag_keys_fn.clone(),
1283 payload: {
1284 let model_id = self.model_id;
1285 Rc::new(move |idx: usize| {
1286 DragPayload::typed(RowDragData::<T> {
1287 source: model_id,
1288 rows: vec![idx],
1289 items: None,
1290 })
1291 })
1292 },
1293 accept: self.source.dnd.accept_drop_fn.clone(),
1294 view: self.model_id,
1295 name: {
1296 // The type-ahead label resolver, where the application
1297 // gave one; routed through the source's string accessor
1298 // so an unloaded row yields no name rather than a
1299 // fabricated one.
1300 let with_item_str = self.source.with_item_str_fn.clone();
1301 let label = self.type_ahead_label.clone();
1302 Rc::new(move |index: usize| {
1303 let label = label.as_ref()?;
1304 // `label` is index-keyed here (a grid's delegate
1305 // is), but the read still goes through the source so
1306 // an unloaded tile yields no name rather than one
1307 // computed for an absent item.
1308 (with_item_str)(index, &|_item: &T| label(index))
1309 })
1310 },
1311 });
1312 let focused = self.focused_index.clone();
1313 let selection = self.selection.clone();
1314 let strategy = strategy.clone();
1315 let scroll_y = self.scroll_y.clone();
1316 let max_scroll_y = self.max_scroll_y.clone();
1317 let viewport_height = self.viewport_height.clone();
1318 let viewport_width = self.viewport_width.clone();
1319 let viewport_origin = self.viewport_origin.clone();
1320 Rc::new(
1321 move |from: usize,
1322 destination: usize,
1323 ctx: &mut teksilo_core::widget::EventContext| {
1324 let Some((dest, utterance)) = mover.commit_to(from, destination) else {
1325 return;
1326 };
1327 focused.set(Some(dest));
1328 if let Some(ref sel) = selection {
1329 sel.select(dest);
1330 }
1331 keyboard::reveal_tile(
1332 &strategy,
1333 &scroll_y,
1334 &max_scroll_y,
1335 &viewport_height,
1336 &viewport_width,
1337 &viewport_origin,
1338 dest,
1339 ctx,
1340 );
1341 ctx.announce(utterance);
1342 },
1343 ) as Rc<dyn Fn(usize, usize, &mut teksilo_core::widget::EventContext)>
1344 });
1345
1346 handlers = handlers.on_key(build_grid_key_handler(GridKeyConfig {
1347 len_fn: self.source.len_fn.clone(),
1348 col_count: self.column_count.clone(),
1349 focused_index: self.focused_index.clone(),
1350 selection: self.selection.clone(),
1351 scroll_y: self.scroll_y.clone(),
1352 max_scroll_y: self.max_scroll_y.clone(),
1353 viewport_height: self.viewport_height.clone(),
1354 viewport_width: self.viewport_width.clone(),
1355 viewport_origin: self.viewport_origin.clone(),
1356 strategy: strategy.clone(),
1357 wrap_navigation: self.wrap_navigation,
1358 tab_traversal: self.tab_traversal,
1359 on_tile_activate: self.on_tile_activate.clone(),
1360 reorder_perform: reorder_perform.clone(),
1361 type_ahead_timeout: self.type_ahead_timeout,
1362 type_ahead: self.type_ahead.clone(),
1363 tile_map: self.tile_map.clone(),
1364 // Route through the source's string accessor so an unloaded
1365 // (lazy/windowed) row is skipped rather than searched with
1366 // whatever the app's index-only closure happens to compute for
1367 // it — mirrors `ListView::with_item_str_fn`. The public
1368 // `type_ahead_label(usize) -> String` API is unchanged; this
1369 // just gates it on row residency.
1370 type_ahead_label: self.type_ahead_label.as_ref().map(|label| {
1371 let label = label.clone();
1372 let with_item_str = self.source.with_item_str_fn.clone();
1373 Rc::new(move |i: usize| (with_item_str)(i, &|_item: &T| label(i)))
1374 as Rc<dyn Fn(usize) -> Option<String>>
1375 }),
1376 }));
1377
1378 // Rubber-band marquee (Multi mode only). A container pointer handler
1379 // records the modifier state at press time for additive selection;
1380 // the drag handler sweeps the rectangle — from the `DragSurface` that
1381 // wraps the body pane, not from this root. See below.
1382 let mut marquee_drag: Option<_> = None;
1383 let marquee_on = self.marquee_selection
1384 && self
1385 .selection
1386 .as_ref()
1387 .map(|s| s.mode() == SelectionMode::Multi)
1388 .unwrap_or(false);
1389 if marquee_on {
1390 let additive_mods = Rc::new(Cell::new(false));
1391 {
1392 let mods = additive_mods.clone();
1393 handlers = handlers.on_pointer_event(move |event, _ctx| {
1394 if let WidgetEvent::PointerDown { modifiers, .. } = event {
1395 mods.set(modifiers.command() || modifiers.shift());
1396 }
1397 EventResponse::Ignored
1398 });
1399 }
1400 // The marquee's `on_drag` does NOT go on this root, and that is
1401 // load-bearing rather than tidy. This root is the `PanClaim`
1402 // holder, and a drag on the node that also captures the press
1403 // latches at `drag_slop` (18 dp) before a claim can win at
1404 // `pan_slop` (36) — the claim is then never evaluated at all, so a
1405 // `Multi`-selection grid did not scroll under a finger *and* did
1406 // not marquee (the drag declines a press that lands on a tile).
1407 // Hanging it on the `DragSurface` below instead makes it a strict
1408 // ancestor of whatever takes the press, which is the one shape the
1409 // tree arms `DragActivation` for: `Immediate` for a mouse (5 dp,
1410 // exactly as before), `AfterLongPress` for a finger.
1411 marquee_drag = Some(build_marquee_handler(MarqueeConfig {
1412 marquee: self.marquee.clone(),
1413 selection: self.selection.clone().unwrap(),
1414 strategy: strategy.clone(),
1415 scroll_y: self.scroll_y.clone(),
1416 viewport_width: self.viewport_width.clone(),
1417 len_fn: self.source.len_fn.clone(),
1418 additive_mods,
1419 }));
1420
1421 // Viewport-edge auto-scroll while the marquee is active, so a
1422 // rubber-band selection can extend past the visible window —
1423 // matching `TabBar`/`TreeView`'s drag-tick edge-scroll. Those
1424 // ride `on_drag_tick`, which only fires for an `active_drag`
1425 // (a `DragPayload` session started via `start_drag`); the
1426 // marquee is a plain gesture-recognizer drag (`on_drag`) with
1427 // no such session, so it drives itself from the raw per-frame
1428 // handle instead — the same "owner-driven, non-visibility-
1429 // bound" path the rich-text editor's drag-select auto-scroll
1430 // uses. Not gated on reduced-motion: this is an interaction
1431 // (extending the selection), not decorative motion.
1432 let frame_request = ctx.frame_request_handle();
1433 let marquee_for_tick = self.marquee.clone();
1434 let scroll_for_tick = self.scroll_y.clone();
1435 let max_scroll_for_tick = self.max_scroll_y.clone();
1436 let viewport_h_for_tick = self.viewport_height.clone();
1437 ctx.effect(&ctx.frame_tick(), move |_delta| {
1438 let Some(st) = marquee_for_tick.get() else {
1439 return;
1440 };
1441 let step = selection::marquee_auto_scroll_step(
1442 st.current.y,
1443 viewport_h_for_tick.get(),
1444 st.kind,
1445 );
1446 if step != 0.0 {
1447 let max = max_scroll_for_tick.get();
1448 let new_y = (scroll_for_tick.get() + step).clamp(0.0, max);
1449 scroll_for_tick.set(new_y);
1450 // Still inside the edge band (or the marquee moved
1451 // again next frame) — keep the chain alive so the
1452 // pointer doesn't need to wiggle to keep scrolling.
1453 frame_request.set(true);
1454 }
1455 });
1456 }
1457
1458 // Drop target: intra-grid reorder + foreign-rows receive + external
1459 // drops, with an insertion indicator painted by the overlay.
1460 // Hover/drop are routed through the SOURCE's `can_accept` /
1461 // `accept_drop` (the pre-drop validation), so a same-view
1462 // `RowDragData<T>` reorders and a foreign payload is the source's
1463 // call — falling back to the zero-custom-source `accept_foreign_rows`
1464 // sugar, then the app-level `on_item_drop` escape hatch.
1465 if self.export.is_drop_target(self.reorderable) || self.on_item_drop.is_some() {
1466 let has_drop_cb = self.on_item_drop.is_some();
1467 let my_id = self.model_id;
1468
1469 let strategy_h = strategy.clone();
1470 let scroll_h = self.scroll_y.clone();
1471 let vp_w_h = self.viewport_width.clone();
1472 let len_h = self.source.len_fn.clone();
1473 let can_accept_h = self.source.dnd.can_accept_fn.clone();
1474 let insertion_h = self.insertion.clone();
1475 let export_for_hover = self.export.clone();
1476 handlers = handlers.on_drag_hover(move |payload, position, _ctx| {
1477 let len = (len_h)();
1478 let idx = drag::insertion_index(
1479 strategy_h.as_ref(),
1480 position,
1481 scroll_h.get(),
1482 vp_w_h.get(),
1483 len,
1484 );
1485 let allowed = drop_allowed::<T>(
1486 &can_accept_h,
1487 payload,
1488 idx,
1489 len,
1490 my_id,
1491 has_drop_cb,
1492 &export_for_hover,
1493 );
1494 if allowed {
1495 insertion_h.set(Some(idx));
1496 // Engage (stops drop-target bubbling); the overlay paints
1497 // the insertion bar, so no framework-drawn feedback.
1498 teksilo_core::DropFeedback::Accept
1499 } else {
1500 insertion_h.set(None);
1501 teksilo_core::DropFeedback::NoFeedback
1502 }
1503 });
1504
1505 let insertion_leave = self.insertion.clone();
1506 handlers = handlers.on_drag_leave(move |_ctx| {
1507 insertion_leave.set(None);
1508 });
1509
1510 let strategy_d = strategy.clone();
1511 let scroll_d = self.scroll_y.clone();
1512 let vp_w_d = self.viewport_width.clone();
1513 let len_d = self.source.len_fn.clone();
1514 let accept_drop_d = self.source.dnd.accept_drop_fn.clone();
1515 let drop_cb = self.on_item_drop.clone();
1516 let insertion_d = self.insertion.clone();
1517 let export_for_drop = self.export.clone();
1518 let reorderable_for_drop = self.reorderable;
1519 handlers = handlers.on_drop(move |mut payload, position, ctx| {
1520 insertion_d.set(None);
1521 let len = (len_d)();
1522 let to = drag::insertion_index(
1523 strategy_d.as_ref(),
1524 position,
1525 scroll_d.get(),
1526 vp_w_d.get(),
1527 len,
1528 );
1529 let is_same_view = payload
1530 .get_typed::<RowDragData<T>>()
1531 .is_some_and(|rd| rd.source == my_id);
1532 // (a) Same-view reorder + any source-handled drop go through
1533 // accept_drop first. A same-view drop only reorders when this
1534 // view is `reorderable` — otherwise it falls through and is
1535 // treated like a foreign payload (branches b/c).
1536 if (reorderable_for_drop || !is_same_view)
1537 && let Some((target, position_kind)) = flat_insertion_target(to, len)
1538 && (accept_drop_d)(&payload, target, position_kind, my_id)
1539 {
1540 // Only suppress our OWN move-out for a genuine same-view
1541 // drop.
1542 if is_same_view {
1543 export_for_drop.note_self_reorder();
1544 }
1545 return true;
1546 }
1547 // (b) Shared foreign-receive sugar: accept exported rows from
1548 // a different view/source without a custom ListDataSource.
1549 // Peeks before taking, so a payload that doesn't match
1550 // (same-view, or reorder-only) still reaches the raw escape
1551 // hatch (c) with its typed data intact.
1552 if export_for_drop.foreign_receive(&mut payload, my_id, to, ctx) {
1553 return true;
1554 }
1555 // (c) Raw escape hatch for any other payload the app wants to
1556 // handle itself.
1557 if let Some(ref cb) = drop_cb {
1558 return cb(payload, to, ctx);
1559 }
1560 false
1561 });
1562 }
1563 ctx.apply_self_handlers(handlers);
1564
1565 // Children: body pane (or empty view), scrollbar, overlay.
1566 // (Incremental loading — `request_window` / `fetch_more` — lives in the
1567 // body pane's realize loop now, driven by the source's `can_fetch_more`
1568 // / `fetch_more` capabilities; it fires on each scroll-buffer exit.)
1569 self.body_pane_id = None;
1570 self.empty_id = None;
1571 self.scrollbar_id = None;
1572 self.overlay_id = None;
1573 self.pinned_header_id = None;
1574
1575 let len = self.source.len();
1576 if len == 0 {
1577 self.tile_map.borrow_mut().clear();
1578 if let Some(ref ef) = self.empty_view {
1579 self.empty_id = Some(ctx.add_boxed(ef()));
1580 }
1581 } else {
1582 // Pane → root total refresh (measuring strategies): re-place
1583 // this root when the body pane's measurements changed the
1584 // content total, so `max_scroll_y` / the thumb ratio pick up
1585 // the corrected value next frame.
1586 let pane_total_refresh = ctx.signal(0_u64);
1587 pane_total_refresh.bind_to(
1588 ctx.self_id(),
1589 ctx.binding_registry(),
1590 teksilo_core::binding::BindingLevel::Relayout,
1591 );
1592 let pane = GridBodyPane {
1593 len_fn: self.source.len_fn.clone(),
1594 with_item_fn: self.source.with_item_fn.clone(),
1595 delegate: self.delegate.clone(),
1596 strategy: strategy.clone(),
1597 viewport_width: self.viewport_width.clone(),
1598 viewport_height: self.viewport_height.clone(),
1599 viewport_origin: self.viewport_origin.clone(),
1600 column_count: self.column_count.clone(),
1601 scroll_y: self.scroll_y.clone(),
1602 selection: self.selection.clone(),
1603 focused_index: self.focused_index.clone(),
1604 on_tile_activate: self.on_tile_activate.clone(),
1605 activate_on: self.activate_on,
1606 reorder_perform: reorder_perform.clone(),
1607 tile_context_menu: self.tile_context_menu.clone(),
1608 tile_a11y_label: self.tile_a11y_label.clone(),
1609 reorderable: self.reorderable,
1610 model_id: self.model_id,
1611 scope_owner: ctx.self_id(),
1612 drag_fn: self.source.dnd.drag_fn.clone(),
1613 row_state_fn: self.source.dnd.row_state_fn.clone(),
1614 request_window_fn: self.source.dnd.request_window_fn.clone(),
1615 can_fetch_more_fn: self.source.dnd.can_fetch_more_fn.clone(),
1616 fetch_more_fn: self.source.dnd.fetch_more_fn.clone(),
1617 export: self.export.clone(),
1618 read_item_fn: self.source.read_item_fn.clone(),
1619 snapshot_out_fn: self.source.dnd.snapshot_out_fn.clone(),
1620 tile_map: self.tile_map.clone(),
1621 header_factory: self.header_factory(),
1622 header_title: self.section_data.as_ref().map(|d| d.title_fn.clone()),
1623 // Fresh per GridView rebuild; persists across the
1624 // pane's own (buffer-exit / re-check) rebuilds.
1625 version: Signal::new(0_u64),
1626 prev_built_start: Rc::new(Cell::new(0)),
1627 prev_built_end: Rc::new(Cell::new(0)),
1628 total_refresh: pane_total_refresh,
1629 tile_entries: Vec::new(),
1630 tile_roots: Vec::new(),
1631 header_entries: Vec::new(),
1632 in_place_children: Cell::new(false),
1633 };
1634 let pane_id = ctx.add(pane);
1635 // A no-op tap gives the pane its own gesture arena, so a press on
1636 // the background *between* tiles is captured inside the
1637 // `DragSurface` rather than by it. Without that the surface would be
1638 // the captor and its own drag would win the arbitration at 18 dp,
1639 // which is the bug the surface exists to fix. A press on a tile is
1640 // captured by the tile's own absorber, deeper still — which it must
1641 // be, or the release would be dispatched here and bubbled outward,
1642 // never reaching the tile that recorded the press.
1643 ctx.apply_handlers(pane_id, crate::data_views::press_absorber());
1644 let surface_id = ctx.add(crate::data_views::DragSurface::new(pane_id));
1645 if let Some(drag) = marquee_drag.take() {
1646 ctx.apply_handlers(surface_id, HandlerSet::new().on_drag(drag));
1647 }
1648 self.body_pane_id = Some(surface_id);
1649
1650 let overlay = GridOverlay {
1651 focused_index: self.focused_index.clone(),
1652 // Grid root's inclusive focus signal (stack empty here → resolves
1653 // to this root) + input modality, so the ring is keyboard-only
1654 // and hides when the grid loses focus.
1655 view_focused: ctx.view_focus_active(),
1656 focus_visible: ctx.focus_visible(),
1657 selection: self.selection.clone(),
1658 scroll_y: self.scroll_y.clone(),
1659 strategy: strategy.clone(),
1660 viewport_width: self.viewport_width.clone(),
1661 marquee: self.marquee.clone(),
1662 insertion: self.insertion.clone(),
1663 style: self.style.clone(),
1664 len_fn: self.source.len_fn.clone(),
1665 };
1666 self.overlay_id = Some(ctx.add(overlay));
1667
1668 // Sticky pinned header slot (reused widget showing the current
1669 // section's header at the viewport top). Skipped when the
1670 // provider declares zero sections — `PinnedHeader::build` would
1671 // otherwise unconditionally invoke the factory at
1672 // `current_section`'s default (0), and a hand-rolled provider
1673 // indexing directly into its own section list would panic.
1674 self.pinned_header_id = None;
1675 let section_count = self
1676 .section_data
1677 .as_ref()
1678 .map(|d| (d.counts_fn)().len())
1679 .unwrap_or(0);
1680 if self.pinned_section_headers && section_count > 0 {
1681 if let Some(factory) = self.header_factory() {
1682 let ph = PinnedHeader {
1683 current_section: self.current_section.clone(),
1684 factory,
1685 child: None,
1686 style: self.style.clone(),
1687 };
1688 self.pinned_header_id = Some(ctx.add(ph));
1689 }
1690 }
1691 }
1692
1693 if self.show_scrollbar {
1694 let sb = ScrollBar::new(
1695 ScrollBarOrientation::Vertical,
1696 self.scroll_y.clone(),
1697 self.max_scroll_y.clone(),
1698 self.viewport_ratio_y.clone(),
1699 )
1700 .visual(match self.scroll_bar_style {
1701 ScrollBarMode::Permanent => ScrollBarVisual::Permanent,
1702 ScrollBarMode::Overlay => ScrollBarVisual::Overlay,
1703 ScrollBarMode::Thin => ScrollBarVisual::Thin,
1704 });
1705 self.scrollbar_id = Some(ctx.add(sb));
1706 }
1707
1708 // Loading overlay (on top of everything).
1709 self.loading_id = None;
1710 if let Some(flag) = &self.is_loading {
1711 if flag.get() {
1712 if let Some(ref lv) = self.loading_view {
1713 self.loading_id = Some(ctx.add_boxed(lv()));
1714 }
1715 }
1716 }
1717
1718 // Order = paint order. Overlay then loading paint last (on top).
1719 let mut children = Vec::new();
1720 if let Some(id) = self.body_pane_id {
1721 children.push(id);
1722 }
1723 if let Some(id) = self.empty_id {
1724 children.push(id);
1725 }
1726 if let Some(id) = self.scrollbar_id {
1727 children.push(id);
1728 }
1729 if let Some(id) = self.overlay_id {
1730 children.push(id);
1731 }
1732 if let Some(id) = self.pinned_header_id {
1733 children.push(id);
1734 }
1735 if let Some(id) = self.loading_id {
1736 children.push(id);
1737 }
1738 children
1739 }
1740
1741 fn layout_response(
1742 &self,
1743 proposal: SizeProposal,
1744 _ctx: &LayoutContext,
1745 ) -> teksilo_core::widget::LayoutResponse {
1746 // Only an allocation may seed the cached viewport (`common::viewport`);
1747 // the body pane shares these cells, and `build` sizes its realization
1748 // window — and the strategy its column count — from them.
1749 let size = crate::common::viewport::viewport_size(
1750 proposal,
1751 &self.viewport_height,
1752 Size::new(400.0, 400.0),
1753 );
1754 if proposal.width.is_some() {
1755 self.viewport_width.set(size.width);
1756 }
1757 size.into()
1758 }
1759
1760 fn place_children(
1761 &self,
1762 bounds: Rect,
1763 _proposal: SizeProposal,
1764 children: &mut [WidgetPlacement],
1765 _ctx: &LayoutContext,
1766 ) {
1767 let Some(ref strategy) = self.strategy else {
1768 return;
1769 };
1770 let len = self.source.len();
1771 let vp_h = bounds.height;
1772 // The rubber band's resistance is a fraction of the viewport. This
1773 // view does not band, but the scroller reads the extent either way and
1774 // this is the only pass that knows it.
1775 self.scroller
1776 .borrow_mut()
1777 .set_viewport(teksilo_canvas::Vec2::new(bounds.width, vp_h));
1778
1779 // Query the strategy at a SINGLE, stable body width per frame (using
1780 // the previous frame's scrollbar decision). Querying at two widths
1781 // would flip a variable strategy's column count back and forth and
1782 // reset its measurement cache every frame. The scrollbar appearing /
1783 // disappearing settles in one frame.
1784 // Permanent reserves a column for the bar; Overlay / Thin float
1785 // over the content, so tiles span the full width.
1786 let reserves_bar = self.scroll_bar_style == ScrollBarMode::Permanent;
1787 let body_w = if self.last_needs_scrollbar.get() && reserves_bar {
1788 (bounds.width - SCROLLBAR_THICKNESS).max(0.0)
1789 } else {
1790 bounds.width
1791 };
1792 self.viewport_width.set(body_w);
1793
1794 let cols = strategy.column_count(body_w).max(1);
1795 if self.column_count.get() != cols {
1796 self.column_count.set(cols);
1797 }
1798
1799 let total = strategy.total_content_height(len, body_w);
1800 let needs_sb = self.show_scrollbar && total > vp_h + 0.5;
1801 if self.last_needs_scrollbar.get() != needs_sb {
1802 self.last_needs_scrollbar.set(needs_sb);
1803 }
1804 let max_y = (total - vp_h).max(0.0);
1805 self.max_scroll_y.set(max_y);
1806 let ratio = if total > 0.0 {
1807 (vp_h / total).clamp(0.0, 1.0)
1808 } else {
1809 1.0
1810 };
1811 self.viewport_ratio_y.set(ratio);
1812 // Clamp scroll (matches ListView).
1813 let cur = self.scroll_y.get();
1814 let clamped = cur.clamp(0.0, max_y);
1815 if (clamped - cur).abs() > 0.001 {
1816 self.scroll_y.set(clamped);
1817 }
1818
1819 // Sticky pinned header: track the current section and decide whether
1820 // the in-flow header has scrolled above the top.
1821 let pinned_rect = if self.pinned_header_id.is_some() {
1822 let cur = strategy.current_section(self.scroll_y.get(), body_w);
1823 if let Some(cur) = cur {
1824 if self.current_section.get() != cur {
1825 self.current_section.set(cur);
1826 }
1827 // Show the pinned slot only once the real header is above top.
1828 strategy.header_rect(cur, body_w).map(|r| {
1829 let screen_y = bounds.y + r.y - self.scroll_y.get();
1830 let visible = screen_y < bounds.y - 0.5;
1831 (visible, r.height)
1832 })
1833 } else {
1834 None
1835 }
1836 } else {
1837 None
1838 };
1839
1840 let body_rect_origin = bounds.origin();
1841 let body_size = Size::new(body_w, vp_h);
1842 for child in children.iter_mut() {
1843 if Some(child.id) == self.scrollbar_id {
1844 if needs_sb {
1845 // Right edge in all modes — in Overlay / Thin `body_w`
1846 // spans the full width, so anchor off `bounds.width`.
1847 child.origin =
1848 Point::new(bounds.x + bounds.width - SCROLLBAR_THICKNESS, bounds.y);
1849 child.size = Size::new(SCROLLBAR_THICKNESS, vp_h);
1850 } else {
1851 child.origin = bounds.origin();
1852 child.size = Size::ZERO;
1853 }
1854 } else if Some(child.id) == self.pinned_header_id {
1855 match pinned_rect {
1856 Some((true, h)) => {
1857 child.origin = bounds.origin();
1858 child.size = Size::new(body_w, h);
1859 }
1860 _ => {
1861 child.origin = bounds.origin();
1862 child.size = Size::ZERO;
1863 }
1864 }
1865 } else {
1866 // body pane / empty view / overlay all fill the body rect.
1867 child.origin = body_rect_origin;
1868 child.size = body_size;
1869 }
1870 }
1871 }
1872
1873 fn accessibility(&self, builder: &mut AccessNodeBuilder) {
1874 builder.set_role(teksilo_core::accesskit::Role::Grid);
1875 if let Some(ref label) = self.a11y_label {
1876 builder.set_name(label.clone());
1877 }
1878
1879 let total = self.source.len();
1880 let cols = self.column_count.get().max(1);
1881 let rows = total.div_ceil(cols);
1882 builder.set_row_count(rows);
1883 builder.set_column_count(cols);
1884 // The set size belongs on the container, not on each tile: AccessKit's
1885 // `size_of_set` differs from ARIA's per-item `aria-setsize`, and
1886 // `size_of_set_from_container` resolves an item's set size by walking
1887 // *up* from it. The logical item count, not the realized window.
1888 if total > 0 {
1889 builder.set_size_of_set(total);
1890 }
1891
1892 if let Some(ref sel) = self.selection {
1893 if sel.mode() == SelectionMode::Multi {
1894 builder.set_multiselectable(true);
1895 }
1896 let count = sel.count();
1897 if count > 0 {
1898 builder.set_value(format!(
1899 "{} item{} selected",
1900 count,
1901 if count == 1 { "" } else { "s" }
1902 ));
1903 }
1904 builder.set_live(teksilo_core::accesskit::Live::Polite);
1905 }
1906
1907 // Roving focus: point active_descendant at the focused tile node.
1908 if let Some(idx) = self.focused_index.get() {
1909 let map = self.tile_map.borrow();
1910 if let Some((_, tile_id)) = map.iter().find(|(i, _)| *i == idx) {
1911 builder.set_active_descendant(widget_id_to_node_id(*tile_id));
1912 }
1913 }
1914 }
1915
1916 /// The context-menu key opens the *focused tile's* menu, not the grid's.
1917 ///
1918 /// A `GridView` is focusable and its tiles are not — the container owns
1919 /// focus and `active_descendant` above is what points assistive technology
1920 /// at the current tile. So the dispatcher's default of "the focused widget"
1921 /// would open the grid's own menu.
1922 ///
1923 /// The tile the user means is the roving cursor, else the first selected
1924 /// tile. Only realized tiles have a widget, so a cursor scrolled outside
1925 /// the virtualization window resolves to nothing and the menu falls back to
1926 /// the grid — which is right, since there is no tile on screen for it to be
1927 /// about.
1928 fn context_menu_key_target(&self) -> Option<WidgetId> {
1929 let index = self.focused_index.get().or_else(|| {
1930 self.selection
1931 .as_ref()
1932 .and_then(|s| s.selected_indices().first().copied())
1933 })?;
1934 let map = self.tile_map.borrow();
1935 map.iter().find(|(i, _)| *i == index).map(|(_, id)| *id)
1936 }
1937
1938 fn as_any(&self) -> Option<&dyn std::any::Any> {
1939 Some(self)
1940 }
1941
1942 fn children(&self) -> Vec<WidgetId> {
1943 let mut ids = Vec::new();
1944 if let Some(id) = self.body_pane_id {
1945 ids.push(id);
1946 }
1947 if let Some(id) = self.empty_id {
1948 ids.push(id);
1949 }
1950 if let Some(id) = self.scrollbar_id {
1951 ids.push(id);
1952 }
1953 if let Some(id) = self.overlay_id {
1954 ids.push(id);
1955 }
1956 if let Some(id) = self.pinned_header_id {
1957 ids.push(id);
1958 }
1959 if let Some(id) = self.loading_id {
1960 ids.push(id);
1961 }
1962 ids
1963 }
1964
1965 fn clips_children(&self) -> bool {
1966 true
1967 }
1968}
1969
1970/// A top-most, event-transparent leaf that paints the focus ring, the
1971/// marquee rectangle and the drag-insertion bar. Drawing
1972/// here rather than in the container sidesteps any parent-vs-child paint-order
1973/// ambiguity — a last sibling always paints over the tiles.
1974struct GridOverlay {
1975 focused_index: Signal<Option<usize>>,
1976 /// `true` while the grid (its root or a descendant) holds keyboard focus —
1977 /// the grid root's inclusive [`BuildContext::view_focus_active`] signal.
1978 /// Gates the focus ring so an unfocused grid shows none.
1979 view_focused: Signal<bool>,
1980 /// Input-modality `:focus-visible`. Gates the focus ring to keyboard
1981 /// navigation, never a mouse click.
1982 focus_visible: Signal<bool>,
1983 /// The grid's selection, for the **container focus ring**: when the grid is
1984 /// keyboard-focused but has no current tile *and* nothing is selected, no
1985 /// tile chrome marks the focus, so the whole grid outlines itself instead.
1986 selection: Option<SelectionModel>,
1987 scroll_y: Signal<f32>,
1988 strategy: Rc<dyn GridLayoutStrategy>,
1989 viewport_width: Rc<Cell<f32>>,
1990 marquee: Signal<Option<MarqueeState>>,
1991 insertion: Signal<Option<usize>>,
1992 style: Option<Rc<dyn GridViewStyle>>,
1993 /// Live item count — `focused_index` is adjusted on every model change,
1994 /// but paint reads a snapshot signal on a different binding level
1995 /// (`AccessibilityOnly` on the grid root vs `RepaintOnly` here), so a
1996 /// stale index can transiently outlive the adjustment. Bounds-check
1997 /// before drawing a ring at a tile that no longer exists.
1998 len_fn: Rc<dyn Fn() -> usize>,
1999}
2000
2001impl std::fmt::Debug for GridOverlay {
2002 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
2003 f.debug_struct("GridOverlay").finish()
2004 }
2005}
2006
2007impl GridOverlay {
2008 fn focus_recipe(&self, ctx: &PaintContext) -> teksilo_core::styles::GridFocusRingRecipe {
2009 resolve_grid_style(&self.style, ctx, |s| s.focus_ring())
2010 }
2011 fn marquee_recipe(&self, ctx: &PaintContext) -> teksilo_core::styles::GridMarqueeRecipe {
2012 resolve_grid_style(&self.style, ctx, |s| s.marquee())
2013 }
2014 fn insertion_recipe(&self, ctx: &PaintContext) -> teksilo_core::styles::GridInsertionRecipe {
2015 resolve_grid_style(&self.style, ctx, |s| s.insertion())
2016 }
2017}
2018
2019/// Geometry of the drag-reorder insertion bar: `(bar_x, row_rect)`, where
2020/// `bar_x` is the bar's CENTER x and `row_rect` supplies its `y`/`height`.
2021/// When `ins < len` this is the LEADING edge of the target tile
2022/// `tile_rect(ins)` — using the target row (not the previous tile's row)
2023/// is what keeps the bar on the correct row at a row boundary, where
2024/// `ins` is the first index of a new row. When `ins >= len` (append) it's
2025/// the trailing edge of the last tile. `None` for an empty grid.
2026fn insertion_bar_geometry(
2027 strategy: &dyn GridLayoutStrategy,
2028 ins: usize,
2029 len: usize,
2030 viewport_width: f32,
2031) -> Option<(f32, TileRect)> {
2032 if len == 0 {
2033 return None;
2034 }
2035 if ins < len {
2036 let r = strategy.tile_rect(ins, viewport_width);
2037 Some((r.x, r))
2038 } else {
2039 let r = strategy.tile_rect(len - 1, viewport_width);
2040 Some((r.x + r.width, r))
2041 }
2042}
2043
2044/// Resolve a decoration recipe from the per-call override → theme slot →
2045/// stock default.
2046fn resolve_grid_style<R: Default>(
2047 override_style: &Option<Rc<dyn GridViewStyle>>,
2048 ctx: &PaintContext,
2049 f: impl Fn(&dyn GridViewStyle) -> R,
2050) -> R {
2051 if let Some(s) = override_style {
2052 f(s.as_ref())
2053 } else if let Some(s) = ctx.theme.style_slots.grid_view.as_ref() {
2054 f(s.as_ref())
2055 } else {
2056 R::default()
2057 }
2058}
2059
2060impl Widget for GridOverlay {
2061 fn build(&mut self, ctx: &mut BuildContext) -> Vec<WidgetId> {
2062 // Repaint on focus / scroll / marquee / insertion change.
2063 self.scroll_y.bind_to(
2064 ctx.self_id(),
2065 ctx.binding_registry(),
2066 BindingLevel::RepaintOnly,
2067 );
2068 self.focused_index.bind_to(
2069 ctx.self_id(),
2070 ctx.binding_registry(),
2071 BindingLevel::RepaintOnly,
2072 );
2073 self.view_focused.bind_to(
2074 ctx.self_id(),
2075 ctx.binding_registry(),
2076 BindingLevel::RepaintOnly,
2077 );
2078 self.focus_visible.bind_to(
2079 ctx.self_id(),
2080 ctx.binding_registry(),
2081 BindingLevel::RepaintOnly,
2082 );
2083 if let Some(ref sel) = self.selection {
2084 sel.selection_signal().bind_to(
2085 ctx.self_id(),
2086 ctx.binding_registry(),
2087 BindingLevel::RepaintOnly,
2088 );
2089 }
2090 self.marquee.bind_to(
2091 ctx.self_id(),
2092 ctx.binding_registry(),
2093 BindingLevel::RepaintOnly,
2094 );
2095 self.insertion.bind_to(
2096 ctx.self_id(),
2097 ctx.binding_registry(),
2098 BindingLevel::RepaintOnly,
2099 );
2100 // Transparent to pointer events so the body beneath stays interactive.
2101 ctx.apply_self_handlers(HandlerSet::new().event_pass_through(true));
2102 Vec::new()
2103 }
2104
2105 fn layout_response(
2106 &self,
2107 proposal: SizeProposal,
2108 _ctx: &LayoutContext,
2109 ) -> teksilo_core::widget::LayoutResponse {
2110 proposal.resolve(0.0, 0.0).into()
2111 }
2112
2113 fn paint(&self, bounds: Rect, canvas: &mut teksilo_canvas::Canvas, ctx: &PaintContext) {
2114 // Marquee rectangle (in widget-local coords → offset by bounds origin).
2115 if let Some(m) = self.marquee.get() {
2116 let lr = m.local_rect(self.scroll_y.get());
2117 let rect = Rect::new(bounds.x + lr.x, bounds.y + lr.y, lr.width, lr.height);
2118 let recipe = self.marquee_recipe(ctx);
2119 let c = recipe.role.resolve(&ctx.theme.colors);
2120 let fill = teksilo_tokens::Color::new(c.r(), c.g(), c.b(), recipe.fill_alpha);
2121 canvas.fill_rect(rect, fill);
2122 canvas.stroke_rect(rect, c, recipe.stroke_width);
2123 }
2124
2125 // Drag-reorder insertion bar: a vertical accent bar at the leading
2126 // edge of the target tile (or trailing edge of the last tile when
2127 // appending).
2128 if let Some(ins) = self.insertion.get()
2129 && let Some((bar_x, r)) =
2130 insertion_bar_geometry(self.strategy.as_ref(), ins, (self.len_fn)(), bounds.width)
2131 {
2132 let scroll_y = self.scroll_y.get();
2133 let y = bounds.y + r.y - scroll_y;
2134 let h = r.height;
2135 if y + h >= bounds.y && y <= bounds.bottom() {
2136 let recipe = self.insertion_recipe(ctx);
2137 let color = recipe.role.resolve(&ctx.theme.colors);
2138 let t = recipe.thickness;
2139 canvas.fill_rect(Rect::new(bounds.x + bar_x - t * 0.5, y, t, h), color);
2140 }
2141 }
2142
2143 // Focus ring — keyboard-only (`:focus-visible`) and only while the grid
2144 // holds focus, so a mouse click never leaves a ring.
2145 if !self.view_focused.get() || !self.focus_visible.get() {
2146 return;
2147 }
2148 // A stale index (outlived by a not-yet-applied model-change
2149 // adjustment) can't draw a ring at a tile that no longer exists —
2150 // treat it the same as "no current tile".
2151 let idx = self.focused_index.get().filter(|&i| i < (self.len_fn)());
2152 let Some(idx) = idx else {
2153 // No current tile. If nothing is selected either, no tile chrome
2154 // marks the focus — outline the whole grid so a Tab-focused empty
2155 // grid still shows where focus landed (mirrors TreeView / ListView).
2156 let empty = self.selection.as_ref().is_none_or(|s| s.count() == 0);
2157 if empty {
2158 let inset = 1.0_f32;
2159 let rect = Rect::new(
2160 bounds.x + inset,
2161 bounds.y + inset,
2162 (bounds.width - inset * 2.0).max(0.0),
2163 (bounds.height - inset * 2.0).max(0.0),
2164 );
2165 let color = teksilo_tokens::BorderRole::Focused.resolve(&ctx.theme.colors);
2166 canvas.stroke_rect(rect, color, 1.5);
2167 }
2168 return;
2169 };
2170 let vp_w = bounds.width;
2171 let r = self.strategy.tile_rect(idx, vp_w);
2172 let scroll_y = self.scroll_y.get();
2173 let recipe = self.focus_recipe(ctx);
2174 let inset = recipe.inset;
2175 let stroke = recipe.thickness;
2176 let rx = bounds.x + r.x + inset;
2177 let ry = bounds.y + r.y - scroll_y + inset;
2178 let rw = (r.width - inset * 2.0).max(0.0);
2179 let rh = (r.height - inset * 2.0).max(0.0);
2180 // Cull if fully outside the viewport.
2181 if ry + rh < bounds.y || ry > bounds.bottom() {
2182 return;
2183 }
2184 let color = recipe.role.resolve(&ctx.theme.colors);
2185 canvas.fill_rect(Rect::new(rx, ry, rw, stroke), color); // top
2186 canvas.fill_rect(Rect::new(rx, ry + rh - stroke, rw, stroke), color); // bottom
2187 canvas.fill_rect(Rect::new(rx, ry, stroke, rh), color); // left
2188 canvas.fill_rect(Rect::new(rx + rw - stroke, ry, stroke, rh), color); // right
2189 }
2190
2191 fn accessibility(&self, builder: &mut AccessNodeBuilder) {
2192 builder.set_hidden();
2193 }
2194}
2195
2196/// The reused sticky-header slot: rebuilds its child from the section header
2197/// factory whenever the current section changes, and paints an opaque
2198/// background so tiles scrolling underneath don't show through.
2199struct PinnedHeader {
2200 current_section: Signal<usize>,
2201 #[allow(clippy::type_complexity)]
2202 factory: Rc<dyn Fn(usize) -> Box<dyn Widget>>,
2203 child: Option<WidgetId>,
2204 style: Option<Rc<dyn GridViewStyle>>,
2205}
2206
2207impl std::fmt::Debug for PinnedHeader {
2208 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
2209 f.debug_struct("PinnedHeader")
2210 .field("section", &self.current_section.get())
2211 .finish()
2212 }
2213}
2214
2215impl Widget for PinnedHeader {
2216 fn build(&mut self, ctx: &mut BuildContext) -> Vec<WidgetId> {
2217 self.current_section
2218 .bind_to(ctx.self_id(), ctx.binding_registry(), BindingLevel::Rebuild);
2219 let section = self.current_section.get();
2220 let id = ctx.add_boxed((self.factory)(section));
2221 self.child = Some(id);
2222 vec![id]
2223 }
2224
2225 fn layout_response(
2226 &self,
2227 proposal: SizeProposal,
2228 _ctx: &LayoutContext,
2229 ) -> teksilo_core::widget::LayoutResponse {
2230 proposal.resolve(0.0, 0.0).into()
2231 }
2232
2233 fn place_children(
2234 &self,
2235 bounds: Rect,
2236 _proposal: SizeProposal,
2237 children: &mut [WidgetPlacement],
2238 _ctx: &LayoutContext,
2239 ) {
2240 for child in children.iter_mut() {
2241 child.origin = bounds.origin();
2242 child.size = bounds.size();
2243 }
2244 }
2245
2246 fn paint(&self, bounds: Rect, canvas: &mut teksilo_canvas::Canvas, ctx: &PaintContext) {
2247 if bounds.height > 0.5 {
2248 let surface = self
2249 .style
2250 .as_ref()
2251 .or(ctx.theme.style_slots.grid_view.as_ref())
2252 .map(|s| s.pinned_header_surface())
2253 .unwrap_or(SurfaceRole::Raised);
2254 canvas.fill_rect(bounds, surface.resolve(&ctx.theme.colors));
2255 }
2256 }
2257
2258 fn children(&self) -> Vec<WidgetId> {
2259 self.child.into_iter().collect()
2260 }
2261
2262 fn clips_children(&self) -> bool {
2263 true
2264 }
2265}
2266
2267#[cfg(test)]
2268mod focus_reveal_tests {
2269 //! Taking focus brings the current tile into the realized window.
2270 //!
2271 //! The other half of `active_descendant`: `GridView::accessibility` can
2272 //! only nominate a tile that has a widget, and virtualization means the
2273 //! current one usually has none until the grid is scrolled to it.
2274
2275 use super::*;
2276 use teksilo_core::widget::LayoutContext;
2277 use teksilo_core::widget_tree::WidgetTree;
2278
2279 #[derive(Debug)]
2280 struct FixedLeaf(f32, f32);
2281 impl Widget for FixedLeaf {
2282 fn layout_response(
2283 &self,
2284 _proposal: SizeProposal,
2285 _ctx: &LayoutContext,
2286 ) -> teksilo_core::widget::LayoutResponse {
2287 Size::new(self.0, self.1).into()
2288 }
2289 }
2290
2291 /// A 300-item grid of 100x50 tiles carrying `selection`, laid out once at
2292 /// 400x300. That width holds three columns, so the items wrap into 100
2293 /// rows of 58px and the viewport shows about five of them.
2294 fn grid_with_selection(selection: &SelectionModel) -> (WidgetTree, WidgetId) {
2295 let model = ListModel::from_vec((0..300).collect::<Vec<usize>>());
2296 let mut tree = WidgetTree::new();
2297 let id = tree.add(
2298 GridView::new(model, |_tc| Box::new(FixedLeaf(100.0, 50.0)))
2299 .tile_size(100.0, 50.0)
2300 .selection(selection.clone()),
2301 );
2302 tree.layout(SizeProposal::exact(400.0, 300.0));
2303 (tree, id)
2304 }
2305
2306 /// The flat index of every realized tile marked selected, read back off
2307 /// the accessibility tree, since that is what the failure was about: the
2308 /// tile has to be a node a platform can name.
2309 ///
2310 /// `position_in_set` reads 0-based here even though `TileA11y` writes the
2311 /// 1-based ARIA number (`grid_view/a11y.rs:88`):
2312 /// `AccessNodeBuilder::set_position_in_set`
2313 /// (`teksilo-core/src/accessibility.rs:726-729`) subtracts the 1 through
2314 /// `to_accesskit_ordinal` (`:359-366`), so the value in a snapshot is the
2315 /// flat index itself.
2316 fn selected_positions(tree: &WidgetTree) -> Vec<usize> {
2317 tree.accessibility_tree_snapshot()
2318 .nodes
2319 .iter()
2320 .filter(|(_, node)| node.is_selected() == Some(true))
2321 .filter_map(|(_, node)| node.position_in_set())
2322 .collect()
2323 }
2324
2325 /// A selection made before the grid is ever looked at is off-window, so
2326 /// nothing carries it into the tree until focus scrolls to it.
2327 #[test]
2328 fn taking_focus_reveals_the_current_tile() {
2329 let selection = SelectionModel::new(SelectionMode::Single);
2330 selection.select(150);
2331 let (mut tree, id) = grid_with_selection(&selection);
2332
2333 assert!(
2334 selected_positions(&tree).is_empty(),
2335 "tile 150 sits fifty rows below the realized window, which is the \
2336 case this is about"
2337 );
2338
2339 tree.focus(id);
2340 tree.layout(SizeProposal::exact(400.0, 300.0));
2341
2342 assert_eq!(
2343 selected_positions(&tree),
2344 vec![150],
2345 "taking focus has to bring the current tile into the realized \
2346 window, or nothing in the tree can be told about it"
2347 );
2348 }
2349
2350 /// The window-space rect of a realized tile, read out of the same
2351 /// `tile_map` the body pane writes and `accessibility` nominates from
2352 /// (`grid_view.rs:1904-1910`). `None` for a tile outside the
2353 /// virtualization window, which has no widget and so no rect.
2354 fn tile_bounds(tree: &WidgetTree, grid: WidgetId, index: usize) -> Option<Rect> {
2355 let tile = tree
2356 .widget_as_any(grid)
2357 .and_then(|any| any.downcast_ref::<GridView<usize>>())
2358 .and_then(|g| {
2359 g.tile_map
2360 .borrow()
2361 .iter()
2362 .find(|(i, _)| *i == index)
2363 .map(|(_, id)| *id)
2364 })?;
2365 Some(tree.bounds(tile))
2366 }
2367
2368 /// And a tile already on screen does not lurch when the grid is clicked
2369 /// into: `ScrollAnchor::Auto`, not a forced anchor.
2370 ///
2371 /// Tile 12 is the one that catches a forced anchor. Three columns of 58px
2372 /// row step put it on the fifth row, spanning y 232..282 of a 300px
2373 /// viewport: fully visible, and far enough down that `Center` would scroll
2374 /// by about 107px to drag it to the middle. A tile on the first row proves
2375 /// nothing here, because centering row 0 asks for a negative offset that
2376 /// the clamp to `0.0..=max_scroll_y` turns back into no movement at all.
2377 #[test]
2378 fn taking_focus_does_not_move_a_tile_already_in_view() {
2379 let selection = SelectionModel::new(SelectionMode::Single);
2380 selection.select(12);
2381 let (mut tree, id) = grid_with_selection(&selection);
2382
2383 let scroll = tree
2384 .widget_as_any(id)
2385 .and_then(|any| any.downcast_ref::<GridView<usize>>())
2386 .map(|g| g.scroll_y_signal().clone())
2387 .expect("the grid is the widget at `id`");
2388 let before = scroll.get();
2389
2390 let rect = tile_bounds(&tree, id, 12).expect("tile 12 is realized");
2391 assert!(
2392 rect.y >= 0.0 && rect.y + rect.height <= 300.0,
2393 "this case only means anything while tile 12 is fully on screen, \
2394 and it spans y {}..{} of a 300px viewport",
2395 rect.y,
2396 rect.y + rect.height
2397 );
2398
2399 tree.focus(id);
2400 tree.layout(SizeProposal::exact(400.0, 300.0));
2401
2402 assert_eq!(
2403 scroll.get(),
2404 before,
2405 "tile 12 is already fully visible, so taking focus must not scroll \
2406 the grid under somebody who can see it"
2407 );
2408 }
2409}