gwm/tui/keymap.rs
1//! Configurable TUI keymap (issue #87).
2//!
3//! Three layers, in order of authority:
4//!
5//! 1. **Built-in defaults** ([`Keymap::defaults`]) — the bindings the
6//! binary ships with. Captures the historical hard-coded set
7//! (`j/k`, `g g`, `Tab`, `o`, `l`, `R`, `y`, `p`, `f/F`, `/`, `?`,
8//! `q`, …).
9//! 2. **User overrides** ([`Keymap::apply_override`]) — fed from
10//! `[tui.keys]` in `.gwm.toml`. An override **replaces** the
11//! default for the targeted action; it does not merge. Passing an
12//! empty `Vec` unbinds the action entirely.
13//! 3. **Hard-coded escape hatches** — `Ctrl+C` (emergency quit) and
14//! `Esc` / `Enter` keep their contextual handling in
15//! `src/tui/mod.rs`. They are deliberately outside the keymap
16//! because their semantics depend on the active view (filter bar,
17//! picker mode, sticky filter, etc.) — folding them into the
18//! keymap would require modal state the configuration language
19//! has no way to express.
20//!
21//! ## Chord / prefix policy
22//!
23//! Per the design decision recorded on PR #87, binding a chord that
24//! is a strict prefix of another chord (e.g. `g` alone while `g g` is
25//! also bound) is a **hard error at load time**. Resolving the
26//! ambiguity at runtime would require a Vim-style 500 ms timeout in
27//! the event loop, which conflicts with the project's preference for
28//! a pure state-machine TUI. Easier to refuse the config and force
29//! the user to pick.
30//!
31//! ## Quit policy
32//!
33//! `quit` is the only action with a guaranteed escape hatch: the
34//! hard-coded `Ctrl+C` branch in `run_app` runs *before* any keymap
35//! lookup, so even an empty / hostile user keymap can be exited. The
36//! doctor check defined in `crate::doctor` emits a warning when no
37//! non-`Ctrl+C` binding for `quit` survives the override layer.
38
39use crate::error::{GwmError, Result};
40use crossterm::event::{KeyCode, KeyEvent, KeyModifiers};
41use std::fmt;
42
43// ---------------------------------------------------------------------------
44// Action enum + ACTIONS table
45// ---------------------------------------------------------------------------
46
47/// Declarative macro that defines `Action`, its slug roundtrip, the
48/// `ACTIONS` table, and `Action::all()` from a **single** ordered list
49/// of `(Variant => "slug")` pairs. Adding a new action means editing
50/// one place; the rest stays in sync mechanically.
51macro_rules! define_actions {
52 ($( $variant:ident => $slug:literal ),* $(,)?) => {
53 /// Every user-rebindable verb the TUI exposes.
54 ///
55 /// Variants whose semantics depend on the active view (`Enter`,
56 /// `Esc`, `Ctrl+C`) are deliberately **not** listed — see the
57 /// module-level "Hard-coded escape hatches" note for why.
58 #[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
59 pub enum Action {
60 $( $variant, )*
61 }
62
63 impl Action {
64 /// Stable string slug used in `[tui.keys]`, `gwm tui keys`, and
65 /// any future docs generator. Lowercase + underscores; never
66 /// renamed in a backwards-incompatible way without a deprecation
67 /// alias.
68 pub fn slug(self) -> &'static str {
69 match self {
70 $( Action::$variant => $slug, )*
71 }
72 }
73
74 /// Inverse of [`Action::slug`]. Used by the config loader to
75 /// translate `.gwm.toml` keys into typed actions.
76 pub fn from_slug(s: &str) -> Option<Self> {
77 match s {
78 $( $slug => Some(Action::$variant), )*
79 _ => None,
80 }
81 }
82
83 /// Iterator over every variant. Order matches the declarative
84 /// macro invocation below, which is also the order surfaced by
85 /// `gwm tui keys`.
86 pub fn all() -> impl Iterator<Item = Self> {
87 [ $( Action::$variant, )* ].into_iter()
88 }
89 }
90
91 /// Static (Action, slug) table — convenience for callers that
92 /// want both at once (the help-overlay renderer, `gwm tui keys`
93 /// printer, `gwm doctor` reporter).
94 pub const ACTIONS: &[(Action, &str)] = &[
95 $( (Action::$variant, $slug), )*
96 ];
97 };
98}
99
100define_actions! {
101 // Navigation
102 Down => "down",
103 Up => "up",
104 Top => "top",
105 Bottom => "bottom",
106 ToggleSidebar => "toggle_sidebar",
107 ToggleSidebarMode => "toggle_sidebar_mode",
108 CycleSidebarLayout => "cycle_sidebar_layout",
109 ToggleSidebarPosition => "toggle_sidebar_position",
110 FocusSwap => "focus_swap",
111 FocusWorktrees => "focus_worktrees",
112 FocusStatus => "focus_status",
113 // Filter
114 Filter => "filter",
115 // Lifecycle / mutating
116 Refresh => "refresh",
117 Sync => "sync",
118 Create => "create",
119 DeleteConfirm => "delete",
120 Bootstrap => "bootstrap",
121 ToggleDeleteBranch => "delete_branch",
122 Pull => "pull",
123 Push => "push",
124 EditWorktree => "edit_worktree",
125 ExitToWorktree => "exit_to_worktree",
126 LazyGitPty => "lazygit_pty",
127 LazyGitFullscreen => "lazygit_fullscreen",
128 ReviewFullscreen => "review_fullscreen",
129 ReviewPty => "review_pty",
130 YankPath => "yank_path",
131 YankBranchName => "yank_branch_name",
132 YankWorktreeName => "yank_worktree_name",
133 TerminalPty => "terminal_pty",
134 TerminalFullscreen => "terminal_fullscreen",
135 BrowseLinks => "browse_links",
136 OpenDocs => "open_docs",
137 LinkPrompt => "link",
138 FetchGithub => "fetch_github",
139 MuxPane => "mux_pane",
140 Macro1 => "macro_one",
141 Macro2 => "macro_two",
142 // Overlays
143 CommandLogs => "command_logs",
144 ConfigPanel => "config_panel",
145 ExecOverlay => "exec_overlay",
146 CleanOverlay => "clean_overlay",
147 Help => "help",
148 Quit => "quit",
149 // Future surface — bound to ':' by default, picked up by #32.
150 CommandPalette => "command_palette",
151}
152
153impl Action {
154 /// Like [`Action::from_slug`] but also accepts the pre-#290 slugs that were
155 /// renamed. Use this in config deserialization so existing `.gwm.toml` files
156 /// with the old key names keep working after the upgrade.
157 ///
158 /// The canonical slug is tried first; only on a miss do the compat aliases
159 /// fire. The aliases are intentionally one-way: `Action::slug()` still
160 /// returns the new canonical slug, so `gwm tui keys` and the help overlay
161 /// stay up-to-date.
162 pub fn from_slug_compat(s: &str) -> Option<Self> {
163 if let Some(a) = Self::from_slug(s) {
164 return Some(a);
165 }
166 COMPAT_ALIASES.iter().find(|(slug, _)| *slug == s).map(|(_, a)| *a)
167 }
168
169 /// Whether this action mutates a repo through the *active* repo context
170 /// (`App.repo`/`workdir`/`config`) rather than only the selected worktree's
171 /// path. In workspace mode (#304) these are blocked while the selected row's
172 /// repo can't be activated, since they would otherwise target the previously
173 /// active repo. Navigation, yanks and read-only launchers are absent on
174 /// purpose — they don't write through the active repo handle.
175 pub fn is_repo_mutating(self) -> bool {
176 matches!(
177 self,
178 Action::Create
179 | Action::DeleteConfirm
180 | Action::Bootstrap
181 | Action::Sync
182 | Action::Pull
183 | Action::Push
184 | Action::EditWorktree
185 | Action::LinkPrompt
186 // FetchGithub persists detected PR/issue titles + states into the
187 // active repo's git config, so it writes through `App.repo` too (#304).
188 | Action::FetchGithub
189 // #325: the exec / clean overlays resolve their command / dir-set from
190 // the *active* repo's `[exec]` / `[clean]` config and act on the
191 // selected worktree's path. With a stale workspace selection both the
192 // config and the path belong to the previously active repo — and exec
193 // runs an arbitrary command while clean deletes directories — so they
194 // must be blocked before the overlay opens (Codex #333 review).
195 | Action::ExecOverlay
196 | Action::CleanOverlay
197 )
198 }
199
200 /// The pre-#290 alias slug(s) that resolve to this action, if any. Used by
201 /// the in-TUI keymap editor (issue #294) to strip a stale alias from a
202 /// legacy config when the canonical slug is (re)written — otherwise the
203 /// alias, applied later in the sorted override walk, would silently shadow
204 /// the new binding (Codex #297 review).
205 pub fn compat_alias_slugs(self) -> impl Iterator<Item = &'static str> {
206 COMPAT_ALIASES
207 .iter()
208 .filter(move |(_, a)| *a == self)
209 .map(|(slug, _)| *slug)
210 }
211}
212
213/// Pre-#290 slug aliases, accepted by [`Action::from_slug_compat`] so existing
214/// `.gwm.toml` files keep working after the #290 rename. The canonical slug is
215/// always preferred; these only fire on a miss. Single source of truth for both
216/// directions (resolve + [`Action::compat_alias_slugs`]).
217const COMPAT_ALIASES: &[(&str, Action)] = &[
218 ("git_tui", Action::LazyGitFullscreen),
219 ("git_tui_overlay", Action::LazyGitPty),
220 ("review", Action::ReviewFullscreen),
221 ("review_overlay", Action::ReviewPty),
222 ("yank", Action::YankPath),
223 ("open", Action::TerminalFullscreen),
224 ("open_terminal_overlay", Action::TerminalPty),
225 ("open_menu", Action::BrowseLinks),
226];
227
228// ---------------------------------------------------------------------------
229// Key-string parser
230// ---------------------------------------------------------------------------
231
232/// One keystroke in a chord. Wraps a crossterm [`KeyCode`] +
233/// [`KeyModifiers`] pair, but only retains the three modifier bits
234/// that are meaningful at the keymap layer (Ctrl / Alt / Shift) —
235/// other crossterm bits (keypad, repeat, …) are filtered in
236/// [`KeyStroke::from_event`].
237#[derive(Debug, Clone, PartialEq, Eq, Hash)]
238pub struct KeyStroke {
239 pub code: KeyCode,
240 pub modifiers: KeyModifiers,
241}
242
243impl KeyStroke {
244 pub fn new(code: KeyCode, modifiers: KeyModifiers) -> Self {
245 let (code, modifiers) = Self::normalize(code, Self::sanitize(modifiers));
246 Self { code, modifiers }
247 }
248
249 /// Build a stroke from a raw crossterm event, dropping modifier
250 /// bits we never bind against (KEYPAD, REPEAT, SUPER, HYPER, META).
251 pub fn from_event(ev: &KeyEvent) -> Self {
252 Self::new(ev.code, ev.modifiers)
253 }
254
255 fn sanitize(m: KeyModifiers) -> KeyModifiers {
256 m & (KeyModifiers::CONTROL | KeyModifiers::ALT | KeyModifiers::SHIFT)
257 }
258
259 /// Fold a shifted character keystroke to a terminal-independent
260 /// canonical form. A shifted letter already encodes its shift state
261 /// in the glyph itself, but terminals disagree on how they report it:
262 ///
263 /// - legacy terminals: `Char('V')` with **no** modifier;
264 /// - many modern terminals: `Char('V')` **with** `SHIFT`;
265 /// - the kitty keyboard protocol: the base key `Char('v')` with `SHIFT`.
266 ///
267 /// All three mean the same keystroke. We canonicalise any `Char` that
268 /// still carries `SHIFT` to its uppercase form with the `SHIFT` bit
269 /// dropped, so a binding written `"V"` (parsed to `Char('V')`, no
270 /// modifier) matches every variant. Without this, the bound chord and
271 /// the runtime event compared unequal on SHIFT-reporting terminals and
272 /// every uppercase binding (`G`, `R`, `V`, `H`, …) silently did nothing
273 /// (PR #192).
274 ///
275 /// `BackTab` gets the same treatment for the same reason: it *is* the
276 /// shifted Tab, but terminals disagree on whether they additionally set
277 /// the `SHIFT` bit (some send bare `BackTab`, others `BackTab` + `SHIFT`,
278 /// kitty `BackTab` + `SHIFT`). We canonicalise to bare `BackTab` so a
279 /// binding written `"BackTab"` matches every variant. Without this the
280 /// modal `prev_field` / `prev_tab` defaults silently stopped firing on
281 /// SHIFT-reporting terminals once they routed through the keymap instead
282 /// of a modifier-blind `match KeyCode::BackTab` (issue #219 review).
283 fn normalize(code: KeyCode, modifiers: KeyModifiers) -> (KeyCode, KeyModifiers) {
284 match code {
285 KeyCode::Char(c) if modifiers.contains(KeyModifiers::SHIFT) => {
286 (KeyCode::Char(c.to_ascii_uppercase()), modifiers - KeyModifiers::SHIFT)
287 }
288 KeyCode::BackTab if modifiers.contains(KeyModifiers::SHIFT) => {
289 (KeyCode::BackTab, modifiers - KeyModifiers::SHIFT)
290 }
291 _ => (code, modifiers),
292 }
293 }
294
295 /// Parse a chord string (`"j"`, `"g g"`, `"Ctrl+x Ctrl+s"`) into
296 /// its sequence of keystrokes. Whitespace separates keystrokes;
297 /// `+` separates modifiers from the key. Use `"Space"` for the
298 /// literal space character.
299 pub fn parse_chord(s: &str) -> Result<Vec<KeyStroke>> {
300 let trimmed = s.trim();
301 if trimmed.is_empty() {
302 return Err(GwmError::Config(format!("keymap: empty key string {:?}", s)));
303 }
304 trimmed.split_whitespace().map(Self::parse_single).collect()
305 }
306
307 fn parse_single(token: &str) -> Result<KeyStroke> {
308 if token.is_empty() {
309 return Err(GwmError::Config("keymap: empty keystroke token".into()));
310 }
311 let parts: Vec<&str> = token.split('+').collect();
312 if parts.iter().any(|p| p.is_empty()) {
313 return Err(GwmError::Config(format!("keymap: dangling '+' in {:?}", token)));
314 }
315 let (key_str, mod_strs) = parts.split_last().expect("token is non-empty, split_last cannot fail");
316
317 let mut modifiers = KeyModifiers::empty();
318 for m in mod_strs {
319 let bit = match *m {
320 "Ctrl" => KeyModifiers::CONTROL,
321 "Alt" => KeyModifiers::ALT,
322 "Shift" => KeyModifiers::SHIFT,
323 other => {
324 return Err(GwmError::Config(format!(
325 "keymap: unknown modifier {:?} in {:?}",
326 other, token
327 )))
328 }
329 };
330 if modifiers.contains(bit) {
331 return Err(GwmError::Config(format!(
332 "keymap: duplicate modifier {:?} in {:?}",
333 m, token
334 )));
335 }
336 modifiers |= bit;
337 }
338
339 let code = parse_keycode(key_str, token)?;
340 // Route through `new` so a chord written `"Shift+v"` canonicalises
341 // to the same `Char('V')` (no SHIFT) as `"V"` — and matches whatever
342 // shift encoding the terminal delivers at runtime. See `normalize`.
343 Ok(KeyStroke::new(code, modifiers))
344 }
345}
346
347fn parse_keycode(s: &str, full_token: &str) -> Result<KeyCode> {
348 let code = match s {
349 "Tab" => KeyCode::Tab,
350 "Enter" => KeyCode::Enter,
351 "Esc" => KeyCode::Esc,
352 "Up" => KeyCode::Up,
353 "Down" => KeyCode::Down,
354 "Left" => KeyCode::Left,
355 "Right" => KeyCode::Right,
356 "Backspace" => KeyCode::Backspace,
357 "BackTab" => KeyCode::BackTab,
358 "Home" => KeyCode::Home,
359 "End" => KeyCode::End,
360 "PageUp" => KeyCode::PageUp,
361 "PageDown" => KeyCode::PageDown,
362 "Insert" => KeyCode::Insert,
363 "Delete" => KeyCode::Delete,
364 "Space" => KeyCode::Char(' '),
365 other if other.starts_with('F') && other.len() > 1 => {
366 let n: u8 = other[1..]
367 .parse()
368 .map_err(|_| GwmError::Config(format!("keymap: invalid function key {:?}", other)))?;
369 if !(1..=12).contains(&n) {
370 return Err(GwmError::Config(format!(
371 "keymap: function key out of range {:?} (expected F1..=F12)",
372 other
373 )));
374 }
375 KeyCode::F(n)
376 }
377 other => {
378 let mut chars = other.chars();
379 let (first, second) = (chars.next(), chars.next());
380 match (first, second) {
381 (Some(c), None) => KeyCode::Char(c),
382 _ => {
383 return Err(GwmError::Config(format!(
384 "keymap: unknown key {:?} in {:?}",
385 other, full_token
386 )))
387 }
388 }
389 }
390 };
391 Ok(code)
392}
393
394impl fmt::Display for KeyStroke {
395 /// Canonical rendering used by `gwm tui keys` and the help overlay.
396 /// Modifier order is always `Ctrl+Alt+Shift+<key>` so two bindings
397 /// that compare equal also render identically.
398 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
399 if self.modifiers.contains(KeyModifiers::CONTROL) {
400 write!(f, "Ctrl+")?;
401 }
402 if self.modifiers.contains(KeyModifiers::ALT) {
403 write!(f, "Alt+")?;
404 }
405 if self.modifiers.contains(KeyModifiers::SHIFT) {
406 write!(f, "Shift+")?;
407 }
408 match self.code {
409 KeyCode::Char(' ') => write!(f, "Space"),
410 KeyCode::Char(c) => write!(f, "{c}"),
411 KeyCode::Tab => write!(f, "Tab"),
412 KeyCode::Enter => write!(f, "Enter"),
413 KeyCode::Esc => write!(f, "Esc"),
414 KeyCode::Up => write!(f, "Up"),
415 KeyCode::Down => write!(f, "Down"),
416 KeyCode::Left => write!(f, "Left"),
417 KeyCode::Right => write!(f, "Right"),
418 KeyCode::Backspace => write!(f, "Backspace"),
419 KeyCode::BackTab => write!(f, "BackTab"),
420 KeyCode::Home => write!(f, "Home"),
421 KeyCode::End => write!(f, "End"),
422 KeyCode::PageUp => write!(f, "PageUp"),
423 KeyCode::PageDown => write!(f, "PageDown"),
424 KeyCode::Insert => write!(f, "Insert"),
425 KeyCode::Delete => write!(f, "Delete"),
426 KeyCode::F(n) => write!(f, "F{n}"),
427 other => write!(f, "{other:?}"),
428 }
429 }
430}
431
432fn format_chord(strokes: &[KeyStroke]) -> String {
433 strokes.iter().map(|s| s.to_string()).collect::<Vec<_>>().join(" ")
434}
435
436// ---------------------------------------------------------------------------
437// Keymap
438// ---------------------------------------------------------------------------
439
440/// Where a given binding came from. Surfaces in `gwm tui keys` as the
441/// third column so the user can audit what's hers vs. what shipped.
442#[derive(Debug, Clone, Copy, PartialEq, Eq)]
443pub enum Source {
444 Default,
445 UserConfig,
446}
447
448/// One entry in the resolved keymap: an action, the list of chords
449/// that fire it (any one match suffices), and the source layer the
450/// binding came from.
451#[derive(Debug, Clone)]
452pub struct Binding {
453 pub action: Action,
454 pub chords: Vec<Vec<KeyStroke>>,
455 pub source: Source,
456}
457
458/// Outcome of [`Keymap::lookup`] for a given pending-keys buffer.
459#[derive(Debug, PartialEq, Eq)]
460pub enum ChordResolution {
461 /// Buffer exactly matches a bound chord. Caller fires the action
462 /// and clears the buffer.
463 Matched(Action),
464 /// Buffer is the strict prefix of at least one bound chord.
465 /// Caller keeps the buffer armed and waits for the next stroke.
466 PendingPrefix,
467 /// Buffer matches no binding and is not the prefix of any.
468 /// Caller clears the buffer (and may retry the last stroke alone
469 /// — that policy lives in the event loop, not here).
470 NoMatch,
471}
472
473#[derive(Debug, Clone)]
474pub struct Keymap {
475 entries: Vec<Binding>,
476}
477
478impl Keymap {
479 /// Built-in defaults. Mirrors the historical hard-coded set in
480 /// `src/tui/mod.rs` before issue #87. Adding a default binding
481 /// here automatically surfaces it in `gwm tui keys` and the help
482 /// overlay.
483 pub fn defaults() -> Self {
484 let entries = vec![
485 def(Action::Down, &["j", "Down"]),
486 def(Action::Up, &["k", "Up"]),
487 def(Action::Top, &["g g"]),
488 def(Action::Bottom, &["G", "End"]),
489 // #290: V=toggle show/hide, S=cycle content (Commits↔Stashes),
490 // Space=cycle orientation (auto/side-by-side/stacked), v=toggle position.
491 def(Action::ToggleSidebar, &["V"]),
492 def(Action::ToggleSidebarMode, &["S"]),
493 def(Action::CycleSidebarLayout, &["Space"]),
494 def(Action::ToggleSidebarPosition, &["v"]),
495 def(Action::FocusSwap, &["Tab"]),
496 def(Action::FocusWorktrees, &["1"]),
497 def(Action::FocusStatus, &["2"]),
498 def(Action::CommandLogs, &["3"]),
499 def(Action::ConfigPanel, &["4"]),
500 // #325: `x` opens the exec profile picker overlay.
501 def(Action::ExecOverlay, &["x"]),
502 // #325: `X` opens the clean reclaim overlay.
503 def(Action::CleanOverlay, &["X"]),
504 def(Action::Filter, &["/"]),
505 def(Action::Refresh, &["f"]),
506 // #290: `s` (lowercase) is now Sync — replaces ToggleSidebarMode.
507 def(Action::Sync, &["s"]),
508 def(Action::Create, &["n"]),
509 def(Action::DeleteConfirm, &["d"]),
510 def(Action::Bootstrap, &["b"]),
511 // #290: `D` (uppercase) is now ToggleDeleteBranch — `p` repurposed as Pull.
512 def(Action::ToggleDeleteBranch, &["D"]),
513 // #290: `p` is now Pull (was ToggleDeleteBranch before).
514 def(Action::Pull, &["p"]),
515 // #290: `P` is Push.
516 def(Action::Push, &["P"]),
517 // #290: `c` opens the edit-worktree modal (rename branch).
518 def(Action::EditWorktree, &["c"]),
519 // #290: `e` exits the TUI and prints the selected worktree path to stdout.
520 def(Action::ExitToWorktree, &["e"]),
521 // #35/#290: `l` opens lazygit in an embedded PTY overlay.
522 def(Action::LazyGitPty, &["l"]),
523 // #290: `L` opens lazygit fullscreen (was unbound before #290).
524 def(Action::LazyGitFullscreen, &["L"]),
525 // #290: `R` opens the review launcher fullscreen (renamed from review).
526 def(Action::ReviewFullscreen, &["R"]),
527 // #35/#290: `r` opens the review launcher in an embedded PTY overlay.
528 def(Action::ReviewPty, &["r"]),
529 // #290: `Y` yanks the worktree path (was `y` before #290).
530 def(Action::YankPath, &["Y"]),
531 // #290: `y` yanks the branch name (was yank-path `y` before #290).
532 def(Action::YankBranchName, &["y"]),
533 // #290: `w` yanks the worktree slug/name.
534 def(Action::YankWorktreeName, &["w"]),
535 // #35/#290: `o` opens a native terminal PTY overlay (renamed from open_terminal_overlay).
536 def(Action::TerminalPty, &["o"]),
537 // #290: `O` opens a native terminal fullscreen (was unbound before #290).
538 def(Action::TerminalFullscreen, &["O"]),
539 // #290: `B` opens the browse-links menu (was `O` for open_menu before #290).
540 def(Action::BrowseLinks, &["B"]),
541 def(Action::OpenDocs, &["."]),
542 // #290: `i` links the selected worktree to an issue/PR (was `L` before #290).
543 def(Action::LinkPrompt, &["i"]),
544 def(Action::FetchGithub, &["F"]),
545 // #290: `t` opens the selected worktree in a new multiplexer pane/tab.
546 def(Action::MuxPane, &["t"]),
547 // #290: `h`/`H` fire user-configured macro1/macro2.
548 def(Action::Macro1, &["h"]),
549 def(Action::Macro2, &["H"]),
550 def(Action::Help, &["?"]),
551 def(Action::Quit, &["q"]),
552 def(Action::CommandPalette, &[":"]),
553 ];
554 Self { entries }
555 }
556
557 /// Replace the chords bound to `action` with `chords` and re-validate
558 /// the full keymap. An empty `Vec` unbinds the action. The validation
559 /// pass rejects:
560 ///
561 /// - the same chord wired to two different actions (conflict);
562 /// - a chord that is a strict prefix of another (`g` while `g g`
563 /// is bound) — see the module-level chord/prefix policy note.
564 ///
565 /// Returns `Err(GwmError::Config(_))` on failure; the keymap is
566 /// left untouched on error so callers can surface the message and
567 /// move on.
568 pub fn apply_override(&mut self, action: Action, chords: Vec<Vec<KeyStroke>>) -> Result<()> {
569 // Build the candidate list. For default bindings on other actions, silently
570 // vacate any chord that the new user override is claiming — user intent is
571 // explicit and wins over shipped defaults. User-vs-user conflicts still
572 // fail validation below.
573 let new_chord_set: std::collections::HashSet<&[KeyStroke]> = chords.iter().map(|c| c.as_slice()).collect();
574 let mut candidate: Vec<(Action, Vec<Vec<KeyStroke>>)> = self
575 .entries
576 .iter()
577 .map(|b| {
578 if b.action == action {
579 (b.action, chords.clone())
580 } else if b.source == Source::Default {
581 let pruned: Vec<Vec<KeyStroke>> = b
582 .chords
583 .iter()
584 .filter(|c| !new_chord_set.contains(c.as_slice()))
585 .cloned()
586 .collect();
587 (b.action, pruned)
588 } else {
589 (b.action, b.chords.clone())
590 }
591 })
592 .collect();
593 if !candidate.iter().any(|(a, _)| *a == action) {
594 candidate.push((action, chords.clone()));
595 }
596 Self::validate(&candidate)?;
597
598 // Commit: vacate the claimed chords from default bindings on other actions,
599 // then update (or insert) the overridden action's binding.
600 for entry in self.entries.iter_mut() {
601 if entry.action != action && entry.source == Source::Default {
602 entry.chords.retain(|c| !new_chord_set.contains(c.as_slice()));
603 }
604 }
605
606 let mut replaced = false;
607 for entry in self.entries.iter_mut() {
608 if entry.action == action {
609 entry.chords = chords.clone();
610 entry.source = Source::UserConfig;
611 replaced = true;
612 break;
613 }
614 }
615 if !replaced {
616 self.entries.push(Binding {
617 action,
618 chords,
619 source: Source::UserConfig,
620 });
621 }
622 Ok(())
623 }
624
625 fn validate(entries: &[(Action, Vec<Vec<KeyStroke>>)]) -> Result<()> {
626 let mut all: Vec<(&[KeyStroke], Action)> = Vec::new();
627 for (action, chords) in entries {
628 for chord in chords {
629 if chord.is_empty() {
630 return Err(GwmError::Config(format!(
631 "keymap: empty chord bound to {:?}",
632 action.slug()
633 )));
634 }
635 all.push((chord.as_slice(), *action));
636 }
637 }
638 for i in 0..all.len() {
639 for j in (i + 1)..all.len() {
640 if all[i].0 == all[j].0 {
641 if all[i].1 != all[j].1 {
642 return Err(GwmError::Config(format!(
643 "keymap: chord {:?} bound to both {:?} and {:?} — conflict",
644 format_chord(all[i].0),
645 all[i].1.slug(),
646 all[j].1.slug()
647 )));
648 }
649 continue;
650 }
651 let (short, long) = if all[i].0.len() < all[j].0.len() {
652 (i, j)
653 } else {
654 (j, i)
655 };
656 if all[short].0.len() < all[long].0.len() && all[long].0.starts_with(all[short].0) {
657 return Err(GwmError::Config(format!(
658 "keymap: chord {:?} (action {:?}) is a prefix of {:?} (action {:?}) — refused at load time so the event loop never has to time out",
659 format_chord(all[short].0),
660 all[short].1.slug(),
661 format_chord(all[long].0),
662 all[long].1.slug()
663 )));
664 }
665 }
666 }
667 Ok(())
668 }
669
670 /// Resolve a pending-keys buffer against the keymap.
671 pub fn lookup(&self, keys: &[KeyStroke]) -> ChordResolution {
672 let mut pending = false;
673 for entry in &self.entries {
674 for chord in &entry.chords {
675 if chord.as_slice() == keys {
676 return ChordResolution::Matched(entry.action);
677 }
678 if chord.len() > keys.len() && chord.starts_with(keys) {
679 pending = true;
680 }
681 }
682 }
683 if pending {
684 ChordResolution::PendingPrefix
685 } else {
686 ChordResolution::NoMatch
687 }
688 }
689
690 /// Snapshot the resolved keymap for `gwm tui keys` / help overlay.
691 /// Order matches the declarative `define_actions!` invocation so the
692 /// rendered table stays stable across runs.
693 pub fn list(&self) -> Vec<Binding> {
694 self.entries.clone()
695 }
696
697 /// The canonical rendering of the **first** chord bound to `action`,
698 /// or `None` when the action is unbound. Used by UI copy that names a
699 /// key inline (e.g. pane titles such as `Issue / PR [F]`,
700 /// issue #224) so the hint tracks user overrides under `[tui.keys]`
701 /// instead of hard-coding a default that may have been rebound. A
702 /// multi-chord action returns its first chord in declaration order,
703 /// matching what `gwm tui keys` lists first.
704 pub fn primary_chord(&self, action: Action) -> Option<String> {
705 self
706 .entries
707 .iter()
708 .find(|b| b.action == action)
709 .and_then(|b| b.chords.first())
710 .map(|chord| format_chord(chord))
711 }
712
713 /// Every chord bound to `action`, comma-joined (`"j, Down"`) or empty when
714 /// unbound — the help-overlay / Keys-tab row form. Mirrors
715 /// [`crate::tui::modal_keymap::ModalKeymap::keys_display`].
716 pub fn keys_display(&self, action: Action) -> String {
717 self
718 .entries
719 .iter()
720 .find(|b| b.action == action)
721 .map(|b| b.chords.iter().map(|c| format_chord(c)).collect::<Vec<_>>().join(", "))
722 .unwrap_or_default()
723 }
724}
725
726/// Build a default `Binding` from a list of chord literals. Panics
727/// if a literal does not parse — that is a programmer error in the
728/// defaults table, never user input.
729fn def(action: Action, chord_literals: &[&str]) -> Binding {
730 let chords = chord_literals
731 .iter()
732 .map(|s| {
733 KeyStroke::parse_chord(s).unwrap_or_else(|e| panic!("default keymap chord {:?} failed to parse: {}", s, e))
734 })
735 .collect();
736 Binding {
737 action,
738 chords,
739 source: Source::Default,
740 }
741}