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
1 change: 1 addition & 0 deletions HANDOFF.md
Original file line number Diff line number Diff line change
Expand Up @@ -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`,输入源已选回萤火虫。
Expand Down
4 changes: 2 additions & 2 deletions docs/tasks/T-ITER-22-coverage.md
Original file line number Diff line number Diff line change
Expand Up @@ -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`) |
Expand Down Expand Up @@ -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. **安装类**从"每次走查"降为"每版一次",因为它们只在换包时才有意义。
Expand Down
24 changes: 24 additions & 0 deletions docs/决策记录.md
Original file line number Diff line number Diff line change
Expand Up @@ -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。
3 changes: 3 additions & 0 deletions docs/待办与积压.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 条新样本)。
Expand Down
73 changes: 49 additions & 24 deletions platforms/macos/Sources/InputController.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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?
Expand Down Expand Up @@ -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
Expand All @@ -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()
Expand All @@ -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
Expand Down Expand Up @@ -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)只选走了前半段,组合还在,刷新预编辑与候选;否则收起候选窗。
Expand All @@ -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
Expand All @@ -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 }
Expand Down
12 changes: 12 additions & 0 deletions platforms/macos/tests/controller-menu.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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")
Expand Down
Loading
Loading