1use crate::keyboard::{KeyCode, KeyEvent, KeyModifiers};
2use crate::state::Mode;
3use serde::{Deserialize, Serialize};
4use std::collections::HashMap;
5use std::str::FromStr;
6
7pub struct CommandLabels {
9 pub hint: &'static str,
11 pub description: &'static str,
13}
14
15macro_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 #[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 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 Noop {
77 config_name: "noop",
78 aliases: ["none", "unbound"],
79 hint: "unbound",
80 description: "Unbound",
81 },
82
83 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 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 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 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 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 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
251pub 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 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#[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#[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 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 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 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 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 pub fn find_key(keymap: &KeyMap, command: &Command) -> Option<KeyEvent> {
442 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 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 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
740impl<'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 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 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 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}