add configurable shortcut bindings
This commit is contained in:
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(_))
|
||||
));
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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");
|
||||
}
|
||||
@@ -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"),
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -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]
|
||||
|
||||
@@ -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("同音 / 近形误识别时,优先按上述写法输出"));
|
||||
|
||||
@@ -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()],
|
||||
};
|
||||
|
||||
@@ -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)
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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)',
|
||||
|
||||
@@ -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: '(按住说话)',
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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');
|
||||
|
||||
@@ -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。
|
||||
|
||||
@@ -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 => (
|
||||
|
||||
@@ -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 => (
|
||||
|
||||
@@ -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. 历史保存 */}
|
||||
|
||||
@@ -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 () => {
|
||||
|
||||
@@ -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>
|
||||
|
||||
Reference in new issue
Block a user