Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
17 commits
Select commit Hold shift + click to select a range
3e24ebc
feat(hotkey): add side-specific modifier combos for dictation
HKLHaoBin Jun 21, 2026
095758b
feat(dictation): mouse middle/side buttons with Hold refcount
HKLHaoBin Jun 21, 2026
71787ba
fix(dictation): keep PR2 types mouse-only without side trigger variants
HKLHaoBin Jun 21, 2026
60672bf
fix(dictation): tighten PR2 scope for mouse-only split
HKLHaoBin Jun 21, 2026
df02187
Trigger CI
Jun 21, 2026
645b35b
docs(types): drop PR1 side-modifier wording from ShortcutBinding comm…
HKLHaoBin Jun 21, 2026
2135949
fix(dictation): emit mouse release when monitor drops or disables hel…
HKLHaoBin Jun 21, 2026
1bf48fe
feat(dictation): add mouse hold mode tests and improve mouse source h…
HKLHaoBin Jun 21, 2026
b791054
merge(side-modifiers): stack PR726 onto mouse-dictation for evdev PR …
HKLHaoBin Jun 21, 2026
23a4f69
feat(linux): add evdev input for side combos and mouse dictation on L…
HKLHaoBin Jun 21, 2026
6ea63bb
fix(linux): sync mouse hold sources before evdev monitor refresh
HKLHaoBin Jun 21, 2026
ebc9fe7
fix(dictation): clear hold sources when hotkey binding changes mid-hold
HKLHaoBin Jun 21, 2026
5cf792d
fix: add missing HotkeyMode import in hotkey_loops.rs
Jun 21, 2026
a997cd1
fix: avoid nested runtime panic in tests by using block_on_async helper
Jun 21, 2026
a5871ee
fix: use futures executor to avoid nested runtime in tests
Jun 21, 2026
d22ded2
fix: constrain block_on_async Future output to ()
Jun 21, 2026
578f6d1
fix: simplify block_on_async to always use futures executor
Jun 21, 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
1 change: 1 addition & 0 deletions openless-all/app/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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"
},
Expand Down
83 changes: 79 additions & 4 deletions openless-all/app/src-tauri/Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 2 additions & 0 deletions openless-all/app/src-tauri/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down Expand Up @@ -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
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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"))]
Expand Down
2 changes: 1 addition & 1 deletion openless-all/app/src-tauri/src/combo_hotkey.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
99 changes: 81 additions & 18 deletions openless-all/app/src-tauri/src/commands/hotkeys.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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() {
Expand Down Expand Up @@ -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();
Expand Down Expand Up @@ -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();
Expand Down Expand Up @@ -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();
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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)]
Expand All @@ -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");
Expand Down Expand Up @@ -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());
}
}
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
1 change: 1 addition & 0 deletions openless-all/app/src-tauri/src/commands/qa.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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());
}
Expand Down
Loading
Loading