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