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 match self.rendered_modifiers() {
234 Some(modifiers) => format!("{modifiers}+{}", self.rendered_key()),
235 None => self.rendered_key(),
236 }
237 }
238
239 #[must_use]
243 pub fn rendered_modifiers(&self) -> Option<String> {
244 let mut parts = Vec::new();
245 if self.has_command() {
246 parts.push("Cmd");
247 }
248 if self.has_super() {
249 parts.push("Super");
250 }
251 if self.has_control() {
252 parts.push("Ctrl");
253 }
254 if self.has_option() {
255 parts.push("Alt");
256 }
257 if self.has_shift() {
258 parts.push("Shift");
259 }
260 (!parts.is_empty()).then(|| parts.join("+"))
261 }
262
263 #[must_use]
265 pub fn rendered_key(&self) -> String {
266 self.key.label()
267 }
268}
269
270#[derive(Clone, Debug, PartialEq, Eq, Error)]
272pub enum KeyComboParseError {
273 #[error("keyboard shortcut must not be empty")]
275 Empty,
276 #[error("keyboard shortcut must contain a key")]
278 MissingKey,
279 #[error("keyboard shortcut must contain exactly one key")]
281 MultipleKeys,
282 #[error("unsupported shortcut token: {0}")]
284 UnknownToken(String),
285 #[error("unsupported shortcut modifier bits: {0:#04x}")]
287 InvalidModifiers(u8),
288}
289
290impl FromStr for KeyCombo {
291 type Err = KeyComboParseError;
292
293 fn from_str(input: &str) -> Result<Self, Self::Err> {
294 let input = input.trim();
295 if input.is_empty() {
296 return Err(KeyComboParseError::Empty);
297 }
298
299 let mut modifiers = 0;
300 let mut key = None;
301 for raw in input.split('+') {
302 let token = raw.trim();
303 if token.is_empty() {
304 return Err(KeyComboParseError::UnknownToken(raw.to_string()));
305 }
306 if let Some(modifier) = parse_modifier(token) {
307 modifiers |= modifier;
308 continue;
309 }
310 if key.is_some() {
311 return Err(KeyComboParseError::MultipleKeys);
312 }
313 key = Some(parse_key(token)?);
314 }
315 let Some(key) = key else {
316 return Err(KeyComboParseError::MissingKey);
317 };
318 Ok(Self { modifiers, key })
319 }
320}
321
322fn parse_modifier(token: &str) -> Option<u8> {
323 match token.to_ascii_lowercase().as_str() {
324 "cmd" | "command" => Some(MOD_COMMAND),
325 "super" | "win" | "meta" => Some(MOD_SUPER),
326 "shift" => Some(MOD_SHIFT),
327 "ctrl" | "control" => Some(MOD_CONTROL),
328 "alt" | "option" => Some(MOD_OPTION),
329 _ => None,
330 }
331}
332
333fn parse_key(token: &str) -> Result<KeyboardUsage, KeyComboParseError> {
334 let lowercase = token.to_ascii_lowercase();
335 let usage = if lowercase.len() == 1 {
336 let character = lowercase.chars().next().unwrap_or_default();
337 match character {
338 'a'..='z' => 0x04 + u8::try_from(character as u32 - 'a' as u32).unwrap_or_default(),
339 '1'..='9' => 0x1e + u8::try_from(character as u32 - '1' as u32).unwrap_or_default(),
340 '0' => 0x27,
341 '-' => 0x2d,
342 '=' => 0x2e,
343 '[' => 0x2f,
344 ']' => 0x30,
345 '\\' => 0x31,
346 ';' => 0x33,
347 '\'' => 0x34,
348 '`' => 0x35,
349 ',' => 0x36,
350 '.' => 0x37,
351 '/' => 0x38,
352 _ => return Err(KeyComboParseError::UnknownToken(token.to_string())),
353 }
354 } else if let Some(number) = lowercase
355 .strip_prefix('f')
356 .and_then(|number| number.parse::<u8>().ok())
357 {
358 match number {
359 1..=12 => 0x3a + number - 1,
360 13..=20 => 0x68 + number - 13,
361 _ => return Err(KeyComboParseError::UnknownToken(token.to_string())),
362 }
363 } else {
364 match lowercase.as_str() {
365 "enter" | "return" => 0x28,
366 "escape" | "esc" => 0x29,
367 "backspace" => 0x2a,
368 "tab" => 0x2b,
369 "space" => 0x2c,
370 "home" => 0x4a,
371 "pageup" | "page-up" => 0x4b,
372 "delete" => 0x4c,
373 "end" => 0x4d,
374 "pagedown" | "page-down" => 0x4e,
375 "right" => 0x4f,
376 "left" => 0x50,
377 "down" => 0x51,
378 "up" => 0x52,
379 _ => return Err(KeyComboParseError::UnknownToken(token.to_string())),
380 }
381 };
382 KeyboardUsage::try_from(usage).map_err(|_| KeyComboParseError::UnknownToken(token.to_string()))
383}
384
385#[cfg(test)]
386mod tests {
387 use super::*;
388
389 #[test]
390 fn ascii_char_names_the_character_a_layout_can_move() {
391 let key = |text: &str| text.parse::<KeyCombo>().expect("valid shortcut").key();
392 assert_eq!(key("Cmd+S").ascii_char(), Some('s'));
393 assert_eq!(key("Cmd+3").ascii_char(), Some('3'));
394 assert_eq!(key("Cmd+[").ascii_char(), Some('['));
395 for fixed in ["F5", "Left", "Enter", "Escape", "Tab"] {
396 assert_eq!(key(fixed).ascii_char(), None, "{fixed}");
397 }
398 }
399
400 #[test]
401 fn parses_super_as_its_own_modifier() {
402 let combo = "Super+End"
403 .parse::<KeyCombo>()
404 .expect("valid shortcut failed");
405 assert!(combo.has_super());
406 assert!(!combo.has_command());
407 assert_eq!(combo.key().code(), 0x4d);
408 assert_eq!(combo.rendered_label(), "Super+End");
409
410 for token in ["Win", "Meta"] {
412 let combo = format!("{token}+L")
413 .parse::<KeyCombo>()
414 .expect("valid shortcut failed");
415 assert!(combo.has_super(), "{token}");
416 assert!(!combo.has_command(), "{token}");
417 }
418
419 let combo = "Super+Shift+Right"
420 .parse::<KeyCombo>()
421 .expect("valid shortcut failed");
422 assert_eq!(combo.rendered_label(), "Super+Shift+Right");
423 }
424
425 #[test]
426 fn parses_modifiers_letters_and_navigation_keys() {
427 let combo = "Cmd+Shift+P"
428 .parse::<KeyCombo>()
429 .expect("valid shortcut failed");
430 assert!(combo.has_command());
431 assert!(combo.has_shift());
432 assert_eq!(combo.key().code(), 0x13);
433 assert_eq!(combo.rendered_label(), "Cmd+Shift+P");
434
435 let combo = "Ctrl+Alt+Left"
436 .parse::<KeyCombo>()
437 .expect("valid shortcut failed");
438 assert!(combo.has_control());
439 assert!(combo.has_option());
440 assert_eq!(combo.key().code(), 0x50);
441 assert_eq!(combo.rendered_label(), "Ctrl+Alt+Left");
442 }
443
444 #[test]
445 fn a_uses_its_platform_neutral_hid_usage() {
446 let combo = "Cmd+A".parse::<KeyCombo>().expect("valid shortcut failed");
447 assert_eq!(combo.key().code(), 0x04);
448 assert_eq!(combo.rendered_label(), "Cmd+A");
449 }
450
451 #[test]
452 fn rejects_missing_multiple_and_unknown_keys() {
453 assert_eq!(
454 "Cmd+Shift".parse::<KeyCombo>(),
455 Err(KeyComboParseError::MissingKey)
456 );
457 assert_eq!(
458 "Cmd+P+K".parse::<KeyCombo>(),
459 Err(KeyComboParseError::MultipleKeys)
460 );
461 assert!(matches!(
462 "Cmd+Hyper".parse::<KeyCombo>(),
463 Err(KeyComboParseError::UnknownToken(_))
464 ));
465 }
466
467 #[test]
468 fn rejects_unknown_serialized_usage_and_modifier_bits() {
469 let Err(error) = toml::from_str::<KeyComboWire>("modifiers = 0\nkey = 255") else {
473 panic!("usage 255 is not a supported HID keyboard usage and must be rejected")
474 };
475 assert!(
476 error
477 .to_string()
478 .contains(&KeyboardUsageError(255).to_string()),
479 "expected the usage guard to reject 255, got: {error}"
480 );
481 assert_eq!(
482 KeyCombo::try_from(KeyComboWire {
483 modifiers: 128,
484 key: KeyboardUsage::try_from(0x04).expect("0x04 is a valid keyboard usage"),
485 }),
486 Err(KeyComboParseError::InvalidModifiers(128))
487 );
488 }
489
490 #[test]
491 fn toml_uses_the_canonical_text_chord() {
492 #[derive(Debug, PartialEq, Eq, Serialize, Deserialize)]
493 struct Wrapper {
494 shortcut: KeyCombo,
495 }
496
497 let combo = "Cmd+Shift+P"
498 .parse::<KeyCombo>()
499 .expect("valid shortcut failed");
500 let wrapper = Wrapper { shortcut: combo };
501 let encoded = toml::to_string(&wrapper).expect("shortcut serialization failed");
502 assert_eq!(encoded, "shortcut = \"Cmd+Shift+P\"\n");
503 assert_eq!(toml::from_str::<Wrapper>(&encoded), Ok(wrapper));
504 }
505}