1use std::fmt;
11
12use serde::{Deserialize, Deserializer, Serialize, Serializer};
13
14const MAX_LEN: usize = 64;
16const MAX_ALTERNATIVES: usize = 8;
18
19#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
21pub enum Platform {
22 Mac,
23 Other,
24}
25
26impl Platform {
27 pub const fn current() -> Self {
29 if cfg!(target_os = "macos") || cfg!(target_os = "ios") { Platform::Mac } else { Platform::Other }
30 }
31}
32
33#[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#[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 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#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, PartialOrd, Ord)]
115pub enum Key {
116 Char(char),
118 Named(Named),
119 F(u8),
121}
122
123impl Key {
124 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 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#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, PartialOrd, Ord)]
171pub struct Chord {
172 pub mods: Mods,
173 pub key: Key,
174}
175
176#[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 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 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 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 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 pub fn display(&self, platform: Platform) -> String {
252 match platform {
253 Platform::Mac => {
254 let mut out = String::new();
255 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 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
336pub 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
348pub fn format_list(chords: &[Chord]) -> String {
350 chords.iter().map(Chord::portable).collect::<Vec<_>>().join(" / ")
351}
352
353pub fn display_list(chords: &[Chord], platform: Platform) -> String {
355 chords.iter().map(|c| c.display(platform)).collect::<Vec<_>>().join(" / ")
356}
357
358pub fn normalize(text: &str) -> Option<String> {
361 parse_list(text).ok().map(|c| format_list(&c))
362}
363
364pub fn display(text: &str, platform: Platform) -> Option<String> {
366 parse_list(text).ok().map(|c| display_list(&c, platform))
367}