diff --git a/openless-all/app/package.json b/openless-all/app/package.json index 60d0cefb..3c045e32 100644 --- a/openless-all/app/package.json +++ b/openless-all/app/package.json @@ -18,6 +18,7 @@ "copy:android-scaffolding": "node scripts/copy-android-scaffolding.mjs", "check:macos-capsule-spaces": "node scripts/macos-capsule-spaces-contract.test.mjs", "check:hotkey-injection": "node scripts/check-hotkey-injection.mjs", + "check:hotkey-side-modifiers": "npx tsx src/lib/hotkeySideModifiers.test.ts", "check:windows-startup-lifecycle": "node scripts/windows-startup-lifecycle-contract.test.mjs", "check:android-updater-pubkey": "node scripts/check-android-updater-pubkey.mjs" }, diff --git a/openless-all/app/src-tauri/Cargo.lock b/openless-all/app/src-tauri/Cargo.lock index a72a299f..a6a2cea9 100644 --- a/openless-all/app/src-tauri/Cargo.lock +++ b/openless-all/app/src-tauri/Cargo.lock @@ -464,6 +464,18 @@ dependencies = [ "serde_core", ] +[[package]] +name = "bitvec" +version = "1.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ddcec3d12c579d40898fe0a9a358a803c23e9c52ca3c425707f81c9436211837" +dependencies = [ + "funty", + "radium", + "tap", + "wyz", +] + [[package]] name = "block" version = "0.1.6" @@ -1630,6 +1642,19 @@ version = "3.3.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "dea2df4cf52843e0452895c455a1a2cfbb842a1e7329671acf418fdc53ed4c59" +[[package]] +name = "evdev" +version = "0.12.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ab6055a93a963297befb0f4f6e18f314aec9767a4bbe88b151126df2433610a7" +dependencies = [ + "bitvec", + "cfg-if", + "libc", + "nix 0.23.2", + "thiserror 1.0.69", +] + [[package]] name = "event-listener" version = "5.4.1" @@ -1707,7 +1732,7 @@ version = "0.3.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "38e2275cc4e4fc009b0669731a1e5ab7ebf11f469eaede2bab9309a5b4d6057f" dependencies = [ - "memoffset", + "memoffset 0.9.1", "rustc_version", ] @@ -1839,6 +1864,12 @@ version = "0.4.7" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7ab85b9b05e3978cc9a9cf8fea7f01b494e1a09ed3037e16ba39edc7a29eb61a" +[[package]] +name = "funty" +version = "2.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e6d5a32815ae3f33302d95fdcb2ce17862f8c65363dcfd29360480ba1001fc9c" + [[package]] name = "futures-channel" version = "0.3.32" @@ -3136,6 +3167,15 @@ dependencies = [ "libc", ] +[[package]] +name = "memoffset" +version = "0.6.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5aa361d4faea93603064a027415f07bd8e1d5c88c9fbf68bf56a285428fd79ce" +dependencies = [ + "autocfg", +] + [[package]] name = "memoffset" version = "0.9.1" @@ -3350,6 +3390,19 @@ version = "1.0.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "650eef8c711430f1a879fdd01d4745a7deea475becfb90269c06775983bbf086" +[[package]] +name = "nix" +version = "0.23.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f3790c00a0150112de0f4cd161e3d7fc4b2d8a5542ffc35f099a2562aecb35c" +dependencies = [ + "bitflags 1.3.2", + "cc", + "cfg-if", + "libc", + "memoffset 0.6.5", +] + [[package]] name = "nix" version = "0.29.0" @@ -3360,7 +3413,7 @@ dependencies = [ "cfg-if", "cfg_aliases", "libc", - "memoffset", + "memoffset 0.9.1", ] [[package]] @@ -3901,6 +3954,7 @@ dependencies = [ "dbus", "enigo", "env_logger", + "evdev", "ferrous-opencc", "foundry-local-sdk", "futures-util", @@ -4497,6 +4551,12 @@ version = "6.0.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f8dcc9c7d52a811697d2151c701e0d08956f92b0e24136cf4cf27b57a6a0d9bf" +[[package]] +name = "radium" +version = "0.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dc33ff2d4973d518d823d61aa239014831e521c75da58e3df4840d3f47749d09" + [[package]] name = "rancor" version = "0.1.1" @@ -5664,6 +5724,12 @@ dependencies = [ "syn 2.0.117", ] +[[package]] +name = "tap" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "55937e1799185b12863d447f42597ed69d9928686b8d88a1df17376a097d8369" + [[package]] name = "tar" version = "0.4.46" @@ -6556,7 +6622,7 @@ version = "1.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f2f6fb2847f6742cd76af783a2a2c49e9375d0a111c7bef6f71cd9e738c72d6e" dependencies = [ - "memoffset", + "memoffset 0.9.1", "tempfile", "windows-sys 0.61.2", ] @@ -8033,6 +8099,15 @@ dependencies = [ "x11-dl", ] +[[package]] +name = "wyz" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "05f360fc0b24296329c78fda852a1e9ae82de9cf7b27dae4b7f62f118f77b9ed" +dependencies = [ + "tap", +] + [[package]] name = "x11" version = "2.21.0" @@ -8165,7 +8240,7 @@ dependencies = [ "futures-sink", "futures-util", "hex", - "nix", + "nix 0.29.0", "ordered-stream", "rand 0.8.6", "serde", diff --git a/openless-all/app/src-tauri/Cargo.toml b/openless-all/app/src-tauri/Cargo.toml index ba7fa19b..a343f236 100644 --- a/openless-all/app/src-tauri/Cargo.toml +++ b/openless-all/app/src-tauri/Cargo.toml @@ -40,6 +40,7 @@ tar = "0.4" tokio = { version = "1", features = ["full"] } tokio-tungstenite = { version = "0.24", features = ["rustls-tls-webpki-roots"] } futures-util = "0.3" +futures = "0.3" reqwest = { version = "0.12", default-features = false, features = ["json", "multipart", "rustls-tls", "stream", "system-proxy"] } zip = "2" thiserror = "1" @@ -89,6 +90,7 @@ features = ["windows-native"] [target.'cfg(target_os = "linux")'.dependencies] dbus = "0.9" +evdev = "0.12" [target.'cfg(all(unix, not(target_os = "macos"), not(target_os = "android")))'.dependencies.keyring] version = "3.6.3" default-features = false diff --git a/openless-all/app/src-tauri/backend-tests/tests/backend_rust.rs b/openless-all/app/src-tauri/backend-tests/tests/backend_rust.rs index a3760ccb..60154db3 100644 --- a/openless-all/app/src-tauri/backend-tests/tests/backend_rust.rs +++ b/openless-all/app/src-tauri/backend-tests/tests/backend_rust.rs @@ -36,6 +36,12 @@ mod asr { #[path = "../../src/coordinator_state.rs"] mod coordinator_state; +#[path = "../../src/global_hotkey_runtime.rs"] +mod global_hotkey_runtime; +#[path = "../../src/combo_hotkey.rs"] +mod combo_hotkey; +#[path = "../../src/side_aware_combo.rs"] +mod side_aware_combo; #[path = "../../src/hotkey.rs"] mod hotkey; #[cfg(not(target_os = "macos"))] diff --git a/openless-all/app/src-tauri/src/combo_hotkey.rs b/openless-all/app/src-tauri/src/combo_hotkey.rs index c0152e99..cc3c3977 100644 --- a/openless-all/app/src-tauri/src/combo_hotkey.rs +++ b/openless-all/app/src-tauri/src/combo_hotkey.rs @@ -19,7 +19,7 @@ use crate::global_hotkey_runtime::{GlobalHotkeyRuntime, RegisteredHotkey}; use crate::shortcut_binding::{parse_global_hotkey, ShortcutBindingError}; use crate::types::ShortcutBinding; -#[derive(Debug, Clone, Copy)] +#[derive(Debug, Clone, Copy, PartialEq, Eq)] pub enum ComboHotkeyEvent { /// 用户按下了配置的组合键。 Pressed, diff --git a/openless-all/app/src-tauri/src/commands/hotkeys.rs b/openless-all/app/src-tauri/src/commands/hotkeys.rs index b22d875e..ddb26d4f 100644 --- a/openless-all/app/src-tauri/src/commands/hotkeys.rs +++ b/openless-all/app/src-tauri/src/commands/hotkeys.rs @@ -40,6 +40,7 @@ pub fn set_translation_hotkey( binding: ShortcutBinding, ) -> Result<(), String> { crate::shortcut_binding::validate_binding(&binding).map_err(|e| e.to_string())?; + crate::shortcut_binding::reject_side_specific_non_dictation(&binding)?; let previous = coord.prefs().get(); reject_dictation_translation_hotkey_overlap(&previous.dictation_hotkey, &binding)?; if let Some(qa_hotkey) = previous.qa_hotkey.as_ref() { @@ -76,6 +77,7 @@ pub fn set_switch_style_hotkey( ) -> Result<(), String> { if let Some(binding) = binding.as_ref() { crate::shortcut_binding::validate_binding(binding).map_err(|e| e.to_string())?; + crate::shortcut_binding::reject_side_specific_non_dictation(binding)?; reject_modifier_only_action_shortcut(binding)?; } let mut prefs = coord.prefs().get(); @@ -106,6 +108,7 @@ pub fn set_open_app_hotkey( ) -> Result<(), String> { if let Some(binding) = binding.as_ref() { crate::shortcut_binding::validate_binding(binding).map_err(|e| e.to_string())?; + crate::shortcut_binding::reject_side_specific_non_dictation(binding)?; reject_modifier_only_action_shortcut(binding)?; } let mut prefs = coord.prefs().get(); @@ -229,6 +232,7 @@ fn reject_hotkey_overlap( } pub(crate) fn reject_hotkey_collisions(prefs: &UserPreferences) -> Result<(), String> { + reject_non_dictation_side_specific_shortcuts(prefs)?; // 停用(None)的 action 快捷键不参与任何冲突检测。 let switch_style = prefs.switch_style_hotkey.as_ref(); let open_app = prefs.open_app_hotkey.as_ref(); @@ -274,6 +278,25 @@ pub(crate) fn reject_hotkey_collisions(prefs: &UserPreferences) -> Result<(), St Ok(()) } +pub(crate) fn reject_non_dictation_side_specific_shortcuts( + prefs: &UserPreferences, +) -> Result<(), String> { + crate::shortcut_binding::reject_side_specific_non_dictation(&prefs.translation_hotkey)?; + if let Some(binding) = prefs.qa_hotkey.as_ref() { + crate::shortcut_binding::reject_side_specific_non_dictation(binding)?; + } + if let Some(binding) = prefs.switch_style_hotkey.as_ref() { + crate::shortcut_binding::reject_side_specific_non_dictation(binding)?; + } + if let Some(binding) = prefs.open_app_hotkey.as_ref() { + crate::shortcut_binding::reject_side_specific_non_dictation(binding)?; + } + if let Some(binding) = prefs.coding_agent_voice_hotkey.as_ref() { + crate::shortcut_binding::reject_side_specific_non_dictation(binding)?; + } + Ok(()) +} + pub(crate) fn reject_dictation_translation_hotkey_overlap( dictation: &ShortcutBinding, translation: &ShortcutBinding, @@ -405,21 +428,7 @@ fn reject_less_computer_open_app_hotkey_overlap( } fn shortcut_bindings_overlap(left: &ShortcutBinding, right: &ShortcutBinding) -> bool { - let left_legacy = crate::shortcut_binding::legacy_modifier_trigger(left); - let right_legacy = crate::shortcut_binding::legacy_modifier_trigger(right); - match (left_legacy, right_legacy) { - (Some(left), Some(right)) => left == right, - (Some(_), None) | (None, Some(_)) => false, - (None, None) => { - let Ok(left) = crate::shortcut_binding::parse_global_hotkey(left) else { - return false; - }; - let Ok(right) = crate::shortcut_binding::parse_global_hotkey(right) else { - return false; - }; - left == right - } - } + crate::shortcut_binding::bindings_overlap(left, right) } #[cfg(test)] @@ -433,9 +442,6 @@ mod tests { } } - /// 锁定碰撞矩阵:每个动作键与 Less Computer 键相同都必须被 reject_hotkey_collisions - /// 拒绝。5 个快捷 setter(set_dictation/translation/switch_style/open_app/qa_hotkey) - /// 此前漏校验 coding_agent_voice_hotkey,已接入对应的 less_computer 校验。 #[test] fn each_action_hotkey_collides_with_less_computer() { let lc = key("LeftControl"); @@ -474,4 +480,61 @@ mod tests { // 复位后再次全不同 → 通过。 assert!(reject_hotkey_collisions(&prefs).is_ok()); } + + #[test] + fn side_specific_dictation_overlaps_generic_qa_hotkey() { + let mut prefs = UserPreferences { + dictation_hotkey: ShortcutBinding { + primary: "D".into(), + modifiers: vec!["cmd-left".into()], + }, + qa_hotkey: Some(ShortcutBinding { + primary: "D".into(), + modifiers: vec!["cmd".into()], + }), + ..Default::default() + }; + #[cfg(target_os = "windows")] + { + assert!(reject_hotkey_collisions(&prefs).is_ok()); + prefs.qa_hotkey = Some(ShortcutBinding { + primary: "D".into(), + modifiers: vec!["super".into()], + }); + assert!(reject_hotkey_collisions(&prefs).is_err()); + } + #[cfg(not(target_os = "windows"))] + { + assert!(reject_hotkey_collisions(&prefs).is_err()); + } + prefs.qa_hotkey = Some(ShortcutBinding { + primary: "D".into(), + modifiers: vec!["cmd".into(), "shift".into()], + }); + assert!(reject_hotkey_collisions(&prefs).is_ok()); + } + + #[test] + fn rejects_side_specific_qa_hotkey_on_save() { + let prefs = UserPreferences { + qa_hotkey: Some(ShortcutBinding { + primary: "D".into(), + modifiers: vec!["cmd-left".into()], + }), + ..Default::default() + }; + assert!(reject_non_dictation_side_specific_shortcuts(&prefs).is_err()); + } + + #[test] + fn accepts_side_specific_dictation_hotkey_on_save() { + let prefs = UserPreferences { + dictation_hotkey: ShortcutBinding { + primary: "D".into(), + modifiers: vec!["cmd-left".into()], + }, + ..Default::default() + }; + assert!(reject_non_dictation_side_specific_shortcuts(&prefs).is_ok()); + } } diff --git a/openless-all/app/src-tauri/src/commands/mod.rs b/openless-all/app/src-tauri/src/commands/mod.rs index 2c128ba5..1bfbdfb7 100644 --- a/openless-all/app/src-tauri/src/commands/mod.rs +++ b/openless-all/app/src-tauri/src/commands/mod.rs @@ -267,6 +267,7 @@ mod tests { switch_style_refreshes: Mutex, open_app_refreshes: Mutex, coding_agent_refreshes: Mutex, + mouse_dictation_refreshes: Mutex, } fn snapshot() -> CredentialsSnapshot { @@ -636,6 +637,10 @@ mod tests { fn refresh_coding_agent_hotkey(&self) { *self.coding_agent_refreshes.lock().unwrap() += 1; } + + fn refresh_mouse_dictation(&self) { + *self.mouse_dictation_refreshes.lock().unwrap() += 1; + } } #[test] diff --git a/openless-all/app/src-tauri/src/commands/qa.rs b/openless-all/app/src-tauri/src/commands/qa.rs index acc64238..e4558b64 100644 --- a/openless-all/app/src-tauri/src/commands/qa.rs +++ b/openless-all/app/src-tauri/src/commands/qa.rs @@ -15,6 +15,7 @@ pub fn set_qa_hotkey( ) -> Result<(), String> { if let Some(binding) = binding.as_ref() { crate::shortcut_binding::validate_binding(binding).map_err(|e| e.to_string())?; + crate::shortcut_binding::reject_side_specific_non_dictation(binding)?; if binding.modifiers.is_empty() && binding.primary.eq_ignore_ascii_case("shift") { return Err("Shift 单键目前只能用于翻译快捷键".into()); } diff --git a/openless-all/app/src-tauri/src/commands/settings.rs b/openless-all/app/src-tauri/src/commands/settings.rs index 03d96fb0..b320085a 100644 --- a/openless-all/app/src-tauri/src/commands/settings.rs +++ b/openless-all/app/src-tauri/src/commands/settings.rs @@ -17,6 +17,7 @@ pub(crate) trait SettingsWriter { fn refresh_dictation_hotkey(&self); fn refresh_qa_hotkey(&self); fn refresh_combo_hotkey(&self); + fn refresh_mouse_dictation(&self); fn refresh_translation_hotkey(&self); fn refresh_switch_style_hotkey(&self); fn refresh_open_app_hotkey(&self); @@ -48,6 +49,10 @@ impl SettingsWriter for Coordinator { self.update_combo_hotkey_binding(); } + fn refresh_mouse_dictation(&self) { + self.update_mouse_dictation_binding(); + } + fn refresh_translation_hotkey(&self) { self.update_translation_hotkey_binding(); } @@ -90,6 +95,10 @@ impl SettingsWriter for Arc { (**self).refresh_combo_hotkey(); } + fn refresh_mouse_dictation(&self) { + (**self).refresh_mouse_dictation(); + } + fn refresh_translation_hotkey(&self) { (**self).refresh_translation_hotkey(); } @@ -121,6 +130,9 @@ pub(crate) fn persist_settings( let translation_changed = previous.translation_hotkey != prefs.translation_hotkey; let switch_style_changed = previous.switch_style_hotkey != prefs.switch_style_hotkey; let open_app_changed = previous.open_app_hotkey != prefs.open_app_hotkey; + let mouse_dictation_changed = previous.mouse_middle_button_dictation + != prefs.mouse_middle_button_dictation + || previous.mouse_side_button_dictation != prefs.mouse_side_button_dictation; let coding_agent_changed = previous.coding_agent_enabled != prefs.coding_agent_enabled || previous.coding_agent_voice_hotkey != prefs.coding_agent_voice_hotkey; let active_asr_provider_changed = previous.active_asr_provider != prefs.active_asr_provider; @@ -151,6 +163,9 @@ pub(crate) fn persist_settings( if dictation_shortcut_changed { coord.refresh_combo_hotkey(); } + if mouse_dictation_changed { + coord.refresh_mouse_dictation(); + } if qa_changed { coord.refresh_qa_hotkey(); } diff --git a/openless-all/app/src-tauri/src/coordinator.rs b/openless-all/app/src-tauri/src/coordinator.rs index 9218eef7..30488477 100644 --- a/openless-all/app/src-tauri/src/coordinator.rs +++ b/openless-all/app/src-tauri/src/coordinator.rs @@ -282,6 +282,11 @@ struct Inner { /// 自定义组合键监听器(global-hotkey crate)。当 `prefs.hotkey.trigger == Custom` 时 /// 代替 modifier-only 的 hotkey monitor。`None` 表示不使用自定义组合键或还没成功安装。 combo_hotkey: Mutex>, + mouse_dictation: Mutex>, + #[cfg(target_os = "linux")] + linux_evdev: Mutex>, + hold_sources: crate::hold_source_tracker::HoldSourceTracker, + side_aware_combo: Mutex>, translation_hotkey: Mutex>, switch_style_hotkey: Mutex>, open_app_hotkey: Mutex>, @@ -403,6 +408,11 @@ impl Coordinator { session_cooldown_until: Mutex::new(None), shortcut_recording_active: AtomicBool::new(false), combo_hotkey: Mutex::new(None), + mouse_dictation: Mutex::new(None), + #[cfg(target_os = "linux")] + linux_evdev: Mutex::new(None), + hold_sources: crate::hold_source_tracker::HoldSourceTracker::new(), + side_aware_combo: Mutex::new(None), translation_hotkey: Mutex::new(None), switch_style_hotkey: Mutex::new(None), open_app_hotkey: Mutex::new(None), @@ -495,6 +505,11 @@ impl Coordinator { session_cooldown_until: Mutex::new(None), shortcut_recording_active: AtomicBool::new(false), combo_hotkey: Mutex::new(None), + mouse_dictation: Mutex::new(None), + #[cfg(target_os = "linux")] + linux_evdev: Mutex::new(None), + hold_sources: crate::hold_source_tracker::HoldSourceTracker::new(), + side_aware_combo: Mutex::new(None), translation_hotkey: Mutex::new(None), switch_style_hotkey: Mutex::new(None), open_app_hotkey: Mutex::new(None), @@ -766,6 +781,18 @@ impl Coordinator { .ok(); } + pub fn start_mouse_dictation_listener(&self) { + let inner = Arc::clone(&self.inner); + std::thread::Builder::new() + .name("openless-mouse-dictation-supervisor".into()) + .spawn(move || mouse_dictation_supervisor_loop(inner)) + .ok(); + } + + pub fn update_mouse_dictation_binding(&self) { + update_mouse_dictation_binding_now(&self.inner); + } + pub fn stop_combo_hotkey_listener(&self) { take_combo_hotkey_on_main_thread(&self.inner); } @@ -810,21 +837,52 @@ impl Coordinator { pub fn update_combo_hotkey_binding(&self) { let prefs = self.inner.prefs.get(); if crate::shortcut_binding::legacy_modifier_trigger(&prefs.dictation_hotkey).is_some() { - // 修饰键单键由 HotkeyMonitor 处理,组合键 monitor 要释放。 take_combo_hotkey_on_main_thread(&self.inner); + self.inner.side_aware_combo.lock().take(); + #[cfg(target_os = "linux")] + refresh_linux_evdev_monitor(&self.inner); log::info!("[coord] combo hotkey 已关闭(modifier-only)"); return; } let binding = prefs.dictation_hotkey.clone(); if is_unconfigured_shortcut(&binding) { - // Custom 但没录到有效主键:清掉旧 monitor,避免旧快捷键继续生效。 take_combo_hotkey_on_main_thread(&self.inner); + self.inner.side_aware_combo.lock().take(); + #[cfg(target_os = "linux")] + refresh_linux_evdev_monitor(&self.inner); log::info!("[coord] combo hotkey 已关闭(无绑定)"); return; - }; + } + + if crate::shortcut_binding::binding_requires_side_aware_hook(&binding) { + take_combo_hotkey_on_main_thread(&self.inner); + self.inner.side_aware_combo.lock().take(); + let (tx, rx) = mpsc::channel::(); + match crate::side_aware_combo::SideAwareComboMonitor::start(binding, tx) { + Ok(monitor) => { + *self.inner.side_aware_combo.lock() = Some(monitor); + let bridge_inner = Arc::clone(&self.inner); + std::thread::Builder::new() + .name("openless-side-combo-bridge".into()) + .spawn(move || combo_hotkey_bridge_loop(bridge_inner, rx)) + .ok(); + log::info!("[coord] side-aware combo hotkey listener installed (via update)"); + } + Err(e) => { + log::warn!("[coord] update side-aware combo binding 失败: {e}"); + } + } + #[cfg(target_os = "linux")] + refresh_linux_evdev_monitor(&self.inner); + return; + } + + self.inner.side_aware_combo.lock().take(); let app = self.inner.app.lock().clone(); let Some(app) = app else { log::warn!("[coord] update combo hotkey binding: AppHandle 未 bind,跳过"); + #[cfg(target_os = "linux")] + refresh_linux_evdev_monitor(&self.inner); return; }; let inner_clone = Arc::clone(&self.inner); @@ -856,6 +914,8 @@ impl Coordinator { } } }); + #[cfg(target_os = "linux")] + refresh_linux_evdev_monitor(&self.inner); } /// 用户在设置里改了 QA 组合键时调用。先持久化(由 prefs.set 完成), @@ -1090,6 +1150,7 @@ impl Coordinator { } pub fn update_hotkey_binding(&self) { + clear_active_hold_sources_on_hotkey_rebind(&self.inner); let prefs = self.inner.prefs.get(); let dictation_trigger = crate::shortcut_binding::legacy_modifier_trigger(&prefs.dictation_hotkey); @@ -1180,7 +1241,15 @@ impl Coordinator { } pub fn hotkey_capability(&self) -> HotkeyCapability { - HotkeyMonitor::capability() + let mut cap = HotkeyMonitor::capability(); + #[cfg(all(not(mobile), target_os = "linux"))] + if let Some(msg) = crate::linux_evdev_input::status_message() { + cap.status_hint = Some(match cap.status_hint { + Some(base) if !base.is_empty() => format!("{base}\n{msg}"), + _ => msg, + }); + } + cap } pub async fn start_dictation(&self) -> Result<(), String> { @@ -2028,7 +2097,9 @@ fn resolve_ark_endpoint_with_policy( #[cfg(test)] mod tests { use super::dictation::abort_recording_with_error; + use super::dictation::{handle_pressed_edge, handle_released_edge}; use super::*; + use crate::hold_source_tracker::TriggerSource; use crate::types::{HotkeyMode, HotkeyTrigger}; use once_cell::sync::Lazy; @@ -2129,6 +2200,164 @@ mod tests { std::env::remove_var("OPENLESS_HOTKEY_INJECTION_DRY_RUN"); } + #[tokio::test] + async fn hold_mode_ends_only_after_last_source_released() { + let _guard = ENV_LOCK.lock().await; + std::env::set_var("OPENLESS_HOTKEY_INJECTION_DRY_RUN", "1"); + + let coordinator = Coordinator::new(); + { + let mut prefs = coordinator.inner.prefs.get(); + prefs.hotkey.mode = HotkeyMode::Hold; + coordinator.inner.prefs.set(prefs).unwrap(); + } + + handle_pressed_edge(&coordinator.inner, TriggerSource::KeyboardDictation).await; + assert_eq!( + coordinator.inner.state.lock().phase, + SessionPhase::Listening + ); + assert_eq!(coordinator.inner.hold_sources.active_count(), 1); + + handle_pressed_edge(&coordinator.inner, TriggerSource::MouseMiddle).await; + assert_eq!(coordinator.inner.hold_sources.active_count(), 2); + assert_eq!( + coordinator.inner.state.lock().phase, + SessionPhase::Listening + ); + + handle_released_edge(&coordinator.inner, TriggerSource::KeyboardDictation).await; + assert_eq!(coordinator.inner.hold_sources.active_count(), 1); + assert_eq!( + coordinator.inner.state.lock().phase, + SessionPhase::Listening + ); + + handle_released_edge(&coordinator.inner, TriggerSource::MouseMiddle).await; + assert_eq!(coordinator.inner.hold_sources.active_count(), 0); + assert_eq!(coordinator.inner.state.lock().phase, SessionPhase::Idle); + + std::env::remove_var("OPENLESS_HOTKEY_INJECTION_DRY_RUN"); + } + + #[tokio::test] + async fn hold_mode_mouse_disable_while_holding_ends_session() { + let _guard = ENV_LOCK.lock().await; + std::env::set_var("OPENLESS_HOTKEY_INJECTION_DRY_RUN", "1"); + + let coordinator = Coordinator::new(); + { + let mut prefs = coordinator.inner.prefs.get(); + prefs.hotkey.mode = HotkeyMode::Hold; + coordinator.inner.prefs.set(prefs).unwrap(); + } + + handle_pressed_edge(&coordinator.inner, TriggerSource::MouseMiddle).await; + assert_eq!( + coordinator.inner.state.lock().phase, + SessionPhase::Listening + ); + assert_eq!(coordinator.inner.hold_sources.active_count(), 1); + + sync_release_mouse_hold_sources(&coordinator.inner); + + assert_eq!(coordinator.inner.hold_sources.active_count(), 0); + assert_eq!(coordinator.inner.state.lock().phase, SessionPhase::Idle); + + std::env::remove_var("OPENLESS_HOTKEY_INJECTION_DRY_RUN"); + } + + #[tokio::test] + async fn hold_mode_concurrent_press_starts_once() { + let _guard = ENV_LOCK.lock().await; + std::env::set_var("OPENLESS_HOTKEY_INJECTION_DRY_RUN", "1"); + + let coordinator = Coordinator::new(); + { + let mut prefs = coordinator.inner.prefs.get(); + prefs.hotkey.mode = HotkeyMode::Hold; + coordinator.inner.prefs.set(prefs).unwrap(); + } + + handle_pressed_edge(&coordinator.inner, TriggerSource::KeyboardDictation).await; + handle_pressed_edge(&coordinator.inner, TriggerSource::MouseMiddle).await; + + assert_eq!( + coordinator.inner.state.lock().phase, + SessionPhase::Listening + ); + assert_eq!(coordinator.inner.hold_sources.active_count(), 2); + + std::env::remove_var("OPENLESS_HOTKEY_INJECTION_DRY_RUN"); + } + + #[tokio::test] + async fn hold_mode_sync_release_mouse_only_keeps_keyboard_hold() { + let _guard = ENV_LOCK.lock().await; + std::env::set_var("OPENLESS_HOTKEY_INJECTION_DRY_RUN", "1"); + + let coordinator = Coordinator::new(); + { + let mut prefs = coordinator.inner.prefs.get(); + prefs.hotkey.mode = HotkeyMode::Hold; + coordinator.inner.prefs.set(prefs).unwrap(); + } + + handle_pressed_edge(&coordinator.inner, TriggerSource::KeyboardDictation).await; + handle_pressed_edge(&coordinator.inner, TriggerSource::MouseMiddle).await; + assert_eq!(coordinator.inner.hold_sources.active_count(), 2); + assert_eq!( + coordinator.inner.state.lock().phase, + SessionPhase::Listening + ); + + sync_release_mouse_hold_sources(&coordinator.inner); + + assert_eq!(coordinator.inner.hold_sources.active_count(), 1); + assert_eq!( + coordinator.inner.state.lock().phase, + SessionPhase::Listening + ); + + handle_released_edge(&coordinator.inner, TriggerSource::KeyboardDictation).await; + assert_eq!(coordinator.inner.hold_sources.active_count(), 0); + assert_eq!(coordinator.inner.state.lock().phase, SessionPhase::Idle); + + std::env::remove_var("OPENLESS_HOTKEY_INJECTION_DRY_RUN"); + } + + #[tokio::test] + async fn hold_mode_hotkey_rebind_while_holding_clears_sources_and_ends_session() { + let _guard = ENV_LOCK.lock().await; + std::env::set_var("OPENLESS_HOTKEY_INJECTION_DRY_RUN", "1"); + + let coordinator = Coordinator::new(); + { + let mut prefs = coordinator.inner.prefs.get(); + prefs.hotkey.mode = HotkeyMode::Hold; + coordinator.inner.prefs.set(prefs).unwrap(); + } + + handle_pressed_edge(&coordinator.inner, TriggerSource::MouseMiddle).await; + assert_eq!( + coordinator.inner.state.lock().phase, + SessionPhase::Listening + ); + assert_eq!(coordinator.inner.hold_sources.active_count(), 1); + + { + let mut prefs = coordinator.inner.prefs.get(); + prefs.hotkey.mode = HotkeyMode::Toggle; + coordinator.inner.prefs.set(prefs).unwrap(); + } + coordinator.update_hotkey_binding(); + + assert_eq!(coordinator.inner.hold_sources.active_count(), 0); + assert_eq!(coordinator.inner.state.lock().phase, SessionPhase::Idle); + + std::env::remove_var("OPENLESS_HOTKEY_INJECTION_DRY_RUN"); + } + #[tokio::test] async fn begin_session_dry_run_enters_listening_and_clears_stale_edges() { let _guard = ENV_LOCK.lock().await; @@ -2591,7 +2820,7 @@ mod tests { state.session_id = session_id(41); } - handle_pressed_edge(&coordinator.inner).await; + handle_pressed_edge(&coordinator.inner, TriggerSource::KeyboardDictation).await; let state = coordinator.inner.state.lock(); assert_eq!(state.phase, SessionPhase::Inserting); @@ -2619,7 +2848,7 @@ mod tests { .hotkey_trigger_held .store(true, Ordering::SeqCst); - handle_pressed_edge(&coordinator.inner).await; + handle_pressed_edge(&coordinator.inner, TriggerSource::KeyboardDictation).await; assert_eq!( coordinator.inner.state.lock().phase, diff --git a/openless-all/app/src-tauri/src/coordinator/dictation.rs b/openless-all/app/src-tauri/src/coordinator/dictation.rs index 0638a080..b637722d 100644 --- a/openless-all/app/src-tauri/src/coordinator/dictation.rs +++ b/openless-all/app/src-tauri/src/coordinator/dictation.rs @@ -8,6 +8,7 @@ use crate::types::HotkeyMode; use super::qa::handle_qa_option_edge; use super::resources::*; use super::*; +use crate::hold_source_tracker::TriggerSource; /// 同一个 hotkey 边沿之间的最小间隔。低于此阈值的连按整体作为误触丢弃 —— /// 避免微动开关回弹 / 用户手抖双击造成的空转写报错和 ASR session 抢资源。 @@ -458,10 +459,23 @@ fn default_done_message(status: InsertStatus, polish_failed: bool) -> Option) { - let was_held = inner.hotkey_trigger_held.swap(true, Ordering::SeqCst); - if !was_held { - // 防抖:相邻 < HOTKEY_DEBOUNCE 的边沿直接丢弃,记到 log 方便排查。 +pub(super) async fn handle_pressed_edge( + inner: &Arc, + source: TriggerSource, +) { + let mode = inner.prefs.get().hotkey.mode; + if mode == HotkeyMode::Hold { + let Some(prev_count) = inner.hold_sources.press(source) else { + return; + }; + if prev_count != 0 { + return; + } + } else if inner.hotkey_trigger_held.swap(true, Ordering::SeqCst) { + return; + } + + // 防抖:相邻 < HOTKEY_DEBOUNCE 的边沿直接丢弃,记到 log 方便排查。 // 与 `hotkey_trigger_held` 互补:held 防 press-without-release,本检查防 // press-release-press 三连过快。每个有效边沿都会更新时间戳。 let now = std::time::Instant::now(); @@ -478,6 +492,11 @@ pub(super) async fn handle_pressed_edge(inner: &Arc) { "[coord] hotkey pressed edge debounced (< {} ms since last dispatch)", HOTKEY_DEBOUNCE.as_millis() ); + if mode == HotkeyMode::Hold { + inner.hold_sources.release(source); + } else { + inner.hotkey_trigger_held.store(false, Ordering::SeqCst); + } return; } @@ -494,7 +513,6 @@ pub(super) async fn handle_pressed_edge(inner: &Arc) { } else { handle_pressed(inner).await; } - } } /// 「排队接力」放行窗口(ms)。识别中按下热键想录下一条时,那个 Pressed 在处理期间就被缓进 @@ -560,20 +578,30 @@ pub(super) async fn handle_pressed(inner: &Arc) { } } -pub(super) async fn handle_released_edge(inner: &Arc) { - let was_held = inner.hotkey_trigger_held.swap(false, Ordering::SeqCst); - if was_held { - // QA 浮窗可见时,Option 行为是 press-toggle(不分 hold/release),release 边沿忽略。 - // 与 handle_pressed_edge 的路由对称:dictation session 在跑时 Pressed 已经被路由到 - // dictation,那 Released 必须也路由到 dictation —— 否则 Hold 模式松开热键时 - // end_session 不会触发,dictation 永远停不下来。审计 3.3.1。 - let dictation_active = !matches!(inner.state.lock().phase, SessionPhase::Idle); - let panel_visible = inner.qa_state.lock().panel_visible; - if panel_visible && !dictation_active { +pub(super) async fn handle_released_edge( + inner: &Arc, + source: TriggerSource, +) { + let mode = inner.prefs.get().hotkey.mode; + if mode == HotkeyMode::Hold { + let remaining = inner.hold_sources.release(source); + if remaining != 0 { return; } - handle_released(inner).await; + } else if !inner.hotkey_trigger_held.swap(false, Ordering::SeqCst) { + return; + } + + // QA 浮窗可见时,Option 行为是 press-toggle(不分 hold/release),release 边沿忽略。 + // 与 handle_pressed_edge 的路由对称:dictation session 在跑时 Pressed 已经被路由到 + // dictation,那 Released 必须也路由到 dictation —— 否则 Hold 模式松开热键时 + // end_session 不会触发,dictation 永远停不下来。审计 3.3.1。 + let dictation_active = !matches!(inner.state.lock().phase, SessionPhase::Idle); + let panel_visible = inner.qa_state.lock().panel_visible; + if panel_visible && !dictation_active { + return; } + handle_released(inner).await; } pub(super) async fn handle_released(inner: &Arc) { diff --git a/openless-all/app/src-tauri/src/coordinator/hotkey_loops.rs b/openless-all/app/src-tauri/src/coordinator/hotkey_loops.rs index 7b964f0a..814f0277 100644 --- a/openless-all/app/src-tauri/src/coordinator/hotkey_loops.rs +++ b/openless-all/app/src-tauri/src/coordinator/hotkey_loops.rs @@ -7,6 +7,22 @@ use super::*; +use crate::hold_source_tracker::TriggerSource; +use crate::types::HotkeyMode; + +/// 在可能已有 tokio runtime 的线程上安全地执行异步块。 +/// 测试环境中(tokio::test)调用 `async_runtime::block_on` 会 panic, +/// 而 `tokio::task::block_in_place` 又要求 multi-thread runtime。 +/// 使用 `futures::executor::block_on` 作为通用回退,它不依赖 tokio。 +fn block_on_async(f: F) +where + F: std::future::Future, +{ + // 直接优先使用 futures executor,因为它不依赖任何 tokio runtime 状态, + // 可以安全地在已有 runtime 的线程上调用。 + futures::executor::block_on(f); +} + // ─────────────────────────── hotkey bridging ─────────────────────────── pub(super) fn hotkey_supervisor_loop(inner: Arc) { @@ -488,21 +504,50 @@ pub(super) fn combo_hotkey_supervisor_loop(inner: Arc) { // 读当前 prefs let prefs = inner.prefs.get(); if crate::shortcut_binding::legacy_modifier_trigger(&prefs.dictation_hotkey).is_some() { - // 不是 Custom → 卸载后退出守护 take_combo_hotkey_on_main_thread(&inner); - // 对齐主 supervisor 的 exit-on-success:装/卸交给 update_combo_hotkey_binding 主动路径,issue #470 + inner.side_aware_combo.lock().take(); return; } let binding = prefs.dictation_hotkey.clone(); if is_unconfigured_shortcut(&binding) { take_combo_hotkey_on_main_thread(&inner); - // 对齐主 supervisor 的 exit-on-success:装/卸交给 update_combo_hotkey_binding 主动路径,issue #470 + inner.side_aware_combo.lock().take(); return; } + if crate::shortcut_binding::binding_requires_side_aware_hook(&binding) { + take_combo_hotkey_on_main_thread(&inner); + if inner.side_aware_combo.lock().is_some() { + return; + } + let (tx, rx) = mpsc::channel::(); + match crate::side_aware_combo::SideAwareComboMonitor::start(binding, tx) { + Ok(monitor) => { + *inner.side_aware_combo.lock() = Some(monitor); + let inner_clone = Arc::clone(&inner); + std::thread::Builder::new() + .name("openless-side-combo-bridge".into()) + .spawn(move || combo_hotkey_bridge_loop(inner_clone, rx)) + .ok(); + #[cfg(target_os = "linux")] + refresh_linux_evdev_monitor(&inner); + return; + } + Err(e) => { + attempts += 1; + if attempts <= 3 || attempts % 10 == 0 { + log::warn!("[coord] side-aware combo 第 {attempts} 次注册失败: {e}; 3s 后重试"); + } + std::thread::sleep(std::time::Duration::from_secs(3)); + continue; + } + } + } + + inner.side_aware_combo.lock().take(); + if inner.combo_hotkey.lock().is_some() { - // 对齐主 supervisor 的 exit-on-success:装/卸交给 update_combo_hotkey_binding 主动路径,issue #470 return; } @@ -550,7 +595,10 @@ pub(super) fn combo_hotkey_supervisor_loop(inner: Arc) { .name("openless-combo-hotkey-bridge".into()) .spawn(move || combo_hotkey_bridge_loop(inner_clone, rx)) .ok(); + #[cfg(target_os = "linux")] + sync_custom_dictation_to_plugin(&inner); attempts = 0; + return; } Err(e) => { attempts += 1; @@ -574,12 +622,20 @@ pub(super) fn combo_hotkey_bridge_loop(inner: Arc, rx: mpsc::Receiver { async_runtime::block_on(async { - handle_pressed_edge(&inner_cloned).await; + handle_pressed_edge( + &inner_cloned, + TriggerSource::KeyboardDictation, + ) + .await; }); } ComboHotkeyEvent::Released => { async_runtime::block_on(async { - handle_released_edge(&inner_cloned).await; + handle_released_edge( + &inner_cloned, + TriggerSource::KeyboardDictation, + ) + .await; }); } } @@ -999,12 +1055,20 @@ pub(super) fn hotkey_bridge_loop(inner: Arc, rx: mpsc::Receiver { async_runtime::block_on(async { - handle_pressed_edge(&inner_cloned).await; + handle_pressed_edge( + &inner_cloned, + TriggerSource::KeyboardDictation, + ) + .await; }); } HotkeyEvent::Released => { async_runtime::block_on(async { - handle_released_edge(&inner_cloned).await; + handle_released_edge( + &inner_cloned, + TriggerSource::KeyboardDictation, + ) + .await; }); } HotkeyEvent::Cancelled => { @@ -1030,6 +1094,7 @@ pub(super) fn hotkey_bridge_loop(inner: Arc, rx: mpsc::Receiver) { inner.hotkey_trigger_held.store(false, Ordering::SeqCst); + inner.hold_sources.reset(); if let Some(monitor) = inner.hotkey.lock().as_ref() { monitor.reset_held_state(); } @@ -1132,11 +1197,11 @@ pub(super) async fn handle_window_hotkey_event( log::info!( "[window-hotkey] pressed trigger={trigger:?} code={code} repeat={repeat}" ); - handle_pressed_edge(inner).await; + handle_pressed_edge(inner, TriggerSource::KeyboardDictation).await; } "keyup" => { log::info!("[window-hotkey] released trigger={trigger:?} code={code}"); - handle_released_edge(inner).await; + handle_released_edge(inner, TriggerSource::KeyboardDictation).await; } _ => {} } @@ -1160,6 +1225,9 @@ pub(super) fn window_key_matches_trigger(trigger: crate::types::HotkeyTrigger, k } HotkeyTrigger::LeftOption => (key == "Alt" || key == "AltGraph") && code == "AltLeft", HotkeyTrigger::RightCommand => key == "Meta" && code == "MetaRight", + HotkeyTrigger::LeftCommand => key == "Meta" && code == "MetaLeft", + HotkeyTrigger::LeftShift => key == "Shift" && code == "ShiftLeft", + HotkeyTrigger::RightShift => key == "Shift" && code == "ShiftRight", HotkeyTrigger::Fn => key == "Control" && code == "ControlRight", // MediaPlayPause 走 WH_KEYBOARD_LL,不走 window hotkey fallback HotkeyTrigger::MediaPlayPause => false, @@ -1167,3 +1235,205 @@ pub(super) fn window_key_matches_trigger(trigger: crate::types::HotkeyTrigger, k HotkeyTrigger::Custom => false, } } + +pub(super) fn sync_release_mouse_hold_sources(inner: &Arc) { + let mode = inner.prefs.get().hotkey.mode; + if mode != HotkeyMode::Hold { + return; + } + inner.hold_sources.release(TriggerSource::MouseMiddle); + inner.hold_sources.release(TriggerSource::MouseSide); + if inner.hold_sources.active_count() != 0 { + return; + } + let phase = inner.state.lock().phase; + let inner_clone = Arc::clone(inner); + block_on_async(async { + match phase { + SessionPhase::Listening => { + let _ = end_session(&inner_clone).await; + } + SessionPhase::Starting => { + request_stop_during_starting( + &inner_clone, + "mouse dictation disabled while held", + ); + } + _ => {} + } + }); +} + +/// Clears all active Hold sources when dictation hotkey/mode is rebound mid-hold. +/// Prefs may already reflect the new mode, so this must not gate on `HotkeyMode::Hold`. +pub(super) fn clear_active_hold_sources_on_hotkey_rebind(inner: &Arc) { + if inner.hold_sources.active_count() == 0 { + return; + } + inner.hold_sources.reset(); + inner.hotkey_trigger_held.store(false, Ordering::SeqCst); + let phase = inner.state.lock().phase; + let inner_clone = Arc::clone(inner); + block_on_async(async { + match phase { + SessionPhase::Listening => { + let _ = end_session(&inner_clone).await; + } + SessionPhase::Starting => { + request_stop_during_starting( + &inner_clone, + "hotkey binding changed while hold sources active", + ); + } + _ => {} + } + }); +} + +pub(super) fn mouse_dictation_bridge_loop( + inner: Arc, + rx: mpsc::Receiver<(HotkeyEvent, TriggerSource)>, +) { + while let Ok((evt, source)) = rx.recv() { + if inner.shortcut_recording_active.load(Ordering::SeqCst) { + continue; + } + let inner_cloned = Arc::clone(&inner); + match evt { + HotkeyEvent::Pressed => { + async_runtime::block_on(async { + handle_pressed_edge(&inner_cloned, source).await; + }); + } + HotkeyEvent::Released => { + async_runtime::block_on(async { + handle_released_edge(&inner_cloned, source).await; + }); + } + _ => {} + } + } +} + +pub(super) fn mouse_dictation_supervisor_loop(inner: Arc) { + loop { + if inner.shutdown.load(Ordering::SeqCst) { + return; + } + let prefs = inner.prefs.get(); + let config = crate::mouse_dictation::MouseDictationConfig { + middle_enabled: prefs.mouse_middle_button_dictation, + side_enabled: prefs.mouse_side_button_dictation, + }; + let needs_side_combo = crate::shortcut_binding::binding_requires_side_aware_hook( + &prefs.dictation_hotkey, + ); + let needs_mouse = config.middle_enabled || config.side_enabled; + + #[cfg(target_os = "linux")] + if needs_side_combo && !needs_mouse { + refresh_linux_evdev_monitor(&inner); + if inner.linux_evdev.lock().is_some() { + return; + } + log::warn!("[coord] linux evdev monitor for side combo failed; retry in 3s"); + std::thread::sleep(std::time::Duration::from_secs(3)); + continue; + } + + #[cfg(not(target_os = "linux"))] + if !needs_mouse { + sync_release_mouse_hold_sources(&inner); + inner.mouse_dictation.lock().take(); + return; + } + + #[cfg(target_os = "linux")] + if !needs_mouse && !needs_side_combo { + sync_release_mouse_hold_sources(&inner); + inner.mouse_dictation.lock().take(); + inner.linux_evdev.lock().take(); + return; + } + + if inner.mouse_dictation.lock().is_some() { + crate::mouse_dictation::MouseDictationMonitor::update_config(config); + #[cfg(target_os = "linux")] + refresh_linux_evdev_monitor(&inner); + return; + } + + let (tx, rx) = mpsc::channel::<(HotkeyEvent, TriggerSource)>(); + match start_mouse_dictation_monitor(config, tx.clone()) { + Ok(monitor) => { + *inner.mouse_dictation.lock() = Some(monitor); + let inner_clone = Arc::clone(&inner); + std::thread::Builder::new() + .name("openless-mouse-dictation-bridge".into()) + .spawn(move || mouse_dictation_bridge_loop(inner_clone, rx)) + .ok(); + #[cfg(target_os = "linux")] + refresh_linux_evdev_monitor(&inner); + return; + } + Err(err) => { + log::warn!("[coord] mouse dictation monitor failed: {err}; retry in 3s"); + std::thread::sleep(std::time::Duration::from_secs(3)); + } + } + } +} + +fn start_mouse_dictation_monitor( + config: crate::mouse_dictation::MouseDictationConfig, + tx: mpsc::Sender<(HotkeyEvent, TriggerSource)>, +) -> Result { + #[cfg(target_os = "windows")] + crate::mouse_dictation::platform::ensure_hook_thread()?; + crate::mouse_dictation::MouseDictationMonitor::start(config, tx) +} + +#[cfg(target_os = "linux")] +pub(super) fn refresh_linux_evdev_monitor(inner: &Arc) { + sync_release_mouse_hold_sources(inner); + inner.linux_evdev.lock().take(); + try_start_linux_evdev_monitor(inner); +} + +#[cfg(target_os = "linux")] +pub(super) fn try_start_linux_evdev_monitor(inner: &Arc) { + let config = crate::linux_evdev_input::config_from_prefs(&inner.prefs.get()); + if !crate::linux_evdev_input::monitor_needed(&config) { + crate::linux_evdev_input::set_status_message(None); + return; + } + let (tx, rx) = mpsc::channel::<(HotkeyEvent, TriggerSource)>(); + match crate::linux_evdev_input::LinuxEvdevMonitor::start(config, tx.clone()) { + Ok(monitor) => { + *inner.linux_evdev.lock() = Some(monitor); + let inner_clone = Arc::clone(inner); + std::thread::Builder::new() + .name("openless-linux-evdev-mouse-bridge".into()) + .spawn(move || mouse_dictation_bridge_loop(inner_clone, rx)) + .ok(); + } + Err(err) => { + log::warn!("[coord] linux evdev monitor failed: {err}"); + crate::linux_evdev_input::set_status_message(Some(format!( + "{err} 鼠标/侧别组合键需读取 /dev/input/event*;若无权限请将用户加入 input 组(sudo usermod -aG input $USER)后重新登录。" + ))); + } + } +} + +pub(super) fn update_mouse_dictation_binding_now(inner: &Arc) { + sync_release_mouse_hold_sources(inner); + inner.mouse_dictation.lock().take(); + #[cfg(target_os = "linux")] + refresh_linux_evdev_monitor(inner); + let inner_clone = Arc::clone(inner); + std::thread::Builder::new() + .name("openless-mouse-dictation-supervisor".into()) + .spawn(move || mouse_dictation_supervisor_loop(inner_clone)) + .ok(); +} diff --git a/openless-all/app/src-tauri/src/hold_source_tracker.rs b/openless-all/app/src-tauri/src/hold_source_tracker.rs new file mode 100644 index 00000000..7a8f1440 --- /dev/null +++ b/openless-all/app/src-tauri/src/hold_source_tracker.rs @@ -0,0 +1,125 @@ +//! Tracks active hold-to-talk trigger sources (keyboard / mouse middle / mouse side). +//! +//! Hold mode begins when the first source is pressed and ends when the last source is released. + +use std::sync::atomic::{AtomicBool, AtomicU32, Ordering}; + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum TriggerSource { + KeyboardDictation, + MouseMiddle, + MouseSide, +} + +pub struct HoldSourceTracker { + keyboard: AtomicBool, + mouse_middle: AtomicBool, + mouse_side: AtomicBool, + active_count: AtomicU32, +} + +impl HoldSourceTracker { + pub fn new() -> Self { + Self { + keyboard: AtomicBool::new(false), + mouse_middle: AtomicBool::new(false), + mouse_side: AtomicBool::new(false), + active_count: AtomicU32::new(0), + } + } + + pub fn reset(&self) { + self.keyboard.store(false, Ordering::SeqCst); + self.mouse_middle.store(false, Ordering::SeqCst); + self.mouse_side.store(false, Ordering::SeqCst); + self.active_count.store(0, Ordering::SeqCst); + } + + /// Returns the active count **before** increment on a fresh press edge. + /// Duplicate press edges for the same source return `None`. + pub fn press(&self, source: TriggerSource) -> Option { + let slot = self.slot(source); + if slot.swap(true, Ordering::SeqCst) { + return None; + } + Some(self.active_count.fetch_add(1, Ordering::SeqCst)) + } + + /// Returns the remaining active source count after release. + pub fn release(&self, source: TriggerSource) -> u32 { + let slot = self.slot(source); + if !slot.swap(false, Ordering::SeqCst) { + return self.active_count.load(Ordering::SeqCst); + } + self.active_count.fetch_sub(1, Ordering::SeqCst); + self.active_count.load(Ordering::SeqCst) + } + + pub fn active_count(&self) -> u32 { + self.active_count.load(Ordering::SeqCst) + } + + fn slot(&self, source: TriggerSource) -> &AtomicBool { + match source { + TriggerSource::KeyboardDictation => &self.keyboard, + TriggerSource::MouseMiddle => &self.mouse_middle, + TriggerSource::MouseSide => &self.mouse_side, + } + } +} + +impl Default for HoldSourceTracker { + fn default() -> Self { + Self::new() + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn hold_source_count_tracks_multiple_sources() { + let tracker = HoldSourceTracker::new(); + assert_eq!(tracker.press(TriggerSource::KeyboardDictation), Some(0)); + assert_eq!(tracker.active_count(), 1); + assert_eq!(tracker.press(TriggerSource::KeyboardDictation), None); + assert_eq!(tracker.active_count(), 1); + + assert_eq!(tracker.press(TriggerSource::MouseMiddle), Some(1)); + assert_eq!(tracker.active_count(), 2); + + assert_eq!( + tracker.release(TriggerSource::KeyboardDictation), + 1 + ); + assert_eq!(tracker.release(TriggerSource::MouseMiddle), 0); + } + + #[test] + fn last_release_returns_zero() { + let tracker = HoldSourceTracker::new(); + assert_eq!(tracker.press(TriggerSource::KeyboardDictation), Some(0)); + assert_eq!(tracker.release(TriggerSource::KeyboardDictation), 0); + assert_eq!(tracker.active_count(), 0); + } + + #[test] + fn duplicate_release_is_no_op() { + let tracker = HoldSourceTracker::new(); + assert_eq!(tracker.press(TriggerSource::MouseSide), Some(0)); + assert_eq!(tracker.release(TriggerSource::MouseSide), 0); + assert_eq!(tracker.release(TriggerSource::MouseSide), 0); + assert_eq!(tracker.active_count(), 0); + } + + #[test] + fn keyboard_and_mouse_hold_tracks_independently() { + let tracker = HoldSourceTracker::new(); + assert_eq!(tracker.press(TriggerSource::KeyboardDictation), Some(0)); + assert_eq!(tracker.press(TriggerSource::MouseMiddle), Some(1)); + assert_eq!(tracker.active_count(), 2); + assert_eq!(tracker.release(TriggerSource::KeyboardDictation), 1); + assert_eq!(tracker.release(TriggerSource::MouseMiddle), 0); + } +} diff --git a/openless-all/app/src-tauri/src/hotkey.rs b/openless-all/app/src-tauri/src/hotkey.rs index 7fa0eaea..f77159bd 100644 --- a/openless-all/app/src-tauri/src/hotkey.rs +++ b/openless-all/app/src-tauri/src/hotkey.rs @@ -398,11 +398,15 @@ mod platform { const TAP_OPTION_DEFAULT: CgEventTapOptions = 0; const KEY_DOWN: CgEventType = 10; + const KEY_UP: CgEventType = 11; const FLAGS_CHANGED: CgEventType = 12; + const OTHER_MOUSE_DOWN: CgEventType = 25; + const OTHER_MOUSE_UP: CgEventType = 26; const TAP_DISABLED_BY_TIMEOUT: CgEventType = 0xFFFF_FFFE; const TAP_DISABLED_BY_USER_INPUT: CgEventType = 0xFFFF_FFFF; const KEYBOARD_EVENT_KEYCODE: CgEventField = 9; + const MOUSE_EVENT_BUTTON_NUMBER: CgEventField = 3; const FLAG_MASK_SHIFT: CgEventFlags = 0x0002_0000; const FLAG_MASK_CONTROL: CgEventFlags = 0x0004_0000; @@ -464,7 +468,11 @@ mod platform { tx: Sender, status_tx: StartupTx>, ) { - let mask: CgEventMask = (1u64 << FLAGS_CHANGED) | (1u64 << KEY_DOWN); + let mask: CgEventMask = (1u64 << FLAGS_CHANGED) + | (1u64 << KEY_DOWN) + | (1u64 << KEY_UP) + | (1u64 << OTHER_MOUSE_DOWN) + | (1u64 << OTHER_MOUSE_UP); let handles = Arc::new(MacShutdownHandles { tap: std::sync::Mutex::new(None), runloop: std::sync::Mutex::new(None), @@ -531,7 +539,21 @@ mod platform { return event; } FLAGS_CHANGED => handle_flags_changed(ctx, event), - KEY_DOWN => handle_key_down(ctx, event), + KEY_DOWN => { + handle_key_down(ctx, event); + let keycode = unsafe { CGEventGetIntegerValueField(event, KEYBOARD_EVENT_KEYCODE) }; + crate::side_aware_combo::platform::dispatch_keycode(keycode, false, 0, true); + } + KEY_UP => { + let keycode = unsafe { CGEventGetIntegerValueField(event, KEYBOARD_EVENT_KEYCODE) }; + crate::side_aware_combo::platform::dispatch_keycode(keycode, false, 0, false); + } + OTHER_MOUSE_DOWN | OTHER_MOUSE_UP => { + let button = + unsafe { CGEventGetIntegerValueField(event, MOUSE_EVENT_BUTTON_NUMBER) }; + let pressed = event_type == OTHER_MOUSE_DOWN; + crate::mouse_dictation::platform::dispatch_button_number(button, pressed); + } _ => {} } event @@ -555,6 +577,7 @@ mod platform { } let keycode = unsafe { CGEventGetIntegerValueField(event, KEYBOARD_EVENT_KEYCODE) }; + crate::side_aware_combo::platform::dispatch_keycode(keycode, true, flags, false); handle_optional_modifier_trigger( ctx, keycode, @@ -631,6 +654,9 @@ mod platform { HotkeyTrigger::LeftOption => 58, HotkeyTrigger::RightOption | HotkeyTrigger::RightAlt => 61, HotkeyTrigger::RightCommand => 54, + HotkeyTrigger::LeftCommand => 55, + HotkeyTrigger::LeftShift => 56, + HotkeyTrigger::RightShift => 60, HotkeyTrigger::Fn => 63, HotkeyTrigger::MediaPlayPause => 0, HotkeyTrigger::Custom => unreachable!("custom combo hotkeys use ComboHotkeyMonitor"), @@ -640,7 +666,8 @@ mod platform { fn trigger_to_flag_mask(trigger: HotkeyTrigger) -> CgEventFlags { match trigger { HotkeyTrigger::LeftControl | HotkeyTrigger::RightControl => FLAG_MASK_CONTROL, - HotkeyTrigger::RightCommand => FLAG_MASK_COMMAND, + HotkeyTrigger::LeftCommand | HotkeyTrigger::RightCommand => FLAG_MASK_COMMAND, + HotkeyTrigger::LeftShift | HotkeyTrigger::RightShift => FLAG_MASK_SHIFT, HotkeyTrigger::LeftOption | HotkeyTrigger::RightOption | HotkeyTrigger::RightAlt => { FLAG_MASK_ALTERNATE } @@ -779,6 +806,7 @@ mod platform { const VK_LMENU: u32 = 0xA4; const VK_RMENU: u32 = 0xA5; const VK_RWIN: u32 = 0x5C; + const VK_LWIN: u32 = 0x5B; const VK_MEDIA_PLAY_PAUSE: u32 = 0xB3; const LLKHF_INJECTED: u32 = 0x0000_0010; const ACCEPT_INJECTED_ENV: &str = "OPENLESS_ACCEPT_SYNTHETIC_HOTKEY_EVENTS"; @@ -932,6 +960,9 @@ mod platform { return false; } + let pressed = matches!(message, WM_KEYDOWN | WM_SYSKEYDOWN); + crate::side_aware_combo::platform::dispatch_vk(vk_code, pressed); + // Shift(任一侧)= 翻译模式修饰键。在录音过程中任意时刻按下都生效。详见 issue #4。 if matches!(vk_code, VK_SHIFT | VK_LSHIFT | VK_RSHIFT) { match message { @@ -1036,6 +1067,9 @@ mod platform { HotkeyTrigger::LeftControl => VK_LCONTROL, HotkeyTrigger::RightOption | HotkeyTrigger::RightAlt => VK_RMENU, HotkeyTrigger::RightCommand => VK_RWIN, + HotkeyTrigger::LeftCommand => VK_LWIN, + HotkeyTrigger::LeftShift => VK_LSHIFT, + HotkeyTrigger::RightShift => VK_RSHIFT, HotkeyTrigger::LeftOption => VK_LMENU, HotkeyTrigger::Fn => VK_RCONTROL, HotkeyTrigger::MediaPlayPause => VK_MEDIA_PLAY_PAUSE, @@ -1190,6 +1224,35 @@ mod platform { )); assert_eq!(drain(&right_alt_rx), vec![HotkeyEvent::Pressed]); } + + #[test] + fn windows_shift_side_combo_receives_pressed_via_dispatch_keyboard_event() { + use crate::combo_hotkey::ComboHotkeyEvent; + use crate::side_aware_combo::SideAwareComboMonitor; + use crate::types::ShortcutBinding; + + let (combo_tx, combo_rx) = mpsc::channel(); + let binding = ShortcutBinding { + primary: "D".into(), + modifiers: vec!["shift-left".into()], + }; + let monitor = SideAwareComboMonitor::start(binding, combo_tx).expect("start monitor"); + + let shared = shared(HotkeyTrigger::Custom); + let (ctx, hotkey_rx) = callback_context(shared); + + dispatch_keyboard_event(&ctx, VK_LSHIFT, WM_KEYDOWN); + dispatch_keyboard_event(&ctx, 0x44, WM_KEYDOWN); + + assert_eq!(combo_rx.recv().unwrap(), ComboHotkeyEvent::Pressed); + assert!( + hotkey_rx + .try_iter() + .any(|evt| evt == HotkeyEvent::TranslationModifierPressed) + ); + + drop(monitor); + } } } diff --git a/openless-all/app/src-tauri/src/lib.rs b/openless-all/app/src-tauri/src/lib.rs index 8b75b5df..e5fd854f 100644 --- a/openless-all/app/src-tauri/src/lib.rs +++ b/openless-all/app/src-tauri/src/lib.rs @@ -67,6 +67,22 @@ mod selection; mod selection; #[cfg(not(mobile))] mod shortcut_binding; +mod hold_source_tracker; +#[cfg(not(mobile))] +mod mouse_dictation; +#[cfg(mobile)] +#[path = "mobile_stubs/mouse_dictation.rs"] +mod mouse_dictation; +#[cfg(not(mobile))] +mod side_aware_combo; +#[cfg(mobile)] +#[path = "mobile_stubs/side_aware_combo.rs"] +mod side_aware_combo; +#[cfg(not(mobile))] +mod linux_evdev_input; +#[cfg(mobile)] +#[path = "mobile_stubs/linux_evdev_input.rs"] +mod linux_evdev_input; #[cfg(mobile)] #[path = "mobile_stubs/shortcut_binding.rs"] mod shortcut_binding; @@ -684,6 +700,7 @@ fn run_desktop() { coordinator.start_translation_hotkey_listener(); coordinator.start_switch_style_hotkey_listener(); coordinator.start_open_app_hotkey_listener(); + coordinator.start_mouse_dictation_listener(); } #[cfg(target_os = "macos")] RunEvent::Reopen { .. } => show_main_window(app), diff --git a/openless-all/app/src-tauri/src/linux_evdev_input.rs b/openless-all/app/src-tauri/src/linux_evdev_input.rs new file mode 100644 index 00000000..34e808a3 --- /dev/null +++ b/openless-all/app/src-tauri/src/linux_evdev_input.rs @@ -0,0 +1,481 @@ +//! Linux evdev input for side-specific combos and mouse dictation triggers. + +#[cfg(target_os = "linux")] +mod imp { + use std::sync::atomic::{AtomicBool, Ordering}; + use std::sync::mpsc::Sender; + use std::sync::{Arc, Mutex, OnceLock}; + use std::thread; + use std::time::Duration; + + use evdev::{Device, InputEvent, InputEventKind, Key}; + + use crate::hold_source_tracker::TriggerSource; + use crate::hotkey::HotkeyEvent; + use crate::mouse_dictation::MouseDictationConfig; + use crate::shortcut_binding::binding_requires_side_aware_hook; + use crate::side_aware_combo::{handle_primary_key, handle_side_modifier, SideModifier}; + use crate::types::ShortcutBinding; + + static USER_STATUS: OnceLock>> = OnceLock::new(); + + fn user_status() -> &'static Mutex> { + USER_STATUS.get_or_init(|| Mutex::new(None)) + } + + /// User-visible evdev status for settings / capability hints. + pub fn status_message() -> Option { + user_status().lock().ok()?.clone() + } + + pub fn set_status_message(message: Option) { + if let Ok(mut slot) = user_status().lock() { + *slot = message; + } + } + + const UDEV_HINT: &str = "鼠标/侧别组合键需读取 /dev/input/event*。若无权限,请将用户加入 input 组:`sudo usermod -aG input $USER`,然后重新登录。"; + + pub struct LinuxEvdevConfig { + pub side_combo: Option, + pub mouse: MouseDictationConfig, + } + + pub fn config_from_prefs(prefs: &crate::types::UserPreferences) -> LinuxEvdevConfig { + LinuxEvdevConfig { + side_combo: if binding_requires_side_aware_hook(&prefs.dictation_hotkey) { + Some(prefs.dictation_hotkey.clone()) + } else { + None + }, + mouse: MouseDictationConfig { + middle_enabled: prefs.mouse_middle_button_dictation, + side_enabled: prefs.mouse_side_button_dictation, + }, + } + } + + pub fn monitor_needed(config: &LinuxEvdevConfig) -> bool { + config.mouse.middle_enabled + || config.mouse.side_enabled + || config + .side_combo + .as_ref() + .is_some_and(binding_requires_side_aware_hook) + } + + pub struct LinuxEvdevMonitor { + shutdown: Arc, + } + + impl LinuxEvdevMonitor { + pub fn start( + config: LinuxEvdevConfig, + dictation_tx: Sender<(HotkeyEvent, TriggerSource)>, + ) -> Result { + let needs_combo = config + .side_combo + .as_ref() + .is_some_and(binding_requires_side_aware_hook); + if !config.mouse.middle_enabled + && !config.mouse.side_enabled + && !needs_combo + { + return Err("linux evdev monitor has nothing to watch".into()); + } + + let shutdown = Arc::new(AtomicBool::new(false)); + let shutdown_for_thread = Arc::clone(&shutdown); + thread::Builder::new() + .name("openless-linux-evdev".into()) + .spawn(move || { + run_loop(config, dictation_tx, shutdown_for_thread); + }) + .map_err(|e| format!("spawn linux evdev thread failed: {e}"))?; + + Ok(Self { shutdown }) + } + } + + impl Drop for LinuxEvdevMonitor { + fn drop(&mut self) { + self.shutdown.store(true, Ordering::SeqCst); + } + } + + fn run_loop( + config: LinuxEvdevConfig, + dictation_tx: Sender<(HotkeyEvent, TriggerSource)>, + shutdown: Arc, + ) { + let mut devices: Vec = Vec::new(); + let mut rescan_at = std::time::Instant::now(); + let middle_held = AtomicBool::new(false); + let side_held = AtomicBool::new(false); + while !shutdown.load(Ordering::SeqCst) { + if devices.is_empty() || rescan_at.elapsed() >= Duration::from_secs(5) { + match open_input_devices() { + Ok(opened) if !opened.is_empty() => { + devices = opened; + set_status_message(None); + } + Ok(_) => { + set_status_message(Some(format!( + "未找到可读的输入设备。{UDEV_HINT}" + ))); + } + Err(err) => { + set_status_message(Some(format!("{err} {UDEV_HINT}"))); + } + } + rescan_at = std::time::Instant::now(); + } + + if devices.is_empty() { + thread::sleep(Duration::from_secs(3)); + continue; + } + + poll_devices( + &mut devices, + &config, + &dictation_tx, + &shutdown, + &middle_held, + &side_held, + ); + thread::sleep(Duration::from_millis(10)); + } + } + + fn open_input_devices() -> Result, String> { + let mut devices = Vec::new(); + for entry in + std::fs::read_dir("/dev/input").map_err(|e| format!("read /dev/input: {e}"))? + { + let entry = entry.map_err(|e| e.to_string())?; + let path = entry.path(); + if !path.to_string_lossy().contains("/dev/input/event") { + continue; + } + if let Ok(device) = Device::open(&path) { + devices.push(device); + } + } + Ok(devices) + } + + fn poll_devices( + devices: &mut Vec, + config: &LinuxEvdevConfig, + dictation_tx: &Sender<(HotkeyEvent, TriggerSource)>, + shutdown: &Arc, + middle_held: &AtomicBool, + side_held: &AtomicBool, + ) { + devices.retain_mut(|device| { + if shutdown.load(Ordering::SeqCst) { + return false; + } + match device.fetch_events() { + Ok(events) => { + for event in events { + dispatch_event( + &event, + config, + dictation_tx, + middle_held, + side_held, + ); + } + true + } + Err(err) => { + log::warn!("[linux-evdev] device read failed: {err}"); + false + } + } + }); + } + + fn key_edge_value(value: i32) -> Option { + match value { + 0 => Some(false), + 1 => Some(true), + _ => None, + } + } + + fn dispatch_event( + event: &InputEvent, + config: &LinuxEvdevConfig, + dictation_tx: &Sender<(HotkeyEvent, TriggerSource)>, + middle_held: &AtomicBool, + side_held: &AtomicBool, + ) { + let InputEventKind::Key(key) = event.kind() else { + return; + }; + let Some(pressed) = key_edge_value(event.value()) else { + return; + }; + + match key { + Key::BTN_MIDDLE if config.mouse.middle_enabled => { + dispatch_mouse_edge(dictation_tx, middle_held, TriggerSource::MouseMiddle, pressed); + } + Key::BTN_SIDE | Key::BTN_EXTRA if config.mouse.side_enabled => { + dispatch_mouse_edge(dictation_tx, side_held, TriggerSource::MouseSide, pressed); + } + _ if config.side_combo.is_some() => { + if let Some(side) = side_from_key(key) { + handle_side_modifier(side, pressed); + } else if let Some(primary) = primary_from_key(key) { + handle_primary_key(&primary, pressed); + } + } + _ => {} + } + } + + fn dispatch_mouse_edge( + tx: &Sender<(HotkeyEvent, TriggerSource)>, + held: &AtomicBool, + source: TriggerSource, + pressed: bool, + ) { + if pressed { + if !held.swap(true, Ordering::SeqCst) { + let _ = tx.send((HotkeyEvent::Pressed, source)); + } + } else if held.swap(false, Ordering::SeqCst) { + let _ = tx.send((HotkeyEvent::Released, source)); + } + } + + fn side_from_key(key: Key) -> Option { + match key { + Key::KEY_LEFTMETA => Some(SideModifier::CmdLeft), + Key::KEY_RIGHTMETA => Some(SideModifier::CmdRight), + Key::KEY_LEFTCTRL => Some(SideModifier::CtrlLeft), + Key::KEY_RIGHTCTRL => Some(SideModifier::CtrlRight), + Key::KEY_LEFTALT => Some(SideModifier::AltLeft), + Key::KEY_RIGHTALT => Some(SideModifier::AltRight), + Key::KEY_LEFTSHIFT => Some(SideModifier::ShiftLeft), + Key::KEY_RIGHTSHIFT => Some(SideModifier::ShiftRight), + _ => None, + } + } + + fn primary_from_key(key: Key) -> Option { + match key { + Key::KEY_A => Some("A".into()), + Key::KEY_B => Some("B".into()), + Key::KEY_C => Some("C".into()), + Key::KEY_D => Some("D".into()), + Key::KEY_E => Some("E".into()), + Key::KEY_F => Some("F".into()), + Key::KEY_G => Some("G".into()), + Key::KEY_H => Some("H".into()), + Key::KEY_I => Some("I".into()), + Key::KEY_J => Some("J".into()), + Key::KEY_K => Some("K".into()), + Key::KEY_L => Some("L".into()), + Key::KEY_M => Some("M".into()), + Key::KEY_N => Some("N".into()), + Key::KEY_O => Some("O".into()), + Key::KEY_P => Some("P".into()), + Key::KEY_Q => Some("Q".into()), + Key::KEY_R => Some("R".into()), + Key::KEY_S => Some("S".into()), + Key::KEY_T => Some("T".into()), + Key::KEY_U => Some("U".into()), + Key::KEY_V => Some("V".into()), + Key::KEY_W => Some("W".into()), + Key::KEY_X => Some("X".into()), + Key::KEY_Y => Some("Y".into()), + Key::KEY_Z => Some("Z".into()), + Key::KEY_0 => Some("0".into()), + Key::KEY_1 => Some("1".into()), + Key::KEY_2 => Some("2".into()), + Key::KEY_3 => Some("3".into()), + Key::KEY_4 => Some("4".into()), + Key::KEY_5 => Some("5".into()), + Key::KEY_6 => Some("6".into()), + Key::KEY_7 => Some("7".into()), + Key::KEY_8 => Some("8".into()), + Key::KEY_9 => Some("9".into()), + Key::KEY_SPACE => Some("Space".into()), + Key::KEY_ENTER => Some("Enter".into()), + Key::KEY_TAB => Some("Tab".into()), + Key::KEY_BACKSPACE => Some("Backspace".into()), + Key::KEY_DELETE => Some("Delete".into()), + Key::KEY_ESC => Some("Escape".into()), + Key::KEY_F1 => Some("F1".into()), + Key::KEY_F2 => Some("F2".into()), + Key::KEY_F3 => Some("F3".into()), + Key::KEY_F4 => Some("F4".into()), + Key::KEY_F5 => Some("F5".into()), + Key::KEY_F6 => Some("F6".into()), + Key::KEY_F7 => Some("F7".into()), + Key::KEY_F8 => Some("F8".into()), + Key::KEY_F9 => Some("F9".into()), + Key::KEY_F10 => Some("F10".into()), + Key::KEY_F11 => Some("F11".into()), + Key::KEY_F12 => Some("F12".into()), + _ => None, + } + } + + pub fn is_available() -> bool { + std::path::Path::new("/dev/input").is_dir() + } + + #[cfg(test)] + mod tests { + use super::*; + + #[test] + fn mouse_middle_edge_dispatches_press_release() { + let (tx, rx) = std::sync::mpsc::channel(); + let held = AtomicBool::new(false); + dispatch_mouse_edge(&tx, &held, TriggerSource::MouseMiddle, true); + assert_eq!(rx.recv().unwrap().0, HotkeyEvent::Pressed); + dispatch_mouse_edge(&tx, &held, TriggerSource::MouseMiddle, true); + assert!(rx.try_recv().is_err()); + dispatch_mouse_edge(&tx, &held, TriggerSource::MouseMiddle, false); + assert_eq!(rx.recv().unwrap().0, HotkeyEvent::Released); + } + + #[test] + fn mouse_side_edge_dispatches() { + let (tx, rx) = std::sync::mpsc::channel(); + let held = AtomicBool::new(false); + dispatch_mouse_edge(&tx, &held, TriggerSource::MouseSide, true); + assert_eq!(rx.recv().unwrap().1, TriggerSource::MouseSide); + } + + #[test] + fn mouse_held_state_persists_across_poll_cycles() { + let (tx, rx) = std::sync::mpsc::channel(); + let middle_held = AtomicBool::new(false); + dispatch_mouse_edge(&tx, &middle_held, TriggerSource::MouseMiddle, true); + assert_eq!(rx.recv().unwrap().0, HotkeyEvent::Pressed); + dispatch_mouse_edge(&tx, &middle_held, TriggerSource::MouseMiddle, false); + assert_eq!(rx.recv().unwrap().0, HotkeyEvent::Released); + } + + #[test] + fn key_edge_value_ignores_repeat() { + assert_eq!(key_edge_value(1), Some(true)); + assert_eq!(key_edge_value(0), Some(false)); + assert_eq!(key_edge_value(2), None); + } + + #[test] + fn side_combo_follows_side_aware_dictation_hotkey() { + let mut prefs = crate::types::UserPreferences::default(); + prefs.dictation_hotkey = crate::types::ShortcutBinding { + primary: "D".into(), + modifiers: vec!["cmd-left".into()], + }; + let config = config_from_prefs(&prefs); + assert!(config.side_combo.is_some()); + assert!(monitor_needed(&config)); + } + + #[test] + fn generic_combo_without_mouse_does_not_need_monitor() { + let mut prefs = crate::types::UserPreferences::default(); + prefs.dictation_hotkey = crate::types::ShortcutBinding { + primary: "D".into(), + modifiers: vec!["ctrl".into(), "shift".into()], + }; + prefs.mouse_middle_button_dictation = false; + prefs.mouse_side_button_dictation = false; + let config = config_from_prefs(&prefs); + assert!(config.side_combo.is_none()); + assert!(!monitor_needed(&config)); + } + + #[test] + fn mouse_only_config_has_no_side_combo() { + let mut prefs = crate::types::UserPreferences::default(); + prefs.mouse_middle_button_dictation = true; + let config = config_from_prefs(&prefs); + assert!(config.side_combo.is_none()); + assert!(monitor_needed(&config)); + } + + #[test] + fn side_combo_to_generic_without_mouse_stops_monitor() { + let mut prefs = crate::types::UserPreferences::default(); + prefs.dictation_hotkey = crate::types::ShortcutBinding { + primary: "D".into(), + modifiers: vec!["cmd-left".into()], + }; + assert!(monitor_needed(&config_from_prefs(&prefs))); + + prefs.dictation_hotkey = crate::types::ShortcutBinding { + primary: "D".into(), + modifiers: vec!["ctrl".into(), "shift".into()], + }; + prefs.mouse_middle_button_dictation = false; + prefs.mouse_side_button_dictation = false; + assert!(!monitor_needed(&config_from_prefs(&prefs))); + } + + #[test] + fn mouse_only_to_side_combo_adds_side_combo() { + let mut prefs = crate::types::UserPreferences::default(); + prefs.mouse_middle_button_dictation = true; + assert!(config_from_prefs(&prefs).side_combo.is_none()); + + prefs.dictation_hotkey = crate::types::ShortcutBinding { + primary: "D".into(), + modifiers: vec!["cmd-left".into()], + }; + let config = config_from_prefs(&prefs); + assert!(config.side_combo.is_some()); + assert!(monitor_needed(&config)); + } + } +} + +#[cfg(target_os = "linux")] +pub use imp::*; + +#[cfg(not(target_os = "linux"))] +pub struct LinuxEvdevConfig { + pub side_combo: Option, + pub mouse: crate::mouse_dictation::MouseDictationConfig, +} + +#[cfg(not(target_os = "linux"))] +pub struct LinuxEvdevMonitor; + +#[cfg(not(target_os = "linux"))] +impl LinuxEvdevMonitor { + pub fn start( + _config: LinuxEvdevConfig, + _dictation_tx: std::sync::mpsc::Sender<( + crate::hotkey::HotkeyEvent, + crate::hold_source_tracker::TriggerSource, + )>, + ) -> Result { + Err("linux evdev only available on linux".into()) + } +} + +#[cfg(not(target_os = "linux"))] +pub fn status_message() -> Option { + None +} + +#[cfg(not(target_os = "linux"))] +pub fn set_status_message(_message: Option) {} + +#[cfg(not(target_os = "linux"))] +pub fn is_available() -> bool { + false +} diff --git a/openless-all/app/src-tauri/src/linux_fcitx.rs b/openless-all/app/src-tauri/src/linux_fcitx.rs index 16069c6a..73430ab9 100644 --- a/openless-all/app/src-tauri/src/linux_fcitx.rs +++ b/openless-all/app/src-tauri/src/linux_fcitx.rs @@ -102,6 +102,9 @@ fn trigger_to_keysym(trigger: crate::types::HotkeyTrigger) -> u32 { } crate::types::HotkeyTrigger::LeftOption => KEYSYM_ALT_L, crate::types::HotkeyTrigger::RightCommand => KEYSYM_SUPER_R, + crate::types::HotkeyTrigger::LeftCommand => KEYSYM_SUPER_L, + crate::types::HotkeyTrigger::LeftShift => KEYSYM_SHIFT_L, + crate::types::HotkeyTrigger::RightShift => KEYSYM_SHIFT_R, crate::types::HotkeyTrigger::Fn => KEYSYM_CONTROL_R, crate::types::HotkeyTrigger::MediaPlayPause => unreachable!("Windows-only"), crate::types::HotkeyTrigger::Custom => unreachable!(), @@ -115,6 +118,9 @@ fn trigger_name(trigger: crate::types::HotkeyTrigger) -> &'static str { crate::types::HotkeyTrigger::RightOption | crate::types::HotkeyTrigger::RightAlt => "Alt_R", crate::types::HotkeyTrigger::LeftOption => "Alt_L", crate::types::HotkeyTrigger::RightCommand => "Super_R", + crate::types::HotkeyTrigger::LeftCommand => "Super_L", + crate::types::HotkeyTrigger::LeftShift => "Shift_L", + crate::types::HotkeyTrigger::RightShift => "Shift_R", crate::types::HotkeyTrigger::Fn => "Control_R", crate::types::HotkeyTrigger::MediaPlayPause => unreachable!("Windows-only"), crate::types::HotkeyTrigger::Custom => unreachable!(), diff --git a/openless-all/app/src-tauri/src/mobile_stubs/linux_evdev_input.rs b/openless-all/app/src-tauri/src/mobile_stubs/linux_evdev_input.rs new file mode 100644 index 00000000..d4f88f14 --- /dev/null +++ b/openless-all/app/src-tauri/src/mobile_stubs/linux_evdev_input.rs @@ -0,0 +1,32 @@ +//! Mobile stub — Linux evdev input is unavailable on Android/iOS. + +use crate::hold_source_tracker::TriggerSource; +use crate::hotkey::HotkeyEvent; +use crate::mouse_dictation::MouseDictationConfig; +use crate::types::ShortcutBinding; + +pub struct LinuxEvdevConfig { + pub side_combo: Option, + pub mouse: MouseDictationConfig, +} + +pub struct LinuxEvdevMonitor; + +impl LinuxEvdevMonitor { + pub fn start( + _config: LinuxEvdevConfig, + _dictation_tx: std::sync::mpsc::Sender<(HotkeyEvent, TriggerSource)>, + ) -> Result { + Err("linux evdev is not available on mobile".into()) + } +} + +pub fn status_message() -> Option { + None +} + +pub fn set_status_message(_message: Option) {} + +pub fn is_available() -> bool { + false +} diff --git a/openless-all/app/src-tauri/src/mobile_stubs/mouse_dictation.rs b/openless-all/app/src-tauri/src/mobile_stubs/mouse_dictation.rs new file mode 100644 index 00000000..eebe739d --- /dev/null +++ b/openless-all/app/src-tauri/src/mobile_stubs/mouse_dictation.rs @@ -0,0 +1,38 @@ +//! Mobile stub — global mouse dictation triggers are unavailable on Android/iOS. + +use std::sync::mpsc::Sender; + +use crate::hold_source_tracker::TriggerSource; +use crate::hotkey::HotkeyEvent; + +pub struct MouseDictationConfig { + pub middle_enabled: bool, + pub side_enabled: bool, +} + +pub struct MouseDictationMonitor; + +impl MouseDictationMonitor { + pub fn start( + _config: MouseDictationConfig, + _tx: Sender<(HotkeyEvent, TriggerSource)>, + ) -> Result { + Err("mouse dictation is not available on mobile".into()) + } + + pub fn update_config(_config: MouseDictationConfig) {} +} + +#[cfg(target_os = "windows")] +pub mod platform { + pub fn ensure_hook_thread() -> Result<(), String> { + Err("mouse dictation is not available on mobile".into()) + } + + pub fn dispatch_button_number(_button: i64, _pressed: bool) {} +} + +#[cfg(target_os = "macos")] +pub mod platform { + pub fn dispatch_button_number(_button: i64, _pressed: bool) {} +} diff --git a/openless-all/app/src-tauri/src/mobile_stubs/shortcut_binding.rs b/openless-all/app/src-tauri/src/mobile_stubs/shortcut_binding.rs index 7946a44d..f9796d7e 100644 --- a/openless-all/app/src-tauri/src/mobile_stubs/shortcut_binding.rs +++ b/openless-all/app/src-tauri/src/mobile_stubs/shortcut_binding.rs @@ -16,6 +16,29 @@ pub fn parse_global_hotkey(_binding: &ShortcutBinding) -> Result<(), ShortcutBin Err(ShortcutBindingError::Unavailable) } +pub fn is_side_specific_modifier_tag(_raw: &str) -> bool { + false +} + +pub fn binding_requires_side_aware_hook(_binding: &ShortcutBinding) -> bool { + false +} + +pub const SIDE_SPECIFIC_NON_DICTATION_MSG: &str = + "Side-specific modifier shortcuts are only supported for dictation start/stop."; + +pub fn reject_side_specific_non_dictation(_binding: &ShortcutBinding) -> Result<(), String> { + Ok(()) +} + +pub fn bindings_overlap(_left: &ShortcutBinding, _right: &ShortcutBinding) -> bool { + false +} + +pub fn normalize_side_modifier_tag(raw: &str) -> String { + raw.trim().to_ascii_lowercase() +} + pub fn legacy_modifier_trigger(_binding: &ShortcutBinding) -> Option { None } @@ -27,6 +50,9 @@ pub fn binding_from_legacy_trigger(trigger: HotkeyTrigger) -> ShortcutBinding { HotkeyTrigger::RightControl => "RightControl", HotkeyTrigger::LeftControl => "LeftControl", HotkeyTrigger::RightCommand => "RightCommand", + HotkeyTrigger::LeftCommand => "LeftCommand", + HotkeyTrigger::LeftShift => "LeftShift", + HotkeyTrigger::RightShift => "RightShift", HotkeyTrigger::Fn => "Fn", HotkeyTrigger::MediaPlayPause => "MediaPlayPause", HotkeyTrigger::Custom => "RightOption", diff --git a/openless-all/app/src-tauri/src/mobile_stubs/side_aware_combo.rs b/openless-all/app/src-tauri/src/mobile_stubs/side_aware_combo.rs new file mode 100644 index 00000000..48cafa58 --- /dev/null +++ b/openless-all/app/src-tauri/src/mobile_stubs/side_aware_combo.rs @@ -0,0 +1,45 @@ +//! Mobile stub — side-specific combo hotkeys are unavailable on Android/iOS. + +use std::sync::mpsc::Sender; + +use crate::combo_hotkey::{ComboHotkeyError, ComboHotkeyEvent}; +use crate::types::ShortcutBinding; + +#[derive(Debug, Clone, Copy)] +pub enum SideModifier { + CmdLeft, + CmdRight, + CtrlLeft, + CtrlRight, + AltLeft, + AltRight, + ShiftLeft, + ShiftRight, +} + +pub struct SideAwareComboMonitor; + +impl SideAwareComboMonitor { + pub fn start( + _binding: ShortcutBinding, + _tx: Sender, + ) -> Result { + Err(ComboHotkeyError::RegisterFailed( + "Side-specific combo hotkeys are not available on mobile".into(), + )) + } +} + +pub fn handle_side_modifier(_side: SideModifier, _pressed: bool) {} + +pub fn handle_primary_key(_primary: &str, _pressed: bool) {} + +#[cfg(target_os = "macos")] +pub mod platform { + pub fn dispatch_keycode(_keycode: i64, _flags_changed: bool, _flags: u64, _pressed: bool) {} +} + +#[cfg(target_os = "windows")] +pub mod platform { + pub fn dispatch_vk(_vk_code: u32, _pressed: bool) {} +} diff --git a/openless-all/app/src-tauri/src/mouse_dictation.rs b/openless-all/app/src-tauri/src/mouse_dictation.rs new file mode 100644 index 00000000..795dabfa --- /dev/null +++ b/openless-all/app/src-tauri/src/mouse_dictation.rs @@ -0,0 +1,356 @@ +//! Global mouse-button dictation triggers (middle / side buttons). + +use std::sync::atomic::{AtomicBool, Ordering}; +use std::sync::mpsc::Sender; +use std::sync::{OnceLock, RwLock}; + +use crate::hold_source_tracker::TriggerSource; +use crate::hotkey::HotkeyEvent; + +static ACTIVE_MOUSE: OnceLock>> = OnceLock::new(); + +struct MouseMonitorState { + middle_enabled: bool, + side_enabled: bool, + tx: Sender<(HotkeyEvent, TriggerSource)>, + middle_held: AtomicBool, + side_held: AtomicBool, +} + +pub struct MouseDictationConfig { + pub middle_enabled: bool, + pub side_enabled: bool, +} + +pub struct MouseDictationMonitor; + +impl MouseDictationMonitor { + pub fn start( + config: MouseDictationConfig, + tx: Sender<(HotkeyEvent, TriggerSource)>, + ) -> Result { + if !config.middle_enabled && !config.side_enabled { + return Err("mouse dictation disabled".into()); + } + let slot = ACTIVE_MOUSE.get_or_init(|| RwLock::new(None)); + *slot.write().map_err(|e| e.to_string())? = Some(MouseMonitorState { + middle_enabled: config.middle_enabled, + side_enabled: config.side_enabled, + tx, + middle_held: AtomicBool::new(false), + side_held: AtomicBool::new(false), + }); + Ok(Self) + } + + pub fn update_config(config: MouseDictationConfig) { + if let Some(slot) = ACTIVE_MOUSE.get() { + if let Ok(mut guard) = slot.write() { + if let Some(state) = guard.as_mut() { + if state.middle_enabled && !config.middle_enabled { + release_held_if_held(state, &state.middle_held, TriggerSource::MouseMiddle); + } + if state.side_enabled && !config.side_enabled { + release_held_if_held(state, &state.side_held, TriggerSource::MouseSide); + } + state.middle_enabled = config.middle_enabled; + state.side_enabled = config.side_enabled; + } + } + } + } +} + +impl Drop for MouseDictationMonitor { + fn drop(&mut self) { + if let Some(slot) = ACTIVE_MOUSE.get() { + if let Ok(mut guard) = slot.write() { + if let Some(state) = guard.as_mut() { + release_held_sources(state, true, true); + } + *guard = None; + } + } + } +} + +fn release_held_if_held( + state: &MouseMonitorState, + held: &AtomicBool, + source: TriggerSource, +) { + if held.swap(false, Ordering::SeqCst) { + send_edge(state, HotkeyEvent::Released, source); + } +} + +fn release_held_sources(state: &MouseMonitorState, middle: bool, side: bool) { + if middle { + release_held_if_held(state, &state.middle_held, TriggerSource::MouseMiddle); + } + if side { + release_held_if_held(state, &state.side_held, TriggerSource::MouseSide); + } +} + +fn with_active(f: F) -> Option +where + F: FnOnce(&MouseMonitorState) -> R, +{ + let slot = ACTIVE_MOUSE.get()?; + let guard = slot.read().ok()?; + guard.as_ref().map(f) +} + +fn send_edge(state: &MouseMonitorState, evt: HotkeyEvent, source: TriggerSource) { + if let Err(err) = state.tx.send((evt, source)) { + log::warn!("[mouse-dictation] event send failed: {err}"); + } +} + +pub fn handle_button(button: MouseButton, pressed: bool) { + with_active(|state| { + let (enabled, held, source) = match button { + MouseButton::Middle => ( + state.middle_enabled, + &state.middle_held, + TriggerSource::MouseMiddle, + ), + MouseButton::Side => ( + state.side_enabled, + &state.side_held, + TriggerSource::MouseSide, + ), + }; + if !enabled { + return; + } + if pressed { + if !held.swap(true, Ordering::SeqCst) { + send_edge(state, HotkeyEvent::Pressed, source); + } + } else if held.swap(false, Ordering::SeqCst) { + send_edge(state, HotkeyEvent::Released, source); + } + }); +} + +#[derive(Debug, Clone, Copy)] +pub enum MouseButton { + Middle, + Side, +} + +#[cfg(target_os = "windows")] +pub mod platform { + use super::*; + use std::sync::Mutex; + use windows::Win32::Foundation::{LPARAM, LRESULT, WPARAM}; + use windows::Win32::UI::WindowsAndMessaging::{ + CallNextHookEx, HC_ACTION, SetWindowsHookExW, UnhookWindowsHookEx, HHOOK, WH_MOUSE_LL, + WM_MBUTTONDOWN, WM_MBUTTONUP, WM_XBUTTONDOWN, WM_XBUTTONUP, XBUTTON1, XBUTTON2, + }; + + static MOUSE_HOOK: OnceLock>> = OnceLock::new(); + static HOOK_THREAD_STARTED: OnceLock<()> = OnceLock::new(); + + pub fn ensure_hook_thread() -> Result<(), String> { + if HOOK_THREAD_STARTED.get().is_some() { + return Ok(()); + } + std::thread::Builder::new() + .name("openless-mouse-hook".into()) + .spawn(|| { + if let Err(err) = install_hook() { + log::error!("[mouse-dictation] hook install failed: {err}"); + return; + } + let mut msg = windows::Win32::UI::WindowsAndMessaging::MSG::default(); + unsafe { + while windows::Win32::UI::WindowsAndMessaging::GetMessageW( + &mut msg, + None, + 0, + 0, + ) + .0 + > 0 + { + let _ = windows::Win32::UI::WindowsAndMessaging::TranslateMessage(&msg); + let _ = windows::Win32::UI::WindowsAndMessaging::DispatchMessageW(&msg); + } + } + uninstall_hook(); + }) + .map_err(|e| format!("spawn mouse hook thread: {e}"))?; + let _ = HOOK_THREAD_STARTED.set(()); + Ok(()) + } + + pub fn install_hook() -> Result<(), String> { + let slot = MOUSE_HOOK.get_or_init(|| Mutex::new(None)); + let mut guard = slot.lock().map_err(|e| e.to_string())?; + if guard.is_some() { + return Ok(()); + } + unsafe { + let hook = SetWindowsHookExW(WH_MOUSE_LL, Some(low_level_mouse_proc), None, 0) + .map_err(|e| format!("mouse hook install failed: {e}"))?; + *guard = Some(hook.0 as isize); + } + Ok(()) + } + + pub fn uninstall_hook() { + if let Some(slot) = MOUSE_HOOK.get() { + if let Ok(mut guard) = slot.lock() { + if let Some(hook) = guard.take() { + unsafe { + let _ = UnhookWindowsHookEx(HHOOK(hook as *mut core::ffi::c_void)); + } + } + } + } + } + + unsafe extern "system" fn low_level_mouse_proc( + code: i32, + wparam: WPARAM, + lparam: LPARAM, + ) -> LRESULT { + if code == HC_ACTION as i32 && lparam.0 != 0 { + let msg = wparam.0 as u32; + let mouse = std::ptr::read(lparam.0 as *const MSLLHOOKSTRUCT); + match msg { + WM_MBUTTONDOWN => handle_button(MouseButton::Middle, true), + WM_MBUTTONUP => handle_button(MouseButton::Middle, false), + WM_XBUTTONDOWN | WM_XBUTTONUP => { + let hi = ((mouse.mouseData >> 16) & 0xFFFF) as u16; + if hi == XBUTTON1 as u16 || hi == XBUTTON2 as u16 { + let pressed = msg == WM_XBUTTONDOWN; + handle_button(MouseButton::Side, pressed); + } + } + _ => {} + } + } + CallNextHookEx(None, code, wparam, lparam) + } + + #[repr(C)] + #[derive(Copy, Clone)] + struct MSLLHOOKSTRUCT { + pt: windows::Win32::Foundation::POINT, + mouseData: u32, + flags: u32, + time: u32, + extraInfo: usize, + } +} + +#[cfg(target_os = "macos")] +pub mod platform { + use super::*; + + pub fn dispatch_button_number(button_number: i64, pressed: bool) { + // macOS CGEvent buttonNumber: 0=left, 1=right, 2=middle, 3/4=side buttons + let button = match button_number { + 2 => Some(MouseButton::Middle), + 3 | 4 => Some(MouseButton::Side), + _ => None, + }; + if let Some(button) = button { + handle_button(button, pressed); + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + use std::sync::{mpsc, Mutex}; + + static TEST_LOCK: Mutex<()> = Mutex::new(()); + + fn clear_active_monitor() { + if let Some(slot) = ACTIVE_MOUSE.get() { + if let Ok(mut guard) = slot.write() { + if let Some(state) = guard.take() { + release_held_sources(&state, true, true); + } + } + } + } + + #[test] + fn disabling_held_middle_emits_release() { + let _lock = TEST_LOCK.lock().unwrap(); + clear_active_monitor(); + + let (tx, rx) = mpsc::channel(); + let _monitor = MouseDictationMonitor::start( + MouseDictationConfig { + middle_enabled: true, + side_enabled: false, + }, + tx, + ) + .unwrap(); + + handle_button(MouseButton::Middle, true); + assert_eq!( + rx.recv().unwrap(), + (HotkeyEvent::Pressed, TriggerSource::MouseMiddle) + ); + + MouseDictationMonitor::update_config(MouseDictationConfig { + middle_enabled: false, + side_enabled: false, + }); + assert_eq!( + rx.recv().unwrap(), + (HotkeyEvent::Released, TriggerSource::MouseMiddle) + ); + + clear_active_monitor(); + } + + #[test] + fn dropping_monitor_emits_release_for_held_sources() { + let _lock = TEST_LOCK.lock().unwrap(); + clear_active_monitor(); + + let (tx, rx) = mpsc::channel(); + let monitor = MouseDictationMonitor::start( + MouseDictationConfig { + middle_enabled: true, + side_enabled: true, + }, + tx, + ) + .unwrap(); + + handle_button(MouseButton::Middle, true); + handle_button(MouseButton::Side, true); + assert_eq!( + rx.recv().unwrap(), + (HotkeyEvent::Pressed, TriggerSource::MouseMiddle) + ); + assert_eq!( + rx.recv().unwrap(), + (HotkeyEvent::Pressed, TriggerSource::MouseSide) + ); + + drop(monitor); + + let mut releases = Vec::new(); + while let Ok(evt) = rx.try_recv() { + releases.push(evt); + } + assert_eq!(releases.len(), 2); + assert!(releases.contains(&(HotkeyEvent::Released, TriggerSource::MouseMiddle))); + assert!(releases.contains(&(HotkeyEvent::Released, TriggerSource::MouseSide))); + + clear_active_monitor(); + } +} diff --git a/openless-all/app/src-tauri/src/shortcut_binding.rs b/openless-all/app/src-tauri/src/shortcut_binding.rs index 6b9ad733..155cd379 100644 --- a/openless-all/app/src-tauri/src/shortcut_binding.rs +++ b/openless-all/app/src-tauri/src/shortcut_binding.rs @@ -12,6 +12,114 @@ pub enum ShortcutBindingError { UnsupportedKey(String), } +const SIDE_MODIFIER_TAGS: &[&str] = &[ + "cmd-left", + "cmd-right", + "ctrl-left", + "ctrl-right", + "alt-left", + "alt-right", + "shift-left", + "shift-right", + "super-left", + "super-right", +]; + +pub fn is_side_specific_modifier_tag(raw: &str) -> bool { + SIDE_MODIFIER_TAGS.contains(&normalize_side_modifier_tag(raw).as_str()) +} + +pub fn binding_requires_side_aware_hook(binding: &ShortcutBinding) -> bool { + !binding.modifiers.is_empty() + && binding + .modifiers + .iter() + .any(|tag| is_side_specific_modifier_tag(tag)) +} + +pub const SIDE_SPECIFIC_NON_DICTATION_MSG: &str = + "Side-specific modifier shortcuts are only supported for dictation start/stop."; + +pub fn reject_side_specific_non_dictation(binding: &ShortcutBinding) -> Result<(), String> { + if binding_requires_side_aware_hook(binding) { + return Err(SIDE_SPECIFIC_NON_DICTATION_MSG.to_string()); + } + Ok(()) +} + +fn physical_modifier_class(raw: &str) -> String { + let tag = normalize_side_modifier_tag(raw); + if is_side_specific_modifier_tag(&tag) { + if tag.starts_with("cmd-") || tag.starts_with("super-") { + return "Super".to_string(); + } + if tag.starts_with("ctrl-") { + return "Control".to_string(); + } + if tag.starts_with("alt-") { + return "Alt".to_string(); + } + if tag.starts_with("shift-") { + return "Shift".to_string(); + } + } + physical_class_from_generic_tag(&normalize_modifier_tag(raw)) +} + +fn physical_class_from_generic_tag(tag: &str) -> String { + match tag { + "ctrl" | "control" => "Control".to_string(), + "alt" | "option" | "opt" => "Alt".to_string(), + "shift" => "Shift".to_string(), + #[cfg(target_os = "windows")] + "cmd" | "command" => "Control".to_string(), + #[cfg(target_os = "windows")] + "super" | "meta" | "win" => "Super".to_string(), + #[cfg(not(target_os = "windows"))] + "cmd" | "command" | "super" | "meta" | "win" => "Super".to_string(), + other => other.to_string(), + } +} + +fn physical_modifier_set(binding: &ShortcutBinding) -> std::collections::BTreeSet { + binding + .modifiers + .iter() + .map(|raw| physical_modifier_class(raw)) + .collect() +} + +/// Returns true when two bindings would compete for the same physical shortcut. +pub fn bindings_overlap(left: &ShortcutBinding, right: &ShortcutBinding) -> bool { + let left_legacy = legacy_modifier_trigger(left); + let right_legacy = legacy_modifier_trigger(right); + match (left_legacy, right_legacy) { + (Some(left), Some(right)) => left == right, + (Some(_), None) | (None, Some(_)) => false, + (None, None) => { + if normalize_primary(&left.primary) != normalize_primary(&right.primary) { + return false; + } + let left_side = binding_requires_side_aware_hook(left); + let right_side = binding_requires_side_aware_hook(right); + if left_side && right_side { + let left_mods: std::collections::BTreeSet = left + .modifiers + .iter() + .map(|raw| normalize_side_modifier_tag(raw)) + .collect(); + let right_mods: std::collections::BTreeSet = right + .modifiers + .iter() + .map(|raw| normalize_side_modifier_tag(raw)) + .collect(); + return left_mods == right_mods; + } + physical_modifier_set(left) == physical_modifier_set(right) + } + } +} + pub fn validate_binding(binding: &ShortcutBinding) -> Result<(), ShortcutBindingError> { if legacy_modifier_trigger(binding).is_some() { return Ok(()); @@ -19,6 +127,15 @@ pub fn validate_binding(binding: &ShortcutBinding) -> Result<(), ShortcutBinding if binding.modifiers.is_empty() && binding.primary.eq_ignore_ascii_case("shift") { return Ok(()); } + if binding_requires_side_aware_hook(binding) { + parse_primary(&binding.primary)?; + for raw in &binding.modifiers { + if !is_side_specific_modifier_tag(raw) { + return Err(ShortcutBindingError::UnsupportedModifier(raw.clone())); + } + } + return Ok(()); + } parse_global_hotkey(binding)?; Ok(()) } @@ -53,6 +170,11 @@ pub fn legacy_modifier_trigger(binding: &ShortcutBinding) -> Option { Some(HotkeyTrigger::RightCommand) } + "leftcommand" | "leftcmd" | "leftsuper" | "leftmeta" => Some(HotkeyTrigger::LeftCommand), + "leftshift" => Some(HotkeyTrigger::LeftShift), + "rightshift" => Some(HotkeyTrigger::RightShift), + "shiftleft" => Some(HotkeyTrigger::LeftShift), + "shiftright" => Some(HotkeyTrigger::RightShift), "fn" | "function" => Some(HotkeyTrigger::Fn), "mediaplaypause" | "mediaplay" | "playpause" => Some(HotkeyTrigger::MediaPlayPause), _ => None, @@ -66,6 +188,9 @@ pub fn binding_from_legacy_trigger(trigger: HotkeyTrigger) -> ShortcutBinding { HotkeyTrigger::RightControl => "RightControl", HotkeyTrigger::LeftControl => "LeftControl", HotkeyTrigger::RightCommand => "RightCommand", + HotkeyTrigger::LeftCommand => "LeftCommand", + HotkeyTrigger::LeftShift => "LeftShift", + HotkeyTrigger::RightShift => "RightShift", HotkeyTrigger::Fn => "Fn", HotkeyTrigger::MediaPlayPause => "MediaPlayPause", HotkeyTrigger::Custom => "RightOption", @@ -76,8 +201,19 @@ pub fn binding_from_legacy_trigger(trigger: HotkeyTrigger) -> ShortcutBinding { } } +pub fn normalize_side_modifier_tag(raw: &str) -> String { + match raw.trim().to_ascii_lowercase().as_str() { + "super-left" => "cmd-left".into(), + "super-right" => "cmd-right".into(), + tag => tag.to_string(), + } +} + fn normalize_modifier_tag(raw: &str) -> String { let tag = raw.trim().to_ascii_lowercase(); + if is_side_specific_modifier_tag(&tag) { + return tag; + } #[cfg(target_os = "windows")] { if matches!(tag.as_str(), "cmd" | "command") { @@ -95,7 +231,7 @@ fn normalize_primary(raw: &str) -> String { .to_ascii_lowercase() } -fn parse_primary(raw: &str) -> Result { +pub fn parse_primary(raw: &str) -> Result { let trimmed = raw.trim(); if trimmed.is_empty() { return Err(ShortcutBindingError::UnsupportedKey("(空)".into())); @@ -232,6 +368,41 @@ mod tests { legacy_modifier_trigger(&binding), Some(HotkeyTrigger::RightControl) ); + let left_cmd = ShortcutBinding { + primary: "LeftCommand".into(), + modifiers: vec![], + }; + assert_eq!( + legacy_modifier_trigger(&left_cmd), + Some(HotkeyTrigger::LeftCommand) + ); + } + + #[test] + fn side_specific_combo_requires_side_aware_hook() { + let binding = ShortcutBinding { + primary: "D".into(), + modifiers: vec!["cmd-left".into(), "shift-right".into()], + }; + assert!(binding_requires_side_aware_hook(&binding)); + assert!(validate_binding(&binding).is_ok()); + } + + #[test] + fn generic_combo_uses_global_hotkey_path() { + let binding = ShortcutBinding { + primary: "D".into(), + modifiers: vec!["cmd".into(), "shift".into()], + }; + assert!(!binding_requires_side_aware_hook(&binding)); + assert!(parse_global_hotkey(&binding).is_ok()); + } + + #[test] + fn super_side_aliases_normalize_to_cmd() { + assert_eq!(normalize_side_modifier_tag("super-left"), "cmd-left"); + assert_eq!(normalize_side_modifier_tag("Super-Right"), "cmd-right"); + assert!(is_side_specific_modifier_tag("super-left")); } #[test] @@ -255,4 +426,60 @@ mod tests { assert!(parsed.mods.contains(Modifiers::SHIFT)); } } + + fn combo(primary: &str, modifiers: Vec<&str>) -> ShortcutBinding { + ShortcutBinding { + primary: primary.into(), + modifiers: modifiers.into_iter().map(str::to_string).collect(), + } + } + + #[test] + fn side_combo_overlaps_generic_combo_with_same_physical_modifiers() { + #[cfg(target_os = "windows")] + { + assert!(!bindings_overlap( + &combo("D", vec!["cmd-left"]), + &combo("D", vec!["cmd"]), + )); + assert!(bindings_overlap( + &combo("D", vec!["cmd-left"]), + &combo("D", vec!["super"]), + )); + } + #[cfg(not(target_os = "windows"))] + { + assert!(bindings_overlap( + &combo("D", vec!["cmd-left"]), + &combo("D", vec!["cmd"]), + )); + } + } + + #[test] + fn side_combo_does_not_overlap_generic_combo_with_extra_modifier() { + assert!(!bindings_overlap( + &combo("D", vec!["cmd-left"]), + &combo("D", vec!["cmd", "shift"]), + )); + } + + #[test] + fn side_combos_with_different_sides_do_not_overlap() { + assert!(!bindings_overlap( + &combo("D", vec!["cmd-left"]), + &combo("D", vec!["cmd-right"]), + )); + } + + #[test] + fn rejects_side_specific_non_dictation_shortcut() { + let binding = combo("D", vec!["cmd-left"]); + assert!(binding_requires_side_aware_hook(&binding)); + assert_eq!( + reject_side_specific_non_dictation(&binding).unwrap_err(), + SIDE_SPECIFIC_NON_DICTATION_MSG, + ); + assert!(reject_side_specific_non_dictation(&combo("D", vec!["cmd"])).is_ok()); + } } diff --git a/openless-all/app/src-tauri/src/side_aware_combo.rs b/openless-all/app/src-tauri/src/side_aware_combo.rs new file mode 100644 index 00000000..99de07f5 --- /dev/null +++ b/openless-all/app/src-tauri/src/side_aware_combo.rs @@ -0,0 +1,568 @@ +//! Side-specific combo hotkey matching (e.g. Left Cmd + D). +//! +//! `global-hotkey` cannot distinguish left/right modifiers. This module maintains +//! physical modifier state and matches combos registered via [`SideAwareComboMonitor`]. + +use std::sync::mpsc::Sender; +use std::sync::{OnceLock, RwLock}; + +use parking_lot::Mutex; + +use crate::combo_hotkey::ComboHotkeyEvent; +use crate::shortcut_binding::{is_side_specific_modifier_tag, normalize_side_modifier_tag}; +use crate::types::ShortcutBinding; + +static ACTIVE_MONITOR: OnceLock>> = OnceLock::new(); + +struct ActiveSideCombo { + binding: ShortcutBinding, + tx: Sender, + state: Mutex, +} + +#[derive(Default, Clone, Copy, Debug)] +struct ModifierSideState { + cmd_left: bool, + cmd_right: bool, + ctrl_left: bool, + ctrl_right: bool, + alt_left: bool, + alt_right: bool, + shift_left: bool, + shift_right: bool, +} + +#[derive(Debug)] +struct SideAwareComboState { + binding: ShortcutBinding, + modifiers: ModifierSideState, + combo_active: bool, +} + +impl SideAwareComboState { + fn new(binding: ShortcutBinding) -> Self { + Self { + binding, + modifiers: ModifierSideState::default(), + combo_active: false, + } + } + + fn set_side(&mut self, side: SideModifier, pressed: bool) { + match side { + SideModifier::CmdLeft => self.modifiers.cmd_left = pressed, + SideModifier::CmdRight => self.modifiers.cmd_right = pressed, + SideModifier::CtrlLeft => self.modifiers.ctrl_left = pressed, + SideModifier::CtrlRight => self.modifiers.ctrl_right = pressed, + SideModifier::AltLeft => self.modifiers.alt_left = pressed, + SideModifier::AltRight => self.modifiers.alt_right = pressed, + SideModifier::ShiftLeft => self.modifiers.shift_left = pressed, + SideModifier::ShiftRight => self.modifiers.shift_right = pressed, + } + } + + fn expected_modifier_tags(&self) -> Vec { + let mut tags: Vec = self + .binding + .modifiers + .iter() + .map(|raw| normalize_side_modifier_tag(raw)) + .collect(); + tags.sort(); + tags + } + + fn pressed_modifier_tags(&self) -> Vec { + let mut tags = Vec::new(); + if self.modifiers.cmd_left { + tags.push("cmd-left".into()); + } + if self.modifiers.cmd_right { + tags.push("cmd-right".into()); + } + if self.modifiers.ctrl_left { + tags.push("ctrl-left".into()); + } + if self.modifiers.ctrl_right { + tags.push("ctrl-right".into()); + } + if self.modifiers.alt_left { + tags.push("alt-left".into()); + } + if self.modifiers.alt_right { + tags.push("alt-right".into()); + } + if self.modifiers.shift_left { + tags.push("shift-left".into()); + } + if self.modifiers.shift_right { + tags.push("shift-right".into()); + } + tags.sort(); + tags + } + + fn modifiers_match(&self) -> bool { + self.expected_modifier_tags() == self.pressed_modifier_tags() + } + + fn on_primary(&mut self, primary: &str, pressed: bool) -> Option { + if !primary_eq(&self.binding.primary, primary) { + return None; + } + if pressed { + if self.modifiers_match() && !self.combo_active { + self.combo_active = true; + return Some(ComboHotkeyEvent::Pressed); + } + return None; + } + if self.combo_active { + self.combo_active = false; + return Some(ComboHotkeyEvent::Released); + } + None + } + + fn on_modifier_release(&mut self, side: SideModifier) -> Option { + self.set_side(side, false); + if self.combo_active && !self.modifiers_match() { + self.combo_active = false; + return Some(ComboHotkeyEvent::Released); + } + None + } +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum SideModifier { + CmdLeft, + CmdRight, + CtrlLeft, + CtrlRight, + AltLeft, + AltRight, + ShiftLeft, + ShiftRight, +} + +pub struct SideAwareComboMonitor; + +impl SideAwareComboMonitor { + pub fn start( + binding: ShortcutBinding, + tx: Sender, + ) -> Result { + if binding.modifiers.is_empty() + || binding + .modifiers + .iter() + .any(|tag| !is_side_specific_modifier_tag(tag)) + { + return Err(crate::combo_hotkey::ComboHotkeyError::UnsupportedModifier( + "binding is not side-specific".into(), + )); + } + crate::shortcut_binding::parse_primary(&binding.primary).map_err(|e| { + crate::combo_hotkey::ComboHotkeyError::UnsupportedKey(e.to_string()) + })?; + + let slot = ACTIVE_MONITOR.get_or_init(|| RwLock::new(None)); + let mut guard = slot.write().expect("side combo monitor lock poisoned"); + *guard = Some(ActiveSideCombo { + binding: binding.clone(), + tx, + state: Mutex::new(SideAwareComboState::new(binding)), + }); + Ok(Self) + } +} + +impl Drop for SideAwareComboMonitor { + fn drop(&mut self) { + if let Some(slot) = ACTIVE_MONITOR.get() { + *slot.write().expect("side combo monitor lock poisoned") = None; + } + } +} + +fn with_active(f: F) -> Option +where + F: FnOnce(&ActiveSideCombo) -> R, +{ + let slot = ACTIVE_MONITOR.get()?; + let guard = slot.read().ok()?; + guard.as_ref().map(f) +} + +fn send_event(tx: &Sender, evt: ComboHotkeyEvent) { + if let Err(err) = tx.send(evt) { + log::warn!("[side-aware-combo] event send failed: {err}"); + } +} + +pub fn handle_side_modifier(side: SideModifier, pressed: bool) { + if let Some(evt) = with_active(|active| { + let mut state = active.state.lock(); + if pressed { + state.set_side(side, true); + None + } else { + state.on_modifier_release(side) + } + }) + .flatten() + { + with_active(|active| send_event(&active.tx, evt)); + } +} + +pub fn handle_primary_key(primary: &str, pressed: bool) { + if let Some(evt) = with_active(|active| { + let mut state = active.state.lock(); + state.on_primary(primary, pressed) + }) + .flatten() + { + with_active(|active| send_event(&active.tx, evt)); + } +} + +fn primary_eq(expected: &str, actual: &str) -> bool { + expected.trim().eq_ignore_ascii_case(actual.trim()) +} + +#[cfg(target_os = "windows")] +pub mod platform { + use super::*; + + use windows::Win32::UI::Input::KeyboardAndMouse::{ + VK_BACK, VK_DELETE, VK_DOWN, VK_END, VK_ESCAPE, VK_F1, VK_F10, VK_F11, VK_F12, VK_F2, + VK_F3, VK_F4, VK_F5, VK_F6, VK_F7, VK_F8, VK_F9, VK_HOME, VK_INSERT, VK_LCONTROL, + VK_LEFT, VK_LMENU, VK_LSHIFT, VK_LWIN, VK_OEM_1, VK_OEM_2, VK_OEM_3, VK_OEM_4, VK_OEM_5, + VK_OEM_6, VK_OEM_7, VK_OEM_COMMA, VK_OEM_MINUS, VK_OEM_PERIOD, VK_OEM_PLUS, VK_RETURN, + VK_RIGHT, VK_RCONTROL, VK_RMENU, VK_RSHIFT, VK_RWIN, VK_SPACE, VK_TAB, VK_UP, + }; + + pub fn dispatch_vk(vk_code: u32, pressed: bool) { + if let Some(side) = side_from_vk(vk_code) { + handle_side_modifier(side, pressed); + return; + } + if let Some(primary) = primary_from_vk(vk_code) { + handle_primary_key(&primary, pressed); + } + } + + fn side_from_vk(vk_code: u32) -> Option { + match vk_code { + x if x == VK_LWIN.0 as u32 => Some(SideModifier::CmdLeft), + x if x == VK_RWIN.0 as u32 => Some(SideModifier::CmdRight), + x if x == VK_LCONTROL.0 as u32 => Some(SideModifier::CtrlLeft), + x if x == VK_RCONTROL.0 as u32 => Some(SideModifier::CtrlRight), + x if x == VK_LMENU.0 as u32 => Some(SideModifier::AltLeft), + x if x == VK_RMENU.0 as u32 => Some(SideModifier::AltRight), + x if x == VK_LSHIFT.0 as u32 => Some(SideModifier::ShiftLeft), + x if x == VK_RSHIFT.0 as u32 => Some(SideModifier::ShiftRight), + _ => None, + } + } + + fn primary_from_vk(vk_code: u32) -> Option { + if (0x41..=0x5A).contains(&vk_code) { + return Some(((vk_code as u8) as char).to_string()); + } + if (0x30..=0x39).contains(&vk_code) { + return Some(((vk_code as u8) as char).to_string()); + } + let named = match vk_code { + x if x == VK_SPACE.0 as u32 => "Space", + x if x == VK_RETURN.0 as u32 => "Enter", + x if x == VK_TAB.0 as u32 => "Tab", + x if x == VK_BACK.0 as u32 => "Backspace", + x if x == VK_DELETE.0 as u32 => "Delete", + x if x == VK_ESCAPE.0 as u32 => "Escape", + x if x == VK_HOME.0 as u32 => "Home", + x if x == VK_END.0 as u32 => "End", + x if x == VK_INSERT.0 as u32 => "Insert", + x if x == VK_UP.0 as u32 => "ArrowUp", + x if x == VK_DOWN.0 as u32 => "ArrowDown", + x if x == VK_LEFT.0 as u32 => "ArrowLeft", + x if x == VK_RIGHT.0 as u32 => "ArrowRight", + x if x == VK_F1.0 as u32 => "F1", + x if x == VK_F2.0 as u32 => "F2", + x if x == VK_F3.0 as u32 => "F3", + x if x == VK_F4.0 as u32 => "F4", + x if x == VK_F5.0 as u32 => "F5", + x if x == VK_F6.0 as u32 => "F6", + x if x == VK_F7.0 as u32 => "F7", + x if x == VK_F8.0 as u32 => "F8", + x if x == VK_F9.0 as u32 => "F9", + x if x == VK_F10.0 as u32 => "F10", + x if x == VK_F11.0 as u32 => "F11", + x if x == VK_F12.0 as u32 => "F12", + x if x == VK_OEM_1.0 as u32 => ";", + x if x == VK_OEM_PLUS.0 as u32 => "=", + x if x == VK_OEM_COMMA.0 as u32 => ",", + x if x == VK_OEM_MINUS.0 as u32 => "-", + x if x == VK_OEM_PERIOD.0 as u32 => ".", + x if x == VK_OEM_2.0 as u32 => "/", + x if x == VK_OEM_3.0 as u32 => "`", + x if x == VK_OEM_4.0 as u32 => "[", + x if x == VK_OEM_5.0 as u32 => "\\", + x if x == VK_OEM_6.0 as u32 => "]", + x if x == VK_OEM_7.0 as u32 => "'", + _ => return None, + }; + Some(named.to_string()) + } +} + +/// macOS virtual keycode → ShortcutBinding.primary (US ANSI layout). +fn macos_keycode_to_primary(keycode: i64) -> Option<&'static str> { + match keycode { + // A-Z (non-contiguous on macOS) + 0 => Some("A"), + 1 => Some("S"), + 2 => Some("D"), + 3 => Some("F"), + 4 => Some("H"), + 5 => Some("G"), + 6 => Some("Z"), + 7 => Some("X"), + 8 => Some("C"), + 9 => Some("V"), + 11 => Some("B"), + 12 => Some("Q"), + 13 => Some("W"), + 14 => Some("E"), + 15 => Some("R"), + 16 => Some("Y"), + 17 => Some("T"), + 31 => Some("O"), + 32 => Some("U"), + 34 => Some("I"), + 35 => Some("P"), + 37 => Some("L"), + 38 => Some("J"), + 40 => Some("K"), + 45 => Some("N"), + 46 => Some("M"), + // 0-9 top row + 18 => Some("1"), + 19 => Some("2"), + 20 => Some("3"), + 21 => Some("4"), + 22 => Some("6"), + 23 => Some("5"), + 25 => Some("9"), + 26 => Some("7"), + 28 => Some("8"), + 29 => Some("0"), + // Punctuation + 24 => Some("="), + 27 => Some("-"), + 30 => Some("]"), + 33 => Some("["), + 39 => Some("'"), + 41 => Some(";"), + 42 => Some("\\"), + 43 => Some(","), + 44 => Some("/"), + 47 => Some("."), + 50 => Some("`"), + // Special keys + 36 => Some("Enter"), + 48 => Some("Tab"), + 49 => Some("Space"), + 51 => Some("Backspace"), + 53 => Some("Escape"), + 117 => Some("Delete"), + 123 => Some("ArrowLeft"), + 124 => Some("ArrowRight"), + 125 => Some("ArrowDown"), + 126 => Some("ArrowUp"), + // Function keys + 122 => Some("F1"), + 120 => Some("F2"), + 99 => Some("F3"), + 118 => Some("F4"), + 96 => Some("F5"), + 97 => Some("F6"), + 98 => Some("F7"), + 100 => Some("F8"), + 101 => Some("F9"), + 109 => Some("F10"), + 103 => Some("F11"), + 111 => Some("F12"), + _ => None, + } +} + +#[cfg(target_os = "macos")] +pub mod platform { + use super::*; + + use std::os::raw::c_int; + + #[link(name = "CoreGraphics", kind = "framework")] + extern "C" { + fn CGEventSourceKeyState(state: c_int, key: u16) -> bool; + } + + const COMBINED_SESSION: c_int = 1; + + pub fn dispatch_keycode(keycode: i64, flags_changed: bool, _flags: u64, pressed: bool) { + if flags_changed { + if let Some(side) = side_from_keycode(keycode) { + handle_side_modifier(side, keycode_is_down(keycode)); + } + return; + } + + if let Some(primary) = primary_from_keycode(keycode) { + handle_primary_key(&primary, pressed); + } + } + + fn keycode_is_down(keycode: i64) -> bool { + unsafe { CGEventSourceKeyState(COMBINED_SESSION, keycode as u16) } + } + + pub(crate) fn side_from_keycode(keycode: i64) -> Option { + match keycode { + 55 => Some(SideModifier::CmdLeft), + 54 => Some(SideModifier::CmdRight), + 59 => Some(SideModifier::CtrlLeft), + 62 => Some(SideModifier::CtrlRight), + 58 => Some(SideModifier::AltLeft), + 61 => Some(SideModifier::AltRight), + 56 => Some(SideModifier::ShiftLeft), + 60 => Some(SideModifier::ShiftRight), + _ => None, + } + } + + fn primary_from_keycode(keycode: i64) -> Option { + super::macos_keycode_to_primary(keycode).map(str::to_string) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn macos_keycode_2_is_d() { + assert_eq!(macos_keycode_to_primary(2), Some("D")); + } + + #[test] + fn macos_keycode_us_ansi_samples() { + assert_eq!(macos_keycode_to_primary(17), Some("T")); + assert_eq!(macos_keycode_to_primary(11), Some("B")); + assert_eq!(macos_keycode_to_primary(24), Some("=")); + assert_eq!(macos_keycode_to_primary(29), Some("0")); + assert_eq!(macos_keycode_to_primary(44), Some("/")); + } + + #[test] + fn macos_letter_keycodes_are_non_contiguous() { + assert_eq!(macos_keycode_to_primary(0), Some("A")); + assert_eq!(macos_keycode_to_primary(1), Some("S")); + assert_eq!(macos_keycode_to_primary(8), Some("C")); + assert_eq!(macos_keycode_to_primary(9), Some("V")); + assert_eq!(macos_keycode_to_primary(12), Some("Q")); + assert_eq!(macos_keycode_to_primary(17), Some("T")); + } + + #[test] + fn side_specific_combo_matches_required_modifiers() { + let mut state = SideAwareComboState::new(ShortcutBinding { + primary: "D".into(), + modifiers: vec!["cmd-left".into()], + }); + state.set_side(SideModifier::CmdLeft, true); + assert!(state.modifiers_match()); + let evt = state.on_primary("D", true); + assert_eq!(evt, Some(ComboHotkeyEvent::Pressed)); + } + + #[test] + fn shift_right_combo_requires_right_shift() { + let mut state = SideAwareComboState::new(ShortcutBinding { + primary: "D".into(), + modifiers: vec!["shift-right".into()], + }); + state.set_side(SideModifier::ShiftLeft, true); + assert!(!state.modifiers_match()); + state.set_side(SideModifier::ShiftLeft, false); + state.set_side(SideModifier::ShiftRight, true); + assert!(state.modifiers_match()); + assert_eq!( + state.on_primary("D", true), + Some(ComboHotkeyEvent::Pressed) + ); + } + + #[test] + fn extra_modifier_blocks_exact_match() { + let mut state = SideAwareComboState::new(ShortcutBinding { + primary: "D".into(), + modifiers: vec!["cmd-left".into()], + }); + state.set_side(SideModifier::CmdLeft, true); + state.set_side(SideModifier::ShiftLeft, true); + assert!(!state.modifiers_match()); + assert_eq!(state.on_primary("D", true), None); + } + + #[test] + fn releasing_extra_modifier_allows_combo() { + let mut state = SideAwareComboState::new(ShortcutBinding { + primary: "D".into(), + modifiers: vec!["cmd-left".into()], + }); + state.set_side(SideModifier::CmdLeft, true); + state.set_side(SideModifier::CmdRight, true); + assert!(!state.modifiers_match()); + state.set_side(SideModifier::CmdRight, false); + assert!(state.modifiers_match()); + } + + #[test] + fn super_left_alias_matches_physical_cmd_left() { + let mut state = SideAwareComboState::new(ShortcutBinding { + primary: "D".into(), + modifiers: vec!["super-left".into()], + }); + state.set_side(SideModifier::CmdLeft, true); + assert!(state.modifiers_match()); + assert_eq!( + state.on_primary("D", true), + Some(ComboHotkeyEvent::Pressed) + ); + } + + #[test] + fn super_left_alias_rejects_extra_modifier() { + let mut state = SideAwareComboState::new(ShortcutBinding { + primary: "D".into(), + modifiers: vec!["super-left".into()], + }); + state.set_side(SideModifier::CmdLeft, true); + state.set_side(SideModifier::ShiftLeft, true); + assert!(!state.modifiers_match()); + assert_eq!(state.on_primary("D", true), None); + } + + #[cfg(target_os = "macos")] + #[test] + fn macos_side_keycodes_are_distinct() { + use crate::side_aware_combo::platform::side_from_keycode; + assert_eq!(side_from_keycode(55), Some(SideModifier::CmdLeft)); + assert_eq!(side_from_keycode(54), Some(SideModifier::CmdRight)); + assert_eq!(side_from_keycode(56), Some(SideModifier::ShiftLeft)); + assert_eq!(side_from_keycode(60), Some(SideModifier::ShiftRight)); + } +} diff --git a/openless-all/app/src-tauri/src/types.rs b/openless-all/app/src-tauri/src/types.rs index c63c65a1..d1aa0f81 100644 --- a/openless-all/app/src-tauri/src/types.rs +++ b/openless-all/app/src-tauri/src/types.rs @@ -801,6 +801,12 @@ pub struct UserPreferences { /// Android: floating overlay control diameter in dp. #[serde(default = "default_android_overlay_size_dp")] pub android_overlay_size_dp: u32, + /// 桌面端:按下鼠标中键(滚轮)触发听写。与键盘快捷键独立,默认关闭。 + #[serde(default)] + pub mouse_middle_button_dictation: bool, + /// 桌面端:按下鼠标侧键(前进/后退)触发听写。与键盘快捷键独立,默认关闭。 + #[serde(default)] + pub mouse_side_button_dictation: bool, } fn default_local_asr_model() -> String { @@ -980,6 +986,10 @@ struct UserPreferencesWire { android_overlay_cancel_swipe_direction: AndroidOverlayCancelSwipeDirection, #[serde(default = "default_android_overlay_size_dp")] android_overlay_size_dp: u32, + #[serde(default)] + mouse_middle_button_dictation: bool, + #[serde(default)] + mouse_side_button_dictation: bool, } impl Default for UserPreferencesWire { @@ -1058,6 +1068,8 @@ impl Default for UserPreferencesWire { android_overlay_left_swipe_action: prefs.android_overlay_left_swipe_action, android_overlay_cancel_swipe_direction: prefs.android_overlay_cancel_swipe_direction, android_overlay_size_dp: prefs.android_overlay_size_dp, + mouse_middle_button_dictation: prefs.mouse_middle_button_dictation, + mouse_side_button_dictation: prefs.mouse_side_button_dictation, } } } @@ -1169,6 +1181,8 @@ impl<'de> Deserialize<'de> for UserPreferences { android_overlay_size_dp: normalize_android_overlay_size_dp( wire.android_overlay_size_dp, ), + mouse_middle_button_dictation: wire.mouse_middle_button_dictation, + mouse_side_button_dictation: wire.mouse_side_button_dictation, }) } } @@ -1901,6 +1915,8 @@ impl Default for UserPreferences { android_overlay_cancel_swipe_direction: default_android_overlay_cancel_swipe_direction( ), android_overlay_size_dp: default_android_overlay_size_dp(), + mouse_middle_button_dictation: false, + mouse_side_button_dictation: false, } } } @@ -2073,6 +2089,9 @@ pub enum HotkeyTrigger { RightControl, LeftControl, RightCommand, + LeftCommand, + LeftShift, + RightShift, Fn, RightAlt, // Windows synonym for RightOption MediaPlayPause, @@ -2087,6 +2106,9 @@ impl HotkeyTrigger { HotkeyTrigger::RightControl => "右 Control", HotkeyTrigger::LeftControl => "左 Control", HotkeyTrigger::RightCommand => "右 Command", + HotkeyTrigger::LeftCommand => "左 Command", + HotkeyTrigger::LeftShift => "左 Shift", + HotkeyTrigger::RightShift => "右 Shift", HotkeyTrigger::Fn => "Fn (地球键)", HotkeyTrigger::RightAlt => "右 Alt", HotkeyTrigger::MediaPlayPause => "⏯ Media 播放/暂停", @@ -2180,6 +2202,9 @@ fn legacy_trigger_code(trigger: HotkeyTrigger) -> &'static str { HotkeyTrigger::RightControl => "ControlRight", HotkeyTrigger::LeftControl => "ControlLeft", HotkeyTrigger::RightCommand => "MetaRight", + HotkeyTrigger::LeftCommand => "MetaLeft", + HotkeyTrigger::LeftShift => "ShiftLeft", + HotkeyTrigger::RightShift => "ShiftRight", #[cfg(target_os = "windows")] HotkeyTrigger::Fn => "ControlRight", #[cfg(not(target_os = "windows"))] @@ -2300,6 +2325,9 @@ impl HotkeyCapability { HotkeyTrigger::RightControl, HotkeyTrigger::LeftControl, HotkeyTrigger::RightCommand, + HotkeyTrigger::LeftCommand, + HotkeyTrigger::LeftShift, + HotkeyTrigger::RightShift, HotkeyTrigger::Fn, HotkeyTrigger::Custom, ], @@ -2320,6 +2348,9 @@ impl HotkeyCapability { HotkeyTrigger::RightAlt, HotkeyTrigger::LeftControl, HotkeyTrigger::RightCommand, + HotkeyTrigger::LeftCommand, + HotkeyTrigger::LeftShift, + HotkeyTrigger::RightShift, HotkeyTrigger::MediaPlayPause, HotkeyTrigger::Custom, ], @@ -2342,6 +2373,9 @@ impl HotkeyCapability { HotkeyTrigger::RightAlt, HotkeyTrigger::RightControl, HotkeyTrigger::LeftControl, + HotkeyTrigger::LeftCommand, + HotkeyTrigger::LeftShift, + HotkeyTrigger::RightShift, HotkeyTrigger::Custom, ], requires_accessibility_permission: false, @@ -2349,7 +2383,8 @@ impl HotkeyCapability { supports_side_specific_modifiers: true, explicit_fallback_available: false, status_hint: Some( - "Linux 使用 fcitx5 插件监听热键和提交文字;无需桌面环境额外配置。".into(), + "Linux 使用 fcitx5 插件监听热键和提交文字。鼠标/侧别组合键需 evdev 读取 /dev/input/event*;若无权限请将用户加入 input 组(sudo usermod -aG input $USER)后重新登录。" + .into(), ), } } diff --git a/openless-all/app/src/components/ShortcutRecorder.tsx b/openless-all/app/src/components/ShortcutRecorder.tsx index fad6e257..f5b046e3 100644 --- a/openless-all/app/src/components/ShortcutRecorder.tsx +++ b/openless-all/app/src/components/ShortcutRecorder.tsx @@ -1,8 +1,8 @@ import { useEffect, useRef, useState, type CSSProperties, type KeyboardEvent } from 'react'; import { useTranslation } from 'react-i18next'; -import { currentPlatform, formatComboLabel } from '../lib/hotkey'; +import { formatComboLabel, getHotkeyTriggerLabel, modifiersFromPressedCodes, shortcutFromLegacyTrigger } from '../lib/hotkey'; import { setShortcutRecordingActive, validateShortcutBinding } from '../lib/ipc'; -import type { ShortcutBinding } from '../lib/types'; +import type { HotkeyTrigger, ShortcutBinding } from '../lib/types'; export function ShortcutRecorder({ value, @@ -12,6 +12,8 @@ export function ShortcutRecorder({ onDisable, disableLabel, comboOnly = false, + modifierPresets = [], + sideSpecificModifiers = false, }: { value: ShortcutBinding; onSave: (binding: ShortcutBinding) => Promise; @@ -22,12 +24,21 @@ export function ShortcutRecorder({ disableLabel?: string; /** 仅允许组合键(修饰键+主键 / 功能键);拒绝单修饰键,因为全局热键无法注册它。 */ comboOnly?: boolean; + /** 平台可用的单修饰键快捷选项(如 Fn,WebView 无法录制)。 */ + modifierPresets?: HotkeyTrigger[]; + /** 听写 start/stop 专用:录制 cmd-left / ctrl-right 等侧向修饰键。 */ + sideSpecificModifiers?: boolean; }) { const { t } = useTranslation(); const [recording, setRecording] = useState(false); const [error, setError] = useState(null); const pendingModifier = useRef(null); const pendingTimer = useRef(null); + const pressedCodes = useRef>(new Set()); + + const clearPressedCodes = () => { + pressedCodes.current.clear(); + }; const clearPendingModifier = () => { if (pendingTimer.current !== null) { @@ -37,8 +48,13 @@ export function ShortcutRecorder({ pendingModifier.current = null; }; - useEffect(() => () => { + const resetRecordingState = () => { clearPendingModifier(); + clearPressedCodes(); + }; + + useEffect(() => () => { + resetRecordingState(); void setShortcutRecordingActive(false); }, []); @@ -52,14 +68,14 @@ export function ShortcutRecorder({ useEffect(() => { if (!disabled || !recording) return; setRecording(false); - clearPendingModifier(); + resetRecordingState(); }, [disabled, recording]); const finish = async (binding: ShortcutBinding) => { try { await validateShortcutBinding(binding); await onSave(binding); - clearPendingModifier(); + resetRecordingState(); setRecording(false); setError(null); } catch { @@ -74,13 +90,12 @@ export function ShortcutRecorder({ if (e.key === 'Escape') { setRecording(false); setError(null); - clearPendingModifier(); + resetRecordingState(); return; } if (isModifierKey(e.key)) { - // comboOnly:快速 Agent 等全局热键不支持单修饰键,提示用户配真正的组合键。 + pressedCodes.current.add(e.code); if (comboOnly) { - setError(t('settings.recording.comboNeedKey', '请配组合键(如 ⌘⇧J),不支持单独的修饰键')); return; } const primary = modifierPrimaryFromCode(e.code, e.key); @@ -97,13 +112,17 @@ export function ShortcutRecorder({ } clearPendingModifier(); const primary = primaryFromKeyboardEvent(e); - if (primary) void finish({ primary, modifiers: modifiersFromKeyboardEvent(e) }); + if (primary) { + void finish({ primary, modifiers: modifiersFromPressedCodes(pressedCodes.current, sideSpecificModifiers) }); + } }; const onKeyUp = (e: KeyboardEvent) => { if (!recording || disabled || !isModifierKey(e.key)) return; e.preventDefault(); e.stopPropagation(); + pressedCodes.current.delete(e.code); + if (comboOnly) return; const primary = modifierPrimaryFromCode(e.code, e.key); if (primary && pendingModifier.current?.primary === primary) { const binding = pendingModifier.current; @@ -137,7 +156,6 @@ export function ShortcutRecorder({ cursor: recording || disabled ? 'default' : 'pointer', opacity: disabled ? 0.68 : 1, }; - // 「停用」旋钮:与「录制快捷键」同高、紧贴在它左边,组成两个并排的小旋钮。 const disableKnobStyle: CSSProperties = { fontSize: 12, padding: '5px 12px', @@ -149,13 +167,24 @@ export function ShortcutRecorder({ fontWeight: 500, cursor: recording ? 'default' : 'pointer', }; - // 录制按钮(+ 可选停用旋钮)成组靠右,保证「停用」永远贴着「录制」。 const controlsGroupStyle: CSSProperties = { display: 'inline-flex', alignItems: 'center', gap: 8, marginLeft: alignRecordButton ? 'auto' : undefined, }; + const presetChipStyle: CSSProperties = { + fontSize: 11, + padding: '4px 10px', + borderRadius: 999, + border: '0.5px solid var(--ol-line-strong)', + background: 'transparent', + color: 'var(--ol-ink-3)', + fontFamily: 'inherit', + cursor: disabled || recording ? 'default' : 'pointer', + }; + + const presetTriggers = modifierPresets.filter(t => t !== 'custom' && t !== 'mediaPlayPause'); return (
@@ -181,7 +210,7 @@ export function ShortcutRecorder({ if (disabled) return; setRecording(true); setError(null); - clearPendingModifier(); + resetRecordingState(); }} disabled={recording || disabled} style={recordButtonStyle} @@ -190,6 +219,24 @@ export function ShortcutRecorder({
+ {!comboOnly && presetTriggers.length > 0 && ( +
+ + {t('settings.recording.modifierPresetsLabel', '常用单键:')} + + {presetTriggers.map(trigger => ( + + ))} +
+ )} {recording && (
, string> = { + rightOption: 'RightOption', + leftOption: 'LeftOption', + rightControl: 'RightControl', + leftControl: 'LeftControl', + rightCommand: 'RightCommand', + leftCommand: 'LeftCommand', + leftShift: 'LeftShift', + rightShift: 'RightShift', + fn: 'Fn', + rightAlt: 'RightOption', + mediaPlayPause: 'MediaPlayPause', + }; + return { + primary: map[trigger as Exclude] ?? 'RightOption', + modifiers: [], + }; +} + /** 把 ComboBinding 或 QaHotkeyBinding 格式化为可读标签,如 "⌘⇧D" / "Ctrl+Shift+D"。 */ export function formatComboLabel(binding: ComboBinding | QaHotkeyBinding | ShortcutBinding): string { const parts: string[] = []; const platform = currentPlatform(); // 固定输出顺序:Ctrl/Cmd → Alt/Option → Shift → Super - const modifierOrder = ['cmd', 'ctrl', 'alt', 'shift', 'super'] as const; + const modifierOrder = [ + 'cmd-left', 'cmd-right', 'cmd', 'ctrl-left', 'ctrl-right', 'ctrl', + 'alt-left', 'alt-right', 'alt', 'shift-left', 'shift-right', 'shift', + 'super-left', 'super-right', 'super', + ] as const; for (const tag of modifierOrder) { if (binding.modifiers.some(m => m.toLowerCase() === tag)) { - parts.push(modifierDisplayName(tag, platform)); + parts.push(sideModifierDisplayName(tag, platform)); } } @@ -217,7 +247,80 @@ export function currentPlatform(): { isMac: boolean; isWindows: boolean } { }; } +/** Build side-specific modifier tags from Web KeyboardEvent.code values. */ +export function sideModifiersFromPressedCodes(codes: Iterable): string[] { + const set = codes instanceof Set ? codes : new Set(codes); + const modifiers: string[] = []; + if (set.has('MetaLeft')) modifiers.push('cmd-left'); + else if (set.has('MetaRight')) modifiers.push('cmd-right'); + if (set.has('ControlLeft')) modifiers.push('ctrl-left'); + else if (set.has('ControlRight')) modifiers.push('ctrl-right'); + if (set.has('AltLeft')) modifiers.push('alt-left'); + else if (set.has('AltRight')) modifiers.push('alt-right'); + if (set.has('ShiftLeft')) modifiers.push('shift-left'); + else if (set.has('ShiftRight')) modifiers.push('shift-right'); + return modifiers; +} + +/** Build generic modifier tags (cmd/super/ctrl/alt/shift) from pressed key codes. */ +export function genericModifiersFromPressedCodes(codes: Iterable): string[] { + const set = codes instanceof Set ? codes : new Set(codes); + const modifiers: string[] = []; + const { isMac } = currentPlatform(); + if (isMac) { + if (set.has('MetaLeft') || set.has('MetaRight')) modifiers.push('cmd'); + } else if ( + set.has('MetaLeft') + || set.has('MetaRight') + || set.has('OSLeft') + || set.has('OSRight') + ) { + modifiers.push('super'); + } + if (set.has('ControlLeft') || set.has('ControlRight')) modifiers.push('ctrl'); + if (set.has('AltLeft') || set.has('AltRight')) modifiers.push('alt'); + if (set.has('ShiftLeft') || set.has('ShiftRight')) modifiers.push('shift'); + return modifiers; +} + +export function modifiersFromPressedCodes( + codes: Iterable, + sideSpecific = false, +): string[] { + return sideSpecific + ? sideModifiersFromPressedCodes(codes) + : genericModifiersFromPressedCodes(codes); +} + function modifierDisplayName(tag: string, platform: { isMac: boolean; isWindows: boolean }): string { + return sideModifierDisplayName(tag, platform); +} + +function sideModifierDisplayName(tag: string, platform: { isMac: boolean; isWindows: boolean }): string { + const sideLabels: Record = platform.isMac + ? { + 'cmd-left': '左 ⌘', + 'cmd-right': '右 ⌘', + 'ctrl-left': '左 ⌃', + 'ctrl-right': '右 ⌃', + 'alt-left': '左 ⌥', + 'alt-right': '右 ⌥', + 'shift-left': '左 ⇧', + 'shift-right': '右 ⇧', + } + : { + 'cmd-left': platform.isWindows ? '左 Win' : '左 Super', + 'cmd-right': platform.isWindows ? '右 Win' : '右 Super', + 'ctrl-left': '左 Ctrl', + 'ctrl-right': '右 Ctrl', + 'alt-left': '左 Alt', + 'alt-right': '右 Alt', + 'shift-left': '左 Shift', + 'shift-right': '右 Shift', + 'super-left': platform.isWindows ? '左 Win' : '左 Super', + 'super-right': platform.isWindows ? '右 Win' : '右 Super', + }; + if (sideLabels[tag]) return sideLabels[tag]; if (platform.isMac) { switch (tag) { case 'cmd': return '\u2318'; @@ -274,6 +377,9 @@ function formatPrimary(primary: string): string { case 'rightcontrol': return isMac ? 'Right ⌃' : 'Right Ctrl'; case 'leftcontrol': return isMac ? 'Left ⌃' : 'Left Ctrl'; case 'rightcommand': return isMac ? 'Right ⌘' : (currentPlatform().isWindows ? 'Right Win' : 'Right Super'); + case 'leftcommand': return isMac ? 'Left ⌘' : (currentPlatform().isWindows ? 'Left Win' : 'Left Super'); + case 'leftshift': return isMac ? 'Left ⇧' : 'Left Shift'; + case 'rightshift': return isMac ? 'Right ⇧' : 'Right Shift'; case 'fn': return 'Fn'; case 'mediaplaypause': return '⏯ Media'; case 'shift': return isMac ? '⇧' : 'Shift'; diff --git a/openless-all/app/src/lib/hotkeySideModifiers.test.ts b/openless-all/app/src/lib/hotkeySideModifiers.test.ts new file mode 100644 index 00000000..7b9e85ce --- /dev/null +++ b/openless-all/app/src/lib/hotkeySideModifiers.test.ts @@ -0,0 +1,75 @@ +import { + genericModifiersFromPressedCodes, + modifiersFromPressedCodes, + shortcutFromLegacyTrigger, + sideModifiersFromPressedCodes, +} from './hotkey'; + +function assertEqual(actual: T, expected: T, message: string) { + if (actual !== expected) { + throw new Error(`${message}: expected ${JSON.stringify(expected)}, got ${JSON.stringify(actual)}`); + } +} + +function assertDeepEqual(actual: string[], expected: string[], message: string) { + assertEqual(actual.join(','), expected.join(','), message); +} + +function mockNavigator(platform: string, userAgent = '') { + Object.defineProperty(globalThis, 'navigator', { + value: { platform, userAgent }, + configurable: true, + }); +} + +assertEqual( + shortcutFromLegacyTrigger('leftCommand').primary, + 'LeftCommand', + 'maps leftCommand trigger primary', +); + +assertDeepEqual( + sideModifiersFromPressedCodes(new Set(['MetaLeft', 'ShiftRight'])), + ['cmd-left', 'shift-right'], + 'MetaLeft+ShiftRight produces side tags', +); + +assertDeepEqual( + modifiersFromPressedCodes(new Set(['MetaLeft', 'ShiftRight']), true), + ['cmd-left', 'shift-right'], + 'side-specific mode uses side tags', +); + +mockNavigator('MacIntel', 'Macintosh'); +assertDeepEqual( + genericModifiersFromPressedCodes(new Set(['MetaLeft', 'ShiftRight'])), + ['cmd', 'shift'], + 'macOS generic mode maps Meta to cmd', +); + +mockNavigator('Win32', 'Windows'); +assertDeepEqual( + genericModifiersFromPressedCodes(new Set(['MetaLeft', 'ShiftRight'])), + ['super', 'shift'], + 'non-mac generic mode maps Meta to super', +); + +assertDeepEqual( + modifiersFromPressedCodes(new Set(['MetaLeft', 'ShiftRight'])), + ['super', 'shift'], + 'default recording on non-mac uses super', +); + +assertDeepEqual( + sideModifiersFromPressedCodes(new Set(['MetaRight', 'KeyD'])), + ['cmd-right'], + 'MetaRight+D binding uses cmd-right only', +); + +assertDeepEqual( + sideModifiersFromPressedCodes(new Set(['MetaLeft', 'MetaRight'])), + ['cmd-left'], + 'left cmd wins when both meta keys are tracked', +); + +console.log('hotkeySideModifiers.test.ts passed'); diff --git a/openless-all/app/src/lib/ipc/mock-data.ts b/openless-all/app/src/lib/ipc/mock-data.ts index 402f8cc8..6c0afe10 100644 --- a/openless-all/app/src/lib/ipc/mock-data.ts +++ b/openless-all/app/src/lib/ipc/mock-data.ts @@ -104,6 +104,8 @@ export let mockSettings: UserPreferences = { androidOverlayLeftSwipeAction: "translation", androidOverlayCancelSwipeDirection: "up", androidOverlaySizeDp: 72, + mouseMiddleButtonDictation: false, + mouseSideButtonDictation: false, } const mockFullStylePrompts: StyleSystemPrompts = { diff --git a/openless-all/app/src/lib/stylePrefs.test.ts b/openless-all/app/src/lib/stylePrefs.test.ts index d049a4a5..5bb70b48 100644 --- a/openless-all/app/src/lib/stylePrefs.test.ts +++ b/openless-all/app/src/lib/stylePrefs.test.ts @@ -91,6 +91,8 @@ const previousPrefs: UserPreferences = { androidOverlayLeftSwipeAction: 'translation', androidOverlayCancelSwipeDirection: 'up', androidOverlaySizeDp: 72, + mouseMiddleButtonDictation: false, + mouseSideButtonDictation: false, }; const nextPrefs: UserPreferences = { diff --git a/openless-all/app/src/lib/types.ts b/openless-all/app/src/lib/types.ts index 17b52bd7..a16062fc 100644 --- a/openless-all/app/src/lib/types.ts +++ b/openless-all/app/src/lib/types.ts @@ -81,6 +81,9 @@ export type HotkeyTrigger = | 'rightControl' | 'leftControl' | 'rightCommand' + | 'leftCommand' + | 'leftShift' + | 'rightShift' | 'fn' | 'rightAlt' | 'mediaPlayPause' @@ -125,9 +128,9 @@ export interface HotkeyStatus { } export interface ShortcutBinding { - /** 主键,例如 "D" / "Space" / "F1" / "RightOption" / "Shift" */ + /** 主键,例如 "D" / "Space" / "F1" / "RightOption" / "LeftShift" */ primary: string; - /** 修饰符列表,元素小写:"cmd" | "shift" | "alt" | "ctrl"。 */ + /** 修饰符:泛化 tag(cmd/ctrl/…)或侧别 tag(cmd-left/ctrl-right/…)。 */ modifiers: string[]; } @@ -384,6 +387,10 @@ export interface UserPreferences { androidOverlayCancelSwipeDirection: AndroidOverlayCancelSwipeDirection; /** Android: floating overlay control diameter in dp. */ androidOverlaySizeDp: number; + /** 桌面端:鼠标中键(滚轮按下)触发听写。默认 false。 */ + mouseMiddleButtonDictation: boolean; + /** 桌面端:鼠标侧键(前进/后退)触发听写。默认 false。 */ + mouseSideButtonDictation: boolean; } export interface MarketplaceListItem { diff --git a/openless-all/app/src/pages/settings/RecordingInputSection.tsx b/openless-all/app/src/pages/settings/RecordingInputSection.tsx index c66530a9..a4d43925 100644 --- a/openless-all/app/src/pages/settings/RecordingInputSection.tsx +++ b/openless-all/app/src/pages/settings/RecordingInputSection.tsx @@ -113,6 +113,7 @@ export function RecordingInputSection() { const isAndroid = platformCaps?.platform === 'android'; const showDesktopHotkey = platformCaps?.supportsDesktopHotkey === true; const showDesktopInsert = showDesktopHotkey && os !== 'linux'; + const showMouseDictation = showDesktopHotkey; const showDesktopStartup = showDesktopHotkey; const onModeChange = (mode: HotkeyMode) => @@ -180,6 +181,8 @@ export function RecordingInputSection() { { await setDictationHotkey(binding); await savePrefs({ ...prefs, dictationHotkey: binding }); @@ -187,6 +190,28 @@ export function RecordingInputSection() { /> )} + {showMouseDictation && ( + + void savePrefs({ ...prefs, mouseMiddleButtonDictation: next })} + /> + + )} + {showMouseDictation && ( + + void savePrefs({ ...prefs, mouseSideButtonDictation: next })} + /> + + )} {showDesktopHotkey && (
diff --git a/openless-all/app/src/pages/settings/ShortcutsSection.tsx b/openless-all/app/src/pages/settings/ShortcutsSection.tsx index 0b8e9f8b..063d14b4 100644 --- a/openless-all/app/src/pages/settings/ShortcutsSection.tsx +++ b/openless-all/app/src/pages/settings/ShortcutsSection.tsx @@ -66,6 +66,8 @@ export function ShortcutsSection() { { await setDictationHotkey(binding); await savePrefs({ ...prefs, dictationHotkey: binding });