Skip to main content

gwm/tui/
modal_keymap.rs

1//! Contextual modal / overlay keymap (issue #219).
2//!
3//! The global keymap in [`crate::tui::keymap`] resolves the `View::List`
4//! verbs (`j`, `g g`, `o`, `R`, …) and, deliberately, *only* those: its
5//! module note explains why `Esc` / `Enter` were kept hard-coded — their
6//! meaning depends on the active view, and a single global table cannot
7//! express "Enter submits in the create modal but activates the focused
8//! button in the confirm modal".
9//!
10//! This module lifts that limitation for the modals/overlays. Every modal
11//! is a [`KeyContext`]; each context owns a small set of typed verbs
12//! ([`ModalAction`]); the same physical key can map to different verbs in
13//! different contexts without any global conflict, because resolution is
14//! always scoped to the **active** context.
15//!
16//! ## Single-stroke only
17//!
18//! Unlike the global keymap, modal bindings are **single keystrokes** —
19//! no chords, no prefixes, no pending buffer. Modals are short-lived and
20//! the project refuses a Vim-style runtime timeout (see the global
21//! keymap's chord/prefix note). A user override that supplies a
22//! multi-stroke chord (`"g g"`) is rejected at load time with a precise
23//! message rather than silently never firing.
24//!
25//! ## What stays hard-coded
26//!
27//! - `Ctrl+C` — the emergency quit in `run_app`, ahead of every lookup.
28//! - `View::List`'s `Esc` / `Enter` — still contextual on filter / picker
29//!   / sticky-filter state, which the config language cannot express.
30//! - The PTY overlay's `Esc` — it is an *emergency* detach from a child
31//!   that otherwise receives every keystroke; making it rebindable would
32//!   silently steal a key from lazygit / the shell. Documented in the
33//!   `View::Pty` branch.
34//!
35//! ## Config surface
36//!
37//! Bindings live under `[tui.keys.modal.<context-path>]` in `.gwm.toml`,
38//! nested below a dedicated `modal` namespace inside the global `[tui.keys]`
39//! table. The separate namespace keeps a modal context from colliding with a
40//! same-named global action (`create` / `help` / `command_logs` / `link` are
41//! both) at the `tui.keys.<name>` path — a collision the layered merge would
42//! otherwise resolve by silently dropping the global override (issue #219
43//! review):
44//!
45//! ```toml
46//! [tui.keys]                  # global verbs — arrays, unchanged
47//! quit   = ["q"]
48//! create = ["c"]              # global action; coexists with the modal below
49//!
50//! [tui.keys.modal.confirm]    # contextual verbs — single strokes
51//! confirm = ["y"]
52//! cancel  = ["n", "Esc"]
53//!
54//! [tui.keys.modal.link.choose_target]
55//! issue = ["i"]
56//! pr    = ["p"]
57//! ```
58//!
59//! The walker that turns that TOML into a [`ModalKeymap`] lives in
60//! [`crate::config`]; this module owns the typed model, the defaults, the
61//! per-context conflict validation, and the single-stroke resolver.
62
63use crate::error::{GwmError, Result};
64use crate::tui::keymap::{KeyStroke, Source};
65use crossterm::event::{KeyCode, KeyModifiers};
66use std::collections::HashMap;
67
68// ---------------------------------------------------------------------------
69// KeyContext
70// ---------------------------------------------------------------------------
71
72/// One modal / overlay surface whose keys are independently rebindable.
73///
74/// `config_path` is the dotted key under `[tui.keys.modal]` that addresses
75/// the context's sub-table (`confirm`, `link.choose_target`, `config.edit`).
76#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, PartialOrd, Ord)]
77pub enum KeyContext {
78  /// Create-worktree modal (also reused by the rename / `View::Edit` modal).
79  Create,
80  /// Delete-confirmation modal.
81  Confirm,
82  /// Keybindings / help overlay (scroll-only).
83  Help,
84  /// Command-logs overlay (issue #226, scroll-only + copy).
85  CommandLogs,
86  /// Settings panel navigation (issue #232).
87  Config,
88  /// Settings panel while a numeric field is being edited (sub-mode of
89  /// [`KeyContext::Config`]; a separate context because `Enter` means
90  /// *commit* here but *activate* in nav).
91  ConfigEdit,
92  /// Bootstrap-report overlay (scroll-only / close).
93  Report,
94  /// Browse-links menu (issue #224 / #290).
95  OpenMenu,
96  /// Command palette overlay (issue #32).
97  CommandPalette,
98  /// Link prompt, stage 1 — choose issue vs PR.
99  LinkChooseTarget,
100  /// Link prompt, stage 2 — type the issue / PR number.
101  LinkInputNumber,
102  /// Exec profile picker overlay (issue #325).
103  ExecPicker,
104  /// Clean reclaim overlay (issue #325).
105  Clean,
106}
107
108impl KeyContext {
109  /// Dotted key under `[tui.keys.modal]` addressing this context's sub-table.
110  pub fn config_path(self) -> &'static str {
111    match self {
112      KeyContext::Create => "create",
113      KeyContext::Confirm => "confirm",
114      KeyContext::Help => "help",
115      KeyContext::CommandLogs => "command_logs",
116      KeyContext::Config => "config",
117      KeyContext::ConfigEdit => "config.edit",
118      KeyContext::Report => "report",
119      KeyContext::OpenMenu => "open_menu",
120      KeyContext::CommandPalette => "palette",
121      KeyContext::LinkChooseTarget => "link.choose_target",
122      KeyContext::LinkInputNumber => "link.input_number",
123      KeyContext::ExecPicker => "exec",
124      KeyContext::Clean => "clean",
125    }
126  }
127
128  /// Inverse of [`Self::config_path`] — used by the config walker to map a
129  /// `[tui.keys.modal.<path>]` sub-table back to a typed context.
130  pub fn from_config_path(path: &str) -> Option<Self> {
131    Self::all().iter().copied().find(|c| c.config_path() == path)
132  }
133
134  /// Every context, in declaration order (the order `gwm tui keys` lists).
135  pub fn all() -> &'static [KeyContext] {
136    use KeyContext::*;
137    &[
138      Create,
139      Confirm,
140      Help,
141      CommandLogs,
142      Config,
143      ConfigEdit,
144      Report,
145      OpenMenu,
146      CommandPalette,
147      LinkChooseTarget,
148      LinkInputNumber,
149      ExecPicker,
150      Clean,
151    ]
152  }
153}
154
155// ---------------------------------------------------------------------------
156// ModalAction + defaults table
157// ---------------------------------------------------------------------------
158
159/// Declarative definition of every modal verb, grouped by context, with its
160/// local verb slug and built-in default keystrokes. One ordered list keeps
161/// the enum, the `context`/`verb`/`default` accessors, and `all()` in sync.
162macro_rules! define_modal_actions {
163  ( $( $ctx:ident { $( $variant:ident => $verb:literal [ $( $chord:literal ),* $(,)? ] ),* $(,)? } )* ) => {
164    /// A context-qualified modal verb. Variant names are
165    /// `<Context><Verb>` so the flat enum stays unambiguous; the
166    /// `(context, verb)` pair is what the config surface addresses.
167    #[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
168    pub enum ModalAction {
169      $( $( $variant, )* )*
170    }
171
172    impl ModalAction {
173      /// The context this verb belongs to.
174      pub fn context(self) -> KeyContext {
175        match self { $( $( ModalAction::$variant => KeyContext::$ctx, )* )* }
176      }
177
178      /// The context-local verb slug used under `[tui.keys.modal.<context>]`.
179      pub fn verb(self) -> &'static str {
180        match self { $( $( ModalAction::$variant => $verb, )* )* }
181      }
182
183      /// Built-in default keystroke literals (each a single stroke).
184      fn default_chord_strs(self) -> &'static [&'static str] {
185        match self { $( $( ModalAction::$variant => &[ $( $chord, )* ], )* )* }
186      }
187
188      /// Every verb, in declaration order.
189      pub fn all() -> impl Iterator<Item = Self> {
190        [ $( $( ModalAction::$variant, )* )* ].into_iter()
191      }
192    }
193  };
194}
195
196define_modal_actions! {
197  Create {
198    CreateCancel    => "cancel"     [ "Esc" ],
199    CreateNextField => "next_field" [ "Tab" ],
200    CreatePrevField => "prev_field" [ "BackTab" ],
201    CreateSubmit    => "submit"     [ "Enter" ],
202    CreatePrevType  => "prev_type"  [ "Up", "Left", "h" ],
203    CreateNextType  => "next_type"  [ "Down", "Right", "l" ],
204  }
205  Confirm {
206    ConfirmConfirm      => "confirm"       [ "y" ],
207    ConfirmActivate     => "activate"      [ "Enter" ],
208    ConfirmCancel       => "cancel"        [ "n", "Esc" ],
209    ConfirmFocusConfirm => "focus_confirm" [ "Left", "h" ],
210    ConfirmFocusCancel  => "focus_cancel"  [ "Right", "l" ],
211    ConfirmToggleFocus  => "toggle_focus"  [ "Tab" ],
212  }
213  Help {
214    HelpClose        => "close"         [ "Esc", "q", "?" ],
215    HelpScrollDown   => "scroll_down"   [ "Down", "j" ],
216    HelpScrollUp     => "scroll_up"     [ "Up", "k" ],
217    HelpScrollRight  => "scroll_right"  [ "Right", "l" ],
218    HelpScrollLeft   => "scroll_left"   [ "Left", "h" ],
219    HelpScrollTop    => "scroll_top"    [ "Home", "g" ],
220    HelpScrollBottom => "scroll_bottom" [ "End", "G" ],
221  }
222  CommandLogs {
223    CommandLogsClose        => "close"         [ "Esc", "q" ],
224    CommandLogsCopy         => "copy"          [ "y" ],
225    CommandLogsScrollDown   => "scroll_down"   [ "Down", "j" ],
226    CommandLogsScrollUp     => "scroll_up"     [ "Up", "k" ],
227    CommandLogsScrollRight  => "scroll_right"  [ "Right", "l" ],
228    CommandLogsScrollLeft   => "scroll_left"   [ "Left", "h" ],
229    CommandLogsScrollTop    => "scroll_top"    [ "Home", "g" ],
230    CommandLogsScrollBottom => "scroll_bottom" [ "End", "G" ],
231  }
232  Config {
233    ConfigClose        => "close"         [ "Esc", "q" ],
234    ConfigNextTab      => "next_tab"      [ "Tab" ],
235    ConfigPrevTab      => "prev_tab"      [ "BackTab" ],
236    ConfigToggleLayer  => "toggle_layer"  [ "L" ],
237    ConfigActivate     => "activate"      [ "Space", "Enter" ],
238    ConfigSelectNext   => "select_next"   [ "Down", "j" ],
239    ConfigSelectPrev   => "select_prev"   [ "Up", "k" ],
240    ConfigScrollRight  => "scroll_right"  [ "Right", "l" ],
241    ConfigScrollLeft   => "scroll_left"   [ "Left", "h" ],
242    ConfigScrollTop    => "scroll_top"    [ "Home", "g" ],
243    ConfigScrollBottom => "scroll_bottom" [ "End", "G" ],
244  }
245  ConfigEdit {
246    ConfigEditSubmit => "submit" [ "Enter" ],
247    ConfigEditCancel => "cancel" [ "Esc" ],
248  }
249  Report {
250    ReportClose => "close" [ "Esc", "q", "Enter" ],
251  }
252  OpenMenu {
253    OpenMenuClose  => "close"  [ "Esc", "q" ],
254    OpenMenuToggle => "toggle" [ "j", "k", "Down", "Up" ],
255    OpenMenuAccept => "accept" [ "Enter" ],
256    OpenMenuIssue  => "issue"  [ "i" ],
257    OpenMenuPr     => "pr"     [ "p" ],
258  }
259  CommandPalette {
260    CommandPaletteClose  => "close"  [ "Esc" ],
261    CommandPaletteAccept => "accept" [ "Enter" ],
262    CommandPalettePrev   => "prev"   [ "Up" ],
263    CommandPaletteNext   => "next"   [ "Down", "Tab" ],
264  }
265  LinkChooseTarget {
266    LinkChooseNext   => "next"   [ "j", "Down" ],
267    LinkChoosePrev   => "prev"   [ "k", "Up" ],
268    LinkChooseIssue  => "issue"  [ "i" ],
269    LinkChoosePr     => "pr"     [ "p" ],
270    LinkChooseAccept => "accept" [ "Enter" ],
271    LinkChooseCancel => "cancel" [ "Esc" ],
272  }
273  LinkInputNumber {
274    LinkInputSubmit => "submit" [ "Enter" ],
275    LinkInputCancel => "cancel" [ "Esc" ],
276  }
277  ExecPicker {
278    ExecPickerNext   => "next"   [ "j", "Down" ],
279    ExecPickerPrev   => "prev"   [ "k", "Up" ],
280    ExecPickerAccept => "accept" [ "Enter" ],
281    ExecPickerCancel => "cancel" [ "Esc" ],
282  }
283  Clean {
284    CleanNext    => "next"    [ "j", "Down" ],
285    CleanPrev    => "prev"    [ "k", "Up" ],
286    CleanConfirm => "confirm" [ "y", "Enter" ],
287    CleanCancel  => "cancel"  [ "n", "Esc" ],
288  }
289}
290
291impl ModalAction {
292  /// Resolve a `(context, verb-slug)` pair to a typed verb. Used by the
293  /// config walker to translate `[tui.keys.modal.<context>].<verb>` keys.
294  pub fn from_context_verb(ctx: KeyContext, verb: &str) -> Option<Self> {
295    Self::all().find(|a| a.context() == ctx && a.verb() == verb)
296  }
297
298  /// Default keystrokes for this verb, parsed. Panics on a malformed
299  /// literal — that is a programmer error in the table above, never user
300  /// input (same contract as the global keymap's `def`).
301  fn default_keys(self) -> Vec<KeyStroke> {
302    self
303      .default_chord_strs()
304      .iter()
305      .map(|s| {
306        parse_single(s)
307          .unwrap_or_else(|e| panic!("default modal binding {:?} for {:?} failed to parse: {}", s, self, e))
308      })
309      .collect()
310  }
311}
312
313/// Parse a binding string that must resolve to exactly one keystroke.
314/// Modal bindings have no chord machinery, so a multi-stroke string is a
315/// hard error (returned to the user verbatim by the config walker).
316pub fn parse_single(s: &str) -> Result<KeyStroke> {
317  let strokes = KeyStroke::parse_chord(s)?;
318  let stroke = match strokes.into_iter().collect::<Vec<_>>().as_slice() {
319    [one] => one.clone(),
320    _ => {
321      return Err(GwmError::Config(format!(
322        "modal bindings must be a single keystroke, got chord {:?} (modals have no chord timeout)",
323        s
324      )))
325    }
326  };
327  // Ctrl+C is the emergency quit handled in `run_app` ahead of every lookup,
328  // so a modal binding to it would never fire — reject it rather than let
329  // `gwm tui keys` / footer hints advertise an unreachable action (#219 review).
330  if stroke.modifiers.contains(KeyModifiers::CONTROL)
331    && matches!(stroke.code, KeyCode::Char(c) if c.eq_ignore_ascii_case(&'c'))
332  {
333    return Err(GwmError::Config(format!(
334      "modal bindings cannot use {:?}: Ctrl+C is the reserved emergency quit (handled before any modal lookup)",
335      s
336    )));
337  }
338  Ok(stroke)
339}
340
341// ---------------------------------------------------------------------------
342// ModalKeymap
343// ---------------------------------------------------------------------------
344
345/// One resolved binding: a verb, the single-strokes that fire it (any one
346/// match suffices), and the layer it came from.
347#[derive(Debug, Clone)]
348pub struct ModalBinding {
349  pub action: ModalAction,
350  pub keys: Vec<KeyStroke>,
351  pub source: Source,
352}
353
354/// The resolved contextual keymap: every modal verb with its keys. Built
355/// from [`ModalKeymap::defaults`] then layered with user overrides via
356/// [`ModalKeymap::apply_override`].
357#[derive(Debug, Clone)]
358pub struct ModalKeymap {
359  entries: Vec<ModalBinding>,
360}
361
362impl ModalKeymap {
363  /// Built-in defaults — mirror the historical hard-coded modal routing in
364  /// `src/tui/mod.rs` before issue #219.
365  pub fn defaults() -> Self {
366    let entries = ModalAction::all()
367      .map(|action| ModalBinding {
368        action,
369        keys: action.default_keys(),
370        source: Source::Default,
371      })
372      .collect();
373    Self { entries }
374  }
375
376  /// Replace the keys bound to `action` with `keys` and re-validate the
377  /// action's **context**. An empty `Vec` unbinds the verb.
378  ///
379  /// Validation rejects the same stroke wired to two different verbs *in
380  /// the same context* (cross-context reuse is fine — that is the whole
381  /// point). A default binding in the same context silently vacates any
382  /// stroke the override claims, mirroring the global keymap: explicit
383  /// user intent wins over a shipped default.
384  pub fn apply_override(&mut self, action: ModalAction, keys: Vec<KeyStroke>) -> Result<()> {
385    let ctx = action.context();
386    let claimed: Vec<&KeyStroke> = keys.iter().collect();
387
388    // Build the post-override key→action map for this context (excluding the
389    // action being replaced), vacating claimed strokes from defaults.
390    let mut map: HashMap<KeyStroke, ModalAction> = HashMap::new();
391    for b in &self.entries {
392      if b.action.context() != ctx || b.action == action {
393        continue;
394      }
395      for k in &b.keys {
396        if b.source == Source::Default && claimed.contains(&k) {
397          continue;
398        }
399        map.insert(k.clone(), b.action);
400      }
401    }
402    for k in &keys {
403      if let Some(prev) = map.get(k) {
404        return Err(GwmError::Config(format!(
405          "context {}: key {} bound to both {:?} and {:?} — conflict",
406          ctx.config_path(),
407          k,
408          prev.verb(),
409          action.verb()
410        )));
411      }
412    }
413
414    // Commit: vacate claimed strokes from same-context defaults, then
415    // replace the target verb's binding.
416    let claimed_owned: Vec<KeyStroke> = keys.clone();
417    for entry in self.entries.iter_mut() {
418      if entry.action.context() == ctx && entry.action != action && entry.source == Source::Default {
419        entry.keys.retain(|k| !claimed_owned.contains(k));
420      }
421    }
422    if let Some(entry) = self.entries.iter_mut().find(|b| b.action == action) {
423      entry.keys = keys;
424      entry.source = Source::UserConfig;
425    } else {
426      self.entries.push(ModalBinding {
427        action,
428        keys,
429        source: Source::UserConfig,
430      });
431    }
432    Ok(())
433  }
434
435  /// Resolve a single keystroke against the bindings of `ctx`. Returns the
436  /// matched verb, or `None` when nothing in this context binds the stroke
437  /// (the caller then applies the context's text-input / default fallback).
438  pub fn resolve(&self, ctx: KeyContext, stroke: &KeyStroke) -> Option<ModalAction> {
439    self
440      .entries
441      .iter()
442      .filter(|b| b.action.context() == ctx)
443      .find(|b| b.keys.iter().any(|k| k == stroke))
444      .map(|b| b.action)
445  }
446
447  /// Every binding whose verb lives in `ctx`, in declaration order.
448  pub fn bindings_for(&self, ctx: KeyContext) -> Vec<&ModalBinding> {
449    self.entries.iter().filter(|b| b.action.context() == ctx).collect()
450  }
451
452  /// Snapshot of every binding, declaration order, for `gwm tui keys` /
453  /// the help overlay / `gwm doctor`.
454  pub fn list(&self) -> &[ModalBinding] {
455    &self.entries
456  }
457
458  /// The first key bound to `action`, rendered for an inline hint (the
459  /// statusbar chip / help-overlay footer). `None` when the verb is
460  /// unbound — the caller drops it rather than advertise a phantom key.
461  /// Mirrors [`crate::tui::keymap::Keymap::primary_chord`].
462  pub fn primary_key(&self, action: ModalAction) -> Option<String> {
463    self
464      .entries
465      .iter()
466      .find(|b| b.action == action)
467      .and_then(|b| b.keys.first())
468      .map(|k| k.to_string())
469  }
470
471  /// Every key bound to `action`, comma-joined (`"n, Esc"`) or empty when
472  /// unbound — the help-overlay row form, matching the global keymap's
473  /// `keys_for` rendering.
474  pub fn keys_display(&self, action: ModalAction) -> String {
475    self
476      .entries
477      .iter()
478      .find(|b| b.action == action)
479      .map(|b| b.keys.iter().map(|k| k.to_string()).collect::<Vec<_>>().join(", "))
480      .unwrap_or_default()
481  }
482}
483
484impl Default for ModalKeymap {
485  fn default() -> Self {
486    Self::defaults()
487  }
488}