Skip to main content

rusticity_term/
keymap.rs

1use crossterm::event::{KeyCode, KeyEvent, KeyModifiers};
2
3#[derive(Debug, Clone, Copy, PartialEq)]
4pub enum Mode {
5    Normal,
6    SpaceMenu,
7    ServicePicker,
8    ResourceFinder,
9    ColumnSelector,
10    FilterInput,
11    EventFilterInput,
12    InsightsInput,
13    ErrorModal,
14    HelpModal,
15    RegionPicker,
16    ProfilePicker,
17    CalendarPicker,
18    TabPicker,
19    SessionPicker,
20    QuitConfirm,
21}
22
23pub fn handle_key(key: KeyEvent, mode: Mode) -> Option<Action> {
24    match mode {
25        Mode::Normal => match key.code {
26            KeyCode::Char('q') => Some(Action::Quit),
27            KeyCode::Char('c') if key.modifiers.contains(KeyModifiers::CONTROL) => {
28                Some(Action::ConfirmQuit)
29            }
30            KeyCode::Char('w') if key.modifiers.contains(KeyModifiers::CONTROL) => {
31                Some(Action::CloseService)
32            }
33            KeyCode::Char('o') if key.modifiers.contains(KeyModifiers::CONTROL) => {
34                Some(Action::OpenInConsole)
35            }
36            KeyCode::Char('l') if key.modifiers == KeyModifiers::CONTROL => {
37                Some(Action::OpenRelatedResources)
38            }
39            KeyCode::Char('r') if key.modifiers == KeyModifiers::CONTROL => Some(Action::Refresh),
40            KeyCode::Char('u') if key.modifiers.contains(KeyModifiers::CONTROL) => {
41                Some(Action::PageUp)
42            }
43            KeyCode::Char('d') if key.modifiers.contains(KeyModifiers::CONTROL) => {
44                Some(Action::PageDown)
45            }
46            KeyCode::Esc => Some(Action::GoBack),
47            KeyCode::Char('i') => Some(Action::StartFilter),
48            KeyCode::Char('c') => Some(Action::OpenCalendar),
49            KeyCode::Down => Some(Action::NextItem),
50            KeyCode::Up => Some(Action::PrevItem),
51            KeyCode::Right => Some(Action::ExpandRow),
52            KeyCode::Left => Some(Action::CollapseRow),
53            KeyCode::Tab => Some(Action::NextDetailTab),
54            KeyCode::BackTab => Some(Action::PrevDetailTab),
55            KeyCode::Enter => Some(Action::Select),
56            KeyCode::Char(' ') => Some(Action::OpenSpaceMenu),
57            KeyCode::Char('p') if key.modifiers.contains(KeyModifiers::CONTROL) => {
58                Some(Action::CopyToClipboard)
59            }
60            KeyCode::Char('p') => Some(Action::OpenColumnSelector),
61            KeyCode::Char('e') => Some(Action::ToggleExactMatch),
62            KeyCode::Char('x') => Some(Action::ToggleShowExpired),
63            KeyCode::Char('s') => Some(Action::CycleSortColumn),
64            KeyCode::Char('o') => Some(Action::ToggleSortDirection),
65            KeyCode::Char('m') if key.modifiers.is_empty() => Some(Action::LoadMore),
66            KeyCode::Char('y') => Some(Action::Yank),
67            KeyCode::Char('[') => Some(Action::PrevTab),
68            KeyCode::Char(']') => Some(Action::NextTab),
69            KeyCode::Char('?') => Some(Action::ShowHelp),
70            KeyCode::Char('N')
71                if key.modifiers.contains(KeyModifiers::CONTROL)
72                    && key.modifiers.contains(KeyModifiers::SHIFT) =>
73            {
74                Some(Action::NextTab)
75            }
76            KeyCode::Char('P')
77                if key.modifiers.contains(KeyModifiers::CONTROL)
78                    && key.modifiers.contains(KeyModifiers::SHIFT) =>
79            {
80                Some(Action::PrevTab)
81            }
82            KeyCode::Char(c) if c.is_ascii_digit() => Some(Action::FilterInput(c)),
83            KeyCode::Char('P') => Some(Action::ApplyFilter),
84            _ => None,
85        },
86        Mode::SpaceMenu => match key.code {
87            KeyCode::Esc => Some(Action::CloseMenu),
88            KeyCode::Char('o') => Some(Action::OpenServicePicker),
89            KeyCode::Char('r') => Some(Action::OpenRegionPicker),
90            KeyCode::Char('p') => Some(Action::OpenProfilePicker),
91            KeyCode::Char('a') => Some(Action::OpenCloudWatchAlarms),
92            KeyCode::Char('c') => Some(Action::CloseService),
93            KeyCode::Char('b') | KeyCode::Char('t') => Some(Action::OpenTabPicker),
94            KeyCode::Char('s') => Some(Action::OpenSessionPicker),
95            KeyCode::Char('f') => Some(Action::OpenResourceFinder),
96            KeyCode::Char('h') => Some(Action::ShowHelp),
97            _ => None,
98        },
99        Mode::ResourceFinder => match key.code {
100            KeyCode::Esc => Some(Action::CloseMenu),
101            KeyCode::Down => Some(Action::NextItem),
102            KeyCode::Up => Some(Action::PrevItem),
103            KeyCode::PageDown => Some(Action::PageDown),
104            KeyCode::PageUp => Some(Action::PageUp),
105            KeyCode::Enter => Some(Action::Select),
106            KeyCode::BackTab => Some(Action::PrevFilterFocus),
107            KeyCode::Tab if key.modifiers.contains(KeyModifiers::SHIFT) => {
108                Some(Action::PrevFilterFocus)
109            }
110            KeyCode::Tab => Some(Action::NextFilterFocus),
111            KeyCode::Backspace => Some(Action::FilterBackspace),
112            KeyCode::Char('r') if key.modifiers == KeyModifiers::CONTROL => Some(Action::Refresh),
113            KeyCode::Char('w') if key.modifiers == KeyModifiers::CONTROL => {
114                Some(Action::DeleteWord)
115            }
116            KeyCode::Char('c') if key.modifiers == KeyModifiers::CONTROL => Some(Action::CloseMenu),
117            KeyCode::Char(c)
118                if !key
119                    .modifiers
120                    .intersects(KeyModifiers::CONTROL | KeyModifiers::ALT) =>
121            {
122                Some(Action::FilterInput(c))
123            }
124            _ => None,
125        },
126        Mode::ServicePicker => match key.code {
127            KeyCode::Esc => Some(Action::ExitFilterMode),
128            KeyCode::Down => Some(Action::NextItem),
129            KeyCode::Up => Some(Action::PrevItem),
130            KeyCode::Enter => Some(Action::Select),
131            KeyCode::Char('w') if key.modifiers.contains(KeyModifiers::CONTROL) => {
132                Some(Action::DeleteWord)
133            }
134            KeyCode::Left if key.modifiers.contains(KeyModifiers::ALT) => Some(Action::WordLeft),
135            KeyCode::Right if key.modifiers.contains(KeyModifiers::ALT) => Some(Action::WordRight),
136            KeyCode::Char(c) => Some(Action::FilterInput(c)),
137            KeyCode::Backspace => Some(Action::FilterBackspace),
138            _ => None,
139        },
140        Mode::ColumnSelector => match key.code {
141            KeyCode::Esc => Some(Action::CloseColumnSelector),
142            KeyCode::Down => Some(Action::NextItem),
143            KeyCode::Up => Some(Action::PrevItem),
144            KeyCode::Char(' ') | KeyCode::Enter => Some(Action::ToggleColumn),
145            KeyCode::Tab => Some(Action::NextPreferences),
146            KeyCode::BackTab => Some(Action::PrevPreferences),
147            KeyCode::Char('d') if key.modifiers.contains(KeyModifiers::CONTROL) => {
148                Some(Action::PageDown)
149            }
150            KeyCode::Char('u') if key.modifiers.contains(KeyModifiers::CONTROL) => {
151                Some(Action::PageUp)
152            }
153            _ => None,
154        },
155        Mode::FilterInput => match key.code {
156            KeyCode::Esc => Some(Action::CloseMenu),
157            KeyCode::Enter => Some(Action::ApplyFilter),
158            KeyCode::BackTab => Some(Action::PrevFilterFocus),
159            KeyCode::Tab if key.modifiers.contains(KeyModifiers::SHIFT) => {
160                Some(Action::PrevFilterFocus)
161            }
162            KeyCode::Tab => Some(Action::NextFilterFocus),
163            KeyCode::Up => Some(Action::PrevItem),
164            KeyCode::Down => Some(Action::NextItem),
165            KeyCode::Left if key.modifiers.contains(KeyModifiers::ALT) => Some(Action::WordLeft),
166            KeyCode::Right if key.modifiers.contains(KeyModifiers::ALT) => Some(Action::WordRight),
167            KeyCode::Left => Some(Action::PageUp),
168            KeyCode::Right => Some(Action::PageDown),
169            KeyCode::Char(' ') => Some(Action::ToggleFilterCheckbox),
170            KeyCode::Char('w') if key.modifiers.contains(KeyModifiers::CONTROL) => {
171                Some(Action::DeleteWord)
172            }
173            KeyCode::Char(c) if c != ' ' => Some(Action::FilterInput(c)),
174            KeyCode::Backspace => Some(Action::FilterBackspace),
175            _ => None,
176        },
177        Mode::EventFilterInput => match key.code {
178            KeyCode::Esc => Some(Action::CloseMenu),
179            KeyCode::Enter => Some(Action::ApplyFilter),
180            KeyCode::BackTab => Some(Action::PrevFilterFocus),
181            KeyCode::Tab if key.modifiers.contains(KeyModifiers::SHIFT) => {
182                Some(Action::PrevFilterFocus)
183            }
184            KeyCode::Tab => Some(Action::NextFilterFocus),
185            KeyCode::Char(' ') => Some(Action::ToggleFilterCheckbox),
186            KeyCode::Char('w') if key.modifiers.contains(KeyModifiers::CONTROL) => {
187                Some(Action::DeleteWord)
188            }
189            KeyCode::Left if key.modifiers.contains(KeyModifiers::ALT) => Some(Action::WordLeft),
190            KeyCode::Right if key.modifiers.contains(KeyModifiers::ALT) => Some(Action::WordRight),
191            KeyCode::Char(c) if c != ' ' => Some(Action::FilterInput(c)),
192            KeyCode::Backspace => Some(Action::FilterBackspace),
193            _ => None,
194        },
195        Mode::InsightsInput => match key.code {
196            KeyCode::Esc => Some(Action::CloseMenu),
197            KeyCode::Enter => Some(Action::Select),
198            KeyCode::Tab => Some(Action::NextFilterFocus),
199            KeyCode::Char('r') if key.modifiers.contains(KeyModifiers::CONTROL) => {
200                Some(Action::Refresh)
201            }
202            KeyCode::Char('w') if key.modifiers.contains(KeyModifiers::CONTROL) => {
203                Some(Action::DeleteWord)
204            }
205            KeyCode::Left if key.modifiers.contains(KeyModifiers::ALT) => Some(Action::WordLeft),
206            KeyCode::Right if key.modifiers.contains(KeyModifiers::ALT) => Some(Action::WordRight),
207            KeyCode::Down => Some(Action::NextItem),
208            KeyCode::Up => Some(Action::PrevItem),
209            KeyCode::Char(' ') => Some(Action::ToggleFilterCheckbox),
210            KeyCode::Char(c) if c != ' ' => Some(Action::FilterInput(c)),
211            KeyCode::Backspace => Some(Action::FilterBackspace),
212            _ => None,
213        },
214        Mode::ErrorModal => match key.code {
215            KeyCode::Char('r') if key.modifiers.contains(KeyModifiers::CONTROL) => {
216                Some(Action::RetryLoad)
217            }
218            KeyCode::Char('y') => Some(Action::Yank),
219            KeyCode::Char('q') | KeyCode::Esc => Some(Action::Quit),
220            _ => None,
221        },
222        Mode::HelpModal => match key.code {
223            KeyCode::Esc | KeyCode::Enter | KeyCode::Char('q') | KeyCode::Char('?') => {
224                Some(Action::CloseMenu)
225            }
226            _ => None,
227        },
228        Mode::QuitConfirm => match key.code {
229            KeyCode::Char('y') | KeyCode::Enter => Some(Action::ConfirmQuit),
230            KeyCode::Char('n') | KeyCode::Esc | KeyCode::Char('q') => Some(Action::CancelQuit),
231            _ => None,
232        },
233        Mode::RegionPicker => match key.code {
234            KeyCode::Esc => Some(Action::ExitFilterMode),
235            KeyCode::Char('i') => Some(Action::EnterFilterMode),
236            KeyCode::Char('j') | KeyCode::Down => Some(Action::NextItem),
237            KeyCode::Char('k') | KeyCode::Up => Some(Action::PrevItem),
238            KeyCode::Enter => Some(Action::Select),
239            KeyCode::Char('l') if key.modifiers.contains(KeyModifiers::CONTROL) => {
240                Some(Action::MeasureLatency)
241            }
242            KeyCode::Char('r') if key.modifiers == KeyModifiers::CONTROL => Some(Action::Refresh),
243            KeyCode::Backspace => Some(Action::FilterBackspace),
244            KeyCode::Char(c) => Some(Action::FilterInput(c)),
245            _ => None,
246        },
247        Mode::CalendarPicker => match key.code {
248            KeyCode::Esc => Some(Action::CloseCalendar),
249            KeyCode::Left => Some(Action::CalendarPrevDay),
250            KeyCode::Down => Some(Action::CalendarNextWeek),
251            KeyCode::Up => Some(Action::CalendarPrevWeek),
252            KeyCode::Right => Some(Action::CalendarNextDay),
253            KeyCode::Char('n') | KeyCode::Tab => Some(Action::CalendarNextMonth),
254            KeyCode::Char('p') | KeyCode::BackTab => Some(Action::CalendarPrevMonth),
255            KeyCode::Enter => Some(Action::CalendarSelect),
256            _ => None,
257        },
258        Mode::TabPicker => match key.code {
259            KeyCode::Esc => Some(Action::CloseMenu),
260            KeyCode::Down => Some(Action::NextItem),
261            KeyCode::Up => Some(Action::PrevItem),
262            KeyCode::Enter => Some(Action::Select),
263            KeyCode::Backspace => Some(Action::FilterBackspace),
264            KeyCode::Char(c) => Some(Action::FilterInput(c)),
265            _ => None,
266        },
267        Mode::SessionPicker => match key.code {
268            KeyCode::Esc => Some(Action::ExitFilterMode),
269            KeyCode::Char('i') => Some(Action::EnterFilterMode),
270            KeyCode::Down => Some(Action::NextItem),
271            KeyCode::Up => Some(Action::PrevItem),
272            KeyCode::Enter => Some(Action::LoadSession),
273            KeyCode::Char('r') if key.modifiers == KeyModifiers::CONTROL => Some(Action::Refresh),
274            KeyCode::Backspace => Some(Action::FilterBackspace),
275            KeyCode::Char(c) => Some(Action::FilterInput(c)),
276            _ => None,
277        },
278        Mode::ProfilePicker => match key.code {
279            KeyCode::Esc => Some(Action::ExitFilterMode),
280            KeyCode::Char('i') => Some(Action::EnterFilterMode),
281            KeyCode::Down => Some(Action::NextItem),
282            KeyCode::Up => Some(Action::PrevItem),
283            KeyCode::Enter => Some(Action::Select),
284            KeyCode::Char('r') if key.modifiers == KeyModifiers::CONTROL => Some(Action::Refresh),
285            KeyCode::Backspace => Some(Action::FilterBackspace),
286            KeyCode::Char(c) => Some(Action::FilterInput(c)),
287            _ => None,
288        },
289    }
290}
291
292#[derive(Debug, Clone, PartialEq)]
293pub enum Action {
294    Quit,
295    ConfirmQuit,
296    CancelQuit,
297    CloseService,
298    NextItem,
299    PrevItem,
300    NextPane,
301    PrevPane,
302    CollapseRow,
303    ExpandRow,
304    Select,
305    OpenSpaceMenu,
306    CloseMenu,
307    OpenServicePicker,
308    OpenResourceFinder,
309    OpenRelatedResources,
310    OpenCloudWatch,
311    OpenCloudWatchSplit,
312    OpenCloudWatchAlarms,
313    FilterInput(char),
314    FilterBackspace,
315    DeleteWord,
316    WordLeft,
317    WordRight,
318    OpenColumnSelector,
319    ToggleColumn,
320    NextPreferences,
321    PrevPreferences,
322    CloseColumnSelector,
323    StartFilter,
324    StartEventFilter,
325    ApplyFilter,
326    ToggleExactMatch,
327    ToggleShowExpired,
328    GoBack,
329    NextFilterFocus,
330    PrevFilterFocus,
331    ToggleFilterCheckbox,
332    CycleSortColumn,
333    ToggleSortDirection,
334    ScrollUp,
335    ScrollDown,
336    PageUp,
337    PageDown,
338    Refresh,
339    MeasureLatency,
340    RetryLoad,
341    LoadMore,
342    Yank,
343    OpenInConsole,
344    OpenInBrowser,
345    ShowHelp,
346    OpenRegionPicker,
347    OpenCalendar,
348    CloseCalendar,
349    CalendarPrevDay,
350    CalendarNextDay,
351    CalendarPrevWeek,
352    CalendarNextWeek,
353    CalendarPrevMonth,
354    CalendarNextMonth,
355    CalendarSelect,
356    NextTab,
357    PrevTab,
358    NextDetailTab,
359    PrevDetailTab,
360    CloseTab,
361    OpenTabPicker,
362    OpenSessionPicker,
363    OpenProfilePicker,
364    LoadSession,
365    SaveSession,
366    CopyToClipboard,
367    EnterFilterMode,
368    ExitFilterMode,
369    Noop,
370}
371
372#[cfg(test)]
373mod tests {
374    use super::*;
375
376    #[test]
377    fn test_space_o_opens_service_menu() {
378        let key = KeyEvent::new(KeyCode::Char('o'), KeyModifiers::NONE);
379        let action = handle_key(key, Mode::SpaceMenu);
380        assert_eq!(action, Some(Action::OpenServicePicker));
381    }
382
383    #[test]
384    fn test_insights_input_accepts_chars() {
385        let key = KeyEvent::new(KeyCode::Char('a'), KeyModifiers::NONE);
386        let action = handle_key(key, Mode::InsightsInput);
387        assert_eq!(action, Some(Action::FilterInput('a')));
388
389        let key2 = KeyEvent::new(KeyCode::Char('1'), KeyModifiers::NONE);
390        let action2 = handle_key(key2, Mode::InsightsInput);
391        assert_eq!(action2, Some(Action::FilterInput('1')));
392    }
393
394    #[test]
395    fn test_insights_input_esc_closes() {
396        let key = KeyEvent::new(KeyCode::Esc, KeyModifiers::NONE);
397        let action = handle_key(key, Mode::InsightsInput);
398        assert_eq!(action, Some(Action::CloseMenu));
399    }
400
401    #[test]
402    fn test_service_menu_accepts_input() {
403        let key = KeyEvent::new(KeyCode::Char('c'), KeyModifiers::NONE);
404        let action = handle_key(key, Mode::ServicePicker);
405        assert_eq!(action, Some(Action::FilterInput('c')));
406    }
407
408    #[test]
409    fn test_service_menu_navigation() {
410        let key_down = KeyEvent::new(KeyCode::Down, KeyModifiers::NONE);
411        let action_down = handle_key(key_down, Mode::ServicePicker);
412        assert_eq!(action_down, Some(Action::NextItem));
413
414        let key_up = KeyEvent::new(KeyCode::Up, KeyModifiers::NONE);
415        let action_up = handle_key(key_up, Mode::ServicePicker);
416        assert_eq!(action_up, Some(Action::PrevItem));
417    }
418
419    #[test]
420    fn test_service_menu_backspace() {
421        let key = KeyEvent::new(KeyCode::Backspace, KeyModifiers::NONE);
422        let action = handle_key(key, Mode::ServicePicker);
423        assert_eq!(action, Some(Action::FilterBackspace));
424    }
425
426    #[test]
427    fn test_ctrl_shift_n_next_tab() {
428        let key = KeyEvent::new(
429            KeyCode::Char('N'),
430            KeyModifiers::CONTROL | KeyModifiers::SHIFT,
431        );
432        let action = handle_key(key, Mode::Normal);
433        assert_eq!(action, Some(Action::NextTab));
434    }
435
436    #[test]
437    fn test_ctrl_shift_p_prev_tab() {
438        let key = KeyEvent::new(
439            KeyCode::Char('P'),
440            KeyModifiers::CONTROL | KeyModifiers::SHIFT,
441        );
442        let action = handle_key(key, Mode::Normal);
443        assert_eq!(action, Some(Action::PrevTab));
444    }
445
446    #[test]
447    fn test_space_c_close_tab() {
448        let key = KeyEvent::new(KeyCode::Char('c'), KeyModifiers::NONE);
449        let action = handle_key(key, Mode::SpaceMenu);
450        assert_eq!(action, Some(Action::CloseService));
451    }
452
453    #[test]
454    fn test_space_b_window_picker() {
455        let key = KeyEvent::new(KeyCode::Char('b'), KeyModifiers::NONE);
456        let action = handle_key(key, Mode::SpaceMenu);
457        assert_eq!(action, Some(Action::OpenTabPicker));
458    }
459
460    #[test]
461    fn test_window_picker_navigation() {
462        let key_down = KeyEvent::new(KeyCode::Down, KeyModifiers::NONE);
463        let action = handle_key(key_down, Mode::TabPicker);
464        assert_eq!(action, Some(Action::NextItem));
465
466        let key_up = KeyEvent::new(KeyCode::Up, KeyModifiers::NONE);
467        let action_up = handle_key(key_up, Mode::TabPicker);
468        assert_eq!(action_up, Some(Action::PrevItem));
469    }
470
471    #[test]
472    fn test_window_picker_select() {
473        let key = KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE);
474        let action = handle_key(key, Mode::TabPicker);
475        assert_eq!(action, Some(Action::Select));
476    }
477
478    #[test]
479    fn test_space_opens_space_menu_in_normal_mode() {
480        let key = KeyEvent::new(KeyCode::Char(' '), KeyModifiers::NONE);
481        let action = handle_key(key, Mode::Normal);
482        assert_eq!(action, Some(Action::OpenSpaceMenu));
483    }
484
485    #[test]
486    fn test_space_menu_o_opens_service_menu() {
487        let key = KeyEvent::new(KeyCode::Char('o'), KeyModifiers::NONE);
488        let action = handle_key(key, Mode::SpaceMenu);
489        assert_eq!(action, Some(Action::OpenServicePicker));
490    }
491
492    #[test]
493    fn test_ctrl_r_refreshes_profile_picker() {
494        let key = KeyEvent::new(KeyCode::Char('r'), KeyModifiers::CONTROL);
495        let action = handle_key(key, Mode::ProfilePicker);
496        assert_eq!(action, Some(Action::Refresh));
497    }
498
499    #[test]
500    fn test_ctrl_r_refreshes_region_picker() {
501        let key = KeyEvent::new(KeyCode::Char('r'), KeyModifiers::CONTROL);
502        let action = handle_key(key, Mode::RegionPicker);
503        assert_eq!(action, Some(Action::Refresh));
504    }
505
506    #[test]
507    fn test_ctrl_r_refreshes_session_picker() {
508        let key = KeyEvent::new(KeyCode::Char('r'), KeyModifiers::CONTROL);
509        let action = handle_key(key, Mode::SessionPicker);
510        assert_eq!(action, Some(Action::Refresh));
511    }
512
513    #[test]
514    fn test_p_opens_column_selector() {
515        let key = KeyEvent::new(KeyCode::Char('p'), KeyModifiers::NONE);
516        let action = handle_key(key, Mode::Normal);
517        assert_eq!(
518            action,
519            Some(Action::OpenColumnSelector),
520            "p should open column selector (preferences)"
521        );
522    }
523
524    #[test]
525    fn test_ctrl_p_copies_to_clipboard() {
526        let key = KeyEvent::new(KeyCode::Char('p'), KeyModifiers::CONTROL);
527        let action = handle_key(key, Mode::Normal);
528        assert_eq!(
529            action,
530            Some(Action::CopyToClipboard),
531            "Ctrl+P should copy screen to clipboard (print)"
532        );
533    }
534
535    #[test]
536    fn test_y_yanks_selected_item() {
537        let key = KeyEvent::new(KeyCode::Char('y'), KeyModifiers::NONE);
538        let action = handle_key(key, Mode::Normal);
539        assert_eq!(
540            action,
541            Some(Action::Yank),
542            "y should yank (copy) selected item"
543        );
544    }
545
546    #[test]
547    fn test_space_toggles_checkbox_in_filter_input() {
548        let key = KeyEvent::new(KeyCode::Char(' '), KeyModifiers::NONE);
549        let action = handle_key(key, Mode::FilterInput);
550        assert_eq!(
551            action,
552            Some(Action::ToggleFilterCheckbox),
553            "Space should toggle checkbox in FilterInput mode"
554        );
555    }
556
557    #[test]
558    fn test_space_not_added_to_filter_text() {
559        // Space should toggle checkbox, not be added to filter text
560        let key = KeyEvent::new(KeyCode::Char(' '), KeyModifiers::NONE);
561        let action = handle_key(key, Mode::FilterInput);
562        assert_ne!(
563            action,
564            Some(Action::FilterInput(' ')),
565            "Space should not be added to filter text"
566        );
567    }
568
569    #[test]
570    fn test_q_in_normal_mode_shows_quit_confirmation() {
571        let key = KeyEvent::new(KeyCode::Char('q'), KeyModifiers::NONE);
572        let action = handle_key(key, Mode::Normal);
573        assert_eq!(
574            action,
575            Some(Action::Quit),
576            "q in Normal mode must trigger Quit (shows confirmation dialog)"
577        );
578    }
579
580    #[test]
581    fn test_ctrl_c_quits_immediately_without_confirmation() {
582        let key = KeyEvent::new(KeyCode::Char('c'), KeyModifiers::CONTROL);
583        let action = handle_key(key, Mode::Normal);
584        assert_eq!(
585            action,
586            Some(Action::ConfirmQuit),
587            "Ctrl+C must quit immediately (ConfirmQuit, not Quit)"
588        );
589    }
590
591    #[test]
592    fn test_q_in_service_picker_shows_quit_confirmation() {
593        // 'q' in ServicePicker now dispatches FilterInput('q') so it can type 'q'
594        // when filter is active (e.g. typing "sqs"). When filter is NOT active,
595        // app.rs handles FilterInput('q') as QuitConfirm.
596        let key = KeyEvent::new(KeyCode::Char('q'), KeyModifiers::NONE);
597        let action = handle_key(key, Mode::ServicePicker);
598        assert_eq!(
599            action,
600            Some(Action::FilterInput('q')),
601            "q in ServicePicker must dispatch FilterInput so it can type 'sqs' in filter"
602        );
603    }
604
605    #[test]
606    fn test_q_in_service_picker_not_filtering_triggers_quit() {
607        // When ServicePicker filter is NOT active, FilterInput('q') triggers QuitConfirm
608        use crate::app::App;
609        let mut app = App::new_without_client("default".to_string(), None);
610        app.mode = Mode::ServicePicker;
611        app.service_picker.filter_active = false; // not filtering
612
613        app.handle_action(Action::FilterInput('q'));
614
615        assert_eq!(
616            app.mode,
617            Mode::QuitConfirm,
618            "q when not filtering in ServicePicker must show quit confirmation"
619        );
620    }
621
622    #[test]
623    fn test_q_in_service_picker_while_filtering_types_q() {
624        // When ServicePicker filter IS active (user is typing), 'q' types into filter
625        use crate::app::App;
626        let mut app = App::new_without_client("default".to_string(), None);
627        app.mode = Mode::ServicePicker;
628        app.service_picker.filter_active = true; // filter is active (user pressed 'i')
629
630        app.handle_action(Action::FilterInput('q'));
631
632        assert_eq!(
633            app.mode,
634            Mode::ServicePicker,
635            "q while filtering must NOT quit"
636        );
637        assert_eq!(
638            app.service_picker.filter, "q",
639            "q while filtering must type 'q' into filter"
640        );
641    }
642
643    #[test]
644    fn test_i_in_service_picker_dispatches_filter_input() {
645        let key = KeyEvent::new(KeyCode::Char('i'), KeyModifiers::NONE);
646        let action = handle_key(key, Mode::ServicePicker);
647        assert_eq!(
648            action,
649            Some(Action::FilterInput('i')),
650            "i in ServicePicker must dispatch FilterInput('i')"
651        );
652    }
653
654    #[test]
655    fn test_i_in_service_picker_not_filtering_activates_filter() {
656        use crate::app::App;
657        let mut app = App::new_without_client("default".to_string(), None);
658        app.mode = Mode::ServicePicker;
659        app.service_picker.filter_active = false;
660
661        app.handle_action(Action::FilterInput('i'));
662
663        assert!(app.service_picker.filter_active);
664        assert_eq!(
665            app.service_picker.filter, "",
666            "i activates filter but must not type itself"
667        );
668    }
669
670    #[test]
671    fn test_i_in_service_picker_while_filtering_types_i() {
672        use crate::app::App;
673        let mut app = App::new_without_client("default".to_string(), None);
674        app.mode = Mode::ServicePicker;
675        app.service_picker.filter_active = true;
676        app.service_picker.filter = "sqs".to_string();
677
678        app.handle_action(Action::FilterInput('i'));
679
680        assert_eq!(
681            app.service_picker.filter, "sqsi",
682            "i while filtering must type 'i' into filter"
683        );
684        assert_eq!(
685            app.mode,
686            Mode::ServicePicker,
687            "mode must stay ServicePicker"
688        );
689    }
690
691    #[test]
692    fn test_service_filter_types_all_chars_in_sqs() {
693        use crate::app::App;
694        let mut app = App::new_without_client("default".to_string(), None);
695        app.mode = Mode::ServicePicker;
696        app.service_picker.filter_active = true;
697
698        for c in ['s', 'q', 's'] {
699            app.handle_action(Action::FilterInput(c));
700        }
701
702        assert_eq!(app.service_picker.filter, "sqs");
703        assert_eq!(app.mode, Mode::ServicePicker);
704    }
705
706    #[test]
707    fn test_any_char_auto_activates_filter_in_service_picker() {
708        // Typing any non-q char when not filtering auto-activates and types it
709        use crate::app::App;
710        let mut app = App::new_without_client("default".to_string(), None);
711        app.mode = Mode::ServicePicker;
712        app.service_picker.filter_active = false;
713
714        app.handle_action(Action::FilterInput('s'));
715
716        assert!(app.service_picker.filter_active);
717        assert_eq!(app.service_picker.filter, "s");
718    }
719
720    #[test]
721    fn test_quit_confirm_mode_y_confirms() {
722        let key = KeyEvent::new(KeyCode::Char('y'), KeyModifiers::NONE);
723        let action = handle_key(key, Mode::QuitConfirm);
724        assert_eq!(action, Some(Action::ConfirmQuit));
725    }
726
727    #[test]
728    fn test_quit_confirm_mode_enter_confirms() {
729        let key = KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE);
730        let action = handle_key(key, Mode::QuitConfirm);
731        assert_eq!(action, Some(Action::ConfirmQuit));
732    }
733
734    #[test]
735    fn test_quit_confirm_mode_n_cancels() {
736        let key = KeyEvent::new(KeyCode::Char('n'), KeyModifiers::NONE);
737        let action = handle_key(key, Mode::QuitConfirm);
738        assert_eq!(action, Some(Action::CancelQuit));
739    }
740
741    #[test]
742    fn test_quit_confirm_mode_esc_cancels() {
743        let key = KeyEvent::new(KeyCode::Esc, KeyModifiers::NONE);
744        let action = handle_key(key, Mode::QuitConfirm);
745        assert_eq!(action, Some(Action::CancelQuit));
746    }
747
748    #[test]
749    fn test_quit_shows_confirmation_dialog_not_immediate_quit() {
750        use crate::app::{App, Service};
751        let mut app = App::new_without_client("default".to_string(), None);
752        app.current_service = Service::CloudWatchLogGroups;
753        app.service_selected = true;
754        app.mode = Mode::Normal;
755
756        app.handle_action(Action::Quit);
757
758        assert_eq!(
759            app.mode,
760            Mode::QuitConfirm,
761            "q must show confirmation dialog"
762        );
763        assert!(
764            app.running,
765            "app must still be running after q (before confirmation)"
766        );
767    }
768
769    #[test]
770    fn test_confirm_quit_actually_quits() {
771        use crate::app::App;
772        let mut app = App::new_without_client("default".to_string(), None);
773        app.mode = Mode::QuitConfirm;
774
775        app.handle_action(Action::ConfirmQuit);
776
777        assert!(!app.running, "ConfirmQuit must set running=false");
778    }
779
780    #[test]
781    fn test_cancel_quit_returns_to_normal() {
782        use crate::app::App;
783        let mut app = App::new_without_client("default".to_string(), None);
784        app.mode = Mode::QuitConfirm;
785
786        app.handle_action(Action::CancelQuit);
787
788        assert_eq!(
789            app.mode,
790            Mode::Normal,
791            "CancelQuit must return to Normal mode"
792        );
793        assert!(app.running, "app must still be running after cancel");
794    }
795}