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 §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**,入口在设置页快捷键列表;**尚未装包实测**。
Expand Down
11 changes: 11 additions & 0 deletions docs/reviews/261-practice-flow-and-ui-fixes.md
Original file line number Diff line number Diff line change
Expand Up @@ -35,3 +35,14 @@
## 5. 关联

ADR-313、报告 257/259/260。

## 6. 字根显示改为与学习区一致(2026-11-08 反馈)

**反馈**:练习页把字根和键位交替写成 `土F 丿T 匕X`,阅读不直观。

**改法**:新增 `PracticeRootRowView`,与学习区同一套呈现——**字形在上、大写键位在下**,格子等宽居中;
- 识别码题型:字根行常驻,作为判断末笔/结构的依据;
- 看字打码题型:答题后再显示字根行(不提前泄题),反馈文案不再重复拼接字根;
- 窗口高度 336→356,纵向重排:题型 / 题干 / 提示 / 字根行 / 作答 / 反馈 / 按钮 / 进度。

**测试**:新增字根行断言(键位大写、顺序一致);版式自检仍要求所有控件在内容区内。
3 changes: 1 addition & 2 deletions platforms/macos/Sources/Practice.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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())")
}

/// 提交识别码选择;证据说明用**正确**的末笔与结构,而不是用户选的。
Expand Down
38 changes: 38 additions & 0 deletions platforms/macos/Sources/PracticeRootRowView.swift
Original file line number Diff line number Diff line change
@@ -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
}
}
}
37 changes: 26 additions & 11 deletions platforms/macos/Sources/PracticeWindow.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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?
Expand All @@ -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: "末笔")
Expand Down Expand Up @@ -80,63 +82,68 @@ 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))
let reveal = NSButton(title: "显示答案", target: self, action: #selector(revealAnswer))
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
}

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 }
Expand All @@ -160,6 +167,7 @@ final class PracticeWindow: NSObject, NSWindowDelegate {
}

private func showCurrent() {
answered = false
answerView.clear()
pendingStroke = nil
pendingStructure = nil
Expand All @@ -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) }
}
Expand Down Expand Up @@ -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
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 @@ -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}"
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", "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),
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", "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),
Expand Down
2 changes: 1 addition & 1 deletion platforms/macos/tests/test-composition-navigation.py
Original file line number Diff line number Diff line change
Expand Up @@ -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),
Expand Down
2 changes: 1 addition & 1 deletion platforms/macos/tests/test-preview-menu.py
Original file line number Diff line number Diff line change
Expand Up @@ -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),
Expand Down
2 changes: 1 addition & 1 deletion platforms/macos/tests/test-settings-presentation.py
Original file line number Diff line number Diff line change
Expand Up @@ -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),
Expand Down
2 changes: 1 addition & 1 deletion platforms/macos/tests/test-tools-presentation.py
Original file line number Diff line number Diff line change
Expand Up @@ -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),
Expand Down
2 changes: 1 addition & 1 deletion platforms/macos/tests/test-wubi-learning.py
Original file line number Diff line number Diff line change
Expand Up @@ -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),
Expand Down
Loading
Loading