Skip to main content

tix/
keybindings.rs

1use std::collections::HashMap;
2use std::fmt;
3
4use crossterm::event::{KeyCode, KeyEvent, KeyModifiers};
5use serde::de::Error as _;
6use serde::{Deserialize, Deserializer, Serialize, Serializer};
7
8use crate::error::{Result, TixError};
9
10#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
11pub enum InputContext {
12    Board,
13    /// The batch modal (WP07). Its own context so a key means one thing there and
14    /// is not borrowed from the board's — `space` marks a card in a batch and is not
15    /// a board action, and `y` confirms in a batch and is not a board action either.
16    BulkModal,
17    /// The triage inbox and its decision modal (WP06, T029/T031).
18    ///
19    /// Its own context for the reason `BulkModal`'s doc gives:
20    /// `a`/`d`/`u`/`f` decide here and would be board actions there, and the
21    /// inbox is keyboard-driven all the way down. It carries **free text** (a
22    /// decline reason, a duplicate target), so `cancel` is `esc` and *only*
23    /// `esc` — `q` stays a typeable letter for the same reason `LinkPrompt`
24    /// keeps it.
25    Triage,
26    Rollover,
27    Detail,
28    Form,
29    FormPicker,
30    ConfirmDelete,
31    MoveIntoDone,
32    /// The blocked-move acknowledgement prompt (WP04). Its own context so the
33    /// command bar can advertise `y`/`esc` and nothing else.
34    ConfirmBlockedMove,
35    Help,
36    /// The keybindings overlay's `/` input line (tix #1496).
37    ///
38    /// Its own context for the reason [`InputContext::Filter`] is one: it reads **free
39    /// text**. Keeping the list mode's `q`/`?` bound to cancel here would rewrite them
40    /// to `esc` before the handler saw them, and a query could never contain a `q` —
41    /// the same trap the free-text modes below document.
42    HelpSearch,
43    ContextMenu,
44    Filter,
45    FilterModal,
46    Settings,
47    SettingsPrompt,
48    PlanningManagement,
49    PlanningConfirm,
50    PlanningForm,
51    DatePicker,
52    Comment,
53    Asset,
54    EditWarning,
55    /// The two-step create/edit relationship prompt (#1359).
56    ///
57    /// Free text, so `cancel` is `Esc` and **only** `Esc` — see the note on the
58    /// `Asset`/`Comment`/`Filter` group for why binding `q` here would be a real
59    /// defect rather than a preference: a title fragment containing `q` is ordinary.
60    LinkPrompt,
61}
62
63impl InputContext {
64    pub const ALL: &'static [Self] = &[
65        Self::Board,
66        Self::Rollover,
67        Self::Detail,
68        Self::Form,
69        Self::FormPicker,
70        Self::ConfirmDelete,
71        Self::MoveIntoDone,
72        Self::ConfirmBlockedMove,
73        Self::Help,
74        Self::HelpSearch,
75        Self::ContextMenu,
76        Self::Filter,
77        Self::FilterModal,
78        Self::BulkModal,
79        Self::Triage,
80        Self::Settings,
81        Self::SettingsPrompt,
82        Self::PlanningManagement,
83        Self::PlanningConfirm,
84        Self::PlanningForm,
85        Self::DatePicker,
86        Self::Comment,
87        Self::Asset,
88        Self::EditWarning,
89        // Free text like the others, and missing from this list until the overlay's
90        // completeness guard needed it: a context the guard does not walk is a context
91        // whose keys can go undocumented.
92        Self::LinkPrompt,
93    ];
94
95    pub const fn as_str(self) -> &'static str {
96        match self {
97            Self::Board => "board",
98            Self::Rollover => "rollover",
99            Self::Detail => "detail",
100            Self::Form => "form",
101            Self::FormPicker => "form_picker",
102            Self::ConfirmDelete => "confirm_delete",
103            Self::ConfirmBlockedMove => "confirm_blocked_move",
104            Self::MoveIntoDone => "move_into_done",
105            Self::Help => "help",
106            Self::HelpSearch => "help_search",
107            Self::ContextMenu => "context_menu",
108            Self::Filter => "filter",
109            Self::FilterModal => "filter_modal",
110            Self::BulkModal => "bulk_modal",
111            Self::Settings => "settings",
112            Self::SettingsPrompt => "settings_prompt",
113            Self::PlanningManagement => "planning_management",
114            Self::PlanningConfirm => "planning_confirm",
115            Self::PlanningForm => "planning_form",
116            Self::DatePicker => "date_picker",
117            Self::Comment => "comment",
118            Self::Asset => "asset",
119            Self::EditWarning => "edit_warning",
120            Self::LinkPrompt => "link_prompt",
121            Self::Triage => "triage",
122        }
123    }
124}
125
126#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
127#[serde(rename_all = "snake_case")]
128pub enum TuiAction {
129    MoveUp,
130    MoveDown,
131    MoveLeft,
132    MoveRight,
133    NextBoard,
134    PreviousBoard,
135    Open,
136    Edit,
137    Agent,
138    ToggleColumn,
139    FilterModal,
140    BulkModal,
141    /// Open the triage inbox (WP06, T029). Its own action rather than a chord
142    /// bolted onto another one, so `t` can be rebound from `keybindings.board`
143    /// like every other board key and the help overlay can name it.
144    Triage,
145    Mark,
146    Unmark,
147    MovePrevious,
148    MoveNext,
149    NewIssue,
150    /// Duplicate the selected issue into the create-issue form, prefilled from it
151    /// (tix #1484).
152    ///
153    /// `C`, the capital of `c` (toggle column): a pair that reads together without
154    /// changing what any existing key means, which is the same rule `B`/`A` follow.
155    CloneIssue,
156    /// Show the archived issues on the board, or hide them again (tix #1492).
157    ///
158    /// `a` was unbound on the board. The toggle is a *reload*, not a display
159    /// filter: the archived files are not read until it is on.
160    ToggleArchived,
161    Delete,
162    Refresh,
163    Settings,
164    Planning,
165    NewBoard,
166    Help,
167    Quit,
168    TextFilter,
169    ReorderUp,
170    ReorderDown,
171    Close,
172    ScrollUp,
173    ScrollDown,
174    Comment,
175    Asset,
176    /// Open the create-relationship prompt from the detail view (#1359).
177    NewRelationship,
178    /// Re-kind or retarget the relationship under the detail pane's cursor.
179    EditRelationship,
180    /// Remove the relationship under the cursor, after confirmation.
181    RemoveRelationship,
182    /// Move the detail pane's relationship cursor.
183    NextRelationship,
184    PreviousRelationship,
185    Submit,
186    Cancel,
187    Next,
188    Previous,
189    OpenPicker,
190    Reset,
191    Toggle,
192    AddColumn,
193    NewPlanningItem,
194    EditPlanningItem,
195    DeletePlanningItem,
196    Confirm,
197    Create,
198    PreviousYear,
199    NextYear,
200    PreviousMonth,
201    NextMonth,
202    Reedit,
203    Ignore,
204}
205
206impl TuiAction {
207    pub const fn as_str(self) -> &'static str {
208        match self {
209            Self::MoveUp => "move_up",
210            Self::MoveDown => "move_down",
211            Self::MoveLeft => "move_left",
212            Self::MoveRight => "move_right",
213            Self::NextBoard => "next_board",
214            Self::PreviousBoard => "previous_board",
215            Self::Open => "open",
216            Self::Edit => "edit",
217            Self::Agent => "agent",
218            Self::ToggleColumn => "toggle_column",
219            Self::FilterModal => "filter_modal",
220            Self::BulkModal => "bulk_modal",
221            Self::Triage => "triage",
222            Self::Mark => "mark",
223            Self::Unmark => "unmark",
224            Self::MovePrevious => "move_previous",
225            Self::MoveNext => "move_next",
226            Self::NewIssue => "new_issue",
227            Self::CloneIssue => "clone_issue",
228            Self::ToggleArchived => "toggle_archived",
229            Self::Delete => "delete",
230            Self::Refresh => "refresh",
231            Self::Settings => "settings",
232            Self::Planning => "planning",
233            Self::NewBoard => "new_board",
234            Self::Help => "help",
235            Self::Quit => "quit",
236            Self::TextFilter => "text_filter",
237            Self::ReorderUp => "reorder_up",
238            Self::ReorderDown => "reorder_down",
239            Self::Close => "close",
240            Self::ScrollUp => "scroll_up",
241            Self::ScrollDown => "scroll_down",
242            Self::Comment => "comment",
243            Self::Asset => "asset",
244            Self::NewRelationship => "new_relationship",
245            Self::EditRelationship => "edit_relationship",
246            Self::RemoveRelationship => "remove_relationship",
247            Self::NextRelationship => "next_relationship",
248            Self::PreviousRelationship => "previous_relationship",
249            Self::Submit => "submit",
250            Self::Cancel => "cancel",
251            Self::Next => "next",
252            Self::Previous => "previous",
253            Self::OpenPicker => "open_picker",
254            Self::Reset => "reset",
255            Self::Toggle => "toggle",
256            Self::AddColumn => "add_column",
257            Self::NewPlanningItem => "new_planning_item",
258            Self::EditPlanningItem => "edit_planning_item",
259            Self::DeletePlanningItem => "delete_planning_item",
260            Self::Confirm => "confirm",
261            Self::Create => "create",
262            Self::PreviousYear => "previous_year",
263            Self::NextYear => "next_year",
264            Self::PreviousMonth => "previous_month",
265            Self::NextMonth => "next_month",
266            Self::Reedit => "reedit",
267            Self::Ignore => "ignore",
268        }
269    }
270
271    pub const fn description(self) -> &'static str {
272        match self {
273            Self::MoveUp => "move up",
274            Self::MoveDown => "move down",
275            Self::MoveLeft => "previous column",
276            Self::MoveRight => "next column",
277            Self::NextBoard => "next board",
278            Self::PreviousBoard => "previous board",
279            Self::Open => "open detail",
280            Self::Edit => "edit",
281            Self::Agent => "open agent",
282            Self::ToggleColumn => "toggle column",
283            Self::FilterModal => "filter criteria",
284            Self::BulkModal => "batch select",
285            Self::Triage => "triage inbox",
286            Self::Mark => "mark for batch",
287            Self::Unmark => "unmark for batch",
288            Self::MovePrevious => "move to previous column",
289            Self::MoveNext => "move to next column",
290            Self::NewIssue => "new issue",
291            Self::CloneIssue => "clone issue",
292            Self::ToggleArchived => "show archived issues",
293            Self::Delete => "delete",
294            Self::Refresh => "refresh",
295            Self::Settings => "settings",
296            Self::Planning => "planning",
297            Self::NewBoard => "new board",
298            Self::Help => "help",
299            Self::Quit => "quit",
300            Self::TextFilter => "filter",
301            Self::ReorderUp => "reorder up",
302            Self::ReorderDown => "reorder down",
303            Self::Close => "close",
304            Self::ScrollUp => "scroll up one page",
305            Self::ScrollDown => "scroll down one page",
306            Self::Comment => "comment",
307            Self::Asset => "asset",
308            Self::NewRelationship => "new relationship",
309            Self::EditRelationship => "edit relationship",
310            Self::RemoveRelationship => "remove relationship",
311            Self::NextRelationship => "next relationship",
312            Self::PreviousRelationship => "previous relationship",
313            Self::Submit => "submit",
314            Self::Cancel => "cancel",
315            Self::Next => "next",
316            Self::Previous => "previous",
317            Self::OpenPicker => "open picker",
318            Self::Reset => "reset",
319            Self::Toggle => "toggle",
320            Self::AddColumn => "add column",
321            Self::NewPlanningItem => "new",
322            Self::EditPlanningItem => "edit",
323            Self::DeletePlanningItem => "delete",
324            Self::Confirm => "confirm",
325            Self::Create => "create",
326            Self::PreviousYear => "previous year",
327            Self::NextYear => "next year",
328            Self::PreviousMonth => "previous month",
329            Self::NextMonth => "next month",
330            Self::Reedit => "re-edit",
331            Self::Ignore => "ignore",
332        }
333    }
334}
335
336pub type Binding = Vec<String>;
337
338macro_rules! partial_bindings {
339    ($($field:ident),+ $(,)?) => {
340        #[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
341        #[serde(default, deny_unknown_fields)]
342        pub struct ActionBindings {
343            $(
344                #[serde(skip_serializing_if = "Option::is_none")]
345                pub $field: Option<Binding>,
346            )+
347        }
348    };
349}
350
351partial_bindings!(
352    move_up,
353    move_down,
354    move_left,
355    move_right,
356    next_board,
357    previous_board,
358    open,
359    edit,
360    agent,
361    toggle_column,
362    filter_modal,
363    bulk_modal,
364    triage,
365    mark,
366    unmark,
367    move_previous,
368    move_next,
369    new_issue,
370    clone_issue,
371    toggle_archived,
372    delete,
373    refresh,
374    settings,
375    planning,
376    new_board,
377    help,
378    quit,
379    text_filter,
380    reorder_up,
381    reorder_down,
382    close,
383    scroll_up,
384    scroll_down,
385    comment,
386    asset,
387    submit,
388    cancel,
389    next,
390    previous,
391    open_picker,
392    reset,
393    toggle,
394    add_column,
395    new_planning_item,
396    edit_planning_item,
397    delete_planning_item,
398    confirm,
399    create,
400    previous_year,
401    next_year,
402    previous_month,
403    next_month,
404    reedit,
405    ignore,
406);
407
408macro_rules! keybindings_config {
409    ($($field:ident => $context:ident),+ $(,)?) => {
410        #[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
411        #[serde(default, deny_unknown_fields)]
412        pub struct KeyBindings {
413            $(
414                #[serde(skip_serializing_if = "ActionBindings::is_empty")]
415                pub $field: ActionBindings,
416            )+
417        }
418
419        impl KeyBindings {
420            pub fn is_empty(&self) -> bool {
421                self == &Self::default()
422            }
423
424            fn overrides(&self, context: InputContext) -> &ActionBindings {
425                match context {
426                    $(InputContext::$context => &self.$field,)+
427                }
428            }
429        }
430    };
431}
432
433keybindings_config!(
434    board => Board,
435    rollover => Rollover,
436    detail => Detail,
437    form => Form,
438    form_picker => FormPicker,
439    confirm_delete => ConfirmDelete,
440    confirm_blocked_move => ConfirmBlockedMove,
441    help_search => HelpSearch,
442    move_into_done => MoveIntoDone,
443    help => Help,
444    context_menu => ContextMenu,
445    filter => Filter,
446    filter_modal => FilterModal,
447    bulk_modal => BulkModal,
448    triage => Triage,
449    settings => Settings,
450    settings_prompt => SettingsPrompt,
451    planning_management => PlanningManagement,
452    planning_confirm => PlanningConfirm,
453    planning_form => PlanningForm,
454    date_picker => DatePicker,
455    comment => Comment,
456    asset => Asset,
457    edit_warning => EditWarning,
458    link_prompt => LinkPrompt,
459);
460
461#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
462pub struct KeyChord {
463    pub code: KeyCode,
464    pub modifiers: KeyModifiers,
465}
466
467impl KeyChord {
468    pub fn parse(value: &str) -> std::result::Result<Self, String> {
469        let value = value.trim();
470        if value.is_empty() {
471            return Err("key chord must not be empty".into());
472        }
473        if value.chars().count() == 1 {
474            let ch = value.chars().next().expect("value has one character");
475            if ch.is_control() {
476                return Err(format!("unsupported key name `{value}`"));
477            }
478            let modifiers = if ch.is_ascii_uppercase() {
479                KeyModifiers::SHIFT
480            } else {
481                KeyModifiers::NONE
482            };
483            return Ok(Self {
484                code: KeyCode::Char(ch),
485                modifiers,
486            });
487        }
488        let mut parts = value.split('+');
489        let key = parts.next_back().ok_or("missing key name")?.trim();
490        let mut modifiers = KeyModifiers::NONE;
491        for part in parts {
492            match part.trim().to_ascii_lowercase().as_str() {
493                "ctrl" | "control" => modifiers |= KeyModifiers::CONTROL,
494                "alt" | "option" => modifiers |= KeyModifiers::ALT,
495                "shift" => modifiers |= KeyModifiers::SHIFT,
496                "super" | "cmd" | "command" => modifiers |= KeyModifiers::SUPER,
497                other => return Err(format!("unsupported modifier `{other}`")),
498            }
499        }
500        if key.is_empty() {
501            return Err("missing key name".into());
502        }
503        let mut code = match key.to_ascii_lowercase().as_str() {
504            "backtab" => return Err("use shift+tab instead of `backtab`".into()),
505            "shift+tab" => KeyCode::BackTab,
506            "backspace" => KeyCode::Backspace,
507            "tab" => KeyCode::Tab,
508            "enter" | "return" => KeyCode::Enter,
509            "esc" | "escape" => KeyCode::Esc,
510            "space" => KeyCode::Char(' '),
511            "left" => KeyCode::Left,
512            "right" => KeyCode::Right,
513            "up" => KeyCode::Up,
514            "down" => KeyCode::Down,
515            "home" => KeyCode::Home,
516            "end" => KeyCode::End,
517            "pageup" | "pgup" => KeyCode::PageUp,
518            "pagedown" | "pgdn" | "pgdown" => KeyCode::PageDown,
519            "delete" | "del" => KeyCode::Delete,
520            "insert" | "ins" => KeyCode::Insert,
521            _ => {
522                if let Some(number) = key
523                    .to_ascii_lowercase()
524                    .strip_prefix('f')
525                    .and_then(|n| n.parse::<u8>().ok())
526                {
527                    if (1..=12).contains(&number) {
528                        KeyCode::F(number)
529                    } else {
530                        return Err(format!("unsupported function key `{key}`"));
531                    }
532                } else {
533                    let mut chars = key.chars();
534                    let Some(ch) = chars.next().filter(|_| chars.next().is_none()) else {
535                        return Err(format!("unsupported key name `{key}`"));
536                    };
537                    if ch.is_control() {
538                        return Err(format!("unsupported key name `{key}`"));
539                    }
540                    if ch.is_ascii_uppercase() {
541                        modifiers |= KeyModifiers::SHIFT;
542                    }
543                    KeyCode::Char(ch)
544                }
545            }
546        };
547        if code == KeyCode::Tab && modifiers.contains(KeyModifiers::SHIFT) {
548            code = KeyCode::BackTab;
549            modifiers.remove(KeyModifiers::SHIFT);
550        }
551        if code == KeyCode::BackTab {
552            modifiers |= KeyModifiers::SHIFT;
553        }
554        Ok(Self { code, modifiers })
555    }
556
557    pub fn matches(&self, event: KeyEvent) -> bool {
558        if self.code != event.code {
559            return false;
560        }
561        if self.modifiers.is_empty() {
562            return event.modifiers.is_empty();
563        }
564        if self.code == KeyCode::BackTab {
565            return event.modifiers.contains(KeyModifiers::SHIFT) || event.code == KeyCode::BackTab;
566        }
567        if let (KeyCode::Char(expected), KeyCode::Char(actual)) = (self.code, event.code) {
568            let shift_expected = self.modifiers.contains(KeyModifiers::SHIFT);
569            let normalized_expected = if shift_expected {
570                expected.to_ascii_lowercase()
571            } else {
572                expected
573            };
574            let shift_actual =
575                actual.is_uppercase() || event.modifiers.contains(KeyModifiers::SHIFT);
576            let expected_other = self.modifiers.difference(KeyModifiers::SHIFT);
577            let actual_other = event.modifiers.difference(KeyModifiers::SHIFT);
578            return expected_other == actual_other
579                && normalized_expected == actual.to_ascii_lowercase()
580                && shift_expected == shift_actual;
581        }
582        event.modifiers == self.modifiers
583    }
584
585    pub fn display(&self) -> String {
586        let mut out = String::new();
587        if self.modifiers.contains(KeyModifiers::CONTROL) {
588            out.push_str("Ctrl+");
589        }
590        if self.modifiers.contains(KeyModifiers::ALT) {
591            out.push_str("Alt+");
592        }
593        if self.modifiers.contains(KeyModifiers::SUPER) {
594            out.push_str("Super+");
595        }
596        if self.modifiers.contains(KeyModifiers::SHIFT) && self.code != KeyCode::BackTab {
597            out.push_str("Shift+");
598        }
599        out.push_str(&match self.code {
600            KeyCode::BackTab => "Shift+Tab".into(),
601            KeyCode::Char(' ') => "Space".into(),
602            KeyCode::Char(ch) => ch.to_string(),
603            KeyCode::Enter => "Enter".into(),
604            KeyCode::Esc => "Esc".into(),
605            KeyCode::Left => "Left".into(),
606            KeyCode::Right => "Right".into(),
607            KeyCode::Up => "Up".into(),
608            KeyCode::Down => "Down".into(),
609            KeyCode::PageUp => "PgUp".into(),
610            KeyCode::PageDown => "PgDn".into(),
611            KeyCode::Delete => "Delete".into(),
612            KeyCode::Insert => "Insert".into(),
613            KeyCode::Home => "Home".into(),
614            KeyCode::End => "End".into(),
615            KeyCode::F(n) => format!("F{n}"),
616            other => format!("{other:?}"),
617        });
618        out
619    }
620}
621
622impl Serialize for KeyChord {
623    fn serialize<S>(&self, serializer: S) -> std::result::Result<S::Ok, S::Error>
624    where
625        S: Serializer,
626    {
627        serializer.serialize_str(&self.display())
628    }
629}
630
631impl<'de> Deserialize<'de> for KeyChord {
632    fn deserialize<D>(deserializer: D) -> std::result::Result<Self, D::Error>
633    where
634        D: Deserializer<'de>,
635    {
636        let value = String::deserialize(deserializer)?;
637        Self::parse(&value).map_err(D::Error::custom)
638    }
639}
640
641impl fmt::Display for KeyChord {
642    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
643        formatter.write_str(&self.display())
644    }
645}
646
647#[derive(Debug, Clone, Default, PartialEq, Eq)]
648pub struct EffectiveBindings {
649    modes: HashMap<InputContext, HashMap<TuiAction, Vec<KeyChord>>>,
650}
651
652fn chord(value: &str) -> KeyChord {
653    KeyChord::parse(value).expect("built-in key chord must parse")
654}
655
656fn set(map: &mut HashMap<TuiAction, Vec<KeyChord>>, action: TuiAction, values: &[&str]) {
657    map.insert(action, values.iter().map(|value| chord(value)).collect());
658}
659
660impl KeyBindings {
661    pub fn resolve(overrides: &KeyBindings) -> Result<EffectiveBindings> {
662        let mut effective = EffectiveBindings::default();
663        for context in InputContext::ALL {
664            let mut mode = default_mode(*context);
665            overlay(&mut mode, overrides.overrides(*context), *context)?;
666            effective.modes.insert(*context, mode);
667        }
668        Ok(effective)
669    }
670}
671
672impl EffectiveBindings {
673    pub fn action(&self, context: InputContext, event: KeyEvent) -> Option<TuiAction> {
674        self.modes
675            .get(&context)?
676            .iter()
677            .find_map(|(action, chords)| {
678                chords
679                    .iter()
680                    .any(|key| key.matches(event))
681                    .then_some(*action)
682            })
683    }
684
685    /// Every action this context binds, sorted by name so the order is stable.
686    ///
687    /// For the help overlay's completeness guard: that overlay's per-context action
688    /// list is written by hand, and a binding it does not name is a key that works and
689    /// that nobody can discover.
690    pub fn bound_actions(&self, context: InputContext) -> Vec<TuiAction> {
691        let mut actions: Vec<TuiAction> = self
692            .modes
693            .get(&context)
694            .map(|mode| mode.keys().copied().collect())
695            .unwrap_or_default();
696        actions.sort_by_key(|action| action.as_str());
697        actions
698    }
699
700    pub fn chords(&self, context: InputContext, action: TuiAction) -> &[KeyChord] {
701        self.modes
702            .get(&context)
703            .and_then(|mode| mode.get(&action))
704            .map(Vec::as_slice)
705            .unwrap_or_default()
706    }
707
708    pub fn label(&self, context: InputContext, action: TuiAction) -> String {
709        let labels: Vec<String> = self
710            .chords(context, action)
711            .iter()
712            .map(ToString::to_string)
713            .collect();
714        if labels.is_empty() {
715            "—".into()
716        } else {
717            labels.join(" / ")
718        }
719    }
720
721    pub fn canonical_event(&self, context: InputContext, action: TuiAction) -> KeyEvent {
722        let (code, modifiers) = match (context, action) {
723            (_, TuiAction::ReorderUp) => (KeyCode::Up, KeyModifiers::CONTROL),
724            (_, TuiAction::ReorderDown) => (KeyCode::Down, KeyModifiers::CONTROL),
725            (InputContext::Rollover, TuiAction::Previous) => (KeyCode::Tab, KeyModifiers::NONE),
726            (InputContext::PlanningManagement, TuiAction::Next) => {
727                (KeyCode::Tab, KeyModifiers::NONE)
728            }
729            (InputContext::FormPicker, TuiAction::Next) => (KeyCode::Up, KeyModifiers::NONE),
730            (InputContext::FormPicker, TuiAction::Previous) => (KeyCode::Down, KeyModifiers::NONE),
731            (InputContext::DatePicker, TuiAction::PreviousYear) => {
732                (KeyCode::Char('['), KeyModifiers::NONE)
733            }
734            (InputContext::DatePicker, TuiAction::NextYear) => {
735                (KeyCode::Char(']'), KeyModifiers::NONE)
736            }
737            (InputContext::DatePicker, TuiAction::PreviousMonth) => {
738                (KeyCode::PageUp, KeyModifiers::NONE)
739            }
740            (InputContext::DatePicker, TuiAction::NextMonth) => {
741                (KeyCode::PageDown, KeyModifiers::NONE)
742            }
743            (InputContext::DatePicker, TuiAction::Reset) => (KeyCode::Delete, KeyModifiers::NONE),
744            (InputContext::Settings, TuiAction::NewBoard) => {
745                (KeyCode::Char('n'), KeyModifiers::NONE)
746            }
747            (InputContext::Settings, TuiAction::Delete) => (KeyCode::Char('x'), KeyModifiers::NONE),
748            // #1441/#1442: `scroll_up`/`scroll_down` are **page** steps and must
749            // canonicalize to themselves. The generic arms below fold them into
750            // Up/Down, which is what made the detail pane's `PgUp`/`PgDn` arms dead
751            // code: the key arrived canonicalized as `Down` and moved the
752            // relationship cursor instead of scrolling, so a bound key did something
753            // other than what it was bound to. These arms sit above the generics
754            // because the match is top-down.
755            (_, TuiAction::ScrollUp) => (KeyCode::PageUp, KeyModifiers::NONE),
756            (_, TuiAction::ScrollDown) => (KeyCode::PageDown, KeyModifiers::NONE),
757            (_, TuiAction::MoveUp | TuiAction::Previous) => (KeyCode::Up, KeyModifiers::NONE),
758            (_, TuiAction::MoveDown | TuiAction::Next) => (KeyCode::Down, KeyModifiers::NONE),
759            (_, TuiAction::MoveLeft) => (KeyCode::Left, KeyModifiers::NONE),
760            (_, TuiAction::MoveRight) => (KeyCode::Right, KeyModifiers::NONE),
761            (_, TuiAction::PreviousBoard) => (KeyCode::BackTab, KeyModifiers::NONE),
762            (_, TuiAction::NextBoard) => (KeyCode::Tab, KeyModifiers::NONE),
763            (_, TuiAction::Submit | TuiAction::Open | TuiAction::Ignore) => {
764                (KeyCode::Enter, KeyModifiers::NONE)
765            }
766            (_, TuiAction::NewIssue) => (KeyCode::Char('n'), KeyModifiers::NONE),
767            (_, TuiAction::CloneIssue) => (KeyCode::Char('C'), KeyModifiers::NONE),
768            (_, TuiAction::ToggleArchived) => (KeyCode::Char('a'), KeyModifiers::NONE),
769            (_, TuiAction::Edit | TuiAction::EditPlanningItem) => {
770                (KeyCode::Char('e'), KeyModifiers::NONE)
771            }
772            (_, TuiAction::Agent) => (KeyCode::Char('A'), KeyModifiers::NONE),
773            (_, TuiAction::ToggleColumn | TuiAction::Comment) => {
774                (KeyCode::Char('c'), KeyModifiers::NONE)
775            }
776            (_, TuiAction::FilterModal) => (KeyCode::Char('f'), KeyModifiers::NONE),
777            (_, TuiAction::BulkModal) => (KeyCode::Char('B'), KeyModifiers::NONE),
778            (_, TuiAction::Triage) => (KeyCode::Char('t'), KeyModifiers::NONE),
779            (_, TuiAction::Mark) => (KeyCode::Char(' '), KeyModifiers::NONE),
780            (_, TuiAction::Unmark) => (KeyCode::Char('x'), KeyModifiers::NONE),
781            (_, TuiAction::MovePrevious) => (KeyCode::Char('<'), KeyModifiers::NONE),
782            (_, TuiAction::MoveNext) => (KeyCode::Char('>'), KeyModifiers::NONE),
783            (_, TuiAction::Delete) => (KeyCode::Char('d'), KeyModifiers::NONE),
784            (_, TuiAction::DeletePlanningItem) => (KeyCode::Char('x'), KeyModifiers::NONE),
785            (_, TuiAction::Refresh | TuiAction::Reset | TuiAction::Reedit) => {
786                (KeyCode::Char('r'), KeyModifiers::NONE)
787            }
788            (_, TuiAction::Settings) => (KeyCode::Char('s'), KeyModifiers::NONE),
789            (_, TuiAction::Planning) => (KeyCode::Char('m'), KeyModifiers::NONE),
790            (_, TuiAction::NewPlanningItem) => (KeyCode::Char('n'), KeyModifiers::NONE),
791            (_, TuiAction::NewBoard) => (KeyCode::Char('b'), KeyModifiers::NONE),
792            (_, TuiAction::AddColumn) => (KeyCode::Char('a'), KeyModifiers::NONE),
793            (_, TuiAction::Help) => (KeyCode::Char('?'), KeyModifiers::NONE),
794            (_, TuiAction::Quit) => (KeyCode::Char('q'), KeyModifiers::NONE),
795            (_, TuiAction::TextFilter) => (KeyCode::Char('/'), KeyModifiers::NONE),
796            (_, TuiAction::Close | TuiAction::Cancel) => (KeyCode::Esc, KeyModifiers::NONE),
797            (_, TuiAction::Asset) => (KeyCode::Char('a'), KeyModifiers::NONE),
798            // #1359. `e` is the editor in the detail view, so re-kind is `E` — the
799            // same relationship the CLI's `tix link edit <id>` changes.
800            (_, TuiAction::NewRelationship) => (KeyCode::Char('n'), KeyModifiers::NONE),
801            (_, TuiAction::EditRelationship) => (KeyCode::Char('E'), KeyModifiers::SHIFT),
802            (_, TuiAction::RemoveRelationship) => (KeyCode::Char('d'), KeyModifiers::NONE),
803            (_, TuiAction::NextRelationship) => (KeyCode::Char('j'), KeyModifiers::NONE),
804            (_, TuiAction::PreviousRelationship) => (KeyCode::Char('k'), KeyModifiers::NONE),
805            (_, TuiAction::OpenPicker) => (KeyCode::F(2), KeyModifiers::NONE),
806            (_, TuiAction::Toggle) => (KeyCode::Char(' '), KeyModifiers::NONE),
807            (_, TuiAction::Confirm) => (KeyCode::Char('y'), KeyModifiers::NONE),
808            (_, TuiAction::Create) => (KeyCode::Char('+'), KeyModifiers::NONE),
809            _ => (KeyCode::Esc, KeyModifiers::NONE),
810        };
811        KeyEvent::new(code, modifiers)
812    }
813}
814
815fn defaults(values: &[(&str, &[&str])]) -> HashMap<TuiAction, Vec<KeyChord>> {
816    let mut map = HashMap::new();
817    for (action, keys) in values {
818        let action = match *action {
819            "move_up" => TuiAction::MoveUp,
820            "move_down" => TuiAction::MoveDown,
821            "move_left" => TuiAction::MoveLeft,
822            "move_right" => TuiAction::MoveRight,
823            "next_board" => TuiAction::NextBoard,
824            "previous_board" => TuiAction::PreviousBoard,
825            "open" => TuiAction::Open,
826            "edit" => TuiAction::Edit,
827            "agent" => TuiAction::Agent,
828            "toggle_column" => TuiAction::ToggleColumn,
829            "filter_modal" => TuiAction::FilterModal,
830            "bulk_modal" => TuiAction::BulkModal,
831            "triage" => TuiAction::Triage,
832            "mark" => TuiAction::Mark,
833            "unmark" => TuiAction::Unmark,
834            "move_previous" => TuiAction::MovePrevious,
835            "move_next" => TuiAction::MoveNext,
836            "new_issue" => TuiAction::NewIssue,
837            "clone_issue" => TuiAction::CloneIssue,
838            "toggle_archived" => TuiAction::ToggleArchived,
839            "delete" => TuiAction::Delete,
840            "refresh" => TuiAction::Refresh,
841            "settings" => TuiAction::Settings,
842            "planning" => TuiAction::Planning,
843            "new_board" => TuiAction::NewBoard,
844            "help" => TuiAction::Help,
845            "quit" => TuiAction::Quit,
846            "text_filter" => TuiAction::TextFilter,
847            "reorder_up" => TuiAction::ReorderUp,
848            "reorder_down" => TuiAction::ReorderDown,
849            "close" => TuiAction::Close,
850            "scroll_up" => TuiAction::ScrollUp,
851            "scroll_down" => TuiAction::ScrollDown,
852            "comment" => TuiAction::Comment,
853            "asset" => TuiAction::Asset,
854            "new_relationship" => TuiAction::NewRelationship,
855            "edit_relationship" => TuiAction::EditRelationship,
856            "remove_relationship" => TuiAction::RemoveRelationship,
857            "next_relationship" => TuiAction::NextRelationship,
858            "previous_relationship" => TuiAction::PreviousRelationship,
859            "submit" => TuiAction::Submit,
860            "cancel" => TuiAction::Cancel,
861            "next" => TuiAction::Next,
862            "previous" => TuiAction::Previous,
863            "open_picker" => TuiAction::OpenPicker,
864            "reset" => TuiAction::Reset,
865            "toggle" => TuiAction::Toggle,
866            "add_column" => TuiAction::AddColumn,
867            "new_planning_item" => TuiAction::NewPlanningItem,
868            "edit_planning_item" => TuiAction::EditPlanningItem,
869            "delete_planning_item" => TuiAction::DeletePlanningItem,
870            "confirm" => TuiAction::Confirm,
871            "create" => TuiAction::Create,
872            "previous_year" => TuiAction::PreviousYear,
873            "next_year" => TuiAction::NextYear,
874            "previous_month" => TuiAction::PreviousMonth,
875            "next_month" => TuiAction::NextMonth,
876            "reedit" => TuiAction::Reedit,
877            "ignore" => TuiAction::Ignore,
878            _ => unreachable!(),
879        };
880        set(&mut map, action, keys);
881    }
882    map
883}
884
885fn default_mode(context: InputContext) -> HashMap<TuiAction, Vec<KeyChord>> {
886    match context {
887        InputContext::Board => defaults(&[
888            ("move_up", &["up", "k"]),
889            ("move_down", &["down", "j"]),
890            // tix #1442 (follow-up): a screen of cards. They have to be *bound* for the
891            // board to see them at all: `App::handle_key` routes an unbound key in a
892            // non-text context to `F(12)` rather than passing it through, which is why
893            // `PgUp`/`PgDn` did nothing here while the wheel next to them worked. They
894            // canonicalize to `PageUp`/`PageDown`, so the handler can tell a page from
895            // a row.
896            ("scroll_up", &["pgup"]),
897            ("scroll_down", &["pgdn"]),
898            ("move_left", &["left", "h"]),
899            ("move_right", &["right", "l"]),
900            ("next_board", &["tab"]),
901            ("previous_board", &["shift+tab"]),
902            ("open", &["enter"]),
903            ("edit", &["e"]),
904            ("agent", &["A"]),
905            ("toggle_column", &["c"]),
906            ("filter_modal", &["f"]),
907            ("bulk_modal", &["B"]),
908            // WP06: the triage inbox. `t` was unbound, so this adds a key
909            // rather than changing what any existing one means.
910            ("triage", &["t"]),
911            // The chord is spelled `space`, not a literal blank: `KeyChord::parse`
912            // trims its input, so a bare `" "` is the empty chord and every
913            // built-in binding panics at startup.
914            ("mark", &["space"]),
915            ("unmark", &["x"]),
916            ("move_previous", &["<"]),
917            ("move_next", &[">"]),
918            ("new_issue", &["n"]),
919            // #1484: the capital of `c` (toggle column), not a new letter — the
920            // board's free letters are its navigation, and `C` keeps "clone" beside
921            // the toggle it is the second half of.
922            ("clone_issue", &["C"]),
923            // #1492: `a` was free on the board, and "archived" is what it means.
924            ("toggle_archived", &["a"]),
925            ("delete", &["d"]),
926            ("refresh", &["r"]),
927            ("settings", &["s"]),
928            ("planning", &["m", "M"]),
929            ("new_board", &["b"]),
930            ("help", &["?"]),
931            ("quit", &["q", "Q", "esc"]),
932            ("text_filter", &["/"]),
933            ("reorder_up", &["ctrl+up"]),
934            ("reorder_down", &["ctrl+down"]),
935        ]),
936        InputContext::Rollover => defaults(&[
937            ("submit", &["enter"]),
938            ("previous", &["left", "right", "tab"]),
939            ("cancel", &["esc"]),
940        ]),
941        InputContext::Detail => defaults(&[
942            ("close", &["q", "Q", "esc"]),
943            ("edit", &["e"]),
944            ("scroll_up", &["pgup"]),
945            ("scroll_down", &["pgdn"]),
946            ("help", &["?"]),
947            ("comment", &["c"]),
948            ("asset", &["a"]),
949            // #1359: the three mutating relationship operations, plus the cursor
950            // that names which edge `E` and `d` act on. `e` stays the editor, so
951            // re-kind is `E`.
952            ("new_relationship", &["n"]),
953            ("edit_relationship", &["E"]),
954            ("remove_relationship", &["d"]),
955            ("next_relationship", &["j", "down"]),
956            ("previous_relationship", &["k", "up"]),
957        ]),
958        InputContext::Form | InputContext::PlanningForm => defaults(&[
959            ("cancel", &["esc"]),
960            ("submit", &["enter"]),
961            ("next", &["tab", "down"]),
962            ("previous", &["shift+tab", "up"]),
963            ("open_picker", &["f2"]),
964        ]),
965        InputContext::FormPicker => defaults(&[
966            ("cancel", &["esc", "f2"]),
967            ("next", &["up"]),
968            ("previous", &["down"]),
969            ("submit", &["enter"]),
970            ("create", &["+", "insert"]),
971        ]),
972        InputContext::ConfirmDelete => defaults(&[("confirm", &["y", "Y"]), ("cancel", &["esc"])]),
973        // `n` cancels as well as `esc`: a yes/no prompt whose only "no" is a
974        // non-printing key is a trap for anyone who does not know it.
975        InputContext::ConfirmBlockedMove => defaults(&[
976            ("confirm", &["y", "Y", "enter"]),
977            ("cancel", &["n", "N", "esc"]),
978        ]),
979        // Free-text entry. `cancel` is **`esc` only**, and that is load-bearing
980        // rather than a style choice.
981        //
982        // All four of these read arbitrary text into a buffer — a commit message, a
983        // comment body, a **filesystem path**, a filter string. Binding `q` to cancel
984        // means the letter `q` cannot be typed, and it is worse than that: the
985        // binding resolves to the action's canonical event (`esc`), so `q` does not
986        // merely fail to type, it **leaves the mode**, and every character after it
987        // is then interpreted as a command in the mode underneath.
988        //
989        // This was found by a test flake (#1305), not by inspection. The asset
990        // round-trip test types the source path through `handle_key`, and the path
991        // came from a `tempfile` temp directory whose six random characters are drawn
992        // from `[A-Za-z0-9]`. Roughly one run in forty produced a name containing a
993        // `q`; the prompt closed mid-path and the rest of the string was applied as
994        // Detail-mode keys — which is how a run came to report
995        // `status = "Commented by …"` and no asset at all. A user attaching an asset
996        // from a path containing `q` gets the same thing, and it silently creates a
997        // comment on the issue.
998        //
999        // The test was at fault for depending on a random name, but the behaviour is
1000        // a real defect, so the binding moves rather than the fixture. `esc` remains
1001        // the cancel key, which is already this codebase's convention for text entry
1002        // — `FilterModal` and `ConfirmDelete` both bind `esc` and nothing else.
1003        //
1004        // `q`-to-close is kept where there is no text to type: `Help`,
1005        // `ContextMenu`, `Settings`, `PlanningManagement`, `DatePicker`, and the
1006        // yes/no `ConfirmBlockedMove` keep it.
1007        InputContext::MoveIntoDone
1008        | InputContext::Comment
1009        | InputContext::Asset
1010        | InputContext::Filter => defaults(&[("cancel", &["esc"]), ("submit", &["enter"])]),
1011        // The overlay and the context menu are *not* the same surface, and sharing a
1012        // context made them one: a binding added for the overlay (`pgup`/`pgdn`, tix
1013        // #1441) would silently become a context-menu key too. They share a key set
1014        // today only by coincidence, so they get their own arms.
1015        InputContext::Help => defaults(&[
1016            ("cancel", &["esc", "q", "?"]),
1017            ("next", &["down", "j"]),
1018            ("previous", &["up", "k"]),
1019            ("submit", &["enter"]),
1020            // #1496: `/` filters the list. Bound rather than handled as a raw key so it
1021            // is rebindable like everything else — and so the overlay's own rows name
1022            // it, since a search nobody knows about is a search nobody uses.
1023            ("text_filter", &["/"]),
1024            // #1441/#1442: one page at a time. These have to be *bound* for the
1025            // overlay to see them at all — `App::handle_key` routes an unbound key in
1026            // a non-text context to `F(12)` rather than passing it through — and they
1027            // canonicalize to themselves so the handler can tell a page from a row.
1028            ("scroll_up", &["pgup"]),
1029            ("scroll_down", &["pgdn"]),
1030        ]),
1031        InputContext::ContextMenu => defaults(&[
1032            ("cancel", &["esc", "q", "?"]),
1033            ("next", &["down", "j"]),
1034            ("previous", &["up", "k"]),
1035            ("submit", &["enter"]),
1036        ]),
1037        // The overlay's `/` input (tix #1496): free text, so `cancel` is `Esc` and only
1038        // `Esc`. `q` and `?` stay ordinary characters here — binding them to cancel
1039        // would rewrite them to `esc` inside `App::handle_key`, before the handler saw
1040        // them, and a query could never contain a `q`.
1041        InputContext::HelpSearch => defaults(&[("cancel", &["esc"]), ("submit", &["enter"])]),
1042        InputContext::BulkModal => defaults(&[
1043            ("cancel", &["esc"]),
1044            ("next", &["tab", "down"]),
1045            ("previous", &["shift+tab", "up"]),
1046            ("submit", &["enter"]),
1047        ]),
1048        // WP06. Five bindings and nothing else, because every remaining inbox
1049        // key (`q`, `j`, `k`, `a`, `d`, `u`, `f`, `p`, `y`, `?`) has to arrive
1050        // **raw**: a decline reason containing `q` and a duplicate target
1051        // containing `a` are ordinary, and `allows_text_fallback` is what keeps
1052        // them letters instead of turning them into a close or an accept.
1053        InputContext::Triage => defaults(&[
1054            ("cancel", &["esc"]),
1055            ("submit", &["enter"]),
1056            ("move_up", &["up"]),
1057            ("move_down", &["down"]),
1058            ("toggle", &["space"]),
1059        ]),
1060        InputContext::FilterModal => defaults(&[
1061            ("cancel", &["esc"]),
1062            ("next", &["tab", "down"]),
1063            ("previous", &["shift+tab", "up"]),
1064            ("reset", &["r", "R"]),
1065            ("submit", &["enter"]),
1066            ("open_picker", &["f2"]),
1067        ]),
1068        InputContext::Settings => defaults(&[
1069            ("close", &["q", "Q", "esc"]),
1070            ("next", &["tab"]),
1071            ("previous", &["shift+tab"]),
1072            ("move_up", &["up", "k"]),
1073            ("move_down", &["down", "j"]),
1074            ("toggle", &["space", "enter"]),
1075            ("add_column", &["a"]),
1076            ("new_board", &["n"]),
1077            ("delete", &["x", "X"]),
1078        ]),
1079        InputContext::SettingsPrompt => defaults(&[
1080            ("cancel", &["esc", "q", "Q"]),
1081            ("submit", &["enter", "y", "Y"]),
1082        ]),
1083        InputContext::PlanningManagement => defaults(&[
1084            ("close", &["q", "Q", "esc"]),
1085            ("move_up", &["up", "k"]),
1086            ("move_down", &["down", "j"]),
1087            ("next", &["tab", "shift+tab"]),
1088            ("new_planning_item", &["n"]),
1089            ("edit_planning_item", &["e"]),
1090            ("delete_planning_item", &["x", "X"]),
1091        ]),
1092        InputContext::PlanningConfirm => {
1093            defaults(&[("confirm", &["y", "Y", "enter"]), ("cancel", &["esc"])])
1094        }
1095        InputContext::DatePicker => defaults(&[
1096            ("cancel", &["esc", "q"]),
1097            ("submit", &["enter"]),
1098            ("move_up", &["up", "k"]),
1099            ("move_down", &["down", "j"]),
1100            ("move_left", &["left", "h"]),
1101            ("move_right", &["right", "l"]),
1102            ("previous_month", &["pgup"]),
1103            ("next_month", &["pgdn"]),
1104            ("previous_year", &["["]),
1105            ("next_year", &["]"]),
1106            ("reset", &["delete", "c", "C"]),
1107        ]),
1108        InputContext::EditWarning => defaults(&[
1109            ("reedit", &["r", "R"]),
1110            ("ignore", &["i", "I", "enter", "esc"]),
1111        ]),
1112        // #1359. Free text, so `q` must be typeable: a title fragment or a path
1113        // containing `q` is unremarkable, and binding it to cancel closes the prompt
1114        // and applies the rest of the input as commands underneath. See the note on
1115        // the `Asset`/`Comment`/`Filter` group for the full reasoning.
1116        InputContext::LinkPrompt => defaults(&[("cancel", &["esc"]), ("submit", &["enter"])]),
1117    }
1118}
1119
1120macro_rules! overlay_fields {
1121    ($map:ident, $overrides:ident, $context:expr, $($field:ident => $action:ident),+ $(,)?) => {
1122        $(
1123            if let Some(values) = &$overrides.$field {
1124                let mut chords = Vec::with_capacity(values.len());
1125                for value in values {
1126                    chords.push(KeyChord::parse(value).map_err(|message| TixError::Config(format!(
1127                        "`keybindings.{}.{}`: {message}", $context.as_str(), stringify!($field)
1128                    )))?);
1129                }
1130                if chords.is_empty() {
1131                    return Err(TixError::Config(format!(
1132                        "`keybindings.{}.{}` must not be empty", $context.as_str(), stringify!($field)
1133                    )));
1134                }
1135                $map.insert(TuiAction::$action, chords);
1136            }
1137        )+
1138    };
1139}
1140
1141fn overlay(
1142    map: &mut HashMap<TuiAction, Vec<KeyChord>>,
1143    overrides: &ActionBindings,
1144    context: InputContext,
1145) -> Result<()> {
1146    overlay_fields!(map, overrides, context,
1147        move_up => MoveUp, move_down => MoveDown, move_left => MoveLeft, move_right => MoveRight,
1148        next_board => NextBoard, previous_board => PreviousBoard, open => Open, edit => Edit,
1149        agent => Agent, toggle_column => ToggleColumn, filter_modal => FilterModal,
1150        bulk_modal => BulkModal, mark => Mark, unmark => Unmark, triage => Triage,
1151        move_previous => MovePrevious, move_next => MoveNext, new_issue => NewIssue,
1152        clone_issue => CloneIssue,
1153        toggle_archived => ToggleArchived,
1154        delete => Delete, refresh => Refresh, settings => Settings, planning => Planning,
1155        new_board => NewBoard, help => Help, quit => Quit, text_filter => TextFilter, reorder_up => ReorderUp,
1156        reorder_down => ReorderDown, close => Close, scroll_up => ScrollUp, scroll_down => ScrollDown,
1157        comment => Comment, asset => Asset, submit => Submit, cancel => Cancel, next => Next,
1158        previous => Previous, open_picker => OpenPicker, reset => Reset, toggle => Toggle,
1159        add_column => AddColumn, new_planning_item => NewPlanningItem,
1160        edit_planning_item => EditPlanningItem, delete_planning_item => DeletePlanningItem,
1161        confirm => Confirm, create => Create, previous_year => PreviousYear, next_year => NextYear,
1162        previous_month => PreviousMonth, next_month => NextMonth, reedit => Reedit, ignore => Ignore,
1163    );
1164    let mut seen: HashMap<KeyChord, TuiAction> = HashMap::new();
1165    for (action, chords) in map {
1166        for key in chords {
1167            if let Some(previous) = seen.insert(*key, *action) {
1168                return Err(TixError::Config(format!(
1169                    "`keybindings.{}`: `{}` is assigned to both `{}` and `{}`",
1170                    context.as_str(),
1171                    key,
1172                    previous.as_str(),
1173                    action.as_str()
1174                )));
1175            }
1176        }
1177    }
1178    Ok(())
1179}
1180
1181impl ActionBindings {
1182    pub fn is_empty(&self) -> bool {
1183        self == &Self::default()
1184    }
1185}
1186
1187#[cfg(test)]
1188mod tests {
1189    use super::*;
1190
1191    #[test]
1192    fn parses_and_normalizes_documented_chords() {
1193        assert_eq!(KeyChord::parse("ESC").unwrap(), chord("esc"));
1194        assert_eq!(KeyChord::parse("Enter").unwrap(), chord("enter"));
1195        assert_eq!(KeyChord::parse("LEFT").unwrap(), chord("left"));
1196        assert_eq!(KeyChord::parse("PgUp").unwrap(), chord("pgup"));
1197        assert_eq!(KeyChord::parse("F2").unwrap(), chord("f2"));
1198        assert_eq!(KeyChord::parse("Ctrl+UP").unwrap(), chord("ctrl+up"));
1199        assert_eq!(KeyChord::parse("A").unwrap().display(), "Shift+A");
1200        assert!(KeyChord::parse("ctrl+wat").is_err());
1201        assert!(KeyChord::parse("f99").is_err());
1202    }
1203
1204    #[test]
1205    fn defaults_validate_and_cross_mode_reuse_is_allowed() {
1206        let defaults = KeyBindings::resolve(&KeyBindings::default()).unwrap();
1207        assert!(
1208            defaults
1209                .action(
1210                    InputContext::Board,
1211                    KeyEvent::new(KeyCode::Char('q'), KeyModifiers::NONE)
1212                )
1213                .is_some()
1214        );
1215        let mut overrides = KeyBindings::default();
1216        overrides.board.open = Some(vec!["f4".into()]);
1217        overrides.detail.close = Some(vec!["f4".into()]);
1218        assert!(KeyBindings::resolve(&overrides).is_ok());
1219    }
1220
1221    #[test]
1222    fn conflicts_report_mode_and_actions() {
1223        let mut overrides = KeyBindings::default();
1224        overrides.board.open = Some(vec!["ctrl+n".into()]);
1225        overrides.board.new_issue = Some(vec!["ctrl+n".into()]);
1226        let error = KeyBindings::resolve(&overrides).unwrap_err().to_string();
1227        assert!(error.contains("board") && error.contains("open") && error.contains("new_issue"));
1228    }
1229}