Skip to main content

ite_cli/
keys.rs

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