fix(hotkey): Support Space and Mouse4/5 as dictation shortcuts (#1113)
* fix(hotkey): allow Space and Mouse4/5 as dictation shortcuts Normalize Space recording to the named primary so validate/parse no longer trim it to empty (#1109), surface real binding/register errors, and add a Windows WH_MOUSE_LL path so Mouse4/Mouse5 can be recorded, saved, and trigger dictation globally (#1099). * fix(i18n): add mouseSideHint to remaining locales Unblocks tsc after Mouse4/Mouse5 recording hint was added for en/zh-CN. * fix(linux-egui): use current time in host_contract history fixture Hardcoded 2026-08-27 falls outside the test's 30-day retention window as of 2026-09-26, so append_history pruned the entry and list_history returned []. * fix(linux-egui): restore missing main.rs test helpers cargo check --all-targets failed because remote UI unit tests still called disconnected_app/rendered_text after those fixtures were dropped. * fix(linux-egui): drop obsolete remote_ui unit test remote_ui was removed from OpenLessEguiApp but the fingerprint rendering test still called it, breaking cargo check --all-targets on Linux egui CI. * fix(core): avoid tokio::spawn in e2ee sync store for TaskSpawner seam Linux egui CI's check-core-runtime-seam rejects production tokio::spawn. Match the in-task async blocks used by the last green Linux egui PR. * fix(core): mark e2ee test modules for TaskSpawner seam scanner The seam gate only recognizes #[cfg(test)], not #![cfg(test)]. Add an explicit marker module so test-only tokio::spawn usage is ignored. * fix(ci): exclude desktop registration on mobile and stabilize history fixture --------- Co-authored-by: Chris233 <h-chris233@outlook.com>
This commit is contained in:
1 parent
913279c049
commit
820d7acf7a
29 files changed
+1233
-292
No files matched your search
@@ -9343,6 +9343,42 @@ mod tests {
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn mouse_shortcut_registration_failure_rolls_back_before_retry() {
|
||||
let (backend, _) = backend();
|
||||
let previous = backend.get_preferences().dictation_hotkey;
|
||||
let mut next = backend.get_preferences();
|
||||
next.dictation_hotkey = crate::shared_types::ShortcutBinding {
|
||||
primary: "Mouse4".into(),
|
||||
modifiers: vec!["ctrl".into()],
|
||||
};
|
||||
let runtime = RecordingSettingsRuntime {
|
||||
fail_commit: true,
|
||||
..RecordingSettingsRuntime::default()
|
||||
};
|
||||
assert!(backend
|
||||
.update_settings(next.clone(), crate::SettingsUpdateOptions::STRICT, &runtime)
|
||||
.is_err());
|
||||
assert_eq!(backend.get_preferences().dictation_hotkey, previous);
|
||||
assert_eq!(backend.snapshot().preferences_revision, 0);
|
||||
assert_eq!(
|
||||
runtime.actions.lock().unwrap().as_slice(),
|
||||
["prepare", "commit", "restore"]
|
||||
);
|
||||
backend
|
||||
.update_settings(
|
||||
next.clone(),
|
||||
crate::SettingsUpdateOptions::STRICT,
|
||||
&RecordingSettingsRuntime::default(),
|
||||
)
|
||||
.unwrap();
|
||||
assert_eq!(
|
||||
backend.get_preferences().dictation_hotkey,
|
||||
next.dictation_hotkey
|
||||
);
|
||||
assert_eq!(backend.snapshot().preferences_revision, 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn settings_persistence_failure_restores_prepared_effects() {
|
||||
let host = Arc::new(FakeHost::default());
|
||||
@@ -11026,7 +11062,9 @@ mod tests {
|
||||
let mut preferences = backend.get_preferences();
|
||||
preferences.translation_target_language = "English".to_string();
|
||||
preferences.working_languages = vec!["简体中文".to_string()];
|
||||
preferences.history_retention_days = 30;
|
||||
// This fixed-clock test checks snapshot persistence, not wall-clock retention.
|
||||
// Retention is covered independently by history::tests.
|
||||
preferences.history_retention_days = 0;
|
||||
preferences.history_max_entries = Some(20);
|
||||
backend.set_preferences(preferences).unwrap();
|
||||
backend.start().await.unwrap();
|
||||
|
||||
@@ -330,9 +330,10 @@ pub use shared_types::{
|
||||
LOCAL_ASR_KEEP_LOADED_FOREVER_SECS,
|
||||
};
|
||||
pub use shortcut_types::{
|
||||
binding_from_legacy_trigger, binding_requires_side_aware_hook, bindings_overlap,
|
||||
is_modifier_chord_binding, is_side_specific_modifier_tag, legacy_modifier_trigger,
|
||||
normalize_side_modifier_tag, reconcile_hotkey_collisions, reject_bare_shift_dictation_shortcut,
|
||||
binding_from_legacy_trigger, binding_requires_mouse_hook, binding_requires_side_aware_hook,
|
||||
bindings_overlap, is_modifier_chord_binding, is_mouse_button_primary,
|
||||
is_side_specific_modifier_tag, legacy_modifier_trigger, normalize_side_modifier_tag,
|
||||
reconcile_hotkey_collisions, reject_bare_shift_dictation_shortcut,
|
||||
reject_dictation_qa_hotkey_overlap, reject_dictation_translation_hotkey_overlap,
|
||||
reject_hotkey_collisions, reject_modifier_only_action_shortcut,
|
||||
reject_non_dictation_side_specific_shortcuts, reject_qa_less_computer_hotkey_overlap,
|
||||
@@ -340,7 +341,7 @@ pub use shortcut_types::{
|
||||
reject_qa_translation_hotkey_overlap, reject_selection_polish_hotkey_collisions,
|
||||
reject_side_specific_non_dictation, reject_style_pack_hotkey_conflicts,
|
||||
sync_dictation_hotkey_legacy_fields, validate_shortcut_binding, ShortcutBindingError,
|
||||
SIDE_SPECIFIC_NON_DICTATION_MSG,
|
||||
MOUSE_NON_DICTATION_MSG, SIDE_SPECIFIC_NON_DICTATION_MSG,
|
||||
};
|
||||
pub use silence_auto_stop::{SilenceAutoStop, SilenceDecision};
|
||||
pub use streaming_insert::{
|
||||
|
||||
@@ -56,13 +56,30 @@ pub fn binding_requires_side_aware_hook(binding: &ShortcutBinding) -> bool {
|
||||
.any(|tag| is_side_specific_modifier_tag(tag))
|
||||
}
|
||||
|
||||
pub fn is_mouse_button_primary(primary: &str) -> bool {
|
||||
matches!(
|
||||
primary.trim().to_ascii_uppercase().as_str(),
|
||||
"MOUSE4" | "MOUSE5"
|
||||
)
|
||||
}
|
||||
|
||||
pub fn binding_requires_mouse_hook(binding: &ShortcutBinding) -> bool {
|
||||
is_mouse_button_primary(&binding.primary)
|
||||
}
|
||||
|
||||
pub const SIDE_SPECIFIC_NON_DICTATION_MSG: &str =
|
||||
"Side-specific modifier shortcuts are only supported for dictation start/stop.";
|
||||
|
||||
pub const MOUSE_NON_DICTATION_MSG: &str =
|
||||
"Mouse button shortcuts are only supported for dictation start/stop.";
|
||||
|
||||
pub fn reject_side_specific_non_dictation(binding: &ShortcutBinding) -> Result<(), String> {
|
||||
if binding.primary == "MacDictationKey" {
|
||||
return Err("The Mac Dictation key is only supported for dictation start/stop.".into());
|
||||
}
|
||||
if binding_requires_mouse_hook(binding) {
|
||||
return Err(MOUSE_NON_DICTATION_MSG.to_string());
|
||||
}
|
||||
if binding_requires_side_aware_hook(binding) {
|
||||
return Err(SIDE_SPECIFIC_NON_DICTATION_MSG.to_string());
|
||||
}
|
||||
@@ -234,6 +251,12 @@ pub fn validate_shortcut_binding(binding: &ShortcutBinding) -> Result<(), Shortc
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
if binding_requires_mouse_hook(binding) && binding_requires_side_aware_hook(binding) {
|
||||
return Err(ShortcutBindingError::UnsupportedModifier(
|
||||
"mouse button bindings do not support side-specific modifiers".into(),
|
||||
));
|
||||
}
|
||||
|
||||
validate_primary(&binding.primary)?;
|
||||
for raw in &binding.modifiers {
|
||||
if binding_requires_side_aware_hook(binding) {
|
||||
@@ -264,6 +287,8 @@ pub fn validate_shortcut_binding(binding: &ShortcutBinding) -> Result<(), Shortc
|
||||
}
|
||||
|
||||
fn validate_primary(raw: &str) -> Result<(), ShortcutBindingError> {
|
||||
// Literal space character must not be trimmed to empty (#1109).
|
||||
let raw = if raw == " " { "Space" } else { raw };
|
||||
let trimmed = raw.trim();
|
||||
if trimmed.is_empty() {
|
||||
return Err(ShortcutBindingError::UnsupportedKey("(空)".into()));
|
||||
@@ -300,6 +325,8 @@ fn validate_primary(raw: &str) -> Result<(), ShortcutBindingError> {
|
||||
| "LEFT"
|
||||
| "ARROWRIGHT"
|
||||
| "RIGHT"
|
||||
| "MOUSE4"
|
||||
| "MOUSE5"
|
||||
| "F1"
|
||||
| "F2"
|
||||
| "F3"
|
||||
@@ -886,6 +913,24 @@ mod tests {
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn mouse_bindings_are_dictation_only_and_reject_side_modifiers() {
|
||||
for primary in ["Mouse4", "Mouse5"] {
|
||||
let binding = combo(primary, &["ctrl"]);
|
||||
assert!(validate_shortcut_binding(&binding).is_ok());
|
||||
assert_eq!(
|
||||
reject_side_specific_non_dictation(&binding).unwrap_err(),
|
||||
MOUSE_NON_DICTATION_MSG
|
||||
);
|
||||
assert!(validate_shortcut_binding(&combo(primary, &["ctrl-left"])).is_err());
|
||||
let preferences = UserPreferences {
|
||||
qa_hotkey: Some(binding),
|
||||
..UserPreferences::default()
|
||||
};
|
||||
assert!(reject_hotkey_collisions(&preferences).is_err());
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn side_specific_rules_are_shared_by_all_hosts() {
|
||||
let side_specific = combo("D", &["cmd-left", "shift-right"]);
|
||||
@@ -906,7 +951,9 @@ mod tests {
|
||||
assert!(is_modifier_chord_binding(&chord));
|
||||
|
||||
assert!(validate_shortcut_binding(&combo("ModifierChord", &["ctrl-left"])).is_err());
|
||||
assert!(validate_shortcut_binding(&combo("ModifierChord", &["ctrl", "super-left"])).is_err());
|
||||
assert!(
|
||||
validate_shortcut_binding(&combo("ModifierChord", &["ctrl", "super-left"])).is_err()
|
||||
);
|
||||
assert!(
|
||||
validate_shortcut_binding(&combo("ModifierChord", &["cmd-left", "super-left"]))
|
||||
.is_err()
|
||||
|
||||
@@ -555,6 +555,20 @@ fn unsupported_primary(primary: &str) -> BackendError {
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn mouse_shortcuts_are_rejected_by_the_linux_host() {
|
||||
for primary in ["Mouse4", "Mouse5"] {
|
||||
let binding = ShortcutBinding {
|
||||
primary: primary.into(),
|
||||
modifiers: vec!["ctrl".into()],
|
||||
};
|
||||
assert_eq!(
|
||||
shortcut_to_raw(&binding).unwrap_err().code,
|
||||
BackendErrorCode::Unsupported
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn raw_shortcut_conversion_covers_default_linux_actions() {
|
||||
let qa = ShortcutBinding {
|
||||
|
||||
+74
-7
@@ -789,7 +789,7 @@ dependencies = [
|
||||
"js-sys",
|
||||
"log",
|
||||
"wasm-bindgen",
|
||||
"windows-core",
|
||||
"windows-core 0.62.2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -1173,6 +1173,7 @@ version = "0.1.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"base64",
|
||||
"log",
|
||||
"openless-core",
|
||||
"rcgen",
|
||||
"rustls",
|
||||
@@ -1180,9 +1181,11 @@ dependencies = [
|
||||
"serde_json",
|
||||
"sha2",
|
||||
"tempfile",
|
||||
"thiserror 2.0.20",
|
||||
"time",
|
||||
"tokio",
|
||||
"uuid",
|
||||
"windows",
|
||||
"x509-parser",
|
||||
]
|
||||
|
||||
@@ -2468,17 +2471,51 @@ dependencies = [
|
||||
"rustls-pki-types",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "windows"
|
||||
version = "0.58.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "dd04d41d93c4992d421894c18c8b43496aa748dd4c081bac0dc93eb0489272b6"
|
||||
dependencies = [
|
||||
"windows-core 0.58.0",
|
||||
"windows-targets",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "windows-core"
|
||||
version = "0.58.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "6ba6d44ec8c2591c134257ce647b7ea6b20335bf6379a27dac5f1641fcf59f99"
|
||||
dependencies = [
|
||||
"windows-implement 0.58.0",
|
||||
"windows-interface 0.58.0",
|
||||
"windows-result 0.2.0",
|
||||
"windows-strings 0.1.0",
|
||||
"windows-targets",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "windows-core"
|
||||
version = "0.62.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "b8e83a14d34d0623b51dce9581199302a221863196a1dde71a7663a4c2be9deb"
|
||||
dependencies = [
|
||||
"windows-implement",
|
||||
"windows-interface",
|
||||
"windows-implement 0.60.2",
|
||||
"windows-interface 0.59.3",
|
||||
"windows-link",
|
||||
"windows-result",
|
||||
"windows-strings",
|
||||
"windows-result 0.4.1",
|
||||
"windows-strings 0.5.1",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "windows-implement"
|
||||
version = "0.58.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "2bbd5b46c938e506ecbce286b6628a02171d56153ba733b6c741fc627ec9579b"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"syn 2.0.119",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -2492,6 +2529,17 @@ dependencies = [
|
||||
"syn 2.0.119",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "windows-interface"
|
||||
version = "0.58.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "053c4c462dc91d3b1504c6fe5a726dd15e216ba718e84a0e46a88fbe5ded3515"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"syn 2.0.119",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "windows-interface"
|
||||
version = "0.59.3"
|
||||
@@ -2516,8 +2564,17 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "02752bf7fbdcce7f2a27a742f798510f3e5ad88dbe84871e5168e2120c3d5720"
|
||||
dependencies = [
|
||||
"windows-link",
|
||||
"windows-result",
|
||||
"windows-strings",
|
||||
"windows-result 0.4.1",
|
||||
"windows-strings 0.5.1",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "windows-result"
|
||||
version = "0.2.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "1d1043d8214f791817bab27572aaa8af63732e11bf84aa21a45a78d6c317ae0e"
|
||||
dependencies = [
|
||||
"windows-targets",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -2529,6 +2586,16 @@ dependencies = [
|
||||
"windows-link",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "windows-strings"
|
||||
version = "0.1.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "4cd9b125c486025df0eabcb585e62173c6c9eddcec5d117d3b6e8c30e2ee4d10"
|
||||
dependencies = [
|
||||
"windows-result 0.2.0",
|
||||
"windows-targets",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "windows-strings"
|
||||
version = "0.5.1"
|
||||
|
||||
@@ -20,3 +20,10 @@ tempfile = "3"
|
||||
base64 = "0.22"
|
||||
sha2 = "0.10"
|
||||
uuid = { version = "1", features = ["v4"] }
|
||||
|
||||
[dev-dependencies]
|
||||
log = "0.4"
|
||||
thiserror = "2"
|
||||
|
||||
[target.'cfg(windows)'.dev-dependencies]
|
||||
windows = { version = "0.58", features = ["Win32_Foundation", "Win32_System_Threading", "Win32_UI_WindowsAndMessaging", "Win32_UI_Input_KeyboardAndMouse"] }
|
||||
@@ -0,0 +1,17 @@
|
||||
//! Run the production mouse monitor without loading Tauri's ASR native DLLs.
|
||||
mod types {
|
||||
pub use openless_core::shared_types::ShortcutBinding;
|
||||
}
|
||||
|
||||
mod shortcut_binding {
|
||||
pub use openless_core::binding_requires_mouse_hook;
|
||||
}
|
||||
|
||||
// Only the shared event/error DTOs are used, never the stub's registration methods.
|
||||
#[allow(dead_code)]
|
||||
#[path = "../../src/mobile_stubs/combo_hotkey.rs"]
|
||||
mod combo_hotkey;
|
||||
|
||||
#[allow(dead_code)]
|
||||
#[path = "../../src/mouse_dictation.rs"]
|
||||
mod mouse_dictation;
|
||||
@@ -16,6 +16,23 @@ struct TauriSettingsRuntime<'a> {
|
||||
coord: &'a Coordinator,
|
||||
}
|
||||
|
||||
fn settings_save_error(error: openless_core::BackendError) -> String {
|
||||
let mut message = error.message;
|
||||
if let Some(failures) = error
|
||||
.details
|
||||
.as_ref()
|
||||
.and_then(|details| details.get("compensationErrors"))
|
||||
.and_then(serde_json::Value::as_array)
|
||||
{
|
||||
for failure in failures {
|
||||
if let Some(reason) = failure.get("message").and_then(serde_json::Value::as_str) {
|
||||
message.push_str(&format!("; rollback failed: {reason}"));
|
||||
}
|
||||
}
|
||||
}
|
||||
message
|
||||
}
|
||||
|
||||
impl<'a> TauriSettingsRuntime<'a> {
|
||||
fn new(coord: &'a Coordinator) -> Self {
|
||||
Self { coord }
|
||||
@@ -167,7 +184,7 @@ fn persist_settings_with_host_lock_held(
|
||||
);
|
||||
}
|
||||
})
|
||||
.map_err(|error| error.to_string())
|
||||
.map_err(settings_save_error)
|
||||
}
|
||||
|
||||
fn persist_settings_preserving_update_channel(
|
||||
@@ -194,7 +211,7 @@ pub(crate) fn persist_strict_settings(
|
||||
&TauriSettingsRuntime::new(coord),
|
||||
)
|
||||
.map(|_| ())
|
||||
.map_err(|error| error.to_string())
|
||||
.map_err(settings_save_error)
|
||||
}
|
||||
|
||||
async fn invalidate_llm_tests_if_thinking_changed(
|
||||
@@ -313,6 +330,21 @@ pub async fn set_settings(
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn shortcut_save_reports_registration_and_rollback_failure() {
|
||||
let mut error = openless_core::BackendError::new(
|
||||
openless_core::BackendErrorCode::Platform,
|
||||
"hook installation failed",
|
||||
);
|
||||
error.details = Some(serde_json::json!({
|
||||
"compensationErrors": [{"message": "old binding restore failed"}]
|
||||
}));
|
||||
assert_eq!(
|
||||
settings_save_error(error),
|
||||
"hook installation failed; rollback failed: old binding restore failed"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn settings_save_preserves_current_style_preferences_before_write() {
|
||||
let packs = crate::types::builtin_style_packs();
|
||||
@@ -721,12 +753,12 @@ pub(crate) fn replace_dictation_hotkey(
|
||||
if coord.dictation_shortcut_is_busy() {
|
||||
return Err("macDictationKeyBusy".into());
|
||||
}
|
||||
if binding == prefs.dictation_hotkey {
|
||||
// No settings effect is generated for an unchanged binding.
|
||||
return coord.try_update_native_dictation_binding();
|
||||
}
|
||||
}
|
||||
}
|
||||
if binding == prefs.dictation_hotkey {
|
||||
// Re-saving an unchanged binding must retry a failed startup listener.
|
||||
return coord.try_update_native_dictation_binding();
|
||||
}
|
||||
prefs.dictation_hotkey = binding;
|
||||
sync_dictation_hotkey_legacy_fields(&mut prefs);
|
||||
reject_hotkey_collisions(&prefs)?;
|
||||
@@ -738,5 +770,5 @@ pub(crate) fn replace_dictation_hotkey(
|
||||
&TauriSettingsRuntime::new(coord),
|
||||
)
|
||||
.map(|_| ())
|
||||
.map_err(|error| error.to_string())
|
||||
.map_err(settings_save_error)
|
||||
}
|
||||
@@ -30,7 +30,6 @@ mod capsule_focus;
|
||||
#[path = "coordinator/dictation_core.rs"]
|
||||
mod dictation;
|
||||
mod hotkey_loops;
|
||||
#[cfg(target_os = "macos")]
|
||||
mod native_dictation_key;
|
||||
mod qa;
|
||||
mod restore_runtime;
|
||||
@@ -42,6 +41,7 @@ pub(crate) use capsule_focus::{
|
||||
restore_focus_target_if_possible,
|
||||
};
|
||||
use hotkey_loops::*;
|
||||
use native_dictation_key::try_install_mouse_dictation;
|
||||
|
||||
// Instance-local Less Computer replay source used by the compatibility command.
|
||||
pub(crate) use dictation::{less_computer_event_replay_after, LessComputerEventReplay};
|
||||
@@ -519,6 +519,8 @@ struct Inner {
|
||||
/// 代替 modifier-only 的 hotkey monitor。`None` 表示不使用自定义组合键或还没成功安装。
|
||||
combo_hotkey: Mutex<Option<ComboHotkeyMonitor>>,
|
||||
side_aware_combo: Mutex<Option<crate::side_aware_combo::SideAwareComboMonitor>>,
|
||||
/// Mouse4/Mouse5 听写监听(WH_MOUSE_LL);与 combo / side-aware 互斥。
|
||||
mouse_dictation: Mutex<Option<crate::mouse_dictation::MouseDictationMonitor>>,
|
||||
translation_hotkey: Mutex<Option<ComboHotkeyMonitor>>,
|
||||
switch_style_hotkey: Mutex<Option<ComboHotkeyMonitor>>,
|
||||
open_app_hotkey: Mutex<Option<ComboHotkeyMonitor>>,
|
||||
@@ -676,6 +678,7 @@ impl Coordinator {
|
||||
less_computer_combo_pending_press: Mutex::new(None),
|
||||
combo_hotkey: Mutex::new(None),
|
||||
side_aware_combo: Mutex::new(None),
|
||||
mouse_dictation: Mutex::new(None),
|
||||
translation_hotkey: Mutex::new(None),
|
||||
switch_style_hotkey: Mutex::new(None),
|
||||
open_app_hotkey: Mutex::new(None),
|
||||
@@ -804,6 +807,7 @@ impl Coordinator {
|
||||
less_computer_combo_pending_press: Mutex::new(None),
|
||||
combo_hotkey: Mutex::new(None),
|
||||
side_aware_combo: Mutex::new(None),
|
||||
mouse_dictation: Mutex::new(None),
|
||||
translation_hotkey: Mutex::new(None),
|
||||
switch_style_hotkey: Mutex::new(None),
|
||||
open_app_hotkey: Mutex::new(None),
|
||||
@@ -979,6 +983,7 @@ impl Coordinator {
|
||||
#[allow(dead_code)]
|
||||
pub fn request_shutdown(&self) {
|
||||
self.inner.shutdown.store(true, Ordering::SeqCst);
|
||||
self.inner.mouse_dictation.lock().take();
|
||||
}
|
||||
|
||||
/// Call once from RunEvent::Ready, even when recovery is still pending.
|
||||
@@ -1207,82 +1212,6 @@ impl Coordinator {
|
||||
try_sync_style_pack_hotkeys_on_main_thread(&self.inner)
|
||||
}
|
||||
|
||||
/// 用户在设置里改了自定义组合键时调用。
|
||||
pub(crate) fn update_combo_hotkey_binding(&self) {
|
||||
let target = hotkey_runtime_target(&self.inner);
|
||||
if crate::shortcut_binding::legacy_modifier_trigger(&target.dictation).is_some() {
|
||||
take_combo_hotkey_on_main_thread(&self.inner);
|
||||
self.inner.side_aware_combo.lock().take();
|
||||
log::info!("[coord] combo hotkey 已关闭(modifier-only)");
|
||||
return;
|
||||
}
|
||||
let binding = target.dictation;
|
||||
if is_unconfigured_shortcut(&binding) {
|
||||
take_combo_hotkey_on_main_thread(&self.inner);
|
||||
self.inner.side_aware_combo.lock().take();
|
||||
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::<HotkeyEvent>();
|
||||
let combo_tx = spawn_combo_abort_bridge(&self.inner, handle_trigger_combined);
|
||||
match crate::side_aware_combo::SideAwareComboMonitor::start(binding, tx, combo_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 || 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}");
|
||||
}
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
self.inner.side_aware_combo.lock().take();
|
||||
let inner_clone = Arc::clone(&self.inner);
|
||||
let binding_for_main = binding.clone();
|
||||
if self
|
||||
.inner
|
||||
.host
|
||||
.run_on_main_thread(move || {
|
||||
if let Some(monitor) = inner_clone.combo_hotkey.lock().as_ref() {
|
||||
if let Err(e) = monitor.update_binding(binding_for_main.clone()) {
|
||||
log::warn!("[coord] update combo hotkey binding 失败: {e}");
|
||||
}
|
||||
return;
|
||||
}
|
||||
let (tx, rx) = mpsc::channel::<ComboHotkeyEvent>();
|
||||
match ComboHotkeyMonitor::start(binding_for_main, tx) {
|
||||
Ok(monitor) => {
|
||||
*inner_clone.combo_hotkey.lock() = Some(monitor);
|
||||
log::info!(
|
||||
"[coord] combo hotkey listener installed on main thread (via update)"
|
||||
);
|
||||
let bridge_inner = Arc::clone(&inner_clone);
|
||||
std::thread::Builder::new()
|
||||
.name("openless-combo-hotkey-bridge".into())
|
||||
.spawn(move || combo_hotkey_bridge_loop(bridge_inner, rx))
|
||||
.ok();
|
||||
}
|
||||
Err(e) => {
|
||||
log::warn!("[coord] update combo hotkey binding 失败: {e}");
|
||||
}
|
||||
}
|
||||
})
|
||||
.is_err()
|
||||
{
|
||||
log::warn!("[coord] update combo hotkey binding: AppHandle 未 bind,跳过");
|
||||
}
|
||||
}
|
||||
|
||||
/// 用户在设置里改了 QA 组合键时调用。先持久化(由 prefs.set 完成),
|
||||
/// 然后通知活着的 monitor 重新注册;monitor 不存在时 supervisor 会自然
|
||||
/// 在下一次循环里读到新的 prefs。
|
||||
@@ -1492,29 +1421,6 @@ impl Coordinator {
|
||||
report_insert_fallback_card_height(&self.inner, presentation_id, height)
|
||||
}
|
||||
|
||||
pub(crate) fn update_hotkey_binding(&self) {
|
||||
let target = hotkey_runtime_target(&self.inner);
|
||||
let dictation_trigger = crate::shortcut_binding::legacy_modifier_trigger(&target.dictation);
|
||||
let binding = crate::types::HotkeyBinding {
|
||||
trigger: dictation_trigger.unwrap_or(crate::types::HotkeyTrigger::Custom),
|
||||
mode: target.dictation_mode,
|
||||
keys: None,
|
||||
};
|
||||
if dictation_trigger.is_some() {
|
||||
take_combo_hotkey_on_main_thread(&self.inner);
|
||||
} else {
|
||||
self.update_combo_hotkey_binding();
|
||||
}
|
||||
self.ensure_modifier_hotkey_monitor(binding);
|
||||
self.update_modifier_shortcut_bindings();
|
||||
}
|
||||
|
||||
fn ensure_modifier_hotkey_monitor(&self, binding: crate::types::HotkeyBinding) {
|
||||
if let Err(error) = self.try_ensure_modifier_hotkey_monitor(binding) {
|
||||
log::warn!("[coord] modifier hotkey update failed: {error}");
|
||||
}
|
||||
}
|
||||
|
||||
fn try_ensure_modifier_hotkey_monitor(
|
||||
&self,
|
||||
binding: crate::types::HotkeyBinding,
|
||||
@@ -1591,28 +1497,9 @@ impl Coordinator {
|
||||
if previous.style_packs != next.style_packs {
|
||||
self.try_update_style_pack_hotkey_bindings()?;
|
||||
}
|
||||
#[cfg(target_os = "macos")]
|
||||
let native_transition = previous.dictation.primary == crate::macos_dictation_key::PRIMARY
|
||||
|| next.dictation.primary == crate::macos_dictation_key::PRIMARY;
|
||||
#[cfg(not(target_os = "macos"))]
|
||||
let native_transition = false;
|
||||
if native_transition {
|
||||
#[cfg(target_os = "macos")]
|
||||
if previous.dictation != next.dictation
|
||||
|| previous.dictation_mode != next.dictation_mode
|
||||
{
|
||||
self.try_update_native_dictation_binding()?;
|
||||
self.update_modifier_shortcut_bindings();
|
||||
}
|
||||
} else {
|
||||
if previous.dictation != next.dictation
|
||||
|| previous.dictation_mode != next.dictation_mode
|
||||
{
|
||||
self.update_hotkey_binding();
|
||||
}
|
||||
if previous.dictation != next.dictation {
|
||||
self.update_combo_hotkey_binding();
|
||||
}
|
||||
if previous.dictation != next.dictation || previous.dictation_mode != next.dictation_mode {
|
||||
self.try_update_native_dictation_binding()?;
|
||||
self.update_modifier_shortcut_bindings();
|
||||
}
|
||||
if previous.qa != next.qa {
|
||||
self.update_qa_hotkey_binding();
|
||||
|
||||
@@ -1377,6 +1377,7 @@ pub(super) fn combo_hotkey_supervisor_loop(inner: Arc<Inner>) {
|
||||
if crate::shortcut_binding::legacy_modifier_trigger(&target.dictation).is_some() {
|
||||
take_combo_hotkey_on_main_thread(&inner);
|
||||
inner.side_aware_combo.lock().take();
|
||||
inner.mouse_dictation.lock().take();
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -1384,97 +1385,19 @@ pub(super) fn combo_hotkey_supervisor_loop(inner: Arc<Inner>) {
|
||||
if is_unconfigured_shortcut(&binding) {
|
||||
take_combo_hotkey_on_main_thread(&inner);
|
||||
inner.side_aware_combo.lock().take();
|
||||
inner.mouse_dictation.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::<HotkeyEvent>();
|
||||
let combo_tx = spawn_combo_abort_bridge(&inner, handle_trigger_combined);
|
||||
match crate::side_aware_combo::SideAwareComboMonitor::start(binding, tx, combo_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 || hotkey_bridge_loop(inner_clone, rx))
|
||||
.ok();
|
||||
return;
|
||||
}
|
||||
Err(e) => {
|
||||
attempts += 1;
|
||||
if attempts <= 3 || attempts % 10 == 0 {
|
||||
log::warn!(
|
||||
"[coord] side-aware combo 第 {attempts} 次注册失败: {e}; 3s 后重试"
|
||||
);
|
||||
}
|
||||
drop(registration);
|
||||
std::thread::sleep(std::time::Duration::from_secs(3));
|
||||
continue;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
inner.side_aware_combo.lock().take();
|
||||
|
||||
if inner.combo_hotkey.lock().is_some() {
|
||||
return;
|
||||
}
|
||||
|
||||
let (tx, rx) = mpsc::channel::<ComboHotkeyEvent>();
|
||||
let (init_tx, init_rx) =
|
||||
mpsc::sync_channel::<Result<ComboHotkeyMonitor, ComboHotkeyError>>(0);
|
||||
let binding_for_main = binding.clone();
|
||||
if inner
|
||||
.host
|
||||
.run_on_main_thread(move || {
|
||||
let result = ComboHotkeyMonitor::start(binding_for_main, tx);
|
||||
let _ = init_tx.send(result);
|
||||
})
|
||||
.is_err()
|
||||
{
|
||||
drop(registration);
|
||||
std::thread::sleep(std::time::Duration::from_secs(1));
|
||||
continue;
|
||||
}
|
||||
|
||||
let init_result = match init_rx.recv_timeout(std::time::Duration::from_secs(5)) {
|
||||
Ok(r) => r,
|
||||
Err(_) => {
|
||||
drop(init_rx);
|
||||
attempts += 1;
|
||||
if attempts <= 3 || attempts % 10 == 0 {
|
||||
log::warn!(
|
||||
"[coord] combo hotkey 第 {attempts} 次注册超时(主线程未回执);3s 后重试"
|
||||
);
|
||||
}
|
||||
drop(registration);
|
||||
std::thread::sleep(std::time::Duration::from_secs(3));
|
||||
continue;
|
||||
}
|
||||
let coordinator = Coordinator {
|
||||
inner: Arc::clone(&inner),
|
||||
};
|
||||
|
||||
match init_result {
|
||||
Ok(monitor) => {
|
||||
*inner.combo_hotkey.lock() = Some(monitor);
|
||||
log::info!(
|
||||
"[coord] combo hotkey listener installed on main thread (after {} attempt(s))",
|
||||
attempts + 1
|
||||
);
|
||||
let inner_clone = Arc::clone(&inner);
|
||||
std::thread::Builder::new()
|
||||
.name("openless-combo-hotkey-bridge".into())
|
||||
.spawn(move || combo_hotkey_bridge_loop(inner_clone, rx))
|
||||
.ok();
|
||||
return;
|
||||
}
|
||||
Err(e) => {
|
||||
match coordinator.try_update_native_dictation_binding() {
|
||||
Ok(()) => return,
|
||||
Err(error) => {
|
||||
attempts += 1;
|
||||
if attempts <= 3 || attempts % 10 == 0 {
|
||||
log::warn!("[coord] combo hotkey 第 {attempts} 次注册失败: {e}; 3s 后重试");
|
||||
log::warn!("[coord] dictation shortcut registration attempt {attempts} failed: {error}; retrying in 3s");
|
||||
}
|
||||
drop(registration);
|
||||
std::thread::sleep(std::time::Duration::from_secs(3));
|
||||
@@ -2469,6 +2392,7 @@ pub(crate) mod less_computer_test_support {
|
||||
less_computer_combo_pending_press: Mutex::new(None),
|
||||
combo_hotkey: Mutex::new(None),
|
||||
side_aware_combo: Mutex::new(None),
|
||||
mouse_dictation: Mutex::new(None),
|
||||
translation_hotkey: Mutex::new(None),
|
||||
switch_style_hotkey: Mutex::new(None),
|
||||
open_app_hotkey: Mutex::new(None),
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
use super::*;
|
||||
|
||||
impl Coordinator {
|
||||
#[cfg(target_os = "macos")]
|
||||
pub(crate) fn dictation_shortcut_is_busy(&self) -> bool {
|
||||
!matches!(
|
||||
self.backend().snapshot().dictation.phase,
|
||||
@@ -14,8 +15,19 @@ impl Coordinator {
|
||||
|
||||
/// Keep the previous listener until replacement registration succeeds. The
|
||||
/// caller is a worker thread; Carbon ownership changes run on the UI thread.
|
||||
#[cfg(not(mobile))]
|
||||
pub(crate) fn try_update_native_dictation_binding(&self) -> Result<(), String> {
|
||||
let target = hotkey_runtime_target(&self.inner);
|
||||
if !is_unconfigured_shortcut(&target.dictation) {
|
||||
crate::shortcut_binding::validate_binding(&target.dictation)
|
||||
.map_err(|error| error.to_string())?;
|
||||
}
|
||||
self.try_ensure_modifier_hotkey_monitor(crate::types::HotkeyBinding {
|
||||
trigger: crate::shortcut_binding::legacy_modifier_trigger(&target.dictation)
|
||||
.unwrap_or(crate::types::HotkeyTrigger::Custom),
|
||||
mode: target.dictation_mode,
|
||||
keys: None,
|
||||
})?;
|
||||
let inner = Arc::clone(&self.inner);
|
||||
let (done_tx, done_rx) = mpsc::sync_channel(1);
|
||||
// Cancel queued work on timeout. Once a callback has started, wait for
|
||||
@@ -27,7 +39,7 @@ impl Coordinator {
|
||||
let cancelled = callback_cancelled.lock();
|
||||
let result = (|| {
|
||||
if *cancelled || hotkey_runtime_target(&inner) != target {
|
||||
return Err("macDictationKeyChanged".into());
|
||||
return Err("Dictation shortcut target changed during registration".into());
|
||||
}
|
||||
let binding = target.dictation.clone();
|
||||
let trigger = crate::shortcut_binding::legacy_modifier_trigger(&binding);
|
||||
@@ -37,6 +49,11 @@ impl Coordinator {
|
||||
}
|
||||
inner.combo_hotkey.lock().take();
|
||||
inner.side_aware_combo.lock().take();
|
||||
inner.mouse_dictation.lock().take();
|
||||
} else if crate::shortcut_binding::binding_requires_mouse_hook(&binding) {
|
||||
try_install_mouse_dictation(&inner, binding)?;
|
||||
inner.combo_hotkey.lock().take();
|
||||
inner.side_aware_combo.lock().take();
|
||||
} else if crate::shortcut_binding::binding_requires_side_aware_hook(&binding) {
|
||||
let mut slot = inner.side_aware_combo.lock();
|
||||
if let Some(monitor) = slot.as_ref() {
|
||||
@@ -48,8 +65,7 @@ impl Coordinator {
|
||||
.map_err(|error| error.to_string())?;
|
||||
} else {
|
||||
let (tx, rx) = mpsc::channel::<HotkeyEvent>();
|
||||
let combo_tx =
|
||||
spawn_combo_abort_bridge(&inner, handle_trigger_combined);
|
||||
let combo_tx = spawn_combo_abort_bridge(&inner, handle_trigger_combined);
|
||||
let monitor = crate::side_aware_combo::SideAwareComboMonitor::start(
|
||||
binding, tx, combo_tx,
|
||||
)
|
||||
@@ -62,6 +78,7 @@ impl Coordinator {
|
||||
*slot = Some(monitor);
|
||||
}
|
||||
inner.combo_hotkey.lock().take();
|
||||
inner.mouse_dictation.lock().take();
|
||||
} else {
|
||||
let mut slot = inner.combo_hotkey.lock();
|
||||
if let Some(monitor) = slot.as_ref() {
|
||||
@@ -80,6 +97,7 @@ impl Coordinator {
|
||||
*slot = Some(monitor);
|
||||
}
|
||||
inner.side_aware_combo.lock().take();
|
||||
inner.mouse_dictation.lock().take();
|
||||
}
|
||||
if let Some(monitor) = inner.hotkey.lock().as_ref() {
|
||||
monitor.update_binding(crate::types::HotkeyBinding {
|
||||
@@ -102,10 +120,46 @@ impl Coordinator {
|
||||
Ok(result) => result,
|
||||
Err(_) => {
|
||||
*cancelled = true;
|
||||
Err("macDictationKeyUnavailable".into())
|
||||
Err("Dictation shortcut registration did not acknowledge startup".into())
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(mobile)]
|
||||
pub(crate) fn try_update_native_dictation_binding(&self) -> Result<(), String> {
|
||||
Err("Global dictation shortcuts are unavailable on mobile".into())
|
||||
}
|
||||
}
|
||||
|
||||
/// Publish only after both the native hook and its event consumer are ready.
|
||||
pub(super) fn try_install_mouse_dictation(
|
||||
inner: &Arc<Inner>,
|
||||
binding: crate::types::ShortcutBinding,
|
||||
) -> Result<(), String> {
|
||||
crate::shortcut_binding::validate_binding(&binding).map_err(|error| error.to_string())?;
|
||||
let mut slot = inner.mouse_dictation.lock();
|
||||
if inner.shutdown.load(Ordering::SeqCst) {
|
||||
return Err("Cannot register mouse shortcut during shutdown".into());
|
||||
}
|
||||
if slot.as_ref().is_some_and(|monitor| !monitor.is_running()) {
|
||||
slot.take();
|
||||
}
|
||||
if let Some(monitor) = slot.as_ref() {
|
||||
return monitor
|
||||
.update_binding(binding)
|
||||
.map_err(|error| error.to_string());
|
||||
}
|
||||
let bridge_inner = Arc::clone(inner);
|
||||
let monitor =
|
||||
crate::mouse_dictation::MouseDictationMonitor::start_with_bridge(binding, |rx| {
|
||||
std::thread::Builder::new()
|
||||
.name("openless-mouse-dictation-bridge".into())
|
||||
.spawn(move || combo_hotkey_bridge_loop(bridge_inner, rx))
|
||||
.map(|_| ())
|
||||
.map_err(|error| error.to_string())
|
||||
})?;
|
||||
*slot = Some(monitor);
|
||||
Ok(())
|
||||
}
|
||||
@@ -26,6 +26,10 @@ impl openless_core::config::RestoreRuntimeEffects for RestoreHost {
|
||||
.ok_or_else(|| failure("restore_host_unavailable"))?;
|
||||
openless_core::reject_hotkey_collisions(&target)
|
||||
.map_err(|_| failure("restore_hotkey_conflict"))?;
|
||||
if crate::shortcut_binding::binding_requires_mouse_hook(&target.dictation_hotkey) {
|
||||
crate::shortcut_binding::validate_binding(&target.dictation_hotkey)
|
||||
.map_err(|_| failure("restore_mouse_hotkey_unsupported"))?;
|
||||
}
|
||||
let coord = Coordinator {
|
||||
inner: Arc::clone(&inner),
|
||||
};
|
||||
@@ -197,7 +201,12 @@ fn reconcile_hotkeys_on_main(
|
||||
let trigger = crate::shortcut_binding::legacy_modifier_trigger(&target.dictation);
|
||||
if trigger.is_some() || is_unconfigured_shortcut(&target.dictation) {
|
||||
inner.side_aware_combo.lock().take();
|
||||
inner.mouse_dictation.lock().take();
|
||||
} else if crate::shortcut_binding::binding_requires_mouse_hook(&target.dictation) {
|
||||
inner.side_aware_combo.lock().take();
|
||||
try_install_mouse_dictation(inner, target.dictation.clone())?;
|
||||
} else if crate::shortcut_binding::binding_requires_side_aware_hook(&target.dictation) {
|
||||
inner.mouse_dictation.lock().take();
|
||||
let mut side_slot = inner.side_aware_combo.lock();
|
||||
if let Some(monitor) = side_slot.as_ref() {
|
||||
monitor
|
||||
@@ -221,6 +230,7 @@ fn reconcile_hotkeys_on_main(
|
||||
}
|
||||
} else {
|
||||
inner.side_aware_combo.lock().take();
|
||||
inner.mouse_dictation.lock().take();
|
||||
let (send, receive) = mpsc::channel();
|
||||
let monitor = ComboHotkeyMonitor::start(target.dictation.clone(), send)
|
||||
.map_err(|error| error.to_string())?;
|
||||
|
||||
@@ -96,6 +96,11 @@ mod side_aware_combo;
|
||||
#[cfg(mobile)]
|
||||
#[path = "mobile_stubs/side_aware_combo.rs"]
|
||||
mod side_aware_combo;
|
||||
#[cfg(not(mobile))]
|
||||
mod mouse_dictation;
|
||||
#[cfg(mobile)]
|
||||
#[path = "mobile_stubs/mouse_dictation.rs"]
|
||||
mod mouse_dictation;
|
||||
mod tauri_events;
|
||||
mod types;
|
||||
#[cfg(not(mobile))]
|
||||
|
||||
@@ -0,0 +1,38 @@
|
||||
//! Mobile stub — global mouse dictation triggers are unavailable on Android/iOS.
|
||||
|
||||
use std::sync::mpsc::Sender;
|
||||
|
||||
use crate::combo_hotkey::{ComboHotkeyError, ComboHotkeyEvent};
|
||||
use crate::types::ShortcutBinding;
|
||||
|
||||
pub struct MouseDictationMonitor;
|
||||
|
||||
impl MouseDictationMonitor {
|
||||
pub fn start_with_bridge(
|
||||
_binding: ShortcutBinding,
|
||||
_bridge: impl FnOnce(std::sync::mpsc::Receiver<ComboHotkeyEvent>) -> Result<(), String>,
|
||||
) -> Result<Self, String> {
|
||||
Err("Mouse shortcuts are only supported on Windows".into())
|
||||
}
|
||||
|
||||
pub fn is_running(&self) -> bool {
|
||||
false
|
||||
}
|
||||
|
||||
pub fn start(
|
||||
_binding: ShortcutBinding,
|
||||
_tx: Sender<ComboHotkeyEvent>,
|
||||
) -> Result<Self, ComboHotkeyError> {
|
||||
Err(ComboHotkeyError::RegisterFailed(
|
||||
"mouse dictation is not available on mobile".into(),
|
||||
))
|
||||
}
|
||||
|
||||
pub fn update_binding(&self, _binding: ShortcutBinding) -> Result<(), ComboHotkeyError> {
|
||||
Err(ComboHotkeyError::RegisterFailed(
|
||||
"mouse dictation is not available on mobile".into(),
|
||||
))
|
||||
}
|
||||
}
|
||||
|
||||
pub fn handle_button(_primary: &str, _pressed: bool) {}
|
||||
@@ -3,8 +3,9 @@
|
||||
use crate::types::ShortcutBinding;
|
||||
|
||||
pub use openless_core::{
|
||||
binding_requires_side_aware_hook, bindings_overlap, is_side_specific_modifier_tag,
|
||||
legacy_modifier_trigger, normalize_side_modifier_tag, reject_side_specific_non_dictation,
|
||||
binding_requires_mouse_hook, binding_requires_side_aware_hook, bindings_overlap,
|
||||
is_mouse_button_primary, is_side_specific_modifier_tag, legacy_modifier_trigger,
|
||||
normalize_side_modifier_tag, reject_side_specific_non_dictation, MOUSE_NON_DICTATION_MSG,
|
||||
SIDE_SPECIFIC_NON_DICTATION_MSG,
|
||||
};
|
||||
|
||||
|
||||
@@ -0,0 +1,614 @@
|
||||
//! Global Mouse4 / Mouse5 dictation triggers via a dedicated mouse hook.
|
||||
//!
|
||||
//! Keyboard combos use `global-hotkey` / `RegisterHotKey`, which cannot register
|
||||
//! mouse buttons. This module mirrors the MacDictationKey / side-aware pattern:
|
||||
//! install a native listener and emit [`ComboHotkeyEvent`] into the shared
|
||||
//! combo bridge so Hold / Toggle edge semantics stay identical.
|
||||
|
||||
use std::sync::atomic::{AtomicBool, Ordering};
|
||||
use std::sync::mpsc::Sender;
|
||||
use std::sync::{OnceLock, RwLock};
|
||||
use std::time::Instant;
|
||||
|
||||
use crate::combo_hotkey::{ComboHotkeyError, ComboHotkeyEvent};
|
||||
use crate::shortcut_binding::binding_requires_mouse_hook;
|
||||
use crate::types::ShortcutBinding;
|
||||
|
||||
static ACTIVE_MOUSE: OnceLock<RwLock<Option<MouseMonitorState>>> = OnceLock::new();
|
||||
|
||||
struct MouseMonitorState {
|
||||
primary: String,
|
||||
/// Normalized generic modifier tags: ctrl / alt / shift / super.
|
||||
modifiers: Vec<String>,
|
||||
tx: Sender<ComboHotkeyEvent>,
|
||||
held: AtomicBool,
|
||||
}
|
||||
|
||||
impl MouseMonitorState {
|
||||
fn handle_edge(&self, pressed: bool, modifiers_match: bool) {
|
||||
if pressed {
|
||||
if modifiers_match && !self.held.swap(true, Ordering::SeqCst) {
|
||||
send_edge(self, ComboHotkeyEvent::Pressed { at: Instant::now() });
|
||||
}
|
||||
} else {
|
||||
// Release belongs to the accepted press even if modifiers changed.
|
||||
release_held(self);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub struct MouseDictationMonitor {
|
||||
#[cfg(all(target_os = "windows", not(test)))]
|
||||
_hook: platform::HookThread,
|
||||
}
|
||||
|
||||
impl MouseDictationMonitor {
|
||||
/// A bridge failure drops the installed hook before returning to the transaction.
|
||||
pub fn start_with_bridge(
|
||||
binding: ShortcutBinding,
|
||||
bridge: impl FnOnce(std::sync::mpsc::Receiver<ComboHotkeyEvent>) -> Result<(), String>,
|
||||
) -> Result<Self, String> {
|
||||
let (tx, rx) = std::sync::mpsc::channel();
|
||||
let monitor = Self::start(binding, tx).map_err(|error| error.to_string())?;
|
||||
bridge(rx)?;
|
||||
Ok(monitor)
|
||||
}
|
||||
|
||||
pub fn is_running(&self) -> bool {
|
||||
#[cfg(all(target_os = "windows", not(test)))]
|
||||
{
|
||||
self._hook.is_running()
|
||||
}
|
||||
#[cfg(any(not(target_os = "windows"), test))]
|
||||
{
|
||||
true
|
||||
}
|
||||
}
|
||||
|
||||
pub fn start(
|
||||
binding: ShortcutBinding,
|
||||
tx: Sender<ComboHotkeyEvent>,
|
||||
) -> Result<Self, ComboHotkeyError> {
|
||||
#[cfg(all(not(target_os = "windows"), not(test)))]
|
||||
{
|
||||
let _ = (binding, tx);
|
||||
return Err(ComboHotkeyError::RegisterFailed(
|
||||
"Mouse4/Mouse5 global hotkeys are currently supported on Windows only".into(),
|
||||
));
|
||||
}
|
||||
|
||||
#[cfg(any(target_os = "windows", test))]
|
||||
{
|
||||
let state = state_from_binding(binding, tx)?;
|
||||
let slot = ACTIVE_MOUSE.get_or_init(|| RwLock::new(None));
|
||||
let mut guard = slot
|
||||
.write()
|
||||
.map_err(|e| ComboHotkeyError::RegisterFailed(e.to_string()))?;
|
||||
if guard.is_some() {
|
||||
return Err(ComboHotkeyError::RegisterFailed(
|
||||
"mouse monitor already active".into(),
|
||||
));
|
||||
}
|
||||
#[cfg(all(target_os = "windows", not(test)))]
|
||||
let hook = platform::HookThread::start().map_err(ComboHotkeyError::RegisterFailed)?;
|
||||
*guard = Some(state);
|
||||
Ok(Self {
|
||||
#[cfg(all(target_os = "windows", not(test)))]
|
||||
_hook: hook,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
pub fn update_binding(&self, binding: ShortcutBinding) -> Result<(), ComboHotkeyError> {
|
||||
let slot = ACTIVE_MOUSE
|
||||
.get()
|
||||
.ok_or_else(|| ComboHotkeyError::RegisterFailed("mouse monitor inactive".into()))?;
|
||||
let mut guard = slot
|
||||
.write()
|
||||
.map_err(|e| ComboHotkeyError::RegisterFailed(e.to_string()))?;
|
||||
let Some(existing) = guard.as_mut() else {
|
||||
return Err(ComboHotkeyError::RegisterFailed(
|
||||
"mouse monitor inactive".into(),
|
||||
));
|
||||
};
|
||||
let next = state_from_binding(binding, existing.tx.clone())?;
|
||||
release_held(existing);
|
||||
existing.primary = next.primary;
|
||||
existing.modifiers = next.modifiers;
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
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(state);
|
||||
}
|
||||
*guard = None;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn state_from_binding(
|
||||
binding: ShortcutBinding,
|
||||
tx: Sender<ComboHotkeyEvent>,
|
||||
) -> Result<MouseMonitorState, ComboHotkeyError> {
|
||||
if !binding_requires_mouse_hook(&binding) {
|
||||
return Err(ComboHotkeyError::UnsupportedKey(binding.primary));
|
||||
}
|
||||
let primary = normalize_mouse_primary(&binding.primary)
|
||||
.ok_or_else(|| ComboHotkeyError::UnsupportedKey(binding.primary.clone()))?;
|
||||
let mut modifiers = Vec::new();
|
||||
for raw in &binding.modifiers {
|
||||
modifiers.push(
|
||||
normalize_generic_modifier(raw)
|
||||
.ok_or_else(|| ComboHotkeyError::UnsupportedModifier(raw.clone()))?,
|
||||
);
|
||||
}
|
||||
modifiers.sort();
|
||||
modifiers.dedup();
|
||||
Ok(MouseMonitorState {
|
||||
primary,
|
||||
modifiers,
|
||||
tx,
|
||||
held: AtomicBool::new(false),
|
||||
})
|
||||
}
|
||||
|
||||
fn normalize_mouse_primary(raw: &str) -> Option<String> {
|
||||
match raw.trim().to_ascii_uppercase().as_str() {
|
||||
"MOUSE4" => Some("Mouse4".into()),
|
||||
"MOUSE5" => Some("Mouse5".into()),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
fn normalize_generic_modifier(raw: &str) -> Option<String> {
|
||||
match raw.trim().to_ascii_lowercase().as_str() {
|
||||
"ctrl" | "control" => Some("ctrl".into()),
|
||||
"alt" | "option" | "opt" => Some("alt".into()),
|
||||
"shift" => Some("shift".into()),
|
||||
"cmd" | "command" if cfg!(target_os = "windows") => Some("ctrl".into()),
|
||||
"cmd" | "command" | "super" | "meta" | "win" => Some("super".into()),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
fn release_held(state: &MouseMonitorState) {
|
||||
if state.held.swap(false, Ordering::SeqCst) {
|
||||
send_edge(state, ComboHotkeyEvent::Released { at: Instant::now() });
|
||||
}
|
||||
}
|
||||
|
||||
fn with_active<F, R>(f: F) -> Option<R>
|
||||
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: ComboHotkeyEvent) {
|
||||
if let Err(err) = state.tx.send(evt) {
|
||||
log::warn!("[mouse-dictation] event send failed: {err}");
|
||||
}
|
||||
}
|
||||
|
||||
fn modifiers_match(required: &[String]) -> bool {
|
||||
#[cfg(all(target_os = "windows", not(test)))]
|
||||
{
|
||||
use windows::Win32::UI::Input::KeyboardAndMouse::GetAsyncKeyState;
|
||||
// Match hotkey.rs: use raw VK codes rather than VIRTUAL_KEY helpers.
|
||||
const VK_SHIFT: i32 = 0x10;
|
||||
const VK_CONTROL: i32 = 0x11;
|
||||
const VK_MENU: i32 = 0x12;
|
||||
const VK_LWIN: i32 = 0x5B;
|
||||
const VK_RWIN: i32 = 0x5C;
|
||||
let ctrl = unsafe { GetAsyncKeyState(VK_CONTROL) } < 0;
|
||||
let alt = unsafe { GetAsyncKeyState(VK_MENU) } < 0;
|
||||
let shift = unsafe { GetAsyncKeyState(VK_SHIFT) } < 0;
|
||||
let meta =
|
||||
unsafe { GetAsyncKeyState(VK_LWIN) } < 0 || unsafe { GetAsyncKeyState(VK_RWIN) } < 0;
|
||||
match_modifier_states(required, [ctrl, alt, shift, meta])
|
||||
}
|
||||
#[cfg(any(not(target_os = "windows"), test))]
|
||||
{
|
||||
// Tests synthesize edges without real modifier state; require empty modifiers.
|
||||
match_modifier_states(required, [false; 4])
|
||||
}
|
||||
}
|
||||
|
||||
fn match_modifier_states(required: &[String], down: [bool; 4]) -> bool {
|
||||
// Exact matching also rejects unexpected modifiers for bare mouse bindings.
|
||||
["ctrl", "alt", "shift", "super"]
|
||||
.into_iter()
|
||||
.zip(down)
|
||||
.all(|(tag, pressed)| pressed == required.iter().any(|required| required == tag))
|
||||
}
|
||||
|
||||
/// Dispatch a Mouse4 / Mouse5 edge into the active monitor (if any).
|
||||
pub fn handle_button(primary: &str, pressed: bool) {
|
||||
let Some(normalized) = normalize_mouse_primary(primary) else {
|
||||
return;
|
||||
};
|
||||
with_active(|state| {
|
||||
if state.primary != normalized {
|
||||
return;
|
||||
}
|
||||
state.handle_edge(pressed, modifiers_match(&state.modifiers));
|
||||
});
|
||||
}
|
||||
|
||||
#[cfg(target_os = "windows")]
|
||||
pub mod platform {
|
||||
use super::*;
|
||||
use windows::Win32::Foundation::{LPARAM, LRESULT, WPARAM};
|
||||
use windows::Win32::System::Threading::GetCurrentThreadId;
|
||||
use windows::Win32::UI::WindowsAndMessaging::{
|
||||
CallNextHookEx, PeekMessageW, PostThreadMessageW, SetWindowsHookExW, UnhookWindowsHookEx,
|
||||
HC_ACTION, HHOOK, MSLLHOOKSTRUCT, PM_NOREMOVE, WH_MOUSE_LL, WM_QUIT, WM_XBUTTONDOWN,
|
||||
WM_XBUTTONUP, XBUTTON1, XBUTTON2,
|
||||
};
|
||||
|
||||
pub struct HookThread {
|
||||
thread_id: u32,
|
||||
thread: Option<std::thread::JoinHandle<()>>,
|
||||
}
|
||||
|
||||
impl HookThread {
|
||||
pub fn is_running(&self) -> bool {
|
||||
self.thread
|
||||
.as_ref()
|
||||
.is_some_and(|thread| !thread.is_finished())
|
||||
}
|
||||
|
||||
pub fn start() -> Result<Self, String> {
|
||||
Self::start_with_install(|| unsafe {
|
||||
SetWindowsHookExW(WH_MOUSE_LL, Some(low_level_mouse_proc), None, 0)
|
||||
.map_err(|error| format!("mouse hook install failed: {error}"))
|
||||
})
|
||||
}
|
||||
|
||||
fn start_with_install(
|
||||
install: impl FnOnce() -> Result<HHOOK, String> + Send + 'static,
|
||||
) -> Result<Self, String> {
|
||||
let (ready_tx, ready_rx) = std::sync::mpsc::sync_channel(0);
|
||||
let thread = std::thread::Builder::new()
|
||||
.name("openless-mouse-hook".into())
|
||||
.spawn(move || unsafe {
|
||||
let mut msg = windows::Win32::UI::WindowsAndMessaging::MSG::default();
|
||||
// Create the queue before publishing its thread id to Drop.
|
||||
let _ = PeekMessageW(&mut msg, None, 0, 0, PM_NOREMOVE);
|
||||
let hook = match install() {
|
||||
Ok(hook) => hook,
|
||||
Err(error) => {
|
||||
let _ = ready_tx.send(Err(error));
|
||||
return;
|
||||
}
|
||||
};
|
||||
if ready_tx.send(Ok(GetCurrentThreadId())).is_ok() {
|
||||
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);
|
||||
}
|
||||
}
|
||||
let _ = UnhookWindowsHookEx(hook);
|
||||
})
|
||||
.map_err(|e| format!("spawn mouse hook thread: {e}"))?;
|
||||
// A timeout drops the rendezvous receiver; the worker then unhooks itself.
|
||||
let thread_id = ready_rx
|
||||
.recv_timeout(std::time::Duration::from_secs(3))
|
||||
.map_err(|error| format!("mouse hook startup failed: {error}"))??;
|
||||
Ok(Self {
|
||||
thread_id,
|
||||
thread: Some(thread),
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
impl Drop for HookThread {
|
||||
fn drop(&mut self) {
|
||||
let Some(thread) = self.thread.take() else {
|
||||
return;
|
||||
};
|
||||
if !thread.is_finished() {
|
||||
if let Err(error) =
|
||||
unsafe { PostThreadMessageW(self.thread_id, WM_QUIT, WPARAM(0), LPARAM(0)) }
|
||||
{
|
||||
log::error!("[mouse-dictation] stop hook failed: {error}");
|
||||
return;
|
||||
}
|
||||
}
|
||||
// MouseDictationMonitor released the callback's state lock before this join.
|
||||
if thread.join().is_err() {
|
||||
log::error!("[mouse-dictation] hook thread panicked");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
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);
|
||||
if matches!(msg, WM_XBUTTONDOWN | WM_XBUTTONUP) {
|
||||
let hi = ((mouse.mouseData >> 16) & 0xFFFF) as u16;
|
||||
let primary = if hi == XBUTTON1 as u16 {
|
||||
Some("Mouse4")
|
||||
} else if hi == XBUTTON2 as u16 {
|
||||
Some("Mouse5")
|
||||
} else {
|
||||
None
|
||||
};
|
||||
if let Some(primary) = primary {
|
||||
handle_button(primary, msg == WM_XBUTTONDOWN);
|
||||
}
|
||||
}
|
||||
}
|
||||
CallNextHookEx(None, code, wparam, lparam)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn installation_failure_is_reported_and_can_retry() {
|
||||
for attempt in 0..2 {
|
||||
let error =
|
||||
HookThread::start_with_install(move || Err(format!("install {attempt}")))
|
||||
.err()
|
||||
.expect("installation must fail");
|
||||
assert_eq!(error, format!("install {attempt}"));
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
#[ignore = "installs real Windows hooks; run explicitly on an interactive desktop"]
|
||||
fn native_hook_can_stop_and_restart() {
|
||||
for _ in 0..3 {
|
||||
drop(HookThread::start().expect("native hook startup"));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[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.as_mut() {
|
||||
release_held(state);
|
||||
}
|
||||
*guard = None;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn mouse4_binding() -> ShortcutBinding {
|
||||
ShortcutBinding {
|
||||
primary: "Mouse4".into(),
|
||||
modifiers: vec![],
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn failed_bridge_drops_monitor_and_allows_retry() {
|
||||
let _lock = TEST_LOCK.lock().unwrap();
|
||||
let error = MouseDictationMonitor::start_with_bridge(mouse4_binding(), |_| {
|
||||
Err("spawn bridge failed".into())
|
||||
})
|
||||
.err()
|
||||
.expect("bridge failure must propagate");
|
||||
assert_eq!(error, "spawn bridge failed");
|
||||
let mut receiver = None;
|
||||
let monitor = MouseDictationMonitor::start_with_bridge(mouse4_binding(), |rx| {
|
||||
receiver = Some(rx);
|
||||
Ok(())
|
||||
})
|
||||
.unwrap();
|
||||
handle_button("Mouse4", true);
|
||||
let receiver = receiver.unwrap();
|
||||
assert!(matches!(
|
||||
receiver.try_recv().unwrap(),
|
||||
ComboHotkeyEvent::Pressed { .. }
|
||||
));
|
||||
drop(monitor);
|
||||
assert!(matches!(
|
||||
receiver.try_recv().unwrap(),
|
||||
ComboHotkeyEvent::Released { .. }
|
||||
));
|
||||
assert!(matches!(
|
||||
receiver.try_recv(),
|
||||
Err(mpsc::TryRecvError::Disconnected)
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn modifiers_match_exactly_and_use_keyboard_aliases() {
|
||||
let required = vec!["ctrl".into()];
|
||||
assert!(match_modifier_states(
|
||||
&required,
|
||||
[true, false, false, false]
|
||||
));
|
||||
assert!(!match_modifier_states(&required, [false; 4]));
|
||||
assert!(!match_modifier_states(
|
||||
&required,
|
||||
[true, false, true, false]
|
||||
));
|
||||
assert!(!match_modifier_states(&[], [true, false, false, false]));
|
||||
assert_eq!(
|
||||
normalize_generic_modifier("cmd").as_deref(),
|
||||
Some(if cfg!(target_os = "windows") {
|
||||
"ctrl"
|
||||
} else {
|
||||
"super"
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn edges_deduplicate_and_release_after_modifiers_change() {
|
||||
let (tx, rx) = mpsc::channel();
|
||||
let state = state_from_binding(mouse4_binding(), tx).unwrap();
|
||||
state.handle_edge(true, false);
|
||||
assert!(rx.try_recv().is_err());
|
||||
state.handle_edge(true, true);
|
||||
state.handle_edge(true, true);
|
||||
assert!(matches!(
|
||||
rx.try_recv().unwrap(),
|
||||
ComboHotkeyEvent::Pressed { .. }
|
||||
));
|
||||
assert!(rx.try_recv().is_err());
|
||||
state.handle_edge(false, false);
|
||||
state.handle_edge(false, false);
|
||||
assert!(matches!(
|
||||
rx.try_recv().unwrap(),
|
||||
ComboHotkeyEvent::Released { .. }
|
||||
));
|
||||
assert!(rx.try_recv().is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn invalid_update_preserves_held_binding_and_valid_update_releases_once() {
|
||||
let _lock = TEST_LOCK.lock().unwrap();
|
||||
let (tx, rx) = mpsc::channel();
|
||||
let monitor = MouseDictationMonitor::start(mouse4_binding(), tx).unwrap();
|
||||
handle_button("Mouse4", true);
|
||||
let _ = rx.try_recv().unwrap();
|
||||
assert!(monitor
|
||||
.update_binding(ShortcutBinding {
|
||||
primary: "Mouse5".into(),
|
||||
modifiers: vec!["ctrl-left".into()],
|
||||
})
|
||||
.is_err());
|
||||
assert!(rx.try_recv().is_err());
|
||||
monitor
|
||||
.update_binding(ShortcutBinding {
|
||||
primary: "Mouse5".into(),
|
||||
modifiers: vec![],
|
||||
})
|
||||
.unwrap();
|
||||
assert!(matches!(
|
||||
rx.try_recv().unwrap(),
|
||||
ComboHotkeyEvent::Released { .. }
|
||||
));
|
||||
handle_button("Mouse4", false);
|
||||
assert!(rx.try_recv().is_err());
|
||||
drop(monitor);
|
||||
assert!(rx.try_recv().is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn invalid_start_does_not_reserve_singleton() {
|
||||
let _lock = TEST_LOCK.lock().unwrap();
|
||||
let (tx, _rx) = mpsc::channel();
|
||||
assert!(MouseDictationMonitor::start(
|
||||
ShortcutBinding {
|
||||
primary: "Mouse3".into(),
|
||||
modifiers: vec![],
|
||||
},
|
||||
tx.clone()
|
||||
)
|
||||
.is_err());
|
||||
for _ in 0..3 {
|
||||
drop(MouseDictationMonitor::start(mouse4_binding(), tx.clone()).unwrap());
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn press_release_emits_combo_edges() {
|
||||
let _lock = TEST_LOCK.lock().unwrap();
|
||||
clear_active_monitor();
|
||||
let (tx, rx) = mpsc::channel();
|
||||
let _monitor = MouseDictationMonitor::start(mouse4_binding(), tx).unwrap();
|
||||
|
||||
handle_button("Mouse4", true);
|
||||
assert!(matches!(
|
||||
rx.recv().unwrap(),
|
||||
ComboHotkeyEvent::Pressed { .. }
|
||||
));
|
||||
handle_button("Mouse4", false);
|
||||
assert!(matches!(
|
||||
rx.recv().unwrap(),
|
||||
ComboHotkeyEvent::Released { .. }
|
||||
));
|
||||
|
||||
clear_active_monitor();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn wrong_button_ignored() {
|
||||
let _lock = TEST_LOCK.lock().unwrap();
|
||||
clear_active_monitor();
|
||||
let (tx, rx) = mpsc::channel();
|
||||
let _monitor = MouseDictationMonitor::start(mouse4_binding(), tx).unwrap();
|
||||
|
||||
handle_button("Mouse5", true);
|
||||
assert!(rx.try_recv().is_err());
|
||||
|
||||
clear_active_monitor();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn dropping_monitor_emits_release_when_held() {
|
||||
let _lock = TEST_LOCK.lock().unwrap();
|
||||
clear_active_monitor();
|
||||
let (tx, rx) = mpsc::channel();
|
||||
let monitor = MouseDictationMonitor::start(mouse4_binding(), tx).unwrap();
|
||||
|
||||
handle_button("Mouse4", true);
|
||||
assert!(matches!(
|
||||
rx.recv().unwrap(),
|
||||
ComboHotkeyEvent::Pressed { .. }
|
||||
));
|
||||
drop(monitor);
|
||||
assert!(matches!(
|
||||
rx.recv().unwrap(),
|
||||
ComboHotkeyEvent::Released { .. }
|
||||
));
|
||||
|
||||
clear_active_monitor();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn update_binding_switches_primary() {
|
||||
let _lock = TEST_LOCK.lock().unwrap();
|
||||
clear_active_monitor();
|
||||
let (tx, rx) = mpsc::channel();
|
||||
let monitor = MouseDictationMonitor::start(mouse4_binding(), tx).unwrap();
|
||||
|
||||
monitor
|
||||
.update_binding(ShortcutBinding {
|
||||
primary: "Mouse5".into(),
|
||||
modifiers: vec![],
|
||||
})
|
||||
.unwrap();
|
||||
handle_button("Mouse4", true);
|
||||
assert!(rx.try_recv().is_err());
|
||||
handle_button("Mouse5", true);
|
||||
assert!(matches!(
|
||||
rx.recv().unwrap(),
|
||||
ComboHotkeyEvent::Pressed { .. }
|
||||
));
|
||||
|
||||
clear_active_monitor();
|
||||
}
|
||||
}
|
||||
@@ -7,13 +7,22 @@ use crate::types::HotkeyTrigger;
|
||||
use crate::types::ShortcutBinding;
|
||||
|
||||
pub use openless_core::{
|
||||
binding_requires_side_aware_hook, bindings_overlap, is_side_specific_modifier_tag,
|
||||
legacy_modifier_trigger, normalize_side_modifier_tag, reject_side_specific_non_dictation,
|
||||
ShortcutBindingError, SIDE_SPECIFIC_NON_DICTATION_MSG,
|
||||
binding_requires_mouse_hook, binding_requires_side_aware_hook, bindings_overlap,
|
||||
is_side_specific_modifier_tag, legacy_modifier_trigger, normalize_side_modifier_tag,
|
||||
reject_side_specific_non_dictation, ShortcutBindingError, SIDE_SPECIFIC_NON_DICTATION_MSG,
|
||||
};
|
||||
|
||||
pub fn validate_binding(binding: &ShortcutBinding) -> Result<(), ShortcutBindingError> {
|
||||
openless_core::validate_shortcut_binding(binding)?;
|
||||
if binding_requires_mouse_hook(binding) {
|
||||
return if cfg!(target_os = "windows") {
|
||||
Ok(())
|
||||
} else {
|
||||
Err(ShortcutBindingError::UnsupportedKey(
|
||||
"Mouse4/Mouse5 global shortcuts require Windows".into(),
|
||||
))
|
||||
};
|
||||
}
|
||||
#[cfg(target_os = "macos")]
|
||||
if binding.primary == crate::macos_dictation_key::PRIMARY {
|
||||
return Ok(());
|
||||
@@ -61,6 +70,7 @@ fn normalize_modifier_tag(raw: &str) -> String {
|
||||
}
|
||||
|
||||
pub fn parse_primary(raw: &str) -> Result<Code, ShortcutBindingError> {
|
||||
let raw = if raw == " " { "Space" } else { raw };
|
||||
let trimmed = raw.trim();
|
||||
if trimmed.is_empty() {
|
||||
return Err(ShortcutBindingError::UnsupportedKey("(空)".into()));
|
||||
@@ -268,6 +278,44 @@ mod tests {
|
||||
assert!(validate_binding(&binding).is_ok());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn mouse_button_skips_global_hotkey_parse() {
|
||||
let binding = ShortcutBinding {
|
||||
primary: "Mouse4".into(),
|
||||
modifiers: vec![],
|
||||
};
|
||||
assert!(binding_requires_mouse_hook(&binding));
|
||||
assert_eq!(
|
||||
validate_binding(&binding).is_ok(),
|
||||
cfg!(target_os = "windows")
|
||||
);
|
||||
assert!(parse_global_hotkey(&binding).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn mouse_with_ctrl_validates_without_registerhotkey() {
|
||||
let binding = ShortcutBinding {
|
||||
primary: "Mouse5".into(),
|
||||
modifiers: vec!["ctrl".into()],
|
||||
};
|
||||
assert!(binding_requires_mouse_hook(&binding));
|
||||
assert_eq!(
|
||||
validate_binding(&binding).is_ok(),
|
||||
cfg!(target_os = "windows")
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn literal_space_primary_normalizes() {
|
||||
let binding = ShortcutBinding {
|
||||
primary: " ".into(),
|
||||
modifiers: vec!["ctrl".into()],
|
||||
};
|
||||
assert!(validate_binding(&binding).is_ok());
|
||||
let parsed = parse_global_hotkey(&binding).expect("space parses");
|
||||
assert_eq!(parsed.key, Code::Space);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn generic_combo_uses_global_hotkey_path() {
|
||||
let binding = ShortcutBinding {
|
||||
|
||||
@@ -8,7 +8,11 @@ import {
|
||||
MODIFIER_CHORD_PRIMARY,
|
||||
modifiersFromPressedCodes,
|
||||
} from '../lib/hotkey';
|
||||
import { functionKeyPrimaryFromEvent } from '../lib/hotkeyRecorder';
|
||||
import {
|
||||
primaryFromKeyboardEvent,
|
||||
formatShortcutSaveError,
|
||||
shortcutFromMouseEvent,
|
||||
} from '../lib/hotkeyRecorder';
|
||||
import { KbdGroup } from './Kbd';
|
||||
import { setShortcutRecordingActive, validateShortcutBinding } from '../lib/ipc';
|
||||
import type { ShortcutBinding } from '../lib/types';
|
||||
@@ -35,6 +39,7 @@ export function ShortcutRecorder({
|
||||
comboOnly = false,
|
||||
sideSpecificModifiers = false,
|
||||
allowMacDictationKey = false,
|
||||
allowMouseButtons = false,
|
||||
}: {
|
||||
value: ShortcutBinding | null;
|
||||
onSave: (binding: ShortcutBinding) => Promise<void>;
|
||||
@@ -54,6 +59,8 @@ export function ShortcutRecorder({
|
||||
sideSpecificModifiers?: boolean;
|
||||
/** macOS dictation only: choose the dedicated key as the single trigger. */
|
||||
allowMacDictationKey?: boolean;
|
||||
/** Windows dictation only; other shortcut consumers cannot install mouse hooks. */
|
||||
allowMouseButtons?: boolean;
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
const [recording, setRecording] = useState(false);
|
||||
@@ -131,10 +138,7 @@ export function ShortcutRecorder({
|
||||
setRecording(false);
|
||||
setError(null);
|
||||
} catch (reason) {
|
||||
const message = String(reason);
|
||||
setError(
|
||||
message.includes('macDictationKey') ? message : t('settings.recording.comboConflict'),
|
||||
);
|
||||
setError(formatShortcutSaveError(reason, t('settings.recording.shortcutSaveFailed')));
|
||||
}
|
||||
};
|
||||
|
||||
@@ -168,12 +172,23 @@ export function ShortcutRecorder({
|
||||
console.warn('[shortcut] recording state sync failed', error);
|
||||
}
|
||||
})();
|
||||
const onMouseDown = (e: MouseEvent) => {
|
||||
if (cancelled || !allowMouseButtons) return;
|
||||
const binding = shortcutFromMouseEvent(e);
|
||||
if (!binding) return;
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
clearPendingModifier();
|
||||
void finishRef.current(binding);
|
||||
};
|
||||
window.addEventListener('mousedown', onMouseDown, true);
|
||||
return () => {
|
||||
cancelled = true;
|
||||
unlisten?.();
|
||||
window.removeEventListener('mousedown', onMouseDown, true);
|
||||
void setShortcutRecordingActive(false);
|
||||
};
|
||||
}, [recording]);
|
||||
}, [recording, allowMouseButtons]);
|
||||
|
||||
/** 开始录入:同时收起菜单——「录制快捷键」按下后,重置/停用两个按钮随之消失。 */
|
||||
const startRecording = () => {
|
||||
@@ -261,10 +276,7 @@ export function ShortcutRecorder({
|
||||
try {
|
||||
await onReset?.();
|
||||
} catch (reason) {
|
||||
const message = String(reason);
|
||||
setError(
|
||||
message.includes('macDictationKey') ? message : t('settings.recording.comboConflict'),
|
||||
);
|
||||
setError(formatShortcutSaveError(reason, t('settings.recording.shortcutSaveFailed')));
|
||||
}
|
||||
};
|
||||
|
||||
@@ -376,6 +388,7 @@ export function ShortcutRecorder({
|
||||
{t('settings.recording.comboRecordHint')}
|
||||
<div style={{ fontSize: 11, color: 'var(--ol-ink-4)', marginTop: 4 }}>
|
||||
Esc · {t('common.cancel')}
|
||||
{allowMouseButtons && <> · {t('settings.recording.mouseSideHint')}</>}
|
||||
</div>
|
||||
</motion.div>
|
||||
) : (
|
||||
@@ -509,48 +522,3 @@ function modifierPrimaryFromCode(code: string, key: string): string {
|
||||
if (key === 'Shift') return 'Shift';
|
||||
return '';
|
||||
}
|
||||
|
||||
function primaryFromKeyboardEvent(e: KeyboardEvent): string {
|
||||
const functionKey = functionKeyPrimaryFromEvent(e);
|
||||
if (functionKey) return functionKey;
|
||||
const printable = primaryFromPrintableCode(e.code);
|
||||
if (printable) return printable;
|
||||
if (e.key.length === 1) return e.key;
|
||||
const codeToName: Record<string, string> = {
|
||||
Space: 'Space',
|
||||
Enter: 'Enter',
|
||||
Tab: 'Tab',
|
||||
Backspace: 'Backspace',
|
||||
Delete: 'Delete',
|
||||
ArrowUp: 'ArrowUp',
|
||||
ArrowDown: 'ArrowDown',
|
||||
ArrowLeft: 'ArrowLeft',
|
||||
ArrowRight: 'ArrowRight',
|
||||
Home: 'Home',
|
||||
End: 'End',
|
||||
PageUp: 'PageUp',
|
||||
PageDown: 'PageDown',
|
||||
};
|
||||
if (/^F\d{1,2}$/.test(e.key)) return e.key;
|
||||
return codeToName[e.code] || e.key;
|
||||
}
|
||||
|
||||
function primaryFromPrintableCode(code: string): string {
|
||||
if (/^Key[A-Z]$/.test(code)) return code.slice(3);
|
||||
if (/^Digit[0-9]$/.test(code)) return code.slice(5);
|
||||
const codeToPrimary: Record<string, string> = {
|
||||
Backquote: '`',
|
||||
Minus: '-',
|
||||
Equal: '=',
|
||||
BracketLeft: '[',
|
||||
BracketRight: ']',
|
||||
Backslash: '\\',
|
||||
Semicolon: ';',
|
||||
Quote: "'",
|
||||
Comma: ',',
|
||||
Period: '.',
|
||||
Slash: '/',
|
||||
IntlBackslash: '\\',
|
||||
};
|
||||
return codeToPrimary[code] || '';
|
||||
}
|
||||
@@ -1471,6 +1471,8 @@ export const de: typeof zhCN = {
|
||||
comboRecorded: 'Aufgezeichnet',
|
||||
comboClear: 'Leeren',
|
||||
comboConflict: 'Diese Tastenkombination ist nicht verfügbar',
|
||||
shortcutSaveFailed: 'Tastenkombination konnte nicht gespeichert werden',
|
||||
mouseSideHint: 'Mausseitentasten Mouse4 / Mouse5 werden als globale Diktier-Hotkeys unter Windows unterstützt',
|
||||
allowNonTsfFallbackLabel: 'Alternative ohne TSF erlauben',
|
||||
allowNonTsfFallbackDesc:
|
||||
'Windows: Falls das Einfügen über TSF fehlschlägt, wird Unicode-Text dosiert über SendInput eingegeben. Schlägt auch das fehl, wird der Text in die Zwischenablage kopiert.',
|
||||
|
||||
@@ -1442,6 +1442,8 @@ export const en: typeof zhCN = {
|
||||
comboRecorded: 'Recorded',
|
||||
comboClear: 'Clear',
|
||||
comboConflict: 'This shortcut combination is not available',
|
||||
shortcutSaveFailed: 'Failed to save shortcut',
|
||||
mouseSideHint: 'Mouse4 / Mouse5 side buttons are supported as global dictation hotkeys on Windows',
|
||||
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.',
|
||||
|
||||
@@ -1463,6 +1463,8 @@ export const es: typeof zhCN = {
|
||||
comboRecorded: 'Atajo registrado',
|
||||
comboClear: 'Borrar',
|
||||
comboConflict: 'Esta combinación de teclas no está disponible',
|
||||
shortcutSaveFailed: 'No se pudo guardar el atajo',
|
||||
mouseSideHint: 'Los botones laterales Mouse4 / Mouse5 se admiten como atajos globales de dictado en Windows',
|
||||
allowNonTsfFallbackLabel: 'Permitir alternativa sin TSF',
|
||||
allowNonTsfFallbackDesc:
|
||||
'Windows: si falla la inserción TSF, usa SendInput Unicode con pausas. Si también falla, copia el texto al portapapeles.',
|
||||
|
||||
@@ -1485,6 +1485,8 @@ export const fr: typeof zhCN = {
|
||||
comboRecorded: 'Raccourci enregistré',
|
||||
comboClear: 'Effacer',
|
||||
comboConflict: 'Cette combinaison n’est pas disponible',
|
||||
shortcutSaveFailed: 'Impossible d’enregistrer le raccourci',
|
||||
mouseSideHint: 'Les boutons latéraux Mouse4 / Mouse5 sont pris en charge comme raccourcis de dictée globaux sous Windows',
|
||||
allowNonTsfFallbackLabel: 'Autoriser une solution de repli sans TSF',
|
||||
allowNonTsfFallbackDesc:
|
||||
'Windows : si l’insertion TSF échoue, utilise SendInput Unicode avec des pauses. En cas de nouvel échec, copie le texte dans le presse-papiers.',
|
||||
|
||||
@@ -1432,6 +1432,8 @@ export const ja: typeof zhCN = {
|
||||
comboRecorded: '記録済み',
|
||||
comboClear: 'クリア',
|
||||
comboConflict: 'このショートカットの組み合わせは使用できません',
|
||||
shortcutSaveFailed: 'ショートカットの保存に失敗しました',
|
||||
mouseSideHint: 'マウスサイドボタン Mouse4 / Mouse5 は Windows でグローバルなディクテーションホットキーとして使えます',
|
||||
allowNonTsfFallbackLabel: '非 TSF フォールバックを許可',
|
||||
allowNonTsfFallbackDesc:
|
||||
'Windows:TSF 入力が失敗した時は分割した Unicode SendInput を使い、それも失敗した場合はクリップボードへコピーします。',
|
||||
|
||||
@@ -1420,6 +1420,8 @@ export const ko: typeof zhCN = {
|
||||
comboRecorded: '녹화됨',
|
||||
comboClear: '지우기',
|
||||
comboConflict: '이 단축키 조합은 사용할 수 없습니다',
|
||||
shortcutSaveFailed: '단축키를 저장하지 못했습니다',
|
||||
mouseSideHint: '마우스 측면 버튼 Mouse4 / Mouse5는 Windows에서 전역 받아쓰기 단축키로 지원됩니다',
|
||||
allowNonTsfFallbackLabel: '비 TSF 폴백 허용',
|
||||
allowNonTsfFallbackDesc:
|
||||
'Windows: TSF 입력이 실패하면 분할된 Unicode SendInput을 사용하고, 그래도 실패하면 텍스트를 클립보드에 복사합니다.',
|
||||
|
||||
@@ -1373,6 +1373,8 @@ export const zhCN = {
|
||||
comboRecorded: '已录制',
|
||||
comboClear: '清除',
|
||||
comboConflict: '该快捷键组合不可用',
|
||||
shortcutSaveFailed: '保存快捷键失败',
|
||||
mouseSideHint: '支持鼠标侧键 Mouse4 / Mouse5(Windows 全局)',
|
||||
allowNonTsfFallbackLabel: '允许非 TSF 兜底',
|
||||
allowNonTsfFallbackDesc:
|
||||
'Windows:TSF 失败时使用分批 Unicode SendInput;如果仍失败,再复制到剪贴板。',
|
||||
|
||||
@@ -1374,6 +1374,8 @@ export const zhTW: typeof zhCN = {
|
||||
comboRecorded: '已錄製',
|
||||
comboClear: '清除',
|
||||
comboConflict: '此快捷鍵組合不可用',
|
||||
shortcutSaveFailed: '儲存快捷鍵失敗',
|
||||
mouseSideHint: '支援滑鼠側鍵 Mouse4 / Mouse5(Windows 全域)',
|
||||
allowNonTsfFallbackLabel: '允許非 TSF 兜底',
|
||||
allowNonTsfFallbackDesc:
|
||||
'Windows:TSF 失敗時使用分批 Unicode SendInput;如果仍失敗,再複製到剪貼簿。',
|
||||
|
||||
@@ -1,7 +1,10 @@
|
||||
import {
|
||||
createHotkeyRecorderState,
|
||||
formatShortcutSaveError,
|
||||
shortcutFromMouseEvent,
|
||||
functionKeyPrimaryFromEvent,
|
||||
orderHotkeyCodes,
|
||||
primaryFromKeyboardEvent,
|
||||
updateHotkeyRecorderState,
|
||||
} from './hotkeyRecorder';
|
||||
|
||||
@@ -11,6 +14,28 @@ function assertEqual<T>(actual: T, expected: T, name: string) {
|
||||
}
|
||||
}
|
||||
|
||||
for (const message of [
|
||||
'RegisterFailed',
|
||||
'ManagerInitFailed',
|
||||
'Windows only',
|
||||
'Conflict',
|
||||
'unexpected backend failure',
|
||||
]) {
|
||||
assertEqual(formatShortcutSaveError(message, 'Save failed'), message, 'preserves backend error');
|
||||
assertEqual(
|
||||
formatShortcutSaveError(new Error(message), 'Save failed'),
|
||||
message,
|
||||
'preserves Error message',
|
||||
);
|
||||
}
|
||||
for (const reason of ['', ' ', null, undefined, {}]) {
|
||||
assertEqual(
|
||||
formatShortcutSaveError(reason, 'Save failed'),
|
||||
'Save failed',
|
||||
'empty error fallback',
|
||||
);
|
||||
}
|
||||
|
||||
function assertDeepEqual(actual: unknown, expected: unknown, name: string) {
|
||||
const actualJson = JSON.stringify(actual);
|
||||
const expectedJson = JSON.stringify(expected);
|
||||
@@ -19,6 +44,45 @@ function assertDeepEqual(actual: unknown, expected: unknown, name: string) {
|
||||
}
|
||||
}
|
||||
|
||||
for (const [button, primary] of [
|
||||
[3, 'Mouse4'],
|
||||
[4, 'Mouse5'],
|
||||
] as const) {
|
||||
assertDeepEqual(
|
||||
shortcutFromMouseEvent({
|
||||
button,
|
||||
ctrlKey: true,
|
||||
altKey: false,
|
||||
shiftKey: false,
|
||||
metaKey: false,
|
||||
}),
|
||||
{ primary, modifiers: ['ctrl'] },
|
||||
'mouse event preserves a modifier held before recording',
|
||||
);
|
||||
assertDeepEqual(
|
||||
shortcutFromMouseEvent({
|
||||
button,
|
||||
ctrlKey: false,
|
||||
altKey: false,
|
||||
shiftKey: false,
|
||||
metaKey: false,
|
||||
}),
|
||||
{ primary, modifiers: [] },
|
||||
'bare mouse side button',
|
||||
);
|
||||
}
|
||||
assertEqual(
|
||||
shortcutFromMouseEvent({
|
||||
button: 0,
|
||||
ctrlKey: true,
|
||||
altKey: false,
|
||||
shiftKey: false,
|
||||
metaKey: false,
|
||||
}),
|
||||
null,
|
||||
'ordinary click cannot become a shortcut',
|
||||
);
|
||||
|
||||
function apply(state = createHotkeyRecorderState(), code: string, pressed: boolean) {
|
||||
const next = updateHotkeyRecorderState(state, code, pressed);
|
||||
return next;
|
||||
@@ -132,3 +196,16 @@ assertEqual(
|
||||
null,
|
||||
'printable key preserved',
|
||||
);
|
||||
|
||||
assertEqual(
|
||||
primaryFromKeyboardEvent({ code: 'Space', key: ' ' }),
|
||||
'Space',
|
||||
'Space key normalizes to named Space (not literal space)',
|
||||
);
|
||||
assertEqual(
|
||||
primaryFromKeyboardEvent({ code: 'Space', key: 'Space' }),
|
||||
'Space',
|
||||
'Space code with Space key name',
|
||||
);
|
||||
assertEqual(primaryFromKeyboardEvent({ code: 'KeyA', key: 'a' }), 'A', 'letter from code');
|
||||
assertEqual(primaryFromKeyboardEvent({ code: 'Digit1', key: '!' }), '1', 'digit from code');
|
||||
@@ -1,3 +1,6 @@
|
||||
import type { ShortcutBinding } from './types';
|
||||
import { windowMouseHotkeyCode } from './windowHotkeyFallback';
|
||||
|
||||
export interface HotkeyRecorderState {
|
||||
pressedCodes: string[];
|
||||
draftCodes: string[];
|
||||
@@ -116,3 +119,72 @@ export function functionKeyPrimaryFromEvent(event: { code: string; key: string }
|
||||
if (supported.test(event.key)) return event.key;
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Normalize a keyboard event into the ShortcutBinding primary string.
|
||||
* Space must use the named code — `e.key === ' '` is length 1 and would otherwise
|
||||
* be trimmed to empty by backend validate_primary/parse_primary (#1109).
|
||||
*/
|
||||
export function primaryFromKeyboardEvent(event: { code: string; key: string }): string {
|
||||
const functionKey = functionKeyPrimaryFromEvent(event);
|
||||
if (functionKey) return functionKey;
|
||||
const printable = primaryFromPrintableCode(event.code);
|
||||
if (printable) return printable;
|
||||
if (event.code === 'Space' || event.key === ' ') return 'Space';
|
||||
if (event.key.length === 1) return event.key;
|
||||
const codeToName: Record<string, string> = {
|
||||
Space: 'Space',
|
||||
Enter: 'Enter',
|
||||
Tab: 'Tab',
|
||||
Backspace: 'Backspace',
|
||||
Delete: 'Delete',
|
||||
ArrowUp: 'ArrowUp',
|
||||
ArrowDown: 'ArrowDown',
|
||||
ArrowLeft: 'ArrowLeft',
|
||||
ArrowRight: 'ArrowRight',
|
||||
Home: 'Home',
|
||||
End: 'End',
|
||||
PageUp: 'PageUp',
|
||||
PageDown: 'PageDown',
|
||||
};
|
||||
if (/^F\d{1,2}$/.test(event.key)) return event.key;
|
||||
return codeToName[event.code] || event.key;
|
||||
}
|
||||
|
||||
function primaryFromPrintableCode(code: string): string {
|
||||
if (/^Key[A-Z]$/.test(code)) return code.slice(3);
|
||||
if (/^Digit[0-9]$/.test(code)) return code.slice(5);
|
||||
const codeToPrimary: Record<string, string> = {
|
||||
Backquote: '`',
|
||||
Minus: '-',
|
||||
Equal: '=',
|
||||
BracketLeft: '[',
|
||||
BracketRight: ']',
|
||||
Backslash: '\\',
|
||||
Semicolon: ';',
|
||||
Quote: "'",
|
||||
Comma: ',',
|
||||
Period: '.',
|
||||
Slash: '/',
|
||||
IntlBackslash: '\\',
|
||||
};
|
||||
return codeToPrimary[code] || '';
|
||||
}
|
||||
/** Preserve backend failure details without classifying localized strings. */
|
||||
export function formatShortcutSaveError(reason: unknown, fallback: string): string {
|
||||
const message =
|
||||
reason instanceof Error ? reason.message : typeof reason === 'string' ? reason : '';
|
||||
return message.trim() || fallback;
|
||||
}
|
||||
export function shortcutFromMouseEvent(
|
||||
event: Pick<MouseEvent, 'button' | 'ctrlKey' | 'altKey' | 'shiftKey' | 'metaKey'>,
|
||||
): ShortcutBinding | null {
|
||||
const primary = windowMouseHotkeyCode(event.button);
|
||||
if (!primary) return null;
|
||||
const modifiers: string[] = [];
|
||||
if (event.ctrlKey) modifiers.push('ctrl');
|
||||
if (event.altKey) modifiers.push('alt');
|
||||
if (event.shiftKey) modifiers.push('shift');
|
||||
if (event.metaKey) modifiers.push('super');
|
||||
return { primary, modifiers };
|
||||
}
|
||||
@@ -258,11 +258,15 @@ export function RecordingInputSection() {
|
||||
</div>
|
||||
)}
|
||||
{showDesktopHotkey && (
|
||||
<SettingRow label={t('settings.recording.hotkeyLabel')}>
|
||||
<SettingRow
|
||||
label={t('settings.recording.hotkeyLabel')}
|
||||
desc={os !== 'win' ? t('settings.recording.mouseSideHint') : undefined}
|
||||
>
|
||||
<ShortcutRecorder
|
||||
value={prefs.dictationHotkey}
|
||||
sideSpecificModifiers
|
||||
allowMacDictationKey={os === 'mac'}
|
||||
allowMouseButtons={os === 'win'}
|
||||
// 录音快捷键是核心热键,Rust 端不接受 null,不可停用——置灰并提示。
|
||||
disableDisabled
|
||||
disableHint={t('settings.recording.comboDisableHint')}
|
||||
|
||||
Reference in new issue
Block a user