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 @@ -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`。**装机后的性能/内存实测仍待候选包**。
Expand Down
24 changes: 24 additions & 0 deletions docs/reviews/255-install-feedback-fixes.md
Original file line number Diff line number Diff line change
Expand Up @@ -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`;不需要时删掉该文件即静默。
123 changes: 92 additions & 31 deletions platforms/macos/Sources/LearningHints.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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?
Expand All @@ -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<Int>]
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<Int>] = [:]
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..<tab], encoding: .utf8), char.count == 1 {
table[char] = start..<end
}
}
start = end + 1
}
index = table
}

var indexedCount: Int { index.count }
var materializedCount: Int { cache.count }

func hint(for char: String) -> 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<UInt8>) -> 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
}
}
6 changes: 6 additions & 0 deletions platforms/macos/tests/wubi-learning.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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")
Expand Down
Loading