use std::collections::{HashMap, HashSet};
use ratatui::crossterm::event::{KeyCode, KeyEvent, KeyModifiers};
use crate::config::Config;
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
pub struct Chord {
pub code: KeyCode,
pub mods: KeyModifiers,
}
impl Chord {
pub fn of(ev: &KeyEvent) -> Chord {
let mut mods = ev.modifiers;
let code = match ev.code {
KeyCode::Char(c) if c.is_ascii_uppercase() => {
mods |= KeyModifiers::SHIFT;
KeyCode::Char(c.to_ascii_lowercase())
}
other => other,
};
Chord { code, mods }
}
#[allow(clippy::wrong_self_convention)] pub fn to_spec(&self) -> String {
let mut parts: Vec<&str> = Vec::new();
if self.mods.contains(KeyModifiers::CONTROL) {
parts.push("ctrl");
}
if self.mods.contains(KeyModifiers::ALT) {
parts.push("alt");
}
if self.mods.contains(KeyModifiers::SHIFT) {
parts.push("shift");
}
let name = match self.code {
KeyCode::Enter => "enter".to_string(),
KeyCode::Tab => "tab".to_string(),
KeyCode::BackTab => "backtab".to_string(),
KeyCode::Esc => "esc".to_string(),
KeyCode::Backspace => "backspace".to_string(),
KeyCode::Delete => "delete".to_string(),
KeyCode::Insert => "insert".to_string(),
KeyCode::Up => "up".to_string(),
KeyCode::Down => "down".to_string(),
KeyCode::Left => "left".to_string(),
KeyCode::Right => "right".to_string(),
KeyCode::Home => "home".to_string(),
KeyCode::End => "end".to_string(),
KeyCode::PageUp => "pageup".to_string(),
KeyCode::PageDown => "pagedown".to_string(),
KeyCode::F(n) => format!("f{n}"),
KeyCode::Char(' ') => "space".to_string(),
KeyCode::Char(c) => c.to_string(),
other => format!("{other:?}"),
};
parts.push(&name);
parts.join("+")
}
}
#[derive(Debug, Clone, Default)]
pub struct Keymap {
map: HashMap<Vec<Chord>, String>,
prefixes: HashSet<Vec<Chord>>,
}
#[derive(Debug, PartialEq, Eq)]
pub enum SeqResolution<'a> {
Run(&'a str),
Pending,
PendingWithFallback(&'a str),
None,
}
impl Keymap {
pub fn build(cfg: &Config) -> Keymap {
let mut km = Keymap::default();
let mut prior_owner: HashMap<Vec<Chord>, &'static str> = HashMap::new();
for cmd in crate::command::registry().all() {
for spec in cmd.keys {
let Some(seq) = parse_key_seq(spec) else {
eprintln!(
"mnml: command `{}` declares key `{spec}` that doesn't parse — chord ignored, command still palette-reachable",
cmd.id
);
continue;
};
if let Some(prev) = prior_owner.get(&seq)
&& *prev != cmd.id
{
eprintln!(
"mnml: keymap collision on `{spec}` — `{prev}` overridden by `{}` (drop one default to silence)",
cmd.id
);
}
prior_owner.insert(seq.clone(), cmd.id);
km.map.insert(seq, cmd.id.to_string());
}
}
if super::is_vim_style(cfg) {
for spec in [
"ctrl+w", "ctrl+g", "ctrl+d", "ctrl+u", "ctrl+e", "ctrl+y", "ctrl+r", "ctrl+n",
"ctrl+h", "ctrl+j", "ctrl+t", "ctrl+f",
] {
if let Some(seq) = parse_key_seq(spec) {
km.map.remove(&seq);
}
}
} else {
for (spec, id) in [
("ctrl+]", "editor.indent_line"),
("ctrl+[", "editor.outdent_line"),
] {
if let Some(seq) = parse_key_seq(spec) {
km.map.insert(seq, id.to_string());
}
}
for spec in ["ctrl+l"] {
if let Some(seq) = parse_key_seq(spec) {
km.map.remove(&seq);
}
}
}
for section in ["global", cfg.editor.input_style.as_str()] {
if let Some(table) = cfg.keys.get(section) {
for (key, id) in table {
let Some(seq) = parse_key_seq(key) else {
eprintln!("mnml: [keys.{section}] bad key spec {key:?} — ignored");
continue;
};
let id = id.trim();
if id.is_empty() || id == "none" || id == "unbound" {
km.map.remove(&seq);
} else {
km.map.insert(seq, id.to_string());
}
}
}
}
km.rebuild_prefixes();
km
}
fn rebuild_prefixes(&mut self) {
self.prefixes.clear();
for seq in self.map.keys() {
for i in 1..seq.len() {
self.prefixes.insert(seq[..i].to_vec());
}
}
}
pub fn resolve(&self, ev: &KeyEvent) -> Option<&str> {
let chord = Chord::of(ev);
self.map
.get(std::slice::from_ref(&chord))
.map(String::as_str)
}
pub fn resolve_seq(&self, seq: &[Chord]) -> SeqResolution<'_> {
if seq.is_empty() {
return SeqResolution::None;
}
let exact = self.map.get(seq).map(String::as_str);
let is_prefix = self.prefixes.contains(seq);
match (exact, is_prefix) {
(Some(id), false) => SeqResolution::Run(id),
(Some(id), true) => SeqResolution::PendingWithFallback(id),
(None, true) => SeqResolution::Pending,
(None, false) => SeqResolution::None,
}
}
pub fn iter(&self) -> impl Iterator<Item = (&[Chord], &str)> {
self.map.iter().map(|(s, id)| (s.as_slice(), id.as_str()))
}
pub fn bind(&mut self, spec: &str, id: &str) {
if let Some(seq) = parse_key_seq(spec) {
self.map.insert(seq, id.to_string());
self.rebuild_prefixes();
}
}
}
pub fn chord_seq_to_spec(seq: &[Chord]) -> String {
seq.iter()
.map(|c| c.to_spec())
.collect::<Vec<_>>()
.join(" ")
}
pub fn parse_key_seq(spec: &str) -> Option<Vec<Chord>> {
let mut out = Vec::new();
for tok in spec.split_whitespace() {
let ev = parse_key_spec(tok)?;
out.push(Chord::of(&ev));
}
if out.is_empty() { None } else { Some(out) }
}
pub fn parse_key_spec(spec: &str) -> Option<KeyEvent> {
let spec = spec.trim();
if spec.is_empty() {
return None;
}
let mut mods = KeyModifiers::NONE;
let mut rest = spec;
loop {
let lower = rest.to_ascii_lowercase();
if let Some(r) = lower
.strip_prefix("ctrl+")
.or_else(|| lower.strip_prefix("c-"))
{
mods |= KeyModifiers::CONTROL;
rest = &rest[rest.len() - r.len()..];
} else if let Some(r) = lower
.strip_prefix("shift+")
.or_else(|| lower.strip_prefix("s-"))
{
mods |= KeyModifiers::SHIFT;
rest = &rest[rest.len() - r.len()..];
} else if let Some(r) = lower
.strip_prefix("alt+")
.or_else(|| lower.strip_prefix("a-"))
.or_else(|| lower.strip_prefix("meta+"))
{
mods |= KeyModifiers::ALT;
rest = &rest[rest.len() - r.len()..];
} else if let Some(r) = lower
.strip_prefix("super+")
.or_else(|| lower.strip_prefix("cmd+"))
.or_else(|| lower.strip_prefix("win+"))
{
mods |= KeyModifiers::SUPER;
rest = &rest[rest.len() - r.len()..];
} else {
break;
}
}
let code = key_code(rest)?;
Some(KeyEvent::new(code, mods))
}
fn key_code(token: &str) -> Option<KeyCode> {
let t = token.to_ascii_lowercase();
Some(match t.as_str() {
"enter" | "return" | "cr" => KeyCode::Enter,
"tab" => KeyCode::Tab,
"backtab" => KeyCode::BackTab,
"esc" | "escape" => KeyCode::Esc,
"space" | "leader" => KeyCode::Char(' '),
"backspace" | "bs" => KeyCode::Backspace,
"delete" | "del" => KeyCode::Delete,
"insert" | "ins" => KeyCode::Insert,
"up" => KeyCode::Up,
"down" => KeyCode::Down,
"left" => KeyCode::Left,
"right" => KeyCode::Right,
"home" => KeyCode::Home,
"end" => KeyCode::End,
"pageup" | "pgup" => KeyCode::PageUp,
"pagedown" | "pgdn" | "pgdown" => KeyCode::PageDown,
"f1" => KeyCode::F(1),
"f2" => KeyCode::F(2),
"f3" => KeyCode::F(3),
"f4" => KeyCode::F(4),
"f5" => KeyCode::F(5),
"f6" => KeyCode::F(6),
"f7" => KeyCode::F(7),
"f8" => KeyCode::F(8),
"f9" => KeyCode::F(9),
"f10" => KeyCode::F(10),
"f11" => KeyCode::F(11),
"f12" => KeyCode::F(12),
_ => {
let mut chars = token.chars();
let c = chars.next()?;
if chars.next().is_some() {
return None; }
KeyCode::Char(c)
}
})
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn parses_modified_and_named() {
let e = parse_key_spec("ctrl+q").unwrap();
assert_eq!(e.code, KeyCode::Char('q'));
assert!(e.modifiers.contains(KeyModifiers::CONTROL));
assert_eq!(parse_key_spec("enter").unwrap().code, KeyCode::Enter);
assert_eq!(parse_key_spec("down").unwrap().code, KeyCode::Down);
let e = parse_key_spec("ctrl+shift+p").unwrap();
assert!(
e.modifiers.contains(KeyModifiers::CONTROL)
&& e.modifiers.contains(KeyModifiers::SHIFT)
);
assert_eq!(parse_key_spec("a").unwrap().code, KeyCode::Char('a'));
assert!(parse_key_spec("nope-not-a-key").is_none());
}
#[test]
fn leader_alias_maps_to_space() {
assert_eq!(parse_key_spec("leader").unwrap().code, KeyCode::Char(' '));
assert_eq!(
parse_key_spec("ctrl+leader").unwrap().code,
KeyCode::Char(' ')
);
}
#[test]
fn chord_normalizes_uppercase_char() {
let a = Chord::of(&KeyEvent::new(KeyCode::Char('P'), KeyModifiers::NONE));
let b = Chord::of(&KeyEvent::new(KeyCode::Char('p'), KeyModifiers::SHIFT));
assert_eq!(a, b);
assert_eq!(a.code, KeyCode::Char('p'));
assert!(a.mods.contains(KeyModifiers::SHIFT));
}
#[test]
fn default_keymap_has_builtin_chords() {
let km = Keymap::build(&Config::default());
let ev = |s: &str| parse_key_spec(s).unwrap();
assert_eq!(km.resolve(&ev("ctrl+q")), Some("app.quit"));
assert_eq!(km.resolve(&ev("ctrl+p")), Some("picker.files"));
assert_eq!(km.resolve(&ev("f1")), Some("view.help"));
assert_eq!(km.resolve(&ev("ctrl+shift+p")), Some("palette"));
assert_eq!(km.resolve(&ev("ctrl+b")), Some("view.toggle_tree"));
assert_eq!(km.resolve(&ev("ctrl+z")), None);
}
#[test]
fn config_overlays_and_unbinds() {
let mut cfg = Config::default();
let mut global = std::collections::BTreeMap::new();
global.insert("ctrl+;".to_string(), "palette".to_string()); global.insert("ctrl+p".to_string(), "none".to_string()); global.insert("ctrl+b".to_string(), "tree.refresh".to_string()); cfg.keys.insert("global".to_string(), global);
let km = Keymap::build(&cfg);
let ev = |s: &str| parse_key_spec(s).unwrap();
assert_eq!(km.resolve(&ev("ctrl+;")), Some("palette"));
assert_eq!(km.resolve(&ev("ctrl+p")), None);
assert_eq!(km.resolve(&ev("ctrl+b")), Some("tree.refresh"));
assert_eq!(km.resolve(&ev("f1")), Some("view.help"));
}
#[test]
fn input_style_section_overrides_global() {
let mut cfg = Config::default();
cfg.editor.input_style = "vim".to_string();
cfg.keys.insert(
"global".to_string(),
std::collections::BTreeMap::from([("ctrl+g".to_string(), "app.quit".to_string())]),
);
cfg.keys.insert(
"vim".to_string(),
std::collections::BTreeMap::from([("ctrl+g".to_string(), "tree.refresh".to_string())]),
);
let km = Keymap::build(&cfg);
assert_eq!(
km.resolve(&parse_key_spec("ctrl+g").unwrap()),
Some("tree.refresh")
);
}
fn chord(spec: &str) -> Chord {
Chord::of(&parse_key_spec(spec).unwrap())
}
#[test]
fn parse_key_seq_handles_single_and_multi_token() {
assert_eq!(parse_key_seq("ctrl+a").unwrap().len(), 1);
let seq = parse_key_seq("ctrl+k ctrl+i").unwrap();
assert_eq!(seq.len(), 2);
assert_eq!(seq[0], chord("ctrl+k"));
assert_eq!(seq[1], chord("ctrl+i"));
}
#[test]
fn parse_key_seq_rejects_bad_token() {
assert!(parse_key_seq("ctrl+k bogus").is_none());
assert!(parse_key_seq("").is_none());
}
#[test]
fn resolve_seq_returns_run_for_exact_match() {
let km = Keymap::build(&Config::default());
assert_eq!(
km.resolve_seq(&[chord("f1")]),
SeqResolution::Run("view.help")
);
}
#[test]
fn resolve_seq_returns_none_for_unbound() {
let km = Keymap::build(&Config::default());
assert_eq!(km.resolve_seq(&[chord("ctrl+z")]), SeqResolution::None);
assert_eq!(km.resolve_seq(&[]), SeqResolution::None);
}
#[test]
fn resolve_seq_returns_pending_with_fallback_for_chain_prefix() {
let km = Keymap::build(&Config::default());
match km.resolve_seq(&[chord("ctrl+k")]) {
SeqResolution::PendingWithFallback(fb) => assert_eq!(fb, "whichkey.leader"),
other => panic!("expected PendingWithFallback, got {other:?}"),
}
assert_eq!(
km.resolve_seq(&[chord("ctrl+k"), chord("ctrl+i")]),
SeqResolution::Run("lsp.hover")
);
}
#[test]
fn resolve_seq_pending_alone_when_prefix_has_no_leaf() {
let mut km = Keymap::default();
km.map
.insert(parse_key_seq("alt+q alt+z").unwrap(), "test.cmd".into());
km.rebuild_prefixes();
assert_eq!(km.resolve_seq(&[chord("alt+q")]), SeqResolution::Pending);
assert_eq!(
km.resolve_seq(&[chord("alt+q"), chord("alt+z")]),
SeqResolution::Run("test.cmd")
);
}
#[test]
fn chord_seq_to_spec_joins_with_spaces() {
let seq = vec![chord("ctrl+k"), chord("ctrl+i")];
assert_eq!(chord_seq_to_spec(&seq), "ctrl+k ctrl+i");
let lone = vec![chord("f5")];
assert_eq!(chord_seq_to_spec(&lone), "f5");
}
}