1use std::str::FromStr;
4
5use nutype::nutype;
6use serde::{Deserialize, Deserializer, Serialize, Serializer, de};
7use thiserror::Error;
8
9const MOD_COMMAND: u8 = 1 << 0;
10const MOD_SHIFT: u8 = 1 << 1;
11const MOD_CONTROL: u8 = 1 << 2;
12const MOD_OPTION: u8 = 1 << 3;
13const MOD_SUPER: u8 = 1 << 4;
14const ALL_MODIFIERS: u8 = MOD_COMMAND | MOD_SHIFT | MOD_CONTROL | MOD_OPTION | MOD_SUPER;
15
16#[nutype(
22 const_fn,
23 validate(with = validate_keyboard_usage, error = KeyboardUsageError),
24 derive(Clone, Copy, Debug, PartialEq, Eq, Hash, TryFrom, Into, Serialize, Deserialize),
25)]
26pub struct KeyboardUsage(u8);
27
28impl KeyboardUsage {
29 #[must_use]
31 pub const fn code(self) -> u8 {
32 self.into_inner()
33 }
34
35 #[must_use]
40 pub fn ascii_char(self) -> Option<char> {
41 let code = self.into_inner();
42 match code {
43 0x04..=0x1d => Some(char::from(b'a' + code - 0x04)),
44 0x1e..=0x26 => Some(char::from(b'1' + code - 0x1e)),
45 0x27 => Some('0'),
46 0x2d => Some('-'),
47 0x2e => Some('='),
48 0x2f => Some('['),
49 0x30 => Some(']'),
50 0x31 => Some('\\'),
51 0x33 => Some(';'),
52 0x34 => Some('\''),
53 0x35 => Some('`'),
54 0x36 => Some(','),
55 0x37 => Some('.'),
56 0x38 => Some('/'),
57 _ => None,
58 }
59 }
60
61 fn label(self) -> String {
62 let code = self.into_inner();
63 match code {
64 0x04..=0x1d => char::from(b'A' + code - 0x04).to_string(),
65 0x1e..=0x26 => char::from(b'1' + code - 0x1e).to_string(),
66 0x27 => "0".to_string(),
67 0x28 => "Enter".to_string(),
68 0x29 => "Escape".to_string(),
69 0x2a => "Backspace".to_string(),
70 0x2b => "Tab".to_string(),
71 0x2c => "Space".to_string(),
72 0x2d => "-".to_string(),
73 0x2e => "=".to_string(),
74 0x2f => "[".to_string(),
75 0x30 => "]".to_string(),
76 0x31 => "\\".to_string(),
77 0x33 => ";".to_string(),
78 0x34 => "'".to_string(),
79 0x35 => "`".to_string(),
80 0x36 => ",".to_string(),
81 0x37 => ".".to_string(),
82 0x38 => "/".to_string(),
83 0x3a..=0x45 => format!("F{}", code - 0x3a + 1),
84 0x4a => "Home".to_string(),
85 0x4b => "PageUp".to_string(),
86 0x4c => "Delete".to_string(),
87 0x4d => "End".to_string(),
88 0x4e => "PageDown".to_string(),
89 0x4f => "Right".to_string(),
90 0x50 => "Left".to_string(),
91 0x51 => "Down".to_string(),
92 0x52 => "Up".to_string(),
93 0x68..=0x6f => format!("F{}", code - 0x68 + 13),
94 _ => format!("Usage 0x{code:02X}"),
95 }
96 }
97}
98
99#[derive(Clone, Copy, Debug, PartialEq, Eq, Error)]
101#[error("unsupported keyboard usage: {0:#04x}")]
102pub struct KeyboardUsageError(pub u8);
103
104#[expect(
105 clippy::trivially_copy_pass_by_ref,
106 reason = "nutype custom validators receive a reference to the wrapped value"
107)]
108const fn validate_keyboard_usage(value: &u8) -> Result<(), KeyboardUsageError> {
109 if matches!(
110 value,
111 0x04..=0x31 | 0x33..=0x38 | 0x3a..=0x45 | 0x4a..=0x52 | 0x68..=0x6f
112 ) {
113 Ok(())
114 } else {
115 Err(KeyboardUsageError(*value))
116 }
117}
118
119#[derive(Clone, Debug, PartialEq, Eq, Hash)]
124pub struct KeyCombo {
125 modifiers: u8,
126 key: KeyboardUsage,
127}
128
129#[derive(Serialize, Deserialize)]
130struct KeyComboWire {
131 modifiers: u8,
132 key: KeyboardUsage,
133}
134
135impl TryFrom<KeyComboWire> for KeyCombo {
136 type Error = KeyComboParseError;
137
138 fn try_from(value: KeyComboWire) -> Result<Self, Self::Error> {
139 if value.modifiers & !ALL_MODIFIERS != 0 {
140 return Err(KeyComboParseError::InvalidModifiers(value.modifiers));
141 }
142 Ok(Self {
143 modifiers: value.modifiers,
144 key: value.key,
145 })
146 }
147}
148
149impl From<KeyCombo> for KeyComboWire {
150 fn from(value: KeyCombo) -> Self {
151 Self {
152 modifiers: value.modifiers,
153 key: value.key,
154 }
155 }
156}
157
158impl Serialize for KeyCombo {
159 fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
160 where
161 S: Serializer,
162 {
163 if serializer.is_human_readable() {
164 serializer.serialize_str(&self.rendered_label())
165 } else {
166 KeyComboWire {
167 modifiers: self.modifiers,
168 key: self.key,
169 }
170 .serialize(serializer)
171 }
172 }
173}
174
175impl<'de> Deserialize<'de> for KeyCombo {
176 fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
177 where
178 D: Deserializer<'de>,
179 {
180 if deserializer.is_human_readable() {
181 String::deserialize(deserializer)?
182 .parse()
183 .map_err(de::Error::custom)
184 } else {
185 Self::try_from(KeyComboWire::deserialize(deserializer)?).map_err(de::Error::custom)
186 }
187 }
188}
189
190impl KeyCombo {
191 #[must_use]
193 pub const fn key(&self) -> KeyboardUsage {
194 self.key
195 }
196
197 #[must_use]
200 pub const fn has_command(&self) -> bool {
201 self.modifiers & MOD_COMMAND != 0
202 }
203
204 #[must_use]
206 pub const fn has_shift(&self) -> bool {
207 self.modifiers & MOD_SHIFT != 0
208 }
209
210 #[must_use]
212 pub const fn has_control(&self) -> bool {
213 self.modifiers & MOD_CONTROL != 0
214 }
215
216 #[must_use]
218 pub const fn has_option(&self) -> bool {
219 self.modifiers & MOD_OPTION != 0
220 }
221
222 #[must_use]
226 pub const fn has_super(&self) -> bool {
227 self.modifiers & MOD_SUPER != 0
228 }
229
230 #[must_use]
232 pub fn rendered_label(&self) -> String {
233 let mut parts = Vec::new();
234 if self.has_command() {
235 parts.push("Cmd".to_string());
236 }
237 if self.has_super() {
238 parts.push("Super".to_string());
239 }
240 if self.has_control() {
241 parts.push("Ctrl".to_string());
242 }
243 if self.has_option() {
244 parts.push("Alt".to_string());
245 }
246 if self.has_shift() {
247 parts.push("Shift".to_string());
248 }
249 parts.push(self.key.label());
250 parts.join("+")
251 }
252}
253
254#[derive(Clone, Debug, PartialEq, Eq, Error)]
256pub enum KeyComboParseError {
257 #[error("keyboard shortcut must not be empty")]
259 Empty,
260 #[error("keyboard shortcut must contain a key")]
262 MissingKey,
263 #[error("keyboard shortcut must contain exactly one key")]
265 MultipleKeys,
266 #[error("unsupported shortcut token: {0}")]
268 UnknownToken(String),
269 #[error("unsupported shortcut modifier bits: {0:#04x}")]
271 InvalidModifiers(u8),
272}
273
274impl FromStr for KeyCombo {
275 type Err = KeyComboParseError;
276
277 fn from_str(input: &str) -> Result<Self, Self::Err> {
278 let input = input.trim();
279 if input.is_empty() {
280 return Err(KeyComboParseError::Empty);
281 }
282
283 let mut modifiers = 0;
284 let mut key = None;
285 for raw in input.split('+') {
286 let token = raw.trim();
287 if token.is_empty() {
288 return Err(KeyComboParseError::UnknownToken(raw.to_string()));
289 }
290 if let Some(modifier) = parse_modifier(token) {
291 modifiers |= modifier;
292 continue;
293 }
294 if key.is_some() {
295 return Err(KeyComboParseError::MultipleKeys);
296 }
297 key = Some(parse_key(token)?);
298 }
299 let Some(key) = key else {
300 return Err(KeyComboParseError::MissingKey);
301 };
302 Ok(Self { modifiers, key })
303 }
304}
305
306fn parse_modifier(token: &str) -> Option<u8> {
307 match token.to_ascii_lowercase().as_str() {
308 "cmd" | "command" => Some(MOD_COMMAND),
309 "super" | "win" | "meta" => Some(MOD_SUPER),
310 "shift" => Some(MOD_SHIFT),
311 "ctrl" | "control" => Some(MOD_CONTROL),
312 "alt" | "option" => Some(MOD_OPTION),
313 _ => None,
314 }
315}
316
317fn parse_key(token: &str) -> Result<KeyboardUsage, KeyComboParseError> {
318 let lowercase = token.to_ascii_lowercase();
319 let usage = if lowercase.len() == 1 {
320 let character = lowercase.chars().next().unwrap_or_default();
321 match character {
322 'a'..='z' => 0x04 + u8::try_from(character as u32 - 'a' as u32).unwrap_or_default(),
323 '1'..='9' => 0x1e + u8::try_from(character as u32 - '1' as u32).unwrap_or_default(),
324 '0' => 0x27,
325 '-' => 0x2d,
326 '=' => 0x2e,
327 '[' => 0x2f,
328 ']' => 0x30,
329 '\\' => 0x31,
330 ';' => 0x33,
331 '\'' => 0x34,
332 '`' => 0x35,
333 ',' => 0x36,
334 '.' => 0x37,
335 '/' => 0x38,
336 _ => return Err(KeyComboParseError::UnknownToken(token.to_string())),
337 }
338 } else if let Some(number) = lowercase
339 .strip_prefix('f')
340 .and_then(|number| number.parse::<u8>().ok())
341 {
342 match number {
343 1..=12 => 0x3a + number - 1,
344 13..=20 => 0x68 + number - 13,
345 _ => return Err(KeyComboParseError::UnknownToken(token.to_string())),
346 }
347 } else {
348 match lowercase.as_str() {
349 "enter" | "return" => 0x28,
350 "escape" | "esc" => 0x29,
351 "backspace" => 0x2a,
352 "tab" => 0x2b,
353 "space" => 0x2c,
354 "home" => 0x4a,
355 "pageup" | "page-up" => 0x4b,
356 "delete" => 0x4c,
357 "end" => 0x4d,
358 "pagedown" | "page-down" => 0x4e,
359 "right" => 0x4f,
360 "left" => 0x50,
361 "down" => 0x51,
362 "up" => 0x52,
363 _ => return Err(KeyComboParseError::UnknownToken(token.to_string())),
364 }
365 };
366 KeyboardUsage::try_from(usage).map_err(|_| KeyComboParseError::UnknownToken(token.to_string()))
367}
368
369#[cfg(test)]
370mod tests {
371 use super::*;
372
373 #[test]
374 fn ascii_char_names_the_character_a_layout_can_move() {
375 let key = |text: &str| text.parse::<KeyCombo>().expect("valid shortcut").key();
376 assert_eq!(key("Cmd+S").ascii_char(), Some('s'));
377 assert_eq!(key("Cmd+3").ascii_char(), Some('3'));
378 assert_eq!(key("Cmd+[").ascii_char(), Some('['));
379 for fixed in ["F5", "Left", "Enter", "Escape", "Tab"] {
380 assert_eq!(key(fixed).ascii_char(), None, "{fixed}");
381 }
382 }
383
384 #[test]
385 fn parses_super_as_its_own_modifier() {
386 let combo = "Super+End"
387 .parse::<KeyCombo>()
388 .expect("valid shortcut failed");
389 assert!(combo.has_super());
390 assert!(!combo.has_command());
391 assert_eq!(combo.key().code(), 0x4d);
392 assert_eq!(combo.rendered_label(), "Super+End");
393
394 for token in ["Win", "Meta"] {
396 let combo = format!("{token}+L")
397 .parse::<KeyCombo>()
398 .expect("valid shortcut failed");
399 assert!(combo.has_super(), "{token}");
400 assert!(!combo.has_command(), "{token}");
401 }
402
403 let combo = "Super+Shift+Right"
404 .parse::<KeyCombo>()
405 .expect("valid shortcut failed");
406 assert_eq!(combo.rendered_label(), "Super+Shift+Right");
407 }
408
409 #[test]
410 fn parses_modifiers_letters_and_navigation_keys() {
411 let combo = "Cmd+Shift+P"
412 .parse::<KeyCombo>()
413 .expect("valid shortcut failed");
414 assert!(combo.has_command());
415 assert!(combo.has_shift());
416 assert_eq!(combo.key().code(), 0x13);
417 assert_eq!(combo.rendered_label(), "Cmd+Shift+P");
418
419 let combo = "Ctrl+Alt+Left"
420 .parse::<KeyCombo>()
421 .expect("valid shortcut failed");
422 assert!(combo.has_control());
423 assert!(combo.has_option());
424 assert_eq!(combo.key().code(), 0x50);
425 assert_eq!(combo.rendered_label(), "Ctrl+Alt+Left");
426 }
427
428 #[test]
429 fn a_uses_its_platform_neutral_hid_usage() {
430 let combo = "Cmd+A".parse::<KeyCombo>().expect("valid shortcut failed");
431 assert_eq!(combo.key().code(), 0x04);
432 assert_eq!(combo.rendered_label(), "Cmd+A");
433 }
434
435 #[test]
436 fn rejects_missing_multiple_and_unknown_keys() {
437 assert_eq!(
438 "Cmd+Shift".parse::<KeyCombo>(),
439 Err(KeyComboParseError::MissingKey)
440 );
441 assert_eq!(
442 "Cmd+P+K".parse::<KeyCombo>(),
443 Err(KeyComboParseError::MultipleKeys)
444 );
445 assert!(matches!(
446 "Cmd+Hyper".parse::<KeyCombo>(),
447 Err(KeyComboParseError::UnknownToken(_))
448 ));
449 }
450
451 #[test]
452 fn rejects_unknown_serialized_usage_and_modifier_bits() {
453 let Err(error) = toml::from_str::<KeyComboWire>("modifiers = 0\nkey = 255") else {
457 panic!("usage 255 is not a supported HID keyboard usage and must be rejected")
458 };
459 assert!(
460 error
461 .to_string()
462 .contains(&KeyboardUsageError(255).to_string()),
463 "expected the usage guard to reject 255, got: {error}"
464 );
465 assert_eq!(
466 KeyCombo::try_from(KeyComboWire {
467 modifiers: 128,
468 key: KeyboardUsage::try_from(0x04).expect("0x04 is a valid keyboard usage"),
469 }),
470 Err(KeyComboParseError::InvalidModifiers(128))
471 );
472 }
473
474 #[test]
475 fn toml_uses_the_canonical_text_chord() {
476 #[derive(Debug, PartialEq, Eq, Serialize, Deserialize)]
477 struct Wrapper {
478 shortcut: KeyCombo,
479 }
480
481 let combo = "Cmd+Shift+P"
482 .parse::<KeyCombo>()
483 .expect("valid shortcut failed");
484 let wrapper = Wrapper { shortcut: combo };
485 let encoded = toml::to_string(&wrapper).expect("shortcut serialization failed");
486 assert_eq!(encoded, "shortcut = \"Cmd+Shift+P\"\n");
487 assert_eq!(toml::from_str::<Wrapper>(&encoded), Ok(wrapper));
488 }
489}