use std::collections::BTreeMap;
use crossterm::event::{KeyCode, KeyEvent, KeyModifiers};
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Action {
Up,
Down,
Top,
Bottom,
PageUp,
PageDown,
HalfPageUp,
HalfPageDown,
TabGit,
TabFiles,
TabArchive,
ToggleSelect,
Jump,
Open,
GitTool,
JumpCd,
OpenApp,
Filter,
ChangesFilter,
Github,
Preview,
PreviewPosition,
PreviewScrollUp,
PreviewScrollDown,
PreviewShrink,
PreviewGrow,
Columns,
Sort,
SectionJump,
ManageSections,
ReorderUp,
ReorderDown,
Add,
Edit,
Delete,
Undo,
ToggleFav,
Zip,
ZipAll,
Archive,
Slug,
ToggleSlugs,
CopyPath,
RepairPath,
Errors,
Reload,
ReloadFetch,
RefreshOne,
RefreshOneFetch,
Help,
Quit,
}
struct ActionSpec {
action: Action,
config_name: &'static str,
description: &'static str,
default_keys: &'static [&'static str],
}
const ACTIONS: &[ActionSpec] = &[
ActionSpec {
action: Action::Up,
config_name: "up",
description: "up",
default_keys: &["up"],
},
ActionSpec {
action: Action::Down,
config_name: "down",
description: "down",
default_keys: &["down"],
},
ActionSpec {
action: Action::Top,
config_name: "top",
description: "top",
default_keys: &["g"],
},
ActionSpec {
action: Action::Bottom,
config_name: "bottom",
description: "bottom",
default_keys: &["G"],
},
ActionSpec {
action: Action::PageUp,
config_name: "page_up",
description: "page up",
default_keys: &["pgup"],
},
ActionSpec {
action: Action::PageDown,
config_name: "page_down",
description: "page down",
default_keys: &["pgdn"],
},
ActionSpec {
action: Action::HalfPageUp,
config_name: "half_page_up",
description: "half page up",
default_keys: &["ctrl+u"],
},
ActionSpec {
action: Action::HalfPageDown,
config_name: "half_page_down",
description: "half page down",
default_keys: &["ctrl+d"],
},
ActionSpec {
action: Action::TabGit,
config_name: "tab_git",
description: "git repos",
default_keys: &["1"],
},
ActionSpec {
action: Action::TabFiles,
config_name: "tab_files",
description: "files",
default_keys: &["2"],
},
ActionSpec {
action: Action::TabArchive,
config_name: "tab_archive",
description: "archive",
default_keys: &["3"],
},
ActionSpec {
action: Action::ToggleSelect,
config_name: "select",
description: "select",
default_keys: &["space"],
},
ActionSpec {
action: Action::Jump,
config_name: "jump",
description: "cd",
default_keys: &["enter"],
},
ActionSpec {
action: Action::Open,
config_name: "open",
description: "open",
default_keys: &["L"],
},
ActionSpec {
action: Action::GitTool,
config_name: "git_tool",
description: "lazygit",
default_keys: &["l"],
},
ActionSpec {
action: Action::JumpCd,
config_name: "jump_cd",
description: "cd",
default_keys: &["o"],
},
ActionSpec {
action: Action::OpenApp,
config_name: "open_app",
description: "open in app",
default_keys: &["O"],
},
ActionSpec {
action: Action::Filter,
config_name: "filter",
description: "filter",
default_keys: &["f"],
},
ActionSpec {
action: Action::ChangesFilter,
config_name: "changes_filter",
description: "changes",
default_keys: &["F"],
},
ActionSpec {
action: Action::Github,
config_name: "github",
description: "github",
default_keys: &["b"],
},
ActionSpec {
action: Action::Preview,
config_name: "preview",
description: "detail panel",
default_keys: &["v"],
},
ActionSpec {
action: Action::PreviewPosition,
config_name: "preview_position",
description: "right/bottom",
default_keys: &["V"],
},
ActionSpec {
action: Action::PreviewScrollUp,
config_name: "preview_scroll_up",
description: "scroll panel",
default_keys: &["ctrl+up"],
},
ActionSpec {
action: Action::PreviewScrollDown,
config_name: "preview_scroll_down",
description: "scroll panel",
default_keys: &["ctrl+down"],
},
ActionSpec {
action: Action::PreviewShrink,
config_name: "preview_shrink",
description: "smaller",
default_keys: &["ctrl+left"],
},
ActionSpec {
action: Action::PreviewGrow,
config_name: "preview_grow",
description: "bigger",
default_keys: &["ctrl+right"],
},
ActionSpec {
action: Action::Columns,
config_name: "columns",
description: "columns",
default_keys: &["c"],
},
ActionSpec {
action: Action::Sort,
config_name: "sort",
description: "sort",
default_keys: &["t"],
},
ActionSpec {
action: Action::SectionJump,
config_name: "section_jump",
description: "jump to section",
default_keys: &["s"],
},
ActionSpec {
action: Action::ManageSections,
config_name: "manage_sections",
description: "sections",
default_keys: &["M"],
},
ActionSpec {
action: Action::ReorderUp,
config_name: "reorder_up",
description: "reorder up",
default_keys: &["alt+up"],
},
ActionSpec {
action: Action::ReorderDown,
config_name: "reorder_down",
description: "reorder down",
default_keys: &["alt+down"],
},
ActionSpec {
action: Action::Add,
config_name: "add",
description: "add",
default_keys: &["n"],
},
ActionSpec {
action: Action::Edit,
config_name: "edit",
description: "edit",
default_keys: &["e"],
},
ActionSpec {
action: Action::Delete,
config_name: "delete",
description: "del",
default_keys: &["d", "del", "backspace"],
},
ActionSpec {
action: Action::Undo,
config_name: "undo",
description: "undo",
default_keys: &["u"],
},
ActionSpec {
action: Action::ToggleFav,
config_name: "fav",
description: "fav",
default_keys: &["*"],
},
ActionSpec {
action: Action::Zip,
config_name: "zip",
description: "zip",
default_keys: &["z"],
},
ActionSpec {
action: Action::ZipAll,
config_name: "zip_all",
description: "zip all",
default_keys: &["Z"],
},
ActionSpec {
action: Action::Archive,
config_name: "archive",
description: "archive/restore",
default_keys: &["A"],
},
ActionSpec {
action: Action::Slug,
config_name: "slug",
description: "slug",
default_keys: &["S"],
},
ActionSpec {
action: Action::ToggleSlugs,
config_name: "toggle_slugs",
description: "slugs",
default_keys: &["i"],
},
ActionSpec {
action: Action::CopyPath,
config_name: "copy_path",
description: "copy path",
default_keys: &["y"],
},
ActionSpec {
action: Action::RepairPath,
config_name: "repair_path",
description: "fix path",
default_keys: &["p"],
},
ActionSpec {
action: Action::Errors,
config_name: "errors",
description: "errors",
default_keys: &["!"],
},
ActionSpec {
action: Action::Reload,
config_name: "reload",
description: "reload/check",
default_keys: &["r"],
},
ActionSpec {
action: Action::ReloadFetch,
config_name: "reload_fetch",
description: "reload+fetch",
default_keys: &["R"],
},
ActionSpec {
action: Action::RefreshOne,
config_name: "refresh_one",
description: "refresh one",
default_keys: &["x"],
},
ActionSpec {
action: Action::RefreshOneFetch,
config_name: "refresh_one_fetch",
description: "refresh+fetch",
default_keys: &["X"],
},
ActionSpec {
action: Action::Help,
config_name: "help",
description: "help",
default_keys: &["?"],
},
ActionSpec {
action: Action::Quit,
config_name: "quit",
description: "quit",
default_keys: &["q"],
},
];
impl Action {
pub fn all() -> impl Iterator<Item = Action> + Clone {
ACTIONS.iter().map(|spec| spec.action)
}
fn spec(self) -> &'static ActionSpec {
ACTIONS
.iter()
.find(|spec| spec.action == self)
.expect("every action has an ACTIONS entry")
}
pub fn config_name(self) -> &'static str {
self.spec().config_name
}
pub fn description(self) -> &'static str {
self.spec().description
}
pub fn default_keys(self) -> &'static [&'static str] {
self.spec().default_keys
}
fn from_config_name(name: &str) -> Option<Action> {
Action::all().find(|a| a.config_name() == name)
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct KeyChord {
code: KeyCode,
ctrl: bool,
alt: bool,
}
impl KeyChord {
pub fn parse(text: &str) -> Option<KeyChord> {
let parts: Vec<&str> = text
.split('+')
.map(str::trim)
.filter(|p| !p.is_empty())
.collect();
let (code_token, modifiers) = parts.split_last()?;
let mut ctrl = false;
let mut alt = false;
for modifier in modifiers {
match modifier.to_ascii_lowercase().as_str() {
"ctrl" | "control" => ctrl = true,
"alt" | "option" => alt = true,
"shift" => {}
_ => return None,
}
}
let code = code_from_token(code_token)?;
Some(KeyChord { code, ctrl, alt })
}
pub fn matches(&self, key: &KeyEvent) -> bool {
self.code == key.code
&& self.ctrl == key.modifiers.contains(KeyModifiers::CONTROL)
&& self.alt == key.modifiers.contains(KeyModifiers::ALT)
}
pub fn display(&self) -> String {
let mut text = String::new();
if self.ctrl {
text.push_str("ctrl+");
}
if self.alt {
text.push_str("alt+");
}
text.push_str(&token_for_code(self.code));
text
}
}
fn code_from_token(token: &str) -> Option<KeyCode> {
let lower = token.to_ascii_lowercase();
let code = match lower.as_str() {
"enter" | "return" => KeyCode::Enter,
"esc" | "escape" => KeyCode::Esc,
"tab" => KeyCode::Tab,
"space" => KeyCode::Char(' '),
"backspace" => KeyCode::Backspace,
"up" => KeyCode::Up,
"down" => KeyCode::Down,
"left" => KeyCode::Left,
"right" => KeyCode::Right,
"pgup" | "pageup" => KeyCode::PageUp,
"pgdn" | "pgdown" | "pagedown" => KeyCode::PageDown,
"home" => KeyCode::Home,
"end" => KeyCode::End,
"del" | "delete" => KeyCode::Delete,
_ => return function_or_char(token, &lower),
};
Some(code)
}
fn function_or_char(token: &str, lower: &str) -> Option<KeyCode> {
if let Some(digits) = lower.strip_prefix('f')
&& let Ok(number) = digits.parse::<u8>()
&& (1..=12).contains(&number)
{
return Some(KeyCode::F(number));
}
let mut chars = token.chars();
let first = chars.next()?;
if chars.next().is_some() {
return None;
}
Some(KeyCode::Char(first))
}
fn token_for_code(code: KeyCode) -> String {
match code {
KeyCode::Char(' ') => "space".to_string(),
KeyCode::Char(ch) => ch.to_string(),
KeyCode::Enter => "enter".to_string(),
KeyCode::Esc => "esc".to_string(),
KeyCode::Tab => "tab".to_string(),
KeyCode::Backspace => "backspace".to_string(),
KeyCode::Up => "up".to_string(),
KeyCode::Down => "down".to_string(),
KeyCode::Left => "left".to_string(),
KeyCode::Right => "right".to_string(),
KeyCode::PageUp => "pgup".to_string(),
KeyCode::PageDown => "pgdn".to_string(),
KeyCode::Home => "home".to_string(),
KeyCode::End => "end".to_string(),
KeyCode::Delete => "del".to_string(),
KeyCode::F(number) => format!("f{number}"),
_ => "?".to_string(),
}
}
#[derive(Debug, Clone)]
pub struct Conflict {
pub key: String,
pub action: Action,
pub claimed_by: Action,
}
#[derive(Debug, Clone)]
pub struct Keymap {
entries: Vec<(KeyChord, Action)>,
conflicts: Vec<Conflict>,
}
impl Default for Keymap {
fn default() -> Self {
Self::from_overrides(&BTreeMap::new())
}
}
impl Keymap {
pub fn from_overrides(overrides: &BTreeMap<String, Vec<String>>) -> Self {
for name in overrides.keys() {
if Action::from_config_name(name).is_none() {
log::warn!("unknown key action '{name}' in config, ignoring");
}
}
let mut entries: Vec<(KeyChord, Action)> = Vec::new();
let mut conflicts: Vec<Conflict> = Vec::new();
for action in Action::all() {
for key in override_keys(overrides, action) {
bind_key(&mut entries, &mut conflicts, action, &key);
}
}
Self { entries, conflicts }
}
pub fn conflicts(&self) -> &[Conflict] {
&self.conflicts
}
pub fn action_for(&self, key: &KeyEvent) -> Option<Action> {
self.entries
.iter()
.find(|(chord, _)| chord.matches(key))
.map(|(_, action)| *action)
}
pub fn keys_for(&self, action: Action) -> Vec<String> {
self.entries
.iter()
.filter(|(_, bound)| *bound == action)
.map(|(chord, _)| chord.display())
.collect()
}
pub fn hints(&self, actions: &[Action]) -> Vec<(String, String)> {
actions
.iter()
.filter_map(|&action| {
let keys = self.keys_for(action).join("/");
if keys.is_empty() {
None
} else {
Some((keys, action.description().to_string()))
}
})
.collect()
}
}
fn bind_key(
entries: &mut Vec<(KeyChord, Action)>,
conflicts: &mut Vec<Conflict>,
action: Action,
key: &str,
) {
let Some(chord) = KeyChord::parse(key) else {
log::warn!("invalid key '{key}' for '{}'", action.config_name());
return;
};
if let Some((_, owner)) =
entries.iter().find(|(existing, _)| *existing == chord)
{
log::warn!(
"key '{key}' already bound to '{}', ignoring for '{}'",
owner.config_name(),
action.config_name()
);
conflicts.push(Conflict {
key: chord.display(),
action,
claimed_by: *owner,
});
return;
}
entries.push((chord, action));
}
fn override_keys(
overrides: &BTreeMap<String, Vec<String>>,
action: Action,
) -> Vec<String> {
overrides
.get(action.config_name())
.cloned()
.unwrap_or_else(|| {
action
.default_keys()
.iter()
.map(|s| (*s).to_string())
.collect()
})
}
#[cfg(test)]
mod tests {
use super::*;
fn key(code: KeyCode, mods: KeyModifiers) -> KeyEvent {
KeyEvent::new(code, mods)
}
#[test]
fn parses_chars_function_keys_and_named_keys() {
assert_eq!(KeyChord::parse("a").unwrap().display(), "a");
assert_eq!(KeyChord::parse("G").unwrap().display(), "G");
assert_eq!(KeyChord::parse("pgup").unwrap().display(), "pgup");
assert_eq!(KeyChord::parse("enter").unwrap().display(), "enter");
assert_eq!(KeyChord::parse("ctrl+u").unwrap().display(), "ctrl+u");
assert_eq!(KeyChord::parse("alt+up").unwrap().display(), "alt+up");
assert!(KeyChord::parse("nonsense").is_none());
assert!(KeyChord::parse("").is_none());
}
#[test]
fn matches_distinguishes_ctrl_and_alt_arrows() {
let plain = KeyChord::parse("up").unwrap();
let ctrl = KeyChord::parse("ctrl+up").unwrap();
let alt = KeyChord::parse("alt+up").unwrap();
assert!(plain.matches(&key(KeyCode::Up, KeyModifiers::NONE)));
assert!(!plain.matches(&key(KeyCode::Up, KeyModifiers::CONTROL)));
assert!(ctrl.matches(&key(KeyCode::Up, KeyModifiers::CONTROL)));
assert!(alt.matches(&key(KeyCode::Up, KeyModifiers::ALT)));
}
#[test]
fn default_map_binds_expected_actions() {
let map = Keymap::default();
assert_eq!(
map.action_for(&key(KeyCode::Char('n'), KeyModifiers::NONE)),
Some(Action::Add),
);
assert_eq!(
map.action_for(&key(KeyCode::Char('z'), KeyModifiers::NONE)),
Some(Action::Zip),
);
assert_eq!(
map.action_for(&key(KeyCode::Char('Z'), KeyModifiers::SHIFT)),
Some(Action::ZipAll),
);
assert_eq!(
map.action_for(&key(KeyCode::Up, KeyModifiers::ALT)),
Some(Action::ReorderUp),
);
assert_eq!(map.keys_for(Action::Delete), vec!["d", "del", "backspace"]);
}
#[test]
fn ctrl_and_plain_letters_do_not_collide() {
let map = Keymap::default();
assert_eq!(
map.action_for(&key(KeyCode::Char('u'), KeyModifiers::CONTROL)),
Some(Action::HalfPageUp),
);
assert_eq!(
map.action_for(&key(KeyCode::Char('u'), KeyModifiers::NONE)),
Some(Action::Undo),
);
assert_eq!(
map.action_for(&key(KeyCode::Char('d'), KeyModifiers::CONTROL)),
Some(Action::HalfPageDown),
);
assert_eq!(
map.action_for(&key(KeyCode::Char('d'), KeyModifiers::NONE)),
Some(Action::Delete),
);
}
#[test]
fn override_replaces_an_action_and_keeps_others() {
let mut overrides = BTreeMap::new();
overrides.insert("add".to_string(), vec!["N".to_string()]);
let map = Keymap::from_overrides(&overrides);
assert_eq!(
map.action_for(&key(KeyCode::Char('N'), KeyModifiers::SHIFT)),
Some(Action::Add),
);
assert_ne!(
map.action_for(&key(KeyCode::Char('n'), KeyModifiers::NONE)),
Some(Action::Add),
);
assert_eq!(map.keys_for(Action::Edit), vec!["e"]);
}
#[test]
fn default_map_has_no_conflicts() {
assert!(Keymap::default().conflicts().is_empty());
}
#[test]
fn every_action_has_a_reachable_default_key() {
let map = Keymap::default();
for action in Action::all() {
assert!(
!map.keys_for(action).is_empty(),
"'{}' has no bound key",
action.config_name()
);
for text in action.default_keys() {
let chord =
KeyChord::parse(text).expect("default key must parse");
let mut modifiers = KeyModifiers::NONE;
if chord.ctrl {
modifiers |= KeyModifiers::CONTROL;
}
if chord.alt {
modifiers |= KeyModifiers::ALT;
}
assert_eq!(
map.action_for(&key(chord.code, modifiers)),
Some(action),
"'{text}' does not resolve back to '{}'",
action.config_name()
);
}
}
}
#[test]
fn hints_pair_keys_with_descriptions() {
let map = Keymap::default();
let hints = map.hints(&[Action::Add, Action::Quit]);
assert_eq!(hints[0], ("n".to_string(), "add".to_string()));
assert_eq!(hints[1], ("q".to_string(), "quit".to_string()));
}
#[test]
fn actions_table_matches_the_enum() {
fn covered(action: Action) {
match action {
Action::Up
| Action::Down
| Action::Top
| Action::Bottom
| Action::PageUp
| Action::PageDown
| Action::HalfPageUp
| Action::HalfPageDown
| Action::TabGit
| Action::TabFiles
| Action::TabArchive
| Action::ToggleSelect
| Action::Jump
| Action::Open
| Action::GitTool
| Action::JumpCd
| Action::OpenApp
| Action::Filter
| Action::ChangesFilter
| Action::Github
| Action::Preview
| Action::PreviewPosition
| Action::PreviewScrollUp
| Action::PreviewScrollDown
| Action::PreviewShrink
| Action::PreviewGrow
| Action::Columns
| Action::Sort
| Action::SectionJump
| Action::ManageSections
| Action::ReorderUp
| Action::ReorderDown
| Action::Add
| Action::Edit
| Action::Delete
| Action::Undo
| Action::ToggleFav
| Action::Zip
| Action::ZipAll
| Action::Archive
| Action::Slug
| Action::ToggleSlugs
| Action::CopyPath
| Action::RepairPath
| Action::Errors
| Action::Reload
| Action::ReloadFetch
| Action::RefreshOne
| Action::RefreshOneFetch
| Action::Help
| Action::Quit => {}
}
}
let _ = covered;
let mut names: Vec<&str> =
ACTIONS.iter().map(|spec| spec.config_name).collect();
names.sort_unstable();
let total = names.len();
names.dedup();
assert_eq!(names.len(), total, "duplicate config_name in ACTIONS");
for action in Action::all() {
let _ = action.spec();
}
}
}