Skip to main content

trail/input/
keymap.rs

1//! Default and user-configured key bindings.
2//!
3//! Resolves `[keymap]` TOML tables to `Action` values for each mode. Arrow
4//! keys and Enter remain built-in aliases for ergonomic terminal navigation.
5
6use 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
15/// Translates a `KeyEvent` in Navigation Mode into an `Action`.
16///
17/// Configured bindings are resolved first from `state.config.keymap`. Built-in
18/// non-text fallbacks such as arrows, Enter, and Backspace are then checked.
19/// Multi-key sequences are represented by compact strings such as `gg`, `ya`,
20/// and `dd`.
21pub 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        // Shift+arrow scrolls the preview — checked before the bare arrows,
47        // which match on the key code alone and would otherwise swallow it and
48        // move the selection instead.
49        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        // Ctrl+C cancels Trail without writing --cwd-file.
58        KeyCode::Char('c') if is_ctrl_chord(key.modifiers) => Some(Action::Cancel),
59        // Phase 8: tab management built-in fallbacks.
60        // These fire when not overridden by a configured keymap binding.
61        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
70/// Translates a `KeyEvent` in Visual Mode into an `Action`.
71///
72/// Visual mode shares Navigation Mode's bindings — `j`, `k`, `G`, `gg` and the
73/// arrows move exactly as they do there, which is what extends the range — and
74/// reinterprets the few that end it:
75///
76/// - `Space` and `v` add the range to the marks;
77/// - `h`/`l` (and the other ways of leaving the directory) add it and then go;
78/// - `Esc` abandons it, leaving the marks as they were;
79/// - `dd` deletes it, after one confirmation.
80///
81/// Everything else is ignored: a yank or a paste in the middle of selecting is
82/// more likely a slip than a request.
83pub 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
112/// Translates a `KeyEvent` in Search Mode into an `Action`.
113///
114/// Search Mode is a typing mode, so an unmodified printable character is
115/// **always** appended to the query — before the configured keymap is even
116/// consulted. Binding a bare character to a movement action here would make
117/// that character impossible to search for, which is exactly what the shipped
118/// defaults used to do with `j` and `k`: the list moved and the letter never
119/// reached the query.
120///
121/// Everything that is not text therefore drives the mode: arrows and
122/// `Ctrl-n`/`Ctrl-p` move, Enter confirms, Esc leaves, Backspace deletes.
123/// Those are what `[keymap.search]` may rebind, and
124/// `TrailConfig::validate` rejects a single-character binding so the
125/// shadowing cannot be reintroduced through config.
126pub fn search(key: KeyEvent, keymap: &KeymapConfig) -> Option<Action> {
127    if let KeyCode::Char(ch) = key.code {
128        // Capitals (Shift) and AltGr characters (Ctrl+Alt on Windows) are text too.
129        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        // Readline-style chords, so the hands never have to leave the home row
145        // now that j/k are text. Built-in aliases, like Ctrl-h for backspace.
146        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
175/// Returns `ch` when it is the first key of a configured multi-key sequence
176/// such as `gg` or `ya`, so the dispatcher waits for the second key.
177///
178/// Named-key bindings are excluded before the prefix test. A binding like
179/// `tab` or `ctrl-r` is one key, not the letters it is spelled with; without
180/// the exclusion `t` and `c` became prefixes that nothing could complete, and
181/// each silently swallowed the keystroke after it.
182fn 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
197/// Whether a character key with `modifiers` is a Ctrl chord rather than a
198/// typed character.
199///
200/// Ctrl **and** Alt together is how Windows reports AltGr, which Spanish,
201/// Latin American, German and many other layouts need for `@`, `\`, `|`,
202/// `~`, `{` and `[`. Read as Ctrl, AltGr+Q on es-MX arrived as `ctrl-@` and
203/// matched nothing, so a binding of `@` could never be pressed there — the
204/// same trap §2.18 fixed for Command and Search Mode.
205fn is_ctrl_chord(modifiers: KeyModifiers) -> bool {
206    modifiers.contains(KeyModifiers::CONTROL) && !super::is_text_modifiers(modifiers)
207}
208
209/// Every key name [`key_to_config_string`] can produce without a modifier.
210const 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
224/// Whether `binding` names a single key — `tab`, `ctrl-r`, `shift-end` — as
225/// opposed to a sequence of typed characters.
226///
227/// Matches exactly the spellings [`key_to_config_string`] produces: a bare key
228/// name, or one prefixed with `ctrl-` or `shift-`.
229fn 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        // Listing order. `s` is never bound on its own: `configured_nav_prefix`
261        // makes any first character of a longer binding a prefix, so these six
262        // give it its meaning without any change to the sequence machinery.
263        "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        // Phase 8: tab management.
286        "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 pane scrolling.
291        "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
322/// Renders `key` in the form `[keymap]` bindings are written in, or `None` for
323/// a key with no config spelling.
324///
325/// A character carries its own shift: crossterm delivers `Shift`+`j` as
326/// `Char('J')`, which is why `jump_bottom = "G"` works and why a `shift-`
327/// prefix is added only to *named* keys — `shift-down`, `shift-end`. Without
328/// that prefix every shifted arrow would be indistinguishable from the bare
329/// one, and `Shift`+`↓` could not scroll the preview while `↓` moves the
330/// selection.
331fn 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        // A space is spelled out: a binding of " " would be invisible in the
337        // config file, and easy to take for an empty string.
338        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        // Terminals split on how they report Shift+Tab: some send Tab with the
345        // shift modifier, others a distinct BackTab with no modifier. Both must
346        // reach the same binding.
347        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    /// The bug this guards: `tab` made `t` a prefix and `ctrl-r` made `c` one,
370    /// so each waited for a second key that could never complete a sequence
371    /// and swallowed it.
372    #[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    /// `is_named_key` has to recognise every spelling the key translator can
398    /// produce, or a new named key would reintroduce the swallowed keystroke.
399    #[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    /// The bug this guards: `j` and `k` moved the selection instead of being
451    /// typed, so neither letter could appear in a search query. A configured
452    /// binding must not be able to reintroduce that.
453    #[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    /// Windows reports AltGr as Ctrl+Alt; on es-MX that is how `\` is typed.
512    #[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    /// A shifted character carries its own capital, so `J`/`K` reach the
525    /// preview-scroll actions through the ordinary configured path — the same
526    /// one that already serves `G`.
527    #[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    /// The bug this guards: the built-in arrow fallbacks match on the key code
562    /// alone, so `Shift+↓` used to move the selection. It must scroll the
563    /// preview while the bare arrow still moves, and `j`/`k` must be untouched.
564    #[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        // A capital is already the shifted form of the character.
624        assert_eq!(
625            key_to_config_string(shifted(KeyCode::Char('J'))),
626            Some("J".to_owned())
627        );
628        // Terminals that send BackTab instead of Shift+Tab reach the same binding.
629        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    /// The maintainer types on Spanish (Latin America), where `@` is AltGr+Q
707    /// and Windows reports AltGr as Ctrl+Alt. `@` has to work typed that way,
708    /// with Shift (US English), and bare (a terminal that drops the modifier).
709    #[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    /// AltGr must not make a character into a Ctrl chord — but a real Ctrl
732    /// chord must stay one, or `Ctrl-c` would stop cancelling.
733    #[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}