Skip to main content

teksilo_widgets/
grid_view.rs

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