Merge pull request #236 from LeslieLeung/feature/key-binding
feat: add configurable shortcut bindings
This commit is contained in:
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());
|
||||
}
|
||||
}
|
||||
@@ -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]
|
||||
|
||||
@@ -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");
|
||||
}
|
||||
@@ -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"),
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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()
|
||||
};
|
||||
|
||||
@@ -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());
|
||||
}
|
||||
}
|
||||
@@ -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));
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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] || '';
|
||||
}
|
||||
@@ -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)',
|
||||
|
||||
@@ -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: '(按住说话)',
|
||||
|
||||
@@ -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: '(按住說話)',
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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');
|
||||
|
||||
@@ -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')。 */
|
||||
|
||||
@@ -5,7 +5,7 @@ import { useEffect, useMemo, useState } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { Icon } from '../components/Icon';
|
||||
import { detectOS } from '../components/WindowChrome';
|
||||
import { getHotkeyTriggerLabel } from '../lib/hotkey';
|
||||
import { formatComboLabel } from '../lib/hotkey';
|
||||
import { clearHistory, deleteHistoryEntry, listHistory } from '../lib/ipc';
|
||||
import type { DictationSession, PolishMode } from '../lib/types';
|
||||
import { useHotkeySettings } from '../state/HotkeySettingsContext';
|
||||
@@ -41,7 +41,7 @@ export function History() {
|
||||
const [items, setItems] = useState<DictationSession[]>([]);
|
||||
const [selectedId, setSelectedId] = useState<string | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const { hotkey } = useHotkeySettings();
|
||||
const { prefs } = useHotkeySettings();
|
||||
|
||||
const refresh = async () => {
|
||||
const data = await listHistory();
|
||||
@@ -130,7 +130,7 @@ export function History() {
|
||||
{loading && <div style={{ padding: 16, fontSize: 12, color: 'var(--ol-ink-4)' }}>{t('common.loading')}</div>}
|
||||
{!loading && filtered.length === 0 && (
|
||||
<div style={{ padding: 16, fontSize: 12, color: 'var(--ol-ink-4)' }}>
|
||||
{t('history.empty', { trigger: getHotkeyTriggerLabel(hotkey?.trigger) })}
|
||||
{t('history.empty', { trigger: prefs ? formatComboLabel(prefs.dictationHotkey) : '' })}
|
||||
</div>
|
||||
)}
|
||||
{filtered.map(s => (
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
import { useEffect, useMemo, useState } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { Icon } from '../components/Icon';
|
||||
import { getHotkeyTriggerLabel } from '../lib/hotkey';
|
||||
import { formatComboLabel } from '../lib/hotkey';
|
||||
import { getCredentials, listHistory } from '../lib/ipc';
|
||||
import type { CredentialsStatus, DictationSession, PolishMode } from '../lib/types';
|
||||
import { useHotkeySettings } from '../state/HotkeySettingsContext';
|
||||
@@ -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 => (
|
||||
|
||||
@@ -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. 历史保存 */}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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>
|
||||
|
||||
Reference in new issue
Block a user