Skip to main content

tix/ui/
commandbar.rs

1//! Single command registry shared by the persistent bottom hint bar and the
2//! `?` help overlay (FR-009/FR-010, C-003).
3//!
4//! One source of truth: the hint bar renders a compact `key desc` slice
5//! filtered to the currently active [`mode`]s, and the overlay renders the
6//! full list with a mode column. Both derive from [`COMMANDS`], so the two
7//! views can never drift.
8
9use std::fmt::Write;
10
11/// A stable mode-name tag. The registry filters by these string tags; the
12/// runtime [`Mode`](crate::app::Mode) maps each screen to one or more tags.
13pub 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
20/// One bindable action. `modes` selects which screen the hint bar advertises
21/// it on; `mode` is the static label the help overlay prints.
22pub 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
29/// Every bindable action in the TUI. The `?` overlay and the hint bar both
30/// iterate this list, so adding a command here (and one binding in
31/// `app::handle_key`) is all a new action costs.
32pub 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
149/// Sane display width for the single-line hint bar (NFR-007).
150const MAX_HINT_CHARS: usize = 80;
151
152/// The hint-bar text for the given active mode tags: compact `key desc`
153/// entries joined with a separator, truncated to [`MAX_HINT_CHARS`] chars
154/// with an ellipsis when it would overflow (FR-009). Only commands whose
155/// `modes` intersect `active_modes` are shown.
156pub 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    // Strip the trailing separator, then truncate.
165    let joined = text.trim_end_matches(" · ");
166    truncate(joined, MAX_HINT_CHARS)
167}
168
169/// Every overlay row as `(key, desc, mode)` — one per registry entry, in
170/// registry order (FR-010). All rows are `'static` from the constants above.
171pub 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    // Reserve one char for the ellipsis so the total stays within the limit.
183    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}