// key.rs --- Chord type and Emacs-style parser. //! Keypress representation and Emacs-style notation parser. //! //! Pmacs's keymap (T M2.4) lives over [`crossterm::event::KeyEvent`] //! but doesn't expose crossterm types to users: a chord is a //! [`Chord`] (key + modifiers) and a sequence of chords is a //! [`Sequence`]. The parser reads strings like `"C-x C-s"` into a //! [`Sequence`]; `Display` prints the canonical form back. //! //! # Notation //! //! Single chord: zero or more single-letter modifier prefixes followed //! by a `-` and one named-or-character key. //! //! * `C-` --- Control //! * `M-` --- Alt (Meta in Emacs lineage) //! * `S-` --- Shift (only meaningful for non-letter keys; letters //! should use the literal uppercase character) //! * `s-` --- Super //! //! Modifiers commute (`C-M-x` and `M-C-x` parse identically). Keys are //! either a single character (`a`, `1`, `/`) or a name: //! //! * `RET` (Enter), `SPC` (Space), `TAB`, `ESC`, `BS` (Backspace), //! `DEL` (Delete) //! * ``, ``, ``, `` //! * ``, ``, ``, ``, `` //! * `` ... `` //! //! Sequences are space-separated chords: `C-x C-s` is two chords. use std::fmt; use crossterm::event::{KeyCode, KeyModifiers}; use thiserror::Error; // --------------------------------------------------------------------------- // Chord and Sequence // --------------------------------------------------------------------------- /// A single keypress with modifiers. /// /// Two `Chord`s are equal when their `code` and `modifiers` match /// exactly. The keymap trie hashes on this. #[derive(Copy, Clone, Debug, Eq, Hash, PartialEq)] pub struct Chord { /// The key code (after kitty-keyboard-protocol disambiguation when /// the terminal supports it; see frontend setup at T M2.4). pub code: KeyCode, /// The active modifiers. We canonicalize on construction (e.g. /// uppercase letters strip SHIFT) so logically-equivalent input /// hashes the same way. pub modifiers: KeyModifiers, } impl Chord { /// Build a chord, canonicalizing modifiers vs code. /// /// Canonicalization rules: /// * `KeyCode::Char('A')` with `SHIFT` --- the SHIFT bit is /// stripped; the uppercase letter already implies it. /// * `KeyCode::Char('a')` with `SHIFT` --- promoted to /// `KeyCode::Char('A')` and SHIFT stripped. Some terminals (or /// kitty-protocol modes the user might enable separately) report /// shifted ASCII letters as the unshifted code with SHIFT set; /// normalizing here means `S-a` and `A` hash identically and /// self-insert produces `A`. Non-letter shifted keys like /// `Shift+9` rely on terminal-side layout translation (frontend /// does not push `REPORT_ALL_KEYS_AS_ESCAPE_CODES`); pmacs has /// no layout knowledge to map `9 + SHIFT` to `(`. /// * `KeyCode::Char(c)` for any `c` whose lowercase is itself /// (`/`, `1`, ...) leaves modifiers as-is. #[must_use] pub fn new(code: KeyCode, modifiers: KeyModifiers) -> Self { let (code, modifiers) = match code { KeyCode::Char(ch) if ch.is_ascii_uppercase() => (code, modifiers - KeyModifiers::SHIFT), KeyCode::Char(ch) if ch.is_ascii_lowercase() && modifiers.contains(KeyModifiers::SHIFT) => { ( KeyCode::Char(ch.to_ascii_uppercase()), modifiers - KeyModifiers::SHIFT, ) } _ => (code, modifiers), }; Self { code, modifiers } } /// Build a plain unmodified chord. #[must_use] pub fn plain(code: KeyCode) -> Self { Self::new(code, KeyModifiers::NONE) } } /// A sequence of one or more chords (e.g. `C-x C-s` is two chords). /// /// Defined as `Vec` rather than a slice newtype because most /// call sites build sequences incrementally (the dispatcher's pending /// prefix buffer) or pass them as already-owned vectors. pub type Sequence = Vec; // --------------------------------------------------------------------------- // Parser // --------------------------------------------------------------------------- /// Parser failures. #[derive(Debug, Error, PartialEq, Eq)] pub enum KeyParseError { /// The input was empty or contained only whitespace. #[error("empty key sequence")] Empty, /// A modifier prefix didn't have a `-` or had a duplicate. #[error("bad modifier in chord {chord:?}: {detail}")] BadModifier { /// The chord substring that failed. chord: String, /// What specifically was wrong. detail: String, }, /// The token after the last `-` (or the bare token) wasn't a known /// named key or a single character. #[error("unknown key {token:?} in chord {chord:?}")] UnknownKey { /// The chord substring that failed. chord: String, /// The unknown token. token: String, }, } /// Parse a single chord, e.g. `C-x` or `M-RET` or `` or `a`. /// /// # Errors /// /// Returns [`KeyParseError`] for empty input, malformed modifier /// prefixes, or unknown keys. pub fn parse_chord(s: &str) -> Result { let trimmed = s.trim(); if trimmed.is_empty() { return Err(KeyParseError::Empty); } let mut modifiers = KeyModifiers::NONE; let mut rest = trimmed; // Modifier prefixes: while the next two chars are "X-" with X in // {C, M, S, s} and there's something after, peel it off. loop { let mut chars = rest.chars(); let first = chars.next(); let second = chars.next(); let after = chars.as_str(); match (first, second) { (Some(c), Some('-')) if "CMSs".contains(c) && !after.is_empty() => { let bit = match c { 'C' => KeyModifiers::CONTROL, 'M' => KeyModifiers::ALT, 'S' => KeyModifiers::SHIFT, 's' => KeyModifiers::SUPER, _ => unreachable!(), }; if modifiers.contains(bit) { return Err(KeyParseError::BadModifier { chord: trimmed.to_owned(), detail: format!("modifier `{c}-` repeated"), }); } modifiers |= bit; rest = after; } _ => break, } } let code = parse_key_code(rest, trimmed)?; Ok(Chord::new(code, modifiers)) } /// Parse a sequence: whitespace-separated chords. `C-x C-s` is two /// chords; `C-x f` is two (extra spaces are fine). /// /// # Errors /// /// Returns [`KeyParseError::Empty`] if no chords parse out, or any /// per-chord error from [`parse_chord`]. pub fn parse_sequence(s: &str) -> Result { let mut out = Vec::new(); for tok in s.split_whitespace() { out.push(parse_chord(tok)?); } if out.is_empty() { return Err(KeyParseError::Empty); } Ok(out) } fn parse_key_code(token: &str, full_chord: &str) -> Result { // Named keys (case-sensitive uppercase canonical, but accept lowercase). let upper = token.to_ascii_uppercase(); let named = match upper.as_str() { "RET" | "RETURN" | "ENTER" => Some(KeyCode::Enter), "TAB" => Some(KeyCode::Tab), "SPC" | "SPACE" => Some(KeyCode::Char(' ')), "ESC" | "ESCAPE" => Some(KeyCode::Esc), "BS" | "BACKSPACE" => Some(KeyCode::Backspace), "DEL" | "DELETE" => Some(KeyCode::Delete), _ => None, }; if let Some(c) = named { return Ok(c); } // Bracketed names: , , , ... if token.starts_with('<') && token.ends_with('>') && token.len() >= 3 { let inner = &token[1..token.len() - 1].to_ascii_lowercase(); let code = match inner.as_str() { "up" => KeyCode::Up, "down" => KeyCode::Down, "left" => KeyCode::Left, "right" => KeyCode::Right, "home" => KeyCode::Home, "end" => KeyCode::End, "pageup" | "pgup" => KeyCode::PageUp, "pagedown" | "pgdn" => KeyCode::PageDown, "insert" | "ins" => KeyCode::Insert, other if other.starts_with('f') => { let n: u8 = other[1..].parse().map_err(|_| KeyParseError::UnknownKey { chord: full_chord.to_owned(), token: token.to_owned(), })?; if !(1..=12).contains(&n) { return Err(KeyParseError::UnknownKey { chord: full_chord.to_owned(), token: token.to_owned(), }); } KeyCode::F(n) } _ => { return Err(KeyParseError::UnknownKey { chord: full_chord.to_owned(), token: token.to_owned(), }); } }; return Ok(code); } // Single character. let mut chars = token.chars(); let ch = chars.next().ok_or_else(|| KeyParseError::UnknownKey { chord: full_chord.to_owned(), token: token.to_owned(), })?; if chars.next().is_some() { // Multi-char token that didn't match any rule above. return Err(KeyParseError::UnknownKey { chord: full_chord.to_owned(), token: token.to_owned(), }); } Ok(KeyCode::Char(ch)) } // --------------------------------------------------------------------------- // Display // --------------------------------------------------------------------------- impl fmt::Display for Chord { fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { if self.modifiers.contains(KeyModifiers::CONTROL) { f.write_str("C-")?; } if self.modifiers.contains(KeyModifiers::ALT) { f.write_str("M-")?; } if self.modifiers.contains(KeyModifiers::SHIFT) { f.write_str("S-")?; } if self.modifiers.contains(KeyModifiers::SUPER) { f.write_str("s-")?; } match self.code { KeyCode::Char(' ') => f.write_str("SPC"), KeyCode::Char(c) => write!(f, "{c}"), KeyCode::Enter => f.write_str("RET"), KeyCode::Tab => f.write_str("TAB"), KeyCode::Esc => f.write_str("ESC"), KeyCode::Backspace => f.write_str("BS"), KeyCode::Delete => f.write_str("DEL"), KeyCode::Up => f.write_str(""), KeyCode::Down => f.write_str(""), KeyCode::Left => f.write_str(""), KeyCode::Right => f.write_str(""), KeyCode::Home => f.write_str(""), KeyCode::End => f.write_str(""), KeyCode::PageUp => f.write_str(""), KeyCode::PageDown => f.write_str(""), KeyCode::Insert => f.write_str(""), KeyCode::F(n) => write!(f, ""), other => write!(f, "{other:?}"), } } } /// Render a sequence as a space-separated chord string. #[must_use] pub fn display_sequence(seq: &[Chord]) -> String { use std::fmt::Write; let mut out = String::new(); for (i, c) in seq.iter().enumerate() { if i > 0 { out.push(' '); } let _ = write!(out, "{c}"); } out } // --------------------------------------------------------------------------- // Tests // --------------------------------------------------------------------------- #[cfg(test)] mod tests { use super::*; fn ctrl(c: char) -> Chord { Chord::new(KeyCode::Char(c), KeyModifiers::CONTROL) } fn plain(c: char) -> Chord { Chord::plain(KeyCode::Char(c)) } #[test] fn parses_plain_char() { assert_eq!(parse_chord("a").unwrap(), plain('a')); assert_eq!(parse_chord("/").unwrap(), plain('/')); } #[test] fn parses_control_modifier() { assert_eq!(parse_chord("C-x").unwrap(), ctrl('x')); } #[test] fn parses_meta_and_combinations() { assert_eq!( parse_chord("M-x").unwrap(), Chord::new(KeyCode::Char('x'), KeyModifiers::ALT) ); assert_eq!( parse_chord("C-M-x").unwrap(), Chord::new( KeyCode::Char('x'), KeyModifiers::CONTROL | KeyModifiers::ALT ) ); // Modifiers commute. assert_eq!(parse_chord("C-M-x").unwrap(), parse_chord("M-C-x").unwrap()); } #[test] fn parses_named_keys() { assert_eq!(parse_chord("RET").unwrap(), Chord::plain(KeyCode::Enter)); assert_eq!( parse_chord("SPC").unwrap(), Chord::plain(KeyCode::Char(' ')) ); assert_eq!(parse_chord("TAB").unwrap(), Chord::plain(KeyCode::Tab)); assert_eq!(parse_chord("ESC").unwrap(), Chord::plain(KeyCode::Esc)); assert_eq!(parse_chord("BS").unwrap(), Chord::plain(KeyCode::Backspace)); assert_eq!(parse_chord("DEL").unwrap(), Chord::plain(KeyCode::Delete)); } #[test] fn parses_bracketed_keys() { assert_eq!(parse_chord("").unwrap(), Chord::plain(KeyCode::Up)); assert_eq!(parse_chord("").unwrap(), Chord::plain(KeyCode::F(1))); assert_eq!(parse_chord("").unwrap(), Chord::plain(KeyCode::F(12))); assert_eq!( parse_chord("").unwrap(), Chord::plain(KeyCode::PageUp) ); assert_eq!( parse_chord("C-").unwrap(), Chord::new(KeyCode::Up, KeyModifiers::CONTROL) ); } #[test] fn parses_sequences() { let seq = parse_sequence("C-x C-s").unwrap(); assert_eq!(seq, vec![ctrl('x'), ctrl('s')]); let seq = parse_sequence("C-x f RET").unwrap(); assert_eq!(seq.len(), 3); assert_eq!(seq[2].code, KeyCode::Enter); } #[test] fn empty_input_errors() { assert_eq!(parse_chord("").unwrap_err(), KeyParseError::Empty); assert_eq!(parse_chord(" ").unwrap_err(), KeyParseError::Empty); assert_eq!(parse_sequence("").unwrap_err(), KeyParseError::Empty); } #[test] fn unknown_key_errors() { match parse_chord("") { Err(KeyParseError::UnknownKey { token, .. }) => assert_eq!(token, ""), other => panic!("expected UnknownKey, got {other:?}"), } match parse_chord("") { Err(KeyParseError::UnknownKey { token, .. }) => assert_eq!(token, ""), other => panic!("expected UnknownKey, got {other:?}"), } // Multi-char token that's not bracketed and not named. match parse_chord("hello") { Err(KeyParseError::UnknownKey { token, .. }) => assert_eq!(token, "hello"), other => panic!("expected UnknownKey, got {other:?}"), } } #[test] fn duplicate_modifier_errors() { match parse_chord("C-C-x") { Err(KeyParseError::BadModifier { detail, .. }) => { assert!(detail.contains("repeated"), "detail: {detail}"); } other => panic!("expected BadModifier, got {other:?}"), } } #[test] fn uppercase_letter_strips_shift_bit() { // A user typing "A" (literal) doesn't carry the SHIFT modifier; // the uppercase letter implies it. We canonicalize so a chord // built from KeyCode::Char('A') + SHIFT hashes the same as the // bare uppercase chord. let with_shift = Chord::new(KeyCode::Char('A'), KeyModifiers::SHIFT); let bare = Chord::plain(KeyCode::Char('A')); assert_eq!(with_shift, bare); } #[test] fn lowercase_letter_with_shift_promotes_to_uppercase() { // Some terminals (or kitty-protocol modes a user may enable // separately) report shifted ASCII letters as the unshifted // code with SHIFT set: `Shift+a` arrives as `Char('a')+SHIFT`, // not `Char('A')`. The canonicalization promotes to the // uppercase form so self-insert produces 'A' and bindings // for `S-a` hash identically to bindings for `A`. let promoted = Chord::new(KeyCode::Char('a'), KeyModifiers::SHIFT); let bare = Chord::plain(KeyCode::Char('A')); assert_eq!(promoted, bare); } #[test] fn shift_a_equals_capital_a_through_parser() { // Parser emits the same chord whether you write `S-a` or `A`. assert_eq!(parse_chord("S-a").unwrap(), parse_chord("A").unwrap()); } #[test] fn non_letter_shifted_chars_keep_modifiers() { // pmacs has no layout knowledge: a chord built from // `Char('9')+SHIFT` cannot be promoted to `Char('(')`. We // leave it as-is and rely on the terminal to deliver the // post-shift character (`(`) in normal operation. This test // pins the no-touch behavior so the canonicalization doesn't // creep into territory that would need a layout map. let chord = Chord::new(KeyCode::Char('9'), KeyModifiers::SHIFT); assert_eq!(chord.code, KeyCode::Char('9')); assert!(chord.modifiers.contains(KeyModifiers::SHIFT)); } #[test] fn display_round_trips_canonical_form() { let cases = [ ("a", plain('a')), ("C-x", ctrl('x')), ( "C-M-x", Chord::new( KeyCode::Char('x'), KeyModifiers::CONTROL | KeyModifiers::ALT, ), ), ("RET", Chord::plain(KeyCode::Enter)), ("SPC", Chord::plain(KeyCode::Char(' '))), ("", Chord::plain(KeyCode::Up)), ("", Chord::plain(KeyCode::F(5))), ]; for (text, chord) in cases { assert_eq!(format!("{chord}"), text, "display of {chord:?}"); assert_eq!(parse_chord(text).unwrap(), chord, "parse of {text:?}"); } } #[test] fn display_sequence_round_trips() { let seq = vec![ctrl('x'), ctrl('s')]; let s = display_sequence(&seq); assert_eq!(s, "C-x C-s"); assert_eq!(parse_sequence(&s).unwrap(), seq); } }