1#[derive(Debug, Clone, Copy, PartialEq, Eq)]
17pub struct Hint {
18 pub key: &'static str,
20 pub label: &'static str,
22}
23
24const fn hint(key: &'static str, label: &'static str) -> Hint {
26 Hint { key, label }
27}
28
29pub 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
41pub 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
51pub 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
61pub 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
72pub 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
86pub 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
96pub fn confirm_hints() -> &'static [Hint] {
98 const HINTS: &[Hint] = &[hint("y", "remove"), hint("Esc", "cancel")];
99 HINTS
100}
101
102pub fn confirm_create_hints() -> &'static [Hint] {
105 const HINTS: &[Hint] = &[hint("y", "create & switch"), hint("Esc", "cancel")];
106 HINTS
107}
108
109pub fn confirm_delete_branch_hints() -> &'static [Hint] {
112 const HINTS: &[Hint] = &[hint("y", "delete"), hint("Esc", "cancel")];
113 HINTS
114}
115
116pub 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
127pub fn confirm_init_submodules_hints() -> &'static [Hint] {
130 const HINTS: &[Hint] = &[hint("Enter/y", "initialize"), hint("n/Esc", "skip")];
131 HINTS
132}
133
134pub fn exit_blocked_hints() -> &'static [Hint] {
138 const HINTS: &[Hint] = &[hint("y", "abandon"), hint("Esc", "keep working")];
139 HINTS
140}
141
142pub fn help_hints() -> &'static [Hint] {
144 const HINTS: &[Hint] = &[hint("any key", "close")];
145 HINTS
146}
147
148pub fn format_hint_row(hints: &[Hint]) -> String {
151 hints
152 .iter()
153 .map(|h| format!("{}: {}", h.key, h.label))
154 .collect::<Vec<_>>()
155 .join(" ")
156}
157
158#[cfg(test)]
159mod tests {
160 use super::*;
161 use crate::tui::app::testutil::app;
162 use crate::tui::app::{
163 App, CheckoutState, ComposeField, CreateState, CreateStep, ExitBlockedState, ExitIntent,
164 Mode, PrComposeState, PrItem, PrPickerState, StaleBaseState,
165 };
166 use crate::tui::event::Effect;
167 use crate::tui::options::OptionList;
168 use crossterm::event::{Event, KeyCode, KeyEvent, KeyModifiers};
169
170 #[test]
171 fn format_hint_row_joins_key_and_label() {
172 let row = format_hint_row(&[hint("↑/↓", "options"), hint("Esc", "cancel")]);
173 assert_eq!(row, "↑/↓: options Esc: cancel");
174 }
175
176 #[test]
177 fn every_hint_is_well_formed() {
178 let tables = [
179 filter_hints(),
180 create_hints(),
181 pr_picker_hints(),
182 compose_ai_hints(),
183 compose_edit_hints(),
184 checkout_hints(),
185 confirm_hints(),
186 confirm_create_hints(),
187 confirm_delete_branch_hints(),
188 confirm_stale_base_hints(),
189 exit_blocked_hints(),
190 help_hints(),
191 ];
192 for table in tables {
193 for h in table {
194 assert!(!h.key.is_empty(), "empty key in {table:?}");
195 assert!(!h.label.is_empty(), "empty label for {:?}", h.key);
196 }
197 }
198 }
199
200 fn key_event(key: &str) -> KeyEvent {
204 let first = key.split('/').next().unwrap_or(key);
205 let (code, mods) = match first {
206 "type" | "any key" => (KeyCode::Char('x'), KeyModifiers::empty()),
207 "↑" => (KeyCode::Up, KeyModifiers::empty()),
208 "↓" => (KeyCode::Down, KeyModifiers::empty()),
209 "Enter" => (KeyCode::Enter, KeyModifiers::empty()),
210 "Esc" => (KeyCode::Esc, KeyModifiers::empty()),
211 "Tab" => (KeyCode::Tab, KeyModifiers::empty()),
212 "Shift+Tab" => (KeyCode::BackTab, KeyModifiers::empty()),
213 "Backspace" => (KeyCode::Backspace, KeyModifiers::empty()),
214 "y" => (KeyCode::Char('y'), KeyModifiers::empty()),
215 "u" => (KeyCode::Char('u'), KeyModifiers::empty()),
216 "p" => (KeyCode::Char('p'), KeyModifiers::empty()),
217 "Ctrl-A" => (KeyCode::Char('a'), KeyModifiers::CONTROL),
218 "Ctrl-S" => (KeyCode::Char('s'), KeyModifiers::CONTROL),
219 "Ctrl-D" => (KeyCode::Char('d'), KeyModifiers::CONTROL),
220 "Ctrl-M" => (KeyCode::Char('m'), KeyModifiers::CONTROL),
221 "Ctrl-E" => (KeyCode::Char('e'), KeyModifiers::CONTROL),
222 other => panic!("unrecognized hint key {other:?}; teach key_event()"),
223 };
224 KeyEvent::new(code, mods)
225 }
226
227 fn pr(number: u64) -> PrItem {
228 PrItem {
229 number,
230 title: format!("pr {number}"),
231 author: "x".into(),
232 state: "OPEN".into(),
233 created_at: "2024-01-15T10:30:00Z".into(),
234 }
235 }
236
237 fn options(items: &[&str]) -> OptionList {
238 let mut ol = OptionList::new(items.iter().map(|s| (*s).into()).collect());
239 ol.open();
240 ol
241 }
242
243 fn arranged(mode_kind: &str) -> App {
247 let mut a = app(&[("alpha", true), ("alpine", false), ("beta", false)]);
248 match mode_kind {
249 "filter" => {
250 a.filter = "al".into();
251 a.selected = 1;
252 a.mode = Mode::Filter;
253 }
254 "create" => {
255 a.mode = Mode::Create(CreateState {
256 step: CreateStep::Branch,
257 branch: "fe".into(),
258 options: options(&["main", "master"]),
259 ..Default::default()
260 });
261 }
262 "pr_picker" => {
263 a.mode = Mode::PrPicker(PrPickerState {
264 prs: vec![pr(1), pr(2)],
265 selected: 1,
266 ..Default::default()
267 });
268 }
269 "compose" => {
270 a.mode = Mode::PrCompose(PrComposeState {
271 field: ComposeField::Model,
272 title: "hi".into(),
273 ..Default::default()
274 });
275 }
276 "checkout" => {
277 a.mode = Mode::Checkout(CheckoutState {
278 worktree_index: 0,
279 query: "m".into(),
280 options: options(&["main", "master"]),
281 ..Default::default()
282 });
283 }
284 "confirm" => a.mode = Mode::ConfirmRemove(0),
285 "confirm_create" => a.mode = Mode::ConfirmCreate(0),
286 "confirm_delete_branch" => {
287 a.mode = Mode::ConfirmDeleteBranch {
288 index: 0,
289 force: false,
290 }
291 }
292 "confirm_stale_base" => {
293 a.mode = Mode::ConfirmStaleBase(StaleBaseState {
294 branch: "feature".into(),
295 base: Some("main".into()),
296 behind: 1,
297 upstream_display: "origin/main".into(),
298 can_fast_forward: true,
299 })
300 }
301 "exit_blocked" => {
302 a.mode = Mode::ExitBlocked(ExitBlockedState {
303 intent: ExitIntent::Quit,
304 })
305 }
306 "help" => a.mode = Mode::Help,
307 other => panic!("unknown mode {other}"),
308 }
309 a
310 }
311
312 fn fingerprint(a: &App) -> String {
314 format!("{:?}|{}|{}", a.mode, a.filter, a.selected)
315 }
316
317 fn assert_hints_live(mode_kind: &str, hints: &[Hint]) {
321 for h in hints {
322 let mut a = arranged(mode_kind);
323 let before = fingerprint(&a);
324 let effect = a.handle_event(Event::Key(key_event(h.key)));
325 let after = fingerprint(&a);
326 assert!(
327 effect != Effect::None || before != after,
328 "{mode_kind} hint {:?} ({}) was ignored by the handler",
329 h.key,
330 h.label,
331 );
332 }
333 }
334
335 #[test]
336 fn modal_hints_drive_real_handlers() {
337 assert_hints_live("filter", filter_hints());
338 assert_hints_live("create", create_hints());
339 assert_hints_live("pr_picker", pr_picker_hints());
340 assert_hints_live("compose", compose_ai_hints());
341 assert_hints_live("compose", compose_edit_hints());
342 assert_hints_live("checkout", checkout_hints());
343 assert_hints_live("confirm", confirm_hints());
344 assert_hints_live("confirm_create", confirm_create_hints());
345 assert_hints_live("confirm_delete_branch", confirm_delete_branch_hints());
346 assert_hints_live("confirm_stale_base", confirm_stale_base_hints());
347 assert_hints_live("exit_blocked", exit_blocked_hints());
348 assert_hints_live("help", help_hints());
349 }
350}