Skip to main content

qframe/keymap/
chord.rs

1//! Key chords such as `ctrl+shift+p`.
2
3use std::fmt;
4use std::str::FromStr;
5
6/// A key without modifiers.
7#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, PartialOrd, Ord)]
8pub enum Key {
9    /// A printable character, stored lowercase for letters.
10    Char(char),
11    /// Enter / Return.
12    Enter,
13    /// Escape.
14    Esc,
15    /// Tab.
16    Tab,
17    /// Space bar.
18    Space,
19    /// Backspace.
20    Backspace,
21    /// Delete.
22    Delete,
23    /// Insert.
24    Insert,
25    /// Home.
26    Home,
27    /// End.
28    End,
29    /// Page up.
30    PageUp,
31    /// Page down.
32    PageDown,
33    /// Arrow up.
34    Up,
35    /// Arrow down.
36    Down,
37    /// Arrow left.
38    Left,
39    /// Arrow right.
40    Right,
41    /// Function key F1–F24.
42    F(u8),
43    /// The context menu key (reported by terminals with the kitty keyboard protocol).
44    Menu,
45}
46
47const NAMED: [(&str, Key); 16] = [
48    ("enter", Key::Enter),
49    ("esc", Key::Esc),
50    ("tab", Key::Tab),
51    ("space", Key::Space),
52    ("backspace", Key::Backspace),
53    ("delete", Key::Delete),
54    ("insert", Key::Insert),
55    ("home", Key::Home),
56    ("end", Key::End),
57    ("pgup", Key::PageUp),
58    ("pgdn", Key::PageDown),
59    ("up", Key::Up),
60    ("down", Key::Down),
61    ("left", Key::Left),
62    ("right", Key::Right),
63    ("menu", Key::Menu),
64];
65
66/// Held modifier keys.
67#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Default, PartialOrd, Ord)]
68pub struct Modifiers {
69    /// Control.
70    pub ctrl: bool,
71    /// Alt / Option.
72    pub alt: bool,
73    /// Shift. A letter keeps shift beside its lowercase form: typing `A` is `shift+a`, and so is
74    /// `"A"` in a keymap file. Other characters fold shift into the character itself: `?`, not
75    /// `shift+/`.
76    pub shift: bool,
77}
78
79/// A key plus modifiers.
80#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, PartialOrd, Ord)]
81pub struct KeyChord {
82    /// The key.
83    pub key: Key,
84    /// The modifiers held with it.
85    pub mods: Modifiers,
86}
87
88impl KeyChord {
89    /// A chord without modifiers.
90    #[must_use]
91    pub fn plain(key: Key) -> Self {
92        Self { key, mods: Modifiers::default() }
93    }
94
95    /// Short label for key hint bars: `ctrl p`, `shift tab`, `?`, `f12`.
96    #[must_use]
97    pub fn label(&self) -> String {
98        self.parts().join(" ")
99    }
100
101    fn parts(&self) -> Vec<String> {
102        let mut parts = Vec::new();
103        if self.mods.ctrl {
104            parts.push("ctrl".to_owned());
105        }
106        if self.mods.alt {
107            parts.push("alt".to_owned());
108        }
109        if self.mods.shift {
110            parts.push("shift".to_owned());
111        }
112        parts.push(match self.key {
113            Key::Char(c) => c.to_string(),
114            Key::F(n) => format!("f{n}"),
115            other => {
116                NAMED.iter().find(|(_, key)| *key == other).map(|(name, _)| (*name).to_owned()).unwrap_or_default()
117            }
118        });
119        parts
120    }
121}
122
123impl FromStr for KeyChord {
124    type Err = String;
125
126    /// Parses `ctrl+shift+p`, `?`, `f12`, `shift+tab`. Modifier and key names are
127    /// case-insensitive, but an uppercase letter means shift plus that letter: `S` is
128    /// `shift+s`, as a terminal reports it. `+` alone is the plus key.
129    fn from_str(text: &str) -> Result<Self, Self::Err> {
130        let trimmed = text.trim();
131        if trimmed.is_empty() {
132            return Err("empty key binding".to_owned());
133        }
134        let (modifier_part, key_part) = if trimmed == "+" {
135            ("", "+")
136        } else if let Some(prefix) = trimmed.strip_suffix("++") {
137            (prefix, "+")
138        } else {
139            match trimmed.rsplit_once('+') {
140                Some((mods, key)) => (mods, key),
141                None => ("", trimmed),
142            }
143        };
144        let mut mods = Modifiers::default();
145        for modifier in modifier_part.split('+').filter(|m| !m.is_empty()) {
146            match modifier.to_lowercase().as_str() {
147                "ctrl" | "control" => mods.ctrl = true,
148                "alt" | "option" => mods.alt = true,
149                "shift" => mods.shift = true,
150                other => {
151                    return Err(format!("unknown modifier `{other}` in `{text}`; use ctrl, alt or shift"));
152                }
153            }
154        }
155        let key = parse_key(key_part, &mut mods).ok_or_else(|| {
156            format!("unknown key `{key_part}` in `{text}`; use a character, f1–f24 or a key name such as enter, esc, tab, space, up")
157        })?;
158        Ok(Self { key, mods })
159    }
160}
161
162/// The key named `text`. A single uppercase letter turns on `mods.shift` and becomes its
163/// lowercase letter, the way the terminal runtime normalises typed letters.
164fn parse_key(text: &str, mods: &mut Modifiers) -> Option<Key> {
165    let mut chars = text.chars();
166    if let (Some(c), None) = (chars.next(), chars.next()) {
167        if c.is_whitespace() || c.is_control() {
168            return None;
169        }
170        if c.is_uppercase() {
171            mods.shift = true;
172            return Some(Key::Char(c.to_lowercase().next().unwrap_or(c)));
173        }
174        return Some(Key::Char(c));
175    }
176    let name = text.to_lowercase();
177    if let Some((_, key)) = NAMED.iter().find(|(n, _)| *n == name) {
178        return Some(*key);
179    }
180    let number = name.strip_prefix('f')?.parse::<u8>().ok()?;
181    (1..=24).contains(&number).then_some(Key::F(number))
182}
183
184impl fmt::Display for KeyChord {
185    /// The canonical form used in keymap files: `ctrl+shift+p`.
186    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
187        f.write_str(&self.parts().join("+"))
188    }
189}
190
191#[cfg(test)]
192mod tests {
193    use super::*;
194
195    fn chord(text: &str) -> KeyChord {
196        text.parse().expect("valid chord")
197    }
198
199    #[test]
200    fn parses_modifiers_and_keys() {
201        let c = chord("Ctrl+Shift+P");
202        assert_eq!(c.key, Key::Char('p'));
203        assert!(c.mods.ctrl && c.mods.shift && !c.mods.alt);
204        assert_eq!(chord("?"), KeyChord::plain(Key::Char('?')));
205        assert_eq!(chord("f12"), KeyChord::plain(Key::F(12)));
206        assert_eq!(chord("shift+tab").key, Key::Tab);
207        assert_eq!(chord("+"), KeyChord::plain(Key::Char('+')));
208        assert_eq!(chord("ctrl++").key, Key::Char('+'));
209    }
210
211    #[test]
212    fn an_uppercase_letter_means_shift_plus_that_letter() {
213        assert_eq!(chord("S"), chord("shift+s"));
214        assert_eq!(chord("ctrl+S"), chord("ctrl+shift+s"));
215        assert_eq!(chord("shift+S"), chord("shift+s"));
216        assert_eq!(chord("s"), KeyChord::plain(Key::Char('s')));
217        assert_eq!(chord("Ş"), chord("shift+ş"));
218        assert_eq!(chord("F12"), KeyChord::plain(Key::F(12)), "key names stay case-insensitive");
219        assert_eq!(chord("Ctrl+Enter"), chord("ctrl+enter"));
220        assert_eq!(chord("?"), KeyChord::plain(Key::Char('?')), "symbols carry no shift");
221        assert_eq!(chord("S").to_string(), "shift+s");
222    }
223
224    #[test]
225    fn rejects_unknown_parts() {
226        assert!("hyper+x".parse::<KeyChord>().is_err());
227        assert!("ctrl+banana".parse::<KeyChord>().is_err());
228        assert!("f25".parse::<KeyChord>().is_err());
229        assert!("".parse::<KeyChord>().is_err());
230    }
231
232    #[test]
233    fn formats_for_files_and_hint_bars() {
234        let c = chord("shift+ctrl+pgup");
235        assert_eq!(c.to_string(), "ctrl+shift+pgup");
236        assert_eq!(c.label(), "ctrl shift pgup");
237        assert_eq!(chord("f12").label(), "f12");
238    }
239}