Skip to content

feat(hotkey): 全局快捷键可停用/清空开关 (#576)#592

Merged
H-Chris233 merged 1 commit into
betafrom
feat/issue-576-global-hotkey-disable
Jun 4, 2026
Merged

feat(hotkey): 全局快捷键可停用/清空开关 (#576)#592
H-Chris233 merged 1 commit into
betafrom
feat/issue-576-global-hotkey-disable

Conversation

@appergb
Copy link
Copy Markdown
Collaborator

@appergb appergb commented Jun 4, 2026

User description

问题

Closes #576

0602 版本默认注册的「切换风格 / 唤起 App」全局快捷键无法关闭,与其他软件冲突,且会拦截软件内快捷键。

已在 issue 下 @H-Chris233 同步(他此前回复「会做的」),避免重复劳动。

方案

复用 QA 快捷键已有的 Option<ShortcutBinding> = None 停用模式,给这两个 action 键加「可清空停用」。默认仍开启(Some(默认键)),对老用户零行为变化,仅新增可关。

根因:原先 wire→inner 转换用 unwrap_or_else(default)None 塌缩成默认键,用户根本无法表达「已停用」。本 PR 改为直传 Option

改动

后端

  • types.rs:inner switch_style_hotkey/open_app_hotkeyOptiondefault_* 返回 SomeWire::default 携带 Some(缺字段=启用);from-wire 直传 Option
  • coordinator.rsaction_hotkey_binding 返回 Option;supervisor / update 加 None 反注册分支;reset-latch 对 Option 解包。
  • commands.rs:两个 setter 接受 OptionNone=停用,跳过 validate/overlap);所有 reject_*_overlap 读取处对 Option 解包(停用项不参与冲突检测)。

前端

  • types.ts/ipc.ts:字段与 setter 改 nullable。
  • ShortcutsSection:有值→录制器 +「停用」按钮;停用→「启用」按钮恢复默认(仿 QA 行)。
  • lib/hotkey.tsdefaultSwitchStyleShortcut/defaultOpenAppShortcut
  • 5 份 i18n 加 settings.shortcuts.enable/disable

验证

  • cargo check / cargo test --lib 通过(新增 4 项:默认启用、缺字段启用、None 往返为 null、显式绑定往返;既有 overlap 拒绝测试不变)
  • tsc --noEmit 通过
  • 真机:清空切换风格键 → 全局键释放、软件内同键可用;重启后保持停用

兼容性

  • preferences.json 带绑定 → 读回 Some(启用)。
  • 缺字段(新装/旧版) → wire struct-default 落到 Some(默认键)(启用)。
  • 用户清空 → 序列化 null,读回保持 None(停用)。

⚠️ 翻译键(Shift 单键,机制不同)、dictation/QA(本就可关)未纳入本 PR,保持单一职责。

@claude 请审核。


PR Type

Bug fix, Enhancement


Description

  • 后端将全局快捷键字段改为可空 Option

  • 前端UI增加启用/停用按钮

  • 冲突检测跳过已停用的快捷键

  • 序列化保持 null,默认仍启用


Diagram Walkthrough

flowchart LR
  User["用户操作"] -->|点击「停用」| Disable["后端:Option=None"]
  User -->|点击「启用」| Enable["后端:Option=Some(默认键)"]
  Disable --> Unregister["反注册全局键"]
  Enable --> Register["注册全局键"]
  Unregister --> Done1["快捷键不生效"]
  Register --> Done2["快捷键正常使用"]
Loading

File Walkthrough

Relevant files
Enhancement
6 files
types.rs
将 action 热键字段改为 Option                                                                     
+66/-14 
commands.rs
允许传入 null 表示停用,跳过验证                                                                           
+82/-47 
types.ts
将 switchStyleHotkey 和 openAppHotkey 改为可空                                 
+4/-4     
ipc.ts
setter 接受 null 参数                                                                               
+3/-2     
ShortcutsSection.tsx
添加启用/停用按钮切换状态                                                                                       
+91/-17 
hotkey.ts
添加默认快捷键辅助函数                                                                                           
+10/-0   
Error handling
1 files
coordinator.rs
根据 Option 反注册或更新绑定                                                                             
+25/-11 
Documentation
5 files
en.ts
添加 enable/disable 翻译                                                                         
+2/-0     
ja.ts
添加 enable/disable 翻译                                                                         
+2/-0     
ko.ts
添加 enable/disable 翻译                                                                         
+2/-0     
zh-CN.ts
添加「启用」/「停用」翻译                                                                                       
+2/-0     
zh-TW.ts
添加「啟用」/「停用」翻譯                                                                                       
+2/-0     

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 拒绝(含停用项跳过)。
@appergb
Copy link
Copy Markdown
Collaborator Author

appergb commented Jun 4, 2026

@claude 请审核这个 PR:给「切换风格/唤起 App」全局快捷键加可停用开关(复用 QA 的 Option=None 模式)。重点看:(1) types.rs from-wire 去掉 unwrap_or_else 后,老 preferences.json/缺字段是否仍默认启用(向后兼容);(2) None=停用是否被所有 reject_*_overlap 冲突检测正确跳过;(3) supervisor/update 的 None 反注册分支是否会让停用即时生效且不重复注册;(4) 前端「停用/启用」交互是否覆盖 Some↔None 双向。

@claude
Copy link
Copy Markdown

claude Bot commented Jun 4, 2026

Claude finished @appergb's task in 5m 15s —— View job


PR #592 代码审核

任务清单

  • 加载工具
  • 读取后端文件 (types.rs, coordinator.rs, commands.rs)
  • 读取前端文件 (ShortcutsSection.tsx, ipc.ts, types.ts, hotkey.ts)
  • 分析向后兼容性 (focus 1)
  • 分析冲突检测中的 None 跳过 (focus 2)
  • 分析反注册分支 (focus 3)
  • 分析前端双向交互 (focus 4)
  • 发布审核结果

结论:✅ 逻辑正确,可合并,附 2 个轻微建议


Focus 1:向后兼容性(types.rs from-wire 去掉 unwrap_or_else

结论:正确 ✅

关键点在 UserPreferencesWire 的两层保护:

  1. struct 级别 #[serde(default)]types.rs 第 773 行)加上自定义 Default impl(第 893-894 行):

    switch_style_hotkey: prefs.switch_style_hotkey,  // = Some(default_binding)
    open_app_hotkey: prefs.open_app_hotkey,            // = Some(default_binding)

    当 JSON 缺少 switchStyleHotkey 字段(老用户/新安装)→ serde 用 UserPreferencesWire::default() 填充 → Some(default_binding) → 直传 → 启用

  2. JSON 含 "switchStyleHotkey": null(用户主动停用)→ serde 反序列化 nullNone → 直传 → 停用

  3. JSON 含实际绑定 → Some(binding) → 直传 → 保留绑定

4 条新测试(action_hotkeys_default_to_enabledmissing_action_hotkeys_default_to_enableddisabled_action_hotkeys_round_trip_as_nullexplicit_action_hotkey_binding_round_trips)覆盖了所有场景。

注意:原 Wire Defaultswitch_style_hotkey: None → 新改为 prefs.switch_style_hotkeySome(default))是必要改动,不能漏。如果只去掉 unwrap_or_else 而不改 Wire Default,缺字段时就会变成 None(停用),造成回归。PR 里这两处配套修改都做了。✅


Focus 2:None 是否被所有 reject_*_overlap 正确跳过

结论:完整 ✅

逐一验证 6 处 setter 和 reject_hotkey_collisions

调用点 switch_style None 跳过 open_app None 跳过
set_qa_hotkey if let Some(switch_style) if let Some(open_app)
set_dictation_hotkey if let Some(switch_style) if let Some(open_app)
set_translation_hotkey if let Some(switch_style) if let Some(open_app)
set_switch_style_hotkey(None) 整个校验块被 if let Some(binding) 门控 ✅ 同上 ✅
set_open_app_hotkey(None) 同上 ✅ 同上 ✅
set_combo_hotkey if let Some(switch_style) if let Some(open_app)
reject_hotkey_collisions if let Some(switch_style) if let Some(open_app)
switch_style vs open_app 互检 if let (Some(s), Some(o)) 同上 ✅

所有冲突检测对都已正确处理停用状态,无遗漏。


Focus 3:supervisor / update 的 None 反注册分支

结论:立即生效且无重复注册 ✅

即时路径(用户点击「停用」):

// coordinator.rs update_action_hotkey_binding
let Some(binding) = action_hotkey_binding(&self.inner, kind) else {
    take_action_hotkey_on_main_thread(&self.inner, kind);  // 反注册
    log::info!("...");
    return;  // ← 立即返回,不会再尝试注册
};

一次调用 → 反注册 → 返回,无重复注册

supervisor loop(后台轮询):

let Some(binding) = action_hotkey_binding(&inner, kind) else {
    take_action_hotkey_on_main_thread(&inner, kind);  // 每 5s 重复一次
    std::thread::sleep(std::time::Duration::from_secs(5));
    continue;
};

这和现有 modifier-only 短路键的处理模式完全一致(is_modifier_only_shortcut 也是每 5s 调一次)。take_action_hotkey_on_main_thread 把 hotkey 句柄置 None 是幂等操作,重复调用无副作用。

⚠️ 轻微建议(不阻塞合并):停用期间 supervisor 每 5 秒向 main thread 派发一次 no-op。可加一个「已停用」状态位,避免无意义的主线程派发。但这与现有 modifier-only 行为完全一致,不是新引入的问题,可作为后续优化。

重新启用的竞态:用户点「启用」→ update_action_hotkey_binding 立即注册 → supervisor 在下次醒来(≤5s)读到 Some(binding) 后走正常注册路径(会替换已有句柄,取决于实现,但预期是幂等的)。这是现有设计的已知权衡,不是本 PR 引入的问题。✅


Focus 4:前端「停用/启用」是否覆盖 Some↔None 双向

结论:双向均已覆盖 ✅

Some → None(停用):

{prefs.switchStyleHotkey ? (
  <>
    <ShortcutRecorder ... onSave={binding => setSwitchStyleHotkey(binding)} />
    <button onClick={() => { setSwitchStyleHotkey(null); savePrefs({...prefs, switchStyleHotkey: null}); }}>
      {t('settings.shortcuts.disable')}
    </button>
  </>
)}

None → Some(启用):

{!prefs.switchStyleHotkey && (
  <button onClick={() => {
    const binding = defaultSwitchStyleShortcut();
    setSwitchStyleHotkey(binding);
    savePrefs({...prefs, switchStyleHotkey: binding});
  }}>
    {t('settings.shortcuts.enable')}
  </button>
)}

defaultSwitchStyleShortcut() / defaultOpenAppShortcut() 的平台判断(isMac ? ['cmd','shift'] : ['ctrl','shift'])与后端 default_app_shortcut_modifiers() 逻辑一致 ✅

5 种语言 i18n 键(enable/disable)全部补齐 ✅

⚠️ 轻微建议(不阻塞合并):「启用」按钮的 onClick 没有 try/catch。若默认键与现有快捷键冲突,后端返回错误后 UI 没有反馈(错误被 async event handler 静默吞掉)。不过现有录制器的 onSave 也没有显式错误处理,默认键冲突概率极低(Cmd+Shift+S/O),行为与组件整体风格一致,可视需要后续补 toast 提示。


其他小项

  • enableBtnStyle / disableBtnStyle 用了 var(--ol-blue) 等 CSS 变量,与现有 codebase 风格一致,无硬编码颜色问题 ✅
  • #[serde(default = "default_switch_style_hotkey")] 留在 UserPreferences 字段上(第 609 行)——该注解在自定义 Deserialize impl 下实际不生效,但保留作文档价值,不影响正确性 ✅

@github-actions
Copy link
Copy Markdown

github-actions Bot commented Jun 4, 2026

PR Reviewer Guide 🔍

Here are some key observations to aid the review process:

🎫 Ticket compliance analysis 🔶

576 - Partially compliant

Compliant requirements:

  • 为切换风格和唤起App快捷键增加了启用/停用开关
  • 用户可以通过“停用”按钮清空快捷键(设置为null),实现关闭
  • 默认仍启用,对老用户零行为变化

Non-compliant requirements:

(无)

Requires further human verification:

(无)

⏱️ Estimated effort to review: 3 🔵🔵🔵⚪⚪
🧪 PR contains tests
🔒 No security concerns identified
⚡ No major issues detected

@H-Chris233 H-Chris233 merged commit 984169d into beta Jun 4, 2026
4 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

[macos] 建议增加全局快捷键注册的开关

2 participants