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}