Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
18 commits
Select commit Hold shift + click to select a range
170a1a5
feat(dictation): mouse middle/side buttons with Hold refcount
HKLHaoBin Jun 21, 2026
77bfb36
fix(dictation): keep PR2 types mouse-only without side trigger variants
HKLHaoBin Jun 21, 2026
5acbcc0
fix(dictation): tighten PR2 scope for mouse-only split
HKLHaoBin Jun 21, 2026
fc9301a
Trigger CI
Jun 21, 2026
2956aa2
docs(types): drop PR1 side-modifier wording from ShortcutBinding comm…
HKLHaoBin Jun 21, 2026
b68b565
fix(dictation): emit mouse release when monitor drops or disables hel…
HKLHaoBin Jun 21, 2026
33de4cb
feat(dictation): add mouse hold mode tests and improve mouse source h…
HKLHaoBin Jun 21, 2026
0924e7c
test(dictation): sync_release_mouse preserves keyboard hold source
HKLHaoBin Jun 21, 2026
eb0a463
fix(dictation): clear hold sources when hotkey binding changes mid-hold
HKLHaoBin Jun 21, 2026
407ab26
fix(ci): import HotkeyMode in hotkey_loops.rs
Jun 21, 2026
813911f
fix(ci): avoid nested runtime in tests by extracting async release_mo…
Jun 21, 2026
9b22bbc
fix(ci): extract async clear_active_hold_sources_on_hotkey_rebind_asy…
Jun 21, 2026
faad142
fix(ci): use futures::executor::block_on instead of async_runtime::bl…
Jun 21, 2026
a3e0201
fix(ci): use tokio::spawn + mpsc channel instead of block_on when in …
Jun 21, 2026
f5232ae
fix(ci): use std::thread::spawn + handle.block_on to avoid blocking t…
Jun 21, 2026
5dc5b50
fix(rebase): restore lib modules and side-aware HotkeyTrigger literals
HKLHaoBin Jun 25, 2026
98f90d1
fix(rebase): restore side-aware HotkeyTrigger variants in Rust types
HKLHaoBin Jun 25, 2026
53450fc
fix(tests): implement refresh_mouse_dictation on MockWriter
HKLHaoBin Jun 25, 2026
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
16 changes: 16 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 @@ -133,6 +142,9 @@ pub(crate) fn persist_settings_with_keyboard_apply<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 windows_keyboard_list_changed = previous.windows_sendinput_insertion_only
Expand Down Expand Up @@ -207,6 +219,9 @@ pub(crate) fn persist_settings_with_keyboard_apply<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 Expand Up @@ -579,6 +594,7 @@ mod persist_settings_tests {
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
186 changes: 181 additions & 5 deletions openless-all/app/src-tauri/src/coordinator.rs
Original file line number Diff line number Diff line change
Expand Up @@ -283,7 +283,8 @@ struct Inner {
/// 代替 modifier-only 的 hotkey monitor。`None` 表示不使用自定义组合键或还没成功安装。
combo_hotkey: Mutex<Option<ComboHotkeyMonitor>>,
side_aware_combo: Mutex<Option<crate::side_aware_combo::SideAwareComboMonitor>>,
translation_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>>,
/// 翻译模式触发标志。每次 begin_session 重置为 false;hotkey 监听器在
Expand Down Expand Up @@ -405,7 +406,8 @@ impl Coordinator {
shortcut_recording_active: AtomicBool::new(false),
combo_hotkey: Mutex::new(None),
side_aware_combo: Mutex::new(None),
translation_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),
translation_modifier_seen: AtomicBool::new(false),
Expand Down Expand Up @@ -498,7 +500,8 @@ impl Coordinator {
shortcut_recording_active: AtomicBool::new(false),
combo_hotkey: Mutex::new(None),
side_aware_combo: Mutex::new(None),
translation_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),
translation_modifier_seen: AtomicBool::new(false),
Expand Down Expand Up @@ -769,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 @@ -1116,6 +1131,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 @@ -2062,6 +2078,7 @@ 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 @@ -2162,6 +2179,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 @@ -2624,7 +2800,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 @@ -2652,7 +2828,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