Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions HANDOFF.md
Original file line number Diff line number Diff line change
Expand Up @@ -136,6 +136,10 @@ make macos-offline-tests

## 9. 会话日志(最近)

- 2026-11-15 / Claude Code(air):**输入法菜单固定顺序 + 符号快捷键(ADR-360,报告 288)**。菜单动作区固定为
「学习区 → 练习 → 符号与表情 → 使用指南 → 检查更新 → 设置 → 关于」,正式/预览共用 `InputMenuAction.sections`;
新增学习区/练习/符号入口;`hotkey.open_symbols` 默认 `alt+s` → `alt+c`(C = Character;保持单修饰键队形,已有显式配置不覆盖)。
三处菜单探针与设置页断言同步;快照缓存键加入 `libime_ffi.a`。
- 2026-11-15 / Claude Code(air):**交互式面板验收修复(报告 287)**。`make macos-dev` 在 Claude Code 的
非 TTY stdin 下 `selectors.register` 崩溃;改为非 TTY 时只轮询子进程、关闭窗口即结束。交互会话中真实数据
变化降为 WARN(预览只写私有根);测试期间真实词库 2,046→2,060 行属已装输入法正常学习,未回滚。
Expand Down
4 changes: 2 additions & 2 deletions crates/ime-config/src/schema.rs
Original file line number Diff line number Diff line change
Expand Up @@ -296,9 +296,9 @@ pub static SCHEMA: &[OptionSpec] = &[
key: "hotkey.open_symbols",
value_type: ValueType::String,
enum_labels_zh: &[],
default: "\"alt+s\"",
default: "\"alt+c\"",
title_zh: "符号与 emoji",
doc_zh: "打开符号 / emoji 选择面板(分类与中英文搜索);默认 alt+s,留空 = 关闭。反引号 ` 可在候选窗中快速打开分类",
doc_zh: "打开符号 / emoji 选择面板(分类与中英文搜索);默认 alt+c(C = Character/字符;单修饰键与其它状态键保持同队形,⌥E 是死键不可单用),留空 = 关闭。反引号 ` 可在候选窗中快速打开分类",
scope: Scope::Hotkey,
},
OptionSpec {
Expand Down
6 changes: 4 additions & 2 deletions docs/reviews/287-interactive-panel-non-tty.md
Original file line number Diff line number Diff line change
Expand Up @@ -31,8 +31,10 @@ Claude Code 的 `!` / `Bash` 环境里 stdin 不是可 `select()` 的终端(

## 4. 关于真实数据

首次崩溃后的手工测试期间,真实 `user-dictionary.tsv` 从 2,046 行变为 2,060 行(新增 14 条五笔词条),
`config.toml` 未变。该变化与已装输入法在其它窗口的正常学习一致,**未回滚**(属于用户真实数据)。
手工验收期间,真实 `user-dictionary.tsv` 从 2,046 行变为 2,060 行(新增 14 条五笔词条),
`config.toml` 的 `candidate.show_wubi_learning` 变为 `true`(02:20)。自动化隔离探针(同样会
`ime_config_init(私有根)` 并 `setString("mode", …)`)仍 PASS:真实文件在预览进程外未被写入。
因此这些变化与已装输入法在其它窗口的正常学习/⌥L 切换一致,**未回滚**(属于用户真实数据)。

## 5. 关联

Expand Down
63 changes: 63 additions & 0 deletions docs/reviews/288-input-menu-order-and-symbols-hotkey.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,63 @@
# 288 — 输入法菜单固定顺序 + 符号面板快捷键调整(ADR-360)

> 2026-11-15。人工验收反馈:菜单缺「练习」入口、顺序无策略、`⌥S` 易冲突,且默认键要保持单修饰键队形。

## 1. 问题

1. **入口缺失**:正式宿主的输入法菜单只有「检查更新… / 设置 / 关于 / 使用指南…」,
没有「练习…」「符号与表情…」;预览菜单连「使用指南…」也没有,人工验收看到的是不完整菜单。
2. **顺序无策略**:入口按添加历史排列,新增一个就要重新记一次位置。
3. **快捷键冲突**:`hotkey.open_symbols = "alt+s"` 与用户已有快捷键冲突。

## 2. 固定顺序

```text
状态区(IMK 契约,永远在最前)
模式 · 中英 · 全/半角 · 简/繁 · 标点
────
学习与工具
显示学习区(勾选)· 练习… · 符号与表情… · 使用指南…
────
应用
检查更新… · 设置… · 关于
────
异常状态(仅出错时出现,恒在最后)
```

规则:

- 顺序只写在 `InputMenuAction.sections`(`platforms/macos/Sources/InputMenu.swift`),正式宿主与预览共用;
- 新增入口**只能追加到所属组末尾**,并同步更新三处探针;
- 每组之间一条分隔线;错误状态不进固定顺序。

## 3. 新增入口

| 菜单项 | 快捷键来源 | 行为 |
|---|---|---|
| 显示学习区 | `hotkey.toggle_wubi_learning`(默认 ⌥L) | 与 ⌥L 同源,切换显示偏好并打勾,不提交当前组合 |
| 练习… | `hotkey.open_practice`(默认 ⌥K) | 打开离线练习窗口 |
| 符号与表情… | `hotkey.open_symbols`(默认 **⌥C**) | 先提交当前组合,再把选中符号插入原插入点;预览里插入预览输入区 |
| 使用指南… | 无 | 打开完整使用指南 |

## 4. 快捷键

`hotkey.open_symbols` 默认 `alt+s` → **`alt+c`**:

- C = Character(字符/符号),与其它状态键保持「单 ⌥ + 字母」的同一队形;
- `⌥S` 与应用快捷键冲突(用户反馈);`⌥E` 在 ADR-209 已确认是死键,不能单独使用;
- 用户明确要求保持单修饰键队形,故不采用双修饰键;
- **已有显式配置不静默覆盖**:老用户文件里写了 `alt+s` 就继续用,设置页显示实际值。

## 5. 验证

- `input-menu.swift`:断言分组与标题顺序、只有「使用指南」无快捷键;
- `controller-menu.swift`:正式宿主冷菜单 **14 项**、两条分隔线、位置与快捷键全部核对;
- `preview-menu.swift`:预览菜单同序,7 个动作各触发一次回调;
- `settings-presentation.swift`:设置页显示 `⌥C`;
- UI 快照:设置快捷键页已重新渲染;快照缓存键加入 `libime_ffi.a`,Schema 默认值变化不再复用旧二进制;
- `make macos-offline-tests` 68 文件 / 337 例 PASS;`make verify` PASS。

## 6. 关联

ADR-360、ADR-209/313/349/351、`InputMenu.swift`、`InputController.swift`、
`Preview/InputMenuPreview.swift`、`schema.rs`、`docs/配置架构.md`。
25 changes: 25 additions & 0 deletions docs/决策记录.md
Original file line number Diff line number Diff line change
Expand Up @@ -3963,3 +3963,28 @@
- **状态**:采纳(2026-11-15)。
- **关联**:ADR-358、报告 284/285、`docs/standards/wubi-decomposition.md`、`d2-glyph-aliases.tsv`、
`wubi86-root-inventory.tsv`、`data/sources.toml`。

### ADR-360:输入法菜单固定顺序;符号面板快捷键改为 ⌥C

- **背景**:用户人工验收时反馈 ① 输入法菜单缺「练习」「使用指南」入口(预览菜单尤其明显),
② 菜单项顺序没有策略、后续迭代不可预期,③ 符号与表情的 `⌥S` 容易与已有快捷键冲突。
- **决策**:
1. 菜单分三区并固定顺序:**状态区**(模式 → 中英 → 全/半角 → 简/繁 → 标点,5 行由 IMK 契约固定在最前);
**学习与工具**(显示学习区 → 练习… → 符号与表情… → 使用指南…);
**应用**(检查更新… → 设置… → 关于)。异常状态恒在最后且只在出错时出现。
2. 顺序写成 `InputMenuAction.sections`(`InputMenu.swift`),正式宿主与预览共用;
新增入口只能追加到所属组末尾并更新 `input-menu.swift` / `controller-menu.swift` / `preview-menu.swift` 三处探针。
3. 动作区新增「显示学习区」(勾选态,与 ⌥L 同源)、「练习…」(与 ⌥K 同源)、「符号与表情…」
(与快捷键同源);「使用指南…」保留在工具组。
4. `hotkey.open_symbols` 默认从 `alt+s` 改为 **`alt+c`**:C = Character(字符/符号),
与其它状态键保持「单 ⌥ + 字母」同一队形;`⌥S` 与应用快捷键冲突,`⌥E` 按 ADR-209 是死键。
**已有显式配置不静默覆盖**。
- **理由**:状态类每次打开菜单都会被看,放最前;学习与工具是日常高频,放中间;应用级操作最少用,放最后。
固定顺序让验收和文档可以直接断言,不靠记忆。
- **结果**:正式菜单 14 项(5 状态 + 2 分隔线 + 4 工具 + 3 应用);预览菜单与正式一致;
设置页显示 `⌥C`;三处菜单探针与 `settings-presentation` 全部通过。
- **边界**:不改 IMK 前 5 行的命令语义;不新增配置文件;不迁移用户已显式设置的快捷键;
预览的「符号与表情」插入到预览输入区,不经过系统 IME。
- **状态**:采纳(2026-11-15)。
- **关联**:ADR-209/313/349/351、报告 288、`InputMenu.swift`、`InputController.swift`、
`Preview/InputMenuPreview.swift`、`crates/ime-config/src/schema.rs`、`docs/配置架构.md`。
5 changes: 5 additions & 0 deletions docs/配置架构.md
Original file line number Diff line number Diff line change
Expand Up @@ -148,6 +148,9 @@ toggle_variant = "alt+t" # 简/繁(T = 繁体)| 组合键 | none
toggle_wubi_learning = "alt+l" # 显示/隐藏学习区(L = Learning)| none
open_settings = "alt+," # 打开设置 | 组合键 | none(与菜单项提示同源)
open_about = "alt+a" # 关于(A = About)| 组合键 | none(与菜单项提示同源)
open_update = "alt+g" # 检查更新(G = GitHub)| 组合键 | none(与菜单项提示同源)
open_practice = "alt+k" # 练习(K = 练习)| 组合键 | none
open_symbols = "alt+c" # 符号与表情(C = Character/字符)| 组合键 | none

[input]
punctuation = "chinese" # 标点:chinese = 中文 | english = 英文(⌥P 可切换)
Expand Down Expand Up @@ -261,6 +264,8 @@ crates/

Schema 增加 input.punctuation = "chinese" | "english"(默认 chinese)和 input.full_width = "half" | "full"(默认半角);默认值仍只由 Schema 定义,配置注释自动生成。中英快捷键(Shift)是会话内临时切换,不写配置;**全/半角快捷键自 ADR-305 起与标点/字形一致,写配置并持久化**(旧版布尔值 `false/true` 读取时迁移为 `half/full`,不改写用户文件)。hotkey.toggle_mode 由宿主消费,正式模式切换经 ime_config_set 保存。

2026-11-15(ADR-360):`hotkey.open_symbols` 默认从 `alt+s` 改为 `alt+c`(C = Character/字符;⌥S 与应用快捷键冲突,⌥E 是死键)。单修饰键与其它状态键保持同一队形;已有显式配置不静默覆盖;设置页显示 `⌥C`。输入法菜单动作区同时固定为「学习区 → 练习 → 符号与表情 → 使用指南 → 检查更新 → 设置 → 关于」。

2026-11-06(ADR-313):新增 `hotkey.open_practice`(默认 `alt+k`),打开离线练习窗口;作用域 `Hotkey`,`none`/空即关闭,与其它快捷键同一套冲突检测;不新增配置文件、不改持久化路径。

2026-10-18(ADR-207):新增 `hotkey.open_settings`(默认 `ctrl+,`)与 `hotkey.open_about`(默认 `ctrl+shift+a`),由 Schema 提供默认值,设置页与其它快捷键统一管理;宿主与预览把同一配置显示为菜单项右侧快捷键,按键路径在控制器中处理,不依赖菜单跟踪。`none`/空 = 关闭。
Expand Down
49 changes: 37 additions & 12 deletions platforms/macos/Preview/InputMenuPreview.swift
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,10 @@ final class PreviewInputMenu: NSObject, NSMenuDelegate {
var onCheckUpdates: (() -> Void)?
var onSettings: (() -> Void)?
var onAbout: (() -> Void)?
var onToggleLearning: (() -> Void)?
var onPractice: (() -> Void)?
var onSymbols: (() -> Void)?
var onGuide: (() -> Void)?
lazy var menu: NSMenu = makeMenu()

init(session: ImeSession) {
Expand All @@ -32,25 +36,42 @@ final class PreviewInputMenu: NSObject, NSMenuDelegate {
private func makeMenu() -> NSMenu {
let result = InputStateMenu.make(state: state, target: self)
result.delegate = self
result.addItem(.separator())
for (title, action) in [("检查更新…", #selector(checkUpdates(_:))),
("设置", #selector(showSettings(_:))), ("关于", #selector(showAbout(_:)))] {
let item = NSMenuItem(title: title, action: action, keyEquivalent: "")
item.target = self
result.addItem(item)
// ADR-360:与正式宿主共用同一份动作区顺序。
for section in InputMenuAction.sections {
result.addItem(.separator())
for action in section {
let item = NSMenuItem(title: action.title, action: selector(for: action), keyEquivalent: "")
item.tag = action.rawValue
item.target = self
result.addItem(item)
}
}
return result
}

private func selector(for action: InputMenuAction) -> Selector {
switch action {
case .toggleLearning: return #selector(toggleLearning(_:))
case .practice: return #selector(showPractice(_:))
case .symbols: return #selector(showSymbols(_:))
case .guide: return #selector(showGuide(_:))
case .update: return #selector(checkUpdates(_:))
case .settings: return #selector(showSettings(_:))
case .about: return #selector(showAbout(_:))
}
}

func refresh() {
if menu.items.count >= 9 {
for (item, key) in [(menu.items[6], "hotkey.open_update"),
(menu.items[7], "hotkey.open_settings"),
(menu.items[8], "hotkey.open_about")] {
item.keyEquivalent = ""
item.keyEquivalentModifierMask = []
for item in menu.items where item.tag >= InputMenuAction.toggleLearning.rawValue {
guard let action = InputMenuAction(rawValue: item.tag) else { continue }
item.keyEquivalent = ""
item.keyEquivalentModifierMask = []
if let key = action.hotkeyKey {
_ = item.applyConfiguredHotkey(ImeConfig.hotkey(key))
}
if action == .toggleLearning {
item.state = ImeConfig.bool("candidate.show_wubi_learning") ? .on : .off
}
}
for (item, key) in zip(menu.items.prefix(5), InputStateMenu.shortcuts) {
item.keyEquivalent = ""
Expand Down Expand Up @@ -80,6 +101,10 @@ final class PreviewInputMenu: NSObject, NSMenuDelegate {
}

@objc private func checkUpdates(_ sender: Any?) { onCheckUpdates?() }
@objc private func toggleLearning(_ sender: Any?) { onToggleLearning?() }
@objc private func showPractice(_ sender: Any?) { onPractice?() }
@objc private func showSymbols(_ sender: Any?) { onSymbols?() }
@objc private func showGuide(_ sender: Any?) { onGuide?() }

private func changed(_ success: Bool = true) {
refresh()
Expand Down
13 changes: 13 additions & 0 deletions platforms/macos/Preview/main.swift
Original file line number Diff line number Diff line change
Expand Up @@ -262,6 +262,19 @@ final class PreviewDelegate: NSObject, NSApplicationDelegate, NSWindowDelegate {
menu.onCheckUpdates = { UpdateEntry.open() }
menu.onSettings = { [weak self] in self?.openSettings() }
menu.onAbout = { AboutUI.show() }
menu.onToggleLearning = { [weak self] in
let next = !ImeConfig.bool("candidate.show_wubi_learning")
if ImeConfig.setToml("candidate.show_wubi_learning", next ? "true" : "false") { self?.input?.refresh() }
self?.syncMenuControls()
}
menu.onPractice = { PracticeWindow.shared.show() }
menu.onGuide = { OnboardingWindow.shared.show() }
menu.onSymbols = { [weak self] in
guard let input = self?.input else { return }
SymbolPickerWindow.shared.show { [weak input] text in
input?.insertText(text, replacementRange: NSRange(location: NSNotFound, length: 0))
}
}
previewMenu = menu
input.stateChanged = { [weak self] in self?.syncMenuControls() }
let menuButton = NSButton(title: "输入法菜单", target: self, action: #selector(openInputMenu(_:)))
Expand Down
86 changes: 63 additions & 23 deletions platforms/macos/Sources/InputController.swift
Original file line number Diff line number Diff line change
Expand Up @@ -231,23 +231,17 @@ final class LampyrisInputController: IMKInputController {
let menu = InputStateMenu.make(state: inputMenuState(), target: self)
stateMenu = menu
refreshStateMenu()
menu.addItem(NSMenuItem.separator())
let update = NSMenuItem(
title: "检查更新…", action: #selector(checkForUpdates), keyEquivalent: "")
update.target = self
menu.addItem(update)
let settings = NSMenuItem(
title: "设置", action: #selector(showSettings), keyEquivalent: "")
settings.target = self
menu.addItem(settings)
let about = NSMenuItem(
title: "关于", action: #selector(showAbout), keyEquivalent: "")
about.target = self
menu.addItem(about)
let guide = NSMenuItem(
title: "使用指南…", action: #selector(showGuide), keyEquivalent: "")
guide.target = self
menu.addItem(guide)
// ADR-360:动作区顺序由 InputMenuAction.sections 固定;新增入口只追加到所属组末尾。
for section in InputMenuAction.sections {
menu.addItem(NSMenuItem.separator())
for action in section {
let item = NSMenuItem(title: action.title, action: selector(for: action),
keyEquivalent: "")
item.tag = action.rawValue
item.target = self
menu.addItem(item)
}
}
// 异常状态(仅在出错时出现)
var alerts: [String] = []
if let session, session.dictionaryError != IME_OK {
Expand All @@ -271,10 +265,54 @@ final class LampyrisInputController: IMKInputController {
return menu
}

private func selector(for action: InputMenuAction) -> Selector {
switch action {
case .toggleLearning: return #selector(toggleLearning)
case .practice: return #selector(showPractice)
case .symbols: return #selector(showSymbols)
case .guide: return #selector(showGuide)
case .update: return #selector(checkForUpdates)
case .settings: return #selector(showSettings)
case .about: return #selector(showAbout)
}
}

@objc private func checkForUpdates() {
UpdateEntry.open()
}

/// 菜单里的学习区开关与 ⌥L 同源:只改显示偏好,不提交当前组合。
@objc private func toggleLearning() {
let next = !ImeConfig.bool("candidate.show_wubi_learning")
if ImeConfig.setToml("candidate.show_wubi_learning", next ? "true" : "false") {
showStateFeedback(StateFeedback.learning(next))
} else {
presentConfigError()
}
refreshStateMenu()
}

@objc private func showPractice() {
cancelClientComposition()
PracticeWindow.shared.show()
}

/// 菜单入口与 ⌥C(原 ⌥S)同源:先提交当前组合,再把选中的符号插入原插入点。
@objc private func showSymbols() {
if SymbolPickerWindow.shared.isVisible {
SymbolPickerWindow.shared.close()
return
}
guard let client = activeClient else {
NSSound.beep()
return
}
if let session { commitForSymbolPanel(client, session: session) }
SymbolPickerWindow.shared.show { [client] text in
client.insertText(text, replacementRange: NSRange(location: NSNotFound, length: 0))
}
}

private var stateMenu: NSMenu?

private func inputMenuState() -> InputMenuState {
Expand All @@ -294,14 +332,16 @@ final class LampyrisInputController: IMKInputController {
item.keyEquivalentModifierMask = []
}
}
if menu.items.count >= 8 {
for (item, key) in [(menu.items[6], "hotkey.open_update"),
(menu.items[7], "hotkey.open_settings"),
(menu.items[8], "hotkey.open_about")] {
item.keyEquivalent = ""
item.keyEquivalentModifierMask = []
for item in menu.items where item.tag >= InputMenuAction.toggleLearning.rawValue {
guard let action = InputMenuAction(rawValue: item.tag) else { continue }
item.keyEquivalent = ""
item.keyEquivalentModifierMask = []
if let key = action.hotkeyKey {
_ = item.applyConfiguredHotkey(ImeConfig.hotkey(key))
}
if action == .toggleLearning {
item.state = ImeConfig.bool("candidate.show_wubi_learning") ? .on : .off
}
}
InputStateMenu.refresh(menu, state: inputMenuState())
}
Expand Down
Loading
Loading