feat(evil-keys): add keybinding crate with trie dispatch, count prefix, and timeout
Plug'n'play modal keybinding system inspired by Doom Emacs + Evil mode.
Generic over consumer Action type. Core: Key parser ("C-d", "SPC"),
trie-based sequence matching with conflict detection, count prefix (5j),
timeout tracking, which-key introspection, and multi-mode dispatch.
78 unit tests covering key parsing, trie conflicts, dispatch state machine,
count accumulation, timeout expiry, and which-key generation.
Ultraworked with [Sisyphus](https://github.com/code-yeongyu/claude-agent)
Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>
This commit is contained in:
@@ -0,0 +1,443 @@
|
||||
use crossterm::event::{KeyCode, KeyEvent, KeyModifiers};
|
||||
use std::fmt;
|
||||
use std::hash::{Hash, Hasher};
|
||||
|
||||
use crate::error::ParseError;
|
||||
|
||||
#[derive(Clone, Copy, Debug)]
|
||||
pub struct Key {
|
||||
pub code: KeyCode,
|
||||
pub modifiers: KeyModifiers,
|
||||
}
|
||||
|
||||
impl PartialEq for Key {
|
||||
fn eq(&self, other: &Self) -> bool {
|
||||
self.code == other.code && self.modifiers == other.modifiers
|
||||
}
|
||||
}
|
||||
|
||||
impl Eq for Key {}
|
||||
|
||||
impl Hash for Key {
|
||||
fn hash<H: Hasher>(&self, state: &mut H) {
|
||||
hash_key_code(&self.code, state);
|
||||
self.modifiers.bits().hash(state);
|
||||
}
|
||||
}
|
||||
|
||||
fn hash_key_code<H: Hasher>(code: &KeyCode, state: &mut H) {
|
||||
match code {
|
||||
KeyCode::Backspace => (0u8).hash(state),
|
||||
KeyCode::Enter => (1u8).hash(state),
|
||||
KeyCode::Left => (2u8).hash(state),
|
||||
KeyCode::Right => (3u8).hash(state),
|
||||
KeyCode::Up => (4u8).hash(state),
|
||||
KeyCode::Down => (5u8).hash(state),
|
||||
KeyCode::Home => (6u8).hash(state),
|
||||
KeyCode::End => (7u8).hash(state),
|
||||
KeyCode::PageUp => (8u8).hash(state),
|
||||
KeyCode::PageDown => (9u8).hash(state),
|
||||
KeyCode::Tab => (10u8).hash(state),
|
||||
KeyCode::BackTab => (11u8).hash(state),
|
||||
KeyCode::Delete => (12u8).hash(state),
|
||||
KeyCode::Insert => (13u8).hash(state),
|
||||
KeyCode::F(n) => {
|
||||
(14u8).hash(state);
|
||||
n.hash(state);
|
||||
}
|
||||
KeyCode::Char(c) => {
|
||||
(15u8).hash(state);
|
||||
c.hash(state);
|
||||
}
|
||||
KeyCode::Null => (16u8).hash(state),
|
||||
KeyCode::Esc => (17u8).hash(state),
|
||||
KeyCode::CapsLock => (18u8).hash(state),
|
||||
KeyCode::ScrollLock => (19u8).hash(state),
|
||||
KeyCode::NumLock => (20u8).hash(state),
|
||||
KeyCode::PrintScreen => (21u8).hash(state),
|
||||
KeyCode::Pause => (22u8).hash(state),
|
||||
KeyCode::Menu => (23u8).hash(state),
|
||||
KeyCode::KeypadBegin => (24u8).hash(state),
|
||||
KeyCode::Media(m) => {
|
||||
(25u8).hash(state);
|
||||
(*m as u8).hash(state);
|
||||
}
|
||||
KeyCode::Modifier(m) => {
|
||||
(26u8).hash(state);
|
||||
(*m as u8).hash(state);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl From<KeyEvent> for Key {
|
||||
fn from(e: KeyEvent) -> Self {
|
||||
Key {
|
||||
code: e.code,
|
||||
modifiers: e.modifiers,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl fmt::Display for Key {
|
||||
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
|
||||
if self.modifiers.contains(KeyModifiers::CONTROL) {
|
||||
write!(f, "C-")?;
|
||||
}
|
||||
if self.modifiers.contains(KeyModifiers::SHIFT) {
|
||||
write!(f, "S-")?;
|
||||
}
|
||||
if self.modifiers.contains(KeyModifiers::ALT) {
|
||||
write!(f, "A-")?;
|
||||
}
|
||||
|
||||
match self.code {
|
||||
KeyCode::Char(' ') => write!(f, "SPC"),
|
||||
KeyCode::Char(c) => write!(f, "{c}"),
|
||||
KeyCode::Esc => write!(f, "Esc"),
|
||||
KeyCode::Tab => write!(f, "Tab"),
|
||||
KeyCode::Enter => write!(f, "Enter"),
|
||||
KeyCode::Backspace => write!(f, "Backspace"),
|
||||
KeyCode::F(n) => write!(f, "F{n}"),
|
||||
KeyCode::Left => write!(f, "Left"),
|
||||
KeyCode::Right => write!(f, "Right"),
|
||||
KeyCode::Up => write!(f, "Up"),
|
||||
KeyCode::Down => write!(f, "Down"),
|
||||
KeyCode::Home => write!(f, "Home"),
|
||||
KeyCode::End => write!(f, "End"),
|
||||
KeyCode::PageUp => write!(f, "PageUp"),
|
||||
KeyCode::PageDown => write!(f, "PageDown"),
|
||||
KeyCode::Delete => write!(f, "Delete"),
|
||||
KeyCode::Insert => write!(f, "Insert"),
|
||||
_ => write!(f, "?"),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub fn parse_key(input: &str) -> Result<Key, ParseError> {
|
||||
if input.trim().is_empty() {
|
||||
return Err(ParseError::EmptyInput);
|
||||
}
|
||||
|
||||
if input.contains(' ') {
|
||||
return Err(ParseError::UnknownKey(input.to_string()));
|
||||
}
|
||||
|
||||
let trimmed = input;
|
||||
|
||||
let mut modifiers = KeyModifiers::NONE;
|
||||
let mut remaining = trimmed;
|
||||
let mut has_ctrl = false;
|
||||
let mut has_shift = false;
|
||||
let mut has_alt = false;
|
||||
|
||||
loop {
|
||||
if remaining.starts_with("C-") {
|
||||
if has_ctrl {
|
||||
return Err(ParseError::DuplicateModifier);
|
||||
}
|
||||
has_ctrl = true;
|
||||
modifiers |= KeyModifiers::CONTROL;
|
||||
remaining = &remaining[2..];
|
||||
} else if remaining.starts_with("S-") {
|
||||
if has_shift {
|
||||
return Err(ParseError::DuplicateModifier);
|
||||
}
|
||||
has_shift = true;
|
||||
modifiers |= KeyModifiers::SHIFT;
|
||||
remaining = &remaining[2..];
|
||||
} else if remaining.starts_with("A-") {
|
||||
if has_alt {
|
||||
return Err(ParseError::DuplicateModifier);
|
||||
}
|
||||
has_alt = true;
|
||||
modifiers |= KeyModifiers::ALT;
|
||||
remaining = &remaining[2..];
|
||||
} else {
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
if remaining.is_empty() {
|
||||
return Err(ParseError::DanglingModifier);
|
||||
}
|
||||
|
||||
let code = match remaining {
|
||||
"SPC" => KeyCode::Char(' '),
|
||||
"Esc" => KeyCode::Esc,
|
||||
"Tab" => KeyCode::Tab,
|
||||
"Enter" => KeyCode::Enter,
|
||||
"Backspace" => KeyCode::Backspace,
|
||||
"Left" => KeyCode::Left,
|
||||
"Right" => KeyCode::Right,
|
||||
"Up" => KeyCode::Up,
|
||||
"Down" => KeyCode::Down,
|
||||
"Home" => KeyCode::Home,
|
||||
"End" => KeyCode::End,
|
||||
"PageUp" => KeyCode::PageUp,
|
||||
"PageDown" => KeyCode::PageDown,
|
||||
"Delete" => KeyCode::Delete,
|
||||
"Insert" => KeyCode::Insert,
|
||||
"-" => KeyCode::Char('-'),
|
||||
s if s.starts_with('F') && s.len() > 1 => {
|
||||
let num_str = &s[1..];
|
||||
match num_str.parse::<u8>() {
|
||||
Ok(n) if (1..=12).contains(&n) => KeyCode::F(n),
|
||||
_ => return Err(ParseError::UnknownKey(input.to_string())),
|
||||
}
|
||||
}
|
||||
s if s.len() == 1 => {
|
||||
let c = s.chars().next().expect("non-empty string");
|
||||
if !c.is_ascii() {
|
||||
return Err(ParseError::UnknownKey(input.to_string()));
|
||||
}
|
||||
if has_shift && c.is_ascii_uppercase() {
|
||||
return Err(ParseError::RedundantShift);
|
||||
}
|
||||
KeyCode::Char(c)
|
||||
}
|
||||
_ => return Err(ParseError::UnknownKey(input.to_string())),
|
||||
};
|
||||
|
||||
Ok(Key { code, modifiers })
|
||||
}
|
||||
|
||||
pub fn parse_sequence(input: &str) -> Result<Vec<Key>, ParseError> {
|
||||
let trimmed = input.trim();
|
||||
|
||||
if trimmed.is_empty() {
|
||||
return Err(ParseError::EmptyInput);
|
||||
}
|
||||
|
||||
trimmed.split_whitespace().map(parse_key).collect()
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn test_empty_input() {
|
||||
assert_eq!(parse_key(""), Err(ParseError::EmptyInput));
|
||||
assert_eq!(parse_key(" "), Err(ParseError::EmptyInput));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_space_in_key() {
|
||||
assert!(matches!(parse_key(" j"), Err(ParseError::UnknownKey(_))));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_dangling_modifier() {
|
||||
assert_eq!(parse_key("C-"), Err(ParseError::DanglingModifier));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_duplicate_modifier() {
|
||||
assert_eq!(parse_key("C-C-d"), Err(ParseError::DuplicateModifier));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_lowercase_modifier() {
|
||||
assert!(matches!(parse_key("c-d"), Err(ParseError::UnknownKey(_))));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_ctrl_d() {
|
||||
let key = parse_key("C-d").unwrap();
|
||||
assert_eq!(key.code, KeyCode::Char('d'));
|
||||
assert_eq!(key.modifiers, KeyModifiers::CONTROL);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_ctrl_shift_d() {
|
||||
let key = parse_key("C-S-d").unwrap();
|
||||
assert_eq!(key.code, KeyCode::Char('d'));
|
||||
assert_eq!(key.modifiers, KeyModifiers::CONTROL | KeyModifiers::SHIFT);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_modifier_order_normalized() {
|
||||
let key1 = parse_key("S-C-d").unwrap();
|
||||
let key2 = parse_key("C-S-d").unwrap();
|
||||
assert_eq!(key1, key2);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_uppercase_no_shift() {
|
||||
let key = parse_key("G").unwrap();
|
||||
assert_eq!(key.code, KeyCode::Char('G'));
|
||||
assert_eq!(key.modifiers, KeyModifiers::NONE);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_shift_lowercase() {
|
||||
let key = parse_key("S-g").unwrap();
|
||||
assert_eq!(key.code, KeyCode::Char('g'));
|
||||
assert_eq!(key.modifiers, KeyModifiers::SHIFT);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_redundant_shift() {
|
||||
assert_eq!(parse_key("S-G"), Err(ParseError::RedundantShift));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_bare_modifier_letters() {
|
||||
let key = parse_key("C").unwrap();
|
||||
assert_eq!(key.code, KeyCode::Char('C'));
|
||||
assert_eq!(key.modifiers, KeyModifiers::NONE);
|
||||
|
||||
let key = parse_key("S").unwrap();
|
||||
assert_eq!(key.code, KeyCode::Char('S'));
|
||||
assert_eq!(key.modifiers, KeyModifiers::NONE);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_special_keys() {
|
||||
let key = parse_key("SPC").unwrap();
|
||||
assert_eq!(key.code, KeyCode::Char(' '));
|
||||
assert_eq!(key.modifiers, KeyModifiers::NONE);
|
||||
|
||||
assert!(matches!(parse_key("spc"), Err(ParseError::UnknownKey(_))));
|
||||
|
||||
let key = parse_key("Esc").unwrap();
|
||||
assert_eq!(key.code, KeyCode::Esc);
|
||||
assert_eq!(key.modifiers, KeyModifiers::NONE);
|
||||
|
||||
let key = parse_key("Tab").unwrap();
|
||||
assert_eq!(key.code, KeyCode::Tab);
|
||||
assert_eq!(key.modifiers, KeyModifiers::NONE);
|
||||
|
||||
let key = parse_key("Enter").unwrap();
|
||||
assert_eq!(key.code, KeyCode::Enter);
|
||||
assert_eq!(key.modifiers, KeyModifiers::NONE);
|
||||
|
||||
// Tab != 't'
|
||||
assert_ne!(parse_key("Tab").unwrap(), parse_key("t").unwrap());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_function_keys() {
|
||||
let key = parse_key("F1").unwrap();
|
||||
assert_eq!(key.code, KeyCode::F(1));
|
||||
assert_eq!(key.modifiers, KeyModifiers::NONE);
|
||||
|
||||
let key = parse_key("F12").unwrap();
|
||||
assert_eq!(key.code, KeyCode::F(12));
|
||||
assert_eq!(key.modifiers, KeyModifiers::NONE);
|
||||
|
||||
assert!(matches!(parse_key("F0"), Err(ParseError::UnknownKey(_))));
|
||||
assert!(matches!(parse_key("F13"), Err(ParseError::UnknownKey(_))));
|
||||
|
||||
let key = parse_key("F").unwrap();
|
||||
assert_eq!(key.code, KeyCode::Char('F'));
|
||||
assert_eq!(key.modifiers, KeyModifiers::NONE);
|
||||
|
||||
let key = parse_key("C-F1").unwrap();
|
||||
assert_eq!(key.code, KeyCode::F(1));
|
||||
assert_eq!(key.modifiers, KeyModifiers::CONTROL);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_symbols() {
|
||||
let key = parse_key("-").unwrap();
|
||||
assert_eq!(key.code, KeyCode::Char('-'));
|
||||
assert_eq!(key.modifiers, KeyModifiers::NONE);
|
||||
|
||||
let key = parse_key("C--").unwrap();
|
||||
assert_eq!(key.code, KeyCode::Char('-'));
|
||||
assert_eq!(key.modifiers, KeyModifiers::CONTROL);
|
||||
|
||||
let key = parse_key("[").unwrap();
|
||||
assert_eq!(key.code, KeyCode::Char('['));
|
||||
assert_eq!(key.modifiers, KeyModifiers::NONE);
|
||||
|
||||
let key = parse_key("?").unwrap();
|
||||
assert_eq!(key.code, KeyCode::Char('?'));
|
||||
assert_eq!(key.modifiers, KeyModifiers::NONE);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_digits() {
|
||||
for c in '0'..='9' {
|
||||
let key = parse_key(&c.to_string()).unwrap();
|
||||
assert_eq!(key.code, KeyCode::Char(c));
|
||||
assert_eq!(key.modifiers, KeyModifiers::NONE);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_invalid_non_ascii() {
|
||||
assert!(matches!(parse_key("é"), Err(ParseError::UnknownKey(_))));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_space_is_sequence() {
|
||||
assert!(matches!(parse_key("g g"), Err(ParseError::UnknownKey(_))));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_display_roundtrip() {
|
||||
let test_cases = [
|
||||
"j", "G", "C-d", "C-S-d", "SPC", "Esc", "Tab", "Enter", "F1", "-", "C--", "[",
|
||||
];
|
||||
for input in test_cases {
|
||||
let key = parse_key(input).unwrap();
|
||||
let displayed = key.to_string();
|
||||
let reparsed = parse_key(&displayed).unwrap();
|
||||
assert_eq!(key, reparsed, "roundtrip failed for {input}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_display_specific() {
|
||||
let key = Key {
|
||||
code: KeyCode::Char(' '),
|
||||
modifiers: KeyModifiers::NONE,
|
||||
};
|
||||
assert_eq!(key.to_string(), "SPC");
|
||||
|
||||
let key = Key {
|
||||
code: KeyCode::Char('d'),
|
||||
modifiers: KeyModifiers::CONTROL | KeyModifiers::SHIFT,
|
||||
};
|
||||
assert_eq!(key.to_string(), "C-S-d");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_sequence_parsing() {
|
||||
let seq = parse_sequence("g g").unwrap();
|
||||
assert_eq!(seq.len(), 2);
|
||||
assert_eq!(seq[0].code, KeyCode::Char('g'));
|
||||
assert_eq!(seq[1].code, KeyCode::Char('g'));
|
||||
|
||||
let seq = parse_sequence("SPC b l").unwrap();
|
||||
assert_eq!(seq.len(), 3);
|
||||
assert_eq!(seq[0].code, KeyCode::Char(' '));
|
||||
assert_eq!(seq[1].code, KeyCode::Char('b'));
|
||||
assert_eq!(seq[2].code, KeyCode::Char('l'));
|
||||
|
||||
assert_eq!(parse_sequence(""), Err(ParseError::EmptyInput));
|
||||
assert_eq!(parse_sequence(" "), Err(ParseError::EmptyInput));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_from_key_event() {
|
||||
let event = KeyEvent::new(KeyCode::Char('j'), KeyModifiers::NONE);
|
||||
let key = Key::from(event);
|
||||
assert_eq!(key.code, KeyCode::Char('j'));
|
||||
assert_eq!(key.modifiers, KeyModifiers::NONE);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_key_hash() {
|
||||
use std::collections::HashMap;
|
||||
|
||||
let mut map = HashMap::new();
|
||||
let key1 = parse_key("C-d").unwrap();
|
||||
let key2 = parse_key("C-d").unwrap();
|
||||
|
||||
map.insert(key1, "action");
|
||||
assert_eq!(map.get(&key2), Some(&"action"));
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user