1use std::fmt::Write;
2
3use crate::keybindings::{EffectiveBindings, InputContext, KeyBindings, TuiAction};
4
5pub const MODE_BOARD: &str = "board";
6pub const MODE_DETAIL: &str = "detail";
7pub const MODE_HELP: &str = "help";
8pub const MODE_COMMENT: &str = "comment";
9pub const MODE_ASSET: &str = "asset";
10pub const MODE_FILTER: &str = "filter";
11
12const MAX_HINT_CHARS: usize = 80;
13
14pub struct Cmd {
15 pub key: &'static str,
16 pub desc: &'static str,
17 pub modes: &'static [&'static str],
18 pub mode: &'static str,
19}
20
21pub const COMMANDS: &[Cmd] = &[
22 Cmd {
23 key: "j/k",
24 desc: "move up/down",
25 modes: &[MODE_BOARD],
26 mode: MODE_BOARD,
27 },
28 Cmd {
29 key: "n",
30 desc: "new issue",
31 modes: &[MODE_BOARD],
32 mode: MODE_BOARD,
33 },
34 Cmd {
35 key: "?",
36 desc: "help",
37 modes: &[MODE_BOARD, MODE_DETAIL],
38 mode: "board/detail",
39 },
40 Cmd {
41 key: "h/l",
42 desc: "column",
43 modes: &[MODE_BOARD],
44 mode: MODE_BOARD,
45 },
46 Cmd {
47 key: "enter",
48 desc: "open",
49 modes: &[MODE_BOARD],
50 mode: MODE_BOARD,
51 },
52 Cmd {
53 key: "d",
54 desc: "delete",
55 modes: &[MODE_BOARD],
56 mode: MODE_BOARD,
57 },
58 Cmd {
59 key: "< / >",
60 desc: "move",
61 modes: &[MODE_BOARD],
62 mode: MODE_BOARD,
63 },
64 Cmd {
65 key: "r",
66 desc: "refresh",
67 modes: &[MODE_BOARD],
68 mode: MODE_BOARD,
69 },
70 Cmd {
71 key: "/",
72 desc: "filter",
73 modes: &[MODE_BOARD],
74 mode: MODE_BOARD,
75 },
76 Cmd {
77 key: "f",
78 desc: "criteria",
79 modes: &[MODE_BOARD],
80 mode: MODE_BOARD,
81 },
82 Cmd {
83 key: "c",
84 desc: "comment",
85 modes: &[MODE_DETAIL],
86 mode: MODE_DETAIL,
87 },
88 Cmd {
89 key: "a",
90 desc: "asset",
91 modes: &[MODE_DETAIL],
92 mode: MODE_DETAIL,
93 },
94];
95
96fn actions(context: InputContext) -> &'static [TuiAction] {
97 match context {
98 InputContext::Board => &[
99 TuiAction::NewIssue,
100 TuiAction::Help,
101 TuiAction::MoveUp,
102 TuiAction::MoveDown,
103 TuiAction::Planning,
104 TuiAction::Quit,
105 ],
106 InputContext::Detail => &[
107 TuiAction::Comment,
108 TuiAction::Asset,
109 TuiAction::NewRelationship,
113 TuiAction::NextRelationship,
114 TuiAction::PreviousRelationship,
115 TuiAction::EditRelationship,
116 TuiAction::RemoveRelationship,
117 TuiAction::Edit,
118 TuiAction::ScrollUp,
119 TuiAction::ScrollDown,
120 TuiAction::Help,
121 ],
122 _ => &[],
123 }
124}
125
126pub fn hint_bar(active_modes: &[&str]) -> String {
127 let bindings =
128 KeyBindings::resolve(&KeyBindings::default()).expect("built-in bindings are valid");
129 for mode in active_modes {
130 if let Some(context) = match *mode {
131 MODE_BOARD => Some(InputContext::Board),
132 MODE_DETAIL => Some(InputContext::Detail),
133 _ => None,
134 } {
135 return hint_bar_for(&bindings, context);
136 }
137 }
138 String::new()
139}
140
141pub fn hint_bar_for(bindings: &EffectiveBindings, context: InputContext) -> String {
142 if context == InputContext::Board
143 && bindings.label(context, TuiAction::MoveUp) == "Up / k"
144 && bindings.label(context, TuiAction::MoveDown) == "Down / j"
145 {
146 return truncate(
147 &format!(
148 "j/k nav · {} new issue · {} help",
149 bindings.label(context, TuiAction::NewIssue),
150 bindings.label(context, TuiAction::Help),
151 ),
152 MAX_HINT_CHARS,
153 );
154 }
155 let mut text = String::new();
156 for action in actions(context) {
157 let _ = write!(
158 text,
159 "{} {} · ",
160 bindings.label(context, *action),
161 action.description()
162 );
163 }
164 truncate(text.trim_end_matches(" · "), MAX_HINT_CHARS)
165}
166
167const HELP_CONTEXTS: [InputContext; 25] = [
173 InputContext::Detail,
174 InputContext::Board,
175 InputContext::Rollover,
176 InputContext::Form,
177 InputContext::FormPicker,
178 InputContext::ConfirmDelete,
179 InputContext::MoveIntoDone,
180 InputContext::ConfirmBlockedMove,
183 InputContext::Help,
184 InputContext::HelpSearch,
185 InputContext::ContextMenu,
186 InputContext::Filter,
187 InputContext::FilterModal,
188 InputContext::Settings,
189 InputContext::SettingsPrompt,
190 InputContext::PlanningManagement,
191 InputContext::PlanningConfirm,
192 InputContext::PlanningForm,
193 InputContext::DatePicker,
194 InputContext::Comment,
195 InputContext::Asset,
196 InputContext::LinkPrompt,
197 InputContext::EditWarning,
198 InputContext::BulkModal,
202 InputContext::Triage,
205];
206
207pub fn help_overlay(bindings: &EffectiveBindings) -> Vec<(String, String, String)> {
208 let mut rows = vec![
209 (
210 "mouse click".to_string(),
211 "click".to_string(),
212 "board".to_string(),
213 ),
214 (
215 "mouse double".to_string(),
216 "double-click".to_string(),
217 "board".to_string(),
218 ),
219 (
220 "mouse right".to_string(),
221 "right-click".to_string(),
222 "board".to_string(),
223 ),
224 (
225 "mouse drag".to_string(),
226 "drag".to_string(),
227 "board".to_string(),
228 ),
229 ];
230 for context in HELP_CONTEXTS {
231 for action in all_actions(context) {
232 rows.push((
233 bindings.label(context, *action),
234 action.description().to_string(),
235 context.as_str().to_string(),
236 ));
237 }
238 }
239 rows
240}
241
242fn all_actions(context: InputContext) -> &'static [TuiAction] {
243 match context {
244 InputContext::Board => &[
245 TuiAction::MoveUp,
246 TuiAction::MoveDown,
247 TuiAction::MoveLeft,
248 TuiAction::MoveRight,
249 TuiAction::NextBoard,
250 TuiAction::PreviousBoard,
251 TuiAction::Open,
252 TuiAction::Edit,
253 TuiAction::Agent,
254 TuiAction::ToggleColumn,
255 TuiAction::FilterModal,
256 TuiAction::BulkModal,
257 TuiAction::Triage,
258 TuiAction::Mark,
259 TuiAction::Unmark,
260 TuiAction::MovePrevious,
261 TuiAction::MoveNext,
262 TuiAction::ScrollUp,
266 TuiAction::ScrollDown,
267 TuiAction::NewIssue,
268 TuiAction::CloneIssue,
273 TuiAction::ToggleArchived,
274 TuiAction::Delete,
275 TuiAction::Refresh,
276 TuiAction::Settings,
277 TuiAction::Planning,
278 TuiAction::NewBoard,
279 TuiAction::Help,
280 TuiAction::Quit,
281 TuiAction::TextFilter,
282 TuiAction::ReorderUp,
283 TuiAction::ReorderDown,
284 ],
285 InputContext::Detail => &[
286 TuiAction::Close,
289 TuiAction::Edit,
290 TuiAction::ScrollUp,
291 TuiAction::ScrollDown,
292 TuiAction::Help,
293 TuiAction::Comment,
294 TuiAction::Asset,
295 TuiAction::NewRelationship,
296 TuiAction::NextRelationship,
297 TuiAction::PreviousRelationship,
298 TuiAction::EditRelationship,
299 TuiAction::RemoveRelationship,
300 ],
301 InputContext::Rollover => &[TuiAction::Submit, TuiAction::Previous, TuiAction::Cancel],
302 InputContext::Form | InputContext::PlanningForm => &[
303 TuiAction::Submit,
304 TuiAction::Cancel,
305 TuiAction::Next,
306 TuiAction::Previous,
307 TuiAction::OpenPicker,
308 ],
309 InputContext::FormPicker => &[
310 TuiAction::Cancel,
311 TuiAction::Next,
312 TuiAction::Previous,
313 TuiAction::Submit,
314 TuiAction::Create,
315 ],
316 InputContext::ConfirmDelete
317 | InputContext::PlanningConfirm
318 | InputContext::ConfirmBlockedMove => &[TuiAction::Confirm, TuiAction::Cancel],
319 InputContext::MoveIntoDone
320 | InputContext::Filter
321 | InputContext::Comment
322 | InputContext::Asset
323 | InputContext::LinkPrompt => &[TuiAction::Submit, TuiAction::Cancel],
324 InputContext::Help => &[
325 TuiAction::Cancel,
326 TuiAction::Next,
327 TuiAction::Previous,
328 TuiAction::TextFilter,
330 TuiAction::ScrollUp,
334 TuiAction::ScrollDown,
335 TuiAction::Submit,
336 ],
337 InputContext::ContextMenu => &[
338 TuiAction::Cancel,
339 TuiAction::Next,
340 TuiAction::Previous,
341 TuiAction::Submit,
342 ],
343 InputContext::HelpSearch => &[TuiAction::Cancel, TuiAction::Submit],
345 InputContext::BulkModal => &[
346 TuiAction::Next,
347 TuiAction::Previous,
348 TuiAction::Submit,
349 TuiAction::Cancel,
350 ],
351 InputContext::Triage => &[
357 TuiAction::MoveUp,
358 TuiAction::MoveDown,
359 TuiAction::Toggle,
360 TuiAction::Submit,
361 TuiAction::Cancel,
362 ],
363 InputContext::FilterModal => &[
364 TuiAction::Next,
365 TuiAction::Previous,
366 TuiAction::Reset,
367 TuiAction::Submit,
368 TuiAction::Cancel,
369 TuiAction::OpenPicker,
370 ],
371 InputContext::Settings => &[
372 TuiAction::MoveUp,
373 TuiAction::MoveDown,
374 TuiAction::Next,
375 TuiAction::Previous,
376 TuiAction::Toggle,
377 TuiAction::AddColumn,
378 TuiAction::NewBoard,
379 TuiAction::Delete,
380 TuiAction::Close,
381 ],
382 InputContext::SettingsPrompt => &[TuiAction::Submit, TuiAction::Cancel],
383 InputContext::PlanningManagement => &[
384 TuiAction::MoveUp,
385 TuiAction::MoveDown,
386 TuiAction::Next,
387 TuiAction::NewPlanningItem,
388 TuiAction::EditPlanningItem,
389 TuiAction::DeletePlanningItem,
390 TuiAction::Close,
391 ],
392 InputContext::DatePicker => &[
393 TuiAction::MoveUp,
394 TuiAction::MoveDown,
395 TuiAction::MoveLeft,
396 TuiAction::MoveRight,
397 TuiAction::PreviousMonth,
398 TuiAction::NextMonth,
399 TuiAction::PreviousYear,
400 TuiAction::NextYear,
401 TuiAction::Submit,
402 TuiAction::Reset,
403 TuiAction::Cancel,
404 ],
405 InputContext::EditWarning => &[TuiAction::Reedit, TuiAction::Ignore],
406 }
407}
408
409fn truncate(value: &str, max_chars: usize) -> String {
410 if max_chars == 0 || value.chars().count() <= max_chars {
411 return value.to_string();
412 }
413 let take = max_chars.saturating_sub(1);
414 let head: String = value.chars().take(take).collect();
415 format!("{}…", head.trim_end())
416}
417
418#[cfg(test)]
419mod tests {
420 use super::*;
421
422 #[test]
430 fn every_bound_action_appears_in_the_overlay() {
431 let bindings =
432 KeyBindings::resolve(&KeyBindings::default()).expect("built-in bindings are valid");
433 let mut missing: Vec<String> = Vec::new();
434 for context in HELP_CONTEXTS {
435 let listed = all_actions(context);
436 for action in bindings.bound_actions(context) {
437 if !listed.contains(&action) {
438 missing.push(format!("{} — {}", context.as_str(), action.as_str()));
439 }
440 }
441 }
442 assert!(
443 missing.is_empty(),
444 "the overlay does not list these bound actions, so nothing in the help \
445 would tell a user they exist:\n {}",
446 missing.join("\n ")
447 );
448 }
449
450 #[test]
453 fn the_overlay_and_its_guard_cover_the_same_contexts() {
454 let bindings =
455 KeyBindings::resolve(&KeyBindings::default()).expect("built-in bindings are valid");
456 let drawn = help_overlay(&bindings);
457 for context in HELP_CONTEXTS {
458 let scope = context.as_str();
459 assert!(
460 drawn.iter().any(|(_, _, listed)| listed == scope),
461 "`{scope}` is in HELP_CONTEXTS but contributes no rows to the overlay"
462 );
463 }
464 }
465
466 #[test]
467 fn dynamic_hint_uses_effective_aliases() {
468 let mut overrides = KeyBindings::default();
469 overrides.board.new_issue = Some(vec!["ctrl+n".into(), "f4".into()]);
470 overrides.board.help = Some(vec!["f1".into()]);
471 let bindings = KeyBindings::resolve(&overrides).unwrap();
472 let hint = hint_bar_for(&bindings, InputContext::Board);
473 assert!(hint.contains("Ctrl+n / F4 new issue"), "got {hint:?}");
474 assert!(hint.contains("F1 help"), "got {hint:?}");
475 }
476
477 #[test]
478 fn help_covers_every_configured_action() {
479 let bindings = KeyBindings::resolve(&KeyBindings::default()).unwrap();
480 let rows = help_overlay(&bindings);
481 for context in InputContext::ALL {
482 for action in all_actions(*context) {
483 assert!(
484 rows.iter()
485 .any(|(_, description, mode)| description == action.description()
486 && mode == context.as_str())
487 );
488 }
489 }
490 }
491}