Skip to main content

ite_cli/
keys.rs

1//! Key representation and parsing of config key strings like `ctrl+e`.
2
3use crossterm::event::{KeyCode, KeyEvent, KeyModifiers};
4
5/// A normalized key: uppercase-char keys absorb the SHIFT modifier, so
6/// `shift+j`, `J`, and a crossterm event for shift-j all compare equal.
7#[derive(Clone, Copy, PartialEq, Eq, Hash, Debug)]
8pub struct Key {
9    pub code: KeyCode,
10    pub mods: KeyModifiers,
11}
12
13impl Key {
14    pub fn new(code: KeyCode, mods: KeyModifiers) -> Self {
15        Self { code, mods }.normalized()
16    }
17
18    fn normalized(mut self) -> Self {
19        if self.code == KeyCode::BackTab {
20            self.code = KeyCode::Tab;
21            self.mods.insert(KeyModifiers::SHIFT);
22        }
23        if let KeyCode::Char(c) = self.code {
24            if self.mods.contains(KeyModifiers::SHIFT) && c.is_alphabetic() {
25                self.code = KeyCode::Char(c.to_ascii_uppercase());
26            }
27            // SHIFT carries no extra information for character keys: the
28            // terminal sends the shifted glyph itself. Space is the exception,
29            // shifted or not it is still a space, so the modifier has to
30            // survive for `shift+space` to be its own binding.
31            if c != ' ' {
32                self.mods.remove(KeyModifiers::SHIFT);
33            }
34        }
35        self
36    }
37
38    /// Parse a config key string such as `j`, `J`, `ctrl+e`, `shift+right`,
39    /// `ctrl+enter`, or `alt+s`.
40    pub fn parse(s: &str) -> Result<Self, String> {
41        let mut mods = KeyModifiers::NONE;
42        let parts: Vec<&str> = s.split('+').collect();
43        let (mod_parts, key_part) = match parts.split_last() {
44            Some((last, rest)) if !last.is_empty() => (rest, *last),
45            _ => return Err(format!("invalid key: {s:?}")),
46        };
47        for part in mod_parts {
48            match part.to_ascii_lowercase().as_str() {
49                "ctrl" | "control" => mods |= KeyModifiers::CONTROL,
50                "alt" | "meta" => mods |= KeyModifiers::ALT,
51                "shift" => mods |= KeyModifiers::SHIFT,
52                other => return Err(format!("unknown modifier: {other:?}")),
53            }
54        }
55        let code = parse_key_name(key_part)?;
56        Ok(Self::new(code, mods))
57    }
58
59    /// Normalize an incoming crossterm event into a `Key`.
60    pub fn from_event(ev: KeyEvent) -> Self {
61        Self::new(ev.code, ev.modifiers)
62    }
63}
64
65fn parse_key_name(name: &str) -> Result<KeyCode, String> {
66    let mut chars = name.chars();
67    if let (Some(c), None) = (chars.next(), chars.next()) {
68        return Ok(KeyCode::Char(c));
69    }
70    let code = match name.to_ascii_lowercase().as_str() {
71        "enter" | "return" => KeyCode::Enter,
72        "tab" => KeyCode::Tab,
73        "esc" | "escape" => KeyCode::Esc,
74        "space" => KeyCode::Char(' '),
75        "up" | "up-arrow" => KeyCode::Up,
76        "down" | "down-arrow" => KeyCode::Down,
77        "left" | "left-arrow" => KeyCode::Left,
78        "right" | "right-arrow" => KeyCode::Right,
79        "home" => KeyCode::Home,
80        "end" => KeyCode::End,
81        "pageup" | "page-up" => KeyCode::PageUp,
82        "pagedown" | "page-down" => KeyCode::PageDown,
83        "backspace" => KeyCode::Backspace,
84        "delete" | "del" => KeyCode::Delete,
85        "insert" => KeyCode::Insert,
86        f if f.starts_with('f') => {
87            let n: u8 = f[1..]
88                .parse()
89                .map_err(|_| format!("unknown key: {name:?}"))?;
90            if (1..=24).contains(&n) {
91                KeyCode::F(n)
92            } else {
93                return Err(format!("unknown key: {name:?}"));
94            }
95        }
96        _ => return Err(format!("unknown key: {name:?}")),
97    };
98    Ok(code)
99}
100
101#[cfg(test)]
102mod tests {
103    use super::*;
104
105    fn key(code: KeyCode, mods: KeyModifiers) -> Key {
106        Key::new(code, mods)
107    }
108
109    #[test]
110    fn parses_bare_char() {
111        assert_eq!(
112            Key::parse("j").unwrap(),
113            key(KeyCode::Char('j'), KeyModifiers::NONE)
114        );
115    }
116
117    #[test]
118    fn parses_ctrl_char() {
119        assert_eq!(
120            Key::parse("ctrl+e").unwrap(),
121            key(KeyCode::Char('e'), KeyModifiers::CONTROL)
122        );
123    }
124
125    #[test]
126    fn parses_alt_char() {
127        assert_eq!(
128            Key::parse("alt+s").unwrap(),
129            key(KeyCode::Char('s'), KeyModifiers::ALT)
130        );
131    }
132
133    #[test]
134    fn shift_letter_normalizes_to_uppercase_char() {
135        // "shift+j" and "J" are the same key.
136        assert_eq!(Key::parse("shift+j").unwrap(), Key::parse("J").unwrap());
137        assert_eq!(
138            Key::parse("J").unwrap(),
139            key(KeyCode::Char('J'), KeyModifiers::NONE)
140        );
141    }
142
143    #[test]
144    fn parses_named_keys() {
145        assert_eq!(
146            Key::parse("enter").unwrap(),
147            key(KeyCode::Enter, KeyModifiers::NONE)
148        );
149        assert_eq!(
150            Key::parse("ctrl+enter").unwrap(),
151            key(KeyCode::Enter, KeyModifiers::CONTROL)
152        );
153        assert_eq!(
154            Key::parse("alt+enter").unwrap(),
155            key(KeyCode::Enter, KeyModifiers::ALT)
156        );
157        assert_eq!(
158            Key::parse("tab").unwrap(),
159            key(KeyCode::Tab, KeyModifiers::NONE)
160        );
161        assert_eq!(
162            Key::parse("shift+right").unwrap(),
163            key(KeyCode::Right, KeyModifiers::SHIFT)
164        );
165        assert_eq!(
166            Key::parse("esc").unwrap(),
167            key(KeyCode::Esc, KeyModifiers::NONE)
168        );
169        assert_eq!(
170            Key::parse("space").unwrap(),
171            key(KeyCode::Char(' '), KeyModifiers::NONE)
172        );
173    }
174
175    #[test]
176    fn shift_space_stays_distinct_from_space() {
177        // Space is the one character key whose shifted form is the same glyph,
178        // so the modifier is all that tells the two apart.
179        assert_ne!(
180            Key::parse("shift+space").unwrap(),
181            Key::parse("space").unwrap()
182        );
183        assert_eq!(
184            Key::parse("shift+space").unwrap(),
185            key(KeyCode::Char(' '), KeyModifiers::SHIFT)
186        );
187        let ev = KeyEvent::new(KeyCode::Char(' '), KeyModifiers::SHIFT);
188        assert_eq!(Key::from_event(ev), Key::parse("shift+space").unwrap());
189    }
190
191    #[test]
192    fn rejects_unknown_keys_and_modifiers() {
193        assert!(Key::parse("bogus").is_err());
194        assert!(Key::parse("hyper+j").is_err());
195        assert!(Key::parse("").is_err());
196    }
197
198    #[test]
199    fn event_normalization_matches_parse() {
200        // Terminals report shift+j as Char('J') with SHIFT set.
201        let ev = KeyEvent::new(KeyCode::Char('J'), KeyModifiers::SHIFT);
202        assert_eq!(Key::from_event(ev), Key::parse("J").unwrap());
203
204        let ev = KeyEvent::new(KeyCode::Char('e'), KeyModifiers::CONTROL);
205        assert_eq!(Key::from_event(ev), Key::parse("ctrl+e").unwrap());
206
207        // SHIFT is preserved for non-char keys.
208        let ev = KeyEvent::new(KeyCode::Right, KeyModifiers::SHIFT);
209        assert_eq!(Key::from_event(ev), Key::parse("shift+right").unwrap());
210
211        // Crossterm represents shift-tab as BackTab.
212        let ev = KeyEvent::new(KeyCode::BackTab, KeyModifiers::SHIFT);
213        assert_eq!(Key::from_event(ev), Key::parse("shift+tab").unwrap());
214    }
215}