216a11b9db
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>
444 lines
13 KiB
Rust
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"));
|
|
}
|
|
}
|