Skip to main content

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}