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 BulkModal,
17 Triage,
26 Cycle,
32 Milestones,
38 Repos,
44 Rollover,
45 Detail,
46 Form,
47 FormPicker,
48 ConfirmDelete,
49 MoveIntoDone,
50 ConfirmBlockedMove,
53 Help,
54 HelpSearch,
61 ContextMenu,
62 RelatedPicker,
68 Filter,
69 FilterModal,
70 Settings,
71 SettingsPrompt,
72 PlanningManagement,
73 PlanningConfirm,
74 PlanningForm,
75 DatePicker,
76 Comment,
77 Asset,
78 EditRepair,
86 EditConflict,
94 LinkPrompt,
100 Shell,
109}
110
111impl InputContext {
112 pub const ALL: &'static [Self] = &[
113 Self::Board,
114 Self::Rollover,
115 Self::Detail,
116 Self::Form,
117 Self::FormPicker,
118 Self::ConfirmDelete,
119 Self::MoveIntoDone,
120 Self::ConfirmBlockedMove,
121 Self::Help,
122 Self::HelpSearch,
123 Self::ContextMenu,
124 Self::RelatedPicker,
127 Self::Filter,
128 Self::FilterModal,
129 Self::BulkModal,
130 Self::Triage,
131 Self::Cycle,
132 Self::Milestones,
133 Self::Repos,
136 Self::Settings,
137 Self::SettingsPrompt,
138 Self::PlanningManagement,
139 Self::PlanningConfirm,
140 Self::PlanningForm,
141 Self::DatePicker,
142 Self::Comment,
143 Self::Asset,
144 Self::EditRepair,
145 Self::EditConflict,
148 Self::LinkPrompt,
152 Self::Shell,
155 ];
156
157 pub const fn as_str(self) -> &'static str {
158 match self {
159 Self::Board => "board",
160 Self::Rollover => "rollover",
161 Self::Detail => "detail",
162 Self::Form => "form",
163 Self::FormPicker => "form_picker",
164 Self::ConfirmDelete => "confirm_delete",
165 Self::ConfirmBlockedMove => "confirm_blocked_move",
166 Self::MoveIntoDone => "move_into_done",
167 Self::Help => "help",
168 Self::HelpSearch => "help_search",
169 Self::ContextMenu => "context_menu",
170 Self::RelatedPicker => "related_picker",
171 Self::Filter => "filter",
172 Self::FilterModal => "filter_modal",
173 Self::BulkModal => "bulk_modal",
174 Self::Settings => "settings",
175 Self::SettingsPrompt => "settings_prompt",
176 Self::PlanningManagement => "planning_management",
177 Self::PlanningConfirm => "planning_confirm",
178 Self::PlanningForm => "planning_form",
179 Self::DatePicker => "date_picker",
180 Self::Comment => "comment",
181 Self::Asset => "asset",
182 Self::EditRepair => "edit_repair",
183 Self::EditConflict => "edit_conflict",
184 Self::LinkPrompt => "link_prompt",
185 Self::Triage => "triage",
186 Self::Cycle => "cycle",
187 Self::Milestones => "milestones",
188 Self::Repos => "repos",
189 Self::Shell => "shell",
190 }
191 }
192}
193
194#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
195#[serde(rename_all = "snake_case")]
196pub enum TuiAction {
197 MoveUp,
198 MoveDown,
199 MoveLeft,
200 MoveRight,
201 NextBoard,
202 PreviousBoard,
203 Open,
204 Edit,
205 Agent,
206 View,
211 ToggleColumn,
212 FilterModal,
213 BulkModal,
214 Triage,
218 Mark,
219 Unmark,
220 MovePrevious,
221 MoveNext,
222 NewIssue,
223 CloneIssue,
229 ToggleArchived,
234 ToggleChildren,
241 Delete,
242 Refresh,
243 Settings,
244 Planning,
245 NewBoard,
246 Help,
247 Quit,
248 TextFilter,
249 ReorderUp,
250 ReorderDown,
251 Close,
252 ScrollUp,
253 ScrollDown,
254 Comment,
255 Asset,
256 NewRelationship,
258 EditRelationship,
260 RemoveRelationship,
262 EditParent,
271 NextRelationship,
273 PreviousRelationship,
274 RelatedPicker,
280 FollowRelated,
284 NavBack,
289 Submit,
290 Cancel,
291 Next,
292 Previous,
293 OpenPicker,
294 Reset,
295 Toggle,
296 AddColumn,
297 NewPlanningItem,
298 EditPlanningItem,
299 DeletePlanningItem,
300 Confirm,
301 Create,
302 PreviousYear,
303 NextYear,
304 PreviousMonth,
305 NextMonth,
306 Reedit,
307 Ignore,
308 DeclareKey,
316 Discard,
324 SwitchToBoard,
326 SwitchToTriage,
328 SwitchToCycle,
330 SwitchToMilestones,
332 SwitchToRepos,
337 SwitchToNewIssue,
339 SwitchToConfig,
341 ToggleNavigation,
343}
344
345impl TuiAction {
346 pub const fn as_str(self) -> &'static str {
347 match self {
348 Self::MoveUp => "move_up",
349 Self::MoveDown => "move_down",
350 Self::MoveLeft => "move_left",
351 Self::MoveRight => "move_right",
352 Self::NextBoard => "next_board",
353 Self::PreviousBoard => "previous_board",
354 Self::Open => "open",
355 Self::Edit => "edit",
356 Self::Agent => "agent",
357 Self::View => "view",
358 Self::ToggleColumn => "toggle_column",
359 Self::FilterModal => "filter_modal",
360 Self::BulkModal => "bulk_modal",
361 Self::Triage => "triage",
362 Self::Mark => "mark",
363 Self::Unmark => "unmark",
364 Self::MovePrevious => "move_previous",
365 Self::MoveNext => "move_next",
366 Self::NewIssue => "new_issue",
367 Self::CloneIssue => "clone_issue",
368 Self::ToggleArchived => "toggle_archived",
369 Self::ToggleChildren => "toggle_children",
370 Self::Delete => "delete",
371 Self::Refresh => "refresh",
372 Self::Settings => "settings",
373 Self::Planning => "planning",
374 Self::NewBoard => "new_board",
375 Self::Help => "help",
376 Self::Quit => "quit",
377 Self::TextFilter => "text_filter",
378 Self::ReorderUp => "reorder_up",
379 Self::ReorderDown => "reorder_down",
380 Self::Close => "close",
381 Self::ScrollUp => "scroll_up",
382 Self::ScrollDown => "scroll_down",
383 Self::Comment => "comment",
384 Self::Asset => "asset",
385 Self::NewRelationship => "new_relationship",
386 Self::EditRelationship => "edit_relationship",
387 Self::RemoveRelationship => "remove_relationship",
388 Self::EditParent => "edit_parent",
389 Self::NextRelationship => "next_relationship",
390 Self::PreviousRelationship => "previous_relationship",
391 Self::RelatedPicker => "related_picker",
392 Self::FollowRelated => "follow_related",
393 Self::NavBack => "nav_back",
394 Self::Submit => "submit",
395 Self::Cancel => "cancel",
396 Self::Next => "next",
397 Self::Previous => "previous",
398 Self::OpenPicker => "open_picker",
399 Self::Reset => "reset",
400 Self::Toggle => "toggle",
401 Self::AddColumn => "add_column",
402 Self::NewPlanningItem => "new_planning_item",
403 Self::EditPlanningItem => "edit_planning_item",
404 Self::DeletePlanningItem => "delete_planning_item",
405 Self::Confirm => "confirm",
406 Self::Create => "create",
407 Self::PreviousYear => "previous_year",
408 Self::NextYear => "next_year",
409 Self::PreviousMonth => "previous_month",
410 Self::NextMonth => "next_month",
411 Self::Reedit => "reedit",
412 Self::Ignore => "ignore",
413 Self::DeclareKey => "declare_key",
414 Self::Discard => "discard",
415 Self::SwitchToBoard => "switch_to_board",
416 Self::SwitchToTriage => "switch_to_triage",
417 Self::SwitchToCycle => "switch_to_cycle",
418 Self::SwitchToMilestones => "switch_to_milestones",
419 Self::SwitchToRepos => "switch_to_repos",
420 Self::SwitchToNewIssue => "switch_to_new_issue",
421 Self::SwitchToConfig => "switch_to_config",
422 Self::ToggleNavigation => "toggle_navigation",
423 }
424 }
425
426 pub const fn description(self) -> &'static str {
427 match self {
428 Self::MoveUp => "move up",
429 Self::MoveDown => "move down",
430 Self::MoveLeft => "previous column",
431 Self::MoveRight => "next column",
432 Self::NextBoard => "next board",
433 Self::PreviousBoard => "previous board",
434 Self::Open => "open detail",
435 Self::Edit => "edit",
436 Self::Agent => "open agent",
437 Self::View => "view issue",
438 Self::ToggleColumn => "toggle column",
439 Self::FilterModal => "filter criteria",
440 Self::BulkModal => "batch select",
441 Self::Triage => "triage inbox",
442 Self::Mark => "mark for batch",
443 Self::Unmark => "unmark for batch",
444 Self::MovePrevious => "move to previous column",
445 Self::MoveNext => "move to next column",
446 Self::NewIssue => "new issue",
447 Self::CloneIssue => "clone issue",
448 Self::ToggleArchived => "show archived issues",
449 Self::ToggleChildren => "toggle sub-issues",
450 Self::Delete => "delete",
451 Self::Refresh => "refresh",
452 Self::Settings => "settings",
453 Self::Planning => "planning",
454 Self::NewBoard => "new board",
455 Self::Help => "help",
456 Self::Quit => "quit",
457 Self::TextFilter => "search",
458 Self::ReorderUp => "reorder up",
459 Self::ReorderDown => "reorder down",
460 Self::Close => "close",
461 Self::ScrollUp => "scroll up one page",
462 Self::ScrollDown => "scroll down one page",
463 Self::Comment => "comment",
464 Self::Asset => "asset",
465 Self::NewRelationship => "new relationship",
466 Self::EditRelationship => "edit relationship",
467 Self::RemoveRelationship => "remove relationship",
468 Self::EditParent => "set parent",
469 Self::NextRelationship => "next relationship",
470 Self::PreviousRelationship => "previous relationship",
471 Self::RelatedPicker => "related issues",
472 Self::FollowRelated => "follow related issue",
473 Self::NavBack => "back",
474 Self::Submit => "submit",
475 Self::Cancel => "cancel",
476 Self::Next => "next",
477 Self::Previous => "previous",
478 Self::OpenPicker => "open picker",
479 Self::Reset => "reset",
480 Self::Toggle => "toggle",
481 Self::AddColumn => "add column",
482 Self::NewPlanningItem => "new",
483 Self::EditPlanningItem => "edit",
484 Self::DeletePlanningItem => "delete",
485 Self::Confirm => "confirm",
486 Self::Create => "create",
487 Self::PreviousYear => "previous year",
488 Self::NextYear => "next year",
489 Self::PreviousMonth => "previous month",
490 Self::NextMonth => "next month",
491 Self::Reedit => "re-edit",
492 Self::Ignore => "ignore",
493 Self::DeclareKey => "declare the key in tix.toml",
494 Self::Discard => "discard the edit",
495 Self::SwitchToBoard => "board mode",
496 Self::SwitchToTriage => "triage mode",
497 Self::SwitchToCycle => "cycle mode",
498 Self::SwitchToMilestones => "milestones mode",
499 Self::SwitchToRepos => "repos mode",
500 Self::SwitchToNewIssue => "new issue mode",
501 Self::SwitchToConfig => "config mode",
502 Self::ToggleNavigation => "toggle navigation",
503 }
504 }
505
506 pub const fn description_in(self, context: InputContext) -> &'static str {
514 match (context, self) {
515 (InputContext::Cycle, Self::MoveLeft) => "previous group",
516 (InputContext::Cycle, Self::MoveRight) => "next group",
517 (InputContext::Settings, Self::MoveLeft) => "previous pane",
518 (InputContext::Settings, Self::MoveRight) => "next pane",
519 (InputContext::Settings, Self::Submit) => "step into the body / activate row",
520 (InputContext::EditRepair, Self::Confirm) => "apply the proposed fix",
524 (InputContext::EditRepair, Self::DeclareKey) => "declare the key in tix.toml",
525 (InputContext::EditRepair, Self::Reedit) => "reopen the editor on the retained copy",
526 (InputContext::EditRepair, Self::Ignore) => "acknowledge (keeps the copy)",
527 (InputContext::EditRepair, Self::Discard) => "discard the edit (asks again)",
528 (InputContext::EditConflict, Self::Confirm) => "overwrite the file with this edit",
532 (InputContext::EditConflict, Self::Ignore) => "keep the on-disk version",
533 (InputContext::EditConflict, Self::Reedit) => "compare in the editor",
534 _ => self.description(),
535 }
536 }
537}
538
539pub type Binding = Vec<String>;
540
541macro_rules! partial_bindings {
542 ($($field:ident),+ $(,)?) => {
543 #[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
544 #[serde(default, deny_unknown_fields)]
545 pub struct ActionBindings {
546 $(
547 #[serde(skip_serializing_if = "Option::is_none")]
548 pub $field: Option<Binding>,
549 )+
550 }
551 };
552}
553
554partial_bindings!(
555 move_up,
556 move_down,
557 move_left,
558 move_right,
559 next_board,
560 previous_board,
561 open,
562 edit,
563 agent,
564 view,
565 toggle_column,
566 filter_modal,
567 bulk_modal,
568 triage,
569 mark,
570 unmark,
571 move_previous,
572 move_next,
573 new_issue,
574 clone_issue,
575 toggle_archived,
576 toggle_children,
577 delete,
578 refresh,
579 settings,
580 planning,
581 new_board,
582 help,
583 quit,
584 text_filter,
585 reorder_up,
586 reorder_down,
587 close,
588 scroll_up,
589 scroll_down,
590 comment,
591 asset,
592 edit_parent,
593 submit,
594 cancel,
595 next,
596 previous,
597 open_picker,
598 reset,
599 toggle,
600 add_column,
601 new_planning_item,
602 edit_planning_item,
603 delete_planning_item,
604 confirm,
605 create,
606 previous_year,
607 next_year,
608 previous_month,
609 next_month,
610 reedit,
611 ignore,
612 declare_key,
613 discard,
614 switch_to_board,
615 switch_to_triage,
616 switch_to_cycle,
617 switch_to_milestones,
618 switch_to_repos,
619 switch_to_new_issue,
620 switch_to_config,
621 toggle_navigation,
622);
623
624macro_rules! keybindings_config {
625 ($($field:ident => $context:ident),+ $(,)?) => {
626 #[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
627 #[serde(default, deny_unknown_fields)]
628 pub struct KeyBindings {
629 $(
630 #[serde(skip_serializing_if = "ActionBindings::is_empty")]
631 pub $field: ActionBindings,
632 )+
633 }
634
635 impl KeyBindings {
636 pub fn is_empty(&self) -> bool {
637 self == &Self::default()
638 }
639
640 fn overrides(&self, context: InputContext) -> &ActionBindings {
641 match context {
642 $(InputContext::$context => &self.$field,)+
643 }
644 }
645 }
646 };
647}
648
649keybindings_config!(
650 board => Board,
651 rollover => Rollover,
652 detail => Detail,
653 form => Form,
654 form_picker => FormPicker,
655 confirm_delete => ConfirmDelete,
656 confirm_blocked_move => ConfirmBlockedMove,
657 help_search => HelpSearch,
658 move_into_done => MoveIntoDone,
659 help => Help,
660 context_menu => ContextMenu,
661 related_picker => RelatedPicker,
664 filter => Filter,
665 filter_modal => FilterModal,
666 bulk_modal => BulkModal,
667 triage => Triage,
668 cycle => Cycle,
669 milestones => Milestones,
670 repos => Repos,
673 settings => Settings,
674 settings_prompt => SettingsPrompt,
675 planning_management => PlanningManagement,
676 planning_confirm => PlanningConfirm,
677 planning_form => PlanningForm,
678 date_picker => DatePicker,
679 comment => Comment,
680 asset => Asset,
681 edit_repair => EditRepair,
682 edit_conflict => EditConflict,
683 link_prompt => LinkPrompt,
684 shell => Shell,
685);
686
687#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
688pub struct KeyChord {
689 pub code: KeyCode,
690 pub modifiers: KeyModifiers,
691}
692
693impl KeyChord {
694 pub fn parse(value: &str) -> std::result::Result<Self, String> {
695 let value = value.trim();
696 if value.is_empty() {
697 return Err("key chord must not be empty".into());
698 }
699 if value.chars().count() == 1 {
700 let ch = value.chars().next().expect("value has one character");
701 if ch.is_control() {
702 return Err(format!("unsupported key name `{value}`"));
703 }
704 let modifiers = if ch.is_ascii_uppercase() {
705 KeyModifiers::SHIFT
706 } else {
707 KeyModifiers::NONE
708 };
709 return Ok(Self {
710 code: KeyCode::Char(ch),
711 modifiers,
712 });
713 }
714 let mut parts = value.split('+');
715 let key = parts.next_back().ok_or("missing key name")?.trim();
716 let mut modifiers = KeyModifiers::NONE;
717 for part in parts {
718 match part.trim().to_ascii_lowercase().as_str() {
719 "ctrl" | "control" => modifiers |= KeyModifiers::CONTROL,
720 "alt" | "option" => modifiers |= KeyModifiers::ALT,
721 "shift" => modifiers |= KeyModifiers::SHIFT,
722 "super" | "cmd" | "command" => modifiers |= KeyModifiers::SUPER,
723 other => return Err(format!("unsupported modifier `{other}`")),
724 }
725 }
726 if key.is_empty() {
727 return Err("missing key name".into());
728 }
729 let mut code = match key.to_ascii_lowercase().as_str() {
730 "backtab" => return Err("use shift+tab instead of `backtab`".into()),
731 "shift+tab" => KeyCode::BackTab,
732 "backspace" => KeyCode::Backspace,
733 "tab" => KeyCode::Tab,
734 "enter" | "return" => KeyCode::Enter,
735 "esc" | "escape" => KeyCode::Esc,
736 "space" => KeyCode::Char(' '),
737 "left" => KeyCode::Left,
738 "right" => KeyCode::Right,
739 "up" => KeyCode::Up,
740 "down" => KeyCode::Down,
741 "home" => KeyCode::Home,
742 "end" => KeyCode::End,
743 "pageup" | "pgup" => KeyCode::PageUp,
744 "pagedown" | "pgdn" | "pgdown" => KeyCode::PageDown,
745 "delete" | "del" => KeyCode::Delete,
746 "insert" | "ins" => KeyCode::Insert,
747 _ => {
748 if let Some(number) = key
749 .to_ascii_lowercase()
750 .strip_prefix('f')
751 .and_then(|n| n.parse::<u8>().ok())
752 {
753 if (1..=12).contains(&number) {
754 KeyCode::F(number)
755 } else {
756 return Err(format!("unsupported function key `{key}`"));
757 }
758 } else {
759 let mut chars = key.chars();
760 let Some(ch) = chars.next().filter(|_| chars.next().is_none()) else {
761 return Err(format!("unsupported key name `{key}`"));
762 };
763 if ch.is_control() {
764 return Err(format!("unsupported key name `{key}`"));
765 }
766 if ch.is_ascii_uppercase() {
767 modifiers |= KeyModifiers::SHIFT;
768 }
769 KeyCode::Char(ch)
770 }
771 }
772 };
773 if code == KeyCode::Tab && modifiers.contains(KeyModifiers::SHIFT) {
774 code = KeyCode::BackTab;
775 modifiers.remove(KeyModifiers::SHIFT);
776 }
777 if code == KeyCode::BackTab {
778 modifiers |= KeyModifiers::SHIFT;
779 }
780 Ok(Self { code, modifiers })
781 }
782
783 pub fn matches(&self, event: KeyEvent) -> bool {
784 if self.code != event.code {
785 return false;
786 }
787 if self.modifiers.is_empty() {
788 return event.modifiers.is_empty();
789 }
790 if self.code == KeyCode::BackTab {
791 return event.modifiers.contains(KeyModifiers::SHIFT) || event.code == KeyCode::BackTab;
792 }
793 if let (KeyCode::Char(expected), KeyCode::Char(actual)) = (self.code, event.code) {
794 let shift_expected = self.modifiers.contains(KeyModifiers::SHIFT);
795 let normalized_expected = if shift_expected {
796 expected.to_ascii_lowercase()
797 } else {
798 expected
799 };
800 let shift_actual =
801 actual.is_uppercase() || event.modifiers.contains(KeyModifiers::SHIFT);
802 let expected_other = self.modifiers.difference(KeyModifiers::SHIFT);
803 let actual_other = event.modifiers.difference(KeyModifiers::SHIFT);
804 return expected_other == actual_other
805 && normalized_expected == actual.to_ascii_lowercase()
806 && shift_expected == shift_actual;
807 }
808 event.modifiers == self.modifiers
809 }
810
811 pub fn display(&self) -> String {
812 let mut out = String::new();
813 if self.modifiers.contains(KeyModifiers::CONTROL) {
814 out.push_str("Ctrl+");
815 }
816 if self.modifiers.contains(KeyModifiers::ALT) {
817 out.push_str("Alt+");
818 }
819 if self.modifiers.contains(KeyModifiers::SUPER) {
820 out.push_str("Super+");
821 }
822 if self.modifiers.contains(KeyModifiers::SHIFT) && self.code != KeyCode::BackTab {
823 out.push_str("Shift+");
824 }
825 out.push_str(&match self.code {
826 KeyCode::BackTab => "Shift+Tab".into(),
827 KeyCode::Char(' ') => "Space".into(),
828 KeyCode::Char(ch) => ch.to_string(),
829 KeyCode::Enter => "Enter".into(),
830 KeyCode::Esc => "Esc".into(),
831 KeyCode::Left => "Left".into(),
832 KeyCode::Right => "Right".into(),
833 KeyCode::Up => "Up".into(),
834 KeyCode::Down => "Down".into(),
835 KeyCode::PageUp => "PgUp".into(),
836 KeyCode::PageDown => "PgDn".into(),
837 KeyCode::Delete => "Delete".into(),
838 KeyCode::Insert => "Insert".into(),
839 KeyCode::Home => "Home".into(),
840 KeyCode::End => "End".into(),
841 KeyCode::F(n) => format!("F{n}"),
842 other => format!("{other:?}"),
843 });
844 out
845 }
846}
847
848impl Serialize for KeyChord {
849 fn serialize<S>(&self, serializer: S) -> std::result::Result<S::Ok, S::Error>
850 where
851 S: Serializer,
852 {
853 serializer.serialize_str(&self.display())
854 }
855}
856
857impl<'de> Deserialize<'de> for KeyChord {
858 fn deserialize<D>(deserializer: D) -> std::result::Result<Self, D::Error>
859 where
860 D: Deserializer<'de>,
861 {
862 let value = String::deserialize(deserializer)?;
863 Self::parse(&value).map_err(D::Error::custom)
864 }
865}
866
867impl fmt::Display for KeyChord {
868 fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
869 formatter.write_str(&self.display())
870 }
871}
872
873#[derive(Debug, Clone, Default, PartialEq, Eq)]
874pub struct EffectiveBindings {
875 modes: HashMap<InputContext, HashMap<TuiAction, Vec<KeyChord>>>,
876}
877
878fn chord(value: &str) -> KeyChord {
879 KeyChord::parse(value).expect("built-in key chord must parse")
880}
881
882fn set(map: &mut HashMap<TuiAction, Vec<KeyChord>>, action: TuiAction, values: &[&str]) {
883 map.insert(action, values.iter().map(|value| chord(value)).collect());
884}
885
886impl KeyBindings {
887 pub fn resolve(overrides: &KeyBindings) -> Result<EffectiveBindings> {
888 let mut effective = EffectiveBindings::default();
889 for context in InputContext::ALL {
890 let mut mode = default_mode(*context);
891 overlay(&mut mode, overrides.overrides(*context), *context)?;
892 effective.modes.insert(*context, mode);
893 }
894 Ok(effective)
895 }
896}
897
898impl EffectiveBindings {
899 pub fn action(&self, context: InputContext, event: KeyEvent) -> Option<TuiAction> {
900 self.modes
901 .get(&context)?
902 .iter()
903 .find_map(|(action, chords)| {
904 chords
905 .iter()
906 .any(|key| key.matches(event))
907 .then_some(*action)
908 })
909 }
910
911 pub fn bound_actions(&self, context: InputContext) -> Vec<TuiAction> {
917 let mut actions: Vec<TuiAction> = self
918 .modes
919 .get(&context)
920 .map(|mode| mode.keys().copied().collect())
921 .unwrap_or_default();
922 actions.sort_by_key(|action| action.as_str());
923 actions
924 }
925
926 pub fn chords(&self, context: InputContext, action: TuiAction) -> &[KeyChord] {
927 self.modes
928 .get(&context)
929 .and_then(|mode| mode.get(&action))
930 .map(Vec::as_slice)
931 .unwrap_or_default()
932 }
933
934 pub fn label(&self, context: InputContext, action: TuiAction) -> String {
935 let labels: Vec<String> = self
936 .chords(context, action)
937 .iter()
938 .map(ToString::to_string)
939 .collect();
940 if labels.is_empty() {
941 "—".into()
942 } else {
943 labels.join(" / ")
944 }
945 }
946
947 pub fn canonical_event(&self, context: InputContext, action: TuiAction) -> KeyEvent {
948 let (code, modifiers) = match (context, action) {
949 (_, TuiAction::ReorderUp) => (KeyCode::Up, KeyModifiers::CONTROL),
950 (_, TuiAction::ReorderDown) => (KeyCode::Down, KeyModifiers::CONTROL),
951 (InputContext::Rollover, TuiAction::Previous) => (KeyCode::Tab, KeyModifiers::NONE),
952 (InputContext::PlanningManagement, TuiAction::Next) => {
953 (KeyCode::Tab, KeyModifiers::NONE)
954 }
955 (InputContext::FormPicker, TuiAction::Next) => (KeyCode::Up, KeyModifiers::NONE),
956 (InputContext::FormPicker, TuiAction::Previous) => (KeyCode::Down, KeyModifiers::NONE),
957 (InputContext::DatePicker, TuiAction::PreviousYear) => {
958 (KeyCode::Char('['), KeyModifiers::NONE)
959 }
960 (InputContext::DatePicker, TuiAction::NextYear) => {
961 (KeyCode::Char(']'), KeyModifiers::NONE)
962 }
963 (InputContext::DatePicker, TuiAction::PreviousMonth) => {
964 (KeyCode::PageUp, KeyModifiers::NONE)
965 }
966 (InputContext::DatePicker, TuiAction::NextMonth) => {
967 (KeyCode::PageDown, KeyModifiers::NONE)
968 }
969 (InputContext::DatePicker, TuiAction::Reset) => (KeyCode::Delete, KeyModifiers::NONE),
970 (InputContext::Settings, TuiAction::NewBoard) => {
971 (KeyCode::Char('n'), KeyModifiers::NONE)
972 }
973 (InputContext::Settings, TuiAction::Delete) => (KeyCode::Char('x'), KeyModifiers::NONE),
974 (_, TuiAction::ScrollUp) => (KeyCode::PageUp, KeyModifiers::NONE),
982 (_, TuiAction::ScrollDown) => (KeyCode::PageDown, KeyModifiers::NONE),
983 (_, TuiAction::MoveUp | TuiAction::Previous) => (KeyCode::Up, KeyModifiers::NONE),
984 (_, TuiAction::MoveDown | TuiAction::Next) => (KeyCode::Down, KeyModifiers::NONE),
985 (_, TuiAction::MoveLeft) => (KeyCode::Left, KeyModifiers::NONE),
986 (_, TuiAction::MoveRight) => (KeyCode::Right, KeyModifiers::NONE),
987 (_, TuiAction::PreviousBoard) => (KeyCode::BackTab, KeyModifiers::NONE),
988 (_, TuiAction::NextBoard) => (KeyCode::Tab, KeyModifiers::NONE),
989 (_, TuiAction::Submit | TuiAction::Open | TuiAction::Ignore) => {
990 (KeyCode::Enter, KeyModifiers::NONE)
991 }
992 (_, TuiAction::NewIssue) => (KeyCode::Char('n'), KeyModifiers::NONE),
993 (_, TuiAction::CloneIssue) => (KeyCode::Char('C'), KeyModifiers::NONE),
994 (_, TuiAction::ToggleArchived) => (KeyCode::Char('a'), KeyModifiers::NONE),
995 (_, TuiAction::ToggleChildren) => (KeyCode::Char('u'), KeyModifiers::NONE),
996 (_, TuiAction::Edit | TuiAction::EditPlanningItem) => {
997 (KeyCode::Char('e'), KeyModifiers::NONE)
998 }
999 (_, TuiAction::Agent) => (KeyCode::Char('A'), KeyModifiers::NONE),
1000 (_, TuiAction::View) => (KeyCode::Char('v'), KeyModifiers::NONE),
1001 (_, TuiAction::ToggleColumn | TuiAction::Comment) => {
1002 (KeyCode::Char('c'), KeyModifiers::NONE)
1003 }
1004 (_, TuiAction::FilterModal) => (KeyCode::Char('f'), KeyModifiers::NONE),
1005 (_, TuiAction::BulkModal) => (KeyCode::Char('B'), KeyModifiers::NONE),
1006 (_, TuiAction::Triage) => (KeyCode::Char('t'), KeyModifiers::NONE),
1007 (_, TuiAction::Mark) => (KeyCode::Char(' '), KeyModifiers::NONE),
1008 (_, TuiAction::Unmark) => (KeyCode::Char('x'), KeyModifiers::NONE),
1009 (_, TuiAction::MovePrevious) => (KeyCode::Char('<'), KeyModifiers::NONE),
1010 (_, TuiAction::MoveNext) => (KeyCode::Char('>'), KeyModifiers::NONE),
1011 (_, TuiAction::Delete) => (KeyCode::Char('d'), KeyModifiers::NONE),
1012 (_, TuiAction::DeletePlanningItem) => (KeyCode::Char('x'), KeyModifiers::NONE),
1013 (_, TuiAction::Refresh | TuiAction::Reset | TuiAction::Reedit) => {
1014 (KeyCode::Char('r'), KeyModifiers::NONE)
1015 }
1016 (_, TuiAction::Settings) => (KeyCode::Char('s'), KeyModifiers::NONE),
1017 (_, TuiAction::Planning) => (KeyCode::Char('m'), KeyModifiers::NONE),
1018 (_, TuiAction::NewPlanningItem) => (KeyCode::Char('n'), KeyModifiers::NONE),
1019 (_, TuiAction::NewBoard) => (KeyCode::Char('b'), KeyModifiers::NONE),
1020 (_, TuiAction::AddColumn) => (KeyCode::Char('a'), KeyModifiers::NONE),
1021 (_, TuiAction::Help) => (KeyCode::Char('?'), KeyModifiers::NONE),
1022 (_, TuiAction::Quit) => (KeyCode::Char('q'), KeyModifiers::NONE),
1023 (_, TuiAction::TextFilter) => (KeyCode::Char('/'), KeyModifiers::NONE),
1024 (_, TuiAction::Close | TuiAction::Cancel) => (KeyCode::Esc, KeyModifiers::NONE),
1025 (_, TuiAction::Asset) => (KeyCode::Char('a'), KeyModifiers::NONE),
1026 (_, TuiAction::NewRelationship) => (KeyCode::Char('n'), KeyModifiers::NONE),
1029 (_, TuiAction::EditRelationship) => (KeyCode::Char('E'), KeyModifiers::SHIFT),
1030 (_, TuiAction::RemoveRelationship) => (KeyCode::Char('d'), KeyModifiers::NONE),
1031 (_, TuiAction::EditParent) => (KeyCode::Char('P'), KeyModifiers::NONE),
1034 (_, TuiAction::NextRelationship) => (KeyCode::Char('j'), KeyModifiers::NONE),
1035 (_, TuiAction::PreviousRelationship) => (KeyCode::Char('k'), KeyModifiers::NONE),
1036 (_, TuiAction::RelatedPicker) => (KeyCode::Char('r'), KeyModifiers::NONE),
1041 (_, TuiAction::FollowRelated) => (KeyCode::Char('o'), KeyModifiers::NONE),
1042 (_, TuiAction::NavBack) => (KeyCode::Backspace, KeyModifiers::NONE),
1043 (_, TuiAction::OpenPicker) => (KeyCode::F(2), KeyModifiers::NONE),
1044 (_, TuiAction::SwitchToBoard) => (KeyCode::F(3), KeyModifiers::NONE),
1049 (_, TuiAction::SwitchToTriage) => (KeyCode::F(2), KeyModifiers::NONE),
1050 (_, TuiAction::SwitchToCycle) => (KeyCode::F(4), KeyModifiers::NONE),
1051 (_, TuiAction::SwitchToMilestones) => (KeyCode::F(5), KeyModifiers::NONE),
1052 (_, TuiAction::SwitchToRepos) => (KeyCode::F(6), KeyModifiers::NONE),
1053 (_, TuiAction::SwitchToNewIssue) => (KeyCode::F(1), KeyModifiers::NONE),
1054 (_, TuiAction::SwitchToConfig) => (KeyCode::F(12), KeyModifiers::NONE),
1055 (_, TuiAction::ToggleNavigation) => (KeyCode::Char('n'), KeyModifiers::CONTROL),
1056 (_, TuiAction::Toggle) => (KeyCode::Char(' '), KeyModifiers::NONE),
1057 (_, TuiAction::Confirm) => (KeyCode::Char('y'), KeyModifiers::NONE),
1058 (_, TuiAction::DeclareKey) => (KeyCode::Char('d'), KeyModifiers::NONE),
1062 (_, TuiAction::Discard) => (KeyCode::Char('x'), KeyModifiers::NONE),
1063 (_, TuiAction::Create) => (KeyCode::Char('+'), KeyModifiers::NONE),
1064 _ => (KeyCode::Esc, KeyModifiers::NONE),
1065 };
1066 KeyEvent::new(code, modifiers)
1067 }
1068}
1069
1070fn defaults(values: &[(&str, &[&str])]) -> HashMap<TuiAction, Vec<KeyChord>> {
1071 let mut map = HashMap::new();
1072 for (action, keys) in values {
1073 let action = match *action {
1074 "move_up" => TuiAction::MoveUp,
1075 "move_down" => TuiAction::MoveDown,
1076 "move_left" => TuiAction::MoveLeft,
1077 "move_right" => TuiAction::MoveRight,
1078 "next_board" => TuiAction::NextBoard,
1079 "previous_board" => TuiAction::PreviousBoard,
1080 "open" => TuiAction::Open,
1081 "edit" => TuiAction::Edit,
1082 "agent" => TuiAction::Agent,
1083 "view" => TuiAction::View,
1084 "toggle_column" => TuiAction::ToggleColumn,
1085 "filter_modal" => TuiAction::FilterModal,
1086 "bulk_modal" => TuiAction::BulkModal,
1087 "triage" => TuiAction::Triage,
1088 "mark" => TuiAction::Mark,
1089 "unmark" => TuiAction::Unmark,
1090 "move_previous" => TuiAction::MovePrevious,
1091 "move_next" => TuiAction::MoveNext,
1092 "new_issue" => TuiAction::NewIssue,
1093 "clone_issue" => TuiAction::CloneIssue,
1094 "toggle_archived" => TuiAction::ToggleArchived,
1095 "toggle_children" => TuiAction::ToggleChildren,
1096 "delete" => TuiAction::Delete,
1097 "refresh" => TuiAction::Refresh,
1098 "settings" => TuiAction::Settings,
1099 "planning" => TuiAction::Planning,
1100 "new_board" => TuiAction::NewBoard,
1101 "help" => TuiAction::Help,
1102 "quit" => TuiAction::Quit,
1103 "text_filter" => TuiAction::TextFilter,
1104 "reorder_up" => TuiAction::ReorderUp,
1105 "reorder_down" => TuiAction::ReorderDown,
1106 "close" => TuiAction::Close,
1107 "scroll_up" => TuiAction::ScrollUp,
1108 "scroll_down" => TuiAction::ScrollDown,
1109 "comment" => TuiAction::Comment,
1110 "asset" => TuiAction::Asset,
1111 "new_relationship" => TuiAction::NewRelationship,
1112 "edit_relationship" => TuiAction::EditRelationship,
1113 "remove_relationship" => TuiAction::RemoveRelationship,
1114 "edit_parent" => TuiAction::EditParent,
1115 "next_relationship" => TuiAction::NextRelationship,
1116 "previous_relationship" => TuiAction::PreviousRelationship,
1117 "related_picker" => TuiAction::RelatedPicker,
1118 "follow_related" => TuiAction::FollowRelated,
1119 "nav_back" => TuiAction::NavBack,
1120 "submit" => TuiAction::Submit,
1121 "cancel" => TuiAction::Cancel,
1122 "next" => TuiAction::Next,
1123 "previous" => TuiAction::Previous,
1124 "open_picker" => TuiAction::OpenPicker,
1125 "reset" => TuiAction::Reset,
1126 "toggle" => TuiAction::Toggle,
1127 "add_column" => TuiAction::AddColumn,
1128 "new_planning_item" => TuiAction::NewPlanningItem,
1129 "edit_planning_item" => TuiAction::EditPlanningItem,
1130 "delete_planning_item" => TuiAction::DeletePlanningItem,
1131 "confirm" => TuiAction::Confirm,
1132 "create" => TuiAction::Create,
1133 "previous_year" => TuiAction::PreviousYear,
1134 "next_year" => TuiAction::NextYear,
1135 "previous_month" => TuiAction::PreviousMonth,
1136 "next_month" => TuiAction::NextMonth,
1137 "reedit" => TuiAction::Reedit,
1138 "ignore" => TuiAction::Ignore,
1139 "declare_key" => TuiAction::DeclareKey,
1140 "discard" => TuiAction::Discard,
1141 "switch_to_board" => TuiAction::SwitchToBoard,
1142 "switch_to_triage" => TuiAction::SwitchToTriage,
1143 "switch_to_cycle" => TuiAction::SwitchToCycle,
1144 "switch_to_milestones" => TuiAction::SwitchToMilestones,
1145 "switch_to_repos" => TuiAction::SwitchToRepos,
1146 "switch_to_new_issue" => TuiAction::SwitchToNewIssue,
1147 "switch_to_config" => TuiAction::SwitchToConfig,
1148 "toggle_navigation" => TuiAction::ToggleNavigation,
1149 _ => unreachable!(),
1150 };
1151 set(&mut map, action, keys);
1152 }
1153 map
1154}
1155
1156fn default_mode(context: InputContext) -> HashMap<TuiAction, Vec<KeyChord>> {
1157 match context {
1158 InputContext::Board => defaults(&[
1159 ("move_up", &["up", "k"]),
1160 ("move_down", &["down", "j"]),
1161 ("scroll_up", &["pgup"]),
1168 ("scroll_down", &["pgdn"]),
1169 ("move_left", &["left", "h"]),
1170 ("move_right", &["right", "l"]),
1171 ("next_board", &["tab"]),
1172 ("previous_board", &["shift+tab"]),
1173 ("open", &["enter"]),
1174 ("edit", &["e"]),
1175 ("view", &["v"]),
1177 ("agent", &["A"]),
1178 ("toggle_column", &["c"]),
1179 ("filter_modal", &["f"]),
1180 ("bulk_modal", &["B"]),
1181 ("triage", &["t"]),
1184 ("mark", &["space"]),
1188 ("unmark", &["x"]),
1189 ("move_previous", &["<"]),
1190 ("move_next", &[">"]),
1191 ("new_issue", &["n"]),
1192 ("clone_issue", &["C"]),
1196 ("toggle_archived", &["a"]),
1198 ("toggle_children", &["u"]),
1200 ("delete", &["d"]),
1201 ("refresh", &["r"]),
1202 ("settings", &["s"]),
1203 ("planning", &["m", "M"]),
1204 ("new_board", &["b"]),
1205 ("help", &["?"]),
1206 ("quit", &["q", "Q", "esc"]),
1207 ("text_filter", &["/"]),
1208 ("reorder_up", &["ctrl+up"]),
1209 ("reorder_down", &["ctrl+down"]),
1210 ]),
1211 InputContext::Rollover => defaults(&[
1212 ("submit", &["enter"]),
1213 ("previous", &["left", "right", "tab"]),
1214 ("cancel", &["esc"]),
1215 ]),
1216 InputContext::Detail => defaults(&[
1217 ("close", &["q", "Q", "esc"]),
1218 ("edit", &["e"]),
1219 ("view", &["v"]),
1221 ("scroll_up", &["pgup"]),
1222 ("scroll_down", &["pgdn"]),
1223 ("help", &["?"]),
1224 ("comment", &["c"]),
1225 ("asset", &["a"]),
1226 ("new_relationship", &["n"]),
1230 ("edit_relationship", &["E"]),
1231 ("remove_relationship", &["d"]),
1232 ("edit_parent", &["P"]),
1237 ("next_relationship", &["j", "down"]),
1238 ("previous_relationship", &["k", "up"]),
1239 ("related_picker", &["r"]),
1244 ("follow_related", &["o"]),
1245 ("nav_back", &["backspace"]),
1246 ]),
1247 InputContext::RelatedPicker => defaults(&[
1248 ("cancel", &["esc"]),
1249 ("next", &["down"]),
1250 ("previous", &["up"]),
1251 ("submit", &["enter"]),
1252 ]),
1253 InputContext::Form | InputContext::PlanningForm => defaults(&[
1254 ("cancel", &["esc"]),
1255 ("submit", &["enter"]),
1256 ("next", &["tab", "down"]),
1257 ("previous", &["shift+tab", "up"]),
1258 ("open_picker", &["f2"]),
1259 ]),
1260 InputContext::FormPicker => defaults(&[
1261 ("cancel", &["esc", "f2"]),
1262 ("next", &["up"]),
1263 ("previous", &["down"]),
1264 ("submit", &["enter"]),
1265 ("create", &["+", "insert"]),
1266 ]),
1267 InputContext::ConfirmDelete => defaults(&[("confirm", &["y", "Y"]), ("cancel", &["esc"])]),
1268 InputContext::ConfirmBlockedMove => defaults(&[
1271 ("confirm", &["y", "Y", "enter"]),
1272 ("cancel", &["n", "N", "esc"]),
1273 ]),
1274 InputContext::MoveIntoDone
1303 | InputContext::Comment
1304 | InputContext::Asset
1305 | InputContext::Filter => defaults(&[("cancel", &["esc"]), ("submit", &["enter"])]),
1306 InputContext::Help => defaults(&[
1311 ("cancel", &["esc", "q", "?"]),
1312 ("next", &["down", "j"]),
1313 ("previous", &["up", "k"]),
1314 ("submit", &["enter"]),
1315 ("text_filter", &["/"]),
1319 ("scroll_up", &["pgup"]),
1324 ("scroll_down", &["pgdn"]),
1325 ]),
1326 InputContext::ContextMenu => defaults(&[
1327 ("cancel", &["esc", "q", "?"]),
1328 ("next", &["down", "j"]),
1329 ("previous", &["up", "k"]),
1330 ("submit", &["enter"]),
1331 ]),
1332 InputContext::HelpSearch => defaults(&[("cancel", &["esc"]), ("submit", &["enter"])]),
1337 InputContext::BulkModal => defaults(&[
1338 ("cancel", &["esc"]),
1339 ("next", &["tab", "down"]),
1340 ("previous", &["shift+tab", "up"]),
1341 ("submit", &["enter"]),
1342 ]),
1343 InputContext::Triage => defaults(&[
1349 ("cancel", &["esc"]),
1350 ("submit", &["enter"]),
1351 ("move_up", &["up"]),
1352 ("move_down", &["down"]),
1353 ("toggle", &["space"]),
1354 ]),
1355 InputContext::Cycle => defaults(&[
1360 ("close", &["q", "Q", "esc"]),
1361 ("move_up", &["up", "k"]),
1362 ("move_down", &["down", "j"]),
1363 ("move_left", &["left", "h"]),
1366 ("move_right", &["right", "l"]),
1367 ("planning", &["m"]),
1368 ("help", &["?"]),
1369 ]),
1370 InputContext::Milestones => defaults(&[
1375 ("close", &["q", "Q", "esc"]),
1376 ("move_up", &["up", "k"]),
1377 ("move_down", &["down", "j"]),
1378 ("submit", &["enter"]),
1379 ("new_planning_item", &["n"]),
1380 ("edit_planning_item", &["e"]),
1381 ("delete_planning_item", &["x", "X"]),
1382 ("planning", &["m"]),
1383 ("help", &["?"]),
1384 ]),
1385 InputContext::Repos => defaults(&[
1389 ("close", &["q", "Q", "esc"]),
1390 ("move_up", &["up", "k"]),
1391 ("move_down", &["down", "j"]),
1392 ("submit", &["enter"]),
1393 ("help", &["?"]),
1394 ]),
1395 InputContext::FilterModal => defaults(&[
1396 ("cancel", &["esc"]),
1397 ("next", &["tab", "down"]),
1398 ("previous", &["shift+tab", "up"]),
1399 ("reset", &["r", "R"]),
1400 ("submit", &["enter"]),
1401 ("open_picker", &["f2"]),
1402 ]),
1403 InputContext::Settings => defaults(&[
1418 ("close", &["q", "Q", "esc"]),
1419 ("next_board", &["tab", "]"]),
1420 ("previous_board", &["shift+tab", "["]),
1421 ("move_left", &["left", "h"]),
1422 ("move_right", &["right", "l"]),
1423 ("move_up", &["up", "k"]),
1424 ("move_down", &["down", "j"]),
1425 ("toggle", &["space"]),
1426 ("submit", &["enter"]),
1427 ("add_column", &["a"]),
1428 ("new_board", &["n"]),
1429 ("edit_planning_item", &["e"]),
1430 ("delete", &["x", "X"]),
1431 ]),
1432 InputContext::SettingsPrompt => defaults(&[
1433 ("cancel", &["esc", "q", "Q"]),
1434 ("submit", &["enter", "y", "Y"]),
1435 ]),
1436 InputContext::PlanningManagement => defaults(&[
1437 ("close", &["q", "Q", "esc"]),
1438 ("move_up", &["up", "k"]),
1439 ("move_down", &["down", "j"]),
1440 ("move_left", &["left", "h"]),
1445 ("move_right", &["right", "l"]),
1446 ("next", &["tab", "shift+tab"]),
1447 ("new_planning_item", &["n"]),
1448 ("edit_planning_item", &["e"]),
1449 ("delete_planning_item", &["x", "X"]),
1450 ]),
1451 InputContext::PlanningConfirm => {
1452 defaults(&[("confirm", &["y", "Y", "enter"]), ("cancel", &["esc"])])
1453 }
1454 InputContext::DatePicker => defaults(&[
1455 ("cancel", &["esc", "q"]),
1456 ("submit", &["enter"]),
1457 ("move_up", &["up", "k"]),
1458 ("move_down", &["down", "j"]),
1459 ("move_left", &["left", "h"]),
1460 ("move_right", &["right", "l"]),
1461 ("previous_month", &["pgup"]),
1462 ("next_month", &["pgdn"]),
1463 ("previous_year", &["["]),
1464 ("next_year", &["]"]),
1465 ("reset", &["delete", "c", "C"]),
1466 ]),
1467 InputContext::EditRepair => defaults(&[
1474 ("confirm", &["a", "y"]),
1475 ("declare_key", &["d", "D"]),
1476 ("reedit", &["r", "R"]),
1477 ("ignore", &["i", "I", "enter", "esc"]),
1478 ("discard", &["x", "X"]),
1479 ]),
1480 InputContext::EditConflict => defaults(&[
1488 ("confirm", &["o", "O", "y", "Y"]),
1489 ("ignore", &["a", "A", "esc"]),
1490 ("reedit", &["c", "C", "r", "R"]),
1491 ]),
1492 InputContext::Shell => defaults(&[
1496 ("switch_to_new_issue", &["f1"]),
1497 ("switch_to_triage", &["f2"]),
1498 ("switch_to_board", &["f3"]),
1499 ("switch_to_cycle", &["f4"]),
1500 ("switch_to_milestones", &["f5"]),
1501 ("switch_to_repos", &["f6"]),
1504 ("switch_to_config", &["f12"]),
1505 ("toggle_navigation", &["ctrl+n"]),
1506 ]),
1507 InputContext::LinkPrompt => defaults(&[("cancel", &["esc"]), ("submit", &["enter"])]),
1512 }
1513}
1514
1515macro_rules! overlay_fields {
1516 ($map:ident, $overrides:ident, $context:expr, $($field:ident => $action:ident),+ $(,)?) => {
1517 $(
1518 if let Some(values) = &$overrides.$field {
1519 let mut chords = Vec::with_capacity(values.len());
1520 for value in values {
1521 chords.push(KeyChord::parse(value).map_err(|message| TixError::Config(format!(
1522 "`keybindings.{}.{}`: {message}", $context.as_str(), stringify!($field)
1523 )))?);
1524 }
1525 if chords.is_empty() {
1526 return Err(TixError::Config(format!(
1527 "`keybindings.{}.{}` must not be empty", $context.as_str(), stringify!($field)
1528 )));
1529 }
1530 $map.insert(TuiAction::$action, chords);
1531 }
1532 )+
1533 };
1534}
1535
1536fn overlay(
1537 map: &mut HashMap<TuiAction, Vec<KeyChord>>,
1538 overrides: &ActionBindings,
1539 context: InputContext,
1540) -> Result<()> {
1541 overlay_fields!(map, overrides, context,
1542 move_up => MoveUp, move_down => MoveDown, move_left => MoveLeft, move_right => MoveRight,
1543 next_board => NextBoard, previous_board => PreviousBoard, open => Open, edit => Edit,
1544 agent => Agent, view => View, toggle_column => ToggleColumn, filter_modal => FilterModal,
1545 bulk_modal => BulkModal, mark => Mark, unmark => Unmark, triage => Triage,
1546 move_previous => MovePrevious, move_next => MoveNext, new_issue => NewIssue,
1547 clone_issue => CloneIssue,
1548 toggle_archived => ToggleArchived,
1549 toggle_children => ToggleChildren,
1550 delete => Delete, refresh => Refresh, settings => Settings, planning => Planning,
1551 new_board => NewBoard, help => Help, quit => Quit, text_filter => TextFilter, reorder_up => ReorderUp,
1552 reorder_down => ReorderDown, close => Close, scroll_up => ScrollUp, scroll_down => ScrollDown,
1553 comment => Comment, asset => Asset, edit_parent => EditParent,
1554 submit => Submit, cancel => Cancel, next => Next,
1555 previous => Previous, open_picker => OpenPicker, reset => Reset, toggle => Toggle,
1556 add_column => AddColumn, new_planning_item => NewPlanningItem,
1557 edit_planning_item => EditPlanningItem, delete_planning_item => DeletePlanningItem,
1558 confirm => Confirm, create => Create, previous_year => PreviousYear, next_year => NextYear,
1559 previous_month => PreviousMonth, next_month => NextMonth, reedit => Reedit, ignore => Ignore,
1560 declare_key => DeclareKey, discard => Discard,
1561 switch_to_board => SwitchToBoard, switch_to_triage => SwitchToTriage,
1562 switch_to_cycle => SwitchToCycle, switch_to_milestones => SwitchToMilestones,
1563 switch_to_repos => SwitchToRepos,
1564 switch_to_new_issue => SwitchToNewIssue,
1565 switch_to_config => SwitchToConfig, toggle_navigation => ToggleNavigation,
1566 );
1567 let mut seen: HashMap<KeyChord, TuiAction> = HashMap::new();
1568 for (action, chords) in map {
1569 for key in chords {
1570 if let Some(previous) = seen.insert(*key, *action) {
1571 return Err(TixError::Config(format!(
1572 "`keybindings.{}`: `{}` is assigned to both `{}` and `{}`",
1573 context.as_str(),
1574 key,
1575 previous.as_str(),
1576 action.as_str()
1577 )));
1578 }
1579 }
1580 }
1581 Ok(())
1582}
1583
1584impl ActionBindings {
1585 pub fn is_empty(&self) -> bool {
1586 self == &Self::default()
1587 }
1588}
1589
1590#[cfg(test)]
1591mod tests {
1592 use super::*;
1593
1594 #[test]
1595 fn parses_and_normalizes_documented_chords() {
1596 assert_eq!(KeyChord::parse("ESC").unwrap(), chord("esc"));
1597 assert_eq!(KeyChord::parse("Enter").unwrap(), chord("enter"));
1598 assert_eq!(KeyChord::parse("LEFT").unwrap(), chord("left"));
1599 assert_eq!(KeyChord::parse("PgUp").unwrap(), chord("pgup"));
1600 assert_eq!(KeyChord::parse("F2").unwrap(), chord("f2"));
1601 assert_eq!(KeyChord::parse("Ctrl+UP").unwrap(), chord("ctrl+up"));
1602 assert_eq!(KeyChord::parse("A").unwrap().display(), "Shift+A");
1603 assert!(KeyChord::parse("ctrl+wat").is_err());
1604 assert!(KeyChord::parse("f99").is_err());
1605 }
1606
1607 #[test]
1608 fn defaults_validate_and_cross_mode_reuse_is_allowed() {
1609 let defaults = KeyBindings::resolve(&KeyBindings::default()).unwrap();
1610 assert!(
1611 defaults
1612 .action(
1613 InputContext::Board,
1614 KeyEvent::new(KeyCode::Char('q'), KeyModifiers::NONE)
1615 )
1616 .is_some()
1617 );
1618 let mut overrides = KeyBindings::default();
1619 overrides.board.open = Some(vec!["f4".into()]);
1620 overrides.detail.close = Some(vec!["f4".into()]);
1621 assert!(KeyBindings::resolve(&overrides).is_ok());
1622 }
1623
1624 #[test]
1625 fn board_and_detail_bind_v_to_view() {
1626 let bindings = KeyBindings::resolve(&KeyBindings::default()).unwrap();
1627 for context in [InputContext::Board, InputContext::Detail] {
1628 let event = KeyEvent::new(KeyCode::Char('v'), KeyModifiers::NONE);
1629 assert_eq!(
1630 bindings.action(context, event),
1631 Some(TuiAction::View),
1632 "{context:?} must bind `v` to view"
1633 );
1634 assert_eq!(
1635 bindings.canonical_event(context, TuiAction::View),
1636 event,
1637 "`view` must canonicalize to `v` so the handler sees it as typed"
1638 );
1639 }
1640 }
1641
1642 #[test]
1647 fn the_conflict_prompt_binds_overwrite_abandon_and_compare() {
1648 let bindings = KeyBindings::resolve(&KeyBindings::default()).unwrap();
1649 let cases = [
1650 (KeyCode::Char('o'), TuiAction::Confirm),
1651 (KeyCode::Char('y'), TuiAction::Confirm),
1652 (KeyCode::Char('a'), TuiAction::Ignore),
1653 (KeyCode::Esc, TuiAction::Ignore),
1654 (KeyCode::Char('c'), TuiAction::Reedit),
1655 (KeyCode::Char('r'), TuiAction::Reedit),
1656 ];
1657 for (code, action) in cases {
1658 assert_eq!(
1659 bindings.action(
1660 InputContext::EditConflict,
1661 KeyEvent::new(code, KeyModifiers::NONE)
1662 ),
1663 Some(action),
1664 "{code:?} must be {action:?} in the conflict prompt"
1665 );
1666 }
1667 for (action, expected) in [
1670 (TuiAction::Confirm, KeyCode::Char('y')),
1671 (TuiAction::Ignore, KeyCode::Enter),
1672 (TuiAction::Reedit, KeyCode::Char('r')),
1673 ] {
1674 assert_eq!(
1675 bindings.canonical_event(InputContext::EditConflict, action),
1676 KeyEvent::new(expected, KeyModifiers::NONE),
1677 "{action:?} must hand the handler `{expected:?}`"
1678 );
1679 }
1680 assert_eq!(
1682 bindings.action(
1683 InputContext::EditConflict,
1684 KeyEvent::new(KeyCode::Char('q'), KeyModifiers::NONE)
1685 ),
1686 None
1687 );
1688 }
1689
1690 #[test]
1691 fn conflicts_report_mode_and_actions() {
1692 let mut overrides = KeyBindings::default();
1693 overrides.board.open = Some(vec!["ctrl+n".into()]);
1694 overrides.board.new_issue = Some(vec!["ctrl+n".into()]);
1695 let error = KeyBindings::resolve(&overrides).unwrap_err().to_string();
1696 assert!(error.contains("board") && error.contains("open") && error.contains("new_issue"));
1697 }
1698
1699 #[test]
1700 fn shell_defaults_resolve_mode_switches_and_toggle() {
1701 let bindings = KeyBindings::resolve(&KeyBindings::default()).unwrap();
1702 let cases = [
1705 (KeyCode::F(1), TuiAction::SwitchToNewIssue),
1706 (KeyCode::F(2), TuiAction::SwitchToTriage),
1707 (KeyCode::F(3), TuiAction::SwitchToBoard),
1708 (KeyCode::F(4), TuiAction::SwitchToCycle),
1709 (KeyCode::F(5), TuiAction::SwitchToMilestones),
1710 (KeyCode::F(6), TuiAction::SwitchToRepos),
1711 (KeyCode::F(12), TuiAction::SwitchToConfig),
1712 ];
1713 for (code, action) in cases {
1714 assert_eq!(
1715 bindings.action(InputContext::Shell, KeyEvent::new(code, KeyModifiers::NONE)),
1716 Some(action),
1717 "{code:?} must switch modes in the shell"
1718 );
1719 assert_eq!(
1720 bindings.canonical_event(InputContext::Shell, action),
1721 KeyEvent::new(code, KeyModifiers::NONE),
1722 "{action:?} must canonicalize to its default chord"
1723 );
1724 }
1725 assert_eq!(
1726 bindings.action(
1727 InputContext::Shell,
1728 KeyEvent::new(KeyCode::Char('n'), KeyModifiers::CONTROL)
1729 ),
1730 Some(TuiAction::ToggleNavigation)
1731 );
1732 for number in 7..=11 {
1735 assert_eq!(
1736 bindings.action(
1737 InputContext::Shell,
1738 KeyEvent::new(KeyCode::F(number), KeyModifiers::NONE)
1739 ),
1740 None,
1741 "F{number} must stay unbound in the shell"
1742 );
1743 }
1744 }
1745
1746 #[test]
1747 fn shell_conflict_reports_both_actions_and_mode() {
1748 let mut overrides = KeyBindings::default();
1749 overrides.shell.switch_to_board = Some(vec!["f2".into()]);
1750 overrides.shell.switch_to_triage = Some(vec!["f2".into()]);
1751 let error = KeyBindings::resolve(&overrides).unwrap_err().to_string();
1752 assert!(
1753 error.contains("shell")
1754 && error.contains("switch_to_board")
1755 && error.contains("switch_to_triage"),
1756 "got {error:?}"
1757 );
1758 }
1759}