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 Stats,
54 Schedule,
60 Library,
67 Portfolio,
75 Rollover,
76 Detail,
77 Form,
78 FormPicker,
79 ConfirmDelete,
80 MoveIntoDone,
81 ConfirmBlockedMove,
84 Help,
85 HelpSearch,
92 ContextMenu,
93 RelatedPicker,
99 Filter,
100 FilterModal,
101 Settings,
102 SettingsPrompt,
103 PlanningManagement,
104 PlanningConfirm,
105 PlanningForm,
106 DatePicker,
107 Comment,
108 Asset,
109 EditRepair,
117 EditConflict,
125 LinkPrompt,
131 Shell,
140 Views,
147 ViewsPrompt,
155}
156
157impl InputContext {
158 pub const ALL: &'static [Self] = &[
159 Self::Board,
160 Self::Rollover,
161 Self::Detail,
162 Self::Form,
163 Self::FormPicker,
164 Self::ConfirmDelete,
165 Self::MoveIntoDone,
166 Self::ConfirmBlockedMove,
167 Self::Help,
168 Self::HelpSearch,
169 Self::ContextMenu,
170 Self::RelatedPicker,
173 Self::Filter,
174 Self::FilterModal,
175 Self::BulkModal,
176 Self::Triage,
177 Self::Cycle,
178 Self::Milestones,
179 Self::Repos,
182 Self::Stats,
185 Self::Schedule,
188 Self::Library,
191 Self::Portfolio,
195 Self::Settings,
196 Self::SettingsPrompt,
197 Self::PlanningManagement,
198 Self::PlanningConfirm,
199 Self::PlanningForm,
200 Self::DatePicker,
201 Self::Comment,
202 Self::Asset,
203 Self::EditRepair,
204 Self::EditConflict,
207 Self::LinkPrompt,
211 Self::Shell,
214 Self::Views,
215 Self::ViewsPrompt,
216 ];
217
218 pub const fn as_str(self) -> &'static str {
219 match self {
220 Self::Board => "board",
221 Self::Rollover => "rollover",
222 Self::Detail => "detail",
223 Self::Form => "form",
224 Self::FormPicker => "form_picker",
225 Self::ConfirmDelete => "confirm_delete",
226 Self::ConfirmBlockedMove => "confirm_blocked_move",
227 Self::MoveIntoDone => "move_into_done",
228 Self::Help => "help",
229 Self::HelpSearch => "help_search",
230 Self::ContextMenu => "context_menu",
231 Self::RelatedPicker => "related_picker",
232 Self::Filter => "filter",
233 Self::FilterModal => "filter_modal",
234 Self::BulkModal => "bulk_modal",
235 Self::Settings => "settings",
236 Self::SettingsPrompt => "settings_prompt",
237 Self::PlanningManagement => "planning_management",
238 Self::PlanningConfirm => "planning_confirm",
239 Self::PlanningForm => "planning_form",
240 Self::DatePicker => "date_picker",
241 Self::Comment => "comment",
242 Self::Asset => "asset",
243 Self::EditRepair => "edit_repair",
244 Self::EditConflict => "edit_conflict",
245 Self::LinkPrompt => "link_prompt",
246 Self::Views => "views",
247 Self::ViewsPrompt => "views_prompt",
248 Self::Triage => "triage",
249 Self::Cycle => "cycle",
250 Self::Milestones => "milestones",
251 Self::Repos => "repos",
252 Self::Stats => "stats",
253 Self::Schedule => "schedule",
254 Self::Library => "library",
255 Self::Portfolio => "portfolio",
256 Self::Shell => "shell",
257 }
258 }
259}
260
261#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
262#[serde(rename_all = "snake_case")]
263pub enum TuiAction {
264 MoveUp,
265 MoveDown,
266 MoveLeft,
267 MoveRight,
268 NextBoard,
269 PreviousBoard,
270 Open,
271 Edit,
272 Agent,
273 View,
278 ToggleColumn,
279 FilterModal,
280 BulkModal,
281 Triage,
285 OpenMentions,
296 Views,
299 ApplyView,
301 NewView,
303 RenameView,
305 UpdateView,
307 DuplicateView,
309 DeleteView,
312 Mark,
313 Unmark,
314 MovePrevious,
315 MoveNext,
316 NewIssue,
317 CloneIssue,
323 ToggleArchived,
328 ToggleChildren,
335 Delete,
336 Refresh,
337 Settings,
338 Planning,
339 NewBoard,
340 Help,
341 Quit,
342 TextFilter,
343 ReorderUp,
344 ReorderDown,
345 Close,
346 ScrollUp,
347 ScrollDown,
348 Comment,
349 Asset,
350 NewRelationship,
352 EditRelationship,
354 RemoveRelationship,
356 EditParent,
365 NextRelationship,
367 PreviousRelationship,
368 RelatedPicker,
374 FollowRelated,
378 NavBack,
383 Submit,
384 Cancel,
385 Next,
386 Previous,
387 OpenPicker,
388 Reset,
389 Toggle,
390 AddColumn,
391 MoveColumnUp,
394 MoveColumnDown,
397 NewPlanningItem,
398 EditPlanningItem,
399 DeletePlanningItem,
400 Confirm,
401 Create,
402 PreviousYear,
403 NextYear,
404 PreviousMonth,
405 NextMonth,
406 Reedit,
407 Ignore,
408 DeclareKey,
416 Discard,
424 SwitchToBoard,
426 SwitchToTriage,
428 SwitchToCycle,
430 SwitchToMilestones,
432 SwitchToRepos,
437 SwitchToStats,
442 HealthForm,
449 TimeAdd,
456 TimeEdit,
458 TimeDelete,
464 TimeFilter,
469 TimeBreakdown,
472 TimeHistory,
475 SwitchToSchedule,
477 SeriesGenerate,
479 SeriesPause,
481 SeriesResume,
483 SeriesEnd,
485 SeriesSkip,
487 SeriesDefer,
489 SeriesIgnore,
491 SeriesNew,
497 SwitchToLibrary,
499 SwitchToPortfolio,
504 SwitchToNewIssue,
506 SwitchToConfig,
508 ToggleNavigation,
510}
511
512impl TuiAction {
513 pub const fn as_str(self) -> &'static str {
514 match self {
515 Self::MoveUp => "move_up",
516 Self::MoveDown => "move_down",
517 Self::MoveLeft => "move_left",
518 Self::MoveRight => "move_right",
519 Self::NextBoard => "next_board",
520 Self::PreviousBoard => "previous_board",
521 Self::Open => "open",
522 Self::Edit => "edit",
523 Self::Agent => "agent",
524 Self::View => "view",
525 Self::ToggleColumn => "toggle_column",
526 Self::OpenMentions => "open_mentions",
527 Self::FilterModal => "filter_modal",
528 Self::BulkModal => "bulk_modal",
529 Self::Triage => "triage",
530 Self::Views => "views",
531 Self::ApplyView => "apply_view",
532 Self::NewView => "new_view",
533 Self::RenameView => "rename_view",
534 Self::UpdateView => "update_view",
535 Self::DuplicateView => "duplicate_view",
536 Self::DeleteView => "delete_view",
537 Self::Mark => "mark",
538 Self::Unmark => "unmark",
539 Self::MovePrevious => "move_previous",
540 Self::MoveNext => "move_next",
541 Self::NewIssue => "new_issue",
542 Self::CloneIssue => "clone_issue",
543 Self::ToggleArchived => "toggle_archived",
544 Self::ToggleChildren => "toggle_children",
545 Self::Delete => "delete",
546 Self::Refresh => "refresh",
547 Self::Settings => "settings",
548 Self::Planning => "planning",
549 Self::NewBoard => "new_board",
550 Self::Help => "help",
551 Self::Quit => "quit",
552 Self::TextFilter => "text_filter",
553 Self::ReorderUp => "reorder_up",
554 Self::ReorderDown => "reorder_down",
555 Self::Close => "close",
556 Self::ScrollUp => "scroll_up",
557 Self::ScrollDown => "scroll_down",
558 Self::Comment => "comment",
559 Self::Asset => "asset",
560 Self::NewRelationship => "new_relationship",
561 Self::EditRelationship => "edit_relationship",
562 Self::RemoveRelationship => "remove_relationship",
563 Self::EditParent => "edit_parent",
564 Self::NextRelationship => "next_relationship",
565 Self::PreviousRelationship => "previous_relationship",
566 Self::RelatedPicker => "related_picker",
567 Self::FollowRelated => "follow_related",
568 Self::NavBack => "nav_back",
569 Self::Submit => "submit",
570 Self::Cancel => "cancel",
571 Self::Next => "next",
572 Self::Previous => "previous",
573 Self::OpenPicker => "open_picker",
574 Self::Reset => "reset",
575 Self::Toggle => "toggle",
576 Self::AddColumn => "add_column",
577 Self::MoveColumnUp => "move_column_up",
578 Self::MoveColumnDown => "move_column_down",
579 Self::NewPlanningItem => "new_planning_item",
580 Self::EditPlanningItem => "edit_planning_item",
581 Self::DeletePlanningItem => "delete_planning_item",
582 Self::Confirm => "confirm",
583 Self::Create => "create",
584 Self::PreviousYear => "previous_year",
585 Self::NextYear => "next_year",
586 Self::PreviousMonth => "previous_month",
587 Self::NextMonth => "next_month",
588 Self::Reedit => "reedit",
589 Self::Ignore => "ignore",
590 Self::DeclareKey => "declare_key",
591 Self::Discard => "discard",
592 Self::SwitchToBoard => "switch_to_board",
593 Self::SwitchToTriage => "switch_to_triage",
594 Self::SwitchToCycle => "switch_to_cycle",
595 Self::SwitchToMilestones => "switch_to_milestones",
596 Self::SwitchToRepos => "switch_to_repos",
597 Self::SwitchToStats => "switch_to_stats",
598 Self::HealthForm => "health_form",
599 Self::TimeAdd => "time_add",
600 Self::TimeEdit => "time_edit",
601 Self::TimeDelete => "time_delete",
602 Self::TimeFilter => "time_filter",
603 Self::TimeBreakdown => "time_breakdown",
604 Self::TimeHistory => "time_history",
605 Self::SwitchToSchedule => "switch_to_schedule",
606 Self::SeriesGenerate => "series_generate",
607 Self::SeriesPause => "series_pause",
608 Self::SeriesResume => "series_resume",
609 Self::SeriesEnd => "series_end",
610 Self::SeriesSkip => "series_skip",
611 Self::SeriesDefer => "series_defer",
612 Self::SeriesIgnore => "series_ignore",
613 Self::SeriesNew => "series_new",
614 Self::SwitchToLibrary => "switch_to_library",
615 Self::SwitchToPortfolio => "switch_to_portfolio",
616 Self::SwitchToNewIssue => "switch_to_new_issue",
617 Self::SwitchToConfig => "switch_to_config",
618 Self::ToggleNavigation => "toggle_navigation",
619 }
620 }
621
622 pub const fn description(self) -> &'static str {
623 match self {
624 Self::MoveUp => "move up",
625 Self::MoveDown => "move down",
626 Self::MoveLeft => "previous column",
627 Self::MoveRight => "next column",
628 Self::NextBoard => "next board",
629 Self::PreviousBoard => "previous board",
630 Self::Open => "open detail",
631 Self::Edit => "edit",
632 Self::Agent => "open agent",
633 Self::View => "view issue",
634 Self::ToggleColumn => "toggle column",
635 Self::FilterModal => "filter criteria",
636 Self::BulkModal => "batch select",
637 Self::Triage => "triage inbox",
638 Self::OpenMentions => "mentions",
639 Self::Views => "saved views",
640 Self::ApplyView => "apply view",
641 Self::NewView => "new view",
642 Self::RenameView => "rename view",
643 Self::UpdateView => "update view from live",
644 Self::DuplicateView => "duplicate view",
645 Self::DeleteView => "delete view",
646 Self::Mark => "mark for batch",
647 Self::Unmark => "unmark for batch",
648 Self::MovePrevious => "move to previous column",
649 Self::MoveNext => "move to next column",
650 Self::NewIssue => "new issue",
651 Self::CloneIssue => "clone issue",
652 Self::ToggleArchived => "show archived issues",
653 Self::ToggleChildren => "toggle sub-issues",
654 Self::Delete => "delete",
655 Self::Refresh => "refresh",
656 Self::Settings => "settings",
657 Self::Planning => "planning",
658 Self::NewBoard => "new board",
659 Self::Help => "help",
660 Self::Quit => "quit",
661 Self::TextFilter => "search",
662 Self::ReorderUp => "reorder up",
663 Self::ReorderDown => "reorder down",
664 Self::Close => "close",
665 Self::ScrollUp => "scroll up one page",
666 Self::ScrollDown => "scroll down one page",
667 Self::Comment => "comment",
668 Self::Asset => "asset",
669 Self::NewRelationship => "new relationship",
670 Self::EditRelationship => "edit relationship",
671 Self::RemoveRelationship => "remove relationship",
672 Self::EditParent => "set parent",
673 Self::NextRelationship => "next relationship",
674 Self::PreviousRelationship => "previous relationship",
675 Self::RelatedPicker => "related issues",
676 Self::FollowRelated => "follow related issue",
677 Self::NavBack => "back",
678 Self::Submit => "submit",
679 Self::Cancel => "cancel",
680 Self::Next => "next",
681 Self::Previous => "previous",
682 Self::OpenPicker => "open picker",
683 Self::Reset => "reset",
684 Self::Toggle => "toggle",
685 Self::AddColumn => "add column",
686 Self::MoveColumnUp => "move column up",
687 Self::MoveColumnDown => "move column down",
688 Self::NewPlanningItem => "new",
689 Self::EditPlanningItem => "edit",
690 Self::DeletePlanningItem => "delete",
691 Self::Confirm => "confirm",
692 Self::Create => "create",
693 Self::PreviousYear => "previous year",
694 Self::NextYear => "next year",
695 Self::PreviousMonth => "previous month",
696 Self::NextMonth => "next month",
697 Self::Reedit => "re-edit",
698 Self::Ignore => "ignore",
699 Self::DeclareKey => "declare the key in tix.toml",
700 Self::Discard => "discard the edit",
701 Self::SwitchToBoard => "board mode",
702 Self::SwitchToTriage => "triage mode",
703 Self::SwitchToCycle => "cycle mode",
704 Self::SwitchToMilestones => "milestones mode",
705 Self::SwitchToRepos => "repos mode",
706 Self::SwitchToStats => "the Stats page",
707 Self::HealthForm => "record this issue's health",
708 Self::TimeAdd => "add a time entry",
709 Self::TimeEdit => "correct the selected entry",
710 Self::TimeDelete => "delete the selected entry",
711 Self::TimeFilter => "edit the time filter",
712 Self::TimeBreakdown => "cycle the breakdown",
713 Self::TimeHistory => "show the audit view",
714 Self::SwitchToSchedule => "schedule mode",
715 Self::SeriesGenerate => "generate the due occurrence",
716 Self::SeriesPause => "pause the series",
717 Self::SeriesResume => "resume the series",
718 Self::SeriesEnd => "end the series",
719 Self::SeriesSkip => "skip the due date",
720 Self::SeriesDefer => "defer the due date",
721 Self::SeriesIgnore => "ignore the due date",
722 Self::SeriesNew => "create a new series",
723 Self::SwitchToLibrary => "library mode",
724 Self::SwitchToPortfolio => "portfolio mode",
725 Self::SwitchToNewIssue => "new issue mode",
726 Self::SwitchToConfig => "config mode",
727 Self::ToggleNavigation => "toggle navigation",
728 }
729 }
730
731 pub const fn description_in(self, context: InputContext) -> &'static str {
739 match (context, self) {
740 (InputContext::Cycle, Self::MoveLeft) => "previous group",
741 (InputContext::Cycle, Self::MoveRight) => "next group",
742 (InputContext::Library, Self::Previous) => "previous tab",
745 (InputContext::Library, Self::Next) => "next tab",
746 (InputContext::Library, Self::Submit) => "apply the selected view",
747 (InputContext::Stats, Self::Submit) => "open the row's issue",
751 (InputContext::Settings, Self::MoveLeft) => "previous pane",
752 (InputContext::Settings, Self::MoveRight) => "next pane",
753 (InputContext::Settings, Self::Submit) => "step into the body / activate row",
754 (InputContext::FilterModal, Self::OpenPicker) => "open the focused row's picker",
758 (InputContext::FilterModal, Self::Submit) => "apply and close",
759 (InputContext::FilterModal, Self::Cancel) => "apply and close",
760 (InputContext::EditRepair, Self::Confirm) => "apply the proposed fix",
764 (InputContext::EditRepair, Self::DeclareKey) => "declare the key in tix.toml",
765 (InputContext::EditRepair, Self::Reedit) => "reopen the editor on the retained copy",
766 (InputContext::EditRepair, Self::Ignore) => "acknowledge (keeps the copy)",
767 (InputContext::EditRepair, Self::Discard) => "discard the edit (asks again)",
768 (InputContext::EditConflict, Self::Confirm) => "overwrite the file with this edit",
772 (InputContext::EditConflict, Self::Ignore) => "keep the on-disk version",
773 (InputContext::EditConflict, Self::Reedit) => "compare in the editor",
774 _ => self.description(),
775 }
776 }
777}
778
779pub type Binding = Vec<String>;
780
781macro_rules! partial_bindings {
782 ($($field:ident),+ $(,)?) => {
783 #[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
784 #[serde(default, deny_unknown_fields)]
785 pub struct ActionBindings {
786 $(
787 #[serde(skip_serializing_if = "Option::is_none")]
788 pub $field: Option<Binding>,
789 )+
790 }
791 };
792}
793
794partial_bindings!(
795 open_mentions,
799 move_up,
800 move_down,
801 move_left,
802 move_right,
803 next_board,
804 previous_board,
805 open,
806 edit,
807 agent,
808 view,
809 toggle_column,
810 filter_modal,
811 bulk_modal,
812 triage,
813 views,
814 apply_view,
815 new_view,
816 rename_view,
817 update_view,
818 duplicate_view,
819 delete_view,
820 mark,
821 unmark,
822 move_previous,
823 move_next,
824 new_issue,
825 clone_issue,
826 toggle_archived,
827 toggle_children,
828 delete,
829 refresh,
830 settings,
831 planning,
832 new_board,
833 help,
834 quit,
835 text_filter,
836 reorder_up,
837 reorder_down,
838 close,
839 scroll_up,
840 scroll_down,
841 comment,
842 asset,
843 edit_parent,
844 submit,
845 cancel,
846 next,
847 previous,
848 open_picker,
849 reset,
850 toggle,
851 add_column,
852 move_column_up,
853 move_column_down,
854 new_planning_item,
855 edit_planning_item,
856 delete_planning_item,
857 confirm,
858 create,
859 previous_year,
860 next_year,
861 previous_month,
862 next_month,
863 reedit,
864 ignore,
865 declare_key,
866 discard,
867 switch_to_board,
868 switch_to_triage,
869 switch_to_cycle,
870 switch_to_milestones,
871 switch_to_repos,
872 switch_to_stats,
873 health_form,
874 time_add,
878 time_edit,
879 time_delete,
880 time_filter,
881 time_breakdown,
882 time_history,
883 switch_to_schedule,
885 series_generate,
889 series_pause,
890 series_resume,
891 series_end,
892 series_skip,
893 series_defer,
894 series_ignore,
895 series_new,
896 switch_to_library,
898 switch_to_portfolio,
899 switch_to_new_issue,
900 switch_to_config,
901 toggle_navigation,
902);
903
904macro_rules! keybindings_config {
905 ($($field:ident => $context:ident),+ $(,)?) => {
906 #[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
907 #[serde(default, deny_unknown_fields)]
908 pub struct KeyBindings {
909 $(
910 #[serde(skip_serializing_if = "ActionBindings::is_empty")]
911 pub $field: ActionBindings,
912 )+
913 }
914
915 impl KeyBindings {
916 pub fn is_empty(&self) -> bool {
917 self == &Self::default()
918 }
919
920 fn overrides(&self, context: InputContext) -> &ActionBindings {
921 match context {
922 $(InputContext::$context => &self.$field,)+
923 }
924 }
925 }
926 };
927}
928
929keybindings_config!(
930 board => Board,
931 rollover => Rollover,
932 detail => Detail,
933 form => Form,
934 form_picker => FormPicker,
935 confirm_delete => ConfirmDelete,
936 confirm_blocked_move => ConfirmBlockedMove,
937 help_search => HelpSearch,
938 move_into_done => MoveIntoDone,
939 help => Help,
940 context_menu => ContextMenu,
941 related_picker => RelatedPicker,
944 filter => Filter,
945 filter_modal => FilterModal,
946 bulk_modal => BulkModal,
947 triage => Triage,
948 cycle => Cycle,
949 milestones => Milestones,
950 repos => Repos,
953 stats => Stats,
956 schedule => Schedule,
959 library => Library,
962 portfolio => Portfolio,
965 settings => Settings,
966 settings_prompt => SettingsPrompt,
967 planning_management => PlanningManagement,
968 planning_confirm => PlanningConfirm,
969 planning_form => PlanningForm,
970 date_picker => DatePicker,
971 comment => Comment,
972 asset => Asset,
973 edit_repair => EditRepair,
974 edit_conflict => EditConflict,
975 link_prompt => LinkPrompt,
976 shell => Shell,
977 views => Views,
978 views_prompt => ViewsPrompt,
979);
980
981#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
982pub struct KeyChord {
983 pub code: KeyCode,
984 pub modifiers: KeyModifiers,
985}
986
987impl KeyChord {
988 pub fn parse(value: &str) -> std::result::Result<Self, String> {
989 let value = value.trim();
990 if value.is_empty() {
991 return Err("key chord must not be empty".into());
992 }
993 if value.chars().count() == 1 {
994 let ch = value.chars().next().expect("value has one character");
995 if ch.is_control() {
996 return Err(format!("unsupported key name `{value}`"));
997 }
998 let modifiers = if ch.is_ascii_uppercase() {
999 KeyModifiers::SHIFT
1000 } else {
1001 KeyModifiers::NONE
1002 };
1003 return Ok(Self {
1004 code: KeyCode::Char(ch),
1005 modifiers,
1006 });
1007 }
1008 let mut parts = value.split('+');
1009 let key = parts.next_back().ok_or("missing key name")?.trim();
1010 let mut modifiers = KeyModifiers::NONE;
1011 for part in parts {
1012 match part.trim().to_ascii_lowercase().as_str() {
1013 "ctrl" | "control" => modifiers |= KeyModifiers::CONTROL,
1014 "alt" | "option" => modifiers |= KeyModifiers::ALT,
1015 "shift" => modifiers |= KeyModifiers::SHIFT,
1016 "super" | "cmd" | "command" => modifiers |= KeyModifiers::SUPER,
1017 other => return Err(format!("unsupported modifier `{other}`")),
1018 }
1019 }
1020 if key.is_empty() {
1021 return Err("missing key name".into());
1022 }
1023 let mut code = match key.to_ascii_lowercase().as_str() {
1024 "backtab" => return Err("use shift+tab instead of `backtab`".into()),
1025 "shift+tab" => KeyCode::BackTab,
1026 "backspace" => KeyCode::Backspace,
1027 "tab" => KeyCode::Tab,
1028 "enter" | "return" => KeyCode::Enter,
1029 "esc" | "escape" => KeyCode::Esc,
1030 "space" => KeyCode::Char(' '),
1031 "left" => KeyCode::Left,
1032 "right" => KeyCode::Right,
1033 "up" => KeyCode::Up,
1034 "down" => KeyCode::Down,
1035 "home" => KeyCode::Home,
1036 "end" => KeyCode::End,
1037 "pageup" | "pgup" => KeyCode::PageUp,
1038 "pagedown" | "pgdn" | "pgdown" => KeyCode::PageDown,
1039 "delete" | "del" => KeyCode::Delete,
1040 "insert" | "ins" => KeyCode::Insert,
1041 _ => {
1042 if let Some(number) = key
1043 .to_ascii_lowercase()
1044 .strip_prefix('f')
1045 .and_then(|n| n.parse::<u8>().ok())
1046 {
1047 if (1..=12).contains(&number) {
1048 KeyCode::F(number)
1049 } else {
1050 return Err(format!("unsupported function key `{key}`"));
1051 }
1052 } else {
1053 let mut chars = key.chars();
1054 let Some(ch) = chars.next().filter(|_| chars.next().is_none()) else {
1055 return Err(format!("unsupported key name `{key}`"));
1056 };
1057 if ch.is_control() {
1058 return Err(format!("unsupported key name `{key}`"));
1059 }
1060 if ch.is_ascii_uppercase() {
1061 modifiers |= KeyModifiers::SHIFT;
1062 }
1063 KeyCode::Char(ch)
1064 }
1065 }
1066 };
1067 if code == KeyCode::Tab && modifiers.contains(KeyModifiers::SHIFT) {
1068 code = KeyCode::BackTab;
1069 modifiers.remove(KeyModifiers::SHIFT);
1070 }
1071 if code == KeyCode::BackTab {
1072 modifiers |= KeyModifiers::SHIFT;
1073 }
1074 Ok(Self { code, modifiers })
1075 }
1076
1077 pub fn matches(&self, event: KeyEvent) -> bool {
1078 if self.code != event.code {
1079 return false;
1080 }
1081 if self.modifiers.is_empty() {
1082 return event.modifiers.is_empty();
1083 }
1084 if self.code == KeyCode::BackTab {
1085 return event.modifiers.contains(KeyModifiers::SHIFT) || event.code == KeyCode::BackTab;
1086 }
1087 if let (KeyCode::Char(expected), KeyCode::Char(actual)) = (self.code, event.code) {
1088 let shift_expected = self.modifiers.contains(KeyModifiers::SHIFT);
1089 let normalized_expected = if shift_expected {
1090 expected.to_ascii_lowercase()
1091 } else {
1092 expected
1093 };
1094 let shift_actual =
1095 actual.is_uppercase() || event.modifiers.contains(KeyModifiers::SHIFT);
1096 let expected_other = self.modifiers.difference(KeyModifiers::SHIFT);
1097 let actual_other = event.modifiers.difference(KeyModifiers::SHIFT);
1098 return expected_other == actual_other
1099 && normalized_expected == actual.to_ascii_lowercase()
1100 && shift_expected == shift_actual;
1101 }
1102 event.modifiers == self.modifiers
1103 }
1104
1105 pub fn display(&self) -> String {
1106 let mut out = String::new();
1107 if self.modifiers.contains(KeyModifiers::CONTROL) {
1108 out.push_str("Ctrl+");
1109 }
1110 if self.modifiers.contains(KeyModifiers::ALT) {
1111 out.push_str("Alt+");
1112 }
1113 if self.modifiers.contains(KeyModifiers::SUPER) {
1114 out.push_str("Super+");
1115 }
1116 if self.modifiers.contains(KeyModifiers::SHIFT) && self.code != KeyCode::BackTab {
1117 out.push_str("Shift+");
1118 }
1119 out.push_str(&match self.code {
1120 KeyCode::BackTab => "Shift+Tab".into(),
1121 KeyCode::Char(' ') => "Space".into(),
1122 KeyCode::Char(ch) => ch.to_string(),
1123 KeyCode::Enter => "Enter".into(),
1124 KeyCode::Esc => "Esc".into(),
1125 KeyCode::Left => "Left".into(),
1126 KeyCode::Right => "Right".into(),
1127 KeyCode::Up => "Up".into(),
1128 KeyCode::Down => "Down".into(),
1129 KeyCode::PageUp => "PgUp".into(),
1130 KeyCode::PageDown => "PgDn".into(),
1131 KeyCode::Delete => "Delete".into(),
1132 KeyCode::Insert => "Insert".into(),
1133 KeyCode::Home => "Home".into(),
1134 KeyCode::End => "End".into(),
1135 KeyCode::F(n) => format!("F{n}"),
1136 other => format!("{other:?}"),
1137 });
1138 out
1139 }
1140}
1141
1142impl Serialize for KeyChord {
1143 fn serialize<S>(&self, serializer: S) -> std::result::Result<S::Ok, S::Error>
1144 where
1145 S: Serializer,
1146 {
1147 serializer.serialize_str(&self.display())
1148 }
1149}
1150
1151impl<'de> Deserialize<'de> for KeyChord {
1152 fn deserialize<D>(deserializer: D) -> std::result::Result<Self, D::Error>
1153 where
1154 D: Deserializer<'de>,
1155 {
1156 let value = String::deserialize(deserializer)?;
1157 Self::parse(&value).map_err(D::Error::custom)
1158 }
1159}
1160
1161impl fmt::Display for KeyChord {
1162 fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
1163 formatter.write_str(&self.display())
1164 }
1165}
1166
1167#[derive(Debug, Clone, Default, PartialEq, Eq)]
1168pub struct EffectiveBindings {
1169 modes: HashMap<InputContext, HashMap<TuiAction, Vec<KeyChord>>>,
1170}
1171
1172fn chord(value: &str) -> KeyChord {
1173 KeyChord::parse(value).expect("built-in key chord must parse")
1174}
1175
1176fn set(map: &mut HashMap<TuiAction, Vec<KeyChord>>, action: TuiAction, values: &[&str]) {
1177 map.insert(action, values.iter().map(|value| chord(value)).collect());
1178}
1179
1180impl KeyBindings {
1181 pub fn resolve(overrides: &KeyBindings) -> Result<EffectiveBindings> {
1182 let mut effective = EffectiveBindings::default();
1183 for context in InputContext::ALL {
1184 let mut mode = default_mode(*context);
1185 overlay(&mut mode, overrides.overrides(*context), *context)?;
1186 effective.modes.insert(*context, mode);
1187 }
1188 Ok(effective)
1189 }
1190}
1191
1192impl EffectiveBindings {
1193 pub fn action(&self, context: InputContext, event: KeyEvent) -> Option<TuiAction> {
1194 self.modes
1195 .get(&context)?
1196 .iter()
1197 .find_map(|(action, chords)| {
1198 chords
1199 .iter()
1200 .any(|key| key.matches(event))
1201 .then_some(*action)
1202 })
1203 }
1204
1205 pub fn bound_actions(&self, context: InputContext) -> Vec<TuiAction> {
1211 let mut actions: Vec<TuiAction> = self
1212 .modes
1213 .get(&context)
1214 .map(|mode| mode.keys().copied().collect())
1215 .unwrap_or_default();
1216 actions.sort_by_key(|action| action.as_str());
1217 actions
1218 }
1219
1220 pub fn chords(&self, context: InputContext, action: TuiAction) -> &[KeyChord] {
1221 self.modes
1222 .get(&context)
1223 .and_then(|mode| mode.get(&action))
1224 .map(Vec::as_slice)
1225 .unwrap_or_default()
1226 }
1227
1228 pub fn label(&self, context: InputContext, action: TuiAction) -> String {
1229 let labels: Vec<String> = self
1230 .chords(context, action)
1231 .iter()
1232 .map(ToString::to_string)
1233 .collect();
1234 if labels.is_empty() {
1235 "—".into()
1236 } else {
1237 labels.join(" / ")
1238 }
1239 }
1240
1241 pub fn canonical_event(&self, context: InputContext, action: TuiAction) -> KeyEvent {
1242 let (code, modifiers) = match (context, action) {
1243 (_, TuiAction::ReorderUp) => (KeyCode::Up, KeyModifiers::CONTROL),
1244 (_, TuiAction::ReorderDown) => (KeyCode::Down, KeyModifiers::CONTROL),
1245 (InputContext::Rollover, TuiAction::Previous) => (KeyCode::Tab, KeyModifiers::NONE),
1246 (InputContext::PlanningManagement, TuiAction::Next) => {
1247 (KeyCode::Tab, KeyModifiers::NONE)
1248 }
1249 (InputContext::FormPicker, TuiAction::Next) => (KeyCode::Up, KeyModifiers::NONE),
1250 (InputContext::FormPicker, TuiAction::Previous) => (KeyCode::Down, KeyModifiers::NONE),
1251 (InputContext::DatePicker, TuiAction::PreviousYear) => {
1252 (KeyCode::Char('['), KeyModifiers::NONE)
1253 }
1254 (InputContext::DatePicker, TuiAction::NextYear) => {
1255 (KeyCode::Char(']'), KeyModifiers::NONE)
1256 }
1257 (InputContext::DatePicker, TuiAction::PreviousMonth) => {
1258 (KeyCode::PageUp, KeyModifiers::NONE)
1259 }
1260 (InputContext::DatePicker, TuiAction::NextMonth) => {
1261 (KeyCode::PageDown, KeyModifiers::NONE)
1262 }
1263 (InputContext::DatePicker, TuiAction::Reset) => (KeyCode::Delete, KeyModifiers::NONE),
1264 (InputContext::Library, TuiAction::Next) => (KeyCode::Tab, KeyModifiers::NONE),
1270 (InputContext::Library, TuiAction::Previous) => (KeyCode::BackTab, KeyModifiers::NONE),
1271 (InputContext::Portfolio, TuiAction::Next) => (KeyCode::Tab, KeyModifiers::NONE),
1276 (InputContext::Portfolio, TuiAction::Previous) => {
1277 (KeyCode::BackTab, KeyModifiers::NONE)
1278 }
1279 (InputContext::Stats, TuiAction::Next) => (KeyCode::Tab, KeyModifiers::NONE),
1283 (InputContext::Stats, TuiAction::Previous) => (KeyCode::BackTab, KeyModifiers::NONE),
1284 (InputContext::Settings, TuiAction::NewBoard) => {
1285 (KeyCode::Char('n'), KeyModifiers::NONE)
1286 }
1287 (InputContext::Settings, TuiAction::Delete) => (KeyCode::Char('x'), KeyModifiers::NONE),
1288 (_, TuiAction::ScrollUp) => (KeyCode::PageUp, KeyModifiers::NONE),
1296 (_, TuiAction::ScrollDown) => (KeyCode::PageDown, KeyModifiers::NONE),
1297 (_, TuiAction::MoveUp | TuiAction::Previous) => (KeyCode::Up, KeyModifiers::NONE),
1298 (_, TuiAction::MoveDown | TuiAction::Next) => (KeyCode::Down, KeyModifiers::NONE),
1299 (_, TuiAction::MoveLeft) => (KeyCode::Left, KeyModifiers::NONE),
1300 (_, TuiAction::MoveRight) => (KeyCode::Right, KeyModifiers::NONE),
1301 (_, TuiAction::PreviousBoard) => (KeyCode::BackTab, KeyModifiers::NONE),
1302 (_, TuiAction::NextBoard) => (KeyCode::Tab, KeyModifiers::NONE),
1303 (_, TuiAction::Submit | TuiAction::Open | TuiAction::Ignore) => {
1304 (KeyCode::Enter, KeyModifiers::NONE)
1305 }
1306 (_, TuiAction::NewIssue) => (KeyCode::Char('n'), KeyModifiers::NONE),
1307 (_, TuiAction::CloneIssue) => (KeyCode::Char('C'), KeyModifiers::NONE),
1308 (_, TuiAction::ToggleArchived) => (KeyCode::Char('a'), KeyModifiers::NONE),
1309 (_, TuiAction::ToggleChildren) => (KeyCode::Char('u'), KeyModifiers::NONE),
1310 (_, TuiAction::Edit | TuiAction::EditPlanningItem) => {
1311 (KeyCode::Char('e'), KeyModifiers::NONE)
1312 }
1313 (_, TuiAction::Agent) => (KeyCode::Char('A'), KeyModifiers::NONE),
1314 (InputContext::Board, TuiAction::View) => (KeyCode::Char('V'), KeyModifiers::NONE),
1318 (_, TuiAction::View) => (KeyCode::Char('v'), KeyModifiers::NONE),
1319 (_, TuiAction::ToggleColumn | TuiAction::Comment) => {
1320 (KeyCode::Char('c'), KeyModifiers::NONE)
1321 }
1322 (_, TuiAction::FilterModal) => (KeyCode::Char('f'), KeyModifiers::NONE),
1323 (_, TuiAction::BulkModal) => (KeyCode::Char('B'), KeyModifiers::NONE),
1324 (_, TuiAction::Triage) => (KeyCode::Char('t'), KeyModifiers::NONE),
1325 (_, TuiAction::OpenMentions) => (KeyCode::Char('i'), KeyModifiers::NONE),
1329 (_, TuiAction::Views) => (KeyCode::Char('v'), KeyModifiers::NONE),
1333 (_, TuiAction::ApplyView) => (KeyCode::Enter, KeyModifiers::NONE),
1334 (_, TuiAction::NewView) => (KeyCode::Char('n'), KeyModifiers::NONE),
1335 (_, TuiAction::RenameView) => (KeyCode::Char('e'), KeyModifiers::NONE),
1336 (_, TuiAction::UpdateView) => (KeyCode::Char('u'), KeyModifiers::NONE),
1337 (_, TuiAction::DuplicateView) => (KeyCode::Char('d'), KeyModifiers::NONE),
1338 (_, TuiAction::DeleteView) => (KeyCode::Char('x'), KeyModifiers::NONE),
1339 (_, TuiAction::Mark) => (KeyCode::Char(' '), KeyModifiers::NONE),
1340 (_, TuiAction::Unmark) => (KeyCode::Char('x'), KeyModifiers::NONE),
1341 (_, TuiAction::MovePrevious) => (KeyCode::Char('<'), KeyModifiers::NONE),
1342 (_, TuiAction::MoveNext) => (KeyCode::Char('>'), KeyModifiers::NONE),
1343 (_, TuiAction::Delete) => (KeyCode::Char('d'), KeyModifiers::NONE),
1344 (_, TuiAction::DeletePlanningItem) => (KeyCode::Char('x'), KeyModifiers::NONE),
1345 (_, TuiAction::Refresh | TuiAction::Reset | TuiAction::Reedit) => {
1346 (KeyCode::Char('r'), KeyModifiers::NONE)
1347 }
1348 (_, TuiAction::Settings) => (KeyCode::Char('s'), KeyModifiers::NONE),
1349 (_, TuiAction::Planning) => (KeyCode::Char('m'), KeyModifiers::NONE),
1350 (_, TuiAction::NewPlanningItem) => (KeyCode::Char('n'), KeyModifiers::NONE),
1351 (_, TuiAction::NewBoard) => (KeyCode::Char('b'), KeyModifiers::NONE),
1352 (_, TuiAction::AddColumn) => (KeyCode::Char('a'), KeyModifiers::NONE),
1353 (_, TuiAction::MoveColumnUp) => (KeyCode::Char('K'), KeyModifiers::SHIFT),
1354 (_, TuiAction::MoveColumnDown) => (KeyCode::Char('J'), KeyModifiers::SHIFT),
1355 (_, TuiAction::Help) => (KeyCode::Char('?'), KeyModifiers::NONE),
1356 (_, TuiAction::Quit) => (KeyCode::Char('q'), KeyModifiers::NONE),
1357 (_, TuiAction::TextFilter) => (KeyCode::Char('/'), KeyModifiers::NONE),
1358 (_, TuiAction::Close | TuiAction::Cancel) => (KeyCode::Esc, KeyModifiers::NONE),
1359 (_, TuiAction::Asset) => (KeyCode::Char('a'), KeyModifiers::NONE),
1360 (_, TuiAction::NewRelationship) => (KeyCode::Char('n'), KeyModifiers::NONE),
1363 (_, TuiAction::EditRelationship) => (KeyCode::Char('E'), KeyModifiers::SHIFT),
1364 (_, TuiAction::RemoveRelationship) => (KeyCode::Char('d'), KeyModifiers::NONE),
1365 (_, TuiAction::EditParent) => (KeyCode::Char('P'), KeyModifiers::NONE),
1368 (_, TuiAction::NextRelationship) => (KeyCode::Char('j'), KeyModifiers::NONE),
1369 (_, TuiAction::PreviousRelationship) => (KeyCode::Char('k'), KeyModifiers::NONE),
1370 (_, TuiAction::RelatedPicker) => (KeyCode::Char('r'), KeyModifiers::NONE),
1375 (_, TuiAction::FollowRelated) => (KeyCode::Char('o'), KeyModifiers::NONE),
1376 (_, TuiAction::NavBack) => (KeyCode::Backspace, KeyModifiers::NONE),
1377 (_, TuiAction::OpenPicker) => (KeyCode::F(2), KeyModifiers::NONE),
1378 (_, TuiAction::SwitchToBoard) => (KeyCode::F(3), KeyModifiers::NONE),
1383 (_, TuiAction::SwitchToTriage) => (KeyCode::F(2), KeyModifiers::NONE),
1384 (_, TuiAction::SwitchToCycle) => (KeyCode::F(4), KeyModifiers::NONE),
1385 (_, TuiAction::SwitchToMilestones) => (KeyCode::F(5), KeyModifiers::NONE),
1386 (_, TuiAction::SwitchToRepos) => (KeyCode::F(6), KeyModifiers::NONE),
1387 (_, TuiAction::SwitchToStats) => (KeyCode::F(7), KeyModifiers::NONE),
1390 (_, TuiAction::HealthForm) => (KeyCode::Char('H'), KeyModifiers::NONE),
1395 (_, TuiAction::TimeAdd) => (KeyCode::Char('a'), KeyModifiers::NONE),
1400 (_, TuiAction::TimeEdit) => (KeyCode::Char('e'), KeyModifiers::NONE),
1401 (_, TuiAction::TimeDelete) => (KeyCode::Char('x'), KeyModifiers::NONE),
1402 (_, TuiAction::TimeFilter) => (KeyCode::Char('f'), KeyModifiers::NONE),
1403 (_, TuiAction::TimeBreakdown) => (KeyCode::Char('s'), KeyModifiers::NONE),
1404 (_, TuiAction::TimeHistory) => (KeyCode::Char('h'), KeyModifiers::NONE),
1405 (_, TuiAction::SwitchToSchedule) => (KeyCode::F(8), KeyModifiers::NONE),
1407 (_, TuiAction::SeriesGenerate) => (KeyCode::Char('g'), KeyModifiers::NONE),
1410 (_, TuiAction::SeriesPause) => (KeyCode::Char('p'), KeyModifiers::NONE),
1411 (_, TuiAction::SeriesResume) => (KeyCode::Char('r'), KeyModifiers::NONE),
1412 (_, TuiAction::SeriesEnd) => (KeyCode::Char('x'), KeyModifiers::NONE),
1413 (_, TuiAction::SeriesSkip) => (KeyCode::Char('s'), KeyModifiers::NONE),
1414 (_, TuiAction::SeriesDefer) => (KeyCode::Char('d'), KeyModifiers::NONE),
1415 (_, TuiAction::SeriesIgnore) => (KeyCode::Char('i'), KeyModifiers::NONE),
1416 (_, TuiAction::SeriesNew) => (KeyCode::Char('n'), KeyModifiers::NONE),
1419 (_, TuiAction::SwitchToLibrary) => (KeyCode::F(11), KeyModifiers::NONE),
1422 (_, TuiAction::SwitchToPortfolio) => (KeyCode::F(10), KeyModifiers::NONE),
1424 (_, TuiAction::SwitchToNewIssue) => (KeyCode::F(1), KeyModifiers::NONE),
1425 (_, TuiAction::SwitchToConfig) => (KeyCode::F(12), KeyModifiers::NONE),
1426 (_, TuiAction::ToggleNavigation) => (KeyCode::Char('n'), KeyModifiers::CONTROL),
1427 (_, TuiAction::Toggle) => (KeyCode::Char(' '), KeyModifiers::NONE),
1428 (_, TuiAction::Confirm) => (KeyCode::Char('y'), KeyModifiers::NONE),
1429 (_, TuiAction::DeclareKey) => (KeyCode::Char('d'), KeyModifiers::NONE),
1433 (_, TuiAction::Discard) => (KeyCode::Char('x'), KeyModifiers::NONE),
1434 (_, TuiAction::Create) => (KeyCode::Char('+'), KeyModifiers::NONE),
1435 _ => (KeyCode::Esc, KeyModifiers::NONE),
1436 };
1437 KeyEvent::new(code, modifiers)
1438 }
1439}
1440
1441fn defaults(values: &[(&str, &[&str])]) -> HashMap<TuiAction, Vec<KeyChord>> {
1442 let mut map = HashMap::new();
1443 for (action, keys) in values {
1444 let action = match *action {
1445 "move_up" => TuiAction::MoveUp,
1446 "move_down" => TuiAction::MoveDown,
1447 "move_left" => TuiAction::MoveLeft,
1448 "move_right" => TuiAction::MoveRight,
1449 "next_board" => TuiAction::NextBoard,
1450 "previous_board" => TuiAction::PreviousBoard,
1451 "open" => TuiAction::Open,
1452 "edit" => TuiAction::Edit,
1453 "agent" => TuiAction::Agent,
1454 "view" => TuiAction::View,
1455 "toggle_column" => TuiAction::ToggleColumn,
1456 "filter_modal" => TuiAction::FilterModal,
1457 "bulk_modal" => TuiAction::BulkModal,
1458 "triage" => TuiAction::Triage,
1459 "open_mentions" => TuiAction::OpenMentions,
1460 "views" => TuiAction::Views,
1461 "apply_view" => TuiAction::ApplyView,
1462 "new_view" => TuiAction::NewView,
1463 "rename_view" => TuiAction::RenameView,
1464 "update_view" => TuiAction::UpdateView,
1465 "duplicate_view" => TuiAction::DuplicateView,
1466 "delete_view" => TuiAction::DeleteView,
1467 "mark" => TuiAction::Mark,
1468 "unmark" => TuiAction::Unmark,
1469 "move_previous" => TuiAction::MovePrevious,
1470 "move_next" => TuiAction::MoveNext,
1471 "new_issue" => TuiAction::NewIssue,
1472 "clone_issue" => TuiAction::CloneIssue,
1473 "toggle_archived" => TuiAction::ToggleArchived,
1474 "toggle_children" => TuiAction::ToggleChildren,
1475 "delete" => TuiAction::Delete,
1476 "refresh" => TuiAction::Refresh,
1477 "settings" => TuiAction::Settings,
1478 "planning" => TuiAction::Planning,
1479 "new_board" => TuiAction::NewBoard,
1480 "help" => TuiAction::Help,
1481 "quit" => TuiAction::Quit,
1482 "text_filter" => TuiAction::TextFilter,
1483 "reorder_up" => TuiAction::ReorderUp,
1484 "reorder_down" => TuiAction::ReorderDown,
1485 "close" => TuiAction::Close,
1486 "scroll_up" => TuiAction::ScrollUp,
1487 "scroll_down" => TuiAction::ScrollDown,
1488 "comment" => TuiAction::Comment,
1489 "asset" => TuiAction::Asset,
1490 "new_relationship" => TuiAction::NewRelationship,
1491 "edit_relationship" => TuiAction::EditRelationship,
1492 "remove_relationship" => TuiAction::RemoveRelationship,
1493 "edit_parent" => TuiAction::EditParent,
1494 "next_relationship" => TuiAction::NextRelationship,
1495 "previous_relationship" => TuiAction::PreviousRelationship,
1496 "related_picker" => TuiAction::RelatedPicker,
1497 "follow_related" => TuiAction::FollowRelated,
1498 "nav_back" => TuiAction::NavBack,
1499 "submit" => TuiAction::Submit,
1500 "cancel" => TuiAction::Cancel,
1501 "next" => TuiAction::Next,
1502 "previous" => TuiAction::Previous,
1503 "open_picker" => TuiAction::OpenPicker,
1504 "reset" => TuiAction::Reset,
1505 "toggle" => TuiAction::Toggle,
1506 "add_column" => TuiAction::AddColumn,
1507 "move_column_up" => TuiAction::MoveColumnUp,
1508 "move_column_down" => TuiAction::MoveColumnDown,
1509 "new_planning_item" => TuiAction::NewPlanningItem,
1510 "edit_planning_item" => TuiAction::EditPlanningItem,
1511 "delete_planning_item" => TuiAction::DeletePlanningItem,
1512 "confirm" => TuiAction::Confirm,
1513 "create" => TuiAction::Create,
1514 "previous_year" => TuiAction::PreviousYear,
1515 "next_year" => TuiAction::NextYear,
1516 "previous_month" => TuiAction::PreviousMonth,
1517 "next_month" => TuiAction::NextMonth,
1518 "reedit" => TuiAction::Reedit,
1519 "ignore" => TuiAction::Ignore,
1520 "declare_key" => TuiAction::DeclareKey,
1521 "discard" => TuiAction::Discard,
1522 "switch_to_board" => TuiAction::SwitchToBoard,
1523 "switch_to_triage" => TuiAction::SwitchToTriage,
1524 "switch_to_cycle" => TuiAction::SwitchToCycle,
1525 "switch_to_milestones" => TuiAction::SwitchToMilestones,
1526 "switch_to_repos" => TuiAction::SwitchToRepos,
1527 "switch_to_stats" => TuiAction::SwitchToStats,
1528 "health_form" => TuiAction::HealthForm,
1529 "time_add" => TuiAction::TimeAdd,
1530 "time_edit" => TuiAction::TimeEdit,
1531 "time_delete" => TuiAction::TimeDelete,
1532 "time_filter" => TuiAction::TimeFilter,
1533 "time_breakdown" => TuiAction::TimeBreakdown,
1534 "time_history" => TuiAction::TimeHistory,
1535 "switch_to_schedule" => TuiAction::SwitchToSchedule,
1536 "series_generate" => TuiAction::SeriesGenerate,
1537 "series_pause" => TuiAction::SeriesPause,
1538 "series_resume" => TuiAction::SeriesResume,
1539 "series_end" => TuiAction::SeriesEnd,
1540 "series_skip" => TuiAction::SeriesSkip,
1541 "series_defer" => TuiAction::SeriesDefer,
1542 "series_ignore" => TuiAction::SeriesIgnore,
1543 "series_new" => TuiAction::SeriesNew,
1544 "switch_to_library" => TuiAction::SwitchToLibrary,
1545 "switch_to_portfolio" => TuiAction::SwitchToPortfolio,
1546 "switch_to_new_issue" => TuiAction::SwitchToNewIssue,
1547 "switch_to_config" => TuiAction::SwitchToConfig,
1548 "toggle_navigation" => TuiAction::ToggleNavigation,
1549 _ => unreachable!(),
1550 };
1551 set(&mut map, action, keys);
1552 }
1553 map
1554}
1555
1556fn default_mode(context: InputContext) -> HashMap<TuiAction, Vec<KeyChord>> {
1557 match context {
1558 InputContext::Board => defaults(&[
1559 ("move_up", &["up", "k"]),
1560 ("move_down", &["down", "j"]),
1561 ("scroll_up", &["pgup"]),
1568 ("scroll_down", &["pgdn"]),
1569 ("move_left", &["left", "h"]),
1570 ("move_right", &["right", "l"]),
1571 ("next_board", &["tab"]),
1572 ("previous_board", &["shift+tab"]),
1573 ("open", &["enter"]),
1574 ("edit", &["e"]),
1575 ("view", &["V"]),
1582 ("agent", &["A"]),
1583 ("toggle_column", &["c"]),
1584 ("filter_modal", &["f"]),
1585 ("bulk_modal", &["B"]),
1586 ("triage", &["t"]),
1589 ("open_mentions", &["i"]),
1592 ("views", &["v"]),
1594 ("mark", &["space"]),
1598 ("unmark", &["x"]),
1599 ("move_previous", &["<"]),
1600 ("move_next", &[">"]),
1601 ("new_issue", &["n"]),
1602 ("clone_issue", &["C"]),
1606 ("toggle_archived", &["a"]),
1608 ("toggle_children", &["u"]),
1610 ("delete", &["d"]),
1611 ("refresh", &["r"]),
1612 ("settings", &["s"]),
1613 ("planning", &["m", "M"]),
1614 ("new_board", &["b"]),
1615 ("help", &["?"]),
1616 ("quit", &["q", "Q", "esc"]),
1617 ("text_filter", &["/"]),
1618 ("reorder_up", &["ctrl+up", "shift+up", "K"]),
1624 ("reorder_down", &["ctrl+down", "shift+down", "J"]),
1625 ]),
1626 InputContext::Rollover => defaults(&[
1627 ("submit", &["enter"]),
1628 ("previous", &["left", "right", "tab"]),
1629 ("cancel", &["esc"]),
1630 ]),
1631 InputContext::Detail => defaults(&[
1632 ("close", &["q", "Q", "esc"]),
1633 ("edit", &["e"]),
1634 ("view", &["v"]),
1636 ("scroll_up", &["pgup"]),
1637 ("scroll_down", &["pgdn"]),
1638 ("help", &["?"]),
1639 ("comment", &["c"]),
1640 ("asset", &["a"]),
1641 ("new_relationship", &["n"]),
1645 ("edit_relationship", &["E"]),
1646 ("remove_relationship", &["d"]),
1647 ("edit_parent", &["P"]),
1652 ("next_relationship", &["j", "down"]),
1653 ("previous_relationship", &["k", "up"]),
1654 ("related_picker", &["r"]),
1659 ("follow_related", &["o"]),
1660 ("nav_back", &["backspace"]), ("health_form", &["H"]),
1663 ]),
1664 InputContext::RelatedPicker => defaults(&[
1665 ("cancel", &["esc"]),
1666 ("next", &["down"]),
1667 ("previous", &["up"]),
1668 ("submit", &["enter"]),
1669 ]),
1670 InputContext::Form | InputContext::PlanningForm => defaults(&[
1671 ("cancel", &["esc"]),
1672 ("submit", &["enter"]),
1673 ("next", &["tab", "down"]),
1674 ("previous", &["shift+tab", "up"]),
1675 ("open_picker", &["f2"]),
1676 ]),
1677 InputContext::FormPicker => defaults(&[
1678 ("cancel", &["esc", "f2"]),
1679 ("next", &["up"]),
1680 ("previous", &["down"]),
1681 ("submit", &["enter"]),
1682 ("create", &["+", "insert"]),
1683 ]),
1684 InputContext::ConfirmDelete => defaults(&[("confirm", &["y", "Y"]), ("cancel", &["esc"])]),
1685 InputContext::ConfirmBlockedMove => defaults(&[
1688 ("confirm", &["y", "Y", "enter"]),
1689 ("cancel", &["n", "N", "esc"]),
1690 ]),
1691 InputContext::MoveIntoDone
1720 | InputContext::Comment
1721 | InputContext::Asset
1722 | InputContext::Filter => defaults(&[("cancel", &["esc"]), ("submit", &["enter"])]),
1723 InputContext::Help => defaults(&[
1728 ("cancel", &["esc", "q", "?"]),
1729 ("next", &["down", "j"]),
1730 ("previous", &["up", "k"]),
1731 ("submit", &["enter"]),
1732 ("text_filter", &["/"]),
1736 ("scroll_up", &["pgup"]),
1741 ("scroll_down", &["pgdn"]),
1742 ]),
1743 InputContext::ContextMenu => defaults(&[
1747 ("cancel", &["esc"]),
1748 ("next", &["down"]),
1749 ("previous", &["up"]),
1750 ("submit", &["enter"]),
1751 ("scroll_up", &["pgup"]),
1752 ("scroll_down", &["pgdn"]),
1753 ]),
1754 InputContext::HelpSearch => defaults(&[("cancel", &["esc"]), ("submit", &["enter"])]),
1759 InputContext::BulkModal => defaults(&[
1760 ("cancel", &["esc"]),
1761 ("next", &["tab", "down"]),
1762 ("previous", &["shift+tab", "up"]),
1763 ("submit", &["enter"]),
1764 ]),
1765 InputContext::Triage => defaults(&[
1771 ("cancel", &["esc"]),
1772 ("submit", &["enter"]),
1773 ("move_up", &["up"]),
1774 ("move_down", &["down"]),
1775 ("toggle", &["space"]),
1776 ]),
1777 InputContext::Cycle => defaults(&[
1782 ("close", &["q", "Q", "esc"]),
1783 ("move_up", &["up", "k"]),
1784 ("move_down", &["down", "j"]),
1785 ("move_left", &["left", "h"]),
1788 ("move_right", &["right", "l"]),
1789 ("planning", &["m"]),
1790 ("help", &["?"]),
1791 ]),
1792 InputContext::Milestones => defaults(&[
1797 ("close", &["q", "Q", "esc"]),
1798 ("move_up", &["up", "k"]),
1799 ("move_down", &["down", "j"]),
1800 ("submit", &["enter"]),
1801 ("new_planning_item", &["n"]),
1802 ("edit_planning_item", &["e"]),
1803 ("delete_planning_item", &["x", "X"]),
1804 ("planning", &["m"]),
1805 ("help", &["?"]),
1806 ]),
1807 InputContext::Stats => defaults(&[
1821 ("close", &["q", "Q", "esc"]),
1822 ("move_up", &["up", "k"]),
1823 ("move_down", &["down", "j"]),
1824 ("next", &["tab"]),
1825 ("previous", &["shift+tab"]),
1826 ("submit", &["enter"]),
1827 ("time_add", &["a"]),
1828 ("time_edit", &["e"]),
1829 ("time_delete", &["x"]),
1830 ("time_filter", &["f"]),
1831 ("time_breakdown", &["s"]),
1832 ("time_history", &["h"]),
1833 ("help", &["?"]),
1834 ]),
1835 InputContext::Repos => defaults(&[
1836 ("close", &["q", "Q", "esc"]),
1837 ("move_up", &["up", "k"]),
1838 ("move_down", &["down", "j"]),
1839 ("submit", &["enter"]),
1840 ("help", &["?"]),
1841 ]),
1842 InputContext::Schedule => defaults(&[
1845 ("close", &["q", "Q", "esc"]),
1846 ("move_up", &["up", "k"]),
1847 ("move_down", &["down", "j"]),
1848 ("series_new", &["n"]),
1849 ("series_generate", &["g"]),
1850 ("series_pause", &["p"]),
1851 ("series_resume", &["r"]),
1852 ("series_end", &["x"]),
1853 ("series_skip", &["s"]),
1854 ("series_defer", &["d"]),
1855 ("series_ignore", &["i"]),
1856 ("help", &["?"]),
1857 ]),
1858 InputContext::Library => defaults(&[
1863 ("close", &["q", "Q", "esc"]),
1864 ("next", &["tab", "l", "right"]),
1865 ("previous", &["shift+tab", "h", "left"]),
1866 ("move_up", &["up", "k"]),
1867 ("move_down", &["down", "j"]),
1868 ("submit", &["enter"]),
1869 ("help", &["?"]),
1870 ]),
1871 InputContext::Portfolio => defaults(&[
1876 ("close", &["q", "Q", "esc"]),
1877 ("next", &["tab"]),
1878 ("previous", &["shift+tab"]),
1879 ("move_up", &["up", "k"]),
1880 ("move_down", &["down", "j"]),
1881 ("move_left", &["left", "h"]),
1882 ("move_right", &["right", "l"]),
1883 ("submit", &["enter"]),
1884 ("help", &["?"]),
1885 ]),
1886 InputContext::Views => defaults(&[
1899 ("close", &["q", "Q", "esc"]),
1900 ("move_up", &["up", "k"]),
1901 ("move_down", &["down", "j"]),
1902 ("apply_view", &["enter"]),
1903 ("new_view", &["n"]),
1904 ("rename_view", &["e"]),
1905 ("update_view", &["u"]),
1906 ("duplicate_view", &["d"]),
1907 ("delete_view", &["x", "X"]),
1908 ]),
1909 InputContext::ViewsPrompt => defaults(&[("cancel", &["esc"]), ("submit", &["enter"])]),
1913 InputContext::FilterModal => defaults(&[
1914 ("cancel", &["esc"]),
1915 ("next", &["tab", "down"]),
1916 ("previous", &["shift+tab", "up"]),
1917 ("reset", &["r", "R"]),
1918 ("submit", &["a"]),
1919 ("open_picker", &["f2", "enter", "l", "right"]),
1920 ]),
1921 InputContext::Settings => defaults(&[
1936 ("close", &["q", "Q", "esc"]),
1937 ("next_board", &["tab", "]"]),
1938 ("previous_board", &["shift+tab", "["]),
1939 ("move_left", &["left", "h"]),
1940 ("move_right", &["right", "l"]),
1941 ("move_up", &["up", "k"]),
1942 ("move_down", &["down", "j"]),
1943 ("toggle", &["space"]),
1944 ("submit", &["enter"]),
1945 ("add_column", &["a"]),
1946 ("move_column_up", &["K"]),
1947 ("move_column_down", &["J"]),
1948 ("new_board", &["n"]),
1949 ("edit_planning_item", &["e"]),
1950 ("delete", &["x", "X"]),
1951 ]),
1952 InputContext::SettingsPrompt => defaults(&[
1953 ("cancel", &["esc", "q", "Q"]),
1954 ("submit", &["enter", "y", "Y"]),
1955 ]),
1956 InputContext::PlanningManagement => defaults(&[
1957 ("close", &["q", "Q", "esc"]),
1958 ("move_up", &["up", "k"]),
1959 ("move_down", &["down", "j"]),
1960 ("move_left", &["left", "h"]),
1965 ("move_right", &["right", "l"]),
1966 ("next", &["tab", "shift+tab"]),
1967 ("new_planning_item", &["n"]),
1968 ("edit_planning_item", &["e"]),
1969 ("delete_planning_item", &["x", "X"]),
1970 ]),
1971 InputContext::PlanningConfirm => {
1972 defaults(&[("confirm", &["y", "Y", "enter"]), ("cancel", &["esc"])])
1973 }
1974 InputContext::DatePicker => defaults(&[
1975 ("cancel", &["esc", "q"]),
1976 ("submit", &["enter"]),
1977 ("move_up", &["up", "k"]),
1978 ("move_down", &["down", "j"]),
1979 ("move_left", &["left", "h"]),
1980 ("move_right", &["right", "l"]),
1981 ("previous_month", &["pgup"]),
1982 ("next_month", &["pgdn"]),
1983 ("previous_year", &["["]),
1984 ("next_year", &["]"]),
1985 ("reset", &["delete", "c", "C"]),
1986 ]),
1987 InputContext::EditRepair => defaults(&[
1994 ("confirm", &["a", "y"]),
1995 ("declare_key", &["d", "D"]),
1996 ("reedit", &["r", "R"]),
1997 ("ignore", &["i", "I", "enter", "esc"]),
1998 ("discard", &["x", "X"]),
1999 ]),
2000 InputContext::EditConflict => defaults(&[
2008 ("confirm", &["o", "O", "y", "Y"]),
2009 ("ignore", &["a", "A", "esc"]),
2010 ("reedit", &["c", "C", "r", "R"]),
2011 ]),
2012 InputContext::Shell => defaults(&[
2016 ("switch_to_new_issue", &["f1"]),
2017 ("switch_to_triage", &["f2"]),
2018 ("switch_to_board", &["f3"]),
2019 ("switch_to_cycle", &["f4"]),
2020 ("switch_to_milestones", &["f5"]),
2021 ("switch_to_repos", &["f6"]),
2024 ("switch_to_stats", &["f7"]),
2027 ("switch_to_schedule", &["f8"]),
2030 ("switch_to_portfolio", &["f10"]),
2033 ("switch_to_library", &["f11"]),
2035 ("switch_to_config", &["f12"]),
2036 ("toggle_navigation", &["ctrl+n"]),
2037 ]),
2038 InputContext::LinkPrompt => defaults(&[("cancel", &["esc"]), ("submit", &["enter"])]),
2043 }
2044}
2045
2046macro_rules! overlay_fields {
2047 ($map:ident, $overrides:ident, $context:expr, $($field:ident => $action:ident),+ $(,)?) => {
2048 $(
2049 if let Some(values) = &$overrides.$field {
2050 let mut chords = Vec::with_capacity(values.len());
2051 for value in values {
2052 chords.push(KeyChord::parse(value).map_err(|message| TixError::Config(format!(
2053 "`keybindings.{}.{}`: {message}", $context.as_str(), stringify!($field)
2054 )))?);
2055 }
2056 if chords.is_empty() {
2057 return Err(TixError::Config(format!(
2058 "`keybindings.{}.{}` must not be empty", $context.as_str(), stringify!($field)
2059 )));
2060 }
2061 $map.insert(TuiAction::$action, chords);
2062 }
2063 )+
2064 };
2065}
2066
2067fn overlay(
2068 map: &mut HashMap<TuiAction, Vec<KeyChord>>,
2069 overrides: &ActionBindings,
2070 context: InputContext,
2071) -> Result<()> {
2072 overlay_fields!(map, overrides, context,
2073 move_up => MoveUp, move_down => MoveDown, move_left => MoveLeft, move_right => MoveRight,
2074 next_board => NextBoard, previous_board => PreviousBoard, open => Open, edit => Edit,
2075 agent => Agent, view => View, toggle_column => ToggleColumn, filter_modal => FilterModal,
2076 bulk_modal => BulkModal, mark => Mark, unmark => Unmark, triage => Triage,
2077 open_mentions => OpenMentions,
2078 views => Views, apply_view => ApplyView, new_view => NewView,
2079 rename_view => RenameView, update_view => UpdateView,
2080 duplicate_view => DuplicateView, delete_view => DeleteView,
2081 move_previous => MovePrevious, move_next => MoveNext, new_issue => NewIssue,
2082 clone_issue => CloneIssue,
2083 toggle_archived => ToggleArchived,
2084 toggle_children => ToggleChildren,
2085 delete => Delete, refresh => Refresh, settings => Settings, planning => Planning,
2086 new_board => NewBoard, help => Help, quit => Quit, text_filter => TextFilter, reorder_up => ReorderUp,
2087 reorder_down => ReorderDown, close => Close, scroll_up => ScrollUp, scroll_down => ScrollDown,
2088 comment => Comment, asset => Asset, edit_parent => EditParent,
2089 submit => Submit, cancel => Cancel, next => Next,
2090 previous => Previous, open_picker => OpenPicker, reset => Reset, toggle => Toggle,
2091 add_column => AddColumn, move_column_up => MoveColumnUp, move_column_down => MoveColumnDown,
2092 new_planning_item => NewPlanningItem,
2093 edit_planning_item => EditPlanningItem, delete_planning_item => DeletePlanningItem,
2094 confirm => Confirm, create => Create, previous_year => PreviousYear, next_year => NextYear,
2095 previous_month => PreviousMonth, next_month => NextMonth, reedit => Reedit, ignore => Ignore,
2096 declare_key => DeclareKey, discard => Discard,
2097 switch_to_board => SwitchToBoard, switch_to_triage => SwitchToTriage,
2098 switch_to_cycle => SwitchToCycle, switch_to_milestones => SwitchToMilestones,
2099 switch_to_repos => SwitchToRepos,
2100 switch_to_stats => SwitchToStats, health_form => HealthForm,
2101 time_add => TimeAdd, time_edit => TimeEdit, time_delete => TimeDelete,
2102 time_filter => TimeFilter, time_breakdown => TimeBreakdown, time_history => TimeHistory,
2103 switch_to_schedule => SwitchToSchedule,
2104 series_generate => SeriesGenerate, series_pause => SeriesPause,
2105 series_resume => SeriesResume, series_end => SeriesEnd,
2106 series_skip => SeriesSkip, series_defer => SeriesDefer,
2107 series_ignore => SeriesIgnore,
2108 series_new => SeriesNew,
2109 switch_to_new_issue => SwitchToNewIssue,
2110 switch_to_library => SwitchToLibrary,
2111 switch_to_portfolio => SwitchToPortfolio,
2112 switch_to_new_issue => SwitchToNewIssue,
2113 switch_to_config => SwitchToConfig, toggle_navigation => ToggleNavigation,
2114 );
2115 let mut seen: HashMap<KeyChord, TuiAction> = HashMap::new();
2116 for (action, chords) in map {
2117 for key in chords {
2118 if let Some(previous) = seen.insert(*key, *action) {
2119 return Err(TixError::Config(format!(
2120 "`keybindings.{}`: `{}` is assigned to both `{}` and `{}`",
2121 context.as_str(),
2122 key,
2123 previous.as_str(),
2124 action.as_str()
2125 )));
2126 }
2127 }
2128 }
2129 Ok(())
2130}
2131
2132impl ActionBindings {
2133 pub fn is_empty(&self) -> bool {
2134 self == &Self::default()
2135 }
2136}
2137
2138#[cfg(test)]
2139mod tests {
2140 use super::*;
2141
2142 #[test]
2143 fn parses_and_normalizes_documented_chords() {
2144 assert_eq!(KeyChord::parse("ESC").unwrap(), chord("esc"));
2145 assert_eq!(KeyChord::parse("Enter").unwrap(), chord("enter"));
2146 assert_eq!(KeyChord::parse("LEFT").unwrap(), chord("left"));
2147 assert_eq!(KeyChord::parse("PgUp").unwrap(), chord("pgup"));
2148 assert_eq!(KeyChord::parse("F2").unwrap(), chord("f2"));
2149 assert_eq!(KeyChord::parse("Ctrl+UP").unwrap(), chord("ctrl+up"));
2150 assert_eq!(KeyChord::parse("A").unwrap().display(), "Shift+A");
2151 assert!(KeyChord::parse("ctrl+wat").is_err());
2152 assert!(KeyChord::parse("f99").is_err());
2153 }
2154
2155 #[test]
2156 fn defaults_validate_and_cross_mode_reuse_is_allowed() {
2157 let defaults = KeyBindings::resolve(&KeyBindings::default()).unwrap();
2158 assert!(
2159 defaults
2160 .action(
2161 InputContext::Board,
2162 KeyEvent::new(KeyCode::Char('q'), KeyModifiers::NONE)
2163 )
2164 .is_some()
2165 );
2166 let mut overrides = KeyBindings::default();
2167 overrides.board.open = Some(vec!["f4".into()]);
2168 overrides.detail.close = Some(vec!["f4".into()]);
2169 assert!(KeyBindings::resolve(&overrides).is_ok());
2170 }
2171
2172 #[test]
2173 fn filter_modal_opens_the_picker_and_commits_on_the_apply_key() {
2174 let bindings = KeyBindings::resolve(&KeyBindings::default()).unwrap();
2175 let ctx = InputContext::FilterModal;
2176 for code in [KeyCode::Enter, KeyCode::Char('l'), KeyCode::Right] {
2181 let event = KeyEvent::new(code, KeyModifiers::NONE);
2182 let action = bindings.action(ctx, event);
2183 assert_eq!(
2184 action,
2185 Some(TuiAction::OpenPicker),
2186 "{code:?} must open the focused row's picker"
2187 );
2188 assert_eq!(
2189 bindings.canonical_event(ctx, TuiAction::OpenPicker),
2190 KeyEvent::new(KeyCode::F(2), KeyModifiers::NONE),
2191 "{code:?} must canonicalize to the picker's F2"
2192 );
2193 }
2194 let commit = KeyEvent::new(KeyCode::Char('a'), KeyModifiers::NONE);
2199 assert_eq!(bindings.action(ctx, commit), Some(TuiAction::Submit));
2200 assert_eq!(
2201 bindings.canonical_event(ctx, TuiAction::Submit),
2202 KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE)
2203 );
2204 assert_eq!(
2206 bindings.action(ctx, KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE)),
2207 Some(TuiAction::OpenPicker)
2208 );
2209 assert!(
2210 bindings
2211 .action(ctx, KeyEvent::new(KeyCode::Enter, KeyModifiers::CONTROL))
2212 .is_none(),
2213 "the unreachable `ctrl+enter` chord must not be bound at all"
2214 );
2215 }
2216
2217 #[test]
2218 fn board_binds_v_to_views_and_shift_v_to_view_and_detail_keeps_v() {
2219 let bindings = KeyBindings::resolve(&KeyBindings::default()).unwrap();
2220 let board_view = KeyEvent::new(KeyCode::Char('V'), KeyModifiers::NONE);
2224 assert_eq!(
2225 bindings.action(InputContext::Board, board_view),
2226 Some(TuiAction::View),
2227 "the board must bind `V` to view"
2228 );
2229 assert_eq!(
2230 bindings.canonical_event(InputContext::Board, TuiAction::View),
2231 board_view,
2232 "`view` must canonicalize to the board's own chord so the handler sees it as typed"
2233 );
2234 assert_eq!(
2235 bindings.action(
2236 InputContext::Board,
2237 KeyEvent::new(KeyCode::Char('v'), KeyModifiers::NONE)
2238 ),
2239 Some(TuiAction::Views),
2240 "the board must bind `v` to the saved-views modal"
2241 );
2242 let detail_view = KeyEvent::new(KeyCode::Char('v'), KeyModifiers::NONE);
2243 assert_eq!(
2244 bindings.action(InputContext::Detail, detail_view),
2245 Some(TuiAction::View),
2246 "Detail must bind `v` to view"
2247 );
2248 assert_eq!(
2249 bindings.canonical_event(InputContext::Detail, TuiAction::View),
2250 detail_view,
2251 "`view` must canonicalize to `v` in Detail"
2252 );
2253 }
2254
2255 #[test]
2260 fn the_conflict_prompt_binds_overwrite_abandon_and_compare() {
2261 let bindings = KeyBindings::resolve(&KeyBindings::default()).unwrap();
2262 let cases = [
2263 (KeyCode::Char('o'), TuiAction::Confirm),
2264 (KeyCode::Char('y'), TuiAction::Confirm),
2265 (KeyCode::Char('a'), TuiAction::Ignore),
2266 (KeyCode::Esc, TuiAction::Ignore),
2267 (KeyCode::Char('c'), TuiAction::Reedit),
2268 (KeyCode::Char('r'), TuiAction::Reedit),
2269 ];
2270 for (code, action) in cases {
2271 assert_eq!(
2272 bindings.action(
2273 InputContext::EditConflict,
2274 KeyEvent::new(code, KeyModifiers::NONE)
2275 ),
2276 Some(action),
2277 "{code:?} must be {action:?} in the conflict prompt"
2278 );
2279 }
2280 for (action, expected) in [
2283 (TuiAction::Confirm, KeyCode::Char('y')),
2284 (TuiAction::Ignore, KeyCode::Enter),
2285 (TuiAction::Reedit, KeyCode::Char('r')),
2286 ] {
2287 assert_eq!(
2288 bindings.canonical_event(InputContext::EditConflict, action),
2289 KeyEvent::new(expected, KeyModifiers::NONE),
2290 "{action:?} must hand the handler `{expected:?}`"
2291 );
2292 }
2293 assert_eq!(
2295 bindings.action(
2296 InputContext::EditConflict,
2297 KeyEvent::new(KeyCode::Char('q'), KeyModifiers::NONE)
2298 ),
2299 None
2300 );
2301 }
2302
2303 #[test]
2308 fn settings_binds_column_moves_distinct_from_the_cursor() {
2309 let bindings = KeyBindings::resolve(&KeyBindings::default()).unwrap();
2310 let shifted = |ch: char| KeyEvent::new(KeyCode::Char(ch), KeyModifiers::SHIFT);
2311 assert_eq!(
2312 bindings.action(InputContext::Settings, shifted('K')),
2313 Some(TuiAction::MoveColumnUp)
2314 );
2315 assert_eq!(
2316 bindings.action(InputContext::Settings, shifted('J')),
2317 Some(TuiAction::MoveColumnDown)
2318 );
2319 assert_eq!(
2321 bindings.action(
2322 InputContext::Settings,
2323 KeyEvent::new(KeyCode::Char('k'), KeyModifiers::NONE)
2324 ),
2325 Some(TuiAction::MoveUp)
2326 );
2327 assert_eq!(
2328 bindings.action(
2329 InputContext::Settings,
2330 KeyEvent::new(KeyCode::Char('j'), KeyModifiers::NONE)
2331 ),
2332 Some(TuiAction::MoveDown)
2333 );
2334 assert_eq!(
2336 bindings.canonical_event(InputContext::Settings, TuiAction::MoveColumnUp),
2337 shifted('K')
2338 );
2339 assert_eq!(
2340 bindings.canonical_event(InputContext::Settings, TuiAction::MoveColumnDown),
2341 shifted('J')
2342 );
2343
2344 let mut overrides = KeyBindings::default();
2346 overrides.settings.move_column_up = Some(vec!["ctrl+u".into()]);
2347 overrides.settings.move_column_down = Some(vec!["ctrl+d".into()]);
2348 let overridden = KeyBindings::resolve(&overrides).unwrap();
2349 assert_eq!(
2350 overridden.action(
2351 InputContext::Settings,
2352 KeyEvent::new(KeyCode::Char('u'), KeyModifiers::CONTROL)
2353 ),
2354 Some(TuiAction::MoveColumnUp)
2355 );
2356 assert_eq!(
2357 overridden.action(
2358 InputContext::Settings,
2359 KeyEvent::new(KeyCode::Char('d'), KeyModifiers::CONTROL)
2360 ),
2361 Some(TuiAction::MoveColumnDown)
2362 );
2363
2364 let mut conflict = KeyBindings::default();
2366 conflict.settings.move_column_up = Some(vec!["a".into()]);
2367 let error = KeyBindings::resolve(&conflict).unwrap_err().to_string();
2368 assert!(
2369 error.contains("settings") && error.contains("move_column_up"),
2370 "the conflict must name the mode and the action: {error}"
2371 );
2372 }
2373
2374 #[test]
2378 fn the_mentions_view_binds_i_on_the_board_without_a_conflict() {
2379 let bindings = KeyBindings::resolve(&KeyBindings::default())
2380 .expect("adding open_mentions must not conflict with an existing board chord");
2381 let key = KeyEvent::new(KeyCode::Char('i'), KeyModifiers::NONE);
2382 assert_eq!(
2383 bindings.action(InputContext::Board, key),
2384 Some(TuiAction::OpenMentions)
2385 );
2386 assert_eq!(
2387 bindings.canonical_event(InputContext::Board, TuiAction::OpenMentions),
2388 key
2389 );
2390 assert_eq!(
2391 bindings
2392 .chords(InputContext::Board, TuiAction::OpenMentions)
2393 .len(),
2394 1
2395 );
2396 let mut overrides = KeyBindings::default();
2398 overrides.board.open_mentions = Some(vec!["ctrl+m".into()]);
2399 let rebound = KeyBindings::resolve(&overrides).expect("a rebind resolves");
2400 assert_eq!(
2401 rebound.action(
2402 InputContext::Board,
2403 KeyEvent::new(KeyCode::Char('m'), KeyModifiers::CONTROL)
2404 ),
2405 Some(TuiAction::OpenMentions)
2406 );
2407 }
2408
2409 #[test]
2410 fn conflicts_report_mode_and_actions() {
2411 let mut overrides = KeyBindings::default();
2412 overrides.board.open = Some(vec!["ctrl+n".into()]);
2413 overrides.board.new_issue = Some(vec!["ctrl+n".into()]);
2414 let error = KeyBindings::resolve(&overrides).unwrap_err().to_string();
2415 assert!(error.contains("board") && error.contains("open") && error.contains("new_issue"));
2416 }
2417
2418 #[test]
2419 fn shell_defaults_resolve_mode_switches_and_toggle() {
2420 let bindings = KeyBindings::resolve(&KeyBindings::default()).unwrap();
2421 let cases = [
2424 (KeyCode::F(1), TuiAction::SwitchToNewIssue),
2425 (KeyCode::F(2), TuiAction::SwitchToTriage),
2426 (KeyCode::F(3), TuiAction::SwitchToBoard),
2427 (KeyCode::F(4), TuiAction::SwitchToCycle),
2428 (KeyCode::F(5), TuiAction::SwitchToMilestones),
2429 (KeyCode::F(6), TuiAction::SwitchToRepos),
2430 (KeyCode::F(12), TuiAction::SwitchToConfig),
2431 ];
2432 for (code, action) in cases {
2433 assert_eq!(
2434 bindings.action(InputContext::Shell, KeyEvent::new(code, KeyModifiers::NONE)),
2435 Some(action),
2436 "{code:?} must switch modes in the shell"
2437 );
2438 assert_eq!(
2439 bindings.canonical_event(InputContext::Shell, action),
2440 KeyEvent::new(code, KeyModifiers::NONE),
2441 "{action:?} must canonicalize to its default chord"
2442 );
2443 }
2444 assert_eq!(
2445 bindings.action(
2446 InputContext::Shell,
2447 KeyEvent::new(KeyCode::Char('n'), KeyModifiers::CONTROL)
2448 ),
2449 Some(TuiAction::ToggleNavigation)
2450 );
2451 assert_eq!(
2455 bindings.action(
2456 InputContext::Shell,
2457 KeyEvent::new(KeyCode::F(8), KeyModifiers::NONE)
2458 ),
2459 Some(TuiAction::SwitchToSchedule)
2460 );
2461 assert_eq!(
2462 bindings.action(
2463 InputContext::Shell,
2464 KeyEvent::new(KeyCode::F(11), KeyModifiers::NONE)
2465 ),
2466 Some(TuiAction::SwitchToLibrary)
2467 );
2468 assert_eq!(
2471 bindings.action(
2472 InputContext::Shell,
2473 KeyEvent::new(KeyCode::F(10), KeyModifiers::NONE)
2474 ),
2475 Some(TuiAction::SwitchToPortfolio)
2476 );
2477 assert_eq!(
2484 bindings.action(
2485 InputContext::Shell,
2486 KeyEvent::new(KeyCode::F(9), KeyModifiers::NONE)
2487 ),
2488 None,
2489 "F9 must stay unbound in the shell until the Data page exists"
2490 );
2491 }
2492
2493 #[test]
2494 fn shell_conflict_reports_both_actions_and_mode() {
2495 let mut overrides = KeyBindings::default();
2496 overrides.shell.switch_to_board = Some(vec!["f2".into()]);
2497 overrides.shell.switch_to_triage = Some(vec!["f2".into()]);
2498 let error = KeyBindings::resolve(&overrides).unwrap_err().to_string();
2499 assert!(
2500 error.contains("shell")
2501 && error.contains("switch_to_board")
2502 && error.contains("switch_to_triage"),
2503 "got {error:?}"
2504 );
2505 }
2506}