1use std::fmt::Write as _;
10
11#[derive(Debug, Clone, PartialEq, Eq)]
14pub struct Normalized {
15 pub canonical: String,
16 pub crate_form: String,
17}
18
19const MAX_ECHOED_TOKEN_CHARS: usize = 16;
22
23pub fn normalize(text: &str) -> Result<Normalized, String> {
30 if text.trim().is_empty() {
31 return Err("Enter a shortcut".to_string());
32 }
33
34 let mut ctrl = false;
35 let mut alt = false;
36 let mut shift = false;
37 let mut win = false;
38 let mut key: Option<Key> = None;
39
40 for raw in text.split('+') {
41 let token = raw.trim();
42 if token.is_empty() {
43 return Err("Put a key between each '+'".to_string());
44 }
45 match token.to_ascii_lowercase().as_str() {
46 "ctrl" | "control" => ctrl = true,
47 "alt" | "option" => alt = true,
48 "shift" => shift = true,
49 "win" | "super" | "meta" | "cmd" | "command" => win = true,
50 _ => {
51 if key.is_some() {
52 return Err("Use exactly one key, for example Ctrl+Shift+U".to_string());
53 }
54 key = Some(parse_key(token)?);
55 }
56 }
57 }
58
59 let Some(key) = key else {
60 return Err("Add a key, for example Ctrl+Shift+U".to_string());
61 };
62 if !(ctrl || alt || win) {
63 return Err("Add Ctrl, Alt or Win".to_string());
64 }
65
66 let mut canonical = String::new();
67 let mut crate_form = String::new();
68 for (on, shown, parsed) in [
69 (ctrl, "Ctrl", "control"),
70 (alt, "Alt", "alt"),
71 (shift, "Shift", "shift"),
72 (win, "Win", "super"),
73 ] {
74 if on {
75 let _ = write!(canonical, "{shown}+");
76 let _ = write!(crate_form, "{parsed}+");
77 }
78 }
79 canonical.push_str(&key.canonical);
80 crate_form.push_str(&key.crate_form);
81
82 Ok(Normalized {
83 canonical,
84 crate_form,
85 })
86}
87
88struct Key {
89 canonical: String,
90 crate_form: String,
91}
92
93impl Key {
94 fn fixed(canonical: &str, crate_form: &str) -> Self {
95 Self {
96 canonical: canonical.to_string(),
97 crate_form: crate_form.to_string(),
98 }
99 }
100}
101
102fn parse_key(token: &str) -> Result<Key, String> {
103 let mut chars = token.chars();
104 if let (Some(ch), None) = (chars.next(), chars.next()) {
105 if ch.is_ascii_alphabetic() {
106 let upper = ch.to_ascii_uppercase();
107 return Ok(Key {
108 canonical: upper.to_string(),
109 crate_form: format!("Key{upper}"),
110 });
111 }
112 if ch.is_ascii_digit() {
113 return Ok(Key {
114 canonical: ch.to_string(),
115 crate_form: format!("Digit{ch}"),
116 });
117 }
118 let punctuation = match ch {
119 '-' => Some("Minus"),
120 '=' => Some("Equal"),
121 '[' => Some("BracketLeft"),
122 ']' => Some("BracketRight"),
123 '\\' => Some("Backslash"),
124 ';' => Some("Semicolon"),
125 '\'' => Some("Quote"),
126 ',' => Some("Comma"),
127 '.' => Some("Period"),
128 '/' => Some("Slash"),
129 '`' => Some("Backquote"),
130 _ => None,
131 };
132 if let Some(crate_form) = punctuation {
133 return Ok(Key::fixed(&ch.to_string(), crate_form));
134 }
135 }
136
137 let lower = token.to_ascii_lowercase();
138 if let Some(number) = lower.strip_prefix('f')
139 && number.bytes().all(|b| b.is_ascii_digit())
140 && let Ok(n) = number.parse::<u8>()
141 && (1..=24).contains(&n)
142 {
143 let name = format!("F{n}");
144 return Ok(Key::fixed(&name, &name));
145 }
146
147 let named = match lower.as_str() {
148 "space" => Some(("Space", "Space")),
149 "enter" | "return" => Some(("Enter", "Enter")),
150 "tab" => Some(("Tab", "Tab")),
151 "backspace" => Some(("Backspace", "Backspace")),
152 "delete" | "del" => Some(("Delete", "Delete")),
153 "insert" | "ins" => Some(("Insert", "Insert")),
154 "home" => Some(("Home", "Home")),
155 "end" => Some(("End", "End")),
156 "pageup" | "pgup" => Some(("PageUp", "PageUp")),
157 "pagedown" | "pgdn" => Some(("PageDown", "PageDown")),
158 "up" | "arrowup" => Some(("Up", "ArrowUp")),
159 "down" | "arrowdown" => Some(("Down", "ArrowDown")),
160 "left" | "arrowleft" => Some(("Left", "ArrowLeft")),
161 "right" | "arrowright" => Some(("Right", "ArrowRight")),
162 "escape" | "esc" => return Err("Escape closes the popover".to_string()),
163 _ => None,
164 };
165 if let Some((canonical, crate_form)) = named {
166 return Ok(Key::fixed(canonical, crate_form));
167 }
168
169 Err(format!("Unsupported key: {}", echo_token(token)))
170}
171
172fn echo_token(token: &str) -> String {
174 let echoed: String = token
175 .chars()
176 .filter(char::is_ascii_graphic)
177 .take(MAX_ECHOED_TOKEN_CHARS)
178 .collect();
179 if echoed.is_empty() {
180 "?".to_string()
181 } else {
182 echoed
183 }
184}
185
186#[cfg(windows)]
187mod binding {
188 use global_hotkey::hotkey::HotKey;
189 use global_hotkey::{GlobalHotKeyEvent, GlobalHotKeyManager, HotKeyState};
190
191 use crate::display::sanitize_untrusted_line;
192
193 use super::normalize;
194
195 pub struct HotkeyBinding {
201 manager: GlobalHotKeyManager,
202 current: Option<HotKey>,
203 }
204
205 impl HotkeyBinding {
206 pub fn new() -> Result<Self, String> {
209 let manager = GlobalHotKeyManager::new().map_err(|err| {
210 format!(
211 "Could not set up the global shortcut: {}",
212 sanitize_untrusted_line(&err.to_string())
213 )
214 })?;
215 Ok(Self {
216 manager,
217 current: None,
218 })
219 }
220
221 pub fn apply(&mut self, canonical: Option<&str>) -> Result<(), String> {
225 let wanted = match canonical {
226 None => None,
227 Some(text) => {
228 let normalized = normalize(text)?;
229 let hotkey: HotKey = normalized.crate_form.parse().map_err(|err| {
230 format!(
231 "{} is not a shortcut this build can register: {}",
232 normalized.canonical,
233 sanitize_untrusted_line(&format!("{err}"))
234 )
235 })?;
236 Some((normalized.canonical, hotkey))
237 }
238 };
239
240 if let (Some(current), Some((_, hotkey))) = (self.current, &wanted)
241 && current == *hotkey
242 {
243 return Ok(());
244 }
245
246 if let Some(previous) = self.current.take() {
247 self.manager.unregister(previous).map_err(|err| {
248 format!(
249 "Could not release the previous shortcut: {}",
250 sanitize_untrusted_line(&err.to_string())
251 )
252 })?;
253 }
254
255 let Some((canonical, hotkey)) = wanted else {
256 return Ok(());
257 };
258 self.manager
259 .register(hotkey)
260 .map_err(|err| register_failure(&canonical, &err))?;
261 self.current = Some(hotkey);
262 Ok(())
263 }
264
265 pub fn current_id(&self) -> Option<u32> {
267 self.current.map(|hotkey| hotkey.id())
268 }
269 }
270
271 fn register_failure(canonical: &str, err: &global_hotkey::Error) -> String {
273 match err {
274 global_hotkey::Error::AlreadyRegistered(_) => {
275 format!("{canonical} is already used by another app")
276 }
277 other => format!(
278 "Could not register {canonical}: {}",
279 sanitize_untrusted_line(&other.to_string())
280 ),
281 }
282 }
283
284 pub fn install_press_handler<F: Fn(u32) + Send + Sync + 'static>(handler: F) {
288 GlobalHotKeyEvent::set_event_handler(Some(move |event: GlobalHotKeyEvent| {
289 if event.state() == HotKeyState::Pressed {
290 handler(event.id());
291 }
292 }));
293 }
294
295 #[cfg(test)]
296 mod tests {
297 use std::str::FromStr;
298
299 use global_hotkey::hotkey::{Code, HotKey, Modifiers};
300
301 use super::super::normalize;
302 use super::register_failure;
303
304 #[test]
305 fn crate_parses_the_crate_form_we_emit() {
306 let normalized = normalize("Ctrl+Shift+U").expect("valid shortcut");
307 let hotkey = HotKey::from_str(&normalized.crate_form).expect("crate accepts it");
308 assert_eq!(hotkey.mods, Modifiers::CONTROL | Modifiers::SHIFT);
309 assert_eq!(hotkey.key, Code::KeyU);
310 }
311
312 #[test]
313 fn crate_parses_every_key_family_we_emit() {
314 for text in [
315 "Win+Alt+F12",
316 "Ctrl+1",
317 "Alt+Up",
318 "Ctrl+Alt+Space",
319 "Ctrl+`",
320 "Ctrl+Shift+\\",
321 ] {
322 let normalized = normalize(text).expect(text);
323 HotKey::from_str(&normalized.crate_form).expect(text);
324 }
325 }
326
327 #[test]
328 fn already_registered_becomes_a_friendly_sentence() {
329 let hotkey = HotKey::new(Some(Modifiers::CONTROL), Code::KeyU);
330 let message =
331 register_failure("Ctrl+U", &global_hotkey::Error::AlreadyRegistered(hotkey));
332 assert_eq!(message, "Ctrl+U is already used by another app");
333 }
334
335 #[test]
336 fn other_failures_name_the_shortcut_and_strip_controls() {
337 let err = global_hotkey::Error::FailedToRegister("bad\u{1b}[31m vk".to_string());
338 let message = register_failure("Ctrl+U", &err);
339 assert!(
340 message.starts_with("Could not register Ctrl+U: "),
341 "{message}"
342 );
343 assert!(!message.contains('\u{1b}'), "{message}");
344 }
345 }
346}
347
348#[cfg(windows)]
349pub use binding::{HotkeyBinding, install_press_handler};
350
351#[cfg(test)]
352mod tests {
353 use super::*;
354
355 fn canonical(text: &str) -> String {
356 normalize(text)
357 .unwrap_or_else(|err| panic!("{text}: {err}"))
358 .canonical
359 }
360
361 fn crate_form(text: &str) -> String {
362 normalize(text)
363 .unwrap_or_else(|err| panic!("{text}: {err}"))
364 .crate_form
365 }
366
367 fn refusal(text: &str) -> String {
368 match normalize(text) {
369 Ok(normalized) => panic!("{text} was accepted as {normalized:?}"),
370 Err(err) => err,
371 }
372 }
373
374 #[test]
375 fn modifiers_come_out_in_canonical_order() {
376 assert_eq!(canonical("shift+ctrl+u"), "Ctrl+Shift+U");
377 assert_eq!(canonical("win+shift+alt+ctrl+k"), "Ctrl+Alt+Shift+Win+K");
378 }
379
380 #[test]
381 fn whitespace_and_case_are_forgiven() {
382 assert_eq!(canonical(" CTRL + Shift + u "), "Ctrl+Shift+U");
383 }
384
385 #[test]
386 fn modifier_aliases_collapse() {
387 assert_eq!(canonical("control+u"), "Ctrl+U");
388 assert_eq!(canonical("option+u"), "Alt+U");
389 for win in ["win", "super", "meta", "cmd", "command"] {
390 assert_eq!(canonical(&format!("{win}+u")), "Win+U", "{win}");
391 }
392 }
393
394 #[test]
395 fn repeated_modifiers_are_harmless() {
396 assert_eq!(canonical("ctrl+control+u"), "Ctrl+U");
397 }
398
399 #[test]
400 fn function_keys() {
401 assert_eq!(canonical("alt+f1"), "Alt+F1");
402 assert_eq!(canonical("ctrl+F24"), "Ctrl+F24");
403 assert_eq!(crate_form("ctrl+F24"), "control+F24");
404 }
405
406 #[test]
407 fn arrows_use_short_canonical_and_crate_names() {
408 assert_eq!(canonical("ctrl+arrowup"), "Ctrl+Up");
409 assert_eq!(crate_form("ctrl+up"), "control+ArrowUp");
410 assert_eq!(crate_form("alt+left"), "alt+ArrowLeft");
411 }
412
413 #[test]
414 fn punctuation_keys() {
415 assert_eq!(canonical("ctrl+-"), "Ctrl+-");
416 assert_eq!(crate_form("ctrl+-"), "control+Minus");
417 assert_eq!(crate_form("ctrl+="), "control+Equal");
418 assert_eq!(crate_form("ctrl+["), "control+BracketLeft");
419 assert_eq!(crate_form("ctrl+]"), "control+BracketRight");
420 assert_eq!(crate_form("ctrl+\\"), "control+Backslash");
421 assert_eq!(crate_form("ctrl+;"), "control+Semicolon");
422 assert_eq!(crate_form("ctrl+'"), "control+Quote");
423 assert_eq!(crate_form("ctrl+,"), "control+Comma");
424 assert_eq!(crate_form("ctrl+."), "control+Period");
425 assert_eq!(crate_form("ctrl+/"), "control+Slash");
426 assert_eq!(crate_form("ctrl+`"), "control+Backquote");
427 }
428
429 #[test]
430 fn digits_and_named_keys() {
431 assert_eq!(canonical("ctrl+1"), "Ctrl+1");
432 assert_eq!(crate_form("ctrl+1"), "control+Digit1");
433 assert_eq!(canonical("ctrl+space"), "Ctrl+Space");
434 assert_eq!(canonical("ctrl+pgup"), "Ctrl+PageUp");
435 assert_eq!(crate_form("win+enter"), "super+Enter");
436 }
437
438 #[test]
439 fn crate_form_spells_letters_as_key_codes() {
440 assert_eq!(crate_form("Ctrl+Shift+U"), "control+shift+KeyU");
441 assert_eq!(crate_form("win+alt+z"), "alt+super+KeyZ");
442 }
443
444 #[test]
445 fn shift_alone_is_not_enough() {
446 assert_eq!(refusal("Shift+U"), "Add Ctrl, Alt or Win");
447 }
448
449 #[test]
450 fn a_bare_key_is_refused() {
451 assert_eq!(refusal("u"), "Add Ctrl, Alt or Win");
452 }
453
454 #[test]
455 fn two_keys_are_refused() {
456 assert_eq!(
457 refusal("ctrl+u+i"),
458 "Use exactly one key, for example Ctrl+Shift+U"
459 );
460 }
461
462 #[test]
463 fn only_modifiers_are_refused() {
464 assert_eq!(refusal("ctrl+shift"), "Add a key, for example Ctrl+Shift+U");
465 }
466
467 #[test]
468 fn escape_is_refused() {
469 assert_eq!(refusal("ctrl+escape"), "Escape closes the popover");
470 assert_eq!(refusal("ctrl+esc"), "Escape closes the popover");
471 }
472
473 #[test]
474 fn blank_is_refused() {
475 assert_eq!(refusal(""), "Enter a shortcut");
476 assert_eq!(refusal(" "), "Enter a shortcut");
477 }
478
479 #[test]
480 fn an_empty_token_is_refused() {
481 assert_eq!(refusal("ctrl++u"), "Put a key between each '+'");
482 }
483
484 #[test]
485 fn unknown_tokens_are_echoed() {
486 assert_eq!(refusal("ctrl+numpad5"), "Unsupported key: numpad5");
487 assert_eq!(refusal("ctrl+f25"), "Unsupported key: f25");
488 assert_eq!(refusal("ctrl+f0"), "Unsupported key: f0");
489 assert_eq!(refusal("ctrl+f1x"), "Unsupported key: f1x");
490 }
491
492 #[test]
493 fn overlong_garbage_is_clipped_and_stripped() {
494 let garbage = format!("ctrl+\u{1b}[31m{}\u{e9}", "x".repeat(40));
495 let message = refusal(&garbage);
496 assert_eq!(message, format!("Unsupported key: [31m{}", "x".repeat(12)));
497 assert!(message.len() <= "Unsupported key: ".len() + MAX_ECHOED_TOKEN_CHARS);
498 }
499
500 #[test]
501 fn a_token_with_nothing_printable_echoes_a_placeholder() {
502 assert_eq!(refusal("ctrl+\u{1b}"), "Unsupported key: ?");
503 }
504}