feat(hotkey): 全局快捷键可停用/清空开关 (#576)
0602 版本默认注册的「切换风格 / 唤起 App」全局快捷键无法关闭,与其他软件冲突 且会拦截软件内快捷键。本 PR 复用 QA 快捷键已有的 `Option<ShortcutBinding>=None` 停用模式,给这两个 action 键加上「可清空停用」能力。 默认仍开启(Some 默认键),对老用户零行为变化,仅新增可关。 后端: - types.rs: 内层 AppPrefs switch_style_hotkey/open_app_hotkey 改 Option;default fn 返回 Some(默认键);Wire::default 改携带 Some(缺字段=启用);from-wire 去掉 unwrap_or_else 直传 Option(None=停用不再被塌缩成默认键——这正是「无法关闭」的根因)。 - coordinator.rs: action_hotkey_binding 返回 Option;supervisor / update 加 None 反注册 分支;reset-latch 对 Option 解包。 - commands.rs: set_switch_style_hotkey/set_open_app_hotkey 接受 Option(None=停用,跳过 validate/overlap);所有 reject_*_overlap 读取处对 Option 解包(停用项不参与冲突检测)。 前端: - types.ts/ipc.ts: switchStyleHotkey/openAppHotkey 与 setter 改 nullable。 - ShortcutsSection: 有值→录制器+「停用」按钮;停用→「启用」按钮恢复默认(仿 QA 行)。 - lib/hotkey.ts: defaultSwitchStyleShortcut/defaultOpenAppShortcut helper。 - 5 份 i18n 加 settings.shortcuts.enable/disable。 测试:默认启用、缺字段启用、None 往返为 null、显式绑定往返、overlap 拒绝(含停用项跳过)。
This commit is contained in:
1 parent
30cf33e2b1
commit
d8ec007fcd
12 files changed
+293
-97
No files matched your search
@@ -1879,8 +1879,12 @@ pub fn set_qa_hotkey(
|
||||
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)?;
|
||||
if let Some(switch_style) = prefs.switch_style_hotkey.as_ref() {
|
||||
reject_qa_switch_style_hotkey_overlap(binding, switch_style)?;
|
||||
}
|
||||
if let Some(open_app) = prefs.open_app_hotkey.as_ref() {
|
||||
reject_qa_open_app_hotkey_overlap(binding, open_app)?;
|
||||
}
|
||||
}
|
||||
prefs.qa_hotkey = binding;
|
||||
coord.prefs().set(prefs).map_err(|e| e.to_string())?;
|
||||
@@ -1920,8 +1924,12 @@ pub fn set_dictation_hotkey(
|
||||
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)?;
|
||||
if let Some(switch_style) = prefs.switch_style_hotkey.as_ref() {
|
||||
reject_dictation_switch_style_hotkey_overlap(&binding, switch_style)?;
|
||||
}
|
||||
if let Some(open_app) = prefs.open_app_hotkey.as_ref() {
|
||||
reject_dictation_open_app_hotkey_overlap(&binding, open_app)?;
|
||||
}
|
||||
prefs.dictation_hotkey = binding;
|
||||
sync_dictation_hotkey_legacy_fields(&mut prefs);
|
||||
coord.prefs().set(prefs).map_err(|e| e.to_string())?;
|
||||
@@ -1941,8 +1949,12 @@ pub fn set_translation_hotkey(
|
||||
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)?;
|
||||
if let Some(switch_style) = previous.switch_style_hotkey.as_ref() {
|
||||
reject_translation_switch_style_hotkey_overlap(&binding, switch_style)?;
|
||||
}
|
||||
if let Some(open_app) = previous.open_app_hotkey.as_ref() {
|
||||
reject_translation_open_app_hotkey_overlap(&binding, open_app)?;
|
||||
}
|
||||
let mut prefs = previous.clone();
|
||||
prefs.translation_hotkey = binding;
|
||||
coord.prefs().set(prefs).map_err(|e| e.to_string())?;
|
||||
@@ -1956,40 +1968,55 @@ pub fn set_translation_hotkey(
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// 设置「切换风格」全局快捷键。`binding == None`(前端传 null)= 停用:清空绑定并
|
||||
/// 反注册全局键。镜像 `set_qa_hotkey` 的 `Option=None` 停用模式(issue #576)。
|
||||
#[tauri::command]
|
||||
pub fn set_switch_style_hotkey(
|
||||
coord: CoordinatorState<'_>,
|
||||
binding: ShortcutBinding,
|
||||
binding: Option<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)?;
|
||||
if let Some(binding) = binding.as_ref() {
|
||||
crate::shortcut_binding::validate_binding(binding).map_err(|e| e.to_string())?;
|
||||
reject_modifier_only_action_shortcut(binding)?;
|
||||
}
|
||||
let mut prefs = coord.prefs().get();
|
||||
if let Some(binding) = binding.as_ref() {
|
||||
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)?;
|
||||
}
|
||||
if let Some(open_app) = prefs.open_app_hotkey.as_ref() {
|
||||
reject_switch_style_open_app_hotkey_overlap(binding, open_app)?;
|
||||
}
|
||||
}
|
||||
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(())
|
||||
}
|
||||
|
||||
/// 设置「唤起 App」全局快捷键。`binding == None`(前端传 null)= 停用(同上)。
|
||||
#[tauri::command]
|
||||
pub fn set_open_app_hotkey(
|
||||
coord: CoordinatorState<'_>,
|
||||
binding: ShortcutBinding,
|
||||
binding: Option<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)?;
|
||||
if let Some(binding) = binding.as_ref() {
|
||||
crate::shortcut_binding::validate_binding(binding).map_err(|e| e.to_string())?;
|
||||
reject_modifier_only_action_shortcut(binding)?;
|
||||
}
|
||||
let mut prefs = coord.prefs().get();
|
||||
if let Some(binding) = binding.as_ref() {
|
||||
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)?;
|
||||
}
|
||||
if let Some(switch_style) = prefs.switch_style_hotkey.as_ref() {
|
||||
reject_switch_style_open_app_hotkey_overlap(switch_style, 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();
|
||||
@@ -2030,8 +2057,12 @@ pub fn set_combo_hotkey(coord: CoordinatorState<'_>, binding: ComboBinding) -> R
|
||||
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)?;
|
||||
if let Some(switch_style) = prefs.switch_style_hotkey.as_ref() {
|
||||
reject_dictation_switch_style_hotkey_overlap(&shortcut, switch_style)?;
|
||||
}
|
||||
if let Some(open_app) = prefs.open_app_hotkey.as_ref() {
|
||||
reject_dictation_open_app_hotkey_overlap(&shortcut, open_app)?;
|
||||
}
|
||||
prefs.custom_combo_hotkey = Some(binding);
|
||||
prefs.dictation_hotkey = shortcut;
|
||||
sync_dictation_hotkey_legacy_fields(&mut prefs);
|
||||
@@ -2088,30 +2119,34 @@ fn reject_hotkey_overlap(
|
||||
}
|
||||
|
||||
fn reject_hotkey_collisions(prefs: &UserPreferences) -> Result<(), String> {
|
||||
// 停用(None)的 action 快捷键不参与任何冲突检测。
|
||||
let switch_style = prefs.switch_style_hotkey.as_ref();
|
||||
let open_app = prefs.open_app_hotkey.as_ref();
|
||||
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)?;
|
||||
if let Some(switch_style) = switch_style {
|
||||
reject_qa_switch_style_hotkey_overlap(qa_hotkey, switch_style)?;
|
||||
}
|
||||
if let Some(open_app) = open_app {
|
||||
reject_qa_open_app_hotkey_overlap(qa_hotkey, open_app)?;
|
||||
}
|
||||
}
|
||||
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,
|
||||
)?;
|
||||
if let Some(switch_style) = switch_style {
|
||||
reject_dictation_switch_style_hotkey_overlap(&prefs.dictation_hotkey, switch_style)?;
|
||||
reject_translation_switch_style_hotkey_overlap(&prefs.translation_hotkey, switch_style)?;
|
||||
}
|
||||
if let Some(open_app) = open_app {
|
||||
reject_dictation_open_app_hotkey_overlap(&prefs.dictation_hotkey, open_app)?;
|
||||
reject_translation_open_app_hotkey_overlap(&prefs.translation_hotkey, open_app)?;
|
||||
}
|
||||
if let (Some(switch_style), Some(open_app)) = (switch_style, open_app) {
|
||||
reject_switch_style_open_app_hotkey_overlap(switch_style, open_app)?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
@@ -4064,14 +4099,14 @@ mod tests {
|
||||
primary: "T".to_string(),
|
||||
modifiers: vec!["ctrl".to_string(), "alt".to_string()],
|
||||
},
|
||||
switch_style_hotkey: ShortcutBinding {
|
||||
switch_style_hotkey: Some(ShortcutBinding {
|
||||
primary: "S".to_string(),
|
||||
modifiers: vec!["ctrl".to_string(), "alt".to_string()],
|
||||
},
|
||||
open_app_hotkey: ShortcutBinding {
|
||||
}),
|
||||
open_app_hotkey: Some(ShortcutBinding {
|
||||
primary: "O".to_string(),
|
||||
modifiers: vec!["ctrl".to_string(), "alt".to_string()],
|
||||
},
|
||||
}),
|
||||
hotkey: HotkeyBinding {
|
||||
trigger: HotkeyTrigger::Custom,
|
||||
mode: HotkeyMode::Hold,
|
||||
@@ -4488,7 +4523,7 @@ mod tests {
|
||||
};
|
||||
let prefs = UserPreferences {
|
||||
translation_hotkey: binding.clone(),
|
||||
switch_style_hotkey: binding,
|
||||
switch_style_hotkey: Some(binding),
|
||||
..Default::default()
|
||||
};
|
||||
|
||||
@@ -4507,8 +4542,8 @@ mod tests {
|
||||
modifiers: vec!["cmd".into(), "shift".into()],
|
||||
};
|
||||
let prefs = UserPreferences {
|
||||
switch_style_hotkey: binding.clone(),
|
||||
open_app_hotkey: binding,
|
||||
switch_style_hotkey: Some(binding.clone()),
|
||||
open_app_hotkey: Some(binding),
|
||||
..Default::default()
|
||||
};
|
||||
|
||||
|
||||
@@ -728,7 +728,12 @@ impl Coordinator {
|
||||
}
|
||||
|
||||
fn update_action_hotkey_binding(&self, kind: ActionHotkeyKind) {
|
||||
let binding = action_hotkey_binding(&self.inner, kind);
|
||||
// None = 用户主动停用:反注册全局键,立即生效。
|
||||
let Some(binding) = action_hotkey_binding(&self.inner, kind) else {
|
||||
take_action_hotkey_on_main_thread(&self.inner, kind);
|
||||
log::info!("[coord] action hotkey {kind:?} 已停用(用户清空)");
|
||||
return;
|
||||
};
|
||||
if is_modifier_only_shortcut(&binding) {
|
||||
take_action_hotkey_on_main_thread(&self.inner, kind);
|
||||
log::warn!("[coord] action hotkey {kind:?} 使用了不支持的 modifier-only 绑定,已关闭");
|
||||
@@ -1517,7 +1522,12 @@ fn action_hotkey_supervisor_loop(inner: Arc<Inner>, kind: ActionHotkeyKind) {
|
||||
if inner.shutdown.load(Ordering::SeqCst) {
|
||||
return;
|
||||
}
|
||||
let binding = action_hotkey_binding(&inner, kind);
|
||||
// None = 用户主动停用:反注册并睡着等 prefs 改动(由 update 路径唤醒)。
|
||||
let Some(binding) = action_hotkey_binding(&inner, kind) else {
|
||||
take_action_hotkey_on_main_thread(&inner, kind);
|
||||
std::thread::sleep(std::time::Duration::from_secs(5));
|
||||
continue;
|
||||
};
|
||||
if is_modifier_only_shortcut(&binding) {
|
||||
take_action_hotkey_on_main_thread(&inner, kind);
|
||||
std::thread::sleep(std::time::Duration::from_secs(5));
|
||||
@@ -1711,7 +1721,7 @@ fn action_hotkey_slot(
|
||||
fn action_hotkey_binding(
|
||||
inner: &Arc<Inner>,
|
||||
kind: ActionHotkeyKind,
|
||||
) -> crate::types::ShortcutBinding {
|
||||
) -> Option<crate::types::ShortcutBinding> {
|
||||
let prefs = inner.prefs.get();
|
||||
match kind {
|
||||
ActionHotkeyKind::SwitchStyle => prefs.switch_style_hotkey,
|
||||
@@ -1868,17 +1878,21 @@ fn reset_shortcut_held_state(inner: &Arc<Inner>) {
|
||||
}
|
||||
}
|
||||
}
|
||||
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 let Some(switch_style) = prefs.switch_style_hotkey.as_ref() {
|
||||
if !is_modifier_only_shortcut(switch_style) {
|
||||
if let Some(monitor) = inner.switch_style_hotkey.lock().as_ref() {
|
||||
if let Err(e) = monitor.update_binding(switch_style.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}");
|
||||
if let Some(open_app) = prefs.open_app_hotkey.as_ref() {
|
||||
if !is_modifier_only_shortcut(open_app) {
|
||||
if let Some(monitor) = inner.open_app_hotkey.lock().as_ref() {
|
||||
if let Err(e) = monitor.update_binding(open_app.clone()) {
|
||||
log::warn!("[coord] reset open-app hotkey latch failed: {e}");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -604,10 +604,13 @@ pub struct UserPreferences {
|
||||
pub custom_combo_hotkey: Option<ComboBinding>,
|
||||
#[serde(default = "default_translation_hotkey")]
|
||||
pub translation_hotkey: ShortcutBinding,
|
||||
/// 「切换风格」全局快捷键。`None` = 停用(不注册全局键);`Some(...)` = 注册。
|
||||
/// 默认 `Some(默认键)`,对老用户零行为变化,仅新增可清空(issue #576)。
|
||||
#[serde(default = "default_switch_style_hotkey")]
|
||||
pub switch_style_hotkey: ShortcutBinding,
|
||||
pub switch_style_hotkey: Option<ShortcutBinding>,
|
||||
/// 「唤起 App」全局快捷键。`None` = 停用;`Some(...)` = 注册。默认 `Some(默认键)`。
|
||||
#[serde(default = "default_open_app_hotkey")]
|
||||
pub open_app_hotkey: ShortcutBinding,
|
||||
pub open_app_hotkey: Option<ShortcutBinding>,
|
||||
/// 本地 Qwen3-ASR 当前激活的模型 id("qwen3-asr-0.6b" / "qwen3-asr-1.7b")。
|
||||
/// 仅在 active_asr_provider == "local-qwen3" 时有意义。
|
||||
#[serde(default = "default_local_asr_model")]
|
||||
@@ -886,8 +889,9 @@ impl Default for UserPreferencesWire {
|
||||
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,
|
||||
// 默认携带默认键(Some),保证缺字段时仍是启用状态;None 专表「用户主动停用」。
|
||||
switch_style_hotkey: prefs.switch_style_hotkey,
|
||||
open_app_hotkey: prefs.open_app_hotkey,
|
||||
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,
|
||||
@@ -968,10 +972,11 @@ impl<'de> Deserialize<'de> for UserPreferences {
|
||||
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),
|
||||
// 直传 Option:None = 用户主动停用,不再用 unwrap_or_else 塌缩成默认键
|
||||
// (那正是 #576「无法关闭」的根因)。缺字段时 wire 的 serde struct-default
|
||||
// 会落到 Some(默认键),保证老用户/新用户仍是启用。
|
||||
switch_style_hotkey: wire.switch_style_hotkey,
|
||||
open_app_hotkey: wire.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,
|
||||
@@ -1014,18 +1019,18 @@ fn default_translation_hotkey() -> ShortcutBinding {
|
||||
}
|
||||
}
|
||||
|
||||
fn default_switch_style_hotkey() -> ShortcutBinding {
|
||||
ShortcutBinding {
|
||||
fn default_switch_style_hotkey() -> Option<ShortcutBinding> {
|
||||
Some(ShortcutBinding {
|
||||
primary: "S".into(),
|
||||
modifiers: default_app_shortcut_modifiers(),
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
fn default_open_app_hotkey() -> ShortcutBinding {
|
||||
ShortcutBinding {
|
||||
fn default_open_app_hotkey() -> Option<ShortcutBinding> {
|
||||
Some(ShortcutBinding {
|
||||
primary: "O".into(),
|
||||
modifiers: default_app_shortcut_modifiers(),
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
fn default_app_shortcut_modifiers() -> Vec<String> {
|
||||
@@ -2320,6 +2325,53 @@ mod tests {
|
||||
assert!(!restored.audio_cue_on_record);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn action_hotkeys_default_to_enabled() {
|
||||
// issue #576:默认仍开启(Some 默认键),对老用户零行为变化。
|
||||
let prefs = UserPreferences::default();
|
||||
assert!(prefs.switch_style_hotkey.is_some());
|
||||
assert!(prefs.open_app_hotkey.is_some());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn missing_action_hotkeys_default_to_enabled() {
|
||||
// 老用户/缺字段:wire 的 struct-default 落到 Some(默认键),不应被当成停用。
|
||||
let prefs: UserPreferences = serde_json::from_str("{}").unwrap();
|
||||
assert!(prefs.switch_style_hotkey.is_some());
|
||||
assert!(prefs.open_app_hotkey.is_some());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn disabled_action_hotkeys_round_trip_as_null() {
|
||||
// issue #576:用户清空(None=停用)后存盘→读回必须仍是 None,
|
||||
// 不能像旧逻辑那样被 unwrap_or_else 塌缩回默认键。
|
||||
let disabled = UserPreferences {
|
||||
switch_style_hotkey: None,
|
||||
open_app_hotkey: None,
|
||||
..Default::default()
|
||||
};
|
||||
let json = serde_json::to_string(&disabled).unwrap();
|
||||
assert!(
|
||||
json.contains("\"switchStyleHotkey\":null"),
|
||||
"停用应序列化成 null,实际: {json}"
|
||||
);
|
||||
let restored: UserPreferences = serde_json::from_str(&json).unwrap();
|
||||
assert!(restored.switch_style_hotkey.is_none());
|
||||
assert!(restored.open_app_hotkey.is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn explicit_action_hotkey_binding_round_trips() {
|
||||
// 旧 preferences.json 里带实际绑定 → 读回应保留为 Some(启用)。
|
||||
let prefs: UserPreferences = serde_json::from_str(
|
||||
r#"{"switchStyleHotkey":{"primary":"S","modifiers":["cmd","shift"]}}"#,
|
||||
)
|
||||
.unwrap();
|
||||
let binding = prefs.switch_style_hotkey.expect("应保留为 Some");
|
||||
assert_eq!(binding.primary, "S");
|
||||
assert_eq!(binding.modifiers, vec!["cmd".to_string(), "shift".to_string()]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn missing_custom_style_prompts_defaults_to_empty() {
|
||||
let prefs: UserPreferences = serde_json::from_str("{}").unwrap();
|
||||
|
||||
@@ -684,6 +684,8 @@ export const en: typeof zhCN = {
|
||||
confirm: 'Confirm capsule insertion',
|
||||
switchStyle: 'Switch to previous style',
|
||||
openApp: 'Open OpenLess',
|
||||
enable: 'Enable',
|
||||
disable: 'Disable',
|
||||
confirmHint: 'Click ✓ on the capsule',
|
||||
notSupported: 'Not yet supported',
|
||||
},
|
||||
|
||||
@@ -686,6 +686,8 @@ export const ja: typeof zhCN = {
|
||||
confirm: 'カプセル入力を確定',
|
||||
switchStyle: '前のスタイルに切り替え',
|
||||
openApp: 'OpenLess を開く',
|
||||
enable: '有効化',
|
||||
disable: '無効化',
|
||||
confirmHint: '右側の ✓ をクリック',
|
||||
notSupported: '未対応',
|
||||
},
|
||||
|
||||
@@ -686,6 +686,8 @@ export const ko: typeof zhCN = {
|
||||
confirm: '캡슐 입력 확정',
|
||||
switchStyle: '이전 스타일로 전환',
|
||||
openApp: 'OpenLess 열기',
|
||||
enable: '활성화',
|
||||
disable: '비활성화',
|
||||
confirmHint: '오른쪽 ✓ 클릭',
|
||||
notSupported: '지원되지 않음',
|
||||
},
|
||||
|
||||
@@ -682,6 +682,8 @@ export const zhCN = {
|
||||
confirm: '胶囊确认插入',
|
||||
switchStyle: '切换上一次风格',
|
||||
openApp: '打开 OpenLess',
|
||||
enable: '启用',
|
||||
disable: '停用',
|
||||
confirmHint: '点击右侧 ✓',
|
||||
notSupported: '暂未支持',
|
||||
},
|
||||
|
||||
@@ -684,6 +684,8 @@ export const zhTW: typeof zhCN = {
|
||||
confirm: '膠囊確認插入',
|
||||
switchStyle: '切換上一次風格',
|
||||
openApp: '打開 OpenLess',
|
||||
enable: '啟用',
|
||||
disable: '停用',
|
||||
confirmHint: '點擊右側 ✓',
|
||||
notSupported: '暫未支持',
|
||||
},
|
||||
|
||||
@@ -12,6 +12,16 @@ export function defaultAppShortcutModifiers(): string[] {
|
||||
return currentPlatform().isMac ? ['cmd', 'shift'] : ['ctrl', 'shift'];
|
||||
}
|
||||
|
||||
// 「停用」后重新「启用」时恢复的默认键,与后端 default_switch_style_hotkey /
|
||||
// default_open_app_hotkey 保持一致(issue #576)。
|
||||
export function defaultSwitchStyleShortcut(): ShortcutBinding {
|
||||
return { primary: 'S', modifiers: defaultAppShortcutModifiers() };
|
||||
}
|
||||
|
||||
export function defaultOpenAppShortcut(): ShortcutBinding {
|
||||
return { primary: 'O', modifiers: defaultAppShortcutModifiers() };
|
||||
}
|
||||
|
||||
export function getHotkeyTriggerLabel(trigger: HotkeyTrigger | null | undefined): string {
|
||||
if (!trigger) return i18n.t('hotkey.fallback');
|
||||
if (trigger === 'custom') return i18n.t('hotkey.triggers.custom');
|
||||
|
||||
@@ -1100,11 +1100,12 @@ export function setTranslationHotkey(binding: ShortcutBinding): Promise<void> {
|
||||
return invokeOrMock("set_translation_hotkey", { binding }, () => undefined)
|
||||
}
|
||||
|
||||
export function setSwitchStyleHotkey(binding: ShortcutBinding): Promise<void> {
|
||||
// binding = null 表示停用(清空全局键),与 set_qa_hotkey 一致(issue #576)。
|
||||
export function setSwitchStyleHotkey(binding: ShortcutBinding | null): Promise<void> {
|
||||
return invokeOrMock("set_switch_style_hotkey", { binding }, () => undefined)
|
||||
}
|
||||
|
||||
export function setOpenAppHotkey(binding: ShortcutBinding): Promise<void> {
|
||||
export function setOpenAppHotkey(binding: ShortcutBinding | null): Promise<void> {
|
||||
return invokeOrMock("set_open_app_hotkey", { binding }, () => undefined)
|
||||
}
|
||||
|
||||
|
||||
@@ -255,10 +255,10 @@ export interface UserPreferences {
|
||||
customComboHotkey: ComboBinding | null;
|
||||
/** 录音中触发翻译的全局快捷键。默认 Shift。 */
|
||||
translationHotkey: ShortcutBinding;
|
||||
/** 切换到上一个润色风格的全局快捷键。 */
|
||||
switchStyleHotkey: ShortcutBinding;
|
||||
/** 打开 OpenLess 主窗口的全局快捷键。 */
|
||||
openAppHotkey: ShortcutBinding;
|
||||
/** 切换到上一个润色风格的全局快捷键。null = 用户已停用(issue #576)。 */
|
||||
switchStyleHotkey: ShortcutBinding | null;
|
||||
/** 打开 OpenLess 主窗口的全局快捷键。null = 用户已停用(issue #576)。 */
|
||||
openAppHotkey: ShortcutBinding | null;
|
||||
/** 本地 Qwen3-ASR 当前激活的模型 id。仅在 activeAsrProvider === 'local-qwen3' 时有意义。 */
|
||||
localAsrActiveModel: string;
|
||||
/** 本地模型下载源镜像('huggingface' / 'hf-mirror')。 */
|
||||
|
||||
@@ -1,8 +1,9 @@
|
||||
// 快捷键设置:开始/停止、翻译、问答、切风格、唤起 App、以及只读取消/确认提示。
|
||||
|
||||
import type { CSSProperties } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { ShortcutRecorder } from '../../components/ShortcutRecorder';
|
||||
import { defaultQaShortcut } from '../../lib/hotkey';
|
||||
import { defaultOpenAppShortcut, defaultQaShortcut, defaultSwitchStyleShortcut } from '../../lib/hotkey';
|
||||
import {
|
||||
setDictationHotkey,
|
||||
setOpenAppHotkey,
|
||||
@@ -15,6 +16,31 @@ import { Card } from '../_atoms';
|
||||
import { SettingRow } from './shared';
|
||||
import { detectOS } from '../../components/WindowChrome';
|
||||
|
||||
const enableBtnStyle: CSSProperties = {
|
||||
alignSelf: 'flex-start',
|
||||
fontSize: 12,
|
||||
padding: '5px 14px',
|
||||
background: 'var(--ol-blue)',
|
||||
color: '#fff',
|
||||
border: 0,
|
||||
borderRadius: 6,
|
||||
fontFamily: 'inherit',
|
||||
fontWeight: 500,
|
||||
cursor: 'pointer',
|
||||
};
|
||||
|
||||
const disableBtnStyle: CSSProperties = {
|
||||
alignSelf: 'flex-start',
|
||||
fontSize: 11,
|
||||
padding: '3px 10px',
|
||||
background: 'transparent',
|
||||
color: 'var(--ol-ink-4)',
|
||||
border: '0.5px solid var(--ol-line-strong)',
|
||||
borderRadius: 6,
|
||||
fontFamily: 'inherit',
|
||||
cursor: 'pointer',
|
||||
};
|
||||
|
||||
export function ShortcutsSection() {
|
||||
const { t } = useTranslation();
|
||||
const os = detectOS();
|
||||
@@ -84,24 +110,72 @@ export function ShortcutsSection() {
|
||||
)}
|
||||
</SettingRow>
|
||||
<SettingRow label={t('settings.shortcuts.switchStyle')}>
|
||||
<ShortcutRecorder
|
||||
value={prefs.switchStyleHotkey}
|
||||
alignRecordButton
|
||||
onSave={async binding => {
|
||||
await setSwitchStyleHotkey(binding);
|
||||
await savePrefs({ ...prefs, switchStyleHotkey: binding });
|
||||
}}
|
||||
/>
|
||||
{prefs.switchStyleHotkey ? (
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 6, width: '100%' }}>
|
||||
<ShortcutRecorder
|
||||
value={prefs.switchStyleHotkey}
|
||||
alignRecordButton
|
||||
onSave={async binding => {
|
||||
await setSwitchStyleHotkey(binding);
|
||||
await savePrefs({ ...prefs, switchStyleHotkey: binding });
|
||||
}}
|
||||
/>
|
||||
<button
|
||||
onClick={async () => {
|
||||
await setSwitchStyleHotkey(null);
|
||||
await savePrefs({ ...prefs, switchStyleHotkey: null });
|
||||
}}
|
||||
style={disableBtnStyle}
|
||||
>
|
||||
{t('settings.shortcuts.disable', 'Disable')}
|
||||
</button>
|
||||
</div>
|
||||
) : (
|
||||
<button
|
||||
onClick={async () => {
|
||||
const binding = defaultSwitchStyleShortcut();
|
||||
await setSwitchStyleHotkey(binding);
|
||||
await savePrefs({ ...prefs, switchStyleHotkey: binding });
|
||||
}}
|
||||
style={enableBtnStyle}
|
||||
>
|
||||
{t('settings.shortcuts.enable', 'Enable')}
|
||||
</button>
|
||||
)}
|
||||
</SettingRow>
|
||||
<SettingRow label={t('settings.shortcuts.openApp')}>
|
||||
<ShortcutRecorder
|
||||
value={prefs.openAppHotkey}
|
||||
alignRecordButton
|
||||
onSave={async binding => {
|
||||
await setOpenAppHotkey(binding);
|
||||
await savePrefs({ ...prefs, openAppHotkey: binding });
|
||||
}}
|
||||
/>
|
||||
{prefs.openAppHotkey ? (
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 6, width: '100%' }}>
|
||||
<ShortcutRecorder
|
||||
value={prefs.openAppHotkey}
|
||||
alignRecordButton
|
||||
onSave={async binding => {
|
||||
await setOpenAppHotkey(binding);
|
||||
await savePrefs({ ...prefs, openAppHotkey: binding });
|
||||
}}
|
||||
/>
|
||||
<button
|
||||
onClick={async () => {
|
||||
await setOpenAppHotkey(null);
|
||||
await savePrefs({ ...prefs, openAppHotkey: null });
|
||||
}}
|
||||
style={disableBtnStyle}
|
||||
>
|
||||
{t('settings.shortcuts.disable', 'Disable')}
|
||||
</button>
|
||||
</div>
|
||||
) : (
|
||||
<button
|
||||
onClick={async () => {
|
||||
const binding = defaultOpenAppShortcut();
|
||||
await setOpenAppHotkey(binding);
|
||||
await savePrefs({ ...prefs, openAppHotkey: binding });
|
||||
}}
|
||||
style={enableBtnStyle}
|
||||
>
|
||||
{t('settings.shortcuts.enable', 'Enable')}
|
||||
</button>
|
||||
)}
|
||||
</SettingRow>
|
||||
{readonlyRows.map(([k, v]) => (
|
||||
<SettingRow key={k} label={k}>
|
||||
|
||||
Reference in new issue
Block a user