soma_tauri_shell/
shortcut.rs1use std::sync::Mutex;
11
12use tauri::AppHandle;
13use tauri_plugin_global_shortcut::{Code, GlobalShortcutExt, Modifiers, Shortcut};
14
15use crate::command::CommandResult;
16
17#[derive(Default)]
21pub struct ActiveShortcutState(Mutex<Option<String>>);
22
23impl ActiveShortcutState {
24 #[must_use]
25 pub fn new() -> Self {
26 Self(Mutex::new(None))
27 }
28
29 pub fn current(&self) -> Option<String> {
32 self.0.lock().ok().and_then(|guard| guard.clone())
33 }
34}
35
36pub fn parse_shortcut(label: &str) -> Option<Shortcut> {
46 let mut segments: Vec<&str> = label
47 .split('+')
48 .map(str::trim)
49 .filter(|s| !s.is_empty())
50 .collect();
51 let key_token = segments.pop()?;
52 let code = parse_code(key_token)?;
53
54 let mut modifiers = Modifiers::empty();
55 for token in segments {
56 modifiers |= parse_modifier(token)?;
57 }
58 let modifiers = if modifiers.is_empty() {
59 None
60 } else {
61 Some(modifiers)
62 };
63 Some(Shortcut::new(modifiers, code))
64}
65
66fn parse_modifier(token: &str) -> Option<Modifiers> {
67 match token.to_ascii_lowercase().as_str() {
68 "ctrl" | "control" => Some(Modifiers::CONTROL),
69 "alt" | "option" => Some(Modifiers::ALT),
70 "shift" => Some(Modifiers::SHIFT),
71 "cmd" | "command" | "super" | "meta" => Some(Modifiers::SUPER),
72 _ => None,
73 }
74}
75
76fn parse_code(token: &str) -> Option<Code> {
77 let lower = token.to_ascii_lowercase();
78 let code = match lower.as_str() {
79 "space" => Code::Space,
80 "enter" | "return" => Code::Enter,
81 "tab" => Code::Tab,
82 "escape" | "esc" => Code::Escape,
83 "up" => Code::ArrowUp,
84 "down" => Code::ArrowDown,
85 "left" => Code::ArrowLeft,
86 "right" => Code::ArrowRight,
87 "f1" => Code::F1,
88 "f2" => Code::F2,
89 "f3" => Code::F3,
90 "f4" => Code::F4,
91 "f5" => Code::F5,
92 "f6" => Code::F6,
93 "f7" => Code::F7,
94 "f8" => Code::F8,
95 "f9" => Code::F9,
96 "f10" => Code::F10,
97 "f11" => Code::F11,
98 "f12" => Code::F12,
99 _ if lower.len() == 1 => single_char_code(lower.chars().next()?)?,
100 _ => return None,
101 };
102 Some(code)
103}
104
105fn single_char_code(ch: char) -> Option<Code> {
106 match ch {
107 'a' => Some(Code::KeyA),
108 'b' => Some(Code::KeyB),
109 'c' => Some(Code::KeyC),
110 'd' => Some(Code::KeyD),
111 'e' => Some(Code::KeyE),
112 'f' => Some(Code::KeyF),
113 'g' => Some(Code::KeyG),
114 'h' => Some(Code::KeyH),
115 'i' => Some(Code::KeyI),
116 'j' => Some(Code::KeyJ),
117 'k' => Some(Code::KeyK),
118 'l' => Some(Code::KeyL),
119 'm' => Some(Code::KeyM),
120 'n' => Some(Code::KeyN),
121 'o' => Some(Code::KeyO),
122 'p' => Some(Code::KeyP),
123 'q' => Some(Code::KeyQ),
124 'r' => Some(Code::KeyR),
125 's' => Some(Code::KeyS),
126 't' => Some(Code::KeyT),
127 'u' => Some(Code::KeyU),
128 'v' => Some(Code::KeyV),
129 'w' => Some(Code::KeyW),
130 'x' => Some(Code::KeyX),
131 'y' => Some(Code::KeyY),
132 'z' => Some(Code::KeyZ),
133 '0' => Some(Code::Digit0),
134 '1' => Some(Code::Digit1),
135 '2' => Some(Code::Digit2),
136 '3' => Some(Code::Digit3),
137 '4' => Some(Code::Digit4),
138 '5' => Some(Code::Digit5),
139 '6' => Some(Code::Digit6),
140 '7' => Some(Code::Digit7),
141 '8' => Some(Code::Digit8),
142 '9' => Some(Code::Digit9),
143 _ => None,
144 }
145}
146
147pub fn register_shortcut(
152 app: &AppHandle,
153 state: &ActiveShortcutState,
154 new_label: &str,
155) -> CommandResult<()> {
156 let new_shortcut = parse_shortcut(new_label)
157 .ok_or_else(|| format!("shortcut label `{new_label}` could not be parsed"))?;
158
159 let Ok(mut guard) = state.0.lock() else {
160 tracing::error!(
166 "ActiveShortcutState mutex poisoned; falling back to unregister_all before registering '{new_label}'"
167 );
168 app.global_shortcut()
169 .unregister_all()
170 .map_err(|err| err.to_string())?;
171 app.global_shortcut()
172 .register(new_shortcut)
173 .map_err(|err| err.to_string())?;
174 return Ok(());
175 };
176
177 if guard.as_deref() == Some(new_label) {
178 return Ok(());
179 }
180 if let Some(old_label) = guard.take() {
181 if let Some(old_shortcut) = parse_shortcut(&old_label) {
182 if let Err(err) = app.global_shortcut().unregister(old_shortcut) {
183 tracing::warn!("failed to unregister old shortcut '{old_label}': {err}");
184 }
185 }
186 }
187 app.global_shortcut()
188 .register(new_shortcut)
189 .map_err(|err| err.to_string())?;
190 *guard = Some(new_label.to_string());
191 Ok(())
192}
193
194#[cfg(test)]
195#[path = "shortcut_tests.rs"]
196mod tests;