diff --git a/.agents/skills/iterate-macos-ime/SKILL.md b/.agents/skills/iterate-macos-ime/SKILL.md index 026daaa2..60852e65 100644 --- a/.agents/skills/iterate-macos-ime/SKILL.md +++ b/.agents/skills/iterate-macos-ime/SKILL.md @@ -18,6 +18,8 @@ description: 在已装 macOS 输入法上做「换包 → 验收 → 还原」 > `make macos-dev` 使用私有配置/词库根,可在非 TTY(Claude Code `!`)下运行:**关闭窗口即结束**并清理临时目录; > 真实 `user-dictionary.tsv` 若在会话中变化,通常来自已装输入法在其它窗口的学习,不是预览越界(报告 287)。 +> **测 ⌥ 快捷键前先切到 ABC**:输入源是已装萤火虫时,IMK 会在系统层先消费 ⌥M/⌥F/⌥P/⌥T,预览收不到。 +> `make macos-dev` 会自动切到 ABC 并在结束时还原;手动流程记得自己切回(报告 289)。 既有`macos-fast-reinstall`路径的前提只有三条,缺一就停:**输入源已移除**、**宿主已退出**、**包装前校验签名/公证**。 diff --git a/HANDOFF.md b/HANDOFF.md index 8d6c08ce..7afa0373 100644 --- a/HANDOFF.md +++ b/HANDOFF.md @@ -136,6 +136,12 @@ make macos-offline-tests ## 9. 会话日志(最近) +- 2026-11-15 / Claude Code(air):**预览自动切输入源**。`make macos-dev` 启动时若当前是萤火虫, + 自动切到 ABC 并在退出(含异常)后切回;实测双向 `tissrc select` 均生效,会话后已还原 Lampyris。 + 预览冒烟新增 ⌥M/⌥F/⌥P/⌥T 状态断言。 +- 2026-11-15 / Claude Code(air):**人工验收三项修复(ADR-361,报告 289)**。指南窗口 380→260 并改两栏 + (右侧步骤图示)+ 新增离屏快照;隔离预览补齐 `symbols/emoji/B0-runtime/wubi86-core/level3` 资源,符号面板可搜可插; + 练习新增「三级简码」题型(只用 D17-ter 权威表,3 格作答)。离线回归 68 文件/337 例、`make verify` 全绿。 - 2026-11-15 / Claude Code(air):**输入法菜单固定顺序 + 符号快捷键(ADR-360,报告 288)**。菜单动作区固定为 「学习区 → 练习 → 符号与表情 → 使用指南 → 检查更新 → 设置 → 关于」,正式/预览共用 `InputMenuAction.sections`; 新增学习区/练习/符号入口;`hotkey.open_symbols` 默认 `alt+s` → `alt+c`(C = Character;保持单修饰键队形,已有显式配置不覆盖)。 diff --git a/docs/reviews/289-manual-acceptance-fixes.md b/docs/reviews/289-manual-acceptance-fixes.md new file mode 100644 index 00000000..3b5b0e18 --- /dev/null +++ b/docs/reviews/289-manual-acceptance-fixes.md @@ -0,0 +1,88 @@ +# 289 — 人工验收三项修复:指南布局 / 预览数据 / 三级简码练习 + +> 2026-11-15。用户在隔离预览窗口验收时反馈的三项问题。 + +## 1. 使用指南下半部分空白 + +**现象**:指南窗口 560×380,内容都在上半部,下半部大片空白。 + +**处理**: + +- 窗口高度 380 → **260**; +- 布局改为两栏:左侧图标/标题/正文,右侧 132×132 的步骤图示卡片(强调色 10% 底、 + 56pt SF Symbol),把原来的空白变成视觉锚点; +- 新增 `OnboardingWindow.renderForTesting` 与 `guide-{light,dark}-step{1,4}.png` 快照, + 以后改布局可直接对比。 + +## 2. 符号 / emoji 面板「缺少 symbols.tsv」 + +**现象**:预览的符号面板只有搜索框和「其他」分类,底部报「缺少 symbols.tsv」, +无法搜索或插入。 + +**原因**:隔离预览 App 只软链了 `Resources/data`,但 `SymbolCatalog` 按正式包的方式从 +`Contents/Resources/symbols.tsv`、`emoji.tsv` 读取;学习提示(`B0-runtime.tsv`)、 +词组题库(`wubi86-core.tsv`)同样缺失。 + +**处理**: + +- `run-panel-interactive.py` 把预览包 `Contents/Resources/` 下的数据文件**全部软链**到隔离 App, + 以后新增资源不会再漏; +- `dev-preview.sh` 补齐 `symbols.tsv`、`emoji.tsv`、`wubi86-core.tsv`、`wubi86-level3-codes.tsv`; +- 快照/预览仍只读仓库数据,不写真实配置。 + +## 3. 练习没有三级简码 + +**现象**:练习分段只有「字根 / 一级 / 二级 / 完整码 / 词组 / 识别码」。 + +**处理**(ADR-361): + +- 新增 `PracticeKind.code3`,插在二级与完整码之间; +- 题库只对 **D17-ter 权威表**里明确收录的 `full` 字出题,绝不把「完整码前 3 位」当简码; +- 作答框 3 格,拆解说明追加「三级 XXX」; +- `wubi86-level3-codes.tsv` 随包分发;预览/隔离 App 也已带上; +- 指南第 4 步的题型顺序同步。 + +## 4. 预览输入区不消费打开类快捷键 + +**现象**:在 `make macos-dev` 的输入框里按 `⌥C`/`⌥K`/`⌥G`,快捷键不生效,反而把 Option 字符插入文本。 + +**原因**:正式宿主在 `handleHotkey` 里先处理 `open_update/open_practice/open_symbols`,再交给 +`session.shortcut`;预览的 `PreviewInput.handleShortcut` 只处理了设置/关于,缺少这三个入口。 + +**处理**: + +- 预览补齐 `onOpenUpdate` / `onOpenPractice` / `onOpenSymbols`,与菜单回调共用同一个符号插入闭包; +- 预览冒烟新增断言:合成 `⌥C`/`⌥K`/`⌥G` 事件必须分别触发对应回调,不能被当作文本插入。 + +## 5. 预览里 ⌥M/⌥F/⌥P/⌥T 仍不生效 + +**现象**:补齐预览的打开类快捷键后,`⌥C/⌥K/⌥G` 正常,但 `⌥M/⌥F/⌥P/⌥T` 仍无反应。 + +**定位**: + +- 预览冒烟(合成事件 + 私有配置)里四个状态键**全部通过**:`⌥M` 切模式、`⌥F` 全半角、 + `⌥P` 标点、`⌥T` 简繁;说明预览代码路径与持久化写入都正常。 +- 当前系统输入源是 `cn.jokerhub.inputmethod.Lampyris.default`(已装输入法)。IMK 会在系统层 + 先把这些 ⌥ 快捷键交给已装宿主;预览窗口收不到。会话期间真实 `config.toml` 被改成 + `punctuation=english / full_width=full / chinese_variant=traditional`,正是这四个键的效果。 +- `⌥C` 之所以正常,是因为已装 1.3.0 的符号键还是 `⌥S`,不认识 `⌥C`,事件才落到预览。 + +**处理**: + +- `make macos-dev` 启动时查询当前输入源;若是萤火虫,**自动切到 ABC**,关闭窗口/异常退出时 + 在 `finally` 里切回原输入源(实测双向 `tissrc select` 均生效);切换失败时打印手动提示; +- 预览冒烟新增四个状态键的断言(模式/全半角/标点/简繁各切换一次),防止回归; +- 这是输入法系统的预期行为,不是预览或产品的 bug;自动化只改变本机输入源并在会话结束时还原。 + +## 6. 验证 + +- `test-wubi-learning`:新增 `.code3` 答案 3 位与判分断言,题库非空; +- `test-symbol-picker`、`test-ui-snapshots`、三处菜单探针 PASS; +- `make macos-ui-snapshots`:新增指南快照(亮/暗 × 第 1/4 步); +- 预览冒烟新增状态快捷键断言;`make macos-offline-tests` 68 文件 / 337 例 PASS;`make verify` PASS。 + +## 7. 关联 + +ADR-361、ADR-345/314、报告 288、`OnboardingWindow.swift`、`Practice.swift`、 +`PracticeWindow.swift`、`PracticeExplanationView.swift`、`Preview/main.swift`、 +`run-panel-interactive.py`、`dev-preview.sh`。 diff --git "a/docs/\345\206\263\347\255\226\350\256\260\345\275\225.md" "b/docs/\345\206\263\347\255\226\350\256\260\345\275\225.md" index df1b7c48..0c507573 100644 --- "a/docs/\345\206\263\347\255\226\350\256\260\345\275\225.md" +++ "b/docs/\345\206\263\347\255\226\350\256\260\345\275\225.md" @@ -3988,3 +3988,22 @@ - **状态**:采纳(2026-11-15)。 - **关联**:ADR-209/313/349/351、报告 288、`InputMenu.swift`、`InputController.swift`、 `Preview/InputMenuPreview.swift`、`crates/ime-config/src/schema.rs`、`docs/配置架构.md`。 + +### ADR-361:练习新增「三级简码」题型(使用 D17-ter 权威表) + +- **背景**:ADR-314 当时关闭了三级简码练习,原因是引擎侧没有「哪些字真有三级简码」的码表, + 只能把「前 3 码」当简码教,容易教错。ADR-345 后来引入 D17-ter 权威表(3,362 条,已随包用于 + 候选命中),练习现在有条件按同一张表出题。 +- **决策**: + 1. 新增 `PracticeKind.code3`,顺序放在「二级简码」与「完整码」之间: + 字根 → 一级 → 二级 → 三级 → 完整码 → 词组 → 识别码。 + 2. 题库只取 `LearningHints` 的 `full` 条目,并且**该字必须出现在 D17-ter 表里**;不在表里的字不出题, + 绝不把「完整码前 3 位」当三级简码。 + 3. 三级简码表随包分发(`wubi86-level3-codes.tsv`,LGPL-3.0,来源 D9/D17-ter), + 练习按 `Resources/` 读取;缺失时该题型题库为空,不报错。 + 4. 作答框 3 格;拆解说明在「完整码 / 一级 / 二级」之后追加「三级」。 +- **结果**:练习页多一个「三级简码」分段;题型探针覆盖答案 3 位与判分。 +- **边界**:不改变候选排序与自动上屏规则;表里没有的字不猜;旧进度文件按题型 raw value 读取,不受影响。 +- **状态**:采纳(2026-11-15)。 +- **关联**:ADR-314/345、报告 289、`Practice.swift`、`PracticeWindow.swift`、`PracticeExplanationView.swift`、 + `platforms/macos/build.sh` 的 `wubi86-level3-codes.tsv` 资源。 diff --git a/platforms/macos/Preview/main.swift b/platforms/macos/Preview/main.swift index 3328ea80..10a57295 100644 --- a/platforms/macos/Preview/main.swift +++ b/platforms/macos/Preview/main.swift @@ -14,6 +14,9 @@ final class PreviewInput: NSTextView { var persistModeChanges = false var onOpenSettings: (() -> Void)? var onOpenAbout: (() -> Void)? + var onOpenUpdate: (() -> Void)? + var onOpenPractice: (() -> Void)? + var onOpenSymbols: (() -> Void)? init(session: ImeSession) { self.session = session @@ -51,6 +54,15 @@ final class PreviewInput: NSTextView { if session.matches("hotkey.open_about", event), let onOpenAbout { cancelComposition(); onOpenAbout(); return true } + if session.matches("hotkey.open_update", event), let onOpenUpdate { + cancelComposition(); onOpenUpdate(); return true + } + if session.matches("hotkey.open_practice", event), let onOpenPractice { + cancelComposition(); onOpenPractice(); return true + } + if session.matches("hotkey.open_symbols", event), let onOpenSymbols { + cancelComposition(); onOpenSymbols(); return true + } let shortcut = session.shortcut(event, persistMode: persistModeChanges) if shortcut.consumed { session.markKeyWhileShift() @@ -225,6 +237,15 @@ final class PreviewDelegate: NSObject, NSApplicationDelegate, NSWindowDelegate { input.persistModeChanges = isolated input.onOpenSettings = { [weak self] in self?.openSettings() } input.onOpenAbout = { AboutUI.show() } + input.onOpenUpdate = { UpdateEntry.open() } + input.onOpenPractice = { PracticeWindow.shared.show() } + let openSymbols: () -> Void = { [weak input] in + guard let input else { return } + SymbolPickerWindow.shared.show { [weak input] text in + input?.insertText(text, replacementRange: NSRange(location: NSNotFound, length: 0)) + } + } + input.onOpenSymbols = openSymbols self.input = input let scroll = NSScrollView() scroll.hasVerticalScroller = true @@ -269,12 +290,7 @@ final class PreviewDelegate: NSObject, NSApplicationDelegate, NSWindowDelegate { } 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)) - } - } + menu.onSymbols = openSymbols previewMenu = menu input.stateChanged = { [weak self] in self?.syncMenuControls() } let menuButton = NSButton(title: "输入法菜单", target: self, action: #selector(openInputMenu(_:))) @@ -1094,6 +1110,36 @@ if CommandLine.arguments.contains("--smoke-test") { else { fputs("诊断信息缺少版本/系统/架构。\n", stderr); exit(1) } + // ADR-360:预览输入区必须与正式宿主一样消费打开类快捷键,不能把 ⌥C/⌥K/⌥G 当文本插入。 + let shortcutProbe = PreviewInput(session: session) + var opened: [String] = [] + shortcutProbe.onOpenUpdate = { opened.append("update") } + shortcutProbe.onOpenPractice = { opened.append("practice") } + shortcutProbe.onOpenSymbols = { opened.append("symbols") } + for (key, character) in [(UInt16(8), "c"), (UInt16(40), "k"), (UInt16(5), "g")] { + shortcutProbe.keyDown(with: navigationEvent(key, .option, characters: character)) + } + guard opened == ["symbols", "practice", "update"] else { + fputs("预览未消费打开类快捷键:\(opened)\n", stderr); exit(1) + } + // 状态类快捷键必须真正改变会话(预览没有 HUD,用户只能从行为看到结果)。 + // 前面的快捷键校验把 toggle_mode 改成了 none;恢复默认并测试持久化路径。 + guard ImeConfig.setString("hotkey.toggle_mode", "alt+m") else { exit(1) } + shortcutProbe.persistModeChanges = true + session.setMode("mixed") + let mEvent = navigationEvent(46, .option, characters: "m") + shortcutProbe.keyDown(with: mEvent) + guard session.mode == "wubi" else { fputs("⌥M 未切换模式:\(session.mode)\n", stderr); exit(1) } + let widthBefore = session.isFullWidth + shortcutProbe.keyDown(with: navigationEvent(3, .option, characters: "f")) + guard session.isFullWidth != widthBefore else { fputs("⌥F 未切换全半角\n", stderr); exit(1) } + let shortcutPunctuationBefore = ImeConfig.string("input.punctuation") + shortcutProbe.keyDown(with: navigationEvent(35, .option, characters: "p")) + guard ImeConfig.string("input.punctuation") != shortcutPunctuationBefore else { fputs("⌥P 未切换标点\n", stderr); exit(1) } + let shortcutVariantBefore = session.isTraditional + shortcutProbe.keyDown(with: navigationEvent(17, .option, characters: "t")) + guard session.isTraditional != shortcutVariantBefore else { fputs("⌥T 未切换简繁\n", stderr); exit(1) } + session.setMode("mixed") try? FileManager.default.removeItem(at: smokeDirectory) print("视觉证据:\(qaDirectory.path)") print("✅ 预览冒烟通过:文本渲染、候选、五笔/拼音、数字选字/回车字母、退格/Esc、清空。") diff --git a/platforms/macos/Sources/OnboardingWindow.swift b/platforms/macos/Sources/OnboardingWindow.swift index 1f58b3f4..f355f2c2 100644 --- a/platforms/macos/Sources/OnboardingWindow.swift +++ b/platforms/macos/Sources/OnboardingWindow.swift @@ -33,7 +33,7 @@ enum OnboardingGuide { OnboardingStep( symbol: "graduationcap", title: "练习与跟打", - body: "\(hotkey("hotkey.open_practice")) 打开练习:字根 → 一级简码 → 二级简码 → 完整码 → 词组 → 识别码,\n" + body: "\(hotkey("hotkey.open_practice")) 打开练习:字根 → 一级简码 → 二级简码 → 三级简码 → 完整码 → 词组 → 识别码,\n" + "按掌握度逐级解锁;练习窗口里也可以进入「文章跟打」。\n" + "进度只写本机,可一键重置。"), OnboardingStep( @@ -79,6 +79,8 @@ final class OnboardingWindow: NSObject, NSWindowDelegate { private var steps: [OnboardingStep] = [] private var index = 0 private let icon = NSImageView() + private let illustration = NSImageView() + private let illustrationBox = NSView() private let titleLabel = NSTextField(labelWithString: "") private let bodyLabel = NSTextField(wrappingLabelWithString: "") private let stepLabel = NSTextField(labelWithString: "") @@ -107,10 +109,28 @@ final class OnboardingWindow: NSObject, NSWindowDelegate { window?.orderOut(nil) } + /// 离屏渲染指南(报告 289):不显示窗口、不激活 App,供 UI 快照与布局回归使用。 + func renderForTesting(appearance: NSAppearance, step: Int = 0) -> NSBitmapImageRep? { + prepare() + steps = OnboardingGuide.steps() + guard !steps.isEmpty, let panel = window, let view = panel.contentView else { return nil } + index = min(max(0, step), steps.count - 1) + update() + panel.appearance = appearance + var image: NSBitmapImageRep? + appearance.performAsCurrentDrawingAppearance { + view.layoutSubtreeIfNeeded() + guard let bitmap = view.bitmapImageRepForCachingDisplay(in: view.bounds) else { return } + view.cacheDisplay(in: view.bounds, to: bitmap) + image = ImeAppearance.opaqueSnapshot(bitmap, background: .windowBackgroundColor) + } + return image + } + private func prepare() { guard window == nil else { return } let panel = OnboardingPanel( - contentRect: NSRect(x: 0, y: 0, width: 560, height: 380), + contentRect: NSRect(x: 0, y: 0, width: 560, height: 260), styleMask: [.titled, .closable], backing: .buffered, defer: false) panel.title = "萤火虫使用指南" panel.isReleasedWhenClosed = false @@ -144,11 +164,34 @@ final class OnboardingWindow: NSObject, NSWindowDelegate { bottom.orientation = .horizontal bottom.alignment = .centerY bottom.spacing = 10 - let content = NSStackView(views: [icon, titleLabel, bodyLabel, bottom]) + + // 左侧文字 + 右侧大图标:把原来窗口下半部分的空白变成有效信息区(报告 289)。 + let header = NSStackView(views: [icon, titleLabel, bodyLabel]) + header.orientation = .vertical + header.alignment = .leading + header.spacing = 12 + header.translatesAutoresizingMaskIntoConstraints = false + + illustrationBox.wantsLayer = true + illustrationBox.layer?.cornerRadius = 18 + illustrationBox.layer?.backgroundColor = NSColor.controlAccentColor.withAlphaComponent(0.10).cgColor + illustrationBox.translatesAutoresizingMaskIntoConstraints = false + illustration.imageScaling = .scaleProportionallyUpOrDown + illustration.contentTintColor = .controlAccentColor + illustration.translatesAutoresizingMaskIntoConstraints = false + illustrationBox.addSubview(illustration) + + let top = NSStackView(views: [header, illustrationBox]) + top.orientation = .horizontal + top.alignment = .top + top.spacing = 24 + top.translatesAutoresizingMaskIntoConstraints = false + + let content = NSStackView(views: [top, NSView(), bottom]) content.orientation = .vertical content.alignment = .leading - content.spacing = 14 - content.edgeInsets = NSEdgeInsets(top: 22, left: 24, bottom: 18, right: 24) + content.spacing = 16 + content.edgeInsets = NSEdgeInsets(top: 24, left: 24, bottom: 18, right: 24) content.translatesAutoresizingMaskIntoConstraints = false panel.contentView?.addSubview(content) guard let host = panel.contentView else { return } @@ -157,8 +200,16 @@ final class OnboardingWindow: NSObject, NSWindowDelegate { content.leadingAnchor.constraint(equalTo: host.leadingAnchor), content.trailingAnchor.constraint(equalTo: host.trailingAnchor), content.bottomAnchor.constraint(equalTo: host.bottomAnchor), - bodyLabel.widthAnchor.constraint(equalTo: content.widthAnchor, constant: -48), + top.widthAnchor.constraint(equalTo: content.widthAnchor, constant: -48), + header.widthAnchor.constraint(equalTo: top.widthAnchor, constant: -156), + bodyLabel.widthAnchor.constraint(equalTo: header.widthAnchor), bottom.widthAnchor.constraint(equalTo: content.widthAnchor, constant: -48), + illustrationBox.widthAnchor.constraint(equalToConstant: 132), + illustrationBox.heightAnchor.constraint(equalToConstant: 132), + illustration.centerXAnchor.constraint(equalTo: illustrationBox.centerXAnchor), + illustration.centerYAnchor.constraint(equalTo: illustrationBox.centerYAnchor), + illustration.widthAnchor.constraint(equalToConstant: 64), + illustration.heightAnchor.constraint(equalToConstant: 64), ]) panel.center() window = panel @@ -168,6 +219,9 @@ final class OnboardingWindow: NSObject, NSWindowDelegate { guard steps.indices.contains(index) else { return } let step = steps[index] icon.image = NSImage(systemSymbolName: step.symbol, accessibilityDescription: step.title) + illustration.image = NSImage(systemSymbolName: step.symbol, accessibilityDescription: step.title)? + .withSymbolConfiguration(NSImage.SymbolConfiguration(pointSize: 56, weight: .regular)) + illustrationBox.setAccessibilityLabel("步骤图示:\(step.title)") titleLabel.stringValue = step.title bodyLabel.stringValue = step.body stepLabel.stringValue = "\(index + 1) / \(steps.count)" diff --git a/platforms/macos/Sources/Practice.swift b/platforms/macos/Sources/Practice.swift index f4326adc..7f31c757 100644 --- a/platforms/macos/Sources/Practice.swift +++ b/platforms/macos/Sources/Practice.swift @@ -2,15 +2,14 @@ import Foundation /// 离线练习(ADR-313):只从 `full` 条目出题,本地判分,进度写本地有界 TSV。 /// -/// 题型:字根 / 一级简码 / 二级简码 / 完整码 / 词组 / 识别码,按入门到熟练的顺序排(ADR-332/334)。 -/// 字根题取自键位字根表(`KeyRootLayout`),其余复用 `LearningHints`(ADR-311)。 -/// -/// 三级简码需要"哪些字真有三级简码"的码表(引擎侧至今没收录,ADR-314 关闭),此处不提供, -/// 否则会把"前 3 码"当成三级简码教错。 +/// 题型:字根 / 一级简码 / 二级简码 / 三级简码 / 完整码 / 词组 / 识别码,按入门到熟练的顺序排(ADR-332/334/361)。 +/// 字根题取自键位字根表(`KeyRootLayout`),其余复用 `LearningHints`(ADR-311); +/// 三级简码用随包的 D17-ter 权威表(ADR-345),只对表里明确收录的字出题,不把"前 3 码"当简码。 enum PracticeKind: String, CaseIterable { case roots = "roots" case code1 = "code1" case code2 = "code2" + case code3 = "code3" case code = "code" case phrase = "phrase" case recognition = "recognition" @@ -20,6 +19,7 @@ enum PracticeKind: String, CaseIterable { case .roots: return "字根" case .code1: return "一级简码" case .code2: return "二级简码" + case .code3: return "三级简码" case .code: return "完整码" case .phrase: return "词组" case .recognition: return "识别码" @@ -35,6 +35,7 @@ enum PracticeKind: String, CaseIterable { case .roots: return question.code case .code1: return question.short1 case .code2: return question.short2 + case .code3: return question.short3 case .code, .phrase: return question.code case .recognition: return nil } @@ -46,6 +47,7 @@ enum PracticeKind: String, CaseIterable { case .roots: return "这个字根在哪个键上?(1 位)" case .code1: return "打出它的一级简码(1 位)" case .code2: return "打出它的二级简码(2 位)" + case .code3: return "打出它的三级简码(3 位)" case .code: return "打出它的完整五笔码(\(question.code.count) 位)" case .phrase: return "打出这个词的词组码(4 位)" case .recognition: return "按上面的字根与键位,判断末笔与结构" @@ -62,6 +64,8 @@ struct PracticeQuestion { let short1: String? let short2: String? + /// 三级简码(D17-ter 表);表里没有该字时为 nil,出题会跳过。 + var short3: String? { Practice.level3Codes[char] } } enum Practice { @@ -121,6 +125,26 @@ enum Practice { return String(Array(row)[index]) } + /// 三级简码表(D17-ter):字 → 三码。首次用到时读一次;缺失时返回空表。 + static let level3Codes: [String: String] = { + let name = "wubi86-level3-codes" + let bundle = Bundle.main + let candidates = [bundle.url(forResource: name, withExtension: "tsv"), + bundle.resourceURL?.appendingPathComponent("\(name).tsv"), + bundle.bundleURL.appendingPathComponent("Resources/\(name).tsv"), + bundle.bundleURL.deletingLastPathComponent() + .appendingPathComponent("Resources/\(name).tsv")] + guard let url = candidates.compactMap({ $0 }).first(where: { FileManager.default.fileExists(atPath: $0.path) }), + let text = try? String(contentsOf: url, encoding: .utf8) else { return [:] } + var out: [String: String] = [:] + for line in text.split(separator: "\n") where !line.hasPrefix("#") { + let fields = line.split(separator: "\t") + guard fields.count >= 2, fields[0].count == 3, fields[1].count == 1 else { continue } + out[String(fields[1])] = String(fields[0]) + } + return out + }() + /// 出题范围:`full` 且带完整码与字根(可判分)。 static func bank(kind: PracticeKind, limit: Int = 3000) -> [PracticeQuestion] { if kind == .roots { return rootBank(layout: KeyRootLayout.load()) } @@ -132,6 +156,7 @@ enum Practice { if kind == .recognition, hint.recognition == nil { continue } if kind == .code1, hint.short1?.count != 1 { continue } if kind == .code2, hint.short2?.count != 2 { continue } + if kind == .code3, level3Codes[char] == nil { continue } out.append(PracticeQuestion(kind: kind, char: char, code: code, roots: hint.roots, recognition: hint.recognition, short1: hint.short1, short2: hint.short2)) diff --git a/platforms/macos/Sources/PracticeExplanationView.swift b/platforms/macos/Sources/PracticeExplanationView.swift index a55eb823..18e642d2 100644 --- a/platforms/macos/Sources/PracticeExplanationView.swift +++ b/platforms/macos/Sources/PracticeExplanationView.swift @@ -3,7 +3,7 @@ import AppKit /// 练习的「拆解 + 识别码 + 码/简码」整体展示(与学习区同一套视觉语言)。 /// /// 上排:字根格(字形上、大写键位下);右侧:识别码两行(说明 + 大写字母); -/// 下排:本题目要求的那一项排最前,随后是完整码与一/二级简码。未揭晓答案时只画字根格,不泄题。 +/// 下排:本题目要求的那一项排最前,随后是完整码与一/二/三级简码。未揭晓答案时只画字根格,不泄题。 final class PracticeExplanationView: NSView { static let cellWidth: CGFloat = 46 static let height: CGFloat = 46 @@ -13,6 +13,7 @@ final class PracticeExplanationView: NSView { private var code: String? private var short1: String? private var short2: String? + private var short3: String? private var target: String? private var placeholder: String? private var revealsAnswer = true @@ -25,6 +26,7 @@ final class PracticeExplanationView: NSView { code = nil short1 = nil short2 = nil + short3 = nil target = nil revealsAnswer = false placeholder = text @@ -34,13 +36,15 @@ final class PracticeExplanationView: NSView { func show(char: String, roots: [(glyph: String, key: String)], recognition: (letter: String, stroke: String, structure: String)?, - code: String, short1: String?, short2: String?, target: String? = nil, + code: String, short1: String?, short2: String?, short3: String? = nil, + target: String? = nil, revealsAnswer: Bool) { self.roots = Array(roots.prefix(6)) self.recognition = recognition self.code = code self.short1 = short1 self.short2 = short2 + self.short3 = short3 self.target = target self.revealsAnswer = revealsAnswer self.placeholder = nil @@ -64,6 +68,10 @@ final class PracticeExplanationView: NSView { let text = "二级 " + short2.uppercased() if !parts.contains(text) { parts.append(text) } } + if let short3 { + let text = "三级 " + short3.uppercased() + if !parts.contains(text) { parts.append(text) } + } return parts } diff --git a/platforms/macos/Sources/PracticeWindow.swift b/platforms/macos/Sources/PracticeWindow.swift index cd29c831..fc54066e 100644 --- a/platforms/macos/Sources/PracticeWindow.swift +++ b/platforms/macos/Sources/PracticeWindow.swift @@ -385,13 +385,14 @@ final class PracticeWindow: NSObject, NSWindowDelegate { case .phrase: return "词组码 \(question.code.uppercased())" case .code1: return question.short1.map { "一级 \($0.uppercased())" } case .code2: return question.short2.map { "二级 \($0.uppercased())" } + case .code3: return question.short3.map { "三级 \($0.uppercased())" } default: return "完整码 \(question.code.uppercased())" } }() // 字根题的「码」只是所在键、词组题的码是词组码,都不是单字的完整码,不能当「完整码」展示(否则教错)。 explanation.show(char: question.char, roots: question.roots, recognition: question.recognition, code: kind == .roots || kind == .phrase ? "" : question.code, - short1: question.short1, short2: question.short2, + short1: question.short1, short2: question.short2, short3: question.short3, target: target, revealsAnswer: revealsAnswer) } @@ -486,6 +487,7 @@ final class PracticeWindow: NSObject, NSWindowDelegate { switch kind { case .roots, .code1: return 1 case .code2: return 2 + case .code3: return 3 case .code: return min(4, max(3, question.code.count)) case .phrase: return 4 case .recognition: return 4 @@ -672,6 +674,7 @@ final class PracticeWindow: NSObject, NSWindowDelegate { : "这个字根在哪个键上?(1 位)" case .code1: return "打出它的一级简码(1 位)" case .code2: return "打出它的二级简码(2 位)" + case .code3: return "打出它的三级简码(3 位)" case .code: return "打出它的完整五笔码(\(question.code.count) 位)" case .phrase: return "打出这个词的词组码(4 位)" } diff --git a/platforms/macos/build.sh b/platforms/macos/build.sh index a8da7512..85f94e0f 100755 --- a/platforms/macos/build.sh +++ b/platforms/macos/build.sh @@ -148,6 +148,8 @@ copy_data "${GENERATED}/wubi86-extended.tsv" "wubi86-extended.tsv" copy_data "${GENERATED}/pinyin-extended.tsv" "pinyin-extended.tsv" copy_data "${GENERATED}/emoji.tsv" "emoji.tsv" copy_data "${ROOT}/data/symbols.tsv" "symbols.tsv" +# 三级简码练习(ADR-345 表,ADR-361 进入练习):只用于题型题库,不参与候选顺序。 +copy_data "${ROOT}/data/sources/wubi86-level3-codes.tsv" "wubi86-level3-codes.tsv" copy_data "${GENERATED}/B0-runtime.tsv" "B0-runtime.tsv" copy_data "${GENERATED}/variant-s2t.tsv" "variant-s2t.tsv" if [ ! -f "${GENERATED}/pinyin-core.tsv" ]; then diff --git a/platforms/macos/dev-preview.sh b/platforms/macos/dev-preview.sh index 1948a86e..88a76677 100755 --- a/platforms/macos/dev-preview.sh +++ b/platforms/macos/dev-preview.sh @@ -33,9 +33,12 @@ PLIST cp "${ROOT}/assets/icons/yinghuo-official-app.icns" "${APP}/Contents/Resources/Lampyris.icns" # 学习区结构化提示(ADR-311):预览也需要,便于看真实渲染 [ -f "${DATA}/B0-runtime.tsv" ] && cp "${DATA}/B0-runtime.tsv" "${APP}/Contents/Resources/B0-runtime.tsv" +# 练习的词组题库直接读 Resources 下的码表。 +[ -f "${DATA}/wubi86-core.tsv" ] && cp "${DATA}/wubi86-core.tsv" "${APP}/Contents/Resources/wubi86-core.tsv" # 符号面板与正式包同源读取 Resources 下的 TSV,预览也要能打开。 [ -f "${ROOT}/data/symbols.tsv" ] && cp "${ROOT}/data/symbols.tsv" "${APP}/Contents/Resources/symbols.tsv" [ -f "${DATA}/emoji.tsv" ] && cp "${DATA}/emoji.tsv" "${APP}/Contents/Resources/emoji.tsv" +[ -f "${ROOT}/data/sources/wubi86-level3-codes.tsv" ] && cp "${ROOT}/data/sources/wubi86-level3-codes.tsv" "${APP}/Contents/Resources/wubi86-level3-codes.tsv" ln -sfn "${DATA}" "${APP}/Contents/Resources/data" export SWIFT_MODULECACHE_PATH="${BUILD}/module-cache" export CLANG_MODULE_CACHE_PATH="${BUILD}/module-cache" diff --git a/platforms/macos/tests/run-panel-interactive.py b/platforms/macos/tests/run-panel-interactive.py index 38ace6b5..8ec87718 100644 --- a/platforms/macos/tests/run-panel-interactive.py +++ b/platforms/macos/tests/run-panel-interactive.py @@ -28,6 +28,13 @@ def stamp(path): (contents / 'Resources').mkdir() shutil.copy2(SOURCE, contents / 'MacOS/LampyrisIsolated') (contents / 'Resources/data').symlink_to(ROOT / 'data/generated', target_is_directory=True) +# 预览窗口按正式包的方式从 Contents/Resources 读数据(符号、emoji、学习提示、码表、三级简码)。 +# 只建 data/ 软链会让这些面板报「缺少 xxx.tsv」;这里把预览包 Resources 里的数据文件全部软链过来。 +preview_resources = SOURCE.parents[1] / 'Resources' +for item in sorted(preview_resources.iterdir()): + if item.name == 'data' or item.is_dir(): + continue + (contents / 'Resources' / item.name).symlink_to(item) (contents / 'Info.plist').write_bytes(plistlib.dumps({ 'CFBundleIdentifier': bundle, 'CFBundleExecutable': 'LampyrisIsolated', 'CFBundleName': 'LampyrisIsolated', 'CFBundlePackageType': 'APPL', @@ -35,9 +42,47 @@ def stamp(path): })) protected = [REAL / 'config.toml', REAL / 'user-dictionary.tsv'] before = [stamp(path) for path in protected] +TISSRC = ROOT / 'tools/macos-probe/bin/tissrc' +ABC = 'com.apple.keylayout.ABC' + + +def input_source(): + try: + listed = subprocess.run([str(TISSRC), 'list'], capture_output=True, text=True, timeout=5).stdout + except (OSError, subprocess.SubprocessError): + return None + for line in listed.splitlines(): + if line.startswith('current '): + return line.split(maxsplit=1)[1] + return None + + +def select_input_source(source): + if not TISSRC.is_file() or source is None: + return False + try: + result = subprocess.run([str(TISSRC), 'select', source], + capture_output=True, text=True, timeout=5) + except (OSError, subprocess.SubprocessError): + return False + return result.returncode == 0 + + +# 先查输入源再启动:TIS 枚举可能拉起已装宿主,不应发生在预览进程之后。 +# 已装萤火虫在系统层会先消费 ⌥M/⌥F/⌥P/⌥T,预览收不到;会话期间临时切到 ABC,结束时还原。 +original_source = input_source() +switched_source = False +if original_source and original_source.startswith('cn.jokerhub.inputmethod.'): + if select_input_source(ABC) and input_source() == ABC: + switched_source = True + print('已临时切到 ABC(预览才能收到 ⌥ 快捷键);关闭窗口后自动切回。', flush=True) + else: + print('⚠️ 当前输入源是「萤火虫」,且无法自动切到 ABC:它会在系统层先消费 ⌥M/⌥F/⌥P/⌥T。\n' + ' 请手动按 ⌃Space 切到 ABC 再测快捷键。', flush=True) process = subprocess.Popen([str(contents / 'MacOS/LampyrisIsolated'), '--panel-isolation-probe', 'interactive', str(work), str(ROOT / 'data/generated')]) -print(f'OWNED_APP={bundle}\nOWNED_PID={process.pid}\nOWNED_ROOT={work}', flush=True) +print(f'OWNED_APP={bundle}\nOWNED_PID={process.pid}\nOWNED_ROOT={work}\n' + f'INPUT_SOURCE={input_source() or "unknown"}', flush=True) interactive_stdin = sys.stdin.isatty() if interactive_stdin: print('请在隔离窗口点击“输入法菜单”或“偏好设置”;关闭窗口或在此输入 close 结束。', flush=True) @@ -84,3 +129,9 @@ def stamp(path): process.wait(timeout=5) if work.exists(): print(f'RETAINED={work}', flush=True) + # 先确保预览进程已退出,再还原输入源,避免还原后又被残留窗口消费快捷键。 + if switched_source and original_source: + if select_input_source(original_source) and input_source() == original_source: + print(f'已切回输入源:{original_source}', flush=True) + else: + print(f'未能自动切回输入源,请手动切回:{original_source}', flush=True) diff --git a/platforms/macos/tests/test-wubi-learning.py b/platforms/macos/tests/test-wubi-learning.py index 2aad39a8..0df63a03 100644 --- a/platforms/macos/tests/test-wubi-learning.py +++ b/platforms/macos/tests/test-wubi-learning.py @@ -31,6 +31,9 @@ def test_real_ffi_teaching_model(self): resources / "key-roots-wubi86.tsv") # 词组题库(ADR-334)读随包码表 shutil.copyfile(ROOT / "data/generated/wubi86-core.tsv", resources / "wubi86-core.tsv") + # 三级简码题库(ADR-345/361)读随包权威表 + shutil.copyfile(ROOT / "data/sources/wubi86-level3-codes.tsv", + resources / "wubi86-level3-codes.tsv") environment = {**os.environ, "CARGO_NET_OFFLINE": "true", "CLANG_MODULE_CACHE_PATH": (os.environ.get('LAMPYRIS_SWIFT_MODULE_CACHE') or str(temporary / 'module-cache'))} commands = [ diff --git a/platforms/macos/tests/ui-snapshots.swift b/platforms/macos/tests/ui-snapshots.swift index c77371ac..45ecf584 100644 --- a/platforms/macos/tests/ui-snapshots.swift +++ b/platforms/macos/tests/ui-snapshots.swift @@ -68,6 +68,15 @@ for (theme, appearance) in themes { }.joined(separator: " ")) } +print("使用指南") +for (theme, appearance) in themes { + for step in [0, 3] { + save(OnboardingWindow.shared.renderForTesting(appearance: NSAppearance(named: appearance)!, step: step), + "guide-\(theme)-step\(step + 1)") + } + print(" ↳ guide/\(theme):欢迎页与练习页(两栏布局,右侧步骤图示)") +} + print("诊断面板") InfoWindow.preparePresentationForTesting(title: "诊断信息", text: AboutUI.text(), allowsCopy: true, links: [InfoLink(title: "报告问题", url: FeedbackUI.issuesURL), diff --git a/platforms/macos/tests/wubi-learning.swift b/platforms/macos/tests/wubi-learning.swift index 1a2d63c1..812e9860 100644 --- a/platforms/macos/tests/wubi-learning.swift +++ b/platforms/macos/tests/wubi-learning.swift @@ -148,6 +148,7 @@ for kind in PracticeKind.allCases { case .roots: require(question.code.count == 1, "字根题答案是 1 位键位") case .code1: require(question.short1?.count == 1, "一级简码题必须有一级简码") case .code2: require(question.short2?.count == 2, "二级简码题必须有二级简码") + case .code3: require(question.short3?.count == 3, "三级简码题必须有三级简码") case .code: require(!question.code.isEmpty, "完整码题必须有码") case .phrase: require(question.code.count == 4 && (2...4).contains(question.char.count), "词组题是 2–4 字、4 位码") case .recognition: require(question.recognition != nil, "识别码题必须有识别码") @@ -161,6 +162,10 @@ if let sample = Practice.bank(kind: .code1, limit: 200).first, let short = sampl if let sample = Practice.bank(kind: .code2, limit: 200).first, let short = sample.short2 { require(Practice.judgeCode(short, question: sample), "二级简码应判对") } +if let sample = Practice.bank(kind: .code3, limit: 200).first, let short = sample.short3 { + require(short.count == 3, "三级简码应为 3 位") + require(Practice.judgeCode(short, question: sample), "三级简码应判对") +} // 作答框:4 格上限、z 只点亮不入答案、退格/Esc 清空 let answerField = PracticeAnswerView(frame: NSRect(x: 0, y: 0, width: 300, height: 60)) diff --git a/scripts/ui-snapshots.sh b/scripts/ui-snapshots.sh index bceb6409..c366955a 100755 --- a/scripts/ui-snapshots.sh +++ b/scripts/ui-snapshots.sh @@ -37,6 +37,8 @@ PANEL_SOURCES=( platforms/macos/Sources/UserDictionary.swift platforms/macos/Sources/Lookup.swift platforms/macos/Sources/StateHUD.swift + platforms/macos/Sources/Onboarding.swift + platforms/macos/Sources/OnboardingWindow.swift ) cargo build --release -p ime-ffi >/dev/null