From b733ccc458874ce118e18c82f0493a2a0527957a Mon Sep 17 00:00:00 2001 From: CJ Pais Date: Tue, 20 Jan 2026 08:23:35 +0800 Subject: [PATCH] init attempt at new kb (#580) * init attempt at new kb * 0.1.1 handy-keys * format * cleanup * support for capslock/shiftlock? * move kb to experimental * use hook * fix macos bug * attempt mouse buttons as well --- src-tauri/Cargo.lock | 244 +++++++- src-tauri/Cargo.toml | 1 + src-tauri/src/lib.rs | 4 + src-tauri/src/settings.rs | 21 + src-tauri/src/shortcut/handler.rs | 91 +++ src-tauri/src/shortcut/handy_keys.rs | 545 ++++++++++++++++ .../src/{shortcut.rs => shortcut/mod.rs} | 587 ++++++++++-------- src-tauri/src/shortcut/tauri_impl.rs | 192 ++++++ src/bindings.ts | 53 +- ...dyShortcut.tsx => GlobalShortcutInput.tsx} | 40 +- .../settings/HandyKeysShortcutInput.tsx | 300 +++++++++ src/components/settings/PasteMethod.tsx | 10 +- src/components/settings/ShortcutInput.tsx | 30 + .../settings/advanced/AdvancedSettings.tsx | 5 + .../settings/debug/DebugSettings.tsx | 4 +- .../debug/KeyboardImplementationSelector.tsx | 70 +++ .../settings/general/GeneralSettings.tsx | 4 +- .../settings/history/HistorySettings.tsx | 7 +- src/components/settings/index.ts | 4 +- src/hooks/useOsType.ts | 16 + src/i18n/locales/en/translation.json | 5 + 21 files changed, 1916 insertions(+), 317 deletions(-) create mode 100644 src-tauri/src/shortcut/handler.rs create mode 100644 src-tauri/src/shortcut/handy_keys.rs rename src-tauri/src/{shortcut.rs => shortcut/mod.rs} (63%) create mode 100644 src-tauri/src/shortcut/tauri_impl.rs rename src/components/settings/{HandyShortcut.tsx => GlobalShortcutInput.tsx} (91%) create mode 100644 src/components/settings/HandyKeysShortcutInput.tsx create mode 100644 src/components/settings/ShortcutInput.tsx create mode 100644 src/components/settings/debug/KeyboardImplementationSelector.tsx create mode 100644 src/hooks/useOsType.ts diff --git a/src-tauri/Cargo.lock b/src-tauri/Cargo.lock index 7cf44c1..ddb80ee 100644 --- a/src-tauri/Cargo.lock +++ b/src-tauri/Cargo.lock @@ -837,6 +837,21 @@ dependencies = [ "cc", ] +[[package]] +name = "cocoa" +version = "0.22.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "667fdc068627a2816b9ff831201dd9864249d6ee8d190b9532357f1fc0f61ea7" +dependencies = [ + "bitflags 1.3.2", + "block", + "core-foundation 0.9.4", + "core-graphics 0.21.0", + "foreign-types 0.3.2", + "libc", + "objc", +] + [[package]] name = "cocoa" version = "0.24.1" @@ -908,13 +923,23 @@ dependencies = [ "version_check", ] +[[package]] +name = "core-foundation" +version = "0.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "57d24c7a13c43e870e37c1556b74555437870a04514f7685f5b354e090567171" +dependencies = [ + "core-foundation-sys 0.7.0", + "libc", +] + [[package]] name = "core-foundation" version = "0.9.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "91e195e091a93c46f7102ec7818a2aa394e1e1771c3ab4825963fa03e45afb8f" dependencies = [ - "core-foundation-sys", + "core-foundation-sys 0.8.7", "libc", ] @@ -924,16 +949,46 @@ version = "0.10.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b2a6cd9ae233e7f62ba4e9353e81a88df7fc8a5987b8d445b4d90c879bd156f6" dependencies = [ - "core-foundation-sys", + "core-foundation-sys 0.8.7", "libc", ] +[[package]] +name = "core-foundation-sys" +version = "0.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b3a71ab494c0b5b860bdc8407ae08978052417070c2ced38573a9157ad75b8ac" + [[package]] name = "core-foundation-sys" version = "0.8.7" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "773648b94d0e5d620f64f280777445740e61fe701025087ec8b57f45c791888b" +[[package]] +name = "core-graphics" +version = "0.19.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b3889374e6ea6ab25dba90bb5d96202f61108058361f6dc72e8b03e6f8bbe923" +dependencies = [ + "bitflags 1.3.2", + "core-foundation 0.7.0", + "foreign-types 0.3.2", + "libc", +] + +[[package]] +name = "core-graphics" +version = "0.21.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "52a67c4378cf203eace8fb6567847eb641fd6ff933c1145a115c6ee820ebb978" +dependencies = [ + "bitflags 1.3.2", + "core-foundation 0.9.4", + "foreign-types 0.3.2", + "libc", +] + [[package]] name = "core-graphics" version = "0.22.3" @@ -1584,6 +1639,29 @@ version = "3.3.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "dea2df4cf52843e0452895c455a1a2cfbb842a1e7329671acf418fdc53ed4c59" +[[package]] +name = "evdev-rs" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b92abc30d5fd1e4f6440dee4d626abc68f4a9b5014dc1de575901e23c2e02321" +dependencies = [ + "bitflags 1.3.2", + "evdev-sys", + "libc", + "log", +] + +[[package]] +name = "evdev-sys" +version = "0.2.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cdcf0d489f4d9a80ac2b3b35b92fdd8fcf68d33bb67f947afe5cd36e482de576" +dependencies = [ + "cc", + "libc", + "pkg-config", +] + [[package]] name = "event-listener" version = "5.4.1" @@ -2303,11 +2381,12 @@ dependencies = [ "ferrous-opencc", "flate2", "futures-util", + "handy-keys", "hound", "log", "natural", "once_cell", - "rdev", + "rdev 0.5.0-2", "regex", "reqwest", "rodio", @@ -2344,6 +2423,25 @@ dependencies = [ "windows 0.61.3", ] +[[package]] +name = "handy-keys" +version = "0.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "10f85c247daebc113bb7128afa9ee19eb83b9a6d1301772b89111328ab856186" +dependencies = [ + "bitflags 2.10.0", + "block2 0.6.2", + "objc2 0.6.3", + "objc2-app-kit", + "objc2-core-foundation", + "objc2-core-graphics", + "objc2-foundation 0.3.2", + "rdev 0.5.3", + "serde", + "thiserror 2.0.17", + "windows 0.58.0", +] + [[package]] name = "hashbrown" version = "0.12.3" @@ -2568,7 +2666,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "33e57f83510bb73707521ebaffa789ec8caf86f9657cad665b092b581d40e9fb" dependencies = [ "android_system_properties", - "core-foundation-sys", + "core-foundation-sys 0.8.7", "iana-time-zone-haiku", "js-sys", "log", @@ -2760,6 +2858,17 @@ dependencies = [ "cfb", ] +[[package]] +name = "inotify" +version = "0.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "46dd0a94b393c730779ccfd2a872b67b1eb67be3fc33082e733bdb38b5fde4d4" +dependencies = [ + "bitflags 1.3.2", + "inotify-sys", + "libc", +] + [[package]] name = "inotify" version = "0.10.2" @@ -3095,7 +3204,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "edf7710fbff50c24124331760978fb9086d6de6288dcdb38b25a97f8b1bdebbb" dependencies = [ "core-foundation 0.9.4", - "core-foundation-sys", + "core-foundation-sys 0.8.7", ] [[package]] @@ -3597,7 +3706,9 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "2a180dd8642fa45cdb7dd721cd4c11b1cadd4929ce112ebd8b9f5803cc79d536" dependencies = [ "bitflags 2.10.0", + "block2 0.6.2", "dispatch2", + "libc", "objc2 0.6.3", ] @@ -3608,10 +3719,13 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e022c9d066895efa1345f8e33e584b9f958da2fd4cd116792e15e07e4720a807" dependencies = [ "bitflags 2.10.0", + "block2 0.6.2", "dispatch2", + "libc", "objc2 0.6.3", "objc2-core-foundation", "objc2-io-surface", + "objc2-metal 0.3.2", ] [[package]] @@ -3722,6 +3836,17 @@ dependencies = [ "objc2-foundation 0.2.2", ] +[[package]] +name = "objc2-metal" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a0125f776a10d00af4152d74616409f0d4a2053a6f57fa5b7d6aa2854ac04794" +dependencies = [ + "bitflags 2.10.0", + "objc2 0.6.3", + "objc2-foundation 0.3.2", +] + [[package]] name = "objc2-osa-kit" version = "0.3.2" @@ -3744,7 +3869,7 @@ dependencies = [ "block2 0.5.1", "objc2 0.5.2", "objc2-foundation 0.2.2", - "objc2-metal", + "objc2-metal 0.2.2", ] [[package]] @@ -4643,14 +4768,14 @@ name = "rdev" version = "0.5.0-2" source = "git+https://github.com/rustdesk-org/rdev#a90dbe1172f8832f54c97c62e823c5a34af5fdfe" dependencies = [ - "cocoa", + "cocoa 0.24.1", "core-foundation 0.9.4", - "core-foundation-sys", + "core-foundation-sys 0.8.7", "core-graphics 0.22.3", "dispatch", "enum-map", "epoll", - "inotify", + "inotify 0.10.2", "lazy_static", "libc", "log", @@ -4662,6 +4787,25 @@ dependencies = [ "x11", ] +[[package]] +name = "rdev" +version = "0.5.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "00552ca2dc2f93b84cd7b5581de49549411e4e41d89e1c691bcb93dc4be360c3" +dependencies = [ + "cocoa 0.22.0", + "core-foundation 0.7.0", + "core-foundation-sys 0.7.0", + "core-graphics 0.19.2", + "epoll", + "evdev-rs", + "inotify 0.8.3", + "lazy_static", + "libc", + "winapi", + "x11", +] + [[package]] name = "realfft" version = "3.5.0" @@ -5135,7 +5279,7 @@ checksum = "897b2245f0b511c87893af39b033e5ca9cce68824c4d7e7630b5a1d339658d02" dependencies = [ "bitflags 2.10.0", "core-foundation 0.9.4", - "core-foundation-sys", + "core-foundation-sys 0.8.7", "libc", "security-framework-sys", ] @@ -5146,7 +5290,7 @@ version = "2.15.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "cc1f0cbffaac4852523ce30d8bd3c5cdc873501d96ff467ca09b6767bb8cd5c0" dependencies = [ - "core-foundation-sys", + "core-foundation-sys 0.8.7", "libc", ] @@ -5839,7 +5983,7 @@ version = "0.6.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8e1d1b10ced5ca923a1fcb8d03e96b8d3268065d724548c0211415ff6ac6bac4" dependencies = [ - "core-foundation-sys", + "core-foundation-sys 0.8.7", "libc", ] @@ -7417,8 +7561,8 @@ dependencies = [ "webview2-com-sys", "windows 0.61.3", "windows-core 0.61.2", - "windows-implement", - "windows-interface", + "windows-implement 0.60.2", + "windows-interface 0.59.3", ] [[package]] @@ -7544,6 +7688,16 @@ dependencies = [ "windows-targets 0.52.6", ] +[[package]] +name = "windows" +version = "0.58.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dd04d41d93c4992d421894c18c8b43496aa748dd4c081bac0dc93eb0489272b6" +dependencies = [ + "windows-core 0.58.0", + "windows-targets 0.52.6", +] + [[package]] name = "windows" version = "0.61.3" @@ -7576,14 +7730,27 @@ dependencies = [ "windows-targets 0.52.6", ] +[[package]] +name = "windows-core" +version = "0.58.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6ba6d44ec8c2591c134257ce647b7ea6b20335bf6379a27dac5f1641fcf59f99" +dependencies = [ + "windows-implement 0.58.0", + "windows-interface 0.58.0", + "windows-result 0.2.0", + "windows-strings 0.1.0", + "windows-targets 0.52.6", +] + [[package]] name = "windows-core" version = "0.61.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c0fdd3ddb90610c7638aa2b3a3ab2904fb9e5cdbecc643ddb3647212781c4ae3" dependencies = [ - "windows-implement", - "windows-interface", + "windows-implement 0.60.2", + "windows-interface 0.59.3", "windows-link 0.1.3", "windows-result 0.3.4", "windows-strings 0.4.2", @@ -7595,8 +7762,8 @@ version = "0.62.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b8e83a14d34d0623b51dce9581199302a221863196a1dde71a7663a4c2be9deb" dependencies = [ - "windows-implement", - "windows-interface", + "windows-implement 0.60.2", + "windows-interface 0.59.3", "windows-link 0.2.1", "windows-result 0.4.1", "windows-strings 0.5.1", @@ -7613,6 +7780,17 @@ dependencies = [ "windows-threading", ] +[[package]] +name = "windows-implement" +version = "0.58.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2bbd5b46c938e506ecbce286b6628a02171d56153ba733b6c741fc627ec9579b" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.108", +] + [[package]] name = "windows-implement" version = "0.60.2" @@ -7624,6 +7802,17 @@ dependencies = [ "syn 2.0.108", ] +[[package]] +name = "windows-interface" +version = "0.58.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "053c4c462dc91d3b1504c6fe5a726dd15e216ba718e84a0e46a88fbe5ded3515" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.108", +] + [[package]] name = "windows-interface" version = "0.59.3" @@ -7677,6 +7866,15 @@ dependencies = [ "windows-targets 0.52.6", ] +[[package]] +name = "windows-result" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1d1043d8214f791817bab27572aaa8af63732e11bf84aa21a45a78d6c317ae0e" +dependencies = [ + "windows-targets 0.52.6", +] + [[package]] name = "windows-result" version = "0.3.4" @@ -7695,6 +7893,16 @@ dependencies = [ "windows-link 0.2.1", ] +[[package]] +name = "windows-strings" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4cd9b125c486025df0eabcb585e62173c6c9eddcec5d117d3b6e8c30e2ee4d10" +dependencies = [ + "windows-result 0.2.0", + "windows-targets 0.52.6", +] + [[package]] name = "windows-strings" version = "0.4.2" diff --git a/src-tauri/Cargo.toml b/src-tauri/Cargo.toml index e765c00..913a3ef 100644 --- a/src-tauri/Cargo.toml +++ b/src-tauri/Cargo.toml @@ -69,6 +69,7 @@ rusqlite = { version = "0.37", features = ["bundled"] } tar = "0.4.44" flate2 = "1.0" transcribe-rs = { version = "0.2.2", features = ["whisper", "parakeet", "moonshine"] } +handy-keys = "0.1.4" ferrous-opencc = "0.2.3" specta = "=2.0.0-rc.22" specta-typescript = "0.0.9" diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index ffc630d..556dd3a 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -269,6 +269,10 @@ pub fn run() { shortcut::change_append_trailing_space_setting, shortcut::change_app_language_setting, shortcut::change_update_checks_setting, + shortcut::change_keyboard_implementation_setting, + shortcut::get_keyboard_implementation, + shortcut::handy_keys::start_handy_keys_recording, + shortcut::handy_keys::stop_handy_keys_recording, trigger_update_check, commands::cancel_operation, commands::get_app_dir_path, diff --git a/src-tauri/src/settings.rs b/src-tauri/src/settings.rs index ffe2e31..8feb07f 100644 --- a/src-tauri/src/settings.rs +++ b/src-tauri/src/settings.rs @@ -151,6 +151,24 @@ pub enum RecordingRetentionPeriod { Months3, } +#[derive(Serialize, Deserialize, Debug, Clone, Copy, PartialEq, Eq, Type)] +#[serde(rename_all = "snake_case")] +pub enum KeyboardImplementation { + Tauri, + HandyKeys, +} + +impl Default for KeyboardImplementation { + fn default() -> Self { + // Default to HandyKeys only on macOS where it's well-tested. + // Windows and Linux use Tauri by default (handy-keys not sufficiently tested yet). + #[cfg(target_os = "macos")] + return KeyboardImplementation::HandyKeys; + #[cfg(not(target_os = "macos"))] + return KeyboardImplementation::Tauri; + } +} + impl Default for ModelUnloadTimeout { fn default() -> Self { ModelUnloadTimeout::Never @@ -295,6 +313,8 @@ pub struct AppSettings { pub app_language: String, #[serde(default)] pub experimental_enabled: bool, + #[serde(default)] + pub keyboard_implementation: KeyboardImplementation, } fn default_model() -> String { @@ -584,6 +604,7 @@ pub fn get_default_settings() -> AppSettings { append_trailing_space: false, app_language: default_app_language(), experimental_enabled: false, + keyboard_implementation: KeyboardImplementation::default(), } } diff --git a/src-tauri/src/shortcut/handler.rs b/src-tauri/src/shortcut/handler.rs new file mode 100644 index 0000000..c00fb47 --- /dev/null +++ b/src-tauri/src/shortcut/handler.rs @@ -0,0 +1,91 @@ +//! Shared shortcut event handling logic +//! +//! This module contains the common logic for handling shortcut events, +//! used by both the Tauri and handy-keys implementations. + +use log::warn; +use std::sync::Arc; +use tauri::{AppHandle, Manager}; + +use crate::actions::ACTION_MAP; +use crate::managers::audio::AudioRecordingManager; +use crate::settings::get_settings; +use crate::ManagedToggleState; + +/// Handle a shortcut event from either implementation. +/// +/// This function contains the shared logic for: +/// - Looking up the action in ACTION_MAP +/// - Handling the cancel binding (only fires when recording) +/// - Handling push-to-talk mode (start on press, stop on release) +/// - Handling toggle mode (toggle state on press only) +/// +/// # Arguments +/// * `app` - The Tauri app handle +/// * `binding_id` - The ID of the binding (e.g., "transcribe", "cancel") +/// * `hotkey_string` - The string representation of the hotkey +/// * `is_pressed` - Whether this is a key press (true) or release (false) +pub fn handle_shortcut_event( + app: &AppHandle, + binding_id: &str, + hotkey_string: &str, + is_pressed: bool, +) { + let settings = get_settings(app); + + let Some(action) = ACTION_MAP.get(binding_id) else { + warn!( + "No action defined in ACTION_MAP for shortcut ID '{}'. Shortcut: '{}', Pressed: {}", + binding_id, hotkey_string, is_pressed + ); + return; + }; + + // Cancel binding: only fires when recording and key is pressed + if binding_id == "cancel" { + let audio_manager = app.state::>(); + if audio_manager.is_recording() && is_pressed { + action.start(app, binding_id, hotkey_string); + } + return; + } + + // Push-to-talk mode: start on press, stop on release + if settings.push_to_talk { + if is_pressed { + action.start(app, binding_id, hotkey_string); + } else { + action.stop(app, binding_id, hotkey_string); + } + return; + } + + // Toggle mode: toggle state on press only + if is_pressed { + // Determine action and update state while holding the lock, + // but RELEASE the lock before calling the action to avoid deadlocks. + // (Actions may need to acquire the lock themselves, e.g., cancel_current_operation) + let should_start: bool; + { + let toggle_state_manager = app.state::(); + let mut states = toggle_state_manager + .lock() + .expect("Failed to lock toggle state manager"); + + let is_currently_active = states + .active_toggles + .entry(binding_id.to_string()) + .or_insert(false); + + should_start = !*is_currently_active; + *is_currently_active = should_start; + } // Lock released here + + // Now call the action without holding the lock + if should_start { + action.start(app, binding_id, hotkey_string); + } else { + action.stop(app, binding_id, hotkey_string); + } + } +} diff --git a/src-tauri/src/shortcut/handy_keys.rs b/src-tauri/src/shortcut/handy_keys.rs new file mode 100644 index 0000000..963351b --- /dev/null +++ b/src-tauri/src/shortcut/handy_keys.rs @@ -0,0 +1,545 @@ +//! Handy-keys based keyboard shortcut implementation +//! +//! This module provides an alternative to Tauri's global-shortcut plugin +//! using the handy-keys library for more control over keyboard events. +//! +//! ## Architecture +//! +//! The implementation uses a dedicated manager thread that owns the `HotkeyManager`: +//! +//! ```text +//! ┌─────────────────┐ commands ┌──────────────────────┐ +//! │ Main Thread │ ───────────────▶ │ Manager Thread │ +//! │ │ (via channel) │ │ +//! │ - register() │ │ - owns HotkeyManager │ +//! │ - unregister() │ │ - polls for events │ +//! └─────────────────┘ │ - dispatches actions │ +//! └──────────────────────┘ +//! ``` +//! +//! This design ensures thread-safety since `HotkeyManager` is only accessed +//! from a single thread. Commands (register/unregister) are sent via an mpsc +//! channel and responses are synchronously awaited. +//! +//! ## Recording Mode +//! +//! For UI key capture, a separate `KeyboardListener` is created on-demand and +//! polled from a dedicated recording thread. Events are emitted to the frontend +//! via Tauri's event system. + +use handy_keys::{Hotkey, HotkeyId, HotkeyManager, HotkeyState, KeyboardListener}; +use log::{debug, error, info}; +use serde::Serialize; +use specta::Type; +use std::collections::HashMap; +use std::sync::atomic::{AtomicBool, Ordering}; +use std::sync::mpsc::{self, Receiver, Sender}; +use std::sync::{Arc, Mutex}; +use std::thread::{self, JoinHandle}; +use tauri::{AppHandle, Emitter, Manager}; + +use crate::settings::{self, get_settings, ShortcutBinding}; + +use super::handler::handle_shortcut_event; + +/// Commands that can be sent to the hotkey manager thread +enum ManagerCommand { + Register { + binding_id: String, + hotkey_string: String, + response: Sender>, + }, + Unregister { + binding_id: String, + response: Sender>, + }, + Shutdown, +} + +/// State for the handy-keys shortcut manager +pub struct HandyKeysState { + /// Channel to send commands to the manager thread (wrapped in Mutex for Sync) + command_sender: Mutex>, + /// Handle to the manager thread (wrapped in Mutex for Sync, allows proper join on drop) + thread_handle: Mutex>>, + /// Recording listener for UI key capture (only active during recording) + recording_listener: Mutex>, + /// Flag indicating if we're in recording mode + is_recording: AtomicBool, + /// The binding ID being recorded (if any) + recording_binding_id: Mutex>, + /// Flag to stop recording loop + recording_running: Arc, +} + +/// Key event sent to frontend during recording mode +#[derive(Debug, Clone, Serialize, Type)] +pub struct FrontendKeyEvent { + /// Currently pressed modifier keys + pub modifiers: Vec, + /// The key that was pressed (if any) + pub key: Option, + /// Whether this is a key down event + pub is_key_down: bool, + /// The full hotkey string (e.g., "option+space") + pub hotkey_string: String, +} + +impl HandyKeysState { + /// Create a new HandyKeysState + pub fn new(app: AppHandle) -> Result { + let (cmd_tx, cmd_rx) = mpsc::channel::(); + + // Start the manager thread + let app_clone = app.clone(); + let thread_handle = thread::spawn(move || { + Self::manager_thread(cmd_rx, app_clone); + }); + + Ok(Self { + command_sender: Mutex::new(cmd_tx), + thread_handle: Mutex::new(Some(thread_handle)), + recording_listener: Mutex::new(None), + is_recording: AtomicBool::new(false), + recording_binding_id: Mutex::new(None), + recording_running: Arc::new(AtomicBool::new(false)), + }) + } + + /// The main manager thread - owns the HotkeyManager and processes commands + fn manager_thread(cmd_rx: Receiver, app: AppHandle) { + info!("handy-keys manager thread started"); + + // Create the HotkeyManager in this thread + let manager = match HotkeyManager::new() { + Ok(m) => m, + Err(e) => { + error!("Failed to create HotkeyManager: {}", e); + return; + } + }; + + // Maps binding IDs to HotkeyIds and hotkey strings + let mut binding_to_hotkey: HashMap = HashMap::new(); + let mut hotkey_to_binding: HashMap = HashMap::new(); // (binding_id, hotkey_string) + + loop { + // Check for hotkey events (non-blocking) + while let Some(event) = manager.try_recv() { + if let Some((binding_id, hotkey_string)) = hotkey_to_binding.get(&event.id) { + debug!( + "handy-keys event: binding={}, hotkey={}, state={:?}", + binding_id, hotkey_string, event.state + ); + let is_pressed = event.state == HotkeyState::Pressed; + handle_shortcut_event(&app, binding_id, hotkey_string, is_pressed); + } + } + + // Check for commands (non-blocking with timeout) + match cmd_rx.recv_timeout(std::time::Duration::from_millis(10)) { + Ok(cmd) => match cmd { + ManagerCommand::Register { + binding_id, + hotkey_string, + response, + } => { + let result = Self::do_register( + &manager, + &mut binding_to_hotkey, + &mut hotkey_to_binding, + &binding_id, + &hotkey_string, + ); + let _ = response.send(result); + } + ManagerCommand::Unregister { + binding_id, + response, + } => { + let result = Self::do_unregister( + &manager, + &mut binding_to_hotkey, + &mut hotkey_to_binding, + &binding_id, + ); + let _ = response.send(result); + } + ManagerCommand::Shutdown => { + info!("handy-keys manager thread shutting down"); + break; + } + }, + Err(mpsc::RecvTimeoutError::Timeout) => { + // No command, continue + } + Err(mpsc::RecvTimeoutError::Disconnected) => { + info!("Command channel disconnected, shutting down"); + break; + } + } + } + + info!("handy-keys manager thread stopped"); + } + + /// Register a hotkey + fn do_register( + manager: &HotkeyManager, + binding_to_hotkey: &mut HashMap, + hotkey_to_binding: &mut HashMap, + binding_id: &str, + hotkey_string: &str, + ) -> Result<(), String> { + let hotkey: Hotkey = hotkey_string + .parse() + .map_err(|e| format!("Failed to parse hotkey '{}': {}", hotkey_string, e))?; + + let id = manager + .register(hotkey) + .map_err(|e| format!("Failed to register hotkey: {}", e))?; + + binding_to_hotkey.insert(binding_id.to_string(), id); + hotkey_to_binding.insert(id, (binding_id.to_string(), hotkey_string.to_string())); + + debug!( + "Registered handy-keys shortcut: {} -> {:?}", + binding_id, hotkey + ); + Ok(()) + } + + /// Unregister a hotkey + fn do_unregister( + manager: &HotkeyManager, + binding_to_hotkey: &mut HashMap, + hotkey_to_binding: &mut HashMap, + binding_id: &str, + ) -> Result<(), String> { + if let Some(id) = binding_to_hotkey.remove(binding_id) { + manager + .unregister(id) + .map_err(|e| format!("Failed to unregister hotkey: {}", e))?; + hotkey_to_binding.remove(&id); + debug!("Unregistered handy-keys shortcut: {}", binding_id); + } + Ok(()) + } + + /// Register a shortcut binding + pub fn register(&self, binding: &ShortcutBinding) -> Result<(), String> { + let (tx, rx) = mpsc::channel(); + self.command_sender + .lock() + .map_err(|_| "Failed to lock command_sender")? + .send(ManagerCommand::Register { + binding_id: binding.id.clone(), + hotkey_string: binding.current_binding.clone(), + response: tx, + }) + .map_err(|_| "Failed to send register command")?; + + rx.recv() + .map_err(|_| "Failed to receive register response")? + } + + /// Unregister a shortcut binding + pub fn unregister(&self, binding: &ShortcutBinding) -> Result<(), String> { + let (tx, rx) = mpsc::channel(); + self.command_sender + .lock() + .map_err(|_| "Failed to lock command_sender")? + .send(ManagerCommand::Unregister { + binding_id: binding.id.clone(), + response: tx, + }) + .map_err(|_| "Failed to send unregister command")?; + + rx.recv() + .map_err(|_| "Failed to receive unregister response")? + } + + /// Start recording mode for a specific binding + pub fn start_recording(&self, app: &AppHandle, binding_id: String) -> Result<(), String> { + if self.is_recording.load(Ordering::SeqCst) { + return Err("Already recording".into()); + } + + // Create a new keyboard listener for recording + let listener = KeyboardListener::new() + .map_err(|e| format!("Failed to create keyboard listener: {}", e))?; + + { + let mut recording = self + .recording_listener + .lock() + .map_err(|_| "Failed to lock recording_listener")?; + *recording = Some(listener); + } + { + let mut binding = self + .recording_binding_id + .lock() + .map_err(|_| "Failed to lock recording_binding_id")?; + *binding = Some(binding_id); + } + + self.is_recording.store(true, Ordering::SeqCst); + self.recording_running.store(true, Ordering::SeqCst); + + // Start a thread to emit key events to the frontend + let app_clone = app.clone(); + let recording_running = Arc::clone(&self.recording_running); + thread::spawn(move || { + Self::recording_loop(app_clone, recording_running); + }); + + debug!("Started handy-keys recording mode"); + Ok(()) + } + + /// Recording loop - emits key events to frontend during recording + fn recording_loop(app: AppHandle, running: Arc) { + while running.load(Ordering::SeqCst) { + let event = { + let state = match app.try_state::() { + Some(s) => s, + None => break, + }; + let listener = state.recording_listener.lock().ok(); + listener.as_ref().and_then(|l| l.as_ref()?.try_recv()) + }; + + if let Some(key_event) = event { + // Convert to frontend-friendly format + let frontend_event = FrontendKeyEvent { + modifiers: modifiers_to_strings(key_event.modifiers), + key: key_event.key.map(|k| k.to_string().to_lowercase()), + is_key_down: key_event.is_key_down, + hotkey_string: key_event + .as_hotkey() + .map(|h| h.to_handy_string()) + .unwrap_or_default(), + }; + + // Emit to frontend + if let Err(e) = app.emit("handy-keys-event", &frontend_event) { + error!("Failed to emit key event: {}", e); + } + } else { + thread::sleep(std::time::Duration::from_millis(10)); + } + } + + debug!("Recording loop ended"); + } + + /// Stop recording mode + pub fn stop_recording(&self) -> Result<(), String> { + self.is_recording.store(false, Ordering::SeqCst); + self.recording_running.store(false, Ordering::SeqCst); + + { + let mut recording = self + .recording_listener + .lock() + .map_err(|_| "Failed to lock recording_listener")?; + *recording = None; + } + { + let mut binding = self + .recording_binding_id + .lock() + .map_err(|_| "Failed to lock recording_binding_id")?; + *binding = None; + } + + debug!("Stopped handy-keys recording mode"); + Ok(()) + } +} + +impl Drop for HandyKeysState { + fn drop(&mut self) { + // Signal recording to stop + self.recording_running.store(false, Ordering::SeqCst); + self.is_recording.store(false, Ordering::SeqCst); + + // Send shutdown command + if let Ok(sender) = self.command_sender.lock() { + let _ = sender.send(ManagerCommand::Shutdown); + } + + // Wait for the manager thread to finish + if let Ok(mut handle) = self.thread_handle.lock() { + if let Some(h) = handle.take() { + let _ = h.join(); + } + } + } +} + +/// Convert handy-keys Modifiers to a list of strings +fn modifiers_to_strings(modifiers: handy_keys::Modifiers) -> Vec { + let mut result = Vec::new(); + + if modifiers.contains(handy_keys::Modifiers::CTRL) { + result.push("ctrl".to_string()); + } + if modifiers.contains(handy_keys::Modifiers::OPT) { + #[cfg(target_os = "macos")] + result.push("option".to_string()); + #[cfg(not(target_os = "macos"))] + result.push("alt".to_string()); + } + if modifiers.contains(handy_keys::Modifiers::SHIFT) { + result.push("shift".to_string()); + } + if modifiers.contains(handy_keys::Modifiers::CMD) { + #[cfg(target_os = "macos")] + result.push("command".to_string()); + #[cfg(not(target_os = "macos"))] + result.push("super".to_string()); + } + if modifiers.contains(handy_keys::Modifiers::FN) { + result.push("fn".to_string()); + } + + result +} + +/// Validate a shortcut string for the HandyKeys implementation. +/// HandyKeys is more permissive: allows modifier-only combos and the fn key. +pub fn validate_shortcut(raw: &str) -> Result<(), String> { + if raw.trim().is_empty() { + return Err("Shortcut cannot be empty".into()); + } + // HandyKeys accepts modifier-only, key-only, and modifier+key combos + // Just verify the string is parseable + raw.parse::() + .map(|_| ()) + .map_err(|e| format!("Invalid shortcut for HandyKeys: {}", e)) +} + +/// Initialize handy-keys shortcuts +pub fn init_shortcuts(app: &AppHandle) -> Result<(), String> { + let state = HandyKeysState::new(app.clone())?; + + let default_bindings = settings::get_default_settings().bindings; + let user_settings = settings::load_or_create_app_settings(app); + + // Register all bindings except cancel (which is dynamic) + for (id, default_binding) in default_bindings { + if id == "cancel" { + continue; + } + + let binding = user_settings + .bindings + .get(&id) + .cloned() + .unwrap_or(default_binding); + + if let Err(e) = state.register(&binding) { + error!( + "Failed to register handy-keys shortcut {} during init: {}", + id, e + ); + } + } + + app.manage(state); + info!("handy-keys shortcuts initialized"); + Ok(()) +} + +/// Register the cancel shortcut (called when recording starts) +pub fn register_cancel_shortcut(app: &AppHandle) { + // Disabled on Linux due to instability + #[cfg(target_os = "linux")] + { + let _ = app; + return; + } + + #[cfg(not(target_os = "linux"))] + { + let app_clone = app.clone(); + tauri::async_runtime::spawn(async move { + if let Some(cancel_binding) = get_settings(&app_clone).bindings.get("cancel").cloned() { + if let Some(state) = app_clone.try_state::() { + if let Err(e) = state.register(&cancel_binding) { + error!("Failed to register cancel shortcut: {}", e); + } + } + } + }); + } +} + +/// Unregister the cancel shortcut (called when recording stops) +pub fn unregister_cancel_shortcut(app: &AppHandle) { + #[cfg(target_os = "linux")] + { + let _ = app; + return; + } + + #[cfg(not(target_os = "linux"))] + { + let app_clone = app.clone(); + tauri::async_runtime::spawn(async move { + if let Some(cancel_binding) = get_settings(&app_clone).bindings.get("cancel").cloned() { + if let Some(state) = app_clone.try_state::() { + let _ = state.unregister(&cancel_binding); + } + } + }); + } +} + +/// Register a shortcut +pub fn register_shortcut(app: &AppHandle, binding: ShortcutBinding) -> Result<(), String> { + let state = app + .try_state::() + .ok_or("HandyKeysState not initialized")?; + state.register(&binding) +} + +/// Unregister a shortcut +pub fn unregister_shortcut(app: &AppHandle, binding: ShortcutBinding) -> Result<(), String> { + let state = app + .try_state::() + .ok_or("HandyKeysState not initialized")?; + state.unregister(&binding) +} + +/// Start key recording mode +#[tauri::command] +#[specta::specta] +pub fn start_handy_keys_recording(app: AppHandle, binding_id: String) -> Result<(), String> { + let settings = get_settings(&app); + if settings.keyboard_implementation != settings::KeyboardImplementation::HandyKeys { + return Err("handy-keys is not the active keyboard implementation".into()); + } + + let state = app + .try_state::() + .ok_or("HandyKeysState not initialized")?; + state.start_recording(&app, binding_id) +} + +/// Stop key recording mode +#[tauri::command] +#[specta::specta] +pub fn stop_handy_keys_recording(app: AppHandle) -> Result<(), String> { + let settings = get_settings(&app); + if settings.keyboard_implementation != settings::KeyboardImplementation::HandyKeys { + return Err("handy-keys is not the active keyboard implementation".into()); + } + + let state = app + .try_state::() + .ok_or("HandyKeysState not initialized")?; + state.stop_recording() +} diff --git a/src-tauri/src/shortcut.rs b/src-tauri/src/shortcut/mod.rs similarity index 63% rename from src-tauri/src/shortcut.rs rename to src-tauri/src/shortcut/mod.rs index d290943..da0e160 100644 --- a/src-tauri/src/shortcut.rs +++ b/src-tauri/src/shortcut/mod.rs @@ -1,42 +1,99 @@ -use log::{error, warn}; +//! Keyboard shortcut management module +//! +//! This module provides a unified interface for keyboard shortcuts with +//! multiple backend implementations: +//! +//! - `tauri`: Uses Tauri's built-in global-shortcut plugin +//! - `handy_keys`: Uses the handy-keys library for more control +//! +//! The active implementation is determined by the `keyboard_implementation` +//! setting and can be changed at runtime. + +mod handler; +pub mod handy_keys; +mod tauri_impl; + +use log::{error, info, warn}; use serde::Serialize; use specta::Type; -use std::sync::Arc; use tauri::{AppHandle, Emitter, Manager}; use tauri_plugin_autostart::ManagerExt; -use tauri_plugin_global_shortcut::{GlobalShortcutExt, Shortcut, ShortcutState}; -use crate::actions::ACTION_MAP; -use crate::managers::audio::AudioRecordingManager; -use crate::settings::ShortcutBinding; use crate::settings::{ - self, get_settings, ClipboardHandling, LLMPrompt, OverlayPosition, PasteMethod, SoundTheme, - APPLE_INTELLIGENCE_DEFAULT_MODEL_ID, APPLE_INTELLIGENCE_PROVIDER_ID, + self, get_settings, ClipboardHandling, KeyboardImplementation, LLMPrompt, OverlayPosition, + PasteMethod, ShortcutBinding, SoundTheme, APPLE_INTELLIGENCE_DEFAULT_MODEL_ID, + APPLE_INTELLIGENCE_PROVIDER_ID, }; use crate::tray; -use crate::ManagedToggleState; +// Note: Commands are accessed via shortcut::handy_keys:: in lib.rs + +/// Initialize shortcuts using the configured implementation pub fn init_shortcuts(app: &AppHandle) { - let default_bindings = settings::get_default_settings().bindings; let user_settings = settings::load_or_create_app_settings(app); - // Register all default shortcuts, applying user customizations - for (id, default_binding) in default_bindings { - if id == "cancel" { - continue; // Skip cancel shortcut, it will be registered dynamically + // Check which implementation to use + match user_settings.keyboard_implementation { + KeyboardImplementation::Tauri => { + tauri_impl::init_shortcuts(app); } - let binding = user_settings - .bindings - .get(&id) - .cloned() - .unwrap_or(default_binding); + KeyboardImplementation::HandyKeys => { + if let Err(e) = handy_keys::init_shortcuts(app) { + error!("Failed to initialize handy-keys shortcuts: {}", e); + // Fall back to Tauri implementation and persist this fallback + warn!("Falling back to Tauri global shortcut implementation and saving fallback to settings"); - if let Err(e) = register_shortcut(app, binding) { - error!("Failed to register shortcut {} during init: {}", id, e); + // Update settings to persist the fallback so we don't retry HandyKeys on next launch + let mut settings = settings::get_settings(app); + settings.keyboard_implementation = KeyboardImplementation::Tauri; + settings::write_settings(app, settings); + + tauri_impl::init_shortcuts(app); + } } } } +/// Register the cancel shortcut (called when recording starts) +pub fn register_cancel_shortcut(app: &AppHandle) { + let settings = get_settings(app); + match settings.keyboard_implementation { + KeyboardImplementation::Tauri => tauri_impl::register_cancel_shortcut(app), + KeyboardImplementation::HandyKeys => handy_keys::register_cancel_shortcut(app), + } +} + +/// Unregister the cancel shortcut (called when recording stops) +pub fn unregister_cancel_shortcut(app: &AppHandle) { + let settings = get_settings(app); + match settings.keyboard_implementation { + KeyboardImplementation::Tauri => tauri_impl::unregister_cancel_shortcut(app), + KeyboardImplementation::HandyKeys => handy_keys::unregister_cancel_shortcut(app), + } +} + +/// Register a shortcut using the appropriate implementation +pub fn register_shortcut(app: &AppHandle, binding: ShortcutBinding) -> Result<(), String> { + let settings = get_settings(app); + match settings.keyboard_implementation { + KeyboardImplementation::Tauri => tauri_impl::register_shortcut(app, binding), + KeyboardImplementation::HandyKeys => handy_keys::register_shortcut(app, binding), + } +} + +/// Unregister a shortcut using the appropriate implementation +pub fn unregister_shortcut(app: &AppHandle, binding: ShortcutBinding) -> Result<(), String> { + let settings = get_settings(app); + match settings.keyboard_implementation { + KeyboardImplementation::Tauri => tauri_impl::unregister_shortcut(app, binding), + KeyboardImplementation::HandyKeys => handy_keys::unregister_shortcut(app, binding), + } +} + +// ============================================================================ +// Binding Management Commands +// ============================================================================ + #[derive(Serialize, Type)] pub struct BindingResponse { success: bool, @@ -66,6 +123,7 @@ pub fn change_binding( }); } }; + // If this is the cancel binding, just update the settings and return // It's managed dynamically, so we don't register/unregister here if id == "cancel" { @@ -87,8 +145,9 @@ pub fn change_binding( error!("change_binding error: {}", error_msg); } - // Validate the new shortcut before we touch the current registration - if let Err(e) = validate_shortcut_string(&binding) { + // Validate the new shortcut for the current keyboard implementation + if let Err(e) = validate_shortcut_for_implementation(&binding, settings.keyboard_implementation) + { warn!("change_binding validation error: {}", e); return Err(e); } @@ -126,21 +185,273 @@ pub fn change_binding( #[specta::specta] pub fn reset_binding(app: AppHandle, id: String) -> Result { let binding = settings::get_stored_binding(&app, &id); - - return change_binding(app, id, binding.default_binding); + change_binding(app, id, binding.default_binding) } +/// Temporarily unregister a binding while the user is editing it in the UI. +/// This avoids firing the action while keys are being recorded. +#[tauri::command] +#[specta::specta] +pub fn suspend_binding(app: AppHandle, id: String) -> Result<(), String> { + if let Some(b) = settings::get_bindings(&app).get(&id).cloned() { + if let Err(e) = unregister_shortcut(&app, b) { + error!("suspend_binding error for id '{}': {}", id, e); + return Err(e); + } + } + Ok(()) +} + +/// Re-register the binding after the user has finished editing. +#[tauri::command] +#[specta::specta] +pub fn resume_binding(app: AppHandle, id: String) -> Result<(), String> { + if let Some(b) = settings::get_bindings(&app).get(&id).cloned() { + if let Err(e) = register_shortcut(&app, b) { + error!("resume_binding error for id '{}': {}", id, e); + return Err(e); + } + } + Ok(()) +} + +// ============================================================================ +// Keyboard Implementation Switching +// ============================================================================ + +/// Result of changing keyboard implementation +#[derive(Serialize, Type)] +pub struct ImplementationChangeResult { + pub success: bool, + /// List of binding IDs that were reset to defaults due to incompatibility + pub reset_bindings: Vec, +} + +/// Change the keyboard implementation with runtime switching. +/// This will unregister all shortcuts from the old implementation, +/// validate shortcuts for the new implementation (resetting invalid ones to defaults), +/// and register them with the new implementation. +#[tauri::command] +#[specta::specta] +pub fn change_keyboard_implementation_setting( + app: AppHandle, + implementation: String, +) -> Result { + let current_settings = settings::get_settings(&app); + let current_impl = current_settings.keyboard_implementation; + let new_impl = parse_keyboard_implementation(&implementation); + + // If same implementation, nothing to do + if current_impl == new_impl { + return Ok(ImplementationChangeResult { + success: true, + reset_bindings: vec![], + }); + } + + info!( + "Switching keyboard implementation from {:?} to {:?}", + current_impl, new_impl + ); + + // Unregister all shortcuts from the current implementation + unregister_all_shortcuts(&app, current_impl); + + // Update the setting + let mut settings = settings::get_settings(&app); + settings.keyboard_implementation = new_impl; + settings::write_settings(&app, settings); + + // Initialize new implementation if needed (HandyKeys needs state) + if new_impl == KeyboardImplementation::HandyKeys { + if initialize_handy_keys_with_rollback(&app)? { + // Shortcuts already registered during init + return Ok(ImplementationChangeResult { + success: true, + reset_bindings: vec![], + }); + } + } + + // Register all shortcuts with new implementation, resetting invalid ones + let reset_bindings = register_all_shortcuts_for_implementation(&app, new_impl); + + // Emit event to notify frontend of the change + let _ = app.emit( + "settings-changed", + serde_json::json!({ + "setting": "keyboard_implementation", + "value": implementation, + "reset_bindings": reset_bindings + }), + ); + + info!("Keyboard implementation switched to {:?}", new_impl); + + Ok(ImplementationChangeResult { + success: true, + reset_bindings, + }) +} + +/// Get the current keyboard implementation +#[tauri::command] +#[specta::specta] +pub fn get_keyboard_implementation(app: AppHandle) -> String { + let settings = settings::get_settings(&app); + match settings.keyboard_implementation { + KeyboardImplementation::Tauri => "tauri".to_string(), + KeyboardImplementation::HandyKeys => "handy_keys".to_string(), + } +} + +// ============================================================================ +// Validation Helpers +// ============================================================================ + +/// Validate a shortcut for a specific implementation +fn validate_shortcut_for_implementation( + raw: &str, + implementation: KeyboardImplementation, +) -> Result<(), String> { + match implementation { + KeyboardImplementation::Tauri => tauri_impl::validate_shortcut(raw), + KeyboardImplementation::HandyKeys => handy_keys::validate_shortcut(raw), + } +} + +/// Parse a keyboard implementation string into the enum +fn parse_keyboard_implementation(s: &str) -> KeyboardImplementation { + match s { + "tauri" => KeyboardImplementation::Tauri, + "handy_keys" => KeyboardImplementation::HandyKeys, + other => { + warn!( + "Invalid keyboard implementation '{}', defaulting to tauri", + other + ); + KeyboardImplementation::Tauri + } + } +} + +/// Unregister all shortcuts for the current implementation +fn unregister_all_shortcuts(app: &AppHandle, implementation: KeyboardImplementation) { + let bindings = settings::get_bindings(app); + + for (id, binding) in bindings { + // Skip cancel shortcut as it's dynamically registered + if id == "cancel" { + continue; + } + + let result = match implementation { + KeyboardImplementation::Tauri => tauri_impl::unregister_shortcut(app, binding), + KeyboardImplementation::HandyKeys => handy_keys::unregister_shortcut(app, binding), + }; + + if let Err(e) = result { + warn!( + "Failed to unregister shortcut '{}' during switch: {}", + id, e + ); + } + } +} + +/// Register all shortcuts for a specific implementation, validating and resetting invalid ones +fn register_all_shortcuts_for_implementation( + app: &AppHandle, + implementation: KeyboardImplementation, +) -> Vec { + let mut reset_bindings = Vec::new(); + let default_bindings = settings::get_default_settings().bindings; + let mut current_settings = settings::get_settings(app); + + for (id, default_binding) in &default_bindings { + // Skip cancel shortcut as it's dynamically registered + if id == "cancel" { + continue; + } + + let mut binding = current_settings + .bindings + .get(id) + .cloned() + .unwrap_or_else(|| default_binding.clone()); + + // Validate the shortcut for the target implementation + if let Err(e) = + validate_shortcut_for_implementation(&binding.current_binding, implementation) + { + info!( + "Shortcut '{}' ({}) is invalid for {:?}: {}. Resetting to default.", + id, binding.current_binding, implementation, e + ); + + // Reset to default + binding.current_binding = default_binding.current_binding.clone(); + current_settings + .bindings + .insert(id.clone(), binding.clone()); + reset_bindings.push(id.clone()); + } + + // Register with the appropriate implementation + let result = match implementation { + KeyboardImplementation::Tauri => tauri_impl::register_shortcut(app, binding), + KeyboardImplementation::HandyKeys => handy_keys::register_shortcut(app, binding), + }; + + if let Err(e) = result { + error!( + "Failed to register shortcut '{}' for {:?}: {}", + id, implementation, e + ); + } + } + + // Save settings if any bindings were reset + if !reset_bindings.is_empty() { + settings::write_settings(app, current_settings); + } + + reset_bindings +} + +/// Initialize HandyKeys if not already initialized, with rollback on failure +fn initialize_handy_keys_with_rollback(app: &AppHandle) -> Result { + if app.try_state::().is_some() { + return Ok(false); // Already initialized, caller should continue + } + + if let Err(e) = handy_keys::init_shortcuts(app) { + error!("Failed to initialize HandyKeys: {}", e); + // Rollback to Tauri + let mut settings = settings::get_settings(app); + settings.keyboard_implementation = KeyboardImplementation::Tauri; + settings::write_settings(app, settings); + tauri_impl::init_shortcuts(app); + return Err(format!( + "Failed to initialize HandyKeys: {}. Reverted to Tauri.", + e + )); + } + + // init_shortcuts already registered shortcuts + Ok(true) +} + +// ============================================================================ +// General Settings Commands +// ============================================================================ + #[tauri::command] #[specta::specta] pub fn change_ptt_setting(app: AppHandle, enabled: bool) -> Result<(), String> { let mut settings = settings::get_settings(&app); - - // TODO if the setting is currently false, we probably want to - // cancel any ongoing recordings or actions settings.push_to_talk = enabled; - settings::write_settings(&app, settings); - Ok(()) } @@ -606,7 +917,6 @@ pub fn change_mute_while_recording_setting(app: AppHandle, enabled: bool) -> Res let mut settings = settings::get_settings(&app); settings.mute_while_recording = enabled; settings::write_settings(&app, settings); - Ok(()) } @@ -616,7 +926,6 @@ pub fn change_append_trailing_space_setting(app: AppHandle, enabled: bool) -> Re let mut settings = settings::get_settings(&app); settings.append_trailing_space = enabled; settings::write_settings(&app, settings); - Ok(()) } @@ -632,217 +941,3 @@ pub fn change_app_language_setting(app: AppHandle, language: String) -> Result<( Ok(()) } - -/// Validate that a shortcut contains at least one non-modifier key. -/// The tauri-plugin-global-shortcut library requires at least one main key. -fn validate_shortcut_string(raw: &str) -> Result<(), String> { - if raw.trim().is_empty() { - return Err("Shortcut cannot be empty".into()); - } - - let modifiers = [ - "ctrl", "control", "shift", "alt", "option", "meta", "command", "cmd", "super", "win", - "windows", - ]; - let has_non_modifier = raw - .split('+') - .any(|part| !modifiers.contains(&part.trim().to_lowercase().as_str())); - - if has_non_modifier { - Ok(()) - } else { - Err("Shortcut must include a main key (letter, number, F-key, etc.) in addition to modifiers".into()) - } -} - -/// Temporarily unregister a binding while the user is editing it in the UI. -/// This avoids firing the action while keys are being recorded. -#[tauri::command] -#[specta::specta] -pub fn suspend_binding(app: AppHandle, id: String) -> Result<(), String> { - if let Some(b) = settings::get_bindings(&app).get(&id).cloned() { - if let Err(e) = unregister_shortcut(&app, b) { - error!("suspend_binding error for id '{}': {}", id, e); - return Err(e); - } - } - Ok(()) -} - -/// Re-register the binding after the user has finished editing. -#[tauri::command] -#[specta::specta] -pub fn resume_binding(app: AppHandle, id: String) -> Result<(), String> { - if let Some(b) = settings::get_bindings(&app).get(&id).cloned() { - if let Err(e) = register_shortcut(&app, b) { - error!("resume_binding error for id '{}': {}", id, e); - return Err(e); - } - } - Ok(()) -} - -pub fn register_cancel_shortcut(app: &AppHandle) { - // Cancel shortcut is disabled on Linux due to instability with dynamic shortcut registration - #[cfg(target_os = "linux")] - { - let _ = app; - return; - } - - #[cfg(not(target_os = "linux"))] - { - let app_clone = app.clone(); - tauri::async_runtime::spawn(async move { - if let Some(cancel_binding) = get_settings(&app_clone).bindings.get("cancel").cloned() { - if let Err(e) = register_shortcut(&app_clone, cancel_binding) { - eprintln!("Failed to register cancel shortcut: {}", e); - } - } - }); - } -} - -pub fn unregister_cancel_shortcut(app: &AppHandle) { - // Cancel shortcut is disabled on Linux due to instability with dynamic shortcut registration - #[cfg(target_os = "linux")] - { - let _ = app; - return; - } - - #[cfg(not(target_os = "linux"))] - { - let app_clone = app.clone(); - tauri::async_runtime::spawn(async move { - if let Some(cancel_binding) = get_settings(&app_clone).bindings.get("cancel").cloned() { - // We ignore errors here as it might already be unregistered - let _ = unregister_shortcut(&app_clone, cancel_binding); - } - }); - } -} - -pub fn register_shortcut(app: &AppHandle, binding: ShortcutBinding) -> Result<(), String> { - // Validate human-level rules first - if let Err(e) = validate_shortcut_string(&binding.current_binding) { - warn!( - "_register_shortcut validation error for binding '{}': {}", - binding.current_binding, e - ); - return Err(e); - } - - // Parse shortcut and return error if it fails - let shortcut = match binding.current_binding.parse::() { - Ok(s) => s, - Err(e) => { - let error_msg = format!( - "Failed to parse shortcut '{}': {}", - binding.current_binding, e - ); - error!("_register_shortcut parse error: {}", error_msg); - return Err(error_msg); - } - }; - - // Prevent duplicate registrations that would silently shadow one another - if app.global_shortcut().is_registered(shortcut) { - let error_msg = format!("Shortcut '{}' is already in use", binding.current_binding); - warn!("_register_shortcut duplicate error: {}", error_msg); - return Err(error_msg); - } - - // Clone binding.id for use in the closure - let binding_id_for_closure = binding.id.clone(); - - app.global_shortcut() - .on_shortcut(shortcut, move |ah, scut, event| { - if scut == &shortcut { - let shortcut_string = scut.into_string(); - let settings = get_settings(ah); - - if let Some(action) = ACTION_MAP.get(&binding_id_for_closure) { - if binding_id_for_closure == "cancel" { - let audio_manager = ah.state::>(); - if audio_manager.is_recording() && event.state == ShortcutState::Pressed { - action.start(ah, &binding_id_for_closure, &shortcut_string); - } - return; - } else if settings.push_to_talk { - if event.state == ShortcutState::Pressed { - action.start(ah, &binding_id_for_closure, &shortcut_string); - } else if event.state == ShortcutState::Released { - action.stop(ah, &binding_id_for_closure, &shortcut_string); - } - } else { - // Toggle mode: toggle on press only - if event.state == ShortcutState::Pressed { - // Determine action and update state while holding the lock, - // but RELEASE the lock before calling the action to avoid deadlocks. - // (Actions may need to acquire the lock themselves, e.g., cancel_current_operation) - let should_start: bool; - { - let toggle_state_manager = ah.state::(); - let mut states = toggle_state_manager - .lock() - .expect("Failed to lock toggle state manager"); - - let is_currently_active = states - .active_toggles - .entry(binding_id_for_closure.clone()) - .or_insert(false); - - should_start = !*is_currently_active; - *is_currently_active = should_start; - } // Lock released here - - // Now call the action without holding the lock - if should_start { - action.start(ah, &binding_id_for_closure, &shortcut_string); - } else { - action.stop(ah, &binding_id_for_closure, &shortcut_string); - } - } - } - } else { - warn!( - "No action defined in ACTION_MAP for shortcut ID '{}'. Shortcut: '{}', State: {:?}", - binding_id_for_closure, shortcut_string, event.state - ); - } - } - }) - .map_err(|e| { - let error_msg = format!("Couldn't register shortcut '{}': {}", binding.current_binding, e); - error!("_register_shortcut registration error: {}", error_msg); - error_msg - })?; - - Ok(()) -} - -pub fn unregister_shortcut(app: &AppHandle, binding: ShortcutBinding) -> Result<(), String> { - let shortcut = match binding.current_binding.parse::() { - Ok(s) => s, - Err(e) => { - let error_msg = format!( - "Failed to parse shortcut '{}' for unregistration: {}", - binding.current_binding, e - ); - error!("_unregister_shortcut parse error: {}", error_msg); - return Err(error_msg); - } - }; - - app.global_shortcut().unregister(shortcut).map_err(|e| { - let error_msg = format!( - "Failed to unregister shortcut '{}': {}", - binding.current_binding, e - ); - error!("_unregister_shortcut error: {}", error_msg); - error_msg - })?; - - Ok(()) -} diff --git a/src-tauri/src/shortcut/tauri_impl.rs b/src-tauri/src/shortcut/tauri_impl.rs new file mode 100644 index 0000000..d8f0018 --- /dev/null +++ b/src-tauri/src/shortcut/tauri_impl.rs @@ -0,0 +1,192 @@ +//! Tauri global-shortcut implementation +//! +//! This module provides shortcut functionality using Tauri's built-in +//! global-shortcut plugin. + +use log::{error, warn}; +use tauri::AppHandle; +use tauri_plugin_global_shortcut::{GlobalShortcutExt, Shortcut, ShortcutState}; + +use crate::settings::{self, get_settings, ShortcutBinding}; + +use super::handler::handle_shortcut_event; + +/// Initialize shortcuts using Tauri's global-shortcut plugin +pub fn init_shortcuts(app: &AppHandle) { + let default_bindings = settings::get_default_settings().bindings; + let user_settings = settings::load_or_create_app_settings(app); + + // Register all default shortcuts, applying user customizations + for (id, default_binding) in default_bindings { + if id == "cancel" { + continue; // Skip cancel shortcut, it will be registered dynamically + } + let binding = user_settings + .bindings + .get(&id) + .cloned() + .unwrap_or(default_binding); + + if let Err(e) = register_shortcut(app, binding) { + error!("Failed to register shortcut {} during init: {}", id, e); + } + } +} + +/// Validate a shortcut string for the Tauri global-shortcut implementation. +/// Tauri requires at least one non-modifier key and doesn't support the fn key. +pub fn validate_shortcut(raw: &str) -> Result<(), String> { + if raw.trim().is_empty() { + return Err("Shortcut cannot be empty".into()); + } + + let modifiers = [ + "ctrl", "control", "shift", "alt", "option", "meta", "command", "cmd", "super", "win", + "windows", + ]; + + // Check for fn key which Tauri doesn't support + let parts: Vec = raw.split('+').map(|p| p.trim().to_lowercase()).collect(); + for part in &parts { + if part == "fn" || part == "function" { + return Err("The 'fn' key is not supported by Tauri global shortcuts".into()); + } + } + + // Check for at least one non-modifier key + let has_non_modifier = parts.iter().any(|part| !modifiers.contains(&part.as_str())); + + if has_non_modifier { + Ok(()) + } else { + Err("Tauri shortcuts must include a main key (letter, number, F-key, etc.) in addition to modifiers".into()) + } +} + +/// Register a shortcut using Tauri's global-shortcut plugin +pub fn register_shortcut(app: &AppHandle, binding: ShortcutBinding) -> Result<(), String> { + // Validate for Tauri requirements + if let Err(e) = validate_shortcut(&binding.current_binding) { + warn!( + "register_tauri_shortcut validation error for binding '{}': {}", + binding.current_binding, e + ); + return Err(e); + } + + // Parse shortcut and return error if it fails + let shortcut = match binding.current_binding.parse::() { + Ok(s) => s, + Err(e) => { + let error_msg = format!( + "Failed to parse shortcut '{}': {}", + binding.current_binding, e + ); + error!("register_tauri_shortcut parse error: {}", error_msg); + return Err(error_msg); + } + }; + + // Prevent duplicate registrations that would silently shadow one another + if app.global_shortcut().is_registered(shortcut) { + let error_msg = format!("Shortcut '{}' is already in use", binding.current_binding); + warn!("register_tauri_shortcut duplicate error: {}", error_msg); + return Err(error_msg); + } + + // Clone binding.id for use in the closure + let binding_id_for_closure = binding.id.clone(); + + app.global_shortcut() + .on_shortcut(shortcut, move |app_handle, scut, event| { + if scut == &shortcut { + let shortcut_string = scut.into_string(); + let is_pressed = event.state == ShortcutState::Pressed; + handle_shortcut_event( + app_handle, + &binding_id_for_closure, + &shortcut_string, + is_pressed, + ); + } + }) + .map_err(|e| { + let error_msg = format!( + "Couldn't register shortcut '{}': {}", + binding.current_binding, e + ); + error!("register_tauri_shortcut registration error: {}", error_msg); + error_msg + })?; + + Ok(()) +} + +/// Unregister a shortcut from Tauri's global-shortcut plugin +pub fn unregister_shortcut(app: &AppHandle, binding: ShortcutBinding) -> Result<(), String> { + let shortcut = match binding.current_binding.parse::() { + Ok(s) => s, + Err(e) => { + let error_msg = format!( + "Failed to parse shortcut '{}' for unregistration: {}", + binding.current_binding, e + ); + error!("unregister_tauri_shortcut parse error: {}", error_msg); + return Err(error_msg); + } + }; + + app.global_shortcut().unregister(shortcut).map_err(|e| { + let error_msg = format!( + "Failed to unregister shortcut '{}': {}", + binding.current_binding, e + ); + error!("unregister_tauri_shortcut error: {}", error_msg); + error_msg + })?; + + Ok(()) +} + +/// Register the cancel shortcut (called when recording starts) +pub fn register_cancel_shortcut(app: &AppHandle) { + // Cancel shortcut is disabled on Linux due to instability with dynamic shortcut registration + #[cfg(target_os = "linux")] + { + let _ = app; + return; + } + + #[cfg(not(target_os = "linux"))] + { + let app_clone = app.clone(); + tauri::async_runtime::spawn(async move { + if let Some(cancel_binding) = get_settings(&app_clone).bindings.get("cancel").cloned() { + if let Err(e) = register_shortcut(&app_clone, cancel_binding) { + error!("Failed to register cancel shortcut: {}", e); + } + } + }); + } +} + +/// Unregister the cancel shortcut (called when recording stops) +pub fn unregister_cancel_shortcut(app: &AppHandle) { + // Cancel shortcut is disabled on Linux due to instability with dynamic shortcut registration + #[cfg(target_os = "linux")] + { + let _ = app; + return; + } + + #[cfg(not(target_os = "linux"))] + { + let app_clone = app.clone(); + tauri::async_runtime::spawn(async move { + if let Some(cancel_binding) = get_settings(&app_clone).bindings.get("cancel").cloned() { + // We ignore errors here as it might already be unregistered + let _ = unregister_shortcut(&app_clone, cancel_binding); + } + }); + } +} diff --git a/src/bindings.ts b/src/bindings.ts index b7ea634..6b50126 100644 --- a/src/bindings.ts +++ b/src/bindings.ts @@ -276,6 +276,48 @@ async changeUpdateChecksSetting(enabled: boolean) : Promise else return { status: "error", error: e as any }; } }, +/** + * Change the keyboard implementation with runtime switching. + * This will unregister all shortcuts from the old implementation, + * validate shortcuts for the new implementation (resetting invalid ones to defaults), + * and register them with the new implementation. + */ +async changeKeyboardImplementationSetting(implementation: string) : Promise> { + try { + return { status: "ok", data: await TAURI_INVOKE("change_keyboard_implementation_setting", { implementation }) }; +} catch (e) { + if(e instanceof Error) throw e; + else return { status: "error", error: e as any }; +} +}, +/** + * Get the current keyboard implementation + */ +async getKeyboardImplementation() : Promise { + return await TAURI_INVOKE("get_keyboard_implementation"); +}, +/** + * Start key recording mode + */ +async startHandyKeysRecording(bindingId: string) : Promise> { + try { + return { status: "ok", data: await TAURI_INVOKE("start_handy_keys_recording", { bindingId }) }; +} catch (e) { + if(e instanceof Error) throw e; + else return { status: "error", error: e as any }; +} +}, +/** + * Stop key recording mode + */ +async stopHandyKeysRecording() : Promise> { + try { + return { status: "ok", data: await TAURI_INVOKE("stop_handy_keys_recording") }; +} catch (e) { + if(e instanceof Error) throw e; + else return { status: "error", error: e as any }; +} +}, async triggerUpdateCheck() : Promise> { try { return { status: "ok", data: await TAURI_INVOKE("trigger_update_check") }; @@ -648,13 +690,22 @@ async isLaptop() : Promise> { /** user-defined types **/ -export type AppSettings = { bindings: Partial<{ [key in string]: ShortcutBinding }>; push_to_talk: boolean; audio_feedback: boolean; audio_feedback_volume?: number; sound_theme?: SoundTheme; start_hidden?: boolean; autostart_enabled?: boolean; update_checks_enabled?: boolean; selected_model?: string; always_on_microphone?: boolean; selected_microphone?: string | null; clamshell_microphone?: string | null; selected_output_device?: string | null; translate_to_english?: boolean; selected_language?: string; overlay_position?: OverlayPosition; debug_mode?: boolean; log_level?: LogLevel; custom_words?: string[]; model_unload_timeout?: ModelUnloadTimeout; word_correction_threshold?: number; history_limit?: number; recording_retention_period?: RecordingRetentionPeriod; paste_method?: PasteMethod; clipboard_handling?: ClipboardHandling; post_process_enabled?: boolean; post_process_provider_id?: string; post_process_providers?: PostProcessProvider[]; post_process_api_keys?: Partial<{ [key in string]: string }>; post_process_models?: Partial<{ [key in string]: string }>; post_process_prompts?: LLMPrompt[]; post_process_selected_prompt_id?: string | null; mute_while_recording?: boolean; append_trailing_space?: boolean; app_language?: string; experimental_enabled?: boolean } +export type AppSettings = { bindings: Partial<{ [key in string]: ShortcutBinding }>; push_to_talk: boolean; audio_feedback: boolean; audio_feedback_volume?: number; sound_theme?: SoundTheme; start_hidden?: boolean; autostart_enabled?: boolean; update_checks_enabled?: boolean; selected_model?: string; always_on_microphone?: boolean; selected_microphone?: string | null; clamshell_microphone?: string | null; selected_output_device?: string | null; translate_to_english?: boolean; selected_language?: string; overlay_position?: OverlayPosition; debug_mode?: boolean; log_level?: LogLevel; custom_words?: string[]; model_unload_timeout?: ModelUnloadTimeout; word_correction_threshold?: number; history_limit?: number; recording_retention_period?: RecordingRetentionPeriod; paste_method?: PasteMethod; clipboard_handling?: ClipboardHandling; post_process_enabled?: boolean; post_process_provider_id?: string; post_process_providers?: PostProcessProvider[]; post_process_api_keys?: Partial<{ [key in string]: string }>; post_process_models?: Partial<{ [key in string]: string }>; post_process_prompts?: LLMPrompt[]; post_process_selected_prompt_id?: string | null; mute_while_recording?: boolean; append_trailing_space?: boolean; app_language?: string; experimental_enabled?: boolean; keyboard_implementation?: KeyboardImplementation } export type AudioDevice = { index: string; name: string; is_default: boolean } export type BindingResponse = { success: boolean; binding: ShortcutBinding | null; error: string | null } export type ClipboardHandling = "dont_modify" | "copy_to_clipboard" export type CustomSounds = { start: boolean; stop: boolean } export type EngineType = "Whisper" | "Parakeet" | "Moonshine" export type HistoryEntry = { id: number; file_name: string; timestamp: number; saved: boolean; title: string; transcription_text: string; post_processed_text: string | null; post_process_prompt: string | null } +/** + * Result of changing keyboard implementation + */ +export type ImplementationChangeResult = { success: boolean; +/** + * List of binding IDs that were reset to defaults due to incompatibility + */ +reset_bindings: string[] } +export type KeyboardImplementation = "tauri" | "handy_keys" export type LLMPrompt = { id: string; name: string; prompt: string } export type LogLevel = "trace" | "debug" | "info" | "warn" | "error" export type ModelInfo = { id: string; name: string; description: string; filename: string; url: string | null; size_mb: number; is_downloaded: boolean; is_downloading: boolean; partial_size: number; is_directory: boolean; engine_type: EngineType; accuracy_score: number; speed_score: number } diff --git a/src/components/settings/HandyShortcut.tsx b/src/components/settings/GlobalShortcutInput.tsx similarity index 91% rename from src/components/settings/HandyShortcut.tsx rename to src/components/settings/GlobalShortcutInput.tsx index bc595f0..4dfd3c4 100644 --- a/src/components/settings/HandyShortcut.tsx +++ b/src/components/settings/GlobalShortcutInput.tsx @@ -1,26 +1,25 @@ import React, { useEffect, useState, useRef } from "react"; import { useTranslation } from "react-i18next"; -import { type } from "@tauri-apps/plugin-os"; import { getKeyName, formatKeyCombination, normalizeKey, - type OSType, } from "../../lib/utils/keyboard"; import { ResetButton } from "../ui/ResetButton"; import { SettingContainer } from "../ui/SettingContainer"; import { useSettings } from "../../hooks/useSettings"; +import { useOsType } from "../../hooks/useOsType"; import { commands } from "@/bindings"; import { toast } from "sonner"; -interface HandyShortcutProps { +interface GlobalShortcutInputProps { descriptionMode?: "inline" | "tooltip"; grouped?: boolean; shortcutId: string; disabled?: boolean; } -export const HandyShortcut: React.FC = ({ +export const GlobalShortcutInput: React.FC = ({ descriptionMode = "tooltip", grouped = false, shortcutId, @@ -35,42 +34,11 @@ export const HandyShortcut: React.FC = ({ null, ); const [originalBinding, setOriginalBinding] = useState(""); - const [osType, setOsType] = useState("unknown"); const shortcutRefs = useRef>(new Map()); + const osType = useOsType(); const bindings = getSetting("bindings") || {}; - // Detect and store OS type - useEffect(() => { - const detectOsType = async () => { - try { - const detectedType = type(); - let normalizedType: OSType; - - switch (detectedType) { - case "macos": - normalizedType = "macos"; - break; - case "windows": - normalizedType = "windows"; - break; - case "linux": - normalizedType = "linux"; - break; - default: - normalizedType = "unknown"; - } - - setOsType(normalizedType); - } catch (error) { - console.error("Error detecting OS type:", error); - setOsType("unknown"); - } - }; - - detectOsType(); - }, []); - useEffect(() => { // Only add event listeners when we're in editing mode if (editingShortcutId === null) return; diff --git a/src/components/settings/HandyKeysShortcutInput.tsx b/src/components/settings/HandyKeysShortcutInput.tsx new file mode 100644 index 0000000..99b29f7 --- /dev/null +++ b/src/components/settings/HandyKeysShortcutInput.tsx @@ -0,0 +1,300 @@ +import React, { useEffect, useState, useRef, useCallback } from "react"; +import { useTranslation } from "react-i18next"; +import { listen } from "@tauri-apps/api/event"; +import { formatKeyCombination } from "../../lib/utils/keyboard"; +import { ResetButton } from "../ui/ResetButton"; +import { SettingContainer } from "../ui/SettingContainer"; +import { useSettings } from "../../hooks/useSettings"; +import { useOsType } from "../../hooks/useOsType"; +import { commands } from "@/bindings"; +import { toast } from "sonner"; + +interface HandyKeysShortcutInputProps { + descriptionMode?: "inline" | "tooltip"; + grouped?: boolean; + shortcutId: string; + disabled?: boolean; +} + +interface HandyKeysEvent { + modifiers: string[]; + key: string | null; + is_key_down: boolean; + hotkey_string: string; +} + +export const HandyKeysShortcutInput: React.FC = ({ + descriptionMode = "tooltip", + grouped = false, + shortcutId, + disabled = false, +}) => { + const { t } = useTranslation(); + const { getSetting, updateBinding, resetBinding, isUpdating, isLoading } = + useSettings(); + const [isRecording, setIsRecording] = useState(false); + const [currentKeys, setCurrentKeys] = useState(""); + const [originalBinding, setOriginalBinding] = useState(""); + const shortcutRef = useRef(null); + const unlistenRef = useRef<(() => void) | null>(null); + // Use a ref to track currentKeys for the event handler (avoids stale closure) + const currentKeysRef = useRef(""); + const osType = useOsType(); + + const bindings = getSetting("bindings") || {}; + + // Handle cancellation + const cancelRecording = useCallback(async () => { + if (!isRecording) return; + + // Stop listening for backend events + if (unlistenRef.current) { + unlistenRef.current(); + unlistenRef.current = null; + } + + // Stop backend recording + await commands.stopHandyKeysRecording().catch(console.error); + + // Restore original binding + if (originalBinding) { + try { + await updateBinding(shortcutId, originalBinding); + } catch (error) { + console.error("Failed to restore original binding:", error); + toast.error(t("settings.general.shortcut.errors.restore")); + } + } + + setIsRecording(false); + setCurrentKeys(""); + currentKeysRef.current = ""; + setOriginalBinding(""); + }, [isRecording, originalBinding, shortcutId, updateBinding, t]); + + // Set up event listener for handy-keys events + useEffect(() => { + if (!isRecording) return; + + let cleanup = false; + + const setupListener = async () => { + // Listen for key events from backend + const unlisten = await listen( + "handy-keys-event", + async (event) => { + if (cleanup) return; + + const { hotkey_string, is_key_down } = event.payload; + + if (is_key_down && hotkey_string) { + // Update both state (for display) and ref (for release handler) + currentKeysRef.current = hotkey_string; + setCurrentKeys(hotkey_string); + } else if (!is_key_down && currentKeysRef.current) { + // Key released - commit the shortcut using the ref value + const keysToCommit = currentKeysRef.current; + try { + await updateBinding(shortcutId, keysToCommit); + } catch (error) { + console.error("Failed to change binding:", error); + toast.error( + t("settings.general.shortcut.errors.set", { + error: String(error), + }), + ); + + // Reset to original binding on error + if (originalBinding) { + try { + await updateBinding(shortcutId, originalBinding); + } catch (resetError) { + console.error("Failed to reset binding:", resetError); + toast.error(t("settings.general.shortcut.errors.reset")); + } + } + } + + // Stop recording + if (unlistenRef.current) { + unlistenRef.current(); + unlistenRef.current = null; + } + await commands.stopHandyKeysRecording().catch(console.error); + setIsRecording(false); + setCurrentKeys(""); + currentKeysRef.current = ""; + setOriginalBinding(""); + } + }, + ); + + unlistenRef.current = unlisten; + }; + + setupListener(); + + // Handle escape key to cancel + const handleKeyDown = (e: KeyboardEvent) => { + if (e.key === "Escape") { + e.preventDefault(); + cancelRecording(); + } + }; + + window.addEventListener("keydown", handleKeyDown); + + return () => { + cleanup = true; + window.removeEventListener("keydown", handleKeyDown); + if (unlistenRef.current) { + unlistenRef.current(); + unlistenRef.current = null; + } + // Stop backend recording on unmount to prevent orphaned recording loops + commands.stopHandyKeysRecording().catch(console.error); + }; + }, [ + isRecording, + shortcutId, + originalBinding, + updateBinding, + cancelRecording, + t, + ]); + + // Handle click outside + useEffect(() => { + if (!isRecording) return; + + const handleClickOutside = (e: MouseEvent) => { + if ( + shortcutRef.current && + !shortcutRef.current.contains(e.target as Node) + ) { + cancelRecording(); + } + }; + + window.addEventListener("click", handleClickOutside); + return () => window.removeEventListener("click", handleClickOutside); + }, [isRecording, cancelRecording]); + + // Start recording a new shortcut + const startRecording = async () => { + if (isRecording) return; + + // Store the original binding to restore if canceled + setOriginalBinding(bindings[shortcutId]?.current_binding || ""); + + // Start backend recording + try { + await commands.startHandyKeysRecording(shortcutId); + setIsRecording(true); + setCurrentKeys(""); + currentKeysRef.current = ""; + } catch (error) { + console.error("Failed to start recording:", error); + toast.error( + t("settings.general.shortcut.errors.set", { error: String(error) }), + ); + } + }; + + // Format the current shortcut keys being recorded + const formatCurrentKeys = (): string => { + if (!currentKeys) return t("settings.general.shortcut.pressKeys"); + return formatKeyCombination(currentKeys, osType); + }; + + // If still loading, show loading state + if (isLoading) { + return ( + +
+ {t("settings.general.shortcut.loading")} +
+
+ ); + } + + // If no bindings are loaded, show empty state + if (Object.keys(bindings).length === 0) { + return ( + +
+ {t("settings.general.shortcut.none")} +
+
+ ); + } + + const binding = bindings[shortcutId]; + if (!binding) { + return ( + +
+ {t("settings.general.shortcut.none")} +
+
+ ); + } + + // Get translated name and description for the binding + const translatedName = t( + `settings.general.shortcut.bindings.${shortcutId}.name`, + binding.name, + ); + const translatedDescription = t( + `settings.general.shortcut.bindings.${shortcutId}.description`, + binding.description, + ); + + return ( + +
+ {isRecording ? ( +
+ {formatCurrentKeys()} +
+ ) : ( +
+ {formatKeyCombination(binding.current_binding, osType)} +
+ )} + resetBinding(shortcutId)} + disabled={isUpdating(`binding_${shortcutId}`)} + /> +
+
+ ); +}; diff --git a/src/components/settings/PasteMethod.tsx b/src/components/settings/PasteMethod.tsx index 383b93a..8621567 100644 --- a/src/components/settings/PasteMethod.tsx +++ b/src/components/settings/PasteMethod.tsx @@ -1,9 +1,9 @@ -import React, { useEffect, useState } from "react"; +import React from "react"; import { useTranslation } from "react-i18next"; -import { type as getOsType } from "@tauri-apps/plugin-os"; import { Dropdown } from "../ui/Dropdown"; import { SettingContainer } from "../ui/SettingContainer"; import { useSettings } from "../../hooks/useSettings"; +import { useOsType } from "../../hooks/useOsType"; import type { PasteMethod } from "@/bindings"; interface PasteMethodProps { @@ -15,7 +15,7 @@ export const PasteMethodSetting: React.FC = React.memo( ({ descriptionMode = "tooltip", grouped = false }) => { const { t } = useTranslation(); const { getSetting, updateSetting, isUpdating } = useSettings(); - const [osType, setOsType] = useState("unknown"); + const osType = useOsType(); const getPasteMethodOptions = (osType: string) => { const mod = osType === "macos" ? "Cmd" : "Ctrl"; @@ -58,10 +58,6 @@ export const PasteMethodSetting: React.FC = React.memo( return options; }; - useEffect(() => { - setOsType(getOsType()); - }, []); - const selectedMethod = (getSetting("paste_method") || "ctrl_v") as PasteMethod; diff --git a/src/components/settings/ShortcutInput.tsx b/src/components/settings/ShortcutInput.tsx new file mode 100644 index 0000000..817900d --- /dev/null +++ b/src/components/settings/ShortcutInput.tsx @@ -0,0 +1,30 @@ +import React from "react"; +import { useSettings } from "../../hooks/useSettings"; +import { GlobalShortcutInput } from "./GlobalShortcutInput"; +import { HandyKeysShortcutInput } from "./HandyKeysShortcutInput"; + +interface ShortcutInputProps { + descriptionMode?: "inline" | "tooltip"; + grouped?: boolean; + shortcutId: string; + disabled?: boolean; +} + +/** + * Wrapper component that selects the appropriate shortcut input implementation + * based on the keyboard_implementation setting. + * + * - "tauri" (default): Uses GlobalShortcutInput with JS keyboard events + * - "handy_keys": Uses HandyKeysShortcutInput with backend key events + */ +export const ShortcutInput: React.FC = (props) => { + const { getSetting } = useSettings(); + const keyboardImplementation = getSetting("keyboard_implementation"); + + // Default to Tauri implementation if not set + if (keyboardImplementation === "handy_keys") { + return ; + } + + return ; +}; diff --git a/src/components/settings/advanced/AdvancedSettings.tsx b/src/components/settings/advanced/AdvancedSettings.tsx index 755247d..a6104aa 100644 --- a/src/components/settings/advanced/AdvancedSettings.tsx +++ b/src/components/settings/advanced/AdvancedSettings.tsx @@ -16,6 +16,7 @@ import { HistoryLimit } from "../HistoryLimit"; import { RecordingRetentionPeriodSelector } from "../RecordingRetentionPeriod"; import { ExperimentalToggle } from "../ExperimentalToggle"; import { useSettings } from "../../../hooks/useSettings"; +import { KeyboardImplementationSelector } from "../debug/KeyboardImplementationSelector"; export const AdvancedSettings: React.FC = () => { const { t } = useTranslation(); @@ -60,6 +61,10 @@ export const AdvancedSettings: React.FC = () => { {experimentalEnabled && ( + )} diff --git a/src/components/settings/debug/DebugSettings.tsx b/src/components/settings/debug/DebugSettings.tsx index 630ef7f..60102b0 100644 --- a/src/components/settings/debug/DebugSettings.tsx +++ b/src/components/settings/debug/DebugSettings.tsx @@ -7,7 +7,7 @@ import { SettingsGroup } from "../../ui/SettingsGroup"; import { AlwaysOnMicrophone } from "../AlwaysOnMicrophone"; import { SoundPicker } from "../SoundPicker"; import { ClamshellMicrophoneSelector } from "../ClamshellMicrophoneSelector"; -import { HandyShortcut } from "../HandyShortcut"; +import { ShortcutInput } from "../ShortcutInput"; import { UpdateChecksToggle } from "../UpdateChecksToggle"; import { useSettings } from "../../../hooks/useSettings"; @@ -31,7 +31,7 @@ export const DebugSettings: React.FC = () => { {/* Cancel shortcut is disabled on Linux due to instability with dynamic shortcut registration */} {!isLinux && ( - = ({ descriptionMode = "tooltip", grouped = false }) => { + const { t } = useTranslation(); + const { getSetting, isUpdating, refreshSettings } = useSettings(); + const currentImplementation = + getSetting("keyboard_implementation") ?? "tauri"; + + const handleSelect = async (value: string) => { + if (value === currentImplementation) return; + + try { + const result = await commands.changeKeyboardImplementationSetting(value); + + if (result.status === "error") { + console.error( + "Failed to update keyboard implementation:", + result.error, + ); + toast.error(String(result.error)); + return; + } + + // If any bindings were reset due to incompatibility, notify the user + if (result.data.reset_bindings.length > 0) { + toast.warning(t("settings.debug.keyboardImplementation.bindingsReset")); + } + + await refreshSettings(); + } catch (error) { + console.error("Failed to update keyboard implementation:", error); + toast.error(String(error)); + } + }; + + return ( + + + + ); +}; diff --git a/src/components/settings/general/GeneralSettings.tsx b/src/components/settings/general/GeneralSettings.tsx index 86508b1..ca0f56e 100644 --- a/src/components/settings/general/GeneralSettings.tsx +++ b/src/components/settings/general/GeneralSettings.tsx @@ -2,7 +2,7 @@ import React from "react"; import { useTranslation } from "react-i18next"; import { MicrophoneSelector } from "../MicrophoneSelector"; import { LanguageSelector } from "../LanguageSelector"; -import { HandyShortcut } from "../HandyShortcut"; +import { ShortcutInput } from "../ShortcutInput"; import { SettingsGroup } from "../../ui/SettingsGroup"; import { OutputDeviceSelector } from "../OutputDeviceSelector"; import { PushToTalk } from "../PushToTalk"; @@ -21,7 +21,7 @@ export const GeneralSettings: React.FC = () => { return (
- + {showLanguageSelector && ( )} diff --git a/src/components/settings/history/HistorySettings.tsx b/src/components/settings/history/HistorySettings.tsx index 4de7170..cf75c17 100644 --- a/src/components/settings/history/HistorySettings.tsx +++ b/src/components/settings/history/HistorySettings.tsx @@ -5,12 +5,10 @@ import { Button } from "../../ui/Button"; import { Copy, Star, Check, Trash2, FolderOpen } from "lucide-react"; import { convertFileSrc } from "@tauri-apps/api/core"; import { listen } from "@tauri-apps/api/event"; -import { platform } from "@tauri-apps/plugin-os"; import { readFile } from "@tauri-apps/plugin-fs"; import { commands, type HistoryEntry } from "@/bindings"; import { formatDateTime } from "@/utils/dateFormat"; - -const IS_LINUX = platform() === "linux"; +import { useOsType } from "@/hooks/useOsType"; interface OpenRecordingsButtonProps { onClick: () => void; @@ -35,6 +33,7 @@ const OpenRecordingsButton: React.FC = ({ export const HistorySettings: React.FC = () => { const { t } = useTranslation(); + const osType = useOsType(); const [historyEntries, setHistoryEntries] = useState([]); const [loading, setLoading] = useState(true); @@ -97,7 +96,7 @@ export const HistorySettings: React.FC = () => { try { const result = await commands.getAudioFilePath(fileName); if (result.status === "ok") { - if (IS_LINUX) { + if (osType === "linux") { const fileData = await readFile(result.data); const blob = new Blob([fileData], { type: "audio/wav" }); diff --git a/src/components/settings/index.ts b/src/components/settings/index.ts index b01bcd2..8301479 100644 --- a/src/components/settings/index.ts +++ b/src/components/settings/index.ts @@ -14,7 +14,9 @@ export { AlwaysOnMicrophone } from "./AlwaysOnMicrophone"; export { PushToTalk } from "./PushToTalk"; export { AudioFeedback } from "./AudioFeedback"; export { ShowOverlay } from "./ShowOverlay"; -export { HandyShortcut } from "./HandyShortcut"; +export { GlobalShortcutInput } from "./GlobalShortcutInput"; +export { HandyKeysShortcutInput } from "./HandyKeysShortcutInput"; +export { ShortcutInput } from "./ShortcutInput"; export { TranslateToEnglish } from "./TranslateToEnglish"; export { CustomWords } from "./CustomWords"; export { PostProcessingToggle } from "./PostProcessingToggle"; diff --git a/src/hooks/useOsType.ts b/src/hooks/useOsType.ts new file mode 100644 index 0000000..cb2cb2f --- /dev/null +++ b/src/hooks/useOsType.ts @@ -0,0 +1,16 @@ +import { type } from "@tauri-apps/plugin-os"; +import { type OSType } from "../lib/utils/keyboard"; + +/** + * Get the current OS type for keyboard handling. + * This is a simple wrapper - type() is synchronous. + */ +export function useOsType(): OSType { + const osType = type(); + // type() returns "macos" | "windows" | "linux" | "ios" | "android" + // OSType expects "macos" | "windows" | "linux" | "unknown" + if (osType === "macos" || osType === "windows" || osType === "linux") { + return osType; + } + return "unknown"; +} diff --git a/src/i18n/locales/en/translation.json b/src/i18n/locales/en/translation.json index 81173f7..059bec6 100644 --- a/src/i18n/locales/en/translation.json +++ b/src/i18n/locales/en/translation.json @@ -365,6 +365,11 @@ "label": "Append Trailing Space", "description": "Add a space after pasted transcription" }, + "keyboardImplementation": { + "title": "Keyboard Implementation", + "description": "Choose the keyboard shortcut backend.", + "bindingsReset": "Keyboard shortcuts were incompatible and reset to defaults" + }, "paths": { "appData": "App Data:", "models": "Models:",