teksilo_widgets/table_view/keyboard.rs
1// SPDX-License-Identifier: MPL-2.0
2// SPDX-FileCopyrightText: 2026 FernTech
3
4//! Shared keyboard handler for `TableView` and `TreeTableView`.
5//!
6//! The handler is generic over `RowNavigator` so flat and tree
7//! navigation reuse the same key matrix. Tree-specific arrow-left /
8//! arrow-right collapse/expand semantics fall through automatically
9//! because the trait's default `is_expanded` / `has_children` /
10//! `toggle_expanded` methods are no-ops on a flat table.
11
12use std::rc::Rc;
13use std::time::Duration;
14
15use teksilo_core::event::{EventResponse, Key, WidgetEvent};
16use teksilo_core::signal::Signal;
17use teksilo_core::widget::EventContext;
18use teksilo_data::SelectionMode;
19
20use super::PaneBoundaries;
21use super::body::SharedColumnWidths;
22use super::column::{EditTriggers, TabTraversal};
23use super::row_navigator::RowNavigator;
24use super::selection::{CellSelectionModel, TableSelectionMode};
25use crate::common::list_nav;
26use crate::common::row_metrics::SharedRowMetrics;
27use crate::common::type_ahead::TypeAheadState;
28use crate::data_views::RowSelection;
29
30/// Configuration captured from the table at build time and threaded
31/// into the on_key handler. Cheap to clone (signals + Rcs).
32#[derive(Clone)]
33pub(crate) struct KeyHandlerConfig {
34 pub navigator: Rc<dyn RowNavigator>,
35 pub col_count: usize,
36 /// Display position of the tree column — the one hosting the twist and
37 /// indent gutter, and therefore the only column where ArrowLeft/ArrowRight
38 /// collapse/expand instead of moving the cursor.
39 ///
40 /// Resolved per rebuild by the owning widget, because
41 /// [`TreeTableView::tree_column`](crate::TreeTableView::tree_column) names
42 /// a column *id* while user drag-reorder moves its *display* position —
43 /// the two diverge the moment either is used. `TableView` passes `0`: its `FlatNavigator` reports
44 /// `has_children`/`is_expanded` as false and `toggle_expanded` as a no-op,
45 /// so the comparison can never lead anywhere.
46 pub tree_column_display_pos: usize,
47 pub focused_cell: Signal<Option<(usize, usize)>>,
48 /// `(row, col)` → realized cell id. `Space` asks the focused cell whether
49 /// it publishes a keyboard toggle before falling back to the selection —
50 /// scoped to the *cell*, not the row, because a table can carry more than
51 /// one checkbox column and "the row's checkbox" would be arbitrary.
52 #[allow(clippy::type_complexity)]
53 pub cell_map: Rc<std::cell::RefCell<Vec<((usize, usize), teksilo_core::widget_id::WidgetId)>>>,
54 pub selection_mode: TableSelectionMode,
55 pub selection: Option<RowSelection>,
56 pub cell_selection: Option<CellSelectionModel>,
57 pub scroll_y: Signal<f32>,
58 pub max_scroll_y: Signal<f32>,
59 pub viewport_height: Rc<std::cell::Cell<f32>>,
60 /// The row-area's absolute (window) rect: row 0's top sits at
61 /// `body_bounds.y` when `scroll_y == 0`. Read to chase the keyboard-focused
62 /// row into any *enclosing* scroll area via
63 /// [`EventContext::ensure_visible`](teksilo_core::widget::EventContext::ensure_visible)
64 /// — the table's own viewport follow is handled by `scroll_y`. Rows are not
65 /// distinct focusable nodes, so the framework's focus-driven follow never
66 /// reveals the selected row in an outer scroller. Populated by each widget's
67 /// `place_children`.
68 pub body_bounds: Rc<std::cell::Cell<teksilo_canvas::Rect>>,
69 /// Row geometry (uniform / exact / auto-measure) — drives the
70 /// PageUp/PageDown focus-row math.
71 pub row_metrics: SharedRowMetrics,
72 pub tab_traversal: TabTraversal,
73 pub editing_cell: Signal<Option<(usize, usize)>>,
74 /// `(row, col_id)` → invoke user's edit hook. The closure resolves
75 /// `col_id` from a display position; we keep it generic over
76 /// `&str` so the keyboard module doesn't need a `Column<T>`
77 /// reference.
78 pub display_col_to_id: Rc<dyn Fn(usize) -> Option<String>>,
79 /// The [`EditTriggers`] in force for the column at a display position —
80 /// the view's set, overridden by the column's own, and `NONE` for a
81 /// non-editable column. Per column rather than one set for the table
82 /// because that is what the caller declares: entering edit mode on a
83 /// column whose delegate has no editor would only confuse the focus /
84 /// dispatch state, and a tree column usually wants different gestures from
85 /// the value columns beside it.
86 pub display_col_triggers: Rc<dyn Fn(usize) -> EditTriggers>,
87 /// Optional: user callback fired when an edit trigger matches.
88 #[allow(clippy::type_complexity)]
89 pub on_cell_edit_request:
90 Option<Rc<dyn Fn(usize, &str, &mut teksilo_core::widget::EventContext)>>,
91 /// Optional: row-activate (Enter) callback.
92 #[allow(clippy::type_complexity)]
93 pub on_row_activate: Option<Rc<dyn Fn(usize, &mut teksilo_core::widget::EventContext)>>,
94 /// Persistent type-ahead buffer (survives the per-keystroke rebuild).
95 pub type_ahead: Rc<TypeAheadState>,
96 /// Type-ahead label resolver: `row -> Some(text)` for a resident row.
97 /// `None` (the option) disables type-ahead.
98 #[allow(clippy::type_complexity)]
99 pub type_ahead_label: Option<Rc<dyn Fn(usize) -> Option<String>>>,
100 /// Reset window for the type-ahead search term.
101 pub type_ahead_timeout: Duration,
102
103 /// Resolved column widths in display order — shared with the row/header
104 /// layout. Read to compute a display column's horizontal extent for
105 /// ensure-column-visible.
106 pub column_widths: SharedColumnWidths,
107 /// Pane partition (Leading/Middle/Trailing) — pinned columns never
108 /// trigger horizontal scrolling, since they're always visible by
109 /// definition. Snapshotted at build like `col_count` (a pinning/order
110 /// change rebuilds the whole table anyway).
111 pub pane_boundaries: PaneBoundaries,
112 pub scroll_x: Signal<f32>,
113 pub max_scroll_x: Signal<f32>,
114 /// Middle-pane viewport width, populated by `place_children` — the
115 /// horizontal analogue of `viewport_height`.
116 pub middle_viewport_width: Rc<std::cell::Cell<f32>>,
117}
118
119/// Build the on_key closure. Captures config by value; the closure is
120/// `'static` and ready to slot into a `HandlerSet`.
121pub(crate) fn build_key_handler(
122 cfg: KeyHandlerConfig,
123) -> impl FnMut(&WidgetEvent, &mut EventContext) -> EventResponse + 'static {
124 move |event, ctx: &mut EventContext| {
125 let WidgetEvent::KeyDown { key, modifiers, .. } = event else {
126 return EventResponse::Ignored;
127 };
128
129 let row_count = cfg.navigator.row_count();
130 if row_count == 0 || cfg.col_count == 0 {
131 return EventResponse::Ignored;
132 }
133 // The keyboard cursor: the focused cell once the user has navigated or
134 // clicked, else the selected row (a table can be handed a selection
135 // before it is ever focused — a restored last position, a preselected
136 // entry). `None` means "no cursor yet", which is deliberately NOT the
137 // same as `Some((0, 0))`: the directional keys below land ON the near
138 // end cell rather than stepping past it. Collapsing the two is what made
139 // the first ArrowDown skip row 0, the first ArrowUp a dead key
140 // (`prev_row(0)` is `None`), and the first ArrowRight skip column 0.
141 let raw = cfg.focused_cell.get().or_else(|| {
142 cfg.selection
143 .as_ref()
144 .and_then(|s| s.selected_indices().first().copied())
145 .map(|r| (r, 0))
146 });
147 let cursor = raw.map(|(r, c)| (r.min(row_count - 1), c.min(cfg.col_count - 1)));
148 // Anchor for the keys that compute *from* a cell (expand / collapse,
149 // paging, Home/End, activation, editing) rather than step in a
150 // direction.
151 let (row, col) = cursor.unwrap_or((0, 0));
152 // Persist the clamp: if the stored focus was out of range (e.g. rows or
153 // columns were removed since it was set), write the in-bounds cell back
154 // so the focus ring and any later reader don't keep the stale position.
155 if raw.is_some() && raw != Some((row, col)) {
156 cfg.focused_cell.set(Some((row, col)));
157 }
158
159 // Read layout direction live from the dispatch context (a runtime
160 // locale switch dirties the tree but does not rebuild, so a
161 // build-time capture would go stale).
162 let rtl = ctx.is_rtl();
163
164 // A row that is one cell across every column (`TreeTableView`'s
165 // full-width rows). The cursor keeps its column on it, so a move back
166 // down to an ordinary row lands where it left, but every column of it
167 // is the same cell — and that cell carries the chevron.
168 let spans = cfg.navigator.spans_all_columns(row);
169
170 // Tree-aware collapse / expand (flat impls are no-ops, so this is
171 // safe to evaluate eagerly). The keys follow the visual chevron:
172 // under LTR the collapsed chevron points right (ArrowRight
173 // expands, ArrowLeft collapses); under RTL it points left, so the
174 // two arrows swap.
175 let on_tree_column = col == cfg.tree_column_display_pos || spans;
176 let is_collapse_key = if rtl {
177 matches!(key, Key::ArrowRight)
178 } else {
179 matches!(key, Key::ArrowLeft)
180 };
181 let is_expand_key = if rtl {
182 matches!(key, Key::ArrowLeft)
183 } else {
184 matches!(key, Key::ArrowRight)
185 };
186 // Only the unmodified arrows expand: with Shift the key belongs to the
187 // range extension, and with the accelerator to the cursor-only move.
188 // ⌘ is excluded too, or a macOS ⌘←/⌘→ — which the platform spends on
189 // history, never on an outline — would silently open and close rows.
190 let plain_arrow =
191 !modifiers.shift() && !modifiers.ctrl() && !modifiers.alt() && !modifiers.super_key();
192 if plain_arrow && is_collapse_key && on_tree_column {
193 if cfg.navigator.is_expanded(row) {
194 cfg.navigator.toggle_expanded(row);
195 return EventResponse::Handled;
196 }
197 // Nothing to collapse: ascend. The ARIA tree pattern, Explorer,
198 // Nautilus and Dolphin all read Left this way, and `TreeView` has
199 // done so since it shipped — this is the half `TreeTableView` was
200 // missing, which left Left a dead key on every leaf.
201 if let Some(parent) = cfg.navigator.parent_row(row) {
202 cfg.focused_cell.set(Some((parent, col)));
203 apply_selection_extension(&cfg, parent, col, false);
204 ensure_row_visible(&cfg, parent, row_count, ctx);
205 return EventResponse::Handled;
206 }
207 }
208 if plain_arrow
209 && is_expand_key
210 && on_tree_column
211 && cfg.navigator.has_children(row)
212 && !cfg.navigator.is_expanded(row)
213 {
214 cfg.navigator.toggle_expanded(row);
215 return EventResponse::Handled;
216 }
217
218 // `*` expands the whole subtree, `+` / `-` one level — read before
219 // type-ahead, since `Key::to_char` answers for all three.
220 //
221 // Gated on the navigator actually being hierarchical (the same
222 // discriminator `corner_column` uses). A flat `TableView` can never
223 // act on these, and claiming them there returned `Ignored` *before*
224 // the type-to-edit and type-ahead arms below — so on a US board,
225 // where `-` is unshifted, typing a minus to start a negative number
226 // in an `EditTriggers::ANY_KEY` column stopped opening the editor.
227 if is_hierarchical(&cfg)
228 && let Some(chord) = list_nav::tree_chord(*key, *modifiers)
229 {
230 let handled = with_navigator_subtree(&cfg, |ops| match chord {
231 list_nav::TreeChord::ExpandSubtree => {
232 crate::common::tree_expand::expand_subtree(ops, row)
233 }
234 list_nav::TreeChord::CollapseSubtree => {
235 crate::common::tree_expand::collapse_subtree(ops, row)
236 }
237 list_nav::TreeChord::ExpandOne => {
238 if cfg.navigator.has_children(row) && !cfg.navigator.is_expanded(row) {
239 cfg.navigator.toggle_expanded(row);
240 true
241 } else {
242 false
243 }
244 }
245 list_nav::TreeChord::CollapseOne => {
246 if cfg.navigator.is_expanded(row) {
247 cfg.navigator.toggle_expanded(row);
248 true
249 } else {
250 false
251 }
252 }
253 });
254 return if handled {
255 EventResponse::Handled
256 } else {
257 EventResponse::Ignored
258 };
259 }
260
261 // macOS reads ⌘↓ as open, ⌘↑ as collapse-or-ascend and ⌥→/⌥← as a
262 // recursive expand in an outline. All four are dead here otherwise.
263 if let Some(alias) = list_nav::mac_alias(*key, *modifiers, rtl) {
264 let handled = match alias {
265 list_nav::MacAlias::Activate => {
266 if let Some(ref f) = cfg.on_row_activate {
267 f(row, ctx);
268 true
269 } else {
270 false
271 }
272 }
273 list_nav::MacAlias::CollapseOrParent => {
274 if cfg.navigator.is_expanded(row) {
275 cfg.navigator.toggle_expanded(row);
276 true
277 } else if let Some(parent) = cfg.navigator.parent_row(row) {
278 cfg.focused_cell.set(Some((parent, col)));
279 apply_selection_extension(&cfg, parent, col, false);
280 ensure_row_visible(&cfg, parent, row_count, ctx);
281 true
282 } else {
283 false
284 }
285 }
286 list_nav::MacAlias::ExpandSubtree => with_navigator_subtree(&cfg, |ops| {
287 crate::common::tree_expand::expand_subtree(ops, row)
288 }),
289 list_nav::MacAlias::CollapseSubtree => with_navigator_subtree(&cfg, |ops| {
290 crate::common::tree_expand::collapse_subtree(ops, row)
291 }),
292 };
293 return if handled {
294 EventResponse::Handled
295 } else {
296 EventResponse::Ignored
297 };
298 }
299
300 // While a cell editor is open the container must stop claiming the
301 // keys the editor needs. Rows are not focusable nodes, so a key the
302 // editor ignores bubbles straight to this handler: a single-line
303 // editor does nothing with PageDown, and the table would page the
304 // cursor out from under the edit in progress. Escape and the commit /
305 // advance keys stay here, since they are about the *edit*, not the
306 // text. The ARIA grid pattern states the rule outright — everything
307 // else "passes the key event to the focused widget".
308 if cfg.editing_cell.get().is_some()
309 && !matches!(key, Key::Escape | Key::Enter | Key::Tab | Key::F2)
310 {
311 return EventResponse::Ignored;
312 }
313
314 let viewport_h = cfg.viewport_height.get();
315 // A row spanning every column draws none of the columns' cells, so it
316 // has no editor for any of them to open.
317 let edit_triggers = if spans {
318 EditTriggers::NONE
319 } else {
320 (cfg.display_col_triggers)(col)
321 };
322 // A table whose cells are the navigable unit reads Home as the start
323 // of the row; one that selects whole rows has no cell cursor for that
324 // to mean anything against, so its Home is the first row — which is
325 // also what Explorer's details view and every list control do.
326 let view_kind = if cfg.selection_mode.is_cell_mode() {
327 list_nav::ViewKind::CellGrid
328 } else {
329 list_nav::ViewKind::Linear
330 };
331 let nav = list_nav::nav_chord(*key, *modifiers, view_kind);
332
333 // Each directional key, with NO cursor yet, lands ON the end cell it
334 // would have entered from — it does not step past it (see `cursor`
335 // above, and the same rule in `ListView` / `TreeView` / `GridView`).
336 // `first_row` / `last_row` (not raw 0 / row_count-1) so a hierarchical
337 // navigator — `TreeTableView` plugs its own in here — enters at a row
338 // that is actually visible.
339 let new_pos: Option<(usize, usize)> = match key {
340 Key::ArrowUp => match cursor {
341 None => cfg.navigator.last_row().map(|r| (r, col)),
342 Some(_) => cfg.navigator.prev_row(row).map(|r| (r, col)),
343 },
344 Key::ArrowDown => match cursor {
345 None => cfg.navigator.first_row().map(|r| (r, col)),
346 Some(_) => cfg.navigator.next_row(row).map(|r| (r, col)),
347 },
348 // A row spanning every column has no cell beside the cursor: the
349 // move stays on it, as it does at the edge column of any row.
350 Key::ArrowLeft | Key::ArrowRight if cursor.is_some() && spans => None,
351 // Visual-left moves to a higher display index under RTL
352 // (columns run right-to-left), so the two arrows swap their
353 // index delta. The clamps stay tied to the physical edge each
354 // arrow points at. Column 0 is the leading column in both
355 // directions, so a cursor-less entry lands on the column the key
356 // points *away* from: the "next" key on the first column, the
357 // "previous" key on the last.
358 Key::ArrowLeft => {
359 if rtl {
360 match cursor {
361 None => Some((row, 0)),
362 Some(_) => (col + 1 < cfg.col_count).then_some((row, col + 1)),
363 }
364 } else {
365 match cursor {
366 None => Some((row, cfg.col_count - 1)),
367 // `.then` (lazy) — `col - 1` must not be evaluated at col 0.
368 Some(_) => (col > 0).then(|| (row, col - 1)),
369 }
370 }
371 }
372 Key::ArrowRight => {
373 if rtl {
374 match cursor {
375 None => Some((row, cfg.col_count - 1)),
376 Some(_) => (col > 0).then(|| (row, col - 1)),
377 }
378 } else {
379 match cursor {
380 None => Some((row, 0)),
381 Some(_) => (col + 1 < cfg.col_count).then_some((row, col + 1)),
382 }
383 }
384 }
385 // The edge-and-page family, resolved once in `common::list_nav`.
386 // `first_row` / `last_row` rather than raw 0 / count-1 so a
387 // hierarchical navigator lands on a row that is actually visible.
388 Key::Home | Key::End | Key::PageUp | Key::PageDown => {
389 let Some(chord) = nav else {
390 return EventResponse::Ignored;
391 };
392 match chord.movement {
393 // On a row spanning every column, its first cell and its
394 // last are the one the cursor is on.
395 list_nav::NavMove::RowFirst | list_nav::NavMove::RowLast if spans => {
396 Some((row, col))
397 }
398 list_nav::NavMove::RowFirst => Some((row, 0)),
399 list_nav::NavMove::RowLast => Some((row, cfg.col_count - 1)),
400 // Where the accelerator lands depends on which ARIA
401 // pattern this table *is*, and the two disagree on
402 // purpose. A flat cell grid escalates to the corner —
403 // "moves focus to the first cell in the first row" (the
404 // grid pattern, and Excel and Qt's `QTableView`). A
405 // treegrid keeps the column: "moves focus to the cell in
406 // the first row in the same column as the cell that had
407 // focus". A row-selection table has no column cursor to
408 // move at all, so the column simply stays put.
409 list_nav::NavMove::First => cfg
410 .navigator
411 .first_row()
412 .map(|r| (r, corner_column(&cfg, view_kind, col, false))),
413 list_nav::NavMove::Last => cfg
414 .navigator
415 .last_row()
416 .map(|r| (r, corner_column(&cfg, view_kind, col, true))),
417 // Scroll one page, then move focus to the row one viewport
418 // away from the current row's top — offset-table-driven,
419 // so variable heights page by visual distance rather than
420 // by a fixed row count. Guarantees progress even when a
421 // single row is taller than the viewport.
422 list_nav::NavMove::Page { down } => {
423 let new_y = if down {
424 (cfg.scroll_y.get() + viewport_h).min(cfg.max_scroll_y.get())
425 } else {
426 (cfg.scroll_y.get() - viewport_h).max(0.0)
427 };
428 cfg.scroll_y.set(new_y);
429 let r = {
430 let mut m = cfg.row_metrics.borrow_mut();
431 m.resize(row_count);
432 let target_y = if down {
433 m.row_top(row) + viewport_h
434 } else {
435 (m.row_top(row) - viewport_h).max(0.0)
436 };
437 m.row_at(target_y)
438 };
439 let r = if r == row && down {
440 (row + 1).min(row_count - 1)
441 } else if r == row {
442 row.saturating_sub(1)
443 } else {
444 r.min(row_count - 1)
445 };
446 Some((r, col))
447 }
448 }
449 }
450 // Ctrl+Tab / Ctrl+Shift+Tab escape the cell grid: return Ignored so
451 // the framework's focus cycling moves to the next / previous widget.
452 // Plain Tab still navigates cells (the `CellsThenRows` trap), but
453 // this gives keyboard users a reliable way out — the same un-trap
454 // affordance `RichTextEditor` leaves to OS focus navigation.
455 //
456 // Literal `ctrl()`, not `command()`: Ctrl+Tab is Ctrl+Tab on macOS
457 // too — ⌘⇥ belongs to the application switcher and never reaches an
458 // app at all.
459 Key::Tab if modifiers.ctrl() => return EventResponse::Ignored,
460 // A row spanning every column is one stop: Tab leaves it for the
461 // next row, Shift+Tab for the previous one.
462 Key::Tab => {
463 if modifiers.shift() {
464 if col > 0 && !spans {
465 Some((row, col - 1))
466 } else if let Some(prev) = cfg.navigator.prev_row(row) {
467 Some((prev, cfg.col_count - 1))
468 } else if cfg.tab_traversal == TabTraversal::OutOfTable {
469 return EventResponse::Ignored;
470 } else {
471 Some((row, col))
472 }
473 } else {
474 if col + 1 < cfg.col_count && !spans {
475 Some((row, col + 1))
476 } else if let Some(next) = cfg.navigator.next_row(row) {
477 Some((next, 0))
478 } else if cfg.tab_traversal == TabTraversal::OutOfTable {
479 return EventResponse::Ignored;
480 } else {
481 Some((row, col))
482 }
483 }
484 }
485 // Space and its two modified forms. In a **row**-selection table
486 // this is the Explorer trio: Space toggles the focused row, and
487 // Ctrl+Space does the same after a Ctrl+Arrow walk away from the
488 // selection.
489 //
490 // In a **multi**-cell grid the same two chords are already spoken
491 // for, and mean the opposite way round: the ARIA grid pattern and
492 // Excel both read Ctrl+Space as "select this column" and
493 // Shift+Space as "select this row". Reading them as a toggle there
494 // would take a spreadsheet user's two most-used selection chords
495 // and do something else with them.
496 //
497 // `MultiCell` only, not every cell mode: a `SingleCell` table
498 // holds one cell by definition, so "select the column" has no
499 // reading there — it would quietly break the mode's own
500 // invariant. It falls through to the plain toggle below, the same
501 // way `select_all` already declines outside the multi modes.
502 Key::Space
503 if cfg.selection_mode == TableSelectionMode::MultiCell && modifiers.ctrl() =>
504 {
505 select_column(&cfg, col, row_count);
506 cfg.focused_cell.set(Some((row, col)));
507 return EventResponse::Handled;
508 }
509 Key::Space
510 if cfg.selection_mode == TableSelectionMode::MultiCell && modifiers.shift() =>
511 {
512 select_row_cells(&cfg, row);
513 cfg.focused_cell.set(Some((row, col)));
514 return EventResponse::Handled;
515 }
516 Key::Space => {
517 // A cell carrying a checkbox reads Space as "check this" — its
518 // control is out of the Tab order, so this is the only
519 // keyboard route to it. A cell that publishes no toggle falls
520 // back to the selection, exactly as before.
521 if let Some(cell_id) = cfg
522 .cell_map
523 .borrow()
524 .iter()
525 .find(|(pos, _)| *pos == (row, col))
526 .map(|(_, id)| *id)
527 {
528 let fallback_cfg = cfg.clone();
529 ctx.row_space_activate(
530 cell_id,
531 std::rc::Rc::new(move |_ctx: &mut EventContext| {
532 toggle_selection(&fallback_cfg, row, col);
533 }),
534 );
535 cfg.focused_cell.set(Some((row, col)));
536 return EventResponse::Handled;
537 }
538 toggle_selection(&cfg, row, col);
539 cfg.focused_cell.set(Some((row, col)));
540 return EventResponse::Handled;
541 }
542 Key::Enter => {
543 if let Some(ref f) = cfg.on_row_activate {
544 f(row, ctx);
545 } else {
546 toggle_selection(&cfg, row, col);
547 }
548 return EventResponse::Handled;
549 }
550 Key::F2 if edit_triggers.contains(EditTriggers::F2) => {
551 if let Some(col_id) = (cfg.display_col_to_id)(col) {
552 cfg.editing_cell.set(Some((row, col)));
553 if let Some(ref f) = cfg.on_cell_edit_request {
554 f(row, &col_id, ctx);
555 }
556 return EventResponse::Handled;
557 }
558 return EventResponse::Ignored;
559 }
560 // Type-to-edit. `Key::Character` only fires for non-letter
561 // printable chars on this platform; letters arrive as the
562 // dedicated `Key::A`..`Key::Z` variants. Match any key that
563 // has a printable char form via `Key::to_char()`. Gated on
564 // the column's `editable` flag so non-editable columns
565 // don't enter edit mode (which would set `editing_cell`
566 // without any actual editor in the cell to receive focus
567 // and follow-up keystrokes).
568 k if edit_triggers.contains(EditTriggers::ANY_KEY)
569 && !modifiers.ctrl()
570 && !modifiers.alt()
571 && !modifiers.super_key()
572 && k.to_char().is_some() =>
573 {
574 if let Some(col_id) = (cfg.display_col_to_id)(col) {
575 cfg.editing_cell.set(Some((row, col)));
576 if let Some(ref f) = cfg.on_cell_edit_request {
577 f(row, &col_id, ctx);
578 }
579 // Don't claim Handled — the typed character should
580 // propagate to the editor that the cell delegate
581 // swaps in.
582 return EventResponse::Ignored;
583 }
584 return EventResponse::Ignored;
585 }
586 // Type-ahead: a printable char (no Ctrl/Alt/Super) jumps the
587 // focused row to the next row whose label starts with the
588 // accumulated term. Reached only when the type-to-edit arm above
589 // didn't consume the char (no editor on this column / edit off).
590 k if cfg.type_ahead_label.is_some()
591 && !modifiers.ctrl()
592 && !modifiers.alt()
593 && !modifiers.super_key()
594 && k.to_char().is_some() =>
595 {
596 let c = k.to_char().unwrap();
597 let label = cfg.type_ahead_label.as_ref().unwrap();
598 if let Some(nr) =
599 cfg.type_ahead
600 .search(c, row, row_count, cfg.type_ahead_timeout, |i| label(i))
601 {
602 cfg.focused_cell.set(Some((nr, col)));
603 apply_selection_extension(&cfg, nr, col, false);
604 ensure_row_visible(&cfg, nr, row_count, ctx);
605 ensure_col_visible(&cfg, col);
606 return EventResponse::Handled;
607 }
608 return EventResponse::Ignored;
609 }
610 // Ctrl+Shift+A deselects (GTK's chord, unspent by every other
611 // toolkit). `Ignored` outside the multi modes, so the chord
612 // reaches the application rather than being swallowed by a table
613 // that has nothing to deselect — the shape `ListView`, `TreeView`
614 // and `GridView` already use for the same pair. Select all is the
615 // `edit.select_all` action (`run_select_all`), so a plain Ctrl+A
616 // only gets here when it is not bound to it.
617 Key::A if modifiers.command() && modifiers.shift() => {
618 if !cfg.selection_mode.is_multi() {
619 return EventResponse::Ignored;
620 }
621 clear_selection(&cfg);
622 return EventResponse::Handled;
623 }
624 Key::A if modifiers.command() => return EventResponse::Ignored,
625 Key::Escape => {
626 if cfg.editing_cell.get().is_some() {
627 cfg.editing_cell.set(None);
628 } else {
629 cfg.focused_cell.set(None);
630 }
631 return EventResponse::Handled;
632 }
633 _ => None,
634 };
635
636 if let Some((nr, nc)) = new_pos {
637 cfg.focused_cell.set(Some((nr, nc)));
638 // Explorer/Finder convention: Ctrl+Arrow (no Shift) repositions
639 // the keyboard cursor without touching a multiple selection —
640 // the followed "select the row you land on" behavior is opt-out
641 // only via Ctrl, exactly like plain Arrow's select-follow is
642 // opt-in via nothing (default) and Shift+Arrow's extend is
643 // opt-in via Shift. `Ctrl+Space` (the `Key::Space` arms above,
644 // whose `toggle_selection` ignores modifiers) then toggles just the
645 // row or cell the cursor moved to — except in a `MultiCell` grid,
646 // where the ARIA grid pattern spends that chord on "select this
647 // column" instead.
648 let is_arrow = matches!(
649 key,
650 Key::ArrowUp | Key::ArrowDown | Key::ArrowLeft | Key::ArrowRight
651 );
652 // Literal `ctrl()`, macOS included: ⌘↑/⌘↓ already mean something
653 // else in a Finder list, and this Explorer-style cursor pair has no
654 // ⌘ counterpart — Control keeps it reachable and out of the way.
655 let move_cursor_only = is_arrow && modifiers.ctrl() && !modifiers.shift();
656 // The edge-and-page keys carry their own verb from `list_nav`,
657 // where the accelerator means "move the cursor, leave the
658 // selection alone" — the rule GTK4 applies to every navigation
659 // key, Qt in its multi-selection modes, and the arrows above
660 // too. A table that holds one row or cell moves the selection
661 // with the cursor whatever the chord (`for_cardinality`).
662 let op = match nav {
663 Some(chord) => chord.selection,
664 None if move_cursor_only => list_nav::SelectionOp::Suppress,
665 None if modifiers.shift() => list_nav::SelectionOp::Extend,
666 None => list_nav::SelectionOp::Replace,
667 };
668 match op.for_cardinality(super::selection::cardinality(
669 cfg.selection_mode,
670 cfg.selection.as_ref(),
671 cfg.cell_selection.as_ref(),
672 )) {
673 list_nav::SelectionOp::Suppress => {}
674 list_nav::SelectionOp::Extend => apply_selection_extension(&cfg, nr, nc, true),
675 list_nav::SelectionOp::ExtendAdditive => apply_additive_extension(&cfg, nr, nc),
676 list_nav::SelectionOp::Replace => apply_selection_extension(&cfg, nr, nc, false),
677 }
678 ensure_row_visible(&cfg, nr, row_count, ctx);
679 ensure_col_visible(&cfg, nc);
680 return EventResponse::Handled;
681 }
682
683 EventResponse::Ignored
684 }
685}
686
687/// Scroll the viewport so `row` is fully visible — a no-op when it
688/// already is. Gives `TableView` / `TreeTableView` the same
689/// "keyboard-focused row stays on screen" behavior that `ListView` /
690/// `TreeView` already have: every Arrow / Home / End / Ctrl+Home/End /
691/// Tab move that lands on a new row keeps it visible.
692///
693/// `PageUp` / `PageDown` already set `scroll_y` to a page boundary and
694/// pick a focus row at the new viewport edge, so calling this for them
695/// only refines the offset (the chosen row is visible by construction —
696/// no extra jump).
697fn ensure_row_visible(
698 cfg: &KeyHandlerConfig,
699 row: usize,
700 row_count: usize,
701 ctx: &mut EventContext,
702) {
703 let scroll = cfg.scroll_y.get();
704 // Asked first, and with no metrics borrow held: a navigator that covers
705 // part of the viewport reads the same metrics to say how much.
706 let covered = cfg.navigator.reveal_scroll(
707 row,
708 scroll,
709 cfg.viewport_height.get(),
710 cfg.max_scroll_y.get(),
711 );
712 let new_scroll = covered.unwrap_or_else(|| {
713 let mut m = cfg.row_metrics.borrow_mut();
714 m.resize(row_count);
715 m.scroll_for_ensure_visible(
716 row,
717 scroll,
718 cfg.viewport_height.get(),
719 cfg.max_scroll_y.get(),
720 )
721 });
722 if (new_scroll - scroll).abs() > f32::EPSILON {
723 cfg.scroll_y.set(new_scroll);
724 }
725 // After keeping the row in the table's OWN viewport, chain the reveal to
726 // any enclosing scroll area (a form/page the table is embedded in).
727 crate::common::row_metrics::chase_row_into_outer_view(
728 ctx,
729 &cfg.row_metrics,
730 cfg.body_bounds.get(),
731 row,
732 new_scroll,
733 );
734}
735
736/// Scroll the Middle pane horizontally so `display_col` is fully visible —
737/// the horizontal analogue of [`ensure_row_visible`]. A no-op for a
738/// Leading/Trailing-pinned column: pinning already guarantees visibility, so
739/// the column can never trigger horizontal scrolling. Unlike rows (via
740/// `RowMetrics`, virtualized over thousands of entries), the column count is
741/// small and already fully resolved in `column_widths`, so a plain linear
742/// scan suffices — no shared "ColumnMetrics" abstraction needed.
743fn ensure_col_visible(cfg: &KeyHandlerConfig, display_col: usize) {
744 let b = cfg.pane_boundaries;
745 if display_col < b.leading_count || display_col >= b.middle_end {
746 return;
747 }
748 let widths = cfg.column_widths.borrow();
749 let Some(w) = widths.get(display_col).copied() else {
750 return;
751 };
752 // Logical x of `display_col` within the *unscrolled* Middle content
753 // strip (offset from the Middle pane's own leading edge) — i.e.
754 // `column_logical_x` with `scroll_x = 0`, restricted to the Middle
755 // pane's own local space (band_width is irrelevant here since Trailing
756 // never enters this branch).
757 let x: f32 = widths[b.leading_count..display_col].iter().sum();
758 drop(widths);
759
760 let viewport_w = cfg.middle_viewport_width.get();
761 let scroll = cfg.scroll_x.get();
762 let max = cfg.max_scroll_x.get();
763 let new_scroll = if x < scroll {
764 x
765 } else if x + w > scroll + viewport_w {
766 (x + w - viewport_w).max(0.0)
767 } else {
768 scroll
769 }
770 .clamp(0.0, max.max(0.0));
771 if (new_scroll - scroll).abs() > f32::EPSILON {
772 cfg.scroll_x.set(new_scroll);
773 }
774}
775
776/// Which column `Ctrl+Home` / `Ctrl+End` land on.
777///
778/// The ARIA grid and treegrid patterns differ here deliberately: a flat cell
779/// grid escalates to the table's corner, a treegrid keeps the column the
780/// cursor was already in. A row-selection table has no column cursor to move.
781///
782/// The navigator is the discriminator — only a hierarchical one reports a
783/// depth — so this costs no extra config field and cannot fall out of step
784/// with the widget it describes.
785fn corner_column(
786 cfg: &KeyHandlerConfig,
787 view_kind: list_nav::ViewKind,
788 col: usize,
789 last: bool,
790) -> usize {
791 if view_kind != list_nav::ViewKind::CellGrid {
792 return col;
793 }
794 if is_hierarchical(cfg) {
795 col
796 } else if last {
797 cfg.col_count - 1
798 } else {
799 0
800 }
801}
802
803/// Whether the navigator reports a hierarchy at all.
804///
805/// Only `TreeNavigator` answers `depth`, so this is what separates a
806/// `TreeTableView` from a `TableView` without a second config field that
807/// could fall out of step with the widget it describes.
808fn is_hierarchical(cfg: &KeyHandlerConfig) -> bool {
809 cfg.navigator
810 .first_row()
811 .and_then(|r| cfg.navigator.depth(r))
812 .is_some()
813}
814
815/// Run `f` against a [`SubtreeOps`](crate::common::tree_expand::SubtreeOps)
816/// view of the navigator.
817///
818/// A flat table's navigator reports no depth and no children, so every
819/// recursive expand over one is a no-op — which is why `TableView` can share
820/// this path without a second branch.
821fn with_navigator_subtree<R>(
822 cfg: &KeyHandlerConfig,
823 f: impl FnOnce(&crate::common::tree_expand::SubtreeOps) -> R,
824) -> R {
825 let nav = &cfg.navigator;
826 let count = || nav.row_count();
827 let row = |i: usize| {
828 nav.depth(i)
829 .map(|d| (d, nav.has_children(i), nav.is_expanded(i)))
830 };
831 let set = |i: usize, on: bool| {
832 if nav.is_expanded(i) != on {
833 nav.toggle_expanded(i);
834 }
835 };
836 f(&crate::common::tree_expand::SubtreeOps {
837 visible_count: &count,
838 row: &row,
839 set_expanded: &set,
840 })
841}
842
843fn toggle_selection(cfg: &KeyHandlerConfig, row: usize, col: usize) {
844 match cfg.selection_mode {
845 TableSelectionMode::SingleRow | TableSelectionMode::MultiRow => {
846 if let Some(ref s) = cfg.selection {
847 if s.is_selected(row) {
848 // Toggle off: a Multi selection model can have it
849 // both ways; Single mode replaces with empty.
850 if cfg.selection_mode == TableSelectionMode::MultiRow {
851 s.toggle(row);
852 } else {
853 s.clear();
854 }
855 } else {
856 s.select(row);
857 }
858 }
859 }
860 TableSelectionMode::SingleCell | TableSelectionMode::MultiCell => {
861 if let Some(ref cs) = cfg.cell_selection {
862 if cs.is_selected(row, col) && cfg.selection_mode == TableSelectionMode::MultiCell {
863 cs.toggle(row, col);
864 } else {
865 cs.select(row, col);
866 }
867 }
868 }
869 TableSelectionMode::None => {}
870 }
871}
872
873fn apply_selection_extension(cfg: &KeyHandlerConfig, row: usize, col: usize, shift: bool) {
874 match cfg.selection_mode {
875 TableSelectionMode::MultiRow => {
876 if let Some(ref s) = cfg.selection {
877 if shift && s.mode() == SelectionMode::Multi {
878 s.extend_to(row);
879 } else {
880 s.select(row);
881 }
882 }
883 }
884 TableSelectionMode::SingleRow => {
885 if let Some(ref s) = cfg.selection {
886 s.select(row);
887 }
888 }
889 TableSelectionMode::MultiCell => {
890 if let Some(ref cs) = cfg.cell_selection {
891 if shift {
892 cs.extend_to(row, col);
893 } else {
894 cs.select(row, col);
895 }
896 }
897 }
898 TableSelectionMode::SingleCell => {
899 if let Some(ref cs) = cfg.cell_selection {
900 cs.select(row, col);
901 }
902 }
903 TableSelectionMode::None => {}
904 }
905}
906
907/// Ctrl+Shift+`nav` — extend the range while keeping whatever the previous
908/// gesture selected. Only the multi modes have a range to extend.
909fn apply_additive_extension(cfg: &KeyHandlerConfig, row: usize, col: usize) {
910 match cfg.selection_mode {
911 TableSelectionMode::MultiRow => {
912 if let Some(ref s) = cfg.selection {
913 s.extend_to_additive(row);
914 }
915 }
916 // `CellSelectionModel::extend_to` already unions with the set
917 // committed at the last non-extending action, so its plain extend is
918 // the additive one — the row model is the side that needed a second
919 // entry point.
920 TableSelectionMode::MultiCell => {
921 if let Some(ref cs) = cfg.cell_selection {
922 cs.extend_to(row, col);
923 }
924 }
925 _ => apply_selection_extension(cfg, row, col, true),
926 }
927}
928
929/// Ctrl+Space in a cell grid — select the whole column holding the cursor.
930/// The ARIA grid pattern and Excel agree on this; it is *not* the file
931/// manager's "toggle the focused item".
932fn select_column(cfg: &KeyHandlerConfig, col: usize, row_count: usize) {
933 if let Some(ref cs) = cfg.cell_selection {
934 cs.select_cells((0..row_count).map(|r| (r, col)));
935 }
936}
937
938/// Shift+Space in a cell grid — select the whole row holding the cursor.
939fn select_row_cells(cfg: &KeyHandlerConfig, row: usize) {
940 if let Some(ref cs) = cfg.cell_selection {
941 cs.select_cells((0..cfg.col_count).map(|c| (row, c)));
942 }
943}
944
945/// Ctrl+Shift+A — drop the selection whichever model is backing it.
946fn clear_selection(cfg: &KeyHandlerConfig) {
947 match cfg.selection_mode {
948 TableSelectionMode::MultiRow => {
949 if let Some(ref s) = cfg.selection {
950 s.clear();
951 }
952 }
953 TableSelectionMode::MultiCell => {
954 if let Some(ref cs) = cfg.cell_selection {
955 cs.clear();
956 }
957 }
958 _ => {}
959 }
960}
961
962/// Select all for a table, as its `edit.select_all` action runs it. Declined
963/// for an empty table and outside the multi modes, so the command reaches the
964/// application.
965pub(crate) fn run_select_all(cfg: &KeyHandlerConfig) -> bool {
966 let row_count = cfg.navigator.row_count();
967 if row_count == 0 || cfg.col_count == 0 || !cfg.selection_mode.is_multi() {
968 return false;
969 }
970 select_all(cfg, row_count);
971 true
972}
973
974fn select_all(cfg: &KeyHandlerConfig, row_count: usize) {
975 match cfg.selection_mode {
976 TableSelectionMode::MultiRow => {
977 if let Some(ref s) = cfg.selection {
978 s.select_all(row_count);
979 }
980 }
981 TableSelectionMode::MultiCell => {
982 if let Some(ref cs) = cfg.cell_selection {
983 cs.select_all(row_count, cfg.col_count);
984 }
985 }
986 _ => {}
987 }
988}