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 confirm_quit_hints() -> &'static [Hint] {
137 const HINTS: &[Hint] = &[hint("y", "quit anyway"), hint("Esc", "cancel")];
138 HINTS
139}
140
141pub fn help_hints() -> &'static [Hint] {
143 const HINTS: &[Hint] = &[hint("any key", "close")];
144 HINTS
145}
146
147pub 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 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 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 fn fingerprint(a: &App) -> String {
307 format!("{:?}|{}|{}", a.mode, a.filter, a.selected)
308 }
309
310 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}