1use std::collections::HashMap;
2use std::fmt;
3use std::path::Path;
4
5use crossterm::event::{KeyCode, KeyEvent, KeyModifiers};
6use serde::de::Error as _;
7use serde::{Deserialize, Deserializer, Serialize, Serializer};
8
9use crate::error::{Result, TixError};
10
11mod defaults;
12#[allow(clippy::wildcard_imports)]
13use defaults::*;
14
15#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
16pub enum InputContext {
17 Board,
18 BulkModal,
22 Triage,
31 Cycle,
37 Milestones,
43 Repos,
49 Stats,
59 Schedule,
65 Library,
72 Portfolio,
80 Rollover,
81 Detail,
82 Form,
83 FormPicker,
84 ConfirmDelete,
85 MoveIntoDone,
86 ConfirmBlockedMove,
89 Help,
90 HelpSearch,
97 ContextMenu,
98 RelatedPicker,
104 Filter,
105 FilterModal,
106 Settings,
107 SettingsPrompt,
108 PlanningManagement,
109 PlanningConfirm,
110 PlanningForm,
111 DatePicker,
112 Comment,
113 Asset,
114 EditRepair,
122 EditConflict,
130 LinkPrompt,
136 Shell,
145 Views,
152 ViewsPrompt,
160}
161
162impl InputContext {
163 pub const ALL: &'static [Self] = &[
164 Self::Board,
165 Self::Rollover,
166 Self::Detail,
167 Self::Form,
168 Self::FormPicker,
169 Self::ConfirmDelete,
170 Self::MoveIntoDone,
171 Self::ConfirmBlockedMove,
172 Self::Help,
173 Self::HelpSearch,
174 Self::ContextMenu,
175 Self::RelatedPicker,
178 Self::Filter,
179 Self::FilterModal,
180 Self::BulkModal,
181 Self::Triage,
182 Self::Cycle,
183 Self::Milestones,
184 Self::Repos,
187 Self::Stats,
190 Self::Schedule,
193 Self::Library,
196 Self::Portfolio,
200 Self::Settings,
201 Self::SettingsPrompt,
202 Self::PlanningManagement,
203 Self::PlanningConfirm,
204 Self::PlanningForm,
205 Self::DatePicker,
206 Self::Comment,
207 Self::Asset,
208 Self::EditRepair,
209 Self::EditConflict,
212 Self::LinkPrompt,
216 Self::Shell,
219 Self::Views,
220 Self::ViewsPrompt,
221 ];
222
223 pub const fn as_str(self) -> &'static str {
224 match self {
225 Self::Board => "board",
226 Self::Rollover => "rollover",
227 Self::Detail => "detail",
228 Self::Form => "form",
229 Self::FormPicker => "form_picker",
230 Self::ConfirmDelete => "confirm_delete",
231 Self::ConfirmBlockedMove => "confirm_blocked_move",
232 Self::MoveIntoDone => "move_into_done",
233 Self::Help => "help",
234 Self::HelpSearch => "help_search",
235 Self::ContextMenu => "context_menu",
236 Self::RelatedPicker => "related_picker",
237 Self::Filter => "filter",
238 Self::FilterModal => "filter_modal",
239 Self::BulkModal => "bulk_modal",
240 Self::Settings => "settings",
241 Self::SettingsPrompt => "settings_prompt",
242 Self::PlanningManagement => "planning_management",
243 Self::PlanningConfirm => "planning_confirm",
244 Self::PlanningForm => "planning_form",
245 Self::DatePicker => "date_picker",
246 Self::Comment => "comment",
247 Self::Asset => "asset",
248 Self::EditRepair => "edit_repair",
249 Self::EditConflict => "edit_conflict",
250 Self::LinkPrompt => "link_prompt",
251 Self::Views => "views",
252 Self::ViewsPrompt => "views_prompt",
253 Self::Triage => "triage",
254 Self::Cycle => "cycle",
255 Self::Milestones => "milestones",
256 Self::Repos => "repos",
257 Self::Stats => "stats",
258 Self::Schedule => "schedule",
259 Self::Library => "library",
260 Self::Portfolio => "portfolio",
261 Self::Shell => "shell",
262 }
263 }
264}
265
266#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
267#[serde(rename_all = "snake_case")]
268pub enum TuiAction {
269 MoveUp,
270 MoveDown,
271 MoveLeft,
272 MoveRight,
273 NextBoard,
274 PreviousBoard,
275 Open,
276 Edit,
277 Agent,
278 View,
283 ToggleColumn,
284 FilterModal,
285 BulkModal,
286 Triage,
290 OpenMentions,
301 Views,
304 ApplyView,
306 NewView,
308 RenameView,
310 UpdateView,
312 DuplicateView,
314 DeleteView,
317 Mark,
318 Unmark,
319 ClearMarks,
325 MovePrevious,
326 MoveNext,
327 NewIssue,
328 CloneIssue,
334 ToggleArchived,
339 ToggleChildren,
346 Delete,
347 Refresh,
348 Settings,
349 Planning,
350 NewBoard,
351 Help,
352 Quit,
353 TextFilter,
354 ReorderUp,
355 ReorderDown,
356 Close,
357 ScrollUp,
358 ScrollDown,
359 ScrollLineDown,
362 ScrollLineUp,
363 ScrollTop,
365 ScrollBottom,
366 Comment,
367 Asset,
368 NewRelationship,
370 EditRelationship,
372 RemoveRelationship,
374 EditParent,
383 NextRelationship,
385 PreviousRelationship,
386 RelatedPicker,
392 FollowRelated,
396 NavBack,
401 Submit,
402 Cancel,
403 Next,
404 Previous,
405 OpenPicker,
406 Reset,
407 Toggle,
408 AddColumn,
409 MoveColumnUp,
412 MoveColumnDown,
415 NewPlanningItem,
416 EditPlanningItem,
417 DeletePlanningItem,
418 Confirm,
419 Create,
420 PreviousYear,
421 NextYear,
422 PreviousMonth,
423 NextMonth,
424 Reedit,
425 Ignore,
426 DeclareKey,
434 Discard,
442 SwitchToBoard,
444 SwitchToTriage,
446 SwitchToCycle,
448 SwitchToMilestones,
450 SwitchToRepos,
455 SwitchToStats,
460 HealthForm,
467 TimeAdd,
474 TimeEdit,
476 TimeDelete,
482 TimeFilter,
487 TimeBreakdown,
490 TimeHistory,
493 SwitchToSchedule,
495 SeriesGenerate,
497 SeriesPause,
499 SeriesResume,
501 SeriesEnd,
503 SeriesSkip,
505 SeriesDefer,
507 SeriesIgnore,
509 SeriesNew,
515 SwitchToLibrary,
517 SwitchToPortfolio,
522 SwitchToNewIssue,
524 SwitchToConfig,
526 ToggleNavigation,
528}
529
530impl TuiAction {
531 pub const fn as_str(self) -> &'static str {
532 match self {
533 Self::MoveUp => "move_up",
534 Self::MoveDown => "move_down",
535 Self::MoveLeft => "move_left",
536 Self::MoveRight => "move_right",
537 Self::NextBoard => "next_board",
538 Self::PreviousBoard => "previous_board",
539 Self::Open => "open",
540 Self::Edit => "edit",
541 Self::Agent => "agent",
542 Self::View => "view",
543 Self::ToggleColumn => "toggle_column",
544 Self::OpenMentions => "open_mentions",
545 Self::FilterModal => "filter_modal",
546 Self::BulkModal => "bulk_modal",
547 Self::Triage => "triage",
548 Self::Views => "views",
549 Self::ApplyView => "apply_view",
550 Self::NewView => "new_view",
551 Self::RenameView => "rename_view",
552 Self::UpdateView => "update_view",
553 Self::DuplicateView => "duplicate_view",
554 Self::DeleteView => "delete_view",
555 Self::Mark => "mark",
556 Self::Unmark => "unmark",
557 Self::ClearMarks => "clear_marks",
558 Self::MovePrevious => "move_previous",
559 Self::MoveNext => "move_next",
560 Self::NewIssue => "new_issue",
561 Self::CloneIssue => "clone_issue",
562 Self::ToggleArchived => "toggle_archived",
563 Self::ToggleChildren => "toggle_children",
564 Self::Delete => "delete",
565 Self::Refresh => "refresh",
566 Self::Settings => "settings",
567 Self::Planning => "planning",
568 Self::NewBoard => "new_board",
569 Self::Help => "help",
570 Self::Quit => "quit",
571 Self::TextFilter => "text_filter",
572 Self::ReorderUp => "reorder_up",
573 Self::ReorderDown => "reorder_down",
574 Self::Close => "close",
575 Self::ScrollUp => "scroll_up",
576 Self::ScrollDown => "scroll_down",
577 Self::ScrollLineDown => "scroll_line_down",
578 Self::ScrollLineUp => "scroll_line_up",
579 Self::ScrollTop => "scroll_top",
580 Self::ScrollBottom => "scroll_bottom",
581 Self::Comment => "comment",
582 Self::Asset => "asset",
583 Self::NewRelationship => "new_relationship",
584 Self::EditRelationship => "edit_relationship",
585 Self::RemoveRelationship => "remove_relationship",
586 Self::EditParent => "edit_parent",
587 Self::NextRelationship => "next_relationship",
588 Self::PreviousRelationship => "previous_relationship",
589 Self::RelatedPicker => "related_picker",
590 Self::FollowRelated => "follow_related",
591 Self::NavBack => "nav_back",
592 Self::Submit => "submit",
593 Self::Cancel => "cancel",
594 Self::Next => "next",
595 Self::Previous => "previous",
596 Self::OpenPicker => "open_picker",
597 Self::Reset => "reset",
598 Self::Toggle => "toggle",
599 Self::AddColumn => "add_column",
600 Self::MoveColumnUp => "move_column_up",
601 Self::MoveColumnDown => "move_column_down",
602 Self::NewPlanningItem => "new_planning_item",
603 Self::EditPlanningItem => "edit_planning_item",
604 Self::DeletePlanningItem => "delete_planning_item",
605 Self::Confirm => "confirm",
606 Self::Create => "create",
607 Self::PreviousYear => "previous_year",
608 Self::NextYear => "next_year",
609 Self::PreviousMonth => "previous_month",
610 Self::NextMonth => "next_month",
611 Self::Reedit => "reedit",
612 Self::Ignore => "ignore",
613 Self::DeclareKey => "declare_key",
614 Self::Discard => "discard",
615 Self::SwitchToBoard => "switch_to_board",
616 Self::SwitchToTriage => "switch_to_triage",
617 Self::SwitchToCycle => "switch_to_cycle",
618 Self::SwitchToMilestones => "switch_to_milestones",
619 Self::SwitchToRepos => "switch_to_repos",
620 Self::SwitchToStats => "switch_to_stats",
621 Self::HealthForm => "health_form",
622 Self::TimeAdd => "time_add",
623 Self::TimeEdit => "time_edit",
624 Self::TimeDelete => "time_delete",
625 Self::TimeFilter => "time_filter",
626 Self::TimeBreakdown => "time_breakdown",
627 Self::TimeHistory => "time_history",
628 Self::SwitchToSchedule => "switch_to_schedule",
629 Self::SeriesGenerate => "series_generate",
630 Self::SeriesPause => "series_pause",
631 Self::SeriesResume => "series_resume",
632 Self::SeriesEnd => "series_end",
633 Self::SeriesSkip => "series_skip",
634 Self::SeriesDefer => "series_defer",
635 Self::SeriesIgnore => "series_ignore",
636 Self::SeriesNew => "series_new",
637 Self::SwitchToLibrary => "switch_to_library",
638 Self::SwitchToPortfolio => "switch_to_portfolio",
639 Self::SwitchToNewIssue => "switch_to_new_issue",
640 Self::SwitchToConfig => "switch_to_config",
641 Self::ToggleNavigation => "toggle_navigation",
642 }
643 }
644
645 pub const fn description(self) -> &'static str {
646 match self {
647 Self::MoveUp => "move up",
648 Self::MoveDown => "move down",
649 Self::MoveLeft => "previous column",
650 Self::MoveRight => "next column",
651 Self::NextBoard => "next board",
652 Self::PreviousBoard => "previous board",
653 Self::Open => "open detail",
654 Self::Edit => "edit",
655 Self::Agent => "open agent",
656 Self::View => "view issue",
657 Self::ToggleColumn => "toggle column",
658 Self::FilterModal => "filter criteria",
659 Self::BulkModal => "batch select",
660 Self::Triage => "triage inbox",
661 Self::OpenMentions => "mentions",
662 Self::Views => "saved views",
663 Self::ApplyView => "apply view",
664 Self::NewView => "new view",
665 Self::RenameView => "rename view",
666 Self::UpdateView => "update view from live",
667 Self::DuplicateView => "duplicate view",
668 Self::DeleteView => "delete view",
669 Self::Mark => "mark for batch",
670 Self::Unmark => "unmark for batch",
671 Self::ClearMarks => "clear marks",
672 Self::MovePrevious => "move to previous column",
673 Self::MoveNext => "move to next column",
674 Self::NewIssue => "new issue",
675 Self::CloneIssue => "clone issue",
676 Self::ToggleArchived => "show archived issues",
677 Self::ToggleChildren => "toggle sub-issues",
678 Self::Delete => "delete",
679 Self::Refresh => "refresh",
680 Self::Settings => "settings",
681 Self::Planning => "planning",
682 Self::NewBoard => "new board",
683 Self::Help => "help",
684 Self::Quit => "quit",
685 Self::TextFilter => "search",
686 Self::ReorderUp => "reorder up",
687 Self::ReorderDown => "reorder down",
688 Self::Close => "close",
689 Self::ScrollUp => "scroll up one page",
690 Self::ScrollDown => "scroll down one page",
691 Self::ScrollLineDown => "scroll down one line",
692 Self::ScrollLineUp => "scroll up one line",
693 Self::ScrollTop => "scroll to top",
694 Self::ScrollBottom => "scroll to bottom",
695 Self::Comment => "comment",
696 Self::Asset => "asset",
697 Self::NewRelationship => "new relationship",
698 Self::EditRelationship => "edit relationship",
699 Self::RemoveRelationship => "remove relationship",
700 Self::EditParent => "set parent",
701 Self::NextRelationship => "next relationship",
702 Self::PreviousRelationship => "previous relationship",
703 Self::RelatedPicker => "related issues",
704 Self::FollowRelated => "follow related issue",
705 Self::NavBack => "back",
706 Self::Submit => "submit",
707 Self::Cancel => "cancel",
708 Self::Next => "next",
709 Self::Previous => "previous",
710 Self::OpenPicker => "open picker",
711 Self::Reset => "reset",
712 Self::Toggle => "toggle",
713 Self::AddColumn => "add column",
714 Self::MoveColumnUp => "move column up",
715 Self::MoveColumnDown => "move column down",
716 Self::NewPlanningItem => "new",
717 Self::EditPlanningItem => "edit",
718 Self::DeletePlanningItem => "delete",
719 Self::Confirm => "confirm",
720 Self::Create => "create",
721 Self::PreviousYear => "previous year",
722 Self::NextYear => "next year",
723 Self::PreviousMonth => "previous month",
724 Self::NextMonth => "next month",
725 Self::Reedit => "re-edit",
726 Self::Ignore => "ignore",
727 Self::DeclareKey => "declare the key in tix.toml",
728 Self::Discard => "discard the edit",
729 Self::SwitchToBoard => "board mode",
730 Self::SwitchToTriage => "triage mode",
731 Self::SwitchToCycle => "cycle mode",
732 Self::SwitchToMilestones => "milestones mode",
733 Self::SwitchToRepos => "repos mode",
734 Self::SwitchToStats => "the Stats page",
735 Self::HealthForm => "record this issue's health",
736 Self::TimeAdd => "add a time entry",
737 Self::TimeEdit => "correct the selected entry",
738 Self::TimeDelete => "delete the selected entry",
739 Self::TimeFilter => "edit the time filter",
740 Self::TimeBreakdown => "cycle the breakdown",
741 Self::TimeHistory => "show the audit view",
742 Self::SwitchToSchedule => "schedule mode",
743 Self::SeriesGenerate => "generate the due occurrence",
744 Self::SeriesPause => "pause the series",
745 Self::SeriesResume => "resume the series",
746 Self::SeriesEnd => "end the series",
747 Self::SeriesSkip => "skip the due date",
748 Self::SeriesDefer => "defer the due date",
749 Self::SeriesIgnore => "ignore the due date",
750 Self::SeriesNew => "create a new series",
751 Self::SwitchToLibrary => "library mode",
752 Self::SwitchToPortfolio => "portfolio mode",
753 Self::SwitchToNewIssue => "new issue mode",
754 Self::SwitchToConfig => "config mode",
755 Self::ToggleNavigation => "toggle navigation",
756 }
757 }
758
759 pub const fn description_in(self, context: InputContext) -> &'static str {
767 match (context, self) {
768 (InputContext::Cycle, Self::MoveLeft) => "previous group",
769 (InputContext::Cycle, Self::MoveRight) => "next group",
770 (InputContext::Library, Self::Previous) => "previous tab",
773 (InputContext::Library, Self::Next) => "next tab",
774 (InputContext::Library, Self::Submit) => "apply the selected view",
775 (InputContext::Stats, Self::Submit) => "open the row's issue",
779 (InputContext::Settings, Self::MoveLeft) => "previous pane",
780 (InputContext::Settings, Self::MoveRight) => "next pane",
781 (InputContext::Settings, Self::Submit) => "step into the body / activate row",
782 (InputContext::FilterModal, Self::OpenPicker) => "open the focused row's picker",
786 (InputContext::FilterModal, Self::Submit) => "apply and close",
787 (InputContext::FilterModal, Self::Cancel) => "apply and close",
788 (InputContext::EditRepair, Self::Confirm) => "apply the proposed fix",
792 (InputContext::EditRepair, Self::DeclareKey) => "declare the key in tix.toml",
793 (InputContext::EditRepair, Self::Reedit) => "reopen the editor on the retained copy",
794 (InputContext::EditRepair, Self::Ignore) => "acknowledge (keeps the copy)",
795 (InputContext::EditRepair, Self::Discard) => "discard the edit (asks again)",
796 (InputContext::EditConflict, Self::Confirm) => "overwrite the file with this edit",
800 (InputContext::EditConflict, Self::Ignore) => "keep the on-disk version",
801 (InputContext::EditConflict, Self::Reedit) => "compare in the editor",
802 _ => self.description(),
803 }
804 }
805}
806
807pub type Binding = Vec<String>;
808
809macro_rules! partial_bindings {
810 ($($field:ident),+ $(,)?) => {
811 #[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
812 #[serde(default, deny_unknown_fields)]
813 pub struct ActionBindings {
814 $(
815 #[serde(skip_serializing_if = "Option::is_none")]
816 pub $field: Option<Binding>,
817 )+
818 }
819 };
820}
821
822partial_bindings!(
823 open_mentions,
827 move_up,
828 move_down,
829 move_left,
830 move_right,
831 next_board,
832 previous_board,
833 open,
834 edit,
835 agent,
836 view,
837 toggle_column,
838 filter_modal,
839 bulk_modal,
840 triage,
841 views,
842 apply_view,
843 new_view,
844 rename_view,
845 update_view,
846 duplicate_view,
847 delete_view,
848 mark,
849 unmark,
850 clear_marks,
851 move_previous,
852 move_next,
853 new_issue,
854 clone_issue,
855 toggle_archived,
856 toggle_children,
857 delete,
858 refresh,
859 settings,
860 planning,
861 new_board,
862 help,
863 quit,
864 text_filter,
865 reorder_up,
866 reorder_down,
867 close,
868 scroll_up,
869 scroll_down,
870 comment,
871 asset,
872 edit_parent,
873 submit,
874 cancel,
875 next,
876 previous,
877 open_picker,
878 reset,
879 toggle,
880 add_column,
881 move_column_up,
882 move_column_down,
883 new_planning_item,
884 edit_planning_item,
885 delete_planning_item,
886 confirm,
887 create,
888 previous_year,
889 next_year,
890 previous_month,
891 next_month,
892 reedit,
893 ignore,
894 declare_key,
895 discard,
896 switch_to_board,
897 switch_to_triage,
898 switch_to_cycle,
899 switch_to_milestones,
900 switch_to_repos,
901 switch_to_stats,
902 health_form,
903 time_add,
907 time_edit,
908 time_delete,
909 time_filter,
910 time_breakdown,
911 time_history,
912 switch_to_schedule,
914 series_generate,
918 series_pause,
919 series_resume,
920 series_end,
921 series_skip,
922 series_defer,
923 series_ignore,
924 series_new,
925 switch_to_library,
927 switch_to_portfolio,
928 switch_to_new_issue,
929 switch_to_config,
930 toggle_navigation,
931);
932
933macro_rules! keybindings_config {
934 ($($field:ident => $context:ident),+ $(,)?) => {
935 #[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
936 #[serde(default, deny_unknown_fields)]
937 pub struct KeyBindings {
938 $(
939 #[serde(default, skip_serializing_if = "Option::is_none")]
940 pub $field: Option<Box<ActionBindings>>,
941 )+
942 }
943
944 impl KeyBindings {
945 pub fn is_empty(&self) -> bool {
946 self == &Self::default()
947 }
948
949 fn overrides(&self, context: InputContext) -> Option<&ActionBindings> {
950 match context {
951 $(InputContext::$context => self.$field.as_deref(),)+
952 }
953 }
954 }
955 };
956}
957
958keybindings_config!(
959 board => Board,
960 rollover => Rollover,
961 detail => Detail,
962 form => Form,
963 form_picker => FormPicker,
964 confirm_delete => ConfirmDelete,
965 confirm_blocked_move => ConfirmBlockedMove,
966 help_search => HelpSearch,
967 move_into_done => MoveIntoDone,
968 help => Help,
969 context_menu => ContextMenu,
970 related_picker => RelatedPicker,
973 filter => Filter,
974 filter_modal => FilterModal,
975 bulk_modal => BulkModal,
976 triage => Triage,
977 cycle => Cycle,
978 milestones => Milestones,
979 repos => Repos,
982 stats => Stats,
985 schedule => Schedule,
988 library => Library,
991 portfolio => Portfolio,
994 settings => Settings,
995 settings_prompt => SettingsPrompt,
996 planning_management => PlanningManagement,
997 planning_confirm => PlanningConfirm,
998 planning_form => PlanningForm,
999 date_picker => DatePicker,
1000 comment => Comment,
1001 asset => Asset,
1002 edit_repair => EditRepair,
1003 edit_conflict => EditConflict,
1004 link_prompt => LinkPrompt,
1005 shell => Shell,
1006 views => Views,
1007 views_prompt => ViewsPrompt,
1008);
1009
1010#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
1011pub struct KeyChord {
1012 pub code: KeyCode,
1013 pub modifiers: KeyModifiers,
1014}
1015
1016impl KeyChord {
1017 pub fn parse(value: &str) -> std::result::Result<Self, String> {
1018 let value = value.trim();
1019 if value.is_empty() {
1020 return Err("key chord must not be empty".into());
1021 }
1022 if value.chars().count() == 1 {
1023 let ch = value.chars().next().expect("value has one character");
1024 if ch.is_control() {
1025 return Err(format!("unsupported key name `{value}`"));
1026 }
1027 let modifiers = if ch.is_ascii_uppercase() {
1028 KeyModifiers::SHIFT
1029 } else {
1030 KeyModifiers::NONE
1031 };
1032 return Ok(Self {
1033 code: KeyCode::Char(ch),
1034 modifiers,
1035 });
1036 }
1037 let mut parts = value.split('+');
1038 let key = parts.next_back().ok_or("missing key name")?.trim();
1039 let mut modifiers = KeyModifiers::NONE;
1040 for part in parts {
1041 match part.trim().to_ascii_lowercase().as_str() {
1042 "ctrl" | "control" => modifiers |= KeyModifiers::CONTROL,
1043 "alt" | "option" => modifiers |= KeyModifiers::ALT,
1044 "shift" => modifiers |= KeyModifiers::SHIFT,
1045 "super" | "cmd" | "command" => modifiers |= KeyModifiers::SUPER,
1046 other => return Err(format!("unsupported modifier `{other}`")),
1047 }
1048 }
1049 if key.is_empty() {
1050 return Err("missing key name".into());
1051 }
1052 let mut code = match key.to_ascii_lowercase().as_str() {
1053 "backtab" => return Err("use shift+tab instead of `backtab`".into()),
1054 "shift+tab" => KeyCode::BackTab,
1055 "backspace" => KeyCode::Backspace,
1056 "tab" => KeyCode::Tab,
1057 "enter" | "return" => KeyCode::Enter,
1058 "esc" | "escape" => KeyCode::Esc,
1059 "space" => KeyCode::Char(' '),
1060 "left" => KeyCode::Left,
1061 "right" => KeyCode::Right,
1062 "up" => KeyCode::Up,
1063 "down" => KeyCode::Down,
1064 "home" => KeyCode::Home,
1065 "end" => KeyCode::End,
1066 "pageup" | "pgup" => KeyCode::PageUp,
1067 "pagedown" | "pgdn" | "pgdown" => KeyCode::PageDown,
1068 "delete" | "del" => KeyCode::Delete,
1069 "insert" | "ins" => KeyCode::Insert,
1070 _ => {
1071 if let Some(number) = key
1072 .to_ascii_lowercase()
1073 .strip_prefix('f')
1074 .and_then(|n| n.parse::<u8>().ok())
1075 {
1076 if (1..=12).contains(&number) {
1077 KeyCode::F(number)
1078 } else {
1079 return Err(format!("unsupported function key `{key}`"));
1080 }
1081 } else {
1082 let mut chars = key.chars();
1083 let Some(ch) = chars.next().filter(|_| chars.next().is_none()) else {
1084 return Err(format!("unsupported key name `{key}`"));
1085 };
1086 if ch.is_control() {
1087 return Err(format!("unsupported key name `{key}`"));
1088 }
1089 if ch.is_ascii_uppercase() {
1090 modifiers |= KeyModifiers::SHIFT;
1091 }
1092 KeyCode::Char(ch)
1093 }
1094 }
1095 };
1096 if code == KeyCode::Tab && modifiers.contains(KeyModifiers::SHIFT) {
1097 code = KeyCode::BackTab;
1098 modifiers.remove(KeyModifiers::SHIFT);
1099 }
1100 if code == KeyCode::BackTab {
1101 modifiers |= KeyModifiers::SHIFT;
1102 }
1103 Ok(Self { code, modifiers })
1104 }
1105
1106 pub fn matches(&self, event: KeyEvent) -> bool {
1107 if self.code != event.code {
1108 return false;
1109 }
1110 if self.modifiers.is_empty() {
1111 return event.modifiers.is_empty();
1112 }
1113 if self.code == KeyCode::BackTab {
1114 return event.modifiers.contains(KeyModifiers::SHIFT) || event.code == KeyCode::BackTab;
1115 }
1116 if let (KeyCode::Char(expected), KeyCode::Char(actual)) = (self.code, event.code) {
1117 let shift_expected = self.modifiers.contains(KeyModifiers::SHIFT);
1118 let normalized_expected = if shift_expected {
1119 expected.to_ascii_lowercase()
1120 } else {
1121 expected
1122 };
1123 let shift_actual =
1124 actual.is_uppercase() || event.modifiers.contains(KeyModifiers::SHIFT);
1125 let expected_other = self.modifiers.difference(KeyModifiers::SHIFT);
1126 let actual_other = event.modifiers.difference(KeyModifiers::SHIFT);
1127 return expected_other == actual_other
1128 && normalized_expected == actual.to_ascii_lowercase()
1129 && shift_expected == shift_actual;
1130 }
1131 event.modifiers == self.modifiers
1132 }
1133
1134 pub fn display(&self) -> String {
1135 let mut out = String::new();
1136 if self.modifiers.contains(KeyModifiers::CONTROL) {
1137 out.push_str("Ctrl+");
1138 }
1139 if self.modifiers.contains(KeyModifiers::ALT) {
1140 out.push_str("Alt+");
1141 }
1142 if self.modifiers.contains(KeyModifiers::SUPER) {
1143 out.push_str("Super+");
1144 }
1145 if self.modifiers.contains(KeyModifiers::SHIFT) && self.code != KeyCode::BackTab {
1146 out.push_str("Shift+");
1147 }
1148 out.push_str(&match self.code {
1149 KeyCode::BackTab => "Shift+Tab".into(),
1150 KeyCode::Char(' ') => "Space".into(),
1151 KeyCode::Char(ch) => ch.to_string(),
1152 KeyCode::Enter => "Enter".into(),
1153 KeyCode::Esc => "Esc".into(),
1154 KeyCode::Left => "Left".into(),
1155 KeyCode::Right => "Right".into(),
1156 KeyCode::Up => "Up".into(),
1157 KeyCode::Down => "Down".into(),
1158 KeyCode::PageUp => "PgUp".into(),
1159 KeyCode::PageDown => "PgDn".into(),
1160 KeyCode::Delete => "Delete".into(),
1161 KeyCode::Insert => "Insert".into(),
1162 KeyCode::Home => "Home".into(),
1163 KeyCode::End => "End".into(),
1164 KeyCode::F(n) => format!("F{n}"),
1165 other => format!("{other:?}"),
1166 });
1167 out
1168 }
1169}
1170
1171impl Serialize for KeyChord {
1172 fn serialize<S>(&self, serializer: S) -> std::result::Result<S::Ok, S::Error>
1173 where
1174 S: Serializer,
1175 {
1176 serializer.serialize_str(&self.display())
1177 }
1178}
1179
1180impl<'de> Deserialize<'de> for KeyChord {
1181 fn deserialize<D>(deserializer: D) -> std::result::Result<Self, D::Error>
1182 where
1183 D: Deserializer<'de>,
1184 {
1185 let value = String::deserialize(deserializer)?;
1186 Self::parse(&value).map_err(D::Error::custom)
1187 }
1188}
1189
1190impl fmt::Display for KeyChord {
1191 fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
1192 formatter.write_str(&self.display())
1193 }
1194}
1195
1196#[derive(Debug, Clone, Default, PartialEq, Eq)]
1197pub struct EffectiveBindings {
1198 modes: HashMap<InputContext, HashMap<TuiAction, Vec<KeyChord>>>,
1199}
1200
1201impl KeyBindings {
1202 pub fn load(project_root: &Path) -> Result<Self> {
1209 let path = project_root.join(crate::keybindings::KEYBINDINGS_FILE);
1210 if !path.exists() {
1211 return Ok(Self::default());
1212 }
1213 let contents = std::fs::read_to_string(&path)
1214 .map_err(|error| TixError::Config(format!("{}: {error}", path.display())))?;
1215 let overrides: Self = toml::from_str(&contents)
1216 .map_err(|error| TixError::Config(format!("{}: {error}", path.display())))?;
1217 Ok(overrides)
1218 }
1219
1220 pub fn load_effective(project_root: &Path) -> Result<EffectiveBindings> {
1222 Self::resolve(&Self::load(project_root)?)
1223 }
1224
1225 pub fn resolve(overrides: &KeyBindings) -> Result<EffectiveBindings> {
1226 let mut effective = EffectiveBindings::default();
1227 for context in InputContext::ALL {
1228 let mut mode = default_mode(*context);
1229 if let Some(overrides) = overrides.overrides(*context) {
1230 overlay(&mut mode, overrides, *context)?;
1231 }
1232 effective.modes.insert(*context, mode);
1233 }
1234 Ok(effective)
1235 }
1236}
1237
1238pub const KEYBINDINGS_FILE: &str = "tix_keybindings.toml";
1240
1241impl EffectiveBindings {
1242 pub fn action(&self, context: InputContext, event: KeyEvent) -> Option<TuiAction> {
1243 self.modes
1244 .get(&context)?
1245 .iter()
1246 .find_map(|(action, chords)| {
1247 chords
1248 .iter()
1249 .any(|key| key.matches(event))
1250 .then_some(*action)
1251 })
1252 }
1253
1254 pub fn bound_actions(&self, context: InputContext) -> Vec<TuiAction> {
1260 let mut actions: Vec<TuiAction> = self
1261 .modes
1262 .get(&context)
1263 .map(|mode| mode.keys().copied().collect())
1264 .unwrap_or_default();
1265 actions.sort_by_key(|action| action.as_str());
1266 actions
1267 }
1268
1269 pub fn chords(&self, context: InputContext, action: TuiAction) -> &[KeyChord] {
1270 self.modes
1271 .get(&context)
1272 .and_then(|mode| mode.get(&action))
1273 .map(Vec::as_slice)
1274 .unwrap_or_default()
1275 }
1276
1277 pub fn label(&self, context: InputContext, action: TuiAction) -> String {
1278 let labels: Vec<String> = self
1279 .chords(context, action)
1280 .iter()
1281 .map(ToString::to_string)
1282 .collect();
1283 if labels.is_empty() {
1284 "—".into()
1285 } else {
1286 labels.join(" / ")
1287 }
1288 }
1289
1290 #[allow(clippy::too_many_lines)]
1291 pub fn canonical_event(&self, context: InputContext, action: TuiAction) -> KeyEvent {
1292 let (code, modifiers) = match (context, action) {
1293 (_, TuiAction::ReorderUp) => (KeyCode::Up, KeyModifiers::CONTROL),
1294 (_, TuiAction::ReorderDown) => (KeyCode::Down, KeyModifiers::CONTROL),
1295 (InputContext::Rollover, TuiAction::Previous) => (KeyCode::Tab, KeyModifiers::NONE),
1296 (InputContext::PlanningManagement, TuiAction::Next) => {
1297 (KeyCode::Tab, KeyModifiers::NONE)
1298 }
1299 (InputContext::FormPicker, TuiAction::Next) => (KeyCode::Up, KeyModifiers::NONE),
1300 (InputContext::FormPicker, TuiAction::Previous) => (KeyCode::Down, KeyModifiers::NONE),
1301 (InputContext::DatePicker, TuiAction::PreviousYear) => {
1302 (KeyCode::Char('['), KeyModifiers::NONE)
1303 }
1304 (InputContext::DatePicker, TuiAction::NextYear) => {
1305 (KeyCode::Char(']'), KeyModifiers::NONE)
1306 }
1307 (InputContext::DatePicker, TuiAction::PreviousMonth) => {
1308 (KeyCode::PageUp, KeyModifiers::NONE)
1309 }
1310 (InputContext::DatePicker, TuiAction::NextMonth) => {
1311 (KeyCode::PageDown, KeyModifiers::NONE)
1312 }
1313 (InputContext::DatePicker, TuiAction::Reset) => (KeyCode::Delete, KeyModifiers::NONE),
1314 (InputContext::Library, TuiAction::Next) => (KeyCode::Tab, KeyModifiers::NONE),
1320 (InputContext::Library, TuiAction::Previous) => (KeyCode::BackTab, KeyModifiers::NONE),
1321 (InputContext::Portfolio, TuiAction::Next) => (KeyCode::Tab, KeyModifiers::NONE),
1326 (InputContext::Portfolio, TuiAction::Previous) => {
1327 (KeyCode::BackTab, KeyModifiers::NONE)
1328 }
1329 (InputContext::Stats, TuiAction::Next) => (KeyCode::Tab, KeyModifiers::NONE),
1333 (InputContext::Stats, TuiAction::Previous) => (KeyCode::BackTab, KeyModifiers::NONE),
1334 (InputContext::Settings, TuiAction::NewBoard) => {
1335 (KeyCode::Char('n'), KeyModifiers::NONE)
1336 }
1337 (InputContext::Settings, TuiAction::Delete) => (KeyCode::Char('x'), KeyModifiers::NONE),
1338 (_, TuiAction::ScrollUp) => (KeyCode::PageUp, KeyModifiers::NONE),
1346 (_, TuiAction::ScrollDown) => (KeyCode::PageDown, KeyModifiers::NONE),
1347 (_, TuiAction::ScrollLineDown) => (KeyCode::Down, KeyModifiers::NONE),
1351 (_, TuiAction::ScrollLineUp) => (KeyCode::Up, KeyModifiers::NONE),
1352 (_, TuiAction::ScrollTop) => (KeyCode::Char('g'), KeyModifiers::NONE),
1353 (_, TuiAction::ScrollBottom) => (KeyCode::Char('G'), KeyModifiers::NONE),
1354 (_, TuiAction::MoveUp | TuiAction::Previous) => (KeyCode::Up, KeyModifiers::NONE),
1355 (_, TuiAction::MoveDown | TuiAction::Next) => (KeyCode::Down, KeyModifiers::NONE),
1356 (_, TuiAction::MoveLeft) => (KeyCode::Left, KeyModifiers::NONE),
1357 (_, TuiAction::MoveRight) => (KeyCode::Right, KeyModifiers::NONE),
1358 (_, TuiAction::PreviousBoard) => (KeyCode::BackTab, KeyModifiers::NONE),
1359 (_, TuiAction::NextBoard) => (KeyCode::Tab, KeyModifiers::NONE),
1360 (_, TuiAction::Submit | TuiAction::Open | TuiAction::Ignore) => {
1361 (KeyCode::Enter, KeyModifiers::NONE)
1362 }
1363 (_, TuiAction::NewIssue) => (KeyCode::Char('n'), KeyModifiers::NONE),
1364 (_, TuiAction::CloneIssue) => (KeyCode::Char('C'), KeyModifiers::NONE),
1365 (_, TuiAction::ToggleArchived) => (KeyCode::Char('a'), KeyModifiers::NONE),
1366 (_, TuiAction::ToggleChildren) => (KeyCode::Char('u'), KeyModifiers::NONE),
1367 (_, TuiAction::Edit | TuiAction::EditPlanningItem) => {
1368 (KeyCode::Char('e'), KeyModifiers::NONE)
1369 }
1370 (_, TuiAction::Agent) => (KeyCode::Char('A'), KeyModifiers::NONE),
1371 (InputContext::Board, TuiAction::View) => (KeyCode::Char('V'), KeyModifiers::NONE),
1375 (_, TuiAction::View) => (KeyCode::Char('v'), KeyModifiers::NONE),
1376 (_, TuiAction::ToggleColumn | TuiAction::Comment) => {
1377 (KeyCode::Char('c'), KeyModifiers::NONE)
1378 }
1379 (_, TuiAction::FilterModal) => (KeyCode::Char('f'), KeyModifiers::NONE),
1380 (_, TuiAction::BulkModal) => (KeyCode::Char('B'), KeyModifiers::NONE),
1381 (_, TuiAction::Triage) => (KeyCode::Char('t'), KeyModifiers::NONE),
1382 (_, TuiAction::OpenMentions) => (KeyCode::Char('i'), KeyModifiers::NONE),
1386 (_, TuiAction::Views) => (KeyCode::Char('v'), KeyModifiers::NONE),
1390 (_, TuiAction::ApplyView) => (KeyCode::Enter, KeyModifiers::NONE),
1391 (_, TuiAction::NewView) => (KeyCode::Char('n'), KeyModifiers::NONE),
1392 (_, TuiAction::RenameView) => (KeyCode::Char('e'), KeyModifiers::NONE),
1393 (_, TuiAction::UpdateView) => (KeyCode::Char('u'), KeyModifiers::NONE),
1394 (_, TuiAction::DuplicateView) => (KeyCode::Char('d'), KeyModifiers::NONE),
1395 (_, TuiAction::DeleteView) => (KeyCode::Char('x'), KeyModifiers::NONE),
1396 (_, TuiAction::Mark) => (KeyCode::Char(' '), KeyModifiers::NONE),
1397 (_, TuiAction::Unmark) => (KeyCode::Char('x'), KeyModifiers::NONE),
1398 (_, TuiAction::ClearMarks) => (KeyCode::Char('X'), KeyModifiers::NONE),
1399 (_, TuiAction::MovePrevious) => (KeyCode::Char('<'), KeyModifiers::NONE),
1400 (_, TuiAction::MoveNext) => (KeyCode::Char('>'), KeyModifiers::NONE),
1401 (_, TuiAction::Delete) => (KeyCode::Char('d'), KeyModifiers::NONE),
1402 (_, TuiAction::DeletePlanningItem) => (KeyCode::Char('x'), KeyModifiers::NONE),
1403 (_, TuiAction::Refresh | TuiAction::Reset | TuiAction::Reedit) => {
1404 (KeyCode::Char('r'), KeyModifiers::NONE)
1405 }
1406 (_, TuiAction::Settings) => (KeyCode::Char('s'), KeyModifiers::NONE),
1407 (_, TuiAction::Planning) => (KeyCode::Char('m'), KeyModifiers::NONE),
1408 (_, TuiAction::NewPlanningItem) => (KeyCode::Char('n'), KeyModifiers::NONE),
1409 (_, TuiAction::NewBoard) => (KeyCode::Char('b'), KeyModifiers::NONE),
1410 (_, TuiAction::AddColumn) => (KeyCode::Char('a'), KeyModifiers::NONE),
1411 (_, TuiAction::MoveColumnUp) => (KeyCode::Char('K'), KeyModifiers::SHIFT),
1412 (_, TuiAction::MoveColumnDown) => (KeyCode::Char('J'), KeyModifiers::SHIFT),
1413 (_, TuiAction::Help) => (KeyCode::Char('?'), KeyModifiers::NONE),
1414 (_, TuiAction::Quit) => (KeyCode::Char('q'), KeyModifiers::NONE),
1415 (_, TuiAction::TextFilter) => (KeyCode::Char('/'), KeyModifiers::NONE),
1416 (_, TuiAction::Close | TuiAction::Cancel) => (KeyCode::Esc, KeyModifiers::NONE),
1417 (_, TuiAction::Asset) => (KeyCode::Char('a'), KeyModifiers::NONE),
1418 (_, TuiAction::NewRelationship) => (KeyCode::Char('n'), KeyModifiers::NONE),
1421 (_, TuiAction::EditRelationship) => (KeyCode::Char('E'), KeyModifiers::SHIFT),
1422 (_, TuiAction::RemoveRelationship) => (KeyCode::Char('d'), KeyModifiers::NONE),
1423 (_, TuiAction::EditParent) => (KeyCode::Char('P'), KeyModifiers::NONE),
1426 (_, TuiAction::NextRelationship) => (KeyCode::Tab, KeyModifiers::NONE),
1427 (_, TuiAction::PreviousRelationship) => (KeyCode::BackTab, KeyModifiers::NONE),
1428 (_, TuiAction::RelatedPicker) => (KeyCode::Char('r'), KeyModifiers::NONE),
1433 (_, TuiAction::FollowRelated) => (KeyCode::Char('o'), KeyModifiers::NONE),
1434 (_, TuiAction::NavBack) => (KeyCode::Backspace, KeyModifiers::NONE),
1435 (_, TuiAction::OpenPicker) => (KeyCode::F(2), KeyModifiers::NONE),
1436 (_, TuiAction::SwitchToBoard) => (KeyCode::F(3), KeyModifiers::NONE),
1441 (_, TuiAction::SwitchToTriage) => (KeyCode::F(2), KeyModifiers::NONE),
1442 (_, TuiAction::SwitchToCycle) => (KeyCode::F(4), KeyModifiers::NONE),
1443 (_, TuiAction::SwitchToMilestones) => (KeyCode::F(5), KeyModifiers::NONE),
1444 (_, TuiAction::SwitchToRepos) => (KeyCode::F(6), KeyModifiers::NONE),
1445 (_, TuiAction::SwitchToStats) => (KeyCode::F(7), KeyModifiers::NONE),
1448 (_, TuiAction::HealthForm) => (KeyCode::Char('H'), KeyModifiers::NONE),
1453 (_, TuiAction::TimeAdd) => (KeyCode::Char('a'), KeyModifiers::NONE),
1458 (_, TuiAction::TimeEdit) => (KeyCode::Char('e'), KeyModifiers::NONE),
1459 (_, TuiAction::TimeDelete) => (KeyCode::Char('x'), KeyModifiers::NONE),
1460 (_, TuiAction::TimeFilter) => (KeyCode::Char('f'), KeyModifiers::NONE),
1461 (_, TuiAction::TimeBreakdown) => (KeyCode::Char('s'), KeyModifiers::NONE),
1462 (_, TuiAction::TimeHistory) => (KeyCode::Char('H'), KeyModifiers::NONE),
1463 (_, TuiAction::SwitchToSchedule) => (KeyCode::F(8), KeyModifiers::NONE),
1465 (_, TuiAction::SeriesGenerate) => (KeyCode::Char('g'), KeyModifiers::NONE),
1468 (_, TuiAction::SeriesPause) => (KeyCode::Char('p'), KeyModifiers::NONE),
1469 (_, TuiAction::SeriesResume) => (KeyCode::Char('r'), KeyModifiers::NONE),
1470 (_, TuiAction::SeriesEnd) => (KeyCode::Char('x'), KeyModifiers::NONE),
1471 (_, TuiAction::SeriesSkip) => (KeyCode::Char('s'), KeyModifiers::NONE),
1472 (_, TuiAction::SeriesDefer) => (KeyCode::Char('d'), KeyModifiers::NONE),
1473 (_, TuiAction::SeriesIgnore) => (KeyCode::Char('i'), KeyModifiers::NONE),
1474 (_, TuiAction::SeriesNew) => (KeyCode::Char('n'), KeyModifiers::NONE),
1477 (_, TuiAction::SwitchToLibrary) => (KeyCode::F(11), KeyModifiers::NONE),
1480 (_, TuiAction::SwitchToPortfolio) => (KeyCode::F(10), KeyModifiers::NONE),
1482 (_, TuiAction::SwitchToNewIssue) => (KeyCode::F(1), KeyModifiers::NONE),
1483 (_, TuiAction::SwitchToConfig) => (KeyCode::F(12), KeyModifiers::NONE),
1484 (_, TuiAction::ToggleNavigation) => (KeyCode::Char('n'), KeyModifiers::CONTROL),
1485 (_, TuiAction::Toggle) => (KeyCode::Char(' '), KeyModifiers::NONE),
1486 (_, TuiAction::Confirm) => (KeyCode::Char('y'), KeyModifiers::NONE),
1487 (_, TuiAction::DeclareKey) => (KeyCode::Char('d'), KeyModifiers::NONE),
1491 (_, TuiAction::Discard) => (KeyCode::Char('x'), KeyModifiers::NONE),
1492 (_, TuiAction::Create) => (KeyCode::Char('+'), KeyModifiers::NONE),
1493 _ => (KeyCode::Esc, KeyModifiers::NONE),
1494 };
1495 KeyEvent::new(code, modifiers)
1496 }
1497}
1498
1499impl ActionBindings {
1500 pub fn is_empty(&self) -> bool {
1501 self == &Self::default()
1502 }
1503}
1504
1505#[cfg(test)]
1506mod tests;