Files
ui-agregator/crates/evil-keys/src/key.rs
T
Alexander 216a11b9db 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>
2026-05-10 10:59:43 +02:00

444 lines
13 KiB
Rust

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"));
}
}