Skip to main content

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}