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 /// Generic detail overlay (issue #408, scroll-only / close) — agent
85 /// sessions today, the rich PR/Issue view tomorrow.
86 Detail,
87 /// Command-logs overlay (issue #226, scroll-only + copy).
88 CommandLogs,
89 /// Settings panel navigation (issue #232).
90 Config,
91 /// Settings panel while a numeric field is being edited (sub-mode of
92 /// [`KeyContext::Config`]; a separate context because `Enter` means
93 /// *commit* here but *activate* in nav).
94 ConfigEdit,
95 /// Bootstrap-report overlay (scroll-only / close).
96 Report,
97 /// Browse-links menu (issue #224 / #290).
98 OpenMenu,
99 /// Command palette overlay (issue #32).
100 CommandPalette,
101 /// Link prompt, stage 1 — choose issue vs PR.
102 LinkChooseTarget,
103 /// Link prompt, stage 2 — type the issue / PR number.
104 LinkInputNumber,
105 /// Exec profile picker overlay (issue #325).
106 ExecPicker,
107 /// Clean reclaim overlay (issue #325).
108 Clean,
109 /// CI checks overlay (issue #436) — the detail-overlay shell opened on
110 /// the linked PR's per-check rollup list.
111 CiChecks,
112}
113
114impl KeyContext {
115 /// Strokes an ALWAYS-typing context reserves for its input (Codex
116 /// review #456): the dispatch routes them into the query / number /
117 /// value before the modal resolution, so a verb bound to one would be
118 /// unreachable — and `close = ["x"]` would leave the overlay with no
119 /// exit at all. [`ModalKeymap::apply_override`] refuses such bindings
120 /// up front. ConfigEdit qualifies too (iteration 13): the context only
121 /// exists while a value edit is live, and a text field consumes every
122 /// unmodified printable (uppercase included) plus Backspace — its two
123 /// verbs are the edit's only exits. Create is exempt at the context
124 /// level (its type-cycling verbs live on the Type field, which takes
125 /// no text input) — the per-verb exception is
126 /// [`ModalAction::reserved_typing_stroke`]. Mirrors the dispatch
127 /// routes (`App::palette_input_key`, the link number stage,
128 /// `App::settings_edit_input_key`).
129 pub fn reserved_typing_stroke(self, stroke: &KeyStroke) -> bool {
130 use crossterm::event::{KeyCode as KC, KeyModifiers as KM};
131 if stroke.modifiers.intersects(KM::CONTROL | KM::ALT) {
132 return false;
133 }
134 match (self, stroke.code) {
135 (KeyContext::CommandPalette | KeyContext::LinkInputNumber | KeyContext::ConfigEdit, KC::Backspace) => true,
136 // A shifted letter is an uppercase (kitty-style) — not palette
137 // input, so it stays bindable.
138 (KeyContext::CommandPalette, KC::Char(c)) if stroke.modifiers.contains(KM::SHIFT) => c.is_ascii_digit(),
139 (KeyContext::CommandPalette, KC::Char(c)) => c.is_ascii_lowercase() || c.is_ascii_digit() || c == '_' || c == '-',
140 (KeyContext::LinkInputNumber, KC::Char(c)) => c.is_ascii_digit(),
141 // A text field takes uppercase input, so unlike the palette a
142 // shifted letter IS typing here.
143 (KeyContext::ConfigEdit, KC::Char(_)) => true,
144 _ => false,
145 }
146 }
147
148 /// Dotted key under `[tui.keys.modal]` addressing this context's sub-table.
149 pub fn config_path(self) -> &'static str {
150 match self {
151 KeyContext::Create => "create",
152 KeyContext::Confirm => "confirm",
153 KeyContext::Help => "help",
154 KeyContext::Detail => "detail",
155 KeyContext::CommandLogs => "command_logs",
156 KeyContext::Config => "config",
157 KeyContext::ConfigEdit => "config.edit",
158 KeyContext::Report => "report",
159 KeyContext::OpenMenu => "open_menu",
160 KeyContext::CommandPalette => "palette",
161 KeyContext::LinkChooseTarget => "link.choose_target",
162 KeyContext::LinkInputNumber => "link.input_number",
163 KeyContext::ExecPicker => "exec",
164 KeyContext::Clean => "clean",
165 KeyContext::CiChecks => "ci_checks",
166 }
167 }
168
169 /// Inverse of [`Self::config_path`] — used by the config walker to map a
170 /// `[tui.keys.modal.<path>]` sub-table back to a typed context.
171 pub fn from_config_path(path: &str) -> Option<Self> {
172 Self::all().iter().copied().find(|c| c.config_path() == path)
173 }
174
175 /// Every context, in declaration order (the order `gwm tui keys` lists).
176 pub fn all() -> &'static [KeyContext] {
177 use KeyContext::*;
178 &[
179 Create,
180 Confirm,
181 Help,
182 Detail,
183 CommandLogs,
184 Config,
185 ConfigEdit,
186 Report,
187 OpenMenu,
188 CommandPalette,
189 LinkChooseTarget,
190 LinkInputNumber,
191 ExecPicker,
192 Clean,
193 CiChecks,
194 ]
195 }
196}
197
198// ---------------------------------------------------------------------------
199// ModalAction + defaults table
200// ---------------------------------------------------------------------------
201
202/// Declarative definition of every modal verb, grouped by context, with its
203/// local verb slug and built-in default keystrokes. One ordered list keeps
204/// the enum, the `context`/`verb`/`default` accessors, and `all()` in sync.
205macro_rules! define_modal_actions {
206 ( $( $ctx:ident { $( $variant:ident => $verb:literal [ $( $chord:literal ),* $(,)? ] ),* $(,)? } )* ) => {
207 /// A context-qualified modal verb. Variant names are
208 /// `<Context><Verb>` so the flat enum stays unambiguous; the
209 /// `(context, verb)` pair is what the config surface addresses.
210 #[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
211 pub enum ModalAction {
212 $( $( $variant, )* )*
213 }
214
215 impl ModalAction {
216 /// The context this verb belongs to.
217 pub fn context(self) -> KeyContext {
218 match self { $( $( ModalAction::$variant => KeyContext::$ctx, )* )* }
219 }
220
221 /// The context-local verb slug used under `[tui.keys.modal.<context>]`.
222 pub fn verb(self) -> &'static str {
223 match self { $( $( ModalAction::$variant => $verb, )* )* }
224 }
225
226 /// Built-in default keystroke literals (each a single stroke).
227 fn default_chord_strs(self) -> &'static [&'static str] {
228 match self { $( $( ModalAction::$variant => &[ $( $chord, )* ], )* )* }
229 }
230
231 /// Every verb, in declaration order.
232 pub fn all() -> impl Iterator<Item = Self> {
233 [ $( $( ModalAction::$variant, )* )* ].into_iter()
234 }
235 }
236 };
237}
238
239define_modal_actions! {
240 Create {
241 CreateCancel => "cancel" [ "Esc" ],
242 CreateNextField => "next_field" [ "Tab" ],
243 CreatePrevField => "prev_field" [ "BackTab" ],
244 CreateSubmit => "submit" [ "Enter" ],
245 CreatePrevType => "prev_type" [ "Up", "Left", "h" ],
246 CreateNextType => "next_type" [ "Down", "Right", "l" ],
247 // Issue #416. Ctrl-modified on purpose: the create overlay reserves
248 // unmodified printable keys for the text fields, so a bare letter here
249 // would be swallowed while typing a description.
250 CreateToggleMode => "toggle_mode" [ "Ctrl+t" ],
251 }
252 Confirm {
253 ConfirmConfirm => "confirm" [ "y" ],
254 ConfirmActivate => "activate" [ "Enter" ],
255 ConfirmCancel => "cancel" [ "n", "Esc" ],
256 ConfirmFocusConfirm => "focus_confirm" [ "Left", "h" ],
257 ConfirmFocusCancel => "focus_cancel" [ "Right", "l" ],
258 ConfirmToggleFocus => "toggle_focus" [ "Tab" ],
259 }
260 Help {
261 HelpClose => "close" [ "Esc", "q", "?" ],
262 HelpScrollDown => "scroll_down" [ "Down", "j" ],
263 HelpScrollUp => "scroll_up" [ "Up", "k" ],
264 HelpScrollRight => "scroll_right" [ "Right", "l" ],
265 HelpScrollLeft => "scroll_left" [ "Left", "h" ],
266 HelpScrollTop => "scroll_top" [ "Home", "g" ],
267 HelpScrollBottom => "scroll_bottom" [ "End", "G" ],
268 }
269 Detail {
270 DetailClose => "close" [ "Esc", "q" ],
271 DetailSelectNext => "select_next" [ "Down", "j" ],
272 DetailSelectPrev => "select_prev" [ "Up", "k" ],
273 DetailAttach => "attach" [ "a" ],
274 DetailDetach => "detach" [ "d" ],
275 DetailInput => "attach_by_id" [ "i" ],
276 }
277 CommandLogs {
278 CommandLogsClose => "close" [ "Esc", "q" ],
279 CommandLogsCopy => "copy" [ "y" ],
280 CommandLogsScrollDown => "scroll_down" [ "Down", "j" ],
281 CommandLogsScrollUp => "scroll_up" [ "Up", "k" ],
282 CommandLogsScrollRight => "scroll_right" [ "Right", "l" ],
283 CommandLogsScrollLeft => "scroll_left" [ "Left", "h" ],
284 CommandLogsScrollTop => "scroll_top" [ "Home", "g" ],
285 CommandLogsScrollBottom => "scroll_bottom" [ "End", "G" ],
286 }
287 Config {
288 ConfigClose => "close" [ "Esc", "q" ],
289 ConfigNextTab => "next_tab" [ "Tab" ],
290 ConfigPrevTab => "prev_tab" [ "BackTab" ],
291 ConfigToggleLayer => "toggle_layer" [ "L" ],
292 ConfigActivate => "activate" [ "Space", "Enter" ],
293 ConfigSelectNext => "select_next" [ "Down", "j" ],
294 ConfigSelectPrev => "select_prev" [ "Up", "k" ],
295 ConfigScrollRight => "scroll_right" [ "Right", "l" ],
296 ConfigScrollLeft => "scroll_left" [ "Left", "h" ],
297 ConfigScrollTop => "scroll_top" [ "Home", "g" ],
298 ConfigScrollBottom => "scroll_bottom" [ "End", "G" ],
299 }
300 ConfigEdit {
301 ConfigEditSubmit => "submit" [ "Enter" ],
302 ConfigEditCancel => "cancel" [ "Esc" ],
303 }
304 Report {
305 ReportClose => "close" [ "Esc", "q", "Enter" ],
306 }
307 OpenMenu {
308 OpenMenuClose => "close" [ "Esc", "q" ],
309 OpenMenuToggle => "toggle" [ "j", "k", "Down", "Up" ],
310 OpenMenuAccept => "accept" [ "Enter" ],
311 OpenMenuIssue => "issue" [ "i" ],
312 OpenMenuPr => "pr" [ "p" ],
313 }
314 CommandPalette {
315 CommandPaletteClose => "close" [ "Esc" ],
316 CommandPaletteAccept => "accept" [ "Enter" ],
317 CommandPalettePrev => "prev" [ "Up" ],
318 CommandPaletteNext => "next" [ "Down", "Tab" ],
319 }
320 LinkChooseTarget {
321 LinkChooseNext => "next" [ "j", "Down" ],
322 LinkChoosePrev => "prev" [ "k", "Up" ],
323 LinkChooseIssue => "issue" [ "i" ],
324 LinkChoosePr => "pr" [ "p" ],
325 LinkChooseAccept => "accept" [ "Enter" ],
326 LinkChooseCancel => "cancel" [ "Esc" ],
327 }
328 LinkInputNumber {
329 LinkInputSubmit => "submit" [ "Enter" ],
330 LinkInputCancel => "cancel" [ "Esc" ],
331 }
332 ExecPicker {
333 ExecPickerNext => "next" [ "j", "Down" ],
334 ExecPickerPrev => "prev" [ "k", "Up" ],
335 ExecPickerAccept => "accept" [ "Enter" ],
336 ExecPickerCancel => "cancel" [ "Esc" ],
337 }
338 Clean {
339 CleanNext => "next" [ "j", "Down" ],
340 CleanPrev => "prev" [ "k", "Up" ],
341 CleanConfirm => "confirm" [ "y", "Enter" ],
342 CleanCancel => "cancel" [ "n", "Esc" ],
343 }
344 // #436: the defaults mirror the list view's own keys — `/` filters and
345 // `f` refreshes there too (user feedback 2026-07-24).
346 CiChecks {
347 CiChecksClose => "close" [ "Esc", "q" ],
348 CiChecksNext => "select_next" [ "j", "Down" ],
349 CiChecksPrev => "select_prev" [ "k", "Up" ],
350 CiChecksOpen => "open" [ "Enter" ],
351 CiChecksFilter => "filter" [ "/" ],
352 CiChecksRefresh => "refresh" [ "f" ],
353 }
354}
355
356impl ModalAction {
357 /// Resolve a `(context, verb-slug)` pair to a typed verb. Used by the
358 /// config walker to translate `[tui.keys.modal.<context>].<verb>` keys.
359 pub fn from_context_verb(ctx: KeyContext, verb: &str) -> Option<Self> {
360 Self::all().find(|a| a.context() == ctx && a.verb() == verb)
361 }
362
363 /// `true` when binding this verb to `stroke` would leave it unreachable
364 /// or misleading because a typing route consumes the key first (Codex
365 /// review #456). Context-wide reservations
366 /// ([`KeyContext::reserved_typing_stroke`]) apply to every verb; the
367 /// create verbs that must stay operative from the TEXT fields — submit
368 /// (only ever fires from Description), cancel and the field navigation
369 /// — add a per-verb case: every unmodified printable and Backspace is
370 /// typing there. Only the type-cycling verbs keep bare letters: they
371 /// act on the Type field, which takes no text input.
372 pub fn reserved_typing_stroke(self, stroke: &KeyStroke) -> bool {
373 use crossterm::event::{KeyCode as KC, KeyModifiers as KM};
374 if self.context().reserved_typing_stroke(stroke) {
375 return true;
376 }
377 matches!(
378 self,
379 ModalAction::CreateSubmit
380 | ModalAction::CreateCancel
381 | ModalAction::CreateNextField
382 | ModalAction::CreatePrevField
383 // #416: free-form mode has `Name` as its only field, so a bare
384 // printable bound here would be swallowed as typing with no way
385 // back to the structured form.
386 | ModalAction::CreateToggleMode
387 ) && !stroke.modifiers.intersects(KM::CONTROL | KM::ALT)
388 && matches!(stroke.code, KC::Char(_) | KC::Backspace)
389 }
390
391 /// Default keystrokes for this verb, parsed. Panics on a malformed
392 /// literal — that is a programmer error in the table above, never user
393 /// input (same contract as the global keymap's `def`).
394 fn default_keys(self) -> Vec<KeyStroke> {
395 self
396 .default_chord_strs()
397 .iter()
398 .map(|s| {
399 parse_single(s)
400 .unwrap_or_else(|e| panic!("default modal binding {:?} for {:?} failed to parse: {}", s, self, e))
401 })
402 .collect()
403 }
404}
405
406/// Parse a binding string that must resolve to exactly one keystroke.
407/// Modal bindings have no chord machinery, so a multi-stroke string is a
408/// hard error (returned to the user verbatim by the config walker).
409pub fn parse_single(s: &str) -> Result<KeyStroke> {
410 let strokes = KeyStroke::parse_chord(s)?;
411 let stroke = match strokes.into_iter().collect::<Vec<_>>().as_slice() {
412 [one] => one.clone(),
413 _ => {
414 return Err(GwmError::Config(format!(
415 "modal bindings must be a single keystroke, got chord {:?} (modals have no chord timeout)",
416 s
417 )))
418 }
419 };
420 // Ctrl+C is the emergency quit handled in `run_app` ahead of every lookup,
421 // so a modal binding to it would never fire — reject it rather than let
422 // `gwm tui keys` / footer hints advertise an unreachable action (#219 review).
423 if stroke.modifiers.contains(KeyModifiers::CONTROL)
424 && matches!(stroke.code, KeyCode::Char(c) if c.eq_ignore_ascii_case(&'c'))
425 {
426 return Err(GwmError::Config(format!(
427 "modal bindings cannot use {:?}: Ctrl+C is the reserved emergency quit (handled before any modal lookup)",
428 s
429 )));
430 }
431 Ok(stroke)
432}
433
434// ---------------------------------------------------------------------------
435// ModalKeymap
436// ---------------------------------------------------------------------------
437
438/// One resolved binding: a verb, the single-strokes that fire it (any one
439/// match suffices), and the layer it came from.
440#[derive(Debug, Clone)]
441pub struct ModalBinding {
442 pub action: ModalAction,
443 pub keys: Vec<KeyStroke>,
444 pub source: Source,
445}
446
447/// The resolved contextual keymap: every modal verb with its keys. Built
448/// from [`ModalKeymap::defaults`] then layered with user overrides via
449/// [`ModalKeymap::apply_override`].
450#[derive(Debug, Clone)]
451pub struct ModalKeymap {
452 entries: Vec<ModalBinding>,
453}
454
455impl ModalKeymap {
456 /// Built-in defaults — mirror the historical hard-coded modal routing in
457 /// `src/tui/mod.rs` before issue #219.
458 pub fn defaults() -> Self {
459 let entries = ModalAction::all()
460 .map(|action| ModalBinding {
461 action,
462 keys: action.default_keys(),
463 source: Source::Default,
464 })
465 .collect();
466 Self { entries }
467 }
468
469 /// Replace the keys bound to `action` with `keys` and re-validate the
470 /// action's **context**. An empty `Vec` unbinds the verb.
471 ///
472 /// Validation rejects the same stroke wired to two different verbs *in
473 /// the same context* (cross-context reuse is fine — that is the whole
474 /// point). A default binding in the same context silently vacates any
475 /// stroke the override claims, mirroring the global keymap: explicit
476 /// user intent wins over a shipped default.
477 pub fn apply_override(&mut self, action: ModalAction, keys: Vec<KeyStroke>) -> Result<()> {
478 let ctx = action.context();
479 // Refuse a binding the reserved typing would swallow (Codex review
480 // #456): with `palette.close = ["x"]` the override replaces Esc, then
481 // the filter typing consumes `x` — the overlay is left with no exit
482 // short of Ctrl-C. Better a clear config error up front.
483 for k in &keys {
484 if action.reserved_typing_stroke(k) {
485 return Err(GwmError::Config(format!(
486 "context {}: key {} is reserved for typing input there and cannot be bound to {} — \
487 the dispatch routes it into the input before the modal resolution",
488 ctx.config_path(),
489 k,
490 action.verb()
491 )));
492 }
493 }
494 let claimed: Vec<&KeyStroke> = keys.iter().collect();
495
496 // Build the post-override key→action map for this context (excluding the
497 // action being replaced), vacating claimed strokes from defaults.
498 let mut map: HashMap<KeyStroke, ModalAction> = HashMap::new();
499 for b in &self.entries {
500 if b.action.context() != ctx || b.action == action {
501 continue;
502 }
503 for k in &b.keys {
504 if b.source == Source::Default && claimed.contains(&k) {
505 continue;
506 }
507 map.insert(k.clone(), b.action);
508 }
509 }
510 for k in &keys {
511 if let Some(prev) = map.get(k) {
512 return Err(GwmError::Config(format!(
513 "context {}: key {} bound to both {:?} and {:?} — conflict",
514 ctx.config_path(),
515 k,
516 prev.verb(),
517 action.verb()
518 )));
519 }
520 }
521
522 // Commit: vacate claimed strokes from same-context defaults, then
523 // replace the target verb's binding.
524 let claimed_owned: Vec<KeyStroke> = keys.clone();
525 for entry in self.entries.iter_mut() {
526 if entry.action.context() == ctx && entry.action != action && entry.source == Source::Default {
527 entry.keys.retain(|k| !claimed_owned.contains(k));
528 }
529 }
530 if let Some(entry) = self.entries.iter_mut().find(|b| b.action == action) {
531 entry.keys = keys;
532 entry.source = Source::UserConfig;
533 } else {
534 self.entries.push(ModalBinding {
535 action,
536 keys,
537 source: Source::UserConfig,
538 });
539 }
540 Ok(())
541 }
542
543 /// Resolve a single keystroke against the bindings of `ctx`. Returns the
544 /// matched verb, or `None` when nothing in this context binds the stroke
545 /// (the caller then applies the context's text-input / default fallback).
546 pub fn resolve(&self, ctx: KeyContext, stroke: &KeyStroke) -> Option<ModalAction> {
547 self
548 .entries
549 .iter()
550 .filter(|b| b.action.context() == ctx)
551 .find(|b| b.keys.iter().any(|k| k == stroke))
552 .map(|b| b.action)
553 }
554
555 /// Every binding whose verb lives in `ctx`, in declaration order.
556 pub fn bindings_for(&self, ctx: KeyContext) -> Vec<&ModalBinding> {
557 self.entries.iter().filter(|b| b.action.context() == ctx).collect()
558 }
559
560 /// Snapshot of every binding, declaration order, for `gwm tui keys` /
561 /// the help overlay / `gwm doctor`.
562 pub fn list(&self) -> &[ModalBinding] {
563 &self.entries
564 }
565
566 /// The first key bound to `action`, rendered for an inline hint (the
567 /// statusbar chip / help-overlay footer). `None` when the verb is
568 /// unbound — the caller drops it rather than advertise a phantom key.
569 /// Mirrors [`crate::tui::keymap::Keymap::primary_chord`].
570 pub fn primary_key(&self, action: ModalAction) -> Option<String> {
571 self
572 .entries
573 .iter()
574 .find(|b| b.action == action)
575 .and_then(|b| b.keys.first())
576 .map(|k| k.to_string())
577 }
578
579 /// Every key bound to `action`, comma-joined (`"n, Esc"`) or empty when
580 /// unbound — the help-overlay row form, matching the global keymap's
581 /// `keys_for` rendering.
582 pub fn keys_display(&self, action: ModalAction) -> String {
583 self
584 .entries
585 .iter()
586 .find(|b| b.action == action)
587 .map(|b| b.keys.iter().map(|k| k.to_string()).collect::<Vec<_>>().join(", "))
588 .unwrap_or_default()
589 }
590}
591
592impl Default for ModalKeymap {
593 fn default() -> Self {
594 Self::defaults()
595 }
596}