add configurable shortcut bindings

This commit is contained in:
Leslie Leung committed 2026-05-04 15:41:23 +08:00
1 parent 280b50ea17
commit 8065895ac5
25 files changed
+2601 -436

No files matched your search

@@ -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<Inner>,
}
struct Inner {
registered: Mutex<Option<RegisteredHotkey>>,
tx: Sender<ComboHotkeyEvent>,
}
// 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<ComboHotkeyEvent>,
) -> Result<Self, ComboHotkeyError> {
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<GlobalHotKeyEvent>, tx: Sender<ComboHotkeyEvent>) {
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<global_hotkey::hotkey::HotKey, ComboHotkeyError> {
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(_))
));
}
}
+172 -6
View File
@@ -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<Coordinator>>;
@@ -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<Coordinator> {
@@ -57,12 +77,32 @@ impl SettingsWriter for Arc<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();
}
}
fn persist_settings<T: SettingsWriter>(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<ShortcutBinding>,
) -> 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<Option<UserPreferences>>,
dictation_refreshes: Mutex<u32>,
qa_refreshes: Mutex<u32>,
combo_refreshes: Mutex<u32>,
}
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);
}
}
+745 -13
View File
@@ -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<Option<HotkeyMonitor>>,
hotkey_status: Mutex<HotkeyStatus>,
hotkey_trigger_held: AtomicBool,
shortcut_recording_active: AtomicBool,
/// 自定义组合键监听器(global-hotkey crate)。当 `prefs.hotkey.trigger == Custom` 时
/// 代替 modifier-only 的 hotkey monitor。`None` 表示不使用自定义组合键或还没成功安装。
combo_hotkey: Mutex<Option<ComboHotkeyMonitor>>,
translation_hotkey: Mutex<Option<ComboHotkeyMonitor>>,
switch_style_hotkey: Mutex<Option<ComboHotkeyMonitor>>,
open_app_hotkey: Mutex<Option<ComboHotkeyMonitor>>,
/// 翻译模式触发标志。每次 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::<ComboHotkeyEvent>();
match ComboHotkeyMonitor::start(binding_for_main, tx) {
Ok(monitor) => {
*inner_clone.combo_hotkey.lock() = Some(monitor);
log::info!(
"[coord] combo hotkey listener installed on main thread (via update)"
);
let bridge_inner = Arc::clone(&inner_clone);
std::thread::Builder::new()
.name("openless-combo-hotkey-bridge".into())
.spawn(move || combo_hotkey_bridge_loop(bridge_inner, rx))
.ok();
}
Err(e) => {
log::warn!("[coord] update combo hotkey binding 失败: {e}");
}
}
});
}
/// 用户在设置里改了 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::<ComboHotkeyEvent>();
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::<ComboHotkeyEvent>();
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(self.inner.prefs.get().hotkey);
monitor.update_binding(binding);
return;
}
let (tx, rx) = mpsc::channel::<HotkeyEvent>();
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() {
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<Inner>) {
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<Inner>) {
last_error: None,
};
let (tx, rx) = mpsc::channel::<HotkeyEvent>();
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<Inner>) {
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<Inner>) {
fn qa_hotkey_bridge_loop(inner: Arc<Inner>, rx: mpsc::Receiver<QaHotkeyEvent>) {
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<Inner>, rx: mpsc::Receiver<QaHotkeyEvent>) {
}
}
// ─────────────────────────── combo hotkey supervisor ───────────────────────────
fn combo_hotkey_supervisor_loop(inner: Arc<Inner>) {
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::<ComboHotkeyEvent>();
let (init_tx, init_rx) =
mpsc::sync_channel::<Result<ComboHotkeyMonitor, ComboHotkeyError>>(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<Inner>, rx: mpsc::Receiver<ComboHotkeyEvent>) {
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<Inner>) {
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::<ComboHotkeyEvent>();
let (init_tx, init_rx) =
mpsc::sync_channel::<Result<ComboHotkeyMonitor, ComboHotkeyError>>(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<Inner>, rx: mpsc::Receiver<ComboHotkeyEvent>) {
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<Inner>, 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::<ComboHotkeyEvent>();
let (init_tx, init_rx) =
mpsc::sync_channel::<Result<ComboHotkeyMonitor, ComboHotkeyError>>(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<Inner>,
rx: mpsc::Receiver<ComboHotkeyEvent>,
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<Inner>, 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<Inner>) {
let mut prefs = inner.prefs.get();
let order = [
PolishMode::Raw,
PolishMode::Light,
PolishMode::Structured,
PolishMode::Formal,
];
let enabled: Vec<PolishMode> = 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<Inner>) {
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<Inner>) {
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<Inner>, 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<Inner>,
kind: ActionHotkeyKind,
) -> &Mutex<Option<ComboHotkeyMonitor>> {
match kind {
ActionHotkeyKind::SwitchStyle => &inner.switch_style_hotkey,
ActionHotkeyKind::OpenApp => &inner.open_app_hotkey,
}
}
fn action_hotkey_binding(
inner: &Arc<Inner>,
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<Inner>,
) -> (
Option<crate::types::HotkeyTrigger>,
Option<crate::types::HotkeyTrigger>,
) {
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<Inner>) {
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<Inner>) {
// QA hotkey(默认 Cmd+Shift+;)现在只 toggle 浮窗可见性。
// 浮窗内的录音 / 提问由 Option 边沿驱动(handle_pressed_edge → handle_qa_option_edge)。
@@ -631,6 +1345,9 @@ fn close_qa_panel(inner: &Arc<Inner>) {
fn hotkey_bridge_loop(inner: Arc<Inner>, rx: mpsc::Receiver<HotkeyEvent>) {
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<Inner>, rx: mpsc::Receiver<HotkeyEvent>) {
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?)
}
@@ -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<Arc<GlobalHotkeyRuntime>> = OnceCell::new();
pub struct GlobalHotkeyRuntime {
manager: GlobalHotKeyManager,
routes: Mutex<HashMap<u32, Sender<GlobalHotKeyEvent>>>,
}
// 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<GlobalHotkeyRuntime>,
hotkey: HotKey,
}
impl GlobalHotkeyRuntime {
pub fn shared() -> Result<Arc<Self>, 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<Self>,
hotkey: HotKey,
) -> Result<(RegisteredHotkey, Receiver<GlobalHotKeyEvent>), 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<GlobalHotkeyRuntime>) {
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");
}
+253 -17
View File
@@ -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<HotkeyTrigger>,
translation_trigger: Option<HotkeyTrigger>,
);
fn shutdown(&self) {}
}
@@ -39,6 +46,10 @@ struct Shared {
binding: RwLock<HotkeyBinding>,
/// 触发键当前是否处于"按住"状态。OS 自动重复事件用此去重。
trigger_held: AtomicBool,
qa_trigger: RwLock<Option<HotkeyTrigger>>,
qa_trigger_held: AtomicBool,
translation_trigger: RwLock<Option<HotkeyTrigger>>,
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<HotkeyTrigger>,
translation_trigger: Option<HotkeyTrigger>,
) {
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<HotkeyTrigger>,
translation_trigger: Option<HotkeyTrigger>,
) {
*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<HotkeyTrigger>,
translation_trigger: Option<HotkeyTrigger>,
) {
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<HotkeyTrigger>,
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<HotkeyTrigger>,
translation_trigger: Option<HotkeyTrigger>,
) {
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<HotkeyTrigger>,
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<HotkeyEvent>,
) -> Result<Box<dyn HotkeyAdapter>, 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<HotkeyTrigger>,
translation_trigger: Option<HotkeyTrigger>,
) {
update_shared_modifier_shortcuts(&self.shared, qa_trigger, translation_trigger);
}
}
fn run_listen_loop(shared: Arc<Shared>, tx: Sender<HotkeyEvent>, 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<HotkeyEvent>,
key: Key,
trigger: Option<HotkeyTrigger>,
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<HotkeyTrigger>,
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"),
}
}
}
+1 -6
View File
@@ -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)
});
}
+48 -11
View File
@@ -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::<Arc<coordinator::Coordinator>>();
// 同步启动 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::<Arc<coordinator::Coordinator>>();
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<R: tauri::Runtime>(window: &tauri::WebviewWindow<R>) -> ta
/// fallback 仍能从原 app 拿到选区)。
pub(crate) fn show_qa_window<R: tauri::Runtime>(app: &AppHandle<R>, 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]
+11 -6
View File
@@ -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("同音 / 近形误识别时,优先按上述写法输出"));
+47 -199
View File
@@ -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<Option<HotKey>>,
/// 事件转发线程接收 global-hotkey crate 的全局 channel,再过滤 id 后转发到 tx。
forward_alive: Arc<std::sync::atomic::AtomicBool>,
/// 当前关心的 hotkey id(filter 用)。
active_id: Arc<std::sync::atomic::AtomicU32>,
registered: Mutex<Option<RegisteredHotkey>>,
tx: Sender<QaHotkeyEvent>,
}
// 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<QaHotkeyEvent>,
) -> Result<Self, QaHotkeyError> {
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<std::sync::atomic::AtomicBool>,
active_id: Arc<std::sync::atomic::AtomicU32>,
tx: Sender<QaHotkeyEvent>,
) {
// 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<GlobalHotKeyEvent>, tx: Sender<QaHotkeyEvent>) {
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<HotKey, QaHotkeyError> {
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<Code, QaHotkeyError> {
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<Code> {
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<global_hotkey::hotkey::HotKey, QaHotkeyError> {
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()],
};
+4 -3
View File
@@ -330,7 +330,8 @@ mod macos_ax {
unsafe fn cfstring_from_static(bytes_with_nul: &[u8]) -> Option<CFStringRef> {
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> {
@@ -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<HotKey, ShortcutBindingError> {
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<HotkeyTrigger> {
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::<String>()
.to_ascii_lowercase()
}
fn parse_primary(raw: &str) -> Result<Code, ShortcutBindingError> {
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<Code> {
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)
);
}
}
+221 -7
View File
@@ -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<PolishMode>,
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<QaHotkeyBinding>,
pub qa_hotkey: Option<ShortcutBinding>,
/// 是否把每次 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<ComboBinding>,
#[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,
}
fn default_qa_hotkey() -> Option<QaHotkeyBinding> {
Some(QaHotkeyBinding::default())
#[derive(Debug, Clone, Deserialize)]
#[serde(default, rename_all = "camelCase")]
struct UserPreferencesWire {
hotkey: HotkeyBinding,
dictation_hotkey: Option<ShortcutBinding>,
default_mode: PolishMode,
enabled_modes: Vec<PolishMode>,
launch_at_login: bool,
show_capsule: bool,
active_asr_provider: String,
active_llm_provider: String,
restore_clipboard_after_paste: bool,
working_languages: Vec<String>,
translation_target_language: String,
qa_hotkey: Option<ShortcutBinding>,
qa_save_history: bool,
custom_combo_hotkey: Option<ComboBinding>,
translation_hotkey: Option<ShortcutBinding>,
switch_style_hotkey: Option<ShortcutBinding>,
open_app_hotkey: Option<ShortcutBinding>,
}
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<D>(deserializer: D) -> Result<Self, D::Error>
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<ShortcutBinding> {
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<String> {
#[cfg(target_os = "macos")]
{
vec!["cmd".into(), "shift".into()]
}
#[cfg(not(target_os = "macos"))]
{
vec!["ctrl".into(), "shift".into()]
}
}
fn default_dictation_hotkey_from_legacy(
hotkey: &HotkeyBinding,
custom_combo_hotkey: &Option<ComboBinding>,
) -> 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<String> {
@@ -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,10 +278,52 @@ 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<String>,
}
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<String> = 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("+")
}
}
/// 划词语音问答的全局快捷键绑定。原生名字符串:
/// - `primary`:主键(如 `";"`、`"."`、`"A"`、`"F1"`)。
/// - `modifiers`:修饰键集合,元素来自 `{"cmd","ctrl","alt","shift","super"}`。
@@ -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<String>,
}
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<QaChatMessage>,
/// 整段送给 LLM 维持上下文。详见 issue #118 v2。
#[derive(Debug, Clone, Serialize, Deserialize)]
@@ -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)}</kbd>
}}>{prefs ? formatComboLabel(prefs.dictationHotkey) : ''}</kbd>
<span style={{ color: 'var(--ol-ink-4)' }}>{t('shell.shortcutHint')}</span>
</div>
</div>
@@ -25,7 +25,7 @@ export function Onboarding({ onComplete }: OnboardingProps) {
const [accessibility, setAccessibility] = useState<PermissionStatus>('notDetermined');
const [microphone, setMicrophone] = useState<PermissionStatus>('notDetermined');
const [busy, setBusy] = useState(false);
const refreshTimeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null);
const refreshTimeoutRef = useRef<number | null>(null);
const { capability } = useHotkeySettings();
const refresh = async () => {
@@ -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<void>;
alignRecordButton?: boolean;
}) {
const { t } = useTranslation();
const [recording, setRecording] = useState(false);
const [error, setError] = useState<string | null>(null);
const pendingModifier = useRef<ShortcutBinding | null>(null);
const pendingTimer = useRef<number | null>(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<HTMLDivElement>) => {
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<HTMLDivElement>) => {
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 (
<div style={rootStyle}>
<div style={recorderRowStyle}>
<span style={{ padding: '4px 10px', borderRadius: 6, background: 'rgba(0,0,0,0.06)', fontSize: 13, fontFamily: 'var(--ol-font-mono)', fontWeight: 500, color: 'var(--ol-ink)' }}>
{formatComboLabel(value)}
</span>
<button
onClick={() => {
setRecording(true);
setError(null);
clearPendingModifier();
}}
disabled={recording}
style={recordButtonStyle}
>
{recording ? t('settings.recording.comboRecordHint') : t('settings.recording.comboRecordBtn')}
</button>
</div>
{recording && (
<div
tabIndex={-1}
onKeyDown={onKeyDown}
onKeyUp={onKeyUp}
style={{ padding: '8px 12px', borderRadius: 8, background: 'rgba(37,99,235,0.06)', border: '1px solid rgba(37,99,235,0.2)', fontSize: 12, color: 'var(--ol-blue)', outline: 'none' }}
ref={el => el?.focus()}
>
{t('settings.recording.comboRecordHint')}
<div style={{ fontSize: 11, color: 'var(--ol-ink-4)', marginTop: 4 }}>Esc 取消</div>
</div>
)}
{error && <div style={{ fontSize: 11, color: 'var(--ol-red, #ef4444)' }}>{error}</div>}
</div>
);
}
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<string, string> = {
Space: 'Space',
Enter: 'Enter',
Tab: 'Tab',
Backspace: 'Backspace',
Delete: 'Delete',
ArrowUp: 'ArrowUp',
ArrowDown: 'ArrowDown',
ArrowLeft: 'ArrowLeft',
ArrowRight: 'ArrowRight',
Home: 'Home',
End: 'End',
PageUp: 'PageUp',
PageDown: 'PageDown',
};
if (/^F\d{1,2}$/.test(e.key)) return e.key;
return codeToName[e.code] || e.key;
}
+9 -1
View File
@@ -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)',
+9 -1
View File
@@ -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: '(按住说话)',
+117 -3
View File
@@ -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;
}
+42 -2
View File
@@ -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<T>(
// ── 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<string> {
return invokeOrMock('get_qa_hotkey_label', undefined, () => 'Cmd+Shift+;');
}
export function setQaHotkey(binding: QaHotkeyBinding): Promise<void> {
export function setQaHotkey(binding: QaHotkeyBinding | null): Promise<void> {
return invokeOrMock('set_qa_hotkey', { binding }, () => undefined);
}
@@ -274,6 +281,39 @@ export function qaWindowPin(pinned: boolean): Promise<void> {
return invokeOrMock('qa_window_pin', { pinned }, () => undefined);
}
// ── Combo Hotkey (自定义录音组合键) ───────────────────────────────────
export function validateComboHotkey(binding: ComboBinding): Promise<void> {
return invokeOrMock('validate_combo_hotkey', { binding }, () => undefined);
}
export function setComboHotkey(binding: ComboBinding): Promise<void> {
return invokeOrMock('set_combo_hotkey', { binding }, () => undefined);
}
export function validateShortcutBinding(binding: ShortcutBinding): Promise<void> {
return invokeOrMock('validate_shortcut_binding', { binding }, () => undefined);
}
export function setDictationHotkey(binding: ShortcutBinding): Promise<void> {
return invokeOrMock('set_dictation_hotkey', { binding }, () => undefined);
}
export function setTranslationHotkey(binding: ShortcutBinding): Promise<void> {
return invokeOrMock('set_translation_hotkey', { binding }, () => undefined);
}
export function setSwitchStyleHotkey(binding: ShortcutBinding): Promise<void> {
return invokeOrMock('set_switch_style_hotkey', { binding }, () => undefined);
}
export function setOpenAppHotkey(binding: ShortcutBinding): Promise<void> {
return invokeOrMock('set_open_app_hotkey', { binding }, () => undefined);
}
export function setShortcutRecordingActive(active: boolean): Promise<void> {
return invokeOrMock('set_shortcut_recording_active', { active }, () => undefined);
}
export async function openExternal(url: string): Promise<void> {
if (!isTauri) {
window.open(url, '_blank', 'noopener,noreferrer');
+20 -5
View File
@@ -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。
+3 -3
View File
@@ -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<DictationSession[]>([]);
const [selectedId, setSelectedId] = useState<string | null>(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 && <div style={{ padding: 16, fontSize: 12, color: 'var(--ol-ink-4)' }}>{t('common.loading')}</div>}
{!loading && filtered.length === 0 && (
<div style={{ padding: 16, fontSize: 12, color: 'var(--ol-ink-4)' }}>
{t('history.empty', { trigger: getHotkeyTriggerLabel(hotkey?.trigger) })}
{t('history.empty', { trigger: prefs ? formatComboLabel(prefs.dictationHotkey) : '' })}
</div>
)}
{filtered.map(s => (
+4 -4
View File
@@ -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)}</kbd>
}}>{prefs ? formatComboLabel(prefs.dictationHotkey) : ''}</kbd>
{t('overview.pressSuffix')}
</div>
}
@@ -137,7 +137,7 @@ export function Overview({ onOpenHistory }: OverviewProps) {
<div>
{history.length === 0 && (
<div style={{ padding: 24, textAlign: 'center', fontSize: 12, color: 'var(--ol-ink-4)' }}>
{t('overview.recentEmpty', { trigger: getHotkeyTriggerLabel(hotkey?.trigger) })}
{t('overview.recentEmpty', { trigger: prefs ? formatComboLabel(prefs.dictationHotkey) : '' })}
</div>
)}
{history.slice(0, 5).map(s => (
+22 -113
View File
@@ -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() {
<div style={{ fontSize: 11.5, color: 'var(--ol-ink-4)', marginBottom: 12, lineHeight: 1.55 }}>
{t('selectionAsk.hotkey.desc', { recordHotkey: recordHotkeyLabel })}
</div>
<select
value={currentId}
onChange={e => onHotkeyChange(e.target.value)}
style={{
width: '100%',
maxWidth: 360,
height: 32,
padding: '0 10px',
fontSize: 13,
border: '0.5px solid var(--ol-line-strong)',
borderRadius: 8,
background: '#fff',
color: 'var(--ol-ink)',
fontFamily: 'inherit',
cursor: 'default',
<button
onClick={async () => {
await setQaHotkey(enabled ? null : { primary: ';', modifiers: ['cmd', 'shift'] });
await savePrefs({ ...prefs, qaHotkey: enabled ? null : { primary: ';', modifiers: ['cmd', 'shift'] } });
}}
style={{ fontSize: 12, padding: '5px 14px', background: enabled ? 'rgba(0,0,0,0.06)' : 'var(--ol-blue)', color: enabled ? 'var(--ol-ink-2)' : '#fff', border: 0, borderRadius: 6, fontFamily: 'inherit', fontWeight: 500, cursor: 'default', marginBottom: 10 }}
>
<option value={QA_HOTKEY_DISABLED_ID}>{t('selectionAsk.hotkey.optionDisabled')}</option>
{qaHotkeyPresets.map(p => (
<option key={p.id} value={p.id}>{p.label}</option>
))}
</select>
{enabled ? t('selectionAsk.hotkey.optionDisabled') : t('selectionAsk.statusEnabled')}
</button>
{prefs.qaHotkey && (
<ShortcutRecorder
value={prefs.qaHotkey}
onSave={async binding => {
await setQaHotkey(binding);
await savePrefs({ ...prefs, qaHotkey: binding });
}}
/>
)}
</Card>
{/* 2. 历史保存 */}
+96 -28
View File
@@ -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() {
</div>
)}
<SettingRow label={t('settings.recording.hotkeyLabel')} desc={hotkeyDesc}>
<select
value={prefs.hotkey.trigger}
onChange={e => onTriggerChange(e.target.value as HotkeyTrigger)}
style={{
...inputStyle,
maxWidth: 200,
fontFamily: 'var(--ol-font-mono)',
<ShortcutRecorder
value={prefs.dictationHotkey}
onSave={async binding => {
await setDictationHotkey(binding);
await savePrefs({ ...prefs, dictationHotkey: binding });
}}
>
{capability.availableTriggers.map(tr => (
<option key={tr} value={tr}>{getHotkeyTriggerLabel(tr)}</option>
))}
</select>
/>
</SettingRow>
<SettingRow label={t('settings.recording.modeLabel')} desc={t('settings.recording.modeDesc')}>
<div style={{ display: 'inline-flex', padding: 2, borderRadius: 8, background: 'rgba(0,0,0,0.05)' }}>
@@ -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<CredentialFieldStatus>('idle');
const debounceRef = useRef<ReturnType<typeof setTimeout> | null>(null);
const statusRef = useRef<ReturnType<typeof setTimeout> | null>(null);
const debounceRef = useRef<number | null>(null);
const statusRef = useRef<number | null>(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 (
<Card>
<div style={{ fontSize: 12, color: 'var(--ol-ink-4)' }}>{t('common.loading')}</div>
@@ -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 (
<Card>
<div style={{ fontSize: 13, fontWeight: 600, marginBottom: 4 }}>{t('settings.shortcuts.title')}</div>
<div style={{ fontSize: 11.5, color: 'var(--ol-ink-4)', marginBottom: 6 }}>{desc}</div>
{rows.map(([k, v]) => (
<SettingRow label={t('settings.shortcuts.startStop')}>
<div style={{ display: 'flex', flexDirection: 'column', gap: 6, width: '100%' }}>
<ShortcutRecorder
value={prefs.dictationHotkey}
alignRecordButton
onSave={async binding => {
await setDictationHotkey(binding);
await savePrefs({ ...prefs, dictationHotkey: binding });
}}
/>
<div style={{ fontSize: 11, color: 'var(--ol-ink-4)' }}>
{hotkey.mode === 'hold' ? t('hotkey.modeHoldSuffix') : t('hotkey.modeToggleSuffix')}
</div>
</div>
</SettingRow>
<SettingRow label={t('translation.hotkey.title', 'Translation shortcut')}>
<ShortcutRecorder
value={prefs.translationHotkey}
alignRecordButton
onSave={async binding => {
await setTranslationHotkey(binding);
await savePrefs({ ...prefs, translationHotkey: binding });
}}
/>
</SettingRow>
<SettingRow label={t('selectionAsk.hotkey.title')}>
{prefs.qaHotkey ? (
<ShortcutRecorder
value={prefs.qaHotkey}
alignRecordButton
onSave={async binding => {
await setQaHotkey(binding);
await savePrefs({ ...prefs, qaHotkey: binding });
}}
/>
) : (
<button
onClick={async () => {
const binding = defaultQaShortcut();
await setQaHotkey(binding);
await savePrefs({ ...prefs, qaHotkey: binding });
}}
style={{ fontSize: 12, padding: '5px 14px', background: 'var(--ol-blue)', color: '#fff', border: 0, borderRadius: 6, fontFamily: 'inherit', fontWeight: 500, cursor: 'default' }}
>
{t('selectionAsk.hotkey.enable', 'Enable')}
</button>
)}
</SettingRow>
<SettingRow label={t('settings.shortcuts.switchStyle')}>
<ShortcutRecorder
value={prefs.switchStyleHotkey}
alignRecordButton
onSave={async binding => {
await setSwitchStyleHotkey(binding);
await savePrefs({ ...prefs, switchStyleHotkey: binding });
}}
/>
</SettingRow>
<SettingRow label={t('settings.shortcuts.openApp')}>
<ShortcutRecorder
value={prefs.openAppHotkey}
alignRecordButton
onSave={async binding => {
await setOpenAppHotkey(binding);
await savePrefs({ ...prefs, openAppHotkey: binding });
}}
/>
</SettingRow>
{readonlyRows.map(([k, v]) => (
<SettingRow key={k} label={k}>
<kbd style={{
display: 'inline-flex', alignItems: 'center', gap: 4,
@@ -753,6 +814,13 @@ function ShortcutsSection() {
);
}
function defaultQaShortcut(): ShortcutBinding {
const isMac = navigator.platform.includes('Mac') || navigator.userAgent.includes('Mac');
return isMac
? { primary: ';', modifiers: ['cmd', 'shift'] }
: { primary: ';', modifiers: ['ctrl', 'shift'] };
}
function PermissionsSection() {
const { t } = useTranslation();
const [accessibility, setAccessibility] = useState<PermissionStatus | 'loading'>('loading');
@@ -913,7 +981,7 @@ function LanguageSection() {
function AboutSection() {
const { t } = useTranslation();
const [qqCopied, setQqCopied] = useState(false);
const qqCopiedRef = useRef<ReturnType<typeof setTimeout> | null>(null);
const qqCopiedRef = useRef<number | null>(null);
useEffect(() => {
return () => {
+21 -4
View File
@@ -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() {
</select>
</Card>
<Card>
<div style={{ fontSize: 13, fontWeight: 600, marginBottom: 4 }}>{t('translation.hotkey.title', 'Translation shortcut')}</div>
<div style={{ fontSize: 11.5, color: 'var(--ol-ink-4)', marginBottom: 12, lineHeight: 1.55 }}>
{t('translation.hotkey.desc', 'Press this during recording to switch the current dictation into translation mode.')}
</div>
<ShortcutRecorder
value={prefs.translationHotkey}
onSave={async binding => {
await setTranslationHotkey(binding);
await savePrefs({ ...prefs, translationHotkey: binding });
}}
/>
</Card>
{/* 3. 使用方法 */}
<Card>
<div style={{ fontSize: 13, fontWeight: 600, marginBottom: 10 }}>{t('translation.howto.title')}</div>
<ol style={{ margin: 0, paddingLeft: 18, fontSize: 12.5, color: 'var(--ol-ink-2)', lineHeight: 1.7 }}>
<li>{t('translation.howto.step1', { trigger: triggerLabel })}</li>
<li>{t('translation.howto.step2')}</li>
<li>{t('translation.howto.step3')}</li>
<li>{t('translation.howto.step3', { shortcut: translationHotkeyLabel })}</li>
<li>{t('translation.howto.step4')}</li>
<li>{t('translation.howto.step5')}</li>
</ol>