Skip to main content

ashlar_command/
shortcut.rs

1//! Portable keyboard shortcuts.
2//!
3//! One syntax everywhere: `Mod+Shift+S`, where `Mod` is ⌘ on macOS and Ctrl elsewhere. `Ctrl` is the
4//! literal Control key (⌃ on macOS; the same key as `Mod` elsewhere). Legacy spellings are
5//! accepted: `Cmd+Z`, `Command+Z`, `CmdOrCtrl+Z`, `Option+X`, glyphs (`⌘⇧S`). Several shortcuts for
6//! one command are separated by ` / ` (`Mod+Y / Mod+Shift+Z`).
7//!
8//! [`Chord`] is the UI-independent key chord; the UI layer maps its key events onto it.
9
10use std::fmt;
11
12use serde::{Deserialize, Deserializer, Serialize, Serializer};
13
14/// Longest shortcut text parsed.
15const MAX_LEN: usize = 64;
16/// At most this many alternatives in one shortcut list.
17const MAX_ALTERNATIVES: usize = 8;
18
19/// The platform shortcuts are shown and matched for.
20#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
21pub enum Platform {
22    Mac,
23    Other,
24}
25
26impl Platform {
27    /// The platform this build runs on (wasm reports `Other`; a web app can pick from the user agent).
28    pub const fn current() -> Self {
29        if cfg!(target_os = "macos") || cfg!(target_os = "ios") { Platform::Mac } else { Platform::Other }
30    }
31}
32
33/// Modifier keys. `command` is `Mod` (⌘ on macOS, Ctrl elsewhere); `ctrl` is the literal Control key.
34#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, PartialOrd, Ord, Default)]
35pub struct Mods {
36    pub command: bool,
37    pub ctrl: bool,
38    pub alt: bool,
39    pub shift: bool,
40}
41
42impl Mods {
43    pub const NONE: Mods = Mods { command: false, ctrl: false, alt: false, shift: false };
44    pub const COMMAND: Mods = Mods { command: true, ctrl: false, alt: false, shift: false };
45    pub const SHIFT: Mods = Mods { command: false, ctrl: false, alt: false, shift: true };
46}
47
48/// Keys with a name rather than a character.
49#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, PartialOrd, Ord)]
50pub enum Named {
51    Left,
52    Right,
53    Up,
54    Down,
55    Home,
56    End,
57    PageUp,
58    PageDown,
59    Enter,
60    Tab,
61    Backspace,
62    Delete,
63    Escape,
64    Space,
65    Insert,
66}
67
68const NAMED: &[(Named, &str, &[&str])] = &[
69    (Named::Left, "Left", &["arrowleft"]),
70    (Named::Right, "Right", &["arrowright"]),
71    (Named::Up, "Up", &["arrowup"]),
72    (Named::Down, "Down", &["arrowdown"]),
73    (Named::Home, "Home", &[]),
74    (Named::End, "End", &[]),
75    (Named::PageUp, "PageUp", &["pgup"]),
76    (Named::PageDown, "PageDown", &["pgdn", "pgdown"]),
77    (Named::Enter, "Enter", &["return"]),
78    (Named::Tab, "Tab", &[]),
79    (Named::Backspace, "Backspace", &[]),
80    (Named::Delete, "Delete", &["del", "forwarddelete"]),
81    (Named::Escape, "Escape", &["esc"]),
82    (Named::Space, "Space", &["spacebar"]),
83    (Named::Insert, "Insert", &["ins"]),
84];
85
86impl Named {
87    /// The canonical spelling (`PageUp`).
88    pub fn name(self) -> &'static str {
89        NAMED.iter().find(|(n, ..)| *n == self).map(|(_, s, _)| *s).unwrap_or_default()
90    }
91
92    fn mac_glyph(self) -> &'static str {
93        match self {
94            Named::Left => "←",
95            Named::Right => "→",
96            Named::Up => "↑",
97            Named::Down => "↓",
98            Named::Home => "↖",
99            Named::End => "↘",
100            Named::PageUp => "⇞",
101            Named::PageDown => "⇟",
102            Named::Enter => "↩",
103            Named::Tab => "⇥",
104            Named::Backspace => "⌫",
105            Named::Delete => "⌦",
106            Named::Escape => "⎋",
107            Named::Space => "Space",
108            Named::Insert => "Insert",
109        }
110    }
111}
112
113/// The key of a chord.
114#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, PartialOrd, Ord)]
115pub enum Key {
116    /// A character key, letters upper-cased (`S`, `1`, `/`, `]`).
117    Char(char),
118    Named(Named),
119    /// `F1`…`F24`.
120    F(u8),
121}
122
123impl Key {
124    /// Parse a key name: one character, a named key (`PageUp`, `Esc`, `Return`…), `F1`–`F24`,
125    /// `Plus`/`Minus`.
126    pub fn parse(name: &str) -> Option<Key> {
127        let name = name.trim();
128        let mut chars = name.chars();
129        if let (Some(c), None) = (chars.next(), chars.next()) {
130            if c.is_whitespace() {
131                return (c == ' ').then_some(Key::Named(Named::Space));
132            }
133            if c.is_control() {
134                return None;
135            }
136            let mut up = c.to_uppercase();
137            return Some(Key::Char(match (up.next(), up.next()) {
138                (Some(u), None) => u,
139                _ => c,
140            }));
141        }
142        let lower = name.to_ascii_lowercase();
143        if let Some((n, ..)) = NAMED.iter().find(|(_, canon, alts)| canon.eq_ignore_ascii_case(name) || alts.contains(&lower.as_str())) {
144            return Some(Key::Named(*n));
145        }
146        match lower.as_str() {
147            "plus" => return Some(Key::Char('+')),
148            "minus" => return Some(Key::Char('-')),
149            _ => {}
150        }
151        // `F` followed by 1..=24 (and nothing else: `F1x` is not a key)
152        let digits = lower.strip_prefix('f')?;
153        if digits.is_empty() || digits.len() > 2 || !digits.bytes().all(|b| b.is_ascii_digit()) {
154            return None;
155        }
156        let n: u8 = digits.parse().ok()?;
157        (1..=24).contains(&n).then_some(Key::F(n))
158    }
159
160    fn portable(self) -> String {
161        match self {
162            Key::Char(c) => c.to_string(),
163            Key::Named(n) => n.name().to_string(),
164            Key::F(n) => format!("F{n}"),
165        }
166    }
167}
168
169/// One key chord: modifiers plus a key.
170#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, PartialOrd, Ord)]
171pub struct Chord {
172    pub mods: Mods,
173    pub key: Key,
174}
175
176/// Text that is not a shortcut.
177#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)]
178#[error("not a keyboard shortcut: `{0}`")]
179pub struct ParseError(pub String);
180
181fn err(s: &str) -> ParseError {
182    ParseError(s.chars().take(MAX_LEN).collect())
183}
184
185impl Chord {
186    pub const fn new(mods: Mods, key: Key) -> Self {
187        Self { mods, key }
188    }
189
190    /// Parse one shortcut (`Mod+Shift+S`, `Cmd+Z`, `Ctrl+Alt+Delete`, `⌘⇧S`, `Mod++`, `F5`).
191    pub fn parse(text: &str) -> Result<Chord, ParseError> {
192        let s = text.trim();
193        if s.is_empty() || s.len() > MAX_LEN {
194            return Err(err(text));
195        }
196        let mut mods = Mods::NONE;
197        // macOS glyph prefix: ⌃⌥⇧⌘ in any order, then the key (or `+`-separated text)
198        let mut rest = s;
199        while let Some(c) = rest.chars().next() {
200            match c {
201                '⌘' => mods.command = true,
202                '⌃' => mods.ctrl = true,
203                '⌥' => mods.alt = true,
204                '⇧' => mods.shift = true,
205                _ => break,
206            }
207            rest = rest.get(c.len_utf8()..).unwrap_or_default();
208        }
209        if rest.len() != s.len() {
210            let key = glyph_key(rest).or_else(|| Key::parse(rest)).ok_or_else(|| err(text))?;
211            return Ok(Chord { mods, key });
212        }
213        // `Mod++` is the plus key with Mod; a lone `+` is the plus key
214        let (mod_part, name) = if s == "+" {
215            ("", "+")
216        } else if let Some(m) = s.strip_suffix("++") {
217            (m, "+")
218        } else {
219            match s.rsplit_once('+') {
220                Some((m, n)) => (m, n),
221                None => ("", s),
222            }
223        };
224        for m in mod_part.split('+') {
225            match m.trim().to_ascii_lowercase().as_str() {
226                "" if mod_part.is_empty() => {}
227                "mod" | "cmd" | "command" | "cmdorctrl" | "commandorcontrol" | "primary" | "meta" | "super" => mods.command = true,
228                "ctrl" | "control" | "macctrl" => mods.ctrl = true,
229                "alt" | "option" | "opt" => mods.alt = true,
230                "shift" => mods.shift = true,
231                _ => return Err(err(text)),
232            }
233        }
234        let key = glyph_key(name).or_else(|| Key::parse(name)).ok_or_else(|| err(text))?;
235        Ok(Chord { mods, key })
236    }
237
238    /// The portable form (`Mod+Ctrl+Alt+Shift+K`), what settings files and discovery carry.
239    pub fn portable(&self) -> String {
240        let mut out = String::new();
241        for (on, m) in [(self.mods.command, "Mod+"), (self.mods.ctrl, "Ctrl+"), (self.mods.alt, "Alt+"), (self.mods.shift, "Shift+")] {
242            if on {
243                out.push_str(m);
244            }
245        }
246        out.push_str(&self.key.portable());
247        out
248    }
249
250    /// The chord as the user sees it: `⌘⇧S` on macOS, `Ctrl+Shift+S` elsewhere.
251    pub fn display(&self, platform: Platform) -> String {
252        match platform {
253            Platform::Mac => {
254                let mut out = String::new();
255                // Apple's order: Control, Option, Shift, Command
256                for (on, g) in [(self.mods.ctrl, '⌃'), (self.mods.alt, '⌥'), (self.mods.shift, '⇧'), (self.mods.command, '⌘')] {
257                    if on {
258                        out.push(g);
259                    }
260                }
261                match self.key {
262                    Key::Named(n) => out.push_str(n.mac_glyph()),
263                    k => out.push_str(&k.portable()),
264                }
265                out
266            }
267            Platform::Other => {
268                let mut out = String::new();
269                for (on, m) in [(self.mods.command || self.mods.ctrl, "Ctrl+"), (self.mods.alt, "Alt+"), (self.mods.shift, "Shift+")] {
270                    if on {
271                        out.push_str(m);
272                    }
273                }
274                out.push_str(&self.key.portable());
275                out
276            }
277        }
278    }
279
280    /// The chord as the keyboard of `platform` produces it: off macOS, `Mod` and `Ctrl` are the same
281    /// key, so both fold into `command`. Compare chords in this form.
282    pub fn on(self, platform: Platform) -> Chord {
283        match platform {
284            Platform::Mac => self,
285            Platform::Other => Chord { mods: Mods { command: self.mods.command || self.mods.ctrl, ctrl: false, ..self.mods }, key: self.key },
286        }
287    }
288}
289
290fn glyph_key(s: &str) -> Option<Key> {
291    let n = match s.trim() {
292        "←" => Named::Left,
293        "→" => Named::Right,
294        "↑" => Named::Up,
295        "↓" => Named::Down,
296        "↖" => Named::Home,
297        "↘" => Named::End,
298        "⇞" => Named::PageUp,
299        "⇟" => Named::PageDown,
300        "↩" | "⏎" => Named::Enter,
301        "⇥" => Named::Tab,
302        "⌫" => Named::Backspace,
303        "⌦" => Named::Delete,
304        "⎋" => Named::Escape,
305        _ => return None,
306    };
307    Some(Key::Named(n))
308}
309
310impl fmt::Display for Chord {
311    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
312        f.write_str(&self.portable())
313    }
314}
315
316impl std::str::FromStr for Chord {
317    type Err = ParseError;
318    fn from_str(s: &str) -> Result<Self, Self::Err> {
319        Chord::parse(s)
320    }
321}
322
323impl Serialize for Chord {
324    fn serialize<Ser: Serializer>(&self, s: Ser) -> Result<Ser::Ok, Ser::Error> {
325        s.serialize_str(&self.portable())
326    }
327}
328
329impl<'de> Deserialize<'de> for Chord {
330    fn deserialize<D: Deserializer<'de>>(d: D) -> Result<Self, D::Error> {
331        let s = String::deserialize(d)?;
332        Chord::parse(&s).map_err(serde::de::Error::custom)
333    }
334}
335
336/// Parse a list of alternatives (`Mod+Y / Mod+Shift+Z`). Every alternative must parse.
337pub fn parse_list(text: &str) -> Result<Vec<Chord>, ParseError> {
338    if text.len() > MAX_LEN * MAX_ALTERNATIVES {
339        return Err(err(text));
340    }
341    let chords = text.split(" / ").filter(|p| !p.trim().is_empty()).map(Chord::parse).collect::<Result<Vec<_>, _>>()?;
342    if chords.is_empty() || chords.len() > MAX_ALTERNATIVES {
343        return Err(err(text));
344    }
345    Ok(chords)
346}
347
348/// Portable form of a list: `Mod+Y / Mod+Shift+Z`.
349pub fn format_list(chords: &[Chord]) -> String {
350    chords.iter().map(Chord::portable).collect::<Vec<_>>().join(" / ")
351}
352
353/// Display form of a list for `platform`.
354pub fn display_list(chords: &[Chord], platform: Platform) -> String {
355    chords.iter().map(|c| c.display(platform)).collect::<Vec<_>>().join(" / ")
356}
357
358/// A shortcut text in canonical portable form (`cmd+shift+s` → `Mod+Shift+S`); `None` when it is
359/// not a shortcut.
360pub fn normalize(text: &str) -> Option<String> {
361    parse_list(text).ok().map(|c| format_list(&c))
362}
363
364/// A shortcut text as shown on `platform` (`Mod+Shift+S` → `⌘⇧S`); `None` when it is not a shortcut.
365pub fn display(text: &str, platform: Platform) -> Option<String> {
366    parse_list(text).ok().map(|c| display_list(&c, platform))
367}