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}