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