1use crossterm::event::{KeyCode, KeyEvent, KeyModifiers};
2
3#[derive(Debug, Clone, Copy, PartialEq)]
4pub enum Mode {
5 Normal,
6 SpaceMenu,
7 ServicePicker,
8 ColumnSelector,
9 FilterInput,
10 EventFilterInput,
11 InsightsInput,
12 ErrorModal,
13 HelpModal,
14 RegionPicker,
15 ProfilePicker,
16 CalendarPicker,
17 TabPicker,
18 SessionPicker,
19}
20
21pub fn handle_key(key: KeyEvent, mode: Mode) -> Option<Action> {
22 match mode {
23 Mode::Normal => match key.code {
24 KeyCode::Char('q') => Some(Action::Quit),
25 KeyCode::Char('c') if key.modifiers.contains(KeyModifiers::CONTROL) => {
26 Some(Action::Quit)
27 }
28 KeyCode::Char('w') if key.modifiers.contains(KeyModifiers::CONTROL) => {
29 Some(Action::CloseService)
30 }
31 KeyCode::Char('o') if key.modifiers.contains(KeyModifiers::CONTROL) => {
32 Some(Action::OpenInConsole)
33 }
34 KeyCode::Char('r') if key.modifiers == KeyModifiers::CONTROL => Some(Action::Refresh),
35 KeyCode::Char('u') if key.modifiers.contains(KeyModifiers::CONTROL) => {
36 Some(Action::PageUp)
37 }
38 KeyCode::Char('d') if key.modifiers.contains(KeyModifiers::CONTROL) => {
39 Some(Action::PageDown)
40 }
41 KeyCode::Esc => Some(Action::GoBack),
42 KeyCode::Char('i') => Some(Action::StartFilter),
43 KeyCode::Char('c') => Some(Action::OpenCalendar),
44 KeyCode::Down => Some(Action::NextItem),
45 KeyCode::Up => Some(Action::PrevItem),
46 KeyCode::Right => Some(Action::ExpandRow),
47 KeyCode::Left => Some(Action::CollapseRow),
48 KeyCode::Tab => Some(Action::NextDetailTab),
49 KeyCode::BackTab => Some(Action::PrevDetailTab),
50 KeyCode::Enter => Some(Action::Select),
51 KeyCode::Char(' ') => Some(Action::OpenSpaceMenu),
52 KeyCode::Char('p') if key.modifiers.contains(KeyModifiers::CONTROL) => {
53 Some(Action::CopyToClipboard)
54 }
55 KeyCode::Char('p') => Some(Action::OpenColumnSelector),
56 KeyCode::Char('e') => Some(Action::ToggleExactMatch),
57 KeyCode::Char('x') => Some(Action::ToggleShowExpired),
58 KeyCode::Char('s') => Some(Action::CycleSortColumn),
59 KeyCode::Char('o') => Some(Action::ToggleSortDirection),
60 KeyCode::Char('y') => Some(Action::Yank),
61 KeyCode::Char('[') => Some(Action::PrevTab),
62 KeyCode::Char(']') => Some(Action::NextTab),
63 KeyCode::Char('?') => Some(Action::ShowHelp),
64 KeyCode::Char('N')
65 if key.modifiers.contains(KeyModifiers::CONTROL)
66 && key.modifiers.contains(KeyModifiers::SHIFT) =>
67 {
68 Some(Action::NextTab)
69 }
70 KeyCode::Char('P')
71 if key.modifiers.contains(KeyModifiers::CONTROL)
72 && key.modifiers.contains(KeyModifiers::SHIFT) =>
73 {
74 Some(Action::PrevTab)
75 }
76 KeyCode::Char(c) if c.is_ascii_digit() => Some(Action::FilterInput(c)),
77 KeyCode::Char('P') => Some(Action::ApplyFilter),
78 _ => None,
79 },
80 Mode::SpaceMenu => match key.code {
81 KeyCode::Esc => Some(Action::CloseMenu),
82 KeyCode::Char('o') => Some(Action::OpenServicePicker),
83 KeyCode::Char('r') => Some(Action::OpenRegionPicker),
84 KeyCode::Char('p') => Some(Action::OpenProfilePicker),
85 KeyCode::Char('a') => Some(Action::OpenCloudWatchAlarms),
86 KeyCode::Char('c') => Some(Action::CloseService),
87 KeyCode::Char('b') | KeyCode::Char('t') => Some(Action::OpenTabPicker),
88 KeyCode::Char('s') => Some(Action::OpenSessionPicker),
89 KeyCode::Char('h') => Some(Action::ShowHelp),
90 _ => None,
91 },
92 Mode::ServicePicker => match key.code {
93 KeyCode::Esc => Some(Action::ExitFilterMode),
94 KeyCode::Char('i') if key.modifiers.is_empty() => Some(Action::EnterFilterMode),
95 KeyCode::Down => Some(Action::NextItem),
96 KeyCode::Up => Some(Action::PrevItem),
97 KeyCode::Enter => Some(Action::Select),
98 KeyCode::Char('w') if key.modifiers.contains(KeyModifiers::CONTROL) => {
99 Some(Action::DeleteWord)
100 }
101 KeyCode::Left if key.modifiers.contains(KeyModifiers::ALT) => Some(Action::WordLeft),
102 KeyCode::Right if key.modifiers.contains(KeyModifiers::ALT) => Some(Action::WordRight),
103 KeyCode::Char(c) if c != 'i' => Some(Action::FilterInput(c)),
104 KeyCode::Backspace => Some(Action::FilterBackspace),
105 _ => None,
106 },
107 Mode::ColumnSelector => match key.code {
108 KeyCode::Esc => Some(Action::CloseColumnSelector),
109 KeyCode::Down => Some(Action::NextItem),
110 KeyCode::Up => Some(Action::PrevItem),
111 KeyCode::Char(' ') | KeyCode::Enter => Some(Action::ToggleColumn),
112 KeyCode::Tab => Some(Action::NextPreferences),
113 KeyCode::BackTab => Some(Action::PrevPreferences),
114 KeyCode::Char('d') if key.modifiers.contains(KeyModifiers::CONTROL) => {
115 Some(Action::PageDown)
116 }
117 KeyCode::Char('u') if key.modifiers.contains(KeyModifiers::CONTROL) => {
118 Some(Action::PageUp)
119 }
120 _ => None,
121 },
122 Mode::FilterInput => match key.code {
123 KeyCode::Esc => Some(Action::CloseMenu),
124 KeyCode::Enter => Some(Action::ApplyFilter),
125 KeyCode::BackTab => Some(Action::PrevFilterFocus),
126 KeyCode::Tab if key.modifiers.contains(KeyModifiers::SHIFT) => {
127 Some(Action::PrevFilterFocus)
128 }
129 KeyCode::Tab => Some(Action::NextFilterFocus),
130 KeyCode::Up => Some(Action::PrevItem),
131 KeyCode::Down => Some(Action::NextItem),
132 KeyCode::Left if key.modifiers.contains(KeyModifiers::ALT) => Some(Action::WordLeft),
133 KeyCode::Right if key.modifiers.contains(KeyModifiers::ALT) => Some(Action::WordRight),
134 KeyCode::Left => Some(Action::PageUp),
135 KeyCode::Right => Some(Action::PageDown),
136 KeyCode::Char(' ') => Some(Action::ToggleFilterCheckbox),
137 KeyCode::Char('w') if key.modifiers.contains(KeyModifiers::CONTROL) => {
138 Some(Action::DeleteWord)
139 }
140 KeyCode::Char(c) if c != ' ' => Some(Action::FilterInput(c)),
141 KeyCode::Backspace => Some(Action::FilterBackspace),
142 _ => None,
143 },
144 Mode::EventFilterInput => match key.code {
145 KeyCode::Esc => Some(Action::CloseMenu),
146 KeyCode::Enter => Some(Action::ApplyFilter),
147 KeyCode::BackTab => Some(Action::PrevFilterFocus),
148 KeyCode::Tab if key.modifiers.contains(KeyModifiers::SHIFT) => {
149 Some(Action::PrevFilterFocus)
150 }
151 KeyCode::Tab => Some(Action::NextFilterFocus),
152 KeyCode::Char(' ') => Some(Action::ToggleFilterCheckbox),
153 KeyCode::Char('w') if key.modifiers.contains(KeyModifiers::CONTROL) => {
154 Some(Action::DeleteWord)
155 }
156 KeyCode::Left if key.modifiers.contains(KeyModifiers::ALT) => Some(Action::WordLeft),
157 KeyCode::Right if key.modifiers.contains(KeyModifiers::ALT) => Some(Action::WordRight),
158 KeyCode::Char(c) if c != ' ' => Some(Action::FilterInput(c)),
159 KeyCode::Backspace => Some(Action::FilterBackspace),
160 _ => None,
161 },
162 Mode::InsightsInput => match key.code {
163 KeyCode::Esc => Some(Action::CloseMenu),
164 KeyCode::Enter => Some(Action::Select),
165 KeyCode::Tab => Some(Action::NextFilterFocus),
166 KeyCode::Char('r') if key.modifiers.contains(KeyModifiers::CONTROL) => {
167 Some(Action::Refresh)
168 }
169 KeyCode::Char('w') if key.modifiers.contains(KeyModifiers::CONTROL) => {
170 Some(Action::DeleteWord)
171 }
172 KeyCode::Left if key.modifiers.contains(KeyModifiers::ALT) => Some(Action::WordLeft),
173 KeyCode::Right if key.modifiers.contains(KeyModifiers::ALT) => Some(Action::WordRight),
174 KeyCode::Down => Some(Action::NextItem),
175 KeyCode::Up => Some(Action::PrevItem),
176 KeyCode::Char(' ') => Some(Action::ToggleFilterCheckbox),
177 KeyCode::Char(c) if c != ' ' => Some(Action::FilterInput(c)),
178 KeyCode::Backspace => Some(Action::FilterBackspace),
179 _ => None,
180 },
181 Mode::ErrorModal => match key.code {
182 KeyCode::Char('r') if key.modifiers.contains(KeyModifiers::CONTROL) => {
183 Some(Action::RetryLoad)
184 }
185 KeyCode::Char('y') => Some(Action::Yank),
186 KeyCode::Char('q') | KeyCode::Esc => Some(Action::Quit),
187 _ => None,
188 },
189 Mode::HelpModal => match key.code {
190 KeyCode::Esc | KeyCode::Enter | KeyCode::Char('q') | KeyCode::Char('?') => {
191 Some(Action::CloseMenu)
192 }
193 _ => None,
194 },
195 Mode::RegionPicker => match key.code {
196 KeyCode::Esc => Some(Action::ExitFilterMode),
197 KeyCode::Char('i') => Some(Action::EnterFilterMode),
198 KeyCode::Char('j') | KeyCode::Down => Some(Action::NextItem),
199 KeyCode::Char('k') | KeyCode::Up => Some(Action::PrevItem),
200 KeyCode::Enter => Some(Action::Select),
201 KeyCode::Char('s') => Some(Action::Refresh),
202 KeyCode::Char('r') if key.modifiers == KeyModifiers::CONTROL => Some(Action::Refresh),
203 KeyCode::Backspace => Some(Action::FilterBackspace),
204 KeyCode::Char(c) => Some(Action::FilterInput(c)),
205 _ => None,
206 },
207 Mode::CalendarPicker => match key.code {
208 KeyCode::Esc => Some(Action::CloseCalendar),
209 KeyCode::Left => Some(Action::CalendarPrevDay),
210 KeyCode::Down => Some(Action::CalendarNextWeek),
211 KeyCode::Up => Some(Action::CalendarPrevWeek),
212 KeyCode::Right => Some(Action::CalendarNextDay),
213 KeyCode::Char('n') | KeyCode::Tab => Some(Action::CalendarNextMonth),
214 KeyCode::Char('p') | KeyCode::BackTab => Some(Action::CalendarPrevMonth),
215 KeyCode::Enter => Some(Action::CalendarSelect),
216 _ => None,
217 },
218 Mode::TabPicker => match key.code {
219 KeyCode::Esc => Some(Action::CloseMenu),
220 KeyCode::Down => Some(Action::NextItem),
221 KeyCode::Up => Some(Action::PrevItem),
222 KeyCode::Enter => Some(Action::Select),
223 KeyCode::Backspace => Some(Action::FilterBackspace),
224 KeyCode::Char(c) => Some(Action::FilterInput(c)),
225 _ => None,
226 },
227 Mode::SessionPicker => match key.code {
228 KeyCode::Esc => Some(Action::ExitFilterMode),
229 KeyCode::Char('i') => Some(Action::EnterFilterMode),
230 KeyCode::Down => Some(Action::NextItem),
231 KeyCode::Up => Some(Action::PrevItem),
232 KeyCode::Enter => Some(Action::LoadSession),
233 KeyCode::Char('r') if key.modifiers == KeyModifiers::CONTROL => Some(Action::Refresh),
234 KeyCode::Backspace => Some(Action::FilterBackspace),
235 KeyCode::Char(c) => Some(Action::FilterInput(c)),
236 _ => None,
237 },
238 Mode::ProfilePicker => match key.code {
239 KeyCode::Esc => Some(Action::ExitFilterMode),
240 KeyCode::Char('i') => Some(Action::EnterFilterMode),
241 KeyCode::Down => Some(Action::NextItem),
242 KeyCode::Up => Some(Action::PrevItem),
243 KeyCode::Enter => Some(Action::Select),
244 KeyCode::Char('r') if key.modifiers == KeyModifiers::CONTROL => Some(Action::Refresh),
245 KeyCode::Backspace => Some(Action::FilterBackspace),
246 KeyCode::Char(c) => Some(Action::FilterInput(c)),
247 _ => None,
248 },
249 }
250}
251
252#[derive(Debug, Clone, PartialEq)]
253pub enum Action {
254 Quit,
255 CloseService,
256 NextItem,
257 PrevItem,
258 NextPane,
259 PrevPane,
260 CollapseRow,
261 ExpandRow,
262 Select,
263 OpenSpaceMenu,
264 CloseMenu,
265 OpenServicePicker,
266 OpenCloudWatch,
267 OpenCloudWatchSplit,
268 OpenCloudWatchAlarms,
269 FilterInput(char),
270 FilterBackspace,
271 DeleteWord,
272 WordLeft,
273 WordRight,
274 OpenColumnSelector,
275 ToggleColumn,
276 NextPreferences,
277 PrevPreferences,
278 CloseColumnSelector,
279 StartFilter,
280 StartEventFilter,
281 ApplyFilter,
282 ToggleExactMatch,
283 ToggleShowExpired,
284 GoBack,
285 NextFilterFocus,
286 PrevFilterFocus,
287 ToggleFilterCheckbox,
288 CycleSortColumn,
289 ToggleSortDirection,
290 ScrollUp,
291 ScrollDown,
292 PageUp,
293 PageDown,
294 Refresh,
295 RetryLoad,
296 Yank,
297 OpenInConsole,
298 OpenInBrowser,
299 ShowHelp,
300 OpenRegionPicker,
301 OpenCalendar,
302 CloseCalendar,
303 CalendarPrevDay,
304 CalendarNextDay,
305 CalendarPrevWeek,
306 CalendarNextWeek,
307 CalendarPrevMonth,
308 CalendarNextMonth,
309 CalendarSelect,
310 NextTab,
311 PrevTab,
312 NextDetailTab,
313 PrevDetailTab,
314 CloseTab,
315 OpenTabPicker,
316 OpenSessionPicker,
317 OpenProfilePicker,
318 LoadSession,
319 SaveSession,
320 CopyToClipboard,
321 EnterFilterMode,
322 ExitFilterMode,
323 Noop,
324}
325
326#[cfg(test)]
327mod tests {
328 use super::*;
329
330 #[test]
331 fn test_space_o_opens_service_menu() {
332 let key = KeyEvent::new(KeyCode::Char('o'), KeyModifiers::NONE);
333 let action = handle_key(key, Mode::SpaceMenu);
334 assert_eq!(action, Some(Action::OpenServicePicker));
335 }
336
337 #[test]
338 fn test_insights_input_accepts_chars() {
339 let key = KeyEvent::new(KeyCode::Char('a'), KeyModifiers::NONE);
340 let action = handle_key(key, Mode::InsightsInput);
341 assert_eq!(action, Some(Action::FilterInput('a')));
342
343 let key2 = KeyEvent::new(KeyCode::Char('1'), KeyModifiers::NONE);
344 let action2 = handle_key(key2, Mode::InsightsInput);
345 assert_eq!(action2, Some(Action::FilterInput('1')));
346 }
347
348 #[test]
349 fn test_insights_input_esc_closes() {
350 let key = KeyEvent::new(KeyCode::Esc, KeyModifiers::NONE);
351 let action = handle_key(key, Mode::InsightsInput);
352 assert_eq!(action, Some(Action::CloseMenu));
353 }
354
355 #[test]
356 fn test_service_menu_accepts_input() {
357 let key = KeyEvent::new(KeyCode::Char('c'), KeyModifiers::NONE);
358 let action = handle_key(key, Mode::ServicePicker);
359 assert_eq!(action, Some(Action::FilterInput('c')));
360 }
361
362 #[test]
363 fn test_service_menu_navigation() {
364 let key_down = KeyEvent::new(KeyCode::Down, KeyModifiers::NONE);
365 let action_down = handle_key(key_down, Mode::ServicePicker);
366 assert_eq!(action_down, Some(Action::NextItem));
367
368 let key_up = KeyEvent::new(KeyCode::Up, KeyModifiers::NONE);
369 let action_up = handle_key(key_up, Mode::ServicePicker);
370 assert_eq!(action_up, Some(Action::PrevItem));
371 }
372
373 #[test]
374 fn test_service_menu_backspace() {
375 let key = KeyEvent::new(KeyCode::Backspace, KeyModifiers::NONE);
376 let action = handle_key(key, Mode::ServicePicker);
377 assert_eq!(action, Some(Action::FilterBackspace));
378 }
379
380 #[test]
381 fn test_ctrl_shift_n_next_tab() {
382 let key = KeyEvent::new(
383 KeyCode::Char('N'),
384 KeyModifiers::CONTROL | KeyModifiers::SHIFT,
385 );
386 let action = handle_key(key, Mode::Normal);
387 assert_eq!(action, Some(Action::NextTab));
388 }
389
390 #[test]
391 fn test_ctrl_shift_p_prev_tab() {
392 let key = KeyEvent::new(
393 KeyCode::Char('P'),
394 KeyModifiers::CONTROL | KeyModifiers::SHIFT,
395 );
396 let action = handle_key(key, Mode::Normal);
397 assert_eq!(action, Some(Action::PrevTab));
398 }
399
400 #[test]
401 fn test_space_c_close_tab() {
402 let key = KeyEvent::new(KeyCode::Char('c'), KeyModifiers::NONE);
403 let action = handle_key(key, Mode::SpaceMenu);
404 assert_eq!(action, Some(Action::CloseService));
405 }
406
407 #[test]
408 fn test_space_b_window_picker() {
409 let key = KeyEvent::new(KeyCode::Char('b'), KeyModifiers::NONE);
410 let action = handle_key(key, Mode::SpaceMenu);
411 assert_eq!(action, Some(Action::OpenTabPicker));
412 }
413
414 #[test]
415 fn test_window_picker_navigation() {
416 let key_down = KeyEvent::new(KeyCode::Down, KeyModifiers::NONE);
417 let action = handle_key(key_down, Mode::TabPicker);
418 assert_eq!(action, Some(Action::NextItem));
419
420 let key_up = KeyEvent::new(KeyCode::Up, KeyModifiers::NONE);
421 let action_up = handle_key(key_up, Mode::TabPicker);
422 assert_eq!(action_up, Some(Action::PrevItem));
423 }
424
425 #[test]
426 fn test_window_picker_select() {
427 let key = KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE);
428 let action = handle_key(key, Mode::TabPicker);
429 assert_eq!(action, Some(Action::Select));
430 }
431
432 #[test]
433 fn test_space_opens_space_menu_in_normal_mode() {
434 let key = KeyEvent::new(KeyCode::Char(' '), KeyModifiers::NONE);
435 let action = handle_key(key, Mode::Normal);
436 assert_eq!(action, Some(Action::OpenSpaceMenu));
437 }
438
439 #[test]
440 fn test_space_menu_o_opens_service_menu() {
441 let key = KeyEvent::new(KeyCode::Char('o'), KeyModifiers::NONE);
442 let action = handle_key(key, Mode::SpaceMenu);
443 assert_eq!(action, Some(Action::OpenServicePicker));
444 }
445
446 #[test]
447 fn test_ctrl_r_refreshes_profile_picker() {
448 let key = KeyEvent::new(KeyCode::Char('r'), KeyModifiers::CONTROL);
449 let action = handle_key(key, Mode::ProfilePicker);
450 assert_eq!(action, Some(Action::Refresh));
451 }
452
453 #[test]
454 fn test_ctrl_r_refreshes_region_picker() {
455 let key = KeyEvent::new(KeyCode::Char('r'), KeyModifiers::CONTROL);
456 let action = handle_key(key, Mode::RegionPicker);
457 assert_eq!(action, Some(Action::Refresh));
458 }
459
460 #[test]
461 fn test_ctrl_r_refreshes_session_picker() {
462 let key = KeyEvent::new(KeyCode::Char('r'), KeyModifiers::CONTROL);
463 let action = handle_key(key, Mode::SessionPicker);
464 assert_eq!(action, Some(Action::Refresh));
465 }
466
467 #[test]
468 fn test_p_opens_column_selector() {
469 let key = KeyEvent::new(KeyCode::Char('p'), KeyModifiers::NONE);
470 let action = handle_key(key, Mode::Normal);
471 assert_eq!(
472 action,
473 Some(Action::OpenColumnSelector),
474 "p should open column selector (preferences)"
475 );
476 }
477
478 #[test]
479 fn test_ctrl_p_copies_to_clipboard() {
480 let key = KeyEvent::new(KeyCode::Char('p'), KeyModifiers::CONTROL);
481 let action = handle_key(key, Mode::Normal);
482 assert_eq!(
483 action,
484 Some(Action::CopyToClipboard),
485 "Ctrl+P should copy screen to clipboard (print)"
486 );
487 }
488
489 #[test]
490 fn test_y_yanks_selected_item() {
491 let key = KeyEvent::new(KeyCode::Char('y'), KeyModifiers::NONE);
492 let action = handle_key(key, Mode::Normal);
493 assert_eq!(
494 action,
495 Some(Action::Yank),
496 "y should yank (copy) selected item"
497 );
498 }
499
500 #[test]
501 fn test_space_toggles_checkbox_in_filter_input() {
502 let key = KeyEvent::new(KeyCode::Char(' '), KeyModifiers::NONE);
503 let action = handle_key(key, Mode::FilterInput);
504 assert_eq!(
505 action,
506 Some(Action::ToggleFilterCheckbox),
507 "Space should toggle checkbox in FilterInput mode"
508 );
509 }
510
511 #[test]
512 fn test_space_not_added_to_filter_text() {
513 let key = KeyEvent::new(KeyCode::Char(' '), KeyModifiers::NONE);
515 let action = handle_key(key, Mode::FilterInput);
516 assert_ne!(
517 action,
518 Some(Action::FilterInput(' ')),
519 "Space should not be added to filter text"
520 );
521 }
522}