1use std::fmt::Write;
10
11pub const MODE_BOARD: &str = "board";
14pub const MODE_DETAIL: &str = "detail";
15pub const MODE_HELP: &str = "help";
16pub const MODE_COMMENT: &str = "comment";
17pub const MODE_ASSET: &str = "asset";
18pub const MODE_FILTER: &str = "filter";
19
20pub struct Cmd {
23 pub key: &'static str,
24 pub desc: &'static str,
25 pub modes: &'static [&'static str],
26 pub mode: &'static str,
27}
28
29pub const COMMANDS: &[Cmd] = &[
33 Cmd {
34 key: "j/k",
35 desc: "nav",
36 modes: &[MODE_BOARD],
37 mode: MODE_BOARD,
38 },
39 Cmd {
40 key: "n",
41 desc: "new",
42 modes: &[MODE_BOARD],
43 mode: MODE_BOARD,
44 },
45 Cmd {
46 key: "?",
47 desc: "help",
48 modes: &[MODE_BOARD, MODE_DETAIL],
49 mode: "board/detail",
50 },
51 Cmd {
52 key: "h/l",
53 desc: "column",
54 modes: &[MODE_BOARD],
55 mode: MODE_BOARD,
56 },
57 Cmd {
58 key: "enter",
59 desc: "open",
60 modes: &[MODE_BOARD],
61 mode: MODE_BOARD,
62 },
63 Cmd {
64 key: "d",
65 desc: "delete",
66 modes: &[MODE_BOARD],
67 mode: MODE_BOARD,
68 },
69 Cmd {
70 key: "< / >",
71 desc: "move",
72 modes: &[MODE_BOARD],
73 mode: MODE_BOARD,
74 },
75 Cmd {
76 key: "r",
77 desc: "refresh",
78 modes: &[MODE_BOARD],
79 mode: MODE_BOARD,
80 },
81 Cmd {
82 key: "/",
83 desc: "filter",
84 modes: &[MODE_BOARD],
85 mode: MODE_BOARD,
86 },
87 Cmd {
88 key: "f",
89 desc: "criteria",
90 modes: &[MODE_BOARD],
91 mode: MODE_BOARD,
92 },
93 Cmd {
94 key: "Tab",
95 desc: "switch",
96 modes: &[MODE_BOARD],
97 mode: MODE_BOARD,
98 },
99 Cmd {
100 key: "c",
101 desc: "toggle",
102 modes: &[MODE_BOARD],
103 mode: MODE_BOARD,
104 },
105 Cmd {
106 key: "s",
107 desc: "settings",
108 modes: &[MODE_BOARD],
109 mode: MODE_BOARD,
110 },
111 Cmd {
112 key: "b",
113 desc: "new board",
114 modes: &[MODE_BOARD],
115 mode: MODE_BOARD,
116 },
117 Cmd {
118 key: "e",
119 desc: "edit",
120 modes: &[MODE_BOARD, MODE_DETAIL],
121 mode: "board/detail",
122 },
123 Cmd {
124 key: "q",
125 desc: "quit",
126 modes: &[MODE_BOARD],
127 mode: MODE_BOARD,
128 },
129 Cmd {
130 key: "c",
131 desc: "comment",
132 modes: &[MODE_DETAIL],
133 mode: MODE_DETAIL,
134 },
135 Cmd {
136 key: "a",
137 desc: "asset",
138 modes: &[MODE_DETAIL],
139 mode: MODE_DETAIL,
140 },
141 Cmd {
142 key: "pgu/pgd",
143 desc: "scroll",
144 modes: &[MODE_DETAIL],
145 mode: MODE_DETAIL,
146 },
147];
148
149const MAX_HINT_CHARS: usize = 80;
151
152pub fn hint_bar(active_modes: &[&str]) -> String {
157 let mut text = String::new();
158 for cmd in COMMANDS {
159 if !cmd.modes.iter().any(|m| active_modes.contains(m)) {
160 continue;
161 }
162 let _ = write!(text, "{} {} · ", cmd.key, cmd.desc);
163 }
164 let joined = text.trim_end_matches(" · ");
166 truncate(joined, MAX_HINT_CHARS)
167}
168
169pub fn help_overlay_list() -> Vec<(&'static str, &'static str, &'static str)> {
172 COMMANDS
173 .iter()
174 .map(|cmd| (cmd.key, cmd.desc, cmd.mode))
175 .collect()
176}
177
178fn truncate(s: &str, max_chars: usize) -> String {
179 if max_chars == 0 || s.chars().count() <= max_chars {
180 return s.to_string();
181 }
182 let take = max_chars.saturating_sub(1);
184 let head: String = s.chars().take(take).collect();
185 format!("{}…", head.trim_end())
186}
187
188#[cfg(test)]
189mod tests {
190 use super::*;
191
192 #[test]
193 fn hint_bar_contains_help_binding() {
194 let bar = hint_bar(&[MODE_BOARD]);
195 assert!(bar.contains("? help"), "got {bar:?}");
196 assert!(bar.contains("j/k nav"), "got {bar:?}");
197 }
198
199 #[test]
200 fn hint_bar_filters_by_active_mode() {
201 let board_bar = hint_bar(&[MODE_BOARD]);
202 assert!(board_bar.contains("n new"));
203 assert!(!board_bar.contains("c comment"), "got {board_bar:?}");
204
205 let detail_bar = hint_bar(&[MODE_DETAIL]);
206 assert!(detail_bar.contains("c comment"));
207 assert!(detail_bar.contains("a asset"));
208 assert!(!detail_bar.contains("n new"), "got {detail_bar:?}");
209 }
210
211 #[test]
212 fn hint_bar_truncates_overflow_with_ellipsis() {
213 let bar = hint_bar(&[MODE_BOARD]);
214 assert!(
215 bar.chars().count() <= MAX_HINT_CHARS,
216 "board hint ran {}-wide, max {MAX_HINT_CHARS}",
217 bar.chars().count()
218 );
219 assert!(
220 bar.ends_with('…'),
221 "a populated board hint must overflow and truncate: {bar:?}"
222 );
223 }
224
225 #[test]
226 fn hint_bar_unknown_mode_is_empty() {
227 assert_eq!(hint_bar(&["nope"]), "");
228 }
229
230 #[test]
231 fn overlay_lists_every_registry_entry_exactly_once() {
232 let rows = help_overlay_list();
233 assert_eq!(rows.len(), COMMANDS.len());
234 let mut pairs: Vec<(&str, &str)> = rows.iter().map(|(k, _, m)| (*k, *m)).collect();
235 pairs.sort_unstable();
236 pairs.dedup();
237 assert_eq!(
238 pairs.len(),
239 COMMANDS.len(),
240 "every (key, mode) pair must be unique"
241 );
242 for cmd in COMMANDS {
243 let found = rows.iter().any(|(k, _, m)| *k == cmd.key && *m == cmd.mode);
244 assert!(found, "overlay must list {} ({})", cmd.key, cmd.mode);
245 }
246 }
247}