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