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 @@ -61,6 +61,7 @@
- **B3 学习区已实现(报告 253)**:⌥L 学习区两行式(完整码+简码 / 字根格+识别码),候选窗零改动;词组按输入码对应到字;`LearningHints` 读 `B0-runtime.tsv`,缺失退回 D14。离屏实拍 `docs/design/learning-real-*.png`。**装机后的性能/内存实测仍待候选包**。
- **B3 草图(已成历史)**(`docs/design/l3-hint.md` + `learning-area-mock.png`):按用户裁定改为**候选窗零改动**,学习信息只放 ⌥L 学习区,学习区自己两行封顶(单字:字+码+简码 / 字根+识别码依据;词组:前 3 字 + 「逐字 ▸」)。`partial` 未知字根显示 `?`、标注「拆解待核验」;数据源优先 B0-runtime,未覆盖的字走 D14 兜底。**实现未开始,等评审三个细节**。
- **B5 已收口(ADR-314 / 报告 258)**:冷首键 ~28 ms(学习区关着也一样,用户不可感知)→ 不优化;A1/A2/A3/A5 全部关闭并写触发条件;内存按 ADR-312 双阈值(默认 30 / 学习区 34)。
- **练习流程已修 + 自动化(报告 261)**:输入框换成 `PracticeAnswerView`(不经输入法,回车才判分);识别码说明改用**正确**末笔·结构、标题不再被按钮压住;新增 `PracticeSession` 纯逻辑与 8 条流程断言(判分/进度/输入过滤)。
- **练习窗口版式已修正(报告 260)**:翻转坐标从顶部排、固定 460×336、题干居中、进度行不再被裁、A/C 共用纵向基准,并加了版式自检(控件不越界、不与题型控件重叠)。**待装包看真机**。
- **B4 练习已实现(ADR-313 / 报告 257)**:只看 `full` 出题,题型 A(看字打码)+ C(识别码判断);进度写 `~/Library/Application Support/Lampyris/practice.tsv`(有界 3,000 行 / 256 KiB、原子写、删除即重置);快捷键 `hotkey.open_practice` 默认 **⌥K**,入口在设置页快捷键列表;**尚未装包实测**。

Expand Down
37 changes: 37 additions & 0 deletions docs/reviews/261-practice-flow-and-ui-fixes.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
# 261 — 练习流程修复 + 识别码界面修正 + 流程自动化测试

- 日期:2026-11-06 | 来源:用户实测「回车后正确率不变」「识别码判断界面需要优化」「流程要自动化验证」

## 1. 回车不判分(根因)

练习窗口的输入框是 `NSTextField`,而它所在的宿主就是输入法本身:键入会先被输入法接管,
**回车被输入法当作候选提交**,字段的 action 根本没触发 → 只有识别码点选会记进度,正确率停在 0%。

**修法**:换成自绘的 `PracticeAnswerView`(`NSView` + `keyDown`),自己收键、**不经过输入法**:
只接受 A–Y 字母(大小写都收,统一转小写)、退格、回车;回车触发提交。

## 2. 识别码界面(截图问题)

| 问题 | 修法 |
|---|---|
| 「末笔」「结构」标题被按钮压住、文字被截断 | 标题宽度 32→46、按钮起点 36→52,「末笔」选中后就地显示「末笔 撇」 |
| 判错的说明把**用户的**选择当成答案 | 说明改用**正确**的末笔·结构(`PracticeSession` 里取 `question.recognition`) |

## 3. 自动化:练习流程跑通

新增 `PracticeSession`(无 AppKit 的纯逻辑),把"出题 → 作答 → 判分 → 记进度"收进一处;
离线回归(`platforms/macos/tests/wubi-learning.swift`)现在断言:

1. 正确码判对、**进度摘要随之变化**、`practice.tsv` 记 1 次作答与 1 次正确;
2. 错误码也计数(次数 +1、正确不变);
3. 识别码:正确末笔/结构判对,说明里包含**正确**的字母与末笔;
4. `PracticeAnswerView`:只收 a–y(含大写归一)、退格删除、`z` 忽略、回车回调拿到已输入内容。

## 4. 验证

- `make verify` PASS;离线回归 **62 文件 / 328 例 PASS**(新增流程断言全绿)。
- 真机复验待装包。

## 5. 关联

ADR-313、报告 257/259/260。
57 changes: 57 additions & 0 deletions platforms/macos/Sources/Practice.swift
Original file line number Diff line number Diff line change
Expand Up @@ -132,3 +132,60 @@ final class PracticeProgress {
return "已练 \(rows.count) 字 · \(attempts) 次 · 正确率 \(correct * 100 / attempts)%"
}
}


/// 一局练习的纯逻辑(无 AppKit),便于自动化测试跑通流程。
final class PracticeSession {
struct Result {
let correct: Bool
let explanation: String
}

private(set) var kind: PracticeKind
private(set) var question: PracticeQuestion?
private let progress: PracticeProgress
private(set) var bank: [PracticeQuestion] = []

init(kind: PracticeKind, progress: PracticeProgress) {
self.kind = kind
self.progress = progress
reload()
}

func switchKind(_ kind: PracticeKind) {
self.kind = kind
reload()
}

func reload() {
bank = Practice.bank(kind: kind)
question = nil
next()
}

@discardableResult
func next() -> PracticeQuestion? {
question = bank.randomElement()
return question
}

/// 提交打字答案;`nil` 表示没有题目。
func submitCode(_ answer: String) -> Result? {
guard let question else { return nil }
let correct = Practice.judgeCode(answer, question: question)
progress.record(char: question.char, kind: kind, correct: correct)
return Result(correct: correct,
explanation: "完整码 \(question.code.uppercased()) · 字根 \(question.rootHint)")
}

/// 提交识别码选择;证据说明用**正确**的末笔与结构,而不是用户选的。
func submitRecognition(stroke: String, structure: String) -> Result? {
guard let question, let expected = question.recognition else { return nil }
let correct = Practice.judgeRecognition(stroke: stroke, structure: structure, question: question)
progress.record(char: question.char, kind: kind, correct: correct)
return Result(correct: correct,
explanation: "识别码 \(expected.letter)(\(expected.stroke)·\(expected.structure))")
}

var summary: String { progress.summary }
}
53 changes: 53 additions & 0 deletions platforms/macos/Sources/PracticeAnswerView.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,53 @@
import AppKit

/// 练习作答输入框:自己收键,不经过输入法(避免回车被 IME 吃掉、字母被组合)。
/// 只接受 a–y 小写字母、退格与回车;其余按键忽略。
final class PracticeAnswerView: NSView {
var onReturn: ((String) -> Void)?
private(set) var text = "" {
didSet { needsDisplay = true }
}
var placeholder = "输入完整五笔码后回车"

override var acceptsFirstResponder: Bool { true }
override var isFlipped: Bool { true }

override func keyDown(with event: NSEvent) {
guard let characters = event.charactersIgnoringModifiers else { return }
for scalar in characters.unicodeScalars {
switch scalar.value {
case 0x0D, 0x03:
onReturn?(text)
case 0x7F, 0x08:
if !text.isEmpty { text.removeLast() }
default:
let value = Int(scalar.value)
if (65...89).contains(value) || (97...121).contains(value),
let letter = UnicodeScalar(value) {
text.append(Character(String(letter).lowercased()))
}
}
}
}


func clear() { text = "" }
func setTextForTesting(_ value: String) { text = value }

override func draw(_ dirtyRect: NSRect) {
let border = NSBezierPath(roundedRect: bounds.insetBy(dx: 0.5, dy: 0.5), xRadius: 6, yRadius: 6)
NSColor.textBackgroundColor.setFill()
border.fill()
NSColor.systemGreen.setStroke()
border.lineWidth = 2
border.stroke()
let shown = text.isEmpty ? placeholder : text.uppercased()
let attributes: [NSAttributedString.Key: Any] = [
.font: text.isEmpty ? NSFont.systemFont(ofSize: 13)
: NSFont.monospacedSystemFont(ofSize: 15, weight: .medium),
.foregroundColor: text.isEmpty ? NSColor.placeholderTextColor : NSColor.labelColor,
]
NSAttributedString(string: shown, attributes: attributes)
.draw(in: bounds.insetBy(dx: 8, dy: 5))
}
}
87 changes: 41 additions & 46 deletions platforms/macos/Sources/PracticeWindow.swift
Original file line number Diff line number Diff line change
Expand Up @@ -14,16 +14,16 @@ final class PracticeWindow: NSObject, NSWindowDelegate {
private let promptLabel = NSTextField(labelWithString: "")
private let feedbackLabel = NSTextField(wrappingLabelWithString: "")
private let progressLabel = NSTextField(labelWithString: "")
private let answerField = NSTextField(string: "")
private let answerView = PracticeAnswerView()
private let kindControl = NSSegmentedControl(labels: PracticeKind.allCases.map(\.title),
trackingMode: .selectOne, target: nil, action: nil)
private let strokeTitle = NSTextField(labelWithString: "末笔")
private let structureTitle = NSTextField(labelWithString: "结构")
private let strokeButtons = Practice.strokes.map { NSButton(title: $0, target: nil, action: nil) }
private let structureButtons = Practice.structures.map { NSButton(title: $0, target: nil, action: nil) }
private var practiceProgress: PracticeProgress?
private var bank: [PracticeQuestion] = []
private var current: PracticeQuestion?
private lazy var session = PracticeSession(kind: .code, progress: progress)
private var current: PracticeQuestion? { session.question }
private var pendingStroke: String?
private var pendingStructure: String?
private var lastAnswer: String?
Expand All @@ -48,7 +48,7 @@ final class PracticeWindow: NSObject, NSWindowDelegate {
reload()
window?.makeKeyAndOrderFront(nil)
NSApp.activate(ignoringOtherApps: true)
window?.makeFirstResponder(answerField)
window?.makeFirstResponder(answerView)
}

private func build() {
Expand Down Expand Up @@ -83,36 +83,33 @@ final class PracticeWindow: NSObject, NSWindowDelegate {
promptLabel.frame = NSRect(x: margin, y: 120, width: width - margin * 2, height: 18)
content.addSubview(promptLabel)

answerField.frame = NSRect(x: margin, y: 148, width: width - margin * 2, height: 26)
answerField.placeholderString = "输入完整五笔码后回车"
answerField.target = self
answerField.action = #selector(submitCode)
answerField.font = .monospacedSystemFont(ofSize: 14, weight: .regular)
content.addSubview(answerField)
answerView.frame = NSRect(x: margin, y: 148, width: width - margin * 2, height: 30)
answerView.onReturn = { [weak self] text in self?.submitCode(text) }
content.addSubview(answerView)

// 识别码:两行带标题的按钮组
strokeTitle.font = .systemFont(ofSize: 11)
strokeTitle.textColor = .secondaryLabelColor
strokeTitle.frame = NSRect(x: margin, y: 148, width: 32, height: 24)
strokeTitle.frame = NSRect(x: margin, y: 150, width: 46, height: 18)
content.addSubview(strokeTitle)
for (index, button) in strokeButtons.enumerated() {
button.target = self
button.action = #selector(pickStroke(_:))
button.tag = index
button.bezelStyle = .rounded
button.frame = NSRect(x: margin + 36 + CGFloat(index) * 74, y: 144, width: 68, height: 28)
button.frame = NSRect(x: margin + 52 + CGFloat(index) * 72, y: 144, width: 66, height: 28)
content.addSubview(button)
}
structureTitle.font = .systemFont(ofSize: 11)
structureTitle.textColor = .secondaryLabelColor
structureTitle.frame = NSRect(x: margin, y: 186, width: 32, height: 24)
structureTitle.frame = NSRect(x: margin, y: 188, width: 46, height: 18)
content.addSubview(structureTitle)
for (index, button) in structureButtons.enumerated() {
button.target = self
button.action = #selector(pickStructure(_:))
button.tag = index
button.bezelStyle = .rounded
button.frame = NSRect(x: margin + 36 + CGFloat(index) * 108, y: 182, width: 100, height: 28)
button.frame = NSRect(x: margin + 52 + CGFloat(index) * 116, y: 182, width: 108, height: 28)
content.addSubview(button)
}

Expand All @@ -139,7 +136,7 @@ final class PracticeWindow: NSObject, NSWindowDelegate {

private func applyKindVisibility() {
let isCode = kind == .code
answerField.isHidden = !isCode
answerView.isHidden = !isCode
strokeTitle.isHidden = isCode
structureTitle.isHidden = isCode
for button in strokeButtons + structureButtons { button.isHidden = isCode }
Expand All @@ -153,38 +150,40 @@ final class PracticeWindow: NSObject, NSWindowDelegate {
}

private func reload() {
bank = Practice.bank(kind: kind)
current = nil
nextQuestion()
session.switchKind(kind)
showCurrent()
}

@objc private func nextQuestion() {
guard !bank.isEmpty else {
session.next()
showCurrent()
}

private func showCurrent() {
answerView.clear()
pendingStroke = nil
pendingStructure = nil
lastAnswer = nil
feedbackLabel.stringValue = ""
strokeTitle.stringValue = "末笔"
structureTitle.stringValue = "结构"
guard let question = current else {
questionLabel.stringValue = "题库为空"
promptLabel.stringValue = "当前没有可出题的完整条目(需要 quality=full)"
progressLabel.stringValue = session.summary
return
}
current = bank.randomElement()
lastAnswer = nil
answerField.stringValue = ""
pendingStroke = nil
pendingStructure = nil
feedbackLabel.stringValue = ""
guard let question = current else { return }
questionLabel.stringValue = question.char
promptLabel.stringValue = kind == .code
? "打出它的完整五笔码(\(question.code.count) 位)"
: "拆解:\(question.rootHint)"
progressLabel.stringValue = progress.summary
if kind == .code { window?.makeFirstResponder(answerField) }
progressLabel.stringValue = session.summary
if kind == .code { window?.makeFirstResponder(answerView) }
}

@objc private func submitCode() {
guard let question = current else { return }
let answer = answerField.stringValue
let correct = Practice.judgeCode(answer, question: question)
lastAnswer = answer
finish(correct: correct, explanation: "完整码 \(question.code.uppercased()) · 字根 \(question.rootHint)")
@objc private func submitCode(_ text: String) {
guard let result = session.submitCode(text) else { return }
finish(result)
}

@objc private func pickStroke(_ sender: NSButton) {
Expand All @@ -205,22 +204,19 @@ final class PracticeWindow: NSObject, NSWindowDelegate {
}

private func evaluateRecognition() {
guard let question = current, let stroke = pendingStroke, let structure = pendingStructure else {
guard let stroke = pendingStroke, let structure = pendingStructure else {
feedbackLabel.stringValue = "选一个末笔和一个结构"
feedbackLabel.textColor = .secondaryLabelColor
return
}
let correct = Practice.judgeRecognition(stroke: stroke, structure: structure, question: question)
let letter = question.recognition?.letter ?? "—"
finish(correct: correct, explanation: "识别码 \(letter)(\(stroke)·\(structure))")
guard let result = session.submitRecognition(stroke: stroke, structure: structure) else { return }
finish(result)
}

private func finish(correct: Bool, explanation: String) {
guard let question = current else { return }
progress.record(char: question.char, kind: kind, correct: correct)
feedbackLabel.stringValue = (correct ? "✓ 正确 · " : "✗ 再想想 · ") + explanation
feedbackLabel.textColor = correct ? .systemGreen : .systemOrange
progressLabel.stringValue = progress.summary
private func finish(_ result: PracticeSession.Result) {
feedbackLabel.stringValue = (result.correct ? "✓ 正确 · " : "✗ 再想想 · ") + result.explanation
feedbackLabel.textColor = result.correct ? .systemGreen : .systemOrange
progressLabel.stringValue = session.summary
}

@objc private func revealAnswer() {
Expand All @@ -239,11 +235,10 @@ final class PracticeWindow: NSObject, NSWindowDelegate {
/// 离屏渲染(测试用):建一个不显示的窗口承载视图(AppKit 控件需要 window 才能绘制)。
func renderForTesting(kind: PracticeKind, progressURL: URL, light: Bool) -> NSBitmapImageRep? {
practiceProgress = PracticeProgress(url: progressURL)
session = PracticeSession(kind: kind, progress: practiceProgress!)
let view = buildContent()
kindControl.selectedSegment = PracticeKind.allCases.firstIndex(of: kind) ?? 0
applyKindVisibility()
bank = Practice.bank(kind: kind)
current = bank.first
if let question = current {
questionLabel.stringValue = question.char
promptLabel.stringValue = kind == .code
Expand Down
1 change: 1 addition & 0 deletions platforms/macos/dev-preview.sh
Original file line number Diff line number Diff line change
Expand Up @@ -55,6 +55,7 @@ swiftc -g -Onone -warnings-as-errors -module-name LampyrisPreview \
"${SCRIPT_DIR}/Sources/LearningHints.swift" \
"${SCRIPT_DIR}/Sources/Practice.swift" \
"${SCRIPT_DIR}/Sources/PracticeWindow.swift" \
"${SCRIPT_DIR}/Sources/PracticeAnswerView.swift" \
"${SCRIPT_DIR}/Sources/WubiHints.generated.swift" \
"${ROOT}/target/debug/libime_ffi.a" \
-framework AppKit -o "${BIN}"
Expand Down
2 changes: 1 addition & 1 deletion platforms/macos/tests/test-appearance-contract.py
Original file line number Diff line number Diff line change
Expand Up @@ -25,7 +25,7 @@ def test_native_appearance_contrast_and_geometry(self):
(["swiftc", "-warnings-as-errors", "-I", str(ROOT / "crates/ime-ffi/include"),
str(temporary / "main.swift"),
*[str(ROOT / "platforms/macos/Sources" / name) for name in
["ImeSession.swift", "Config.swift", "CandidateWindow.swift", "CaretPlacement.swift", "LearningHints.swift", "Practice.swift", "PracticeWindow.swift", "Settings.swift", "Feedback.swift", "InfoWindow.swift",
["ImeSession.swift", "Config.swift", "CandidateWindow.swift", "CaretPlacement.swift", "LearningHints.swift", "Practice.swift", "PracticeWindow.swift", "PracticeAnswerView.swift", "Settings.swift", "Feedback.swift", "InfoWindow.swift",
"UserDictionary.swift", "Lookup.swift"]],
str(ROOT / "target/release/libime_ffi.a"), "-framework", "AppKit",
"-o", str(temporary / "probe")], 45),
Expand Down
2 changes: 1 addition & 1 deletion platforms/macos/tests/test-candidate-presentation.py
Original file line number Diff line number Diff line change
Expand Up @@ -28,7 +28,7 @@ def test_native_view_and_real_ffi_paging(self):
str(temporary / "main.swift"),
str(ROOT / "platforms/macos/tests/LocalAcceptanceEvidence.swift"),
*[str(ROOT / "platforms/macos/Sources" / name) for name in
["ImeSession.swift", "Config.swift", "CandidateWindow.swift", "CaretPlacement.swift", "LearningHints.swift", "Practice.swift", "PracticeWindow.swift", "StateHUD.swift"]],
["ImeSession.swift", "Config.swift", "CandidateWindow.swift", "CaretPlacement.swift", "LearningHints.swift", "Practice.swift", "PracticeWindow.swift", "PracticeAnswerView.swift", "StateHUD.swift"]],
str(ROOT / "target/release/libime_ffi.a"), "-framework", "AppKit",
"-o", str(temporary / "probe")], 45),
([str(temporary / "probe"), str(temporary), str(output)], 10),
Expand Down
Loading
Loading