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`: the stock
14//! [`UniformGrid`] gives fixed tile size / fixed column count /
15//! adaptive min-width grids, [`VariableRowGrid`] sizes each row to its
16//! tallest tile, and [`VirtualizedMasonry`] flows items into the
17//! currently-shortest column. Marquee selection, drag-reorder, sections
18//! and sticky headers are layered over whichever one is in force.
19//!
20//! ```ignore
21//! GridView::new(model, |tc| {
22//!     Box::new(Card::new().child(TextWidget::new(lit!(&tc.item.name))))
23//! })
24//! .sizing(GridSizing::Adaptive { min_width: 120.0, max_width: None, height: 140.0 })
25//! .spacing(8.0)
26//! .selection(selection_model)
27//! ```
28//!
29//! ## Column bounds and alignment
30//!
31//! [`GridView::min_columns`] and [`GridView::max_columns`] bound the column
32//! count that [`GridSizing::Fixed`] and [`GridSizing::Adaptive`] derive from the
33//! width ([`GridSizing::FixedColumnCount`] ignores both). A floor the width
34//! cannot afford **narrows** the tiles to share it, never overflowing
35//! horizontally; the tile height stays the sizing's. [`GridView::column_alignment`]
36//! places a block of columns narrower than the content at the leading edge (the
37//! default), the centre or the trailing edge, swapping sides in a right-to-left
38//! layout. Section headers and the detail band keep spanning the content width;
39//! tiles, the rubber band, the insertion bar and the focus ring follow the
40//! offset. An album grid of fixed 182 dp cards, two to ten columns, centred:
41//!
42//! ```ignore
43//! GridView::new(albums, |tc| album_card(tc))
44//!     .sizing(GridSizing::Fixed { width: 182.0, height: 230.0 })
45//!     .min_columns(2)
46//!     .max_columns(10)
47//!     .column_alignment(HAlignment::Center)
48//! ```
49//!
50//! ## Selection by position or by key
51//!
52//! [`GridView::selection`] takes a [`SelectionModel`], which holds positions.
53//! A grid that is sorted or filtered under its selection, or fed by a lazy
54//! source, is built with [`GridView::from_source_keyed`] instead: a
55//! [`KeyedSelectionModel`] holds the source's keys, so the selection stays on
56//! the same items when they move. Both reach the grid through the facade the
57//! other data views use (`data_views::RowSelection`), which turns each click,
58//! key and rubber band into an index or a key.
59//!
60//! ## The detail band
61//!
62//! [`GridView::detail_row`] gives a tile a full-width band that opens directly
63//! under the row holding it, the "album expansion" of a music library. The
64//! band is not a tile and not a row of the grid: a layout strategy wrapped
65//! around the grid's own (`detail::DetailRowStrategy`) moves everything below
66//! the open row down by the band, so virtualization, the scroll range, the
67//! focus ring and the rubber band account for it without knowing about it, and
68//! tiles keep their row and column numbers. The band widget is a child of this
69//! root rather than of the body pane, so it survives the pane's rebuilds on
70//! scroll and resize, and a lazy source loading a window. A waterfall has no
71//! rows and ignores it. See the module docs of `grid_view/detail.rs`.
72//!
73//! Focus inside the band is revealed like focus anywhere: this root answers
74//! the framework's `WidgetEvent::ScrollIntoView`, which a scroll container is
75//! sent when a descendant it clips takes focus.
76//!
77//! ## Scrolling from outside
78//!
79//! [`GridView::handle`] returns a [`GridViewHandle`], a cheap clone usable
80//! before the grid is mounted and through all its rebuilds:
81//! [`scroll_to_index`](GridViewHandle::scroll_to_index),
82//! [`ensure_index_visible`](GridViewHandle::ensure_index_visible) and
83//! [`reveal_band`](GridViewHandle::reveal_band) (a tile and its open band
84//! together). A handle computes no offset when it is called: it records the
85//! request, and the next layout applies it once the band is measured and the
86//! scroll anchoring has run. Opening a band and scrolling to a tile under it
87//! in one turn therefore lands the tile where it was asked to be, which an
88//! offset written from outside does not: the layout keeps the content under
89//! the top edge still across the band opening, and takes that offset for
90//! one of the old layout. The builder's own `scroll_to_index` and
91//! `ensure_index_visible` act at call time, so before the first layout they
92//! do nothing.
93//!
94//! ```ignore
95//! let handle = grid.handle();
96//! // later, from a command:
97//! expanded.set(Some(album));
98//! handle.reveal_band(album);
99//! ```
100//!
101//! ## The keyboard cursor
102//!
103//! The grid has one current tile, its keyboard cursor: the tile the arrow
104//! keys step from, the one the focus ring marks while the grid holds keyboard
105//! focus, and the one it names to assistive technology as its active
106//! descendant. The same [`GridViewHandle`] reads and moves it:
107//! [`cursor`](GridViewHandle::cursor),
108//! [`set_cursor`](GridViewHandle::set_cursor) (clamped to the last tile;
109//! keyboard focus, the selection and the scroll offset are left alone) and
110//! [`cursor_signal`](GridViewHandle::cursor_signal), which an application
111//! observes to key a status line or its context actions on the current tile.
112//! Like a reveal, a cursor set before the grid is mounted is there at its
113//! first build.
114//!
115//! The cursor and the selection are one tile in a single selection: the keys
116//! move them together, and a selection written from outside moves the cursor
117//! onto it. In a multiple selection they are **separate**: `Ctrl+Arrow` moves
118//! the cursor and leaves the selection, so after `→`, `Ctrl+→`, `Ctrl+↓` the
119//! selection is still tile 0 while the cursor, the ring and the active
120//! descendant are on tile 4 (in three columns), and a selection written from
121//! outside leaves the cursor where it was. Read the cursor, not the first
122//! selected tile, for "the tile the user is on".
123//!
124//! An open [detail band](GridView::detail_row) is on the keyboard path
125//! already: `↓` on the open tile moves focus into its band, onto its first
126//! control, while the cursor stays on the tile, and `↑` from the band's top
127//! comes back to it. A band with no control to focus is stepped over. An
128//! application has no walk of its own to write.
129//!
130//! ## A long band
131//!
132//! A band that holds hundreds of rows, an album's tracks say, need not build
133//! them all. [`GridView::detail_visible_span_signal`] publishes the part of
134//! the open band on screen, in band-local coordinates, and only when it
135//! changes, so the band can realize the rows in the span and stand spacers
136//! in for the rest, with no copy of the grid's placement arithmetic:
137//!
138//! ```ignore
139//! const ROW: f32 = 24.0;
140//! let grid = GridView::new(albums, album_card);
141//! let span = grid.detail_visible_span_signal().clone();
142//! grid.detail_row(move |tc| {
143//!     let n = tc.item.tracks.len();
144//!     let first = span.map(|s| s.as_ref().map_or(0, |r| (r.start / ROW) as usize));
145//!     let last = span.map(move |s| {
146//!         s.as_ref().map_or(0, |r| ((r.end / ROW).ceil() as usize).min(n))
147//!     });
148//!     // `window` holds tracks[first..last], refilled when the span moves.
149//!     let window = track_window(&tc.item.tracks, &first, &last);
150//!     Some(Box::new(
151//!         VStack::new()
152//!             .child(FixedSize::new().height(first.map(|f| *f as f32 * ROW)))
153//!             .child(Repeater::new(window, |track| track_row(track)))
154//!             .child(FixedSize::new().height(last.map(move |l| (n - *l) as f32 * ROW))),
155//!     ))
156//! })
157//! ```
158//!
159//! ## Pan to scroll
160//!
161//! The view installs [`common::scrollable::ScrollableBehavior`](crate::common::scrollable::ScrollableBehavior),
162//! which gives it the shared wheel arithmetic, a finger's pan and the
163//! `PanClaim` that puts it on a pan's claimant chain. A pan scrolls it, the
164//! release coasts, and a pan it cannot absorb hands the **whole** event to the
165//! container outside — never a residual. Vertical only, despite the grid: this
166//! view owns no horizontal offset, so a horizontal pan is declined and chains
167//! outward. A pan that starts on a tile scrolls rather than activating it.
168//!
169//! ## The rubber band, and why it is not on this node
170//!
171//! In [`teksilo_data::SelectionMode::Multi`] a drag on the empty background
172//! sweeps a selection rectangle. That drag deliberately does **not** live on
173//! this view's own node, which is the one carrying the `PanClaim`.
174//!
175//! The reason it *was* structural has since been fixed in the framework: a node
176//! that both declares a claim and carries `on_drag` is now given a say through
177//! `PointerSequence::defer_own_drag`, which resolves its
178//! [`teksilo_tokens::DragActivation`] against the claim it is competing with.
179//! Before that arm existed the claim holder's own drag latched at `drag_slop`
180//! and decided the sequence before the claim could win at `pan_slop`, so a
181//! `Multi`-selection grid did not scroll under a finger from anywhere at all,
182//! and a finger on the background swept a band immediately rather than after a
183//! hold.
184//!
185//! What the surface still buys is **scope**, and that is why it stays. This
186//! root's children are the body pane, the scrollbar, the pinned section header,
187//! the empty view, the loading view and the focus overlay — all siblings, most
188//! of them filling the same rectangle. A marquee hung here would be a drag
189//! ancestor of every one of them; hung on a `DragSurface` that encloses the body
190//! pane alone, it sweeps the background and nothing else. The wrapper is also
191//! not this view's to remove in any case: the four row views wrap each **row**
192//! in one, and a row is not a claimant, so `defer_own_drag` — which refuses
193//! anything but a live `MemberRole::Pan` member — cannot reach it. See
194//! `crate::data_views::DragSurface`.
195//!
196//! So the body pane carries a no-op tap that gives it an arena of its own (the
197//! press is captured *inside* the surface, not by it) and the marquee's drag
198//! hangs on a `DragSurface` that strictly encloses the pane — the shape the tree
199//! arms `DragActivation` for through its ancestor walk, which is what makes the
200//! marquee wait for a long press under a finger and latch at 5 dp under a mouse.
201//!
202//! Every **tile** carries that same no-op tap as well, for a reason with nothing
203//! to do with dragging. With the pane holding one, a tile without an arena of
204//! its own leaves the *pane* as the press captor — and a release is dispatched
205//! to the captor and then bubbled target→root, which never reaches a tile
206//! beneath it. A plain selectable grid lost both its finger tap and, under a
207//! mouse, the release that collapses a multi-selection that way.
208
209pub(crate) mod a11y;
210pub(crate) mod body_pane;
211pub(crate) mod detail;
212pub(crate) mod drag;
213pub(crate) mod handle;
214pub(crate) mod keyboard;
215pub mod layout;
216pub mod sections;
217pub(crate) mod selection;
218pub(crate) mod selection_count;
219#[cfg(test)]
220mod tests;
221
222use std::cell::{Cell, RefCell};
223use std::collections::BTreeSet;
224use std::rc::Rc;
225
226use teksilo_canvas::{EdgeInsets, Point, Rect, Size, SizeProposal};
227use teksilo_core::accessibility::{AccessNodeBuilder, widget_id_to_node_id};
228use teksilo_core::binding::BindingLevel;
229use teksilo_core::build_context::BuildContext;
230use teksilo_core::drag_payload::DragPayload;
231use teksilo_core::event::{EventResponse, WidgetEvent};
232use teksilo_core::kinetic::KineticScroller;
233use teksilo_core::pointer::touch_action::PanAxes;
234use teksilo_core::signal::{Prop, Signal};
235use teksilo_core::styles::GridViewStyle;
236use teksilo_core::widget::{LayoutContext, PaintContext, Widget, WidgetPlacement};
237use teksilo_core::widget_builder::HandlerSet;
238use teksilo_core::widget_id::WidgetId;
239use teksilo_data::{
240    DataChange, DropPosition, DropResponse, ItemKey, KeyedSelectionModel, ListModel, SelectionMode,
241    SelectionModel,
242};
243use teksilo_tokens::{HAlignment, OverscrollStyle, SurfaceRole};
244
245use std::time::Duration;
246
247use crate::common::scroll::OverscrollBehavior;
248use crate::data_views::{
249    DragTransferMode, RowDragData, RowSelection, ViewId, ViewKind, flat_insertion_target,
250};
251use crate::list_source::ListSource;
252use crate::primitives::TextWidget;
253use crate::scroll_area::ScrollBarMode;
254use crate::scroll_bar::{ScrollBar, ScrollBarOrientation, ScrollBarVisual};
255
256use body_pane::{GridBodyPane, TileDelegate};
257use keyboard::{GridKeyConfig, build_grid_key_handler};
258use layout::masonry::VirtualizedMasonry;
259use layout::sectioned::SectionedGrid;
260use layout::strategy::{GridLayoutStrategy, TileRect};
261use layout::uniform::UniformGrid;
262use layout::variable_row::VariableRowGrid;
263use sections::SectionData;
264use selection::{MarqueeConfig, MarqueeState, build_marquee_handler};
265use selection_count::{SelectionCountVoice, selection_count_words};
266
267/// [`SectionProvider`] under the name it first shipped with, when only
268/// `GridView` had sections. Both names are the one trait.
269pub use sections::SectionProvider as GridSectionProvider;
270pub use sections::{GroupingSections, SectionProvider, grouping_sections};
271
272/// Which layout strategy `GridView` builds.
273#[derive(Debug, Clone, Copy)]
274enum StrategyKind {
275    /// Fixed row height (the default).
276    Uniform,
277    /// Each row sized to its tallest tile; `estimated` seeds unmeasured rows.
278    VariableRow { estimated: f32 },
279    /// Pinterest-style column-balanced waterfall; per-item variable height.
280    Waterfall { estimated: f32 },
281}
282
283pub use handle::GridViewHandle;
284pub use keyboard::GridTabTraversal;
285pub use layout::{GridSizing, ScrollAnchor};
286
287/// The erased `can_accept` closure type carried by the grid's source.
288type CanAcceptFn = Rc<dyn Fn(&DragPayload, usize, DropPosition, ViewId) -> DropResponse>;
289
290/// Whether a drop at flat insertion `idx` is allowed: the source accepts it
291/// (same-view reorder, or a source that handles the foreign payload directly),
292/// the grid accepts foreign exported tiles via `accept_foreign_rows`, OR it is
293/// a foreign payload and the grid carries an app-level `on_item_drop` handler.
294fn drop_allowed<T: 'static>(
295    can_accept: &CanAcceptFn,
296    payload: &DragPayload,
297    idx: usize,
298    len: usize,
299    view_id: ViewId,
300    has_drop_cb: bool,
301    export: &crate::data_views::RowExport<T>,
302) -> bool {
303    match flat_insertion_target(idx, len) {
304        Some((target, position)) => match (can_accept)(payload, target, position, view_id) {
305            DropResponse::Accept | DropResponse::Redirect(_) => true,
306            DropResponse::Reject => {
307                let foreign = is_foreign::<T>(payload, view_id);
308                foreign && (has_drop_cb || export.accepts_foreign_export(payload, view_id))
309            }
310        },
311        None => false,
312    }
313}
314
315/// A payload is foreign to this grid when it is not a `RowDragData<T>`
316/// originating here (an external app/OS drop, or a tile dragged from
317/// another view).
318fn is_foreign<T: 'static>(payload: &DragPayload, view_id: ViewId) -> bool {
319    payload
320        .get_typed::<RowDragData<T>>()
321        .is_none_or(|rd| rd.source != view_id)
322}
323
324/// Scrollbar thickness, matching `ListView` / `TableView`.
325const SCROLLBAR_THICKNESS: f32 = 12.0;
326
327/// Context passed to the tile delegate for each realized tile.
328///
329/// Richer than `ListView`'s `(index, &item, selected)` — carries the 2D
330/// grid coordinates and focus state (mirrors `TableView`'s `CellContext`).
331/// There is intentionally **no** `is_hovered`: hover changes on every
332/// mouse-move and is handled per-tile inside the delegate's own widget
333/// (its interaction signal), never by rebuilding the grid.
334pub struct TileContext<'a, T: 'static> {
335    /// Flat model index.
336    pub index: usize,
337    /// Row in the logical grid (0-based).
338    pub row: usize,
339    /// Column in the logical grid (0-based).
340    pub col: usize,
341    /// Borrow of the item.
342    pub item: &'a T,
343    /// Whether this tile is in the selection set.
344    pub is_selected: bool,
345    /// Whether this tile is the keyboard-focus current item. A build-time
346    /// snapshot — the canonical focus indicator is the grid's painted focus
347    /// ring (it does not rebuild tiles), so a delegate reading this for
348    /// custom styling accepts a one-rebuild lag.
349    pub is_focused: bool,
350}
351
352/// A virtualized 2D tile grid backed by a `ListModel<T>`.
353pub struct GridView<T: 'static> {
354    source: ListSource<T>,
355    delegate: TileDelegate<T>,
356
357    // Layout configuration (consumed when the strategy is first built).
358    /// The resolved tile sizing. When `sizing_signal` is set (a reactive
359    /// `.sizing(signal)`), `build()` refreshes this from the signal and rebuilds
360    /// the cached strategy on change — the slider-driven live-resize path.
361    sizing: GridSizing,
362    /// Reactive tile sizing, if bound via `.sizing(impl Into<Prop<GridSizing>>)`.
363    /// `None` for the static `.sizing(GridSizing::…)` / `.tile_size` / `.column_count`
364    /// sugar. Mirrors `TabWidget`'s `sizing: Option<Signal<TabSizing>>`.
365    sizing_signal: Option<Signal<GridSizing>>,
366    col_gap: f32,
367    row_gap: f32,
368    inset: EdgeInsets,
369    /// Column-count bounds and leftover alignment, handed to the strategy.
370    column_options: layout::columns::ColumnOptions,
371    strategy_kind: StrategyKind,
372    /// Exact per-item natural height (the variable-height fast-path).
373    #[allow(clippy::type_complexity)]
374    exact_item_height: Option<Rc<dyn Fn(usize) -> f32>>,
375    /// Lazily built on first `build()` and cached so variable-height
376    /// strategies keep their measurement caches across rebuilds.
377    strategy: Option<Rc<dyn GridLayoutStrategy>>,
378
379    // Selection / focus
380    /// The index-based [`SelectionModel`] or a [`KeyedSelectionModel`] behind
381    /// the facade every data view reads its selection through. Clicks, keys,
382    /// the marquee and the tiles all work in indices; the keyed backing turns
383    /// them into keys, so the selection follows its items through a sort, a
384    /// filter or a window slide.
385    selection: Option<RowSelection>,
386    #[allow(clippy::type_complexity)]
387    on_selection_changed: Option<Rc<dyn Fn(&BTreeSet<usize>)>>,
388    focused_index: Signal<Option<usize>>,
389    /// Enable rubber-band marquee (default true; only active in Multi mode).
390    marquee_selection: bool,
391    marquee: Signal<Option<MarqueeState>>,
392
393    // Keyboard
394    wrap_navigation: bool,
395    tab_traversal: GridTabTraversal,
396
397    // Scroll
398    show_scrollbar: bool,
399    overscroll_behavior: OverscrollBehavior,
400    /// Animate wheel scrolling instead of snapping to the new offset.
401    /// Enabled by default — mirrors `ScrollArea`.
402    smooth_scrolling: bool,
403    /// Duration of the smooth scroll animation.
404    smooth_scroll_duration: Duration,
405    /// How the scroll bar is displayed. Defaults to `Permanent` (reserves
406    /// a layout column); `Overlay` / `Thin` float over the content.
407    scroll_bar_style: ScrollBarMode,
408    scroll_y: Signal<f32>,
409    max_scroll_y: Signal<f32>,
410    viewport_ratio_y: Signal<f32>,
411    /// Shared with every [`GridViewHandle`]: the reveal a handle recorded,
412    /// for `place_children` to apply, and the trigger it relays out with.
413    reveal: Rc<handle::RevealShared>,
414    /// Live column count for the current viewport width. Written in
415    /// `place_children`; drives the body pane's reflow rebuild on resize and
416    /// is read by the keyboard handler.
417    column_count: Signal<usize>,
418
419    // Drag-to-reorder + drop
420    reorderable: bool,
421    #[allow(clippy::type_complexity)]
422    on_item_drop: Option<
423        Rc<
424            dyn Fn(
425                teksilo_core::drag_payload::DragPayload,
426                usize,
427                &mut teksilo_core::widget::EventContext,
428            ) -> bool,
429        >,
430    >,
431    /// Insertion index during a reorder drag (painted by `GridOverlay`).
432    insertion: Signal<Option<usize>>,
433    /// Stable, kind-tagged ID for this GridView instance (identifies its own
434    /// reorder vs. a foreign drop, even across widget kinds / windows).
435    model_id: ViewId,
436
437    /// Cross-widget export / foreign-receive machinery — the builders
438    /// (`.exportable`, `.export_external`, `.accept_foreign_rows`,
439    /// `.on_rows_received`, `.on_rows_transferred_out`), the drag-start payload
440    /// build, and the move-out completion, shared by all five data views.
441    export: crate::data_views::RowExport<T>,
442
443    // Activation / context menu / type-ahead
444    #[allow(clippy::type_complexity)]
445    on_tile_activate: Option<Rc<dyn Fn(usize, &mut teksilo_core::widget::EventContext)>>,
446    /// Whether tile activation is a single or double click (default
447    /// `DoubleClick`). Enter always activates.
448    activate_on: crate::data_views::ActivateOn,
449    #[allow(clippy::type_complexity)]
450    tile_context_menu: Option<
451        Rc<
452            dyn Fn(
453                usize,
454                Point,
455                &mut teksilo_core::widget::EventContext,
456            ) -> Option<Box<dyn Widget>>,
457        >,
458    >,
459    type_ahead_timeout: std::time::Duration,
460    /// Accumulate-and-search state for type-ahead. A widget field, not a
461    /// handler local: the buffer has to survive the rebuild a selection or
462    /// scroll change triggers, or a two-key search resets between the
463    /// keystrokes. `common::type_ahead`'s module doc says as much, and
464    /// `ListView` has always held it this way.
465    type_ahead: Rc<crate::common::type_ahead::TypeAheadState>,
466    #[allow(clippy::type_complexity)]
467    type_ahead_label: Option<Rc<dyn Fn(usize) -> String>>,
468    /// Per-tile accessible name — sets each `GridCell`'s `Node::label` so a
469    /// screen reader announces a concise item name ("Title, Type") instead of
470    /// only the grid coordinates. `None` leaves the cell name to its contents.
471    #[allow(clippy::type_complexity)]
472    tile_a11y_label: Option<Rc<dyn Fn(usize) -> String>>,
473
474    // Empty / loading state
475    #[allow(clippy::type_complexity)]
476    empty_view: Option<Rc<dyn Fn() -> Box<dyn Widget>>>,
477    #[allow(clippy::type_complexity)]
478    loading_view: Option<Rc<dyn Fn() -> Box<dyn Widget>>>,
479    is_loading: Option<Prop<bool>>,
480    loading_id: Option<WidgetId>,
481
482    // Sections
483    section_data: Option<SectionData>,
484    #[allow(clippy::type_complexity)]
485    header_delegate: Option<Rc<dyn Fn(usize, &str) -> Box<dyn Widget>>>,
486    header_height: f32,
487    pinned_section_headers: bool,
488    current_section: Signal<usize>,
489    pinned_header_id: Option<WidgetId>,
490
491    // Accessibility
492    a11y_label: Option<String>,
493    /// Shared map (flat index → tile wrapper id), written by the body pane,
494    /// read by `accessibility` for `active_descendant` roving focus.
495    tile_map: Rc<std::cell::RefCell<Vec<(usize, WidgetId)>>>,
496
497    /// Per-call Tier-3 decoration style override (focus ring / marquee /
498    /// insertion bar / pinned header). `None` → theme slot → stock default.
499    style: Option<Rc<dyn GridViewStyle>>,
500
501    // Geometry (synchronous cells, read within the layout pass)
502    viewport_width: Rc<Cell<f32>>,
503    viewport_height: Rc<Cell<f32>>,
504    /// This surface's pan physics: the range a finger's pan is clamped to and
505    /// the offset it is currently holding. Owned by the view rather than by
506    /// the [`ScrollableBehavior`](crate::common::scrollable::ScrollableBehavior)
507    /// so it survives a rebuild, and so `place_children` — the only pass that
508    /// knows the viewport extent — can publish into it.
509    scroller: Rc<RefCell<KineticScroller>>,
510    /// The grid body pane's absolute (window) origin, published by
511    /// `GridBodyPane::place_children` (`None` until laid out). Shared into the
512    /// keyboard handler so it can chase the focused tile into any enclosing
513    /// scroll area (`ctx.ensure_visible`).
514    viewport_origin: Rc<Cell<Option<Point>>>,
515    /// Remembered scrollbar decision so each layout queries the strategy at a
516    /// single, stable body width — querying at two widths per frame would
517    /// thrash a variable strategy's per-row measurement cache.
518    last_needs_scrollbar: Cell<bool>,
519
520    // Build state
521    body_pane_id: Option<WidgetId>,
522    empty_id: Option<WidgetId>,
523    scrollbar_id: Option<WidgetId>,
524    overlay_id: Option<WidgetId>,
525
526    /// Whole-view enabled state, statically or reactively. Forwarded to the
527    /// arena via `ctx.enabled_when(self_id, self.enabled.clone())` at build
528    /// time; a disabled view greys out and stops accepting focus /
529    /// selection / keyboard input (arena-gated).
530    enabled: Prop<bool>,
531
532    // Detail band
533    /// The disclosed tile (`expanded_index`). A default signal when the
534    /// application supplies none, so activation still opens and closes it.
535    expanded: Signal<Option<usize>>,
536    detail_row: Option<detail::DetailBuilder<T>>,
537    /// Which tiles have a band (`detail_row_when`); `None`: every tile may.
538    detail_row_when: Option<detail::DetailWhen<T>>,
539    detail_row_height: Option<detail::DetailHeightFn>,
540    /// Built on the first build when a band is in force, and kept: the
541    /// layout, the tiles, the keys and the band share it across rebuilds.
542    detail: Option<Rc<detail::DetailState>>,
543    /// The layout strategy with the band inserted, kept as its concrete
544    /// type for `place_children`, which measures and places the band.
545    detail_layout: Option<Rc<detail::DetailRowStrategy>>,
546    /// The disclosed tile's anchor, for finding it again after a reset.
547    detail_anchor: Rc<RefCell<Option<crate::data_views::RowAnchor>>>,
548    band_id: Option<WidgetId>,
549    /// The part of the open band on screen, in band-local coordinates
550    /// (`detail_visible_span_signal`). Written by `place_children`.
551    detail_visible_span: Signal<Option<std::ops::Range<f32>>>,
552}
553
554impl<T: 'static> GridView<T> {
555    /// Create a grid backed by a `ListModel<T>`. The `delegate` builds the
556    /// widget for each tile from a [`TileContext`].
557    pub fn new(
558        model: ListModel<T>,
559        delegate: impl Fn(&TileContext<'_, T>) -> Box<dyn Widget> + 'static,
560    ) -> Self {
561        Self::create(ListSource::from_model(model), delegate)
562    }
563
564    /// Create a grid backed by any `ListDataSource` (large / external data).
565    pub fn from_source<S: teksilo_data::ListDataSource<Item = T>>(
566        source: S,
567        delegate: impl Fn(&TileContext<'_, T>) -> Box<dyn Widget> + 'static,
568    ) -> Self {
569        Self::create(erase_source(Rc::new(source)), delegate)
570    }
571
572    /// Create a grid backed by any `ListDataSource` with **keyed** selection.
573    ///
574    /// The `KeyedSelectionModel<S::Key>` holds the selected items by the
575    /// source's key, not by position, so a selection survives the grid being
576    /// sorted (by artist, by year), narrowed by a filter, or a lazy source
577    /// sliding its loaded window: it stays on the same items. Clicks,
578    /// Ctrl/⌘-clicks, Shift-clicks, the keyboard and the rubber band all write
579    /// keys. An item the source no longer shows (removed, or filtered out
580    /// here) leaves the selection, as in [`ListView`](crate::ListView).
581    ///
582    /// [`on_selection_changed`](Self::on_selection_changed) still reports
583    /// indices: the positions the selected keys have when the selection
584    /// changes. A sort moves them without changing the selection, so it does
585    /// not fire. The view stays `GridView<T>`; the index↔key mapping is
586    /// captured from the concrete source here. Mutually exclusive with
587    /// [`selection`](Self::selection) (the last one set wins).
588    pub fn from_source_keyed<S: teksilo_data::ListDataSource<Item = T>>(
589        source: S,
590        keyed: KeyedSelectionModel<S::Key>,
591        delegate: impl Fn(&TileContext<'_, T>) -> Box<dyn Widget> + 'static,
592    ) -> Self
593    where
594        S::Key: ItemKey,
595    {
596        let s = Rc::new(source);
597        let key_at = {
598            let s = s.clone();
599            Rc::new(move |i| s.key_at(i)) as Rc<dyn Fn(usize) -> Option<S::Key>>
600        };
601        let len = {
602            let s = s.clone();
603            Rc::new(move || s.len()) as Rc<dyn Fn() -> usize>
604        };
605        // Existence for the prune after a removal is the keys of the index
606        // space the grid shows, which a lazy source knows before its items
607        // load: what `ListView::from_source_keyed` asks too, gathered once per
608        // prune instead of scanned once per selected key.
609        let selection = RowSelection::from_keyed_flat(keyed, key_at, len);
610        let mut view = Self::create(erase_source(s), delegate);
611        view.selection = Some(selection);
612        view
613    }
614
615    fn create(
616        source: ListSource<T>,
617        delegate: impl Fn(&TileContext<'_, T>) -> Box<dyn Widget> + 'static,
618    ) -> Self {
619        Self {
620            source,
621            delegate: Rc::new(delegate),
622            sizing: GridSizing::Adaptive {
623                min_width: 120.0,
624                max_width: None,
625                height: 120.0,
626            },
627            sizing_signal: None,
628            col_gap: 8.0,
629            row_gap: 8.0,
630            inset: EdgeInsets::ZERO,
631            column_options: layout::columns::ColumnOptions::default(),
632            strategy_kind: StrategyKind::Uniform,
633            exact_item_height: None,
634            strategy: None,
635            selection: None,
636            on_selection_changed: None,
637            focused_index: Signal::new(None),
638            marquee_selection: true,
639            marquee: Signal::new(None),
640            wrap_navigation: false,
641            tab_traversal: GridTabTraversal::OutOfGrid,
642            show_scrollbar: true,
643            overscroll_behavior: OverscrollBehavior::default(),
644            smooth_scrolling: true,
645            smooth_scroll_duration: Duration::from_millis(150),
646            scroll_bar_style: ScrollBarMode::Permanent,
647            scroll_y: Signal::new_animated(0.0),
648            max_scroll_y: Signal::new(0.0),
649            viewport_ratio_y: Signal::new(1.0),
650            reveal: handle::RevealShared::new(),
651            column_count: Signal::new(1),
652            reorderable: false,
653            on_item_drop: None,
654            insertion: Signal::new(None),
655            model_id: ViewId::next(ViewKind::Grid),
656            export: crate::data_views::RowExport::default(),
657            on_tile_activate: None,
658            activate_on: crate::data_views::ActivateOn::default(),
659            tile_context_menu: None,
660            type_ahead_timeout: std::time::Duration::from_millis(500),
661            type_ahead: crate::common::type_ahead::TypeAheadState::new(),
662            type_ahead_label: None,
663            tile_a11y_label: None,
664            empty_view: None,
665            loading_view: None,
666            is_loading: None,
667            loading_id: None,
668            section_data: None,
669            header_delegate: None,
670            header_height: 28.0,
671            pinned_section_headers: false,
672            current_section: Signal::new(0),
673            pinned_header_id: None,
674            a11y_label: None,
675            tile_map: Rc::new(std::cell::RefCell::new(Vec::new())),
676            style: None,
677            viewport_width: Rc::new(Cell::new(400.0)),
678            viewport_height: Rc::new(Cell::new(400.0)),
679            scroller: Rc::new(RefCell::new(KineticScroller::new(OverscrollStyle::Clamp))),
680            viewport_origin: Rc::new(Cell::new(None)),
681            last_needs_scrollbar: Cell::new(false),
682            body_pane_id: None,
683            empty_id: None,
684            scrollbar_id: None,
685            overlay_id: None,
686            enabled: Prop::Static(true),
687            expanded: Signal::new(None),
688            detail_row: None,
689            detail_row_when: None,
690            detail_row_height: None,
691            detail: None,
692            detail_layout: None,
693            detail_anchor: Rc::new(RefCell::new(None)),
694            band_id: None,
695            detail_visible_span: Signal::new(None),
696        }
697    }
698
699    /// Enable or disable the whole view. A disabled view greys out and stops
700    /// accepting focus / selection / keyboard input (arena-gated).
701    pub fn enabled(mut self, enabled: impl Into<Prop<bool>>) -> Self {
702        self.enabled = enabled.into();
703        self
704    }
705
706    // ── Tile sizing & layout ────────────────────────────────────────────
707
708    /// Set the tile sizing / column-count policy.
709    ///
710    /// Accepts a plain [`GridSizing`] (static) **or** a `Signal<GridSizing>`
711    /// (reactive). A bound signal is observed at [`BindingLevel::Rebuild`]: when
712    /// it changes, `build()` rebuilds the cached layout strategy and reflows —
713    /// the internal `scroll_y` / `focused_index` / selection are field signals on
714    /// the same widget instance, so they survive the rebuild (no scroll jump).
715    /// This is the card-size-slider path; mirrors
716    /// [`TabWidget::sizing`](crate::TabWidget::sizing).
717    pub fn sizing(mut self, sizing: impl Into<Prop<GridSizing>>) -> Self {
718        let sig = sizing.into().as_signal();
719        self.sizing = sig.get();
720        self.sizing_signal = Some(sig);
721        self
722    }
723
724    /// Sugar for [`GridSizing::Fixed`] — every tile is exactly `width` × `height`.
725    pub fn tile_size(mut self, width: f32, height: f32) -> Self {
726        self.sizing = GridSizing::Fixed { width, height };
727        self.sizing_signal = None;
728        self
729    }
730
731    /// Sugar for [`GridSizing::FixedColumnCount`] — exactly `count` columns.
732    pub fn column_count(mut self, count: usize, tile_height: f32) -> Self {
733        self.sizing = GridSizing::FixedColumnCount {
734            count,
735            height: tile_height,
736        };
737        self.sizing_signal = None;
738        self
739    }
740
741    /// Switch to variable row heights: each row is sized to its tallest
742    /// tile (SwiftUI `LazyVGrid` semantics). `estimated` seeds rows that
743    /// haven't been measured yet; the scroll position is anchored when an
744    /// estimate is later corrected. Combine with
745    /// [`item_height`](Self::item_height) for exact heights.
746    pub fn variable_row_heights(mut self, estimated: f32) -> Self {
747        self.strategy_kind = StrategyKind::VariableRow {
748            estimated: estimated.max(1.0),
749        };
750        self
751    }
752
753    /// Supply an exact per-**item** natural height. Width-independent, so it
754    /// doesn't depend on the runtime column count: `VariableRowGrid` sizes
755    /// each row to `max(item_height(i))` over its items. Implies variable row
756    /// heights, gives an exact scrollbar, and removes anchoring jitter.
757    pub fn item_height(mut self, f: impl Fn(usize) -> f32 + 'static) -> Self {
758        self.exact_item_height = Some(Rc::new(f));
759        if matches!(self.strategy_kind, StrategyKind::Uniform) {
760            self.strategy_kind = StrategyKind::VariableRow {
761                estimated: self.sizing.tile_height().max(1.0),
762            };
763        }
764        self
765    }
766
767    /// Switch to a Pinterest-style waterfall: per-item variable heights flow
768    /// into the currently-shortest column. Column count comes from the
769    /// configured [`sizing`](Self::sizing); heights are auto-measured (or
770    /// exact via [`item_height`](Self::item_height)). `estimated` seeds
771    /// unmeasured items.
772    pub fn waterfall(mut self, estimated: f32) -> Self {
773        self.strategy_kind = StrategyKind::Waterfall {
774            estimated: estimated.max(1.0),
775        };
776        self
777    }
778
779    // ── Spacing & insets ────────────────────────────────────────────────
780
781    /// Horizontal gap between tiles (default 8).
782    pub fn column_spacing(mut self, spacing: f32) -> Self {
783        self.col_gap = spacing.max(0.0);
784        self
785    }
786
787    /// Vertical gap between tile rows (default 8).
788    pub fn row_spacing(mut self, spacing: f32) -> Self {
789        self.row_gap = spacing.max(0.0);
790        self
791    }
792
793    /// Set both column and row spacing.
794    pub fn spacing(mut self, spacing: f32) -> Self {
795        self.col_gap = spacing.max(0.0);
796        self.row_gap = spacing.max(0.0);
797        self
798    }
799
800    /// Inset from the scroll-content edge to the tiles.
801    pub fn content_inset(mut self, inset: EdgeInsets) -> Self {
802        self.inset = inset;
803        self
804    }
805
806    // ── Column bounds & alignment ───────────────────────────────────────
807
808    /// Never fewer than `count` columns (at least 1).
809    ///
810    /// Bounds the count a [`GridSizing::Fixed`] or [`GridSizing::Adaptive`]
811    /// derives from the width; ignored by [`GridSizing::FixedColumnCount`],
812    /// whose count is already the answer. When the width cannot afford
813    /// `count` columns of the sizing's width, the tiles **narrow** to share
814    /// the width — the grid never overflows horizontally. The tile height is
815    /// kept as the sizing gives it (a card gets narrower, not shorter), so a
816    /// tile that must hold its aspect ratio sizes its own content from the
817    /// width it is handed. If `min_columns` exceeds
818    /// [`max_columns`](Self::max_columns), the maximum wins.
819    pub fn min_columns(mut self, count: usize) -> Self {
820        self.column_options.min_columns = Some(count.max(1));
821        self
822    }
823
824    /// Never more than `count` columns (at least 1), however wide the viewport.
825    ///
826    /// Bounds the count a [`GridSizing::Fixed`] or [`GridSizing::Adaptive`]
827    /// derives from the width; ignored by [`GridSizing::FixedColumnCount`].
828    /// Under `Fixed` the tiles keep their width and the leftover is placed by
829    /// [`column_alignment`](Self::column_alignment); under `Adaptive` they
830    /// stretch to share the width of the capped count, up to `max_width`.
831    pub fn max_columns(mut self, count: usize) -> Self {
832        self.column_options.max_columns = Some(count.max(1));
833        self
834    }
835
836    /// Where the block of columns sits in the viewport when it is narrower
837    /// than the content: [`HAlignment::Leading`] (the default, the tiles
838    /// against the leading inset, leftover trailing), `Center` or
839    /// `Trailing`. Leading and trailing swap in a right-to-left layout. Only
840    /// the columns move: a section header and a [`detail_row`](Self::detail_row)
841    /// band span the content width. The order of the columns within the
842    /// block is the same in both directions.
843    ///
844    /// Only a layout that leaves width over has anything to place: a
845    /// [`GridSizing::Fixed`] grid, an `Adaptive` one with `max_width`, or any
846    /// grid capped by [`max_columns`](Self::max_columns).
847    pub fn column_alignment(mut self, alignment: HAlignment) -> Self {
848        self.column_options.alignment = alignment;
849        self
850    }
851
852    // ── Selection ───────────────────────────────────────────────────────
853
854    /// Set the index-based selection model (modes `None` / `Single` /
855    /// `Multi`). It holds positions: for a selection that stays on its items
856    /// through a sort or a filter, build the grid with
857    /// [`from_source_keyed`](Self::from_source_keyed) instead.
858    pub fn selection(mut self, sel: SelectionModel) -> Self {
859        self.selection = Some(RowSelection::from_index(sel));
860        self
861    }
862
863    /// Called whenever the selection set changes — including programmatic
864    /// changes — with the new set of selected indices. For a keyed selection
865    /// these are the positions its keys have at that moment.
866    ///
867    /// Because it also fires for programmatic changes, it carries no input
868    /// modifiers and no [`EventContext`](teksilo_core::widget::EventContext);
869    /// for the modifiers held at the moment of input, use
870    /// [`on_tile_activate`](Self::on_tile_activate), which receives one.
871    pub fn on_selection_changed(mut self, f: impl Fn(&BTreeSet<usize>) + 'static) -> Self {
872        self.on_selection_changed = Some(Rc::new(f));
873        self
874    }
875
876    /// Enable / disable rubber-band marquee selection (default enabled; only
877    /// active when the selection model is in `Multi` mode).
878    pub fn marquee_selection(mut self, enabled: bool) -> Self {
879        self.marquee_selection = enabled;
880        self
881    }
882
883    // ── Keyboard ────────────────────────────────────────────────────────
884
885    /// Whether arrow navigation wraps across row/grid edges (default false).
886    pub fn wrap_navigation(mut self, enabled: bool) -> Self {
887        self.wrap_navigation = enabled;
888        self
889    }
890
891    /// How Tab moves out of (or within) the grid (default `OutOfGrid`).
892    pub fn tab_traversal(mut self, traversal: GridTabTraversal) -> Self {
893        self.tab_traversal = traversal;
894        self
895    }
896
897    // ── Scrolling ───────────────────────────────────────────────────────
898
899    /// Suppress the internal scrollbar (mount your own via the signal
900    /// accessors so it survives rebuilds).
901    pub fn show_scrollbar(mut self, show: bool) -> Self {
902        self.show_scrollbar = show;
903        self
904    }
905
906    /// Scroll-chaining behavior at the boundary (default `Chain`).
907    pub fn overscroll_behavior(mut self, behavior: OverscrollBehavior) -> Self {
908        self.overscroll_behavior = behavior;
909        self
910    }
911
912    /// Enable or disable animated wheel scrolling (enabled by default).
913    pub fn smooth_scrolling(mut self, enabled: bool) -> Self {
914        self.smooth_scrolling = enabled;
915        self
916    }
917
918    /// Duration of the smooth scroll animation (default 150 ms).
919    pub fn smooth_scroll_duration(mut self, duration: Duration) -> Self {
920        self.smooth_scroll_duration = duration;
921        self
922    }
923
924    /// How the scroll bar is displayed (default `Permanent`). `Overlay`
925    /// and `Thin` float the bar over the content instead of reserving a
926    /// layout column, mirroring `ScrollArea::scroll_bar_style`.
927    pub fn scroll_bar_style(mut self, style: ScrollBarMode) -> Self {
928        self.scroll_bar_style = style;
929        self
930    }
931
932    /// The vertical scroll offset signal.
933    pub fn scroll_y_signal(&self) -> &Signal<f32> {
934        &self.scroll_y
935    }
936
937    /// The maximum scroll offset signal (`content_height - viewport_height`).
938    pub fn max_scroll_y_signal(&self) -> &Signal<f32> {
939        &self.max_scroll_y
940    }
941
942    /// The vertical viewport-to-content ratio signal (drives the thumb size).
943    pub fn viewport_ratio_y_signal(&self) -> &Signal<f32> {
944        &self.viewport_ratio_y
945    }
946
947    /// The part of the open detail band that is on screen, in band-local
948    /// coordinates: `max(0, scroll_y − band.y) .. min(band.height, scroll_y +
949    /// viewport_height − band.y)`, where `band.y` is the band's top in the
950    /// grid's content. `None` while no band is open, and while the open band
951    /// is entirely off screen.
952    ///
953    /// Written by each layout after the scroll offset is clamped, and only
954    /// when it changes, so an observer is not told of a scroll that leaves
955    /// the band's visible part as it was. Meant for a band holding far more
956    /// than a viewport shows, hundreds of tracks say, that realizes only the
957    /// rows in the span: a window of rows between two spacers, with no copy
958    /// of the grid's placement arithmetic. See the module docs, "A long
959    /// band".
960    pub fn detail_visible_span_signal(&self) -> &Signal<Option<std::ops::Range<f32>>> {
961        &self.detail_visible_span
962    }
963
964    /// A [`GridViewHandle`] on this grid: scroll it to a tile, or to a tile
965    /// and its detail band, from outside the widget.
966    ///
967    /// Usable at once and for the grid's whole life: a request made before
968    /// the grid is mounted is applied at its first layout, and the handle
969    /// keeps working through every rebuild. A request is applied by the
970    /// layout, after it has measured the detail band and settled the scroll
971    /// anchoring, so one made in the same turn as opening a band lands where
972    /// the new layout puts the tile. Prefer it to
973    /// [`scroll_to_index`](Self::scroll_to_index) and
974    /// [`ensure_index_visible`](Self::ensure_index_visible), which compute
975    /// the offset when they are called.
976    pub fn handle(&self) -> GridViewHandle {
977        GridViewHandle::new(
978            self.reveal.clone(),
979            self.focused_index.clone(),
980            self.source.len_fn.clone(),
981        )
982    }
983
984    /// Bring `index` into view: nothing when the tile is already fully
985    /// visible, otherwise a scroll per `anchor`, as
986    /// [`scroll_to_index`](Self::scroll_to_index).
987    ///
988    /// Computed at call time from the last layout's geometry and clamped to
989    /// its scroll range. Before the grid has been laid out there is no
990    /// geometry and this does nothing, and a change of layout made in the
991    /// same turn (a detail band opened above the tile) is not yet counted.
992    /// [`GridViewHandle::ensure_index_visible`] has neither limit.
993    pub fn ensure_index_visible(&self, index: usize, anchor: ScrollAnchor) {
994        self.reveal_now(handle::RevealRequest::Ensure { index, anchor });
995    }
996
997    /// Scroll to `index`, forcing its position in the viewport per `anchor`;
998    /// [`ScrollAnchor::Auto`] scrolls the least that shows it.
999    ///
1000    /// Computed at call time, with the limits
1001    /// [`ensure_index_visible`](Self::ensure_index_visible) documents: it
1002    /// does nothing before the grid's first layout.
1003    /// [`GridViewHandle::scroll_to_index`] has neither limit.
1004    pub fn scroll_to_index(&self, index: usize, anchor: ScrollAnchor) {
1005        self.reveal_now(handle::RevealRequest::ScrollTo { index, anchor });
1006    }
1007
1008    /// Apply `request` now, against the last layout.
1009    fn reveal_now(&self, request: handle::RevealRequest) {
1010        let Some(ref strategy) = self.strategy else {
1011            return;
1012        };
1013        if let Some(target) = scroll_for_request(
1014            strategy.as_ref(),
1015            self.detail_layout.as_deref(),
1016            request,
1017            self.source.len(),
1018            self.scroll_y.get(),
1019            self.viewport_height.get(),
1020            self.viewport_width.get(),
1021            self.max_scroll_y.get(),
1022        ) {
1023            self.scroll_y.set(target);
1024        }
1025    }
1026
1027    /// In a single selection, put the keyboard cursor back on the selection
1028    /// whenever the selection moves without it.
1029    ///
1030    /// `ListView::follow_the_selection_in_single_mode` states the defect: a
1031    /// selection written from outside left the cursor, which the view
1032    /// publishes as its active descendant, on a tile no action used. The
1033    /// cursor is **moved** onto the selected tile rather than cleared, because
1034    /// a grid nominates `focused_index` alone as its active descendant and
1035    /// paints its focus ring there — cleared, the grid would announce and show
1036    /// no current tile at all. The model-change observer shifts the cursor
1037    /// before it shifts the selection, so a move made here is never shifted a
1038    /// second time. A grid with no cursor yet gets none from here.
1039    ///
1040    /// The selection is read live, never from the notification's value: an
1041    /// observer registered earlier may rewrite the selection while it is being
1042    /// notified, and the notification it interrupted still arrives afterwards,
1043    /// carrying the value that was just discarded.
1044    fn follow_the_selection_in_single_mode(&self, ctx: &mut BuildContext) {
1045        let Some(selection) = self.selection.clone() else {
1046            return;
1047        };
1048        if selection.mode() != teksilo_data::SelectionMode::Single {
1049            return;
1050        }
1051        let focused_index = self.focused_index.clone();
1052        let live = selection.clone();
1053        let handle = selection.observe_for_rebuild(move || {
1054            put_the_cursor_on_the_selection(&focused_index, &live);
1055        });
1056        ctx.own_handle(handle);
1057    }
1058
1059    /// Scroll the tile the keyboard is on into view when this grid takes
1060    /// focus.
1061    ///
1062    /// Only the tiles near the viewport are realized, so on a grid taller than
1063    /// the window the current tile frequently has no widget. Everything that
1064    /// speaks for it then has nothing to speak about: no node carries
1065    /// `selected`, no tile id is in `tile_map` for `accessibility` to nominate
1066    /// as the `active_descendant`, and a screen reader taking focus here is
1067    /// told nothing at all. The first arrow press steps *past* that tile as
1068    /// well, because the cursor was somewhere the user was never shown.
1069    ///
1070    /// `ScrollAnchor::Auto` (`scroll_delta_to_reveal` returns 0 for a tile
1071    /// already fully visible, `grid_view/layout/strategy.rs:194-202`) rather
1072    /// than a forced anchor: a tile already on screen must not jump under
1073    /// somebody who can see it. `Center` would, and by a lot: a fully visible
1074    /// tile on the fifth row of a 300px viewport gets dragged 107px to reach
1075    /// the middle. It is also the anchor arrow navigation already scrolls with
1076    /// (`grid_view/keyboard.rs:348-355`), so taking focus and then stepping
1077    /// move the viewport by the same rule.
1078    ///
1079    /// The cursor is `focused_index` else the first selected tile, the same
1080    /// one the keyboard steps from (`grid_view/keyboard.rs:113-121`) and the
1081    /// same one the context-menu key targets, so focus reveals the tile the
1082    /// next keystroke will act on.
1083    ///
1084    /// Index to offset is asked of the layout strategy rather than computed
1085    /// here, because a grid wraps and the wrap point is not this widget's to
1086    /// know: `UniformGrid::tile_rect` (`grid_view/layout/uniform.rs:87-99`)
1087    /// puts item `i` on row `i / column_count(viewport_width)` at
1088    /// `inset.top + row * row_step()`, so the offset depends on the width the
1089    /// last layout settled on, and a waterfall strategy has no row formula at
1090    /// all, its items dropping into whichever column is shortest.
1091    /// `viewport_width` is the body width `place_children` published
1092    /// (`grid_view.rs:1789`) and asked the strategy for its column count
1093    /// (`:1791`), the scrollbar column already subtracted, so the reveal and
1094    /// the layout wrap at the same place.
1095    ///
1096    /// The handles are cloned into the effect rather than reaching through
1097    /// `self`, which the closure cannot borrow. `strategy` comes from the
1098    /// caller because `ensure_strategy` has just built it there and takes
1099    /// `&mut self`.
1100    fn reveal_focused_tile_on_focus(
1101        &self,
1102        ctx: &mut BuildContext,
1103        strategy: Rc<dyn GridLayoutStrategy>,
1104    ) {
1105        // Keyed on this grid's own id, not on the enclosing scope: a grid
1106        // nested inside another data view's rows would otherwise read that
1107        // view's focus, since `view_focus_active` prefers whatever scope is
1108        // open on the build stack (`build_context.rs:576-585`). Begin/end
1109        // around nothing leaves the stack as it was
1110        // (`widget_tree/focus_impl.rs:751-760`).
1111        let view_focused = ctx.begin_view_focus();
1112        ctx.end_view_focus();
1113
1114        let scroll_y = self.scroll_y.clone();
1115        let max_scroll_y = self.max_scroll_y.clone();
1116        let viewport_height = self.viewport_height.clone();
1117        let viewport_width = self.viewport_width.clone();
1118        let focused_index = self.focused_index.clone();
1119        let selection = self.selection.clone();
1120
1121        ctx.effect(&view_focused, move |focused| {
1122            if !*focused {
1123                return;
1124            }
1125            let Some(index) = focused_index.get().or_else(|| {
1126                selection
1127                    .as_ref()
1128                    .and_then(|s| s.selected_indices().first().copied())
1129            }) else {
1130                return;
1131            };
1132            if let Some(target) = scroll_for_ensure_visible(
1133                strategy.as_ref(),
1134                index,
1135                scroll_y.get(),
1136                viewport_height.get(),
1137                viewport_width.get(),
1138                max_scroll_y.get(),
1139                ScrollAnchor::Auto,
1140            ) {
1141                scroll_y.set(target);
1142            }
1143        });
1144    }
1145
1146    /// Keep an anchor on the disclosed tile, so a reset that moves it can
1147    /// find it again (`follow_the_disclosed_tile`). Re-anchored on every
1148    /// write, wherever it comes from; the anchor is built at the write,
1149    /// against the data the index refers to.
1150    fn track_the_disclosed_tile(&self, ctx: &mut BuildContext, detail: &detail::DetailState) {
1151        let anchor_of = self.source.anchor_fn.clone();
1152        let slot = self.detail_anchor.clone();
1153        *slot.borrow_mut() = detail.expanded.get().map(|i| anchor_of(i));
1154        ctx.effect(&detail.expanded, move |expanded| {
1155            *slot.borrow_mut() = expanded.map(|i| anchor_of(i));
1156        });
1157    }
1158
1159    // ── Accessibility / empty state ─────────────────────────────────────
1160
1161    // ── Sections ────────────────────────────────────────────────────────
1162
1163    /// Group the flat model into sections, rendering a header above each
1164    /// section's tile band. Sections compose with the uniform tile layout.
1165    pub fn sections<P: SectionProvider>(mut self, provider: P) -> Self {
1166        let provider = Rc::new(provider);
1167        let counts_provider = provider.clone();
1168        let title_provider = provider.clone();
1169        self.section_data = Some(SectionData {
1170            counts_fn: Rc::new(move || counts_provider.section_counts()),
1171            title_fn: Rc::new(move |s| title_provider.section_title(s)),
1172        });
1173        self
1174    }
1175
1176    /// Custom section-header widget builder `(section_index, title)`. Without
1177    /// it a default bold-text header is used.
1178    pub fn section_header_delegate(
1179        mut self,
1180        f: impl Fn(usize, &str) -> Box<dyn Widget> + 'static,
1181    ) -> Self {
1182        self.header_delegate = Some(Rc::new(f));
1183        self
1184    }
1185
1186    /// Height of each section header row (default 28).
1187    pub fn section_header_height(mut self, height: f32) -> Self {
1188        self.header_height = height.max(0.0);
1189        self
1190    }
1191
1192    /// Keep the current section's header pinned to the top while scrolling
1193    /// through it (SwiftUI `pinnedViews:[.sectionHeaders]`).
1194    pub fn pinned_section_headers(mut self, enabled: bool) -> Self {
1195        self.pinned_section_headers = enabled;
1196        self
1197    }
1198
1199    /// Accessible label for the grid container.
1200    pub fn a11y_label(mut self, label: impl Into<String>) -> Self {
1201        self.a11y_label = Some(label.into());
1202        self
1203    }
1204
1205    /// Per-call Tier-3 decoration style override (focus ring, marquee,
1206    /// insertion bar, pinned-header surface). Precedence: this override →
1207    /// `theme.style_slots.grid_view` → the stock `RecipeGridViewStyle`.
1208    pub fn style(mut self, style: impl GridViewStyle) -> Self {
1209        self.style = Some(Rc::new(style));
1210        self
1211    }
1212
1213    /// Build the header-widget factory (section → widget) shared by the body
1214    /// pane and the pinned slot, falling back to a default bold-text header.
1215    #[allow(clippy::type_complexity)]
1216    fn header_factory(&self) -> Option<Rc<dyn Fn(usize) -> Box<dyn Widget>>> {
1217        let data = self.section_data.as_ref()?;
1218        let title_fn = data.title_fn.clone();
1219        let delegate = self.header_delegate.clone();
1220        Some(Rc::new(move |section| {
1221            let title = title_fn(section);
1222            match &delegate {
1223                Some(d) => d(section, &title),
1224                None => Box::new(TextWidget::new(teksilo_i18n::lit!(title))) as Box<dyn Widget>,
1225            }
1226        }))
1227    }
1228
1229    /// Widget shown when the model is empty.
1230    pub fn empty_view(mut self, f: impl Fn() -> Box<dyn Widget> + 'static) -> Self {
1231        self.empty_view = Some(Rc::new(f));
1232        self
1233    }
1234
1235    /// Widget overlaid while `is_loading` reads `true`.
1236    pub fn loading_view(mut self, f: impl Fn() -> Box<dyn Widget> + 'static) -> Self {
1237        self.loading_view = Some(Rc::new(f));
1238        self
1239    }
1240
1241    /// Reactive loading flag; when `true` the [`loading_view`](Self::loading_view)
1242    /// is shown above the grid.
1243    pub fn is_loading(mut self, flag: impl Into<Prop<bool>>) -> Self {
1244        self.is_loading = Some(flag.into());
1245        self
1246    }
1247
1248    // ── Drag-to-reorder ─────────────────────────────────────────────────
1249
1250    /// Enable intra-grid drag reordering (and keyboard Alt+Arrow). The move is
1251    /// routed through the source's `accept_drop` (a built-in `ListModel`
1252    /// reorders via `move_item`; an external source applies its own command).
1253    pub fn reorderable(mut self, enabled: bool) -> Self {
1254        self.reorderable = enabled;
1255        self
1256    }
1257
1258    /// Make tiles **droppable outside this view** — on a
1259    /// [`DropTarget`](crate::DropTarget), another data view, or the OS.
1260    ///
1261    /// A dragged tile (or the whole selection, when the pressed tile is part of
1262    /// a multi-selection) carries clones of its items in a public
1263    /// [`RowDragData<T>`](crate::RowDragData), so a foreign receiver can pull
1264    /// them out with `payload.get_typed::<RowDragData<T>>()` /
1265    /// `DropTarget::on_drop_typed::<RowDragData<T>>()` — no serialization. This
1266    /// also makes tiles a drag source even without [`reorderable`](Self::reorderable).
1267    ///
1268    /// `mode` chooses what happens to the origin rows once a *foreign* target
1269    /// accepts them: [`DragTransferMode::Move`] removes them (via the source's
1270    /// `on_drag_out`, or [`on_rows_transferred_out`](Self::on_rows_transferred_out)),
1271    /// [`DragTransferMode::Copy`] leaves them. A same-view reorder is never a
1272    /// transfer, so `mode` never affects it. Requires `T: Clone`.
1273    pub fn exportable(mut self, mode: DragTransferMode) -> Self
1274    where
1275        T: Clone,
1276    {
1277        self.export.set_exportable(mode);
1278        self
1279    }
1280
1281    /// Additionally advertise the dragged tiles as MIME data so they can be
1282    /// dropped on a [`DropZone`](crate::DropZone) or exported to another
1283    /// application / window via the OS. `f` maps the dragged items to
1284    /// `(mime_type, bytes)` pairs (e.g. `text/plain`, `text/uri-list`, an
1285    /// app-specific `application/x-…`). Implies [`exportable`](Self::exportable)
1286    /// (defaulting to [`DragTransferMode::Move`] if not already set). Requires
1287    /// `T: Clone`.
1288    pub fn export_external(mut self, f: impl Fn(&[T]) -> Vec<(String, Vec<u8>)> + 'static) -> Self
1289    where
1290        T: Clone,
1291    {
1292        self.export.set_export_external(f);
1293        self
1294    }
1295
1296    /// Override how rows moved out to a foreign target are removed from this
1297    /// view. Receives the dragged rows' indices (descending-safe) and the live
1298    /// context. Without this, an [`exportable`](Self::exportable)
1299    /// [`Move`](DragTransferMode::Move) drag removes them through the source's
1300    /// `on_drag_out` (works out of the box for a `ListModel`).
1301    pub fn on_rows_transferred_out(
1302        mut self,
1303        f: impl Fn(&[usize], &mut teksilo_core::widget::EventContext) + 'static,
1304    ) -> Self {
1305        self.export.set_on_rows_transferred_out(f);
1306        self
1307    }
1308
1309    /// Accept exported rows dropped from a **different** view or source without
1310    /// writing a custom `ListDataSource`. Pair with
1311    /// [`on_rows_received`](Self::on_rows_received), which is handed the dropped
1312    /// items and the insertion index. (Same-view reorder is
1313    /// [`reorderable`](Self::reorderable); a custom `ListDataSource` can still
1314    /// accept foreign drops through its `can_accept`/`accept_drop` instead.)
1315    pub fn accept_foreign_rows(mut self, accept: bool) -> Self {
1316        self.export.accept_foreign_rows = accept;
1317        self
1318    }
1319
1320    /// Handler for rows accepted via [`accept_foreign_rows`](Self::accept_foreign_rows):
1321    /// `(items, insertion_index, ctx)`. Insert them into your model at the
1322    /// index.
1323    pub fn on_rows_received(
1324        mut self,
1325        f: impl Fn(Vec<T>, usize, &mut teksilo_core::widget::EventContext) + 'static,
1326    ) -> Self {
1327        self.export.set_on_rows_received(f);
1328        self
1329    }
1330
1331    /// Accept external drops at a flat insertion index. Returns `true` when
1332    /// the drop is accepted.
1333    pub fn on_item_drop(
1334        mut self,
1335        f: impl Fn(
1336            teksilo_core::drag_payload::DragPayload,
1337            usize,
1338            &mut teksilo_core::widget::EventContext,
1339        ) -> bool
1340        + 'static,
1341    ) -> Self {
1342        self.on_item_drop = Some(Rc::new(f));
1343        self
1344    }
1345
1346    // ── Activation / context menu / type-ahead / loading ────────────────
1347
1348    /// Called when a tile is activated (a click per [`activate_on`](Self::activate_on),
1349    /// or Enter on the focused tile) — the "open / default action", distinct
1350    /// from selection.
1351    pub fn on_tile_activate(
1352        mut self,
1353        f: impl Fn(usize, &mut teksilo_core::widget::EventContext) + 'static,
1354    ) -> Self {
1355        self.on_tile_activate = Some(Rc::new(f));
1356        self
1357    }
1358
1359    /// Choose single- vs double-click tile activation (default
1360    /// [`ActivateOn::DoubleClick`](crate::ActivateOn)). Enter activates in either
1361    /// mode.
1362    pub fn activate_on(mut self, mode: crate::data_views::ActivateOn) -> Self {
1363        self.activate_on = mode;
1364        self
1365    }
1366
1367    /// Per-tile context-menu factory: `(index, pointer_position, ctx)` →
1368    /// optional menu widget.
1369    pub fn tile_context_menu(
1370        mut self,
1371        f: impl Fn(usize, Point, &mut teksilo_core::widget::EventContext) -> Option<Box<dyn Widget>>
1372        + 'static,
1373    ) -> Self {
1374        self.tile_context_menu = Some(Rc::new(f));
1375        self
1376    }
1377
1378    /// Supply a per-item label for type-ahead navigation (typing letters
1379    /// jumps to the first matching item). Required to enable type-ahead.
1380    pub fn type_ahead_label(mut self, f: impl Fn(usize) -> String + 'static) -> Self {
1381        self.type_ahead_label = Some(Rc::new(f));
1382        self
1383    }
1384
1385    /// Supply a per-item accessible name applied to each tile's `GridCell`
1386    /// (`Node::label`), so a screen reader announces a concise item name in
1387    /// addition to the row/column position. Without it, the cell's name is left
1388    /// to its contents.
1389    pub fn tile_a11y_label(mut self, f: impl Fn(usize) -> String + 'static) -> Self {
1390        self.tile_a11y_label = Some(Rc::new(f));
1391        self
1392    }
1393
1394    /// Type-ahead reset timeout (default 500 ms; `ZERO` disables).
1395    pub fn type_ahead_timeout(mut self, timeout: std::time::Duration) -> Self {
1396        self.type_ahead_timeout = timeout;
1397        self
1398    }
1399
1400    // ── Detail band ─────────────────────────────────────────────────────
1401
1402    /// The disclosed tile: `Some(index)` opens a full-width band under the
1403    /// row holding tile `index` (see [`detail_row`](Self::detail_row)),
1404    /// `None` closes it. The grid writes it when a tile is activated (not for
1405    /// a tile with nothing to disclose), and follows writes made from
1406    /// outside. Without this the grid keeps its own signal.
1407    pub fn expanded_index(mut self, expanded: Signal<Option<usize>>) -> Self {
1408        self.expanded = expanded;
1409        self
1410    }
1411
1412    /// The content of the detail band: the "album expansion" of a music
1413    /// library, a full-width band opened directly under the row that holds
1414    /// the activated tile.
1415    ///
1416    /// `f` receives the disclosed tile's [`TileContext`], the same one the
1417    /// tile delegate gets, and returns the band's content, or `None` when the
1418    /// tile has nothing to disclose. It is also asked when a tile is
1419    /// activated, to learn whether it has anything, and its answer dropped,
1420    /// so it should be cheap and have no side effects. The band pushes the
1421    /// following rows down, and stays under its tile when a resize changes
1422    /// the column count.
1423    ///
1424    /// With a band, activating a tile (a click per
1425    /// [`activate_on`](Self::activate_on), or Enter) opens it and scrolls it
1426    /// into view, and activating the open tile closes it;
1427    /// [`on_tile_activate`](Self::on_tile_activate) still fires. A tile with
1428    /// nothing to disclose opens nothing. In a single selection, Enter also
1429    /// selects the tile whose band it opens, unless the application has an
1430    /// activation of its own; a multiple selection is left as it is. Space on
1431    /// the open tile closes it. ↓ from the open tile
1432    /// moves focus to the first focusable control in the band, scrolling it
1433    /// into view, and ↑ from there, when the control does not use it, returns
1434    /// to the tile. A band that closes with focus inside it hands focus back
1435    /// to the grid, on its tile. Arrows between tiles step over the band, and
1436    /// the rubber band selects no band.
1437    ///
1438    /// The band follows its tile when the tile moves. A source with item keys
1439    /// is followed through a sort or a filter (a reset); a source without
1440    /// them is followed through inserts, removals and moves, and closes the
1441    /// band on a reset. A [`ListModel`]'s keys are its positions, which
1442    /// identify no item across a reset, so it counts as a source without
1443    /// keys here, through [`from_source`](Self::from_source) as through
1444    /// [`new`](Self::new). The band closes when its tile is removed.
1445    ///
1446    /// The band is measured height-for-width at the content width, or sized
1447    /// by [`detail_row_height`](Self::detail_row_height). It works with the
1448    /// uniform grid, [`variable_row_heights`](Self::variable_row_heights) and
1449    /// [`sections`](Self::sections). A [`waterfall`](Self::waterfall) has no
1450    /// rows to put it under and ignores it. A band that opens, closes or
1451    /// moves above the viewport leaves the visible tiles where they are. Its
1452    /// content is rebuilt when the band moves to another tile, when its own
1453    /// tile's item arrives from a lazy source, and when the grid's rows
1454    /// change (an insert, a removal, a move, an update, a reset); a lazy
1455    /// source loading other windows leaves it as it is.
1456    ///
1457    /// Accessibility: the band is a `Role::Group`, read after its tile's
1458    /// row, named after its tile's [`tile_a11y_label`](Self::tile_a11y_label),
1459    /// or labelled by the tile, or, with the tile outside the realized
1460    /// window, "Details of item N" in the user's language. A tile carries an
1461    /// `expanded` state, `Expand` / `Collapse` actions and, while open, a
1462    /// `controls` relation to the band. Without
1463    /// [`detail_row_when`](Self::detail_row_when) every tile does, one with
1464    /// nothing to disclose included, since knowing otherwise means building
1465    /// its band, and that one stays collapsed. The grid's row and column
1466    /// counts and the tiles' positions do not count the band.
1467    pub fn detail_row(
1468        mut self,
1469        f: impl Fn(&TileContext<'_, T>) -> Option<Box<dyn Widget>> + 'static,
1470    ) -> Self {
1471        self.detail_row = Some(Rc::new(f));
1472        self
1473    }
1474
1475    /// Which tiles have a detail band, answered without building one: `f`
1476    /// receives the tile's [`TileContext`], as
1477    /// [`detail_row`](Self::detail_row) does, and returns `false` for a tile
1478    /// with nothing to disclose.
1479    ///
1480    /// Such a tile is not a disclosure. Assistive technology hears no
1481    /// expanded state on it and is offered no `Expand` or `Collapse`, which it
1482    /// would be otherwise. Activating it opens nothing, `detail_row` is not
1483    /// asked for it, and [`expanded_index`](Self::expanded_index) naming it
1484    /// opens no band.
1485    ///
1486    /// `f` is asked as each tile is built: when it comes into the realized
1487    /// window, after a change to the data, and when its selectedness flips.
1488    /// Answer from the item, cheaply and with no side effects, and agree with
1489    /// `detail_row`: a tile `f` accepts and `detail_row` gives nothing for is
1490    /// offered as expandable, and opens nothing. A tile of a lazy source
1491    /// whose item has not arrived is offered until it does. Without
1492    /// `detail_row`, this does nothing.
1493    pub fn detail_row_when(mut self, f: impl Fn(&TileContext<'_, T>) -> bool + 'static) -> Self {
1494        self.detail_row_when = Some(Rc::new(f));
1495        self
1496    }
1497
1498    /// The detail band's height for the tile at `index`, instead of measuring
1499    /// it. See [`detail_row`](Self::detail_row).
1500    pub fn detail_row_height(mut self, f: impl Fn(usize) -> f32 + 'static) -> Self {
1501        self.detail_row_height = Some(Rc::new(f));
1502        self
1503    }
1504
1505    // ── Internals ───────────────────────────────────────────────────────
1506
1507    /// The detail band's shared state, when a band is in force: a
1508    /// `detail_row` was given and the layout has rows. Built once.
1509    fn ensure_detail(&mut self) -> Option<Rc<detail::DetailState>> {
1510        self.detail_row.as_ref()?;
1511        let has_rows = self.section_data.is_some()
1512            || !matches!(self.strategy_kind, StrategyKind::Waterfall { .. });
1513        if !has_rows {
1514            return None;
1515        }
1516        Some(
1517            self.detail
1518                .get_or_insert_with(|| {
1519                    Rc::new(detail::DetailState::new(
1520                        self.expanded.clone(),
1521                        self.detail_row_height.clone(),
1522                    ))
1523                })
1524                .clone(),
1525        )
1526    }
1527
1528    /// Build (once) and return the layout strategy. Cached so variable
1529    /// strategies keep their measurement caches across rebuilds.
1530    fn ensure_strategy(&mut self) -> Rc<dyn GridLayoutStrategy> {
1531        if let Some(s) = &self.strategy {
1532            return s.clone();
1533        }
1534        // Sections override the strategy kind (uniform tiles + headers).
1535        let sizing = self.sizing;
1536        let options = self.column_options;
1537        let inner: Rc<dyn GridLayoutStrategy> = if let Some(ref data) = self.section_data {
1538            Rc::new(
1539                SectionedGrid::new(
1540                    self.sizing,
1541                    self.col_gap,
1542                    self.row_gap,
1543                    self.inset,
1544                    self.header_height,
1545                    data.counts_fn.clone(),
1546                )
1547                .with_columns(sizing, options),
1548            )
1549        } else {
1550            match self.strategy_kind {
1551                StrategyKind::Uniform => Rc::new(
1552                    UniformGrid::new(self.sizing, self.col_gap, self.row_gap, self.inset)
1553                        .with_columns(sizing, options),
1554                ),
1555                StrategyKind::VariableRow { estimated } => Rc::new(
1556                    VariableRowGrid::new(
1557                        self.sizing,
1558                        self.col_gap,
1559                        self.row_gap,
1560                        self.inset,
1561                        estimated,
1562                        self.exact_item_height.clone(),
1563                    )
1564                    .with_columns(sizing, options),
1565                ),
1566                StrategyKind::Waterfall { estimated } => Rc::new(
1567                    VirtualizedMasonry::new(
1568                        self.sizing,
1569                        self.col_gap,
1570                        self.row_gap,
1571                        self.inset,
1572                        estimated,
1573                        self.exact_item_height.clone(),
1574                    )
1575                    .with_columns(sizing, options),
1576                ),
1577            }
1578        };
1579        let s: Rc<dyn GridLayoutStrategy> = match self.ensure_detail() {
1580            Some(state) => {
1581                let wrapped = Rc::new(detail::DetailRowStrategy::new(
1582                    inner,
1583                    state,
1584                    self.source.len_fn.clone(),
1585                    self.row_gap,
1586                    self.inset,
1587                ));
1588                self.detail_layout = Some(wrapped.clone());
1589                wrapped
1590            }
1591            None => inner,
1592        };
1593        self.strategy = Some(s.clone());
1594        s
1595    }
1596}
1597
1598impl<T: 'static> std::fmt::Debug for GridView<T> {
1599    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
1600        f.debug_struct("GridView")
1601            .field("items", &self.source.len())
1602            .field("scroll_bar_style", &self.scroll_bar_style)
1603            .field("scroll_y", &self.scroll_y.get())
1604            .finish()
1605    }
1606}
1607
1608/// The grid's erasure of `source`.
1609///
1610/// A [`ListModel`] goes through the erasure [`GridView::new`] uses. Its keys
1611/// are its positions (`ListModel::key_at(i)` is `i`), and a reset keeps a
1612/// position for whatever item lands there, so they identify no tile: erased
1613/// as a keyed source, they reported an anchor that tracks its tile, and a
1614/// `replace_all` left the detail band open over another item. Every other
1615/// source's keys are taken as identities.
1616fn erase_source<T: 'static, S: teksilo_data::ListDataSource<Item = T>>(
1617    source: Rc<S>,
1618) -> ListSource<T> {
1619    match (source.as_ref() as &dyn std::any::Any).downcast_ref::<ListModel<T>>() {
1620        Some(model) => ListSource::from_model(model.clone()),
1621        None => ListSource::from_data_source_rc(source),
1622    }
1623}
1624
1625/// In a single selection, move a cursor that is off the selection onto the
1626/// selected tile. A grid with no cursor, or a selection on no tile this grid
1627/// shows, leaves the cursor where it is. See
1628/// `GridView::follow_the_selection_in_single_mode`.
1629fn put_the_cursor_on_the_selection(
1630    focused_index: &Signal<Option<usize>>,
1631    selection: &RowSelection,
1632) {
1633    if selection.mode() != SelectionMode::Single {
1634        return;
1635    }
1636    if let Some(cursor) = focused_index.get()
1637        && !selection.is_selected(cursor)
1638        && let Some(&tile) = selection.selected_indices().first()
1639    {
1640        focused_index.set(Some(tile));
1641    }
1642}
1643
1644/// Keep the detail band on its tile after a change to the data, or close it.
1645///
1646/// A change that says where rows went (an insert, a removal, a move) is
1647/// followed by position, for any source: it is exact, and it closes the band
1648/// when its tile is the one removed. A reset says nothing about where
1649/// anything went; the tile is found again by its key when the source has
1650/// keys (a sort or a filter of a `SortFilterListModel`), and the band closes
1651/// when it has none (a `ListModel`'s positions count as none, see
1652/// `erase_source`), or when the tile is gone.
1653fn follow_the_disclosed_tile(
1654    detail: &detail::DetailState,
1655    anchor: Option<&crate::data_views::RowAnchor>,
1656    change: &DataChange,
1657    len: usize,
1658) {
1659    let Some(open) = detail.expanded.get() else {
1660        return;
1661    };
1662    let next = match change {
1663        DataChange::Reset => anchor
1664            .filter(|a| a.tracks_identity())
1665            .and_then(|a| a.index()),
1666        _ => teksilo_data::data_change::adjust_single_index_for_change(open, change),
1667    }
1668    .filter(|&i| i < len);
1669    if next != Some(open) {
1670        detail.expanded.set(next);
1671    }
1672}
1673
1674/// Hands `f` the [`TileContext`] of a tile, as the band's builder and
1675/// [`GridView::detail_row_when`] receive it, and returns whether the tile's
1676/// item was resident; `f` is not called when it was not.
1677type TileContextReader<T> = Rc<dyn Fn(usize, &mut dyn FnMut(&TileContext<'_, T>)) -> bool>;
1678
1679/// The [`TileContextReader`] the detail band asks through.
1680///
1681/// Reads the tile's row and column from the wrapped strategy rather than the
1682/// band's wrapper, which holds the state this is kept in.
1683fn tile_context_reader<T: 'static>(
1684    read_item_fn: body_pane::ReadItemFn<T>,
1685    strategy: Rc<dyn GridLayoutStrategy>,
1686    viewport_width: Rc<Cell<f32>>,
1687    selection: Option<RowSelection>,
1688    focused_index: Signal<Option<usize>>,
1689) -> TileContextReader<T> {
1690    Rc::new(move |index, f| {
1691        let (row, col) = strategy.tile_row_col(index, viewport_width.get());
1692        let is_selected = selection.as_ref().is_some_and(|s| s.is_selected(index));
1693        let is_focused = focused_index.get() == Some(index);
1694        (read_item_fn)(index, &mut |item| {
1695            f(&TileContext {
1696                index,
1697                row,
1698                col,
1699                item,
1700                is_selected,
1701                is_focused,
1702            })
1703        })
1704    })
1705}
1706
1707/// Whether a tile has a band, as the tiles ask it: the application's
1708/// [`GridView::detail_row_when`] over the tile's [`TileContext`], or `None`
1709/// for a tile of a lazy source whose item has not arrived.
1710fn band_when<T: 'static>(
1711    when: detail::DetailWhen<T>,
1712    read_tile: TileContextReader<T>,
1713) -> detail::BandWhenFn {
1714    Rc::new(move |index| {
1715        let mut answer = false;
1716        read_tile(index, &mut |tc| answer = when(tc)).then_some(answer)
1717    })
1718}
1719
1720/// The detail band's content for a tile, as the band and activation ask for
1721/// it: the application's builder over the tile's item and its
1722/// [`TileContext`], or a placeholder for a tile of a lazy source whose item
1723/// has not arrived.
1724fn band_content<T: 'static>(
1725    builder: detail::DetailBuilder<T>,
1726    read_tile: TileContextReader<T>,
1727    row_state_fn: Rc<dyn Fn(usize) -> teksilo_data::RowState>,
1728) -> detail::BandContentFn {
1729    Rc::new(move |index| {
1730        let mut content = None;
1731        let resident = read_tile(index, &mut |tc| content = builder(tc));
1732        if resident {
1733            (content, true)
1734        } else {
1735            // Hold the band's place, as a tile does, rather than collapse it.
1736            let placeholder = ((row_state_fn)(index) == teksilo_data::RowState::Loading)
1737                .then(crate::data_views::default_placeholder);
1738            (placeholder, false)
1739        }
1740    })
1741}
1742
1743/// Scroll `target`, in window coordinates, into a viewport whose top edge is
1744/// at `viewport_top`, the way `ScrollArea` answers
1745/// `WidgetEvent::ScrollIntoView`: the least scroll that shows it, or the pin
1746/// `align` asks for, gliding over `glide` when given. Returns how far the
1747/// content scrolled.
1748#[allow(clippy::too_many_arguments)]
1749fn reveal_in_viewport(
1750    target: Rect,
1751    margin: f32,
1752    align: teksilo_core::event::ScrollAlign,
1753    glide: Option<Duration>,
1754    scroll_y: &Signal<f32>,
1755    max_scroll_y: &Signal<f32>,
1756    viewport_top: f32,
1757    viewport_height: f32,
1758) -> f32 {
1759    let current = scroll_y.get();
1760    // In content coordinates: an edge at the viewport's top is at `current`.
1761    let top = target.y - viewport_top + current;
1762    let new_y = match align {
1763        // A pin names an exact place, so the margin does not pad it.
1764        teksilo_core::event::ScrollAlign::Fraction(f) => {
1765            top - (viewport_height - target.height) * f
1766        }
1767        teksilo_core::event::ScrollAlign::Minimal => {
1768            let (top, bottom) = (top - margin, top + target.height + margin);
1769            if top <= current && bottom >= current + viewport_height {
1770                // As visible as it can be: larger than the viewport.
1771                current
1772            } else if top < current {
1773                top
1774            } else if bottom > current + viewport_height {
1775                bottom - viewport_height
1776            } else {
1777                current
1778            }
1779        }
1780    }
1781    .clamp(0.0, max_scroll_y.get());
1782    if (new_y - current).abs() <= 0.001 {
1783        return 0.0;
1784    }
1785    match glide {
1786        Some(duration) => scroll_y.animate_to(new_y, duration, teksilo_tokens::Easing::EaseOut),
1787        None => scroll_y.set(new_y),
1788    }
1789    new_y - current
1790}
1791
1792/// The scroll offset that brings tile `index` into view per `anchor`, or
1793/// `None` when the offset already satisfies it.
1794///
1795/// Split out of [`GridView::ensure_index_visible`] so the reveal-on-focus
1796/// effect runs the same arithmetic as the public scroll-into-view API rather
1797/// than a second copy of it: the effect outlives any borrow of `self` and
1798/// holds cloned handles instead of the widget, so it cannot call the method.
1799/// Takes the geometry by value for the same reason. The keyboard has its own
1800/// call into `scroll_delta_to_reveal` (`grid_view/keyboard.rs:496-502`, in
1801/// `reveal_tile`), because it applies the delta to an enclosing scroll area as
1802/// well.
1803fn scroll_for_ensure_visible(
1804    strategy: &dyn GridLayoutStrategy,
1805    index: usize,
1806    scroll_y: f32,
1807    viewport_height: f32,
1808    viewport_width: f32,
1809    max_scroll_y: f32,
1810    anchor: ScrollAnchor,
1811) -> Option<f32> {
1812    let delta =
1813        strategy.scroll_delta_to_reveal(index, scroll_y, viewport_height, viewport_width, anchor);
1814    if delta.abs() <= 0.01 {
1815        return None;
1816    }
1817    Some((scroll_y + delta).clamp(0.0, max_scroll_y))
1818}
1819
1820/// The scroll offset a [`handle::RevealRequest`] asks for, or `None` when the
1821/// offset already satisfies it or the grid has no such tile.
1822///
1823/// The one arithmetic behind [`GridView::scroll_to_index`] and
1824/// [`GridView::ensure_index_visible`], applied at call time, and behind a
1825/// [`GridViewHandle`]'s requests, applied by `place_children`. A band
1826/// request reveals the span from the top of the tile's row to the bottom of
1827/// its band, the row first when the two do not fit, as the grid does for a
1828/// band a user opens; with that tile's band not open it reveals the tile.
1829#[allow(clippy::too_many_arguments)]
1830fn scroll_for_request(
1831    strategy: &dyn GridLayoutStrategy,
1832    detail_layout: Option<&detail::DetailRowStrategy>,
1833    request: handle::RevealRequest,
1834    len: usize,
1835    scroll_y: f32,
1836    viewport_height: f32,
1837    viewport_width: f32,
1838    max_scroll_y: f32,
1839) -> Option<f32> {
1840    let index = request.index();
1841    if index >= len {
1842        return None;
1843    }
1844    let reveal = |anchor| {
1845        scroll_for_ensure_visible(
1846            strategy,
1847            index,
1848            scroll_y,
1849            viewport_height,
1850            viewport_width,
1851            max_scroll_y,
1852            anchor,
1853        )
1854    };
1855    match request {
1856        handle::RevealRequest::ScrollTo { anchor, .. } => reveal(anchor),
1857        handle::RevealRequest::Ensure { anchor, .. } => {
1858            // Fully visible is what `Auto` leaves alone.
1859            reveal(ScrollAnchor::Auto)?;
1860            reveal(anchor)
1861        }
1862        handle::RevealRequest::Band(_) => {
1863            let Some((top, bottom)) =
1864                detail_layout.and_then(|layout| layout.reveal_span(index, viewport_width))
1865            else {
1866                return reveal(ScrollAnchor::Auto);
1867            };
1868            let y = reveal_span_offset(top, bottom, scroll_y, viewport_height, max_scroll_y);
1869            ((y - scroll_y).abs() > 0.001).then_some(y)
1870        }
1871    }
1872}
1873
1874/// The least scroll from `scroll_y` that shows the content span
1875/// `top..bottom`, its top first when it is taller than the viewport, clamped
1876/// to `0..=max_scroll_y`.
1877fn reveal_span_offset(
1878    top: f32,
1879    bottom: f32,
1880    scroll_y: f32,
1881    viewport_height: f32,
1882    max_scroll_y: f32,
1883) -> f32 {
1884    let mut y = scroll_y;
1885    if bottom > y + viewport_height {
1886        y = bottom - viewport_height;
1887    }
1888    if top < y {
1889        y = top;
1890    }
1891    y.clamp(0.0, max_scroll_y)
1892}
1893
1894/// The part of a band at content rect `band` on screen at `scroll_y`, in
1895/// band-local coordinates; `None` when none of it is.
1896fn band_visible_span(
1897    band: TileRect,
1898    scroll_y: f32,
1899    viewport_height: f32,
1900) -> Option<std::ops::Range<f32>> {
1901    let start = (scroll_y - band.y).max(0.0);
1902    let end = (scroll_y + viewport_height - band.y).min(band.height);
1903    (end > start).then_some(start..end)
1904}
1905
1906impl<T: 'static> Widget for GridView<T> {
1907    fn build(&mut self, ctx: &mut BuildContext) -> Vec<WidgetId> {
1908        let self_id = ctx.self_id();
1909        ctx.enabled_when(self_id, self.enabled.clone());
1910
1911        // Reactive tile sizing (the card-size slider): observe the bound signal
1912        // at Rebuild, and when its value changes, drop the cached strategy so
1913        // `ensure_strategy` rebuilds it with the new sizing and the grid reflows.
1914        // Done before `ensure_strategy` so this build already uses the new value.
1915        if let Some(ref sig) = self.sizing_signal {
1916            sig.bind_to(self_id, ctx.binding_registry(), BindingLevel::Rebuild);
1917            let next = sig.get();
1918            if self.sizing != next {
1919                self.sizing = next;
1920                self.strategy = None;
1921            }
1922        }
1923
1924        let strategy = self.ensure_strategy();
1925
1926        // Rebuild trigger (data changes, empty/non-empty transition).
1927        let version = ctx.signal(0_u64);
1928        version.bind_to(ctx.self_id(), ctx.binding_registry(), BindingLevel::Rebuild);
1929
1930        // scroll_y at Relayout so place_children re-writes max_scroll/ratio.
1931        self.scroll_y.bind_to(
1932            ctx.self_id(),
1933            ctx.binding_registry(),
1934            BindingLevel::Relayout,
1935        );
1936        ctx.register_animated_signal(&self.scroll_y);
1937        // A handle's request relays the grid out; `place_children` applies it.
1938        self.reveal.relayout_signal().bind_to(
1939            ctx.self_id(),
1940            ctx.binding_registry(),
1941            BindingLevel::Relayout,
1942        );
1943
1944        // Re-walk container a11y when selection / focus changes, and repaint
1945        // the overlay's container ring. Never a rebuild: each tile watches
1946        // its own selectedness (`TileA11y`). A counter rather than the model's
1947        // own signal, because the facade hides which of the two models it is.
1948        let selection_changed = ctx.signal(0_u64);
1949        if let Some(ref sel) = self.selection {
1950            selection_changed.bind_to(
1951                ctx.self_id(),
1952                ctx.binding_registry(),
1953                BindingLevel::AccessibilityOnly,
1954            );
1955            let changed = selection_changed.clone();
1956            let handle = sel.observe_for_rebuild(move || changed.set(changed.get() + 1));
1957            ctx.own_handle(handle);
1958        }
1959        self.focused_index.bind_to(
1960            ctx.self_id(),
1961            ctx.binding_registry(),
1962            BindingLevel::AccessibilityOnly,
1963        );
1964
1965        self.follow_the_selection_in_single_mode(ctx);
1966        // Taking focus scrolls the current tile into the realized window,
1967        // which is what gives `accessibility` a tile id to nominate.
1968        self.reveal_focused_tile_on_focus(ctx, strategy.clone());
1969
1970        // The detail band, when one is in force. Opening and closing it moves
1971        // the rows after it, which is a relayout here and nothing more: the
1972        // band rebuilds its own content, and no tile is rebuilt.
1973        let detail = self.detail_layout.as_ref().and(self.detail.clone());
1974        if let Some(ref d) = detail {
1975            d.expanded.bind_to(
1976                ctx.self_id(),
1977                ctx.binding_registry(),
1978                BindingLevel::Relayout,
1979            );
1980            // The band rebuilt alone, around content that arrived: its height
1981            // is measured here.
1982            d.version.bind_to(
1983                ctx.self_id(),
1984                ctx.binding_registry(),
1985                BindingLevel::Relayout,
1986            );
1987            self.track_the_disclosed_tile(ctx, d);
1988            if let (Some(layout), Some(builder)) = (&self.detail_layout, &self.detail_row) {
1989                let read_tile = tile_context_reader(
1990                    self.source.read_item_fn.clone(),
1991                    layout.inner(),
1992                    self.viewport_width.clone(),
1993                    self.selection.clone(),
1994                    self.focused_index.clone(),
1995                );
1996                if let Some(when) = &self.detail_row_when {
1997                    d.set_when(band_when(when.clone(), read_tile.clone()));
1998                }
1999                d.set_content(band_content(
2000                    builder.clone(),
2001                    read_tile,
2002                    self.source.dnd.row_state_fn.clone(),
2003                ));
2004            }
2005        }
2006        // The body pane's rebuild trigger, made here so the model observer
2007        // below can rebuild the pane alone when a window loads.
2008        let pane_version = Signal::new(0_u64);
2009
2010        // Observe model changes.
2011        {
2012            let v = version.clone();
2013            let counter = Rc::new(Cell::new(0_u64));
2014            let strategy_obs = strategy.clone();
2015            let selection_obs = self.selection.clone();
2016            let len_fn = self.source.len_fn.clone();
2017            let scroll_reset = self.scroll_y.clone();
2018            let focused_obs = self.focused_index.clone();
2019            let detail_obs = detail.clone();
2020            let anchor_obs = self.detail_anchor.clone();
2021            let pane_v = pane_version.clone();
2022            let handle = (self.source.observe_fn)(Box::new(move |change| {
2023                // Keep the keyboard-focus anchor in step too — otherwise it
2024                // silently points at the wrong tile after an insert / remove
2025                // / move (reachable not just from local edits but from a
2026                // live watcher pushing in a peer process's write), and the
2027                // next Enter/Space acts on the wrong item. Mirrors
2028                // `ListView`'s `focused_index` adjustment.
2029                //
2030                // Before the selection is adjusted, not after: in a single
2031                // selection the selection's own observer puts the cursor on
2032                // the selected tile (`follow_the_selection_in_single_mode`),
2033                // and a cursor shifted after that would move twice.
2034                let had_cursor = focused_obs.get().is_some();
2035                if let Some(current) = focused_obs.get() {
2036                    focused_obs.set(teksilo_data::data_change::adjust_single_index_for_change(
2037                        current, change,
2038                    ));
2039                }
2040                match change {
2041                    DataChange::ItemsInserted { range } => {
2042                        strategy_obs.invalidate_rows(range.start..usize::MAX);
2043                        strategy_obs.resize((len_fn)());
2044                    }
2045                    DataChange::ItemsRemoved { range } => {
2046                        strategy_obs.invalidate_rows(range.start..usize::MAX);
2047                        strategy_obs.resize((len_fn)());
2048                    }
2049                    DataChange::ItemsMoved { .. } => {
2050                        strategy_obs.invalidate_rows(0..usize::MAX);
2051                    }
2052                    DataChange::ItemUpdated { index } => {
2053                        strategy_obs.invalidate_rows(*index..index + 1);
2054                    }
2055                    DataChange::WindowLoaded { range } => {
2056                        strategy_obs.invalidate_rows(range.start..range.end);
2057                    }
2058                    DataChange::Reset => {
2059                        strategy_obs.invalidate_rows(0..usize::MAX);
2060                        strategy_obs.resize(0);
2061                        scroll_reset.set(0.0);
2062                    }
2063                }
2064                // Index-shift (index model: insert / remove / move, cleared
2065                // on a reset) or prune the keys that left the source (keyed
2066                // model, which needs nothing else to follow its items).
2067                if let Some(ref s) = selection_obs {
2068                    s.on_data_change(change);
2069                    // A keyed selection follows its tile through a sort
2070                    // without being written, so no selection observer hears
2071                    // it, while the sort's reset has dropped the cursor. In a
2072                    // single selection the cursor goes back onto the tile, or
2073                    // the grid would name no tile as its active descendant.
2074                    if had_cursor
2075                        && focused_obs.get().is_none()
2076                        && s.mode() == SelectionMode::Single
2077                        && let Some(&tile) = s.selected_indices().first()
2078                    {
2079                        focused_obs.set(Some(tile));
2080                    }
2081                }
2082                if let Some(ref d) = detail_obs {
2083                    // Cloned out: moving the band re-anchors it, through an
2084                    // observer that writes this slot.
2085                    let anchor = anchor_obs.borrow().clone();
2086                    follow_the_disclosed_tile(d, anchor.as_ref(), change, (len_fn)());
2087                    if !matches!(change, DataChange::WindowLoaded { .. }) {
2088                        d.forget_placement();
2089                    }
2090                }
2091                if let DataChange::WindowLoaded { range } = change {
2092                    // Items arrived for tiles the pane drew as placeholders.
2093                    // Nothing else this root builds shows them, so the pane
2094                    // is rebuilt alone: rebuilding the root threw the band's
2095                    // content away, its focus and scroll offset with it,
2096                    // every time a lazy source slid its window. The band is
2097                    // built again only when it was waiting for this window.
2098                    pane_v.set(pane_v.get() + 1);
2099                    if let Some(ref d) = detail_obs
2100                        && d.awaiting_item.get()
2101                        && d.expanded.get().is_some_and(|i| range.contains(&i))
2102                    {
2103                        d.version.set(d.version.get().wrapping_add(1));
2104                    }
2105                    return;
2106                }
2107                let next = counter.get() + 1;
2108                counter.set(next);
2109                v.set(next);
2110            }));
2111            ctx.own_handle(handle);
2112        }
2113
2114        // Fire on_selection_changed on every selection change (interactive
2115        // or programmatic). The framework's reactive observers don't carry
2116        // an EventContext, so the callback receives only the selection set.
2117        if let (Some(sel), Some(cb)) = (&self.selection, &self.on_selection_changed) {
2118            let cb = cb.clone();
2119            let live = sel.clone();
2120            let handle = sel.observe_for_rebuild(move || {
2121                let set: BTreeSet<usize> = live.selected_indices().into_iter().collect();
2122                cb(&set);
2123            });
2124            ctx.own_handle(handle);
2125        }
2126
2127        // Rebuild when the loading flag toggles (shows/hides the overlay).
2128        if let Some(flag) = &self.is_loading {
2129            let v = version.clone();
2130            let c = Rc::new(Cell::new(0_u64));
2131            ctx.effect(&flag.as_signal(), move |_| {
2132                c.set(c.get() + 1);
2133                v.set(c.get());
2134            });
2135        }
2136
2137        // Self handlers: scroll wheel + keyboard.
2138        let mut handlers = HandlerSet::new().clips_children(true).focusable(true);
2139        {
2140            // The wheel arithmetic, the pan and the claim that puts this node
2141            // on a finger's claimant chain all come from `common::scrollable`.
2142            // A wheel still takes the path it always did —
2143            // `handle_scroll_event` branches on the scroll *source*, not the
2144            // phase.
2145            //
2146            // The notch size is snapshotted from the strategy in force at
2147            // build, which is what the hand-rolled handler did: a strategy
2148            // swapped after mount keeps the notch it was built with until the
2149            // next rebuild.
2150            let behavior = crate::common::scrollable::ScrollableBehavior::new(
2151                crate::common::scrollable::ScrollableAxes::vertical(
2152                    self.scroll_y.clone(),
2153                    self.max_scroll_y.clone(),
2154                ),
2155            )
2156            .with_scroller(self.scroller.clone())
2157            // Vertical only: this view owns no horizontal offset, so a
2158            // horizontal pan is declined and chains outward.
2159            .axes(PanAxes::Y)
2160            .overscroll(self.overscroll_behavior)
2161            .smooth(self.smooth_scrolling)
2162            .smooth_duration(self.smooth_scroll_duration)
2163            .line_height(strategy.estimated_row_height().max(1.0))
2164            .reduced_motion(ctx.prefers_reduced_motion())
2165            .physics(ctx.theme().input.scroll_physics)
2166            .before({
2167                // The framework's reveal walk asks every clipping scroll
2168                // container above a widget that takes focus to bring it into
2169                // view. This grid ignored the question, so a control in the
2170                // detail band could take focus below the viewport, clipped.
2171                let scroll_y = self.scroll_y.clone();
2172                let max_scroll_y = self.max_scroll_y.clone();
2173                let viewport_height = self.viewport_height.clone();
2174                let viewport_origin = self.viewport_origin.clone();
2175                let glide = (self.smooth_scrolling && !ctx.prefers_reduced_motion())
2176                    .then_some(self.smooth_scroll_duration);
2177                move |event, _ctx| {
2178                    let WidgetEvent::ScrollIntoView {
2179                        target_bounds,
2180                        margin,
2181                        align,
2182                        motion,
2183                        applied_scroll,
2184                    } = event
2185                    else {
2186                        return None;
2187                    };
2188                    let Some(origin) = viewport_origin.get() else {
2189                        return Some(EventResponse::Ignored);
2190                    };
2191                    let glide =
2192                        glide.filter(|_| *motion == teksilo_core::event::ScrollMotion::Smooth);
2193                    let delta = reveal_in_viewport(
2194                        *target_bounds,
2195                        *margin,
2196                        *align,
2197                        glide,
2198                        &scroll_y,
2199                        &max_scroll_y,
2200                        origin.y,
2201                        viewport_height.get(),
2202                    );
2203                    if let Some(cell) = applied_scroll
2204                        && let Ok(mut applied) = cell.lock()
2205                    {
2206                        *applied = Point::new(0.0, delta);
2207                    }
2208                    Some(EventResponse::Handled)
2209                }
2210            });
2211            handlers = behavior.install(handlers);
2212        }
2213        // --- The non-drag reorder, all four routes at once ---
2214        //
2215        // SC 2.5.7 wants the tile drag reachable without a drag. One closure
2216        // performs the move; the chord below, the tile's context menu and the
2217        // tile's AccessKit custom actions all call it, so the routes cannot
2218        // reach different end states. The move travels the source's own
2219        // drop-accept path — see `common::ordered_move`.
2220        #[allow(clippy::type_complexity)]
2221        let reorder_perform: Option<
2222            Rc<dyn Fn(usize, usize, &mut teksilo_core::widget::EventContext)>,
2223        > = self.reorderable.then(|| {
2224            let mover = Rc::new(crate::common::ordered_move::RowMover {
2225                len: self.source.len_fn.clone(),
2226                stash: self.source.dnd.stash_drag_keys_fn.clone(),
2227                payload: {
2228                    let model_id = self.model_id;
2229                    Rc::new(move |idx: usize| {
2230                        DragPayload::typed(RowDragData::<T> {
2231                            source: model_id,
2232                            rows: vec![idx],
2233                            items: None,
2234                        })
2235                    })
2236                },
2237                accept: self.source.dnd.accept_drop_fn.clone(),
2238                view: self.model_id,
2239                name: {
2240                    // The type-ahead label resolver, where the application
2241                    // gave one; routed through the source's string accessor
2242                    // so an unloaded row yields no name rather than a
2243                    // fabricated one.
2244                    let with_item_str = self.source.with_item_str_fn.clone();
2245                    let label = self.type_ahead_label.clone();
2246                    Rc::new(move |index: usize| {
2247                        let label = label.as_ref()?;
2248                        // `label` is index-keyed here (a grid's delegate
2249                        // is), but the read still goes through the source so
2250                        // an unloaded tile yields no name rather than one
2251                        // computed for an absent item.
2252                        (with_item_str)(index, &|_item: &T| label(index))
2253                    })
2254                },
2255            });
2256            let focused = self.focused_index.clone();
2257            let selection = self.selection.clone();
2258            let strategy = strategy.clone();
2259            let scroll_y = self.scroll_y.clone();
2260            let max_scroll_y = self.max_scroll_y.clone();
2261            let viewport_height = self.viewport_height.clone();
2262            let viewport_width = self.viewport_width.clone();
2263            let viewport_origin = self.viewport_origin.clone();
2264            Rc::new(
2265                move |from: usize,
2266                      destination: usize,
2267                      ctx: &mut teksilo_core::widget::EventContext| {
2268                    let Some((dest, utterance)) = mover.commit_to(from, destination) else {
2269                        return;
2270                    };
2271                    focused.set(Some(dest));
2272                    if let Some(ref sel) = selection {
2273                        sel.select(dest);
2274                    }
2275                    keyboard::reveal_tile(
2276                        &strategy,
2277                        &scroll_y,
2278                        &max_scroll_y,
2279                        &viewport_height,
2280                        &viewport_width,
2281                        &viewport_origin,
2282                        dest,
2283                        ctx,
2284                    );
2285                    ctx.announce(utterance);
2286                },
2287            ) as Rc<dyn Fn(usize, usize, &mut teksilo_core::widget::EventContext)>
2288        });
2289
2290        // Every user action that can change how many tiles are selected runs
2291        // inside this, so the count is said once, by the action that changed
2292        // it. See `selection_count`.
2293        let count_voice = self.selection.clone().map(SelectionCountVoice::new);
2294
2295        // Activating a tile by the pointer: open or close its detail band
2296        // first, so the application's own handler sees the state the
2297        // activation left. The keys do the same in `keyboard::activate_tile`,
2298        // which also decides what Enter does to the selection.
2299        #[allow(clippy::type_complexity)]
2300        let activate: Option<Rc<dyn Fn(usize, &mut teksilo_core::widget::EventContext)>> =
2301            match &detail {
2302                Some(d) => {
2303                    let d = d.clone();
2304                    let user = self.on_tile_activate.clone();
2305                    Some(Rc::new(move |index, ctx| {
2306                        d.activate(index);
2307                        if let Some(user) = &user {
2308                            user(index, ctx);
2309                        }
2310                    }))
2311                }
2312                None => self.on_tile_activate.clone(),
2313            };
2314
2315        // Select all comes through the rebindable `edit.select_all`, inside
2316        // the same count voice as the keys.
2317        {
2318            let len = self.source.len_fn.clone();
2319            let selection = self.selection.clone();
2320            let detail = detail.clone();
2321            let voice = count_voice.clone();
2322            crate::common::standard_edit::register_standard_edit_action(
2323                ctx,
2324                teksilo_core::shortcut::StandardEdit::SelectAll,
2325                move |ctx| {
2326                    let run = |_ctx: &mut teksilo_core::widget::EventContext| {
2327                        keyboard::run_select_all((len)(), selection.as_ref(), detail.as_deref())
2328                    };
2329                    match &voice {
2330                        Some(voice) => voice.around(ctx, run),
2331                        None => run(ctx),
2332                    }
2333                },
2334            );
2335        }
2336
2337        let mut on_key = build_grid_key_handler(GridKeyConfig {
2338            len_fn: self.source.len_fn.clone(),
2339            col_count: self.column_count.clone(),
2340            focused_index: self.focused_index.clone(),
2341            selection: self.selection.clone(),
2342            count_voice: count_voice.clone(),
2343            scroll_y: self.scroll_y.clone(),
2344            max_scroll_y: self.max_scroll_y.clone(),
2345            viewport_height: self.viewport_height.clone(),
2346            viewport_width: self.viewport_width.clone(),
2347            viewport_origin: self.viewport_origin.clone(),
2348            strategy: strategy.clone(),
2349            wrap_navigation: self.wrap_navigation,
2350            tab_traversal: self.tab_traversal,
2351            on_tile_activate: self.on_tile_activate.clone(),
2352            detail: detail.clone(),
2353            reorder_perform: reorder_perform.clone(),
2354            type_ahead_timeout: self.type_ahead_timeout,
2355            type_ahead: self.type_ahead.clone(),
2356            tile_map: self.tile_map.clone(),
2357            // Route through the source's string accessor so an unloaded
2358            // (lazy/windowed) row is skipped rather than searched with
2359            // whatever the app's index-only closure happens to compute for
2360            // it — mirrors `ListView::with_item_str_fn`. The public
2361            // `type_ahead_label(usize) -> String` API is unchanged; this
2362            // just gates it on row residency.
2363            type_ahead_label: self.type_ahead_label.as_ref().map(|label| {
2364                let label = label.clone();
2365                let with_item_str = self.source.with_item_str_fn.clone();
2366                Rc::new(move |i: usize| (with_item_str)(i, &|_item: &T| label(i)))
2367                    as Rc<dyn Fn(usize) -> Option<String>>
2368            }),
2369        });
2370        {
2371            let voice = count_voice.clone();
2372            handlers = handlers.on_key(move |event, ctx| match &voice {
2373                Some(voice) => voice.around(ctx, |ctx| on_key(event, ctx)),
2374                None => on_key(event, ctx),
2375            });
2376        }
2377
2378        // Rubber-band marquee (Multi mode only). A container pointer handler
2379        // records the modifier state at press time for additive selection;
2380        // the drag handler sweeps the rectangle — from the `DragSurface` that
2381        // wraps the body pane, not from this root. See below.
2382        let mut marquee_drag: Option<_> = None;
2383        let marquee_on = self.marquee_selection
2384            && self
2385                .selection
2386                .as_ref()
2387                .map(|s| s.mode() == SelectionMode::Multi)
2388                .unwrap_or(false);
2389        if marquee_on {
2390            let additive_mods = Rc::new(Cell::new(false));
2391            {
2392                let mods = additive_mods.clone();
2393                handlers = handlers.on_pointer_event(move |event, _ctx| {
2394                    if let WidgetEvent::PointerDown { modifiers, .. } = event {
2395                        mods.set(modifiers.command() || modifiers.shift());
2396                    }
2397                    EventResponse::Ignored
2398                });
2399            }
2400            // The marquee's `on_drag` does NOT go on this root, and that is
2401            // load-bearing rather than tidy. This root is the `PanClaim`
2402            // holder, and a drag on the node that also captures the press
2403            // latches at `drag_slop` (18 dp) before a claim can win at
2404            // `pan_slop` (36) — the claim is then never evaluated at all, so a
2405            // `Multi`-selection grid did not scroll under a finger *and* did
2406            // not marquee (the drag declines a press that lands on a tile).
2407            // Hanging it on the `DragSurface` below instead makes it a strict
2408            // ancestor of whatever takes the press, which is the one shape the
2409            // tree arms `DragActivation` for: `Immediate` for a mouse (5 dp,
2410            // exactly as before), `AfterLongPress` for a finger.
2411            let mut sweep = build_marquee_handler(MarqueeConfig {
2412                marquee: self.marquee.clone(),
2413                selection: self.selection.clone().unwrap(),
2414                strategy: strategy.clone(),
2415                scroll_y: self.scroll_y.clone(),
2416                viewport_width: self.viewport_width.clone(),
2417                len_fn: self.source.len_fn.clone(),
2418                additive_mods,
2419            });
2420            let voice = count_voice.clone();
2421            marquee_drag = Some(
2422                move |phase: teksilo_core::gesture::DragPhase,
2423                      ctx: &mut teksilo_core::widget::EventContext| match &voice {
2424                    Some(voice) => voice.around(ctx, |ctx| sweep(phase, ctx)),
2425                    None => sweep(phase, ctx),
2426                },
2427            );
2428
2429            // Viewport-edge auto-scroll while the marquee is active, so a
2430            // rubber-band selection can extend past the visible window —
2431            // matching `TabBar`/`TreeView`'s drag-tick edge-scroll. Those
2432            // ride `on_drag_tick`, which only fires for an `active_drag`
2433            // (a `DragPayload` session started via `start_drag`); the
2434            // marquee is a plain gesture-recognizer drag (`on_drag`) with
2435            // no such session, so it drives itself from the raw per-frame
2436            // handle instead — the same "owner-driven, non-visibility-
2437            // bound" path the rich-text editor's drag-select auto-scroll
2438            // uses. Not gated on reduced-motion: this is an interaction
2439            // (extending the selection), not decorative motion.
2440            let frame_request = ctx.frame_request_handle();
2441            let marquee_for_tick = self.marquee.clone();
2442            let scroll_for_tick = self.scroll_y.clone();
2443            let max_scroll_for_tick = self.max_scroll_y.clone();
2444            let viewport_h_for_tick = self.viewport_height.clone();
2445            ctx.effect(&ctx.frame_tick(), move |_delta| {
2446                let Some(st) = marquee_for_tick.get() else {
2447                    return;
2448                };
2449                let step = selection::marquee_auto_scroll_step(
2450                    st.current.y,
2451                    viewport_h_for_tick.get(),
2452                    st.kind,
2453                );
2454                if step != 0.0 {
2455                    let max = max_scroll_for_tick.get();
2456                    let new_y = (scroll_for_tick.get() + step).clamp(0.0, max);
2457                    scroll_for_tick.set(new_y);
2458                    // Still inside the edge band (or the marquee moved
2459                    // again next frame) — keep the chain alive so the
2460                    // pointer doesn't need to wiggle to keep scrolling.
2461                    frame_request.set(true);
2462                }
2463            });
2464        }
2465
2466        // Drop target: intra-grid reorder + foreign-rows receive + external
2467        // drops, with an insertion indicator painted by the overlay.
2468        // Hover/drop are routed through the SOURCE's `can_accept` /
2469        // `accept_drop` (the pre-drop validation), so a same-view
2470        // `RowDragData<T>` reorders and a foreign payload is the source's
2471        // call — falling back to the zero-custom-source `accept_foreign_rows`
2472        // sugar, then the app-level `on_item_drop` escape hatch.
2473        if self.export.is_drop_target(self.reorderable) || self.on_item_drop.is_some() {
2474            let has_drop_cb = self.on_item_drop.is_some();
2475            let my_id = self.model_id;
2476
2477            let strategy_h = strategy.clone();
2478            let scroll_h = self.scroll_y.clone();
2479            let vp_w_h = self.viewport_width.clone();
2480            let len_h = self.source.len_fn.clone();
2481            let can_accept_h = self.source.dnd.can_accept_fn.clone();
2482            let insertion_h = self.insertion.clone();
2483            let export_for_hover = self.export.clone();
2484            handlers = handlers.on_drag_hover(move |payload, position, _ctx| {
2485                let len = (len_h)();
2486                let idx = drag::insertion_index(
2487                    strategy_h.as_ref(),
2488                    position,
2489                    scroll_h.get(),
2490                    vp_w_h.get(),
2491                    len,
2492                );
2493                let allowed = drop_allowed::<T>(
2494                    &can_accept_h,
2495                    payload,
2496                    idx,
2497                    len,
2498                    my_id,
2499                    has_drop_cb,
2500                    &export_for_hover,
2501                );
2502                if allowed {
2503                    insertion_h.set(Some(idx));
2504                    // Engage (stops drop-target bubbling); the overlay paints
2505                    // the insertion bar, so no framework-drawn feedback.
2506                    teksilo_core::DropFeedback::Accept
2507                } else {
2508                    insertion_h.set(None);
2509                    teksilo_core::DropFeedback::NoFeedback
2510                }
2511            });
2512
2513            let insertion_leave = self.insertion.clone();
2514            handlers = handlers.on_drag_leave(move |_ctx| {
2515                insertion_leave.set(None);
2516            });
2517
2518            let strategy_d = strategy.clone();
2519            let scroll_d = self.scroll_y.clone();
2520            let vp_w_d = self.viewport_width.clone();
2521            let len_d = self.source.len_fn.clone();
2522            let accept_drop_d = self.source.dnd.accept_drop_fn.clone();
2523            let drop_cb = self.on_item_drop.clone();
2524            let insertion_d = self.insertion.clone();
2525            let export_for_drop = self.export.clone();
2526            let reorderable_for_drop = self.reorderable;
2527            handlers = handlers.on_drop(move |mut payload, position, ctx| {
2528                insertion_d.set(None);
2529                let len = (len_d)();
2530                let to = drag::insertion_index(
2531                    strategy_d.as_ref(),
2532                    position,
2533                    scroll_d.get(),
2534                    vp_w_d.get(),
2535                    len,
2536                );
2537                let is_same_view = payload
2538                    .get_typed::<RowDragData<T>>()
2539                    .is_some_and(|rd| rd.source == my_id);
2540                // (a) Same-view reorder + any source-handled drop go through
2541                // accept_drop first. A same-view drop only reorders when this
2542                // view is `reorderable` — otherwise it falls through and is
2543                // treated like a foreign payload (branches b/c).
2544                if (reorderable_for_drop || !is_same_view)
2545                    && let Some((target, position_kind)) = flat_insertion_target(to, len)
2546                    && (accept_drop_d)(&payload, target, position_kind, my_id)
2547                {
2548                    // Only suppress our OWN move-out for a genuine same-view
2549                    // drop.
2550                    if is_same_view {
2551                        export_for_drop.note_self_reorder();
2552                    }
2553                    return true;
2554                }
2555                // (b) Shared foreign-receive sugar: accept exported rows from
2556                // a different view/source without a custom ListDataSource.
2557                // Peeks before taking, so a payload that doesn't match
2558                // (same-view, or reorder-only) still reaches the raw escape
2559                // hatch (c) with its typed data intact.
2560                if export_for_drop.foreign_receive(&mut payload, my_id, to, ctx) {
2561                    return true;
2562                }
2563                // (c) Raw escape hatch for any other payload the app wants to
2564                // handle itself.
2565                if let Some(ref cb) = drop_cb {
2566                    return cb(payload, to, ctx);
2567                }
2568                false
2569            });
2570        }
2571        ctx.apply_self_handlers(handlers);
2572
2573        // Children: body pane (or empty view), scrollbar, overlay.
2574        // (Incremental loading — `request_window` / `fetch_more` — lives in the
2575        // body pane's realize loop now, driven by the source's `can_fetch_more`
2576        // / `fetch_more` capabilities; it fires on each scroll-buffer exit.)
2577        self.body_pane_id = None;
2578        self.empty_id = None;
2579        self.scrollbar_id = None;
2580        self.overlay_id = None;
2581        self.pinned_header_id = None;
2582        self.band_id = None;
2583
2584        let len = self.source.len();
2585        if len == 0 {
2586            self.tile_map.borrow_mut().clear();
2587            // No tiles, so no band: nothing may still point at the last one.
2588            if let Some(ref d) = detail {
2589                d.shown.set(None);
2590                d.band_id.set(None);
2591                d.enterable.set(false);
2592            }
2593            if let Some(ref ef) = self.empty_view {
2594                self.empty_id = Some(ctx.add_boxed(ef()));
2595            }
2596        } else {
2597            // The band before the pane: it records which tile it shows as it
2598            // builds, and the pane's realization window is read off that.
2599            if let Some(d) = &detail {
2600                let return_to_tile: detail::ReturnToTile = {
2601                    let grid = ctx.self_id();
2602                    let focused = self.focused_index.clone();
2603                    let strategy = strategy.clone();
2604                    let scroll_y = self.scroll_y.clone();
2605                    let max_scroll_y = self.max_scroll_y.clone();
2606                    let viewport_height = self.viewport_height.clone();
2607                    let viewport_width = self.viewport_width.clone();
2608                    let viewport_origin = self.viewport_origin.clone();
2609                    Rc::new(move |index, ctx| {
2610                        focused.set(Some(index));
2611                        ctx.request_focus(grid);
2612                        keyboard::reveal_tile(
2613                            &strategy,
2614                            &scroll_y,
2615                            &max_scroll_y,
2616                            &viewport_height,
2617                            &viewport_width,
2618                            &viewport_origin,
2619                            index,
2620                            ctx,
2621                        );
2622                    })
2623                };
2624                self.band_id = Some(ctx.add(detail::DetailBand {
2625                    state: d.clone(),
2626                    len_fn: self.source.len_fn.clone(),
2627                    focused_index: self.focused_index.clone(),
2628                    name_fn: self.tile_a11y_label.clone(),
2629                    tile_map: self.tile_map.clone(),
2630                    return_to_tile,
2631                    grid: ctx.self_id(),
2632                    child: None,
2633                }));
2634            }
2635
2636            // Pane → root total refresh (measuring strategies): re-place
2637            // this root when the body pane's measurements changed the
2638            // content total, so `max_scroll_y` / the thumb ratio pick up
2639            // the corrected value next frame.
2640            let pane_total_refresh = ctx.signal(0_u64);
2641            pane_total_refresh.bind_to(
2642                ctx.self_id(),
2643                ctx.binding_registry(),
2644                teksilo_core::binding::BindingLevel::Relayout,
2645            );
2646            let pane = GridBodyPane {
2647                len_fn: self.source.len_fn.clone(),
2648                with_item_fn: self.source.with_item_fn.clone(),
2649                delegate: self.delegate.clone(),
2650                strategy: strategy.clone(),
2651                viewport_width: self.viewport_width.clone(),
2652                viewport_height: self.viewport_height.clone(),
2653                viewport_origin: self.viewport_origin.clone(),
2654                column_count: self.column_count.clone(),
2655                scroll_y: self.scroll_y.clone(),
2656                selection: self.selection.clone(),
2657                count_voice: count_voice.clone(),
2658                focused_index: self.focused_index.clone(),
2659                on_tile_activate: activate.clone(),
2660                detail: detail.clone(),
2661                activate_on: self.activate_on,
2662                reorder_perform: reorder_perform.clone(),
2663                tile_context_menu: self.tile_context_menu.clone(),
2664                tile_a11y_label: self.tile_a11y_label.clone(),
2665                reorderable: self.reorderable,
2666                model_id: self.model_id,
2667                scope_owner: ctx.self_id(),
2668                drag_fn: self.source.dnd.drag_fn.clone(),
2669                row_state_fn: self.source.dnd.row_state_fn.clone(),
2670                request_window_fn: self.source.dnd.request_window_fn.clone(),
2671                can_fetch_more_fn: self.source.dnd.can_fetch_more_fn.clone(),
2672                fetch_more_fn: self.source.dnd.fetch_more_fn.clone(),
2673                export: self.export.clone(),
2674                read_item_fn: self.source.read_item_fn.clone(),
2675                snapshot_out_fn: self.source.dnd.snapshot_out_fn.clone(),
2676                tile_map: self.tile_map.clone(),
2677                header_factory: self.header_factory(),
2678                header_title: self.section_data.as_ref().map(|d| d.title_fn.clone()),
2679                // Fresh per GridView rebuild; persists across the
2680                // pane's own (buffer-exit / re-check / window-load) rebuilds.
2681                version: pane_version.clone(),
2682                prev_built_start: Rc::new(Cell::new(0)),
2683                prev_built_end: Rc::new(Cell::new(0)),
2684                total_refresh: pane_total_refresh,
2685                tile_entries: Vec::new(),
2686                tile_roots: Vec::new(),
2687                header_entries: Vec::new(),
2688                in_place_children: Cell::new(false),
2689            };
2690            let pane_id = ctx.add(pane);
2691            // A no-op tap gives the pane its own gesture arena, so a press on
2692            // the background *between* tiles is captured inside the
2693            // `DragSurface` rather than by it. Without that the surface would be
2694            // the captor and its own drag would win the arbitration at 18 dp,
2695            // which is the bug the surface exists to fix. A press on a tile is
2696            // captured by the tile's own absorber, deeper still — which it must
2697            // be, or the release would be dispatched here and bubbled outward,
2698            // never reaching the tile that recorded the press.
2699            ctx.apply_handlers(pane_id, crate::data_views::press_absorber());
2700            let surface_id = ctx.add(crate::data_views::DragSurface::new(pane_id));
2701            if let Some(drag) = marquee_drag.take() {
2702                ctx.apply_handlers(surface_id, HandlerSet::new().on_drag(drag));
2703            }
2704            self.body_pane_id = Some(surface_id);
2705
2706            let overlay = GridOverlay {
2707                focused_index: self.focused_index.clone(),
2708                // Grid root's inclusive focus signal (stack empty here → resolves
2709                // to this root) + input modality, so the ring is keyboard-only
2710                // and hides when the grid loses focus.
2711                view_focused: ctx.view_focus_active(),
2712                focus_visible: ctx.focus_visible(),
2713                band_focus: detail.as_ref().map(|d| d.focus_within.clone()),
2714                band_moved: detail.as_ref().map(|d| d.moved.clone()),
2715                selection: self.selection.clone(),
2716                selection_changed: selection_changed.clone(),
2717                scroll_y: self.scroll_y.clone(),
2718                strategy: strategy.clone(),
2719                viewport_width: self.viewport_width.clone(),
2720                marquee: self.marquee.clone(),
2721                insertion: self.insertion.clone(),
2722                style: self.style.clone(),
2723                len_fn: self.source.len_fn.clone(),
2724            };
2725            self.overlay_id = Some(ctx.add(overlay));
2726
2727            // Sticky pinned header slot (reused widget showing the current
2728            // section's header at the viewport top). Skipped when the
2729            // provider declares zero sections — `PinnedHeader::build` would
2730            // otherwise unconditionally invoke the factory at
2731            // `current_section`'s default (0), and a hand-rolled provider
2732            // indexing directly into its own section list would panic.
2733            self.pinned_header_id = None;
2734            let section_count = self
2735                .section_data
2736                .as_ref()
2737                .map(|d| (d.counts_fn)().len())
2738                .unwrap_or(0);
2739            if self.pinned_section_headers && section_count > 0 {
2740                if let Some(factory) = self.header_factory() {
2741                    let ph = PinnedHeader {
2742                        current_section: self.current_section.clone(),
2743                        factory,
2744                        child: None,
2745                        style: self.style.clone(),
2746                    };
2747                    self.pinned_header_id = Some(ctx.add(ph));
2748                }
2749            }
2750        }
2751
2752        if self.show_scrollbar {
2753            let sb = ScrollBar::new(
2754                ScrollBarOrientation::Vertical,
2755                self.scroll_y.clone(),
2756                self.max_scroll_y.clone(),
2757                self.viewport_ratio_y.clone(),
2758            )
2759            .visual(match self.scroll_bar_style {
2760                ScrollBarMode::Permanent => ScrollBarVisual::Permanent,
2761                ScrollBarMode::Overlay => ScrollBarVisual::Overlay,
2762                ScrollBarMode::Thin => ScrollBarVisual::Thin,
2763            });
2764            self.scrollbar_id = Some(ctx.add(sb));
2765        }
2766
2767        // Loading overlay (on top of everything).
2768        self.loading_id = None;
2769        if let Some(flag) = &self.is_loading {
2770            if flag.get() {
2771                if let Some(ref lv) = self.loading_view {
2772                    self.loading_id = Some(ctx.add_boxed(lv()));
2773                }
2774            }
2775        }
2776
2777        // Order = paint order. Overlay then loading paint last (on top).
2778        let mut children = Vec::new();
2779        if let Some(id) = self.body_pane_id {
2780            children.push(id);
2781        }
2782        if let Some(id) = self.band_id {
2783            children.push(id);
2784        }
2785        if let Some(id) = self.empty_id {
2786            children.push(id);
2787        }
2788        if let Some(id) = self.scrollbar_id {
2789            children.push(id);
2790        }
2791        if let Some(id) = self.overlay_id {
2792            children.push(id);
2793        }
2794        if let Some(id) = self.pinned_header_id {
2795            children.push(id);
2796        }
2797        if let Some(id) = self.loading_id {
2798            children.push(id);
2799        }
2800        children
2801    }
2802
2803    fn layout_response(
2804        &self,
2805        proposal: SizeProposal,
2806        _ctx: &LayoutContext,
2807    ) -> teksilo_core::widget::LayoutResponse {
2808        // Only an allocation may seed the cached viewport (`common::viewport`);
2809        // the body pane shares these cells, and `build` sizes its realization
2810        // window — and the strategy its column count — from them.
2811        let size = crate::common::viewport::viewport_size(
2812            proposal,
2813            &self.viewport_height,
2814            Size::new(400.0, 400.0),
2815        );
2816        if proposal.width.is_some() {
2817            self.viewport_width.set(size.width);
2818        }
2819        size.into()
2820    }
2821
2822    fn place_children(
2823        &self,
2824        bounds: Rect,
2825        _proposal: SizeProposal,
2826        children: &mut [WidgetPlacement],
2827        ctx: &LayoutContext,
2828    ) {
2829        let Some(ref strategy) = self.strategy else {
2830            return;
2831        };
2832        // Leading and trailing in `column_alignment` resolve against the
2833        // direction of this pass; every position read below and by the pane
2834        // (and, after layout, by paint, the marquee and the keys) follows.
2835        strategy.set_rtl(ctx.is_rtl());
2836        let len = self.source.len();
2837        let vp_h = bounds.height;
2838        // The rubber band's resistance is a fraction of the viewport. This
2839        // view does not band, but the scroller reads the extent either way and
2840        // this is the only pass that knows it.
2841        self.scroller
2842            .borrow_mut()
2843            .set_viewport(teksilo_canvas::Vec2::new(bounds.width, vp_h));
2844
2845        // Query the strategy at a SINGLE, stable body width per frame (using
2846        // the previous frame's scrollbar decision). Querying at two widths
2847        // would flip a variable strategy's column count back and forth and
2848        // reset its measurement cache every frame. The scrollbar appearing /
2849        // disappearing settles in one frame.
2850        // Permanent reserves a column for the bar; Overlay / Thin float
2851        // over the content, so tiles span the full width.
2852        let reserves_bar = self.scroll_bar_style == ScrollBarMode::Permanent;
2853        let body_w = if self.last_needs_scrollbar.get() && reserves_bar {
2854            (bounds.width - SCROLLBAR_THICKNESS).max(0.0)
2855        } else {
2856            bounds.width
2857        };
2858        self.viewport_width.set(body_w);
2859
2860        let cols = strategy.column_count(body_w).max(1);
2861        if self.column_count.get() != cols {
2862            self.column_count.set(cols);
2863        }
2864
2865        // The detail band is measured before the total is asked for, so the
2866        // total, and the tiles the pane places after this, already include
2867        // its height. Height-for-width at the band's own width, which is the
2868        // content's: a band of text wraps there.
2869        if let Some(layout) = &self.detail_layout {
2870            if let (Some(band), Some(d)) = (self.band_id, &self.detail)
2871                && d.measures()
2872                && let Some((index, rect)) = layout.band_rect(body_w)
2873                && let Some(measured) = ctx.child_size(band, SizeProposal::with_width(rect.width))
2874            {
2875                layout.record_band_height(index, measured.height);
2876            }
2877            // Whatever the band did since the last pass (opened, closed,
2878            // moved, grew), the content under the viewport's top edge stays
2879            // put. Clamped with everything else below.
2880            let shift = layout.settle_placement(self.scroll_y.get(), body_w);
2881            if shift.abs() > 0.01 {
2882                self.scroll_y.set(self.scroll_y.get() + shift);
2883            }
2884        }
2885
2886        let total = strategy.total_content_height(len, body_w);
2887        let needs_sb = self.show_scrollbar && total > vp_h + 0.5;
2888        if self.last_needs_scrollbar.get() != needs_sb {
2889            self.last_needs_scrollbar.set(needs_sb);
2890        }
2891        let max_y = (total - vp_h).max(0.0);
2892        self.max_scroll_y.set(max_y);
2893
2894        // A band a user just opened is scrolled into view with its tile's
2895        // row, now that its height is known: the row first when the two do
2896        // not fit, so the band reads as the tile's.
2897        if let (Some(layout), Some(d)) = (&self.detail_layout, &self.detail)
2898            && let Some(index) = d.reveal.take()
2899            && let Some((top, bottom)) = layout.reveal_span(index, body_w)
2900        {
2901            let cur = self.scroll_y.get();
2902            let y = reveal_span_offset(top, bottom, cur, vp_h, max_y);
2903            if (y - cur).abs() > 0.001 {
2904                self.scroll_y.set(y);
2905            }
2906        }
2907        // A handle's request, at the same point and for the same reason: the
2908        // band is measured and the anchoring above has run, so the offset is
2909        // computed in the layout the tile will be shown in. An offset
2910        // written from outside before this pass would have been taken for
2911        // one of the old layout and moved by the anchoring. After the
2912        // grid's own reveal, which it replaces when both are pending: it was
2913        // asked for explicitly.
2914        if let Some(request) = self.reveal.take()
2915            && let Some(y) = scroll_for_request(
2916                strategy.as_ref(),
2917                self.detail_layout.as_deref(),
2918                request,
2919                len,
2920                self.scroll_y.get(),
2921                vp_h,
2922                body_w,
2923                max_y,
2924            )
2925        {
2926            self.scroll_y.set(y);
2927        }
2928        let ratio = if total > 0.0 {
2929            (vp_h / total).clamp(0.0, 1.0)
2930        } else {
2931            1.0
2932        };
2933        self.viewport_ratio_y.set(ratio);
2934        // Clamp scroll (matches ListView).
2935        let cur = self.scroll_y.get();
2936        let clamped = cur.clamp(0.0, max_y);
2937        if (clamped - cur).abs() > 0.001 {
2938            self.scroll_y.set(clamped);
2939        }
2940
2941        // Sticky pinned header: track the current section and decide whether
2942        // the in-flow header has scrolled above the top.
2943        let pinned_rect = if self.pinned_header_id.is_some() {
2944            let cur = strategy.current_section(self.scroll_y.get(), body_w);
2945            if let Some(cur) = cur {
2946                if self.current_section.get() != cur {
2947                    self.current_section.set(cur);
2948                }
2949                // Show the pinned slot only once the real header is above top.
2950                strategy.header_rect(cur, body_w).map(|r| {
2951                    let screen_y = bounds.y + r.y - self.scroll_y.get();
2952                    let visible = screen_y < bounds.y - 0.5;
2953                    (visible, r.height)
2954                })
2955            } else {
2956                None
2957            }
2958        } else {
2959            None
2960        };
2961
2962        let band_rect = self
2963            .detail_layout
2964            .as_ref()
2965            .and_then(|layout| layout.band_rect(body_w));
2966        // What of the band is on screen, now that the offset is final.
2967        self.detail_visible_span.set_if_changed(
2968            band_rect
2969                .filter(|(_, r)| r.height > 0.0)
2970                .and_then(|(_, r)| band_visible_span(r, self.scroll_y.get(), vp_h)),
2971        );
2972
2973        let body_rect_origin = bounds.origin();
2974        let body_size = Size::new(body_w, vp_h);
2975        for child in children.iter_mut() {
2976            if Some(child.id) == self.band_id {
2977                // In content coordinates, like a tile: the pane fills the
2978                // same rect and places its tiles at `tile_rect - scroll`.
2979                match band_rect {
2980                    Some((_, r)) => {
2981                        child.origin =
2982                            Point::new(bounds.x + r.x, bounds.y + r.y - self.scroll_y.get());
2983                        child.size = Size::new(r.width, r.height);
2984                    }
2985                    None => {
2986                        child.origin = bounds.origin();
2987                        child.size = Size::ZERO;
2988                    }
2989                }
2990            } else if Some(child.id) == self.scrollbar_id {
2991                if needs_sb {
2992                    // Right edge in all modes — in Overlay / Thin `body_w`
2993                    // spans the full width, so anchor off `bounds.width`.
2994                    child.origin =
2995                        Point::new(bounds.x + bounds.width - SCROLLBAR_THICKNESS, bounds.y);
2996                    child.size = Size::new(SCROLLBAR_THICKNESS, vp_h);
2997                } else {
2998                    child.origin = bounds.origin();
2999                    child.size = Size::ZERO;
3000                }
3001            } else if Some(child.id) == self.pinned_header_id {
3002                match pinned_rect {
3003                    Some((true, h)) => {
3004                        child.origin = bounds.origin();
3005                        child.size = Size::new(body_w, h);
3006                    }
3007                    _ => {
3008                        child.origin = bounds.origin();
3009                        child.size = Size::ZERO;
3010                    }
3011                }
3012            } else {
3013                // body pane / empty view / overlay all fill the body rect.
3014                child.origin = body_rect_origin;
3015                child.size = body_size;
3016            }
3017        }
3018    }
3019
3020    fn accessibility(&self, builder: &mut AccessNodeBuilder) {
3021        builder.set_role(teksilo_core::accesskit::Role::Grid);
3022        if let Some(ref label) = self.a11y_label {
3023            builder.set_name(label.clone());
3024        }
3025
3026        let total = self.source.len();
3027        let cols = self.column_count.get().max(1);
3028        let rows = total.div_ceil(cols);
3029        builder.set_row_count(rows);
3030        builder.set_column_count(cols);
3031        // The set size belongs on the container, not on each tile: AccessKit's
3032        // `size_of_set` differs from ARIA's per-item `aria-setsize`, and
3033        // `size_of_set_from_container` resolves an item's set size by walking
3034        // *up* from it. The logical item count, not the realized window.
3035        if total > 0 {
3036            builder.set_size_of_set(total);
3037        }
3038
3039        if let Some(ref sel) = self.selection {
3040            if sel.mode() == SelectionMode::Multi {
3041                builder.set_multiselectable(true);
3042            }
3043            // The count as the grid's value, for a screen reader that reports
3044            // it with the grid. Not a live region: a `Grid` is named by its
3045            // label, so no platform announced the value, and every tile
3046            // inherited the setting and was announced as it scrolled into
3047            // view. The count is spoken by the action that changes it; see
3048            // `selection_count`.
3049            let count = sel.count();
3050            if count > 0 {
3051                builder.set_value(selection_count_words(count));
3052            }
3053        }
3054
3055        // Roving focus: point active_descendant at the focused tile node.
3056        if let Some(idx) = self.focused_index.get() {
3057            let map = self.tile_map.borrow();
3058            if let Some((_, tile_id)) = map.iter().find(|(i, _)| *i == idx) {
3059                builder.set_active_descendant(widget_id_to_node_id(*tile_id));
3060            }
3061        }
3062    }
3063
3064    /// The context-menu key opens the *focused tile's* menu, not the grid's.
3065    ///
3066    /// A `GridView` is focusable and its tiles are not — the container owns
3067    /// focus and `active_descendant` above is what points assistive technology
3068    /// at the current tile. So the dispatcher's default of "the focused widget"
3069    /// would open the grid's own menu.
3070    ///
3071    /// The tile the user means is the roving cursor, else the first selected
3072    /// tile. Only realized tiles have a widget, so a cursor scrolled outside
3073    /// the virtualization window resolves to nothing and the menu falls back to
3074    /// the grid — which is right, since there is no tile on screen for it to be
3075    /// about.
3076    fn context_menu_key_target(&self) -> Option<WidgetId> {
3077        let index = self.focused_index.get().or_else(|| {
3078            self.selection
3079                .as_ref()
3080                .and_then(|s| s.selected_indices().first().copied())
3081        })?;
3082        let map = self.tile_map.borrow();
3083        map.iter().find(|(i, _)| *i == index).map(|(_, id)| *id)
3084    }
3085
3086    fn as_any(&self) -> Option<&dyn std::any::Any> {
3087        Some(self)
3088    }
3089
3090    /// Every child but the detail band, which the body pane presents among
3091    /// the tiles, after its own row (`GridBodyPane::accessibility_children`).
3092    fn accessibility_children(&self) -> Option<Vec<WidgetId>> {
3093        let band = self.band_id?;
3094        Some(
3095            self.children()
3096                .into_iter()
3097                .filter(|&id| id != band)
3098                .collect(),
3099        )
3100    }
3101
3102    fn children(&self) -> Vec<WidgetId> {
3103        let mut ids = Vec::new();
3104        if let Some(id) = self.body_pane_id {
3105            ids.push(id);
3106        }
3107        if let Some(id) = self.band_id {
3108            ids.push(id);
3109        }
3110        if let Some(id) = self.empty_id {
3111            ids.push(id);
3112        }
3113        if let Some(id) = self.scrollbar_id {
3114            ids.push(id);
3115        }
3116        if let Some(id) = self.overlay_id {
3117            ids.push(id);
3118        }
3119        if let Some(id) = self.pinned_header_id {
3120            ids.push(id);
3121        }
3122        if let Some(id) = self.loading_id {
3123            ids.push(id);
3124        }
3125        ids
3126    }
3127
3128    fn clips_children(&self) -> bool {
3129        true
3130    }
3131}
3132
3133/// A top-most, event-transparent leaf that paints the focus ring, the
3134/// marquee rectangle and the drag-insertion bar. Drawing
3135/// here rather than in the container sidesteps any parent-vs-child paint-order
3136/// ambiguity — a last sibling always paints over the tiles.
3137struct GridOverlay {
3138    focused_index: Signal<Option<usize>>,
3139    /// `true` while the grid (its root or a descendant) holds keyboard focus —
3140    /// the grid root's inclusive [`BuildContext::view_focus_active`] signal.
3141    /// Gates the focus ring so an unfocused grid shows none.
3142    view_focused: Signal<bool>,
3143    /// Input-modality `:focus-visible`. Gates the focus ring to keyboard
3144    /// navigation, never a mouse click.
3145    focus_visible: Signal<bool>,
3146    /// `true` while focus is inside the detail band, where the ring would
3147    /// point at a tile the keyboard is not on.
3148    band_focus: Option<Signal<bool>>,
3149    /// Bumped when the detail band's placement changes, which moves the
3150    /// tiles after it: the ring and the insertion bar move with them, though
3151    /// this widget's own bounds do not change.
3152    band_moved: Option<Signal<u64>>,
3153    /// The grid's selection, for the **container focus ring**: when the grid is
3154    /// keyboard-focused but has no current tile *and* nothing is selected, no
3155    /// tile chrome marks the focus, so the whole grid outlines itself instead.
3156    selection: Option<RowSelection>,
3157    /// Bumped on every selection change; repaints the container ring.
3158    selection_changed: Signal<u64>,
3159    scroll_y: Signal<f32>,
3160    strategy: Rc<dyn GridLayoutStrategy>,
3161    viewport_width: Rc<Cell<f32>>,
3162    marquee: Signal<Option<MarqueeState>>,
3163    insertion: Signal<Option<usize>>,
3164    style: Option<Rc<dyn GridViewStyle>>,
3165    /// Live item count — `focused_index` is adjusted on every model change,
3166    /// but paint reads a snapshot signal on a different binding level
3167    /// (`AccessibilityOnly` on the grid root vs `RepaintOnly` here), so a
3168    /// stale index can transiently outlive the adjustment. Bounds-check
3169    /// before drawing a ring at a tile that no longer exists.
3170    len_fn: Rc<dyn Fn() -> usize>,
3171}
3172
3173impl std::fmt::Debug for GridOverlay {
3174    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
3175        f.debug_struct("GridOverlay").finish()
3176    }
3177}
3178
3179impl GridOverlay {
3180    fn focus_recipe(&self, ctx: &PaintContext) -> teksilo_core::styles::GridFocusRingRecipe {
3181        resolve_grid_style(&self.style, ctx, |s| s.focus_ring())
3182    }
3183    fn marquee_recipe(&self, ctx: &PaintContext) -> teksilo_core::styles::GridMarqueeRecipe {
3184        resolve_grid_style(&self.style, ctx, |s| s.marquee())
3185    }
3186    fn insertion_recipe(&self, ctx: &PaintContext) -> teksilo_core::styles::GridInsertionRecipe {
3187        resolve_grid_style(&self.style, ctx, |s| s.insertion())
3188    }
3189}
3190
3191/// Geometry of the drag-reorder insertion bar: `(bar_x, row_rect)`, where
3192/// `bar_x` is the bar's CENTER x and `row_rect` supplies its `y`/`height`.
3193/// When `ins < len` this is the LEADING edge of the target tile
3194/// `tile_rect(ins)` — using the target row (not the previous tile's row)
3195/// is what keeps the bar on the correct row at a row boundary, where
3196/// `ins` is the first index of a new row. When `ins >= len` (append) it's
3197/// the trailing edge of the last tile. `None` for an empty grid.
3198fn insertion_bar_geometry(
3199    strategy: &dyn GridLayoutStrategy,
3200    ins: usize,
3201    len: usize,
3202    viewport_width: f32,
3203) -> Option<(f32, TileRect)> {
3204    if len == 0 {
3205        return None;
3206    }
3207    if ins < len {
3208        let r = strategy.tile_rect(ins, viewport_width);
3209        Some((r.x, r))
3210    } else {
3211        let r = strategy.tile_rect(len - 1, viewport_width);
3212        Some((r.x + r.width, r))
3213    }
3214}
3215
3216/// Resolve a decoration recipe from the per-call override → theme slot →
3217/// stock default.
3218fn resolve_grid_style<R: Default>(
3219    override_style: &Option<Rc<dyn GridViewStyle>>,
3220    ctx: &PaintContext,
3221    f: impl Fn(&dyn GridViewStyle) -> R,
3222) -> R {
3223    if let Some(s) = override_style {
3224        f(s.as_ref())
3225    } else if let Some(s) = ctx.theme.style_slots.grid_view.as_ref() {
3226        f(s.as_ref())
3227    } else {
3228        R::default()
3229    }
3230}
3231
3232impl Widget for GridOverlay {
3233    fn build(&mut self, ctx: &mut BuildContext) -> Vec<WidgetId> {
3234        // Repaint on focus / scroll / marquee / insertion change.
3235        self.scroll_y.bind_to(
3236            ctx.self_id(),
3237            ctx.binding_registry(),
3238            BindingLevel::RepaintOnly,
3239        );
3240        self.focused_index.bind_to(
3241            ctx.self_id(),
3242            ctx.binding_registry(),
3243            BindingLevel::RepaintOnly,
3244        );
3245        self.view_focused.bind_to(
3246            ctx.self_id(),
3247            ctx.binding_registry(),
3248            BindingLevel::RepaintOnly,
3249        );
3250        self.focus_visible.bind_to(
3251            ctx.self_id(),
3252            ctx.binding_registry(),
3253            BindingLevel::RepaintOnly,
3254        );
3255        self.selection_changed.bind_to(
3256            ctx.self_id(),
3257            ctx.binding_registry(),
3258            BindingLevel::RepaintOnly,
3259        );
3260        if let Some(ref band_focus) = self.band_focus {
3261            band_focus.bind_to(
3262                ctx.self_id(),
3263                ctx.binding_registry(),
3264                BindingLevel::RepaintOnly,
3265            );
3266        }
3267        if let Some(ref band_moved) = self.band_moved {
3268            band_moved.bind_to(
3269                ctx.self_id(),
3270                ctx.binding_registry(),
3271                BindingLevel::RepaintOnly,
3272            );
3273        }
3274        self.marquee.bind_to(
3275            ctx.self_id(),
3276            ctx.binding_registry(),
3277            BindingLevel::RepaintOnly,
3278        );
3279        self.insertion.bind_to(
3280            ctx.self_id(),
3281            ctx.binding_registry(),
3282            BindingLevel::RepaintOnly,
3283        );
3284        // Transparent to pointer events so the body beneath stays interactive.
3285        ctx.apply_self_handlers(HandlerSet::new().event_pass_through(true));
3286        Vec::new()
3287    }
3288
3289    fn layout_response(
3290        &self,
3291        proposal: SizeProposal,
3292        _ctx: &LayoutContext,
3293    ) -> teksilo_core::widget::LayoutResponse {
3294        proposal.resolve(0.0, 0.0).into()
3295    }
3296
3297    fn paint(&self, bounds: Rect, canvas: &mut teksilo_canvas::Canvas, ctx: &PaintContext) {
3298        // Marquee rectangle (in widget-local coords → offset by bounds origin).
3299        if let Some(m) = self.marquee.get() {
3300            let lr = m.local_rect(self.scroll_y.get());
3301            let rect = Rect::new(bounds.x + lr.x, bounds.y + lr.y, lr.width, lr.height);
3302            let recipe = self.marquee_recipe(ctx);
3303            let c = recipe.role.resolve(&ctx.theme.colors);
3304            let fill = teksilo_tokens::Color::new(c.r(), c.g(), c.b(), recipe.fill_alpha);
3305            canvas.fill_rect(rect, fill);
3306            canvas.stroke_rect(rect, c, recipe.stroke_width);
3307        }
3308
3309        // Drag-reorder insertion bar: a vertical accent bar at the leading
3310        // edge of the target tile (or trailing edge of the last tile when
3311        // appending).
3312        if let Some(ins) = self.insertion.get()
3313            && let Some((bar_x, r)) =
3314                insertion_bar_geometry(self.strategy.as_ref(), ins, (self.len_fn)(), bounds.width)
3315        {
3316            let scroll_y = self.scroll_y.get();
3317            let y = bounds.y + r.y - scroll_y;
3318            let h = r.height;
3319            if y + h >= bounds.y && y <= bounds.bottom() {
3320                let recipe = self.insertion_recipe(ctx);
3321                let color = recipe.role.resolve(&ctx.theme.colors);
3322                let t = recipe.thickness;
3323                canvas.fill_rect(Rect::new(bounds.x + bar_x - t * 0.5, y, t, h), color);
3324            }
3325        }
3326
3327        // Focus ring — keyboard-only (`:focus-visible`) and only while the grid
3328        // holds focus, so a mouse click never leaves a ring.
3329        if !self.view_focused.get()
3330            || !self.focus_visible.get()
3331            || self.band_focus.as_ref().is_some_and(|f| f.get())
3332        {
3333            return;
3334        }
3335        // A stale index (outlived by a not-yet-applied model-change
3336        // adjustment) can't draw a ring at a tile that no longer exists —
3337        // treat it the same as "no current tile".
3338        let idx = self.focused_index.get().filter(|&i| i < (self.len_fn)());
3339        let Some(idx) = idx else {
3340            // No current tile. If nothing is selected either, no tile chrome
3341            // marks the focus — outline the whole grid so a Tab-focused empty
3342            // grid still shows where focus landed (mirrors TreeView / ListView).
3343            let empty = self.selection.as_ref().is_none_or(|s| !s.has_selection());
3344            if empty {
3345                let inset = 1.0_f32;
3346                let rect = Rect::new(
3347                    bounds.x + inset,
3348                    bounds.y + inset,
3349                    (bounds.width - inset * 2.0).max(0.0),
3350                    (bounds.height - inset * 2.0).max(0.0),
3351                );
3352                let color = teksilo_tokens::BorderRole::Focused.resolve(&ctx.theme.colors);
3353                canvas.stroke_rect(rect, color, 1.5);
3354            }
3355            return;
3356        };
3357        let vp_w = bounds.width;
3358        let r = self.strategy.tile_rect(idx, vp_w);
3359        let scroll_y = self.scroll_y.get();
3360        let recipe = self.focus_recipe(ctx);
3361        let inset = recipe.inset;
3362        let stroke = recipe.thickness;
3363        let rx = bounds.x + r.x + inset;
3364        let ry = bounds.y + r.y - scroll_y + inset;
3365        let rw = (r.width - inset * 2.0).max(0.0);
3366        let rh = (r.height - inset * 2.0).max(0.0);
3367        // Cull if fully outside the viewport.
3368        if ry + rh < bounds.y || ry > bounds.bottom() {
3369            return;
3370        }
3371        let color = recipe.role.resolve(&ctx.theme.colors);
3372        canvas.fill_rect(Rect::new(rx, ry, rw, stroke), color); // top
3373        canvas.fill_rect(Rect::new(rx, ry + rh - stroke, rw, stroke), color); // bottom
3374        canvas.fill_rect(Rect::new(rx, ry, stroke, rh), color); // left
3375        canvas.fill_rect(Rect::new(rx + rw - stroke, ry, stroke, rh), color); // right
3376    }
3377
3378    fn accessibility(&self, builder: &mut AccessNodeBuilder) {
3379        builder.set_hidden();
3380    }
3381}
3382
3383/// The reused sticky-header slot: rebuilds its child from the section header
3384/// factory whenever the current section changes, and paints an opaque
3385/// background so tiles scrolling underneath don't show through.
3386struct PinnedHeader {
3387    current_section: Signal<usize>,
3388    #[allow(clippy::type_complexity)]
3389    factory: Rc<dyn Fn(usize) -> Box<dyn Widget>>,
3390    child: Option<WidgetId>,
3391    style: Option<Rc<dyn GridViewStyle>>,
3392}
3393
3394impl std::fmt::Debug for PinnedHeader {
3395    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
3396        f.debug_struct("PinnedHeader")
3397            .field("section", &self.current_section.get())
3398            .finish()
3399    }
3400}
3401
3402impl Widget for PinnedHeader {
3403    fn build(&mut self, ctx: &mut BuildContext) -> Vec<WidgetId> {
3404        self.current_section
3405            .bind_to(ctx.self_id(), ctx.binding_registry(), BindingLevel::Rebuild);
3406        let section = self.current_section.get();
3407        let id = ctx.add_boxed((self.factory)(section));
3408        self.child = Some(id);
3409        vec![id]
3410    }
3411
3412    fn layout_response(
3413        &self,
3414        proposal: SizeProposal,
3415        _ctx: &LayoutContext,
3416    ) -> teksilo_core::widget::LayoutResponse {
3417        proposal.resolve(0.0, 0.0).into()
3418    }
3419
3420    fn place_children(
3421        &self,
3422        bounds: Rect,
3423        _proposal: SizeProposal,
3424        children: &mut [WidgetPlacement],
3425        _ctx: &LayoutContext,
3426    ) {
3427        for child in children.iter_mut() {
3428            child.origin = bounds.origin();
3429            child.size = bounds.size();
3430        }
3431    }
3432
3433    fn paint(&self, bounds: Rect, canvas: &mut teksilo_canvas::Canvas, ctx: &PaintContext) {
3434        if bounds.height > 0.5 {
3435            let surface = self
3436                .style
3437                .as_ref()
3438                .or(ctx.theme.style_slots.grid_view.as_ref())
3439                .map(|s| s.pinned_header_surface())
3440                .unwrap_or(SurfaceRole::Raised);
3441            canvas.fill_rect(bounds, surface.resolve(&ctx.theme.colors));
3442        }
3443    }
3444
3445    fn children(&self) -> Vec<WidgetId> {
3446        self.child.into_iter().collect()
3447    }
3448
3449    fn clips_children(&self) -> bool {
3450        true
3451    }
3452}
3453
3454#[cfg(test)]
3455mod focus_reveal_tests {
3456    //! Taking focus brings the current tile into the realized window.
3457    //!
3458    //! The other half of `active_descendant`: `GridView::accessibility` can
3459    //! only nominate a tile that has a widget, and virtualization means the
3460    //! current one usually has none until the grid is scrolled to it.
3461
3462    use super::*;
3463    use teksilo_core::widget::LayoutContext;
3464    use teksilo_core::widget_tree::WidgetTree;
3465
3466    #[derive(Debug)]
3467    struct FixedLeaf(f32, f32);
3468    impl Widget for FixedLeaf {
3469        fn layout_response(
3470            &self,
3471            _proposal: SizeProposal,
3472            _ctx: &LayoutContext,
3473        ) -> teksilo_core::widget::LayoutResponse {
3474            Size::new(self.0, self.1).into()
3475        }
3476    }
3477
3478    /// A 300-item grid of 100x50 tiles carrying `selection`, laid out once at
3479    /// 400x300. That width holds three columns, so the items wrap into 100
3480    /// rows of 58px and the viewport shows about five of them.
3481    fn grid_with_selection(selection: &SelectionModel) -> (WidgetTree, WidgetId) {
3482        let model = ListModel::from_vec((0..300).collect::<Vec<usize>>());
3483        let mut tree = WidgetTree::new();
3484        let id = tree.add(
3485            GridView::new(model, |_tc| Box::new(FixedLeaf(100.0, 50.0)))
3486                .tile_size(100.0, 50.0)
3487                .selection(selection.clone()),
3488        );
3489        tree.layout(SizeProposal::exact(400.0, 300.0));
3490        (tree, id)
3491    }
3492
3493    /// The flat index of every realized tile marked selected, read back off
3494    /// the accessibility tree, since that is what the failure was about: the
3495    /// tile has to be a node a platform can name.
3496    ///
3497    /// `position_in_set` reads 0-based here even though `TileA11y` writes the
3498    /// 1-based ARIA number (`grid_view/a11y.rs:88`):
3499    /// `AccessNodeBuilder::set_position_in_set`
3500    /// (`teksilo-core/src/accessibility.rs:726-729`) subtracts the 1 through
3501    /// `to_accesskit_ordinal` (`:359-366`), so the value in a snapshot is the
3502    /// flat index itself.
3503    fn selected_positions(tree: &WidgetTree) -> Vec<usize> {
3504        tree.accessibility_tree_snapshot()
3505            .nodes
3506            .iter()
3507            .filter(|(_, node)| node.is_selected() == Some(true))
3508            .filter_map(|(_, node)| node.position_in_set())
3509            .collect()
3510    }
3511
3512    /// A selection made before the grid is ever looked at is off-window, so
3513    /// nothing carries it into the tree until focus scrolls to it.
3514    #[test]
3515    fn taking_focus_reveals_the_current_tile() {
3516        let selection = SelectionModel::new(SelectionMode::Single);
3517        selection.select(150);
3518        let (mut tree, id) = grid_with_selection(&selection);
3519
3520        assert!(
3521            selected_positions(&tree).is_empty(),
3522            "tile 150 sits fifty rows below the realized window, which is the \
3523             case this is about"
3524        );
3525
3526        tree.focus(id);
3527        tree.layout(SizeProposal::exact(400.0, 300.0));
3528
3529        assert_eq!(
3530            selected_positions(&tree),
3531            vec![150],
3532            "taking focus has to bring the current tile into the realized \
3533             window, or nothing in the tree can be told about it"
3534        );
3535    }
3536
3537    /// The window-space rect of a realized tile, read out of the same
3538    /// `tile_map` the body pane writes and `accessibility` nominates from
3539    /// (`grid_view.rs:1904-1910`). `None` for a tile outside the
3540    /// virtualization window, which has no widget and so no rect.
3541    fn tile_bounds(tree: &WidgetTree, grid: WidgetId, index: usize) -> Option<Rect> {
3542        let tile = tree
3543            .widget_as_any(grid)
3544            .and_then(|any| any.downcast_ref::<GridView<usize>>())
3545            .and_then(|g| {
3546                g.tile_map
3547                    .borrow()
3548                    .iter()
3549                    .find(|(i, _)| *i == index)
3550                    .map(|(_, id)| *id)
3551            })?;
3552        Some(tree.bounds(tile))
3553    }
3554
3555    /// And a tile already on screen does not lurch when the grid is clicked
3556    /// into: `ScrollAnchor::Auto`, not a forced anchor.
3557    ///
3558    /// Tile 12 is the one that catches a forced anchor. Three columns of 58px
3559    /// row step put it on the fifth row, spanning y 232..282 of a 300px
3560    /// viewport: fully visible, and far enough down that `Center` would scroll
3561    /// by about 107px to drag it to the middle. A tile on the first row proves
3562    /// nothing here, because centering row 0 asks for a negative offset that
3563    /// the clamp to `0.0..=max_scroll_y` turns back into no movement at all.
3564    #[test]
3565    fn taking_focus_does_not_move_a_tile_already_in_view() {
3566        let selection = SelectionModel::new(SelectionMode::Single);
3567        selection.select(12);
3568        let (mut tree, id) = grid_with_selection(&selection);
3569
3570        let scroll = tree
3571            .widget_as_any(id)
3572            .and_then(|any| any.downcast_ref::<GridView<usize>>())
3573            .map(|g| g.scroll_y_signal().clone())
3574            .expect("the grid is the widget at `id`");
3575        let before = scroll.get();
3576
3577        let rect = tile_bounds(&tree, id, 12).expect("tile 12 is realized");
3578        assert!(
3579            rect.y >= 0.0 && rect.y + rect.height <= 300.0,
3580            "this case only means anything while tile 12 is fully on screen, \
3581             and it spans y {}..{} of a 300px viewport",
3582            rect.y,
3583            rect.y + rect.height
3584        );
3585
3586        tree.focus(id);
3587        tree.layout(SizeProposal::exact(400.0, 300.0));
3588
3589        assert_eq!(
3590            scroll.get(),
3591            before,
3592            "tile 12 is already fully visible, so taking focus must not scroll \
3593             the grid under somebody who can see it"
3594        );
3595    }
3596}