CodeIssuesPull RequestsActionsSecurityInsights
✨ AI
More
Settings

Custom hotkeys — mouse wheel click + side buttons #3996

Merged⚡ AI-generatedXSccantynz wants to mergeclaude/custom-hotkeysmainopened Mar 27, 2026
4 changed files+175−7
Modifiedtauri-app/src-tauri/Cargo.toml+1−0View fileUnifiedSplit
3636once_cell = "1" # Lazy static initialization
3737whisper-rs = "0.12" # whisper.cpp Rust bindings — on-device speech-to-text
3838dirs = "5" # OS-standard directories for model storage
39rdev = "0.5" # Global mouse + keyboard event capture (for custom hotkeys)
3940candle-core = { version = "0.8", optional = true } # Local LLM inference (future)
4041candle-transformers = { version = "0.8", optional = true }
4142candle-nn = { version = "0.8", optional = true }
Addedtauri-app/src-tauri/src/hotkeys.rs+106−0View fileUnifiedSplit
1// Custom hotkey system — supports keyboard shortcuts AND mouse buttons
2//
3// This is what makes 48co different from WhisperTyping:
4// Users can choose ANY trigger they want — keyboard combo, mouse button, etc.
5//
6// Supported triggers:
7// - Keyboard: CmdOrCtrl+Shift+Space (default)
8// - Mouse: Middle click (mouse wheel press)
9// - Mouse: Mouse button 4 (side button)
10// - Mouse: Mouse button 5 (side button)
11// - Any keyboard key combo the user wants
12//
13// Uses `rdev` crate for global input capture at the OS level.
14// Works on Windows, Mac, Linux.
15
16use rdev::{listen, Event, EventType, Button};
17use std::sync::{Arc, atomic::{AtomicBool, Ordering}};
18use std::thread;
19
20static MOUSE_HOTKEY_ENABLED: AtomicBool = AtomicBool::new(true);
21static MOUSE_BUTTON: once_cell::sync::Lazy<std::sync::Mutex<HotkeyButton>> =
22 once_cell::sync::Lazy::new(|| std::sync::Mutex::new(HotkeyButton::MiddleClick));
23
24#[derive(Debug, Clone, PartialEq)]
25pub enum HotkeyButton {
26 MiddleClick, // Mouse wheel press
27 Mouse4, // Side button (back)
28 Mouse5, // Side button (forward)
29 None, // Disabled — use keyboard only
30}
31
32impl HotkeyButton {
33 pub fn from_str(s: &str) -> Self {
34 match s {
35 "middle" | "middle-click" | "mouse3" => HotkeyButton::MiddleClick,
36 "mouse4" | "back" => HotkeyButton::Mouse4,
37 "mouse5" | "forward" => HotkeyButton::Mouse5,
38 "none" | "disabled" | "keyboard-only" => HotkeyButton::None,
39 _ => HotkeyButton::MiddleClick, // default
40 }
41 }
42
43 pub fn to_str(&self) -> &str {
44 match self {
45 HotkeyButton::MiddleClick => "middle-click",
46 HotkeyButton::Mouse4 => "mouse4",
47 HotkeyButton::Mouse5 => "mouse5",
48 HotkeyButton::None => "keyboard-only",
49 }
50 }
51}
52
53/// Set which mouse button triggers recording
54pub fn set_mouse_hotkey(button: HotkeyButton) {
55 *MOUSE_BUTTON.lock().unwrap() = button;
56}
57
58/// Enable/disable mouse hotkey
59pub fn set_mouse_hotkey_enabled(enabled: bool) {
60 MOUSE_HOTKEY_ENABLED.store(enabled, Ordering::Relaxed);
61}
62
63/// Start the global mouse listener in a background thread.
64/// Calls `on_toggle` whenever the configured mouse button is pressed.
65/// Returns immediately — the listener runs in a separate thread.
66pub fn start_mouse_listener<F>(on_toggle: F)
67where
68 F: Fn() + Send + Sync + 'static,
69{
70 let callback = Arc::new(on_toggle);
71
72 thread::spawn(move || {
73 let cb = callback.clone();
74
75 let result = listen(move |event: Event| {
76 if !MOUSE_HOTKEY_ENABLED.load(Ordering::Relaxed) {
77 return;
78 }
79
80 let target_button = MOUSE_BUTTON.lock().unwrap().clone();
81 if target_button == HotkeyButton::None {
82 return;
83 }
84
85 match event.event_type {
86 EventType::ButtonPress(button) => {
87 let matched = match target_button {
88 HotkeyButton::MiddleClick => button == Button::Middle,
89 HotkeyButton::Mouse4 => button == Button::Unknown(3), // platform-specific
90 HotkeyButton::Mouse5 => button == Button::Unknown(4),
91 HotkeyButton::None => false,
92 };
93
94 if matched {
95 cb();
96 }
97 }
98 _ => {}
99 }
100 });
101
102 if let Err(e) = result {
103 eprintln!("[48co] Mouse listener error: {:?}", e);
104 }
105 });
106}
Modifiedtauri-app/src-tauri/src/lib.rs+34−0View fileUnifiedSplit
1313mod grammar;
1414mod local_whisper;
1515mod local_grammar;
16mod hotkeys;
1617
1718use std::sync::{Arc, Mutex};
1819use tauri::{AppHandle, Manager};
171172 local_whisper::models_dir().to_string_lossy().to_string()
172173}
173174
175#[tauri::command]
176fn set_mouse_hotkey(button: String) {
177 let hk = hotkeys::HotkeyButton::from_str(&button);
178 hotkeys::set_mouse_hotkey(hk);
179}
180
181#[tauri::command]
182fn get_hotkey_options() -> Vec<serde_json::Value> {
183 vec![
184 serde_json::json!({"id": "middle-click", "label": "Mouse Wheel Click", "desc": "Press the scroll wheel as a button"}),
185 serde_json::json!({"id": "mouse4", "label": "Mouse Side Button (Back)", "desc": "Side button near your thumb"}),
186 serde_json::json!({"id": "mouse5", "label": "Mouse Side Button (Forward)", "desc": "Other side button"}),
187 serde_json::json!({"id": "keyboard-only", "label": "Keyboard Only", "desc": "Use Ctrl+Shift+Space only"}),
188 ]
189}
190
174191fn update_tray(app: &AppHandle, recording: bool) {
175192 if let Some(tray) = app.tray_by_id("main") {
176193 let _ = tray.set_tooltip(Some(if recording {
255272 }
256273 })?;
257274
275 // Start mouse button listener (runs in background thread)
276 // This is what enables mouse wheel click as a hotkey
277 let mouse_handle = app.handle().clone();
278 hotkeys::start_mouse_listener(move || {
279 let h = mouse_handle.clone();
280 tauri::async_runtime::spawn(async move {
281 match toggle_recording(h).await {
282 Ok(msg) => println!("[48co] Mouse toggle: {}", msg),
283 Err(e) => eprintln!("[48co] Mouse toggle error: {}", e),
284 }
285 });
286 });
287
288 println!("[48co] Ready. Ctrl+Shift+Space or mouse wheel click to record.");
289
258290 Ok(())
259291 })
260292 .invoke_handler(tauri::generate_handler![
268300 check_model_downloaded,
269301 download_model,
270302 get_models_dir,
303 set_mouse_hotkey,
304 get_hotkey_options,
271305 ])
272306 .run(tauri::generate_context!())
273307 .expect("error while running 48co");
Modifiedtauri-app/src/App.jsx+34−7View fileUnifiedSplit
1111 const [localModel, setLocalModel] = useState('ggml-base.bin')
1212 const [modelDownloaded, setModelDownloaded] = useState(false)
1313 const [downloading, setDownloading] = useState(false)
14 const [mouseHotkey, setMouseHotkey] = useState('middle-click')
1415 const [status, setStatus] = useState('Ready')
1516 const [saved, setSaved] = useState(false)
1617
2526 setAiRewrite(await store.get('aiRewrite') || false)
2627 setUseLocalWhisper(await store.get('useLocalWhisper') || false)
2728 setLocalModel(await store.get('localModel') || 'ggml-base.bin')
29 setMouseHotkey(await store.get('mouseHotkey') || 'middle-click')
2830
2931 // Send to Rust backend
3032 if (await store.get('whisperApiKey')) invoke('set_api_key', { key: await store.get('whisperApiKey') })
3335 invoke('set_ai_rewrite', { enabled: await store.get('aiRewrite') || false })
3436 invoke('set_use_local_whisper', { enabled: await store.get('useLocalWhisper') || false })
3537 invoke('set_local_model', { model: await store.get('localModel') || 'ggml-base.bin' })
38 invoke('set_mouse_hotkey', { button: await store.get('mouseHotkey') || 'middle-click' })
3639
3740 // Check if model is downloaded
3841 const model = await store.get('localModel') || 'ggml-base.bin'
227230 {/* Status */}
228231 <p className="text-xs text-gray-400 text-center">{status}</p>
229232
230 {/* Shortcut info */}
233 {/* Hotkey Settings */}
231234 <div className="mt-8 p-4 bg-gray-50 rounded-lg">
232 <p className="text-xs text-gray-500 font-medium mb-2">Keyboard Shortcut</p>
233 <p className="text-sm text-gray-700">
234 <kbd className="px-1.5 py-0.5 bg-white border border-gray-200 rounded text-xs">Ctrl+Shift+Space</kbd>
235 {' '}to toggle recording
236 </p>
237 <p className="text-xs text-gray-400 mt-2">Works in any app — browser, email, Slack, anywhere</p>
235 <p className="text-xs text-gray-500 font-medium mb-3">Recording Trigger</p>
236
237 <div className="mb-3">
238 <p className="text-sm text-gray-700 mb-1">
239 <kbd className="px-1.5 py-0.5 bg-white border border-gray-200 rounded text-xs">Ctrl+Shift+Space</kbd>
240 {' '}— always active
241 </p>
242 </div>
243
244 <div className="mb-2">
245 <label className="block text-xs text-gray-500 mb-1">Mouse button (optional)</label>
246 <select
247 value={mouseHotkey}
248 onChange={async (e) => {
249 setMouseHotkey(e.target.value)
250 await invoke('set_mouse_hotkey', { button: e.target.value })
251 const store = await load('settings.json')
252 await store.set('mouseHotkey', e.target.value)
253 await store.save()
254 }}
255 className="w-full px-3 py-2 border border-gray-200 rounded-lg text-sm focus:outline-none focus:border-indigo-300"
256 >
257 <option value="middle-click">Mouse Wheel Click (press the scroll wheel)</option>
258 <option value="mouse4">Side Button (Back)</option>
259 <option value="mouse5">Side Button (Forward)</option>
260 <option value="keyboard-only">Keyboard Only — no mouse trigger</option>
261 </select>
262 </div>
263
264 <p className="text-xs text-gray-400">Works in any app — browser, email, Slack, anywhere</p>
238265 </div>
239266 </div>
240267 </div>
241268
c comment · e edit title · m merge · a approve · r request changes · ? shortcuts