diff --git a/HANDOFF.md b/HANDOFF.md index 6a5a726..e21b6f9 100644 --- a/HANDOFF.md +++ b/HANDOFF.md @@ -117,6 +117,7 @@ make macos-offline-tests ## 9. 会话日志(最近) +- 2026-11-12 / Claude Code(air):**用户实机反馈两处缺陷 → ADR-337**。① `12:30` 得到 `12;30`:带 Shift 的标点到核心时是未加 Shift 的字符 → 宿主改用 `typedScalar`(按键码 + Shift 重算)。② 拼音上屏后五笔码提示不出现:探针复现属实(宿主无浮层变化、诊断日志无取光标记录),进程内重放逻辑通过,**根因未定位**;改为上屏前候选快照并给提示路径每个分支加诊断日志。用户确认练习「字根」「词组」题型正常。四道检查全绿,新增 3 条真机样本(共 26 条)。**两项修复都未在真机确认**:等本批 PR 合并出包后装机,先看 `lampyris-hud-diag.log` 里的 `hint skip=/show=`。内存门槛按用户决定:重启电脑后重测再定。DMG 完成页仍未验证。 - 2026-11-12 / Claude Code(air):**首键 +4.6 MiB 归因完成(报告 265 §4.2),不改产品代码**。读 `vmmap` 的 MALLOC ZONE 存活分配:首键时点只有两种状态——5.2 MiB(无会话存活,数据已随 `Weak` 缓存释放)或约 19 MiB(有会话存活);新包 8 轮里 3 轮撞上后者、旧包 5 轮 0 轮(偶然概率约 20%),无会话的轮次新旧完全相同。稳态存活堆新包 +0.5 MiB(21.0 vs 20.5)。`memory-gate.sh` 新增参考字段 `heap_live_mb` / `heap_allocations`。副产结论:冷首键 ~30 ms 很可能来自「最后一个会话释放后重新读盘解析」,与本批无关,是否常驻数据需 ADR。PR #273 待合并;已装仍是 `3e428b7`。 - 2026-11-12 / Claude Code(air):**内存新旧包 A/B(报告 265 §4.1)**。新→旧(1.2.1 发布版)→新,各经 `install.sh` 覆盖安装。稳态私有中位数:`six-words` 新 31.8 / 旧 33.0,`idle-8s` 新 31.6 / 旧 31.0 → **稳态无回归**;但学习区开启时两者都贴 34 MiB 门槛(旧包最高 33.7,新包 11 个样本 2 个超限,同包三次运行 FAIL/PASS/FAIL)。**新发现:首键时点新包 +4.6 MiB(27.8 vs 23.2),未归因。** 结束状态:已装新包 `f38e4e1a…`(`3e428b7`)、输入源萤火虫、config 未变;`updates/` 多出两份 A/B 产生的备份(`…T105406Z`、`…T110001Z`)未删。待用户裁定:门槛是否重测调整、首键内存是否发版前归因。 - 2026-11-12 / Claude Code(air):**报告 265:延迟 PASS、内存门禁 FAIL(未定性)**。PR #272 已合并,`dev`=`main`,无开放 PR、无多余分支。补 `;` 选第二候选真机样本(PASS;`keytopid` 加命名键 `semicolon`),`12:30` 与五笔码浮层探针测不了。`perf-session`:稳态 p95 7.33 ms / 0 外联(2 轮失焦,汇总脚本崩溃,按原始数据重算)。`memory-gate`:学习区开、门槛 34 MiB,稳态私有 31.0–34.6 MiB,1/6 样本超限;当时 load 12–41、swap 10.2/11.3 GB。**下一步:对新旧包做同条件内存 A/B(旧包在 `updates/install-backup-20261112T060612Z/`),结论出来前不升版本号、不发版。** 已装仍是 `3e428b7`,输入源已选回萤火虫。 diff --git a/docs/tasks/T-ITER-22-coverage.md b/docs/tasks/T-ITER-22-coverage.md index 054cf31..4a46e15 100644 --- a/docs/tasks/T-ITER-22-coverage.md +++ b/docs/tasks/T-ITER-22-coverage.md @@ -13,7 +13,7 @@ | IT-22 条目 | 覆盖手段 | 怎么跑 | |---|---|---| -| 1.1–1.3、1.7–1.12 上屏/标点/全角/简繁/模式/中英 | 热键探针 + 质量样本 | `make macos-auto-acceptance`(7 项)、`tools/macos-probe/quality-samples.sh`(23 项) | +| 1.1–1.3、1.7–1.12 上屏/标点/全角/简繁/模式/中英 | 热键探针 + 质量样本 | `make macos-auto-acceptance`(7 项)、`tools/macos-probe/quality-samples.sh`(26 项) | | 1.4 翻页、1.5 方向键选择 | 组合导航单测 + 质量样本「翻页键不破坏输入」 | `make test`、`quality-samples.sh` | | 2.1 长组合不丢字 | 质量样本「长文本不丢字」 | `quality-samples.sh` | | 2.2–2.6 退格/Esc/Home/End/Delete/Tab | 核心与 FFI 单测(ADR-164) | `make test`(`ime-core`/`ime-ffi`) | @@ -77,7 +77,7 @@ make macos-acceptance-full # ~5 分钟:preflight → 热键/上屏 → ## 4. 与旧清单的差异(为什么能砍掉 47 项) 0. **只依赖系统自带应用**(报告234):TextEdit / Safari / Terminal 之外的一律 optional(装了才测、没装 SKIP),由 `test-acceptance-portability.py` 强制。 -1. **同一件事此前被写了三遍**:`quality-samples.sh`(16 项)、`acceptance-auto.sh`(8 项)、IT-22 手工表(12 项重叠)。报告 230 之后两份脚本**按行为分工**:`quality-samples.sh` 断言打字产物(23 项;报告264/265 起含 ADR-325…336 的 10 条:数字后标点、拼音补全、分号选词、以词定字、分段选词、双拼、薄弱字、按应用记住英文),`acceptance-auto.sh` 断言热键状态语义(7 项),重复项已删并由 `test-acceptance-map.py` 守住不再回流。 +1. **同一件事此前被写了三遍**:`quality-samples.sh`(16 项)、`acceptance-auto.sh`(8 项)、IT-22 手工表(12 项重叠)。报告 230 之后两份脚本**按行为分工**:`quality-samples.sh` 断言打字产物(26 项;报告264/265 起含 ADR-325…337 的 13 条:数字后标点、Shift 标点、拼音补全、分号选词、以词定字、分段选词、双拼、薄弱字、按应用记住英文),`acceptance-auto.sh` 断言热键状态语义(7 项),重复项已删并由 `test-acceptance-map.py` 守住不再回流。 2. **离屏单测已经断言了 GUI 结构**:候选/设置/关于/学习的布局与文案由 `test-*-presentation.py` 断言,手工"看一眼分组是否连续"不再重复。 3. **几何/落点类**(多屏、边缘、翻页位置)已有 `CaretPlacement` 与离屏快照对比度自动判定(ADR-301、报告 197)。 4. **安装类**从"每次走查"降为"每版一次",因为它们只在换包时才有意义。 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 5a08488..befd526 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" @@ -3408,3 +3408,27 @@ `make verify`、离线回归、预览冒烟、`build.sh --strict` 全绿。**装机实键待换包后复验(本人非双拼用户的手感判断不可替代真实使用)。** - **状态**:已采纳(2026-11-12)。默认关闭,不影响现有用户。 - **关联**:ADR-324(U3-2)、ADR-326、ADR-330。 + +### ADR-337:带 Shift 的标点按「键码 + Shift」取字符;五笔码提示改用上屏前快照并加诊断 + +- **背景(用户 2026-11-12 实机反馈,候选 `3e428b7`)**: + 1. 中文状态下打 `12:30` 得到 `12;30`——核心收到的是 `;` 而不是 `:`。 + 2. 混输下用拼音打出字词后,光标旁**没有**出现五笔码提示(ADR-331 未生效)。探针复现:拼音上屏后宿主没有浮层窗口变化, + 诊断日志里也没有取光标的记录,说明在调用 `StateHUD` 之前就返回了;同一逻辑用真实数据在进程内重放是通过的,**根因未定位**。 +- **决策**: + 1. 宿主取「本次按键的字符」统一走 `LampyrisInputController.typedScalar`:用 `NSEvent.characters(byApplyingModifiers:)` + 按键码与当前键盘布局计算,按了 Shift 就带 `.shift`,不再直接用 `charactersIgnoringModifiers`。 + 于是 `:` `?` `!` `(` `)` `<` `>` `"` 等带 Shift 的标点到达核心时就是它们自己。字母仍由调用方统一小写。 + 2. 五笔码提示的判定数据改为**上屏前取的本页候选快照**(非字母键且处于组合态时取一次,至多 9 条),不再依赖 + 「候选窗上次显示了什么」这份跨调用的状态;删除 `shownCandidates`。 + 3. 提示路径的每个分支(模式不符 / 不是拼音候选 / 开关关闭 / 查不到码 / 过长 / 取到的光标矩形)在诊断开关打开时写入 + `lampyris-hud-diag.log`(`hint skip=…` / `hint show=…`),下一次装机即可定位。 +- **理由**:第 1 条——不论事件来源如何处理 Shift,按键码重算都是确定的;`12:30` 是 ADR-325 的核心用例,却因为冒号没到核心而失效。 + 第 2、3 条——在根因未知时,先去掉最可疑的跨调用状态,并让失败可观测,而不是继续靠推理。 +- **边界**:不改核心;不改提示的触发条件与文案。`,` `.` 翻页只认未加 Shift 的逗号句号(Shift+`,` 现在是 `<`,按标点处理)。 + **第 2 条是否真的修好提示尚未证实**——若新包仍不显示,以诊断日志为准再修。 +- **验收**:`test-controller-menu.py` 断言 Shift+`;`→`:`、Shift+`/`→`?`、Shift+`,`→`<`、未加 Shift 不变、字母保留大写形态 + (即使事件自带的 `charactersIgnoringModifiers` 是未加 Shift 的字符);`quality-samples.sh` 新增「数字后冒号」「Shift 标点」3 条真机样本(共 26 条)。 + `make verify`、离线回归、预览冒烟、`build.sh --strict` 全绿。**两项修复都待换包后实机确认。** +- **状态**:已采纳(2026-11-12)。 +- **关联**:ADR-325、ADR-331、ADR-335、报告 264/265。 diff --git "a/docs/\345\276\205\345\212\236\344\270\216\347\247\257\345\216\213.md" "b/docs/\345\276\205\345\212\236\344\270\216\347\247\257\345\216\213.md" index 1234205..5ad60ad 100644 --- "a/docs/\345\276\205\345\212\236\344\270\216\347\247\257\345\216\213.md" +++ "b/docs/\345\276\205\345\212\236\344\270\216\347\247\257\345\216\213.md" @@ -76,6 +76,9 @@ > 拼音上屏后的五笔码浮层、练习「字根」题型。**第二档剩余**:U2-2 五笔造词(要定启发式)、U2-3 扩展词库(要联网取数据并登记许可)、 > U2-5 分阶段解锁与跟打(要定口径与文本来源)——三项都需要你先拍板,不是能直接开工的活。 +> **实机反馈(2026-11-12,ADR-337)**:`12:30` → `12;30`(Shift 标点取字符有误,已修);拼音上屏后五笔码提示不出现(已加固并加诊断,**根因未定位、未确认修好**)。 +> 用户已确认练习「字根」「词组」题型正常;内存门槛定为「重启电脑后重测再定」;DMG 完成页待出 DMG 后确认。 +> > **第三批状态(2026-11-12)**:ADR-333…336 已完成(按应用记住中英、词组题型、薄弱字清单、小鹤双拼),四道检查全绿、评测与基线一致。 > > **装机状态(报告 264)**:三批已合入 `main`,候选 `3e428b7` 已装 air;**真机键入复验 22/22 PASS**(含 ADR-325/326/327/330/333/335/336 的 9 条新样本)。 diff --git a/platforms/macos/Sources/InputController.swift b/platforms/macos/Sources/InputController.swift index d989adb..9a7250b 100644 --- a/platforms/macos/Sources/InputController.swift +++ b/platforms/macos/Sources/InputController.swift @@ -13,8 +13,6 @@ final class LampyrisInputController: IMKInputController { private var candidateWindow: CandidateWindow? private var candidateSelection = CandidateSelectionState() private var candidateCount = 7 - /// 候选窗当前显示的候选:上屏后用它判断「刚才选的是不是拼音候选」(ADR-331),不为此多取一次数据。 - private var shownCandidates: [ImeCandidate] = [] private weak var activeClient: (any IMKTextInput)? private var workspaceObserver: NSObjectProtocol? private var dataLoadError: String? @@ -650,8 +648,7 @@ final class LampyrisInputController: IMKInputController { showCandidates(client, session: session) return true } - guard let chars = event.charactersIgnoringModifiers, let scalar = chars.unicodeScalars.first - else { return false } + guard let scalar = Self.typedScalar(event) else { return false } if let count = ImeConfig.int("candidate.count") { candidateCount = count @@ -673,20 +670,25 @@ final class LampyrisInputController: IMKInputController { return true } - if session.isComposing, event.keyCode == 49, candidateSelection.index > 0, - let text = session.select(candidateSelection.index) - { - commit(text, to: client) - candidateSelection.reset() - refreshAfterSelection(client, session: session) - hintWubiCode(for: text) - return true + if session.isComposing, event.keyCode == 49, candidateSelection.index > 0 { + let offered = session.candidates(limit: candidateCount) + if let text = session.select(candidateSelection.index) { + commit(text, to: client) + candidateSelection.reset() + refreshAfterSelection(client, session: session) + hintWubiCode(for: text, offered: offered) + return true + } } let codepoint = feedCodepoint(scalar, shift: flags.contains(.shift), session: session) guard let codepoint else { return false } candidateSelection.reset() + // 上屏前先记下「此刻给出的候选」:上屏后据此判断选中的是不是拼音候选(ADR-331 / 337)。 + // 字母键只会继续组合、不会选词,不取;其余键才可能上屏,此时多取一次本页候选(至多 9 条)。 + let isLetter = scalar.isASCII && scalar.properties.isAlphabetic + let offered = session.isComposing && !isLetter ? session.candidates(limit: candidateCount) : [] let trace = PerfTrace.shared.begin() let outcome = session.feed(codepoint) let engineStamp = PerfTrace.shared.stamp() @@ -706,7 +708,7 @@ final class LampyrisInputController: IMKInputController { candidateWindow?.hide() } PerfTrace.shared.finish(trace, engine: engineStamp, candidates: 1, committed: true) - hintWubiCode(for: text) + hintWubiCode(for: text, offered: offered) return true case .passThrough: return false @@ -757,19 +759,42 @@ final class LampyrisInputController: IMKInputController { /// 混输下用拼音打出一个字/词后,在光标旁提示它的五笔码(ADR-331)。 /// - /// 只在「刚上屏的文本正是候选窗里的一个拼音候选」时提示:五笔打出来的不提示,分段拼出的整句也不提示。 - private func hintWubiCode(for text: String) { - let shown = shownCandidates - shownCandidates = [] - guard !text.isEmpty, session?.mode == "mixed", - shown.contains(where: { $0.text == text && $0.source == .pinyin }) else { return } + /// `offered` 是**上屏前**取到的本页候选:只在「刚上屏的文本正是其中一个拼音候选」时提示—— + /// 五笔打出来的不提示,分段拼出的整句也不提示。每一步为什么没提示都写进诊断日志(ADR-337)。 + private func hintWubiCode(for text: String, offered: [ImeCandidate]) { + let diag = Self.hudDiagEnabled + func skip(_ reason: String) { + if diag { Self.hudDiag("hint skip=\(reason) text=\(text) offered=\(offered.count)") } + } + guard !text.isEmpty else { return } + guard session?.mode == "mixed" else { return skip("mode=\(session?.mode ?? "nil")") } + guard offered.contains(where: { $0.text == text && $0.source == .pinyin }) else { + return skip("not-a-pinyin-candidate") + } let hint = ImeConfig.bool("candidate.hint_wubi_code") let collect = text.count == 1 && ImeConfig.bool("practice.collect_weak_chars") - guard hint || collect, let code = WubiLearning.lookup(text)?.code, !code.isEmpty else { return } + guard hint || collect else { return skip("options-off") } + guard let code = WubiLearning.lookup(text)?.code, !code.isEmpty else { return skip("no-wubi-code") } // 薄弱字清单(ADR-335,默认关):有五笔码却靠拼音打出来的单字,留给练习多出几次。 if collect { WeakChars.shared.record(text) } - guard hint, let message = StateFeedback.wubiHint(text: text, code: code) else { return } - StateHUDController.shared.show(message, caret: hudCaretRect()) + guard hint, let message = StateFeedback.wubiHint(text: text, code: code) else { + return skip(hint ? "too-long" : "hint-off") + } + let caret = hudCaretRect() + if diag { Self.hudDiag("hint show=\(message) caret=\(caret.map(NSStringFromRect) ?? "nil")") } + StateHUDController.shared.show(message, caret: caret) + } + + /// 本次按键打出的字符。 + /// + /// 带 Shift 的标点必须取「按了 Shift 之后」的字符(`:`、`?`、`<`…),否则核心收到的是 `;`、`/`、`,`, + /// 中文标点会出错(`12:30` 变成 `12;30`,ADR-337)。这里按键码与当前键盘布局重新计算, + /// 不依赖 `charactersIgnoringModifiers` 在不同事件来源下对 Shift 的处理;字母大小写由调用方统一。 + static func typedScalar(_ event: NSEvent) -> Unicode.Scalar? { + let shift = event.modifierFlags.intersection(.deviceIndependentFlagsMask).contains(.shift) + let computed = event.characters(byApplyingModifiers: shift ? [.shift] : []) + let chars = (computed?.isEmpty == false ? computed : nil) ?? event.charactersIgnoringModifiers + return chars?.unicodeScalars.first } /// 选词之后:分段候选(ADR-330)只选走了前半段,组合还在,刷新预编辑与候选;否则收起候选窗。 @@ -789,7 +814,6 @@ final class LampyrisInputController: IMKInputController { let candidates = PerfTrace.shared.measure(trace, phase: .candidateData) { session.candidates(limit: candidateCount) } - shownCandidates = candidates guard !candidates.isEmpty else { candidateWindow?.hide() return 0 @@ -798,13 +822,14 @@ final class LampyrisInputController: IMKInputController { candidateWindow = window window.onSelect = { [weak self] index in guard let self, let client = self.activeClient, let session = self.session else { return } + let offered = session.candidates(limit: self.candidateCount) let text = session.select(index) if let text { self.commit(text, to: client) } self.candidateSelection.reset() self.refreshAfterSelection(client, session: session) - if let text { self.hintWubiCode(for: text) } + if let text { self.hintWubiCode(for: text, offered: offered) } } window.onPage = { [weak self] delta in guard let self, let client = self.activeClient, let session = self.session else { return } diff --git a/platforms/macos/tests/controller-menu.swift b/platforms/macos/tests/controller-menu.swift index fae4b63..c425742 100644 --- a/platforms/macos/tests/controller-menu.swift +++ b/platforms/macos/tests/controller-menu.swift @@ -120,6 +120,18 @@ print("PASS controller-menu: flat menu, cold/warm cycle through the IMK service require(StateFeedback.language(true) == "英文" && StateFeedback.language(false) == "中文", "language feedback") require(StateFeedback.width(true) == "全角" && StateFeedback.width(false) == "半角", "width feedback") +// ADR-337:带 Shift 的标点取「按了 Shift 之后」的字符,即使事件自带的 charactersIgnoringModifiers 是未加 Shift 的。 +func keyEvent(_ code: UInt16, shift: Bool, ignoring: String) -> NSEvent { + NSEvent.keyEvent(with: .keyDown, location: .zero, modifierFlags: shift ? [.shift] : [], timestamp: 0, + windowNumber: 0, context: nil, characters: ignoring, charactersIgnoringModifiers: ignoring, + isARepeat: false, keyCode: code)! +} +require(LampyrisInputController.typedScalar(keyEvent(41, shift: true, ignoring: ";")) == ":", "Shift+; is a colon") +require(LampyrisInputController.typedScalar(keyEvent(41, shift: false, ignoring: ";")) == ";", "plain ; stays a semicolon") +require(LampyrisInputController.typedScalar(keyEvent(44, shift: true, ignoring: "/")) == "?", "Shift+/ is a question mark") +require(LampyrisInputController.typedScalar(keyEvent(43, shift: true, ignoring: ",")) == "<", "Shift+, is a less-than sign") +require(LampyrisInputController.typedScalar(keyEvent(47, shift: false, ignoring: ".")) == ".", "plain period") +require(LampyrisInputController.typedScalar(keyEvent(0, shift: true, ignoring: "a")) == "A", "letters keep their shifted form; the caller lowercases") // ADR-333:按应用记住中英状态——没记过不表态、能覆盖、有界、空标识不记。 var languageMemory = AppLanguageMemory() require(languageMemory.english(for: "com.apple.Terminal") == nil, "unknown app has no remembered state") diff --git a/tools/macos-probe/quality-samples.sh b/tools/macos-probe/quality-samples.sh index 235696c..e2ebc4a 100755 --- a/tools/macos-probe/quality-samples.sh +++ b/tools/macos-probe/quality-samples.sh @@ -124,7 +124,10 @@ run "以词定字取首字" 'nihao[' 'e run "以词定字取末字" 'nihao]' 'exact:好' # 选走前半段(第二候选「你好」只覆盖 nihao)后回车:已选文字 + 余码原文一起上屏。 run "分段选词后回车" 'nihaoshijie2;return' 'exact:你好shijie' -# 冒号没有样本:合成的 Shift+; 到宿主时仍是 `;`(报告265),`12:30` 只有单测覆盖。 +# 带 Shift 的标点(ADR-337):宿主按键码 + Shift 重新计算字符,合成事件与真实按键走同一条路。 +run "数字后冒号保持半角" '12;shift+semicolon;30' 'exact:12:30' +run "Shift 标点(问号)" 'shift+/' 'exact:?' +run "Shift 标点(冒号)" 'shift+semicolon' 'exact::' # `;` 选第二候选:第二候选是什么取决于用户词库(学过的词会排上来),所以只断言「不是首选、也不是首选+;」。 case_semicolon_second() { TOTAL=$((TOTAL + 1))