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