Skip to main content

wt/tui/
hints.rs

1//! Single source of truth for the modal status-bar / overlay key hints (issue
2//! #39). Each modal mode declares its keys exactly once here; [`view`] renders
3//! both the bottom status bar and the in-overlay hint rows from these tables,
4//! and the consistency test below drives every hinted key through the real
5//! handler — so a hint can never claim a key the handler ignores, nor drift
6//! from it during a refactor.
7//!
8//! The rebindable List-mode shortcuts are deliberately NOT here: they derive
9//! straight from the [`Keymap`](crate::keys::Keymap) plus
10//! [`KeyAction::label`](crate::keys::KeyAction::label), which is their own
11//! single source of truth.
12//!
13//! [`view`]: crate::tui::view
14
15/// One status-bar / overlay hint: the on-screen key text and what it does.
16#[derive(Debug, Clone, Copy, PartialEq, Eq)]
17pub struct Hint {
18    /// The key as shown to the user (e.g. `Enter`, `Ctrl-S`, `↑/↓`).
19    pub key: &'static str,
20    /// The action label (e.g. `submit`).
21    pub label: &'static str,
22}
23
24/// Terse constructor for the static tables below.
25const fn hint(key: &'static str, label: &'static str) -> Hint {
26    Hint { key, label }
27}
28
29/// Filter-mode hints (typing narrows the list; `↑/↓` move within it).
30pub fn filter_hints() -> &'static [Hint] {
31    const HINTS: &[Hint] = &[
32        hint("type", "to filter"),
33        hint("↑/↓", "move"),
34        hint("Backspace", "delete"),
35        hint("Enter", "apply"),
36        hint("Esc", "clear"),
37    ];
38    HINTS
39}
40
41/// Create-worktree prompt hints.
42pub fn create_hints() -> &'static [Hint] {
43    const HINTS: &[Hint] = &[
44        hint("↑/↓", "options"),
45        hint("Enter", "next / submit"),
46        hint("Esc", "cancel"),
47    ];
48    HINTS
49}
50
51/// PR-picker hints.
52pub fn pr_picker_hints() -> &'static [Hint] {
53    const HINTS: &[Hint] = &[
54        hint("↑/↓", "select"),
55        hint("Enter", "checkout"),
56        hint("Esc", "close"),
57    ];
58    HINTS
59}
60
61/// PR-compose AI auto-fill controls (the first overlay hint row).
62pub fn compose_ai_hints() -> &'static [Hint] {
63    const HINTS: &[Hint] = &[
64        hint("Ctrl-A", "AI fill"),
65        hint("Ctrl-M", "model"),
66        hint("Ctrl-E", "effort"),
67        hint("↑/↓", "pick"),
68    ];
69    HINTS
70}
71
72/// PR-compose editing controls (the second overlay hint row, and the status
73/// bar). `Enter` advances the field (or inserts a newline in the body).
74pub fn compose_edit_hints() -> &'static [Hint] {
75    const HINTS: &[Hint] = &[
76        hint("Ctrl-S", "submit"),
77        hint("Ctrl-D", "draft"),
78        hint("Tab", "field"),
79        hint("Shift+Tab", "prev field"),
80        hint("Enter", "advance"),
81        hint("Esc", "cancel"),
82    ];
83    HINTS
84}
85
86/// Checkout branch-picker hints.
87pub fn checkout_hints() -> &'static [Hint] {
88    const HINTS: &[Hint] = &[
89        hint("↑/↓", "branches"),
90        hint("Enter", "checkout"),
91        hint("Esc", "cancel"),
92    ];
93    HINTS
94}
95
96/// Confirm-remove dialog hints (any non-`y` key cancels; `Esc` is the prompt).
97pub fn confirm_hints() -> &'static [Hint] {
98    const HINTS: &[Hint] = &[hint("y", "remove"), hint("Esc", "cancel")];
99    HINTS
100}
101
102/// Confirm-create dialog hints: `y` creates a worktree for the branch row and
103/// switches into it; any other key cancels (issue #47).
104pub fn confirm_create_hints() -> &'static [Hint] {
105    const HINTS: &[Hint] = &[hint("y", "create & switch"), hint("Esc", "cancel")];
106    HINTS
107}
108
109/// Confirm-delete-branch dialog hints: `y` deletes the branch row's local branch;
110/// any other key cancels (issue #53).
111pub fn confirm_delete_branch_hints() -> &'static [Hint] {
112    const HINTS: &[Hint] = &[hint("y", "delete"), hint("Esc", "cancel")];
113    HINTS
114}
115
116/// Confirm-stale-base dialog hints (issue #56): `u` updates the base, `p`
117/// proceeds off it as-is, any other key cancels.
118pub fn confirm_stale_base_hints() -> &'static [Hint] {
119    const HINTS: &[Hint] = &[
120        hint("u", "update"),
121        hint("p", "proceed"),
122        hint("Esc", "cancel"),
123    ];
124    HINTS
125}
126
127/// Confirm-init-submodules dialog hints (issue #50): `Enter`/`y` (the default)
128/// initializes recursively, `n`/`Esc` leaves them uninitialized.
129pub fn confirm_init_submodules_hints() -> &'static [Hint] {
130    const HINTS: &[Hint] = &[hint("Enter/y", "initialize"), hint("n/Esc", "skip")];
131    HINTS
132}
133
134/// Confirm-quit dialog hints (issue #46 overhaul): `y` quits and abandons the
135/// running background jobs, any other key cancels.
136pub fn confirm_quit_hints() -> &'static [Hint] {
137    const HINTS: &[Hint] = &[hint("y", "quit anyway"), hint("Esc", "cancel")];
138    HINTS
139}
140
141/// Help-overlay hints.
142pub fn help_hints() -> &'static [Hint] {
143    const HINTS: &[Hint] = &[hint("any key", "close")];
144    HINTS
145}
146
147/// Formats a hint slice into an overlay row, e.g.
148/// `↑/↓: options   Enter: next / submit   Esc: cancel`.
149pub fn format_hint_row(hints: &[Hint]) -> String {
150    hints
151        .iter()
152        .map(|h| format!("{}: {}", h.key, h.label))
153        .collect::<Vec<_>>()
154        .join("   ")
155}
156
157#[cfg(test)]
158mod tests {
159    use super::*;
160    use crate::tui::app::testutil::app;
161    use crate::tui::app::{
162        App, CheckoutState, ComposeField, CreateState, CreateStep, Mode, PrComposeState, PrItem,
163        PrPickerState, StaleBaseState,
164    };
165    use crate::tui::event::Effect;
166    use crate::tui::options::OptionList;
167    use crossterm::event::{Event, KeyCode, KeyEvent, KeyModifiers};
168
169    #[test]
170    fn format_hint_row_joins_key_and_label() {
171        let row = format_hint_row(&[hint("↑/↓", "options"), hint("Esc", "cancel")]);
172        assert_eq!(row, "↑/↓: options   Esc: cancel");
173    }
174
175    #[test]
176    fn every_hint_is_well_formed() {
177        let tables = [
178            filter_hints(),
179            create_hints(),
180            pr_picker_hints(),
181            compose_ai_hints(),
182            compose_edit_hints(),
183            checkout_hints(),
184            confirm_hints(),
185            confirm_create_hints(),
186            confirm_delete_branch_hints(),
187            confirm_stale_base_hints(),
188            help_hints(),
189        ];
190        for table in tables {
191            for h in table {
192                assert!(!h.key.is_empty(), "empty key in {table:?}");
193                assert!(!h.label.is_empty(), "empty label for {:?}", h.key);
194            }
195        }
196    }
197
198    /// Parses a displayed hint key back into the event a user would press. For a
199    /// multi-key hint (`↑/↓`) it returns the first key. Panics on an unknown key
200    /// so a newly added hint must be taught here too.
201    fn key_event(key: &str) -> KeyEvent {
202        let first = key.split('/').next().unwrap_or(key);
203        let (code, mods) = match first {
204            "type" | "any key" => (KeyCode::Char('x'), KeyModifiers::empty()),
205            "↑" => (KeyCode::Up, KeyModifiers::empty()),
206            "↓" => (KeyCode::Down, KeyModifiers::empty()),
207            "Enter" => (KeyCode::Enter, KeyModifiers::empty()),
208            "Esc" => (KeyCode::Esc, KeyModifiers::empty()),
209            "Tab" => (KeyCode::Tab, KeyModifiers::empty()),
210            "Shift+Tab" => (KeyCode::BackTab, KeyModifiers::empty()),
211            "Backspace" => (KeyCode::Backspace, KeyModifiers::empty()),
212            "y" => (KeyCode::Char('y'), KeyModifiers::empty()),
213            "u" => (KeyCode::Char('u'), KeyModifiers::empty()),
214            "p" => (KeyCode::Char('p'), KeyModifiers::empty()),
215            "Ctrl-A" => (KeyCode::Char('a'), KeyModifiers::CONTROL),
216            "Ctrl-S" => (KeyCode::Char('s'), KeyModifiers::CONTROL),
217            "Ctrl-D" => (KeyCode::Char('d'), KeyModifiers::CONTROL),
218            "Ctrl-M" => (KeyCode::Char('m'), KeyModifiers::CONTROL),
219            "Ctrl-E" => (KeyCode::Char('e'), KeyModifiers::CONTROL),
220            other => panic!("unrecognized hint key {other:?}; teach key_event()"),
221        };
222        KeyEvent::new(code, mods)
223    }
224
225    fn pr(number: u64) -> PrItem {
226        PrItem {
227            number,
228            title: format!("pr {number}"),
229            author: "x".into(),
230            state: "OPEN".into(),
231            created_at: "2024-01-15T10:30:00Z".into(),
232        }
233    }
234
235    fn options(items: &[&str]) -> OptionList {
236        let mut ol = OptionList::new(items.iter().map(|s| (*s).into()).collect());
237        ol.open();
238        ol
239    }
240
241    /// Builds an app in the named mode, arranged so every key in that mode's
242    /// hint table is active (dropdowns open, fields populated, the selection off
243    /// the top edge so `↑` has somewhere to go).
244    fn arranged(mode_kind: &str) -> App {
245        let mut a = app(&[("alpha", true), ("alpine", false), ("beta", false)]);
246        match mode_kind {
247            "filter" => {
248                a.filter = "al".into();
249                a.selected = 1;
250                a.mode = Mode::Filter;
251            }
252            "create" => {
253                a.mode = Mode::Create(CreateState {
254                    step: CreateStep::Branch,
255                    branch: "fe".into(),
256                    options: options(&["main", "master"]),
257                    ..Default::default()
258                });
259            }
260            "pr_picker" => {
261                a.mode = Mode::PrPicker(PrPickerState {
262                    prs: vec![pr(1), pr(2)],
263                    selected: 1,
264                    ..Default::default()
265                });
266            }
267            "compose" => {
268                a.mode = Mode::PrCompose(PrComposeState {
269                    field: ComposeField::Model,
270                    title: "hi".into(),
271                    ..Default::default()
272                });
273            }
274            "checkout" => {
275                a.mode = Mode::Checkout(CheckoutState {
276                    worktree_index: 0,
277                    query: "m".into(),
278                    options: options(&["main", "master"]),
279                    ..Default::default()
280                });
281            }
282            "confirm" => a.mode = Mode::ConfirmRemove(0),
283            "confirm_create" => a.mode = Mode::ConfirmCreate(0),
284            "confirm_delete_branch" => {
285                a.mode = Mode::ConfirmDeleteBranch {
286                    index: 0,
287                    force: false,
288                }
289            }
290            "confirm_stale_base" => {
291                a.mode = Mode::ConfirmStaleBase(StaleBaseState {
292                    branch: "feature".into(),
293                    base: Some("main".into()),
294                    behind: 1,
295                    upstream_display: "origin/main".into(),
296                    can_fast_forward: true,
297                })
298            }
299            "help" => a.mode = Mode::Help,
300            other => panic!("unknown mode {other}"),
301        }
302        a
303    }
304
305    /// A fingerprint of the parts of the app a modal handler can change.
306    fn fingerprint(a: &App) -> String {
307        format!("{:?}|{}|{}", a.mode, a.filter, a.selected)
308    }
309
310    /// Asserts every hint in `hints`, pressed in a freshly `arranged` app, is
311    /// handled — state changes or a non-`None` effect. This is the anti-drift
312    /// guard: a hint for a key the handler ignores fails here.
313    fn assert_hints_live(mode_kind: &str, hints: &[Hint]) {
314        for h in hints {
315            let mut a = arranged(mode_kind);
316            let before = fingerprint(&a);
317            let effect = a.handle_event(Event::Key(key_event(h.key)));
318            let after = fingerprint(&a);
319            assert!(
320                effect != Effect::None || before != after,
321                "{mode_kind} hint {:?} ({}) was ignored by the handler",
322                h.key,
323                h.label,
324            );
325        }
326    }
327
328    #[test]
329    fn modal_hints_drive_real_handlers() {
330        assert_hints_live("filter", filter_hints());
331        assert_hints_live("create", create_hints());
332        assert_hints_live("pr_picker", pr_picker_hints());
333        assert_hints_live("compose", compose_ai_hints());
334        assert_hints_live("compose", compose_edit_hints());
335        assert_hints_live("checkout", checkout_hints());
336        assert_hints_live("confirm", confirm_hints());
337        assert_hints_live("confirm_create", confirm_create_hints());
338        assert_hints_live("confirm_delete_branch", confirm_delete_branch_hints());
339        assert_hints_live("confirm_stale_base", confirm_stale_base_hints());
340        assert_hints_live("help", help_hints());
341    }
342}