use crossterm::event::{KeyCode, KeyEvent, KeyModifiers};
use crate::actions::transfer::TransferKind;
use crate::actions::Action;
use crate::app::sort::SortBy;
use crate::app::state::AppState;
use crate::config::KeymapConfig;
use crate::input::InputCtx;
pub fn navigation(key: KeyEvent, _ctx: &mut InputCtx, state: &AppState) -> Option<Action> {
if state.pending_delete {
return match key.code {
KeyCode::Enter | KeyCode::Char('y') => Some(Action::ConfirmDelete),
KeyCode::Esc | KeyCode::Char('n') => Some(Action::CancelDelete),
_ => None,
};
}
if let Some(pending) = state.pending_nav_key {
if let Some(sequence) = append_to_sequence(pending, key) {
return nav_action_for_sequence(&state.config.keymap, &sequence);
}
return None;
}
if let Some(action) = configured_nav_action(key, &state.config.keymap) {
return Some(action);
}
if let Some(prefix) = configured_nav_prefix(key, &state.config.keymap) {
return Some(Action::SetPendingNavKey(prefix));
}
match key.code {
KeyCode::Down if key.modifiers.contains(KeyModifiers::SHIFT) => {
Some(Action::PreviewScrollDown)
}
KeyCode::Up if key.modifiers.contains(KeyModifiers::SHIFT) => Some(Action::PreviewScrollUp),
KeyCode::Down => Some(Action::MoveDown),
KeyCode::Up => Some(Action::MoveUp),
KeyCode::Enter | KeyCode::Right => Some(Action::EnterOrOpen),
KeyCode::Backspace | KeyCode::Left => Some(Action::GoParent),
KeyCode::Char('c') if is_ctrl_chord(key.modifiers) => Some(Action::Cancel),
KeyCode::Char('t') if is_ctrl_chord(key.modifiers) => Some(Action::NewTab),
KeyCode::Char('w') if is_ctrl_chord(key.modifiers) => Some(Action::CloseTab),
KeyCode::Tab if key.modifiers.contains(KeyModifiers::SHIFT) => Some(Action::SwitchTabPrev),
KeyCode::Tab => Some(Action::SwitchTabNext),
KeyCode::Esc => None,
_ => None,
}
}
pub fn visual(key: KeyEvent, ctx: &mut InputCtx, state: &AppState) -> Option<Action> {
let action = navigation(key, ctx, state)?;
match action {
Action::MoveDown
| Action::MoveUp
| Action::JumpTop
| Action::JumpBottom
| Action::SetPendingNavKey(_)
| Action::BeginDelete
| Action::ConfirmDelete
| Action::CancelDelete
| Action::Cancel
| Action::PreviewScrollDown
| Action::PreviewScrollUp
| Action::PreviewPageDown
| Action::PreviewPageUp
| Action::PreviewScrollTop
| Action::PreviewScrollBottom => Some(action),
Action::ToggleMark | Action::EnterVisual => Some(Action::CommitVisual(None)),
Action::GoParent
| Action::EnterOrOpen
| Action::HistoryBack
| Action::HistoryForward
| Action::ReturnFromJump => Some(Action::CommitVisual(Some(Box::new(action)))),
Action::ClearMarks => Some(Action::ExitMode),
_ => None,
}
}
pub fn search(key: KeyEvent, keymap: &KeymapConfig) -> Option<Action> {
if let KeyCode::Char(ch) = key.code {
if super::is_text_modifiers(key.modifiers) {
return Some(Action::SearchAppendChar(ch));
}
}
if let Some(action) = configured_search_action(key, keymap) {
return Some(action);
}
match key.code {
KeyCode::Esc => Some(Action::ExitMode),
KeyCode::Enter | KeyCode::Right => Some(Action::SearchConfirm),
KeyCode::Down => Some(Action::SearchMoveDown),
KeyCode::Up => Some(Action::SearchMoveUp),
KeyCode::Backspace => Some(Action::SearchDeleteChar),
KeyCode::Char('n') if key.modifiers.contains(KeyModifiers::CONTROL) => {
Some(Action::SearchMoveDown)
}
KeyCode::Char('p') if key.modifiers.contains(KeyModifiers::CONTROL) => {
Some(Action::SearchMoveUp)
}
KeyCode::Char('h') if key.modifiers.contains(KeyModifiers::CONTROL) => {
Some(Action::SearchDeleteChar)
}
_ => None,
}
}
fn configured_nav_action(key: KeyEvent, keymap: &KeymapConfig) -> Option<Action> {
let key_text = key_to_config_string(key)?;
nav_action_for_sequence(keymap, &key_text)
}
fn configured_search_action(key: KeyEvent, keymap: &KeymapConfig) -> Option<Action> {
let key_text = key_to_config_string(key)?;
keymap.search.iter().find_map(|(name, binding)| {
if binding == &key_text {
search_action_from_name(name)
} else {
None
}
})
}
fn configured_nav_prefix(key: KeyEvent, keymap: &KeymapConfig) -> Option<char> {
let KeyCode::Char(ch) = key.code else {
return None;
};
if is_ctrl_chord(key.modifiers) {
return None;
}
keymap
.navigation
.values()
.filter(|binding| !is_named_key(binding))
.any(|binding| binding.len() > ch.len_utf8() && binding.starts_with(ch))
.then_some(ch)
}
fn is_ctrl_chord(modifiers: KeyModifiers) -> bool {
modifiers.contains(KeyModifiers::CONTROL) && !super::is_text_modifiers(modifiers)
}
const NAMED_KEYS: &[&str] = &[
"enter",
"esc",
"backspace",
"tab",
"left",
"right",
"up",
"down",
"home",
"end",
"space",
];
fn is_named_key(binding: &str) -> bool {
let base = binding
.strip_prefix("ctrl-")
.or_else(|| binding.strip_prefix("shift-"))
.unwrap_or(binding);
binding != base || NAMED_KEYS.contains(&base)
}
fn nav_action_for_sequence(keymap: &KeymapConfig, sequence: &str) -> Option<Action> {
keymap.navigation.iter().find_map(|(name, binding)| {
if binding == sequence {
nav_action_from_name(name)
} else {
None
}
})
}
fn nav_action_from_name(name: &str) -> Option<Action> {
match name {
"move_down" => Some(Action::MoveDown),
"move_up" => Some(Action::MoveUp),
"jump_top" => Some(Action::JumpTop),
"jump_bottom" => Some(Action::JumpBottom),
"enter_or_open" => Some(Action::EnterOrOpen),
"go_parent" => Some(Action::GoParent),
"history_back" => Some(Action::HistoryBack),
"history_forward" => Some(Action::HistoryForward),
"return_from_jump" => Some(Action::ReturnFromJump),
"refresh" => Some(Action::Refresh),
"toggle_hidden" => Some(Action::ToggleHidden),
"sort_name" => Some(Action::SetSortBy(SortBy::Name)),
"sort_size" => Some(Action::SetSortBy(SortBy::Size)),
"sort_time" => Some(Action::SetSortBy(SortBy::Modified)),
"sort_extension" => Some(Action::SetSortBy(SortBy::Extension)),
"sort_reverse" => Some(Action::ToggleSortReverse),
"sort_dirs_first" => Some(Action::ToggleDirsFirst),
"toggle_details" => Some(Action::CycleEntryDetails),
"copy_absolute_path" => Some(Action::CopyAbsPath),
"copy_relative_path" => Some(Action::CopyRelPath),
"copy_filename" => Some(Action::CopyFilename),
"copy_content" => Some(Action::CopyContent),
"delete" => Some(Action::BeginDelete),
"toggle_mark" => Some(Action::ToggleMark),
"clear_marks" => Some(Action::ClearMarks),
"paste_copy" => Some(Action::PasteMarks(TransferKind::Copy)),
"paste_move" => Some(Action::PasteMarks(TransferKind::Move)),
"enter_visual" => Some(Action::EnterVisual),
"repeat_command" => Some(Action::RepeatLastCommand),
"enter_search" => Some(Action::EnterSearch),
"enter_command" => Some(Action::EnterCommand),
"quit" => Some(Action::Quit),
"open_with_os" => Some(Action::OpenWithOs),
"new_tab" => Some(Action::NewTab),
"close_tab" => Some(Action::CloseTab),
"switch_tab_next" => Some(Action::SwitchTabNext),
"switch_tab_prev" => Some(Action::SwitchTabPrev),
"preview_scroll_down" => Some(Action::PreviewScrollDown),
"preview_scroll_up" => Some(Action::PreviewScrollUp),
"preview_page_down" => Some(Action::PreviewPageDown),
"preview_page_up" => Some(Action::PreviewPageUp),
"preview_scroll_top" => Some(Action::PreviewScrollTop),
"preview_scroll_bottom" => Some(Action::PreviewScrollBottom),
_ => None,
}
}
fn search_action_from_name(name: &str) -> Option<Action> {
match name {
"exit" => Some(Action::ExitMode),
"confirm" => Some(Action::SearchConfirm),
"move_down" => Some(Action::SearchMoveDown),
"move_up" => Some(Action::SearchMoveUp),
"delete_char" => Some(Action::SearchDeleteChar),
_ => None,
}
}
fn append_to_sequence(prefix: char, key: KeyEvent) -> Option<String> {
let KeyCode::Char(ch) = key.code else {
return None;
};
if is_ctrl_chord(key.modifiers) {
return None;
}
Some(format!("{prefix}{ch}"))
}
fn key_to_config_string(key: KeyEvent) -> Option<String> {
let named = match key.code {
KeyCode::Char(ch) if is_ctrl_chord(key.modifiers) => {
return Some(format!("ctrl-{ch}").to_ascii_lowercase());
}
KeyCode::Char(' ') => "space",
KeyCode::Char(ch) => return Some(ch.to_string()),
KeyCode::Enter => "enter",
KeyCode::Esc => "esc",
KeyCode::Backspace => "backspace",
KeyCode::Tab => "tab",
KeyCode::BackTab => return Some("shift-tab".to_owned()),
KeyCode::Left => "left",
KeyCode::Right => "right",
KeyCode::Up => "up",
KeyCode::Down => "down",
KeyCode::Home => "home",
KeyCode::End => "end",
_ => return None,
};
if key.modifiers.contains(KeyModifiers::SHIFT) {
Some(format!("shift-{named}"))
} else {
Some(named.to_owned())
}
}
#[cfg(test)]
mod tests {
use super::*;
use crossterm::event::{KeyEventKind, KeyEventState};
#[test]
fn a_named_key_binding_does_not_make_its_first_letter_a_prefix() {
let keymap = crate::config::load(None).unwrap().keymap;
assert_eq!(
configured_nav_prefix(key(KeyCode::Char('t')), &keymap),
None
);
assert_eq!(
configured_nav_prefix(key(KeyCode::Char('c')), &keymap),
None
);
}
#[test]
fn real_sequences_still_make_prefixes() {
let keymap = crate::config::load(None).unwrap().keymap;
for ch in ['g', 'y', 'd', 's'] {
assert_eq!(
configured_nav_prefix(key(KeyCode::Char(ch)), &keymap),
Some(ch),
"`{ch}` starts a default sequence"
);
}
}
#[test]
fn every_translated_named_key_is_recognised() {
let codes = [
KeyCode::Enter,
KeyCode::Esc,
KeyCode::Backspace,
KeyCode::Tab,
KeyCode::BackTab,
KeyCode::Left,
KeyCode::Right,
KeyCode::Up,
KeyCode::Down,
KeyCode::Home,
KeyCode::End,
KeyCode::Char(' '),
];
for code in codes {
for modifiers in [KeyModifiers::NONE, KeyModifiers::SHIFT] {
let event = KeyEvent {
modifiers,
..key(code)
};
let name = key_to_config_string(event).expect("named key translates");
assert!(is_named_key(&name), "`{name}` is not recognised as named");
}
}
assert!(is_named_key("ctrl-r"));
assert!(!is_named_key("gg"));
assert!(!is_named_key("t"));
}
fn key(code: KeyCode) -> KeyEvent {
KeyEvent {
code,
modifiers: KeyModifiers::NONE,
kind: KeyEventKind::Press,
state: KeyEventState::NONE,
}
}
#[test]
fn parses_ctrl_key_binding() {
let event = KeyEvent {
code: KeyCode::Char('r'),
modifiers: KeyModifiers::CONTROL,
kind: KeyEventKind::Press,
state: KeyEventState::NONE,
};
assert_eq!(key_to_config_string(event), Some("ctrl-r".to_owned()));
}
#[test]
fn search_types_every_bare_character_even_when_bound() {
let mut keymap = crate::config::load(None).unwrap().keymap;
keymap.search.insert("move_down".to_owned(), "j".to_owned());
keymap.search.insert("move_up".to_owned(), "k".to_owned());
for ch in ['j', 'k', 'q', ':', '/', 'Z'] {
assert_eq!(
search(key(KeyCode::Char(ch)), &keymap),
Some(Action::SearchAppendChar(ch)),
"{ch} must reach the query"
);
}
}
#[test]
fn search_moves_with_keys_that_are_not_text() {
let keymap = crate::config::load(None).unwrap().keymap;
let ctrl = |ch| KeyEvent {
code: KeyCode::Char(ch),
modifiers: KeyModifiers::CONTROL,
kind: KeyEventKind::Press,
state: KeyEventState::NONE,
};
assert_eq!(
search(key(KeyCode::Down), &keymap),
Some(Action::SearchMoveDown)
);
assert_eq!(
search(key(KeyCode::Up), &keymap),
Some(Action::SearchMoveUp)
);
assert_eq!(search(ctrl('n'), &keymap), Some(Action::SearchMoveDown));
assert_eq!(search(ctrl('p'), &keymap), Some(Action::SearchMoveUp));
assert_eq!(search(ctrl('h'), &keymap), Some(Action::SearchDeleteChar));
assert_eq!(
search(key(KeyCode::Enter), &keymap),
Some(Action::SearchConfirm)
);
assert_eq!(search(key(KeyCode::Esc), &keymap), Some(Action::ExitMode));
}
#[test]
fn shifted_characters_are_still_text() {
let keymap = crate::config::load(None).unwrap().keymap;
let shifted = KeyEvent {
code: KeyCode::Char('K'),
modifiers: KeyModifiers::SHIFT,
kind: KeyEventKind::Press,
state: KeyEventState::NONE,
};
assert_eq!(
search(shifted, &keymap),
Some(Action::SearchAppendChar('K'))
);
}
#[test]
fn altgr_characters_are_text() {
let keymap = crate::config::load(None).unwrap().keymap;
let altgr = KeyEvent {
code: KeyCode::Char('\\'),
modifiers: KeyModifiers::CONTROL | KeyModifiers::ALT,
kind: KeyEventKind::Press,
state: KeyEventState::NONE,
};
assert_eq!(search(altgr, &keymap), Some(Action::SearchAppendChar('\\')));
}
#[test]
fn preview_scroll_bindings_resolve_from_defaults() {
let dir = tempfile::tempdir().unwrap();
let state = AppState::new(dir.path().to_owned()).unwrap();
let mut ctx = InputCtx::default();
let ctrl = |ch| KeyEvent {
code: KeyCode::Char(ch),
modifiers: KeyModifiers::CONTROL,
kind: KeyEventKind::Press,
state: KeyEventState::NONE,
};
let shifted = |code| KeyEvent {
code,
modifiers: KeyModifiers::SHIFT,
kind: KeyEventKind::Press,
state: KeyEventState::NONE,
};
for (event, expected) in [
(key(KeyCode::Char('J')), Action::PreviewScrollDown),
(key(KeyCode::Char('K')), Action::PreviewScrollUp),
(ctrl('f'), Action::PreviewPageDown),
(ctrl('b'), Action::PreviewPageUp),
(shifted(KeyCode::Home), Action::PreviewScrollTop),
(shifted(KeyCode::End), Action::PreviewScrollBottom),
] {
assert_eq!(
navigation(event, &mut ctx, &state),
Some(expected.clone()),
"{event:?} must map to {expected:?}"
);
}
}
#[test]
fn shift_arrows_scroll_the_preview_and_bare_arrows_still_move() {
let dir = tempfile::tempdir().unwrap();
let state = AppState::new(dir.path().to_owned()).unwrap();
let mut ctx = InputCtx::default();
let shifted = |code| KeyEvent {
code,
modifiers: KeyModifiers::SHIFT,
kind: KeyEventKind::Press,
state: KeyEventState::NONE,
};
assert_eq!(
navigation(shifted(KeyCode::Down), &mut ctx, &state),
Some(Action::PreviewScrollDown)
);
assert_eq!(
navigation(shifted(KeyCode::Up), &mut ctx, &state),
Some(Action::PreviewScrollUp)
);
assert_eq!(
navigation(key(KeyCode::Down), &mut ctx, &state),
Some(Action::MoveDown)
);
assert_eq!(
navigation(key(KeyCode::Up), &mut ctx, &state),
Some(Action::MoveUp)
);
assert_eq!(
navigation(key(KeyCode::Char('j')), &mut ctx, &state),
Some(Action::MoveDown)
);
assert_eq!(
navigation(key(KeyCode::Char('k')), &mut ctx, &state),
Some(Action::MoveUp)
);
}
#[test]
fn shift_is_spelled_out_for_named_keys_only() {
let shifted = |code| KeyEvent {
code,
modifiers: KeyModifiers::SHIFT,
kind: KeyEventKind::Press,
state: KeyEventState::NONE,
};
assert_eq!(
key_to_config_string(shifted(KeyCode::Down)),
Some("shift-down".to_owned())
);
assert_eq!(
key_to_config_string(shifted(KeyCode::End)),
Some("shift-end".to_owned())
);
assert_eq!(
key_to_config_string(key(KeyCode::Home)),
Some("home".to_owned())
);
assert_eq!(
key_to_config_string(shifted(KeyCode::Char('J'))),
Some("J".to_owned())
);
assert_eq!(
key_to_config_string(key(KeyCode::BackTab)),
Some("shift-tab".to_owned())
);
assert_eq!(
key_to_config_string(shifted(KeyCode::Tab)),
Some("shift-tab".to_owned())
);
}
#[test]
fn mark_and_paste_bindings_resolve_from_defaults() {
let dir = tempfile::tempdir().unwrap();
let mut state = AppState::new(dir.path().to_owned()).unwrap();
let mut ctx = InputCtx::default();
assert_eq!(
navigation(key(KeyCode::Char(' ')), &mut ctx, &state),
Some(Action::ToggleMark)
);
assert_eq!(
navigation(key(KeyCode::Esc), &mut ctx, &state),
Some(Action::ClearMarks)
);
assert_eq!(
navigation(key(KeyCode::Char('p')), &mut ctx, &state),
Some(Action::SetPendingNavKey('p'))
);
state.pending_nav_key = Some('p');
assert_eq!(
navigation(key(KeyCode::Char('p')), &mut ctx, &state),
Some(Action::PasteMarks(TransferKind::Copy))
);
assert_eq!(
navigation(key(KeyCode::Char('m')), &mut ctx, &state),
Some(Action::PasteMarks(TransferKind::Move))
);
}
#[test]
fn visual_mode_moves_commits_cancels_and_ignores_the_rest() {
let dir = tempfile::tempdir().unwrap();
std::fs::write(dir.path().join("a"), b"").unwrap();
let mut state = AppState::new(dir.path().to_owned()).unwrap();
let mut ctx = InputCtx::default();
assert_eq!(
navigation(key(KeyCode::Char('v')), &mut ctx, &state),
Some(Action::EnterVisual)
);
state.mode = crate::app::mode::Mode::Visual {
anchor: state.entries[0].path.clone(),
};
let mut visual_key = |code| visual(key(code), &mut ctx, &state);
assert_eq!(visual_key(KeyCode::Char('j')), Some(Action::MoveDown));
assert_eq!(visual_key(KeyCode::Char('G')), Some(Action::JumpBottom));
assert_eq!(
visual_key(KeyCode::Char(' ')),
Some(Action::CommitVisual(None))
);
assert_eq!(
visual_key(KeyCode::Char('v')),
Some(Action::CommitVisual(None))
);
assert_eq!(
visual_key(KeyCode::Char('l')),
Some(Action::CommitVisual(Some(Box::new(Action::EnterOrOpen))))
);
assert_eq!(visual_key(KeyCode::Esc), Some(Action::ExitMode));
assert_eq!(
visual_key(KeyCode::Char('q')),
None,
"no quitting mid-range"
);
assert_eq!(visual_key(KeyCode::Char('/')), None);
}
#[test]
fn at_repeats_the_last_command_on_every_layout() {
let dir = tempfile::tempdir().unwrap();
let state = AppState::new(dir.path().to_owned()).unwrap();
let mut ctx = InputCtx::default();
for modifiers in [
KeyModifiers::CONTROL | KeyModifiers::ALT,
KeyModifiers::SHIFT,
KeyModifiers::NONE,
] {
let at = KeyEvent {
modifiers,
..key(KeyCode::Char('@'))
};
assert_eq!(
navigation(at, &mut ctx, &state),
Some(Action::RepeatLastCommand),
"@ with {modifiers:?}"
);
}
}
#[test]
fn altgr_is_text_and_ctrl_is_still_a_chord() {
let event = |modifiers| KeyEvent {
modifiers,
..key(KeyCode::Char('c'))
};
assert_eq!(
key_to_config_string(event(KeyModifiers::CONTROL | KeyModifiers::ALT)),
Some("c".to_owned())
);
assert_eq!(
key_to_config_string(event(KeyModifiers::CONTROL)),
Some("ctrl-c".to_owned())
);
}
#[test]
fn configured_binding_overrides_default_char() {
let mut cfg = crate::config::load(None).unwrap();
cfg.keymap
.navigation
.insert("move_down".to_owned(), "n".to_owned());
let dir = tempfile::tempdir().unwrap();
let state = AppState::with_config(dir.path().to_owned(), cfg).unwrap();
let mut ctx = InputCtx::default();
assert_eq!(
navigation(key(KeyCode::Char('n')), &mut ctx, &state),
Some(Action::MoveDown)
);
}
}