From 8065895ac55ece86706df1490bfbae4ff8ac312a Mon Sep 17 00:00:00 2001 From: Leslie Leung Date: Mon, 4 May 2026 15:41:23 +0800 Subject: [PATCH 01/10] add configurable shortcut bindings --- .../app/src-tauri/src/combo_hotkey.rs | 205 +++++ openless-all/app/src-tauri/src/commands.rs | 178 +++- openless-all/app/src-tauri/src/coordinator.rs | 758 +++++++++++++++++- .../src-tauri/src/global_hotkey_runtime.rs | 107 +++ openless-all/app/src-tauri/src/hotkey.rs | 270 ++++++- openless-all/app/src-tauri/src/insertion.rs | 7 +- openless-all/app/src-tauri/src/lib.rs | 59 +- openless-all/app/src-tauri/src/polish.rs | 17 +- openless-all/app/src-tauri/src/qa_hotkey.rs | 246 ++---- openless-all/app/src-tauri/src/selection.rs | 7 +- .../app/src-tauri/src/shortcut_binding.rs | 231 ++++++ openless-all/app/src-tauri/src/types.rs | 228 +++++- .../app/src/components/FloatingShell.tsx | 18 +- .../app/src/components/Onboarding.tsx | 2 +- .../app/src/components/ShortcutRecorder.tsx | 197 +++++ openless-all/app/src/i18n/en.ts | 10 +- openless-all/app/src/i18n/zh-CN.ts | 10 +- openless-all/app/src/lib/hotkey.ts | 120 ++- openless-all/app/src/lib/ipc.ts | 44 +- openless-all/app/src/lib/types.ts | 25 +- openless-all/app/src/pages/History.tsx | 6 +- openless-all/app/src/pages/Overview.tsx | 8 +- openless-all/app/src/pages/SelectionAsk.tsx | 135 +--- openless-all/app/src/pages/Settings.tsx | 124 ++- openless-all/app/src/pages/Translation.tsx | 25 +- 25 files changed, 2601 insertions(+), 436 deletions(-) create mode 100644 openless-all/app/src-tauri/src/combo_hotkey.rs create mode 100644 openless-all/app/src-tauri/src/global_hotkey_runtime.rs create mode 100644 openless-all/app/src-tauri/src/shortcut_binding.rs create mode 100644 openless-all/app/src/components/ShortcutRecorder.tsx diff --git a/openless-all/app/src-tauri/src/combo_hotkey.rs b/openless-all/app/src-tauri/src/combo_hotkey.rs new file mode 100644 index 00000000..dd8d7144 --- /dev/null +++ b/openless-all/app/src-tauri/src/combo_hotkey.rs @@ -0,0 +1,205 @@ +//! 录音快捷键的自定义组合键监听器。 +//! +//! 与 `hotkey.rs`(modifier-only 听写热键)平行——当用户选择自定义组合键 +//! (如 `Cmd+Shift+D`)时,用 `global-hotkey` crate 注册。 +//! +//! 与 `qa_hotkey.rs` 的关键区别:**同时产出 Pressed 和 Released 边沿事件**, +//! 以支持 Hold(按住说话)模式。`global-hotkey` crate 的 `HotKeyState::Released` +//! 在 macOS (Carbon) 和 Windows 上均可用于检测松开。 +//! +//! 通过 `global_hotkey_runtime` 与 QA 快捷键共享进程级 manager / event receiver。 + +use std::sync::mpsc::{Receiver, Sender}; +use std::sync::Arc; + +use global_hotkey::{GlobalHotKeyEvent, HotKeyState}; +use parking_lot::Mutex; + +use crate::global_hotkey_runtime::{GlobalHotkeyRuntime, RegisteredHotkey}; +use crate::shortcut_binding::{parse_global_hotkey, ShortcutBindingError}; +use crate::types::ShortcutBinding; + +#[derive(Debug, Clone, Copy)] +pub enum ComboHotkeyEvent { + /// 用户按下了配置的组合键。 + Pressed, + /// 用户松开了配置的组合键(用于 Hold 模式结束录音)。 + Released, +} + +#[derive(Debug, thiserror::Error)] +pub enum ComboHotkeyError { + #[error("不支持的修饰键: {0}")] + UnsupportedModifier(String), + #[error("不支持的主键: {0}")] + UnsupportedKey(String), + #[error("注册全局快捷键失败: {0}")] + RegisterFailed(String), + #[error("初始化全局快捷键管理器失败: {0}")] + ManagerInitFailed(String), +} + +/// 自定义组合键全局快捷键监听器。`Drop` 时反注册。 +/// +/// 内部用 `global-hotkey` crate;事件转发线程持有一个共享的 `Sender`。 +/// 与 `QaHotkeyMonitor` 的区别:转发 Pressed **和** Released 事件。 +pub struct ComboHotkeyMonitor { + inner: Arc, +} + +struct Inner { + registered: Mutex>, + tx: Sender, +} + +// global-hotkey 0.6 的 GlobalHotKeyManager 在 Windows 内部持有 HHOOK / window +// handle 等 `*mut c_void`,crate 没标 Send/Sync。与 qa_hotkey.rs 同理。 +unsafe impl Send for Inner {} +unsafe impl Sync for Inner {} + +impl ComboHotkeyMonitor { + /// 启动监听并注册一个组合键。`tx` 在每次按下/松开边沿收到事件。 + /// + /// **注意**:`global-hotkey` crate 在 macOS 要求 manager 在主线程构造。 + /// 调用方需要确保从主线程触发。 + pub fn start( + binding: ShortcutBinding, + tx: Sender, + ) -> Result { + let runtime = GlobalHotkeyRuntime::shared() + .map_err(|e| ComboHotkeyError::ManagerInitFailed(e.to_string()))?; + + let hotkey = parse_binding(&binding)?; + let (registered, rx) = runtime + .register(hotkey) + .map_err(|e| ComboHotkeyError::RegisterFailed(e.to_string()))?; + + let tx_for_thread = tx.clone(); + std::thread::Builder::new() + .name("openless-combo-hotkey-forward".into()) + .spawn(move || forward_loop(rx, tx_for_thread)) + .map_err(|e| ComboHotkeyError::RegisterFailed(format!("spawn forward thread: {e}")))?; + + Ok(Self { + inner: Arc::new(Inner { + registered: Mutex::new(Some(registered)), + tx, + }), + }) + } + + /// 替换当前注册的组合键(用户在设置里改了组合键时)。 + pub fn update_binding(&self, binding: ShortcutBinding) -> Result<(), ComboHotkeyError> { + let next = parse_binding(&binding)?; + let mut current = self.inner.registered.lock(); + if let Some(prev) = current.as_ref() { + if prev.hotkey() == next { + return Ok(()); + } + } + current.take(); + let runtime = GlobalHotkeyRuntime::shared() + .map_err(|e| ComboHotkeyError::ManagerInitFailed(e.to_string()))?; + let (registered, rx) = runtime + .register(next) + .map_err(|e| ComboHotkeyError::RegisterFailed(e.to_string()))?; + std::thread::Builder::new() + .name("openless-combo-hotkey-forward".into()) + .spawn({ + let tx = self.inner.tx.clone(); + move || forward_loop(rx, tx) + }) + .map_err(|e| ComboHotkeyError::RegisterFailed(format!("spawn forward thread: {e}")))?; + *current = Some(registered); + Ok(()) + } +} + +impl Drop for ComboHotkeyMonitor { + fn drop(&mut self) { + self.inner.registered.lock().take(); + } +} + +fn forward_loop(rx: Receiver, tx: Sender) { + while let Ok(event) = rx.recv() { + let combo_event = match event.state() { + HotKeyState::Pressed => ComboHotkeyEvent::Pressed, + HotKeyState::Released => ComboHotkeyEvent::Released, + }; + if let Err(e) = tx.send(combo_event) { + log::warn!("[combo-hotkey] 事件投递失败: {e}"); + break; + } + } + log::info!("[combo-hotkey] 转发线程退出"); +} + +/// 测试一个组合键是否可以注册(不实际注册,仅验证格式)。 +pub fn validate_binding(binding: &ShortcutBinding) -> Result<(), ComboHotkeyError> { + parse_binding(binding)?; + Ok(()) +} + +fn parse_binding( + binding: &ShortcutBinding, +) -> Result { + parse_global_hotkey(binding).map_err(|e| match e { + ShortcutBindingError::UnsupportedModifier(m) => ComboHotkeyError::UnsupportedModifier(m), + ShortcutBindingError::UnsupportedKey(k) => ComboHotkeyError::UnsupportedKey(k), + }) +} + +#[cfg(test)] +mod tests { + use super::*; + use global_hotkey::hotkey::{Code, Modifiers}; + + #[test] + fn parse_cmd_shift_d() { + let binding = ShortcutBinding { + primary: "D".into(), + modifiers: vec!["cmd".into(), "shift".into()], + }; + let parsed = parse_binding(&binding).expect("binding parses"); + assert!(parsed.mods.contains(Modifiers::SUPER)); + assert!(parsed.mods.contains(Modifiers::SHIFT)); + assert_eq!(parsed.key, Code::KeyD); + } + + #[test] + fn parse_ctrl_shift_space() { + let binding = ShortcutBinding { + primary: "Space".into(), + modifiers: vec!["ctrl".into(), "shift".into()], + }; + let parsed = parse_binding(&binding).expect("binding parses"); + assert!(parsed.mods.contains(Modifiers::CONTROL)); + assert!(parsed.mods.contains(Modifiers::SHIFT)); + assert_eq!(parsed.key, Code::Space); + } + + #[test] + fn unsupported_modifier_rejected() { + let binding = ShortcutBinding { + primary: "D".into(), + modifiers: vec!["hyper".into()], + }; + assert!(matches!( + parse_binding(&binding), + Err(ComboHotkeyError::UnsupportedModifier(_)) + )); + } + + #[test] + fn empty_primary_rejected() { + let binding = ShortcutBinding { + primary: "".into(), + modifiers: vec!["cmd".into()], + }; + assert!(matches!( + parse_binding(&binding), + Err(ComboHotkeyError::UnsupportedKey(_)) + )); + } +} diff --git a/openless-all/app/src-tauri/src/commands.rs b/openless-all/app/src-tauri/src/commands.rs index ddbff79a..2c42ccf0 100644 --- a/openless-all/app/src-tauri/src/commands.rs +++ b/openless-all/app/src-tauri/src/commands.rs @@ -12,8 +12,8 @@ use crate::permissions::{self, PermissionStatus}; use crate::persistence::{CredentialAccount, CredentialsSnapshot, CredentialsVault}; use crate::polish::{LLMError, OpenAICompatibleConfig, OpenAICompatibleLLMProvider}; use crate::types::{ - CredentialsStatus, DictationSession, DictionaryEntry, HotkeyCapability, HotkeyStatus, - PolishMode, QaHotkeyBinding, UserPreferences, + ComboBinding, CredentialsStatus, DictationSession, DictionaryEntry, HotkeyCapability, + HotkeyStatus, PolishMode, ShortcutBinding, UserPreferences, }; type CoordinatorState<'a> = State<'a, Arc>; @@ -29,6 +29,10 @@ trait SettingsWriter { fn write_settings(&self, prefs: UserPreferences) -> Result<(), String>; fn refresh_dictation_hotkey(&self); fn refresh_qa_hotkey(&self); + fn refresh_combo_hotkey(&self); + fn refresh_translation_hotkey(&self); + fn refresh_switch_style_hotkey(&self); + fn refresh_open_app_hotkey(&self); } impl SettingsWriter for Coordinator { @@ -43,6 +47,22 @@ impl SettingsWriter for Coordinator { fn refresh_qa_hotkey(&self) { self.update_qa_hotkey_binding(); } + + fn refresh_combo_hotkey(&self) { + self.update_combo_hotkey_binding(); + } + + fn refresh_translation_hotkey(&self) { + self.update_translation_hotkey_binding(); + } + + fn refresh_switch_style_hotkey(&self) { + self.update_switch_style_hotkey_binding(); + } + + fn refresh_open_app_hotkey(&self) { + self.update_open_app_hotkey_binding(); + } } impl SettingsWriter for Arc { @@ -57,12 +77,32 @@ impl SettingsWriter for Arc { fn refresh_qa_hotkey(&self) { self.update_qa_hotkey_binding(); } + + fn refresh_combo_hotkey(&self) { + self.update_combo_hotkey_binding(); + } + + fn refresh_translation_hotkey(&self) { + self.update_translation_hotkey_binding(); + } + + fn refresh_switch_style_hotkey(&self) { + self.update_switch_style_hotkey_binding(); + } + + fn refresh_open_app_hotkey(&self) { + self.update_open_app_hotkey_binding(); + } } fn persist_settings(coord: &T, prefs: UserPreferences) -> Result<(), String> { coord.write_settings(prefs)?; coord.refresh_dictation_hotkey(); coord.refresh_qa_hotkey(); + coord.refresh_combo_hotkey(); + coord.refresh_translation_hotkey(); + coord.refresh_switch_style_hotkey(); + coord.refresh_open_app_hotkey(); Ok(()) } @@ -81,6 +121,11 @@ pub fn get_hotkey_capability(coord: CoordinatorState<'_>) -> HotkeyCapability { coord.hotkey_capability() } +#[tauri::command] +pub fn set_shortcut_recording_active(coord: CoordinatorState<'_>, active: bool) { + coord.set_shortcut_recording_active(active); +} + #[tauri::command] pub fn get_credentials() -> CredentialsStatus { let snap = CredentialsVault::snapshot(); @@ -523,9 +568,18 @@ pub fn get_qa_hotkey_label(coord: CoordinatorState<'_>) -> String { /// 传入 `None` 形式的字段不在这里支持——前端用 `binding == null` 时调下面的 /// "disable" 写法(写 prefs.qa_hotkey = None)即可。 #[tauri::command] -pub fn set_qa_hotkey(coord: CoordinatorState<'_>, binding: QaHotkeyBinding) -> Result<(), String> { +pub fn set_qa_hotkey( + coord: CoordinatorState<'_>, + binding: Option, +) -> Result<(), String> { + if let Some(binding) = binding.as_ref() { + crate::shortcut_binding::validate_binding(binding).map_err(|e| e.to_string())?; + if binding.modifiers.is_empty() && binding.primary.eq_ignore_ascii_case("shift") { + return Err("Shift 单键目前只能用于翻译快捷键".into()); + } + } let mut prefs = coord.prefs().get(); - prefs.qa_hotkey = Some(binding); + prefs.qa_hotkey = binding; coord.prefs().set(prefs).map_err(|e| e.to_string())?; coord.update_qa_hotkey_binding(); Ok(()) @@ -543,6 +597,108 @@ pub fn qa_window_pin(coord: CoordinatorState<'_>, pinned: bool) { coord.qa_window_pin(pinned); } +// ─────────────────────────── 自定义组合键 ─────────────────────────── + +/// 测试一个组合键是否可以注册(验证格式,不实际注册)。 +#[tauri::command] +pub fn validate_shortcut_binding(binding: ShortcutBinding) -> Result<(), String> { + crate::shortcut_binding::validate_binding(&binding).map_err(|e| e.to_string()) +} + +#[tauri::command] +pub fn set_dictation_hotkey( + coord: CoordinatorState<'_>, + binding: ShortcutBinding, +) -> Result<(), String> { + crate::shortcut_binding::validate_binding(&binding).map_err(|e| e.to_string())?; + if binding.modifiers.is_empty() && binding.primary.eq_ignore_ascii_case("shift") { + return Err("Shift 单键目前只能用于翻译快捷键".into()); + } + let mut prefs = coord.prefs().get(); + prefs.dictation_hotkey = binding; + coord.prefs().set(prefs).map_err(|e| e.to_string())?; + coord.update_hotkey_binding(); + coord.update_combo_hotkey_binding(); + Ok(()) +} + +#[tauri::command] +pub fn set_translation_hotkey( + coord: CoordinatorState<'_>, + binding: ShortcutBinding, +) -> Result<(), String> { + crate::shortcut_binding::validate_binding(&binding).map_err(|e| e.to_string())?; + let mut prefs = coord.prefs().get(); + prefs.translation_hotkey = binding; + coord.prefs().set(prefs).map_err(|e| e.to_string())?; + coord.update_translation_hotkey_binding(); + Ok(()) +} + +#[tauri::command] +pub fn set_switch_style_hotkey( + coord: CoordinatorState<'_>, + binding: ShortcutBinding, +) -> Result<(), String> { + crate::shortcut_binding::validate_binding(&binding).map_err(|e| e.to_string())?; + reject_modifier_only_action_shortcut(&binding)?; + let mut prefs = coord.prefs().get(); + prefs.switch_style_hotkey = binding; + coord.prefs().set(prefs).map_err(|e| e.to_string())?; + coord.update_switch_style_hotkey_binding(); + Ok(()) +} + +#[tauri::command] +pub fn set_open_app_hotkey( + coord: CoordinatorState<'_>, + binding: ShortcutBinding, +) -> Result<(), String> { + crate::shortcut_binding::validate_binding(&binding).map_err(|e| e.to_string())?; + reject_modifier_only_action_shortcut(&binding)?; + let mut prefs = coord.prefs().get(); + prefs.open_app_hotkey = binding; + coord.prefs().set(prefs).map_err(|e| e.to_string())?; + coord.update_open_app_hotkey_binding(); + Ok(()) +} + +fn reject_modifier_only_action_shortcut(binding: &ShortcutBinding) -> Result<(), String> { + if binding.modifiers.is_empty() + && (binding.primary.eq_ignore_ascii_case("shift") + || crate::shortcut_binding::legacy_modifier_trigger(binding).is_some()) + { + return Err("该快捷键需要使用组合键或非修饰主键".into()); + } + Ok(()) +} + +#[tauri::command] +pub fn validate_combo_hotkey(binding: ComboBinding) -> Result<(), String> { + validate_shortcut_binding(ShortcutBinding { + primary: binding.primary, + modifiers: binding.modifiers, + }) +} + +/// 设置自定义录音组合键并热更新 monitor。 +#[tauri::command] +pub fn set_combo_hotkey(coord: CoordinatorState<'_>, binding: ComboBinding) -> Result<(), String> { + let mut prefs = coord.prefs().get(); + let shortcut = ShortcutBinding { + primary: binding.primary.clone(), + modifiers: binding.modifiers.clone(), + }; + crate::shortcut_binding::validate_binding(&shortcut).map_err(|e| e.to_string())?; + prefs.custom_combo_hotkey = Some(binding); + prefs.dictation_hotkey = shortcut; + prefs.hotkey.trigger = crate::types::HotkeyTrigger::Custom; + coord.prefs().set(prefs).map_err(|e| e.to_string())?; + coord.update_hotkey_binding(); + coord.update_combo_hotkey_binding(); + Ok(()) +} + // ─────────────────────────── unused but exported (silences dead_code) ─────────────────────────── #[allow(dead_code)] @@ -552,7 +708,7 @@ fn _ensure_snapshot_used(_: CredentialsSnapshot) {} mod tests { use super::{models_url, parse_model_ids, persist_settings, SettingsWriter}; use crate::types::{ - HotkeyBinding, HotkeyMode, HotkeyTrigger, QaHotkeyBinding, UserPreferences, + HotkeyBinding, HotkeyMode, HotkeyTrigger, ShortcutBinding, UserPreferences, }; use std::sync::Mutex; @@ -561,6 +717,7 @@ mod tests { saved: Mutex>, dictation_refreshes: Mutex, qa_refreshes: Mutex, + combo_refreshes: Mutex, } impl SettingsWriter for FakeSettingsWriter { @@ -576,6 +733,14 @@ mod tests { fn refresh_qa_hotkey(&self) { *self.qa_refreshes.lock().unwrap() += 1; } + + fn refresh_combo_hotkey(&self) { + *self.combo_refreshes.lock().unwrap() += 1; + } + + fn refresh_translation_hotkey(&self) {} + fn refresh_switch_style_hotkey(&self) {} + fn refresh_open_app_hotkey(&self) {} } #[test] @@ -606,7 +771,7 @@ mod tests { trigger: HotkeyTrigger::RightControl, mode: HotkeyMode::Toggle, }, - qa_hotkey: Some(QaHotkeyBinding { + qa_hotkey: Some(ShortcutBinding { primary: ";".to_string(), modifiers: vec!["ctrl".to_string(), "shift".to_string()], }), @@ -629,5 +794,6 @@ mod tests { ); assert_eq!(*writer.dictation_refreshes.lock().unwrap(), 1); assert_eq!(*writer.qa_refreshes.lock().unwrap(), 1); + assert_eq!(*writer.combo_refreshes.lock().unwrap(), 1); } } diff --git a/openless-all/app/src-tauri/src/coordinator.rs b/openless-all/app/src-tauri/src/coordinator.rs index 42eea2e8..8ad803ce 100644 --- a/openless-all/app/src-tauri/src/coordinator.rs +++ b/openless-all/app/src-tauri/src/coordinator.rs @@ -19,6 +19,7 @@ use crate::asr::{ DictionaryHotword, RawTranscript, VolcengineCredentials, VolcengineStreamingASR, WhisperBatchASR, }; +use crate::combo_hotkey::{ComboHotkeyError, ComboHotkeyEvent, ComboHotkeyMonitor}; use crate::hotkey::{HotkeyEvent, HotkeyMonitor}; use crate::insertion::TextInserter; use crate::persistence::{ @@ -101,6 +102,13 @@ struct Inner { hotkey: Mutex>, hotkey_status: Mutex, hotkey_trigger_held: AtomicBool, + shortcut_recording_active: AtomicBool, + /// 自定义组合键监听器(global-hotkey crate)。当 `prefs.hotkey.trigger == Custom` 时 + /// 代替 modifier-only 的 hotkey monitor。`None` 表示不使用自定义组合键或还没成功安装。 + combo_hotkey: Mutex>, + translation_hotkey: Mutex>, + switch_style_hotkey: Mutex>, + open_app_hotkey: Mutex>, /// 翻译模式触发标志。每次 begin_session 重置为 false;hotkey 监听器在 /// Listening / Starting 阶段看到 Shift down 边沿时 set true。 /// end_session 在调 polish/translate 前读这个 flag + translation_target_language @@ -131,6 +139,12 @@ enum QaPhase { Processing, } +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +enum ActionHotkeyKind { + SwitchStyle, + OpenApp, +} + struct QaSessionState { phase: QaPhase, cancelled: bool, @@ -185,6 +199,11 @@ impl Coordinator { hotkey: Mutex::new(None), hotkey_status: Mutex::new(HotkeyStatus::default()), hotkey_trigger_held: AtomicBool::new(false), + shortcut_recording_active: AtomicBool::new(false), + combo_hotkey: Mutex::new(None), + translation_hotkey: Mutex::new(None), + switch_style_hotkey: Mutex::new(None), + open_app_hotkey: Mutex::new(None), translation_modifier_seen: AtomicBool::new(false), qa_hotkey: Mutex::new(None), qa_state: Mutex::new(QaSessionState::default()), @@ -241,6 +260,98 @@ impl Coordinator { } } + /// 启动自定义组合键监听器。当 `prefs.hotkey.trigger == Custom` 时, + /// 代替 modifier-only 的 hotkey monitor。 + pub fn start_combo_hotkey_listener(&self) { + let inner = Arc::clone(&self.inner); + std::thread::Builder::new() + .name("openless-combo-hotkey-supervisor".into()) + .spawn(move || combo_hotkey_supervisor_loop(inner)) + .ok(); + } + + pub fn stop_combo_hotkey_listener(&self) { + take_combo_hotkey_on_main_thread(&self.inner); + } + + pub fn start_translation_hotkey_listener(&self) { + let inner = Arc::clone(&self.inner); + std::thread::Builder::new() + .name("openless-translation-hotkey-supervisor".into()) + .spawn(move || translation_hotkey_supervisor_loop(inner)) + .ok(); + } + + pub fn stop_translation_hotkey_listener(&self) { + take_translation_hotkey_on_main_thread(&self.inner); + } + + pub fn start_switch_style_hotkey_listener(&self) { + let inner = Arc::clone(&self.inner); + std::thread::Builder::new() + .name("openless-switch-style-hotkey-supervisor".into()) + .spawn(move || action_hotkey_supervisor_loop(inner, ActionHotkeyKind::SwitchStyle)) + .ok(); + } + + pub fn start_open_app_hotkey_listener(&self) { + let inner = Arc::clone(&self.inner); + std::thread::Builder::new() + .name("openless-open-app-hotkey-supervisor".into()) + .spawn(move || action_hotkey_supervisor_loop(inner, ActionHotkeyKind::OpenApp)) + .ok(); + } + + /// 用户在设置里改了自定义组合键时调用。 + pub fn update_combo_hotkey_binding(&self) { + let prefs = self.inner.prefs.get(); + if crate::shortcut_binding::legacy_modifier_trigger(&prefs.dictation_hotkey).is_some() { + // 不是 Custom → drop combo monitor(如果有的话) + take_combo_hotkey_on_main_thread(&self.inner); + log::info!("[coord] combo hotkey 已关闭(trigger != Custom)"); + return; + } + let binding = prefs.dictation_hotkey.clone(); + if crate::shortcut_binding::legacy_modifier_trigger(&binding).is_some() { + // trigger == Custom 但没绑定 → drop monitor + take_combo_hotkey_on_main_thread(&self.inner); + log::info!("[coord] combo hotkey 已关闭(无绑定)"); + return; + }; + let app = self.inner.app.lock().clone(); + let Some(app) = app else { + log::warn!("[coord] update combo hotkey binding: AppHandle 未 bind,跳过"); + return; + }; + let inner_clone = Arc::clone(&self.inner); + let binding_for_main = binding.clone(); + let _ = app.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::(); + 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}"); + } + } + }); + } + /// 用户在设置里改了 QA 组合键时调用。先持久化(由 prefs.set 完成), /// 然后通知活着的 monitor 重新注册;monitor 不存在时 supervisor 会自然 /// 在下一次循环里读到新的 prefs。 @@ -259,8 +370,24 @@ impl Coordinator { self.inner.qa_hotkey.lock().take(); } log::info!("[coord] QA hotkey 已关闭"); + self.update_modifier_shortcut_bindings(); return; }; + if crate::shortcut_binding::legacy_modifier_trigger(&binding).is_some() { + let app = self.inner.app.lock().clone(); + if let Some(app) = app { + let inner_clone = Arc::clone(&self.inner); + let _ = app.run_on_main_thread(move || { + inner_clone.qa_hotkey.lock().take(); + }); + } else { + self.inner.qa_hotkey.lock().take(); + } + self.update_modifier_shortcut_bindings(); + log::info!("[coord] QA hotkey uses modifier-only listener"); + return; + } + self.update_modifier_shortcut_bindings(); // global-hotkey crate 的 manager.register/unregister 必须主线程跑。 // 没在主线程会让 Carbon 句柄注册看似成功但事件不派发。 let app = self.inner.app.lock().clone(); @@ -298,6 +425,90 @@ impl Coordinator { }); } + pub fn update_translation_hotkey_binding(&self) { + let prefs = self.inner.prefs.get(); + if is_builtin_translation_shift(&prefs.translation_hotkey) + || crate::shortcut_binding::legacy_modifier_trigger(&prefs.translation_hotkey).is_some() + { + take_translation_hotkey_on_main_thread(&self.inner); + self.update_modifier_shortcut_bindings(); + log::info!("[coord] translation hotkey uses modifier-only listener"); + return; + } + self.update_modifier_shortcut_bindings(); + let app = self.inner.app.lock().clone(); + let Some(app) = app else { + log::warn!("[coord] update translation hotkey binding: AppHandle 未 bind,跳过"); + return; + }; + let inner_clone = Arc::clone(&self.inner); + let binding_for_main = prefs.translation_hotkey.clone(); + let _ = app.run_on_main_thread(move || { + if let Some(monitor) = inner_clone.translation_hotkey.lock().as_ref() { + if let Err(e) = monitor.update_binding(binding_for_main.clone()) { + log::warn!("[coord] update translation hotkey binding 失败: {e}"); + } + return; + } + let (tx, rx) = mpsc::channel::(); + match ComboHotkeyMonitor::start(binding_for_main, tx) { + Ok(monitor) => { + *inner_clone.translation_hotkey.lock() = Some(monitor); + let bridge_inner = Arc::clone(&inner_clone); + std::thread::Builder::new() + .name("openless-translation-hotkey-bridge".into()) + .spawn(move || translation_hotkey_bridge_loop(bridge_inner, rx)) + .ok(); + } + Err(e) => log::warn!("[coord] update translation hotkey binding 失败: {e}"), + } + }); + } + + pub fn update_switch_style_hotkey_binding(&self) { + self.update_action_hotkey_binding(ActionHotkeyKind::SwitchStyle); + } + + pub fn update_open_app_hotkey_binding(&self) { + self.update_action_hotkey_binding(ActionHotkeyKind::OpenApp); + } + + fn update_action_hotkey_binding(&self, kind: ActionHotkeyKind) { + let binding = action_hotkey_binding(&self.inner, kind); + if is_modifier_only_shortcut(&binding) { + take_action_hotkey_on_main_thread(&self.inner, kind); + log::warn!("[coord] action hotkey {kind:?} 使用了不支持的 modifier-only 绑定,已关闭"); + return; + } + + let app = self.inner.app.lock().clone(); + let Some(app) = app else { + log::warn!("[coord] update action hotkey binding: AppHandle 未 bind,跳过"); + return; + }; + let inner_clone = Arc::clone(&self.inner); + let _ = app.run_on_main_thread(move || { + if let Some(monitor) = action_hotkey_slot(&inner_clone, kind).lock().as_ref() { + if let Err(e) = monitor.update_binding(binding.clone()) { + log::warn!("[coord] update action hotkey {kind:?} binding 失败: {e}"); + } + return; + } + let (tx, rx) = mpsc::channel::(); + match ComboHotkeyMonitor::start(binding, tx) { + Ok(monitor) => { + *action_hotkey_slot(&inner_clone, kind).lock() = Some(monitor); + let bridge_inner = Arc::clone(&inner_clone); + std::thread::Builder::new() + .name(action_hotkey_bridge_thread_name(kind).into()) + .spawn(move || action_hotkey_bridge_loop(bridge_inner, rx, kind)) + .ok(); + } + Err(e) => log::warn!("[coord] update action hotkey {kind:?} binding 失败: {e}"), + } + }); + } + /// 给前端 Settings 渲染当前 QA 快捷键 label(如 "Cmd+Shift+;")。 /// `qa_hotkey == None` 时返回空串,UI 据此显示「未启用」。 pub fn qa_hotkey_label(&self) -> String { @@ -333,8 +544,59 @@ impl Coordinator { } pub fn update_hotkey_binding(&self) { + let prefs = self.inner.prefs.get(); + let dictation_trigger = + crate::shortcut_binding::legacy_modifier_trigger(&prefs.dictation_hotkey); + let binding = crate::types::HotkeyBinding { + trigger: dictation_trigger.unwrap_or(crate::types::HotkeyTrigger::Custom), + mode: prefs.hotkey.mode, + }; + 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 Some(monitor) = self.inner.hotkey.lock().as_ref() { + monitor.update_binding(binding); + return; + } + let (tx, rx) = mpsc::channel::(); + match HotkeyMonitor::start(binding, tx) { + Ok(monitor) => { + let adapter = monitor.kind(); + *self.inner.hotkey.lock() = Some(monitor); + *self.inner.hotkey_status.lock() = HotkeyStatus { + adapter, + state: HotkeyStatusState::Installed, + message: Some(format!("{} 已安装", adapter.display_name())), + last_error: None, + }; + let inner_clone = Arc::clone(&self.inner); + std::thread::Builder::new() + .name("openless-hotkey-bridge".into()) + .spawn(move || hotkey_bridge_loop(inner_clone, rx)) + .ok(); + } + Err(e) => { + *self.inner.hotkey_status.lock() = HotkeyStatus { + adapter: HotkeyMonitor::capability().adapter, + state: HotkeyStatusState::Failed, + message: Some(e.message.clone()), + last_error: Some(e), + }; + } + } + } + + pub fn update_modifier_shortcut_bindings(&self) { if let Some(monitor) = self.inner.hotkey.lock().as_ref() { - monitor.update_binding(self.inner.prefs.get().hotkey); + let (qa_trigger, translation_trigger) = modifier_shortcut_triggers(&self.inner); + monitor.update_modifier_shortcuts(qa_trigger, translation_trigger); } } @@ -362,6 +624,18 @@ impl Coordinator { cancel_session(&self.inner); } + pub fn set_shortcut_recording_active(&self, active: bool) { + self.inner + .shortcut_recording_active + .store(active, Ordering::SeqCst); + if active { + self.inner + .hotkey_trigger_held + .store(false, Ordering::SeqCst); + } + log::info!("[coord] shortcut recording active={active}"); + } + pub async fn handle_window_hotkey_event( &self, event_type: String, @@ -405,6 +679,8 @@ fn hotkey_supervisor_loop(inner: Arc) { let mut attempts: u32 = 0; let capability = HotkeyMonitor::capability(); loop { + let prefs = inner.prefs.get(); + if inner.hotkey.lock().is_some() { return; } @@ -415,11 +691,20 @@ fn hotkey_supervisor_loop(inner: Arc) { last_error: None, }; let (tx, rx) = mpsc::channel::(); - let binding = inner.prefs.get().hotkey; + let trigger = crate::shortcut_binding::legacy_modifier_trigger(&prefs.dictation_hotkey) + .unwrap_or(crate::types::HotkeyTrigger::Custom); + let binding = crate::types::HotkeyBinding { + trigger, + mode: prefs.hotkey.mode, + }; match HotkeyMonitor::start(binding, tx) { Ok(monitor) => { let adapter = monitor.kind(); *inner.hotkey.lock() = Some(monitor); + if let Some(monitor) = inner.hotkey.lock().as_ref() { + let (qa_trigger, translation_trigger) = modifier_shortcut_triggers(&inner); + monitor.update_modifier_shortcuts(qa_trigger, translation_trigger); + } *inner.hotkey_status.lock() = HotkeyStatus { adapter, state: HotkeyStatusState::Installed, @@ -472,6 +757,15 @@ fn qa_hotkey_supervisor_loop(inner: Arc) { continue; } }; + if crate::shortcut_binding::legacy_modifier_trigger(&binding).is_some() { + inner.qa_hotkey.lock().take(); + if let Some(monitor) = inner.hotkey.lock().as_ref() { + let (qa_trigger, translation_trigger) = modifier_shortcut_triggers(&inner); + monitor.update_modifier_shortcuts(qa_trigger, translation_trigger); + } + std::thread::sleep(std::time::Duration::from_secs(5)); + continue; + } if inner.qa_hotkey.lock().is_some() { // 已注册成功 → 不重复装;睡 5s 复查( binding 变化由 update 路径手动触发 )。 @@ -544,6 +838,9 @@ fn qa_hotkey_supervisor_loop(inner: Arc) { fn qa_hotkey_bridge_loop(inner: Arc, rx: mpsc::Receiver) { while let Ok(evt) = rx.recv() { + if inner.shortcut_recording_active.load(Ordering::SeqCst) { + continue; + } let inner_cloned = Arc::clone(&inner); match evt { QaHotkeyEvent::Pressed => { @@ -553,6 +850,423 @@ fn qa_hotkey_bridge_loop(inner: Arc, rx: mpsc::Receiver) { } } +// ─────────────────────────── combo hotkey supervisor ─────────────────────────── + +fn combo_hotkey_supervisor_loop(inner: Arc) { + let mut attempts: u32 = 0; + loop { + // 读当前 prefs + let prefs = inner.prefs.get(); + if crate::shortcut_binding::legacy_modifier_trigger(&prefs.dictation_hotkey).is_some() { + // 不是 Custom → 睡着等 prefs 改动 + take_combo_hotkey_on_main_thread(&inner); + std::thread::sleep(std::time::Duration::from_secs(5)); + continue; + } + + let binding = prefs.dictation_hotkey.clone(); + + if inner.combo_hotkey.lock().is_some() { + std::thread::sleep(std::time::Duration::from_secs(5)); + continue; + } + + let app = inner.app.lock().clone(); + let app = match app { + Some(a) => a, + None => { + std::thread::sleep(std::time::Duration::from_secs(1)); + continue; + } + }; + + let (tx, rx) = mpsc::channel::(); + let (init_tx, init_rx) = + mpsc::sync_channel::>(1); + let binding_for_main = binding.clone(); + let _ = app.run_on_main_thread(move || { + let result = ComboHotkeyMonitor::start(binding_for_main, tx); + let _ = init_tx.send(result); + }); + + let init_result = match init_rx.recv_timeout(std::time::Duration::from_secs(5)) { + Ok(r) => r, + Err(_) => { + attempts += 1; + if attempts <= 3 || attempts % 10 == 0 { + log::warn!( + "[coord] combo hotkey 第 {attempts} 次注册超时(主线程未回执);3s 后重试" + ); + } + std::thread::sleep(std::time::Duration::from_secs(3)); + continue; + } + }; + + 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(); + attempts = 0; + } + Err(e) => { + attempts += 1; + if attempts <= 3 || attempts % 10 == 0 { + log::warn!("[coord] combo hotkey 第 {attempts} 次注册失败: {e}; 3s 后重试"); + } + std::thread::sleep(std::time::Duration::from_secs(3)); + } + } + } +} + +fn combo_hotkey_bridge_loop(inner: Arc, rx: mpsc::Receiver) { + while let Ok(evt) = rx.recv() { + if inner.shortcut_recording_active.load(Ordering::SeqCst) { + continue; + } + let inner_cloned = Arc::clone(&inner); + match evt { + ComboHotkeyEvent::Pressed => { + async_runtime::spawn(async move { handle_pressed_edge(&inner_cloned).await }); + } + ComboHotkeyEvent::Released => { + async_runtime::spawn(async move { handle_released_edge(&inner_cloned).await }); + } + } + } +} + +fn translation_hotkey_supervisor_loop(inner: Arc) { + let mut attempts: u32 = 0; + loop { + let binding = inner.prefs.get().translation_hotkey; + if is_builtin_translation_shift(&binding) + || crate::shortcut_binding::legacy_modifier_trigger(&binding).is_some() + { + take_translation_hotkey_on_main_thread(&inner); + if let Some(monitor) = inner.hotkey.lock().as_ref() { + let (qa_trigger, translation_trigger) = modifier_shortcut_triggers(&inner); + monitor.update_modifier_shortcuts(qa_trigger, translation_trigger); + } + std::thread::sleep(std::time::Duration::from_secs(5)); + continue; + } + + if inner.translation_hotkey.lock().is_some() { + std::thread::sleep(std::time::Duration::from_secs(5)); + continue; + } + + let app = match inner.app.lock().clone() { + Some(a) => a, + None => { + std::thread::sleep(std::time::Duration::from_secs(1)); + continue; + } + }; + + let (tx, rx) = mpsc::channel::(); + let (init_tx, init_rx) = + mpsc::sync_channel::>(1); + let binding_for_main = binding.clone(); + let _ = app.run_on_main_thread(move || { + let result = ComboHotkeyMonitor::start(binding_for_main, tx); + let _ = init_tx.send(result); + }); + + let init_result = match init_rx.recv_timeout(std::time::Duration::from_secs(5)) { + Ok(r) => r, + Err(_) => { + attempts += 1; + std::thread::sleep(std::time::Duration::from_secs(3)); + continue; + } + }; + + match init_result { + Ok(monitor) => { + *inner.translation_hotkey.lock() = Some(monitor); + let inner_clone = Arc::clone(&inner); + std::thread::Builder::new() + .name("openless-translation-hotkey-bridge".into()) + .spawn(move || translation_hotkey_bridge_loop(inner_clone, rx)) + .ok(); + attempts = 0; + } + Err(e) => { + attempts += 1; + if attempts <= 3 || attempts % 10 == 0 { + log::warn!( + "[coord] translation hotkey 第 {attempts} 次注册失败: {e}; 3s 后重试" + ); + } + std::thread::sleep(std::time::Duration::from_secs(3)); + } + } + } +} + +fn translation_hotkey_bridge_loop(inner: Arc, rx: mpsc::Receiver) { + while let Ok(evt) = rx.recv() { + if inner.shortcut_recording_active.load(Ordering::SeqCst) { + continue; + } + if matches!(evt, ComboHotkeyEvent::Pressed) { + mark_translation_modifier_seen(&inner); + } + } +} + +fn action_hotkey_supervisor_loop(inner: Arc, kind: ActionHotkeyKind) { + let mut attempts: u32 = 0; + loop { + let binding = action_hotkey_binding(&inner, kind); + if is_modifier_only_shortcut(&binding) { + take_action_hotkey_on_main_thread(&inner, kind); + std::thread::sleep(std::time::Duration::from_secs(5)); + continue; + } + + if action_hotkey_slot(&inner, kind).lock().is_some() { + std::thread::sleep(std::time::Duration::from_secs(5)); + continue; + } + + let app = match inner.app.lock().clone() { + Some(a) => a, + None => { + std::thread::sleep(std::time::Duration::from_secs(1)); + continue; + } + }; + + let (tx, rx) = mpsc::channel::(); + let (init_tx, init_rx) = + mpsc::sync_channel::>(1); + let binding_for_main = binding.clone(); + let _ = app.run_on_main_thread(move || { + let result = ComboHotkeyMonitor::start(binding_for_main, tx); + let _ = init_tx.send(result); + }); + + let init_result = match init_rx.recv_timeout(std::time::Duration::from_secs(5)) { + Ok(r) => r, + Err(_) => { + attempts += 1; + if attempts <= 3 || attempts % 10 == 0 { + log::warn!( + "[coord] action hotkey {kind:?} 第 {attempts} 次注册超时;3s 后重试" + ); + } + std::thread::sleep(std::time::Duration::from_secs(3)); + continue; + } + }; + + match init_result { + Ok(monitor) => { + *action_hotkey_slot(&inner, kind).lock() = Some(monitor); + log::info!( + "[coord] action hotkey {kind:?} listener installed after {} attempt(s)", + attempts + 1 + ); + let inner_clone = Arc::clone(&inner); + std::thread::Builder::new() + .name(action_hotkey_bridge_thread_name(kind).into()) + .spawn(move || action_hotkey_bridge_loop(inner_clone, rx, kind)) + .ok(); + attempts = 0; + } + Err(e) => { + attempts += 1; + if attempts <= 3 || attempts % 10 == 0 { + log::warn!( + "[coord] action hotkey {kind:?} 第 {attempts} 次注册失败: {e}; 3s 后重试" + ); + } + std::thread::sleep(std::time::Duration::from_secs(3)); + } + } + } +} + +fn action_hotkey_bridge_loop( + inner: Arc, + rx: mpsc::Receiver, + kind: ActionHotkeyKind, +) { + while let Ok(evt) = rx.recv() { + if inner.shortcut_recording_active.load(Ordering::SeqCst) { + continue; + } + if matches!(evt, ComboHotkeyEvent::Pressed) { + handle_action_hotkey_pressed(&inner, kind); + } + } +} + +fn handle_action_hotkey_pressed(inner: &Arc, kind: ActionHotkeyKind) { + match kind { + ActionHotkeyKind::SwitchStyle => switch_to_previous_style(inner), + ActionHotkeyKind::OpenApp => { + if let Some(app) = inner.app.lock().clone() { + let app_for_main = app.clone(); + let _ = app.run_on_main_thread(move || { + crate::show_main_window(&app_for_main); + }); + } + } + } +} + +fn switch_to_previous_style(inner: &Arc) { + let mut prefs = inner.prefs.get(); + let order = [ + PolishMode::Raw, + PolishMode::Light, + PolishMode::Structured, + PolishMode::Formal, + ]; + let enabled: Vec = order + .into_iter() + .filter(|mode| prefs.enabled_modes.contains(mode)) + .collect(); + if enabled.len() <= 1 { + log::info!("[coord] switch style hotkey ignored: enabled style count <= 1"); + return; + } + let current_index = enabled + .iter() + .position(|mode| *mode == prefs.default_mode) + .unwrap_or(0); + let next_index = if current_index == 0 { + enabled.len() - 1 + } else { + current_index - 1 + }; + prefs.default_mode = enabled[next_index]; + if let Err(e) = inner.prefs.set(prefs.clone()) { + log::warn!("[coord] switch style hotkey 保存失败: {e}"); + } else { + log::info!( + "[coord] switch style hotkey changed default mode to {}", + prefs.default_mode.display_name() + ); + } +} + +fn take_combo_hotkey_on_main_thread(inner: &Arc) { + let app = inner.app.lock().clone(); + if let Some(app) = app { + let inner = Arc::clone(inner); + let _ = app.run_on_main_thread(move || { + inner.combo_hotkey.lock().take(); + }); + } else { + inner.combo_hotkey.lock().take(); + } +} + +fn take_translation_hotkey_on_main_thread(inner: &Arc) { + let app = inner.app.lock().clone(); + if let Some(app) = app { + let inner = Arc::clone(inner); + let _ = app.run_on_main_thread(move || { + inner.translation_hotkey.lock().take(); + }); + } else { + inner.translation_hotkey.lock().take(); + } +} + +fn take_action_hotkey_on_main_thread(inner: &Arc, kind: ActionHotkeyKind) { + let app = inner.app.lock().clone(); + if let Some(app) = app { + let inner = Arc::clone(inner); + let _ = app.run_on_main_thread(move || { + action_hotkey_slot(&inner, kind).lock().take(); + }); + } else { + action_hotkey_slot(inner, kind).lock().take(); + } +} + +fn action_hotkey_slot( + inner: &Arc, + kind: ActionHotkeyKind, +) -> &Mutex> { + match kind { + ActionHotkeyKind::SwitchStyle => &inner.switch_style_hotkey, + ActionHotkeyKind::OpenApp => &inner.open_app_hotkey, + } +} + +fn action_hotkey_binding( + inner: &Arc, + kind: ActionHotkeyKind, +) -> crate::types::ShortcutBinding { + let prefs = inner.prefs.get(); + match kind { + ActionHotkeyKind::SwitchStyle => prefs.switch_style_hotkey, + ActionHotkeyKind::OpenApp => prefs.open_app_hotkey, + } +} + +fn is_modifier_only_shortcut(binding: &crate::types::ShortcutBinding) -> bool { + binding.modifiers.is_empty() + && (binding.primary.eq_ignore_ascii_case("shift") + || crate::shortcut_binding::legacy_modifier_trigger(binding).is_some()) +} + +fn action_hotkey_bridge_thread_name(kind: ActionHotkeyKind) -> &'static str { + match kind { + ActionHotkeyKind::SwitchStyle => "openless-switch-style-hotkey-bridge", + ActionHotkeyKind::OpenApp => "openless-open-app-hotkey-bridge", + } +} + +fn is_builtin_translation_shift(binding: &crate::types::ShortcutBinding) -> bool { + binding.modifiers.is_empty() && binding.primary.eq_ignore_ascii_case("shift") +} + +fn modifier_shortcut_triggers( + inner: &Arc, +) -> ( + Option, + Option, +) { + let prefs = inner.prefs.get(); + let qa_trigger = prefs + .qa_hotkey + .as_ref() + .and_then(crate::shortcut_binding::legacy_modifier_trigger); + let translation_trigger = if is_builtin_translation_shift(&prefs.translation_hotkey) { + None + } else { + crate::shortcut_binding::legacy_modifier_trigger(&prefs.translation_hotkey) + }; + (qa_trigger, translation_trigger) +} + +fn mark_translation_modifier_seen(inner: &Arc) { + let phase = inner.state.lock().phase; + if matches!(phase, SessionPhase::Starting | SessionPhase::Listening) { + inner + .translation_modifier_seen + .store(true, Ordering::SeqCst); + log::info!("[coord] translation modifier seen during {phase:?}"); + } +} + async fn handle_qa_hotkey_pressed(inner: &Arc) { // QA hotkey(默认 Cmd+Shift+;)现在只 toggle 浮窗可见性。 // 浮窗内的录音 / 提问由 Option 边沿驱动(handle_pressed_edge → handle_qa_option_edge)。 @@ -631,6 +1345,9 @@ fn close_qa_panel(inner: &Arc) { fn hotkey_bridge_loop(inner: Arc, rx: mpsc::Receiver) { while let Ok(evt) = rx.recv() { + if inner.shortcut_recording_active.load(Ordering::SeqCst) { + continue; + } let inner_cloned = Arc::clone(&inner); match evt { HotkeyEvent::Pressed => { @@ -643,17 +1360,17 @@ fn hotkey_bridge_loop(inner: Arc, rx: mpsc::Receiver) { cancel_session(&inner_cloned); } HotkeyEvent::TranslationModifierPressed => { - // 仅在 Starting / Listening 阶段把 Shift 边沿计入"翻译模式触发"。 - // Idle 阶段按 Shift 不应该影响下一段录音;Processing/Inserting 已经过了 - // 决定走哪条管线的检查点,再 set 也没意义。 - let phase = inner_cloned.state.lock().phase; - if matches!(phase, SessionPhase::Starting | SessionPhase::Listening) { - inner_cloned - .translation_modifier_seen - .store(true, Ordering::SeqCst); - log::info!("[coord] translation modifier seen during {phase:?}"); + let translation_hotkey = inner_cloned.prefs.get().translation_hotkey; + if is_builtin_translation_shift(&translation_hotkey) + || crate::shortcut_binding::legacy_modifier_trigger(&translation_hotkey) + .is_some() + { + mark_translation_modifier_seen(&inner_cloned); } } + HotkeyEvent::QaShortcutPressed => { + async_runtime::spawn(async move { handle_qa_hotkey_pressed(&inner_cloned).await }); + } } } } @@ -759,6 +1476,9 @@ async fn handle_window_hotkey_event( code: String, repeat: bool, ) -> Result<(), String> { + if inner.shortcut_recording_active.load(Ordering::SeqCst) { + return Ok(()); + } if event_type == "keydown" && key == "Escape" { // Esc 路由(issue #161):QA 浮窗可见时优先取消 QA(不动 dictation); // 否则走 dictation 取消通路。之前无条件 cancel_session 导致 QA 浮窗 @@ -792,7 +1512,11 @@ async fn handle_window_hotkey_event( return Ok(()); } - let trigger = inner.prefs.get().hotkey.trigger; + let Some(trigger) = + crate::shortcut_binding::legacy_modifier_trigger(&inner.prefs.get().dictation_hotkey) + else { + return Ok(()); + }; if !window_key_matches_trigger(trigger, &key, &code) { return Ok(()); } @@ -834,6 +1558,8 @@ fn window_key_matches_trigger(trigger: crate::types::HotkeyTrigger, key: &str, c HotkeyTrigger::LeftOption => (key == "Alt" || key == "AltGraph") && code == "AltRight", HotkeyTrigger::RightCommand => key == "Meta" && code == "MetaRight", HotkeyTrigger::Fn => key == "Control" && code == "ControlRight", + // Custom 走 global-hotkey crate,不走 window hotkey fallback + HotkeyTrigger::Custom => false, } } @@ -2170,7 +2896,13 @@ where let config = OpenAICompatibleConfig::new("ark", "Doubao Ark", base_url, api_key, model); let provider = OpenAICompatibleLLMProvider::new(config); Ok(provider - .answer_chat_streaming(messages, working_languages, front_app, on_delta, should_cancel) + .answer_chat_streaming( + messages, + working_languages, + front_app, + on_delta, + should_cancel, + ) .await?) } diff --git a/openless-all/app/src-tauri/src/global_hotkey_runtime.rs b/openless-all/app/src-tauri/src/global_hotkey_runtime.rs new file mode 100644 index 00000000..b04516a3 --- /dev/null +++ b/openless-all/app/src-tauri/src/global_hotkey_runtime.rs @@ -0,0 +1,107 @@ +//! Shared `global-hotkey` runtime. +//! +//! `global-hotkey` installs a process-level Carbon event handler on macOS and +//! exposes one process-level event receiver. OpenLess has two logical users of +//! that crate (QA and custom dictation combos), so they must share one manager +//! and one dispatcher instead of racing on `GlobalHotKeyEvent::receiver()`. + +use std::collections::HashMap; +use std::sync::mpsc::{self, Receiver, Sender}; +use std::sync::Arc; +use std::time::Duration; + +use global_hotkey::hotkey::HotKey; +use global_hotkey::{GlobalHotKeyEvent, GlobalHotKeyManager}; +use once_cell::sync::OnceCell; +use parking_lot::Mutex; + +static RUNTIME: OnceCell> = OnceCell::new(); + +pub struct GlobalHotkeyRuntime { + manager: GlobalHotKeyManager, + routes: Mutex>>, +} + +// global-hotkey 0.6 does not mark its manager Send/Sync on all platforms even +// though it wraps OS-level handles. Coordinator stores monitors across threads, +// matching the existing qa/combo monitor safety model. +unsafe impl Send for GlobalHotkeyRuntime {} +unsafe impl Sync for GlobalHotkeyRuntime {} + +pub struct RegisteredHotkey { + runtime: Arc, + hotkey: HotKey, +} + +impl GlobalHotkeyRuntime { + pub fn shared() -> Result, String> { + RUNTIME + .get_or_try_init(|| { + let manager = GlobalHotKeyManager::new().map_err(|e| e.to_string())?; + let runtime = Arc::new(Self { + manager, + routes: Mutex::new(HashMap::new()), + }); + start_dispatcher(Arc::clone(&runtime)); + Ok(runtime) + }) + .cloned() + } + + pub fn register( + self: &Arc, + hotkey: HotKey, + ) -> Result<(RegisteredHotkey, Receiver), String> { + self.manager.register(hotkey).map_err(|e| e.to_string())?; + let (tx, rx) = mpsc::channel(); + self.routes.lock().insert(hotkey.id(), tx); + Ok(( + RegisteredHotkey { + runtime: Arc::clone(self), + hotkey, + }, + rx, + )) + } + + fn unregister(&self, hotkey: HotKey) { + self.routes.lock().remove(&hotkey.id()); + if let Err(e) = self.manager.unregister(hotkey) { + log::warn!("[global-hotkey] unregister 失败: {e}"); + } + } + + fn dispatch(&self, event: GlobalHotKeyEvent) { + let tx = self.routes.lock().get(&event.id()).cloned(); + if let Some(tx) = tx { + let _ = tx.send(event); + } + } +} + +impl Drop for RegisteredHotkey { + fn drop(&mut self) { + self.runtime.unregister(self.hotkey); + } +} + +impl RegisteredHotkey { + pub fn hotkey(&self) -> HotKey { + self.hotkey + } +} + +fn start_dispatcher(runtime: Arc) { + std::thread::Builder::new() + .name("openless-global-hotkey-dispatch".into()) + .spawn(move || { + let receiver = GlobalHotKeyEvent::receiver(); + loop { + match receiver.recv_timeout(Duration::from_millis(250)) { + Ok(event) => runtime.dispatch(event), + Err(_) => continue, + } + } + }) + .expect("spawn global hotkey dispatcher"); +} diff --git a/openless-all/app/src-tauri/src/hotkey.rs b/openless-all/app/src-tauri/src/hotkey.rs index c80926c7..e5931ec3 100644 --- a/openless-all/app/src-tauri/src/hotkey.rs +++ b/openless-all/app/src-tauri/src/hotkey.rs @@ -17,6 +17,7 @@ use std::time::Duration; use parking_lot::RwLock; +use crate::types::HotkeyTrigger; use crate::types::{HotkeyAdapterKind, HotkeyBinding, HotkeyCapability, HotkeyInstallError}; #[derive(Clone, Copy, Debug)] @@ -27,11 +28,17 @@ pub enum HotkeyEvent { /// Shift(或未来配置项指定的修饰键)按下边沿。可在录音过程中任何时刻产生; /// 上层据此切换到翻译输出管线。详见 issue #4。 TranslationModifierPressed, + QaShortcutPressed, } pub trait HotkeyAdapter: Send + Sync { fn kind(&self) -> HotkeyAdapterKind; fn update_binding(&self, binding: HotkeyBinding); + fn update_modifier_shortcuts( + &self, + qa_trigger: Option, + translation_trigger: Option, + ); fn shutdown(&self) {} } @@ -39,6 +46,10 @@ struct Shared { binding: RwLock, /// 触发键当前是否处于"按住"状态。OS 自动重复事件用此去重。 trigger_held: AtomicBool, + qa_trigger: RwLock>, + qa_trigger_held: AtomicBool, + translation_trigger: RwLock>, + translation_trigger_held: AtomicBool, /// Shift(翻译修饰键)当前是否按住。用于在 FLAGS_CHANGED 上识别 down 边沿 /// (只在 false → true 时往上层发 TranslationModifierPressed)。详见 issue #4。 translation_modifier_held: AtomicBool, @@ -65,6 +76,15 @@ impl HotkeyMonitor { self.adapter.update_binding(binding); } + pub fn update_modifier_shortcuts( + &self, + qa_trigger: Option, + translation_trigger: Option, + ) { + self.adapter + .update_modifier_shortcuts(qa_trigger, translation_trigger); + } + pub fn kind(&self) -> HotkeyAdapterKind { self.adapter.kind() } @@ -114,6 +134,10 @@ where let shared = Arc::new(Shared { binding: RwLock::new(binding), trigger_held: AtomicBool::new(false), + qa_trigger: RwLock::new(None), + qa_trigger_held: AtomicBool::new(false), + translation_trigger: RwLock::new(None), + translation_trigger_held: AtomicBool::new(false), translation_modifier_held: AtomicBool::new(false), }); @@ -138,6 +162,21 @@ fn update_shared_binding(shared: &Shared, binding: HotkeyBinding) { .store(false, std::sync::atomic::Ordering::SeqCst); } +fn update_shared_modifier_shortcuts( + shared: &Shared, + qa_trigger: Option, + translation_trigger: Option, +) { + *shared.qa_trigger.write() = qa_trigger; + *shared.translation_trigger.write() = translation_trigger; + shared + .qa_trigger_held + .store(false, std::sync::atomic::Ordering::SeqCst); + shared + .translation_trigger_held + .store(false, std::sync::atomic::Ordering::SeqCst); +} + // ─────────────────────────── macOS implementation ─────────────────────────── #[cfg(target_os = "macos")] @@ -148,8 +187,8 @@ mod platform { use std::sync::Arc; use super::{ - install_error, send_or_log, start_listener_thread, update_shared_binding, HotkeyAdapter, - HotkeyEvent, Shared, StartupTx, + install_error, send_or_log, start_listener_thread, update_shared_binding, + update_shared_modifier_shortcuts, HotkeyAdapter, HotkeyEvent, Shared, StartupTx, }; use crate::types::{HotkeyAdapterKind, HotkeyBinding, HotkeyInstallError, HotkeyTrigger}; @@ -182,6 +221,14 @@ mod platform { fn update_binding(&self, binding: HotkeyBinding) { update_shared_binding(&self.shared, binding); } + + fn update_modifier_shortcuts( + &self, + qa_trigger: Option, + translation_trigger: Option, + ) { + update_shared_modifier_shortcuts(&self.shared, qa_trigger, translation_trigger); + } } // ── Raw CG/CF FFI ────────────────────────────────────────────────────── @@ -349,14 +396,38 @@ mod platform { let shift_active = (flags & FLAG_MASK_SHIFT) != 0; let shift_was_held = ctx.shared.translation_modifier_held.load(Ordering::SeqCst); if shift_active && !shift_was_held { - ctx.shared.translation_modifier_held.store(true, Ordering::SeqCst); + ctx.shared + .translation_modifier_held + .store(true, Ordering::SeqCst); send_or_log(&ctx.tx, HotkeyEvent::TranslationModifierPressed); } else if !shift_active && shift_was_held { - ctx.shared.translation_modifier_held.store(false, Ordering::SeqCst); + ctx.shared + .translation_modifier_held + .store(false, Ordering::SeqCst); } let keycode = unsafe { CGEventGetIntegerValueField(event, KEYBOARD_EVENT_KEYCODE) }; + handle_optional_modifier_trigger( + ctx, + keycode, + flags, + *ctx.shared.qa_trigger.read(), + &ctx.shared.qa_trigger_held, + HotkeyEvent::QaShortcutPressed, + ); + handle_optional_modifier_trigger( + ctx, + keycode, + flags, + *ctx.shared.translation_trigger.read(), + &ctx.shared.translation_trigger_held, + HotkeyEvent::TranslationModifierPressed, + ); + let trigger = ctx.shared.binding.read().trigger; + if trigger == HotkeyTrigger::Custom { + return; + } let expected_keycode = trigger_to_keycode(trigger); if keycode != expected_keycode { return; @@ -374,6 +445,30 @@ mod platform { } } + fn handle_optional_modifier_trigger( + ctx: &CallbackContext, + keycode: i64, + flags: CgEventFlags, + trigger: Option, + held: &std::sync::atomic::AtomicBool, + event: HotkeyEvent, + ) { + let Some(trigger) = trigger else { + return; + }; + if trigger == HotkeyTrigger::Custom || keycode != trigger_to_keycode(trigger) { + return; + } + let active = (flags & trigger_to_flag_mask(trigger)) != 0; + let was_held = held.load(Ordering::SeqCst); + if active && !was_held { + held.store(true, Ordering::SeqCst); + send_or_log(&ctx.tx, event); + } else if !active && was_held { + held.store(false, Ordering::SeqCst); + } + } + fn handle_key_down(ctx: &CallbackContext, event: CgEventRef) { let keycode = unsafe { CGEventGetIntegerValueField(event, KEYBOARD_EVENT_KEYCODE) }; if keycode == ESC_KEYCODE { @@ -389,6 +484,7 @@ mod platform { HotkeyTrigger::RightOption | HotkeyTrigger::RightAlt => 61, HotkeyTrigger::RightCommand => 54, HotkeyTrigger::Fn => 63, + HotkeyTrigger::Custom => unreachable!("custom combo hotkeys use ComboHotkeyMonitor"), } } @@ -400,6 +496,7 @@ mod platform { FLAG_MASK_ALTERNATE } HotkeyTrigger::Fn => FLAG_MASK_SECONDARY_FN, + HotkeyTrigger::Custom => unreachable!("custom combo hotkeys use ComboHotkeyMonitor"), } } } @@ -422,8 +519,8 @@ mod platform { }; use super::{ - install_error, send_or_log, start_listener_thread, update_shared_binding, HotkeyAdapter, - HotkeyEvent, Shared, StartupTx, + install_error, send_or_log, start_listener_thread, update_shared_binding, + update_shared_modifier_shortcuts, HotkeyAdapter, HotkeyEvent, Shared, StartupTx, }; use crate::types::{HotkeyAdapterKind, HotkeyBinding, HotkeyInstallError, HotkeyTrigger}; @@ -476,6 +573,14 @@ mod platform { update_shared_binding(&self.shared, binding); } + fn update_modifier_shortcuts( + &self, + qa_trigger: Option, + translation_trigger: Option, + ) { + update_shared_modifier_shortcuts(&self.shared, qa_trigger, translation_trigger); + } + fn shutdown(&self) { unsafe { if let Err(err) = PostThreadMessageW(self.thread_id, WM_QUIT, WPARAM(0), LPARAM(0)) @@ -582,20 +687,45 @@ mod platform { if matches!(vk_code, VK_SHIFT | VK_LSHIFT | VK_RSHIFT) { match message { WM_KEYDOWN | WM_SYSKEYDOWN => { - let was_held = ctx.shared.translation_modifier_held.swap(true, Ordering::SeqCst); + let was_held = ctx + .shared + .translation_modifier_held + .swap(true, Ordering::SeqCst); if !was_held { send_or_log(&ctx.tx, HotkeyEvent::TranslationModifierPressed); } } WM_KEYUP | WM_SYSKEYUP => { - ctx.shared.translation_modifier_held.store(false, Ordering::SeqCst); + ctx.shared + .translation_modifier_held + .store(false, Ordering::SeqCst); } _ => {} } return; } + handle_optional_modifier_trigger( + ctx, + vk_code, + message, + *ctx.shared.qa_trigger.read(), + &ctx.shared.qa_trigger_held, + HotkeyEvent::QaShortcutPressed, + ); + handle_optional_modifier_trigger( + ctx, + vk_code, + message, + *ctx.shared.translation_trigger.read(), + &ctx.shared.translation_trigger_held, + HotkeyEvent::TranslationModifierPressed, + ); + let trigger = ctx.shared.binding.read().trigger; + if trigger == HotkeyTrigger::Custom { + return; + } if vk_code != trigger_to_vk_code(trigger) { return; } @@ -619,6 +749,34 @@ mod platform { } } + fn handle_optional_modifier_trigger( + ctx: &CallbackContext, + vk_code: u32, + message: usize, + trigger: Option, + held: &std::sync::atomic::AtomicBool, + event: HotkeyEvent, + ) { + let Some(trigger) = trigger else { + return; + }; + if trigger == HotkeyTrigger::Custom || vk_code != trigger_to_vk_code(trigger) { + return; + } + match message { + WM_KEYDOWN | WM_SYSKEYDOWN => { + let was_held = held.swap(true, Ordering::SeqCst); + if !was_held { + send_or_log(&ctx.tx, event); + } + } + WM_KEYUP | WM_SYSKEYUP => { + held.store(false, Ordering::SeqCst); + } + _ => {} + } + } + fn trigger_to_vk_code(trigger: HotkeyTrigger) -> u32 { // Windows only gives us a small set of modifier virtual keys that can be // used as reliable modifier-only global triggers, so the cross-platform @@ -633,6 +791,7 @@ mod platform { HotkeyTrigger::RightCommand => VK_RWIN, HotkeyTrigger::LeftOption => VK_RMENU, HotkeyTrigger::Fn => VK_RCONTROL, + HotkeyTrigger::Custom => unreachable!("custom combo hotkeys use ComboHotkeyMonitor"), } } @@ -653,8 +812,8 @@ mod platform { use rdev::{listen, Event, EventType, Key}; use super::{ - install_error, start_listener_thread, update_shared_binding, HotkeyAdapter, HotkeyEvent, - Shared, StartupTx, + install_error, start_listener_thread, update_shared_binding, + update_shared_modifier_shortcuts, HotkeyAdapter, HotkeyEvent, Shared, StartupTx, }; use crate::types::{HotkeyAdapterKind, HotkeyBinding, HotkeyInstallError, HotkeyTrigger}; @@ -662,11 +821,7 @@ mod platform { binding: HotkeyBinding, tx: Sender, ) -> Result, HotkeyInstallError> { - if std::env::var("XDG_SESSION_TYPE") - .ok() - .as_deref() - == Some("wayland") - { + if std::env::var("XDG_SESSION_TYPE").ok().as_deref() == Some("wayland") { return Err(install_error( "wayland_unsupported", "Wayland 暂不支持全局热键,请切到 X11 session 后再试", @@ -697,6 +852,14 @@ mod platform { fn update_binding(&self, binding: HotkeyBinding) { update_shared_binding(&self.shared, binding); } + + fn update_modifier_shortcuts( + &self, + qa_trigger: Option, + translation_trigger: Option, + ) { + update_shared_modifier_shortcuts(&self.shared, qa_trigger, translation_trigger); + } } fn run_listen_loop(shared: Arc, tx: Sender, status_tx: StartupTx<()>) { @@ -734,12 +897,33 @@ mod platform { } // Shift(任一侧)= 翻译模式修饰键。详见 issue #4。 if matches!(key, Key::ShiftLeft | Key::ShiftRight) { - let was_held = shared.translation_modifier_held.swap(true, Ordering::SeqCst); + let was_held = shared + .translation_modifier_held + .swap(true, Ordering::SeqCst); if !was_held { let _ = tx.send(HotkeyEvent::TranslationModifierPressed); } return; } + handle_optional_modifier_press( + shared, + tx, + key, + *shared.qa_trigger.read(), + &shared.qa_trigger_held, + HotkeyEvent::QaShortcutPressed, + ); + handle_optional_modifier_press( + shared, + tx, + key, + *shared.translation_trigger.read(), + &shared.translation_trigger_held, + HotkeyEvent::TranslationModifierPressed, + ); + if trigger == HotkeyTrigger::Custom { + return; + } if key == trigger_to_rdev_key(trigger) { let was_held = shared.trigger_held.swap(true, Ordering::SeqCst); if !was_held { @@ -749,7 +933,24 @@ mod platform { } EventType::KeyRelease(key) => { if matches!(key, Key::ShiftLeft | Key::ShiftRight) { - shared.translation_modifier_held.store(false, Ordering::SeqCst); + shared + .translation_modifier_held + .store(false, Ordering::SeqCst); + return; + } + handle_optional_modifier_release( + shared, + key, + *shared.qa_trigger.read(), + &shared.qa_trigger_held, + ); + handle_optional_modifier_release( + shared, + key, + *shared.translation_trigger.read(), + &shared.translation_trigger_held, + ); + if trigger == HotkeyTrigger::Custom { return; } if key == trigger_to_rdev_key(trigger) { @@ -763,6 +964,40 @@ mod platform { } } + fn handle_optional_modifier_press( + shared: &Shared, + tx: &Sender, + key: Key, + trigger: Option, + held: &std::sync::atomic::AtomicBool, + event: HotkeyEvent, + ) { + let Some(trigger) = trigger else { + return; + }; + if trigger == HotkeyTrigger::Custom || key != trigger_to_rdev_key(trigger) { + return; + } + let was_held = held.swap(true, Ordering::SeqCst); + if !was_held { + let _ = tx.send(event); + } + } + + fn handle_optional_modifier_release( + _shared: &Shared, + key: Key, + trigger: Option, + held: &std::sync::atomic::AtomicBool, + ) { + let Some(trigger) = trigger else { + return; + }; + if trigger != HotkeyTrigger::Custom && key == trigger_to_rdev_key(trigger) { + held.store(false, Ordering::SeqCst); + } + } + fn trigger_to_rdev_key(trigger: HotkeyTrigger) -> Key { match trigger { HotkeyTrigger::RightOption | HotkeyTrigger::RightAlt => Key::AltGr, @@ -771,6 +1006,7 @@ mod platform { HotkeyTrigger::LeftControl => Key::ControlLeft, HotkeyTrigger::RightCommand => Key::MetaRight, HotkeyTrigger::Fn => Key::Function, + HotkeyTrigger::Custom => unreachable!("custom combo hotkeys use ComboHotkeyMonitor"), } } } diff --git a/openless-all/app/src-tauri/src/insertion.rs b/openless-all/app/src-tauri/src/insertion.rs index ef5012d4..a989b5c1 100644 --- a/openless-all/app/src-tauri/src/insertion.rs +++ b/openless-all/app/src-tauri/src/insertion.rs @@ -178,12 +178,7 @@ fn schedule_clipboard_restore(plan: ClipboardRestorePlan) { original }; std::thread::spawn(move || { - restore_clipboard_after_delay( - plan, - original_text, - restore_id, - CLIPBOARD_RESTORE_DELAY, - ) + restore_clipboard_after_delay(plan, original_text, restore_id, CLIPBOARD_RESTORE_DELAY) }); } diff --git a/openless-all/app/src-tauri/src/lib.rs b/openless-all/app/src-tauri/src/lib.rs index 03c69213..eb020823 100644 --- a/openless-all/app/src-tauri/src/lib.rs +++ b/openless-all/app/src-tauri/src/lib.rs @@ -11,8 +11,10 @@ //! - commands: Tauri IPC surface mod asr; +mod combo_hotkey; mod commands; mod coordinator; +mod global_hotkey_runtime; mod hotkey; mod insertion; mod permissions; @@ -21,11 +23,12 @@ mod polish; mod qa_hotkey; mod recorder; mod selection; +mod shortcut_binding; mod types; +use std::sync::atomic::{AtomicBool, Ordering}; #[cfg(target_os = "macos")] use std::sync::mpsc; -use std::sync::atomic::{AtomicBool, Ordering}; use std::sync::Arc; #[cfg(target_os = "macos")] use std::time::Duration; @@ -165,8 +168,8 @@ pub fn run() { let app_handle = app.handle().clone(); coordinator.bind_app(app_handle); coordinator.start_hotkey_listener(); - // 同步启动 QA hotkey listener。和 dictation hotkey 平行,互不抢状态。 - coordinator.start_qa_hotkey_listener(); + // QA / custom combo hotkeys use `global-hotkey` (Carbon on macOS). + // Start those after RunEvent::Ready, when the AppKit event loop is live. if std::env::var("OPENLESS_SHOW_MAIN_ON_START").ok().as_deref() == Some("1") { show_main_window(app.handle()); } @@ -178,6 +181,7 @@ pub fn run() { commands::set_settings, commands::get_hotkey_status, commands::get_hotkey_capability, + commands::set_shortcut_recording_active, commands::get_credentials, commands::set_credential, commands::list_history, @@ -207,8 +211,15 @@ pub fn run() { commands::set_active_llm_provider, commands::get_qa_hotkey_label, commands::set_qa_hotkey, + commands::validate_shortcut_binding, + commands::set_dictation_hotkey, + commands::set_translation_hotkey, + commands::set_switch_style_hotkey, + commands::set_open_app_hotkey, commands::qa_window_dismiss, commands::qa_window_pin, + commands::validate_combo_hotkey, + commands::set_combo_hotkey, commands::validate_provider_credentials, commands::list_provider_models, restart_app, @@ -216,6 +227,16 @@ pub fn run() { .build(tauri::generate_context!()) .expect("error while building tauri application") .run(|app, event| match event { + RunEvent::Ready => { + let coordinator = app.state::>(); + // 同步启动 QA hotkey listener。和 dictation hotkey 平行,互不抢状态。 + coordinator.start_qa_hotkey_listener(); + // 启动自定义组合键监听器。当 trigger == Custom 时替代 modifier-only 监听器。 + coordinator.start_combo_hotkey_listener(); + coordinator.start_translation_hotkey_listener(); + coordinator.start_switch_style_hotkey_listener(); + coordinator.start_open_app_hotkey_listener(); + } #[cfg(target_os = "macos")] RunEvent::Reopen { .. } => show_main_window(app), RunEvent::WindowEvent { label, event, .. } => { @@ -225,7 +246,11 @@ pub fn run() { hide_main_window(app); } #[cfg(target_os = "windows")] - if matches!(event, tauri::WindowEvent::Resized(_) | tauri::WindowEvent::ScaleFactorChanged { .. }) { + if matches!( + event, + tauri::WindowEvent::Resized(_) + | tauri::WindowEvent::ScaleFactorChanged { .. } + ) { if let Some(main) = app.get_webview_window("main") { apply_windows_rounded_frame(&main); } @@ -236,6 +261,8 @@ pub fn run() { let coordinator = app.state::>(); coordinator.stop_hotkey_listener(); coordinator.stop_qa_hotkey_listener(); + coordinator.stop_combo_hotkey_listener(); + coordinator.stop_translation_hotkey_listener(); } _ => {} }); @@ -572,9 +599,7 @@ fn position_qa_window(window: &tauri::WebviewWindow) -> ta /// fallback 仍能从原 app 拿到选区)。 pub(crate) fn show_qa_window(app: &AppHandle, content_kind: &str) { let Some(window) = app.get_webview_window("qa") else { - log::info!( - "[qa] show 跳过:qa 窗口不存在 (content_kind={content_kind})" - ); + log::info!("[qa] show 跳过:qa 窗口不存在 (content_kind={content_kind})"); return; }; // 仅首次 show 时居中;之后保留用户拖动后的位置。 @@ -782,20 +807,32 @@ mod tests { fn capsule_window_bounds_leave_room_for_windows_shadow() { let bounds = capsule_window_bounds(false); #[cfg(target_os = "windows")] - assert_eq!((bounds.width, bounds.height, bounds.bottom_inset), (220.0, 84.0, 12.0)); + assert_eq!( + (bounds.width, bounds.height, bounds.bottom_inset), + (220.0, 84.0, 12.0) + ); #[cfg(not(target_os = "windows"))] - assert_eq!((bounds.width, bounds.height, bounds.bottom_inset), (176.0, 42.0, 0.0)); + assert_eq!( + (bounds.width, bounds.height, bounds.bottom_inset), + (176.0, 42.0, 0.0) + ); } #[test] fn capsule_window_bounds_expand_for_translation_badge() { let bounds = capsule_window_bounds(true); #[cfg(target_os = "windows")] - assert_eq!((bounds.width, bounds.height, bounds.bottom_inset), (220.0, 118.0, 12.0)); + assert_eq!( + (bounds.width, bounds.height, bounds.bottom_inset), + (220.0, 118.0, 12.0) + ); #[cfg(not(target_os = "windows"))] - assert_eq!((bounds.width, bounds.height, bounds.bottom_inset), (176.0, 110.0, 0.0)); + assert_eq!( + (bounds.width, bounds.height, bounds.bottom_inset), + (176.0, 110.0, 0.0) + ); } #[test] diff --git a/openless-all/app/src-tauri/src/polish.rs b/openless-all/app/src-tauri/src/polish.rs index 4388bf0d..8b3d6320 100644 --- a/openless-all/app/src-tauri/src/polish.rs +++ b/openless-all/app/src-tauri/src/polish.rs @@ -310,7 +310,10 @@ impl OpenAICompatibleLLMProvider { let event = buffer[..idx].to_string(); buffer.drain(..idx + 2); for line in event.lines() { - let Some(payload) = line.strip_prefix("data: ").or_else(|| line.strip_prefix("data:")) else { + let Some(payload) = line + .strip_prefix("data: ") + .or_else(|| line.strip_prefix("data:")) + else { continue; }; let payload = payload.trim(); @@ -320,7 +323,10 @@ impl OpenAICompatibleLLMProvider { let v: Value = match serde_json::from_str(payload) { Ok(v) => v, Err(e) => { - log::warn!("[llm] SSE parse skip: {e}; payload preview: {}", safe_str_slice(payload, 80)); + log::warn!( + "[llm] SSE parse skip: {e}; payload preview: {}", + safe_str_slice(payload, 80) + ); continue; } }; @@ -382,9 +388,7 @@ fn context_premise(working_languages: &[String], front_app: Option<&str>) -> Opt .map(|s| s.trim()) .filter(|s| !s.is_empty()) .collect(); - let app = front_app - .map(str::trim) - .filter(|s| !s.is_empty()); + let app = front_app.map(str::trim).filter(|s| !s.is_empty()); if langs.is_empty() && app.is_none() { return None; @@ -900,7 +904,8 @@ mod tests { #[test] fn compose_system_prompt_prefers_correct_spelling_for_hotwords() { - let prompt = compose_system_prompt(PolishMode::Light, &["GitHub".into(), "OpenLess".into()]); + let prompt = + compose_system_prompt(PolishMode::Light, &["GitHub".into(), "OpenLess".into()]); assert!(prompt.contains("用户希望以下写法在输出中保持准确")); assert!(prompt.contains("同音 / 近形误识别时,优先按上述写法输出")); diff --git a/openless-all/app/src-tauri/src/qa_hotkey.rs b/openless-all/app/src-tauri/src/qa_hotkey.rs index 4740a012..70e17c84 100644 --- a/openless-all/app/src-tauri/src/qa_hotkey.rs +++ b/openless-all/app/src-tauri/src/qa_hotkey.rs @@ -7,16 +7,17 @@ //! 仅产出 `QaHotkeyEvent::Pressed` 边沿事件;toggle / 录音生命周期由 //! coordinator 解释(第一次按 → 开始问答;第二次按 → 结束)。 //! -//! 模块依赖:仅 `types`,与 CLAUDE.md "Rust 模块依赖只通过 types.rs 跨模块" 一致。 +//! 通过 `global_hotkey_runtime` 共享进程级 manager / event receiver。 -use std::sync::mpsc::Sender; +use std::sync::mpsc::{Receiver, Sender}; use std::sync::Arc; -use global_hotkey::hotkey::{Code, HotKey, Modifiers}; -use global_hotkey::{GlobalHotKeyEvent, GlobalHotKeyManager, HotKeyState}; +use global_hotkey::{GlobalHotKeyEvent, HotKeyState}; use parking_lot::Mutex; -use crate::types::QaHotkeyBinding; +use crate::global_hotkey_runtime::{GlobalHotkeyRuntime, RegisteredHotkey}; +use crate::shortcut_binding::{parse_global_hotkey, ShortcutBindingError}; +use crate::types::ShortcutBinding; #[derive(Debug, Clone, Copy)] pub enum QaHotkeyEvent { @@ -44,13 +45,9 @@ pub struct QaHotkeyMonitor { } struct Inner { - manager: GlobalHotKeyManager, /// 当前注册的 hotkey 句柄;用于 unregister。 - registered: Mutex>, - /// 事件转发线程接收 global-hotkey crate 的全局 channel,再过滤 id 后转发到 tx。 - forward_alive: Arc, - /// 当前关心的 hotkey id(filter 用)。 - active_id: Arc, + registered: Mutex>, + tx: Sender, } // global-hotkey 0.6 的 GlobalHotKeyManager 在 Windows 内部持有 HHOOK / window @@ -70,95 +67,69 @@ impl QaHotkeyMonitor { /// `AppHandle::run_on_main_thread` 跳到主线程后再 spawn 这个 monitor)。 /// 本函数不强制断言主线程——单元 / 集成测试也跑不到 manager 创建那一行。 pub fn start( - binding: QaHotkeyBinding, + binding: ShortcutBinding, tx: Sender, ) -> Result { - let manager = GlobalHotKeyManager::new() + let runtime = GlobalHotkeyRuntime::shared() .map_err(|e| QaHotkeyError::ManagerInitFailed(e.to_string()))?; let hotkey = parse_binding(&binding)?; - manager + let (registered, rx) = runtime .register(hotkey) .map_err(|e| QaHotkeyError::RegisterFailed(e.to_string()))?; - let active_id = Arc::new(std::sync::atomic::AtomicU32::new(hotkey.id())); - let forward_alive = Arc::new(std::sync::atomic::AtomicBool::new(true)); - // 启动转发线程:消费 global-hotkey 的进程级 channel,filter id 后投递到上层 tx。 // global-hotkey 用 crossbeam_channel,自带超时 recv,便于优雅退出。 - let alive_for_thread = Arc::clone(&forward_alive); - let id_for_thread = Arc::clone(&active_id); + let tx_for_thread = tx.clone(); std::thread::Builder::new() .name("openless-qa-hotkey-forward".into()) - .spawn(move || forward_loop(alive_for_thread, id_for_thread, tx)) + .spawn(move || forward_loop(rx, tx_for_thread)) .map_err(|e| QaHotkeyError::RegisterFailed(format!("spawn forward thread: {e}")))?; Ok(Self { inner: Arc::new(Inner { - manager, - registered: Mutex::new(Some(hotkey)), - forward_alive, - active_id, + registered: Mutex::new(Some(registered)), + tx, }), }) } /// 替换当前注册的 hotkey(用户在设置里改了组合键时)。 - pub fn update_binding(&self, binding: QaHotkeyBinding) -> Result<(), QaHotkeyError> { + pub fn update_binding(&self, binding: ShortcutBinding) -> Result<(), QaHotkeyError> { let next = parse_binding(&binding)?; let mut current = self.inner.registered.lock(); - if let Some(prev) = current.take() { - if prev == next { - *current = Some(prev); + if let Some(prev) = current.as_ref() { + if prev.hotkey() == next { return Ok(()); } - if let Err(e) = self.inner.manager.unregister(prev) { - log::warn!("[qa-hotkey] unregister 旧绑定失败: {e}"); - } } - self.inner - .manager + current.take(); + let runtime = GlobalHotkeyRuntime::shared() + .map_err(|e| QaHotkeyError::ManagerInitFailed(e.to_string()))?; + let (registered, rx) = runtime .register(next) .map_err(|e| QaHotkeyError::RegisterFailed(e.to_string()))?; - *current = Some(next); - self.inner - .active_id - .store(next.id(), std::sync::atomic::Ordering::SeqCst); + // Keep event forwarding alive for the replacement registration. + std::thread::Builder::new() + .name("openless-qa-hotkey-forward".into()) + .spawn({ + let tx = self.inner.tx.clone(); + move || forward_loop(rx, tx) + }) + .map_err(|e| QaHotkeyError::RegisterFailed(format!("spawn forward thread: {e}")))?; + *current = Some(registered); Ok(()) } } impl Drop for QaHotkeyMonitor { fn drop(&mut self) { - // 通知转发线程退出;超时 recv 后自然结束。 - self.inner - .forward_alive - .store(false, std::sync::atomic::Ordering::SeqCst); - if let Some(prev) = self.inner.registered.lock().take() { - if let Err(e) = self.inner.manager.unregister(prev) { - log::warn!("[qa-hotkey] drop 时 unregister 失败: {e}"); - } - } + self.inner.registered.lock().take(); } } -fn forward_loop( - alive: Arc, - active_id: Arc, - tx: Sender, -) { - // global-hotkey crate 用 crossbeam_channel;其 receiver 没暴露 RecvTimeoutError 给外部, - // 所以不区分 timeout vs disconnect,统一 250ms tick 重新 check alive 标志。 - let receiver = GlobalHotKeyEvent::receiver(); - while alive.load(std::sync::atomic::Ordering::SeqCst) { - let event = match receiver.recv_timeout(std::time::Duration::from_millis(250)) { - Ok(e) => e, - Err(_) => continue, - }; - let want = active_id.load(std::sync::atomic::Ordering::SeqCst); - if event.id() != want { - continue; - } +fn forward_loop(rx: Receiver, tx: Sender) { + while let Ok(event) = rx.recv() { if !matches!(event.state(), HotKeyState::Pressed) { continue; } @@ -170,146 +141,23 @@ fn forward_loop( log::info!("[qa-hotkey] 转发线程退出"); } -fn parse_binding(binding: &QaHotkeyBinding) -> Result { - let mut mods = Modifiers::empty(); - for raw in &binding.modifiers { - let tag = normalize_modifier_tag(raw); - let bit = match tag.as_str() { - "cmd" | "command" | "super" | "meta" | "win" => Modifiers::SUPER, - "ctrl" | "control" => Modifiers::CONTROL, - "alt" | "option" | "opt" => Modifiers::ALT, - "shift" => Modifiers::SHIFT, - other => return Err(QaHotkeyError::UnsupportedModifier(other.to_string())), - }; - mods |= bit; - } - let code = parse_primary(&binding.primary)?; - Ok(HotKey::new(Some(mods), code)) -} - -fn normalize_modifier_tag(raw: &str) -> String { - let tag = raw.trim().to_ascii_lowercase(); - #[cfg(target_os = "windows")] - { - if matches!(tag.as_str(), "cmd" | "command") { - return "ctrl".to_string(); - } - } - tag -} - -/// 把用户配置的主键字符串解析成 keyboard_types::Code。 -/// 支持单字符(字母 / 数字 / 符号)+ 常见命名键(F1..F12 / Enter / Tab / Escape / Space)。 -fn parse_primary(raw: &str) -> Result { - let trimmed = raw.trim(); - if trimmed.is_empty() { - return Err(QaHotkeyError::UnsupportedKey("(空)".into())); - } - // 单字符 - if trimmed.chars().count() == 1 { - let ch = trimmed.chars().next().unwrap(); - if let Some(code) = char_to_code(ch) { - return Ok(code); - } - } - // 命名键 - let upper = trimmed.to_ascii_uppercase(); - let named = match upper.as_str() { - "ENTER" | "RETURN" => Code::Enter, - "TAB" => Code::Tab, - "ESC" | "ESCAPE" => Code::Escape, - "SPACE" => Code::Space, - "BACKSPACE" => Code::Backspace, - "DELETE" | "DEL" => Code::Delete, - "HOME" => Code::Home, - "END" => Code::End, - "PAGEUP" => Code::PageUp, - "PAGEDOWN" => Code::PageDown, - "ARROWUP" | "UP" => Code::ArrowUp, - "ARROWDOWN" | "DOWN" => Code::ArrowDown, - "ARROWLEFT" | "LEFT" => Code::ArrowLeft, - "ARROWRIGHT" | "RIGHT" => Code::ArrowRight, - "F1" => Code::F1, - "F2" => Code::F2, - "F3" => Code::F3, - "F4" => Code::F4, - "F5" => Code::F5, - "F6" => Code::F6, - "F7" => Code::F7, - "F8" => Code::F8, - "F9" => Code::F9, - "F10" => Code::F10, - "F11" => Code::F11, - "F12" => Code::F12, - _ => return Err(QaHotkeyError::UnsupportedKey(trimmed.to_string())), - }; - Ok(named) -} - -fn char_to_code(ch: char) -> Option { - let c = ch.to_ascii_uppercase(); - let code = match c { - 'A' => Code::KeyA, - 'B' => Code::KeyB, - 'C' => Code::KeyC, - 'D' => Code::KeyD, - 'E' => Code::KeyE, - 'F' => Code::KeyF, - 'G' => Code::KeyG, - 'H' => Code::KeyH, - 'I' => Code::KeyI, - 'J' => Code::KeyJ, - 'K' => Code::KeyK, - 'L' => Code::KeyL, - 'M' => Code::KeyM, - 'N' => Code::KeyN, - 'O' => Code::KeyO, - 'P' => Code::KeyP, - 'Q' => Code::KeyQ, - 'R' => Code::KeyR, - 'S' => Code::KeyS, - 'T' => Code::KeyT, - 'U' => Code::KeyU, - 'V' => Code::KeyV, - 'W' => Code::KeyW, - 'X' => Code::KeyX, - 'Y' => Code::KeyY, - 'Z' => Code::KeyZ, - '0' => Code::Digit0, - '1' => Code::Digit1, - '2' => Code::Digit2, - '3' => Code::Digit3, - '4' => Code::Digit4, - '5' => Code::Digit5, - '6' => Code::Digit6, - '7' => Code::Digit7, - '8' => Code::Digit8, - '9' => Code::Digit9, - ';' => Code::Semicolon, - ':' => Code::Semicolon, - ',' => Code::Comma, - '.' => Code::Period, - '/' => Code::Slash, - '\\' => Code::Backslash, - '[' => Code::BracketLeft, - ']' => Code::BracketRight, - '\'' => Code::Quote, - '`' => Code::Backquote, - '-' => Code::Minus, - '=' => Code::Equal, - ' ' => Code::Space, - _ => return None, - }; - Some(code) +fn parse_binding( + binding: &ShortcutBinding, +) -> Result { + parse_global_hotkey(binding).map_err(|e| match e { + ShortcutBindingError::UnsupportedModifier(m) => QaHotkeyError::UnsupportedModifier(m), + ShortcutBindingError::UnsupportedKey(k) => QaHotkeyError::UnsupportedKey(k), + }) } #[cfg(test)] mod tests { use super::*; + use global_hotkey::hotkey::{Code, Modifiers}; #[test] fn parse_default_binding() { - let binding = QaHotkeyBinding::default(); + let binding = ShortcutBinding::default_qa(); let parsed = parse_binding(&binding).expect("default binding parses"); assert!(parsed.mods.contains(Modifiers::SHIFT)); assert_eq!(parsed.key, Code::Semicolon); @@ -317,7 +165,7 @@ mod tests { #[test] fn parse_letter_binding() { - let binding = QaHotkeyBinding { + let binding = ShortcutBinding { primary: "k".into(), modifiers: vec!["cmd".into(), "alt".into()], }; @@ -329,7 +177,7 @@ mod tests { #[test] fn unsupported_modifier_rejected() { - let binding = QaHotkeyBinding { + let binding = ShortcutBinding { primary: ";".into(), modifiers: vec!["hyper".into()], }; @@ -341,7 +189,7 @@ mod tests { #[test] fn empty_primary_rejected() { - let binding = QaHotkeyBinding { + let binding = ShortcutBinding { primary: "".into(), modifiers: vec!["cmd".into()], }; @@ -353,7 +201,7 @@ mod tests { #[test] fn cmd_modifier_normalizes_per_platform() { - let binding = QaHotkeyBinding { + let binding = ShortcutBinding { primary: ";".into(), modifiers: vec!["cmd".into(), "shift".into()], }; diff --git a/openless-all/app/src-tauri/src/selection.rs b/openless-all/app/src-tauri/src/selection.rs index 3980ec2e..df1b489f 100644 --- a/openless-all/app/src-tauri/src/selection.rs +++ b/openless-all/app/src-tauri/src/selection.rs @@ -330,7 +330,8 @@ mod macos_ax { unsafe fn cfstring_from_static(bytes_with_nul: &[u8]) -> Option { let cstr = CStr::from_bytes_with_nul(bytes_with_nul).ok()?; - let s = CFStringCreateWithCString(std::ptr::null(), cstr.as_ptr(), K_CF_STRING_ENCODING_UTF8); + let s = + CFStringCreateWithCString(std::ptr::null(), cstr.as_ptr(), K_CF_STRING_ENCODING_UTF8); if s.is_null() { None } else { @@ -442,8 +443,8 @@ mod macos_paste { #[cfg(target_os = "windows")] mod windows_paste { use windows::Win32::UI::Input::KeyboardAndMouse::{ - SendInput, INPUT, INPUT_0, INPUT_KEYBOARD, KEYBDINPUT, KEYBD_EVENT_FLAGS, - KEYEVENTF_KEYUP, VIRTUAL_KEY, VK_C, VK_CONTROL, + SendInput, INPUT, INPUT_0, INPUT_KEYBOARD, KEYBDINPUT, KEYBD_EVENT_FLAGS, KEYEVENTF_KEYUP, + VIRTUAL_KEY, VK_C, VK_CONTROL, }; pub fn send_ctrl_c() -> Result<(), String> { diff --git a/openless-all/app/src-tauri/src/shortcut_binding.rs b/openless-all/app/src-tauri/src/shortcut_binding.rs new file mode 100644 index 00000000..8256796d --- /dev/null +++ b/openless-all/app/src-tauri/src/shortcut_binding.rs @@ -0,0 +1,231 @@ +//! Shared parsing/validation for user-configurable shortcut bindings. + +use global_hotkey::hotkey::{Code, HotKey, Modifiers}; + +use crate::types::{HotkeyTrigger, ShortcutBinding}; + +#[derive(Debug, thiserror::Error)] +pub enum ShortcutBindingError { + #[error("不支持的修饰键: {0}")] + UnsupportedModifier(String), + #[error("不支持的主键: {0}")] + UnsupportedKey(String), +} + +pub fn validate_binding(binding: &ShortcutBinding) -> Result<(), ShortcutBindingError> { + if legacy_modifier_trigger(binding).is_some() { + return Ok(()); + } + if binding.modifiers.is_empty() && binding.primary.eq_ignore_ascii_case("shift") { + return Ok(()); + } + parse_global_hotkey(binding)?; + Ok(()) +} + +pub fn parse_global_hotkey(binding: &ShortcutBinding) -> Result { + let mut mods = Modifiers::empty(); + for raw in &binding.modifiers { + let tag = normalize_modifier_tag(raw); + let bit = match tag.as_str() { + "cmd" | "command" | "super" | "meta" | "win" => Modifiers::SUPER, + "ctrl" | "control" => Modifiers::CONTROL, + "alt" | "option" | "opt" => Modifiers::ALT, + "shift" => Modifiers::SHIFT, + other => return Err(ShortcutBindingError::UnsupportedModifier(other.to_string())), + }; + mods |= bit; + } + let code = parse_primary(&binding.primary)?; + let mods = if mods.is_empty() { None } else { Some(mods) }; + Ok(HotKey::new(mods, code)) +} + +pub fn legacy_modifier_trigger(binding: &ShortcutBinding) -> Option { + if !binding.modifiers.is_empty() { + return None; + } + match normalize_primary(&binding.primary).as_str() { + "rightoption" | "rightalt" => Some(HotkeyTrigger::RightOption), + "leftoption" | "leftalt" => Some(HotkeyTrigger::LeftOption), + "rightcontrol" | "rightctrl" => Some(HotkeyTrigger::RightControl), + "leftcontrol" | "leftctrl" => Some(HotkeyTrigger::LeftControl), + "rightcommand" | "rightcmd" | "rightsuper" | "rightmeta" => { + Some(HotkeyTrigger::RightCommand) + } + "fn" | "function" => Some(HotkeyTrigger::Fn), + _ => None, + } +} + +pub fn binding_from_legacy_trigger(trigger: HotkeyTrigger) -> ShortcutBinding { + let primary = match trigger { + HotkeyTrigger::RightOption | HotkeyTrigger::RightAlt => "RightOption", + HotkeyTrigger::LeftOption => "LeftOption", + HotkeyTrigger::RightControl => "RightControl", + HotkeyTrigger::LeftControl => "LeftControl", + HotkeyTrigger::RightCommand => "RightCommand", + HotkeyTrigger::Fn => "Fn", + HotkeyTrigger::Custom => "RightOption", + }; + ShortcutBinding { + primary: primary.into(), + modifiers: Vec::new(), + } +} + +fn normalize_modifier_tag(raw: &str) -> String { + let tag = raw.trim().to_ascii_lowercase(); + #[cfg(target_os = "windows")] + { + if matches!(tag.as_str(), "cmd" | "command") { + return "ctrl".to_string(); + } + } + tag +} + +fn normalize_primary(raw: &str) -> String { + raw.trim() + .chars() + .filter(|c| !matches!(c, ' ' | '-' | '_')) + .collect::() + .to_ascii_lowercase() +} + +fn parse_primary(raw: &str) -> Result { + let trimmed = raw.trim(); + if trimmed.is_empty() { + return Err(ShortcutBindingError::UnsupportedKey("(空)".into())); + } + if trimmed.chars().count() == 1 { + let ch = trimmed.chars().next().unwrap(); + if let Some(code) = char_to_code(ch) { + return Ok(code); + } + } + let upper = trimmed.to_ascii_uppercase(); + let named = match upper.as_str() { + "ENTER" | "RETURN" => Code::Enter, + "TAB" => Code::Tab, + "ESC" | "ESCAPE" => Code::Escape, + "SPACE" => Code::Space, + "BACKSPACE" => Code::Backspace, + "DELETE" | "DEL" => Code::Delete, + "HOME" => Code::Home, + "END" => Code::End, + "PAGEUP" => Code::PageUp, + "PAGEDOWN" => Code::PageDown, + "ARROWUP" | "UP" => Code::ArrowUp, + "ARROWDOWN" | "DOWN" => Code::ArrowDown, + "ARROWLEFT" | "LEFT" => Code::ArrowLeft, + "ARROWRIGHT" | "RIGHT" => Code::ArrowRight, + "F1" => Code::F1, + "F2" => Code::F2, + "F3" => Code::F3, + "F4" => Code::F4, + "F5" => Code::F5, + "F6" => Code::F6, + "F7" => Code::F7, + "F8" => Code::F8, + "F9" => Code::F9, + "F10" => Code::F10, + "F11" => Code::F11, + "F12" => Code::F12, + _ => return Err(ShortcutBindingError::UnsupportedKey(trimmed.to_string())), + }; + Ok(named) +} + +fn char_to_code(ch: char) -> Option { + let c = ch.to_ascii_uppercase(); + let code = match c { + 'A' => Code::KeyA, + 'B' => Code::KeyB, + 'C' => Code::KeyC, + 'D' => Code::KeyD, + 'E' => Code::KeyE, + 'F' => Code::KeyF, + 'G' => Code::KeyG, + 'H' => Code::KeyH, + 'I' => Code::KeyI, + 'J' => Code::KeyJ, + 'K' => Code::KeyK, + 'L' => Code::KeyL, + 'M' => Code::KeyM, + 'N' => Code::KeyN, + 'O' => Code::KeyO, + 'P' => Code::KeyP, + 'Q' => Code::KeyQ, + 'R' => Code::KeyR, + 'S' => Code::KeyS, + 'T' => Code::KeyT, + 'U' => Code::KeyU, + 'V' => Code::KeyV, + 'W' => Code::KeyW, + 'X' => Code::KeyX, + 'Y' => Code::KeyY, + 'Z' => Code::KeyZ, + '0' => Code::Digit0, + '1' => Code::Digit1, + '2' => Code::Digit2, + '3' => Code::Digit3, + '4' => Code::Digit4, + '5' => Code::Digit5, + '6' => Code::Digit6, + '7' => Code::Digit7, + '8' => Code::Digit8, + '9' => Code::Digit9, + ';' | ':' => Code::Semicolon, + ',' => Code::Comma, + '.' => Code::Period, + '/' => Code::Slash, + '\\' => Code::Backslash, + '[' => Code::BracketLeft, + ']' => Code::BracketRight, + '\'' => Code::Quote, + '`' => Code::Backquote, + '-' => Code::Minus, + '=' => Code::Equal, + ' ' => Code::Space, + _ => return None, + }; + Some(code) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn parses_combo_and_single_key() { + let combo = ShortcutBinding { + primary: "D".into(), + modifiers: vec!["cmd".into(), "shift".into()], + }; + let parsed = parse_global_hotkey(&combo).expect("combo parses"); + assert!(parsed.mods.contains(Modifiers::SUPER)); + assert!(parsed.mods.contains(Modifiers::SHIFT)); + assert_eq!(parsed.key, Code::KeyD); + + let single = ShortcutBinding { + primary: "F8".into(), + modifiers: vec![], + }; + let parsed = parse_global_hotkey(&single).expect("single key parses"); + assert!(parsed.mods.is_empty()); + assert_eq!(parsed.key, Code::F8); + } + + #[test] + fn detects_legacy_modifier_only() { + let binding = ShortcutBinding { + primary: "RightControl".into(), + modifiers: vec![], + }; + assert_eq!( + legacy_modifier_trigger(&binding), + Some(HotkeyTrigger::RightControl) + ); + } +} diff --git a/openless-all/app/src-tauri/src/types.rs b/openless-all/app/src-tauri/src/types.rs index 8e034c9a..aa816c1f 100644 --- a/openless-all/app/src-tauri/src/types.rs +++ b/openless-all/app/src-tauri/src/types.rs @@ -13,7 +13,6 @@ pub enum PolishMode { Formal, } - impl PolishMode { pub fn display_name(&self) -> &'static str { match self { @@ -73,10 +72,11 @@ fn default_true() -> bool { true } -#[derive(Debug, Clone, Serialize, Deserialize)] +#[derive(Debug, Clone, Serialize)] #[serde(default, rename_all = "camelCase")] pub struct UserPreferences { pub hotkey: HotkeyBinding, + pub dictation_hotkey: ShortcutBinding, pub default_mode: PolishMode, pub enabled_modes: Vec, pub launch_at_login: bool, @@ -99,15 +99,155 @@ pub struct UserPreferences { /// coordinator 用 global-hotkey crate 注册组合键(modifier + 主键)。 /// 默认 Cmd+Shift+; (macOS) / Ctrl+Shift+; (Windows)。详见 issue #118。 #[serde(default = "default_qa_hotkey")] - pub qa_hotkey: Option, + pub qa_hotkey: Option, /// 是否把每次 QA 会话写进 history.json。默认 false:QA 默认临时不留痕。 /// 详见 issue #118。 #[serde(default)] pub qa_save_history: bool, + /// 自定义录音组合键。当 `hotkey.trigger == Custom` 时,coordinator 用 + /// `global-hotkey` crate 注册此组合键(支持 Toggle + Hold 模式)。 + /// `None` 且 trigger == Custom 表示用户选了自定义但还没录制。 + #[serde(default)] + pub custom_combo_hotkey: Option, + #[serde(default = "default_translation_hotkey")] + pub translation_hotkey: ShortcutBinding, + #[serde(default = "default_switch_style_hotkey")] + pub switch_style_hotkey: ShortcutBinding, + #[serde(default = "default_open_app_hotkey")] + pub open_app_hotkey: ShortcutBinding, +} + +#[derive(Debug, Clone, Deserialize)] +#[serde(default, rename_all = "camelCase")] +struct UserPreferencesWire { + hotkey: HotkeyBinding, + dictation_hotkey: Option, + default_mode: PolishMode, + enabled_modes: Vec, + launch_at_login: bool, + show_capsule: bool, + active_asr_provider: String, + active_llm_provider: String, + restore_clipboard_after_paste: bool, + working_languages: Vec, + translation_target_language: String, + qa_hotkey: Option, + qa_save_history: bool, + custom_combo_hotkey: Option, + translation_hotkey: Option, + switch_style_hotkey: Option, + open_app_hotkey: Option, +} + +impl Default for UserPreferencesWire { + fn default() -> Self { + let prefs = UserPreferences::default(); + Self { + hotkey: prefs.hotkey, + dictation_hotkey: None, + default_mode: prefs.default_mode, + enabled_modes: prefs.enabled_modes, + launch_at_login: prefs.launch_at_login, + show_capsule: prefs.show_capsule, + active_asr_provider: prefs.active_asr_provider, + active_llm_provider: prefs.active_llm_provider, + restore_clipboard_after_paste: prefs.restore_clipboard_after_paste, + working_languages: prefs.working_languages, + translation_target_language: prefs.translation_target_language, + qa_hotkey: prefs.qa_hotkey, + qa_save_history: prefs.qa_save_history, + custom_combo_hotkey: prefs.custom_combo_hotkey, + translation_hotkey: None, + switch_style_hotkey: None, + open_app_hotkey: None, + } + } +} + +impl<'de> Deserialize<'de> for UserPreferences { + fn deserialize(deserializer: D) -> Result + where + D: serde::Deserializer<'de>, + { + let wire = UserPreferencesWire::deserialize(deserializer)?; + let dictation_hotkey = wire.dictation_hotkey.unwrap_or_else(|| { + default_dictation_hotkey_from_legacy(&wire.hotkey, &wire.custom_combo_hotkey) + }); + Ok(Self { + hotkey: wire.hotkey, + dictation_hotkey, + default_mode: wire.default_mode, + enabled_modes: wire.enabled_modes, + launch_at_login: wire.launch_at_login, + show_capsule: wire.show_capsule, + active_asr_provider: wire.active_asr_provider, + active_llm_provider: wire.active_llm_provider, + restore_clipboard_after_paste: wire.restore_clipboard_after_paste, + working_languages: wire.working_languages, + translation_target_language: wire.translation_target_language, + qa_hotkey: wire.qa_hotkey, + qa_save_history: wire.qa_save_history, + custom_combo_hotkey: wire.custom_combo_hotkey, + translation_hotkey: wire + .translation_hotkey + .unwrap_or_else(default_translation_hotkey), + switch_style_hotkey: wire + .switch_style_hotkey + .unwrap_or_else(default_switch_style_hotkey), + open_app_hotkey: wire.open_app_hotkey.unwrap_or_else(default_open_app_hotkey), + }) + } +} + +fn default_qa_hotkey() -> Option { + Some(ShortcutBinding::default_qa()) +} + +fn default_translation_hotkey() -> ShortcutBinding { + ShortcutBinding { + primary: "Shift".into(), + modifiers: Vec::new(), + } +} + +fn default_switch_style_hotkey() -> ShortcutBinding { + ShortcutBinding { + primary: "S".into(), + modifiers: default_app_shortcut_modifiers(), + } +} + +fn default_open_app_hotkey() -> ShortcutBinding { + ShortcutBinding { + primary: "O".into(), + modifiers: default_app_shortcut_modifiers(), + } +} + +fn default_app_shortcut_modifiers() -> Vec { + #[cfg(target_os = "macos")] + { + vec!["cmd".into(), "shift".into()] + } + #[cfg(not(target_os = "macos"))] + { + vec!["ctrl".into(), "shift".into()] + } } -fn default_qa_hotkey() -> Option { - Some(QaHotkeyBinding::default()) +fn default_dictation_hotkey_from_legacy( + hotkey: &HotkeyBinding, + custom_combo_hotkey: &Option, +) -> ShortcutBinding { + if hotkey.trigger == HotkeyTrigger::Custom { + if let Some(combo) = custom_combo_hotkey { + return ShortcutBinding { + primary: combo.primary.clone(), + modifiers: combo.modifiers.clone(), + }; + } + } + crate::shortcut_binding::binding_from_legacy_trigger(hotkey.trigger) } fn default_working_languages() -> Vec { @@ -118,6 +258,10 @@ impl Default for UserPreferences { fn default() -> Self { Self { hotkey: HotkeyBinding::default(), + dictation_hotkey: default_dictation_hotkey_from_legacy( + &HotkeyBinding::default(), + &None, + ), default_mode: PolishMode::Light, enabled_modes: vec![ PolishMode::Raw, @@ -134,7 +278,49 @@ impl Default for UserPreferences { translation_target_language: String::new(), qa_hotkey: default_qa_hotkey(), qa_save_history: false, + custom_combo_hotkey: None, + translation_hotkey: default_translation_hotkey(), + switch_style_hotkey: default_switch_style_hotkey(), + open_app_hotkey: default_open_app_hotkey(), + } + } +} + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +#[serde(rename_all = "camelCase")] +pub struct ShortcutBinding { + pub primary: String, + pub modifiers: Vec, +} + +impl ShortcutBinding { + pub fn default_qa() -> Self { + #[cfg(target_os = "macos")] + { + Self { + primary: ";".into(), + modifiers: vec!["cmd".into(), "shift".into()], + } + } + #[cfg(not(target_os = "macos"))] + { + Self { + primary: ";".into(), + modifiers: vec!["ctrl".into(), "shift".into()], + } + } + } + + pub fn display_label(&self) -> String { + let mut parts: Vec = Vec::new(); + let modifier_order = ["cmd", "ctrl", "alt", "shift", "super"]; + for tag in modifier_order { + if self.modifiers.iter().any(|m| m.eq_ignore_ascii_case(tag)) { + parts.push(modifier_display(tag).to_string()); + } } + parts.push(display_primary(&self.primary)); + parts.join("+") } } @@ -188,6 +374,30 @@ impl QaHotkeyBinding { } } +/// 录音快捷键的自定义组合键绑定。结构与 `QaHotkeyBinding` 相同: +/// - `primary`:主键(如 `"D"`、`"Space"`、`"F1"`)。 +/// - `modifiers`:修饰键集合,元素来自 `{"cmd","ctrl","alt","shift","super"}`。 +/// +/// 当 `HotkeyBinding.trigger == Custom` 时,coordinator 用 `global-hotkey` crate +/// 注册此组合键,而非 modifier-only 的 CGEventTap / WH_KEYBOARD_LL。 +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +#[serde(rename_all = "camelCase")] +pub struct ComboBinding { + pub primary: String, + pub modifiers: Vec, +} + +impl ComboBinding { + /// 渲染成给前端展示的可读标签。复用 QaHotkeyBinding 的格式化逻辑。 + pub fn display_label(&self) -> String { + let qa = QaHotkeyBinding { + primary: self.primary.clone(), + modifiers: self.modifiers.clone(), + }; + qa.display_label() + } +} + fn modifier_display(tag: &str) -> &'static str { match tag { "cmd" => "Cmd", @@ -233,6 +443,7 @@ pub enum HotkeyTrigger { RightCommand, Fn, RightAlt, // Windows synonym for RightOption + Custom, } impl HotkeyTrigger { @@ -245,6 +456,7 @@ impl HotkeyTrigger { HotkeyTrigger::RightCommand => "右 Command", HotkeyTrigger::Fn => "Fn (地球键)", HotkeyTrigger::RightAlt => "右 Alt", + HotkeyTrigger::Custom => "自定义组合键", } } } @@ -275,7 +487,7 @@ impl HotkeyAdapterKind { } #[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)] -#[serde(default, rename_all = "camelCase")] +#[serde(rename_all = "camelCase")] pub struct HotkeyBinding { pub trigger: HotkeyTrigger, pub mode: HotkeyMode, @@ -306,6 +518,7 @@ impl HotkeyCapability { HotkeyTrigger::LeftControl, HotkeyTrigger::RightCommand, HotkeyTrigger::Fn, + HotkeyTrigger::Custom, ], requires_accessibility_permission: true, supports_modifier_only_trigger: true, @@ -324,6 +537,7 @@ impl HotkeyCapability { HotkeyTrigger::RightAlt, HotkeyTrigger::LeftControl, HotkeyTrigger::RightCommand, + HotkeyTrigger::Custom, ], requires_accessibility_permission: false, supports_modifier_only_trigger: true, @@ -344,6 +558,7 @@ impl HotkeyCapability { HotkeyTrigger::RightAlt, HotkeyTrigger::RightControl, HotkeyTrigger::LeftControl, + HotkeyTrigger::Custom, ], requires_accessibility_permission: false, supports_modifier_only_trigger: true, @@ -462,7 +677,6 @@ pub struct TodayMetrics { pub total_duration_ms: u64, } - /// 划词追问浮窗里一条对话消息。多轮提问会累积成 Vec, /// 整段送给 LLM 维持上下文。详见 issue #118 v2。 #[derive(Debug, Clone, Serialize, Deserialize)] diff --git a/openless-all/app/src/components/FloatingShell.tsx b/openless-all/app/src/components/FloatingShell.tsx index 6e0de024..8e97a4c5 100644 --- a/openless-all/app/src/components/FloatingShell.tsx +++ b/openless-all/app/src/components/FloatingShell.tsx @@ -22,7 +22,7 @@ import { HOTKEY_MODE_MIGRATION_DEFERRED_KEY, shouldShowHotkeyModeMigrationPrompt, } from '../lib/hotkeyMigration'; -import { getHotkeyTriggerLabel } from '../lib/hotkey'; +import { formatComboLabel } from '../lib/hotkey'; import { applyFontScale, readFontScale } from '../lib/fontScale'; import { getCredentials, openExternal } from '../lib/ipc'; import { OL_DATA } from '../lib/mockData'; @@ -75,7 +75,7 @@ function FloatingShellBody({ os, initialTab, initialSettings }: { os: OS; initia const [providerPromptOpen, setProviderPromptOpen] = useState(false); const [hotkeyModePromptOpen, setHotkeyModePromptOpen] = useState(false); const [helpPopoverOpen, setHelpPopoverOpen] = useState(false); - const { hotkey } = useHotkeySettings(); + const { prefs } = useHotkeySettings(); // 字体档位 — 启动时按 localStorage 应用一次;之后改动来自 Settings 的"个性化"section。 useEffect(() => { @@ -139,6 +139,18 @@ function FloatingShellBody({ os, initialTab, initialSettings }: { os: OS; initia setSettingsOpen(true); }; + // ⌘, 打开设置页面 + useEffect(() => { + const onKeyDown = (e: KeyboardEvent) => { + if (e.metaKey && e.key === ',') { + e.preventDefault(); + openSettings(); + } + }; + window.addEventListener('keydown', onKeyDown, true); + return () => window.removeEventListener('keydown', onKeyDown, true); + }, []); + const openProviderSettings = () => { rememberProviderPrompt(); openSettings('providers'); @@ -235,7 +247,7 @@ function FloatingShellBody({ os, initialTab, initialSettings }: { os: OS; initia border: '0.5px solid var(--ol-line-strong)', fontFamily: 'var(--ol-font-mono)', color: 'var(--ol-ink)', boxShadow: '0 1px 0 rgba(0,0,0,.04)', - }}>{getHotkeyTriggerLabel(hotkey?.trigger)} + }}>{prefs ? formatComboLabel(prefs.dictationHotkey) : ''} {t('shell.shortcutHint')} diff --git a/openless-all/app/src/components/Onboarding.tsx b/openless-all/app/src/components/Onboarding.tsx index 1f272707..19ea387c 100644 --- a/openless-all/app/src/components/Onboarding.tsx +++ b/openless-all/app/src/components/Onboarding.tsx @@ -25,7 +25,7 @@ export function Onboarding({ onComplete }: OnboardingProps) { const [accessibility, setAccessibility] = useState('notDetermined'); const [microphone, setMicrophone] = useState('notDetermined'); const [busy, setBusy] = useState(false); - const refreshTimeoutRef = useRef | null>(null); + const refreshTimeoutRef = useRef(null); const { capability } = useHotkeySettings(); const refresh = async () => { diff --git a/openless-all/app/src/components/ShortcutRecorder.tsx b/openless-all/app/src/components/ShortcutRecorder.tsx new file mode 100644 index 00000000..0e4033c9 --- /dev/null +++ b/openless-all/app/src/components/ShortcutRecorder.tsx @@ -0,0 +1,197 @@ +import { useEffect, useRef, useState, type CSSProperties, type KeyboardEvent } from 'react'; +import { useTranslation } from 'react-i18next'; +import { formatComboLabel } from '../lib/hotkey'; +import { setShortcutRecordingActive, validateShortcutBinding } from '../lib/ipc'; +import type { ShortcutBinding } from '../lib/types'; + +export function ShortcutRecorder({ + value, + onSave, + alignRecordButton = false, +}: { + value: ShortcutBinding; + onSave: (binding: ShortcutBinding) => Promise; + alignRecordButton?: boolean; +}) { + const { t } = useTranslation(); + const [recording, setRecording] = useState(false); + const [error, setError] = useState(null); + const pendingModifier = useRef(null); + const pendingTimer = useRef(null); + + const clearPendingModifier = () => { + if (pendingTimer.current !== null) { + window.clearTimeout(pendingTimer.current); + pendingTimer.current = null; + } + pendingModifier.current = null; + }; + + useEffect(() => () => { + clearPendingModifier(); + void setShortcutRecordingActive(false); + }, []); + + useEffect(() => { + void setShortcutRecordingActive(recording); + return () => { + if (recording) void setShortcutRecordingActive(false); + }; + }, [recording]); + + const finish = async (binding: ShortcutBinding) => { + try { + await validateShortcutBinding(binding); + await onSave(binding); + clearPendingModifier(); + setRecording(false); + setError(null); + } catch { + setError(t('settings.recording.comboConflict')); + } + }; + + const onKeyDown = (e: KeyboardEvent) => { + if (!recording) return; + e.preventDefault(); + e.stopPropagation(); + if (e.key === 'Escape') { + setRecording(false); + setError(null); + clearPendingModifier(); + return; + } + if (isModifierKey(e.key)) { + const primary = modifierPrimaryFromCode(e.code, e.key); + if (!primary || pendingModifier.current?.primary === primary) return; + clearPendingModifier(); + const binding = { primary, modifiers: [] }; + pendingModifier.current = binding; + pendingTimer.current = window.setTimeout(() => { + if (pendingModifier.current?.primary === primary) { + void finish(binding); + } + }, 650); + return; + } + clearPendingModifier(); + const primary = primaryFromKeyboardEvent(e); + if (primary) void finish({ primary, modifiers: modifiersFromKeyboardEvent(e) }); + }; + + const onKeyUp = (e: KeyboardEvent) => { + if (!recording || !isModifierKey(e.key)) return; + e.preventDefault(); + e.stopPropagation(); + const primary = modifierPrimaryFromCode(e.code, e.key); + if (primary && pendingModifier.current?.primary === primary) { + const binding = pendingModifier.current; + clearPendingModifier(); + void finish(binding); + } + }; + + const rootStyle: CSSProperties = { + display: 'flex', + flexDirection: 'column', + gap: 6, + width: alignRecordButton ? '100%' : undefined, + }; + const recorderRowStyle: CSSProperties = { + display: 'flex', + alignItems: 'center', + gap: 8, + flexWrap: 'wrap', + width: alignRecordButton ? '100%' : undefined, + }; + const recordButtonStyle: CSSProperties = { + fontSize: 12, + padding: '5px 14px', + background: recording ? 'rgba(37,99,235,0.12)' : 'var(--ol-blue)', + color: recording ? 'var(--ol-blue)' : '#fff', + border: 0, + borderRadius: 6, + fontFamily: 'inherit', + fontWeight: 500, + cursor: recording ? 'default' : 'pointer', + marginLeft: alignRecordButton ? 'auto' : undefined, + }; + + return ( +
+
+ + {formatComboLabel(value)} + + +
+ {recording && ( +
el?.focus()} + > + {t('settings.recording.comboRecordHint')} +
Esc 取消
+
+ )} + {error &&
{error}
} +
+ ); +} + +function modifiersFromKeyboardEvent(e: KeyboardEvent): string[] { + const modifiers: string[] = []; + if (e.metaKey && e.key !== 'Meta') modifiers.push('cmd'); + if (e.ctrlKey && e.key !== 'Control') modifiers.push('ctrl'); + if (e.altKey && e.key !== 'Alt') modifiers.push('alt'); + if (e.shiftKey && e.key !== 'Shift') modifiers.push('shift'); + return modifiers; +} + +function isModifierKey(key: string): boolean { + return key === 'Control' || key === 'Alt' || key === 'Shift' || key === 'Meta'; +} + +function modifierPrimaryFromCode(code: string, key: string): string { + if (key === 'Shift') return 'Shift'; + if (code === 'ControlRight') return 'RightControl'; + if (code === 'ControlLeft') return 'LeftControl'; + if (code === 'AltRight') return 'RightOption'; + if (code === 'AltLeft') return 'LeftOption'; + if (code === 'MetaRight' || code === 'MetaLeft') return 'RightCommand'; + return ''; +} + +function primaryFromKeyboardEvent(e: KeyboardEvent): string { + if (e.key.length === 1) return e.key; + const codeToName: Record = { + 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; +} diff --git a/openless-all/app/src/i18n/en.ts b/openless-all/app/src/i18n/en.ts index 157a63e5..11970847 100644 --- a/openless-all/app/src/i18n/en.ts +++ b/openless-all/app/src/i18n/en.ts @@ -261,11 +261,18 @@ export const en: typeof zhCN = { modeToggle: 'Toggle', modeHold: 'Push-to-talk', migrationNoticeTitle: 'Default recording mode is now Toggle', - migrationNoticeDesc: 'If you changed the hotkey trigger mode before, please confirm it here once. This update changes both the default value and the preference-reading path; if you prefer push-to-talk, switch it back manually.', + migrationNoticeDesc: 'If you changed the hotkey trigger mode before, please confirm it once. This update changes both the default value and the preference-reading path; if you prefer push-to-talk, switch it back manually.', capsuleLabel: 'Recording capsule', capsuleDesc: 'Show a translucent capsule at the bottom of the screen while recording / transcribing.', restoreClipboardLabel: 'Restore clipboard after insert', restoreClipboardDesc: 'Windows / Linux only: restore your original clipboard after a successful paste (default on). Turn off to keep the dictation text in the clipboard so you can manually Ctrl+V if the simulated paste did not actually land. See issue #111.', + comboRecordLabel: 'Record shortcut', + comboRecordDesc: 'Click, then press your desired key combination (e.g. \u2318\u21E7D). Supports Toggle and Push-to-talk modes.', + comboRecordBtn: 'Record shortcut', + comboRecordHint: 'Press your shortcut combination\u2026', + comboRecorded: 'Recorded', + comboClear: 'Clear', + comboConflict: 'This shortcut combination is not available', }, providers: { llmTitle: 'LLM (polishing)', @@ -466,6 +473,7 @@ export const en: typeof zhCN = { rightCommand: 'Right Command', fn: 'Fn (Globe key)', rightAlt: 'Right Alt', + custom: 'Custom combination\u2026', }, fallback: 'Global hotkey', modeHoldSuffix: ' (push-to-talk)', diff --git a/openless-all/app/src/i18n/zh-CN.ts b/openless-all/app/src/i18n/zh-CN.ts index c98358bb..9c491750 100644 --- a/openless-all/app/src/i18n/zh-CN.ts +++ b/openless-all/app/src/i18n/zh-CN.ts @@ -259,11 +259,18 @@ export const zhCN = { modeToggle: '切换式', modeHold: '按住说话', migrationNoticeTitle: '默认已改为切换式说话', - migrationNoticeDesc: '如果你之前改过快捷键触发方式,请在这里手动确认一次。本次更新调整了快捷键方式的默认值与读取逻辑;如果你更习惯按住说话,可以重新切回“按住说话”。', + migrationNoticeDesc: '如果你之前改过快捷键触发方式,请在这里手动确认一次。本次更新调整了快捷键方式的默认值与读取逻辑;如果你更习惯按住说话,可以重新切回"按住说话"。', capsuleLabel: '录音胶囊', capsuleDesc: '录音 / 转写时在屏幕底部显示半透明胶囊。', restoreClipboardLabel: '插入后恢复剪贴板', restoreClipboardDesc: '仅 Windows / Linux:粘贴成功后恢复你原来的剪贴板内容(默认开)。关掉就把听写文本留在剪贴板,模拟粘贴没真正落地时可以手动 Ctrl+V 找回。详见 issue #111。', + comboRecordLabel: '录制快捷键', + comboRecordDesc: '点击后按下你想要的快捷键组合(如 ⌘⇧D),支持 Toggle 和 Hold 模式。', + comboRecordBtn: '录制快捷键', + comboRecordHint: '请按下快捷键组合…', + comboRecorded: '已录制', + comboClear: '清除', + comboConflict: '该快捷键组合不可用', }, providers: { llmTitle: 'LLM 模型(润色)', @@ -464,6 +471,7 @@ export const zhCN = { rightCommand: '右 Command', fn: 'Fn (地球键)', rightAlt: '右 Alt', + custom: '自定义组合键…', }, fallback: '全局快捷键', modeHoldSuffix: '(按住说话)', diff --git a/openless-all/app/src/lib/hotkey.ts b/openless-all/app/src/lib/hotkey.ts index 4819d8b1..177ba35d 100644 --- a/openless-all/app/src/lib/hotkey.ts +++ b/openless-all/app/src/lib/hotkey.ts @@ -1,12 +1,30 @@ import i18n from '../i18n'; -import type { HotkeyBinding, HotkeyTrigger } from './types'; +import type { ComboBinding, HotkeyBinding, HotkeyTrigger, QaHotkeyBinding, ShortcutBinding } from './types'; export function getHotkeyTriggerLabel(trigger: HotkeyTrigger | null | undefined): string { if (!trigger) return i18n.t('hotkey.fallback'); + if (trigger === 'custom') return i18n.t('hotkey.triggers.custom'); return i18n.t(`hotkey.triggers.${trigger}`); } -export function getHotkeyStartStopLabel(binding: HotkeyBinding | null | undefined): string { +export function getHotkeyStartStopLabel( + binding: HotkeyBinding | null | undefined, + comboBinding?: ComboBinding | null, + shortcutBinding?: ShortcutBinding | null, +): string { + if (shortcutBinding) { + const suffix = binding?.mode === 'hold' + ? i18n.t('hotkey.modeHoldSuffix') + : i18n.t('hotkey.modeToggleSuffix'); + return `${formatComboLabel(shortcutBinding)}${suffix}`; + } + if (binding?.trigger === 'custom' && comboBinding) { + const combo = formatComboLabel(comboBinding); + const suffix = binding.mode === 'hold' + ? i18n.t('hotkey.modeHoldSuffix') + : i18n.t('hotkey.modeToggleSuffix'); + return `${combo}${suffix}`; + } const trigger = getHotkeyTriggerLabel(binding?.trigger); const suffix = binding?.mode === 'hold' ? i18n.t('hotkey.modeHoldSuffix') @@ -14,9 +32,105 @@ export function getHotkeyStartStopLabel(binding: HotkeyBinding | null | undefine return `${trigger}${suffix}`; } -export function getHotkeyUsageHint(binding: HotkeyBinding | null | undefined): string { +export function getHotkeyUsageHint( + binding: HotkeyBinding | null | undefined, + comboBinding?: ComboBinding | null, + shortcutBinding?: ShortcutBinding | null, +): string { + if (shortcutBinding) { + const combo = formatComboLabel(shortcutBinding); + return binding?.mode === 'hold' + ? i18n.t('hotkey.usageHold', { trigger: combo }) + : i18n.t('hotkey.usageToggle', { trigger: combo }); + } + if (binding?.trigger === 'custom' && comboBinding) { + const combo = formatComboLabel(comboBinding); + return binding.mode === 'hold' + ? i18n.t('hotkey.usageHold', { trigger: combo }) + : i18n.t('hotkey.usageToggle', { trigger: combo }); + } const trigger = getHotkeyTriggerLabel(binding?.trigger); return binding?.mode === 'hold' ? i18n.t('hotkey.usageHold', { trigger }) : i18n.t('hotkey.usageToggle', { trigger }); } + +/** 把 ComboBinding 或 QaHotkeyBinding 格式化为可读标签,如 "⌘⇧D" / "Ctrl+Shift+D"。 */ +export function formatComboLabel(binding: ComboBinding | QaHotkeyBinding | ShortcutBinding): string { + const parts: string[] = []; + const isMac = navigator.platform.includes('Mac') || navigator.userAgent.includes('Mac'); + + // 固定输出顺序:Ctrl/Cmd → Alt/Option → Shift → Super + const modifierOrder = ['cmd', 'ctrl', 'alt', 'shift', 'super'] as const; + for (const tag of modifierOrder) { + if (binding.modifiers.some(m => m.toLowerCase() === tag)) { + parts.push(modifierDisplayName(tag, isMac)); + } + } + + parts.push(formatPrimary(binding.primary)); + return parts.join(isMac ? '' : '+'); +} + +function modifierDisplayName(tag: string, isMac: boolean): string { + if (isMac) { + switch (tag) { + case 'cmd': return '\u2318'; + case 'ctrl': return '\u2303'; + case 'alt': return '\u2325'; + case 'shift': return '\u21E7'; + case 'super': return '\u2318'; + } + } else { + switch (tag) { + case 'cmd': return 'Ctrl'; + case 'ctrl': return 'Ctrl'; + case 'alt': return 'Alt'; + case 'shift': return 'Shift'; + case 'super': return 'Win'; + } + } + return tag; +} + +function formatPrimary(primary: string): string { + const trimmed = primary.trim(); + if (!trimmed) return '?'; + // 单字母归大写 + if (trimmed.length === 1 && /[a-zA-Z]/.test(trimmed)) { + return trimmed.toUpperCase(); + } + // 常见命名键的 macOS 符号 + const isMac = navigator.platform.includes('Mac') || navigator.userAgent.includes('Mac'); + if (isMac) { + switch (trimmed.toLowerCase()) { + case 'space': return '\u2423'; + case 'enter': + case 'return': return '\u21A9'; + case 'tab': return '\u21E5'; + case 'escape': + case 'esc': return '\u238B'; + case 'backspace': return '\u232B'; + case 'delete': + case 'del': return '\u2326'; + case 'arrowup': + case 'up': return '\u2191'; + case 'arrowdown': + case 'down': return '\u2193'; + case 'arrowleft': + case 'left': return '\u2190'; + case 'arrowright': + case 'right': return '\u2192'; + } + } + switch (trimmed.toLowerCase()) { + case 'rightoption': return isMac ? 'Right ⌥' : 'Right Alt'; + case 'leftoption': return isMac ? 'Left ⌥' : 'Left Alt'; + case 'rightcontrol': return isMac ? 'Right ⌃' : 'Right Ctrl'; + case 'leftcontrol': return isMac ? 'Left ⌃' : 'Left Ctrl'; + case 'rightcommand': return isMac ? 'Right ⌘' : 'Right Win'; + case 'fn': return 'Fn'; + case 'shift': return isMac ? '⇧' : 'Shift'; + } + return trimmed; +} diff --git a/openless-all/app/src/lib/ipc.ts b/openless-all/app/src/lib/ipc.ts index 8c9e7993..7f002686 100644 --- a/openless-all/app/src/lib/ipc.ts +++ b/openless-all/app/src/lib/ipc.ts @@ -3,6 +3,7 @@ // the UI is still operable for visual review. import type { + ComboBinding, CredentialsStatus, DictationSession, DictionaryEntry, @@ -11,6 +12,7 @@ import type { PermissionStatus, PolishMode, QaHotkeyBinding, + ShortcutBinding, UserPreferences, } from './types'; import { OL_DATA } from './mockData'; @@ -38,6 +40,7 @@ export async function invokeOrMock( // ── Mock fixtures ────────────────────────────────────────────────────── const mockSettings: UserPreferences = { hotkey: { trigger: 'rightControl', mode: 'toggle' }, + dictationHotkey: { primary: 'RightControl', modifiers: [] }, defaultMode: 'structured', enabledModes: ['raw', 'light', 'structured', 'formal'], launchAtLogin: false, @@ -49,11 +52,15 @@ const mockSettings: UserPreferences = { translationTargetLanguage: '', qaHotkey: { primary: ';', modifiers: ['cmd', 'shift'] }, qaSaveHistory: false, + customComboHotkey: null, + translationHotkey: { primary: 'Shift', modifiers: [] }, + switchStyleHotkey: { primary: 'S', modifiers: ['cmd', 'shift'] }, + openAppHotkey: { primary: 'O', modifiers: ['cmd', 'shift'] }, }; const mockHotkeyCapability: HotkeyCapability = { adapter: 'windowsLowLevel', - availableTriggers: ['rightControl', 'rightAlt', 'leftControl', 'rightCommand'], + availableTriggers: ['rightControl', 'rightAlt', 'leftControl', 'rightCommand', 'custom'], requiresAccessibilityPermission: false, supportsModifierOnlyTrigger: true, supportsSideSpecificModifiers: true, @@ -262,7 +269,7 @@ export function getQaHotkeyLabel(): Promise { return invokeOrMock('get_qa_hotkey_label', undefined, () => 'Cmd+Shift+;'); } -export function setQaHotkey(binding: QaHotkeyBinding): Promise { +export function setQaHotkey(binding: QaHotkeyBinding | null): Promise { return invokeOrMock('set_qa_hotkey', { binding }, () => undefined); } @@ -274,6 +281,39 @@ export function qaWindowPin(pinned: boolean): Promise { return invokeOrMock('qa_window_pin', { pinned }, () => undefined); } +// ── Combo Hotkey (自定义录音组合键) ─────────────────────────────────── +export function validateComboHotkey(binding: ComboBinding): Promise { + return invokeOrMock('validate_combo_hotkey', { binding }, () => undefined); +} + +export function setComboHotkey(binding: ComboBinding): Promise { + return invokeOrMock('set_combo_hotkey', { binding }, () => undefined); +} + +export function validateShortcutBinding(binding: ShortcutBinding): Promise { + return invokeOrMock('validate_shortcut_binding', { binding }, () => undefined); +} + +export function setDictationHotkey(binding: ShortcutBinding): Promise { + return invokeOrMock('set_dictation_hotkey', { binding }, () => undefined); +} + +export function setTranslationHotkey(binding: ShortcutBinding): Promise { + return invokeOrMock('set_translation_hotkey', { binding }, () => undefined); +} + +export function setSwitchStyleHotkey(binding: ShortcutBinding): Promise { + return invokeOrMock('set_switch_style_hotkey', { binding }, () => undefined); +} + +export function setOpenAppHotkey(binding: ShortcutBinding): Promise { + return invokeOrMock('set_open_app_hotkey', { binding }, () => undefined); +} + +export function setShortcutRecordingActive(active: boolean): Promise { + return invokeOrMock('set_shortcut_recording_active', { active }, () => undefined); +} + export async function openExternal(url: string): Promise { if (!isTauri) { window.open(url, '_blank', 'noopener,noreferrer'); diff --git a/openless-all/app/src/lib/types.ts b/openless-all/app/src/lib/types.ts index 0e75fbc0..5f51191a 100644 --- a/openless-all/app/src/lib/types.ts +++ b/openless-all/app/src/lib/types.ts @@ -36,7 +36,8 @@ export type HotkeyTrigger = | 'leftControl' | 'rightCommand' | 'fn' - | 'rightAlt'; + | 'rightAlt' + | 'custom'; export type HotkeyMode = 'toggle' | 'hold'; @@ -71,16 +72,22 @@ export interface HotkeyStatus { lastError: HotkeyInstallError | null; } -/** 划词语音问答快捷键绑定。null 表示未启用。详见 issue #118。 */ -export interface QaHotkeyBinding { - /** 主键(去掉所有修饰符的字面字符),例如 ";" / "/" / "a" */ +export interface ShortcutBinding { + /** 主键,例如 "D" / "Space" / "F1" / "RightOption" / "Shift" */ primary: string; - /** 修饰符列表,元素小写:"cmd" | "shift" | "option" | "ctrl"。 */ + /** 修饰符列表,元素小写:"cmd" | "shift" | "alt" | "ctrl"。 */ modifiers: string[]; } +/** 划词语音问答快捷键绑定。null 表示未启用。详见 issue #118。 */ +export type QaHotkeyBinding = ShortcutBinding; + +/** 自定义录音组合键绑定。当 hotkey.trigger == 'custom' 时使用。 */ +export type ComboBinding = ShortcutBinding; + export interface UserPreferences { hotkey: HotkeyBinding; + dictationHotkey: ShortcutBinding; defaultMode: PolishMode; enabledModes: PolishMode[]; launchAtLogin: boolean; @@ -97,6 +104,14 @@ export interface UserPreferences { qaHotkey: QaHotkeyBinding | null; /** 是否把 Q&A 历史写到本地存档。详见 issue #118。 */ qaSaveHistory: boolean; + /** 自定义录音组合键。当 hotkey.trigger == 'custom' 时使用。null = 未设置。 */ + customComboHotkey: ComboBinding | null; + /** 录音中触发翻译的全局快捷键。默认 Shift。 */ + translationHotkey: ShortcutBinding; + /** 切换到上一个润色风格的全局快捷键。 */ + switchStyleHotkey: ShortcutBinding; + /** 打开 OpenLess 主窗口的全局快捷键。 */ + openAppHotkey: ShortcutBinding; } /** Rust 通过 `qa:state` 事件下发的 payload。 diff --git a/openless-all/app/src/pages/History.tsx b/openless-all/app/src/pages/History.tsx index 74eb77ca..97e3a601 100644 --- a/openless-all/app/src/pages/History.tsx +++ b/openless-all/app/src/pages/History.tsx @@ -5,7 +5,7 @@ import { useEffect, useMemo, useState } from 'react'; import { useTranslation } from 'react-i18next'; import { Icon } from '../components/Icon'; import { detectOS } from '../components/WindowChrome'; -import { getHotkeyTriggerLabel } from '../lib/hotkey'; +import { formatComboLabel } from '../lib/hotkey'; import { clearHistory, deleteHistoryEntry, listHistory } from '../lib/ipc'; import type { DictationSession, PolishMode } from '../lib/types'; import { useHotkeySettings } from '../state/HotkeySettingsContext'; @@ -41,7 +41,7 @@ export function History() { const [items, setItems] = useState([]); const [selectedId, setSelectedId] = useState(null); const [loading, setLoading] = useState(true); - const { hotkey } = useHotkeySettings(); + const { prefs } = useHotkeySettings(); const refresh = async () => { const data = await listHistory(); @@ -130,7 +130,7 @@ export function History() { {loading &&
{t('common.loading')}
} {!loading && filtered.length === 0 && (
- {t('history.empty', { trigger: getHotkeyTriggerLabel(hotkey?.trigger) })} + {t('history.empty', { trigger: prefs ? formatComboLabel(prefs.dictationHotkey) : '' })}
)} {filtered.map(s => ( diff --git a/openless-all/app/src/pages/Overview.tsx b/openless-all/app/src/pages/Overview.tsx index 75a767e2..84600dc7 100644 --- a/openless-all/app/src/pages/Overview.tsx +++ b/openless-all/app/src/pages/Overview.tsx @@ -3,7 +3,7 @@ import { useEffect, useMemo, useState } from 'react'; import { useTranslation } from 'react-i18next'; import { Icon } from '../components/Icon'; -import { getHotkeyTriggerLabel } from '../lib/hotkey'; +import { formatComboLabel } from '../lib/hotkey'; import { getCredentials, listHistory } from '../lib/ipc'; import type { CredentialsStatus, DictationSession, PolishMode } from '../lib/types'; import { useHotkeySettings } from '../state/HotkeySettingsContext'; @@ -31,7 +31,7 @@ export function Overview({ onOpenHistory }: OverviewProps) { volcengineConfigured: false, arkConfigured: false, }); - const { hotkey } = useHotkeySettings(); + const { prefs } = useHotkeySettings(); useEffect(() => { listHistory().then(setHistory); @@ -89,7 +89,7 @@ export function Overview({ onOpenHistory }: OverviewProps) { background: '#fff', borderRadius: 5, border: '0.5px solid var(--ol-line-strong)', color: 'var(--ol-ink)', - }}>{getHotkeyTriggerLabel(hotkey?.trigger)} + }}>{prefs ? formatComboLabel(prefs.dictationHotkey) : ''} {t('overview.pressSuffix')} } @@ -137,7 +137,7 @@ export function Overview({ onOpenHistory }: OverviewProps) {
{history.length === 0 && (
- {t('overview.recentEmpty', { trigger: getHotkeyTriggerLabel(hotkey?.trigger) })} + {t('overview.recentEmpty', { trigger: prefs ? formatComboLabel(prefs.dictationHotkey) : '' })}
)} {history.slice(0, 5).map(s => ( diff --git a/openless-all/app/src/pages/SelectionAsk.tsx b/openless-all/app/src/pages/SelectionAsk.tsx index 17eadfed..e7438f10 100644 --- a/openless-all/app/src/pages/SelectionAsk.tsx +++ b/openless-all/app/src/pages/SelectionAsk.tsx @@ -9,81 +9,14 @@ import { useTranslation } from 'react-i18next'; import { Card, PageHeader } from './_atoms'; import { useHotkeySettings } from '../state/HotkeySettingsContext'; import { setQaHotkey } from '../lib/ipc'; -import type { QaHotkeyBinding } from '../lib/types'; -import { detectOS, type OS } from '../components/WindowChrome'; -import { getHotkeyTriggerLabel } from '../lib/hotkey'; - -const QA_HOTKEY_DISABLED_ID = 'disabled' as const; - -interface QaHotkeyPreset { - id: string; - binding: QaHotkeyBinding; - label: string; -} - -// macOS:用 Cmd 修饰键,跟系统其他快捷键肌肉记忆一致。 -const QA_HOTKEY_PRESETS_MAC: readonly QaHotkeyPreset[] = [ - { id: 'cmd+shift+;', label: 'Cmd+Shift+;', binding: { primary: ';', modifiers: ['cmd', 'shift'] } }, - { id: 'cmd+shift+/', label: 'Cmd+Shift+/', binding: { primary: '/', modifiers: ['cmd', 'shift'] } }, - { id: 'cmd+shift+.', label: 'Cmd+Shift+.', binding: { primary: '.', modifiers: ['cmd', 'shift'] } }, - { id: 'cmd+shift+,', label: 'Cmd+Shift+,', binding: { primary: ',', modifiers: ['cmd', 'shift'] } }, -] as const; - -// Windows:用 Ctrl 修饰键(macOS Cmd 的对等键)。**不**用 Fn——Win32 的 -// `RegisterHotKey` 和 `WH_KEYBOARD_LL` 都收不到 Fn 的虚拟键码(硬件级 modifier, -// 在 OS 内核之下被吃掉),写进 preset 用户也注册不上。**不**用 Win+x—— -// 大部分 Win+x 已被系统 / Cortana 占用。 -const QA_HOTKEY_PRESETS_WIN: readonly QaHotkeyPreset[] = [ - { id: 'ctrl+shift+;', label: 'Ctrl+Shift+;', binding: { primary: ';', modifiers: ['ctrl', 'shift'] } }, - { id: 'ctrl+shift+/', label: 'Ctrl+Shift+/', binding: { primary: '/', modifiers: ['ctrl', 'shift'] } }, - { id: 'ctrl+shift+.', label: 'Ctrl+Shift+.', binding: { primary: '.', modifiers: ['ctrl', 'shift'] } }, - { id: 'ctrl+shift+,', label: 'Ctrl+Shift+,', binding: { primary: ',', modifiers: ['ctrl', 'shift'] } }, -] as const; - -// Linux:UI 展示用 Super,后端 binding 仍用 SUPER 同义词 `cmd` 透传到 global-hotkey。 -const QA_HOTKEY_PRESETS_LINUX: readonly QaHotkeyPreset[] = [ - { id: 'super+shift+;', label: 'Super+Shift+;', binding: { primary: ';', modifiers: ['cmd', 'shift'] } }, - { id: 'super+shift+/', label: 'Super+Shift+/', binding: { primary: '/', modifiers: ['cmd', 'shift'] } }, - { id: 'super+shift+.', label: 'Super+Shift+.', binding: { primary: '.', modifiers: ['cmd', 'shift'] } }, - { id: 'super+shift+,', label: 'Super+Shift+,', binding: { primary: ',', modifiers: ['cmd', 'shift'] } }, -] as const; - -function getQaHotkeyPresets(os: OS): readonly QaHotkeyPreset[] { - if (os === 'mac') return QA_HOTKEY_PRESETS_MAC; - if (os === 'linux') return QA_HOTKEY_PRESETS_LINUX; - return QA_HOTKEY_PRESETS_WIN; -} - -function normalizeQaModifier(modifier: string): string { - const tag = modifier.toLowerCase(); - if (tag === 'command' || tag === 'super' || tag === 'meta' || tag === 'win') { - return 'cmd'; - } - return tag; -} - -function bindingToPresetId( - binding: QaHotkeyBinding | null, - presets: readonly QaHotkeyPreset[], -): string { - if (!binding) return QA_HOTKEY_DISABLED_ID; - const sortedMods = [...binding.modifiers].map(normalizeQaModifier).sort(); - const match = presets.find(p => { - const pMods = [...p.binding.modifiers].map(normalizeQaModifier).sort(); - return p.binding.primary === binding.primary - && pMods.length === sortedMods.length - && pMods.every((m, i) => m === sortedMods[i]); - }); - return match ? match.id : presets[0].id; -} +import { formatComboLabel } from '../lib/hotkey'; +import { ShortcutRecorder } from '../components/ShortcutRecorder'; export function SelectionAsk() { const { t } = useTranslation(); - const { prefs, hotkey, updatePrefs: savePrefs } = useHotkeySettings(); - const os = detectOS(); - const qaHotkeyPresets = getQaHotkeyPresets(os); - const defaultHotkeyLabel = qaHotkeyPresets[0]?.label ?? '快捷键'; - const recordHotkeyLabel = getHotkeyTriggerLabel(hotkey?.trigger); + const { prefs, updatePrefs: savePrefs } = useHotkeySettings(); + const defaultHotkeyLabel = 'Cmd+Shift+;'; + const recordHotkeyLabel = prefs ? formatComboLabel(prefs.dictationHotkey) : '快捷键'; if (!prefs) { return ( @@ -103,31 +36,11 @@ export function SelectionAsk() { ); } - const onHotkeyChange = async (id: string) => { - if (id === QA_HOTKEY_DISABLED_ID) { - await savePrefs({ ...prefs, qaHotkey: null }); - return; - } - const preset = qaHotkeyPresets.find(p => p.id === id); - if (!preset) return; - // 先让后端真注册成功 → 再写盘 prefs。否则 prefs 跟实际生效的快捷键脱节, - // 会让用户陷入"UI 改了但按了没反应"的迷雾(issue #118 v1 实测过)。 - try { - await setQaHotkey(preset.binding); - } catch (error) { - console.error('[selectionAsk] failed to set qa hotkey', error); - // 后端拒绝绑定(如不支持的主键)→ 不写盘,UI 下次 render 仍显示旧值。 - return; - } - await savePrefs({ ...prefs, qaHotkey: preset.binding }); - }; - const onSaveHistoryChange = (qaSaveHistory: boolean) => savePrefs({ ...prefs, qaSaveHistory }); const enabled = prefs.qaHotkey !== null; - const currentId = bindingToPresetId(prefs.qaHotkey, qaHotkeyPresets); - const currentLabel = qaHotkeyPresets.find(p => p.id === currentId)?.label ?? defaultHotkeyLabel; + const currentLabel = prefs.qaHotkey ? formatComboLabel(prefs.qaHotkey) : defaultHotkeyLabel; return ( <> @@ -164,28 +77,24 @@ export function SelectionAsk() {
{t('selectionAsk.hotkey.desc', { recordHotkey: recordHotkeyLabel })}
- + {enabled ? t('selectionAsk.hotkey.optionDisabled') : t('selectionAsk.statusEnabled')} + + {prefs.qaHotkey && ( + { + await setQaHotkey(binding); + await savePrefs({ ...prefs, qaHotkey: binding }); + }} + /> + )} {/* 2. 历史保存 */} diff --git a/openless-all/app/src/pages/Settings.tsx b/openless-all/app/src/pages/Settings.tsx index 420b9f66..241358bb 100644 --- a/openless-all/app/src/pages/Settings.tsx +++ b/openless-all/app/src/pages/Settings.tsx @@ -5,10 +5,10 @@ import { useEffect, useRef, useState, type CSSProperties, type ReactNode } from 'react'; import { useTranslation } from 'react-i18next'; import { Icon } from '../components/Icon'; +import { ShortcutRecorder } from '../components/ShortcutRecorder'; import { isDialogStatus, UpdateDialog, useAutoUpdate } from '../components/AutoUpdate'; import { APP_VERSION_LABEL } from '../lib/appVersion'; import { isHotkeyModeMigrationNoticeActive } from '../lib/hotkeyMigration'; -import { getHotkeyStartStopLabel, getHotkeyTriggerLabel } from '../lib/hotkey'; import { checkAccessibilityPermission, checkMicrophonePermission, @@ -22,14 +22,19 @@ import { setActiveAsrProvider, setActiveLlmProvider, setCredential, + setDictationHotkey, + setOpenAppHotkey, + setQaHotkey, + setSwitchStyleHotkey, + setTranslationHotkey, validateProviderCredentials, } from '../lib/ipc'; import type { HotkeyCapability, HotkeyMode, HotkeyStatus, - HotkeyTrigger, PermissionStatus, + ShortcutBinding, } from '../lib/types'; import { useHotkeySettings } from '../state/HotkeySettingsContext'; import i18n, { @@ -146,8 +151,6 @@ function RecordingSection() { ); } - const onTriggerChange = (trigger: HotkeyTrigger) => - savePrefs({ ...prefs, hotkey: { ...prefs.hotkey, trigger } }); const onModeChange = (mode: HotkeyMode) => savePrefs({ ...prefs, hotkey: { ...prefs.hotkey, mode } }); const onShowCapsuleChange = (showCapsule: boolean) => @@ -187,19 +190,13 @@ function RecordingSection() {
)} - + />
@@ -526,8 +523,8 @@ function CredentialField({ label, account, placeholder, mono, mask, defaultValue const [loaded, setLoaded] = useState(false); const [dirty, setDirty] = useState(false); const [status, setStatus] = useState('idle'); - const debounceRef = useRef | null>(null); - const statusRef = useRef | null>(null); + const debounceRef = useRef(null); + const statusRef = useRef(null); useEffect(() => { let cancelled = false; @@ -588,7 +585,7 @@ function CredentialField({ label, account, placeholder, mono, mask, defaultValue if (!loaded) return; setDirty(true); if (debounceRef.current) clearTimeout(debounceRef.current); - debounceRef.current = setTimeout(() => save(v, true), 300); + debounceRef.current = window.setTimeout(() => save(v, true), 300); }; const onBlur = () => { @@ -712,9 +709,9 @@ const iconBtnStyle: CSSProperties = { function ShortcutsSection() { const { t } = useTranslation(); - const { hotkey, capability } = useHotkeySettings(); + const { prefs, hotkey, capability, updatePrefs: savePrefs } = useHotkeySettings(); - if (!hotkey || !capability) { + if (!prefs || !hotkey || !capability) { return (
{t('common.loading')}
@@ -725,19 +722,83 @@ function ShortcutsSection() { const desc = capability.requiresAccessibilityPermission ? t('settings.shortcuts.descAcc') : t('settings.shortcuts.descNoAcc'); - const notSupported = t('settings.shortcuts.notSupported'); - const rows: Array<[string, string]> = [ - [t('settings.shortcuts.startStop'), getHotkeyStartStopLabel(hotkey)], + const readonlyRows: Array<[string, string]> = [ [t('settings.shortcuts.cancel'), 'Esc'], [t('settings.shortcuts.confirm'), t('settings.shortcuts.confirmHint')], - [t('settings.shortcuts.switchStyle'), capability.requiresAccessibilityPermission ? '⌘ ⇧ S' : notSupported], - [t('settings.shortcuts.openApp'), capability.requiresAccessibilityPermission ? '⌘ ⇧ O' : notSupported], ]; return (
{t('settings.shortcuts.title')}
{desc}
- {rows.map(([k, v]) => ( + +
+ { + await setDictationHotkey(binding); + await savePrefs({ ...prefs, dictationHotkey: binding }); + }} + /> +
+ {hotkey.mode === 'hold' ? t('hotkey.modeHoldSuffix') : t('hotkey.modeToggleSuffix')} +
+
+
+ + { + await setTranslationHotkey(binding); + await savePrefs({ ...prefs, translationHotkey: binding }); + }} + /> + + + {prefs.qaHotkey ? ( + { + await setQaHotkey(binding); + await savePrefs({ ...prefs, qaHotkey: binding }); + }} + /> + ) : ( + + )} + + + { + await setSwitchStyleHotkey(binding); + await savePrefs({ ...prefs, switchStyleHotkey: binding }); + }} + /> + + + { + await setOpenAppHotkey(binding); + await savePrefs({ ...prefs, openAppHotkey: binding }); + }} + /> + + {readonlyRows.map(([k, v]) => ( ('loading'); @@ -913,7 +981,7 @@ function LanguageSection() { function AboutSection() { const { t } = useTranslation(); const [qqCopied, setQqCopied] = useState(false); - const qqCopiedRef = useRef | null>(null); + const qqCopiedRef = useRef(null); useEffect(() => { return () => { diff --git a/openless-all/app/src/pages/Translation.tsx b/openless-all/app/src/pages/Translation.tsx index 74d9759c..25a37a37 100644 --- a/openless-all/app/src/pages/Translation.tsx +++ b/openless-all/app/src/pages/Translation.tsx @@ -8,11 +8,13 @@ import { useTranslation } from 'react-i18next'; import { Card, PageHeader } from './_atoms'; import { SUPPORTED_LANGUAGES } from '../lib/types'; import { useHotkeySettings } from '../state/HotkeySettingsContext'; -import { getHotkeyTriggerLabel } from '../lib/hotkey'; +import { formatComboLabel } from '../lib/hotkey'; +import { ShortcutRecorder } from '../components/ShortcutRecorder'; +import { setTranslationHotkey } from '../lib/ipc'; export function Translation() { const { t } = useTranslation(); - const { prefs, updatePrefs: savePrefs, hotkey } = useHotkeySettings(); + const { prefs, updatePrefs: savePrefs } = useHotkeySettings(); if (!prefs) { return ( @@ -40,7 +42,8 @@ export function Translation() { const onTargetChange = (translationTargetLanguage: string) => savePrefs({ ...prefs, translationTargetLanguage }); - const triggerLabel = getHotkeyTriggerLabel(hotkey?.trigger); + const triggerLabel = formatComboLabel(prefs.dictationHotkey); + const translationHotkeyLabel = formatComboLabel(prefs.translationHotkey); const enabled = prefs.translationTargetLanguage.trim() !== ''; return ( @@ -132,13 +135,27 @@ export function Translation() {
+ +
{t('translation.hotkey.title', 'Translation shortcut')}
+
+ {t('translation.hotkey.desc', 'Press this during recording to switch the current dictation into translation mode.')} +
+ { + await setTranslationHotkey(binding); + await savePrefs({ ...prefs, translationHotkey: binding }); + }} + /> +
+ {/* 3. 使用方法 */}
{t('translation.howto.title')}
  1. {t('translation.howto.step1', { trigger: triggerLabel })}
  2. {t('translation.howto.step2')}
  3. -
  4. {t('translation.howto.step3')}
  5. +
  6. {t('translation.howto.step3', { shortcut: translationHotkeyLabel })}
  7. {t('translation.howto.step4')}
  8. {t('translation.howto.step5')}
From 13bfe263954a88770f7730d333ba5cf4cb766292 Mon Sep 17 00:00:00 2001 From: Leslie Leung Date: Mon, 4 May 2026 18:43:50 +0800 Subject: [PATCH 02/10] Fix Linux hotkey labels and shutdown cleanup --- openless-all/app/src-tauri/src/coordinator.rs | 8 ++++++ openless-all/app/src-tauri/src/lib.rs | 2 ++ openless-all/app/src-tauri/src/types.rs | 17 +++++++++++-- openless-all/app/src/lib/hotkey.ts | 25 +++++++++++++------ 4 files changed, 42 insertions(+), 10 deletions(-) diff --git a/openless-all/app/src-tauri/src/coordinator.rs b/openless-all/app/src-tauri/src/coordinator.rs index 8abeac0f..c4429121 100644 --- a/openless-all/app/src-tauri/src/coordinator.rs +++ b/openless-all/app/src-tauri/src/coordinator.rs @@ -331,6 +331,10 @@ impl Coordinator { .ok(); } + pub fn stop_switch_style_hotkey_listener(&self) { + take_action_hotkey_on_main_thread(&self.inner, ActionHotkeyKind::SwitchStyle); + } + pub fn start_open_app_hotkey_listener(&self) { let inner = Arc::clone(&self.inner); std::thread::Builder::new() @@ -339,6 +343,10 @@ impl Coordinator { .ok(); } + pub fn stop_open_app_hotkey_listener(&self) { + take_action_hotkey_on_main_thread(&self.inner, ActionHotkeyKind::OpenApp); + } + /// 用户在设置里改了自定义组合键时调用。 pub fn update_combo_hotkey_binding(&self) { let prefs = self.inner.prefs.get(); diff --git a/openless-all/app/src-tauri/src/lib.rs b/openless-all/app/src-tauri/src/lib.rs index c03e0e4c..a38c953c 100644 --- a/openless-all/app/src-tauri/src/lib.rs +++ b/openless-all/app/src-tauri/src/lib.rs @@ -280,6 +280,8 @@ pub fn run() { coordinator.stop_qa_hotkey_listener(); coordinator.stop_combo_hotkey_listener(); coordinator.stop_translation_hotkey_listener(); + coordinator.stop_switch_style_hotkey_listener(); + coordinator.stop_open_app_hotkey_listener(); } _ => {} }); diff --git a/openless-all/app/src-tauri/src/types.rs b/openless-all/app/src-tauri/src/types.rs index b3e6f6f1..93708ea3 100644 --- a/openless-all/app/src-tauri/src/types.rs +++ b/openless-all/app/src-tauri/src/types.rs @@ -381,7 +381,7 @@ impl Default for QaHotkeyBinding { } impl QaHotkeyBinding { - /// 渲染成给前端展示的可读标签(macOS 用 `Cmd`,其他平台用 `Ctrl`)。 + /// 渲染成给前端展示的可读标签。 /// 顺序与人类阅读习惯一致:`Cmd+Shift+;`、`Ctrl+Alt+Shift+.`。 pub fn display_label(&self) -> String { let mut parts: Vec = Vec::new(); @@ -424,7 +424,20 @@ impl ComboBinding { fn modifier_display(tag: &str) -> &'static str { match tag { - "cmd" => "Cmd", + "cmd" => { + #[cfg(target_os = "macos")] + { + "Cmd" + } + #[cfg(target_os = "windows")] + { + "Ctrl" + } + #[cfg(all(not(target_os = "macos"), not(target_os = "windows")))] + { + "Super" + } + } "ctrl" => "Ctrl", "alt" => { #[cfg(target_os = "macos")] diff --git a/openless-all/app/src/lib/hotkey.ts b/openless-all/app/src/lib/hotkey.ts index 177ba35d..94533fb9 100644 --- a/openless-all/app/src/lib/hotkey.ts +++ b/openless-all/app/src/lib/hotkey.ts @@ -58,22 +58,31 @@ export function getHotkeyUsageHint( /** 把 ComboBinding 或 QaHotkeyBinding 格式化为可读标签,如 "⌘⇧D" / "Ctrl+Shift+D"。 */ export function formatComboLabel(binding: ComboBinding | QaHotkeyBinding | ShortcutBinding): string { const parts: string[] = []; - const isMac = navigator.platform.includes('Mac') || navigator.userAgent.includes('Mac'); + const platform = currentPlatform(); // 固定输出顺序:Ctrl/Cmd → Alt/Option → Shift → Super const modifierOrder = ['cmd', 'ctrl', 'alt', 'shift', 'super'] as const; for (const tag of modifierOrder) { if (binding.modifiers.some(m => m.toLowerCase() === tag)) { - parts.push(modifierDisplayName(tag, isMac)); + parts.push(modifierDisplayName(tag, platform)); } } parts.push(formatPrimary(binding.primary)); - return parts.join(isMac ? '' : '+'); + return parts.join(platform.isMac ? '' : '+'); } -function modifierDisplayName(tag: string, isMac: boolean): string { - if (isMac) { +function currentPlatform(): { isMac: boolean; isWindows: boolean } { + const platform = navigator.platform || ''; + const userAgent = navigator.userAgent || ''; + return { + isMac: platform.includes('Mac') || userAgent.includes('Mac'), + isWindows: platform.includes('Win') || userAgent.includes('Windows'), + }; +} + +function modifierDisplayName(tag: string, platform: { isMac: boolean; isWindows: boolean }): string { + if (platform.isMac) { switch (tag) { case 'cmd': return '\u2318'; case 'ctrl': return '\u2303'; @@ -83,11 +92,11 @@ function modifierDisplayName(tag: string, isMac: boolean): string { } } else { switch (tag) { - case 'cmd': return 'Ctrl'; + case 'cmd': return platform.isWindows ? 'Ctrl' : 'Super'; case 'ctrl': return 'Ctrl'; case 'alt': return 'Alt'; case 'shift': return 'Shift'; - case 'super': return 'Win'; + case 'super': return platform.isWindows ? 'Win' : 'Super'; } } return tag; @@ -128,7 +137,7 @@ function formatPrimary(primary: string): string { case 'leftoption': return isMac ? 'Left ⌥' : 'Left Alt'; case 'rightcontrol': return isMac ? 'Right ⌃' : 'Right Ctrl'; case 'leftcontrol': return isMac ? 'Left ⌃' : 'Left Ctrl'; - case 'rightcommand': return isMac ? 'Right ⌘' : 'Right Win'; + case 'rightcommand': return isMac ? 'Right ⌘' : (currentPlatform().isWindows ? 'Right Win' : 'Right Super'); case 'fn': return 'Fn'; case 'shift': return isMac ? '⇧' : 'Shift'; } From 61214aa8fb00552ae62b6cdcd7f9864ef8d2e195 Mon Sep 17 00:00:00 2001 From: Leslie Leung Date: Mon, 4 May 2026 22:02:21 +0800 Subject: [PATCH 03/10] Fix hotkey binding replacement validation --- .../app/src-tauri/src/combo_hotkey.rs | 25 ++++++++++++++++++- openless-all/app/src-tauri/src/commands.rs | 20 +++++++++++---- openless-all/app/src-tauri/src/qa_hotkey.rs | 1 - 3 files changed, 39 insertions(+), 7 deletions(-) diff --git a/openless-all/app/src-tauri/src/combo_hotkey.rs b/openless-all/app/src-tauri/src/combo_hotkey.rs index dd8d7144..68244825 100644 --- a/openless-all/app/src-tauri/src/combo_hotkey.rs +++ b/openless-all/app/src-tauri/src/combo_hotkey.rs @@ -97,7 +97,6 @@ impl ComboHotkeyMonitor { return Ok(()); } } - current.take(); let runtime = GlobalHotkeyRuntime::shared() .map_err(|e| ComboHotkeyError::ManagerInitFailed(e.to_string()))?; let (registered, rx) = runtime @@ -202,4 +201,28 @@ mod tests { Err(ComboHotkeyError::UnsupportedKey(_)) )); } + + #[test] + fn bare_shift_is_rejected_for_combo_hotkey() { + let binding = ShortcutBinding { + primary: "Shift".into(), + modifiers: vec![], + }; + assert!(matches!( + validate_binding(&binding), + Err(ComboHotkeyError::UnsupportedKey(_)) + )); + } + + #[test] + fn legacy_modifier_only_is_rejected_for_combo_hotkey() { + let binding = ShortcutBinding { + primary: "RightOption".into(), + modifiers: vec![], + }; + assert!(matches!( + validate_binding(&binding), + Err(ComboHotkeyError::UnsupportedKey(_)) + )); + } } diff --git a/openless-all/app/src-tauri/src/commands.rs b/openless-all/app/src-tauri/src/commands.rs index 89d0cebe..a91a203e 100644 --- a/openless-all/app/src-tauri/src/commands.rs +++ b/openless-all/app/src-tauri/src/commands.rs @@ -13,8 +13,7 @@ use crate::persistence::{CredentialAccount, CredentialsSnapshot, CredentialsVaul use crate::polish::{LLMError, OpenAICompatibleConfig, OpenAICompatibleLLMProvider}; use crate::types::{ ComboBinding, CredentialsStatus, DictationSession, DictionaryEntry, HotkeyCapability, - HotkeyStatus, PolishMode, ShortcutBinding, UserPreferences, VocabPresetStore, - WindowsImeStatus, + HotkeyStatus, PolishMode, ShortcutBinding, UserPreferences, VocabPresetStore, WindowsImeStatus, }; type CoordinatorState<'a> = State<'a, Arc>; @@ -707,10 +706,11 @@ fn reject_modifier_only_action_shortcut(binding: &ShortcutBinding) -> Result<(), #[tauri::command] pub fn validate_combo_hotkey(binding: ComboBinding) -> Result<(), String> { - validate_shortcut_binding(ShortcutBinding { + crate::combo_hotkey::validate_binding(&ShortcutBinding { primary: binding.primary, modifiers: binding.modifiers, }) + .map_err(|e| e.to_string()) } /// 设置自定义录音组合键并热更新 monitor。 @@ -721,7 +721,7 @@ pub fn set_combo_hotkey(coord: CoordinatorState<'_>, binding: ComboBinding) -> R primary: binding.primary.clone(), modifiers: binding.modifiers.clone(), }; - crate::shortcut_binding::validate_binding(&shortcut).map_err(|e| e.to_string())?; + crate::combo_hotkey::validate_binding(&shortcut).map_err(|e| e.to_string())?; prefs.custom_combo_hotkey = Some(binding); prefs.dictation_hotkey = shortcut; prefs.hotkey.trigger = crate::types::HotkeyTrigger::Custom; @@ -743,7 +743,7 @@ mod tests { SettingsWriter, }; use crate::types::{ - HotkeyBinding, HotkeyMode, HotkeyTrigger, ShortcutBinding, UserPreferences, + ComboBinding, HotkeyBinding, HotkeyMode, HotkeyTrigger, ShortcutBinding, UserPreferences, }; use std::io::{Read, Write}; use std::net::TcpListener; @@ -835,6 +835,16 @@ mod tests { assert_eq!(*writer.combo_refreshes.lock().unwrap(), 1); } + #[test] + fn validate_combo_hotkey_rejects_bare_shift() { + let result = super::validate_combo_hotkey(ComboBinding { + primary: "Shift".into(), + modifiers: vec![], + }); + + assert!(result.is_err()); + } + #[tokio::test] async fn fetch_provider_models_omits_authorization_when_api_key_is_empty() { let listener = TcpListener::bind("127.0.0.1:0").unwrap(); diff --git a/openless-all/app/src-tauri/src/qa_hotkey.rs b/openless-all/app/src-tauri/src/qa_hotkey.rs index 70e17c84..798e2f0c 100644 --- a/openless-all/app/src-tauri/src/qa_hotkey.rs +++ b/openless-all/app/src-tauri/src/qa_hotkey.rs @@ -103,7 +103,6 @@ impl QaHotkeyMonitor { return Ok(()); } } - current.take(); let runtime = GlobalHotkeyRuntime::shared() .map_err(|e| QaHotkeyError::ManagerInitFailed(e.to_string()))?; let (registered, rx) = runtime From 913039e435402ff292d45529e6288ebeb4020561 Mon Sep 17 00:00:00 2001 From: Leslie Leung Date: Mon, 4 May 2026 23:56:37 +0800 Subject: [PATCH 04/10] Fix custom dictation hotkey fallbacks --- openless-all/app/src-tauri/src/commands.rs | 32 ++++++++-- openless-all/app/src-tauri/src/persistence.rs | 2 +- openless-all/app/src-tauri/src/types.rs | 63 ++++++++++++++++--- 3 files changed, 82 insertions(+), 15 deletions(-) diff --git a/openless-all/app/src-tauri/src/commands.rs b/openless-all/app/src-tauri/src/commands.rs index a91a203e..5e21e394 100644 --- a/openless-all/app/src-tauri/src/commands.rs +++ b/openless-all/app/src-tauri/src/commands.rs @@ -642,9 +642,7 @@ pub fn set_dictation_hotkey( binding: ShortcutBinding, ) -> Result<(), String> { crate::shortcut_binding::validate_binding(&binding).map_err(|e| e.to_string())?; - if binding.modifiers.is_empty() && binding.primary.eq_ignore_ascii_case("shift") { - return Err("Shift 单键目前只能用于翻译快捷键".into()); - } + reject_bare_shift_dictation_shortcut(&binding)?; let mut prefs = coord.prefs().get(); prefs.dictation_hotkey = binding; coord.prefs().set(prefs).map_err(|e| e.to_string())?; @@ -706,11 +704,12 @@ fn reject_modifier_only_action_shortcut(binding: &ShortcutBinding) -> Result<(), #[tauri::command] pub fn validate_combo_hotkey(binding: ComboBinding) -> Result<(), String> { - crate::combo_hotkey::validate_binding(&ShortcutBinding { + let shortcut = ShortcutBinding { primary: binding.primary, modifiers: binding.modifiers, - }) - .map_err(|e| e.to_string()) + }; + reject_bare_shift_dictation_shortcut(&shortcut)?; + crate::combo_hotkey::validate_binding(&shortcut).map_err(|e| e.to_string()) } /// 设置自定义录音组合键并热更新 monitor。 @@ -721,6 +720,7 @@ pub fn set_combo_hotkey(coord: CoordinatorState<'_>, binding: ComboBinding) -> R primary: binding.primary.clone(), modifiers: binding.modifiers.clone(), }; + reject_bare_shift_dictation_shortcut(&shortcut)?; crate::combo_hotkey::validate_binding(&shortcut).map_err(|e| e.to_string())?; prefs.custom_combo_hotkey = Some(binding); prefs.dictation_hotkey = shortcut; @@ -731,6 +731,13 @@ pub fn set_combo_hotkey(coord: CoordinatorState<'_>, binding: ComboBinding) -> R Ok(()) } +fn reject_bare_shift_dictation_shortcut(binding: &ShortcutBinding) -> Result<(), String> { + if binding.modifiers.is_empty() && binding.primary.eq_ignore_ascii_case("shift") { + return Err("Shift 单键目前只能用于翻译快捷键".into()); + } + Ok(()) +} + // ─────────────────────────── unused but exported (silences dead_code) ─────────────────────────── #[allow(dead_code)] @@ -845,6 +852,19 @@ mod tests { assert!(result.is_err()); } + #[test] + fn combo_hotkey_bare_shift_rejection_matches_dictation_setter() { + let binding = ShortcutBinding { + primary: "Shift".into(), + modifiers: vec![], + }; + + assert_eq!( + super::reject_bare_shift_dictation_shortcut(&binding), + Err("Shift 单键目前只能用于翻译快捷键".into()) + ); + } + #[tokio::test] async fn fetch_provider_models_omits_authorization_when_api_key_is_empty() { let listener = TcpListener::bind("127.0.0.1:0").unwrap(); diff --git a/openless-all/app/src-tauri/src/persistence.rs b/openless-all/app/src-tauri/src/persistence.rs index dd17d7a6..e844722b 100644 --- a/openless-all/app/src-tauri/src/persistence.rs +++ b/openless-all/app/src-tauri/src/persistence.rs @@ -441,7 +441,7 @@ impl PreferencesStore { ensure_dir(&dir)?; let path = dir.join(PREFERENCES_FILE); let prefs = if path.exists() { - read_or_default::(&path).unwrap_or_default() + read_or_default::(&path)? } else { UserPreferences::default() }; diff --git a/openless-all/app/src-tauri/src/types.rs b/openless-all/app/src-tauri/src/types.rs index 93708ea3..2b37f11a 100644 --- a/openless-all/app/src-tauri/src/types.rs +++ b/openless-all/app/src-tauri/src/types.rs @@ -192,9 +192,11 @@ impl<'de> Deserialize<'de> for UserPreferences { D: serde::Deserializer<'de>, { let wire = UserPreferencesWire::deserialize(deserializer)?; - let dictation_hotkey = wire.dictation_hotkey.unwrap_or_else(|| { - default_dictation_hotkey_from_legacy(&wire.hotkey, &wire.custom_combo_hotkey) - }); + let dictation_hotkey = match wire.dictation_hotkey { + Some(binding) => binding, + None => default_dictation_hotkey_from_legacy(&wire.hotkey, &wire.custom_combo_hotkey) + .map_err(serde::de::Error::custom)?, + }; Ok(Self { hotkey: wire.hotkey, dictation_hotkey, @@ -261,16 +263,21 @@ fn default_app_shortcut_modifiers() -> Vec { fn default_dictation_hotkey_from_legacy( hotkey: &HotkeyBinding, custom_combo_hotkey: &Option, -) -> ShortcutBinding { +) -> Result { if hotkey.trigger == HotkeyTrigger::Custom { if let Some(combo) = custom_combo_hotkey { - return ShortcutBinding { + return Ok(ShortcutBinding { primary: combo.primary.clone(), modifiers: combo.modifiers.clone(), - }; + }); } + return Err( + "hotkey.trigger is custom but dictationHotkey/customComboHotkey is missing".into(), + ); } - crate::shortcut_binding::binding_from_legacy_trigger(hotkey.trigger) + Ok(crate::shortcut_binding::binding_from_legacy_trigger( + hotkey.trigger, + )) } fn default_working_languages() -> Vec { @@ -284,7 +291,8 @@ impl Default for UserPreferences { dictation_hotkey: default_dictation_hotkey_from_legacy( &HotkeyBinding::default(), &None, - ), + ) + .expect("default legacy hotkey is not custom"), default_mode: PolishMode::Light, enabled_modes: vec![ PolishMode::Raw, @@ -759,4 +767,43 @@ mod tests { assert!(prefs.allow_non_tsf_insertion_fallback); } + + #[test] + fn legacy_custom_hotkey_without_custom_binding_is_rejected() { + let result = serde_json::from_str::( + r#"{ + "hotkey": { "trigger": "custom", "mode": "toggle" } + }"#, + ); + + assert!(result.is_err()); + } + + #[test] + fn legacy_custom_hotkey_uses_custom_combo_binding() { + let prefs: UserPreferences = serde_json::from_str( + r#"{ + "hotkey": { "trigger": "custom", "mode": "toggle" }, + "customComboHotkey": { "primary": "D", "modifiers": ["cmd", "shift"] } + }"#, + ) + .unwrap(); + + assert_eq!(prefs.dictation_hotkey.primary, "D"); + assert_eq!(prefs.dictation_hotkey.modifiers, vec!["cmd", "shift"]); + } + + #[test] + fn custom_hotkey_with_dictation_hotkey_preserves_dictation_binding() { + let prefs: UserPreferences = serde_json::from_str( + r#"{ + "hotkey": { "trigger": "custom", "mode": "toggle" }, + "dictationHotkey": { "primary": "Space", "modifiers": ["ctrl"] } + }"#, + ) + .unwrap(); + + assert_eq!(prefs.dictation_hotkey.primary, "Space"); + assert_eq!(prefs.dictation_hotkey.modifiers, vec!["ctrl"]); + } } From 789c6f3fbf02aa1972b62417119655a49dc0feef Mon Sep 17 00:00:00 2001 From: Leslie Leung Date: Tue, 5 May 2026 00:38:32 +0800 Subject: [PATCH 05/10] Fix shortcut recording defaults --- .../app/src-tauri/src/shortcut_binding.rs | 62 +++++++++++++------ .../app/src/components/ShortcutRecorder.tsx | 26 +++++++- openless-all/app/src/lib/hotkey.ts | 20 ++++-- openless-all/app/src/lib/ipc.ts | 9 +-- openless-all/app/src/pages/SelectionAsk.tsx | 10 +-- openless-all/app/src/pages/Settings.tsx | 9 +-- 6 files changed, 94 insertions(+), 42 deletions(-) diff --git a/openless-all/app/src-tauri/src/shortcut_binding.rs b/openless-all/app/src-tauri/src/shortcut_binding.rs index 8256796d..731f8532 100644 --- a/openless-all/app/src-tauri/src/shortcut_binding.rs +++ b/openless-all/app/src-tauri/src/shortcut_binding.rs @@ -166,27 +166,27 @@ fn char_to_code(ch: char) -> Option { 'X' => Code::KeyX, 'Y' => Code::KeyY, 'Z' => Code::KeyZ, - '0' => Code::Digit0, - '1' => Code::Digit1, - '2' => Code::Digit2, - '3' => Code::Digit3, - '4' => Code::Digit4, - '5' => Code::Digit5, - '6' => Code::Digit6, - '7' => Code::Digit7, - '8' => Code::Digit8, - '9' => Code::Digit9, + '0' | ')' => Code::Digit0, + '1' | '!' => Code::Digit1, + '2' | '@' => Code::Digit2, + '3' | '#' => Code::Digit3, + '4' | '$' => Code::Digit4, + '5' | '%' => Code::Digit5, + '6' | '^' => Code::Digit6, + '7' | '&' => Code::Digit7, + '8' | '*' => Code::Digit8, + '9' | '(' => Code::Digit9, ';' | ':' => Code::Semicolon, - ',' => Code::Comma, - '.' => Code::Period, - '/' => Code::Slash, - '\\' => Code::Backslash, - '[' => Code::BracketLeft, - ']' => Code::BracketRight, - '\'' => Code::Quote, - '`' => Code::Backquote, - '-' => Code::Minus, - '=' => Code::Equal, + ',' | '<' => Code::Comma, + '.' | '>' => Code::Period, + '/' | '?' => Code::Slash, + '\\' | '|' => Code::Backslash, + '[' | '{' => Code::BracketLeft, + ']' | '}' => Code::BracketRight, + '\'' | '"' => Code::Quote, + '`' | '~' => Code::Backquote, + '-' | '_' => Code::Minus, + '=' | '+' => Code::Equal, ' ' => Code::Space, _ => return None, }; @@ -228,4 +228,26 @@ mod tests { Some(HotkeyTrigger::RightControl) ); } + + #[test] + fn accepts_shifted_printable_aliases() { + let cases = [ + ("?", Code::Slash), + ("!", Code::Digit1), + (":", Code::Semicolon), + ("+", Code::Equal), + ("_", Code::Minus), + ("{", Code::BracketLeft), + ("|", Code::Backslash), + ]; + for (primary, expected) in cases { + let binding = ShortcutBinding { + primary: primary.into(), + modifiers: vec!["shift".into()], + }; + let parsed = parse_global_hotkey(&binding).expect("shifted printable parses"); + assert_eq!(parsed.key, expected); + assert!(parsed.mods.contains(Modifiers::SHIFT)); + } + } } diff --git a/openless-all/app/src/components/ShortcutRecorder.tsx b/openless-all/app/src/components/ShortcutRecorder.tsx index 0e4033c9..e14da7c5 100644 --- a/openless-all/app/src/components/ShortcutRecorder.tsx +++ b/openless-all/app/src/components/ShortcutRecorder.tsx @@ -1,6 +1,6 @@ import { useEffect, useRef, useState, type CSSProperties, type KeyboardEvent } from 'react'; import { useTranslation } from 'react-i18next'; -import { formatComboLabel } from '../lib/hotkey'; +import { currentPlatform, formatComboLabel } from '../lib/hotkey'; import { setShortcutRecordingActive, validateShortcutBinding } from '../lib/ipc'; import type { ShortcutBinding } from '../lib/types'; @@ -154,7 +154,7 @@ export function ShortcutRecorder({ function modifiersFromKeyboardEvent(e: KeyboardEvent): string[] { const modifiers: string[] = []; - if (e.metaKey && e.key !== 'Meta') modifiers.push('cmd'); + if (e.metaKey && e.key !== 'Meta') modifiers.push(currentPlatform().isMac ? 'cmd' : 'super'); if (e.ctrlKey && e.key !== 'Control') modifiers.push('ctrl'); if (e.altKey && e.key !== 'Alt') modifiers.push('alt'); if (e.shiftKey && e.key !== 'Shift') modifiers.push('shift'); @@ -176,6 +176,8 @@ function modifierPrimaryFromCode(code: string, key: string): string { } function primaryFromKeyboardEvent(e: KeyboardEvent): string { + const printable = primaryFromPrintableCode(e.code); + if (printable) return printable; if (e.key.length === 1) return e.key; const codeToName: Record = { Space: 'Space', @@ -195,3 +197,23 @@ function primaryFromKeyboardEvent(e: KeyboardEvent): string { 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 = { + Backquote: '`', + Minus: '-', + Equal: '=', + BracketLeft: '[', + BracketRight: ']', + Backslash: '\\', + Semicolon: ';', + Quote: "'", + Comma: ',', + Period: '.', + Slash: '/', + IntlBackslash: '\\', + }; + return codeToPrimary[code] || ''; +} diff --git a/openless-all/app/src/lib/hotkey.ts b/openless-all/app/src/lib/hotkey.ts index 94533fb9..048febf2 100644 --- a/openless-all/app/src/lib/hotkey.ts +++ b/openless-all/app/src/lib/hotkey.ts @@ -1,6 +1,17 @@ import i18n from '../i18n'; import type { ComboBinding, HotkeyBinding, HotkeyTrigger, QaHotkeyBinding, ShortcutBinding } from './types'; +export function defaultQaShortcut(): ShortcutBinding { + return { + primary: ';', + modifiers: defaultAppShortcutModifiers(), + }; +} + +export function defaultAppShortcutModifiers(): string[] { + return currentPlatform().isMac ? ['cmd', 'shift'] : ['ctrl', 'shift']; +} + export function getHotkeyTriggerLabel(trigger: HotkeyTrigger | null | undefined): string { if (!trigger) return i18n.t('hotkey.fallback'); if (trigger === 'custom') return i18n.t('hotkey.triggers.custom'); @@ -72,9 +83,10 @@ export function formatComboLabel(binding: ComboBinding | QaHotkeyBinding | Short return parts.join(platform.isMac ? '' : '+'); } -function currentPlatform(): { isMac: boolean; isWindows: boolean } { - const platform = navigator.platform || ''; - const userAgent = navigator.userAgent || ''; +export function currentPlatform(): { isMac: boolean; isWindows: boolean } { + const nav = typeof navigator === 'undefined' ? null : navigator; + const platform = nav?.platform || ''; + const userAgent = nav?.userAgent || ''; return { isMac: platform.includes('Mac') || userAgent.includes('Mac'), isWindows: platform.includes('Win') || userAgent.includes('Windows'), @@ -110,7 +122,7 @@ function formatPrimary(primary: string): string { return trimmed.toUpperCase(); } // 常见命名键的 macOS 符号 - const isMac = navigator.platform.includes('Mac') || navigator.userAgent.includes('Mac'); + const isMac = currentPlatform().isMac; if (isMac) { switch (trimmed.toLowerCase()) { case 'space': return '\u2423'; diff --git a/openless-all/app/src/lib/ipc.ts b/openless-all/app/src/lib/ipc.ts index 15ce1000..769e96fb 100644 --- a/openless-all/app/src/lib/ipc.ts +++ b/openless-all/app/src/lib/ipc.ts @@ -19,6 +19,7 @@ import type { VocabPresetStore, } from './types'; import { OL_DATA } from './mockData'; +import { defaultAppShortcutModifiers, defaultQaShortcut, formatComboLabel } from './hotkey'; declare global { interface Window { @@ -54,12 +55,12 @@ const mockSettings: UserPreferences = { allowNonTsfInsertionFallback: true, workingLanguages: ['简体中文'], translationTargetLanguage: '', - qaHotkey: { primary: ';', modifiers: ['cmd', 'shift'] }, + qaHotkey: defaultQaShortcut(), qaSaveHistory: false, customComboHotkey: null, translationHotkey: { primary: 'Shift', modifiers: [] }, - switchStyleHotkey: { primary: 'S', modifiers: ['cmd', 'shift'] }, - openAppHotkey: { primary: 'O', modifiers: ['cmd', 'shift'] }, + switchStyleHotkey: { primary: 'S', modifiers: defaultAppShortcutModifiers() }, + openAppHotkey: { primary: 'O', modifiers: defaultAppShortcutModifiers() }, }; const mockHotkeyCapability: HotkeyCapability = { @@ -293,7 +294,7 @@ export function restartApp(): Promise { // 详见 issue #118。后端会发 `qa:state` / `qa:dismiss` 事件;前端通过下面四个 // 命令查询与控制 QA 浮窗。 export function getQaHotkeyLabel(): Promise { - return invokeOrMock('get_qa_hotkey_label', undefined, () => 'Cmd+Shift+;'); + return invokeOrMock('get_qa_hotkey_label', undefined, () => formatComboLabel(defaultQaShortcut())); } export function setQaHotkey(binding: QaHotkeyBinding | null): Promise { diff --git a/openless-all/app/src/pages/SelectionAsk.tsx b/openless-all/app/src/pages/SelectionAsk.tsx index 447096d9..1644e9ab 100644 --- a/openless-all/app/src/pages/SelectionAsk.tsx +++ b/openless-all/app/src/pages/SelectionAsk.tsx @@ -9,13 +9,14 @@ import { useTranslation } from 'react-i18next'; import { Card, PageHeader } from './_atoms'; import { useHotkeySettings } from '../state/HotkeySettingsContext'; import { setQaHotkey } from '../lib/ipc'; -import { formatComboLabel } from '../lib/hotkey'; +import { defaultQaShortcut, formatComboLabel } from '../lib/hotkey'; import { ShortcutRecorder } from '../components/ShortcutRecorder'; export function SelectionAsk() { const { t } = useTranslation(); const { prefs, updatePrefs: savePrefs } = useHotkeySettings(); - const defaultHotkeyLabel = 'Cmd+Shift+;'; + const defaultQaHotkey = defaultQaShortcut(); + const defaultHotkeyLabel = formatComboLabel(defaultQaHotkey); const recordHotkeyLabel = prefs ? formatComboLabel(prefs.dictationHotkey) : '快捷键'; if (!prefs) { @@ -79,8 +80,9 @@ export function SelectionAsk() {