1use std::fmt::Write;
2
3use crate::keybindings::{EffectiveBindings, InputContext, KeyBindings, TuiAction};
4use crate::ui::nav::TopMode;
5
6pub const MODE_BOARD: &str = "board";
7pub const MODE_DETAIL: &str = "detail";
8pub const MODE_HELP: &str = "help";
9pub const MODE_COMMENT: &str = "comment";
10pub const MODE_ASSET: &str = "asset";
11pub const MODE_FILTER: &str = "filter";
12
13const MAX_HINT_CHARS: usize = 80;
14
15pub struct Cmd {
16 pub key: &'static str,
17 pub desc: &'static str,
18 pub modes: &'static [&'static str],
19 pub mode: &'static str,
20}
21
22pub const COMMANDS: &[Cmd] = &[
23 Cmd {
24 key: "j/k",
25 desc: "move up/down",
26 modes: &[MODE_BOARD],
27 mode: MODE_BOARD,
28 },
29 Cmd {
30 key: "n",
31 desc: "new issue",
32 modes: &[MODE_BOARD],
33 mode: MODE_BOARD,
34 },
35 Cmd {
36 key: "?",
37 desc: "help",
38 modes: &[MODE_BOARD, MODE_DETAIL],
39 mode: "board/detail",
40 },
41 Cmd {
42 key: "h/l",
43 desc: "column",
44 modes: &[MODE_BOARD],
45 mode: MODE_BOARD,
46 },
47 Cmd {
48 key: "enter",
49 desc: "open",
50 modes: &[MODE_BOARD],
51 mode: MODE_BOARD,
52 },
53 Cmd {
54 key: "d",
55 desc: "delete",
56 modes: &[MODE_BOARD],
57 mode: MODE_BOARD,
58 },
59 Cmd {
60 key: "< / >",
61 desc: "move",
62 modes: &[MODE_BOARD],
63 mode: MODE_BOARD,
64 },
65 Cmd {
66 key: "r",
67 desc: "refresh",
68 modes: &[MODE_BOARD],
69 mode: MODE_BOARD,
70 },
71 Cmd {
72 key: "/",
73 desc: "search",
74 modes: &[MODE_BOARD],
75 mode: MODE_BOARD,
76 },
77 Cmd {
78 key: "f",
79 desc: "criteria",
80 modes: &[MODE_BOARD],
81 mode: MODE_BOARD,
82 },
83 Cmd {
84 key: "c",
85 desc: "comment",
86 modes: &[MODE_DETAIL],
87 mode: MODE_DETAIL,
88 },
89 Cmd {
90 key: "a",
91 desc: "asset",
92 modes: &[MODE_DETAIL],
93 mode: MODE_DETAIL,
94 },
95];
96
97#[allow(clippy::too_many_lines)]
98fn actions(context: InputContext) -> &'static [TuiAction] {
99 match context {
100 InputContext::Board => &[
101 TuiAction::Open,
102 TuiAction::NewIssue,
103 TuiAction::Help,
104 TuiAction::MoveUp,
105 TuiAction::MoveDown,
106 TuiAction::Mark,
107 TuiAction::Unmark,
108 TuiAction::ClearMarks,
109 TuiAction::Planning,
110 TuiAction::ToggleChildren,
113 TuiAction::Quit,
114 ],
115 InputContext::Detail => &[
116 TuiAction::Comment,
117 TuiAction::Asset,
118 TuiAction::RelatedPicker,
122 TuiAction::FollowRelated,
123 TuiAction::NavBack,
124 TuiAction::NewRelationship,
128 TuiAction::NextRelationship,
129 TuiAction::PreviousRelationship,
130 TuiAction::EditRelationship,
131 TuiAction::RemoveRelationship,
132 TuiAction::EditParent,
135 TuiAction::HealthForm,
138 TuiAction::Edit,
139 TuiAction::ScrollLineDown,
140 TuiAction::ScrollLineUp,
141 TuiAction::ScrollUp,
142 TuiAction::ScrollDown,
143 TuiAction::Help,
144 ],
145 InputContext::Triage => &[
149 TuiAction::MoveUp,
150 TuiAction::MoveDown,
151 TuiAction::Toggle,
152 TuiAction::Submit,
153 TuiAction::Cancel,
154 ],
155 InputContext::Cycle => &[
158 TuiAction::MoveUp,
159 TuiAction::MoveDown,
160 TuiAction::MoveLeft,
161 TuiAction::MoveRight,
162 TuiAction::Planning,
163 TuiAction::Help,
164 TuiAction::Close,
165 ],
166 InputContext::Milestones => &[
169 TuiAction::MoveUp,
170 TuiAction::MoveDown,
171 TuiAction::Planning,
172 TuiAction::NewPlanningItem,
173 TuiAction::EditPlanningItem,
174 TuiAction::DeletePlanningItem,
175 TuiAction::Help,
176 TuiAction::Close,
177 ],
178 InputContext::Form | InputContext::PlanningForm => &[
179 TuiAction::Submit,
180 TuiAction::Next,
181 TuiAction::Previous,
182 TuiAction::OpenPicker,
183 TuiAction::Cancel,
184 ],
185 InputContext::FormPicker => &[
186 TuiAction::Next,
187 TuiAction::Previous,
188 TuiAction::Submit,
189 TuiAction::Create,
190 TuiAction::Cancel,
191 ],
192 InputContext::PlanningManagement => &[
193 TuiAction::MoveUp,
194 TuiAction::MoveDown,
195 TuiAction::MoveLeft,
196 TuiAction::MoveRight,
197 TuiAction::Next,
198 TuiAction::NewPlanningItem,
199 TuiAction::EditPlanningItem,
200 TuiAction::DeletePlanningItem,
201 TuiAction::Close,
202 ],
203 InputContext::Settings => &[
204 TuiAction::MoveUp,
205 TuiAction::MoveDown,
206 TuiAction::Toggle,
207 TuiAction::AddColumn,
208 TuiAction::MoveColumnUp,
209 TuiAction::MoveColumnDown,
210 TuiAction::Submit,
211 TuiAction::NewBoard,
212 TuiAction::EditPlanningItem,
213 TuiAction::Delete,
214 TuiAction::NextBoard,
215 TuiAction::PreviousBoard,
216 TuiAction::MoveLeft,
217 TuiAction::MoveRight,
218 TuiAction::Close,
219 ],
220 InputContext::Shell => &[
223 TuiAction::SwitchToBoard,
224 TuiAction::SwitchToTriage,
225 TuiAction::SwitchToCycle,
226 TuiAction::SwitchToMilestones,
227 TuiAction::SwitchToRepos,
228 TuiAction::SwitchToStats,
229 TuiAction::SwitchToSchedule,
230 TuiAction::SwitchToLibrary,
231 TuiAction::SwitchToPortfolio,
232 TuiAction::SwitchToNewIssue,
233 TuiAction::SwitchToConfig,
234 TuiAction::ToggleNavigation,
235 ],
236 InputContext::Repos => &[
238 TuiAction::MoveUp,
239 TuiAction::MoveDown,
240 TuiAction::Submit,
241 TuiAction::Close,
242 ],
243 InputContext::Stats => &[
247 TuiAction::MoveUp,
248 TuiAction::MoveDown,
249 TuiAction::Next,
250 TuiAction::Previous,
251 TuiAction::TimeAdd,
252 TuiAction::TimeEdit,
253 TuiAction::TimeDelete,
254 TuiAction::TimeFilter,
255 TuiAction::TimeBreakdown,
256 TuiAction::TimeHistory,
257 TuiAction::Submit,
258 TuiAction::Help,
259 TuiAction::Close,
260 ],
261 _ => &[],
262 }
263}
264
265pub const fn hint_context_for_mode(mode: TopMode) -> InputContext {
270 match mode {
271 TopMode::Board => InputContext::Board,
272 TopMode::Triage => InputContext::Triage,
273 TopMode::Cycle => InputContext::Cycle,
274 TopMode::Milestones => InputContext::Milestones,
275 TopMode::Repos => InputContext::Repos,
276 TopMode::Stats => InputContext::Stats,
277 TopMode::Schedule => InputContext::Schedule,
278 TopMode::Library => InputContext::Library,
279 TopMode::Portfolio => InputContext::Portfolio,
280 TopMode::NewIssue => InputContext::Form,
281 TopMode::Config => InputContext::Settings,
282 }
283}
284
285pub fn hint_bar(active_modes: &[&str]) -> String {
286 let bindings =
287 KeyBindings::resolve(&KeyBindings::default()).expect("built-in bindings are valid");
288 for mode in active_modes {
289 if let Some(context) = match *mode {
290 MODE_BOARD => Some(InputContext::Board),
291 MODE_DETAIL => Some(InputContext::Detail),
292 _ => None,
293 } {
294 return hint_bar_for(&bindings, context);
295 }
296 }
297 String::new()
298}
299
300pub fn hint_bar_for(bindings: &EffectiveBindings, context: InputContext) -> String {
301 hint_bar_for_with(bindings, context, None)
302}
303
304pub fn hint_bar_for_with(
314 bindings: &EffectiveBindings,
315 context: InputContext,
316 stats_tab: Option<crate::app::StatsTab>,
317) -> String {
318 if context == InputContext::Board
319 && bindings.label(context, TuiAction::MoveUp) == "Up / k"
320 && bindings.label(context, TuiAction::MoveDown) == "Down / j"
321 {
322 return truncate(
323 &format!(
324 "j/k nav · {} new issue · {} mark · {} help",
325 bindings.label(context, TuiAction::NewIssue),
326 bindings.label(context, TuiAction::Mark),
327 bindings.label(context, TuiAction::Help),
328 ),
329 MAX_HINT_CHARS,
330 );
331 }
332 let scoped: Option<&'static [TuiAction]> = if context == InputContext::Stats {
333 stats_tab.map(stats_footer_actions)
334 } else {
335 None
336 };
337 let rows: &'static [TuiAction] = scoped.unwrap_or_else(|| actions(context));
338 let mut text = String::new();
339 for action in rows {
340 let _ = write!(
341 text,
342 "{} {} · ",
343 bindings.label(context, *action),
344 action.description_in(context)
345 );
346 }
347 truncate(text.trim_end_matches(" · "), MAX_HINT_CHARS)
348}
349
350#[cfg(test)]
358const HELP_CONTEXTS: [InputContext; 36] = [
359 InputContext::Detail,
360 InputContext::Board,
361 InputContext::Rollover,
362 InputContext::Form,
363 InputContext::FormPicker,
364 InputContext::ConfirmDelete,
365 InputContext::MoveIntoDone,
366 InputContext::ConfirmBlockedMove,
369 InputContext::Help,
370 InputContext::HelpSearch,
371 InputContext::ContextMenu,
372 InputContext::RelatedPicker,
375 InputContext::Filter,
376 InputContext::FilterModal,
377 InputContext::Settings,
378 InputContext::SettingsPrompt,
379 InputContext::PlanningManagement,
380 InputContext::PlanningConfirm,
381 InputContext::PlanningForm,
382 InputContext::DatePicker,
383 InputContext::Comment,
384 InputContext::Asset,
385 InputContext::LinkPrompt,
386 InputContext::EditRepair,
387 InputContext::EditConflict,
391 InputContext::BulkModal,
395 InputContext::Triage,
398 InputContext::Cycle,
400 InputContext::Milestones,
402 InputContext::Repos,
404 InputContext::Schedule,
406 InputContext::Library,
408 InputContext::Portfolio,
411 InputContext::Shell,
413 InputContext::Views,
415 InputContext::ViewsPrompt,
416];
417
418pub const fn contexts_for_mode(mode: TopMode) -> &'static [InputContext] {
424 match mode {
425 TopMode::Board => &[
430 InputContext::Shell,
431 InputContext::Board,
432 InputContext::Detail,
433 InputContext::Rollover,
434 InputContext::ConfirmDelete,
435 InputContext::MoveIntoDone,
436 InputContext::ConfirmBlockedMove,
437 InputContext::Help,
438 InputContext::HelpSearch,
439 InputContext::ContextMenu,
440 InputContext::RelatedPicker,
443 InputContext::Filter,
444 InputContext::FilterModal,
445 InputContext::Comment,
446 InputContext::Asset,
447 InputContext::LinkPrompt,
448 InputContext::EditRepair,
449 InputContext::EditConflict,
452 InputContext::BulkModal,
453 InputContext::Views,
457 InputContext::ViewsPrompt,
458 ],
459 TopMode::Triage => &[InputContext::Shell, InputContext::Triage],
460 TopMode::Cycle => &[
461 InputContext::Shell,
462 InputContext::Cycle,
463 InputContext::PlanningManagement,
464 InputContext::PlanningConfirm,
465 InputContext::PlanningForm,
466 InputContext::DatePicker,
467 ],
468 TopMode::Milestones => &[
472 InputContext::Shell,
473 InputContext::Milestones,
474 InputContext::PlanningManagement,
475 InputContext::PlanningConfirm,
476 InputContext::PlanningForm,
477 InputContext::DatePicker,
478 InputContext::SettingsPrompt,
479 ],
480 TopMode::Repos => &[InputContext::Shell, InputContext::Repos],
483 TopMode::Stats => &[
486 InputContext::Shell,
487 InputContext::Stats,
488 InputContext::Comment,
489 ],
490 TopMode::Schedule => &[InputContext::Shell, InputContext::Schedule],
493 TopMode::Library => &[InputContext::Shell, InputContext::Library],
496 TopMode::Portfolio => &[InputContext::Shell, InputContext::Portfolio],
499 TopMode::NewIssue => &[
500 InputContext::Shell,
501 InputContext::Form,
502 InputContext::FormPicker,
503 ],
504 TopMode::Config => &[
505 InputContext::Shell,
506 InputContext::Settings,
507 InputContext::SettingsPrompt,
508 ],
509 }
510}
511
512pub fn help_overlay(bindings: &EffectiveBindings, mode: TopMode) -> Vec<(String, String, String)> {
518 help_overlay_for(bindings, mode, None, None)
519}
520
521const STATS_HEALTH_HELP_ACTIONS: &[TuiAction] = &[
527 TuiAction::MoveUp,
528 TuiAction::MoveDown,
529 TuiAction::Next,
530 TuiAction::Previous,
531 TuiAction::Submit,
532 TuiAction::Help,
533 TuiAction::Close,
534];
535
536const STATS_TIME_HELP_ACTIONS: &[TuiAction] = &[
544 TuiAction::MoveUp,
545 TuiAction::MoveDown,
546 TuiAction::Next,
547 TuiAction::Previous,
548 TuiAction::Submit,
549 TuiAction::TimeAdd,
550 TuiAction::TimeEdit,
551 TuiAction::TimeDelete,
552 TuiAction::TimeFilter,
553 TuiAction::TimeBreakdown,
554 TuiAction::TimeHistory,
555 TuiAction::Help,
556 TuiAction::Close,
557];
558
559const STATS_HEALTH_FOOTER: &[TuiAction] = &[
562 TuiAction::MoveUp,
563 TuiAction::MoveDown,
564 TuiAction::Next,
565 TuiAction::Previous,
566 TuiAction::Submit,
567 TuiAction::Help,
568 TuiAction::Close,
569];
570
571const STATS_TIME_FOOTER: &[TuiAction] = &[
577 TuiAction::TimeAdd,
578 TuiAction::TimeEdit,
579 TuiAction::TimeDelete,
580 TuiAction::TimeFilter,
581 TuiAction::TimeBreakdown,
582 TuiAction::TimeHistory,
583 TuiAction::MoveUp,
584 TuiAction::MoveDown,
585 TuiAction::Next,
586 TuiAction::Previous,
587 TuiAction::Submit,
588 TuiAction::Help,
589 TuiAction::Close,
590];
591
592fn stats_help_actions(tab: crate::app::StatsTab) -> &'static [TuiAction] {
595 match tab {
596 crate::app::StatsTab::Health => STATS_HEALTH_HELP_ACTIONS,
597 crate::app::StatsTab::Time => STATS_TIME_HELP_ACTIONS,
598 crate::app::StatsTab::Burndown => crate::ui::analytics::HELP_ACTIONS,
602 }
603}
604
605fn stats_footer_actions(tab: crate::app::StatsTab) -> &'static [TuiAction] {
607 match tab {
608 crate::app::StatsTab::Health => STATS_HEALTH_FOOTER,
609 crate::app::StatsTab::Time => STATS_TIME_FOOTER,
610 crate::app::StatsTab::Burndown => crate::ui::analytics::FOOTER_ACTIONS,
611 }
612}
613
614fn stats_raw_rows(tab: crate::app::StatsTab) -> &'static [(&'static str, &'static str)] {
622 match tab {
623 crate::app::StatsTab::Burndown => crate::ui::analytics::HELP_RAW,
624 crate::app::StatsTab::Health | crate::app::StatsTab::Time => &[],
625 }
626}
627
628pub fn help_overlay_for(
644 bindings: &EffectiveBindings,
645 mode: TopMode,
646 schedule_tab: Option<crate::ui::schedule::ScheduleTab>,
647 stats_tab: Option<crate::app::StatsTab>,
648) -> Vec<(String, String, String)> {
649 use crate::ui::schedule::{
650 CAPACITY_HELP_ACTIONS, CAPACITY_HELP_RAW, CYCLE_HELP_ACTIONS, CYCLE_HELP_RAW, ScheduleTab,
651 };
652
653 let tab_rows = (mode == TopMode::Schedule)
654 .then_some(schedule_tab)
655 .flatten()
656 .and_then(|tab| match tab {
657 ScheduleTab::Cycles => Some((CYCLE_HELP_ACTIONS, CYCLE_HELP_RAW)),
658 ScheduleTab::Capacity => Some((CAPACITY_HELP_ACTIONS, CAPACITY_HELP_RAW)),
659 ScheduleTab::Series => None,
662 });
663 let stats_rows = if mode == TopMode::Stats {
669 stats_tab.map(stats_help_actions)
670 } else {
671 None
672 };
673 let stats_raw = if mode == TopMode::Stats {
676 stats_tab.map(stats_raw_rows)
677 } else {
678 None
679 };
680 let mut rows = Vec::new();
681 if mode == TopMode::Board {
684 rows.extend([
685 (
686 "mouse click".to_string(),
687 "click".to_string(),
688 "board".to_string(),
689 ),
690 (
691 "mouse double".to_string(),
692 "double-click".to_string(),
693 "board".to_string(),
694 ),
695 (
696 "mouse right".to_string(),
697 "right-click".to_string(),
698 "board".to_string(),
699 ),
700 (
701 "mouse drag".to_string(),
702 "drag".to_string(),
703 "board".to_string(),
704 ),
705 ]);
706 }
707 for context in contexts_for_mode(mode) {
708 let schedule_context = *context == InputContext::Schedule;
709 if let Some((actions, raw)) = tab_rows
710 && schedule_context
711 {
712 rows.extend(actions.iter().map(|(action, description)| {
716 (
717 bindings.label(*context, *action),
718 (*description).to_string(),
719 context.as_str().to_string(),
720 )
721 }));
722 rows.extend(raw.iter().map(|(key, description)| {
723 (
724 (*key).to_string(),
725 (*description).to_string(),
726 context.as_str().to_string(),
727 )
728 }));
729 continue;
730 }
731 if let Some(actions) = stats_rows
732 && *context == InputContext::Stats
733 {
734 rows.extend(actions.iter().map(|action| {
737 (
738 bindings.label(*context, *action),
739 action.description_in(*context).to_string(),
740 context.as_str().to_string(),
741 )
742 }));
743 for (key, description) in stats_raw.unwrap_or(&[]) {
745 rows.push((
746 (*key).to_string(),
747 (*description).to_string(),
748 context.as_str().to_string(),
749 ));
750 }
751 continue;
752 }
753 for action in all_actions(*context) {
754 rows.push((
755 bindings.label(*context, *action),
756 action.description_in(*context).to_string(),
757 context.as_str().to_string(),
758 ));
759 }
760 }
761 rows
762}
763
764#[allow(clippy::too_many_lines)]
765fn all_actions(context: InputContext) -> &'static [TuiAction] {
766 match context {
767 InputContext::Board => &[
768 TuiAction::MoveUp,
769 TuiAction::MoveDown,
770 TuiAction::MoveLeft,
771 TuiAction::MoveRight,
772 TuiAction::NextBoard,
773 TuiAction::PreviousBoard,
774 TuiAction::Open,
775 TuiAction::Edit,
776 TuiAction::View,
777 TuiAction::Agent,
778 TuiAction::ToggleColumn,
779 TuiAction::FilterModal,
780 TuiAction::BulkModal,
781 TuiAction::Triage,
782 TuiAction::OpenMentions,
787 TuiAction::Views,
789 TuiAction::Mark,
790 TuiAction::Unmark,
791 TuiAction::ClearMarks,
792 TuiAction::MovePrevious,
793 TuiAction::MoveNext,
794 TuiAction::ScrollUp,
798 TuiAction::ScrollDown,
799 TuiAction::NewIssue,
800 TuiAction::CloneIssue,
805 TuiAction::ToggleArchived,
806 TuiAction::ToggleChildren,
810 TuiAction::Delete,
811 TuiAction::Refresh,
812 TuiAction::Settings,
813 TuiAction::Planning,
814 TuiAction::NewBoard,
815 TuiAction::Help,
816 TuiAction::Quit,
817 TuiAction::TextFilter,
818 TuiAction::ReorderUp,
819 TuiAction::ReorderDown,
820 ],
821 InputContext::Detail => &[
822 TuiAction::Close,
825 TuiAction::Edit,
826 TuiAction::View,
827 TuiAction::ScrollUp,
828 TuiAction::ScrollDown,
829 TuiAction::ScrollLineDown,
830 TuiAction::ScrollLineUp,
831 TuiAction::ScrollTop,
832 TuiAction::ScrollBottom,
833 TuiAction::Help,
834 TuiAction::Comment,
835 TuiAction::Asset,
836 TuiAction::NewRelationship,
837 TuiAction::NextRelationship,
838 TuiAction::PreviousRelationship,
839 TuiAction::EditRelationship,
840 TuiAction::RemoveRelationship,
841 TuiAction::EditParent,
843 TuiAction::HealthForm,
846 TuiAction::RelatedPicker,
850 TuiAction::FollowRelated,
851 TuiAction::NavBack,
852 ],
853 InputContext::RelatedPicker => &[
854 TuiAction::Cancel,
855 TuiAction::Next,
856 TuiAction::Previous,
857 TuiAction::Submit,
858 ],
859 InputContext::Rollover => &[TuiAction::Submit, TuiAction::Previous, TuiAction::Cancel],
860 InputContext::Form | InputContext::PlanningForm => &[
861 TuiAction::Submit,
862 TuiAction::Cancel,
863 TuiAction::Next,
864 TuiAction::Previous,
865 TuiAction::OpenPicker,
866 ],
867 InputContext::FormPicker => &[
868 TuiAction::Cancel,
869 TuiAction::Next,
870 TuiAction::Previous,
871 TuiAction::Submit,
872 TuiAction::Create,
873 ],
874 InputContext::ConfirmDelete
875 | InputContext::PlanningConfirm
876 | InputContext::ConfirmBlockedMove => &[TuiAction::Confirm, TuiAction::Cancel],
877 InputContext::MoveIntoDone
878 | InputContext::Filter
879 | InputContext::Comment
880 | InputContext::Asset
881 | InputContext::LinkPrompt => &[TuiAction::Submit, TuiAction::Cancel],
882 InputContext::Help => &[
883 TuiAction::Cancel,
884 TuiAction::Next,
885 TuiAction::Previous,
886 TuiAction::TextFilter,
888 TuiAction::ScrollUp,
892 TuiAction::ScrollDown,
893 TuiAction::Submit,
894 ],
895 InputContext::ContextMenu => &[
896 TuiAction::Cancel,
897 TuiAction::Next,
898 TuiAction::Previous,
899 TuiAction::Submit,
900 TuiAction::ScrollUp,
901 TuiAction::ScrollDown,
902 ],
903 InputContext::HelpSearch => &[TuiAction::Cancel, TuiAction::Submit],
905 InputContext::BulkModal => &[
906 TuiAction::Next,
907 TuiAction::Previous,
908 TuiAction::Submit,
909 TuiAction::Cancel,
910 ],
911 InputContext::Triage => &[
917 TuiAction::MoveUp,
918 TuiAction::MoveDown,
919 TuiAction::Toggle,
920 TuiAction::Submit,
921 TuiAction::Cancel,
922 ],
923 InputContext::Cycle => &[
924 TuiAction::MoveUp,
925 TuiAction::MoveDown,
926 TuiAction::MoveLeft,
927 TuiAction::MoveRight,
928 TuiAction::Planning,
929 TuiAction::Help,
930 TuiAction::Close,
931 ],
932 InputContext::FilterModal => &[
933 TuiAction::Next,
934 TuiAction::Previous,
935 TuiAction::Reset,
936 TuiAction::Submit,
937 TuiAction::Cancel,
938 TuiAction::OpenPicker,
939 ],
940 InputContext::Milestones => &[
943 TuiAction::MoveUp,
944 TuiAction::MoveDown,
945 TuiAction::Submit,
946 TuiAction::NewPlanningItem,
947 TuiAction::EditPlanningItem,
948 TuiAction::DeletePlanningItem,
949 TuiAction::Planning,
950 TuiAction::Help,
951 TuiAction::Close,
952 ],
953 InputContext::Stats => &[
959 TuiAction::MoveUp,
960 TuiAction::MoveDown,
961 TuiAction::Next,
962 TuiAction::Previous,
963 TuiAction::Submit,
964 TuiAction::TimeAdd,
965 TuiAction::TimeEdit,
966 TuiAction::TimeDelete,
967 TuiAction::TimeFilter,
968 TuiAction::TimeBreakdown,
969 TuiAction::TimeHistory,
970 TuiAction::Help,
971 TuiAction::Close,
972 ],
973 InputContext::Repos => &[
976 TuiAction::MoveUp,
977 TuiAction::MoveDown,
978 TuiAction::Submit,
979 TuiAction::Help,
980 TuiAction::Close,
981 ],
982 InputContext::Schedule => &[
985 TuiAction::MoveUp,
986 TuiAction::MoveDown,
987 TuiAction::SeriesNew,
988 TuiAction::SeriesGenerate,
989 TuiAction::SeriesPause,
990 TuiAction::SeriesResume,
991 TuiAction::SeriesEnd,
992 TuiAction::SeriesSkip,
993 TuiAction::SeriesDefer,
994 TuiAction::SeriesIgnore,
995 TuiAction::Help,
996 TuiAction::Close,
997 ],
998 InputContext::Library => &[
1001 TuiAction::Next,
1002 TuiAction::Previous,
1003 TuiAction::MoveUp,
1004 TuiAction::MoveDown,
1005 TuiAction::Submit,
1006 TuiAction::Help,
1007 TuiAction::Close,
1008 ],
1009 InputContext::Portfolio => &[
1014 TuiAction::Next,
1015 TuiAction::Previous,
1016 TuiAction::MoveUp,
1017 TuiAction::MoveDown,
1018 TuiAction::MoveLeft,
1019 TuiAction::MoveRight,
1020 TuiAction::Submit,
1021 TuiAction::Help,
1022 TuiAction::Close,
1023 ],
1024 InputContext::Views => &[
1028 TuiAction::MoveUp,
1029 TuiAction::MoveDown,
1030 TuiAction::ApplyView,
1031 TuiAction::NewView,
1032 TuiAction::RenameView,
1033 TuiAction::UpdateView,
1034 TuiAction::DuplicateView,
1035 TuiAction::DeleteView,
1036 TuiAction::Close,
1037 ],
1038 InputContext::ViewsPrompt => &[TuiAction::Submit, TuiAction::Cancel],
1039 InputContext::Settings => &[
1040 TuiAction::MoveUp,
1041 TuiAction::MoveDown,
1042 TuiAction::Toggle,
1043 TuiAction::AddColumn,
1044 TuiAction::MoveColumnUp,
1045 TuiAction::MoveColumnDown,
1046 TuiAction::Submit,
1047 TuiAction::NewBoard,
1048 TuiAction::EditPlanningItem,
1049 TuiAction::Delete,
1050 TuiAction::NextBoard,
1051 TuiAction::PreviousBoard,
1052 TuiAction::MoveLeft,
1053 TuiAction::MoveRight,
1054 TuiAction::Close,
1055 ],
1056 InputContext::SettingsPrompt => &[TuiAction::Submit, TuiAction::Cancel],
1057 InputContext::PlanningManagement => &[
1058 TuiAction::MoveUp,
1059 TuiAction::MoveDown,
1060 TuiAction::MoveLeft,
1061 TuiAction::MoveRight,
1062 TuiAction::Next,
1063 TuiAction::NewPlanningItem,
1064 TuiAction::EditPlanningItem,
1065 TuiAction::DeletePlanningItem,
1066 TuiAction::Close,
1067 ],
1068 InputContext::DatePicker => &[
1069 TuiAction::MoveUp,
1070 TuiAction::MoveDown,
1071 TuiAction::MoveLeft,
1072 TuiAction::MoveRight,
1073 TuiAction::PreviousMonth,
1074 TuiAction::NextMonth,
1075 TuiAction::PreviousYear,
1076 TuiAction::NextYear,
1077 TuiAction::Submit,
1078 TuiAction::Reset,
1079 TuiAction::Cancel,
1080 ],
1081 InputContext::EditRepair => &[
1089 TuiAction::Confirm,
1090 TuiAction::DeclareKey,
1091 TuiAction::Reedit,
1092 TuiAction::Ignore,
1093 TuiAction::Discard,
1094 ],
1095 InputContext::EditConflict => &[TuiAction::Reedit, TuiAction::Ignore, TuiAction::Confirm],
1099 InputContext::Shell => &[
1100 TuiAction::SwitchToBoard,
1101 TuiAction::SwitchToTriage,
1102 TuiAction::SwitchToCycle,
1103 TuiAction::SwitchToMilestones,
1104 TuiAction::SwitchToRepos,
1105 TuiAction::SwitchToStats,
1106 TuiAction::SwitchToSchedule,
1107 TuiAction::SwitchToLibrary,
1108 TuiAction::SwitchToPortfolio,
1109 TuiAction::SwitchToNewIssue,
1110 TuiAction::SwitchToConfig,
1111 TuiAction::ToggleNavigation,
1112 ],
1113 }
1114}
1115
1116fn truncate(value: &str, max_chars: usize) -> String {
1117 if max_chars == 0 || value.chars().count() <= max_chars {
1118 return value.to_string();
1119 }
1120 let take = max_chars.saturating_sub(1);
1121 let head: String = value.chars().take(take).collect();
1122 format!("{}…", head.trim_end())
1123}
1124
1125#[cfg(test)]
1126mod tests {
1127 #[allow(clippy::wildcard_imports)]
1128 use super::*;
1129
1130 #[test]
1138 fn every_bound_action_appears_in_the_overlay() {
1139 let bindings =
1140 KeyBindings::resolve(&KeyBindings::default()).expect("built-in bindings are valid");
1141 let mut missing: Vec<String> = Vec::new();
1142 for context in HELP_CONTEXTS {
1143 let listed = all_actions(context);
1144 for action in bindings.bound_actions(context) {
1145 if !listed.contains(&action) {
1146 missing.push(format!("{} — {}", context.as_str(), action.as_str()));
1147 }
1148 }
1149 }
1150 assert!(
1151 missing.is_empty(),
1152 "the overlay does not list these bound actions, so nothing in the help \
1153 would tell a user they exist:\n {}",
1154 missing.join("\n ")
1155 );
1156 }
1157
1158 #[test]
1161 fn the_overlay_and_its_guard_cover_the_same_contexts() {
1162 let bindings =
1163 KeyBindings::resolve(&KeyBindings::default()).expect("built-in bindings are valid");
1164 let drawn: Vec<(String, String, String)> = TopMode::ALL
1165 .iter()
1166 .flat_map(|mode| help_overlay(&bindings, *mode))
1167 .collect();
1168 for context in HELP_CONTEXTS {
1169 let scope = context.as_str();
1170 assert!(
1171 drawn.iter().any(|(_, _, listed)| listed == scope),
1172 "`{scope}` is in HELP_CONTEXTS but contributes no rows to any mode's overlay"
1173 );
1174 }
1175 }
1176
1177 #[test]
1180 fn help_is_scoped_to_the_active_mode() {
1181 let bindings =
1182 KeyBindings::resolve(&KeyBindings::default()).expect("built-in bindings are valid");
1183 for mode in TopMode::ALL {
1184 let allowed: Vec<&str> = contexts_for_mode(mode).iter().map(|c| c.as_str()).collect();
1185 for (key, description, scope) in help_overlay(&bindings, mode) {
1186 assert!(
1187 allowed.contains(&scope.as_str()),
1188 "{mode:?} help shows `{key} {description}` from out-of-scope `{scope}`"
1189 );
1190 }
1191 }
1192 let cycle = help_overlay(&bindings, TopMode::Cycle);
1195 assert!(
1196 !cycle
1197 .iter()
1198 .any(|(_, description, _)| description == "toggle column"),
1199 "cycle help must not list board bindings: {cycle:?}"
1200 );
1201 let board = help_overlay(&bindings, TopMode::Board);
1203 assert!(
1204 board
1205 .iter()
1206 .any(|(_, description, _)| description == "toggle column")
1207 );
1208 for mode in TopMode::ALL {
1210 let rows = help_overlay(&bindings, mode);
1211 assert!(
1212 rows.iter()
1213 .any(|(_, description, _)| description == "toggle navigation"),
1214 "{mode:?} help must list the navigation control"
1215 );
1216 }
1217 }
1218
1219 #[test]
1220 fn dynamic_hint_uses_effective_aliases() {
1221 let mut overrides = KeyBindings::default();
1222 overrides.board.get_or_insert_default().new_issue =
1223 Some(vec!["ctrl+n".into(), "f4".into()]);
1224 overrides.board.get_or_insert_default().help = Some(vec!["f1".into()]);
1225 let bindings = KeyBindings::resolve(&overrides).unwrap();
1226 let hint = hint_bar_for(&bindings, InputContext::Board);
1227 assert!(hint.contains("Ctrl+n / F4 new issue"), "got {hint:?}");
1228 assert!(hint.contains("F1 help"), "got {hint:?}");
1229 }
1230
1231 #[test]
1233 fn hints_follow_the_active_mode() {
1234 let bindings =
1235 KeyBindings::resolve(&KeyBindings::default()).expect("built-in bindings are valid");
1236 let board = hint_bar_for(&bindings, hint_context_for_mode(TopMode::Board));
1237 let triage = hint_bar_for(&bindings, hint_context_for_mode(TopMode::Triage));
1238 let config = hint_bar_for(&bindings, hint_context_for_mode(TopMode::Config));
1239 assert!(board.contains("new issue"), "got {board:?}");
1240 assert!(
1241 triage.contains("toggle") && !triage.contains("new issue"),
1242 "triage hint should describe triage, got {triage:?}"
1243 );
1244 assert!(
1245 config.contains("add column") && !config.contains("new issue"),
1246 "config hint should describe settings, got {config:?}"
1247 );
1248 }
1249
1250 #[test]
1251 fn help_covers_every_configured_action() {
1252 let bindings = KeyBindings::resolve(&KeyBindings::default()).unwrap();
1253 let rows: Vec<(String, String, String)> = TopMode::ALL
1254 .iter()
1255 .flat_map(|mode| help_overlay(&bindings, *mode))
1256 .collect();
1257 for context in InputContext::ALL {
1258 for action in all_actions(*context) {
1259 assert!(
1260 rows.iter().any(|(_, description, mode)| description
1261 == action.description_in(*context)
1262 && mode == context.as_str()),
1263 "{} / {} is not listed by any mode's help",
1264 context.as_str(),
1265 action.as_str()
1266 );
1267 }
1268 }
1269 }
1270}