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/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..6b67dccb 100644 --- a/openless-all/app/src-tauri/src/coordinator.rs +++ b/openless-all/app/src-tauri/src/coordinator.rs @@ -282,6 +282,8 @@ struct Inner { /// 自定义组合键监听器(global-hotkey crate)。当 `prefs.hotkey.trigger == Custom` 时 /// 代替 modifier-only 的 hotkey monitor。`None` 表示不使用自定义组合键或还没成功安装。 combo_hotkey: Mutex>, + mouse_dictation: Mutex>, + hold_sources: crate::hold_source_tracker::HoldSourceTracker, translation_hotkey: Mutex>, switch_style_hotkey: Mutex>, open_app_hotkey: Mutex>, @@ -403,6 +405,8 @@ impl Coordinator { session_cooldown_until: Mutex::new(None), shortcut_recording_active: AtomicBool::new(false), combo_hotkey: Mutex::new(None), + mouse_dictation: Mutex::new(None), + hold_sources: crate::hold_source_tracker::HoldSourceTracker::new(), translation_hotkey: Mutex::new(None), switch_style_hotkey: Mutex::new(None), open_app_hotkey: Mutex::new(None), @@ -495,6 +499,8 @@ impl Coordinator { session_cooldown_until: Mutex::new(None), shortcut_recording_active: AtomicBool::new(false), combo_hotkey: Mutex::new(None), + mouse_dictation: Mutex::new(None), + hold_sources: crate::hold_source_tracker::HoldSourceTracker::new(), translation_hotkey: Mutex::new(None), switch_style_hotkey: Mutex::new(None), open_app_hotkey: Mutex::new(None), @@ -766,6 +772,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); } @@ -1090,6 +1108,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); @@ -2028,7 +2047,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 +2150,165 @@ 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); + + release_mouse_hold_sources(&coordinator.inner).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_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 + ); + + release_mouse_hold_sources(&coordinator.inner).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::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(); + } + clear_active_hold_sources_on_hotkey_rebind_async(&coordinator.inner).await; + 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 +2771,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 +2799,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..844e8a5f 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,9 @@ use super::*; +use crate::hold_source_tracker::TriggerSource; +use crate::types::HotkeyMode; + // ─────────────────────────── hotkey bridging ─────────────────────────── pub(super) fn hotkey_supervisor_loop(inner: Arc) { @@ -574,12 +577,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 +1010,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 +1049,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 +1152,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; } _ => {} } @@ -1167,3 +1187,171 @@ pub(super) fn window_key_matches_trigger(trigger: crate::types::HotkeyTrigger, k HotkeyTrigger::Custom => false, } } + +pub(super) async fn 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; + match phase { + SessionPhase::Listening => { + let _ = end_session(inner).await; + } + SessionPhase::Starting => { + request_stop_during_starting( + inner, + "mouse dictation disabled while held", + ); + } + _ => {} + } +} + +pub(super) fn sync_release_mouse_hold_sources(inner: &Arc) { + if let Ok(handle) = tokio::runtime::Handle::try_current() { + let inner_clone = Arc::clone(inner); + std::thread::spawn(move || { + handle.block_on(release_mouse_hold_sources(&inner_clone)); + }) + .join() + .unwrap(); + } else { + async_runtime::block_on(release_mouse_hold_sources(inner)); + } +} + +/// 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) async fn clear_active_hold_sources_on_hotkey_rebind_async(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; + match phase { + SessionPhase::Listening => { + let _ = end_session(inner).await; + } + SessionPhase::Starting => { + request_stop_during_starting( + inner, + "hotkey binding changed while hold sources active", + ); + } + _ => {} + } +} + +pub(super) fn clear_active_hold_sources_on_hotkey_rebind(inner: &Arc) { + if let Ok(handle) = tokio::runtime::Handle::try_current() { + let inner_clone = Arc::clone(inner); + std::thread::spawn(move || { + handle.block_on(clear_active_hold_sources_on_hotkey_rebind_async(&inner_clone)); + }) + .join() + .unwrap(); + } else { + async_runtime::block_on(clear_active_hold_sources_on_hotkey_rebind_async(inner)); + } +} + +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_mouse = config.middle_enabled || config.side_enabled; + + #[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 { + sync_release_mouse_hold_sources(&inner); + inner.mouse_dictation.lock().take(); + return; + } + + if inner.mouse_dictation.lock().is_some() { + crate::mouse_dictation::MouseDictationMonitor::update_config(config); + 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(); + 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) +} + +pub(super) fn update_mouse_dictation_binding_now(inner: &Arc) { + sync_release_mouse_hold_sources(inner); + inner.mouse_dictation.lock().take(); + 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..96db3a5f 100644 --- a/openless-all/app/src-tauri/src/hotkey.rs +++ b/openless-all/app/src-tauri/src/hotkey.rs @@ -399,10 +399,13 @@ mod platform { const KEY_DOWN: CgEventType = 10; 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 +467,10 @@ 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 << OTHER_MOUSE_DOWN) + | (1u64 << OTHER_MOUSE_UP); let handles = Arc::new(MacShutdownHandles { tap: std::sync::Mutex::new(None), runloop: std::sync::Mutex::new(None), @@ -532,6 +538,12 @@ mod platform { } FLAGS_CHANGED => handle_flags_changed(ctx, event), KEY_DOWN => handle_key_down(ctx, event), + 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 diff --git a/openless-all/app/src-tauri/src/lib.rs b/openless-all/app/src-tauri/src/lib.rs index 8b75b5df..04dec87c 100644 --- a/openless-all/app/src-tauri/src/lib.rs +++ b/openless-all/app/src-tauri/src/lib.rs @@ -67,6 +67,12 @@ 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(mobile)] #[path = "mobile_stubs/shortcut_binding.rs"] mod shortcut_binding; @@ -684,6 +690,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/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/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/types.rs b/openless-all/app/src-tauri/src/types.rs index c63c65a1..eee58ba1 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, } } } diff --git a/openless-all/app/src/i18n/en.ts b/openless-all/app/src/i18n/en.ts index 6a17fecf..f2a57399 100644 --- a/openless-all/app/src/i18n/en.ts +++ b/openless-all/app/src/i18n/en.ts @@ -682,6 +682,10 @@ export const en: typeof zhCN = { comboRecorded: 'Recorded', comboClear: 'Clear', comboConflict: 'This shortcut combination is not available', + mouseMiddleLabel: 'Middle mouse button', + mouseMiddleDesc: 'Press the scroll wheel (middle click) to start/stop dictation. Independent of keyboard shortcuts.', + mouseSideLabel: 'Side mouse buttons', + mouseSideDesc: 'Press mouse side buttons (forward/back) to start/stop dictation. Independent of keyboard shortcuts.', allowNonTsfFallbackLabel: 'Allow non-TSF fallback', allowNonTsfFallbackDesc: 'Windows: when TSF insertion fails, use paced Unicode SendInput; if that still fails, copy the text to the clipboard.', historyGroupTitle: 'History & context', diff --git a/openless-all/app/src/i18n/ja.ts b/openless-all/app/src/i18n/ja.ts index f4c25e04..5c68d826 100644 --- a/openless-all/app/src/i18n/ja.ts +++ b/openless-all/app/src/i18n/ja.ts @@ -684,6 +684,10 @@ export const ja: typeof zhCN = { comboRecorded: '記録済み', comboClear: 'クリア', comboConflict: 'このショートカットの組み合わせは使用できません', + mouseMiddleLabel: 'マウス中ボタンで音声入力', + mouseMiddleDesc: 'ホイールクリック(中ボタン)で音声入力の開始/停止。キーボードショートカットとは独立。', + mouseSideLabel: 'マウスサイドボタンで音声入力', + mouseSideDesc: 'サイドボタン(進む/戻る)で音声入力の開始/停止。キーボードショートカットとは独立。', allowNonTsfFallbackLabel: '非 TSF フォールバックを許可', allowNonTsfFallbackDesc: 'Windows:TSF 入力が失敗した時は分割した Unicode SendInput を使い、それも失敗した場合はクリップボードへコピーします。', historyGroupTitle: '履歴とコンテキスト', diff --git a/openless-all/app/src/i18n/ko.ts b/openless-all/app/src/i18n/ko.ts index 95b74594..ba26a8c1 100644 --- a/openless-all/app/src/i18n/ko.ts +++ b/openless-all/app/src/i18n/ko.ts @@ -684,6 +684,10 @@ export const ko: typeof zhCN = { comboRecorded: '녹화됨', comboClear: '지우기', comboConflict: '이 단축키 조합은 사용할 수 없습니다', + mouseMiddleLabel: '마우스 휠 버튼으로 음성 입력', + mouseMiddleDesc: '휠 클릭(가운데 버튼)으로 음성 입력 시작/중지. 키보드 단축키와 독립적입니다.', + mouseSideLabel: '마우스 측면 버튼으로 음성 입력', + mouseSideDesc: '측면 버튼(앞으로/뒤로)으로 음성 입력 시작/중지. 키보드 단축키와 독립적입니다.', allowNonTsfFallbackLabel: '비 TSF 폴백 허용', allowNonTsfFallbackDesc: 'Windows: TSF 입력이 실패하면 분할된 Unicode SendInput을 사용하고, 그래도 실패하면 텍스트를 클립보드에 복사합니다.', historyGroupTitle: '기록 및 컨텍스트', diff --git a/openless-all/app/src/i18n/zh-CN.ts b/openless-all/app/src/i18n/zh-CN.ts index 2d87e8ab..3cd902b9 100644 --- a/openless-all/app/src/i18n/zh-CN.ts +++ b/openless-all/app/src/i18n/zh-CN.ts @@ -680,6 +680,10 @@ export const zhCN = { comboRecorded: '已录制', comboClear: '清除', comboConflict: '该快捷键组合不可用', + mouseMiddleLabel: '鼠标中键唤起识别', + mouseMiddleDesc: '按下鼠标滚轮(中键)开始/停止语音识别,与键盘快捷键独立。', + mouseSideLabel: '鼠标侧键唤起识别', + mouseSideDesc: '按下鼠标侧键(前进/后退)开始/停止语音识别,与键盘快捷键独立。', allowNonTsfFallbackLabel: '允许非 TSF 兜底', allowNonTsfFallbackDesc: 'Windows:TSF 失败时使用分批 Unicode SendInput;如果仍失败,再复制到剪贴板。', historyGroupTitle: '历史与上下文', diff --git a/openless-all/app/src/i18n/zh-TW.ts b/openless-all/app/src/i18n/zh-TW.ts index 4390c7d4..16280ab1 100644 --- a/openless-all/app/src/i18n/zh-TW.ts +++ b/openless-all/app/src/i18n/zh-TW.ts @@ -657,6 +657,10 @@ export const zhTW: typeof zhCN = { comboRecorded: '已錄製', comboClear: '清除', comboConflict: '此快捷鍵組合不可用', + mouseMiddleLabel: '滑鼠中鍵喚起識別', + mouseMiddleDesc: '按下滑鼠滾輪(中鍵)開始/停止語音識別,與鍵盤快捷鍵獨立。', + mouseSideLabel: '滑鼠側鍵喚起識別', + mouseSideDesc: '按下滑鼠側鍵(前進/後退)開始/停止語音識別,與鍵盤快捷鍵獨立。', microphoneLabel: '首選麥克風', microphoneDesc: '選擇優先使用的輸入設備。設備暫時不可用時會使用系統默認麥克風,重新連接後自動切回首選設備。', microphoneDefault: '系統默認麥克風', 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..a1db2dcb 100644 --- a/openless-all/app/src/lib/types.ts +++ b/openless-all/app/src/lib/types.ts @@ -127,7 +127,7 @@ export interface HotkeyStatus { export interface ShortcutBinding { /** 主键,例如 "D" / "Space" / "F1" / "RightOption" / "Shift" */ primary: string; - /** 修饰符列表,元素小写:"cmd" | "shift" | "alt" | "ctrl"。 */ + /** 修饰符 tag,例如 "cmd" | "shift" | "alt" | "ctrl" */ modifiers: string[]; } @@ -384,6 +384,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..504786b4 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 && os !== 'linux'; const showDesktopStartup = showDesktopHotkey; const onModeChange = (mode: HotkeyMode) => @@ -187,6 +188,28 @@ export function RecordingInputSection() { /> )} + {showMouseDictation && ( + + void savePrefs({ ...prefs, mouseMiddleButtonDictation: next })} + /> + + )} + {showMouseDictation && ( + + void savePrefs({ ...prefs, mouseSideButtonDictation: next })} + /> + + )} {showDesktopHotkey && (