Skip to main content

tix/ui/
commandbar.rs

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            // #1359: the three mutating relationship operations and the cursor that
110            // names which edge `E` and `d` act on. A binding that exists but is not
111            // in the footer is a binding nobody finds.
112            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
167/// The contexts the keybindings overlay documents, in reading order.
168///
169/// One list, shared with the completeness guard in this module's tests: a context
170/// added to the overlay but not to the guard (or the reverse) is how a whole surface
171/// becomes invisible in the help without anyone noticing.
172const 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    // WP04: the blocked-move prompt's keys belong in the help overlay too, or
181    // a user who never guessed `y` has no way to learn it.
182    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    // WP07: the batch surface's keys, for the same reason WP04 put the
199    // blocked-move prompt's there — a key nobody can discover is a key nobody
200    // uses, and the batch surface is entirely keyboard-driven.
201    InputContext::BulkModal,
202    // WP06: the triage inbox's keys, same reason. The `t` that opens it is
203    // listed by the `board` rows below.
204    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            // The page keys (#1442): bound on the board, and until this guard existed
263            // they were the only way to reach a card the column had scrolled past —
264            // documented nowhere.
265            TuiAction::ScrollUp,
266            TuiAction::ScrollDown,
267            TuiAction::NewIssue,
268            // The two board keys added after this list was written: the overlay's list
269            // is hand-written, so a binding it does not name is a key that works and
270            // cannot be discovered. `every_bound_action_appears_in_the_overlay` is the
271            // guard, and it is what found these.
272            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            // The pane's own list, not the footer's: `close` is how you get back, and a
287            // key that returns you is not one to leave out of the reference.
288            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            // #1496: the search key, so the overlay names the way to search it.
329            TuiAction::TextFilter,
330            // #1441/#1442: the overlay's page keys. These are the overlay's *rows*, not
331            // a footer — the footer for this context is the hint bar `render_help` draws
332            // underneath, which is the board's or the pane's.
333            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        // Free text: `Esc` leaves the line (and clears it), `Enter` keeps the query.
344        InputContext::HelpSearch => &[TuiAction::Cancel, TuiAction::Submit],
345        InputContext::BulkModal => &[
346            TuiAction::Next,
347            TuiAction::Previous,
348            TuiAction::Submit,
349            TuiAction::Cancel,
350        ],
351        // WP06: only what the context actually binds. The inbox's decision keys
352        // (`a`, `d`, `u`, `f`, `p`, `y`) are deliberately unbound so they stay
353        // typeable in a reason field, so they cannot appear here as chords —
354        // they are named by the modal's own footer instead, which is how the
355        // batch surface names its unbound keys too.
356        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    /// Every action a documented context binds must appear in the overlay.
423    ///
424    /// `all_actions` is written by hand per context, so a binding can be added to the
425    /// keymap and never appear in the help — the worst of both worlds, because the key
426    /// works and nobody can discover it. It had already happened twice before this
427    /// guard existed (`clone_issue` and `toggle_archived`, both added while this list
428    /// was not).
429    #[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    /// The guard is only as good as its context list: a context the overlay documents
451    /// but the guard does not walk is a surface whose keys can go missing silently.
452    #[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}