Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions openless-all/app/src-tauri/src/commands/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -267,6 +267,7 @@ mod tests {
switch_style_refreshes: Mutex<u32>,
open_app_refreshes: Mutex<u32>,
coding_agent_refreshes: Mutex<u32>,
mouse_dictation_refreshes: Mutex<u32>,
}

fn snapshot() -> CredentialsSnapshot {
Expand Down Expand Up @@ -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]
Expand Down
15 changes: 15 additions & 0 deletions openless-all/app/src-tauri/src/commands/settings.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down Expand Up @@ -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();
}
Expand Down Expand Up @@ -90,6 +95,10 @@ impl<T: SettingsWriter + ?Sized> SettingsWriter for Arc<T> {
(**self).refresh_combo_hotkey();
}

fn refresh_mouse_dictation(&self) {
(**self).refresh_mouse_dictation();
}

fn refresh_translation_hotkey(&self) {
(**self).refresh_translation_hotkey();
}
Expand Down Expand Up @@ -121,6 +130,9 @@ pub(crate) fn persist_settings<T: SettingsWriter>(
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;
Expand Down Expand Up @@ -151,6 +163,9 @@ pub(crate) fn persist_settings<T: SettingsWriter>(
if dictation_shortcut_changed {
coord.refresh_combo_hotkey();
}
if mouse_dictation_changed {
coord.refresh_mouse_dictation();
}
if qa_changed {
coord.refresh_qa_hotkey();
}
Expand Down
184 changes: 182 additions & 2 deletions openless-all/app/src-tauri/src/coordinator.rs
Original file line number Diff line number Diff line change
Expand Up @@ -282,6 +282,8 @@ struct Inner {
/// 自定义组合键监听器(global-hotkey crate)。当 `prefs.hotkey.trigger == Custom` 时
/// 代替 modifier-only 的 hotkey monitor。`None` 表示不使用自定义组合键或还没成功安装。
combo_hotkey: Mutex<Option<ComboHotkeyMonitor>>,
mouse_dictation: Mutex<Option<crate::mouse_dictation::MouseDictationMonitor>>,
hold_sources: crate::hold_source_tracker::HoldSourceTracker,
translation_hotkey: Mutex<Option<ComboHotkeyMonitor>>,
switch_style_hotkey: Mutex<Option<ComboHotkeyMonitor>>,
open_app_hotkey: Mutex<Option<ComboHotkeyMonitor>>,
Expand Down Expand Up @@ -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),
Expand Down Expand Up @@ -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),
Expand Down Expand Up @@ -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);
}
Expand Down Expand Up @@ -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);
Expand Down Expand Up @@ -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;

Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -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);
Expand Down Expand Up @@ -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,
Expand Down
Loading
Loading