diff --git a/HANDOFF.md b/HANDOFF.md index e181c32..833b32c 100644 --- a/HANDOFF.md +++ b/HANDOFF.md @@ -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 §6)**:新增 `PracticeRootRowView`(字形上、大写键位下),识别码题常驻、看字打码题答后显示;窗口高 356。**待装包复验**。 - **练习流程已修 + 自动化(报告 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**,入口在设置页快捷键列表;**尚未装包实测**。 diff --git a/docs/reviews/261-practice-flow-and-ui-fixes.md b/docs/reviews/261-practice-flow-and-ui-fixes.md index bd8fb4c..52ebd76 100644 --- a/docs/reviews/261-practice-flow-and-ui-fixes.md +++ b/docs/reviews/261-practice-flow-and-ui-fixes.md @@ -35,3 +35,14 @@ ## 5. 关联 ADR-313、报告 257/259/260。 + +## 6. 字根显示改为与学习区一致(2026-11-08 反馈) + +**反馈**:练习页把字根和键位交替写成 `土F 丿T 匕X`,阅读不直观。 + +**改法**:新增 `PracticeRootRowView`,与学习区同一套呈现——**字形在上、大写键位在下**,格子等宽居中; +- 识别码题型:字根行常驻,作为判断末笔/结构的依据; +- 看字打码题型:答题后再显示字根行(不提前泄题),反馈文案不再重复拼接字根; +- 窗口高度 336→356,纵向重排:题型 / 题干 / 提示 / 字根行 / 作答 / 反馈 / 按钮 / 进度。 + +**测试**:新增字根行断言(键位大写、顺序一致);版式自检仍要求所有控件在内容区内。 diff --git a/platforms/macos/Sources/Practice.swift b/platforms/macos/Sources/Practice.swift index f512e1a..749a0cb 100644 --- a/platforms/macos/Sources/Practice.swift +++ b/platforms/macos/Sources/Practice.swift @@ -174,8 +174,7 @@ final class PracticeSession { 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)") + return Result(correct: correct, explanation: "完整码 \(question.code.uppercased())") } /// 提交识别码选择;证据说明用**正确**的末笔与结构,而不是用户选的。 diff --git a/platforms/macos/Sources/PracticeRootRowView.swift b/platforms/macos/Sources/PracticeRootRowView.swift new file mode 100644 index 0000000..a4139b9 --- /dev/null +++ b/platforms/macos/Sources/PracticeRootRowView.swift @@ -0,0 +1,38 @@ +import AppKit + +/// 练习里的字根行:与学习区同一套样式——上面字形、下面大写键位,一目了然。 +final class PracticeRootRowView: NSView { + static let cellWidth: CGFloat = 46 + static let rowHeight: CGFloat = 36 + private(set) var roots: [(glyph: String, key: String)] = [] + override var isFlipped: Bool { true } + + func show(_ roots: [(glyph: String, key: String)]) { + self.roots = Array(roots.prefix(6)) + setAccessibilityLabel(self.roots.map { "\($0.glyph),键 \($0.key)" }.joined(separator: ";")) + needsDisplay = true + } + + override func draw(_ dirtyRect: NSRect) { + var x: CGFloat = 0 + for root in roots { + let glyphRect = NSRect(x: x, y: 0, width: Self.cellWidth, height: 21) + let keyRect = NSRect(x: x, y: 21, width: Self.cellWidth, height: 15) + let glyph = NSMutableAttributedString(string: root.glyph, attributes: [ + .font: NSFont.systemFont(ofSize: 15), + .foregroundColor: NSColor.labelColor]) + var paragraph = NSMutableParagraphStyle() + paragraph.alignment = .center + glyph.addAttribute(.paragraphStyle, value: paragraph, range: NSRange(location: 0, length: glyph.length)) + glyph.draw(in: glyphRect) + let key = NSMutableAttributedString(string: root.key.uppercased(), attributes: [ + .font: NSFont.monospacedSystemFont(ofSize: 10.5, weight: .regular), + .foregroundColor: NSColor.secondaryLabelColor]) + paragraph = NSMutableParagraphStyle() + paragraph.alignment = .center + key.addAttribute(.paragraphStyle, value: paragraph, range: NSRange(location: 0, length: key.length)) + key.draw(in: keyRect) + x += Self.cellWidth + } + } +} diff --git a/platforms/macos/Sources/PracticeWindow.swift b/platforms/macos/Sources/PracticeWindow.swift index 5c29d22..29465ff 100644 --- a/platforms/macos/Sources/PracticeWindow.swift +++ b/platforms/macos/Sources/PracticeWindow.swift @@ -6,7 +6,7 @@ import AppKit /// 题型切换 → 题干 → 提示 → 作答区 → 反馈 → 按钮 → 进度。 final class PracticeWindow: NSObject, NSWindowDelegate { static let shared = PracticeWindow() - static let contentSize = NSSize(width: 460, height: 336) + static let contentSize = NSSize(width: 460, height: 356) private var window: NSWindow? private var content: FlippedView? @@ -15,6 +15,8 @@ final class PracticeWindow: NSObject, NSWindowDelegate { private let feedbackLabel = NSTextField(wrappingLabelWithString: "") private let progressLabel = NSTextField(labelWithString: "") private let answerView = PracticeAnswerView() + private let rootRow = PracticeRootRowView() + private var answered = false private let kindControl = NSSegmentedControl(labels: PracticeKind.allCases.map(\.title), trackingMode: .selectOne, target: nil, action: nil) private let strokeTitle = NSTextField(labelWithString: "末笔") @@ -80,42 +82,46 @@ final class PracticeWindow: NSObject, NSWindowDelegate { promptLabel.alignment = .center promptLabel.font = .systemFont(ofSize: 12) promptLabel.textColor = .secondaryLabelColor - promptLabel.frame = NSRect(x: margin, y: 120, width: width - margin * 2, height: 18) + promptLabel.frame = NSRect(x: margin, y: 116, width: width - margin * 2, height: 18) content.addSubview(promptLabel) - answerView.frame = NSRect(x: margin, y: 148, width: width - margin * 2, height: 30) + // 字根行(与学习区同样式:字形上、键位下) + rootRow.frame = NSRect(x: margin, y: 136, width: width - margin * 2, height: PracticeRootRowView.rowHeight) + content.addSubview(rootRow) + + answerView.frame = NSRect(x: margin, y: 176, 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: 150, width: 46, height: 18) + strokeTitle.frame = NSRect(x: margin, y: 182, 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 + 52 + CGFloat(index) * 72, y: 144, width: 66, height: 28) + button.frame = NSRect(x: margin + 52 + CGFloat(index) * 72, y: 176, width: 66, height: 28) content.addSubview(button) } structureTitle.font = .systemFont(ofSize: 11) structureTitle.textColor = .secondaryLabelColor - structureTitle.frame = NSRect(x: margin, y: 188, width: 46, height: 18) + structureTitle.frame = NSRect(x: margin, y: 220, 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 + 52 + CGFloat(index) * 116, y: 182, width: 108, height: 28) + button.frame = NSRect(x: margin + 52 + CGFloat(index) * 116, y: 214, width: 108, height: 28) content.addSubview(button) } feedbackLabel.alignment = .center feedbackLabel.font = .systemFont(ofSize: 12) - feedbackLabel.frame = NSRect(x: margin, y: 218, width: width - margin * 2, height: 40) + feedbackLabel.frame = NSRect(x: margin, y: 250, width: width - margin * 2, height: 40) content.addSubview(feedbackLabel) let next = NSButton(title: "下一题", target: self, action: #selector(nextQuestion)) @@ -123,13 +129,13 @@ final class PracticeWindow: NSObject, NSWindowDelegate { let export = NSButton(title: "打开进度文件", target: self, action: #selector(openProgress)) for (index, button) in [next, reveal, export].enumerated() { button.bezelStyle = .rounded - button.frame = NSRect(x: margin + CGFloat(index) * 130, y: 264, width: 120, height: 28) + button.frame = NSRect(x: margin + CGFloat(index) * 130, y: 292, width: 120, height: 28) content.addSubview(button) } progressLabel.font = .systemFont(ofSize: 11) progressLabel.textColor = .secondaryLabelColor - progressLabel.frame = NSRect(x: margin, y: 300, width: width - margin * 2, height: 16) + progressLabel.frame = NSRect(x: margin, y: 326, width: width - margin * 2, height: 16) content.addSubview(progressLabel) return content } @@ -137,6 +143,7 @@ final class PracticeWindow: NSObject, NSWindowDelegate { private func applyKindVisibility() { let isCode = kind == .code answerView.isHidden = !isCode + rootRow.isHidden = !(kind == .recognition || answered) strokeTitle.isHidden = isCode structureTitle.isHidden = isCode for button in strokeButtons + structureButtons { button.isHidden = isCode } @@ -160,6 +167,7 @@ final class PracticeWindow: NSObject, NSWindowDelegate { } private func showCurrent() { + answered = false answerView.clear() pendingStroke = nil pendingStructure = nil @@ -176,7 +184,9 @@ final class PracticeWindow: NSObject, NSWindowDelegate { questionLabel.stringValue = question.char promptLabel.stringValue = kind == .code ? "打出它的完整五笔码(\(question.code.count) 位)" - : "拆解:\(question.rootHint)" + : "按上面的字根与键位,判断末笔与结构" + rootRow.show(question.roots) + rootRow.isHidden = kind != .recognition progressLabel.stringValue = session.summary if kind == .code { window?.makeFirstResponder(answerView) } } @@ -214,6 +224,11 @@ final class PracticeWindow: NSObject, NSWindowDelegate { } private func finish(_ result: PracticeSession.Result) { + answered = true + if let question = current { + rootRow.show(question.roots) + rootRow.isHidden = false + } feedbackLabel.stringValue = (result.correct ? "✓ 正确 · " : "✗ 再想想 · ") + result.explanation feedbackLabel.textColor = result.correct ? .systemGreen : .systemOrange progressLabel.stringValue = session.summary diff --git a/platforms/macos/dev-preview.sh b/platforms/macos/dev-preview.sh index e860b75..8dace1e 100755 --- a/platforms/macos/dev-preview.sh +++ b/platforms/macos/dev-preview.sh @@ -56,6 +56,7 @@ swiftc -g -Onone -warnings-as-errors -module-name LampyrisPreview \ "${SCRIPT_DIR}/Sources/Practice.swift" \ "${SCRIPT_DIR}/Sources/PracticeWindow.swift" \ "${SCRIPT_DIR}/Sources/PracticeAnswerView.swift" \ + "${SCRIPT_DIR}/Sources/PracticeRootRowView.swift" \ "${SCRIPT_DIR}/Sources/WubiHints.generated.swift" \ "${ROOT}/target/debug/libime_ffi.a" \ -framework AppKit -o "${BIN}" diff --git a/platforms/macos/tests/test-appearance-contract.py b/platforms/macos/tests/test-appearance-contract.py index a67d9c1..2a5fb6e 100644 --- a/platforms/macos/tests/test-appearance-contract.py +++ b/platforms/macos/tests/test-appearance-contract.py @@ -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", "PracticeAnswerView.swift", "Settings.swift", "Feedback.swift", "InfoWindow.swift", + ["ImeSession.swift", "Config.swift", "CandidateWindow.swift", "CaretPlacement.swift", "LearningHints.swift", "Practice.swift", "PracticeWindow.swift", "PracticeAnswerView.swift", "PracticeRootRowView.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), diff --git a/platforms/macos/tests/test-candidate-presentation.py b/platforms/macos/tests/test-candidate-presentation.py index ad5d230..d8ffe94 100644 --- a/platforms/macos/tests/test-candidate-presentation.py +++ b/platforms/macos/tests/test-candidate-presentation.py @@ -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", "PracticeAnswerView.swift", "StateHUD.swift"]], + ["ImeSession.swift", "Config.swift", "CandidateWindow.swift", "CaretPlacement.swift", "LearningHints.swift", "Practice.swift", "PracticeWindow.swift", "PracticeAnswerView.swift", "PracticeRootRowView.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), diff --git a/platforms/macos/tests/test-composition-navigation.py b/platforms/macos/tests/test-composition-navigation.py index 355f190..13c805e 100644 --- a/platforms/macos/tests/test-composition-navigation.py +++ b/platforms/macos/tests/test-composition-navigation.py @@ -24,7 +24,7 @@ def test_real_swift_event_classifier_with_private_config(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", "PracticeAnswerView.swift"]], + ["ImeSession.swift", "Config.swift", "CandidateWindow.swift", "CaretPlacement.swift", "LearningHints.swift", "Practice.swift", "PracticeWindow.swift", "PracticeAnswerView.swift", "PracticeRootRowView.swift"]], str(ROOT / "target/release/libime_ffi.a"), "-framework", "AppKit", "-o", str(temporary / "probe")], 45), ([str(temporary / "probe"), str(temporary)], 10), diff --git a/platforms/macos/tests/test-preview-menu.py b/platforms/macos/tests/test-preview-menu.py index ae4fde9..fda4bbf 100644 --- a/platforms/macos/tests/test-preview-menu.py +++ b/platforms/macos/tests/test-preview-menu.py @@ -43,7 +43,7 @@ def test_real_shared_menu_and_private_session(self): (['swiftc', '-warnings-as-errors', '-I', str(ROOT / 'crates/ime-ffi/include'), 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', 'PracticeAnswerView.swift', 'InputMenu.swift']], + *[str(ROOT / 'platforms/macos/Sources' / name) for name in ['ImeSession.swift', 'Config.swift', 'CandidateWindow.swift', 'CaretPlacement.swift', 'LearningHints.swift', 'Practice.swift', 'PracticeWindow.swift', 'PracticeAnswerView.swift', 'PracticeRootRowView.swift', 'InputMenu.swift']], str(ROOT / 'platforms/macos/Preview/InputMenuPreview.swift'), str(ROOT / 'target/release/libime_ffi.a'), '-framework', 'AppKit', '-o', str(temporary / 'probe')], 45), ([str(temporary / 'probe'), str(temporary), str(ROOT / 'data/generated')], 10), diff --git a/platforms/macos/tests/test-settings-presentation.py b/platforms/macos/tests/test-settings-presentation.py index b3d7793..5a2377f 100644 --- a/platforms/macos/tests/test-settings-presentation.py +++ b/platforms/macos/tests/test-settings-presentation.py @@ -28,7 +28,7 @@ def test_native_panes_and_private_configuration(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", "PracticeAnswerView.swift", "Settings.swift", "Feedback.swift", "InfoWindow.swift", + ["ImeSession.swift", "Config.swift", "CandidateWindow.swift", "CaretPlacement.swift", "LearningHints.swift", "Practice.swift", "PracticeWindow.swift", "PracticeAnswerView.swift", "PracticeRootRowView.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), diff --git a/platforms/macos/tests/test-tools-presentation.py b/platforms/macos/tests/test-tools-presentation.py index 01ffeaa..806a5e1 100644 --- a/platforms/macos/tests/test-tools-presentation.py +++ b/platforms/macos/tests/test-tools-presentation.py @@ -28,7 +28,7 @@ def test_native_forms_and_information_states(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", "PracticeAnswerView.swift", "Settings.swift", "Feedback.swift", "InfoWindow.swift", + ["ImeSession.swift", "Config.swift", "CandidateWindow.swift", "CaretPlacement.swift", "LearningHints.swift", "Practice.swift", "PracticeWindow.swift", "PracticeAnswerView.swift", "PracticeRootRowView.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), diff --git a/platforms/macos/tests/test-wubi-learning.py b/platforms/macos/tests/test-wubi-learning.py index 3718e41..502829b 100644 --- a/platforms/macos/tests/test-wubi-learning.py +++ b/platforms/macos/tests/test-wubi-learning.py @@ -34,7 +34,7 @@ def test_real_ffi_teaching_model(self): 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", "PracticeAnswerView.swift"]], + "LearningHints.swift", "Practice.swift", "PracticeWindow.swift", "PracticeAnswerView.swift", "PracticeRootRowView.swift"]], str(ROOT / "target/release/libime_ffi.a"), "-framework", "AppKit", "-o", str(temporary / "probe")], 45), ([str(temporary / "probe"), str(temporary), str(output)], 10), diff --git a/platforms/macos/tests/wubi-learning.swift b/platforms/macos/tests/wubi-learning.swift index d6cd9dd..a9915ae 100644 --- a/platforms/macos/tests/wubi-learning.swift +++ b/platforms/macos/tests/wubi-learning.swift @@ -53,6 +53,10 @@ guard let recognitionResult = recognitionSession.submitRecognition(stroke: expec require(recognitionResult.correct, "正确末笔/结构应判对") require(recognitionResult.explanation.contains(expected.letter), "说明应包含正确字母") require(recognitionResult.explanation.contains(expected.stroke), "说明应包含**正确**的末笔(不是用户选的)") +// 练习字根行与学习区同样式:字形上、键位下(大写)。 +let rootRow = PracticeRootRowView(frame: NSRect(x: 0, y: 0, width: 400, height: 36)) +rootRow.show([("土", "F"), ("丿", "T"), ("匕", "X")]) +require(rootRow.roots.map(\.key) == ["F", "T", "X"], "字根行键位大写且顺序一致") // 输入视图只接受 a–y、退格与回车。 let answer = PracticeAnswerView(frame: NSRect(x: 0, y: 0, width: 200, height: 30)) var submitted: String?