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
This commit is contained in:
parent
246fb4e7da
commit
b733ccc458
21 changed files with 1916 additions and 317 deletions
244
src-tauri/Cargo.lock
generated
244
src-tauri/Cargo.lock
generated
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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(),
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
91
src-tauri/src/shortcut/handler.rs
Normal file
91
src-tauri/src/shortcut/handler.rs
Normal file
|
|
@ -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::<Arc<AudioRecordingManager>>();
|
||||
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::<ManagedToggleState>();
|
||||
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);
|
||||
}
|
||||
}
|
||||
}
|
||||
545
src-tauri/src/shortcut/handy_keys.rs
Normal file
545
src-tauri/src/shortcut/handy_keys.rs
Normal file
|
|
@ -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<Result<(), String>>,
|
||||
},
|
||||
Unregister {
|
||||
binding_id: String,
|
||||
response: Sender<Result<(), String>>,
|
||||
},
|
||||
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<Sender<ManagerCommand>>,
|
||||
/// Handle to the manager thread (wrapped in Mutex for Sync, allows proper join on drop)
|
||||
thread_handle: Mutex<Option<JoinHandle<()>>>,
|
||||
/// Recording listener for UI key capture (only active during recording)
|
||||
recording_listener: Mutex<Option<KeyboardListener>>,
|
||||
/// Flag indicating if we're in recording mode
|
||||
is_recording: AtomicBool,
|
||||
/// The binding ID being recorded (if any)
|
||||
recording_binding_id: Mutex<Option<String>>,
|
||||
/// Flag to stop recording loop
|
||||
recording_running: Arc<AtomicBool>,
|
||||
}
|
||||
|
||||
/// Key event sent to frontend during recording mode
|
||||
#[derive(Debug, Clone, Serialize, Type)]
|
||||
pub struct FrontendKeyEvent {
|
||||
/// Currently pressed modifier keys
|
||||
pub modifiers: Vec<String>,
|
||||
/// The key that was pressed (if any)
|
||||
pub key: Option<String>,
|
||||
/// 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<Self, String> {
|
||||
let (cmd_tx, cmd_rx) = mpsc::channel::<ManagerCommand>();
|
||||
|
||||
// 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<ManagerCommand>, 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<String, HotkeyId> = HashMap::new();
|
||||
let mut hotkey_to_binding: HashMap<HotkeyId, (String, String)> = 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<String, HotkeyId>,
|
||||
hotkey_to_binding: &mut HashMap<HotkeyId, (String, String)>,
|
||||
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<String, HotkeyId>,
|
||||
hotkey_to_binding: &mut HashMap<HotkeyId, (String, String)>,
|
||||
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<AtomicBool>) {
|
||||
while running.load(Ordering::SeqCst) {
|
||||
let event = {
|
||||
let state = match app.try_state::<HandyKeysState>() {
|
||||
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<String> {
|
||||
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::<Hotkey>()
|
||||
.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::<HandyKeysState>() {
|
||||
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::<HandyKeysState>() {
|
||||
let _ = state.unregister(&cancel_binding);
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/// Register a shortcut
|
||||
pub fn register_shortcut(app: &AppHandle, binding: ShortcutBinding) -> Result<(), String> {
|
||||
let state = app
|
||||
.try_state::<HandyKeysState>()
|
||||
.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::<HandyKeysState>()
|
||||
.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::<HandyKeysState>()
|
||||
.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::<HandyKeysState>()
|
||||
.ok_or("HandyKeysState not initialized")?;
|
||||
state.stop_recording()
|
||||
}
|
||||
|
|
@ -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<BindingResponse, String> {
|
||||
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<String>,
|
||||
}
|
||||
|
||||
/// 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<ImplementationChangeResult, String> {
|
||||
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<String> {
|
||||
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<bool, String> {
|
||||
if app.try_state::<handy_keys::HandyKeysState>().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::<Shortcut>() {
|
||||
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::<Arc<AudioRecordingManager>>();
|
||||
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::<ManagedToggleState>();
|
||||
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::<Shortcut>() {
|
||||
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(())
|
||||
}
|
||||
192
src-tauri/src/shortcut/tauri_impl.rs
Normal file
192
src-tauri/src/shortcut/tauri_impl.rs
Normal file
|
|
@ -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<String> = 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::<Shortcut>() {
|
||||
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::<Shortcut>() {
|
||||
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);
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
|
@ -276,6 +276,48 @@ async changeUpdateChecksSetting(enabled: boolean) : Promise<Result<null, string>
|
|||
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<Result<ImplementationChangeResult, string>> {
|
||||
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<string> {
|
||||
return await TAURI_INVOKE("get_keyboard_implementation");
|
||||
},
|
||||
/**
|
||||
* Start key recording mode
|
||||
*/
|
||||
async startHandyKeysRecording(bindingId: string) : Promise<Result<null, string>> {
|
||||
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<Result<null, string>> {
|
||||
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<Result<null, string>> {
|
||||
try {
|
||||
return { status: "ok", data: await TAURI_INVOKE("trigger_update_check") };
|
||||
|
|
@ -648,13 +690,22 @@ async isLaptop() : Promise<Result<boolean, string>> {
|
|||
|
||||
/** 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 }
|
||||
|
|
|
|||
|
|
@ -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<HandyShortcutProps> = ({
|
||||
export const GlobalShortcutInput: React.FC<GlobalShortcutInputProps> = ({
|
||||
descriptionMode = "tooltip",
|
||||
grouped = false,
|
||||
shortcutId,
|
||||
|
|
@ -35,42 +34,11 @@ export const HandyShortcut: React.FC<HandyShortcutProps> = ({
|
|||
null,
|
||||
);
|
||||
const [originalBinding, setOriginalBinding] = useState<string>("");
|
||||
const [osType, setOsType] = useState<OSType>("unknown");
|
||||
const shortcutRefs = useRef<Map<string, HTMLDivElement | null>>(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;
|
||||
300
src/components/settings/HandyKeysShortcutInput.tsx
Normal file
300
src/components/settings/HandyKeysShortcutInput.tsx
Normal file
|
|
@ -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<HandyKeysShortcutInputProps> = ({
|
||||
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<string>("");
|
||||
const [originalBinding, setOriginalBinding] = useState<string>("");
|
||||
const shortcutRef = useRef<HTMLDivElement | null>(null);
|
||||
const unlistenRef = useRef<(() => void) | null>(null);
|
||||
// Use a ref to track currentKeys for the event handler (avoids stale closure)
|
||||
const currentKeysRef = useRef<string>("");
|
||||
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<HandyKeysEvent>(
|
||||
"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 (
|
||||
<SettingContainer
|
||||
title={t("settings.general.shortcut.title")}
|
||||
description={t("settings.general.shortcut.description")}
|
||||
descriptionMode={descriptionMode}
|
||||
grouped={grouped}
|
||||
>
|
||||
<div className="text-sm text-mid-gray">
|
||||
{t("settings.general.shortcut.loading")}
|
||||
</div>
|
||||
</SettingContainer>
|
||||
);
|
||||
}
|
||||
|
||||
// If no bindings are loaded, show empty state
|
||||
if (Object.keys(bindings).length === 0) {
|
||||
return (
|
||||
<SettingContainer
|
||||
title={t("settings.general.shortcut.title")}
|
||||
description={t("settings.general.shortcut.description")}
|
||||
descriptionMode={descriptionMode}
|
||||
grouped={grouped}
|
||||
>
|
||||
<div className="text-sm text-mid-gray">
|
||||
{t("settings.general.shortcut.none")}
|
||||
</div>
|
||||
</SettingContainer>
|
||||
);
|
||||
}
|
||||
|
||||
const binding = bindings[shortcutId];
|
||||
if (!binding) {
|
||||
return (
|
||||
<SettingContainer
|
||||
title={t("settings.general.shortcut.title")}
|
||||
description={t("settings.general.shortcut.notFound")}
|
||||
descriptionMode={descriptionMode}
|
||||
grouped={grouped}
|
||||
>
|
||||
<div className="text-sm text-mid-gray">
|
||||
{t("settings.general.shortcut.none")}
|
||||
</div>
|
||||
</SettingContainer>
|
||||
);
|
||||
}
|
||||
|
||||
// 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 (
|
||||
<SettingContainer
|
||||
title={translatedName}
|
||||
description={translatedDescription}
|
||||
descriptionMode={descriptionMode}
|
||||
grouped={grouped}
|
||||
disabled={disabled}
|
||||
layout="horizontal"
|
||||
>
|
||||
<div className="flex items-center space-x-1">
|
||||
{isRecording ? (
|
||||
<div
|
||||
ref={shortcutRef}
|
||||
className="px-2 py-1 text-sm font-semibold border border-logo-primary bg-logo-primary/30 rounded min-w-[120px] text-center"
|
||||
>
|
||||
{formatCurrentKeys()}
|
||||
</div>
|
||||
) : (
|
||||
<div
|
||||
className="px-2 py-1 text-sm font-semibold bg-mid-gray/10 border border-mid-gray/80 hover:bg-logo-primary/10 rounded cursor-pointer hover:border-logo-primary"
|
||||
onClick={startRecording}
|
||||
>
|
||||
{formatKeyCombination(binding.current_binding, osType)}
|
||||
</div>
|
||||
)}
|
||||
<ResetButton
|
||||
onClick={() => resetBinding(shortcutId)}
|
||||
disabled={isUpdating(`binding_${shortcutId}`)}
|
||||
/>
|
||||
</div>
|
||||
</SettingContainer>
|
||||
);
|
||||
};
|
||||
|
|
@ -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<PasteMethodProps> = React.memo(
|
|||
({ descriptionMode = "tooltip", grouped = false }) => {
|
||||
const { t } = useTranslation();
|
||||
const { getSetting, updateSetting, isUpdating } = useSettings();
|
||||
const [osType, setOsType] = useState<string>("unknown");
|
||||
const osType = useOsType();
|
||||
|
||||
const getPasteMethodOptions = (osType: string) => {
|
||||
const mod = osType === "macos" ? "Cmd" : "Ctrl";
|
||||
|
|
@ -58,10 +58,6 @@ export const PasteMethodSetting: React.FC<PasteMethodProps> = React.memo(
|
|||
return options;
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
setOsType(getOsType());
|
||||
}, []);
|
||||
|
||||
const selectedMethod = (getSetting("paste_method") ||
|
||||
"ctrl_v") as PasteMethod;
|
||||
|
||||
|
|
|
|||
30
src/components/settings/ShortcutInput.tsx
Normal file
30
src/components/settings/ShortcutInput.tsx
Normal file
|
|
@ -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<ShortcutInputProps> = (props) => {
|
||||
const { getSetting } = useSettings();
|
||||
const keyboardImplementation = getSetting("keyboard_implementation");
|
||||
|
||||
// Default to Tauri implementation if not set
|
||||
if (keyboardImplementation === "handy_keys") {
|
||||
return <HandyKeysShortcutInput {...props} />;
|
||||
}
|
||||
|
||||
return <GlobalShortcutInput {...props} />;
|
||||
};
|
||||
|
|
@ -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 && (
|
||||
<SettingsGroup title={t("settings.advanced.groups.experimental")}>
|
||||
<PostProcessingToggle descriptionMode="tooltip" grouped={true} />
|
||||
<KeyboardImplementationSelector
|
||||
descriptionMode="tooltip"
|
||||
grouped={true}
|
||||
/>
|
||||
</SettingsGroup>
|
||||
)}
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -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 = () => {
|
|||
<ClamshellMicrophoneSelector descriptionMode="tooltip" grouped={true} />
|
||||
{/* Cancel shortcut is disabled on Linux due to instability with dynamic shortcut registration */}
|
||||
{!isLinux && (
|
||||
<HandyShortcut
|
||||
<ShortcutInput
|
||||
shortcutId="cancel"
|
||||
grouped={true}
|
||||
disabled={pushToTalk}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,70 @@
|
|||
import React from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { SettingContainer } from "../../ui/SettingContainer";
|
||||
import { Dropdown, type DropdownOption } from "../../ui/Dropdown";
|
||||
import { useSettings } from "../../../hooks/useSettings";
|
||||
import { commands } from "@/bindings";
|
||||
import { toast } from "sonner";
|
||||
|
||||
const KEYBOARD_IMPLEMENTATION_OPTIONS: DropdownOption[] = [
|
||||
{ value: "tauri", label: "Tauri Global Shortcut" },
|
||||
{ value: "handy_keys", label: "Handy Keys" },
|
||||
];
|
||||
|
||||
interface KeyboardImplementationSelectorProps {
|
||||
descriptionMode?: "tooltip" | "inline";
|
||||
grouped?: boolean;
|
||||
}
|
||||
|
||||
export const KeyboardImplementationSelector: React.FC<
|
||||
KeyboardImplementationSelectorProps
|
||||
> = ({ 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 (
|
||||
<SettingContainer
|
||||
title={t("settings.debug.keyboardImplementation.title")}
|
||||
description={t("settings.debug.keyboardImplementation.description")}
|
||||
descriptionMode={descriptionMode}
|
||||
grouped={grouped}
|
||||
layout="horizontal"
|
||||
>
|
||||
<Dropdown
|
||||
options={KEYBOARD_IMPLEMENTATION_OPTIONS}
|
||||
selectedValue={currentImplementation}
|
||||
onSelect={handleSelect}
|
||||
disabled={isUpdating("keyboard_implementation")}
|
||||
/>
|
||||
</SettingContainer>
|
||||
);
|
||||
};
|
||||
|
|
@ -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 (
|
||||
<div className="max-w-3xl w-full mx-auto space-y-6">
|
||||
<SettingsGroup title={t("settings.general.title")}>
|
||||
<HandyShortcut shortcutId="transcribe" grouped={true} />
|
||||
<ShortcutInput shortcutId="transcribe" grouped={true} />
|
||||
{showLanguageSelector && (
|
||||
<LanguageSelector descriptionMode="tooltip" grouped={true} />
|
||||
)}
|
||||
|
|
|
|||
|
|
@ -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<OpenRecordingsButtonProps> = ({
|
|||
|
||||
export const HistorySettings: React.FC = () => {
|
||||
const { t } = useTranslation();
|
||||
const osType = useOsType();
|
||||
const [historyEntries, setHistoryEntries] = useState<HistoryEntry[]>([]);
|
||||
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" });
|
||||
|
||||
|
|
|
|||
|
|
@ -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";
|
||||
|
|
|
|||
16
src/hooks/useOsType.ts
Normal file
16
src/hooks/useOsType.ts
Normal file
|
|
@ -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";
|
||||
}
|
||||
|
|
@ -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:",
|
||||
|
|
|
|||
Loading…
Reference in a new issue