diff --git a/HANDOFF.md b/HANDOFF.md index 26801a02..52b23bd6 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 301b8a9a..e7807e1d 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/crates/ime-cli/examples/compose_bound.rs b/crates/ime-cli/examples/compose_bound.rs index 537ff574..9e3f9a2b 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 93e15e25..6909d7cf 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 8e6f3b6d..00ae97b0 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 a8f3dfe4..03388428 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(), 30); let keys: Vec<&str> = schema().iter().map(|s| s.key).collect(); assert_eq!( keys, @@ -875,12 +875,14 @@ mod tests { "wubi.auto_commit", "wubi.z_key", "pinyin.fuzzy", + "pinyin.scheme", "candidate.count", "candidate.page_with_comma", "candidate.select_with_semicolon", "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", @@ -896,6 +898,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 b39755e6..b6ceef7b 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 }, @@ -144,6 +155,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 { @@ -287,6 +307,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-core/src/lib.rs b/crates/ime-core/src/lib.rs index 1da57d6c..a5e7b313 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 ee779870..3e0a8068 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, 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 353d414a..9053fdc8 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 a9dcee7e..c5125859 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 00000000..f70e9c8f --- /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 d1cc7dd2..f843e57b 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 61dc6f0d..60036df0 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 bfee299d..aebbefb3 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 6e46adb9..0a05ac37 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 de2aff5d..5a08488e 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,72 @@ 「金」在 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。 + +### 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。 + +### 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。 + +### 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/\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 3faee8c8..bab39a7e 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 · 有证据才做(都写了触发条件,没有触发条件就不开工) | 编号 | 事项 | 现状 | 成本 | 触发条件 | 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 0dce9268..b92ef5e3 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) @@ -132,6 +133,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 @@ -150,6 +154,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/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 6f4646e3..14bbbcc6 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 6a7a7e3a..9e18749a 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 == 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/AppLanguageMemory.swift b/platforms/macos/Sources/AppLanguageMemory.swift new file mode 100644 index 00000000..f4745251 --- /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 e37670ed..d989adb5 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)) } @@ -740,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 ef89c10b..c25730cd 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 } @@ -218,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 { @@ -230,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() } @@ -263,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 b91d236f..a86f23b3 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? @@ -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 } }() @@ -497,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() } @@ -572,6 +575,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/Sources/Settings.swift b/platforms/macos/Sources/Settings.swift index ba26ce6c..3f1d00ec 100644 --- a/platforms/macos/Sources/Settings.swift +++ b/platforms/macos/Sources/Settings.swift @@ -651,8 +651,8 @@ 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"]), - ("五笔与拼音", ["wubi.auto_commit", "wubi.z_key", "pinyin.fuzzy"])] + "input.digit_punctuation", "input.remember_english_per_app"]), + ("五笔与拼音", ["wubi.auto_commit", "wubi.z_key", "pinyin.fuzzy", "pinyin.scheme"])] } else if scopes == [4] { groups = [("更新来源", ["update.feed_url"])] } else if scopes.contains(3) { @@ -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/controller-menu.swift b/platforms/macos/tests/controller-menu.swift index e4614aa1..fae4b639 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 6e78358a..7d5f7eb6 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 == 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", "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 范围)。 @@ -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 == 27, 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") diff --git a/platforms/macos/tests/test-wubi-learning.py b/platforms/macos/tests/test-wubi-learning.py index 81158f11..18862adb 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 dd47d083..2a625091 100644 --- a/platforms/macos/tests/wubi-learning.swift +++ b/platforms/macos/tests/wubi-learning.swift @@ -60,6 +60,45 @@ 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-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") @@ -110,6 +149,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 +273,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: "; "))") }