From 8aa466defdc50986ed5af7bea191d8b79cb4a63c Mon Sep 17 00:00:00 2001 From: wangzhizhou <824219521@qq.com> Date: Thu, 12 Nov 2026 13:20:54 +0800 Subject: [PATCH 1/5] feat: remember the English state per application (ADR-333) Switching to English in one app no longer resets when a new text field in that app gets a fresh controller. The memory is in-process only, bounded to 64 apps, and controlled by input.remember_english_per_app (default on). Signed-off-by: wangzhizhou <824219521@qq.com> --- crates/ime-config/src/lib.rs | 3 +- crates/ime-config/src/schema.rs | 9 +++++ crates/ime-ffi/src/config.rs | 2 +- ...63\347\255\226\350\256\260\345\275\225.md" | 16 +++++++++ ...15\347\275\256\346\236\266\346\236\204.md" | 1 + platforms/macos/Preview/main.swift | 4 +-- .../macos/Sources/AppLanguageMemory.swift | 34 +++++++++++++++++++ platforms/macos/Sources/InputController.swift | 21 ++++++++++++ platforms/macos/Sources/Settings.swift | 2 +- platforms/macos/tests/controller-menu.swift | 15 ++++++++ .../macos/tests/settings-presentation.swift | 6 ++-- 11 files changed, 105 insertions(+), 8 deletions(-) create mode 100644 platforms/macos/Sources/AppLanguageMemory.swift diff --git a/crates/ime-config/src/lib.rs b/crates/ime-config/src/lib.rs index a8f3dfe..d5277bb 100644 --- a/crates/ime-config/src/lib.rs +++ b/crates/ime-config/src/lib.rs @@ -866,7 +866,7 @@ mod tests { #[test] fn schema_is_single_source_and_matches_v1() { - assert_eq!(schema().len(), 27); + assert_eq!(schema().len(), 28); let keys: Vec<&str> = schema().iter().map(|s| s.key).collect(); assert_eq!( keys, @@ -896,6 +896,7 @@ mod tests { "input.full_width", "input.chinese_variant", "input.digit_punctuation", + "input.remember_english_per_app", "update.feed_url", "update.feed_mirrors", ] diff --git a/crates/ime-config/src/schema.rs b/crates/ime-config/src/schema.rs index b39755e..0b9426a 100644 --- a/crates/ime-config/src/schema.rs +++ b/crates/ime-config/src/schema.rs @@ -287,6 +287,15 @@ pub static SCHEMA: &[OptionSpec] = &[ doc_zh: "数字后紧跟的 . 和 : 不转成中文标点,便于输入小数、版本号与时间(如 3.14、12:30)", scope: Scope::Engine, }, + OptionSpec { + key: "input.remember_english_per_app", + value_type: ValueType::Bool, + enum_labels_zh: &[], + default: "true", + title_zh: "按应用记住中英状态", + doc_zh: "在某个应用里切到英文后,回到该应用时保持英文;只记在内存里,退出输入法即清空", + scope: Scope::Engine, + }, OptionSpec { key: "update.feed_url", value_type: ValueType::Url, diff --git a/crates/ime-ffi/src/config.rs b/crates/ime-ffi/src/config.rs index ee77987..85d53ce 100644 --- a/crates/ime-ffi/src/config.rs +++ b/crates/ime-ffi/src/config.rs @@ -463,7 +463,7 @@ mod tests { #[test] fn schema_layout_matches_v1() { let layout = build_schema(); - assert_eq!(layout.view.len, 27); + assert_eq!(layout.view.len, 28); let first = layout._options[0]; // SAFETY:指针由 build_schema 的 CString/Vec 持有,测试期间有效。 unsafe { diff --git "a/docs/\345\206\263\347\255\226\350\256\260\345\275\225.md" "b/docs/\345\206\263\347\255\226\350\256\260\345\275\225.md" index de2aff5..b3a44d9 100644 --- "a/docs/\345\206\263\347\255\226\350\256\260\345\275\225.md" +++ "b/docs/\345\206\263\347\255\226\350\256\260\345\275\225.md" @@ -3339,3 +3339,19 @@ 「金」在 Q、排在题型最前、判分)并把版式自检扩到字根题型;亮/暗实拍已生成并目视确认浅色版式正常。离线回归全绿。 - **状态**:已采纳(2026-11-12)。只改练习窗口,不影响输入行为。 - **关联**:ADR-324(U2-5)、ADR-313/315/316/328。 + +### ADR-333:按应用记住中英状态(U3-5 的一部分) + +- **决策**: + 1. 宿主维护一份「应用 bundle id → 上次是否英文」的**内存**记忆(`AppLanguageMemory`,最多 64 个应用,超出丢最久未更新的)。 + 切换中英时(单独 Shift、菜单、快捷键)与控制器失活时记下;控制器激活时若该应用记过且与当前不同,静默恢复,不弹提示。 + 2. 没记过的应用不做任何事(仍是中文起步)。 + 3. 新增配置项 `input.remember_english_per_app`(Bool,默认 `true`,设置页「默认输入」组)。 +- **理由**:IMK 为每个客户端建一个控制器,中英状态跟着控制器走;同一应用里换个输入框就可能回到中文, + 终端、编辑器用户要反复切(ADR-324 证据 8)。 +- **边界**:这是会话状态,**不落盘**——不写 `config.toml`、不写任何文件,宿主退出即清空,因此不触碰铁律 3,也不是「用户画像」。 + 不做「预设某些应用默认英文」的名单(那是偏好,要另议配置形态)。 +- **验收**:`test-controller-menu.py` 断言(未知应用不表态、覆盖、空标识不记、有界淘汰);Schema 条目数 27→28; + 宿主随该测试一并编译。**跨应用切换的实际效果待换包后复验。** +- **状态**:已采纳(2026-11-12)。 +- **关联**:ADR-324(U3-5)、`docs/配置架构.md` §5。 diff --git "a/docs/\351\205\215\347\275\256\346\236\266\346\236\204.md" "b/docs/\351\205\215\347\275\256\346\236\266\346\236\204.md" index 0dce926..723946e 100644 --- "a/docs/\351\205\215\347\275\256\346\236\266\346\236\204.md" +++ "b/docs/\351\205\215\347\275\256\346\236\266\346\236\204.md" @@ -150,6 +150,7 @@ punctuation = "chinese" # 标点:chinese = 中文 | english = 英文(⌥P full_width = false # 全角 ASCII(⌥Space 可临时切换) chinese_variant = "simplified" # simplified = 简体 | traditional = 繁体(⌥T 可切换) digit_punctuation = true # 数字后紧跟的 . 和 : 保持半角(3.14、12:30;ADR-325) +remember_english_per_app = true # 按应用记住中英状态(只在内存里;ADR-333) ``` 后续按需从 Schema **开放**新选项(如 `show_radical`、`font_size`、`page_up/down`、`简繁切换`、`标点全半角`、`候选横排/竖排`),**加选项不改架构**:Schema 加一项 → UI 自动出现 → 文件按需写。 diff --git a/platforms/macos/Preview/main.swift b/platforms/macos/Preview/main.swift index 6a7a7e3..8463fcb 100644 --- a/platforms/macos/Preview/main.swift +++ b/platforms/macos/Preview/main.swift @@ -904,8 +904,8 @@ if CommandLine.arguments.contains("--smoke-test") { guard restored.candidates(limit: 1).first?.text == "离线测试词" else { exit(1) } // 设置面板(ADR-070):Schema 驱动、界面零硬编码。 let settingsOptions = SettingsSchema.options() - guard settingsOptions.count == 27 else { - fputs("设置项数量应为 27,实际 \(settingsOptions.count)。\n", stderr); exit(1) + guard settingsOptions.count == 28 else { + fputs("设置项数量应为 28,实际 \(settingsOptions.count)。\n", stderr); exit(1) } guard settingsOptions.allSatisfy({ !$0.title.isEmpty && !$0.doc.isEmpty }) else { fputs("设置项缺标题或说明。\n", stderr); exit(1) diff --git a/platforms/macos/Sources/AppLanguageMemory.swift b/platforms/macos/Sources/AppLanguageMemory.swift new file mode 100644 index 0000000..f474525 --- /dev/null +++ b/platforms/macos/Sources/AppLanguageMemory.swift @@ -0,0 +1,34 @@ +// 按应用记住中英状态(ADR-333):只在内存里、随宿主进程存亡,不落盘、不进配置。 + +import Foundation + +/// 「应用 → 上次是否处于英文状态」的有界记忆。 +/// +/// 终端、编辑器里切到英文后,换个输入框或切走再回来不必重切。 +/// 这是**会话状态**而非偏好(`docs/配置架构.md` §5),所以不写 `config.toml`,也不写任何文件。 +struct AppLanguageMemory { + /// 最多记住的应用数;超出时丢掉最久没更新的。 + static let capacity = 64 + + private var states: [String: Bool] = [:] + private var order: [String] = [] + + /// 记下某个应用当前的中英状态。空标识(拿不到 bundle id)不记。 + mutating func remember(_ english: Bool, for bundle: String?) { + guard let bundle, !bundle.isEmpty else { return } + states[bundle] = english + order.removeAll { $0 == bundle } + order.append(bundle) + while order.count > Self.capacity { + states[order.removeFirst()] = nil + } + } + + /// 某个应用上次的中英状态;没记过返回 `nil`(调用方保持默认,不强行切换)。 + func english(for bundle: String?) -> Bool? { + guard let bundle, !bundle.isEmpty else { return nil } + return states[bundle] + } + + var count: Int { states.count } +} diff --git a/platforms/macos/Sources/InputController.swift b/platforms/macos/Sources/InputController.swift index e37670e..df3439b 100644 --- a/platforms/macos/Sources/InputController.swift +++ b/platforms/macos/Sources/InputController.swift @@ -18,6 +18,8 @@ final class LampyrisInputController: IMKInputController { private weak var activeClient: (any IMKTextInput)? private var workspaceObserver: NSObjectProtocol? private var dataLoadError: String? + /// 各应用上次的中英状态(ADR-333)。所有控制器共享,只在内存里。 + private static var languageMemory = AppLanguageMemory() // MARK: - 生命周期 @@ -39,6 +41,7 @@ final class LampyrisInputController: IMKInputController { session = makeSession() } if candidateWindow == nil { candidateWindow = CandidateWindow() } + restoreLanguage() ConfigWatcher.shared.observe(owner: self) { [weak self] key in self?.configChanged(key) } @@ -48,6 +51,7 @@ final class LampyrisInputController: IMKInputController { } override func deactivateServer(_ sender: Any!) { + rememberLanguage() finishComposition(activeClient ?? (sender as? IMKTextInput) ?? client()) ConfigWatcher.shared.clear(owner: self) activeClient = nil @@ -60,6 +64,20 @@ final class LampyrisInputController: IMKInputController { } } + /// 记下当前应用的中英状态(ADR-333)。 + private func rememberLanguage() { + guard let session, ImeConfig.bool("input.remember_english_per_app") else { return } + Self.languageMemory.remember(session.isEnglish, for: activeClient?.bundleIdentifier()) + } + + /// 回到一个应用时恢复它上次的中英状态;没记过就不动。静默恢复,不弹提示。 + private func restoreLanguage() { + guard let session, ImeConfig.bool("input.remember_english_per_app"), + let english = Self.languageMemory.english(for: activeClient?.bundleIdentifier()), + session.isEnglish != english else { return } + session.toggleEnglish() + } + override func commitComposition(_ sender: Any!) { finishComposition((sender as? IMKTextInput) ?? activeClient ?? client()) } @@ -262,6 +280,7 @@ final class LampyrisInputController: IMKInputController { session.reset() } session?.toggleEnglish() + rememberLanguage() showStateFeedback(StateFeedback.language(session?.isEnglish ?? false)) refreshAfterStateChange() } @@ -527,6 +546,7 @@ final class LampyrisInputController: IMKInputController { let learningBeforeShortcut = ImeConfig.bool("candidate.show_wubi_learning") let shortcut = session.shortcut(event, persistMode: true) guard shortcut.consumed else { return false } + rememberLanguage() if session.mode != modeBeforeShortcut { showStateFeedback(StateFeedback.mode(session.mode)) } else if ImeConfig.bool("candidate.show_wubi_learning") != learningBeforeShortcut { @@ -581,6 +601,7 @@ final class LampyrisInputController: IMKInputController { if Self.hudDiagEnabled { Self.hudDiag("shift committed raw=\(raw)") } } candidateWindow?.hide() + rememberLanguage() // 键盘单独 Shift 与菜单/快捷键走同一提示:候选窗已隐藏,这里应弹 HUD。 showStateFeedback(StateFeedback.language(session.isEnglish)) } diff --git a/platforms/macos/Sources/Settings.swift b/platforms/macos/Sources/Settings.swift index ba26ce6..97e57b3 100644 --- a/platforms/macos/Sources/Settings.swift +++ b/platforms/macos/Sources/Settings.swift @@ -651,7 +651,7 @@ final class SettingsPanel: NSObject, NSWindowDelegate, NSToolbarDelegate { let groups: [(String, [String])] if scopes == [0] { groups = [("默认输入", ["mode", "input.chinese_variant", "input.punctuation", "input.full_width", - "input.digit_punctuation"]), + "input.digit_punctuation", "input.remember_english_per_app"]), ("五笔与拼音", ["wubi.auto_commit", "wubi.z_key", "pinyin.fuzzy"])] } else if scopes == [4] { groups = [("更新来源", ["update.feed_url"])] diff --git a/platforms/macos/tests/controller-menu.swift b/platforms/macos/tests/controller-menu.swift index e4614aa..fae4b63 100644 --- a/platforms/macos/tests/controller-menu.swift +++ b/platforms/macos/tests/controller-menu.swift @@ -120,6 +120,21 @@ print("PASS controller-menu: flat menu, cold/warm cycle through the IMK service require(StateFeedback.language(true) == "英文" && StateFeedback.language(false) == "中文", "language feedback") require(StateFeedback.width(true) == "全角" && StateFeedback.width(false) == "半角", "width feedback") +// ADR-333:按应用记住中英状态——没记过不表态、能覆盖、有界、空标识不记。 +var languageMemory = AppLanguageMemory() +require(languageMemory.english(for: "com.apple.Terminal") == nil, "unknown app has no remembered state") +languageMemory.remember(true, for: "com.apple.Terminal") +languageMemory.remember(false, for: "com.apple.TextEdit") +require(languageMemory.english(for: "com.apple.Terminal") == true && languageMemory.english(for: "com.apple.TextEdit") == false, + "per-app state") +languageMemory.remember(false, for: "com.apple.Terminal") +require(languageMemory.english(for: "com.apple.Terminal") == false, "latest state wins") +languageMemory.remember(true, for: nil) +languageMemory.remember(true, for: "") +require(languageMemory.count == 2, "missing bundle ids are ignored") +for index in 0..<(AppLanguageMemory.capacity + 5) { languageMemory.remember(true, for: "app.\(index)") } +require(languageMemory.count == AppLanguageMemory.capacity && languageMemory.english(for: "com.apple.TextEdit") == nil, + "bounded: the stalest apps are dropped") require(StateFeedback.wubiHint(text: "你好", code: "wqvb") == "你好 五笔 WQVB", "wubi code hint") require(StateFeedback.wubiHint(text: "你好", code: nil) == nil && StateFeedback.wubiHint(text: "中华人民共和国", code: "kwwl") == nil, "no hint without a code or for long phrases") diff --git a/platforms/macos/tests/settings-presentation.swift b/platforms/macos/tests/settings-presentation.swift index 6e78358..9287c07 100644 --- a/platforms/macos/tests/settings-presentation.swift +++ b/platforms/macos/tests/settings-presentation.swift @@ -11,12 +11,12 @@ guard ime_config_init(root.appendingPathComponent("config.toml").path) == IME_OK let panel = SettingsPanel.shared panel.prepare() let options = SettingsSchema.options() -guard panel.sectionCount == 5, panel.rowCount == options.count, options.count == 27, +guard panel.sectionCount == 5, panel.rowCount == options.count, options.count == 28, Set(panel.displayedOptionKeys) == Set(options.map(\.key)), !panel.isVisibleForTesting, !panel.dictionaryActionsEnabledForTesting, panel.navigationIsNativeForTesting else { fail("schema/native toolbar/session availability") } panel.selectSectionForTesting(0) -guard panel.visibleOptionKeysForTesting == ["mode", "input.chinese_variant", "input.punctuation", "input.full_width", "input.digit_punctuation", "wubi.auto_commit", "wubi.z_key", "pinyin.fuzzy"] else { fail("design input grouping and order") } +guard panel.visibleOptionKeysForTesting == ["mode", "input.chinese_variant", "input.punctuation", "input.full_width", "input.digit_punctuation", "input.remember_english_per_app", "wubi.auto_commit", "wubi.z_key", "pinyin.fuzzy"] else { fail("design input grouping and order") } // ADR-305:宽度项从复选框改为下拉,与「标点风格」「输出字形」同构 // (模式仍用分段控件,因为它只有三个互斥项且要一眼看全,属既有设计,不在本 ADR 范围)。 @@ -132,7 +132,7 @@ LocalAcceptanceEvidence.record("settings.dictionary-session", expected: "绑定 panel.selectSectionForTesting(2) panel.windowWillClose(Notification(name: NSWindow.willCloseNotification)) panel.prepare() -guard panel.selectedSectionForTesting == 2, panel.rowCount == 27, panel.navigationIsNativeForTesting, +guard panel.selectedSectionForTesting == 2, panel.rowCount == 28, panel.navigationIsNativeForTesting, !panel.isVisibleForTesting else { fail("rebuild with transient pane retention") } LocalAcceptanceEvidence.record("settings.reopen", expected: "关闭重建保留分区,19项不丢失,窗口不显示", actual: ["section": panel.selectedSectionForTesting, "rows": panel.rowCount, "visible": panel.isVisibleForTesting]) print("PASS settings-presentation: schema/panes, native toolbar, private config, errors, disabled operations, light/dark/minimum, offscreen rebuild") From cec03a14c5d02272e25c2e8d02dbed70cbeb9e1c Mon Sep 17 00:00:00 2001 From: wangzhizhou <824219521@qq.com> Date: Thu, 12 Nov 2026 13:22:24 +0800 Subject: [PATCH 2/5] feat: add a phrase drill to practice (ADR-334) Practice only had single-character kinds. A phrase kind asks for the four-letter code of the 800 most common two- to four-character words in the bundled table and explains which letters each character contributes. Signed-off-by: wangzhizhou <824219521@qq.com> --- ...63\347\255\226\350\256\260\345\275\225.md" | 16 +++++ platforms/macos/Sources/Practice.swift | 64 ++++++++++++++++++- platforms/macos/Sources/PracticeWindow.swift | 7 +- platforms/macos/tests/test-wubi-learning.py | 2 + platforms/macos/tests/wubi-learning.swift | 16 ++++- 5 files changed, 100 insertions(+), 5 deletions(-) diff --git "a/docs/\345\206\263\347\255\226\350\256\260\345\275\225.md" "b/docs/\345\206\263\347\255\226\350\256\260\345\275\225.md" index b3a44d9..7ee7928 100644 --- "a/docs/\345\206\263\347\255\226\350\256\260\345\275\225.md" +++ "b/docs/\345\206\263\347\255\226\350\256\260\345\275\225.md" @@ -3355,3 +3355,19 @@ 宿主随该测试一并编译。**跨应用切换的实际效果待换包后复验。** - **状态**:已采纳(2026-11-12)。 - **关联**:ADR-324(U3-5)、`docs/配置架构.md` §5。 + +### ADR-334:练习新增「词组」题型(U2-5 的跟打部分,最小版) + +- **决策**: + 1. `PracticeKind` 新增 `phrase`,排在「完整码」之后:题面是一个 2–4 字词,答案是它的 4 位词组码。 + 2. 题库取自**随包码表** `wubi86-core.tsv`:4 位码、2–4 字、按权重降序取前 800 个(去重,结果确定)。**不新增数据源、不新增随包文件。** + 3. 答后讲解按取码规则把码拆到每个字(两字词 2+2、三字词 1+1+2、四字词 1+1+1+1);长度不在规则内时不讲解。 + 与字根题一样不显示「完整码」字样,改显示「词组码」。 + 4. 判分、进度、错题加权(ADR-328)共用。 +- **理由**:五笔日常输入以词为主,但练习只有单字题;词组取码规则(尤其三字词的 1+1+2)是初学者第二道坎。 +- **边界**:只练「词 → 码」,不做文章跟打与速度统计(文章要定文本来源与许可;速度统计的边界见 ADR-324 决策 2,另议)。 + 五字及以上的词(前三字首码 + 末字首码)本次不出题。 +- **验收**:`test-wubi-learning.py` 新增断言(取词规则、去重、跳过单字/长词/短码、三种取码拆分、规则外不讲解、随包码表出满 800 题), + 版式自检扩到词组题型;浅色实拍已目视确认题面与六段题型控件正常。**答后讲解区的实际版式(两位键码的格子)未目视确认。** +- **状态**:已采纳(2026-11-12)。只改练习窗口。 +- **关联**:ADR-324(U2-5)、ADR-313/328/332。 diff --git a/platforms/macos/Sources/Practice.swift b/platforms/macos/Sources/Practice.swift index ef89c10..867c691 100644 --- a/platforms/macos/Sources/Practice.swift +++ b/platforms/macos/Sources/Practice.swift @@ -2,7 +2,7 @@ import Foundation /// 离线练习(ADR-313):只从 `full` 条目出题,本地判分,进度写本地有界 TSV。 /// -/// 题型:字根 / 一级简码 / 二级简码 / 完整码 / 识别码,按入门到熟练的顺序排(ADR-332)。 +/// 题型:字根 / 一级简码 / 二级简码 / 完整码 / 词组 / 识别码,按入门到熟练的顺序排(ADR-332/334)。 /// 字根题取自键位字根表(`KeyRootLayout`),其余复用 `LearningHints`(ADR-311)。 /// /// 三级简码需要"哪些字真有三级简码"的码表(引擎侧至今没收录,ADR-314 关闭),此处不提供, @@ -12,6 +12,7 @@ enum PracticeKind: String, CaseIterable { case code1 = "code1" case code2 = "code2" case code = "code" + case phrase = "phrase" case recognition = "recognition" var title: String { @@ -20,6 +21,7 @@ enum PracticeKind: String, CaseIterable { case .code1: return "一级简码" case .code2: return "二级简码" case .code: return "完整码" + case .phrase: return "词组" case .recognition: return "识别码" } } @@ -33,7 +35,7 @@ enum PracticeKind: String, CaseIterable { case .roots: return question.code case .code1: return question.short1 case .code2: return question.short2 - case .code: return question.code + case .code, .phrase: return question.code case .recognition: return nil } } @@ -45,6 +47,7 @@ enum PracticeKind: String, CaseIterable { case .code1: return "打出它的一级简码(1 位)" case .code2: return "打出它的二级简码(2 位)" case .code: return "打出它的完整五笔码(\(question.code.count) 位)" + case .phrase: return "打出这个词的词组码(4 位)" case .recognition: return "按上面的字根与键位,判断末笔与结构" } } @@ -76,6 +79,7 @@ enum Practice { /// 出题范围:`full` 且带完整码与字根(可判分)。 static func bank(kind: PracticeKind, limit: Int = 3000) -> [PracticeQuestion] { if kind == .roots { return rootBank(layout: KeyRootLayout.load()) } + if kind == .phrase { return phraseBank } var out: [PracticeQuestion] = [] for char in LearningHints.fullCharacters.prefix(limit) { guard let hint = LearningHints.hint(for: char), hint.quality == "full", @@ -108,6 +112,62 @@ enum Practice { return out } + /// 词组题库上限:只取最常用的一批,练的是取码规则而不是背词表。 + static let phraseLimit = 800 + + /// 词组题库(ADR-334):随包码表里最常用的 2–4 字词,答案是 4 位词组码。首次用到时读一次。 + static let phraseBank: [PracticeQuestion] = { + let name = "wubi86-core.tsv" + let bundle = Bundle.main + let candidates = [bundle.resourceURL?.appendingPathComponent(name), + bundle.bundleURL.appendingPathComponent("Resources/\(name)"), + bundle.bundleURL.deletingLastPathComponent().appendingPathComponent("Resources/\(name)")] + guard let url = candidates.compactMap({ $0 }).first(where: { FileManager.default.fileExists(atPath: $0.path) }), + let text = try? String(contentsOf: url, encoding: .utf8) else { return [] } + return phraseBank(tsv: text, limit: phraseLimit) + }() + + /// 从码表文本挑词组题:2–4 个字、4 位码,按权重降序(同权重按码、词排,结果确定)。 + static func phraseBank(tsv: String, limit: Int) -> [PracticeQuestion] { + var rows: [(code: Substring, text: Substring, weight: Int)] = [] + for line in tsv.split(separator: "\n") where !line.hasPrefix("#") { + let fields = line.split(separator: "\t") + guard fields.count >= 3, fields[0].count == 4, (2...4).contains(fields[1].count), + let weight = Int(fields[2]) else { continue } + rows.append((fields[0], fields[1], weight)) + } + rows.sort { ($1.weight, $0.code, $0.text) < ($0.weight, $1.code, $1.text) } + var seen = Set() + var out: [PracticeQuestion] = [] + for row in rows where seen.insert(row.text).inserted { + let word = String(row.text), code = String(row.code) + out.append(PracticeQuestion(kind: .phrase, char: word, code: code, + roots: phraseParts(word: word, code: code), recognition: nil, + short1: nil, short2: nil)) + if out.count >= limit { break } + } + return out + } + + /// 词组取码规则:两字词各取前两码;三字词前两字各取首码、末字取前两码;四字词各取首码。 + /// 返回「每个字 → 它贡献的码」,供答后讲解;长度对不上规则时返回空(不乱讲)。 + static func phraseParts(word: String, code: String) -> [(glyph: String, key: String)] { + let chars = word.map(String.init), letters = Array(code.uppercased()) + let spans: [Int] + switch chars.count { + case 2: spans = [2, 2] + case 3: spans = [1, 1, 2] + case 4: spans = [1, 1, 1, 1] + default: return [] + } + guard letters.count == 4 else { return [] } + var index = 0 + return zip(chars, spans).map { glyph, span in + defer { index += span } + return (glyph: glyph, key: String(letters[index..<(index + span)])) + } + } + /// 判分:敲码类题型(大小写与空白不敏感)。 static func judgeCode(_ answer: String, question: PracticeQuestion) -> Bool { guard let expected = question.kind.answer(for: question) else { return false } diff --git a/platforms/macos/Sources/PracticeWindow.swift b/platforms/macos/Sources/PracticeWindow.swift index b91d236..04f106c 100644 --- a/platforms/macos/Sources/PracticeWindow.swift +++ b/platforms/macos/Sources/PracticeWindow.swift @@ -323,14 +323,15 @@ final class PracticeWindow: NSObject, NSWindowDelegate { let target: String? = { switch kind { case .roots: return "键位 \(question.code.uppercased())" + case .phrase: return "词组码 \(question.code.uppercased())" case .code1: return question.short1.map { "一级 \($0.uppercased())" } case .code2: return question.short2.map { "二级 \($0.uppercased())" } default: return "完整码 \(question.code.uppercased())" } }() - // 字根题的「码」只是所在键,不是这个字根作为汉字的完整码,不能当完整码展示(否则教错)。 + // 字根题的「码」只是所在键、词组题的码是词组码,都不是单字的完整码,不能当「完整码」展示(否则教错)。 explanation.show(char: question.char, roots: question.roots, - recognition: question.recognition, code: kind == .roots ? "" : question.code, + recognition: question.recognition, code: kind == .roots || kind == .phrase ? "" : question.code, short1: question.short1, short2: question.short2, target: target, revealsAnswer: revealsAnswer) } @@ -410,6 +411,7 @@ final class PracticeWindow: NSObject, NSWindowDelegate { case .roots, .code1: return 1 case .code2: return 2 case .code: return min(4, max(3, question.code.count)) + case .phrase: return 4 case .recognition: return 4 } }() @@ -572,6 +574,7 @@ final class PracticeWindow: NSObject, NSWindowDelegate { case .code1: return "打出它的一级简码(1 位)" case .code2: return "打出它的二级简码(2 位)" case .code: return "打出它的完整五笔码(\(question.code.count) 位)" + case .phrase: return "打出这个词的词组码(4 位)" } } diff --git a/platforms/macos/tests/test-wubi-learning.py b/platforms/macos/tests/test-wubi-learning.py index 81158f1..18862ad 100644 --- a/platforms/macos/tests/test-wubi-learning.py +++ b/platforms/macos/tests/test-wubi-learning.py @@ -29,6 +29,8 @@ def test_real_ffi_teaching_model(self): # 键位字根分布(modules/keymap 生成物):与 bundle 内同名资源一致 shutil.copyfile(ROOT / "modules/keymap/data/key-roots-wubi86.tsv", resources / "key-roots-wubi86.tsv") + # 词组题库(ADR-334)读随包码表 + shutil.copyfile(ROOT / "data/generated/wubi86-core.tsv", resources / "wubi86-core.tsv") environment = {**os.environ, "CARGO_NET_OFFLINE": "true", "CLANG_MODULE_CACHE_PATH": str(temporary / "module-cache")} commands = [ diff --git a/platforms/macos/tests/wubi-learning.swift b/platforms/macos/tests/wubi-learning.swift index dd47d08..bca6737 100644 --- a/platforms/macos/tests/wubi-learning.swift +++ b/platforms/macos/tests/wubi-learning.swift @@ -60,6 +60,15 @@ if let rootQuestion = rootBank.first { require(Practice.judgeCode(rootQuestion.code.uppercased(), question: rootQuestion), "字根题大小写不敏感") require(!Practice.judgeCode("z", question: rootQuestion), "错键判错") } +// ADR-334:词组题——取随包码表最常用的 2–4 字词,讲解按取码规则拆到每个字。 +let phraseSample = "# c\nwqvb\t你好\t50\nwqvb\t你好\t40\nkwwl\t中华人民共和国\t99\nwgkl\t全国\t70\nabc\t短码\t90\nyygk\t计算机\t60\nwwwl\t从众如流\t5\nggll\t一\t999\n" +let phrases = Practice.phraseBank(tsv: phraseSample, limit: 3) +require(phrases.map(\.char) == ["全国", "计算机", "你好"], "按权重取前 3 个 2–4 字词,去重、跳过单字/长词/短码:\(phrases.map(\.char))") +require(Practice.phraseParts(word: "你好", code: "wqvb").map { "\($0.glyph)\($0.key)" } == ["你WQ", "好VB"], "两字词各取前两码") +require(Practice.phraseParts(word: "计算机", code: "yygk").map(\.key) == ["Y", "Y", "GK"], "三字词 1+1+2") +require(Practice.phraseParts(word: "从众如流", code: "wwwl").map(\.key) == ["W", "W", "W", "L"], "四字词各取首码") +require(Practice.phraseParts(word: "中华人民共和国", code: "kwwl").isEmpty, "规则外的长度不乱讲") +require(Practice.bank(kind: .phrase).count == Practice.phraseLimit, "随包码表应出满词组题:\(Practice.bank(kind: .phrase).count)") // ADR-328:出题按错题加权——没练过 2、错一次 9、错一次后连对两次回到 1;且不连出同一个字。 typealias Row = PracticeProgress.Row require(PracticeSession.weight(for: nil) == 2, "没练过的字权重 2") @@ -110,6 +119,7 @@ for kind in PracticeKind.allCases { case .code1: require(question.short1?.count == 1, "一级简码题必须有一级简码") case .code2: require(question.short2?.count == 2, "二级简码题必须有二级简码") case .code: require(!question.code.isEmpty, "完整码题必须有码") + case .phrase: require(question.code.count == 4 && (2...4).contains(question.char.count), "词组题是 2–4 字、4 位码") case .recognition: require(question.recognition != nil, "识别码题必须有识别码") } } @@ -233,7 +243,11 @@ for light in [true, false] { try? data.write(to: flowDir.appendingPathComponent("practice-roots-\(light ? "light" : "dark").png")) } } -for kind in [PracticeKind.roots, .code, .recognition] { +if let image = PracticeWindow.shared.renderForTesting(kind: .phrase, progressURL: flowDir.appendingPathComponent("render.tsv"), light: true), + let data = image.representation(using: .png, properties: [:]) { + try? data.write(to: flowDir.appendingPathComponent("practice-phrase-light.png")) +} +for kind in [PracticeKind.roots, .code, .phrase, .recognition] { let problems = PracticeWindow.shared.layoutProblemsForTesting(kind: kind) require(problems.isEmpty, "练习窗口版式问题:\(problems.joined(separator: "; "))") } From 6f0a37aa38160f32d8994244f4c0671157f67131 Mon Sep 17 00:00:00 2001 From: wangzhizhou <824219521@qq.com> Date: Thu, 12 Nov 2026 13:24:37 +0800 Subject: [PATCH 3/5] feat: feed characters typed via pinyin fallback into practice (ADR-335) With practice.collect_weak_chars on (default off), single characters committed from pinyin candidates in mixed mode are recorded in a bounded local file and weighted up in the full-code drill. Resetting practice progress clears the list. Signed-off-by: wangzhizhou <824219521@qq.com> --- crates/ime-config/src/lib.rs | 3 +- crates/ime-config/src/schema.rs | 9 +++ crates/ime-ffi/src/config.rs | 2 +- ...63\347\255\226\350\256\260\345\275\225.md" | 18 +++++ ...15\347\275\256\346\236\266\346\236\204.md" | 3 + ...01\344\270\216\345\256\211\345\205\250.md" | 3 + platforms/macos/Preview/main.swift | 4 +- platforms/macos/Sources/InputController.swift | 12 ++- platforms/macos/Sources/Practice.swift | 77 ++++++++++++++++++- platforms/macos/Sources/PracticeWindow.swift | 5 +- platforms/macos/Sources/Settings.swift | 2 +- .../macos/tests/settings-presentation.swift | 6 +- platforms/macos/tests/wubi-learning.swift | 30 ++++++++ 13 files changed, 158 insertions(+), 16 deletions(-) diff --git a/crates/ime-config/src/lib.rs b/crates/ime-config/src/lib.rs index d5277bb..e7be2c5 100644 --- a/crates/ime-config/src/lib.rs +++ b/crates/ime-config/src/lib.rs @@ -866,7 +866,7 @@ mod tests { #[test] fn schema_is_single_source_and_matches_v1() { - assert_eq!(schema().len(), 28); + assert_eq!(schema().len(), 29); let keys: Vec<&str> = schema().iter().map(|s| s.key).collect(); assert_eq!( keys, @@ -881,6 +881,7 @@ mod tests { "candidate.pick_char_with_brackets", "candidate.show_wubi_learning", "candidate.hint_wubi_code", + "practice.collect_weak_chars", "appearance.theme", "hotkey.toggle_wubi_learning", "hotkey.toggle_mode", diff --git a/crates/ime-config/src/schema.rs b/crates/ime-config/src/schema.rs index 0b9426a..be3c223 100644 --- a/crates/ime-config/src/schema.rs +++ b/crates/ime-config/src/schema.rs @@ -144,6 +144,15 @@ pub static SCHEMA: &[OptionSpec] = &[ doc_zh: "混输模式下用拼音打出字词后,在光标旁短暂提示它的五笔码,帮助下次直接用五笔", scope: Scope::CandidateWindow, }, + OptionSpec { + key: "practice.collect_weak_chars", + value_type: ValueType::Bool, + enum_labels_zh: &[], + default: "false", + title_zh: "记录薄弱字用于练习", + doc_zh: "混输模式下把靠拼音打出的单字记到本机 weak-chars.tsv,练习「完整码」时多出这些字;不上传、不影响候选排序,重置练习进度时一并清空", + scope: Scope::CandidateWindow, + }, OptionSpec { key: "appearance.theme", value_type: ValueType::Enum { diff --git a/crates/ime-ffi/src/config.rs b/crates/ime-ffi/src/config.rs index 85d53ce..96bc384 100644 --- a/crates/ime-ffi/src/config.rs +++ b/crates/ime-ffi/src/config.rs @@ -463,7 +463,7 @@ mod tests { #[test] fn schema_layout_matches_v1() { let layout = build_schema(); - assert_eq!(layout.view.len, 28); + assert_eq!(layout.view.len, 29); let first = layout._options[0]; // SAFETY:指针由 build_schema 的 CString/Vec 持有,测试期间有效。 unsafe { diff --git "a/docs/\345\206\263\347\255\226\350\256\260\345\275\225.md" "b/docs/\345\206\263\347\255\226\350\256\260\345\275\225.md" index 7ee7928..705cbd1 100644 --- "a/docs/\345\206\263\347\255\226\350\256\260\345\275\225.md" +++ "b/docs/\345\206\263\347\255\226\350\256\260\345\275\225.md" @@ -3371,3 +3371,21 @@ 版式自检扩到词组题型;浅色实拍已目视确认题面与六段题型控件正常。**答后讲解区的实际版式(两位键码的格子)未目视确认。** - **状态**:已采纳(2026-11-12)。只改练习窗口。 - **关联**:ADR-324(U2-5)、ADR-313/328/332。 + +### ADR-335:薄弱字清单——把「用」里靠拼音兜底的字喂给「练」(U3-3) + +- **决策**: + 1. 新增配置项 `practice.collect_weak_chars`(Bool,**默认 `false`**,设置页「五笔学习」组)。开启后,混输模式下用户选中**拼音来源**的 + **单字**候选且该字有五笔码时,记入本机 `~/Library/Application Support/Lampyris/weak-chars.tsv` + (`字 \t 次数 \t 最近时间`,最多 500 行,超限丢次数最少、最久未出现的;原子写)。 + 2. 练习的「完整码」题型里,清单上的字额外加权 8(相当于错过一次,叠加在 ADR-328 的权重上);其它题型不受影响。 + 3. 练习窗口「重置进度」同时清空该清单;直接删除文件同样清空。 +- **理由**:混输的兜底让用户不会卡住,但也让「总想不起码的那几个字」永远靠拼音。这份清单正是用户最该练的字, + 而练习此前只按通用字频出题(ADR-324「用→练」缺口)。 +- **边界(即 ADR-324 决策 2 的落地)**:纯本地、默认关、可一键清除、**不参与候选排序、不出宿主进程、不上报**; + 只记单字与次数,不记词、句、应用、上下文。关闭选项后不再写入(已有文件保留,直到重置或删除)。 + 清单里不在题库(`quality == full`)中的字不会出题。 +- **验收**:`test-wubi-learning.py` 新增断言(只收单个汉字、计数、重载、有界且留下次数多的、清空即删文件、完整码题型加权而其它题型不加权); + Schema 条目数 28→29;`docs/隐私与安全.md` §5 登记了该文件与内存态的中英记忆。**记录路径(拼音上屏触发)待换包后实键复验。** +- **状态**:已采纳(2026-11-12)。 +- **关联**:ADR-324(U3-3,决策 2)、ADR-313/328/331。 diff --git "a/docs/\351\205\215\347\275\256\346\236\266\346\236\204.md" "b/docs/\351\205\215\347\275\256\346\236\266\346\236\204.md" index 723946e..56d05ef 100644 --- "a/docs/\351\205\215\347\275\256\346\236\266\346\236\204.md" +++ "b/docs/\351\205\215\347\275\256\346\236\266\346\236\204.md" @@ -132,6 +132,9 @@ select_with_semicolon = true # ; 选第二候选;纯五笔下 ' 选第三 pick_char_with_brackets = true # [ 取首选词首字、] 取末字(以词定字,ADR-327) hint_wubi_code = true # 混输下拼音上屏后提示该字词的五笔码(ADR-331) +[practice] +collect_weak_chars = false # 把靠拼音打出的单字记到本机 weak-chars.tsv 供练习加权(默认关;ADR-335) + [appearance] theme = "auto" # auto = 跟随系统 | light | dark diff --git "a/docs/\351\232\220\347\247\201\344\270\216\345\256\211\345\205\250.md" "b/docs/\351\232\220\347\247\201\344\270\216\345\256\211\345\205\250.md" index 6f4646e..14bbbcc 100644 --- "a/docs/\351\232\220\347\247\201\344\270\216\345\256\211\345\205\250.md" +++ "b/docs/\351\232\220\347\247\201\344\270\216\345\256\211\345\205\250.md" @@ -74,6 +74,9 @@ | 数据 | 位置 | 生命周期 | 是否加密 | |---|---|---|---| | 用户词库 / 词频 | 本机数据目录 | 用户可删 | 可选(后续) | +| 练习进度 `practice.tsv`(ADR-313) | 本机数据目录 | 练习窗口「重置进度」或直接删除 | — | +| 薄弱字清单 `weak-chars.tsv`(ADR-335,**默认不记**) | 本机数据目录;只存「字 / 次数 / 最近时间」,最多 500 行 | 关闭 `practice.collect_weak_chars` 即停记;「重置进度」或直接删除即清空 | — | +| 各应用中英状态(ADR-333) | **仅内存**(最多 64 个应用标识) | 宿主退出即清空,**不落盘** | — | | 配置 | 本机 `config.toml` | 用户可改可删 | 否(本就用户可见) | | 组合缓冲 / 预编辑 | 内存 | 上屏或取消即释放 | — | | 语音录音(P6,若实现) | **仅内存** | 识别完立即丢弃,**不落盘** | — | diff --git a/platforms/macos/Preview/main.swift b/platforms/macos/Preview/main.swift index 8463fcb..cd63a1c 100644 --- a/platforms/macos/Preview/main.swift +++ b/platforms/macos/Preview/main.swift @@ -904,8 +904,8 @@ if CommandLine.arguments.contains("--smoke-test") { guard restored.candidates(limit: 1).first?.text == "离线测试词" else { exit(1) } // 设置面板(ADR-070):Schema 驱动、界面零硬编码。 let settingsOptions = SettingsSchema.options() - guard settingsOptions.count == 28 else { - fputs("设置项数量应为 28,实际 \(settingsOptions.count)。\n", stderr); exit(1) + guard settingsOptions.count == 29 else { + fputs("设置项数量应为 29,实际 \(settingsOptions.count)。\n", stderr); exit(1) } guard settingsOptions.allSatisfy({ !$0.title.isEmpty && !$0.doc.isEmpty }) else { fputs("设置项缺标题或说明。\n", stderr); exit(1) diff --git a/platforms/macos/Sources/InputController.swift b/platforms/macos/Sources/InputController.swift index df3439b..d989adb 100644 --- a/platforms/macos/Sources/InputController.swift +++ b/platforms/macos/Sources/InputController.swift @@ -761,10 +761,14 @@ final class LampyrisInputController: IMKInputController { private func hintWubiCode(for text: String) { let shown = shownCandidates shownCandidates = [] - guard !text.isEmpty, session?.mode == "mixed", ImeConfig.bool("candidate.hint_wubi_code"), - shown.contains(where: { $0.text == text && $0.source == .pinyin }), - let message = StateFeedback.wubiHint(text: text, code: WubiLearning.lookup(text)?.code) - else { return } + guard !text.isEmpty, session?.mode == "mixed", + shown.contains(where: { $0.text == text && $0.source == .pinyin }) else { return } + let hint = ImeConfig.bool("candidate.hint_wubi_code") + let collect = text.count == 1 && ImeConfig.bool("practice.collect_weak_chars") + guard hint || collect, let code = WubiLearning.lookup(text)?.code, !code.isEmpty else { return } + // 薄弱字清单(ADR-335,默认关):有五笔码却靠拼音打出来的单字,留给练习多出几次。 + if collect { WeakChars.shared.record(text) } + guard hint, let message = StateFeedback.wubiHint(text: text, code: code) else { return } StateHUDController.shared.show(message, caret: hudCaretRect()) } diff --git a/platforms/macos/Sources/Practice.swift b/platforms/macos/Sources/Practice.swift index 867c691..c25730c 100644 --- a/platforms/macos/Sources/Practice.swift +++ b/platforms/macos/Sources/Practice.swift @@ -278,6 +278,70 @@ final class PracticeProgress { } +/// 薄弱字清单(ADR-335):混输下靠拼音兜底打出来的单字,说明它的五笔码还没记住。 +/// +/// **默认不记**(`practice.collect_weak_chars` 关);只存本机一个有界 TSV,删除即清空; +/// 只用来让「完整码」练习多出这些字,**不参与候选排序、不上报**。 +final class WeakChars { + static let shared = WeakChars(url: defaultURL()) + static let maxRows = 500 + private(set) var rows: [String: (count: Int, lastSeen: Int)] = [:] + let url: URL + + init(url: URL) { + self.url = url + guard let text = try? String(contentsOf: url, encoding: .utf8) else { return } + for line in text.split(separator: "\n") where !line.hasPrefix("#") { + let fields = line.split(separator: "\t").map(String.init) + guard fields.count == 3, fields[0].count == 1, let count = Int(fields[1]), let last = Int(fields[2]) + else { continue } + rows[fields[0]] = (count, last) + } + } + + static func defaultURL() -> URL { + let base = FileManager.default.urls(for: .applicationSupportDirectory, in: .userDomainMask)[0] + return base.appendingPathComponent("Lampyris/weak-chars.tsv") + } + + func contains(_ char: String) -> Bool { rows[char] != nil } + + /// 记一次;只收单个汉字。超过上限时丢掉次数最少、最久没出现的。 + func record(_ char: String, now: Int = Int(Date().timeIntervalSince1970)) { + guard char.count == 1, char.unicodeScalars.allSatisfy({ $0.value >= 0x3400 }) else { return } + rows[char] = ((rows[char]?.count ?? 0) + 1, now) + if rows.count > Self.maxRows { + let drop = rows.sorted { ($0.value.count, $0.value.lastSeen) < ($1.value.count, $1.value.lastSeen) } + .prefix(rows.count - Self.maxRows) + for item in drop { rows[item.key] = nil } + } + save() + } + + /// 原子写:先写临时文件再替换;失败保留旧文件。 + private func save() { + var lines = ["# 萤火虫薄弱字清单 v1:字\t拼音兜底次数\t最近时间(离线,可删除清空)"] + for (char, row) in rows.sorted(by: { $0.key < $1.key }) { + lines.append("\(char)\t\(row.count)\t\(row.lastSeen)") + } + let directory = url.deletingLastPathComponent() + try? FileManager.default.createDirectory(at: directory, withIntermediateDirectories: true) + let temporary = directory.appendingPathComponent(".weak-chars.tsv.\(UUID().uuidString)") + do { + try (lines.joined(separator: "\n") + "\n").write(to: temporary, atomically: false, encoding: .utf8) + _ = try FileManager.default.replaceItemAt(url, withItemAt: temporary) + } catch { + try? FileManager.default.removeItem(at: temporary) + } + } + + /// 清空并删除文件。 + func reset() { + rows = [:] + try? FileManager.default.removeItem(at: url) + } +} + /// 一局练习的纯逻辑(无 AppKit),便于自动化测试跑通流程。 final class PracticeSession { struct Result { @@ -290,9 +354,15 @@ final class PracticeSession { private let progress: PracticeProgress private(set) var bank: [PracticeQuestion] = [] - init(kind: PracticeKind, progress: PracticeProgress) { + /// 薄弱字清单:「完整码」题型里这些字额外加权(ADR-335);`nil` = 不用。 + private let weak: WeakChars? + /// 薄弱字的额外权重:相当于「错过一次」。 + static let weakBonus = 8 + + init(kind: PracticeKind, progress: PracticeProgress, weak: WeakChars? = nil) { self.kind = kind self.progress = progress + self.weak = weak reload() } @@ -323,7 +393,10 @@ final class PracticeSession { func next(pick: (Int) -> Int = { Int.random(in: 0..<$0) }) -> PracticeQuestion? { let previous = question?.char let pool = bank.count > 1 ? bank.filter { $0.char != previous } : bank - let weights = pool.map { Self.weight(for: progress.row(char: $0.char, kind: kind)) } + let weights = pool.map { question -> Int in + let bonus = kind == .code && weak?.contains(question.char) == true ? Self.weakBonus : 0 + return Self.weight(for: progress.row(char: question.char, kind: kind)) + bonus + } let total = weights.reduce(0, +) guard total > 0 else { question = nil diff --git a/platforms/macos/Sources/PracticeWindow.swift b/platforms/macos/Sources/PracticeWindow.swift index 04f106c..a86f23b 100644 --- a/platforms/macos/Sources/PracticeWindow.swift +++ b/platforms/macos/Sources/PracticeWindow.swift @@ -70,7 +70,7 @@ final class PracticeWindow: NSObject, NSWindowDelegate { 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 lazy var session = PracticeSession(kind: .code, progress: progress) + private lazy var session = PracticeSession(kind: .code, progress: progress, weak: WeakChars.shared) private var current: PracticeQuestion? { session.question } private var pendingStroke: String? private var pendingStructure: String? @@ -499,11 +499,12 @@ final class PracticeWindow: NSObject, NSWindowDelegate { @objc private func resetProgress() { let alert = NSAlert() alert.messageText = "重置练习进度?" - alert.informativeText = "会删除本机的 practice.tsv(已练字数、次数、正确率归零)。此操作不可撤销。" + alert.informativeText = "会删除本机的 practice.tsv(已练字数、次数、正确率归零)和薄弱字清单 weak-chars.tsv。此操作不可撤销。" alert.addButton(withTitle: "重置") alert.addButton(withTitle: "取消") guard alert.runModal() == .alertFirstButtonReturn else { return } progress.reset() + WeakChars.shared.reset() session.reload() showCurrent() } diff --git a/platforms/macos/Sources/Settings.swift b/platforms/macos/Sources/Settings.swift index 97e57b3..5869c99 100644 --- a/platforms/macos/Sources/Settings.swift +++ b/platforms/macos/Sources/Settings.swift @@ -663,7 +663,7 @@ final class SettingsPanel: NSObject, NSWindowDelegate, NSToolbarDelegate { } else { groups = [("候选显示", ["candidate.count", "candidate.page_with_comma", "candidate.select_with_semicolon", "candidate.pick_char_with_brackets"]), - ("外观", ["appearance.theme"]), ("五笔学习", ["candidate.show_wubi_learning", "candidate.hint_wubi_code"])] + ("外观", ["appearance.theme"]), ("五笔学习", ["candidate.show_wubi_learning", "candidate.hint_wubi_code", "practice.collect_weak_chars"])] } let known = Set(groups.flatMap { $0.1 }) let allGroups = groups + [("其他", options.filter { !known.contains($0.key) }.map(\.key))] diff --git a/platforms/macos/tests/settings-presentation.swift b/platforms/macos/tests/settings-presentation.swift index 9287c07..3468e6b 100644 --- a/platforms/macos/tests/settings-presentation.swift +++ b/platforms/macos/tests/settings-presentation.swift @@ -11,7 +11,7 @@ guard ime_config_init(root.appendingPathComponent("config.toml").path) == IME_OK let panel = SettingsPanel.shared panel.prepare() let options = SettingsSchema.options() -guard panel.sectionCount == 5, panel.rowCount == options.count, options.count == 28, +guard panel.sectionCount == 5, panel.rowCount == options.count, options.count == 29, Set(panel.displayedOptionKeys) == Set(options.map(\.key)), !panel.isVisibleForTesting, !panel.dictionaryActionsEnabledForTesting, panel.navigationIsNativeForTesting else { fail("schema/native toolbar/session availability") } @@ -35,7 +35,7 @@ LocalAcceptanceEvidence.record("settings.state_rows", (panel.rowForTesting(key)?.controlKindForTesting ?? "missing") + ":" + (panel.rowForTesting(key)?.popupTitlesForTesting.joined(separator: "/") ?? "") }) panel.selectSectionForTesting(1) -guard panel.visibleOptionKeysForTesting == ["candidate.count", "candidate.page_with_comma", "candidate.select_with_semicolon", "candidate.pick_char_with_brackets", "appearance.theme", "candidate.show_wubi_learning", "candidate.hint_wubi_code"] else { fail("candidate design group order") } +guard panel.visibleOptionKeysForTesting == ["candidate.count", "candidate.page_with_comma", "candidate.select_with_semicolon", "candidate.pick_char_with_brackets", "appearance.theme", "candidate.show_wubi_learning", "candidate.hint_wubi_code", "practice.collect_weak_chars"] else { fail("candidate design group order") } LocalAcceptanceEvidence.record("settings.groups", expected: "输入/候选按Schema分组,所有22项都有入口", actual: ["count": panel.rowCount, "candidate_keys": panel.visibleOptionKeysForTesting]) guard let countOption = options.first(where: { $0.key == "candidate.count" }) else { fail("count schema") } let numberRow = SettingRow(option: countOption) @@ -132,7 +132,7 @@ LocalAcceptanceEvidence.record("settings.dictionary-session", expected: "绑定 panel.selectSectionForTesting(2) panel.windowWillClose(Notification(name: NSWindow.willCloseNotification)) panel.prepare() -guard panel.selectedSectionForTesting == 2, panel.rowCount == 28, panel.navigationIsNativeForTesting, +guard panel.selectedSectionForTesting == 2, panel.rowCount == 29, panel.navigationIsNativeForTesting, !panel.isVisibleForTesting else { fail("rebuild with transient pane retention") } LocalAcceptanceEvidence.record("settings.reopen", expected: "关闭重建保留分区,19项不丢失,窗口不显示", actual: ["section": panel.selectedSectionForTesting, "rows": panel.rowCount, "visible": panel.isVisibleForTesting]) print("PASS settings-presentation: schema/panes, native toolbar, private config, errors, disabled operations, light/dark/minimum, offscreen rebuild") diff --git a/platforms/macos/tests/wubi-learning.swift b/platforms/macos/tests/wubi-learning.swift index bca6737..2a62509 100644 --- a/platforms/macos/tests/wubi-learning.swift +++ b/platforms/macos/tests/wubi-learning.swift @@ -69,6 +69,36 @@ require(Practice.phraseParts(word: "计算机", code: "yygk").map(\.key) == ["Y" require(Practice.phraseParts(word: "从众如流", code: "wwwl").map(\.key) == ["W", "W", "W", "L"], "四字词各取首码") require(Practice.phraseParts(word: "中华人民共和国", code: "kwwl").isEmpty, "规则外的长度不乱讲") require(Practice.bank(kind: .phrase).count == Practice.phraseLimit, "随包码表应出满词组题:\(Practice.bank(kind: .phrase).count)") +// ADR-335:薄弱字清单——只收单个汉字、有界、可重载、可清空;只给「完整码」题型加权。 +let weakURL = flowDir.appendingPathComponent("weak-chars.tsv") +try? FileManager.default.removeItem(at: weakURL) +let weakChars = WeakChars(url: weakURL) +for rejected in ["你好", "a", "", "1"] { weakChars.record(rejected) } +require(weakChars.rows.isEmpty, "只收单个汉字") +weakChars.record("慧", now: 10) +weakChars.record("慧", now: 20) +require(weakChars.rows["慧"]?.count == 2 && weakChars.contains("慧") && !weakChars.contains("你"), "计数与查询") +require(WeakChars(url: weakURL).rows["慧"]?.count == 2, "可重载") +for scalar in 0x4E00..<(0x4E00 + WeakChars.maxRows + 20) { + weakChars.record(String(UnicodeScalar(scalar)!), now: scalar) +} +require(weakChars.rows.count == WeakChars.maxRows && weakChars.contains("慧"), "有界,且次数多的留下") +weakChars.reset() +require(weakChars.rows.isEmpty && !FileManager.default.fileExists(atPath: weakURL.path), "清空即删文件") +let weakTarget = Practice.bank(kind: .code)[3].char +weakChars.record(weakTarget) +let weakProgress = PracticeProgress(url: flowDir.appendingPathComponent("weak-progress.tsv")) +let weakSession = PracticeSession(kind: .code, progress: weakProgress, weak: weakChars) +// 权重轴:前 3 个字各占 2(0..<6),薄弱字占 2 + 8(6..<16)。 +_ = weakSession.next(pick: { $0 - 1 }) +require(weakSession.next(pick: { _ in 15 })?.char == weakTarget, "薄弱字在完整码题型里加权") +weakSession.switchKind(.code2) +_ = weakSession.next(pick: { $0 - 1 }) +let secondBank = weakSession.bank +if let index = secondBank.firstIndex(where: { $0.char == weakTarget }), index > 0 { + require(weakSession.next(pick: { _ in index * 2 + 2 })?.char != weakTarget, "其它题型不加权") +} +weakChars.reset() // ADR-328:出题按错题加权——没练过 2、错一次 9、错一次后连对两次回到 1;且不连出同一个字。 typealias Row = PracticeProgress.Row require(PracticeSession.weight(for: nil) == 2, "没练过的字权重 2") From b974c167531b81f361a89679725c87c1907ad1f7 Mon Sep 17 00:00:00 2001 From: wangzhizhou <824219521@qq.com> Date: Thu, 12 Nov 2026 13:27:54 +0800 Subject: [PATCH 4/5] feat: add Xiaohe double pinyin for pinyin mode (ADR-336) pinyin.scheme = xiaohe expands every two keys into one full-pinyin syllable before the existing backend runs, so the lexicon, learning and segmented selection are reused. It only applies in pinyin mode; mixed mode keeps full pinyin. Signed-off-by: wangzhizhou <824219521@qq.com> --- crates/ime-cli/examples/compose_bound.rs | 1 + crates/ime-cli/examples/input_quality.rs | 1 + crates/ime-cli/tests/core_vocabulary.rs | 1 + crates/ime-config/src/lib.rs | 3 +- crates/ime-config/src/schema.rs | 11 + crates/ime-core/src/lib.rs | 3 + crates/ime-ffi/src/config.rs | 2 +- crates/ime-ffi/src/data.rs | 1 + crates/ime-ffi/src/session.rs | 3 + crates/ime-pinyin/src/double.rs | 242 ++++++++++++++++++ crates/ime-pinyin/src/engine.rs | 80 ++++++ crates/ime-pinyin/src/lib.rs | 2 + crates/ime-pinyin/tests/reading_quality.rs | 1 + crates/ime-table/src/lib.rs | 4 + ...63\347\255\226\350\256\260\345\275\225.md" | 19 ++ ...15\347\275\256\346\236\266\346\236\204.md" | 1 + platforms/macos/Preview/main.swift | 4 +- platforms/macos/Sources/Settings.swift | 2 +- .../macos/tests/settings-presentation.swift | 6 +- 19 files changed, 379 insertions(+), 8 deletions(-) create mode 100644 crates/ime-pinyin/src/double.rs diff --git a/crates/ime-cli/examples/compose_bound.rs b/crates/ime-cli/examples/compose_bound.rs index 537ff57..9e3f9a2 100644 --- a/crates/ime-cli/examples/compose_bound.rs +++ b/crates/ime-cli/examples/compose_bound.rs @@ -28,6 +28,7 @@ fn build(data_dir: &str) -> Session { auto_commit: false, fuzzy: false, z_wildcard: false, + double_pinyin: false, }); session.set_wubi_engine(Box::new(CodeTableEngine::new(wubi, 4))); session.set_pinyin_engine(Box::new(PinyinEngine::new(Box::new(pinyin), 32))); diff --git a/crates/ime-cli/examples/input_quality.rs b/crates/ime-cli/examples/input_quality.rs index 93e15e2..6909d7c 100644 --- a/crates/ime-cli/examples/input_quality.rs +++ b/crates/ime-cli/examples/input_quality.rs @@ -41,6 +41,7 @@ fn main() -> Result<(), Box> { auto_commit: false, fuzzy: true, z_wildcard: false, + double_pinyin: false, }); eprintln!("load_ms={:.3}", start.elapsed().as_secs_f64() * 1000.0); // Measure resident memory before allocating full reference dictionaries. diff --git a/crates/ime-cli/tests/core_vocabulary.rs b/crates/ime-cli/tests/core_vocabulary.rs index 8e6f3b6..00ae97b 100644 --- a/crates/ime-cli/tests/core_vocabulary.rs +++ b/crates/ime-cli/tests/core_vocabulary.rs @@ -23,6 +23,7 @@ fn common_input_terms_are_reachable_and_submit_the_displayed_candidate() { auto_commit: false, fuzzy: true, z_wildcard: false, + double_pinyin: false, }); for mode in [InputMode::WubiOnly, InputMode::PinyinOnly, InputMode::Mixed] { session.set_mode(mode); diff --git a/crates/ime-config/src/lib.rs b/crates/ime-config/src/lib.rs index e7be2c5..0338842 100644 --- a/crates/ime-config/src/lib.rs +++ b/crates/ime-config/src/lib.rs @@ -866,7 +866,7 @@ mod tests { #[test] fn schema_is_single_source_and_matches_v1() { - assert_eq!(schema().len(), 29); + assert_eq!(schema().len(), 30); let keys: Vec<&str> = schema().iter().map(|s| s.key).collect(); assert_eq!( keys, @@ -875,6 +875,7 @@ mod tests { "wubi.auto_commit", "wubi.z_key", "pinyin.fuzzy", + "pinyin.scheme", "candidate.count", "candidate.page_with_comma", "candidate.select_with_semicolon", diff --git a/crates/ime-config/src/schema.rs b/crates/ime-config/src/schema.rs index be3c223..b6ceef7 100644 --- a/crates/ime-config/src/schema.rs +++ b/crates/ime-config/src/schema.rs @@ -90,6 +90,17 @@ pub static SCHEMA: &[OptionSpec] = &[ doc_zh: "支持 zh/z、ch/c、sh/s、n/l 的基础模糊音", scope: Scope::Engine, }, + OptionSpec { + key: "pinyin.scheme", + value_type: ValueType::Enum { + values: &["full", "xiaohe"], + }, + enum_labels_zh: &["全拼", "小鹤双拼"], + default: "\"full\"", + title_zh: "拼音方案", + doc_zh: "全拼,或小鹤双拼(两键一个音节);双拼只在「拼音」模式下生效,混输模式仍按全拼兜底", + scope: Scope::Engine, + }, OptionSpec { key: "candidate.count", value_type: ValueType::Int { min: 3, max: 9 }, diff --git a/crates/ime-core/src/lib.rs b/crates/ime-core/src/lib.rs index 1da57d6..a5e7b31 100644 --- a/crates/ime-core/src/lib.rs +++ b/crates/ime-core/src/lib.rs @@ -139,6 +139,8 @@ pub struct EngineOptions { pub auto_commit: bool, pub z_wildcard: bool, pub fuzzy: bool, + /// Interpret pinyin input as double pinyin (two keys per syllable, ADR-336). + pub double_pinyin: bool, } pub trait Engine: Send { @@ -1148,6 +1150,7 @@ mod tests { auto_commit: false, z_wildcard: true, fuzzy: false, + double_pinyin: false, }; for session in [&mut first, &mut second] { session.set_pinyin_engine(Box::new(ChangingEngine(false))); diff --git a/crates/ime-ffi/src/config.rs b/crates/ime-ffi/src/config.rs index 96bc384..3e0a806 100644 --- a/crates/ime-ffi/src/config.rs +++ b/crates/ime-ffi/src/config.rs @@ -463,7 +463,7 @@ mod tests { #[test] fn schema_layout_matches_v1() { let layout = build_schema(); - assert_eq!(layout.view.len, 29); + assert_eq!(layout.view.len, 30); let first = layout._options[0]; // SAFETY:指针由 build_schema 的 CString/Vec 持有,测试期间有效。 unsafe { diff --git a/crates/ime-ffi/src/data.rs b/crates/ime-ffi/src/data.rs index 353d414..9053fdc 100644 --- a/crates/ime-ffi/src/data.rs +++ b/crates/ime-ffi/src/data.rs @@ -186,6 +186,7 @@ mod tests { fuzzy: true, auto_commit: true, z_wildcard: false, + double_pinyin: false, }); assert!(!fuzzy.convert("zong", 9).candidates.is_empty()); assert!(exact.convert("zong", 9).candidates.is_empty()); diff --git a/crates/ime-ffi/src/session.rs b/crates/ime-ffi/src/session.rs index a9dcee7..c512585 100644 --- a/crates/ime-ffi/src/session.rs +++ b/crates/ime-ffi/src/session.rs @@ -500,6 +500,9 @@ pub extern "C" fn ime_session_configure(session: *mut ImeSession) -> i32 { auto_commit: boolean("wubi.auto_commit"), fuzzy: boolean("pinyin.fuzzy"), z_wildcard: cfg.get("wubi.z_key").and_then(|v| v.as_str()) == Some("wildcard"), + // 双拼键与五笔码无法共存:只在纯拼音模式下启用(ADR-336)。 + double_pinyin: s.session.mode() == InputMode::PinyinOnly + && cfg.get("pinyin.scheme").and_then(|v| v.as_str()) == Some("xiaohe"), }); let traditional = cfg.get("input.chinese_variant").and_then(|v| v.as_str()) == Some("traditional"); diff --git a/crates/ime-pinyin/src/double.rs b/crates/ime-pinyin/src/double.rs new file mode 100644 index 0000000..f70e9c8 --- /dev/null +++ b/crates/ime-pinyin/src/double.rs @@ -0,0 +1,242 @@ +//! 双拼(小鹤方案):把「两键一音节」的输入展开成全拼,再交给全拼后端(ADR-336)。 +//! +//! 只做按键 → 音节的确定性映射,不带任何数据;音节是否合法由调用方给的判定函数决定。 + +/// 小鹤双拼:声母键 → 声母。`u`=sh、`i`=ch、`v`=zh,其余与全拼相同。 +fn initial(key: u8) -> Option<&'static str> { + Some(match key { + b'b' => "b", + b'p' => "p", + b'm' => "m", + b'f' => "f", + b'd' => "d", + b't' => "t", + b'n' => "n", + b'l' => "l", + b'g' => "g", + b'k' => "k", + b'h' => "h", + b'j' => "j", + b'q' => "q", + b'x' => "x", + b'r' => "r", + b'z' => "z", + b'c' => "c", + b's' => "s", + b'y' => "y", + b'w' => "w", + b'u' => "sh", + b'i' => "ch", + b'v' => "zh", + _ => return None, + }) +} + +/// 小鹤双拼:韵母键 → 可能的韵母(一键至多两个,按常见程度排)。 +fn finals(key: u8) -> &'static [&'static str] { + match key { + b'q' => &["iu"], + b'w' => &["ei"], + b'e' => &["e"], + b'r' => &["uan"], + b't' => &["ue", "ve"], + b'y' => &["un"], + b'u' => &["u"], + b'i' => &["i"], + b'o' => &["uo", "o"], + b'p' => &["ie"], + b'a' => &["a"], + b's' => &["ong", "iong"], + b'd' => &["ai"], + b'f' => &["en"], + b'g' => &["eng"], + b'h' => &["ang"], + b'j' => &["an"], + b'k' => &["uai", "ing"], + b'l' => &["iang", "uang"], + b'z' => &["ou"], + b'x' => &["ia", "ua"], + b'c' => &["ao"], + b'v' => &["ui", "v"], + b'b' => &["in"], + b'n' => &["iao"], + b'm' => &["ian"], + _ => &[], + } +} + +/// 一对按键 → 一个合法音节;`valid` 判定音节是否存在于数据里。 +fn syllable(first: u8, second: u8, valid: &dyn Fn(&str) -> bool) -> Option { + // 零声母:首键就是韵母的第一个字母。`aa`=a、`ai`=ai、`ah`=ang、`eg`=eng、`ou`=ou… + if matches!(first, b'a' | b'o' | b'e') { + let head = first as char; + let mut options: Vec = Vec::new(); + if first == second { + options.push(head.to_string()); + } + options.push(format!("{head}{}", second as char)); + options.extend( + finals(second) + .iter() + .filter(|f| f.starts_with(head)) + .map(|f| (*f).to_string()), + ); + return options.into_iter().find(|s| valid(s)); + } + let initial = initial(first)?; + finals(second) + .iter() + .map(|f| format!("{initial}{f}")) + .find(|s| valid(s)) +} + +/// 展开结果:全拼串与每个音节的长度(用来把全拼上的字节偏移换算回按键数)。 +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct Expanded { + /// 展开后的全拼(音节直接相连;末尾可能带一个没打完的声母)。 + pub full: String, + /// 每个完整音节在 `full` 里的字节长度,按顺序。 + pub lengths: Vec, +} + +impl Expanded { + /// 全拼串上的前 `consumed` 个字节对应多少个按键;不落在音节边界上返回 `None`。 + pub fn keys_for(&self, consumed: usize) -> Option { + let mut total = 0; + for (index, length) in self.lengths.iter().enumerate() { + total += length; + if total == consumed { + return Some((index + 1) * 2); + } + } + None + } +} + +/// 把小鹤双拼输入展开成全拼。任何一对按键拼不出合法音节时返回 `None`(不猜)。 +/// +/// 奇数个按键时,最后一个键当作没打完的声母接在后面,让后端给出补全。 +pub fn expand(input: &str, valid: &dyn Fn(&str) -> bool) -> Option { + let bytes = input.as_bytes(); + if bytes.is_empty() || !bytes.iter().all(u8::is_ascii_lowercase) { + return None; + } + let mut full = String::new(); + let mut lengths = Vec::with_capacity(bytes.len() / 2); + for pair in bytes.chunks(2) { + match pair { + [first, second] => { + let s = syllable(*first, *second, valid)?; + lengths.push(s.len()); + full.push_str(&s); + } + [last] => full.push_str(initial(*last).unwrap_or(match last { + b'a' => "a", + b'o' => "o", + _ => "e", + })), + _ => return None, + } + } + Some(Expanded { full, lengths }) +} + +#[cfg(test)] +mod tests { + use super::*; + + fn valid(s: &str) -> bool { + [ + "ni", "hao", "shi", "jie", "zhong", "guo", "chuang", "xiang", "lve", "lv", "jue", "an", + "ang", "a", "e", "eng", "er", "ou", "ai", "kuai", "qing", "xiong", "wo", "luo", "bo", + "gui", "xia", "hua", + ] + .contains(&s) + } + + /// 小鹤方案打不出的音节:鼻音叹词没有键位;`lo` 与 `luo` 同键,取更常用的 `luo`。全拼模式仍可输入。 + const UNREACHABLE: [&str; 6] = ["hm", "hng", "lo", "m", "n", "ng"]; + + fn full(input: &str) -> Option { + expand(input, &valid).map(|e| e.full) + } + + #[test] + fn pairs_expand_to_syllables_and_pick_the_valid_final() { + assert_eq!(full("nihc").as_deref(), Some("nihao")); + assert_eq!(full("uijp").as_deref(), Some("shijie")); + assert_eq!(full("vsgo").as_deref(), Some("zhongguo")); + // 一键两韵:按音节是否存在取舍。 + assert_eq!(full("il").as_deref(), Some("chuang")); + assert_eq!(full("xl").as_deref(), Some("xiang")); + assert_eq!(full("lt").as_deref(), Some("lve")); + assert_eq!(full("jt").as_deref(), Some("jue")); + assert_eq!(full("lv").as_deref(), Some("lv")); + assert_eq!(full("gv").as_deref(), Some("gui")); + assert_eq!(full("kk").as_deref(), Some("kuai")); + assert_eq!(full("qk").as_deref(), Some("qing")); + assert_eq!(full("xs").as_deref(), Some("xiong")); + assert_eq!(full("lo").as_deref(), Some("luo")); + assert_eq!(full("bo").as_deref(), Some("bo")); + assert_eq!(full("xx").as_deref(), Some("xia")); + assert_eq!(full("hx").as_deref(), Some("hua")); + } + + #[test] + fn zero_initial_syllables_use_the_vowel_as_first_key() { + assert_eq!(full("aa").as_deref(), Some("a")); + assert_eq!(full("ee").as_deref(), Some("e")); + assert_eq!(full("an").as_deref(), Some("an")); + assert_eq!(full("ah").as_deref(), Some("ang")); + assert_eq!(full("eg").as_deref(), Some("eng")); + assert_eq!(full("er").as_deref(), Some("er")); + assert_eq!(full("ou").as_deref(), Some("ou")); + assert_eq!(full("ai").as_deref(), Some("ai")); + } + + /// 用随包的真实音节表核对映射:除了全拼里也没有标准双拼键位的几个叹词音节,其余都必须能打出来。 + #[test] + fn every_packaged_syllable_is_reachable_with_two_keys() { + let path = concat!( + env!("CARGO_MANIFEST_DIR"), + "/../../data/generated/pinyin-chars.tsv" + ); + let data = std::fs::read_to_string(path).unwrap(); + let syllables: std::collections::BTreeSet<&str> = data + .lines() + .filter(|line| !line.starts_with('#')) + .filter_map(|line| line.split('\t').next()) + .collect(); + let valid = |s: &str| syllables.contains(s); + let mut reached = std::collections::BTreeSet::new(); + for first in b'a'..=b'z' { + for second in b'a'..=b'z' { + let keys = [first, second]; + let input = std::str::from_utf8(&keys).unwrap(); + if let Some(e) = expand(input, &valid) { + reached.insert(e.full); + } + } + } + let missing: Vec<&str> = syllables + .iter() + .copied() + .filter(|s| !reached.contains(*s)) + .collect(); + assert_eq!(missing, UNREACHABLE, "双拼打不出来的音节"); + } + + #[test] + fn odd_key_is_an_unfinished_initial_and_bad_pairs_are_not_guessed() { + let e = expand("nihcu", &valid).unwrap(); + assert_eq!(e.full, "nihaosh"); + assert_eq!(e.lengths, [2, 3]); + assert_eq!(e.keys_for(2), Some(2)); + assert_eq!(e.keys_for(5), Some(4)); + assert_eq!(e.keys_for(3), None); + assert_eq!(full("nq"), None); // niu 不在测试音节表里 + assert_eq!(full(""), None); + assert_eq!(full("ni'h"), None); + assert_eq!(full("NI"), None); + } +} diff --git a/crates/ime-pinyin/src/engine.rs b/crates/ime-pinyin/src/engine.rs index d1cc7dd..f843e57 100644 --- a/crates/ime-pinyin/src/engine.rs +++ b/crates/ime-pinyin/src/engine.rs @@ -10,6 +10,8 @@ pub struct PinyinEngine { backend: Arc, max_code_len: usize, fuzzy: bool, + /// 把输入按小鹤双拼解释(ADR-336)。 + double: bool, } impl PinyinEngine { @@ -23,6 +25,44 @@ impl PinyinEngine { backend, max_code_len, fuzzy: false, + double: false, + } + } +} + +impl PinyinEngine { + /// 双拼:把按键展开成全拼再查;分段候选的覆盖长度从「全拼字节」换算回「按键数」。 + /// + /// 展开不出合法音节就不出候选(不猜);双拼下不叠加模糊音。 + fn convert_double(&self, input: &str, max: usize) -> ConvertResult { + let valid = |s: &str| self.backend.segment(s).as_slice() == [s.to_string()]; + let Some(expanded) = crate::double::expand(input, &valid) else { + return ConvertResult::default(); + }; + let candidates = self + .backend + .convert(&expanded.full, max) + .into_iter() + .filter_map(|pc| { + let consumed = match pc.consumed { + 0 => 0, + bytes => expanded.keys_for(bytes)?, + }; + Some(Candidate { + text: pc.text, + code: pc.pinyin, + weight: pc.weight, + source: CandidateSource::Pinyin, + comment: None, + is_prefix: pc.is_completion, + is_fuzzy: false, + consumed, + }) + }) + .collect(); + ConvertResult { + candidates, + should_commit: None, } } } @@ -33,8 +73,12 @@ impl Engine for PinyinEngine { } fn configure(&mut self, options: &ime_core::EngineOptions) { self.fuzzy = options.fuzzy; + self.double = options.double_pinyin; } fn convert(&self, input: &str, max: usize) -> ConvertResult { + if self.double { + return self.convert_double(input, max); + } let mut forms = vec![input.to_string()]; if self.fuzzy && input.len() <= 32 && input.bytes().all(|c| c.is_ascii_lowercase()) { let segmented = self.backend.segment(input); @@ -147,6 +191,7 @@ mod tests { fuzzy: true, auto_commit: false, z_wildcard: false, + double_pinyin: false, }); engine.convert(&"na".repeat(16), 9); assert!(calls.load(Ordering::Relaxed) <= 32); @@ -173,6 +218,7 @@ mod tests { auto_commit: false, z_wildcard: false, fuzzy: true, + double_pinyin: false, }); assert_eq!(e.convert("zhang", 9).candidates[0].text, "脏"); } @@ -190,6 +236,7 @@ mod tests { fuzzy: true, auto_commit: false, z_wildcard: false, + double_pinyin: false, }); let single = engine.convert("zhang", 9).candidates; assert_eq!(single[0].text, "张"); @@ -203,6 +250,39 @@ mod tests { assert!(engine.convert("🙂", 9).candidates.is_empty()); } + #[test] + fn double_pinyin_expands_keys_and_reports_partials_in_keys() { + let backend = TablePinyinBackend::from_tsv( + "ni\t你\t900\nhao\t好\t700\nshi\t是\t950\njie\t界\t600\n", + "ni hao\t你好\t50\nshi jie\t世界\t60\n", + "double", + ) + .unwrap(); + let mut e = PinyinEngine::new(Box::new(backend), 32); + // 关着的时候 `nihc` 不是合法全拼:没有覆盖整串的候选。 + assert!(e + .convert("nihc", 9) + .candidates + .iter() + .all(|c| c.consumed > 0)); + e.configure(&ime_core::EngineOptions { + fuzzy: true, + auto_commit: false, + z_wildcard: false, + double_pinyin: true, + }); + assert_eq!(e.convert("nihc", 9).candidates[0].text, "你好"); + let long = e.convert("nihcuijp", 9).candidates; + assert_eq!(long[0].text, "你好世界"); + // 「你好」覆盖 2 个音节 = 4 个按键(全拼上是 5 个字节)。 + let front = long.iter().find(|c| c.text == "你好").unwrap(); + assert_eq!(front.consumed, 4); + assert_eq!(long.iter().find(|c| c.text == "你").unwrap().consumed, 2); + // 拼不出合法音节:不出候选,也不回退成全拼解释。 + assert!(e.convert("nihao", 9).candidates.is_empty()); + assert_eq!(e.convert("nihc", 9).should_commit, None); + } + #[test] fn convert_marks_source_pinyin() { let e = engine(); diff --git a/crates/ime-pinyin/src/lib.rs b/crates/ime-pinyin/src/lib.rs index 61dc6f0..60036df 100644 --- a/crates/ime-pinyin/src/lib.rs +++ b/crates/ime-pinyin/src/lib.rs @@ -12,11 +12,13 @@ #![forbid(unsafe_code)] mod backend; +mod double; mod engine; mod syllable; mod table; pub use backend::{PinyinBackend, PinyinCandidate}; +pub use double::{expand as expand_double_pinyin, Expanded}; pub use engine::PinyinEngine; pub use syllable::{segment, strip_tones, tone_base, MAX_SYLLABLE_LEN}; pub use table::{TablePinyinBackend, TsvError}; diff --git a/crates/ime-pinyin/tests/reading_quality.rs b/crates/ime-pinyin/tests/reading_quality.rs index bfee299..aebbefb 100644 --- a/crates/ime-pinyin/tests/reading_quality.rs +++ b/crates/ime-pinyin/tests/reading_quality.rs @@ -14,6 +14,7 @@ fn shipped_common_readings_stay_on_first_page() { fuzzy: true, auto_commit: false, z_wildcard: false, + double_pinyin: false, }); // Common alternative readings must remain reachable while weak readings // no longer displace the ordinary targets for xian and xing. diff --git a/crates/ime-table/src/lib.rs b/crates/ime-table/src/lib.rs index 6e46adb..0a05ac3 100644 --- a/crates/ime-table/src/lib.rs +++ b/crates/ime-table/src/lib.rs @@ -577,6 +577,7 @@ mod tests { auto_commit: false, z_wildcard: true, fuzzy: false, + double_pinyin: false, }); assert_eq!( engine @@ -600,6 +601,7 @@ mod tests { auto_commit: false, z_wildcard: true, fuzzy: false, + double_pinyin: false, }); assert_eq!(e.convert("abcd", 9).should_commit, None); let r = e.convert("abcz", 9); @@ -609,6 +611,7 @@ mod tests { auto_commit: true, z_wildcard: false, fuzzy: false, + double_pinyin: false, }); assert_eq!(e.convert("abcd", 9).should_commit, Some("甲".into())); assert!(e.convert("abcz", 9).candidates.is_empty()); @@ -678,6 +681,7 @@ mod tests { auto_commit: false, z_wildcard: wildcard, fuzzy: false, + double_pinyin: false, }); for input in [ "a", "ab", "az", "za", "zz", "azb", "zbz", "aaa", "bbb", "zzz", diff --git "a/docs/\345\206\263\347\255\226\350\256\260\345\275\225.md" "b/docs/\345\206\263\347\255\226\350\256\260\345\275\225.md" index 705cbd1..5a08488 100644 --- "a/docs/\345\206\263\347\255\226\350\256\260\345\275\225.md" +++ "b/docs/\345\206\263\347\255\226\350\256\260\345\275\225.md" @@ -3389,3 +3389,22 @@ Schema 条目数 28→29;`docs/隐私与安全.md` §5 登记了该文件与内存态的中英记忆。**记录路径(拼音上屏触发)待换包后实键复验。** - **状态**:已采纳(2026-11-12)。 - **关联**:ADR-324(U3-3,决策 2)、ADR-313/328/331。 + +### ADR-336:小鹤双拼(U3-2) + +- **决策**: + 1. 新增配置项 `pinyin.scheme`(枚举 `full` / `xiaohe`,默认 `full`,设置页「五笔与拼音」组)。 + 2. 双拼**只在「拼音」模式下生效**;混输模式的拼音兜底仍按全拼(双拼的两键音节与五笔码无法区分)。 + 3. 实现为**按键展开层**(`ime-pinyin::double`):每两键按小鹤方案展开成一个全拼音节,一键对应两个韵母时取**数据里存在**的那个, + 然后交给既有全拼后端;奇数个按键时最后一键当作没打完的声母,交给补全(ADR-326)。任何一对按键拼不出合法音节就不出候选, + **不回退成全拼解释**。分段候选的覆盖长度由「全拼字节」换算回「按键数」(ADR-330)。 + 4. 双拼下不叠加模糊音。不新增数据、不新增依赖。 +- **理由**:双拼用户群体稳定,没有双拼就无法把萤火虫当作唯一输入法(ADR-324 证据 8)。展开层让全拼后端、词表、学习、分段选词全部复用。 +- **映射核对**:单测用随包 `pinyin-chars.tsv` 的 425 个音节穷举 26×26 键对,419 个可达;打不出的 6 个是 `hm` `hng` `m` `n` `ng` + (鼻音叹词,方案本身无键位)与 `lo`(与 `luo` 同键,取更常用的 `luo`),全拼模式仍可输入。 +- **边界 / 已知限制**:只做小鹤一个方案(其它方案是换一张键位表,按需再加);预编辑显示的是**原始按键**而不是展开后的拼音; + 用户词库里学到的拼音词按全拼拼写存储,双拼模式下不会作为「额外候选」被前缀匹配出来(已在词表里的词其学习次数照常生效)。 +- **验收**:`ime-pinyin` 单测(键对展开、零声母、奇数键、不猜、真实音节表可达性、引擎层分段换算);Schema 条目数 29→30; + `make verify`、离线回归、预览冒烟、`build.sh --strict` 全绿。**装机实键待换包后复验(本人非双拼用户的手感判断不可替代真实使用)。** +- **状态**:已采纳(2026-11-12)。默认关闭,不影响现有用户。 +- **关联**:ADR-324(U3-2)、ADR-326、ADR-330。 diff --git "a/docs/\351\205\215\347\275\256\346\236\266\346\236\204.md" "b/docs/\351\205\215\347\275\256\346\236\266\346\236\204.md" index 56d05ef..b92ef5e 100644 --- "a/docs/\351\205\215\347\275\256\346\236\266\346\236\204.md" +++ "b/docs/\351\205\215\347\275\256\346\236\266\346\236\204.md" @@ -124,6 +124,7 @@ z_key = "wildcard" # z 键:wildcard = 万能键 | none = 关闭 [pinyin] fuzzy = false # 模糊音(zh=z、ch=c、sh=s、n=l…) +scheme = "full" # full = 全拼 | xiaohe = 小鹤双拼(只在拼音模式下生效;ADR-336) [candidate] count = 7 # 每页候选数(3–9) diff --git a/platforms/macos/Preview/main.swift b/platforms/macos/Preview/main.swift index cd63a1c..9e18749 100644 --- a/platforms/macos/Preview/main.swift +++ b/platforms/macos/Preview/main.swift @@ -904,8 +904,8 @@ if CommandLine.arguments.contains("--smoke-test") { guard restored.candidates(limit: 1).first?.text == "离线测试词" else { exit(1) } // 设置面板(ADR-070):Schema 驱动、界面零硬编码。 let settingsOptions = SettingsSchema.options() - guard settingsOptions.count == 29 else { - fputs("设置项数量应为 29,实际 \(settingsOptions.count)。\n", stderr); exit(1) + guard settingsOptions.count == 30 else { + fputs("设置项数量应为 30,实际 \(settingsOptions.count)。\n", stderr); exit(1) } guard settingsOptions.allSatisfy({ !$0.title.isEmpty && !$0.doc.isEmpty }) else { fputs("设置项缺标题或说明。\n", stderr); exit(1) diff --git a/platforms/macos/Sources/Settings.swift b/platforms/macos/Sources/Settings.swift index 5869c99..3f1d00e 100644 --- a/platforms/macos/Sources/Settings.swift +++ b/platforms/macos/Sources/Settings.swift @@ -652,7 +652,7 @@ final class SettingsPanel: NSObject, NSWindowDelegate, NSToolbarDelegate { if scopes == [0] { groups = [("默认输入", ["mode", "input.chinese_variant", "input.punctuation", "input.full_width", "input.digit_punctuation", "input.remember_english_per_app"]), - ("五笔与拼音", ["wubi.auto_commit", "wubi.z_key", "pinyin.fuzzy"])] + ("五笔与拼音", ["wubi.auto_commit", "wubi.z_key", "pinyin.fuzzy", "pinyin.scheme"])] } else if scopes == [4] { groups = [("更新来源", ["update.feed_url"])] } else if scopes.contains(3) { diff --git a/platforms/macos/tests/settings-presentation.swift b/platforms/macos/tests/settings-presentation.swift index 3468e6b..7d5f7eb 100644 --- a/platforms/macos/tests/settings-presentation.swift +++ b/platforms/macos/tests/settings-presentation.swift @@ -11,12 +11,12 @@ guard ime_config_init(root.appendingPathComponent("config.toml").path) == IME_OK let panel = SettingsPanel.shared panel.prepare() let options = SettingsSchema.options() -guard panel.sectionCount == 5, panel.rowCount == options.count, options.count == 29, +guard panel.sectionCount == 5, panel.rowCount == options.count, options.count == 30, Set(panel.displayedOptionKeys) == Set(options.map(\.key)), !panel.isVisibleForTesting, !panel.dictionaryActionsEnabledForTesting, panel.navigationIsNativeForTesting else { fail("schema/native toolbar/session availability") } panel.selectSectionForTesting(0) -guard panel.visibleOptionKeysForTesting == ["mode", "input.chinese_variant", "input.punctuation", "input.full_width", "input.digit_punctuation", "input.remember_english_per_app", "wubi.auto_commit", "wubi.z_key", "pinyin.fuzzy"] else { fail("design input grouping and order") } +guard panel.visibleOptionKeysForTesting == ["mode", "input.chinese_variant", "input.punctuation", "input.full_width", "input.digit_punctuation", "input.remember_english_per_app", "wubi.auto_commit", "wubi.z_key", "pinyin.fuzzy", "pinyin.scheme"] else { fail("design input grouping and order") } // ADR-305:宽度项从复选框改为下拉,与「标点风格」「输出字形」同构 // (模式仍用分段控件,因为它只有三个互斥项且要一眼看全,属既有设计,不在本 ADR 范围)。 @@ -132,7 +132,7 @@ LocalAcceptanceEvidence.record("settings.dictionary-session", expected: "绑定 panel.selectSectionForTesting(2) panel.windowWillClose(Notification(name: NSWindow.willCloseNotification)) panel.prepare() -guard panel.selectedSectionForTesting == 2, panel.rowCount == 29, panel.navigationIsNativeForTesting, +guard panel.selectedSectionForTesting == 2, panel.rowCount == 30, panel.navigationIsNativeForTesting, !panel.isVisibleForTesting else { fail("rebuild with transient pane retention") } LocalAcceptanceEvidence.record("settings.reopen", expected: "关闭重建保留分区,19项不丢失,窗口不显示", actual: ["section": panel.selectedSectionForTesting, "rows": panel.rowCount, "visible": panel.isVisibleForTesting]) print("PASS settings-presentation: schema/panes, native toolbar, private config, errors, disabled operations, light/dark/minimum, offscreen rebuild") From 7efc079d97d79bf6f0e5e689e73fb471ad401ab8 Mon Sep 17 00:00:00 2001 From: wangzhizhou <824219521@qq.com> Date: Thu, 12 Nov 2026 13:30:42 +0800 Subject: [PATCH 5/5] docs: record the third P1-U batch and what the rest is blocked on MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Updates the backlog and handoff after ADR-333…336, corrects the stale handoff note about tier two, lists the eight remaining items with the decision or data each needs, and refreshes the README status line that still described 1.0.0. Signed-off-by: wangzhizhou <824219521@qq.com> --- HANDOFF.md | 3 ++- README.md | 2 +- ...36\344\270\216\347\247\257\345\216\213.md" | 23 +++++++++++++++---- 3 files changed, 22 insertions(+), 6 deletions(-) diff --git a/HANDOFF.md b/HANDOFF.md index 26801a0..52b23bd 100644 --- a/HANDOFF.md +++ b/HANDOFF.md @@ -59,7 +59,7 @@ - **P0 · 立即(验证不开发)**:练习页 VoiceOver 实机走查、安装器三条新分支的回归测试、离线回归编译共享缓存、 pro 升级与实键入、IT-22 清单按 1.2.1 复跑。除非抓出缺陷,否则并进 1.3.0 的 B0,不单出 1.2.2。 - **P1-U · 输入体验与学练闭环(ADR-324,2026-11-12 已采纳)**:15 项分三档(拼音可主用、日常输入细节、扩展词库、学练闭环); - 方向已批准,每项开工前仍各写落地 ADR / 任务卡。**第一档 U1-1…U1-5 代码已完成(ADR-325…329),未换包、待装机复验**;下一步第二档。 + 方向已批准,每项开工前仍各写落地 ADR / 任务卡。**ADR-325…336 共 12 项代码已完成并(前两档)合入 main,全部未换包、待装机复验**;剩余 8 项待用户定口径/授权,见积压文档 P1-U 末尾的表。 - **P1 · 1.3.0**:三级简码专项(需引擎 + ADR)、corpus M2-1 调研 → N1 字根字形库 → N2/N3/N4、拆字说明扩充、多方案码表、客户端自动回读缺口。 - **P2 · 有证据才做**:排序观察期、位置记忆、冷首键、拼音后端评估、n-gram(均写触发条件)。 - **明确不做**:云/账号/广告/统计/剪贴板/语音;平台扩展单独立项,不与 macOS 同批。 @@ -117,6 +117,7 @@ make macos-offline-tests ## 9. 会话日志(最近) +- 2026-11-12 / Claude Code(air):**P1-U 第三批(ADR-333…336)+ 前两档已合入 main**。PR [#269](https://github.com/EasyIndie/EasyInputMethod/pull/269)(第一档)与 [#270](https://github.com/EasyIndie/EasyInputMethod/pull/270)(第二档)均已用合并提交并入(`c882c06`、`2f8ee06`),**下面一条日志里「第二档未推送」已过时**。第三批:按应用记住中英状态(仅内存)、练习「词组」题型(随包码表前 800 词)、薄弱字清单(`practice.collect_weak_chars` 默认关,`weak-chars.tsv`)、小鹤双拼(`pinyin.scheme`,仅拼音模式;425 个音节 419 个可达)。Schema 27→30。四道检查全绿、评测 98.2%/86.4% 与基线一致。**三批改动全部未装机**,复验清单见 `docs/待办与积压.md` P1-U;P1-U 剩余 8 项都需用户先定口径或授权取数据(同处有表)。仓库已开启 auto-merge。 - 2026-11-12 / Claude Code(air):**P1-U 第二档可直接做的部分完成(ADR-330…332)**。U2-1 拼音分段选词(`Candidate.consumed`、`Session::preedit`、`IME_SELECT_PARTIAL`,整句学成用户拼音词);U2-4 混输下拼音上屏后提示五笔码(`candidate.hint_wubi_code`,Schema 26→27);U2-5 练习新增「字根」题型并排最前。第一档 PR [#269](https://github.com/EasyIndie/EasyInputMethod/pull/269) 已开(首跑 `macos` 红:预览冒烟写死设置项 23 → `7e37dba` 修)。**第二档提交只在本地 `dev`,未推送**——同一个 `dev → main` 只能有一个 PR,要等 #269 合并后再推并开第二档 PR。四道检查全绿(`make verify`、离线 62/328、预览冒烟、`build.sh --strict`),**均未装机**。教训:离线回归不含预览冒烟与宿主编译,改设置项数量或 `InputController.swift` 后必须另跑这两项。剩余 U2-2 五笔造词 / U2-3 扩展词库 / U2-5 分阶段与跟打都需用户先定口径。 - 2026-11-12 / Claude Code(air):**P1-U 第一档全部完成(ADR-326…329)**。U1-2 拼音没打完给补全(`nih`→你好,补全不拦五笔自动上屏,评测与基线一致 98.2%/86.4%);U1-3 `;`/`'` 选二三候选、`[`/`]` 以词定字(两个开关默认开,Schema 24→26);U1-5 练习按错题加权出题(文件格式不变);U1-4 安装完成页加上手提示(窗口高 300→340,**版式未目视确认**)。`dev` 领先 `main` 7 个提交(`df2019c`…),`make verify` PASS、离线 62 文件 / 328 例 PASS。**未推送、未开 PR、未换包;六项行为都待装机实键复验**(清单见 `docs/待办与积压.md` P1-U 末尾)。下一步:第二档 U2-1 → U2-4 → U2-2。 - 2026-11-12 / Claude Code(air):**U1-1 数字后标点(ADR-325)代码完成**。组合外数字后紧跟的 `.` / `:` 保持半角(`3.14`、`12:30`),新增 `input.digit_punctuation`(默认开,Schema 23→24);核心单测覆盖开/关、全角、标记消耗、选词数字。`make verify` PASS、离线 62 文件 / 328 例 PASS。**未提交、未换包;装机实键待复验。** 已知限制:数字后用方向键/鼠标移光标不消耗标记。 diff --git a/README.md b/README.md index 301b8a9..e7807e1 100644 --- a/README.md +++ b/README.md @@ -4,7 +4,7 @@ > 一款**跨平台、开源可审计、默认离线**的中文输入法:核心输入 + 本地「学·练·用」闭环(ADR-324)。 > -> **状态:macOS 首版已冻结为 1.0.0(build 34),四道门禁只剩 IT-23 五工作日试用在记(见 [HANDOFF](HANDOFF.md) 与 [ROADMAP](ROADMAP.md))。** 试用结束后打 `v1.0.0` 标签发布;下一步计划见 [MVP后执行计划](docs/MVP后执行计划.md)。日常隔离调试运行 `make macos-dev`。 +> **状态:macOS 线上版本为 1.2.1(build 40);`main` 上已合入 1.2.1 之后的输入体验与学练改进(ADR-324…336),尚未发版。** 当前状态见 [HANDOFF](HANDOFF.md),待办见 [待办与积压](docs/待办与积压.md)。日常隔离调试运行 `make macos-dev`。 ## 这是什么 diff --git "a/docs/\345\276\205\345\212\236\344\270\216\347\247\257\345\216\213.md" "b/docs/\345\276\205\345\212\236\344\270\216\347\247\257\345\216\213.md" index 3faee8c..bab39a7 100644 --- "a/docs/\345\276\205\345\212\236\344\270\216\347\247\257\345\216\213.md" +++ "b/docs/\345\276\205\345\212\236\344\270\216\347\247\257\345\216\213.md" @@ -60,12 +60,12 @@ | U2-2 | 二 | **自动造词** — 拼音部分 ✅(ADR-330:分段拼出的 2–8 字整句学成用户拼音词);**五笔连续单字造词未做** | 五笔侧需定启发式(何时算一个词、如何避免学进垃圾) | M | ADR | | U2-3 | 二 | **扩展词库开关 + 第三方词库导入** | 打包五笔词组 2 万 / 拼音词 2 万(全量各约 34 万);`weixin` 无「微信」 | M | 内存分档门槛(ADR-312 做法) | | U2-4 | 二 | ~~拼音上屏时提示五笔码~~ ✅ 代码已完成(ADR-331,`candidate.hint_wubi_code` 默认开);**浮层观感待换包后确认** | 仅混输、仅拼音候选、1–4 字 | S–M | 换包 | -| U2-5 | 二 | **字根入门** ✅(ADR-332:新增「字根」题型并排在最前);**分阶段解锁、词组/文章跟打未做** | 前者要定「掌握」口径,后者要定文本来源与许可 | M–L | ADR | +| U2-5 | 二 | **字根入门** ✅(ADR-332)、**词组题型** ✅(ADR-334);**分阶段解锁、文章跟打未做** | 前者要定「掌握」口径,后者要定文本来源与许可 | M–L | ADR | | U3-1 | 三 | **整句模型** | 即 P2-5;ADR-299 明确不做,需改判 | XL | 语料来源与许可 | -| U3-2 | 三 | **双拼** | 完全没有 | M | `add-engine` | -| U3-3 | 三 | **薄弱字「用→练」闭环** | 无;边界按 ADR-324 决策 2(本地、可清除、不参与排序、默认关) | M | 落地 ADR | +| U3-2 | 三 | ~~双拼~~ ✅ 小鹤方案已完成(ADR-336,`pinyin.scheme`,仅拼音模式);**装机实键待复验** | 其它方案是换键位表,按需再加;预编辑显示原始按键 | M | 换包 | +| U3-3 | 三 | ~~薄弱字「用→练」闭环~~ ✅ 代码已完成(ADR-335,`practice.collect_weak_chars` 默认关);**记录路径待换包复验** | 只记单字与次数;只给「完整码」题型加权 | M | 换包 | | U3-4 | 三 | **高频字拆解补齐** | 「是」为 `partial`,学习区显示 `?` | L | 同 P1-3 | -| U3-5 | 三 | **符号 / emoji / 日期输入、按应用记忆中英状态** | 无 | M 各 | ADR | +| U3-5 | 三 | **按应用记住中英状态** ✅(ADR-333,仅内存);**符号 / emoji / 日期输入未做** | 后者要定触发方式(前缀键/专用键)与符号表来源 | M 各 | ADR | > **第一档状态(2026-11-12)**:U1-1…U1-5 代码全部完成并各自提交(ADR-325…329),`make verify` 与离线回归全绿; > **均未装机**。换包后需集中复验:`3.14` / `12:30`、`nih` / `zhongg` 补全、`;` `'` `[` `]`、设置页三个新开关、 @@ -76,6 +76,21 @@ > 拼音上屏后的五笔码浮层、练习「字根」题型。**第二档剩余**:U2-2 五笔造词(要定启发式)、U2-3 扩展词库(要联网取数据并登记许可)、 > U2-5 分阶段解锁与跟打(要定口径与文本来源)——三项都需要你先拍板,不是能直接开工的活。 +> **第三批状态(2026-11-12)**:ADR-333…336 已完成(按应用记住中英、词组题型、薄弱字清单、小鹤双拼),四道检查全绿、评测与基线一致,**均未装机**。 +> +> **P1-U 里剩下的,全部卡在「先要你定一件事」或「先要数据」上**: +> +> | 项 | 卡在哪 | 需要你给什么 | +> |---|---|---| +> | U2-2 五笔自动造词 | 连续单字何时算一个词、怎么防止学进垃圾 | 一条规则(例如「2 秒内连续上屏的 2–4 个单字,重复出现 3 次才入库」),或明确不做 | +> | U2-3 扩展词库 | 全量表已在仓库(五笔词组 33.7 万 / 拼音词 34 万),但进包会抬高包体与内存,现有 30 MiB 门禁要重新实测定值 | 授权换包实测内存,再定是「默认带」还是「可选开关」 | +> | U2-5 分阶段解锁 | 「掌握」怎么算(正确率?连对次数?) | 一个口径,或明确不做解锁、只保留题型顺序 | +> | U2-5 文章跟打 | 练习文本的来源与许可 | 指定文本来源,或同意由我自编短句(CC-BY-SA,与码表同许可) | +> | U3-1 整句模型 | 需要带上下文的语料(现有词频只有单词频次,解决不了「吃饭把/吧」) | 授权联网调研可用的 GPL 兼容语料 | +> | U3-4 高频字拆解补齐 | 同 P1-3,缺字根字形数据来源 | 同 P1-2 的联网调研授权 | +> | U3-5 符号 / emoji / 日期 | 触发方式与符号表来源 | 选一种触发方式 | +> | 宿主内首次引导 | 「是否首次运行」的状态放哪(铁律 3 禁止第二个配置文件) | 同意用数据目录下的标记文件,或维持只在安装器里提示 | + ### P2 · 有证据才做(都写了触发条件,没有触发条件就不开工) | 编号 | 事项 | 现状 | 成本 | 触发条件 |