Skip to main content

teksilo_widgets/
grid_view.rs

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