Skip to main content

tmprl_core/
key.rs

1//! Key representation and vim-style key-notation parsing.
2//!
3//! This is deliberately *not* crossterm's `KeyEvent`. Keeping our own type is what lets the
4//! keymap be tested without a terminal, and what stops a crossterm major bump from reaching
5//! into the keymap. `tmprl-tui` converts at the edge.
6
7use std::fmt;
8
9#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, PartialOrd, Ord)]
10pub enum Key {
11    Char(char),
12    Enter,
13    Esc,
14    Tab,
15    BackTab,
16    Backspace,
17    Delete,
18    Up,
19    Down,
20    Left,
21    Right,
22    Home,
23    End,
24    PageUp,
25    PageDown,
26    F(u8),
27}
28
29#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Default, PartialOrd, Ord)]
30pub struct Mods {
31    pub ctrl: bool,
32    pub alt: bool,
33    pub shift: bool,
34}
35
36impl Mods {
37    pub const NONE: Self = Self {
38        ctrl: false,
39        alt: false,
40        shift: false,
41    };
42    pub const CTRL: Self = Self {
43        ctrl: true,
44        alt: false,
45        shift: false,
46    };
47
48    pub fn is_none(self) -> bool {
49        self == Self::NONE
50    }
51}
52
53#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, PartialOrd, Ord)]
54pub struct Chord {
55    pub key: Key,
56    pub mods: Mods,
57}
58
59impl Chord {
60    pub const fn plain(key: Key) -> Self {
61        Self {
62            key,
63            mods: Mods::NONE,
64        }
65    }
66    pub const fn ch(c: char) -> Self {
67        Self::plain(Key::Char(c))
68    }
69    pub const fn ctrl(c: char) -> Self {
70        Self {
71            key: Key::Char(c),
72            mods: Mods::CTRL,
73        }
74    }
75
76    /// The character this chord would insert in Insert mode, if any.
77    pub fn as_insertable(self) -> Option<char> {
78        match self.key {
79            Key::Char(c) if self.mods.is_none() => Some(c),
80            _ => None,
81        }
82    }
83}
84
85#[derive(Debug, thiserror::Error, PartialEq, Eq)]
86pub enum KeyParseError {
87    #[error("unterminated `<` in key sequence `{0}`")]
88    Unterminated(String),
89    #[error("unknown key name `<{0}>`")]
90    UnknownKey(String),
91    #[error("empty key sequence")]
92    Empty,
93}
94
95fn key_name(k: Key) -> String {
96    match k {
97        Key::Enter => "CR".into(),
98        Key::Esc => "Esc".into(),
99        Key::Tab => "Tab".into(),
100        Key::BackTab => "S-Tab".into(),
101        Key::Backspace => "BS".into(),
102        Key::Delete => "Del".into(),
103        Key::Up => "Up".into(),
104        Key::Down => "Down".into(),
105        Key::Left => "Left".into(),
106        Key::Right => "Right".into(),
107        Key::Home => "Home".into(),
108        Key::End => "End".into(),
109        Key::PageUp => "PageUp".into(),
110        Key::PageDown => "PageDown".into(),
111        Key::F(n) => format!("F{n}"),
112        Key::Char(' ') => "Space".into(),
113        Key::Char(c) => c.to_string(),
114    }
115}
116
117fn named_key(name: &str) -> Option<Key> {
118    Some(match name {
119        "cr" | "enter" | "return" => Key::Enter,
120        "esc" | "escape" => Key::Esc,
121        "tab" => Key::Tab,
122        "s-tab" | "btab" => Key::BackTab,
123        "bs" | "backspace" => Key::Backspace,
124        "del" | "delete" => Key::Delete,
125        "space" => Key::Char(' '),
126        "up" => Key::Up,
127        "down" => Key::Down,
128        "left" => Key::Left,
129        "right" => Key::Right,
130        "home" => Key::Home,
131        "end" => Key::End,
132        "pageup" => Key::PageUp,
133        "pagedown" => Key::PageDown,
134        _ => {
135            let n = name.strip_prefix('f')?.parse::<u8>().ok()?;
136            if (1..=12).contains(&n) {
137                Key::F(n)
138            } else {
139                return None;
140            }
141        }
142    })
143}
144
145impl fmt::Display for Chord {
146    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
147        let bare = matches!(self.key, Key::Char(c) if c != ' ');
148        if self.mods.is_none() && bare {
149            return write!(f, "{}", key_name(self.key));
150        }
151        let mut prefix = String::new();
152        if self.mods.ctrl {
153            prefix.push_str("C-");
154        }
155        if self.mods.alt {
156            prefix.push_str("A-");
157        }
158        // BackTab already renders as S-Tab; don't double the prefix.
159        if self.mods.shift && self.key != Key::BackTab {
160            prefix.push_str("S-");
161        }
162        write!(f, "<{prefix}{}>", key_name(self.key))
163    }
164}
165
166/// A sequence of chords, e.g. `<leader>ff` is three chords.
167#[derive(Debug, Clone, PartialEq, Eq, Hash, PartialOrd, Ord, Default)]
168pub struct ChordSeq(pub Vec<Chord>);
169
170impl ChordSeq {
171    /// Parse vim key notation. `<leader>` expands to the supplied chord, so the leader is a
172    /// configuration value rather than something baked into every binding.
173    pub fn parse(s: &str, leader: Chord) -> Result<Self, KeyParseError> {
174        let mut out = Vec::new();
175        let chars: Vec<char> = s.chars().collect();
176        let mut i = 0;
177
178        while i < chars.len() {
179            if chars[i] != '<' {
180                out.push(Chord::ch(chars[i]));
181                i += 1;
182                continue;
183            }
184            let close = chars[i..]
185                .iter()
186                .position(|&c| c == '>')
187                .ok_or_else(|| KeyParseError::Unterminated(s.to_string()))?
188                + i;
189            let token: String = chars[i + 1..close].iter().collect();
190            out.push(parse_token(&token, leader)?);
191            i = close + 1;
192        }
193
194        if out.is_empty() {
195            return Err(KeyParseError::Empty);
196        }
197        Ok(Self(out))
198    }
199
200    pub fn len(&self) -> usize {
201        self.0.len()
202    }
203    pub fn is_empty(&self) -> bool {
204        self.0.is_empty()
205    }
206    pub fn starts_with(&self, prefix: &[Chord]) -> bool {
207        self.0.starts_with(prefix)
208    }
209}
210
211fn parse_token(token: &str, leader: Chord) -> Result<Chord, KeyParseError> {
212    let lower = token.to_ascii_lowercase();
213    if lower == "leader" {
214        return Ok(leader);
215    }
216    if let Some(k) = named_key(&lower) {
217        return Ok(Chord::plain(k));
218    }
219
220    // Strip modifier prefixes in any order: <C-A-x>, <A-C-x>.
221    let mut mods = Mods::NONE;
222    let mut rest = token;
223    loop {
224        let head = rest.get(..2).map(str::to_ascii_lowercase);
225        match head.as_deref() {
226            Some("c-") => mods.ctrl = true,
227            Some("a-") | Some("m-") => mods.alt = true,
228            Some("s-") => mods.shift = true,
229            _ => break,
230        }
231        rest = &rest[2..];
232    }
233    if mods.is_none() {
234        return Err(KeyParseError::UnknownKey(token.to_string()));
235    }
236
237    let lower_rest = rest.to_ascii_lowercase();
238    // `<S-Tab>` is BackTab, and its shift is already implied by the name.
239    if mods.shift && lower_rest == "tab" {
240        return Ok(Chord::plain(Key::BackTab));
241    }
242    if let Some(k) = named_key(&lower_rest) {
243        return Ok(Chord { key: k, mods });
244    }
245    let mut it = rest.chars();
246    match (it.next(), it.next()) {
247        (Some(c), None) => Ok(Chord {
248            key: Key::Char(c),
249            mods,
250        }),
251        _ => Err(KeyParseError::UnknownKey(token.to_string())),
252    }
253}
254
255impl fmt::Display for ChordSeq {
256    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
257        for c in &self.0 {
258            write!(f, "{c}")?;
259        }
260        Ok(())
261    }
262}
263
264#[cfg(test)]
265mod tests {
266    use super::*;
267
268    const LEADER: Chord = Chord::ch(' ');
269
270    fn seq(s: &str) -> ChordSeq {
271        ChordSeq::parse(s, LEADER).expect(s)
272    }
273
274    #[test]
275    fn parses_plain_characters() {
276        assert_eq!(seq("j").0, vec![Chord::ch('j')]);
277        assert_eq!(seq("gg").0, vec![Chord::ch('g'), Chord::ch('g')]);
278    }
279
280    #[test]
281    fn parses_control_chords() {
282        assert_eq!(seq("<C-w>").0, vec![Chord::ctrl('w')]);
283        assert_eq!(seq("<c-d>").0, vec![Chord::ctrl('d')]);
284    }
285
286    #[test]
287    fn leader_expands_to_the_configured_chord() {
288        assert_eq!(
289            seq("<leader>ff").0,
290            vec![Chord::ch(' '), Chord::ch('f'), Chord::ch('f')]
291        );
292        // A different leader changes every binding without editing them.
293        let comma = ChordSeq::parse("<leader>x", Chord::ch(',')).unwrap();
294        assert_eq!(comma.0[0], Chord::ch(','));
295    }
296
297    #[test]
298    fn parses_named_keys() {
299        assert_eq!(seq("<Esc>").0, vec![Chord::plain(Key::Esc)]);
300        assert_eq!(seq("<CR>").0, vec![Chord::plain(Key::Enter)]);
301        assert_eq!(seq("<Space>").0, vec![Chord::ch(' ')]);
302        assert_eq!(seq("<F5>").0, vec![Chord::plain(Key::F(5))]);
303        assert_eq!(seq("<S-Tab>").0, vec![Chord::plain(Key::BackTab)]);
304    }
305
306    #[test]
307    fn parses_mixed_sequences() {
308        assert_eq!(
309            seq("<leader>s<C-w>").0,
310            vec![Chord::ch(' '), Chord::ch('s'), Chord::ctrl('w')]
311        );
312    }
313
314    #[test]
315    fn rejects_malformed_input() {
316        assert_eq!(
317            ChordSeq::parse("<C-w", LEADER),
318            Err(KeyParseError::Unterminated("<C-w".into()))
319        );
320        assert_eq!(
321            ChordSeq::parse("<nope>", LEADER),
322            Err(KeyParseError::UnknownKey("nope".into()))
323        );
324        assert_eq!(ChordSeq::parse("", LEADER), Err(KeyParseError::Empty));
325    }
326
327    #[test]
328    fn round_trips_through_display() {
329        for s in ["j", "gg", "<C-w>", "<Esc>", "<Space>", "<F5>", "<S-Tab>"] {
330            assert_eq!(seq(s).to_string(), s, "round-trip failed for {s}");
331        }
332    }
333
334    #[test]
335    fn only_unmodified_characters_are_insertable() {
336        assert_eq!(Chord::ch('j').as_insertable(), Some('j'));
337        assert_eq!(Chord::ctrl('j').as_insertable(), None);
338        assert_eq!(Chord::plain(Key::Esc).as_insertable(), None);
339    }
340}