1use serde::{Deserialize, Serialize};
4use std::fmt;
5use std::str::FromStr;
6
7use crate::error::{Error, Result};
8
9use super::key::Key;
10use super::modifiers::Modifiers;
11
12#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
14#[serde(transparent)]
15pub struct HotkeyId(pub(crate) u32);
16
17impl HotkeyId {
18 pub fn as_u32(&self) -> u32 {
19 self.0
20 }
21}
22
23#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
25pub struct Hotkey {
26 pub modifiers: Modifiers,
27 pub key: Option<Key>,
28}
29
30impl Hotkey {
31 pub fn new(modifiers: Modifiers, key: impl Into<Option<Key>>) -> Result<Self> {
50 let key = key.into();
51 if modifiers.is_empty() && key.is_none() {
52 return Err(Error::EmptyHotkey);
53 }
54 Ok(Self { modifiers, key })
55 }
56
57 pub fn to_lowercase_string(&self) -> String {
62 self.to_string().to_lowercase()
63 }
64
65 pub fn to_handy_string(&self) -> String {
73 #[cfg(target_os = "macos")]
74 fn mod_names(
75 mods: Modifiers,
76 left: Modifiers,
77 right: Modifiers,
78 compound: Modifiers,
79 name: &str,
80 ) -> Option<String> {
81 if mods.contains(compound) {
82 Some(name.to_string())
83 } else if mods.contains(left) {
84 Some(format!("{}_left", name))
85 } else if mods.contains(right) {
86 Some(format!("{}_right", name))
87 } else {
88 None
89 }
90 }
91
92 #[cfg(not(target_os = "macos"))]
93 fn mod_names(
94 mods: Modifiers,
95 left: Modifiers,
96 right: Modifiers,
97 compound: Modifiers,
98 name: &str,
99 ) -> Option<String> {
100 if mods.contains(compound) {
101 Some(name.to_string())
102 } else if mods.contains(left) {
103 Some(format!("{}_left", name))
104 } else if mods.contains(right) {
105 Some(format!("{}_right", name))
106 } else {
107 None
108 }
109 }
110
111 let mut parts = Vec::new();
112
113 if let Some(s) = mod_names(
115 self.modifiers,
116 Modifiers::CTRL_LEFT,
117 Modifiers::CTRL_RIGHT,
118 Modifiers::CTRL,
119 "ctrl",
120 ) {
121 parts.push(s);
122 }
123
124 #[cfg(target_os = "macos")]
126 let opt_name = "option";
127 #[cfg(not(target_os = "macos"))]
128 let opt_name = "alt";
129 if let Some(s) = mod_names(
130 self.modifiers,
131 Modifiers::OPT_LEFT,
132 Modifiers::OPT_RIGHT,
133 Modifiers::OPT,
134 opt_name,
135 ) {
136 parts.push(s);
137 }
138
139 if let Some(s) = mod_names(
141 self.modifiers,
142 Modifiers::SHIFT_LEFT,
143 Modifiers::SHIFT_RIGHT,
144 Modifiers::SHIFT,
145 "shift",
146 ) {
147 parts.push(s);
148 }
149
150 #[cfg(target_os = "macos")]
152 let cmd_name = "command";
153 #[cfg(not(target_os = "macos"))]
154 let cmd_name = "super";
155 if let Some(s) = mod_names(
156 self.modifiers,
157 Modifiers::CMD_LEFT,
158 Modifiers::CMD_RIGHT,
159 Modifiers::CMD,
160 cmd_name,
161 ) {
162 parts.push(s);
163 }
164
165 #[cfg(target_os = "macos")]
167 if self.modifiers.contains(Modifiers::FN) {
168 parts.push("fn".to_string());
169 }
170
171 if let Some(key) = &self.key {
172 let key_str = key.to_string().to_lowercase();
173 let mut result = parts.join("+");
174 if !result.is_empty() {
175 result.push('+');
176 }
177 result.push_str(&key_str);
178 result
179 } else {
180 parts.join("+")
181 }
182 }
183}
184
185impl fmt::Display for Hotkey {
186 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
187 if self.modifiers.is_empty() {
188 if let Some(key) = &self.key {
189 write!(f, "{}", key)
190 } else {
191 write!(f, "(none)")
192 }
193 } else if let Some(key) = &self.key {
194 write!(f, "{}+{}", self.modifiers, key)
195 } else {
196 write!(f, "{}", self.modifiers)
197 }
198 }
199}
200
201impl FromStr for Hotkey {
202 type Err = Error;
203
204 fn from_str(s: &str) -> Result<Self> {
217 let s = s.trim();
218 if s.is_empty() {
219 return Err(Error::EmptyHotkey);
220 }
221
222 let parts: Vec<&str> = s.split('+').map(|p| p.trim()).collect();
223
224 let mut modifiers = Modifiers::empty();
225 let mut key: Option<Key> = None;
226
227 for part in parts {
228 if part.is_empty() {
229 continue;
230 }
231
232 if let Some(m) = Modifiers::parse_single(part) {
234 modifiers |= m;
235 } else {
236 if key.is_some() {
238 return Err(Error::InvalidHotkeyFormat(format!(
239 "Multiple keys specified: already have a key, found '{}'",
240 part
241 )));
242 }
243 key = Some(Key::from_str(part)?);
244 }
245 }
246
247 Hotkey::new(modifiers, key)
248 }
249}
250
251#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
253pub enum HotkeyState {
254 Pressed,
256 Released,
258}
259
260#[derive(Debug, Clone, Copy, Serialize, Deserialize)]
262pub struct HotkeyEvent {
263 pub id: HotkeyId,
264 pub state: HotkeyState,
265}
266
267#[derive(Debug, Clone, Copy, Serialize, Deserialize)]
269pub struct KeyEvent {
270 pub modifiers: Modifiers,
271 pub key: Option<Key>,
272 pub is_key_down: bool,
273 pub changed_modifier: Option<Modifiers>,
276}
277
278impl KeyEvent {
279 pub fn as_hotkey(&self) -> Result<Hotkey> {
281 Hotkey::new(self.modifiers, self.key)
282 }
283}
284
285#[cfg(test)]
286mod tests {
287 use super::*;
288
289 #[test]
290 fn parse_modifier_plus_key() {
291 let hotkey: Hotkey = "Cmd+K".parse().unwrap();
292 assert_eq!(hotkey.modifiers, Modifiers::CMD);
293 assert_eq!(hotkey.key, Some(Key::K));
294 }
295
296 #[test]
297 fn parse_multiple_modifiers_plus_key() {
298 let hotkey: Hotkey = "Cmd+Shift+K".parse().unwrap();
299 assert_eq!(hotkey.modifiers, Modifiers::CMD | Modifiers::SHIFT);
300 assert_eq!(hotkey.key, Some(Key::K));
301
302 let hotkey: Hotkey = "Ctrl+Alt+Delete".parse().unwrap();
303 assert_eq!(hotkey.modifiers, Modifiers::CTRL | Modifiers::OPT);
304 assert_eq!(hotkey.key, Some(Key::Delete));
305 }
306
307 #[test]
308 fn parse_key_only() {
309 let hotkey: Hotkey = "F1".parse().unwrap();
310 assert_eq!(hotkey.modifiers, Modifiers::empty());
311 assert_eq!(hotkey.key, Some(Key::F1));
312
313 let hotkey: Hotkey = "Space".parse().unwrap();
314 assert_eq!(hotkey.modifiers, Modifiers::empty());
315 assert_eq!(hotkey.key, Some(Key::Space));
316 }
317
318 #[test]
319 fn parse_modifiers_only() {
320 let hotkey: Hotkey = "Cmd+Shift".parse().unwrap();
321 assert_eq!(hotkey.modifiers, Modifiers::CMD | Modifiers::SHIFT);
322 assert_eq!(hotkey.key, None);
323 }
324
325 #[test]
326 fn parse_side_specific_hotkey() {
327 let hotkey: Hotkey = "CtrlRight+Space".parse().unwrap();
328 assert_eq!(hotkey.modifiers, Modifiers::CTRL_RIGHT);
329 assert_eq!(hotkey.key, Some(Key::Space));
330
331 let hotkey: Hotkey = "CmdLeft+ShiftRight+K".parse().unwrap();
332 assert_eq!(
333 hotkey.modifiers,
334 Modifiers::CMD_LEFT | Modifiers::SHIFT_RIGHT
335 );
336 assert_eq!(hotkey.key, Some(Key::K));
337 }
338
339 #[test]
340 fn parse_empty_fails() {
341 assert!("".parse::<Hotkey>().is_err());
342 }
343
344 #[test]
345 fn parse_multiple_keys_fails() {
346 assert!("A+B".parse::<Hotkey>().is_err());
347 assert!("Cmd+A+B".parse::<Hotkey>().is_err());
348 }
349
350 #[test]
351 fn parse_case_insensitive() {
352 let h1: Hotkey = "CMD+SHIFT+K".parse().unwrap();
353 let h2: Hotkey = "cmd+shift+k".parse().unwrap();
354 let h3: Hotkey = "Cmd+Shift+K".parse().unwrap();
355 assert_eq!(h1, h2);
356 assert_eq!(h2, h3);
357 }
358
359 #[test]
360 fn hotkey_display() {
361 let hotkey = Hotkey::new(Modifiers::CMD | Modifiers::SHIFT, Key::K).unwrap();
362 let displayed = format!("{}", hotkey);
363 assert!(displayed.contains("Cmd"));
364 assert!(displayed.contains("Shift"));
365 assert!(displayed.contains("K"));
366 }
367
368 #[test]
369 fn hotkey_display_roundtrip_keypad() {
370 let keypad_keys = [
372 Key::KeypadPlus,
373 Key::KeypadMinus,
374 Key::KeypadMultiply,
375 Key::KeypadDivide,
376 Key::KeypadDecimal,
377 Key::KeypadEquals,
378 Key::KeypadEnter,
379 Key::KeypadClear,
380 ];
381 for key in keypad_keys {
382 let hotkey = Hotkey::new(Modifiers::empty(), key).unwrap();
384 let displayed = format!("{}", hotkey);
385 let parsed: Hotkey = displayed.parse().unwrap_or_else(|e| {
386 panic!("Failed to parse '{}' (from {:?}): {}", displayed, key, e)
387 });
388 assert_eq!(parsed, hotkey, "Key-only roundtrip failed for {:?}", key);
389
390 let hotkey = Hotkey::new(Modifiers::CMD, key).unwrap();
392 let displayed = format!("{}", hotkey);
393 let parsed: Hotkey = displayed.parse().unwrap_or_else(|e| {
394 panic!("Failed to parse '{}' (from Cmd+{:?}): {}", displayed, key, e)
395 });
396 assert_eq!(parsed, hotkey, "Cmd+{:?} roundtrip failed", key);
397 }
398 }
399
400 #[test]
401 fn hotkey_new_validates() {
402 assert!(Hotkey::new(Modifiers::CMD, Key::K).is_ok());
404 assert!(Hotkey::new(Modifiers::CMD | Modifiers::SHIFT, None).is_ok());
405 assert!(Hotkey::new(Modifiers::empty(), Key::F1).is_ok());
406
407 assert!(Hotkey::new(Modifiers::empty(), None).is_err());
409 }
410}