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:
吕柏青 committed 2026-06-04 20:38:40 +08:00
1 parent 30cf33e2b1
commit d8ec007fcd
12 files changed
+293 -97

No files matched your search

+84 -49
View File
@@ -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()
};
+25 -11
View File
@@ -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}");
}
}
}
}
+66 -14
View File
@@ -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();
+2
View File
@@ -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',
},
+2
View File
@@ -686,6 +686,8 @@ export const ja: typeof zhCN = {
confirm: 'カプセル入力を確定',
switchStyle: '前のスタイルに切り替え',
openApp: 'OpenLess を開く',
enable: '有効化',
disable: '無効化',
confirmHint: '右側の ✓ をクリック',
notSupported: '未対応',
},
+2
View File
@@ -686,6 +686,8 @@ export const ko: typeof zhCN = {
confirm: '캡슐 입력 확정',
switchStyle: '이전 스타일로 전환',
openApp: 'OpenLess 열기',
enable: '활성화',
disable: '비활성화',
confirmHint: '오른쪽 ✓ 클릭',
notSupported: '지원되지 않음',
},
+2
View File
@@ -682,6 +682,8 @@ export const zhCN = {
confirm: '胶囊确认插入',
switchStyle: '切换上一次风格',
openApp: '打开 OpenLess',
enable: '启用',
disable: '停用',
confirmHint: '点击右侧 ✓',
notSupported: '暂未支持',
},
+2
View File
@@ -684,6 +684,8 @@ export const zhTW: typeof zhCN = {
confirm: '膠囊確認插入',
switchStyle: '切換上一次風格',
openApp: '打開 OpenLess',
enable: '啟用',
disable: '停用',
confirmHint: '點擊右側 ✓',
notSupported: '暫未支持',
},
+10
View File
@@ -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');
+3 -2
View File
@@ -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)
}
+4 -4
View File
@@ -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}>