Skip to main content

teksilo_widgets/grid_view/
keyboard.rs

1// SPDX-License-Identifier: MPL-2.0
2// SPDX-FileCopyrightText: 2026 FernTech
3
4//! 2D keyboard navigation for `GridView`.
5//!
6//! Mirrors `table_view/keyboard.rs` but for a flat-model tile grid: arrow
7//! keys move by ±1 (within a row) and ±columns (between rows), with RTL
8//! horizontal swap, Home/End row ends, Ctrl+Home/End document ends,
9//! PageUp/Down by a viewport of rows, Tab traversal, Space/Enter to
10//! select, Escape to clear focus, and Ctrl+A to select-all. Shift + any
11//! navigation extends the selection range (reading-order, Finder/Explorer
12//! style). Every navigation scrolls the new focus into view.
13
14use std::cell::Cell;
15use std::rc::Rc;
16use std::time::Duration;
17
18use teksilo_core::event::{EventResponse, Key, Modifiers, WidgetEvent};
19use teksilo_core::signal::Signal;
20use teksilo_core::widget::EventContext;
21
22use super::layout::{GridLayoutStrategy, ScrollAnchor};
23use crate::common::list_nav;
24use crate::common::type_ahead::TypeAheadState;
25
26/// How Tab moves out of (or within) the grid.
27#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
28pub enum GridTabTraversal {
29    /// Tab releases focus to the next focusable widget in the window.
30    #[default]
31    OutOfGrid,
32    /// Tab advances to the next tile (wrapping rows); Shift+Tab the previous.
33    WithinGrid,
34}
35
36/// Everything the key handler needs, captured at build time. `col_count`
37/// and `row_height` are shared `Cell`s updated by the layout pass so the
38/// handler always reads the live column count.
39pub(crate) struct GridKeyConfig {
40    pub(crate) len_fn: Rc<dyn Fn() -> usize>,
41    pub(crate) col_count: Signal<usize>,
42    pub(crate) focused_index: Signal<Option<usize>>,
43    pub(crate) selection: Option<crate::data_views::RowSelection>,
44    /// Says the new count when Space changes how many tiles are selected.
45    /// Space reaches the selection through `row_space_activate`, which runs
46    /// after this handler has returned, outside the voice the grid puts
47    /// around the handler itself. A tile that is not realized is selected
48    /// inside the handler instead, and the voice, shared by both, then counts
49    /// once. See `selection_count`.
50    pub(crate) count_voice: Option<super::selection_count::SelectionCountVoice>,
51    pub(crate) scroll_y: Signal<f32>,
52    pub(crate) max_scroll_y: Signal<f32>,
53    pub(crate) viewport_height: Rc<Cell<f32>>,
54    pub(crate) viewport_width: Rc<Cell<f32>>,
55    /// The grid body pane's absolute (window) origin, published each layout
56    /// pass by `GridBodyPane::place_children` (`None` until the pane has laid
57    /// out at least once). Lets the handler compute the focused tile's absolute
58    /// rect (`origin + tile_rect - scroll`) and chase it into any *enclosing*
59    /// scroll area via
60    /// [`EventContext::ensure_visible`](teksilo_core::widget::EventContext::ensure_visible).
61    /// Tiles are virtualized and not focusable, so the focus-driven follow
62    /// never reveals the focused tile in an outer scroller. Left as `None`, the
63    /// chase is skipped so a nav dispatched before the first layout can't anchor
64    /// the rect at (0, 0).
65    pub(crate) viewport_origin: Rc<Cell<Option<teksilo_canvas::Point>>>,
66    pub(crate) strategy: Rc<dyn GridLayoutStrategy>,
67    pub(crate) wrap_navigation: bool,
68    pub(crate) tab_traversal: GridTabTraversal,
69    /// The application's activation (Enter), index only; the app looks up the
70    /// item from its own model handle. The detail band's part in an
71    /// activation is `detail`'s, handled here (`activate_tile`).
72    #[allow(clippy::type_complexity)]
73    pub(crate) on_tile_activate: Option<Rc<dyn Fn(usize, &mut EventContext)>>,
74    /// The detail band, when one is in force: Enter opens and closes it,
75    /// Space closes it, ↓ from the open tile moves into it, and the grid's
76    /// keys stand aside while focus is inside it.
77    pub(crate) detail: Option<Rc<super::detail::DetailState>>,
78    /// The non-drag reorder, bound once by the grid. `Some` exactly when the
79    /// grid is reorderable. `(from, destination) -> ()`: commits through the
80    /// source's own drop-accept path, follows the tile with focus and
81    /// selection, reveals it and announces once. Shared with the tile's context
82    /// menu and its AccessKit custom actions — see `common::ordered_move`.
83    #[allow(clippy::type_complexity)]
84    pub(crate) reorder_perform: Option<Rc<dyn Fn(usize, usize, &mut EventContext)>>,
85    pub(crate) type_ahead_timeout: Duration,
86    /// Persistent across rebuilds — see `GridView::type_ahead`.
87    pub(crate) type_ahead: Rc<TypeAheadState>,
88    /// Index → realized tile id. `Space` asks the focused tile whether it
89    /// publishes a keyboard toggle before falling back to the selection.
90    #[allow(clippy::type_complexity)]
91    pub(crate) tile_map: Rc<std::cell::RefCell<Vec<(usize, teksilo_core::widget_id::WidgetId)>>>,
92    /// `None` when a row isn't resident yet (lazy/windowed source) — skipped
93    /// during the search rather than matched against whatever the label
94    /// closure happens to compute for an absent row.
95    #[allow(clippy::type_complexity)]
96    pub(crate) type_ahead_label: Option<Rc<dyn Fn(usize) -> Option<String>>>,
97}
98
99/// Select all tiles, as the grid's `edit.select_all` action runs it. Declined
100/// without a `Multi` selection, for an empty grid, and while focus is inside
101/// the detail band: a command its content did not use is not about the tiles.
102pub(crate) fn run_select_all(
103    len: usize,
104    selection: Option<&crate::data_views::RowSelection>,
105    detail: Option<&super::detail::DetailState>,
106) -> bool {
107    if detail.is_some_and(|d| d.focus_within.get()) {
108        return false;
109    }
110    match selection {
111        Some(sel) if len > 0 && sel.mode() == teksilo_data::SelectionMode::Multi => {
112            sel.select_all(len);
113            true
114        }
115        _ => false,
116    }
117}
118
119/// Build the `on_key` closure for a `GridView`.
120pub(crate) fn build_grid_key_handler(
121    cfg: GridKeyConfig,
122) -> impl FnMut(&WidgetEvent, &mut EventContext) -> EventResponse + 'static {
123    let ta_state = cfg.type_ahead.clone();
124    move |event, ctx| {
125        let WidgetEvent::KeyDown { key, modifiers, .. } = event else {
126            return EventResponse::Ignored;
127        };
128        // A key from inside the detail band that its content did not use
129        // bubbles up to here. It is not about the tiles: a list at its last
130        // row letting ↓ through must not move the tile cursor behind it.
131        if cfg.detail.as_ref().is_some_and(|d| d.focus_within.get()) {
132            return EventResponse::Ignored;
133        }
134        let n = (cfg.len_fn)();
135        if n == 0 {
136            return EventResponse::Ignored;
137        }
138        let cols = cfg.col_count.get().max(1);
139        let rtl = ctx.is_rtl();
140        // The keyboard cursor: `focused_index` once the user has navigated or
141        // clicked, else the current selection (a grid can be handed a selected
142        // tile before it is ever focused). `None` = "no cursor yet", which is
143        // NOT "cursor on tile 0" — the directional keys below land ON an end
144        // tile rather than stepping past it.
145        let cursor = cfg
146            .focused_index
147            .get()
148            .or_else(|| {
149                cfg.selection
150                    .as_ref()
151                    .and_then(|s| s.selected_indices().first().copied())
152            })
153            .map(|i| i.min(n - 1));
154        // Anchor for the keys that compute *from* a tile (reorder, paging,
155        // row-relative Home/End, type-ahead) rather than step in a direction.
156        let current = cursor.unwrap_or(0);
157        let col = current % cols;
158
159        // The edge-and-page family, resolved once in `common::list_nav`.
160        let nav = list_nav::nav_chord(*key, *modifiers, list_nav::ViewKind::TileGrid);
161
162        // Ctrl+Shift+A deselects. Gated on `Multi` like `ListView` and
163        // `TreeView`: "select all" has no reading for a control that holds at
164        // most one tile, and this handler used to claim the chord even with no
165        // selection model at all. Select all is the `edit.select_all` action
166        // (`run_select_all`), so a plain Ctrl+A only gets here when it is not
167        // bound to it.
168        if modifiers.command() && *key == Key::A {
169            if modifiers.shift()
170                && let Some(ref sel) = cfg.selection
171                && sel.mode() == teksilo_data::SelectionMode::Multi
172            {
173                sel.clear();
174                return EventResponse::Handled;
175            }
176            return EventResponse::Ignored;
177        }
178
179        // Resolve the horizontal arrows, swapping under RTL.
180        let logical_prev = if rtl { Key::ArrowRight } else { Key::ArrowLeft };
181        let logical_next = if rtl { Key::ArrowLeft } else { Key::ArrowRight };
182
183        // Alt+Arrow / Alt+Home / Alt+End: reorder the focused tile. The four
184        // named moves come from `common::ordered_move` (shared with the tile's
185        // context menu and its custom actions); a whole-row step is the grid's
186        // own, because "one row down" is not one of the four.
187        if modifiers.alt()
188            && let Some(ref perform) = cfg.reorder_perform
189        {
190            let named = crate::common::ordered_move::OrderedMove::from_key(
191                *key,
192                *modifiers,
193                crate::common::ordered_move::MoveAxis::Horizontal,
194                rtl,
195            )
196            .and_then(|mv| mv.destination(current, n));
197            let target = match named {
198                Some(t) => Some(t),
199                None if *key == Key::ArrowDown && current + cols < n => Some(current + cols),
200                None if *key == Key::ArrowUp && current >= cols => Some(current - cols),
201                None => None,
202            };
203            if let Some(t) = target {
204                perform(current, t, ctx);
205                return EventResponse::Handled;
206            }
207        }
208
209        // macOS reads ⌘↓ as "open the focused item" in a list or an icon view
210        // — Finder's meaning, and dead here otherwise. Off macOS this is
211        // `None` and costs nothing.
212        //
213        // Read *after* the Alt+Arrow reorder above, not before: ⌥←/⌥→ resolve
214        // to the subtree aliases, which a flat tile grid has no use for, and
215        // claiming them first made keyboard reorder a dead chord on macOS.
216        if let Some(alias) = list_nav::mac_alias(*key, *modifiers, rtl) {
217            if alias == list_nav::MacAlias::Activate {
218                activate_tile(&cfg, current, ctx);
219                return EventResponse::Handled;
220            }
221            return EventResponse::Ignored;
222        }
223
224        // Type-ahead: a bare printable character jumps to the next match.
225        // Use `to_char()` so letters (which arrive as the dedicated
226        // `Key::A`..`Key::Z` variants, NOT `Key::Character`) trigger it too —
227        // matching only `Key::Character` silently broke letter type-ahead.
228        //
229        // A search that finds nothing returns `Ignored` rather than falling
230        // through to the navigation match below, so a printable key can never
231        // be read as a movement — the same shape `ListView` and `TreeView`
232        // use. Nothing in the current key set is both, but the fall-through
233        // made that a latent hazard rather than a decision.
234        if let Some(ref label_fn) = cfg.type_ahead_label
235            && !modifiers.ctrl()
236            && !modifiers.alt()
237            && !modifiers.super_key()
238            && let Some(c) = key.to_char()
239        {
240            return match ta_state.search(c, current, n, cfg.type_ahead_timeout, |i| label_fn(i)) {
241                Some(idx) => {
242                    cfg.focused_index.set(Some(idx));
243                    if let Some(ref sel) = cfg.selection {
244                        sel.select(idx);
245                    }
246                    ensure_visible(&cfg, idx, ctx);
247                    EventResponse::Handled
248                }
249                None => EventResponse::Ignored,
250            };
251        }
252
253        // With no cursor yet, a directional key lands ON the near end tile
254        // (first for forward/down, last for backward/up) instead of stepping
255        // past it — otherwise the very first ArrowRight would skip tile 0.
256        let new_idx: Option<usize> = if *key == logical_next {
257            if cursor.is_none() {
258                Some(0)
259            } else if !cfg.wrap_navigation && col == cols - 1 {
260                None
261            } else {
262                Some((current + 1).min(n - 1))
263            }
264        } else if *key == logical_prev {
265            if cursor.is_none() {
266                Some(n - 1)
267            } else if !cfg.wrap_navigation && col == 0 {
268                None
269            } else {
270                Some(current.saturating_sub(1))
271            }
272        } else {
273            match key {
274                Key::ArrowDown => {
275                    if cursor.is_none() {
276                        Some(0)
277                    } else if *modifiers == Modifiers::NONE
278                        && let Some(band) =
279                            cfg.detail.as_ref().and_then(|d| enterable_band(d, current))
280                    {
281                        // Into the open tile's band, onto its first control.
282                        // The cursor stays on the tile, for ↑ to return to.
283                        ctx.request_focus_into(band);
284                        return EventResponse::Handled;
285                    } else if current + cols < n {
286                        Some(current + cols)
287                    } else {
288                        None
289                    }
290                }
291                Key::ArrowUp => {
292                    if cursor.is_none() {
293                        Some(n - 1)
294                    } else if current >= cols {
295                        Some(current - cols)
296                    } else {
297                        None
298                    }
299                }
300                // Home / End reach the first / last tile of the collection,
301                // not the ends of the reflow row. A wrapped grid's rows change
302                // with the window width, so "first tile in this row" is not a
303                // target a user can form a model of — `GtkGridView` and Qt's
304                // `QListView` in icon mode both resolve it absolutely for that
305                // reason. The accelerator therefore adds no new destination
306                // here; it only suppresses the selection, via `list_nav`.
307                Key::Home | Key::End | Key::PageUp | Key::PageDown => {
308                    let Some(chord) = nav else {
309                        return EventResponse::Ignored;
310                    };
311                    Some(match chord.movement {
312                        list_nav::NavMove::First | list_nav::NavMove::RowFirst => 0,
313                        list_nav::NavMove::Last | list_nav::NavMove::RowLast => n - 1,
314                        // Paged by real geometry rather than by
315                        // `estimated_row_height`, so `VariableRowGrid` and
316                        // `VirtualizedMasonry` page to the tile the user
317                        // actually lands on. The shared `ensure_visible` tail
318                        // then does the scrolling — this arm used to scroll
319                        // too, moving the viewport twice for one keypress.
320                        list_nav::NavMove::Page { down } => {
321                            page_target(&cfg, current, cols, n, down)
322                        }
323                    })
324                }
325                // Ctrl+Tab / Ctrl+Shift+Tab escape the grid, so a
326                // `WithinGrid` traversal is never a trap. Literal `ctrl()`,
327                // macOS included: ⌘⇥ belongs to the application switcher and
328                // never reaches an app. Same hatch `TableView` has.
329                Key::Tab if modifiers.ctrl() => return EventResponse::Ignored,
330                Key::Tab if cfg.tab_traversal == GridTabTraversal::WithinGrid => {
331                    if modifiers.shift() {
332                        if current == 0 {
333                            None
334                        } else {
335                            Some(current - 1)
336                        }
337                    } else if current + 1 < n {
338                        Some(current + 1)
339                    } else {
340                        None
341                    }
342                }
343                Key::Enter => {
344                    activate_tile(&cfg, current, ctx);
345                    return EventResponse::Handled;
346                }
347                Key::Space if modifiers.ctrl() => {
348                    // Ctrl+Space toggles the focused tile's selection — the
349                    // keyboard equivalent of Ctrl+click. In a multiple
350                    // selection it pairs with Ctrl+Arrow's cursor-only move so
351                    // a user can walk the
352                    // cursor without disturbing the existing selection,
353                    // then Ctrl+Space to add tiles one at a time.
354                    //
355                    // Both halves stay on literal `ctrl()`, macOS included:
356                    // ⌘Space is Spotlight and never reaches an app, and ⌘↑/⌘↓
357                    // already mean something else in a Finder list. This
358                    // Explorer-style cursor pair has no ⌘ counterpart, so
359                    // Control keeps it reachable and out of the platform's way.
360                    if let Some(ref sel) = cfg.selection {
361                        sel.toggle(current);
362                    }
363                    cfg.focused_index.set(Some(current));
364                    return EventResponse::Handled;
365                }
366                Key::Space if cfg.detail.as_ref().is_some_and(|d| d.is_open(current)) => {
367                    // Space on the open tile closes its band, as Enter does,
368                    // and leaves the selection alone.
369                    if let Some(d) = &cfg.detail {
370                        d.expanded.set(None);
371                    }
372                    cfg.focused_index.set(Some(current));
373                    return EventResponse::Handled;
374                }
375                Key::Space => {
376                    // A tile carrying a checkbox reads Space as "check this" —
377                    // its control is out of the Tab order, so this is the only
378                    // keyboard route to it. Otherwise: toggle in `Multi`,
379                    // select in `Single`, the rule `ListView` and `TreeView`
380                    // follow. (This used to select unconditionally, so Space
381                    // could never unpick a tile.)
382                    let selection = cfg.selection.clone();
383                    let voice = cfg.count_voice.clone();
384                    let fallback = std::rc::Rc::new(move |ctx: &mut EventContext| {
385                        let Some(ref sel) = selection else {
386                            return;
387                        };
388                        let pick = |_: &mut EventContext| {
389                            if sel.mode() == teksilo_data::SelectionMode::Multi {
390                                sel.toggle(current);
391                            } else {
392                                sel.select(current);
393                            }
394                        };
395                        match &voice {
396                            Some(voice) => voice.around(ctx, pick),
397                            None => pick(ctx),
398                        }
399                    });
400                    match cfg
401                        .tile_map
402                        .borrow()
403                        .iter()
404                        .find(|(i, _)| *i == current)
405                        .map(|(_, id)| *id)
406                    {
407                        Some(tile_id) => ctx.row_space_activate(tile_id, fallback),
408                        None => fallback(ctx),
409                    }
410                    cfg.focused_index.set(Some(current));
411                    return EventResponse::Handled;
412                }
413                Key::Escape => {
414                    cfg.focused_index.set(None);
415                    return EventResponse::Handled;
416                }
417                _ => return EventResponse::Ignored,
418            }
419        };
420
421        let Some(idx) = new_idx else {
422            return EventResponse::Ignored;
423        };
424        cfg.focused_index.set(Some(idx));
425        // What the chord does to the selection. The edge-and-page keys carry
426        // their own answer from `list_nav`, where the accelerator means "move
427        // the cursor, leave the selection alone" — in a multiple selection. A
428        // single selection moves with the cursor (`for_cardinality`), whatever
429        // the chord.
430        //
431        // The arrows keep reading literal `ctrl()`: ⌘Space is Spotlight and
432        // ⌘↑/⌘↓ mean something else in a Finder icon view, so this pair has no
433        // ⌘ counterpart to move to (see the Ctrl+Space arm above). Checked
434        // against `logical_next`/`logical_prev` (already RTL-swapped) plus the
435        // raw vertical keys, so the chord follows the visual arrow.
436        let op = match nav {
437            Some(chord) => chord.selection,
438            None if modifiers.ctrl()
439                && !modifiers.shift()
440                && !modifiers.alt()
441                && (*key == logical_next
442                    || *key == logical_prev
443                    || *key == Key::ArrowDown
444                    || *key == Key::ArrowUp) =>
445            {
446                list_nav::SelectionOp::Suppress
447            }
448            None if modifiers.shift() => list_nav::SelectionOp::Extend,
449            None => list_nav::SelectionOp::Replace,
450        };
451        if let Some(ref sel) = cfg.selection {
452            match op.for_cardinality(sel.mode().into()) {
453                list_nav::SelectionOp::Replace => sel.select(idx),
454                list_nav::SelectionOp::Suppress => {}
455                list_nav::SelectionOp::Extend => sel.extend_to(idx),
456                list_nav::SelectionOp::ExtendAdditive => sel.extend_to_additive(idx),
457            }
458        }
459        ensure_visible(&cfg, idx, ctx);
460        EventResponse::Handled
461    }
462}
463
464/// Enter, and ⌘↓ on macOS: open or close tile `current`'s detail band, then
465/// run the application's activation, which sees the band as the key left it.
466///
467/// Opening the band selects the tile in a single selection, where the
468/// selected tile is the one being looked at, and only when the application
469/// has no activation of its own, which is the rule Enter follows without a
470/// band. A multiple selection is left alone: Ctrl+arrows move the cursor
471/// without touching it precisely so that a reader can look at a tile, and
472/// opening its band must not collapse the selection onto it. Closing the
473/// band leaves the selection alone. A tile with nothing to disclose, or a
474/// grid with no band, follows the rule activation has without one.
475fn activate_tile(cfg: &GridKeyConfig, current: usize, ctx: &mut EventContext) {
476    use super::detail::Disclosure;
477    cfg.focused_index.set(Some(current));
478    let disclosure = cfg.detail.as_ref().map(|d| d.activate(current));
479    let select = match disclosure {
480        Some(Disclosure::Opened) => {
481            cfg.on_tile_activate.is_none()
482                && cfg
483                    .selection
484                    .as_ref()
485                    .is_some_and(|sel| sel.mode() == teksilo_data::SelectionMode::Single)
486        }
487        Some(Disclosure::Closed) => false,
488        Some(Disclosure::Nothing) | None => cfg.on_tile_activate.is_none(),
489    };
490    if select && let Some(ref sel) = cfg.selection {
491        sel.select(current);
492    }
493    if let Some(ref cb) = cfg.on_tile_activate {
494        cb(current, ctx);
495    }
496}
497
498/// The band ↓ enters from tile `current`: `current`'s band, open, with a
499/// control in it to take focus. Without one, ↓ is plain navigation. The
500/// control is revealed as focus lands on it, by the grid's answer to the
501/// framework's `ScrollIntoView`.
502fn enterable_band(
503    detail: &super::detail::DetailState,
504    current: usize,
505) -> Option<teksilo_core::widget_id::WidgetId> {
506    if detail.is_open(current) && detail.enterable.get() {
507        detail.band_id.get()
508    } else {
509        None
510    }
511}
512
513/// The tile one viewport away from `current`, in the direction pressed.
514///
515/// Measured against the layout strategy's real tile rectangles rather than
516/// `estimated_row_height`, because two of the three shipped strategies do not
517/// have a single row height: `VariableRowGrid` sizes each row to its tallest
518/// tile and `VirtualizedMasonry` has no rows at all. Paging by the estimate
519/// landed short or long on both, and by an amount that changed as the
520/// measurements converged.
521///
522/// The walk is per-row rather than a closed form for the same reason — with
523/// variable rows there is no `rows × height` to divide by. It costs one
524/// `tile_rect` per row of a single viewport, on a keypress.
525fn page_target(cfg: &GridKeyConfig, current: usize, cols: usize, n: usize, down: bool) -> usize {
526    let viewport = cfg.viewport_height.get();
527    let width = cfg.viewport_width.get();
528    let origin = cfg.strategy.tile_rect(current, width).y;
529    let mut candidate = current;
530    let mut probe = current;
531    loop {
532        probe = if down {
533            match probe.checked_add(cols) {
534                Some(next) if next < n => next,
535                _ => break,
536            }
537        } else {
538            match probe.checked_sub(cols) {
539                Some(prev) => prev,
540                None => break,
541            }
542        };
543        let y = cfg.strategy.tile_rect(probe, width).y;
544        if (y - origin).abs() > viewport {
545            // One row past the viewport edge: stop at the last row inside it,
546            // unless that would not move at all.
547            candidate = if candidate == current {
548                probe
549            } else {
550                candidate
551            };
552            break;
553        }
554        candidate = probe;
555    }
556    // Guarantee progress even when a single tile is taller than the viewport.
557    if candidate == current {
558        if down {
559            (current + cols).min(n - 1)
560        } else {
561            current.saturating_sub(cols)
562        }
563    } else {
564        candidate
565    }
566}
567
568fn ensure_visible(cfg: &GridKeyConfig, idx: usize, ctx: &mut EventContext) {
569    reveal_tile(
570        &cfg.strategy,
571        &cfg.scroll_y,
572        &cfg.max_scroll_y,
573        &cfg.viewport_height,
574        &cfg.viewport_width,
575        &cfg.viewport_origin,
576        idx,
577        ctx,
578    );
579}
580
581/// Scroll tile `idx` into the grid's own viewport, then chase it into any
582/// enclosing scroll area.
583///
584/// Takes the pieces rather than a [`GridKeyConfig`] because the reorder commit
585/// closure — which lives in `GridView::build`, above the key handler — needs the
586/// same reveal, and one reveal is what keeps the chord and the menu row leaving
587/// the tile in the same place.
588#[allow(clippy::too_many_arguments)]
589pub(crate) fn reveal_tile(
590    strategy: &Rc<dyn GridLayoutStrategy>,
591    scroll_y: &Signal<f32>,
592    max_scroll_y: &Signal<f32>,
593    viewport_height: &Rc<Cell<f32>>,
594    viewport_width: &Rc<Cell<f32>>,
595    viewport_origin: &Rc<Cell<Option<teksilo_canvas::Point>>>,
596    idx: usize,
597    ctx: &mut EventContext,
598) {
599    let delta = strategy.scroll_delta_to_reveal(
600        idx,
601        scroll_y.get(),
602        viewport_height.get(),
603        viewport_width.get(),
604        ScrollAnchor::Auto,
605    );
606    if delta.abs() > 0.01 {
607        let max = max_scroll_y.get();
608        let new_y = (scroll_y.get() + delta).clamp(0.0, max);
609        scroll_y.set(new_y);
610    }
611    // Computed analytically from the layout strategy — the tile may be
612    // virtualized (not realized as a live widget) — using the post-scroll offset
613    // so the rect is the tile's resting on-screen position. Skipped when the
614    // body pane hasn't published its origin yet (a nav before the first layout),
615    // so the rect is never anchored at a stale (0, 0).
616    let Some(origin) = viewport_origin.get() else {
617        return;
618    };
619    let vp_w = viewport_width.get();
620    let r = strategy.tile_rect(idx, vp_w);
621    let scroll = scroll_y.get();
622    let rect =
623        teksilo_canvas::Rect::new(origin.x + r.x, origin.y + r.y - scroll, r.width, r.height);
624    ctx.ensure_visible(rect);
625}