Merge pull request #236 from LeslieLeung/feature/key-binding

feat: add configurable shortcut bindings
This commit is contained in:
TRIP authored and GitHub committed 2026-05-07 09:24:33 +08:00
commit 98da5970bf
31 files changed
+3566 -474

No files matched your search

@@ -67,7 +67,10 @@ impl LocalAsrCache {
slot.take();
}
}
log::info!("[local-asr cache] loading {model_id} from {}", model_dir.display());
log::info!(
"[local-asr cache] loading {model_id} from {}",
model_dir.display()
);
let engine = Arc::new(QwenAsrEngine::load(model_dir)?);
let mut slot = self.inner.lock();
*slot = Some(CachedEngine {
@@ -124,7 +127,10 @@ impl LocalAsrCache {
{
let taken = self.inner.lock().take();
if let Some(cached) = taken {
log::info!("[local-asr cache] release engine {} on demand", cached.model_id);
log::info!(
"[local-asr cache] release engine {} on demand",
cached.model_id
);
drop(cached);
pressure_relief_macos();
}
@@ -134,13 +134,20 @@ fn keep_file(path: &str) -> bool {
return false;
}
let lower = path.to_ascii_lowercase();
if lower.ends_with(".md") || lower.ends_with(".png") || lower.ends_with(".jpg")
|| lower.ends_with(".jpeg") || lower.ends_with(".gif") || lower.ends_with(".svg")
if lower.ends_with(".md")
|| lower.ends_with(".png")
|| lower.ends_with(".jpg")
|| lower.ends_with(".jpeg")
|| lower.ends_with(".gif")
|| lower.ends_with(".svg")
{
return false;
}
let ext = lower.rsplit('.').next().unwrap_or("");
matches!(ext, "json" | "safetensors" | "txt" | "bin" | "model" | "tiktoken")
matches!(
ext,
"json" | "safetensors" | "txt" | "bin" | "model" | "tiktoken"
)
}
#[derive(Debug, Clone, Serialize)]
@@ -205,7 +212,10 @@ impl DownloadManager {
flag.store(true, Ordering::SeqCst);
log::info!("[local-asr] cancel requested for {}", model_id.as_str());
} else {
log::info!("[local-asr] cancel requested for {} but no active download", model_id.as_str());
log::info!(
"[local-asr] cancel requested for {} but no active download",
model_id.as_str()
);
}
}
@@ -289,9 +299,8 @@ async fn run_download(
}
}
let in_flight_bytes: Arc<Vec<AtomicU64>> = Arc::new(
info.files.iter().map(|_| AtomicU64::new(0)).collect()
);
let in_flight_bytes: Arc<Vec<AtomicU64>> =
Arc::new(info.files.iter().map(|_| AtomicU64::new(0)).collect());
let already_done_bytes: u64 = info
.files
.iter()
@@ -480,7 +489,13 @@ async fn download_one(
// 远端文件 ≤ 一个 chunk 大小:直接单 chunk,不走 sparse + idx
if total_size <= CHUNK_SIZE {
let result = chunk_with_retry(
client, url, &partial, 0, total_size - 1, &cancel, &on_progress,
client,
url,
&partial,
0,
total_size - 1,
&cancel,
&on_progress,
)
.await;
if cancel.load(Ordering::SeqCst) {
@@ -499,7 +514,8 @@ async fn download_one(
let done_set = read_idx(&idx_path);
// 3. 预先把 .partial 撑到最终大小(sparse 文件,holes = 零字节)
if !partial.exists() || std::fs::metadata(&partial).map(|m| m.len()).unwrap_or(0) != total_size {
if !partial.exists() || std::fs::metadata(&partial).map(|m| m.len()).unwrap_or(0) != total_size
{
let f = std::fs::OpenOptions::new()
.write(true)
.create(true)
@@ -630,7 +646,10 @@ fn read_idx(path: &Path) -> HashSet<usize> {
fn append_idx(path: &Path, idx: usize) -> std::io::Result<()> {
use std::io::Write;
let mut f = std::fs::OpenOptions::new().create(true).append(true).open(path)?;
let mut f = std::fs::OpenOptions::new()
.create(true)
.append(true)
.open(path)?;
writeln!(f, "{idx}")
}
@@ -657,7 +676,17 @@ async fn chunk_with_retry(
if cancel.load(Ordering::SeqCst) {
anyhow::bail!("cancelled");
}
match try_download_range_append(client, url, partial, range_start, range_end, cancel, on_progress).await {
match try_download_range_append(
client,
url,
partial,
range_start,
range_end,
cancel,
on_progress,
)
.await
{
Ok(()) => return Ok(()),
Err(e) => {
let msg = format!("{e:#}");
@@ -673,7 +702,8 @@ async fn chunk_with_retry(
}
}
}
Err(last_err.unwrap_or_else(|| anyhow::anyhow!("chunk failed after {PER_CHUNK_ATTEMPTS} attempts")))
Err(last_err
.unwrap_or_else(|| anyhow::anyhow!("chunk failed after {PER_CHUNK_ATTEMPTS} attempts")))
}
async fn try_download_range_append(
@@ -687,12 +717,19 @@ async fn try_download_range_append(
) -> Result<()> {
let mut req = client.get(url);
req = req.header("Range", format!("bytes={range_start}-{range_end}"));
let resp = req.send().await.with_context(|| format!("HTTP GET {url} failed"))?;
let resp = req
.send()
.await
.with_context(|| format!("HTTP GET {url} failed"))?;
let status = resp.status();
if status.as_u16() != 200 && status.as_u16() != 206 {
anyhow::bail!("HTTP {status} for {url}");
}
let effective_start = if status.as_u16() == 200 { 0 } else { range_start };
let effective_start = if status.as_u16() == 200 {
0
} else {
range_start
};
// 截断 partial 到本次 attempt 的起点,再 seek 写入。
// 老 append 实现的 bug:若上一次 attempt 已写了部分字节后失败,retry 拿到的还是
@@ -744,7 +781,18 @@ async fn chunk_with_retry_seek(
if cancel.load(Ordering::SeqCst) {
anyhow::bail!("cancelled");
}
match try_download_range_seek(client, url, partial, range_start, range_end, cancel, bytes_in_file, on_progress).await {
match try_download_range_seek(
client,
url,
partial,
range_start,
range_end,
cancel,
bytes_in_file,
on_progress,
)
.await
{
Ok(()) => return Ok(()),
Err(e) => {
let msg = format!("{e:#}");
@@ -760,7 +808,11 @@ async fn chunk_with_retry_seek(
}
}
}
Err(last_err.unwrap_or_else(|| anyhow::anyhow!("chunk [{range_start}-{range_end}] failed after {PER_CHUNK_ATTEMPTS} attempts")))
Err(last_err.unwrap_or_else(|| {
anyhow::anyhow!(
"chunk [{range_start}-{range_end}] failed after {PER_CHUNK_ATTEMPTS} attempts"
)
}))
}
async fn try_download_range_seek(
@@ -806,7 +858,8 @@ async fn try_download_range_seek(
}
let bytes = chunk.context("read stream chunk failed")?;
file.write_all(&bytes).await.context("write chunk failed")?;
let new_total = bytes_in_file.fetch_add(bytes.len() as u64, Ordering::Relaxed) + bytes.len() as u64;
let new_total =
bytes_in_file.fetch_add(bytes.len() as u64, Ordering::Relaxed) + bytes.len() as u64;
on_progress(new_total);
}
file.flush().await.ok();
@@ -68,10 +68,11 @@ impl LocalQwenAsr {
// 走 Tauri 持有的 runtime handle,不依赖调用方上下文(虽然这里目前都在 async 路径上调,
// 但保持一致更稳)。
let engine = Arc::clone(&self.engine);
let text = tauri::async_runtime::spawn_blocking(move || engine.transcribe_stream(&samples_f32))
.await
.context("transcribe spawn_blocking join 失败")?
.context("qwen_transcribe_stream 失败")?;
let text =
tauri::async_runtime::spawn_blocking(move || engine.transcribe_stream(&samples_f32))
.await
.context("transcribe spawn_blocking join 失败")?
.context("qwen_transcribe_stream 失败")?;
// 解绑回调,避免 idle 期 C 端任何后续触发。
self.engine.set_token_handler::<fn(&str)>(None);
@@ -16,10 +16,10 @@ mod qwen_engine;
#[cfg(target_os = "macos")]
mod qwen_ffi;
#[cfg(target_os = "macos")]
pub use qwen_engine::QwenAsrEngine;
#[cfg(target_os = "macos")]
pub use local_provider::LocalQwenAsr;
#[cfg(target_os = "macos")]
pub use qwen_engine::QwenAsrEngine;
pub use download::{DownloadManager, Mirror};
pub use models::{ModelId, ModelStatus};
@@ -79,9 +79,8 @@ impl QwenAsrEngine {
/// 批式转写:一次性给完整音频(mono f32 16kHz)。
pub fn transcribe_audio(&self, samples: &[f32]) -> Result<String> {
// SAFETY: samples 在调用期间存活;返回是 C `malloc` 出的字符串。
let raw = unsafe {
qwen_transcribe_audio(self.ctx, samples.as_ptr(), samples.len() as i32)
};
let raw =
unsafe { qwen_transcribe_audio(self.ctx, samples.as_ptr(), samples.len() as i32) };
if raw.is_null() {
anyhow::bail!("qwen_transcribe_audio 返回 NULL");
}
@@ -95,9 +94,8 @@ impl QwenAsrEngine {
/// 流式转写:内部按 2s chunk 切片,token 通过 `set_token_handler` 注册的
/// 回调实时吐出;返回值是最终完整文本。
pub fn transcribe_stream(&self, samples: &[f32]) -> Result<String> {
let raw = unsafe {
qwen_transcribe_stream(self.ctx, samples.as_ptr(), samples.len() as i32)
};
let raw =
unsafe { qwen_transcribe_stream(self.ctx, samples.as_ptr(), samples.len() as i32) };
if raw.is_null() {
anyhow::bail!("qwen_transcribe_stream 返回 NULL");
}
@@ -50,21 +50,18 @@ pub async fn run_test(model_id: ModelId) -> Result<TestResult> {
// qwen_load 是同步阻塞调用且较慢(数秒);扔到 spawn_blocking 不阻塞 tokio runtime。
let load_start = Instant::now();
let dir_for_blocking = dir.clone();
let engine = tauri::async_runtime::spawn_blocking(move || {
load_engine(&dir_for_blocking)
})
.await
.map_err(|e| anyhow::anyhow!("spawn_blocking join failed: {e:#}"))??;
let engine = tauri::async_runtime::spawn_blocking(move || load_engine(&dir_for_blocking))
.await
.map_err(|e| anyhow::anyhow!("spawn_blocking join failed: {e:#}"))??;
let load_ms = load_start.elapsed().as_millis() as u64;
// transcribe_audio 也是阻塞 + 重活,同样扔到 blocking pool。
let trans_start = Instant::now();
let engine_clone = Arc::clone(&engine);
let text = tauri::async_runtime::spawn_blocking(move || {
engine_clone.transcribe_audio(&samples)
})
.await
.map_err(|e| anyhow::anyhow!("spawn_blocking join failed: {e:#}"))??;
let text =
tauri::async_runtime::spawn_blocking(move || engine_clone.transcribe_audio(&samples))
.await
.map_err(|e| anyhow::anyhow!("spawn_blocking join failed: {e:#}"))??;
let transcribe_ms = trans_start.elapsed().as_millis() as u64;
Ok(TestResult {
@@ -113,13 +110,17 @@ fn decode_wav_16k_mono(bytes: &[u8]) -> Result<Vec<f32>> {
if body_start + 16 > bytes.len() {
anyhow::bail!("fmt chunk 越界");
}
let format = u16::from_le_bytes(bytes[body_start..body_start + 2].try_into().unwrap());
let format =
u16::from_le_bytes(bytes[body_start..body_start + 2].try_into().unwrap());
if format != 1 {
anyhow::bail!("只支持 PCM(format=1),当前 format={format}");
}
channels = u16::from_le_bytes(bytes[body_start + 2..body_start + 4].try_into().unwrap());
sample_rate = u32::from_le_bytes(bytes[body_start + 4..body_start + 8].try_into().unwrap());
bits_per_sample = u16::from_le_bytes(bytes[body_start + 14..body_start + 16].try_into().unwrap());
channels =
u16::from_le_bytes(bytes[body_start + 2..body_start + 4].try_into().unwrap());
sample_rate =
u32::from_le_bytes(bytes[body_start + 4..body_start + 8].try_into().unwrap());
bits_per_sample =
u16::from_le_bytes(bytes[body_start + 14..body_start + 16].try_into().unwrap());
}
b"data" => {
data_offset = body_start;
@@ -0,0 +1,267 @@
//! 录音快捷键的自定义组合键监听器。
//!
//! 与 `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()))?;
// runtime 已按 hotkey id 分发;这里保留 id 检查作为防线,
// 避免未来误接回进程级事件流后串到其他快捷键。
let hotkey_id = registered.hotkey().id();
let tx_for_thread = tx.clone();
std::thread::Builder::new()
.name("openless-combo-hotkey-forward".into())
.spawn(move || forward_loop(hotkey_id, 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(());
}
}
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()))?;
let hotkey_id = registered.hotkey().id();
std::thread::Builder::new()
.name("openless-combo-hotkey-forward".into())
.spawn({
let tx = self.inner.tx.clone();
move || forward_loop(hotkey_id, 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(hotkey_id: u32, rx: Receiver<GlobalHotKeyEvent>, tx: Sender<ComboHotkeyEvent>) {
while let Ok(event) = rx.recv() {
if event.id() != hotkey_id {
continue;
}
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(_))
));
}
#[test]
fn bare_shift_is_rejected_for_combo_hotkey() {
let binding = ShortcutBinding {
primary: "Shift".into(),
modifiers: vec![],
};
assert!(matches!(
validate_binding(&binding),
Err(ComboHotkeyError::UnsupportedKey(_))
));
}
#[test]
fn legacy_modifier_only_is_rejected_for_combo_hotkey() {
let binding = ShortcutBinding {
primary: "RightOption".into(),
modifiers: vec![],
};
assert!(matches!(
validate_binding(&binding),
Err(ComboHotkeyError::UnsupportedKey(_))
));
}
#[test]
fn forward_loop_ignores_unrelated_hotkey_ids() {
let (event_tx, event_rx) = std::sync::mpsc::channel();
let (out_tx, out_rx) = std::sync::mpsc::channel();
event_tx
.send(GlobalHotKeyEvent {
id: 7,
state: HotKeyState::Pressed,
})
.unwrap();
event_tx
.send(GlobalHotKeyEvent {
id: 8,
state: HotKeyState::Released,
})
.unwrap();
event_tx
.send(GlobalHotKeyEvent {
id: 8,
state: HotKeyState::Pressed,
})
.unwrap();
drop(event_tx);
forward_loop(8, event_rx, out_tx);
assert!(matches!(out_rx.recv().unwrap(), ComboHotkeyEvent::Released));
assert!(matches!(out_rx.recv().unwrap(), ComboHotkeyEvent::Pressed));
assert!(out_rx.try_recv().is_err());
}
}
+624 -10
View File
@@ -12,8 +12,8 @@ use crate::permissions::{self, PermissionStatus};
use crate::persistence::{CredentialAccount, CredentialsSnapshot, CredentialsVault};
use crate::polish::{LLMError, OpenAICompatibleConfig, OpenAICompatibleLLMProvider};
use crate::types::{
ChineseScriptPreference, CredentialsStatus, DictationSession, DictionaryEntry,
HotkeyCapability, HotkeyStatus, OutputLanguagePreference, PolishMode, QaHotkeyBinding,
ChineseScriptPreference, ComboBinding, CredentialsStatus, DictationSession, DictionaryEntry,
HotkeyCapability, HotkeyStatus, OutputLanguagePreference, PolishMode, ShortcutBinding,
UserPreferences, VocabPresetStore, WindowsImeStatus,
};
@@ -30,6 +30,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 {
@@ -44,6 +48,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> {
@@ -58,12 +78,37 @@ 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> {
fn persist_settings<T: SettingsWriter>(
coord: &T,
mut prefs: UserPreferences,
) -> Result<(), String> {
sync_dictation_hotkey_legacy_fields(&mut prefs);
reject_hotkey_collisions(&prefs)?;
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(())
}
@@ -91,6 +136,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_windows_ime_status() -> WindowsImeStatus {
crate::windows_ime_profile::get_windows_ime_status()
@@ -154,7 +204,10 @@ pub fn set_credential(window: Window, account: String, value: String) -> Result<
}
#[tauri::command]
pub fn set_active_asr_provider(coord: CoordinatorState<'_>, provider: String) -> Result<(), String> {
pub fn set_active_asr_provider(
coord: CoordinatorState<'_>,
provider: String,
) -> Result<(), String> {
CredentialsVault::set_active_asr_provider(&provider).map_err(|e| e.to_string())?;
if provider == crate::asr::local::PROVIDER_ID {
// 切到本地 ASR → 后台预加载模型,下次按 hotkey 时不必等数秒。
@@ -728,9 +781,24 @@ 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);
if let Some(binding) = binding.as_ref() {
reject_dictation_qa_hotkey_overlap(&prefs.dictation_hotkey, binding)?;
reject_qa_translation_hotkey_overlap(binding, &prefs.translation_hotkey)?;
reject_qa_switch_style_hotkey_overlap(binding, &prefs.switch_style_hotkey)?;
reject_qa_open_app_hotkey_overlap(binding, &prefs.open_app_hotkey)?;
}
prefs.qa_hotkey = binding;
coord.prefs().set(prefs).map_err(|e| e.to_string())?;
coord.update_qa_hotkey_binding();
Ok(())
@@ -748,6 +816,293 @@ 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())?;
reject_bare_shift_dictation_shortcut(&binding)?;
let mut prefs = coord.prefs().get();
if let Some(qa_hotkey) = prefs.qa_hotkey.as_ref() {
reject_dictation_qa_hotkey_overlap(&binding, qa_hotkey)?;
}
reject_dictation_translation_hotkey_overlap(&binding, &prefs.translation_hotkey)?;
reject_dictation_switch_style_hotkey_overlap(&binding, &prefs.switch_style_hotkey)?;
reject_dictation_open_app_hotkey_overlap(&binding, &prefs.open_app_hotkey)?;
prefs.dictation_hotkey = binding;
sync_dictation_hotkey_legacy_fields(&mut prefs);
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 previous = coord.prefs().get();
reject_dictation_translation_hotkey_overlap(&previous.dictation_hotkey, &binding)?;
if let Some(qa_hotkey) = previous.qa_hotkey.as_ref() {
reject_qa_translation_hotkey_overlap(qa_hotkey, &binding)?;
}
reject_translation_switch_style_hotkey_overlap(&binding, &previous.switch_style_hotkey)?;
reject_translation_open_app_hotkey_overlap(&binding, &previous.open_app_hotkey)?;
let mut prefs = previous.clone();
prefs.translation_hotkey = binding;
coord.prefs().set(prefs).map_err(|e| e.to_string())?;
if let Err(e) = coord.try_update_translation_hotkey_binding() {
if let Err(rollback_err) = coord.prefs().set(previous) {
log::warn!("[commands] 回滚翻译快捷键失败: {rollback_err}");
}
coord.update_translation_hotkey_binding();
return Err(e);
}
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();
reject_dictation_switch_style_hotkey_overlap(&prefs.dictation_hotkey, &binding)?;
reject_translation_switch_style_hotkey_overlap(&prefs.translation_hotkey, &binding)?;
if let Some(qa_hotkey) = prefs.qa_hotkey.as_ref() {
reject_qa_switch_style_hotkey_overlap(qa_hotkey, &binding)?;
}
reject_switch_style_open_app_hotkey_overlap(&binding, &prefs.open_app_hotkey)?;
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();
reject_dictation_open_app_hotkey_overlap(&prefs.dictation_hotkey, &binding)?;
reject_translation_open_app_hotkey_overlap(&prefs.translation_hotkey, &binding)?;
if let Some(qa_hotkey) = prefs.qa_hotkey.as_ref() {
reject_qa_open_app_hotkey_overlap(qa_hotkey, &binding)?;
}
reject_switch_style_open_app_hotkey_overlap(&prefs.switch_style_hotkey, &binding)?;
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> {
let shortcut = ShortcutBinding {
primary: binding.primary,
modifiers: binding.modifiers,
};
reject_bare_shift_dictation_shortcut(&shortcut)?;
crate::combo_hotkey::validate_binding(&shortcut).map_err(|e| e.to_string())
}
/// 设置自定义录音组合键并热更新 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(),
};
reject_bare_shift_dictation_shortcut(&shortcut)?;
crate::combo_hotkey::validate_binding(&shortcut).map_err(|e| e.to_string())?;
if let Some(qa_hotkey) = prefs.qa_hotkey.as_ref() {
reject_dictation_qa_hotkey_overlap(&shortcut, qa_hotkey)?;
}
reject_dictation_translation_hotkey_overlap(&shortcut, &prefs.translation_hotkey)?;
reject_dictation_switch_style_hotkey_overlap(&shortcut, &prefs.switch_style_hotkey)?;
reject_dictation_open_app_hotkey_overlap(&shortcut, &prefs.open_app_hotkey)?;
prefs.custom_combo_hotkey = Some(binding);
prefs.dictation_hotkey = shortcut;
sync_dictation_hotkey_legacy_fields(&mut prefs);
coord.prefs().set(prefs).map_err(|e| e.to_string())?;
coord.update_hotkey_binding();
coord.update_combo_hotkey_binding();
Ok(())
}
fn reject_bare_shift_dictation_shortcut(binding: &ShortcutBinding) -> Result<(), String> {
if binding.modifiers.is_empty() && binding.primary.eq_ignore_ascii_case("shift") {
return Err("Shift 单键目前只能用于翻译快捷键".into());
}
Ok(())
}
fn sync_dictation_hotkey_legacy_fields(prefs: &mut UserPreferences) {
if let Some(trigger) = crate::shortcut_binding::legacy_modifier_trigger(&prefs.dictation_hotkey)
{
prefs.hotkey.trigger = trigger;
prefs.custom_combo_hotkey = None;
return;
}
prefs.hotkey.trigger = crate::types::HotkeyTrigger::Custom;
prefs.custom_combo_hotkey = if prefs.dictation_hotkey.primary.trim().is_empty() {
None
} else {
Some(ComboBinding {
primary: prefs.dictation_hotkey.primary.clone(),
modifiers: prefs.dictation_hotkey.modifiers.clone(),
})
};
}
fn reject_dictation_qa_hotkey_overlap(
dictation: &ShortcutBinding,
qa: &ShortcutBinding,
) -> Result<(), String> {
if shortcut_bindings_overlap(dictation, qa) {
return Err("QA 快捷键不能和听写快捷键相同".into());
}
Ok(())
}
fn reject_hotkey_overlap(
left: &ShortcutBinding,
right: &ShortcutBinding,
message: &'static str,
) -> Result<(), String> {
if shortcut_bindings_overlap(left, right) {
return Err(message.into());
}
Ok(())
}
fn reject_hotkey_collisions(prefs: &UserPreferences) -> Result<(), String> {
if let Some(qa_hotkey) = prefs.qa_hotkey.as_ref() {
reject_dictation_qa_hotkey_overlap(&prefs.dictation_hotkey, qa_hotkey)?;
reject_qa_translation_hotkey_overlap(qa_hotkey, &prefs.translation_hotkey)?;
reject_qa_switch_style_hotkey_overlap(qa_hotkey, &prefs.switch_style_hotkey)?;
reject_qa_open_app_hotkey_overlap(qa_hotkey, &prefs.open_app_hotkey)?;
}
reject_dictation_translation_hotkey_overlap(&prefs.dictation_hotkey, &prefs.translation_hotkey)?;
reject_dictation_switch_style_hotkey_overlap(&prefs.dictation_hotkey, &prefs.switch_style_hotkey)?;
reject_dictation_open_app_hotkey_overlap(&prefs.dictation_hotkey, &prefs.open_app_hotkey)?;
reject_translation_switch_style_hotkey_overlap(
&prefs.translation_hotkey,
&prefs.switch_style_hotkey,
)?;
reject_translation_open_app_hotkey_overlap(&prefs.translation_hotkey, &prefs.open_app_hotkey)?;
reject_switch_style_open_app_hotkey_overlap(&prefs.switch_style_hotkey, &prefs.open_app_hotkey)?;
Ok(())
}
fn reject_dictation_translation_hotkey_overlap(
dictation: &ShortcutBinding,
translation: &ShortcutBinding,
) -> Result<(), String> {
reject_hotkey_overlap(dictation, translation, "翻译快捷键不能和听写快捷键相同")
}
fn reject_dictation_switch_style_hotkey_overlap(
dictation: &ShortcutBinding,
switch_style: &ShortcutBinding,
) -> Result<(), String> {
reject_hotkey_overlap(dictation, switch_style, "切换风格快捷键不能和听写快捷键相同")
}
fn reject_dictation_open_app_hotkey_overlap(
dictation: &ShortcutBinding,
open_app: &ShortcutBinding,
) -> Result<(), String> {
reject_hotkey_overlap(dictation, open_app, "打开应用快捷键不能和听写快捷键相同")
}
fn reject_qa_translation_hotkey_overlap(
qa: &ShortcutBinding,
translation: &ShortcutBinding,
) -> Result<(), String> {
reject_hotkey_overlap(qa, translation, "翻译快捷键不能和 QA 快捷键相同")
}
fn reject_qa_switch_style_hotkey_overlap(
qa: &ShortcutBinding,
switch_style: &ShortcutBinding,
) -> Result<(), String> {
reject_hotkey_overlap(qa, switch_style, "切换风格快捷键不能和 QA 快捷键相同")
}
fn reject_qa_open_app_hotkey_overlap(
qa: &ShortcutBinding,
open_app: &ShortcutBinding,
) -> Result<(), String> {
reject_hotkey_overlap(qa, open_app, "打开应用快捷键不能和 QA 快捷键相同")
}
fn reject_translation_switch_style_hotkey_overlap(
translation: &ShortcutBinding,
switch_style: &ShortcutBinding,
) -> Result<(), String> {
reject_hotkey_overlap(translation, switch_style, "切换风格快捷键不能和翻译快捷键相同")
}
fn reject_translation_open_app_hotkey_overlap(
translation: &ShortcutBinding,
open_app: &ShortcutBinding,
) -> Result<(), String> {
reject_hotkey_overlap(translation, open_app, "打开应用快捷键不能和翻译快捷键相同")
}
fn reject_switch_style_open_app_hotkey_overlap(
switch_style: &ShortcutBinding,
open_app: &ShortcutBinding,
) -> Result<(), String> {
reject_hotkey_overlap(switch_style, open_app, "打开应用快捷键不能和切换风格快捷键相同")
}
fn shortcut_bindings_overlap(left: &ShortcutBinding, right: &ShortcutBinding) -> bool {
let left_legacy = crate::shortcut_binding::legacy_modifier_trigger(left);
let right_legacy = crate::shortcut_binding::legacy_modifier_trigger(right);
match (left_legacy, right_legacy) {
(Some(left), Some(right)) => left == right,
(Some(_), None) | (None, Some(_)) => false,
(None, None) => {
let Ok(left) = crate::shortcut_binding::parse_global_hotkey(left) else {
return false;
};
let Ok(right) = crate::shortcut_binding::parse_global_hotkey(right) else {
return false;
};
left == right
}
}
}
// ─────────────────────────── local ASR (Qwen3-ASR) ───────────────────────────
use crate::asr::local::{
@@ -777,7 +1132,10 @@ pub fn local_asr_get_settings(coord: CoordinatorState<'_>) -> LocalAsrSettings {
}
#[tauri::command]
pub fn local_asr_set_active_model(coord: CoordinatorState<'_>, model_id: String) -> Result<(), String> {
pub fn local_asr_set_active_model(
coord: CoordinatorState<'_>,
model_id: String,
) -> Result<(), String> {
if ModelId::from_str(&model_id).is_none() {
return Err(format!("unknown model id: {model_id}"));
}
@@ -924,7 +1282,7 @@ mod tests {
};
use crate::persistence::CredentialsSnapshot;
use crate::types::{
HotkeyBinding, HotkeyMode, HotkeyTrigger, QaHotkeyBinding, UserPreferences,
ComboBinding, HotkeyBinding, HotkeyMode, HotkeyTrigger, ShortcutBinding, UserPreferences,
};
use std::io::{Read, Write};
use std::net::TcpListener;
@@ -936,6 +1294,7 @@ mod tests {
saved: Mutex<Option<UserPreferences>>,
dictation_refreshes: Mutex<u32>,
qa_refreshes: Mutex<u32>,
combo_refreshes: Mutex<u32>,
}
fn snapshot() -> CredentialsSnapshot {
@@ -1010,6 +1369,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]
@@ -1064,7 +1431,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()],
}),
@@ -1079,7 +1446,7 @@ mod tests {
.unwrap()
.clone()
.expect("settings saved");
assert_eq!(saved.hotkey.trigger, prefs.hotkey.trigger);
assert_eq!(saved.hotkey.trigger, HotkeyTrigger::RightOption);
assert_eq!(saved.hotkey.mode, prefs.hotkey.mode);
assert_eq!(
saved.qa_hotkey.unwrap().primary,
@@ -1087,6 +1454,253 @@ 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);
}
#[test]
fn sync_dictation_hotkey_sets_modifier_trigger_and_clears_combo() {
let mut prefs = UserPreferences {
hotkey: HotkeyBinding {
trigger: HotkeyTrigger::Custom,
mode: HotkeyMode::Toggle,
},
custom_combo_hotkey: Some(ComboBinding {
primary: "D".into(),
modifiers: vec!["cmd".into(), "shift".into()],
}),
dictation_hotkey: ShortcutBinding {
primary: "RightControl".into(),
modifiers: vec![],
},
..Default::default()
};
super::sync_dictation_hotkey_legacy_fields(&mut prefs);
assert_eq!(prefs.hotkey.trigger, HotkeyTrigger::RightControl);
assert!(prefs.custom_combo_hotkey.is_none());
}
#[test]
fn sync_dictation_hotkey_sets_custom_trigger_and_combo_binding() {
let mut prefs = UserPreferences {
hotkey: HotkeyBinding {
trigger: HotkeyTrigger::RightControl,
mode: HotkeyMode::Toggle,
},
dictation_hotkey: ShortcutBinding {
primary: "D".into(),
modifiers: vec!["cmd".into(), "shift".into()],
},
..Default::default()
};
super::sync_dictation_hotkey_legacy_fields(&mut prefs);
assert_eq!(prefs.hotkey.trigger, HotkeyTrigger::Custom);
let combo = prefs.custom_combo_hotkey.expect("combo binding saved");
assert_eq!(combo.primary, "D");
assert_eq!(
combo.modifiers,
vec!["cmd".to_string(), "shift".to_string()]
);
}
#[test]
fn sync_dictation_hotkey_clears_empty_custom_binding() {
let mut prefs = UserPreferences {
hotkey: HotkeyBinding {
trigger: HotkeyTrigger::RightControl,
mode: HotkeyMode::Toggle,
},
custom_combo_hotkey: Some(ComboBinding {
primary: "D".into(),
modifiers: vec!["cmd".into(), "shift".into()],
}),
dictation_hotkey: ShortcutBinding {
primary: " ".into(),
modifiers: vec!["cmd".into()],
},
..Default::default()
};
super::sync_dictation_hotkey_legacy_fields(&mut prefs);
assert_eq!(prefs.hotkey.trigger, HotkeyTrigger::Custom);
assert!(prefs.custom_combo_hotkey.is_none());
}
#[test]
fn validate_combo_hotkey_rejects_bare_shift() {
let result = super::validate_combo_hotkey(ComboBinding {
primary: "Shift".into(),
modifiers: vec![],
});
assert!(result.is_err());
}
#[test]
fn combo_hotkey_bare_shift_rejection_matches_dictation_setter() {
let binding = ShortcutBinding {
primary: "Shift".into(),
modifiers: vec![],
};
assert_eq!(
super::reject_bare_shift_dictation_shortcut(&binding),
Err("Shift 单键目前只能用于翻译快捷键".into())
);
}
#[test]
fn dictation_qa_overlap_rejects_same_modifier_only_binding() {
let binding = ShortcutBinding {
primary: "RightControl".into(),
modifiers: vec![],
};
assert_eq!(
super::reject_dictation_qa_hotkey_overlap(&binding, &binding),
Err("QA 快捷键不能和听写快捷键相同".into())
);
}
#[test]
fn dictation_qa_overlap_rejects_same_combo_binding() {
let dictation = ShortcutBinding {
primary: ";".into(),
modifiers: vec!["ctrl".into(), "shift".into()],
};
let qa = ShortcutBinding {
primary: ";".into(),
modifiers: vec!["control".into(), "shift".into()],
};
assert_eq!(
super::reject_dictation_qa_hotkey_overlap(&dictation, &qa),
Err("QA 快捷键不能和听写快捷键相同".into())
);
}
#[test]
fn dictation_qa_overlap_allows_distinct_bindings() {
let dictation = ShortcutBinding {
primary: "RightControl".into(),
modifiers: vec![],
};
let qa = ShortcutBinding {
primary: ";".into(),
modifiers: vec!["ctrl".into(), "shift".into()],
};
assert!(super::reject_dictation_qa_hotkey_overlap(&dictation, &qa).is_ok());
}
#[test]
fn dictation_translation_overlap_rejects_same_modifier_only_binding() {
let binding = ShortcutBinding {
primary: "RightControl".into(),
modifiers: vec![],
};
assert_eq!(
super::reject_dictation_translation_hotkey_overlap(&binding, &binding),
Err("翻译快捷键不能和听写快捷键相同".into())
);
}
#[test]
fn dictation_translation_overlap_rejects_same_combo_binding() {
let dictation = ShortcutBinding {
primary: "T".into(),
modifiers: vec!["ctrl".into(), "shift".into()],
};
let translation = ShortcutBinding {
primary: "T".into(),
modifiers: vec!["control".into(), "shift".into()],
};
assert_eq!(
super::reject_dictation_translation_hotkey_overlap(&dictation, &translation),
Err("翻译快捷键不能和听写快捷键相同".into())
);
}
#[test]
fn dictation_translation_overlap_allows_distinct_bindings() {
let dictation = ShortcutBinding {
primary: "RightControl".into(),
modifiers: vec![],
};
let translation = ShortcutBinding {
primary: "Shift".into(),
modifiers: vec![],
};
assert!(
super::reject_dictation_translation_hotkey_overlap(&dictation, &translation).is_ok()
);
}
#[test]
fn persist_settings_rejects_dictation_translation_overlap() {
let writer = FakeSettingsWriter::default();
let binding = ShortcutBinding {
primary: "RightControl".into(),
modifiers: vec![],
};
let prefs = UserPreferences {
dictation_hotkey: binding.clone(),
translation_hotkey: binding,
..Default::default()
};
assert_eq!(
persist_settings(&writer, prefs),
Err("翻译快捷键不能和听写快捷键相同".into())
);
assert!(writer.saved.lock().unwrap().is_none());
}
#[test]
fn persist_settings_rejects_translation_switch_style_overlap() {
let writer = FakeSettingsWriter::default();
let binding = ShortcutBinding {
primary: "T".into(),
modifiers: vec!["cmd".into(), "shift".into()],
};
let prefs = UserPreferences {
translation_hotkey: binding.clone(),
switch_style_hotkey: binding,
..Default::default()
};
assert_eq!(
persist_settings(&writer, prefs),
Err("切换风格快捷键不能和翻译快捷键相同".into())
);
assert!(writer.saved.lock().unwrap().is_none());
}
#[test]
fn persist_settings_rejects_switch_style_open_app_overlap() {
let writer = FakeSettingsWriter::default();
let binding = ShortcutBinding {
primary: "K".into(),
modifiers: vec!["cmd".into(), "shift".into()],
};
let prefs = UserPreferences {
switch_style_hotkey: binding.clone(),
open_app_hotkey: binding,
..Default::default()
};
assert_eq!(
persist_settings(&writer, prefs),
Err("打开应用快捷键不能和切换风格快捷键相同".into())
);
assert!(writer.saved.lock().unwrap().is_none());
}
#[tokio::test]
+830 -18
View File
@@ -20,6 +20,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::{
@@ -150,6 +151,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
@@ -180,6 +188,12 @@ enum QaPhase {
Processing,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
enum ActionHotkeyKind {
SwitchStyle,
OpenApp,
}
struct QaSessionState {
phase: QaPhase,
cancelled: bool,
@@ -246,6 +260,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()),
@@ -267,12 +286,16 @@ impl Coordinator {
let inner = Arc::clone(&self.inner);
tauri::async_runtime::spawn(async move {
let prefs = inner.prefs.get();
let model_id = match crate::asr::local::ModelId::from_str(&prefs.local_asr_active_model) {
Some(m) => m,
None => return,
};
let model_id =
match crate::asr::local::ModelId::from_str(&prefs.local_asr_active_model) {
Some(m) => m,
None => return,
};
if !crate::asr::local::models::is_downloaded(model_id) {
log::info!("[coord] local ASR preload skipped: model {} not downloaded", model_id.as_str());
log::info!(
"[coord] local ASR preload skipped: model {} not downloaded",
model_id.as_str()
);
return;
}
let dir = match crate::asr::local::models::model_dir(model_id) {
@@ -349,6 +372,106 @@ 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 stop_switch_style_hotkey_listener(&self) {
take_action_hotkey_on_main_thread(&self.inner, ActionHotkeyKind::SwitchStyle);
}
pub fn start_open_app_hotkey_listener(&self) {
let inner = Arc::clone(&self.inner);
std::thread::Builder::new()
.name("openless-open-app-hotkey-supervisor".into())
.spawn(move || action_hotkey_supervisor_loop(inner, ActionHotkeyKind::OpenApp))
.ok();
}
pub fn stop_open_app_hotkey_listener(&self) {
take_action_hotkey_on_main_thread(&self.inner, ActionHotkeyKind::OpenApp);
}
/// 用户在设置里改了自定义组合键时调用。
pub fn update_combo_hotkey_binding(&self) {
let prefs = self.inner.prefs.get();
if crate::shortcut_binding::legacy_modifier_trigger(&prefs.dictation_hotkey).is_some() {
// 修饰键单键由 HotkeyMonitor 处理,组合键 monitor 要释放。
take_combo_hotkey_on_main_thread(&self.inner);
log::info!("[coord] combo hotkey 已关闭(modifier-only)");
return;
}
let binding = prefs.dictation_hotkey.clone();
if is_unconfigured_shortcut(&binding) {
// Custom 但没录到有效主键:清掉旧 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。
@@ -367,8 +490,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();
@@ -406,6 +545,84 @@ impl Coordinator {
});
}
pub fn update_translation_hotkey_binding(&self) {
if let Err(e) = self.try_update_translation_hotkey_binding() {
log::warn!("[coord] update translation hotkey binding 失败: {e}");
}
}
pub fn try_update_translation_hotkey_binding(&self) -> Result<(), String> {
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 Ok(());
}
self.update_modifier_shortcut_bindings();
let app = self.inner.app.lock().clone();
let Some(app) = app else {
return Err("AppHandle 未 bind,无法注册翻译快捷键".into());
};
let inner_clone = Arc::clone(&self.inner);
let binding_for_main = prefs.translation_hotkey.clone();
let (result_tx, result_rx) = mpsc::sync_channel::<Result<(), String>>(1);
let _ = app.run_on_main_thread(move || {
let result = update_translation_hotkey_on_main_thread(inner_clone, binding_for_main);
let _ = result_tx.send(result.map_err(|e| e.to_string()));
});
match result_rx.recv_timeout(std::time::Duration::from_secs(5)) {
Ok(result) => result,
Err(_) => Err("注册翻译快捷键超时".into()),
}
}
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 {
@@ -441,8 +658,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);
}
}
@@ -470,6 +738,16 @@ 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 {
reset_shortcut_held_state(&self.inner);
}
log::info!("[coord] shortcut recording active={active}");
}
pub async fn handle_window_hotkey_event(
&self,
event_type: String,
@@ -518,6 +796,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;
}
@@ -528,11 +808,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,
@@ -585,6 +874,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 路径手动触发 )。
@@ -657,6 +955,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 => {
@@ -666,6 +967,450 @@ 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 is_unconfigured_shortcut(&binding) {
take_combo_hotkey_on_main_thread(&inner);
std::thread::sleep(std::time::Duration::from_secs(5));
continue;
}
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 update_translation_hotkey_on_main_thread(
inner: Arc<Inner>,
binding: crate::types::ShortcutBinding,
) -> Result<(), ComboHotkeyError> {
if let Some(monitor) = inner.translation_hotkey.lock().as_ref() {
return monitor.update_binding(binding);
}
let (tx, rx) = mpsc::channel::<ComboHotkeyEvent>();
let monitor = ComboHotkeyMonitor::start(binding, tx)?;
*inner.translation_hotkey.lock() = Some(monitor);
let bridge_inner = Arc::clone(&inner);
std::thread::Builder::new()
.name("openless-translation-hotkey-bridge".into())
.spawn(move || translation_hotkey_bridge_loop(bridge_inner, rx))
.map_err(|e| ComboHotkeyError::RegisterFailed(format!("spawn bridge thread: {e}")))?;
Ok(())
}
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 is_unconfigured_shortcut(binding: &crate::types::ShortcutBinding) -> bool {
binding.primary.trim().is_empty()
}
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)。
@@ -744,6 +1489,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 => {
@@ -756,17 +1504,57 @@ 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 });
}
}
}
}
fn reset_shortcut_held_state(inner: &Arc<Inner>) {
inner.hotkey_trigger_held.store(false, Ordering::SeqCst);
if let Some(monitor) = inner.hotkey.lock().as_ref() {
monitor.reset_held_state();
}
let prefs = inner.prefs.get();
if let Some(binding) = prefs.qa_hotkey.as_ref() {
if crate::shortcut_binding::legacy_modifier_trigger(binding).is_none() {
if let Some(monitor) = inner.qa_hotkey.lock().as_ref() {
if let Err(e) = monitor.update_binding(binding.clone()) {
log::warn!("[coord] reset QA hotkey latch failed: {e}");
}
}
}
}
if !is_builtin_translation_shift(&prefs.translation_hotkey)
&& crate::shortcut_binding::legacy_modifier_trigger(&prefs.translation_hotkey).is_none()
{
if let Some(monitor) = inner.translation_hotkey.lock().as_ref() {
if let Err(e) = monitor.update_binding(prefs.translation_hotkey.clone()) {
log::warn!("[coord] reset translation hotkey latch failed: {e}");
}
}
}
if !is_modifier_only_shortcut(&prefs.switch_style_hotkey) {
if let Some(monitor) = inner.switch_style_hotkey.lock().as_ref() {
if let Err(e) = monitor.update_binding(prefs.switch_style_hotkey.clone()) {
log::warn!("[coord] reset switch-style hotkey latch failed: {e}");
}
}
}
if !is_modifier_only_shortcut(&prefs.open_app_hotkey) {
if let Some(monitor) = inner.open_app_hotkey.lock().as_ref() {
if let Err(e) = monitor.update_binding(prefs.open_app_hotkey.clone()) {
log::warn!("[coord] reset open-app hotkey latch failed: {e}");
}
}
}
}
@@ -974,6 +1762,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 浮窗
@@ -1007,7 +1798,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(());
}
@@ -1049,6 +1844,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,
}
}
@@ -2234,7 +3031,9 @@ fn schedule_local_asr_release(inner: &Arc<Inner>) {
}
#[cfg(target_os = "macos")]
async fn build_local_qwen3(inner: &Arc<Inner>) -> anyhow::Result<Arc<crate::asr::local::LocalQwenAsr>> {
async fn build_local_qwen3(
inner: &Arc<Inner>,
) -> anyhow::Result<Arc<crate::asr::local::LocalQwenAsr>> {
let prefs = inner.prefs.get();
let model_id = crate::asr::local::ModelId::from_str(&prefs.local_asr_active_model)
.ok_or_else(|| anyhow::anyhow!("未知本地模型 id: {}", prefs.local_asr_active_model))?;
@@ -3176,6 +3975,19 @@ mod tests {
assert!(coordinator.inner.hotkey_trigger_held.load(Ordering::SeqCst));
}
#[test]
fn enabling_shortcut_recording_clears_dictation_hold_latch() {
let coordinator = Coordinator::new();
coordinator
.inner
.hotkey_trigger_held
.store(true, Ordering::SeqCst);
coordinator.set_shortcut_recording_active(true);
assert!(!coordinator.inner.hotkey_trigger_held.load(Ordering::SeqCst));
}
#[test]
fn window_hotkey_fallback_is_disabled_when_no_explicit_fallback_is_advertised() {
assert_eq!(
@@ -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");
}
+293 -6
View File
@@ -17,6 +17,7 @@ use std::time::Duration;
use parking_lot::RwLock;
use crate::types::HotkeyTrigger;
use crate::types::{HotkeyAdapterKind, HotkeyBinding, HotkeyCapability, HotkeyInstallError};
#[derive(Clone, Copy, Debug)]
@@ -27,11 +28,44 @@ pub enum HotkeyEvent {
/// Shift(或未来配置项指定的修饰键)按下边沿。可在录音过程中任何时刻产生;
/// 上层据此切换到翻译输出管线。详见 issue #4。
TranslationModifierPressed,
QaShortcutPressed,
}
#[cfg(test)]
mod tests {
use super::*;
use std::sync::atomic::Ordering;
#[test]
fn reset_shared_held_state_clears_all_shortcut_latches() {
let shared = Shared {
binding: RwLock::new(HotkeyBinding::default()),
trigger_held: AtomicBool::new(true),
qa_trigger: RwLock::new(None),
qa_trigger_held: AtomicBool::new(true),
translation_trigger: RwLock::new(None),
translation_trigger_held: AtomicBool::new(true),
translation_modifier_held: AtomicBool::new(true),
};
reset_shared_held_state(&shared);
assert!(!shared.trigger_held.load(Ordering::SeqCst));
assert!(!shared.qa_trigger_held.load(Ordering::SeqCst));
assert!(!shared.translation_trigger_held.load(Ordering::SeqCst));
assert!(!shared.translation_modifier_held.load(Ordering::SeqCst));
}
}
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 reset_held_state(&self);
fn shutdown(&self) {}
}
@@ -39,6 +73,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,10 +103,23 @@ 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()
}
pub fn reset_held_state(&self) {
self.adapter.reset_held_state();
}
pub fn capability() -> HotkeyCapability {
HotkeyCapability::current()
}
@@ -114,6 +165,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 +193,36 @@ 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);
}
fn reset_shared_held_state(shared: &Shared) {
shared
.trigger_held
.store(false, std::sync::atomic::Ordering::SeqCst);
shared
.qa_trigger_held
.store(false, std::sync::atomic::Ordering::SeqCst);
shared
.translation_trigger_held
.store(false, std::sync::atomic::Ordering::SeqCst);
shared
.translation_modifier_held
.store(false, std::sync::atomic::Ordering::SeqCst);
}
// ─────────────────────────── macOS implementation ───────────────────────────
#[cfg(target_os = "macos")]
@@ -148,8 +233,9 @@ 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, reset_shared_held_state, 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 +268,18 @@ 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 reset_held_state(&self) {
reset_shared_held_state(&self.shared);
}
}
// ── Raw CG/CF FFI ──────────────────────────────────────────────────────
@@ -360,7 +458,27 @@ mod platform {
}
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;
@@ -378,6 +496,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 {
@@ -393,6 +535,7 @@ mod platform {
HotkeyTrigger::RightOption | HotkeyTrigger::RightAlt => 61,
HotkeyTrigger::RightCommand => 54,
HotkeyTrigger::Fn => 63,
HotkeyTrigger::Custom => unreachable!("custom combo hotkeys use ComboHotkeyMonitor"),
}
}
@@ -404,6 +547,7 @@ mod platform {
FLAG_MASK_ALTERNATE
}
HotkeyTrigger::Fn => FLAG_MASK_SECONDARY_FN,
HotkeyTrigger::Custom => unreachable!("custom combo hotkeys use ComboHotkeyMonitor"),
}
}
}
@@ -426,8 +570,9 @@ mod platform {
};
use super::{
install_error, send_or_log, start_listener_thread, update_shared_binding, HotkeyAdapter,
HotkeyEvent, Shared, StartupTx,
install_error, reset_shared_held_state, send_or_log, start_listener_thread,
update_shared_binding, update_shared_modifier_shortcuts, HotkeyAdapter, HotkeyEvent,
Shared, StartupTx,
};
use crate::types::{HotkeyAdapterKind, HotkeyBinding, HotkeyInstallError, HotkeyTrigger};
@@ -480,6 +625,18 @@ 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 reset_held_state(&self) {
reset_shared_held_state(&self.shared);
}
fn shutdown(&self) {
unsafe {
if let Err(err) = PostThreadMessageW(self.thread_id, WM_QUIT, WPARAM(0), LPARAM(0))
@@ -606,7 +763,27 @@ mod platform {
return false;
}
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 false;
}
if vk_code != trigger_to_vk_code(trigger) {
return false;
}
@@ -631,6 +808,34 @@ mod platform {
true
}
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
@@ -645,6 +850,7 @@ mod platform {
HotkeyTrigger::RightCommand => VK_RWIN,
HotkeyTrigger::LeftOption => VK_RMENU,
HotkeyTrigger::Fn => VK_RCONTROL,
HotkeyTrigger::Custom => unreachable!("custom combo hotkeys use ComboHotkeyMonitor"),
}
}
@@ -665,8 +871,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, reset_shared_held_state, start_listener_thread, update_shared_binding,
update_shared_modifier_shortcuts, HotkeyAdapter, HotkeyEvent, Shared, StartupTx,
};
use crate::types::{HotkeyAdapterKind, HotkeyBinding, HotkeyInstallError, HotkeyTrigger};
@@ -705,6 +911,18 @@ 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 reset_held_state(&self) {
reset_shared_held_state(&self.shared);
}
}
fn run_listen_loop(shared: Arc<Shared>, tx: Sender<HotkeyEvent>, status_tx: StartupTx<()>) {
@@ -750,6 +968,25 @@ mod platform {
}
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 {
@@ -764,6 +1001,21 @@ mod platform {
.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) {
let was_held = shared.trigger_held.swap(false, Ordering::SeqCst);
if was_held {
@@ -775,6 +1027,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,
@@ -783,6 +1069,7 @@ mod platform {
HotkeyTrigger::LeftControl => Key::ControlLeft,
HotkeyTrigger::RightCommand => Key::MetaRight,
HotkeyTrigger::Fn => Key::Function,
HotkeyTrigger::Custom => unreachable!("custom combo hotkeys use ComboHotkeyMonitor"),
}
}
}
+27 -2
View File
@@ -12,8 +12,10 @@
mod asr;
mod audio_mute;
mod combo_hotkey;
mod commands;
mod coordinator;
mod global_hotkey_runtime;
mod hotkey;
mod insertion;
mod permissions;
@@ -22,6 +24,7 @@ mod polish;
mod qa_hotkey;
mod recorder;
mod selection;
mod shortcut_binding;
mod types;
mod windows_ime_ipc;
mod windows_ime_profile;
@@ -183,8 +186,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());
}
@@ -196,6 +199,7 @@ pub fn run() {
commands::set_settings,
commands::get_hotkey_status,
commands::get_hotkey_capability,
commands::set_shortcut_recording_active,
commands::get_windows_ime_status,
commands::get_credentials,
commands::set_credential,
@@ -228,8 +232,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,
commands::local_asr_get_settings,
@@ -251,6 +262,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, .. } => {
@@ -275,6 +296,10 @@ 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();
coordinator.stop_switch_style_hotkey_listener();
coordinator.stop_open_app_hotkey_listener();
}
_ => {}
});
@@ -707,7 +707,14 @@ impl PreferencesStore {
ensure_dir(&dir)?;
let path = dir.join(PREFERENCES_FILE);
let prefs = if path.exists() {
read_or_default::<UserPreferences>(&path).unwrap_or_default()
read_or_default::<UserPreferences>(&path).unwrap_or_else(|e| {
log::warn!(
"[prefs] load {} failed, using defaults: {}",
path.display(),
e
);
UserPreferences::default()
})
} else {
UserPreferences::default()
};
+82 -199
View File
@@ -7,16 +7,17 @@
//! 仅产出 `QaHotkeyEvent::Pressed` 边沿事件;toggle / 录音生命周期由
//! coordinator 解释(第一次按 → 开始问答;第二次按 → 结束)。
//!
//! 模块依赖:仅 `types`,与 CLAUDE.md "Rust 模块依赖只通过 types.rs 跨模块" 一致。
//! 通过 `global_hotkey_runtime` 共享进程级 manager / event receiver。
use std::sync::mpsc::Sender;
use std::sync::mpsc::{Receiver, Sender};
use std::sync::Arc;
use global_hotkey::hotkey::{Code, HotKey, Modifiers};
use global_hotkey::{GlobalHotKeyEvent, GlobalHotKeyManager, HotKeyState};
use global_hotkey::{GlobalHotKeyEvent, HotKeyState};
use parking_lot::Mutex;
use crate::types::QaHotkeyBinding;
use crate::global_hotkey_runtime::{GlobalHotkeyRuntime, RegisteredHotkey};
use crate::shortcut_binding::{parse_global_hotkey, ShortcutBindingError};
use crate::types::ShortcutBinding;
#[derive(Debug, Clone, Copy)]
pub enum QaHotkeyEvent {
@@ -44,13 +45,9 @@ pub struct QaHotkeyMonitor {
}
struct Inner {
manager: GlobalHotKeyManager,
/// 当前注册的 hotkey 句柄;用于 unregister。
registered: Mutex<Option<HotKey>>,
/// 事件转发线程接收 global-hotkey crate 的全局 channel,再过滤 id 后转发到 tx。
forward_alive: Arc<std::sync::atomic::AtomicBool>,
/// 当前关心的 hotkey id(filter 用)。
active_id: Arc<std::sync::atomic::AtomicU32>,
registered: Mutex<Option<RegisteredHotkey>>,
tx: Sender<QaHotkeyEvent>,
}
// global-hotkey 0.6 的 GlobalHotKeyManager 在 Windows 内部持有 HHOOK / window
@@ -70,93 +67,71 @@ 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);
// 启动转发线程:runtime 已按 hotkey id 分发;这里保留 id 检查作为防线,
// 避免未来误接回进程级事件流后串到其他快捷键。
let hotkey_id = registered.hotkey().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(hotkey_id, 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
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);
let hotkey_id = registered.hotkey().id();
// 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(hotkey_id, 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 {
fn forward_loop(hotkey_id: u32, rx: Receiver<GlobalHotKeyEvent>, tx: Sender<QaHotkeyEvent>) {
while let Ok(event) = rx.recv() {
if event.id() != hotkey_id {
continue;
}
if !matches!(event.state(), HotKeyState::Pressed) {
@@ -170,146 +145,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 +169,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 +181,7 @@ mod tests {
#[test]
fn unsupported_modifier_rejected() {
let binding = QaHotkeyBinding {
let binding = ShortcutBinding {
primary: ";".into(),
modifiers: vec!["hyper".into()],
};
@@ -341,7 +193,7 @@ mod tests {
#[test]
fn empty_primary_rejected() {
let binding = QaHotkeyBinding {
let binding = ShortcutBinding {
primary: "".into(),
modifiers: vec!["cmd".into()],
};
@@ -353,7 +205,7 @@ mod tests {
#[test]
fn cmd_modifier_normalizes_per_platform() {
let binding = QaHotkeyBinding {
let binding = ShortcutBinding {
primary: ";".into(),
modifiers: vec!["cmd".into(), "shift".into()],
};
@@ -370,4 +222,35 @@ mod tests {
assert!(parsed.mods.contains(Modifiers::SUPER));
}
}
#[test]
fn forward_loop_ignores_unrelated_hotkey_ids() {
let (event_tx, event_rx) = std::sync::mpsc::channel();
let (out_tx, out_rx) = std::sync::mpsc::channel();
event_tx
.send(GlobalHotKeyEvent {
id: 41,
state: HotKeyState::Pressed,
})
.unwrap();
event_tx
.send(GlobalHotKeyEvent {
id: 42,
state: HotKeyState::Released,
})
.unwrap();
event_tx
.send(GlobalHotKeyEvent {
id: 42,
state: HotKeyState::Pressed,
})
.unwrap();
drop(event_tx);
forward_loop(42, event_rx, out_tx);
assert!(matches!(out_rx.recv().unwrap(), QaHotkeyEvent::Pressed));
assert!(out_rx.try_recv().is_err());
}
}
+22 -19
View File
@@ -115,14 +115,15 @@ fn run_audio_thread(
startup_tx: Sender<Result<(), RecorderError>>,
runtime_error_tx: Sender<RecorderError>,
) {
let (stream, state) = match build_input_stream(consumer, level_handler, runtime_error_tx.clone()) {
Ok(s) => s,
Err(err) => {
// 启动失败:通知主线程后即退出。
let _ = startup_tx.send(Err(err));
return;
}
};
let (stream, state) =
match build_input_stream(consumer, level_handler, runtime_error_tx.clone()) {
Ok(s) => s,
Err(err) => {
// 启动失败:通知主线程后即退出。
let _ = startup_tx.send(Err(err));
return;
}
};
if let Err(err) = stream.play() {
let _ = startup_tx.send(Err(RecorderError::EngineFailed(format!("play: {err}"))));
@@ -133,9 +134,9 @@ fn run_audio_thread(
let _ = startup_tx.send(Ok(()));
// 启动 liveness watchdog 线程:检测录音回调是否静默停止
const WATCHDOG_CHECK_INTERVAL_MS: u64 = 1000; // 每秒检查一次
const CALLBACK_TIMEOUT_SECS: u64 = 3; // 3 秒没有回调视为异常
const FIRST_CALLBACK_DEADLINE_SECS: u64 = 5; // 5 秒内必须收到首次回调
const WATCHDOG_CHECK_INTERVAL_MS: u64 = 1000; // 每秒检查一次
const CALLBACK_TIMEOUT_SECS: u64 = 3; // 3 秒没有回调视为异常
const FIRST_CALLBACK_DEADLINE_SECS: u64 = 5; // 5 秒内必须收到首次回调
let stop_flag_for_watchdog = Arc::clone(&stop_flag);
let state_for_watchdog = Arc::clone(&state);
@@ -160,10 +161,11 @@ fn run_audio_thread(
"[recorder] watchdog: 录音回调已停止 {} 秒,触发错误恢复",
elapsed.as_secs()
);
let _ = runtime_error_tx_for_watchdog.send(RecorderError::EngineFailed(
format!("录音回调静默停止 {} 秒", elapsed.as_secs())
));
break; // 只报告一次
let _ =
runtime_error_tx_for_watchdog.send(RecorderError::EngineFailed(
format!("录音回调静默停止 {} 秒", elapsed.as_secs()),
));
break; // 只报告一次
}
}
None => {
@@ -174,10 +176,11 @@ fn run_audio_thread(
"[recorder] watchdog: {} 秒内未收到首次回调,触发错误恢复",
elapsed.as_secs()
);
let _ = runtime_error_tx_for_watchdog.send(RecorderError::EngineFailed(
format!("录音启动后 {} 秒内未收到回调", elapsed.as_secs())
));
break; // 只报告一次
let _ =
runtime_error_tx_for_watchdog.send(RecorderError::EngineFailed(
format!("录音启动后 {} 秒内未收到回调", elapsed.as_secs()),
));
break; // 只报告一次
}
}
}
@@ -0,0 +1,253 @@
//! 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)
);
}
#[test]
fn accepts_shifted_printable_aliases() {
let cases = [
("?", Code::Slash),
("!", Code::Digit1),
(":", Code::Semicolon),
("+", Code::Equal),
("_", Code::Minus),
("{", Code::BracketLeft),
("|", Code::Backslash),
];
for (primary, expected) in cases {
let binding = ShortcutBinding {
primary: primary.into(),
modifiers: vec!["shift".into()],
};
let parsed = parse_global_hotkey(&binding).expect("shifted printable parses");
assert_eq!(parsed.key, expected);
assert!(parsed.mods.contains(Modifiers::SHIFT));
}
}
}
+309 -7
View File
@@ -109,10 +109,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,
@@ -154,11 +155,22 @@ 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,
/// 本地 Qwen3-ASR 当前激活的模型 id("qwen3-asr-0.6b" / "qwen3-asr-1.7b")。
/// 仅在 active_asr_provider == "local-qwen3" 时有意义。
#[serde(default = "default_local_asr_model")]
@@ -185,8 +197,170 @@ fn default_local_asr_keep_loaded_secs() -> u32 {
300
}
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,
#[serde(default)]
mute_during_recording: bool,
active_asr_provider: String,
active_llm_provider: String,
restore_clipboard_after_paste: bool,
allow_non_tsf_insertion_fallback: bool,
working_languages: Vec<String>,
translation_target_language: String,
chinese_script_preference: ChineseScriptPreference,
#[serde(default)]
output_language_preference: OutputLanguagePreference,
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>,
#[serde(default = "default_local_asr_model")]
local_asr_active_model: String,
#[serde(default = "default_local_asr_mirror")]
local_asr_mirror: String,
#[serde(default = "default_local_asr_keep_loaded_secs")]
local_asr_keep_loaded_secs: u32,
}
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,
mute_during_recording: prefs.mute_during_recording,
active_asr_provider: prefs.active_asr_provider,
active_llm_provider: prefs.active_llm_provider,
restore_clipboard_after_paste: prefs.restore_clipboard_after_paste,
allow_non_tsf_insertion_fallback: prefs.allow_non_tsf_insertion_fallback,
working_languages: prefs.working_languages,
translation_target_language: prefs.translation_target_language,
chinese_script_preference: prefs.chinese_script_preference,
output_language_preference: prefs.output_language_preference,
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,
local_asr_active_model: prefs.local_asr_active_model,
local_asr_mirror: prefs.local_asr_mirror,
local_asr_keep_loaded_secs: prefs.local_asr_keep_loaded_secs,
}
}
}
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 = match wire.dictation_hotkey {
Some(binding) => binding,
None => default_dictation_hotkey_from_legacy(&wire.hotkey, &wire.custom_combo_hotkey)
.map_err(serde::de::Error::custom)?,
};
Ok(Self {
hotkey: wire.hotkey,
dictation_hotkey,
default_mode: wire.default_mode,
enabled_modes: wire.enabled_modes,
launch_at_login: wire.launch_at_login,
show_capsule: wire.show_capsule,
mute_during_recording: wire.mute_during_recording,
active_asr_provider: wire.active_asr_provider,
active_llm_provider: wire.active_llm_provider,
restore_clipboard_after_paste: wire.restore_clipboard_after_paste,
allow_non_tsf_insertion_fallback: wire.allow_non_tsf_insertion_fallback,
working_languages: wire.working_languages,
translation_target_language: wire.translation_target_language,
chinese_script_preference: wire.chinese_script_preference,
output_language_preference: wire.output_language_preference,
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),
local_asr_active_model: wire.local_asr_active_model,
local_asr_mirror: wire.local_asr_mirror,
local_asr_keep_loaded_secs: wire.local_asr_keep_loaded_secs,
})
}
}
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>,
) -> Result<ShortcutBinding, String> {
if hotkey.trigger == HotkeyTrigger::Custom {
if let Some(combo) = custom_combo_hotkey {
return Ok(ShortcutBinding {
primary: combo.primary.clone(),
modifiers: combo.modifiers.clone(),
});
}
return Err(
"hotkey.trigger is custom but dictationHotkey/customComboHotkey is missing".into(),
);
}
Ok(crate::shortcut_binding::binding_from_legacy_trigger(
hotkey.trigger,
))
}
fn default_working_languages() -> Vec<String> {
@@ -197,6 +371,11 @@ impl Default for UserPreferences {
fn default() -> Self {
Self {
hotkey: HotkeyBinding::default(),
dictation_hotkey: default_dictation_hotkey_from_legacy(
&HotkeyBinding::default(),
&None,
)
.expect("default legacy hotkey is not custom"),
default_mode: PolishMode::Light,
enabled_modes: vec![
PolishMode::Raw,
@@ -217,6 +396,10 @@ impl Default for UserPreferences {
output_language_preference: OutputLanguagePreference::Auto,
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(),
local_asr_active_model: default_local_asr_model(),
local_asr_mirror: default_local_asr_mirror(),
local_asr_keep_loaded_secs: default_local_asr_keep_loaded_secs(),
@@ -224,6 +407,44 @@ impl Default for UserPreferences {
}
}
#[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"}`。
@@ -257,7 +478,7 @@ impl Default for QaHotkeyBinding {
}
impl QaHotkeyBinding {
/// 渲染成给前端展示的可读标签(macOS 用 `Cmd`,其他平台用 `Ctrl`)。
/// 渲染成给前端展示的可读标签。
/// 顺序与人类阅读习惯一致:`Cmd+Shift+;`、`Ctrl+Alt+Shift+.`。
pub fn display_label(&self) -> String {
let mut parts: Vec<String> = Vec::new();
@@ -274,9 +495,46 @@ 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",
"cmd" => {
#[cfg(target_os = "macos")]
{
"Cmd"
}
#[cfg(target_os = "windows")]
{
"Ctrl"
}
#[cfg(all(not(target_os = "macos"), not(target_os = "windows")))]
{
"Super"
}
}
"ctrl" => "Ctrl",
"alt" => {
#[cfg(target_os = "macos")]
@@ -319,6 +577,7 @@ pub enum HotkeyTrigger {
RightCommand,
Fn,
RightAlt, // Windows synonym for RightOption
Custom,
}
impl HotkeyTrigger {
@@ -331,6 +590,7 @@ impl HotkeyTrigger {
HotkeyTrigger::RightCommand => "右 Command",
HotkeyTrigger::Fn => "Fn (地球键)",
HotkeyTrigger::RightAlt => "右 Alt",
HotkeyTrigger::Custom => "自定义组合键",
}
}
}
@@ -361,7 +621,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,
@@ -392,6 +652,7 @@ impl HotkeyCapability {
HotkeyTrigger::LeftControl,
HotkeyTrigger::RightCommand,
HotkeyTrigger::Fn,
HotkeyTrigger::Custom,
],
requires_accessibility_permission: true,
supports_modifier_only_trigger: true,
@@ -410,6 +671,7 @@ impl HotkeyCapability {
HotkeyTrigger::RightAlt,
HotkeyTrigger::LeftControl,
HotkeyTrigger::RightCommand,
HotkeyTrigger::Custom,
],
requires_accessibility_permission: false,
supports_modifier_only_trigger: true,
@@ -430,6 +692,7 @@ impl HotkeyCapability {
HotkeyTrigger::RightAlt,
HotkeyTrigger::RightControl,
HotkeyTrigger::LeftControl,
HotkeyTrigger::Custom,
],
requires_accessibility_permission: false,
supports_modifier_only_trigger: true,
@@ -598,4 +861,43 @@ mod tests {
assert!(prefs.allow_non_tsf_insertion_fallback);
}
#[test]
fn legacy_custom_hotkey_without_custom_binding_is_rejected() {
let result = serde_json::from_str::<UserPreferences>(
r#"{
"hotkey": { "trigger": "custom", "mode": "toggle" }
}"#,
);
assert!(result.is_err());
}
#[test]
fn legacy_custom_hotkey_uses_custom_combo_binding() {
let prefs: UserPreferences = serde_json::from_str(
r#"{
"hotkey": { "trigger": "custom", "mode": "toggle" },
"customComboHotkey": { "primary": "D", "modifiers": ["cmd", "shift"] }
}"#,
)
.unwrap();
assert_eq!(prefs.dictation_hotkey.primary, "D");
assert_eq!(prefs.dictation_hotkey.modifiers, vec!["cmd", "shift"]);
}
#[test]
fn custom_hotkey_with_dictation_hotkey_preserves_dictation_binding() {
let prefs: UserPreferences = serde_json::from_str(
r#"{
"hotkey": { "trigger": "custom", "mode": "toggle" },
"dictationHotkey": { "primary": "Space", "modifiers": ["ctrl"] }
}"#,
)
.unwrap();
assert_eq!(prefs.dictation_hotkey.primary, "Space");
assert_eq!(prefs.dictation_hotkey.modifiers, vec!["ctrl"]);
}
}
@@ -392,10 +392,7 @@ mod tests {
#[test]
fn submit_timeout_covers_native_async_commit_path() {
assert!(
IME_SUBMIT_TIMEOUT
> Duration::from_millis(IME_NATIVE_ASYNC_COMMIT_TIMEOUT_MS)
);
assert!(IME_SUBMIT_TIMEOUT > Duration::from_millis(IME_NATIVE_ASYNC_COMMIT_TIMEOUT_MS));
}
#[test]
@@ -23,6 +23,7 @@ import {
HOTKEY_MODE_MIGRATION_DEFERRED_KEY,
shouldShowHotkeyModeMigrationPrompt,
} from '../lib/hotkeyMigration';
import { formatComboLabel } from '../lib/hotkey';
import { applyFontScale, readFontScale } from '../lib/fontScale';
import { getCredentials, openExternal } from '../lib/ipc';
import {
@@ -30,6 +31,7 @@ import {
shouldShowProviderSetupPrompt,
} from '../lib/providerSetup';
import { NAVIGATE_LOCAL_ASR_EVENT, type SettingsSectionId } from '../pages/Settings';
import { useHotkeySettings } from '../state/HotkeySettingsContext';
import { useAppState, type AppTab } from '../state/useAppState';
interface NavItem {
@@ -74,6 +76,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 { prefs } = useHotkeySettings();
// tab 切换的 cross-fade:旧页 blur+fade out(180ms),结束后挂载新页(走 ol-page-slide enter)。
// displayTab 是实际渲染的 tab,currentTab 是用户点中的目标 tab。
@@ -161,6 +164,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');
@@ -241,6 +256,21 @@ function FloatingShellBody({ os, initialTab, initialSettings }: { os: OS; initia
<div style={{ flex: 1 }} />
{/* shortcut hint — 不要 dashed 边框,否则会切断"整片磨砂玻璃"的视觉 */}
<div style={{ padding: '10px 10px 6px', marginTop: 6 }}>
<div style={{ fontSize: 10.5, color: 'var(--ol-ink-4)', marginBottom: 6, letterSpacing: '0.02em' }}>{t('shell.shortcutLabel')}</div>
<div style={{ display: 'flex', alignItems: 'center', gap: 6, fontSize: 11, color: 'var(--ol-ink-2)' }}>
<kbd style={{
padding: '2px 7px', fontSize: 10.5,
background: 'rgba(255,255,255,0.7)', borderRadius: 5,
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)',
}}>{prefs ? formatComboLabel(prefs.dictationHotkey) : ''}</kbd>
<span style={{ color: 'var(--ol-ink-4)' }}>{t('shell.shortcutHint')}</span>
</div>
</div>
{/* BETA 区域 — 去掉描边和实色背景,让它和底部 footer 一起浮在磨砂玻璃上 */}
<div style={{ marginTop: 8, padding: '10px 10px 4px' }}>
<div style={{ fontSize: 10.5, fontWeight: 600, color: 'var(--ol-blue)', letterSpacing: '0.04em', textTransform: 'uppercase' }}>{t('shell.betaTag')}</div>
@@ -0,0 +1,219 @@
import { useEffect, useRef, useState, type CSSProperties, type KeyboardEvent } from 'react';
import { useTranslation } from 'react-i18next';
import { currentPlatform, 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(currentPlatform().isMac ? 'cmd' : 'super');
if (e.ctrlKey && e.key !== 'Control') modifiers.push('ctrl');
if (e.altKey && e.key !== 'Alt') modifiers.push('alt');
if (e.shiftKey && e.key !== 'Shift') modifiers.push('shift');
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 {
const printable = primaryFromPrintableCode(e.code);
if (printable) return printable;
if (e.key.length === 1) return e.key;
const codeToName: Record<string, string> = {
Space: 'Space',
Enter: 'Enter',
Tab: 'Tab',
Backspace: 'Backspace',
Delete: 'Delete',
ArrowUp: 'ArrowUp',
ArrowDown: 'ArrowDown',
ArrowLeft: 'ArrowLeft',
ArrowRight: 'ArrowRight',
Home: 'Home',
End: 'End',
PageUp: 'PageUp',
PageDown: 'PageDown',
};
if (/^F\d{1,2}$/.test(e.key)) return e.key;
return codeToName[e.code] || e.key;
}
function primaryFromPrintableCode(code: string): string {
if (/^Key[A-Z]$/.test(code)) return code.slice(3);
if (/^Digit[0-9]$/.test(code)) return code.slice(5);
const codeToPrimary: Record<string, string> = {
Backquote: '`',
Minus: '-',
Equal: '=',
BracketLeft: '[',
BracketRight: ']',
Backslash: '\\',
Semicolon: ';',
Quote: "'",
Comma: ',',
Period: '.',
Slash: '/',
IntlBackslash: '\\',
};
return codeToPrimary[code] || '';
}
+9 -1
View File
@@ -273,13 +273,20 @@ 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.',
muteDuringRecordingLabel: 'Mute while recording',
muteDuringRecordingDesc: 'Temporarily mute system output during voice input, then restore the previous mute state when recording stops, is cancelled, or fails.',
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',
allowNonTsfFallbackLabel: 'Allow non-TSF fallback',
allowNonTsfFallbackDesc: 'Windows only: if direct TSF insertion fails, allow Unicode SendInput, shortcut paste, or WM_PASTE. Turn off to verify that insertion is really coming from TSF.',
startupAtBoot: 'Launch at login',
@@ -525,6 +532,7 @@ export const en: typeof zhCN = {
rightCommand: 'Right Command',
fn: 'Fn (Globe key)',
rightAlt: 'Right Alt',
custom: 'Custom combination\u2026',
},
fallback: 'Global hotkey',
modeHoldSuffix: ' (push-to-talk)',
+9 -1
View File
@@ -271,13 +271,20 @@ export const zhCN = {
modeToggle: '切换式',
modeHold: '按住说话',
migrationNoticeTitle: '默认已改为切换式说话',
migrationNoticeDesc: '如果你之前改过快捷键触发方式,请在这里手动确认一次。本次更新调整了快捷键方式的默认值与读取逻辑;如果你更习惯按住说话,可以重新切回“按住说话”。',
migrationNoticeDesc: '如果你之前改过快捷键触发方式,请在这里手动确认一次。本次更新调整了快捷键方式的默认值与读取逻辑;如果你更习惯按住说话,可以重新切回"按住说话"。',
capsuleLabel: '录音胶囊',
capsuleDesc: '录音 / 转写时在屏幕底部显示半透明胶囊。',
muteDuringRecordingLabel: '录音时静音',
muteDuringRecordingDesc: '开始语音输入时临时静音系统输出,停止、取消或出错后恢复原来的静音状态,避免扬声器声音被麦克风收进去。',
restoreClipboardLabel: '插入后恢复剪贴板',
restoreClipboardDesc: '仅 Windows / Linux:粘贴成功后恢复你原来的剪贴板内容(默认开)。关掉就把听写文本留在剪贴板,模拟粘贴没真正落地时可以手动 Ctrl+V 找回。详见 issue #111。',
comboRecordLabel: '录制快捷键',
comboRecordDesc: '点击后按下你想要的快捷键组合(如 ⌘⇧D),支持 Toggle 和 Hold 模式。',
comboRecordBtn: '录制快捷键',
comboRecordHint: '请按下快捷键组合…',
comboRecorded: '已录制',
comboClear: '清除',
comboConflict: '该快捷键组合不可用',
allowNonTsfFallbackLabel: '允许非 TSF 兜底',
allowNonTsfFallbackDesc: '仅 Windows:TSF 直接上屏失败后,允许改用 Unicode SendInput、快捷键粘贴或 WM_PASTE。关闭后可验证是否真实使用 TSF 输入。',
startupAtBoot: '开机自启',
@@ -523,6 +530,7 @@ export const zhCN = {
rightCommand: '右 Command',
fn: 'Fn (地球键)',
rightAlt: '右 Alt',
custom: '自定义组合键…',
},
fallback: '全局快捷键',
modeHoldSuffix: '(按住说话)',
+8
View File
@@ -274,6 +274,13 @@ export const zhTW: typeof zhCN = {
modeHold: '按住說話',
migrationNoticeTitle: '默認已改爲切換式說話',
migrationNoticeDesc: '如果你之前改過快捷鍵觸發方式,請在這裏手動確認一次。本次更新調整了快捷鍵方式的默認值與讀取邏輯;如果你更習慣按住說話,可以重新切回“按住說話”。',
comboRecordLabel: '錄製快捷鍵',
comboRecordDesc: '點擊後按下你想要的快捷鍵組合(如 ⌘⇧D),支援 Toggle 與 Hold 模式。',
comboRecordBtn: '錄製快捷鍵',
comboRecordHint: '請按下快捷鍵組合…',
comboRecorded: '已錄製',
comboClear: '清除',
comboConflict: '此快捷鍵組合不可用',
capsuleLabel: '錄音膠囊',
capsuleDesc: '錄音 / 轉寫時在屏幕底部顯示半透明膠囊。',
muteDuringRecordingLabel: '錄音時靜音',
@@ -525,6 +532,7 @@ export const zhTW: typeof zhCN = {
rightCommand: '右 Command',
fn: 'Fn (地球鍵)',
rightAlt: '右 Alt',
custom: '自訂組合…',
},
fallback: '全局快捷鍵',
modeHoldSuffix: '(按住說話)',
+138 -3
View File
@@ -1,12 +1,41 @@
import i18n from '../i18n';
import type { HotkeyBinding, HotkeyTrigger } from './types';
import type { ComboBinding, HotkeyBinding, HotkeyTrigger, QaHotkeyBinding, ShortcutBinding } from './types';
export function defaultQaShortcut(): ShortcutBinding {
return {
primary: ';',
modifiers: defaultAppShortcutModifiers(),
};
}
export function defaultAppShortcutModifiers(): string[] {
return currentPlatform().isMac ? ['cmd', 'shift'] : ['ctrl', 'shift'];
}
export function getHotkeyTriggerLabel(trigger: HotkeyTrigger | null | undefined): string {
if (!trigger) return i18n.t('hotkey.fallback');
if (trigger === 'custom') return i18n.t('hotkey.triggers.custom');
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 +43,115 @@ 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 platform = currentPlatform();
// 固定输出顺序:Ctrl/Cmd → Alt/Option → Shift → Super
const modifierOrder = ['cmd', 'ctrl', 'alt', 'shift', 'super'] as const;
for (const tag of modifierOrder) {
if (binding.modifiers.some(m => m.toLowerCase() === tag)) {
parts.push(modifierDisplayName(tag, platform));
}
}
parts.push(formatPrimary(binding.primary));
return parts.join(platform.isMac ? '' : '+');
}
export function currentPlatform(): { isMac: boolean; isWindows: boolean } {
const nav = typeof navigator === 'undefined' ? null : navigator;
const platform = nav?.platform || '';
const userAgent = nav?.userAgent || '';
return {
isMac: platform.includes('Mac') || userAgent.includes('Mac'),
isWindows: platform.includes('Win') || userAgent.includes('Windows'),
};
}
function modifierDisplayName(tag: string, platform: { isMac: boolean; isWindows: boolean }): string {
if (platform.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 platform.isWindows ? 'Ctrl' : 'Super';
case 'ctrl': return 'Ctrl';
case 'alt': return 'Alt';
case 'shift': return 'Shift';
case 'super': return platform.isWindows ? 'Win' : 'Super';
}
}
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 = currentPlatform().isMac;
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 ⌘' : (currentPlatform().isWindows ? 'Right Win' : 'Right Super');
case 'fn': return 'Fn';
case 'shift': return isMac ? '⇧' : 'Shift';
}
return trimmed;
}
+47 -6
View File
@@ -3,6 +3,7 @@
// the UI is still operable for visual review.
import type {
ComboBinding,
CredentialsStatus,
DictationSession,
DictionaryEntry,
@@ -11,12 +12,13 @@ import type {
PermissionStatus,
PolishMode,
QaHotkeyBinding,
ShortcutBinding,
UserPreferences,
WindowsImeStatus,
VocabPreset,
VocabPresetStore,
} from './types';
import { OL_DATA } from './mockData';
import { defaultAppShortcutModifiers, defaultQaShortcut, formatComboLabel } from './hotkey';
declare global {
interface Window {
@@ -24,7 +26,8 @@ declare global {
}
}
const isTauri = typeof window !== 'undefined' && '__TAURI_INTERNALS__' in window;
const isTauri =
globalThis.window !== undefined && '__TAURI_INTERNALS__' in globalThis.window;
export async function invokeOrMock<T>(
cmd: string,
@@ -41,6 +44,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,
@@ -52,10 +56,14 @@ const mockSettings: UserPreferences = {
allowNonTsfInsertionFallback: true,
workingLanguages: ['简体中文'],
translationTargetLanguage: '',
qaHotkey: defaultQaShortcut(),
chineseScriptPreference: 'auto',
outputLanguagePreference: 'auto',
qaHotkey: { primary: ';', modifiers: ['cmd', 'shift'] },
qaSaveHistory: false,
customComboHotkey: null,
translationHotkey: { primary: 'Shift', modifiers: [] },
switchStyleHotkey: { primary: 'S', modifiers: defaultAppShortcutModifiers() },
openAppHotkey: { primary: 'O', modifiers: defaultAppShortcutModifiers() },
localAsrActiveModel: 'qwen3-asr-0.6b',
localAsrMirror: 'huggingface',
localAsrKeepLoadedSecs: 300,
@@ -63,7 +71,7 @@ const mockSettings: UserPreferences = {
const mockHotkeyCapability: HotkeyCapability = {
adapter: 'windowsLowLevel',
availableTriggers: ['rightControl', 'rightAlt', 'leftControl', 'rightCommand'],
availableTriggers: ['rightControl', 'rightAlt', 'leftControl', 'rightCommand', 'custom'],
requiresAccessibilityPermission: false,
supportsModifierOnlyTrigger: true,
supportsSideSpecificModifiers: true,
@@ -296,10 +304,10 @@ export function restartApp(): Promise<void> {
// 详见 issue #118。后端会发 `qa:state` / `qa:dismiss` 事件;前端通过下面四个
// 命令查询与控制 QA 浮窗。
export function getQaHotkeyLabel(): Promise<string> {
return invokeOrMock('get_qa_hotkey_label', undefined, () => 'Cmd+Shift+;');
return invokeOrMock('get_qa_hotkey_label', undefined, () => formatComboLabel(defaultQaShortcut()));
}
export function setQaHotkey(binding: QaHotkeyBinding): Promise<void> {
export function setQaHotkey(binding: QaHotkeyBinding | null): Promise<void> {
return invokeOrMock('set_qa_hotkey', { binding }, () => undefined);
}
@@ -311,6 +319,39 @@ export function qaWindowPin(pinned: boolean): Promise<void> {
return invokeOrMock('qa_window_pin', { pinned }, () => undefined);
}
// ── Combo Hotkey (自定义录音组合键) ───────────────────────────────────
export function validateComboHotkey(binding: ComboBinding): Promise<void> {
return invokeOrMock('validate_combo_hotkey', { binding }, () => undefined);
}
export function setComboHotkey(binding: ComboBinding): Promise<void> {
return invokeOrMock('set_combo_hotkey', { binding }, () => undefined);
}
export function validateShortcutBinding(binding: ShortcutBinding): Promise<void> {
return invokeOrMock('validate_shortcut_binding', { binding }, () => undefined);
}
export function setDictationHotkey(binding: ShortcutBinding): Promise<void> {
return invokeOrMock('set_dictation_hotkey', { binding }, () => undefined);
}
export function setTranslationHotkey(binding: ShortcutBinding): Promise<void> {
return invokeOrMock('set_translation_hotkey', { binding }, () => undefined);
}
export function setSwitchStyleHotkey(binding: ShortcutBinding): Promise<void> {
return invokeOrMock('set_switch_style_hotkey', { binding }, () => undefined);
}
export function setOpenAppHotkey(binding: ShortcutBinding): Promise<void> {
return invokeOrMock('set_open_app_hotkey', { binding }, () => undefined);
}
export function setShortcutRecordingActive(active: boolean): Promise<void> {
return invokeOrMock('set_shortcut_recording_active', { active }, () => undefined);
}
export async function openExternal(url: string): Promise<void> {
if (!isTauri) {
window.open(url, '_blank', 'noopener,noreferrer');
+20 -5
View File
@@ -48,7 +48,8 @@ export type HotkeyTrigger =
| 'leftControl'
| 'rightCommand'
| 'fn'
| 'rightAlt';
| 'rightAlt'
| 'custom';
export type HotkeyMode = 'toggle' | 'hold';
@@ -83,14 +84,19 @@ 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 type WindowsImeInstallState =
| 'installed'
| 'notInstalled'
@@ -106,6 +112,7 @@ export interface WindowsImeStatus {
export interface UserPreferences {
hotkey: HotkeyBinding;
dictationHotkey: ShortcutBinding;
defaultMode: PolishMode;
enabledModes: PolishMode[];
launchAtLogin: boolean;
@@ -130,6 +137,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;
/** 本地 Qwen3-ASR 当前激活的模型 id。仅在 activeAsrProvider === 'local-qwen3' 时有意义。 */
localAsrActiveModel: string;
/** 本地模型下载源镜像('huggingface' / 'hf-mirror')。 */
+3 -3
View File
@@ -5,7 +5,7 @@ import { useEffect, useMemo, useState } from 'react';
import { useTranslation } from 'react-i18next';
import { Icon } from '../components/Icon';
import { detectOS } from '../components/WindowChrome';
import { getHotkeyTriggerLabel } from '../lib/hotkey';
import { formatComboLabel } from '../lib/hotkey';
import { clearHistory, deleteHistoryEntry, listHistory } from '../lib/ipc';
import type { DictationSession, PolishMode } from '../lib/types';
import { useHotkeySettings } from '../state/HotkeySettingsContext';
@@ -41,7 +41,7 @@ export function History() {
const [items, setItems] = useState<DictationSession[]>([]);
const [selectedId, setSelectedId] = useState<string | null>(null);
const [loading, setLoading] = useState(true);
const { hotkey } = useHotkeySettings();
const { prefs } = useHotkeySettings();
const refresh = async () => {
const data = await listHistory();
@@ -130,7 +130,7 @@ export function History() {
{loading && <div style={{ padding: 16, fontSize: 12, color: 'var(--ol-ink-4)' }}>{t('common.loading')}</div>}
{!loading && filtered.length === 0 && (
<div style={{ padding: 16, fontSize: 12, color: 'var(--ol-ink-4)' }}>
{t('history.empty', { trigger: getHotkeyTriggerLabel(hotkey?.trigger) })}
{t('history.empty', { trigger: prefs ? formatComboLabel(prefs.dictationHotkey) : '' })}
</div>
)}
{filtered.map(s => (
+26 -3
View File
@@ -3,7 +3,7 @@
import { useEffect, useMemo, useState } from 'react';
import { useTranslation } from 'react-i18next';
import { Icon } from '../components/Icon';
import { getHotkeyTriggerLabel } from '../lib/hotkey';
import { formatComboLabel } from '../lib/hotkey';
import { getCredentials, listHistory } from '../lib/ipc';
import type { CredentialsStatus, DictationSession, PolishMode } from '../lib/types';
import { useHotkeySettings } from '../state/HotkeySettingsContext';
@@ -52,7 +52,7 @@ export function Overview({ onOpenHistory }: OverviewProps) {
volcengineConfigured: false,
arkConfigured: false,
});
const { hotkey } = useHotkeySettings();
const { prefs } = useHotkeySettings();
useEffect(() => {
listHistory().then(setHistory);
@@ -102,6 +102,29 @@ export function Overview({ onOpenHistory }: OverviewProps) {
kicker={t('overview.kicker')}
title={t('overview.title')}
desc={t('overview.desc')}
right={
<div
style={{
display: 'inline-flex', alignItems: 'center', gap: 8,
padding: '6px 12px',
borderRadius: 999,
border: '0.5px solid var(--ol-line-strong)',
background: 'var(--ol-surface-2)',
color: 'var(--ol-ink-3)',
fontSize: 12,
}}
>
<Icon name="cmd" size={12} />
{t('overview.pressPrefix')}
<kbd style={{
padding: '2px 7px', fontSize: 11, fontFamily: 'var(--ol-font-mono)',
background: '#fff', borderRadius: 5,
border: '0.5px solid var(--ol-line-strong)',
color: 'var(--ol-ink)',
}}>{prefs ? formatComboLabel(prefs.dictationHotkey) : ''}</kbd>
{t('overview.pressSuffix')}
</div>
}
/>
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: 12, marginBottom: 18 }}>
@@ -149,7 +172,7 @@ export function Overview({ onOpenHistory }: OverviewProps) {
<div className="ol-thinscroll" style={{ flex: 1, minHeight: 0, overflow: 'auto' }}>
{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 => (
+24 -113
View File
@@ -9,81 +9,15 @@ 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 { defaultQaShortcut, 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 defaultQaHotkey = defaultQaShortcut();
const defaultHotkeyLabel = formatComboLabel(defaultQaHotkey);
const recordHotkeyLabel = prefs ? formatComboLabel(prefs.dictationHotkey) : '快捷键';
if (!prefs) {
return (
@@ -103,31 +37,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 +78,25 @@ 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 () => {
const nextHotkey = enabled ? null : defaultQaHotkey;
await setQaHotkey(nextHotkey);
await savePrefs({ ...prefs, qaHotkey: nextHotkey });
}}
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. 历史保存 */}
+85 -24
View File
@@ -5,10 +5,11 @@
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 { defaultQaShortcut } from '../lib/hotkey';
import {
checkAccessibilityPermission,
checkMicrophonePermission,
@@ -24,13 +25,17 @@ import {
setActiveAsrProvider,
setActiveLlmProvider,
setCredential,
setDictationHotkey,
setOpenAppHotkey,
setQaHotkey,
setSwitchStyleHotkey,
setTranslationHotkey,
validateProviderCredentials,
} from '../lib/ipc';
import type {
HotkeyCapability,
HotkeyMode,
HotkeyStatus,
HotkeyTrigger,
PermissionStatus,
WindowsImeStatus,
} from '../lib/types';
@@ -177,8 +182,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) =>
@@ -222,19 +225,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)' }}>
@@ -913,9 +910,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>
@@ -926,19 +923,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,
+21 -4
View File
@@ -8,11 +8,13 @@ import { useTranslation } from 'react-i18next';
import { Card, PageHeader } from './_atoms';
import { SUPPORTED_LANGUAGES } from '../lib/types';
import { useHotkeySettings } from '../state/HotkeySettingsContext';
import { getHotkeyTriggerLabel } from '../lib/hotkey';
import { formatComboLabel } from '../lib/hotkey';
import { ShortcutRecorder } from '../components/ShortcutRecorder';
import { setTranslationHotkey } from '../lib/ipc';
export function Translation() {
const { t } = useTranslation();
const { prefs, updatePrefs: savePrefs, hotkey } = useHotkeySettings();
const { prefs, updatePrefs: savePrefs } = useHotkeySettings();
if (!prefs) {
return (
@@ -40,7 +42,8 @@ export function Translation() {
const onTargetChange = (translationTargetLanguage: string) =>
savePrefs({ ...prefs, translationTargetLanguage });
const triggerLabel = getHotkeyTriggerLabel(hotkey?.trigger);
const triggerLabel = formatComboLabel(prefs.dictationHotkey);
const translationHotkeyLabel = formatComboLabel(prefs.translationHotkey);
const enabled = prefs.translationTargetLanguage.trim() !== '';
return (
@@ -132,13 +135,27 @@ export function Translation() {
</select>
</Card>
<Card>
<div style={{ fontSize: 13, fontWeight: 600, marginBottom: 4 }}>{t('translation.hotkey.title', 'Translation shortcut')}</div>
<div style={{ fontSize: 11.5, color: 'var(--ol-ink-4)', marginBottom: 12, lineHeight: 1.55 }}>
{t('translation.hotkey.desc', 'Press this during recording to switch the current dictation into translation mode.')}
</div>
<ShortcutRecorder
value={prefs.translationHotkey}
onSave={async binding => {
await setTranslationHotkey(binding);
await savePrefs({ ...prefs, translationHotkey: binding });
}}
/>
</Card>
{/* 3. 使用方法 */}
<Card>
<div style={{ fontSize: 13, fontWeight: 600, marginBottom: 10 }}>{t('translation.howto.title')}</div>
<ol style={{ margin: 0, paddingLeft: 18, fontSize: 12.5, color: 'var(--ol-ink-2)', lineHeight: 1.7 }}>
<li>{t('translation.howto.step1', { trigger: triggerLabel })}</li>
<li>{t('translation.howto.step2', { trigger: triggerLabel })}</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>