Skip to main content

kiosk_core/config/
keys.rs

1use crate::keyboard::{KeyCode, KeyEvent, KeyModifiers};
2use crate::state::Mode;
3use serde::{Deserialize, Serialize};
4use std::collections::HashMap;
5use std::str::FromStr;
6
7/// Labels for a command: short hint for footer bar, long description for help overlay.
8pub struct CommandLabels {
9    /// Short label for the footer bar.
10    pub hint: &'static str,
11    /// Full description for the help overlay.
12    pub description: &'static str,
13}
14
15/// Single source of truth for every `Command` variant and its metadata.
16///
17/// Each entry defines: variant name, config string, optional parse aliases,
18/// footer hint, and help description. The macro generates the enum plus
19/// `FromStr`, `Display`, `Serialize`, and `labels()` — so adding a new
20/// command is a one-line change with no risk of forgetting a match arm.
21macro_rules! define_commands {
22    (
23        $(
24            $variant:ident {
25                config_name: $config_name:literal,
26                $(aliases: [$($alias:literal),+ $(,)?],)?
27                hint: $hint:literal,
28                description: $desc:literal,
29            }
30        ),* $(,)?
31    ) => {
32        /// Commands that can be bound to keys
33        #[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash)]
34        pub enum Command { $($variant),* }
35
36        impl FromStr for Command {
37            type Err = String;
38            fn from_str(s: &str) -> Result<Self, Self::Err> {
39                match s {
40                    $($config_name $($(| $alias)+)? => Ok(Command::$variant),)*
41                    _ => Err(format!("Unknown command: {s}")),
42                }
43            }
44        }
45
46        impl std::fmt::Display for Command {
47            fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
48                f.write_str(match self {
49                    $(Command::$variant => $config_name),*
50                })
51            }
52        }
53
54        impl Serialize for Command {
55            fn serialize<S: serde::Serializer>(&self, serializer: S) -> Result<S::Ok, S::Error> {
56                serializer.serialize_str(&self.to_string())
57            }
58        }
59
60        impl Command {
61            /// Get the labels (footer hint + help description) for this command.
62            pub fn labels(&self) -> CommandLabels {
63                match self {
64                    $(Command::$variant => CommandLabels {
65                        hint: $hint,
66                        description: $desc,
67                    }),*
68                }
69            }
70        }
71    };
72}
73
74define_commands! {
75    // Special
76    Noop {
77        config_name: "noop",
78        aliases: ["none", "unbound"],
79        hint: "unbound",
80        description: "Unbound",
81    },
82
83    // General
84    Quit {
85        config_name: "quit",
86        hint: "quit",
87        description: "Quit the application",
88    },
89    ShowHelp {
90        config_name: "show_help",
91        hint: "help",
92        description: "Show help",
93    },
94
95    // Navigation
96    OpenRepo {
97        config_name: "open_repo",
98        hint: "open",
99        description: "Open repository in tmux",
100    },
101    EnterRepo {
102        config_name: "enter_repo",
103        hint: "branches",
104        description: "Browse branches",
105    },
106    OpenBranch {
107        config_name: "open_branch",
108        hint: "open/create branch",
109        description: "Open branch in tmux",
110    },
111    GoBack {
112        config_name: "go_back",
113        hint: "back",
114        description: "Go back",
115    },
116    NewBranch {
117        config_name: "new_branch",
118        hint: "new branch",
119        description: "New branch",
120    },
121    DeleteWorktree {
122        config_name: "delete_worktree",
123        hint: "delete worktree",
124        description: "Delete worktree",
125    },
126
127    // List movement
128    MoveUp {
129        config_name: "move_up",
130        hint: "up",
131        description: "Move up",
132    },
133    MoveDown {
134        config_name: "move_down",
135        hint: "down",
136        description: "Move down",
137    },
138    HalfPageUp {
139        config_name: "half_page_up",
140        hint: "half page up",
141        description: "Half page up",
142    },
143    HalfPageDown {
144        config_name: "half_page_down",
145        hint: "half page down",
146        description: "Half page down",
147    },
148    PageUp {
149        config_name: "page_up",
150        hint: "page up",
151        description: "Page up",
152    },
153    PageDown {
154        config_name: "page_down",
155        hint: "page down",
156        description: "Page down",
157    },
158    MoveTop {
159        config_name: "move_top",
160        hint: "top",
161        description: "Move to top",
162    },
163    MoveBottom {
164        config_name: "move_bottom",
165        hint: "bottom",
166        description: "Move to bottom",
167    },
168
169    // Text editing — cursor movement (char → word → line)
170    MoveCursorLeft {
171        config_name: "move_cursor_left",
172        hint: "cursor left",
173        description: "Move cursor left",
174    },
175    MoveCursorRight {
176        config_name: "move_cursor_right",
177        hint: "cursor right",
178        description: "Move cursor right",
179    },
180    MoveCursorWordLeft {
181        config_name: "move_cursor_word_left",
182        hint: "word left",
183        description: "Move cursor word left",
184    },
185    MoveCursorWordRight {
186        config_name: "move_cursor_word_right",
187        hint: "word right",
188        description: "Move cursor word right",
189    },
190    MoveCursorStart {
191        config_name: "move_cursor_start",
192        hint: "cursor start",
193        description: "Move cursor to start",
194    },
195    MoveCursorEnd {
196        config_name: "move_cursor_end",
197        hint: "cursor end",
198        description: "Move cursor to end",
199    },
200
201    // Text editing — deletion (char → word → line)
202    DeleteBackwardChar {
203        config_name: "delete_backward_char",
204        hint: "del char back",
205        description: "Delete backward char",
206    },
207    DeleteForwardChar {
208        config_name: "delete_forward_char",
209        hint: "del char fwd",
210        description: "Delete forward char",
211    },
212    DeleteBackwardWord {
213        config_name: "delete_backward_word",
214        hint: "del word back",
215        description: "Delete backward word",
216    },
217    DeleteForwardWord {
218        config_name: "delete_forward_word",
219        hint: "del word fwd",
220        description: "Delete forward word",
221    },
222    DeleteToStart {
223        config_name: "delete_to_start",
224        hint: "del to start",
225        description: "Delete to start of line",
226    },
227    DeleteToEnd {
228        config_name: "delete_to_end",
229        hint: "del to end",
230        description: "Delete to end of line",
231    },
232
233    // Modal
234    Confirm {
235        config_name: "confirm",
236        hint: "confirm",
237        description: "Confirm",
238    },
239    Cancel {
240        config_name: "cancel",
241        hint: "cancel",
242        description: "Cancel",
243    },
244    TabComplete {
245        config_name: "tab_complete",
246        hint: "complete",
247        description: "Tab completion",
248    },
249}
250
251/// Key bindings for a specific layer/mode
252pub type KeyMap = HashMap<KeyEvent, Command>;
253
254#[derive(Debug, Clone, PartialEq, Eq)]
255pub struct KeybindingEntry {
256    pub key: KeyEvent,
257    pub command: Command,
258    pub description: &'static str,
259}
260
261#[derive(Debug, Clone, PartialEq, Eq)]
262pub struct KeybindingSection {
263    pub name: &'static str,
264    pub entries: Vec<KeybindingEntry>,
265}
266
267#[derive(Debug, Clone, PartialEq, Eq)]
268pub struct FlattenedKeybindingRow {
269    pub section_index: usize,
270    pub section_name: &'static str,
271    pub key_display: String,
272    pub command: Command,
273    pub description: &'static str,
274}
275
276#[derive(Debug, Clone, PartialEq, Eq)]
277pub struct ModeKeybindingCatalog {
278    pub mode: Mode,
279    pub sections: Vec<KeybindingSection>,
280    pub flattened: Vec<FlattenedKeybindingRow>,
281}
282
283#[repr(u8)]
284#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
285enum Layer {
286    // Layer precedence source of truth: earlier variants are lower precedence.
287    General,
288    TextEdit,
289    ListNavigation,
290    RepoSelect,
291    BranchSelect,
292    Modal,
293}
294
295impl Layer {
296    const ORDER_ASC: [Layer; 6] = [
297        Layer::General,
298        Layer::TextEdit,
299        Layer::ListNavigation,
300        Layer::RepoSelect,
301        Layer::BranchSelect,
302        Layer::Modal,
303    ];
304
305    fn section_name(self) -> &'static str {
306        match self {
307            Layer::General => "general",
308            Layer::TextEdit => "text_edit",
309            Layer::ListNavigation => "list_navigation",
310            Layer::RepoSelect => "repo_select",
311            Layer::BranchSelect => "branch_select",
312            Layer::Modal => "modal",
313        }
314    }
315}
316
317/// Complete key binding configuration, composed from reusable layers.
318#[derive(Debug, Clone, Serialize)]
319pub struct KeysConfig {
320    pub general: KeyMap,
321    pub text_edit: KeyMap,
322    pub list_navigation: KeyMap,
323    pub modal: KeyMap,
324    pub repo_select: KeyMap,
325    pub branch_select: KeyMap,
326}
327
328/// Intermediate structure for deserializing key bindings
329#[derive(Debug, Deserialize)]
330struct KeysConfigRaw {
331    #[serde(default)]
332    general: HashMap<String, String>,
333    #[serde(default)]
334    text_edit: HashMap<String, String>,
335    #[serde(default)]
336    list_navigation: HashMap<String, String>,
337    #[serde(default)]
338    modal: HashMap<String, String>,
339    #[serde(default)]
340    repo_select: HashMap<String, String>,
341    #[serde(default)]
342    branch_select: HashMap<String, String>,
343}
344
345impl Default for KeysConfig {
346    fn default() -> Self {
347        Self::new()
348    }
349}
350
351impl KeysConfig {
352    pub fn new() -> Self {
353        Self {
354            general: Self::default_general(),
355            text_edit: Self::default_text_edit(),
356            list_navigation: Self::default_list_navigation(),
357            modal: Self::default_modal(),
358            repo_select: Self::default_repo_select(),
359            branch_select: Self::default_branch_select(),
360        }
361    }
362
363    /// Build the effective keymap for a given app mode.
364    pub fn keymap_for_mode(&self, mode: &Mode) -> KeyMap {
365        let mut combined = KeyMap::new();
366        for layer in Layer::ORDER_ASC {
367            if Self::mode_uses_layer(mode, layer) {
368                Self::apply_layer(&mut combined, self.layer(layer));
369            }
370        }
371
372        combined
373    }
374
375    /// Build keybinding sections for a mode, filtering out entries that are
376    /// overridden or unbound by higher-priority layers in the effective keymap.
377    pub fn sections_for_mode(&self, mode: &Mode) -> Vec<KeybindingSection> {
378        let effective = self.keymap_for_mode(mode);
379        Layer::ORDER_ASC
380            .into_iter()
381            .filter(|layer| Self::mode_uses_layer(mode, *layer))
382            .map(|layer| KeybindingSection {
383                name: layer.section_name(),
384                entries: Self::entries_for_layer(self.layer(layer))
385                    .into_iter()
386                    .filter(|e| effective.get(&e.key) == Some(&e.command))
387                    .collect(),
388            })
389            .filter(|section| !section.entries.is_empty())
390            .collect()
391    }
392
393    /// Build sectioned and flattened keybinding data for a mode.
394    /// Sections are returned in descending precedence order (highest first)
395    /// so the most relevant bindings appear at the top of the help overlay.
396    pub fn catalog_for_mode(&self, mode: &Mode) -> ModeKeybindingCatalog {
397        let mut sections = self.sections_for_mode(mode);
398        sections.reverse();
399        let flattened = sections
400            .iter()
401            .enumerate()
402            .flat_map(|(section_index, section)| {
403                section
404                    .entries
405                    .iter()
406                    .map(move |entry| FlattenedKeybindingRow {
407                        section_index,
408                        section_name: section.name,
409                        key_display: entry.key.to_string(),
410                        command: entry.command.clone(),
411                        description: entry.description,
412                    })
413            })
414            .collect();
415
416        ModeKeybindingCatalog {
417            mode: mode.clone(),
418            sections,
419            flattened,
420        }
421    }
422
423    /// Return key-layer section names ordered from lowest to highest precedence.
424    pub fn docs_section_order_asc() -> Vec<&'static str> {
425        Layer::ORDER_ASC
426            .into_iter()
427            .map(Layer::section_name)
428            .collect()
429    }
430
431    #[cfg(test)]
432    fn layer_order_names_for_mode(mode: &Mode) -> Vec<&'static str> {
433        Layer::ORDER_ASC
434            .into_iter()
435            .filter(|layer| Self::mode_uses_layer(mode, *layer))
436            .map(Layer::section_name)
437            .collect()
438    }
439
440    /// Find the first key bound to a given command in a keymap.
441    pub fn find_key(keymap: &KeyMap, command: &Command) -> Option<KeyEvent> {
442        // Prefer shorter/simpler key representations
443        let mut found: Vec<_> = keymap
444            .iter()
445            .filter(|(_, cmd)| *cmd == command)
446            .map(|(key, _)| *key)
447            .collect();
448        found.sort();
449        found.into_iter().next()
450    }
451
452    fn apply_layer(base: &mut KeyMap, layer: &KeyMap) {
453        for (key, command) in layer {
454            if *command == Command::Noop {
455                base.remove(key);
456            } else {
457                base.insert(*key, command.clone());
458            }
459        }
460    }
461
462    fn entries_for_layer(layer: &KeyMap) -> Vec<KeybindingEntry> {
463        let mut entries: Vec<KeybindingEntry> = layer
464            .iter()
465            .filter_map(|(key, command)| {
466                if *command == Command::Noop {
467                    None
468                } else {
469                    Some(KeybindingEntry {
470                        key: *key,
471                        command: command.clone(),
472                        description: command.labels().description,
473                    })
474                }
475            })
476            .collect();
477
478        entries.sort_by(|a, b| {
479            a.command
480                .cmp(&b.command)
481                .then_with(|| a.key.to_string().cmp(&b.key.to_string()))
482        });
483        entries
484    }
485
486    fn layer(&self, layer: Layer) -> &KeyMap {
487        match layer {
488            Layer::General => &self.general,
489            Layer::TextEdit => &self.text_edit,
490            Layer::ListNavigation => &self.list_navigation,
491            Layer::RepoSelect => &self.repo_select,
492            Layer::BranchSelect => &self.branch_select,
493            Layer::Modal => &self.modal,
494        }
495    }
496
497    fn mode_uses_layer(mode: &Mode, layer: Layer) -> bool {
498        match layer {
499            Layer::General => true,
500            Layer::TextEdit => mode.supports_text_edit(),
501            Layer::ListNavigation => mode.supports_list_navigation(),
502            Layer::RepoSelect => mode.supports_repo_select_actions(),
503            Layer::BranchSelect => mode.supports_branch_select_actions(),
504            Layer::Modal => mode.supports_modal_actions(),
505        }
506    }
507
508    fn default_general() -> KeyMap {
509        let mut map = KeyMap::new();
510        map.insert(
511            KeyEvent::new(KeyCode::Char('c'), KeyModifiers::CONTROL),
512            Command::Quit,
513        );
514        map.insert(
515            KeyEvent::new(KeyCode::Char('h'), KeyModifiers::CONTROL),
516            Command::ShowHelp,
517        );
518        map
519    }
520
521    fn default_text_edit() -> KeyMap {
522        let mut map = KeyMap::new();
523        map.insert(
524            KeyEvent::new(KeyCode::Backspace, KeyModifiers::NONE),
525            Command::DeleteBackwardChar,
526        );
527        map.insert(
528            KeyEvent::new(KeyCode::Delete, KeyModifiers::NONE),
529            Command::DeleteForwardChar,
530        );
531        map.insert(
532            KeyEvent::new(KeyCode::Char('d'), KeyModifiers::CONTROL),
533            Command::DeleteForwardChar,
534        );
535        map.insert(
536            KeyEvent::new(KeyCode::Char('w'), KeyModifiers::CONTROL),
537            Command::DeleteBackwardWord,
538        );
539        map.insert(
540            KeyEvent::new(KeyCode::Backspace, KeyModifiers::ALT),
541            Command::DeleteBackwardWord,
542        );
543        map.insert(
544            KeyEvent::new(KeyCode::Char('d'), KeyModifiers::ALT),
545            Command::DeleteForwardWord,
546        );
547        map.insert(
548            KeyEvent::new(KeyCode::Char('u'), KeyModifiers::CONTROL),
549            Command::DeleteToStart,
550        );
551        map.insert(
552            KeyEvent::new(KeyCode::Char('k'), KeyModifiers::CONTROL),
553            Command::DeleteToEnd,
554        );
555        map.insert(
556            KeyEvent::new(KeyCode::Left, KeyModifiers::NONE),
557            Command::MoveCursorLeft,
558        );
559        map.insert(
560            KeyEvent::new(KeyCode::Right, KeyModifiers::NONE),
561            Command::MoveCursorRight,
562        );
563        map.insert(
564            KeyEvent::new(KeyCode::Char('b'), KeyModifiers::ALT),
565            Command::MoveCursorWordLeft,
566        );
567        map.insert(
568            KeyEvent::new(KeyCode::Left, KeyModifiers::ALT),
569            Command::MoveCursorWordLeft,
570        );
571        map.insert(
572            KeyEvent::new(KeyCode::Char('f'), KeyModifiers::ALT),
573            Command::MoveCursorWordRight,
574        );
575        map.insert(
576            KeyEvent::new(KeyCode::Right, KeyModifiers::ALT),
577            Command::MoveCursorWordRight,
578        );
579        map.insert(
580            KeyEvent::new(KeyCode::Home, KeyModifiers::NONE),
581            Command::MoveCursorStart,
582        );
583        map.insert(
584            KeyEvent::new(KeyCode::Char('a'), KeyModifiers::CONTROL),
585            Command::MoveCursorStart,
586        );
587        map.insert(
588            KeyEvent::new(KeyCode::End, KeyModifiers::NONE),
589            Command::MoveCursorEnd,
590        );
591        map.insert(
592            KeyEvent::new(KeyCode::Char('e'), KeyModifiers::CONTROL),
593            Command::MoveCursorEnd,
594        );
595        map
596    }
597
598    fn default_list_navigation() -> KeyMap {
599        let mut map = KeyMap::new();
600        map.insert(
601            KeyEvent::new(KeyCode::Up, KeyModifiers::NONE),
602            Command::MoveUp,
603        );
604        map.insert(
605            KeyEvent::new(KeyCode::Down, KeyModifiers::NONE),
606            Command::MoveDown,
607        );
608        map.insert(
609            KeyEvent::new(KeyCode::Char('p'), KeyModifiers::CONTROL),
610            Command::MoveUp,
611        );
612        map.insert(
613            KeyEvent::new(KeyCode::Char('n'), KeyModifiers::CONTROL),
614            Command::MoveDown,
615        );
616        map.insert(
617            KeyEvent::new(KeyCode::Char('j'), KeyModifiers::ALT),
618            Command::HalfPageDown,
619        );
620        map.insert(
621            KeyEvent::new(KeyCode::Char('k'), KeyModifiers::ALT),
622            Command::HalfPageUp,
623        );
624        map.insert(
625            KeyEvent::new(KeyCode::PageUp, KeyModifiers::NONE),
626            Command::PageUp,
627        );
628        map.insert(
629            KeyEvent::new(KeyCode::PageDown, KeyModifiers::NONE),
630            Command::PageDown,
631        );
632        map.insert(
633            KeyEvent::new(KeyCode::Char('v'), KeyModifiers::CONTROL),
634            Command::PageDown,
635        );
636        map.insert(
637            KeyEvent::new(KeyCode::Char('v'), KeyModifiers::ALT),
638            Command::PageUp,
639        );
640        map.insert(
641            KeyEvent::new(KeyCode::Char('g'), KeyModifiers::ALT),
642            Command::MoveTop,
643        );
644        map.insert(
645            KeyEvent::new(KeyCode::Char('G'), KeyModifiers::ALT),
646            Command::MoveBottom,
647        );
648        map
649    }
650
651    fn default_modal() -> KeyMap {
652        let mut map = KeyMap::new();
653        map.insert(
654            KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE),
655            Command::Confirm,
656        );
657        map.insert(
658            KeyEvent::new(KeyCode::Esc, KeyModifiers::NONE),
659            Command::Cancel,
660        );
661        map.insert(
662            KeyEvent::new(KeyCode::Tab, KeyModifiers::NONE),
663            Command::TabComplete,
664        );
665        map
666    }
667
668    fn default_repo_select() -> KeyMap {
669        let mut map = KeyMap::new();
670        map.insert(
671            KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE),
672            Command::OpenRepo,
673        );
674        map.insert(
675            KeyEvent::new(KeyCode::Tab, KeyModifiers::NONE),
676            Command::EnterRepo,
677        );
678        map.insert(
679            KeyEvent::new(KeyCode::Esc, KeyModifiers::NONE),
680            Command::Quit,
681        );
682        map
683    }
684
685    fn default_branch_select() -> KeyMap {
686        let mut map = KeyMap::new();
687        map.insert(
688            KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE),
689            Command::OpenBranch,
690        );
691        map.insert(
692            KeyEvent::new(KeyCode::Esc, KeyModifiers::NONE),
693            Command::GoBack,
694        );
695        map.insert(
696            KeyEvent::new(KeyCode::Char('o'), KeyModifiers::CONTROL),
697            Command::NewBranch,
698        );
699        map.insert(
700            KeyEvent::new(KeyCode::Char('x'), KeyModifiers::CONTROL),
701            Command::DeleteWorktree,
702        );
703        map
704    }
705
706    /// Parse a string representation of keybindings into a `KeyMap`
707    fn parse_keymap(raw_map: &HashMap<String, String>) -> Result<KeyMap, String> {
708        let mut keymap = KeyMap::new();
709        for (key_str, command_str) in raw_map {
710            let key_event =
711                KeyEvent::from_str(key_str).map_err(|e| format!("Invalid key '{key_str}': {e}"))?;
712            let command = Command::from_str(command_str)
713                .map_err(|e| format!("Invalid command '{command_str}': {e}"))?;
714            keymap.insert(key_event, command);
715        }
716        Ok(keymap)
717    }
718
719    fn extend_layer(base: &mut KeyMap, raw_map: &HashMap<String, String>) -> Result<(), String> {
720        base.extend(Self::parse_keymap(raw_map)?);
721        Ok(())
722    }
723
724    /// Merge user configuration with defaults.
725    ///
726    /// Keep `Noop` values so higher-precedence layers can explicitly unbind inherited mappings.
727    fn from_raw(raw: &KeysConfigRaw) -> Result<Self, String> {
728        let mut config = Self::default();
729        Self::extend_layer(&mut config.general, &raw.general)?;
730        Self::extend_layer(&mut config.text_edit, &raw.text_edit)?;
731        Self::extend_layer(&mut config.list_navigation, &raw.list_navigation)?;
732        Self::extend_layer(&mut config.modal, &raw.modal)?;
733        Self::extend_layer(&mut config.repo_select, &raw.repo_select)?;
734        Self::extend_layer(&mut config.branch_select, &raw.branch_select)?;
735
736        Ok(config)
737    }
738}
739
740// Custom deserializer for KeysConfig
741impl<'de> Deserialize<'de> for KeysConfig {
742    fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
743    where
744        D: serde::Deserializer<'de>,
745    {
746        let raw = KeysConfigRaw::deserialize(deserializer)?;
747        KeysConfig::from_raw(&raw).map_err(serde::de::Error::custom)
748    }
749}
750
751#[cfg(test)]
752mod tests {
753    use super::*;
754
755    #[test]
756    fn test_command_from_str() {
757        assert_eq!(Command::from_str("quit").unwrap(), Command::Quit);
758        assert_eq!(
759            Command::from_str("delete_backward_char").unwrap(),
760            Command::DeleteBackwardChar
761        );
762        assert!(Command::from_str("invalid_command").is_err());
763    }
764
765    #[test]
766    fn test_command_display() {
767        assert_eq!(Command::Quit.to_string(), "quit");
768        assert_eq!(
769            Command::DeleteBackwardWord.to_string(),
770            "delete_backward_word"
771        );
772    }
773
774    #[test]
775    fn test_default_keys_config() {
776        let config = KeysConfig::default();
777        assert!(!config.general.is_empty());
778        assert!(!config.text_edit.is_empty());
779        assert!(!config.list_navigation.is_empty());
780        assert!(!config.modal.is_empty());
781        assert!(!config.repo_select.is_empty());
782        assert!(!config.branch_select.is_empty());
783    }
784
785    #[test]
786    fn test_parse_keymap() {
787        let mut raw_map = HashMap::new();
788        raw_map.insert("C-c".to_string(), "quit".to_string());
789        raw_map.insert("enter".to_string(), "confirm".to_string());
790
791        let keymap = KeysConfig::parse_keymap(&raw_map).unwrap();
792        assert_eq!(keymap.len(), 2);
793
794        let ctrl_c = KeyEvent::new(KeyCode::Char('c'), KeyModifiers::CONTROL);
795        assert_eq!(keymap.get(&ctrl_c), Some(&Command::Quit));
796
797        let enter = KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE);
798        assert_eq!(keymap.get(&enter), Some(&Command::Confirm));
799    }
800
801    #[test]
802    fn test_parse_invalid_key() {
803        let mut raw_map = HashMap::new();
804        raw_map.insert("invalid-key".to_string(), "quit".to_string());
805
806        let result = KeysConfig::parse_keymap(&raw_map);
807        assert!(result.is_err());
808    }
809
810    #[test]
811    fn test_parse_invalid_command() {
812        let mut raw_map = HashMap::new();
813        raw_map.insert("C-c".to_string(), "invalid_command".to_string());
814
815        let result = KeysConfig::parse_keymap(&raw_map);
816        assert!(result.is_err());
817    }
818
819    #[test]
820    fn test_mode_precedence_more_specific_wins() {
821        let raw = KeysConfigRaw {
822            general: HashMap::new(),
823            text_edit: HashMap::new(),
824            list_navigation: HashMap::new(),
825            modal: HashMap::new(),
826            repo_select: {
827                let mut map = HashMap::new();
828                map.insert("C-c".to_string(), "show_help".to_string());
829                map
830            },
831            branch_select: HashMap::new(),
832        };
833
834        let config = KeysConfig::from_raw(&raw).unwrap();
835        let map = config.keymap_for_mode(&Mode::RepoSelect);
836        let ctrl_c = KeyEvent::new(KeyCode::Char('c'), KeyModifiers::CONTROL);
837        assert_eq!(map.get(&ctrl_c), Some(&Command::ShowHelp));
838    }
839
840    #[test]
841    fn test_noop_can_unbind_inherited_mapping() {
842        let raw = KeysConfigRaw {
843            general: HashMap::new(),
844            text_edit: HashMap::new(),
845            list_navigation: HashMap::new(),
846            modal: HashMap::new(),
847            repo_select: HashMap::new(),
848            branch_select: {
849                let mut map = HashMap::new();
850                map.insert("C-n".to_string(), "noop".to_string());
851                map
852            },
853        };
854
855        let config = KeysConfig::from_raw(&raw).unwrap();
856        let map = config.keymap_for_mode(&Mode::BranchSelect);
857        let ctrl_n = KeyEvent::new(KeyCode::Char('n'), KeyModifiers::CONTROL);
858        assert_eq!(map.get(&ctrl_n), None, "C-n should be unbound");
859    }
860
861    #[test]
862    fn test_find_key_reverse_lookup() {
863        let config = KeysConfig::default();
864        let keymap = config.keymap_for_mode(&Mode::RepoSelect);
865        let key = KeysConfig::find_key(&keymap, &Command::Quit);
866        assert_eq!(
867            key,
868            Some(KeyEvent::new(KeyCode::Char('c'), KeyModifiers::CONTROL))
869        );
870    }
871
872    #[test]
873    fn test_default_text_edit_bindings() {
874        let config = KeysConfig::default();
875        let keymap = config.keymap_for_mode(&Mode::RepoSelect);
876
877        let left = KeyEvent::new(KeyCode::Left, KeyModifiers::NONE);
878        let right = KeyEvent::new(KeyCode::Right, KeyModifiers::NONE);
879        let home = KeyEvent::new(KeyCode::Home, KeyModifiers::NONE);
880        let end = KeyEvent::new(KeyCode::End, KeyModifiers::NONE);
881
882        assert_eq!(keymap.get(&left), Some(&Command::MoveCursorLeft));
883        assert_eq!(keymap.get(&right), Some(&Command::MoveCursorRight));
884        assert_eq!(keymap.get(&home), Some(&Command::MoveCursorStart));
885        assert_eq!(keymap.get(&end), Some(&Command::MoveCursorEnd));
886    }
887
888    #[test]
889    fn test_modal_precedence_over_general_in_confirm_delete() {
890        let raw = KeysConfigRaw {
891            general: {
892                let mut map = HashMap::new();
893                map.insert("enter".to_string(), "quit".to_string());
894                map
895            },
896            text_edit: HashMap::new(),
897            list_navigation: HashMap::new(),
898            modal: HashMap::new(),
899            repo_select: HashMap::new(),
900            branch_select: HashMap::new(),
901        };
902
903        let config = KeysConfig::from_raw(&raw).unwrap();
904        let map = config.keymap_for_mode(&Mode::ConfirmWorktreeDelete {
905            branch_name: "x".to_string(),
906            has_session: false,
907        });
908        let enter = KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE);
909        assert_eq!(map.get(&enter), Some(&Command::Confirm));
910    }
911
912    #[test]
913    fn test_modal_noop_can_unbind_general_in_confirm_delete() {
914        let raw = KeysConfigRaw {
915            general: {
916                let mut map = HashMap::new();
917                map.insert("esc".to_string(), "quit".to_string());
918                map
919            },
920            text_edit: HashMap::new(),
921            list_navigation: HashMap::new(),
922            modal: {
923                let mut map = HashMap::new();
924                map.insert("esc".to_string(), "noop".to_string());
925                map
926            },
927            repo_select: HashMap::new(),
928            branch_select: HashMap::new(),
929        };
930
931        let config = KeysConfig::from_raw(&raw).unwrap();
932        let map = config.keymap_for_mode(&Mode::ConfirmWorktreeDelete {
933            branch_name: "x".to_string(),
934            has_session: false,
935        });
936        let esc = KeyEvent::new(KeyCode::Esc, KeyModifiers::NONE);
937        assert_eq!(map.get(&esc), None, "Esc should be unbound in modal");
938    }
939
940    #[test]
941    fn test_layer_order_is_exported_for_docs() {
942        assert_eq!(
943            KeysConfig::layer_order_names_for_mode(&Mode::RepoSelect),
944            vec!["general", "text_edit", "list_navigation", "repo_select"]
945        );
946        assert_eq!(
947            KeysConfig::layer_order_names_for_mode(&Mode::SelectBaseBranch),
948            vec!["general", "text_edit", "list_navigation", "modal"]
949        );
950        assert_eq!(
951            KeysConfig::layer_order_names_for_mode(&Mode::ConfirmWorktreeDelete {
952                branch_name: "x".to_string(),
953                has_session: false,
954            }),
955            vec!["general", "modal"]
956        );
957    }
958
959    #[test]
960    fn test_docs_section_order_asc_is_derived_from_layer_precedence() {
961        assert_eq!(
962            KeysConfig::docs_section_order_asc(),
963            vec![
964                "general",
965                "text_edit",
966                "list_navigation",
967                "repo_select",
968                "branch_select",
969                "modal",
970            ]
971        );
972    }
973
974    #[test]
975    fn test_sections_for_mode_uses_layer_precedence_order() {
976        let config = KeysConfig::default();
977        let section_names: Vec<&str> = config
978            .sections_for_mode(&Mode::BranchSelect)
979            .iter()
980            .map(|section| section.name)
981            .collect();
982
983        assert_eq!(
984            section_names,
985            vec!["general", "text_edit", "list_navigation", "branch_select"]
986        );
987    }
988
989    #[test]
990    fn test_sections_for_mode_excludes_noop_entries() {
991        let raw = KeysConfigRaw {
992            general: {
993                let mut map = HashMap::new();
994                map.insert("C-c".to_string(), "noop".to_string());
995                map.insert("C-h".to_string(), "show_help".to_string());
996                map
997            },
998            text_edit: HashMap::new(),
999            list_navigation: HashMap::new(),
1000            modal: HashMap::new(),
1001            repo_select: HashMap::new(),
1002            branch_select: HashMap::new(),
1003        };
1004
1005        let config = KeysConfig::from_raw(&raw).unwrap();
1006        let general = config
1007            .sections_for_mode(&Mode::RepoSelect)
1008            .into_iter()
1009            .find(|section| section.name == "general")
1010            .unwrap();
1011
1012        assert_eq!(general.entries.len(), 1);
1013        assert_eq!(general.entries[0].command, Command::ShowHelp);
1014    }
1015
1016    #[test]
1017    fn test_sections_for_mode_hides_entries_overridden_by_higher_layer_noop() {
1018        let raw = KeysConfigRaw {
1019            general: HashMap::new(),
1020            text_edit: HashMap::new(),
1021            list_navigation: HashMap::new(),
1022            modal: HashMap::new(),
1023            repo_select: HashMap::new(),
1024            branch_select: {
1025                let mut map = HashMap::new();
1026                // Override the inherited list_navigation C-n binding with noop
1027                map.insert("C-n".to_string(), "noop".to_string());
1028                map
1029            },
1030        };
1031
1032        let config = KeysConfig::from_raw(&raw).unwrap();
1033        let sections = config.sections_for_mode(&Mode::BranchSelect);
1034
1035        let list_nav = sections
1036            .iter()
1037            .find(|s| s.name == "list_navigation")
1038            .expect("list_navigation section should exist");
1039
1040        assert!(
1041            !list_nav
1042                .entries
1043                .iter()
1044                .any(|e| e.command == Command::MoveDown
1045                    && e.key == KeyEvent::new(KeyCode::Char('n'), KeyModifiers::CONTROL)),
1046            "C-n -> MoveDown should be hidden when overridden by higher-layer noop"
1047        );
1048
1049        // The 'down' key binding for MoveDown should still be present
1050        assert!(
1051            list_nav
1052                .entries
1053                .iter()
1054                .any(|e| e.command == Command::MoveDown
1055                    && e.key == KeyEvent::new(KeyCode::Down, KeyModifiers::NONE)),
1056            "down -> MoveDown should still be shown (not overridden)"
1057        );
1058    }
1059
1060    #[test]
1061    fn test_sections_for_mode_omits_fully_unbound_layers() {
1062        let raw = KeysConfigRaw {
1063            general: {
1064                let mut map = HashMap::new();
1065                map.insert("C-c".to_string(), "noop".to_string());
1066                map.insert("C-h".to_string(), "noop".to_string());
1067                map
1068            },
1069            text_edit: HashMap::new(),
1070            list_navigation: HashMap::new(),
1071            modal: HashMap::new(),
1072            repo_select: {
1073                let mut map = HashMap::new();
1074                map.insert("enter".to_string(), "open_repo".to_string());
1075                map
1076            },
1077            branch_select: HashMap::new(),
1078        };
1079
1080        let config = KeysConfig::from_raw(&raw).unwrap();
1081        let section_names: Vec<&str> = config
1082            .sections_for_mode(&Mode::RepoSelect)
1083            .iter()
1084            .map(|s| s.name)
1085            .collect();
1086
1087        assert!(
1088            !section_names.contains(&"general"),
1089            "Fully-unbound general layer should be omitted"
1090        );
1091        assert!(
1092            section_names.contains(&"repo_select"),
1093            "Layer with bindings should be present"
1094        );
1095    }
1096
1097    #[test]
1098    fn test_catalog_for_mode_flattened_order_is_deterministic() {
1099        let config = KeysConfig::default();
1100        let catalog = config.catalog_for_mode(&Mode::RepoSelect);
1101
1102        let section_names: Vec<&str> = catalog
1103            .sections
1104            .iter()
1105            .map(|section| section.name)
1106            .collect();
1107        assert_eq!(
1108            section_names,
1109            vec!["repo_select", "list_navigation", "text_edit", "general"]
1110        );
1111
1112        let mut previous_section_index = 0;
1113        let mut previous_command: Option<Command> = None;
1114        let mut previous_key = String::new();
1115        for row in &catalog.flattened {
1116            if row.section_index != previous_section_index {
1117                assert_eq!(row.section_index, previous_section_index + 1);
1118                previous_section_index = row.section_index;
1119            } else if previous_command.as_ref() == Some(&row.command) {
1120                assert!(previous_key <= row.key_display);
1121            } else {
1122                assert!(previous_command.as_ref() <= Some(&row.command));
1123            }
1124            previous_command = Some(row.command.clone());
1125            previous_key = row.key_display.clone();
1126        }
1127    }
1128
1129    #[test]
1130    fn test_modal_overrides_lower_layers_in_select_base_branch() {
1131        let raw = KeysConfigRaw {
1132            general: HashMap::new(),
1133            text_edit: HashMap::new(),
1134            list_navigation: {
1135                let mut map = HashMap::new();
1136                map.insert("enter".to_string(), "move_down".to_string());
1137                map
1138            },
1139            modal: HashMap::new(),
1140            repo_select: HashMap::new(),
1141            branch_select: HashMap::new(),
1142        };
1143
1144        let config = KeysConfig::from_raw(&raw).unwrap();
1145        let map = config.keymap_for_mode(&Mode::SelectBaseBranch);
1146        let enter = KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE);
1147        assert_eq!(
1148            map.get(&enter),
1149            Some(&Command::Confirm),
1150            "modal should have highest precedence in select-base flow"
1151        );
1152    }
1153
1154    #[test]
1155    fn test_default_text_edit_and_navigation_bindings() {
1156        let config = KeysConfig::default();
1157        let ctrl_u = KeyEvent::new(KeyCode::Char('u'), KeyModifiers::CONTROL);
1158        let ctrl_d = KeyEvent::new(KeyCode::Char('d'), KeyModifiers::CONTROL);
1159        let alt_j = KeyEvent::new(KeyCode::Char('j'), KeyModifiers::ALT);
1160        let alt_k = KeyEvent::new(KeyCode::Char('k'), KeyModifiers::ALT);
1161        let ctrl_v = KeyEvent::new(KeyCode::Char('v'), KeyModifiers::CONTROL);
1162        let alt_v = KeyEvent::new(KeyCode::Char('v'), KeyModifiers::ALT);
1163
1164        assert_eq!(
1165            config
1166                .keymap_for_mode(&Mode::RepoSelect)
1167                .get(&ctrl_u)
1168                .cloned(),
1169            Some(Command::DeleteToStart)
1170        );
1171        assert_eq!(
1172            config
1173                .keymap_for_mode(&Mode::RepoSelect)
1174                .get(&ctrl_d)
1175                .cloned(),
1176            Some(Command::DeleteForwardChar)
1177        );
1178        assert_eq!(
1179            config
1180                .keymap_for_mode(&Mode::RepoSelect)
1181                .get(&alt_j)
1182                .cloned(),
1183            Some(Command::HalfPageDown)
1184        );
1185        assert_eq!(
1186            config
1187                .keymap_for_mode(&Mode::RepoSelect)
1188                .get(&alt_k)
1189                .cloned(),
1190            Some(Command::HalfPageUp)
1191        );
1192        assert_eq!(
1193            config
1194                .keymap_for_mode(&Mode::RepoSelect)
1195                .get(&ctrl_v)
1196                .cloned(),
1197            Some(Command::PageDown)
1198        );
1199        assert_eq!(
1200            config
1201                .keymap_for_mode(&Mode::RepoSelect)
1202                .get(&alt_v)
1203                .cloned(),
1204            Some(Command::PageUp)
1205        );
1206    }
1207
1208    #[test]
1209    fn test_noop_aliases() {
1210        assert_eq!(Command::from_str("noop").unwrap(), Command::Noop);
1211        assert_eq!(Command::from_str("none").unwrap(), Command::Noop);
1212        assert_eq!(Command::from_str("unbound").unwrap(), Command::Noop);
1213    }
1214
1215    #[test]
1216    fn test_every_command_roundtrips_through_display_and_from_str() {
1217        let all_commands = [
1218            Command::Noop,
1219            Command::Quit,
1220            Command::ShowHelp,
1221            Command::OpenRepo,
1222            Command::EnterRepo,
1223            Command::OpenBranch,
1224            Command::GoBack,
1225            Command::NewBranch,
1226            Command::DeleteWorktree,
1227            Command::MoveUp,
1228            Command::MoveDown,
1229            Command::HalfPageUp,
1230            Command::HalfPageDown,
1231            Command::PageUp,
1232            Command::PageDown,
1233            Command::MoveTop,
1234            Command::MoveBottom,
1235            Command::DeleteBackwardChar,
1236            Command::DeleteForwardChar,
1237            Command::DeleteBackwardWord,
1238            Command::DeleteForwardWord,
1239            Command::DeleteToStart,
1240            Command::DeleteToEnd,
1241            Command::MoveCursorLeft,
1242            Command::MoveCursorRight,
1243            Command::MoveCursorWordLeft,
1244            Command::MoveCursorWordRight,
1245            Command::MoveCursorStart,
1246            Command::MoveCursorEnd,
1247            Command::Confirm,
1248            Command::Cancel,
1249            Command::TabComplete,
1250        ];
1251
1252        for cmd in &all_commands {
1253            let s = cmd.to_string();
1254            let parsed = Command::from_str(&s).unwrap_or_else(|e| {
1255                panic!("Command::{cmd:?} serializes as \"{s}\" but fails to parse back: {e}")
1256            });
1257            assert_eq!(
1258                &parsed, cmd,
1259                "Roundtrip failed for Command::{cmd:?} (serialized as \"{s}\")"
1260            );
1261        }
1262    }
1263
1264    #[test]
1265    fn test_every_command_has_non_empty_description() {
1266        let all_commands = [
1267            Command::Noop,
1268            Command::Quit,
1269            Command::ShowHelp,
1270            Command::OpenRepo,
1271            Command::EnterRepo,
1272            Command::OpenBranch,
1273            Command::GoBack,
1274            Command::NewBranch,
1275            Command::DeleteWorktree,
1276            Command::MoveUp,
1277            Command::MoveDown,
1278            Command::HalfPageUp,
1279            Command::HalfPageDown,
1280            Command::PageUp,
1281            Command::PageDown,
1282            Command::MoveTop,
1283            Command::MoveBottom,
1284            Command::DeleteBackwardChar,
1285            Command::DeleteForwardChar,
1286            Command::DeleteBackwardWord,
1287            Command::DeleteForwardWord,
1288            Command::DeleteToStart,
1289            Command::DeleteToEnd,
1290            Command::MoveCursorLeft,
1291            Command::MoveCursorRight,
1292            Command::MoveCursorWordLeft,
1293            Command::MoveCursorWordRight,
1294            Command::MoveCursorStart,
1295            Command::MoveCursorEnd,
1296            Command::Confirm,
1297            Command::Cancel,
1298            Command::TabComplete,
1299        ];
1300
1301        for cmd in &all_commands {
1302            let labels = cmd.labels();
1303            assert!(
1304                !labels.description.is_empty(),
1305                "Command::{cmd:?} has an empty description"
1306            );
1307        }
1308    }
1309
1310    #[test]
1311    fn test_catalog_for_mode_excludes_noop_from_flattened_rows() {
1312        let raw = KeysConfigRaw {
1313            general: {
1314                let mut map = HashMap::new();
1315                map.insert("C-c".to_string(), "noop".to_string());
1316                map.insert("C-h".to_string(), "show_help".to_string());
1317                map
1318            },
1319            text_edit: HashMap::new(),
1320            list_navigation: HashMap::new(),
1321            modal: HashMap::new(),
1322            repo_select: HashMap::new(),
1323            branch_select: HashMap::new(),
1324        };
1325
1326        let config = KeysConfig::from_raw(&raw).unwrap();
1327        let catalog = config.catalog_for_mode(&Mode::RepoSelect);
1328
1329        for row in &catalog.flattened {
1330            assert_ne!(
1331                row.command,
1332                Command::Noop,
1333                "Flattened rows should not contain Noop entries, found: {}",
1334                row.key_display
1335            );
1336        }
1337
1338        // Verify C-h (show_help) IS present
1339        assert!(
1340            catalog
1341                .flattened
1342                .iter()
1343                .any(|r| r.command == Command::ShowHelp),
1344            "Non-noop commands should still be in flattened rows"
1345        );
1346    }
1347
1348    #[test]
1349    fn test_footer_commands_all_have_hints() {
1350        let modes: Vec<Mode> = vec![
1351            Mode::RepoSelect,
1352            Mode::BranchSelect,
1353            Mode::SelectBaseBranch,
1354            Mode::ConfirmWorktreeDelete {
1355                branch_name: "x".into(),
1356                has_session: false,
1357            },
1358        ];
1359
1360        for mode in &modes {
1361            for cmd in mode.footer_commands() {
1362                assert!(
1363                    !cmd.labels().hint.is_empty(),
1364                    "Command::{cmd:?} is in footer_commands for {mode:?} but has an empty hint"
1365                );
1366            }
1367        }
1368    }
1369
1370    #[test]
1371    fn test_footer_commands_have_key_bindings() {
1372        let keys = KeysConfig::default();
1373        let modes: Vec<Mode> = vec![
1374            Mode::RepoSelect,
1375            Mode::BranchSelect,
1376            Mode::SelectBaseBranch,
1377            Mode::ConfirmWorktreeDelete {
1378                branch_name: "x".into(),
1379                has_session: false,
1380            },
1381        ];
1382
1383        for mode in &modes {
1384            let keymap = keys.keymap_for_mode(mode);
1385            for cmd in mode.footer_commands() {
1386                assert!(
1387                    KeysConfig::find_key(&keymap, cmd).is_some(),
1388                    "Command::{cmd:?} is in footer_commands for {mode:?} but has no default key binding"
1389                );
1390            }
1391        }
1392    }
1393
1394    #[test]
1395    fn test_loading_and_help_have_no_footer_commands() {
1396        assert!(
1397            Mode::Loading("test".into()).footer_commands().is_empty(),
1398            "Loading mode should have no footer commands"
1399        );
1400        assert!(
1401            Mode::Help {
1402                previous: Box::new(Mode::RepoSelect)
1403            }
1404            .footer_commands()
1405            .is_empty(),
1406            "Help mode should have no footer commands"
1407        );
1408    }
1409}