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::drag_payload::DragPayload;
19use teksilo_core::event::{EventResponse, Key, WidgetEvent};
20use teksilo_core::signal::Signal;
21use teksilo_core::widget::EventContext;
22use teksilo_data::{DropPosition, SelectionModel};
23
24use super::layout::{GridLayoutStrategy, ScrollAnchor};
25use crate::common::type_ahead::TypeAheadState;
26use crate::data_views::ViewId;
27
28/// How Tab moves out of (or within) the grid.
29#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
30pub enum GridTabTraversal {
31    /// Tab releases focus to the next focusable widget in the window.
32    #[default]
33    OutOfGrid,
34    /// Tab advances to the next tile (wrapping rows); Shift+Tab the previous.
35    WithinGrid,
36}
37
38/// Everything the key handler needs, captured at build time. `col_count`
39/// and `row_height` are shared `Cell`s updated by the layout pass so the
40/// handler always reads the live column count.
41pub(crate) struct GridKeyConfig {
42    pub(crate) len_fn: Rc<dyn Fn() -> usize>,
43    pub(crate) col_count: Signal<usize>,
44    pub(crate) focused_index: Signal<Option<usize>>,
45    pub(crate) selection: Option<SelectionModel>,
46    pub(crate) scroll_y: Signal<f32>,
47    pub(crate) max_scroll_y: Signal<f32>,
48    pub(crate) viewport_height: Rc<Cell<f32>>,
49    pub(crate) viewport_width: Rc<Cell<f32>>,
50    /// The grid body pane's absolute (window) origin, published each layout
51    /// pass by `GridBodyPane::place_children` (`None` until the pane has laid
52    /// out at least once). Lets the handler compute the focused tile's absolute
53    /// rect (`origin + tile_rect - scroll`) and chase it into any *enclosing*
54    /// scroll area via
55    /// [`EventContext::ensure_visible`](teksilo_core::widget::EventContext::ensure_visible).
56    /// Tiles are virtualized and not focusable, so the focus-driven follow
57    /// never reveals the focused tile in an outer scroller. Left as `None`, the
58    /// chase is skipped so a nav dispatched before the first layout can't anchor
59    /// the rect at (0, 0).
60    pub(crate) viewport_origin: Rc<Cell<Option<teksilo_canvas::Point>>>,
61    pub(crate) strategy: Rc<dyn GridLayoutStrategy>,
62    pub(crate) wrap_navigation: bool,
63    pub(crate) tab_traversal: GridTabTraversal,
64    /// Activation (Enter / double-click) — index only; the app looks up the
65    /// item from its own model handle.
66    #[allow(clippy::type_complexity)]
67    pub(crate) on_tile_activate: Option<Rc<dyn Fn(usize, &mut EventContext)>>,
68    pub(crate) reorderable: bool,
69    /// Source-owned reorder commit (erased from the backing `ListDataSource`).
70    /// Alt+Arrow synthesizes a same-view `RowDragData<T>` (via
71    /// `make_reorder_payload`, below) and routes it through the exact same
72    /// path a pointer drop takes. `(payload, target, position, view_id) ->
73    /// applied`.
74    #[allow(clippy::type_complexity)]
75    pub(crate) accept_drop_fn: Rc<dyn Fn(&DragPayload, usize, DropPosition, ViewId) -> bool>,
76    /// This grid's id, stamped into the synthetic drag payload so the source
77    /// recognizes the move as same-view.
78    pub(crate) view_id: ViewId,
79    /// Builds the synthetic same-view reorder payload
80    /// (`DragPayload::typed(RowDragData::<T> { .. })`) for a given source
81    /// index. Erases the grid's item type `T` so this (non-generic) module
82    /// doesn't need a type parameter — mirrors the `DndLazy` erasure pattern.
83    pub(crate) make_reorder_payload: Rc<dyn Fn(usize) -> DragPayload>,
84    pub(crate) type_ahead_timeout: Duration,
85    /// `None` when a row isn't resident yet (lazy/windowed source) — skipped
86    /// during the search rather than matched against whatever the label
87    /// closure happens to compute for an absent row.
88    #[allow(clippy::type_complexity)]
89    pub(crate) type_ahead_label: Option<Rc<dyn Fn(usize) -> Option<String>>>,
90}
91
92/// Build the `on_key` closure for a `GridView`.
93pub(crate) fn build_grid_key_handler(
94    cfg: GridKeyConfig,
95) -> impl FnMut(&WidgetEvent, &mut EventContext) -> EventResponse + 'static {
96    // Shared accumulate-and-search type-ahead state (mirrors `ListView`).
97    let ta_state = TypeAheadState::new();
98    move |event, ctx| {
99        let WidgetEvent::KeyDown { key, modifiers, .. } = event else {
100            return EventResponse::Ignored;
101        };
102        let n = (cfg.len_fn)();
103        if n == 0 {
104            return EventResponse::Ignored;
105        }
106        let cols = cfg.col_count.get().max(1);
107        let rtl = ctx.is_rtl();
108        // The keyboard cursor: `focused_index` once the user has navigated or
109        // clicked, else the current selection (a grid can be handed a selected
110        // tile before it is ever focused). `None` = "no cursor yet", which is
111        // NOT "cursor on tile 0" — the directional keys below land ON an end
112        // tile rather than stepping past it.
113        let cursor = cfg
114            .focused_index
115            .get()
116            .or_else(|| {
117                cfg.selection
118                    .as_ref()
119                    .and_then(|s| s.selected_indices().first().copied())
120            })
121            .map(|i| i.min(n - 1));
122        // Anchor for the keys that compute *from* a tile (reorder, paging,
123        // row-relative Home/End, type-ahead) rather than step in a direction.
124        let current = cursor.unwrap_or(0);
125        let col = current % cols;
126
127        // Select-all — Ctrl+A, ⌘A on macOS.
128        if modifiers.command() && *key == Key::A {
129            if let Some(ref sel) = cfg.selection {
130                sel.select_all(n);
131            }
132            return EventResponse::Handled;
133        }
134
135        // Resolve the horizontal arrows, swapping under RTL.
136        let logical_prev = if rtl { Key::ArrowRight } else { Key::ArrowLeft };
137        let logical_next = if rtl { Key::ArrowLeft } else { Key::ArrowRight };
138
139        // Alt+Arrow: reorder the focused tile (when reorderable).
140        if modifiers.alt() && cfg.reorderable {
141            let target = if *key == logical_next && current + 1 < n {
142                Some(current + 1)
143            } else if *key == logical_prev && current > 0 {
144                Some(current - 1)
145            } else if *key == Key::ArrowDown && current + cols < n {
146                Some(current + cols)
147            } else if *key == Key::ArrowUp && current >= cols {
148                Some(current - cols)
149            } else {
150                None
151            };
152            if let Some(t) = target {
153                // Express the positional move as a same-view drop the source
154                // can validate + apply: dropping `current` *after* `t` when
155                // moving forward, *before* `t` when moving back, yields
156                // `move_item(current, t)` for an in-memory model.
157                let position = if t > current {
158                    DropPosition::After
159                } else {
160                    DropPosition::Before
161                };
162                let payload = (cfg.make_reorder_payload)(current);
163                if (cfg.accept_drop_fn)(&payload, t, position, cfg.view_id) {
164                    cfg.focused_index.set(Some(t));
165                    if let Some(ref sel) = cfg.selection {
166                        sel.select(t);
167                    }
168                    ensure_visible(&cfg, t, ctx);
169                }
170                return EventResponse::Handled;
171            }
172        }
173
174        // Type-ahead: a bare printable character jumps to the next match.
175        // Use `to_char()` so letters (which arrive as the dedicated
176        // `Key::A`..`Key::Z` variants, NOT `Key::Character`) trigger it too —
177        // matching only `Key::Character` silently broke letter type-ahead.
178        if let Some(ref label_fn) = cfg.type_ahead_label
179            && !modifiers.ctrl()
180            && !modifiers.alt()
181            && !modifiers.super_key()
182            && let Some(c) = key.to_char()
183            && let Some(idx) =
184                ta_state.search(c, current, n, cfg.type_ahead_timeout, |i| label_fn(i))
185        {
186            cfg.focused_index.set(Some(idx));
187            if let Some(ref sel) = cfg.selection {
188                sel.select(idx);
189            }
190            ensure_visible(&cfg, idx, ctx);
191            return EventResponse::Handled;
192        }
193
194        // With no cursor yet, a directional key lands ON the near end tile
195        // (first for forward/down, last for backward/up) instead of stepping
196        // past it — otherwise the very first ArrowRight would skip tile 0.
197        let new_idx: Option<usize> = if *key == logical_next {
198            if cursor.is_none() {
199                Some(0)
200            } else if !cfg.wrap_navigation && col == cols - 1 {
201                None
202            } else {
203                Some((current + 1).min(n - 1))
204            }
205        } else if *key == logical_prev {
206            if cursor.is_none() {
207                Some(n - 1)
208            } else if !cfg.wrap_navigation && col == 0 {
209                None
210            } else {
211                Some(current.saturating_sub(1))
212            }
213        } else {
214            match key {
215                Key::ArrowDown => {
216                    if cursor.is_none() {
217                        Some(0)
218                    } else if current + cols < n {
219                        Some(current + cols)
220                    } else {
221                        None
222                    }
223                }
224                Key::ArrowUp => {
225                    if cursor.is_none() {
226                        Some(n - 1)
227                    } else if current >= cols {
228                        Some(current - cols)
229                    } else {
230                        None
231                    }
232                }
233                // Accelerator + Home / End (⌘ on macOS) jumps to the first /
234                // last tile; plain Home / End stay within the row.
235                Key::Home if modifiers.command() => Some(0),
236                Key::End if modifiers.command() => Some(n - 1),
237                Key::Home => Some(current - col), // first item in this row
238                Key::End => Some((current - col + cols - 1).min(n - 1)),
239                Key::PageDown => {
240                    let rows = rows_per_page(&cfg);
241                    page_scroll(&cfg, rows as f32);
242                    Some((current + rows * cols).min(n - 1))
243                }
244                Key::PageUp => {
245                    let rows = rows_per_page(&cfg);
246                    page_scroll(&cfg, -(rows as f32));
247                    Some(current.saturating_sub(rows * cols))
248                }
249                Key::Tab if cfg.tab_traversal == GridTabTraversal::WithinGrid => {
250                    if modifiers.shift() {
251                        if current == 0 {
252                            None
253                        } else {
254                            Some(current - 1)
255                        }
256                    } else if current + 1 < n {
257                        Some(current + 1)
258                    } else {
259                        None
260                    }
261                }
262                Key::Enter => {
263                    cfg.focused_index.set(Some(current));
264                    if let Some(ref cb) = cfg.on_tile_activate {
265                        cb(current, ctx);
266                    } else if let Some(ref sel) = cfg.selection {
267                        sel.select(current);
268                    }
269                    return EventResponse::Handled;
270                }
271                Key::Space if modifiers.ctrl() => {
272                    // Ctrl+Space toggles the focused tile's selection — the
273                    // keyboard equivalent of Ctrl+click. Pairs with
274                    // Ctrl+Arrow's cursor-only move so a user can walk the
275                    // cursor without disturbing the existing selection,
276                    // then Ctrl+Space to add tiles one at a time.
277                    //
278                    // Both halves stay on literal `ctrl()`, macOS included:
279                    // ⌘Space is Spotlight and never reaches an app, and ⌘↑/⌘↓
280                    // already mean something else in a Finder list. This
281                    // Explorer-style cursor pair has no ⌘ counterpart, so
282                    // Control keeps it reachable and out of the platform's way.
283                    if let Some(ref sel) = cfg.selection {
284                        sel.toggle(current);
285                    }
286                    cfg.focused_index.set(Some(current));
287                    return EventResponse::Handled;
288                }
289                Key::Space => {
290                    if let Some(ref sel) = cfg.selection {
291                        sel.select(current);
292                    }
293                    cfg.focused_index.set(Some(current));
294                    return EventResponse::Handled;
295                }
296                Key::Escape => {
297                    cfg.focused_index.set(None);
298                    return EventResponse::Handled;
299                }
300                _ => return EventResponse::Ignored,
301            }
302        };
303
304        let Some(idx) = new_idx else {
305            return EventResponse::Ignored;
306        };
307        cfg.focused_index.set(Some(idx));
308        // Ctrl+Arrow (no Shift, no Alt — Alt+Arrow reorder and Ctrl+Home/End
309        // keep their existing behavior) moves the keyboard cursor only,
310        // leaving the selection untouched. Checked against
311        // `logical_next`/`logical_prev` (already RTL-swapped above) plus
312        // the raw vertical keys, so the chord follows the visual arrow.
313        // Literal `ctrl()` — see the Ctrl+Space arm above.
314        let cursor_only = modifiers.ctrl()
315            && !modifiers.shift()
316            && !modifiers.alt()
317            && (*key == logical_next
318                || *key == logical_prev
319                || *key == Key::ArrowDown
320                || *key == Key::ArrowUp);
321        if !cursor_only && let Some(ref sel) = cfg.selection {
322            if modifiers.shift() {
323                sel.extend_to(idx);
324            } else {
325                sel.select(idx);
326            }
327        }
328        ensure_visible(&cfg, idx, ctx);
329        EventResponse::Handled
330    }
331}
332
333fn rows_per_page(cfg: &GridKeyConfig) -> usize {
334    let vp = cfg.viewport_height.get();
335    let step = cfg.strategy.estimated_row_height().max(1.0);
336    ((vp / step).floor() as usize).max(1)
337}
338
339/// Scroll by `rows` rows (signed), clamped. Used by PageUp/PageDown so the
340/// viewport tracks the focus jump.
341fn page_scroll(cfg: &GridKeyConfig, rows: f32) {
342    let step = cfg.strategy.estimated_row_height().max(1.0);
343    let max = cfg.max_scroll_y.get();
344    let new_y = (cfg.scroll_y.get() + rows * step).clamp(0.0, max);
345    cfg.scroll_y.set(new_y);
346}
347
348fn ensure_visible(cfg: &GridKeyConfig, idx: usize, ctx: &mut EventContext) {
349    let delta = cfg.strategy.scroll_delta_to_reveal(
350        idx,
351        cfg.scroll_y.get(),
352        cfg.viewport_height.get(),
353        cfg.viewport_width.get(),
354        ScrollAnchor::Auto,
355    );
356    if delta.abs() > 0.01 {
357        let max = cfg.max_scroll_y.get();
358        let new_y = (cfg.scroll_y.get() + delta).clamp(0.0, max);
359        cfg.scroll_y.set(new_y);
360    }
361    // After keeping the tile in the grid's OWN viewport, chase it into any
362    // enclosing scroll area. Computed analytically from the layout strategy —
363    // the tile may be virtualized (not realized as a live widget) — using the
364    // post-scroll offset so the rect is the tile's resting on-screen position.
365    // Skip when the body pane hasn't published its origin yet (a nav before the
366    // first layout), so the rect is never anchored at a stale (0, 0).
367    let Some(origin) = cfg.viewport_origin.get() else {
368        return;
369    };
370    let vp_w = cfg.viewport_width.get();
371    let r = cfg.strategy.tile_rect(idx, vp_w);
372    let scroll_y = cfg.scroll_y.get();
373    let rect =
374        teksilo_canvas::Rect::new(origin.x + r.x, origin.y + r.y - scroll_y, r.width, r.height);
375    ctx.ensure_visible(rect);
376}