diff --git a/HANDOFF.md b/HANDOFF.md index 6a7fb34..a888533 100644 --- a/HANDOFF.md +++ b/HANDOFF.md @@ -55,6 +55,7 @@ - **1.2.0 未定候选**:dev 已含二级简码 598 条(报告 238),但**还没做过装机验收**;发版前要定验收范围、跑 `FAST=1 make macos-auto-acceptance` 与人工走查,tag 需单独授权。 - ~~pro 本机实键入未确认~~ → **用户已在 pro 上确认:候选正常、切源顺手**(2026-11-06)。 - **air 曾出现「输入菜单里没有萤火虫」**:根因是**输入源被移出启用列表**(系统设置里加/改输入源时删掉了),已由用户重新添加恢复;排查与恢复步骤写进 `docs/故障恢复.md` §4.1(含“TIS API 从后台会话启用不持久化”的实测结论)。 +- **⌥L 已定案(报告 255 §7)**:加了 Option 键诊断日志后确认——处理器收到并消费了 ⌥L(`learning=true`、HUD 切换、`inputText` 0 次),用户此前是在 **Ghostty** 里测的,Ghostty 自己把 ⌥L 映射成 `¬` 送进 pty;**TextEdit / Terminal 实测正常**。诊断开关:`~/Library/Application Support/Lampyris/hud-diag` 存在时才写日志,删掉即静默。 - **修复包已装(同一 build 39,执行 `fdaffb35…`)**:⌥L 漏 `¬` 与「我」拆字键位已修;复测稳态 p95 **6.12 ms**、soak 18 轮 0 截尾/0 外联/footprint 36.3 MiB。**覆盖安装会让 macOS 把输入源移出启用列表**(需用户重新添加,已记入报告 255 §6,1.2.0 收口要给安装器加重新注册+提示)。 - **装机反馈三条已闭环(报告 255)**:① ⌥L 漏 `¬` → 组合键处理前移,已修;② 「我」拆字键位错 → 运行时资源按 `positionMismatch` 截断并降级,已修;③ `trnt` 不自动上屏 = 同码还有「特性/选择性」,预期行为不改。**修正需重新出候选包装 air**。 - **B3 学习区已实现(报告 253)**:⌥L 学习区两行式(完整码+简码 / 字根格+识别码),候选窗零改动;词组按输入码对应到字;`LearningHints` 读 `B0-runtime.tsv`,缺失退回 D14。离屏实拍 `docs/design/learning-real-*.png`。**装机后的性能/内存实测仍待候选包**。 diff --git a/docs/reviews/255-install-feedback-fixes.md b/docs/reviews/255-install-feedback-fixes.md index 5442086..bf90f46 100644 --- a/docs/reviews/255-install-feedback-fixes.md +++ b/docs/reviews/255-install-feedback-fixes.md @@ -63,3 +63,27 @@ 或在文档里把这条写进安装步骤,避免用户以为装坏了。 已记录,未在本轮实现。 + +## 7. ⌥L 漏字符:最终结论(2026-11-06 晚,诊断包 `45bc4d6b`) + +给控制器加了"hud-diag 开启时记录每个带 Option 的 keyDown"的诊断后,实测日志: + +``` +[hud] keydown chars=¬ ignore=l flags=524288 learning=true ×14 +[hud] feedback msg=学习区:开 / 关 +[hud] inputText ... ← 0 次 +``` + +- 我们的处理器**收到并匹配**(`learning=true`),HUD 正常切换,`inputText` 一次没被调用 → **事件确实被消费**; +- 日志里 `frontmost=com.mitchellh.ghostty`:**用户是在 Ghostty 里按的**。Ghostty 自己对 Option 组合键做字符映射(⌥L → ¬)并送进 pty,不认输入法的"已消费"。 +- 用户复测:**TextEdit 与 Terminal.app 都正常**(只有 HUD 提示,无 `¬`)。 + +**结论**:修复有效;Ghostty 的行为属其自身 Option 映射,不是输入法缺陷。文档与反馈都按此记录。 + +**附带发现(低优先)**:19 次按键里有 1 次 `learning=false`(刚激活后的首击,被"组合键时效"守卫挡下)—— +首击漏字符是另一个小问题,可单独修(放宽首击的时效判定)。 + +## 8. 诊断开关 + +`hud-diag` 文件存在时,控制器会把带 Option 的 keyDown(原始字符、ignoring 字符、flags、热键是否命中) +与 `inputText` 投递写入 `$TMPDIR/lampyris-hud-diag.log`;不需要时删掉该文件即静默。 diff --git a/platforms/macos/Sources/LearningHints.swift b/platforms/macos/Sources/LearningHints.swift index 61a3948..f0ba83f 100644 --- a/platforms/macos/Sources/LearningHints.swift +++ b/platforms/macos/Sources/LearningHints.swift @@ -3,7 +3,9 @@ import Foundation /// 学习区结构化提示(ADR-311 v2,`B0-runtime.tsv`)。 /// /// 资源在 App bundle 的 `Contents/Resources`:`字 \t 全码 \t 质量 \t 字根(形:键) \t 识别码(字母|末笔|结构) \t 简码(一/二)`。 -/// 缺失或损坏时返回空表,学习区退回旧的 D14 数据(只读、不报错)。 +/// **按需解析**:只常驻原始字节(约 120 KB)与"字 → 行区间"索引,命中时才解析单行并缓存最近若干条。 +/// 一次性解析 3,000 条实测会让宿主多占 2–4 MiB,超出常驻预算(报告 256)。 +/// 资源缺失或损坏时返回空表,学习区退回旧的 D14 数据(只读、不报错)。 struct LearningHint { let char: String let code: String? @@ -18,45 +20,104 @@ struct LearningHint { } enum LearningHints { - static let shared: [String: LearningHint] = load() + private static let store = Store() static func hint(for text: String) -> LearningHint? { guard text.count == 1 else { return nil } - return shared[text] + return store.hint(for: text) } - private static func load() -> [String: LearningHint] { - let candidates = [Bundle.main.url(forResource: "B0-runtime", withExtension: "tsv"), - Bundle.main.bundleURL.appendingPathComponent("Resources/B0-runtime.tsv"), - Bundle.main.bundleURL.deletingLastPathComponent() - .appendingPathComponent("Resources/B0-runtime.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: LearningHint] = [:] - out.reserveCapacity(3200) - for line in text.split(separator: "\n", omittingEmptySubsequences: true) where !line.hasPrefix("#") { - let fields = line.split(separator: "\t", omittingEmptySubsequences: false).map(String.init) - guard fields.count == 6, fields[0].count == 1 else { continue } - let roots: [(String, String)] = fields[3].split(separator: " ").compactMap { cell in - let parts = cell.split(separator: ":", maxSplits: 1, omittingEmptySubsequences: false).map(String.init) - guard parts.count == 2, !parts[0].isEmpty, parts[1].count == 1 else { return nil } - return (parts[0], parts[1].uppercased()) + /// 供测试与诊断:索引条目数(不等于已解析条数)。 + static var indexedCount: Int { store.indexedCount } + static var materializedCount: Int { store.materializedCount } + + private final class Store { + private let bytes: [UInt8] + private let index: [String: Range] + private var cache: [String: LearningHint] = [:] + private let cacheLimit = 64 + + init() { + let candidates = [Bundle.main.url(forResource: "B0-runtime", withExtension: "tsv"), + Bundle.main.bundleURL.appendingPathComponent("Resources/B0-runtime.tsv"), + Bundle.main.bundleURL.deletingLastPathComponent() + .appendingPathComponent("Resources/B0-runtime.tsv")] + let data = candidates.compactMap { $0 } + .first(where: { FileManager.default.fileExists(atPath: $0.path) }) + .flatMap { try? Data(contentsOf: $0) } + guard let data else { + bytes = [] + index = [:] + return + } + bytes = [UInt8](data) + var table: [String: Range] = [:] + table.reserveCapacity(3200) + var start = 0 + while start < bytes.count { + var end = start + while end < bytes.count, bytes[end] != 0x0A { end += 1 } + if end > start, bytes[start] != 0x23 { // '#' 注释行跳过 + var tab = start + while tab < end, bytes[tab] != 0x09 { tab += 1 } + if let char = String(bytes: bytes[start.. LearningHint? { + if let cached = cache[char] { return cached } + guard let range = index[char] else { return nil } + guard let hint = Self.parse(bytes[range]) else { return nil } + if cache.count >= cacheLimit { cache.removeAll(keepingCapacity: true) } + cache[char] = hint + return hint + } + + private static func parse(_ slice: ArraySlice) -> LearningHint? { + var fields: [[UInt8]] = [] + var current: [UInt8] = [] + for byte in slice { + if byte == 0x09 { + fields.append(current) + current = [] + } else { + current.append(byte) + } } + fields.append(current) + guard fields.count == 6, + let char = String(bytes: fields[0], encoding: .utf8), char.count == 1, + let quality = String(bytes: fields[2], encoding: .utf8) else { return nil } + let roots: [(String, String)] = String(bytes: fields[3], encoding: .utf8)? + .split(separator: " ").compactMap { cell in + let parts = cell.split(separator: ":", maxSplits: 1, omittingEmptySubsequences: false) + guard parts.count == 2, !parts[0].isEmpty, parts[1].count == 1 else { return nil } + return (String(parts[0]), parts[1].uppercased()) + } ?? [] let recognition: (String, String, String)? = { - let parts = fields[4].split(separator: "|", omittingEmptySubsequences: false).map(String.init) + let parts = (String(bytes: fields[4], encoding: .utf8) ?? "") + .split(separator: "|", omittingEmptySubsequences: false) guard parts.count == 3, !parts[0].isEmpty else { return nil } - return (parts[0].uppercased(), parts[1], parts[2]) + return (parts[0].uppercased(), String(parts[1]), String(parts[2])) }() - let shorts = fields[5].split(separator: "/").map { $0.uppercased() } - out[fields[0]] = LearningHint( - char: fields[0], - code: fields[1].isEmpty ? nil : fields[1].uppercased(), - quality: fields[2], - roots: roots, - recognition: recognition, - short1: shorts.count > 0 ? shorts[0] : nil, - short2: shorts.count > 1 ? shorts[1] : nil) + let shorts = (String(bytes: fields[5], encoding: .utf8) ?? "") + .split(separator: "/").map { $0.uppercased() } + let code = String(bytes: fields[1], encoding: .utf8) + return LearningHint(char: char, + code: (code?.isEmpty ?? true) ? nil : code?.uppercased(), + quality: quality, + roots: roots, + recognition: recognition, + short1: shorts.count > 0 ? shorts[0] : nil, + short2: shorts.count > 1 ? shorts[1] : nil) } - return out } } diff --git a/platforms/macos/tests/wubi-learning.swift b/platforms/macos/tests/wubi-learning.swift index cb03713..ef9248d 100644 --- a/platforms/macos/tests/wubi-learning.swift +++ b/platforms/macos/tests/wubi-learning.swift @@ -14,6 +14,12 @@ require(me?.characters.first?.recognition == nil, "four-root code has no invente for text in ["我们", "我国", "特别", "特点", "突然", "投入"] { require(WubiLearning.lookup(text)?.characters.allSatisfy { !$0.roots.isEmpty } == true, "feedback phrase coverage " + text) } +// ADR-311 v2:学习区资源按需解析(只建索引,不预先解析 3,000 条)。 +require(LearningHints.indexedCount >= 3000, "学习区索引应覆盖 3,000 字,实际 \(LearningHints.indexedCount)") +require(LearningHints.materializedCount == 0, "加载时应零解析") +require(LearningHints.hint(for: "和")?.recognition?.letter == "G", "按需解析能取到识别码") +require(LearningHints.materializedCount == 1, "只解析被查询的那一条") +require(LearningHints.hint(for: "我")?.roots.count == 3, "位次不符的条目已截断") let phrase = WubiLearning.lookup("中华人民共和国") require(phrase?.code == "kwwl" && phrase?.characters.count == 7, "long phrase summary and every character") require(phrase?.characters[3].code == "nav" && phrase?.characters[3].recognition?.key == "V", "verified long-word decomposition")