1use crossterm::event::{KeyCode, KeyEvent, KeyModifiers};
7
8use crate::actions::transfer::TransferKind;
9use crate::actions::Action;
10use crate::app::sort::SortBy;
11use crate::app::state::AppState;
12use crate::config::KeymapConfig;
13use crate::input::InputCtx;
14
15pub fn navigation(key: KeyEvent, _ctx: &mut InputCtx, state: &AppState) -> Option<Action> {
22 if state.pending_delete {
23 return match key.code {
24 KeyCode::Enter | KeyCode::Char('y') => Some(Action::ConfirmDelete),
25 KeyCode::Esc | KeyCode::Char('n') => Some(Action::CancelDelete),
26 _ => None,
27 };
28 }
29
30 if let Some(pending) = state.pending_nav_key {
31 if let Some(sequence) = append_to_sequence(pending, key) {
32 return nav_action_for_sequence(&state.config.keymap, &sequence);
33 }
34 return None;
35 }
36
37 if let Some(action) = configured_nav_action(key, &state.config.keymap) {
38 return Some(action);
39 }
40
41 if let Some(prefix) = configured_nav_prefix(key, &state.config.keymap) {
42 return Some(Action::SetPendingNavKey(prefix));
43 }
44
45 match key.code {
46 KeyCode::Down if key.modifiers.contains(KeyModifiers::SHIFT) => {
50 Some(Action::PreviewScrollDown)
51 }
52 KeyCode::Up if key.modifiers.contains(KeyModifiers::SHIFT) => Some(Action::PreviewScrollUp),
53 KeyCode::Down => Some(Action::MoveDown),
54 KeyCode::Up => Some(Action::MoveUp),
55 KeyCode::Enter | KeyCode::Right => Some(Action::EnterOrOpen),
56 KeyCode::Backspace | KeyCode::Left => Some(Action::GoParent),
57 KeyCode::Char('c') if is_ctrl_chord(key.modifiers) => Some(Action::Cancel),
59 KeyCode::Char('t') if is_ctrl_chord(key.modifiers) => Some(Action::NewTab),
62 KeyCode::Char('w') if is_ctrl_chord(key.modifiers) => Some(Action::CloseTab),
63 KeyCode::Tab if key.modifiers.contains(KeyModifiers::SHIFT) => Some(Action::SwitchTabPrev),
64 KeyCode::Tab => Some(Action::SwitchTabNext),
65 KeyCode::Esc => None,
66 _ => None,
67 }
68}
69
70pub fn visual(key: KeyEvent, ctx: &mut InputCtx, state: &AppState) -> Option<Action> {
84 let action = navigation(key, ctx, state)?;
85 match action {
86 Action::MoveDown
87 | Action::MoveUp
88 | Action::JumpTop
89 | Action::JumpBottom
90 | Action::SetPendingNavKey(_)
91 | Action::BeginDelete
92 | Action::ConfirmDelete
93 | Action::CancelDelete
94 | Action::Cancel
95 | Action::PreviewScrollDown
96 | Action::PreviewScrollUp
97 | Action::PreviewPageDown
98 | Action::PreviewPageUp
99 | Action::PreviewScrollTop
100 | Action::PreviewScrollBottom => Some(action),
101 Action::ToggleMark | Action::EnterVisual => Some(Action::CommitVisual(None)),
102 Action::GoParent
103 | Action::EnterOrOpen
104 | Action::HistoryBack
105 | Action::HistoryForward
106 | Action::ReturnFromJump => Some(Action::CommitVisual(Some(Box::new(action)))),
107 Action::ClearMarks => Some(Action::ExitMode),
108 _ => None,
109 }
110}
111
112pub fn search(key: KeyEvent, keymap: &KeymapConfig) -> Option<Action> {
127 if let KeyCode::Char(ch) = key.code {
128 if super::is_text_modifiers(key.modifiers) {
130 return Some(Action::SearchAppendChar(ch));
131 }
132 }
133
134 if let Some(action) = configured_search_action(key, keymap) {
135 return Some(action);
136 }
137
138 match key.code {
139 KeyCode::Esc => Some(Action::ExitMode),
140 KeyCode::Enter | KeyCode::Right => Some(Action::SearchConfirm),
141 KeyCode::Down => Some(Action::SearchMoveDown),
142 KeyCode::Up => Some(Action::SearchMoveUp),
143 KeyCode::Backspace => Some(Action::SearchDeleteChar),
144 KeyCode::Char('n') if key.modifiers.contains(KeyModifiers::CONTROL) => {
147 Some(Action::SearchMoveDown)
148 }
149 KeyCode::Char('p') if key.modifiers.contains(KeyModifiers::CONTROL) => {
150 Some(Action::SearchMoveUp)
151 }
152 KeyCode::Char('h') if key.modifiers.contains(KeyModifiers::CONTROL) => {
153 Some(Action::SearchDeleteChar)
154 }
155 _ => None,
156 }
157}
158
159fn configured_nav_action(key: KeyEvent, keymap: &KeymapConfig) -> Option<Action> {
160 let key_text = key_to_config_string(key)?;
161 nav_action_for_sequence(keymap, &key_text)
162}
163
164fn configured_search_action(key: KeyEvent, keymap: &KeymapConfig) -> Option<Action> {
165 let key_text = key_to_config_string(key)?;
166 keymap.search.iter().find_map(|(name, binding)| {
167 if binding == &key_text {
168 search_action_from_name(name)
169 } else {
170 None
171 }
172 })
173}
174
175fn configured_nav_prefix(key: KeyEvent, keymap: &KeymapConfig) -> Option<char> {
183 let KeyCode::Char(ch) = key.code else {
184 return None;
185 };
186 if is_ctrl_chord(key.modifiers) {
187 return None;
188 }
189 keymap
190 .navigation
191 .values()
192 .filter(|binding| !is_named_key(binding))
193 .any(|binding| binding.len() > ch.len_utf8() && binding.starts_with(ch))
194 .then_some(ch)
195}
196
197fn is_ctrl_chord(modifiers: KeyModifiers) -> bool {
206 modifiers.contains(KeyModifiers::CONTROL) && !super::is_text_modifiers(modifiers)
207}
208
209const NAMED_KEYS: &[&str] = &[
211 "enter",
212 "esc",
213 "backspace",
214 "tab",
215 "left",
216 "right",
217 "up",
218 "down",
219 "home",
220 "end",
221 "space",
222];
223
224fn is_named_key(binding: &str) -> bool {
230 let base = binding
231 .strip_prefix("ctrl-")
232 .or_else(|| binding.strip_prefix("shift-"))
233 .unwrap_or(binding);
234 binding != base || NAMED_KEYS.contains(&base)
235}
236
237fn nav_action_for_sequence(keymap: &KeymapConfig, sequence: &str) -> Option<Action> {
238 keymap.navigation.iter().find_map(|(name, binding)| {
239 if binding == sequence {
240 nav_action_from_name(name)
241 } else {
242 None
243 }
244 })
245}
246
247fn nav_action_from_name(name: &str) -> Option<Action> {
248 match name {
249 "move_down" => Some(Action::MoveDown),
250 "move_up" => Some(Action::MoveUp),
251 "jump_top" => Some(Action::JumpTop),
252 "jump_bottom" => Some(Action::JumpBottom),
253 "enter_or_open" => Some(Action::EnterOrOpen),
254 "go_parent" => Some(Action::GoParent),
255 "history_back" => Some(Action::HistoryBack),
256 "history_forward" => Some(Action::HistoryForward),
257 "return_from_jump" => Some(Action::ReturnFromJump),
258 "refresh" => Some(Action::Refresh),
259 "toggle_hidden" => Some(Action::ToggleHidden),
260 "sort_name" => Some(Action::SetSortBy(SortBy::Name)),
264 "sort_size" => Some(Action::SetSortBy(SortBy::Size)),
265 "sort_time" => Some(Action::SetSortBy(SortBy::Modified)),
266 "sort_extension" => Some(Action::SetSortBy(SortBy::Extension)),
267 "sort_reverse" => Some(Action::ToggleSortReverse),
268 "sort_dirs_first" => Some(Action::ToggleDirsFirst),
269 "toggle_details" => Some(Action::CycleEntryDetails),
270 "copy_absolute_path" => Some(Action::CopyAbsPath),
271 "copy_relative_path" => Some(Action::CopyRelPath),
272 "copy_filename" => Some(Action::CopyFilename),
273 "copy_content" => Some(Action::CopyContent),
274 "delete" => Some(Action::BeginDelete),
275 "toggle_mark" => Some(Action::ToggleMark),
276 "clear_marks" => Some(Action::ClearMarks),
277 "paste_copy" => Some(Action::PasteMarks(TransferKind::Copy)),
278 "paste_move" => Some(Action::PasteMarks(TransferKind::Move)),
279 "enter_visual" => Some(Action::EnterVisual),
280 "repeat_command" => Some(Action::RepeatLastCommand),
281 "enter_search" => Some(Action::EnterSearch),
282 "enter_command" => Some(Action::EnterCommand),
283 "quit" => Some(Action::Quit),
284 "open_with_os" => Some(Action::OpenWithOs),
285 "new_tab" => Some(Action::NewTab),
287 "close_tab" => Some(Action::CloseTab),
288 "switch_tab_next" => Some(Action::SwitchTabNext),
289 "switch_tab_prev" => Some(Action::SwitchTabPrev),
290 "preview_scroll_down" => Some(Action::PreviewScrollDown),
292 "preview_scroll_up" => Some(Action::PreviewScrollUp),
293 "preview_page_down" => Some(Action::PreviewPageDown),
294 "preview_page_up" => Some(Action::PreviewPageUp),
295 "preview_scroll_top" => Some(Action::PreviewScrollTop),
296 "preview_scroll_bottom" => Some(Action::PreviewScrollBottom),
297 _ => None,
298 }
299}
300
301fn search_action_from_name(name: &str) -> Option<Action> {
302 match name {
303 "exit" => Some(Action::ExitMode),
304 "confirm" => Some(Action::SearchConfirm),
305 "move_down" => Some(Action::SearchMoveDown),
306 "move_up" => Some(Action::SearchMoveUp),
307 "delete_char" => Some(Action::SearchDeleteChar),
308 _ => None,
309 }
310}
311
312fn append_to_sequence(prefix: char, key: KeyEvent) -> Option<String> {
313 let KeyCode::Char(ch) = key.code else {
314 return None;
315 };
316 if is_ctrl_chord(key.modifiers) {
317 return None;
318 }
319 Some(format!("{prefix}{ch}"))
320}
321
322fn key_to_config_string(key: KeyEvent) -> Option<String> {
332 let named = match key.code {
333 KeyCode::Char(ch) if is_ctrl_chord(key.modifiers) => {
334 return Some(format!("ctrl-{ch}").to_ascii_lowercase());
335 }
336 KeyCode::Char(' ') => "space",
339 KeyCode::Char(ch) => return Some(ch.to_string()),
340 KeyCode::Enter => "enter",
341 KeyCode::Esc => "esc",
342 KeyCode::Backspace => "backspace",
343 KeyCode::Tab => "tab",
344 KeyCode::BackTab => return Some("shift-tab".to_owned()),
348 KeyCode::Left => "left",
349 KeyCode::Right => "right",
350 KeyCode::Up => "up",
351 KeyCode::Down => "down",
352 KeyCode::Home => "home",
353 KeyCode::End => "end",
354 _ => return None,
355 };
356
357 if key.modifiers.contains(KeyModifiers::SHIFT) {
358 Some(format!("shift-{named}"))
359 } else {
360 Some(named.to_owned())
361 }
362}
363
364#[cfg(test)]
365mod tests {
366 use super::*;
367 use crossterm::event::{KeyEventKind, KeyEventState};
368
369 #[test]
373 fn a_named_key_binding_does_not_make_its_first_letter_a_prefix() {
374 let keymap = crate::config::load(None).unwrap().keymap;
375 assert_eq!(
376 configured_nav_prefix(key(KeyCode::Char('t')), &keymap),
377 None
378 );
379 assert_eq!(
380 configured_nav_prefix(key(KeyCode::Char('c')), &keymap),
381 None
382 );
383 }
384
385 #[test]
386 fn real_sequences_still_make_prefixes() {
387 let keymap = crate::config::load(None).unwrap().keymap;
388 for ch in ['g', 'y', 'd', 's'] {
389 assert_eq!(
390 configured_nav_prefix(key(KeyCode::Char(ch)), &keymap),
391 Some(ch),
392 "`{ch}` starts a default sequence"
393 );
394 }
395 }
396
397 #[test]
400 fn every_translated_named_key_is_recognised() {
401 let codes = [
402 KeyCode::Enter,
403 KeyCode::Esc,
404 KeyCode::Backspace,
405 KeyCode::Tab,
406 KeyCode::BackTab,
407 KeyCode::Left,
408 KeyCode::Right,
409 KeyCode::Up,
410 KeyCode::Down,
411 KeyCode::Home,
412 KeyCode::End,
413 KeyCode::Char(' '),
414 ];
415 for code in codes {
416 for modifiers in [KeyModifiers::NONE, KeyModifiers::SHIFT] {
417 let event = KeyEvent {
418 modifiers,
419 ..key(code)
420 };
421 let name = key_to_config_string(event).expect("named key translates");
422 assert!(is_named_key(&name), "`{name}` is not recognised as named");
423 }
424 }
425 assert!(is_named_key("ctrl-r"));
426 assert!(!is_named_key("gg"));
427 assert!(!is_named_key("t"));
428 }
429
430 fn key(code: KeyCode) -> KeyEvent {
431 KeyEvent {
432 code,
433 modifiers: KeyModifiers::NONE,
434 kind: KeyEventKind::Press,
435 state: KeyEventState::NONE,
436 }
437 }
438
439 #[test]
440 fn parses_ctrl_key_binding() {
441 let event = KeyEvent {
442 code: KeyCode::Char('r'),
443 modifiers: KeyModifiers::CONTROL,
444 kind: KeyEventKind::Press,
445 state: KeyEventState::NONE,
446 };
447 assert_eq!(key_to_config_string(event), Some("ctrl-r".to_owned()));
448 }
449
450 #[test]
454 fn search_types_every_bare_character_even_when_bound() {
455 let mut keymap = crate::config::load(None).unwrap().keymap;
456 keymap.search.insert("move_down".to_owned(), "j".to_owned());
457 keymap.search.insert("move_up".to_owned(), "k".to_owned());
458
459 for ch in ['j', 'k', 'q', ':', '/', 'Z'] {
460 assert_eq!(
461 search(key(KeyCode::Char(ch)), &keymap),
462 Some(Action::SearchAppendChar(ch)),
463 "{ch} must reach the query"
464 );
465 }
466 }
467
468 #[test]
469 fn search_moves_with_keys_that_are_not_text() {
470 let keymap = crate::config::load(None).unwrap().keymap;
471 let ctrl = |ch| KeyEvent {
472 code: KeyCode::Char(ch),
473 modifiers: KeyModifiers::CONTROL,
474 kind: KeyEventKind::Press,
475 state: KeyEventState::NONE,
476 };
477
478 assert_eq!(
479 search(key(KeyCode::Down), &keymap),
480 Some(Action::SearchMoveDown)
481 );
482 assert_eq!(
483 search(key(KeyCode::Up), &keymap),
484 Some(Action::SearchMoveUp)
485 );
486 assert_eq!(search(ctrl('n'), &keymap), Some(Action::SearchMoveDown));
487 assert_eq!(search(ctrl('p'), &keymap), Some(Action::SearchMoveUp));
488 assert_eq!(search(ctrl('h'), &keymap), Some(Action::SearchDeleteChar));
489 assert_eq!(
490 search(key(KeyCode::Enter), &keymap),
491 Some(Action::SearchConfirm)
492 );
493 assert_eq!(search(key(KeyCode::Esc), &keymap), Some(Action::ExitMode));
494 }
495
496 #[test]
497 fn shifted_characters_are_still_text() {
498 let keymap = crate::config::load(None).unwrap().keymap;
499 let shifted = KeyEvent {
500 code: KeyCode::Char('K'),
501 modifiers: KeyModifiers::SHIFT,
502 kind: KeyEventKind::Press,
503 state: KeyEventState::NONE,
504 };
505 assert_eq!(
506 search(shifted, &keymap),
507 Some(Action::SearchAppendChar('K'))
508 );
509 }
510
511 #[test]
513 fn altgr_characters_are_text() {
514 let keymap = crate::config::load(None).unwrap().keymap;
515 let altgr = KeyEvent {
516 code: KeyCode::Char('\\'),
517 modifiers: KeyModifiers::CONTROL | KeyModifiers::ALT,
518 kind: KeyEventKind::Press,
519 state: KeyEventState::NONE,
520 };
521 assert_eq!(search(altgr, &keymap), Some(Action::SearchAppendChar('\\')));
522 }
523
524 #[test]
528 fn preview_scroll_bindings_resolve_from_defaults() {
529 let dir = tempfile::tempdir().unwrap();
530 let state = AppState::new(dir.path().to_owned()).unwrap();
531 let mut ctx = InputCtx::default();
532 let ctrl = |ch| KeyEvent {
533 code: KeyCode::Char(ch),
534 modifiers: KeyModifiers::CONTROL,
535 kind: KeyEventKind::Press,
536 state: KeyEventState::NONE,
537 };
538 let shifted = |code| KeyEvent {
539 code,
540 modifiers: KeyModifiers::SHIFT,
541 kind: KeyEventKind::Press,
542 state: KeyEventState::NONE,
543 };
544
545 for (event, expected) in [
546 (key(KeyCode::Char('J')), Action::PreviewScrollDown),
547 (key(KeyCode::Char('K')), Action::PreviewScrollUp),
548 (ctrl('f'), Action::PreviewPageDown),
549 (ctrl('b'), Action::PreviewPageUp),
550 (shifted(KeyCode::Home), Action::PreviewScrollTop),
551 (shifted(KeyCode::End), Action::PreviewScrollBottom),
552 ] {
553 assert_eq!(
554 navigation(event, &mut ctx, &state),
555 Some(expected.clone()),
556 "{event:?} must map to {expected:?}"
557 );
558 }
559 }
560
561 #[test]
565 fn shift_arrows_scroll_the_preview_and_bare_arrows_still_move() {
566 let dir = tempfile::tempdir().unwrap();
567 let state = AppState::new(dir.path().to_owned()).unwrap();
568 let mut ctx = InputCtx::default();
569 let shifted = |code| KeyEvent {
570 code,
571 modifiers: KeyModifiers::SHIFT,
572 kind: KeyEventKind::Press,
573 state: KeyEventState::NONE,
574 };
575
576 assert_eq!(
577 navigation(shifted(KeyCode::Down), &mut ctx, &state),
578 Some(Action::PreviewScrollDown)
579 );
580 assert_eq!(
581 navigation(shifted(KeyCode::Up), &mut ctx, &state),
582 Some(Action::PreviewScrollUp)
583 );
584 assert_eq!(
585 navigation(key(KeyCode::Down), &mut ctx, &state),
586 Some(Action::MoveDown)
587 );
588 assert_eq!(
589 navigation(key(KeyCode::Up), &mut ctx, &state),
590 Some(Action::MoveUp)
591 );
592 assert_eq!(
593 navigation(key(KeyCode::Char('j')), &mut ctx, &state),
594 Some(Action::MoveDown)
595 );
596 assert_eq!(
597 navigation(key(KeyCode::Char('k')), &mut ctx, &state),
598 Some(Action::MoveUp)
599 );
600 }
601
602 #[test]
603 fn shift_is_spelled_out_for_named_keys_only() {
604 let shifted = |code| KeyEvent {
605 code,
606 modifiers: KeyModifiers::SHIFT,
607 kind: KeyEventKind::Press,
608 state: KeyEventState::NONE,
609 };
610
611 assert_eq!(
612 key_to_config_string(shifted(KeyCode::Down)),
613 Some("shift-down".to_owned())
614 );
615 assert_eq!(
616 key_to_config_string(shifted(KeyCode::End)),
617 Some("shift-end".to_owned())
618 );
619 assert_eq!(
620 key_to_config_string(key(KeyCode::Home)),
621 Some("home".to_owned())
622 );
623 assert_eq!(
625 key_to_config_string(shifted(KeyCode::Char('J'))),
626 Some("J".to_owned())
627 );
628 assert_eq!(
630 key_to_config_string(key(KeyCode::BackTab)),
631 Some("shift-tab".to_owned())
632 );
633 assert_eq!(
634 key_to_config_string(shifted(KeyCode::Tab)),
635 Some("shift-tab".to_owned())
636 );
637 }
638
639 #[test]
640 fn mark_and_paste_bindings_resolve_from_defaults() {
641 let dir = tempfile::tempdir().unwrap();
642 let mut state = AppState::new(dir.path().to_owned()).unwrap();
643 let mut ctx = InputCtx::default();
644
645 assert_eq!(
646 navigation(key(KeyCode::Char(' ')), &mut ctx, &state),
647 Some(Action::ToggleMark)
648 );
649 assert_eq!(
650 navigation(key(KeyCode::Esc), &mut ctx, &state),
651 Some(Action::ClearMarks)
652 );
653 assert_eq!(
654 navigation(key(KeyCode::Char('p')), &mut ctx, &state),
655 Some(Action::SetPendingNavKey('p'))
656 );
657 state.pending_nav_key = Some('p');
658 assert_eq!(
659 navigation(key(KeyCode::Char('p')), &mut ctx, &state),
660 Some(Action::PasteMarks(TransferKind::Copy))
661 );
662 assert_eq!(
663 navigation(key(KeyCode::Char('m')), &mut ctx, &state),
664 Some(Action::PasteMarks(TransferKind::Move))
665 );
666 }
667
668 #[test]
669 fn visual_mode_moves_commits_cancels_and_ignores_the_rest() {
670 let dir = tempfile::tempdir().unwrap();
671 std::fs::write(dir.path().join("a"), b"").unwrap();
672 let mut state = AppState::new(dir.path().to_owned()).unwrap();
673 let mut ctx = InputCtx::default();
674 assert_eq!(
675 navigation(key(KeyCode::Char('v')), &mut ctx, &state),
676 Some(Action::EnterVisual)
677 );
678 state.mode = crate::app::mode::Mode::Visual {
679 anchor: state.entries[0].path.clone(),
680 };
681
682 let mut visual_key = |code| visual(key(code), &mut ctx, &state);
683 assert_eq!(visual_key(KeyCode::Char('j')), Some(Action::MoveDown));
684 assert_eq!(visual_key(KeyCode::Char('G')), Some(Action::JumpBottom));
685 assert_eq!(
686 visual_key(KeyCode::Char(' ')),
687 Some(Action::CommitVisual(None))
688 );
689 assert_eq!(
690 visual_key(KeyCode::Char('v')),
691 Some(Action::CommitVisual(None))
692 );
693 assert_eq!(
694 visual_key(KeyCode::Char('l')),
695 Some(Action::CommitVisual(Some(Box::new(Action::EnterOrOpen))))
696 );
697 assert_eq!(visual_key(KeyCode::Esc), Some(Action::ExitMode));
698 assert_eq!(
699 visual_key(KeyCode::Char('q')),
700 None,
701 "no quitting mid-range"
702 );
703 assert_eq!(visual_key(KeyCode::Char('/')), None);
704 }
705
706 #[test]
710 fn at_repeats_the_last_command_on_every_layout() {
711 let dir = tempfile::tempdir().unwrap();
712 let state = AppState::new(dir.path().to_owned()).unwrap();
713 let mut ctx = InputCtx::default();
714 for modifiers in [
715 KeyModifiers::CONTROL | KeyModifiers::ALT,
716 KeyModifiers::SHIFT,
717 KeyModifiers::NONE,
718 ] {
719 let at = KeyEvent {
720 modifiers,
721 ..key(KeyCode::Char('@'))
722 };
723 assert_eq!(
724 navigation(at, &mut ctx, &state),
725 Some(Action::RepeatLastCommand),
726 "@ with {modifiers:?}"
727 );
728 }
729 }
730
731 #[test]
734 fn altgr_is_text_and_ctrl_is_still_a_chord() {
735 let event = |modifiers| KeyEvent {
736 modifiers,
737 ..key(KeyCode::Char('c'))
738 };
739 assert_eq!(
740 key_to_config_string(event(KeyModifiers::CONTROL | KeyModifiers::ALT)),
741 Some("c".to_owned())
742 );
743 assert_eq!(
744 key_to_config_string(event(KeyModifiers::CONTROL)),
745 Some("ctrl-c".to_owned())
746 );
747 }
748
749 #[test]
750 fn configured_binding_overrides_default_char() {
751 let mut cfg = crate::config::load(None).unwrap();
752 cfg.keymap
753 .navigation
754 .insert("move_down".to_owned(), "n".to_owned());
755 let dir = tempfile::tempdir().unwrap();
756 let state = AppState::with_config(dir.path().to_owned(), cfg).unwrap();
757 let mut ctx = InputCtx::default();
758 assert_eq!(
759 navigation(key(KeyCode::Char('n')), &mut ctx, &state),
760 Some(Action::MoveDown)
761 );
762 }
763}