pmacs/src/key.rs

513 lines
18 KiB
Rust

// 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)
//! * `<up>`, `<down>`, `<left>`, `<right>`
//! * `<home>`, `<end>`, `<pageup>`, `<pagedown>`, `<insert>`
//! * `<f1>` ... `<f12>`
//!
//! 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<Chord>` 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<Chord>;
// ---------------------------------------------------------------------------
// 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 `<f5>` or `a`.
///
/// # Errors
///
/// Returns [`KeyParseError`] for empty input, malformed modifier
/// prefixes, or unknown keys.
pub fn parse_chord(s: &str) -> Result<Chord, KeyParseError> {
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<Sequence, KeyParseError> {
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<KeyCode, KeyParseError> {
// 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: <up>, <f5>, <pageup>, ...
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("<up>"),
KeyCode::Down => f.write_str("<down>"),
KeyCode::Left => f.write_str("<left>"),
KeyCode::Right => f.write_str("<right>"),
KeyCode::Home => f.write_str("<home>"),
KeyCode::End => f.write_str("<end>"),
KeyCode::PageUp => f.write_str("<pageup>"),
KeyCode::PageDown => f.write_str("<pagedown>"),
KeyCode::Insert => f.write_str("<insert>"),
KeyCode::F(n) => write!(f, "<f{n}>"),
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("<up>").unwrap(), Chord::plain(KeyCode::Up));
assert_eq!(parse_chord("<f1>").unwrap(), Chord::plain(KeyCode::F(1)));
assert_eq!(parse_chord("<f12>").unwrap(), Chord::plain(KeyCode::F(12)));
assert_eq!(
parse_chord("<pageup>").unwrap(),
Chord::plain(KeyCode::PageUp)
);
assert_eq!(
parse_chord("C-<up>").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("<wat>") {
Err(KeyParseError::UnknownKey { token, .. }) => assert_eq!(token, "<wat>"),
other => panic!("expected UnknownKey, got {other:?}"),
}
match parse_chord("<f99>") {
Err(KeyParseError::UnknownKey { token, .. }) => assert_eq!(token, "<f99>"),
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(' '))),
("<up>", Chord::plain(KeyCode::Up)),
("<f5>", 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);
}
}