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 help_hints() -> &'static [Hint] {
136 const HINTS: &[Hint] = &[hint("any key", "close")];
137 HINTS
138}
139
140pub fn format_hint_row(hints: &[Hint]) -> String {
143 hints
144 .iter()
145 .map(|h| format!("{}: {}", h.key, h.label))
146 .collect::<Vec<_>>()
147 .join(" ")
148}
149
150#[cfg(test)]
151mod tests {
152 use super::*;
153 use crate::tui::app::testutil::app;
154 use crate::tui::app::{
155 App, CheckoutState, ComposeField, CreateState, CreateStep, Mode, PrComposeState, PrItem,
156 PrPickerState, StaleBaseState,
157 };
158 use crate::tui::event::Effect;
159 use crate::tui::options::OptionList;
160 use crossterm::event::{Event, KeyCode, KeyEvent, KeyModifiers};
161
162 #[test]
163 fn format_hint_row_joins_key_and_label() {
164 let row = format_hint_row(&[hint("↑/↓", "options"), hint("Esc", "cancel")]);
165 assert_eq!(row, "↑/↓: options Esc: cancel");
166 }
167
168 #[test]
169 fn every_hint_is_well_formed() {
170 let tables = [
171 filter_hints(),
172 create_hints(),
173 pr_picker_hints(),
174 compose_ai_hints(),
175 compose_edit_hints(),
176 checkout_hints(),
177 confirm_hints(),
178 confirm_create_hints(),
179 confirm_delete_branch_hints(),
180 confirm_stale_base_hints(),
181 help_hints(),
182 ];
183 for table in tables {
184 for h in table {
185 assert!(!h.key.is_empty(), "empty key in {table:?}");
186 assert!(!h.label.is_empty(), "empty label for {:?}", h.key);
187 }
188 }
189 }
190
191 fn key_event(key: &str) -> KeyEvent {
195 let first = key.split('/').next().unwrap_or(key);
196 let (code, mods) = match first {
197 "type" | "any key" => (KeyCode::Char('x'), KeyModifiers::empty()),
198 "↑" => (KeyCode::Up, KeyModifiers::empty()),
199 "↓" => (KeyCode::Down, KeyModifiers::empty()),
200 "Enter" => (KeyCode::Enter, KeyModifiers::empty()),
201 "Esc" => (KeyCode::Esc, KeyModifiers::empty()),
202 "Tab" => (KeyCode::Tab, KeyModifiers::empty()),
203 "Shift+Tab" => (KeyCode::BackTab, KeyModifiers::empty()),
204 "Backspace" => (KeyCode::Backspace, KeyModifiers::empty()),
205 "y" => (KeyCode::Char('y'), KeyModifiers::empty()),
206 "u" => (KeyCode::Char('u'), KeyModifiers::empty()),
207 "p" => (KeyCode::Char('p'), KeyModifiers::empty()),
208 "Ctrl-A" => (KeyCode::Char('a'), KeyModifiers::CONTROL),
209 "Ctrl-S" => (KeyCode::Char('s'), KeyModifiers::CONTROL),
210 "Ctrl-D" => (KeyCode::Char('d'), KeyModifiers::CONTROL),
211 "Ctrl-M" => (KeyCode::Char('m'), KeyModifiers::CONTROL),
212 "Ctrl-E" => (KeyCode::Char('e'), KeyModifiers::CONTROL),
213 other => panic!("unrecognized hint key {other:?}; teach key_event()"),
214 };
215 KeyEvent::new(code, mods)
216 }
217
218 fn pr(number: u64) -> PrItem {
219 PrItem {
220 number,
221 title: format!("pr {number}"),
222 author: "x".into(),
223 state: "OPEN".into(),
224 created_at: "2024-01-15T10:30:00Z".into(),
225 }
226 }
227
228 fn options(items: &[&str]) -> OptionList {
229 let mut ol = OptionList::new(items.iter().map(|s| (*s).into()).collect());
230 ol.open();
231 ol
232 }
233
234 fn arranged(mode_kind: &str) -> App {
238 let mut a = app(&[("alpha", true), ("alpine", false), ("beta", false)]);
239 match mode_kind {
240 "filter" => {
241 a.filter = "al".into();
242 a.selected = 1;
243 a.mode = Mode::Filter;
244 }
245 "create" => {
246 a.mode = Mode::Create(CreateState {
247 step: CreateStep::Branch,
248 branch: "fe".into(),
249 options: options(&["main", "master"]),
250 ..Default::default()
251 });
252 }
253 "pr_picker" => {
254 a.mode = Mode::PrPicker(PrPickerState {
255 prs: vec![pr(1), pr(2)],
256 selected: 1,
257 ..Default::default()
258 });
259 }
260 "compose" => {
261 a.mode = Mode::PrCompose(PrComposeState {
262 field: ComposeField::Model,
263 title: "hi".into(),
264 ..Default::default()
265 });
266 }
267 "checkout" => {
268 a.mode = Mode::Checkout(CheckoutState {
269 worktree_index: 0,
270 query: "m".into(),
271 options: options(&["main", "master"]),
272 ..Default::default()
273 });
274 }
275 "confirm" => a.mode = Mode::ConfirmRemove(0),
276 "confirm_create" => a.mode = Mode::ConfirmCreate(0),
277 "confirm_delete_branch" => {
278 a.mode = Mode::ConfirmDeleteBranch {
279 index: 0,
280 force: false,
281 }
282 }
283 "confirm_stale_base" => {
284 a.mode = Mode::ConfirmStaleBase(StaleBaseState {
285 branch: "feature".into(),
286 base: Some("main".into()),
287 behind: 1,
288 upstream_display: "origin/main".into(),
289 can_fast_forward: true,
290 })
291 }
292 "help" => a.mode = Mode::Help,
293 other => panic!("unknown mode {other}"),
294 }
295 a
296 }
297
298 fn fingerprint(a: &App) -> String {
300 format!("{:?}|{}|{}", a.mode, a.filter, a.selected)
301 }
302
303 fn assert_hints_live(mode_kind: &str, hints: &[Hint]) {
307 for h in hints {
308 let mut a = arranged(mode_kind);
309 let before = fingerprint(&a);
310 let effect = a.handle_event(Event::Key(key_event(h.key)));
311 let after = fingerprint(&a);
312 assert!(
313 effect != Effect::None || before != after,
314 "{mode_kind} hint {:?} ({}) was ignored by the handler",
315 h.key,
316 h.label,
317 );
318 }
319 }
320
321 #[test]
322 fn modal_hints_drive_real_handlers() {
323 assert_hints_live("filter", filter_hints());
324 assert_hints_live("create", create_hints());
325 assert_hints_live("pr_picker", pr_picker_hints());
326 assert_hints_live("compose", compose_ai_hints());
327 assert_hints_live("compose", compose_edit_hints());
328 assert_hints_live("checkout", checkout_hints());
329 assert_hints_live("confirm", confirm_hints());
330 assert_hints_live("confirm_create", confirm_create_hints());
331 assert_hints_live("confirm_delete_branch", confirm_delete_branch_hints());
332 assert_hints_live("confirm_stale_base", confirm_stale_base_hints());
333 assert_hints_live("help", help_hints());
334 }
335}