diff --git a/crates/app-iced/src/fonts.rs b/crates/app-iced/src/fonts.rs index 5070906..b43761e 100644 --- a/crates/app-iced/src/fonts.rs +++ b/crates/app-iced/src/fonts.rs @@ -24,6 +24,7 @@ //! ## 中文那一半(M92 / D28 后半) //! //! 语言探测 + 「中文环境有没有中文字体」的判据在 [`cjk`](判据本体在零依赖的 `unlucky_i18n::font`), +//! 语言的真值源是 [`crate::platform::locale`](Windows 上 `reg.exe` 读注册表,零 FFI), //! 结论作为 [`Resolved::cjk`] 进同一行启动日志。**英文环境结论恒为 `NotNeeded`**:不要求中文字体、 //! 不下载任何字体(系统里已有的 CJK 族照旧躺在 iced 的字体库里,我们既不注册新的也删不掉——那是 //! cosmic-text 的全局状态,不是本模块能管的事)。 diff --git a/crates/app-iced/src/fonts/cjk.rs b/crates/app-iced/src/fonts/cjk.rs index b31db97..a4dd5eb 100644 --- a/crates/app-iced/src/fonts/cjk.rs +++ b/crates/app-iced/src/fonts/cjk.rs @@ -4,6 +4,8 @@ //! //! - **判据**(要不要中文字体、哪些族算候选、缺了要不要下载)全在 `unlucky_i18n::font`,是纯函数, //! 零 UI 依赖、脱机可测; +//! - **语言**从 [`unlucky_i18n::lang::resolve`] 拿:`ui.language` 显式值 > [`SystemLocale`] +//! (Windows 读注册表,零 FFI)> 环境变量 > 英文; //! - **本模块只做适配**:把 [`super::Face`] 列表变成一个「某族装没装」的查询,并处理 //! **读不到字体库**这种情况。 //! @@ -15,26 +17,28 @@ //! //! ## 诚实边界(抓得住什么 / 抓不住什么) //! -//! - **抓得住**:语言 + 字体库族名 → 策略(含 `Unprobeable` 这一支); -//! - **抓不住**:族名存在 ≠ 字形覆盖(`SimSun` 也可能是个残缺子集)。真判据要开 `ttf-parser` 查 cmap, -//! 本 mission 不引依赖(M60 Q3 spike 做过,结论见 `DevDocs`)。 +//! - **抓得住**:语言 + 字体库族名 → 策略(含 `Unprobeable` 这一支);优先级顺序(注入的假源可测); +//! - **抓不住**:①族名存在 ≠ 字形覆盖(`SimSun` 也可能是个残缺子集)。真判据要开 `ttf-parser` +//! 查 cmap,本 mission 不引依赖(M60 Q3 spike 做过,结论见 `DevDocs`); +//! ②`ui.language` 这一级目前**没有接线**(设置层还没有读取点),调用点传 `None`。 use unlucky_i18n::font::{self, Strategy}; -use unlucky_i18n::lang::{self, Detection}; +use unlucky_i18n::lang::{self, Detection, EnvLocale}; use super::Face; +use crate::platform::locale::SystemLocale; /// 一次中文探测的结论(启动日志的第二段)。 #[derive(Debug, Clone, PartialEq, Eq)] pub struct CjkReport { - /// 语言探测(含原始 locale,便于解释「为什么是英文」)。 + /// 语言探测(含原始 locale 与来源,便于解释「为什么是英文」)。 pub detection: Detection, /// 字体策略结论。 pub strategy: Strategy, } impl CjkReport { - /// 一行日志片段(`lang=zh-CN locale=zh_CN.UTF-8 cjk=system:"Microsoft YaHei"`)。 + /// 一行日志片段(`lang=zh-CN locale=zh-CN source=registry cjk=system:"Microsoft YaHei"`)。 pub fn describe(&self) -> String { format!("{} {}", self.detection.describe(), self.strategy.describe()) } @@ -55,9 +59,18 @@ pub fn report(detection: Detection, faces: Option<&[Face]>) -> CjkReport { } } -/// 探测本机(locale 走环境变量;换 Win32 真值源是后续 mission 的注入点)。 +/// 探测本机:优先级见 [`unlucky_i18n::lang::resolve`]。 +/// +/// `setting` 是配置里 `ui.language` 的原文(`zh_cn` / `en_us`);`None` = **还没读配置** +/// (设置层的读取点尚未接线,见模块文档的诚实边界)。注意 `None` 与 `Some("不认识的值")` +/// 效果相同:都不当显式选择,继续往下走。 +pub fn probe_with(setting: Option<&str>, faces: Option<&[Face]>) -> CjkReport { + report(lang::resolve(setting, &SystemLocale, &EnvLocale), faces) +} + +/// 不读配置时的入口(启动路径用)。 pub fn probe(faces: Option<&[Face]>) -> CjkReport { - report(lang::detect_env(), faces) + probe_with(None, faces) } #[cfg(test)] @@ -77,14 +90,16 @@ mod tests { fn zh() -> Detection { Detection { lang: unlucky_i18n::Lang::ZhCn, - raw: Some("zh_CN.UTF-8".to_owned()), + raw: Some("zh-CN".to_owned()), + origin: "injected", } } fn en() -> Detection { Detection { lang: unlucky_i18n::Lang::En, - raw: Some("en_US.UTF-8".to_owned()), + raw: Some("en-US".to_owned()), + origin: "injected", } } @@ -102,7 +117,7 @@ mod tests { ); assert_eq!( report.describe(), - "lang=zh-CN locale=zh_CN.UTF-8 cjk=system:\"Microsoft YaHei\"" + "lang=zh-CN locale=zh-CN source=injected cjk=system:\"Microsoft YaHei\"" ); } @@ -144,4 +159,29 @@ mod tests { report.describe() ); } + + #[test] + fn explicit_setting_overrides_the_platform_source() { + // 抓得住:`ui.language` 显式值被平台源盖掉(用户手选中文/英文必须赢过系统语言); + // 也让「平台源真的接在链路上」有个不依赖本机语言的证据。 + // 抓不住:`ui.language` 在真机上读到什么(设置层还没接线,调用点传 None)。 + let installed = faces(&["Microsoft YaHei"]); + + let forced_english = probe_with(Some("en_us"), Some(&installed)); + assert_eq!(forced_english.detection.lang, unlucky_i18n::Lang::En); + assert_eq!( + forced_english.detection.origin, + unlucky_i18n::lang::ORIGIN_SETTING + ); + assert_eq!(forced_english.strategy, Strategy::NotNeeded); + + let forced_chinese = probe_with(Some("zh_cn"), Some(&installed)); + assert_eq!( + forced_chinese.strategy, + Strategy::System { + family: "Microsoft YaHei" + } + ); + assert!(forced_chinese.describe().contains("source=setting")); + } } diff --git a/crates/app-iced/src/fonts/tests.rs b/crates/app-iced/src/fonts/tests.rs index 734a8ed..5616c58 100644 --- a/crates/app-iced/src/fonts/tests.rs +++ b/crates/app-iced/src/fonts/tests.rs @@ -146,6 +146,7 @@ fn describe_is_one_line_and_mentions_all_three_stacks() { detection: Detection { lang: unlucky_i18n::Lang::En, raw: None, + origin: unlucky_i18n::lang::ORIGIN_DEFAULT, }, strategy: Strategy::NotNeeded, }, diff --git a/crates/app-iced/src/platform/locale.rs b/crates/app-iced/src/platform/locale.rs new file mode 100644 index 0000000..bd28142 --- /dev/null +++ b/crates/app-iced/src/platform/locale.rs @@ -0,0 +1,301 @@ +//! 系统语言的**真值源**(D28):Windows 走 `reg.exe` 读注册表——**零 `unsafe`、零 FFI、零新依赖**。 +//! +//! ## 为什么是子进程而不是 FFI +//! +//! 先例是 M73 的壁纸探测(`crates/core/src/platform/wallpaper/windows.rs`):同样是「Windows 上 +//! 只有注册表知道答案」的问题,它用 `reg.exe query` 解决,`unsafe` 一个没加。本 crate 的 `unsafe` +//! 只允许出现在 [`crate::platform::win32`](D36 的纪律),所以这里照抄那条路。 +//! +//! 为什么不 `GetUserDefaultLocaleName`:它要 FFI,而它对**本用途**并不比注册表准——Windows 的 +//! 「区域格式」与「显示语言」本来就是两个设置,两者都有各自的说法(见下面的诚实边界)。 +//! +//! ## 读什么、按什么顺序 +//! +//! 1. `HKCU\Control Panel\International` 的 `LocaleName`——Win7+ 都有,值就是 `zh-CN` 这种标签, +//! [`unlucky_i18n::Lang::from_locale`] 直接吃得下; +//! 2. 读不到 `LocaleName` 时退到同一个键的 `Locale`(8 位十六进制 LCID,如 `00000804`), +//! 用一张**很短的**表翻成标签;表里没有的返回 `None`(宁可英文,也不猜错语种)。 +//! +//! 优先级链在 [`unlucky_i18n::lang::resolve`]:**`ui.language` 显式值 > 本模块 > 环境变量 > 英文**。 +//! +//! ## 诚实边界(抓得住什么 / 抓不住什么) +//! +//! - **抓得住**:`reg query` 输出的解析(含 CRLF、空值、值名大小写)、`LocaleName` 缺失时退 LCID +//! 的分支、命令失败/缺失时的 `None`; +//! - **抓不住**:①`LocaleName` 是**区域格式**,用户完全可能在设置里把「显示语言」改成英文而区域 +//! 仍是 `zh-CN`(或反过来)——真要读显示语言得 `GetUserDefaultUILanguage`(FFI)或多语言 +//! 用户界面注册表(`...\Control Panel\Desktop` 的 `PreferredUILanguages`,本 mission 未做); +//! ②策略禁用 `reg.exe` / Wine 环境 → `None` → 落到环境变量 → 英文; +//! ③代码页(`chcp`)与这里无关,注册表值不受它影响。 + +use unlucky_i18n::lang::LocaleSource; + +/// 语言相关的注册表键。 +const INTERNATIONAL_KEY: &str = r"HKCU\Control Panel\International"; + +/// 首选值名:值是 BCP-47 风格标签(`zh-CN`)。 +const LOCALE_NAME_VALUE: &str = "LocaleName"; + +/// 兜底值名:值是 8 位十六进制 LCID(`00000804`)。 +const LOCALE_VALUE: &str = "Locale"; + +/// 常见 LCID → 语言的短表(**故意短**:不认识的返回 `None`,让链路落到英文)。 +/// +/// 覆盖面:中文四地 + 英语三地 + 几个大语种。这张表只在 `LocaleName` 缺失时用到, +/// 而 `LocaleName` 从 Win7 起就有,所以它实际上是「老系统 / 被裁剪的系统」专用。 +const LCID_TAGS: &[(&str, &str)] = &[ + ("00000804", "zh-CN"), + ("00000404", "zh-TW"), + ("00000c04", "zh-HK"), + ("00001004", "zh-SG"), + ("00000409", "en-US"), + ("00000809", "en-GB"), + ("00000c09", "en-AU"), + ("00000411", "ja-JP"), + ("00000412", "ko-KR"), + ("00000407", "de-DE"), + ("0000040c", "fr-FR"), + ("00000410", "it-IT"), + ("00000c0a", "es-ES"), + ("00000416", "pt-BR"), + ("00000419", "ru-RU"), + ("00000415", "pl-PL"), + ("0000041f", "tr-TR"), + ("00000406", "da-DK"), + ("0000040b", "fi-FI"), + ("00000413", "nl-NL"), +]; + +/// 跑外部命令的接口(注入点:单测不 spawn 进程)。 +pub trait RegIo { + /// 跑一条命令取 stdout;命令缺失 / 非零退出 / 空输出都返回 `None`。 + fn run(&self, program: &str, args: &[&str]) -> Option; +} + +/// 真实实现:`reg.exe` 子进程(非 Windows 上恒 `None`)。 +#[derive(Debug, Clone, Copy, Default)] +pub struct HostReg; + +impl RegIo for HostReg { + fn run(&self, program: &str, args: &[&str]) -> Option { + if !cfg!(windows) { + // 其它平台的「系统语言」真值源不是注册表;`resolve` 会继续往环境变量走。 + return None; + } + + let output = std::process::Command::new(program) + .args(args) + .output() + .ok()?; + // `Command::output` 对非零退出同样返回 `Ok`,退出码要单独判(`reg.exe` 查不到值就是非零)。 + if !output.status.success() || output.stdout.is_empty() { + return None; + } + Some(String::from_utf8_lossy(&output.stdout).into_owned()) + } +} + +/// 系统语言源(实现 [`LocaleSource`],日志里自称 `registry`)。 +#[derive(Debug, Clone, Copy, Default)] +pub struct SystemLocale; + +impl LocaleSource for SystemLocale { + fn locale(&self) -> Option { + detect_with(&HostReg) + } + + fn label(&self) -> &'static str { + "registry" + } +} + +/// 用给定 IO 读一次系统语言(`LocaleName` 优先,退 `Locale` 的 LCID)。 +pub fn detect_with(io: &dyn RegIo) -> Option { + let by_name = io + .run( + "reg.exe", + &["query", INTERNATIONAL_KEY, "/v", LOCALE_NAME_VALUE], + ) + .and_then(|text| parse_reg_sz(&text, LOCALE_NAME_VALUE)) + .filter(|value| looks_like_tag(value)); + if by_name.is_some() { + return by_name; + } + + io.run("reg.exe", &["query", INTERNATIONAL_KEY, "/v", LOCALE_VALUE]) + .and_then(|text| parse_reg_sz(&text, LOCALE_VALUE)) + .and_then(|lcid| lcid_tag(&lcid)) + .map(str::to_owned) +} + +/// 取 `reg query <键> /v <值名>` 输出里某个 `REG_SZ` 值的原文。 +/// +/// 真机输出形如(本机 Windows 11 24H2 实测,CRLF): +/// +/// ```text +/// HKEY_CURRENT_USER\Control Panel\International +/// LocaleName REG_SZ zh-CN +/// ``` +/// +/// 值名大小写不敏感(`reg.exe` 原样回显查询时给的名字);值可能含空格,所以「值名 + 类型」 +/// 之后整段取原文,不 `split_whitespace` 再拼(会吃掉连续空格)。 +pub fn parse_reg_sz(text: &str, value_name: &str) -> Option { + text.lines().find_map(|line| { + let trimmed = line.trim_start(); + let name_end = trimmed.find(char::is_whitespace)?; + if !trimmed[..name_end].eq_ignore_ascii_case(value_name) { + return None; + } + let after_name = trimmed[name_end..].trim_start(); + let kind_end = after_name.find(char::is_whitespace)?; + let value = after_name[kind_end..].trim(); + (!value.is_empty()).then(|| value.to_owned()) + }) +} + +/// LCID(十六进制原文,如 `00000804`)→ 标签(大小写不敏感)。 +pub fn lcid_tag(lcid: &str) -> Option<&'static str> { + LCID_TAGS + .iter() + .find(|(key, _)| key.eq_ignore_ascii_case(lcid.trim())) + .map(|(_, tag)| *tag) +} + +/// 像不像一个语言标签(`zh-CN` / `en` / `zh-Hans-CN`)。 +/// +/// 只拦明显不是标签的东西(空值、带空格、带路径分隔符)——`reg.exe` 在某些策略下会回 +/// `REG_SZ` 空串或一长串本地化文本,那些不该被当成语言喂给 `from_locale`。 +fn looks_like_tag(value: &str) -> bool { + !value.is_empty() + && value.len() <= 35 + && value + .chars() + .all(|ch| ch.is_ascii_alphanumeric() || ch == '-') +} + +#[cfg(test)] +mod tests { + use super::*; + + /// 本机 Windows 11 24H2 实测抓下来的两段原文(含 `\r\n`)。 + const REAL_LOCALE_NAME: &str = "\r\nHKEY_CURRENT_USER\\Control Panel\\International\r\n LocaleName REG_SZ zh-CN\r\n\r\n"; + const REAL_LOCALE: &str = "\r\nHKEY_CURRENT_USER\\Control Panel\\International\r\n Locale REG_SZ 00000804\r\n\r\n"; + + struct FakeReg { + name: Option<&'static str>, + locale: Option<&'static str>, + fail: bool, + } + + impl FakeReg { + fn both(name: Option<&'static str>, locale: Option<&'static str>) -> Self { + FakeReg { + name, + locale, + fail: false, + } + } + } + + impl RegIo for FakeReg { + fn run(&self, program: &str, args: &[&str]) -> Option { + assert_eq!(program, "reg.exe", "程序名写错就不是注册表读了"); + assert_eq!(args[0], "query"); + assert_eq!(args[1], INTERNATIONAL_KEY); + assert_eq!(args[2], "/v"); + if self.fail { + return None; + } + + let raw = match args[3] { + LOCALE_NAME_VALUE => self.name?, + LOCALE_VALUE => self.locale?, + other => panic!("没料到的值名:{other}"), + }; + Some(format!( + "\r\nHKEY_CURRENT_USER\\Control Panel\\International\r\n {} REG_SZ {raw}\r\n\r\n", + args[3] + )) + } + } + + #[test] + fn real_registry_output_parses_to_the_locale_tag() { + // 抓得住:解析器与真机 `reg query` 输出格式之间的契约(CRLF、前导空白、值名与类型之间的 + // 多个空格)。这两段夹具是真机抓的原文,不是我编的。 + // 抓不住:这台机器的值本身会变(用户改区域设置)——测试只钉格式,不钉具体语言。 + assert_eq!( + parse_reg_sz(REAL_LOCALE_NAME, LOCALE_NAME_VALUE).as_deref(), + Some("zh-CN") + ); + assert_eq!( + parse_reg_sz(REAL_LOCALE, LOCALE_VALUE).as_deref(), + Some("00000804") + ); + // 值名大小写不敏感(reg.exe 会原样回显查询里给的名字) + assert_eq!( + parse_reg_sz(REAL_LOCALE_NAME, "localename").as_deref(), + Some("zh-CN") + ); + // 空值 = 没设(不是空串语言) + let empty = " LocaleName REG_SZ \r\n"; + assert_eq!(parse_reg_sz(empty, LOCALE_NAME_VALUE), None); + // 别的值名不该被误取 + assert_eq!(parse_reg_sz(REAL_LOCALE_NAME, "Locale"), None); + } + + #[test] + fn lcid_fallback_covers_chinese_and_english() { + // 抓得住:LCID 兜底表的关键格子(中文四地 + 英语三地)被改动或删掉; + // 以及「不认识就 None」这条(不许猜成某个语言)。 + // 抓不住:这 20 条之外的语言——没有 `LocaleName` 的老系统上它们会落到英文。 + assert_eq!(lcid_tag("00000804"), Some("zh-CN")); + assert_eq!(lcid_tag("00000804\r"), Some("zh-CN"), "要去空白"); + assert_eq!(lcid_tag("00000404"), Some("zh-TW")); + assert_eq!(lcid_tag("00000C04"), Some("zh-HK"), "大小写不敏感"); + assert_eq!(lcid_tag("00001004"), Some("zh-SG")); + assert_eq!(lcid_tag("00000409"), Some("en-US")); + assert_eq!(lcid_tag("00000411"), Some("ja-JP")); + assert_eq!(lcid_tag("00000437"), None, "表外语言必须 None"); + assert_eq!(lcid_tag(""), None); + } + + #[test] + fn detect_prefers_locale_name_and_falls_back_to_lcid() { + // 抓得住:两条取值路径的顺序(有 LocaleName 就不该再看 LCID),以及命令失败时的 None。 + // 抓不住:真机注册表里到底有什么(下面的 windows_live 测试才碰真机)。 + assert_eq!( + detect_with(&FakeReg::both(Some("zh-CN"), Some("00000409"))).as_deref(), + Some("zh-CN"), + "LocaleName 优先" + ); + assert_eq!( + detect_with(&FakeReg::both(None, Some("00000804"))).as_deref(), + Some("zh-CN"), + "没有 LocaleName 才退 LCID" + ); + assert_eq!(detect_with(&FakeReg::both(None, None)), None); + assert_eq!( + detect_with(&FakeReg { + name: Some("zh-CN"), + locale: Some("00000804"), + fail: true, + }), + None, + "命令失败就是 None,不许猜" + ); + // 值名对但值不像标签(策略回了一串本地化文本)→ 不许当成语言 + assert_eq!(detect_with(&FakeReg::both(Some("中文(简体)"), None)), None); + } + + #[test] + #[cfg(windows)] + fn windows_live_registry_query_returns_a_tag() { + // 抓得住:`reg.exe` 这条路在本机真的通(spawn 成功、退出码为 0、输出能被解析)。 + // 抓不住:它读到的「区域格式」是否等于用户想用的**界面语言**(见模块文档的诚实边界)。 + let tag = SystemLocale.locale().expect("本机应当读得到 LocaleName"); + assert!(!tag.is_empty()); + assert!(looks_like_tag(&tag), "解析出来的不是语言标签:{tag:?}"); + assert_eq!(SystemLocale.label(), "registry"); + } +} diff --git a/crates/app-iced/src/platform/mod.rs b/crates/app-iced/src/platform/mod.rs index f8baeb2..0689fbe 100644 --- a/crates/app-iced/src/platform/mod.rs +++ b/crates/app-iced/src/platform/mod.rs @@ -3,6 +3,7 @@ //! - 本文件 = 纯判据 [`effective_window_alpha`]:不读环境变量、不调 FFI、不碰窗口句柄, //! 只回答「按这个渲染后端和用户要的 α,最终该给窗口什么值」; //! - [`win32::transparency`] = 唯一允许 `unsafe` 的文件(`SetLayeredWindowAttributes`)。 +//! - [`locale`] = 系统语言真值源(D28):`reg.exe` 子进程读注册表,**零 `unsafe` / 零 FFI**。 //! //! 为什么判据要单独拎出来:D36 的降级规则(α 下限 0.08 / `≤0` 一律拒绝 / CPU 档也允许 //! 窗口级 α)**可以**被断言证明;而 FFI 的**视觉**结果在本仓库的验证环境(RDP + 虚拟显示 @@ -13,6 +14,7 @@ use unlucky_presenter::prefs::WINDOW_ALPHA_MIN; use crate::Backend; +pub mod locale; pub mod win32; /// 用户请求的整窗 α → 真正写进窗口的值(纯函数)。 diff --git a/crates/i18n/src/catalog.rs b/crates/i18n/src/catalog.rs index d2fa823..6fc8b7f 100644 --- a/crates/i18n/src/catalog.rs +++ b/crates/i18n/src/catalog.rs @@ -3,15 +3,12 @@ //! ## 为什么 key 是枚举而不是字符串 //! //! 字符串 key(`"nav.instances"`)拼错了编译器一声不响,运行时才知道——本仓的说法是「运行时静默回落」。 -//! 枚举 key 把这类错误全部前移到**编译期**: +//! 枚举 key 把这类错误前移到**编译期**:`Key::NavInstance`(真名是 `NavInstances`)直接 +//! `error[E0599]`,不可能跑到运行时。 //! -//! ```compile_fail -//! // `Key::NavInstance` 不存在(真名是 `NavInstances`)⇒ E0599,不会编译过。 -//! let _ = unlucky_i18n::Key::NavInstance; -//! ``` -//! -//! 这条 `compile_fail` 用例本身就是一个测试:哪天有人把 `Key` 改成字符串包装类型,它会**变绿失败** -//! (能编译过 = 编译期校验没了)。 +//! **这里没有守卫测试**:曾经写过一个 `compile_fail` 文档测试想守这条,但复审实测它对 +//! 「把 `Key` 换成字符串包装类型」这类变异仍然全绿——它守的是「这段代码编译不过」,而不是 +//! 「编译期校验还在」。说不清真假的守卫比没有更糟,所以删了;这条纪律只靠枚举这个事实本身。 //! //! ## 为什么两套译文写在**同一行** //! diff --git a/crates/i18n/src/lang.rs b/crates/i18n/src/lang.rs index b5c7bce..a38fcc0 100644 --- a/crates/i18n/src/lang.rs +++ b/crates/i18n/src/lang.rs @@ -15,14 +15,22 @@ //! 后续 mission 加「Win32 `GetUserDefaultLocaleName`」或「读配置里的 `ui.language`」也只是再实现一遍 //! 这个 trait。 //! +//! ## 优先级([`resolve`]) +//! +//! **`ui.language` 显式值 > 平台源 > POSIX 环境变量 > 英文**。本 crate 只有 [`EnvLocale`] +//! 一个具体实现——平台真值源(Windows 注册表那一路)在 UI 壳层,因为读注册表要开子进程, +//! 而本 crate 的纪律是不碰外部世界。 +//! //! ## 诚实边界(抓得住什么 / 抓不住什么) //! //! - **抓得住**:环境变量字符串 → 语言的映射(含 `zh_CN.UTF-8` / `zh-Hans-CN` / 大小写 / 修饰符); -//! 变量优先级(`LC_ALL` > `LC_MESSAGES` > `LANG`); -//! - **抓不住**:**Windows 上这三个变量通常都不存在**,`GetUserDefaultLocaleName` 才是真值源。 -//! 本 mission 不碰 FFI(`app-iced::platform` 才是全仓唯一 `unsafe` 落点),所以 Windows 目前 -//! 一律落到 [`Lang::En`];配置里的 `ui.language` 覆盖也是后续接线 mission 的事。两者都是**已知缺口**, -//! 不是「已完成」。 +//! 变量优先级(`LC_ALL` > `LC_MESSAGES` > `LANG`);[`resolve`] 的四级优先级; +//! - **抓不住**:**Windows 上这三个变量通常都不存在**——那条路的真值源是 +//! `crates/app-iced/src/platform/locale.rs`(`reg.exe` 读注册表,零 FFI), +//! 它不在本 crate 里(本 crate 零依赖、不 spawn 进程)。所以「Windows 上拿不到语言」 +//! 这件事只在**没有接线平台源**时成立,接线后由 `resolve` 的二级兜住。 +//! 配置里的 `ui.language` 读取属于设置层接线(本 crate 只提供 [`Lang::from_setting`] 与 +//! [`resolve`] 的形状)。 use std::env; @@ -112,9 +120,21 @@ impl Default for Lang { pub trait LocaleSource { /// 原始 locale 标签(`None` = 取不到,不是「英文」的断言)。 fn locale(&self) -> Option; + + /// 这个源在启动日志里的名字(`registry` / `env` / …)。默认 `injected`(测试用)。 + fn label(&self) -> &'static str { + "injected" + } } -/// 进程环境变量式 locale 源(POSIX 语义;Windows 上基本取不到)。 +/// [`Detection::origin`]:结论来自 `ui.language` 的显式值。 +pub const ORIGIN_SETTING: &str = "setting"; +/// [`Detection::origin`]:结论来自环境变量([`EnvLocale`])。 +pub const ORIGIN_ENV: &str = "env"; +/// [`Detection::origin`]:一个源都没取到,落到默认英文。 +pub const ORIGIN_DEFAULT: &str = "default"; + +/// 进程环境变量式 locale 源(POSIX 语义;Windows 上基本取不到,见模块文档)。 #[derive(Debug, Clone, Copy, Default)] pub struct EnvLocale; @@ -125,25 +145,32 @@ impl LocaleSource for EnvLocale { .iter() .find_map(|name| env::var(name).ok().filter(|value| !value.trim().is_empty())) } + + fn label(&self) -> &'static str { + ORIGIN_ENV + } } -/// 探测结论(`raw` 一起带出来,是为了日志能解释「为什么是英文」)。 +/// 探测结论(`raw` 与 `origin` 一起带出来,是为了日志能解释「为什么是英文」)。 #[derive(Debug, Clone, PartialEq, Eq)] pub struct Detection { /// 结论。 pub lang: Lang, - /// 原始 locale 标签(`None` = 一个变量都没取到)。 + /// 原始 locale 标签(`None` = 一个源都没取到)。 pub raw: Option, + /// 结论的来源(`setting` / 平台源自己的 [`LocaleSource::label`] / `env` / `default`)。 + pub origin: &'static str, } impl Detection { - /// 一行日志(`lang=zh-CN locale=zh_CN.UTF-8` / `lang=en locale=<未设置>`)。 + /// 一行日志(`lang=zh-CN locale=zh-CN source=registry` / `lang=en locale=<未设置> source=default`)。 #[must_use] pub fn describe(&self) -> String { format!( - "lang={} locale={}", + "lang={} locale={} source={}", self.lang.tag(), - self.raw.as_deref().unwrap_or("<未设置>") + self.raw.as_deref().unwrap_or("<未设置>"), + self.origin ) } } @@ -151,17 +178,59 @@ impl Detection { /// 用给定来源探测语言。 #[must_use] pub fn detect(source: &dyn LocaleSource) -> Detection { - let raw = source.locale(); - let lang = raw.as_deref().map_or(Lang::En, Lang::from_locale); - Detection { lang, raw } + match source.locale() { + Some(raw) => Detection { + lang: Lang::from_locale(&raw), + raw: Some(raw), + origin: source.label(), + }, + None => Detection { + lang: Lang::En, + raw: None, + origin: ORIGIN_DEFAULT, + }, + } } -/// 用进程环境变量探测语言(启动日志用)。 +/// 用进程环境变量探测语言。 #[must_use] pub fn detect_env() -> Detection { detect(&EnvLocale) } +/// 全量优先级:**`ui.language` 显式值 > 平台源 > POSIX 环境变量 > 英文**。 +/// +/// - `setting`:配置里 `ui.language` 的原文(`zh_cn` / `en_us`)。给了但不认识 → **跳过** +/// (当作「没配」继续往下走,而不是把它猜成某种语言); +/// - `platform`:平台真值源(Windows 上是 `app-iced::platform::locale` 读注册表;其它平台没有); +/// - `env`:`LC_ALL > LC_MESSAGES > LANG`([`EnvLocale`]); +/// - 都不给 → [`Lang::En`],`origin = default`。 +/// +/// 这正是 D28 要的顺序:**用户手选的**压过系统,系统压过环境变量,都没有就英文。 +#[must_use] +pub fn resolve( + setting: Option<&str>, + platform: &dyn LocaleSource, + env: &dyn LocaleSource, +) -> Detection { + if let Some(value) = setting { + if let Some(lang) = Lang::from_setting(value) { + return Detection { + lang, + raw: Some(value.to_owned()), + origin: ORIGIN_SETTING, + }; + } + } + + let from_platform = detect(platform); + if from_platform.raw.is_some() { + return from_platform; + } + + detect(env) +} + #[cfg(test)] mod tests { use super::*; @@ -214,22 +283,82 @@ mod tests { #[test] fn detect_falls_back_to_english_without_a_locale() { // 抓得住:取不到 locale 时**不 panic、不猜中文**(D28 的「纯英文环境不用管」)。 - // 抓不住:Windows 上「取不到」是否等于用户真的说英文——这正是本 crate 的已知缺口。 + // 抓不住:Windows 上「环境变量取不到」是否等于用户真的说英文——那是平台源的活 + // (`app-iced::platform::locale`),见下面 resolve 的优先级测试。 let detection = detect(&Fixed(None)); assert_eq!(detection.lang, Lang::En); assert_eq!(detection.raw, None); - assert_eq!(detection.describe(), "lang=en locale=<未设置>"); + assert_eq!(detection.origin, ORIGIN_DEFAULT); + assert_eq!( + detection.describe(), + "lang=en locale=<未设置> source=default" + ); assert_eq!(Lang::default(), Lang::En); } #[test] fn detect_uses_the_injected_source_verbatim() { - // 抓得住:探测结论确实来自注入源(不会被本机环境变量污染)。 + // 抓得住:探测结论确实来自注入源(不会被本机环境变量污染),且 origin 取自源自报的名字。 // 抓不住:EnvLocale 到底读了哪几个变量(那条路径只能靠人工/集成测试看日志)。 let detection = detect(&Fixed(Some("zh_CN.UTF-8"))); assert_eq!(detection.lang, Lang::ZhCn); assert_eq!(detection.raw.as_deref(), Some("zh_CN.UTF-8")); - assert_eq!(detection.describe(), "lang=zh-CN locale=zh_CN.UTF-8"); + assert_eq!(detection.origin, "injected"); + assert_eq!( + detection.describe(), + "lang=zh-CN locale=zh_CN.UTF-8 source=injected" + ); + assert_eq!(EnvLocale.label(), "env"); + } + + #[test] + fn resolve_priority_is_setting_then_platform_then_env_then_english() { + // 抓得住:四级优先级的顺序本身(用户手选压过系统,系统压过环境变量,都没有才英文); + // 以及「配置值不认识时不当成任何一种语言,继续往下走」。 + // 抓不住:这些源在真机上到底读到什么(平台源的真机行为在 app-iced 侧测)。 + struct Named(&'static str, Option<&'static str>); + + impl LocaleSource for Named { + fn locale(&self) -> Option { + self.1.map(str::to_owned) + } + fn label(&self) -> &'static str { + self.0 + } + } + + let registry_zh = Named("registry", Some("zh-CN")); + let env_en = Named("env", Some("en_US.UTF-8")); + let nothing = Named("registry", None); + + // ① 配置显式值压过一切 + let detection = resolve(Some("en_us"), ®istry_zh, &env_en); + assert_eq!( + (detection.lang, detection.origin), + (Lang::En, ORIGIN_SETTING) + ); + // ② 没配置(或配置不认识)→ 平台源 + for setting in [None, Some("zh-CN"), Some("")] { + let detection = resolve(setting, ®istry_zh, &env_en); + assert_eq!( + (detection.lang, detection.raw.as_deref(), detection.origin), + (Lang::ZhCn, Some("zh-CN"), "registry"), + "setting={setting:?}" + ); + } + // ③ 平台源没有 → 环境变量 + let detection = resolve(None, ¬hing, &env_en); + assert_eq!( + (detection.lang, detection.origin), + (Lang::En, "env"), + "环境变量说是英文,但来源必须是 env" + ); + // ④ 全没有 → 英文 + default + let detection = resolve(None, ¬hing, &Named("env", None)); + assert_eq!( + (detection.lang, detection.origin), + (Lang::En, ORIGIN_DEFAULT) + ); } #[test] diff --git a/crates/i18n/src/lib.rs b/crates/i18n/src/lib.rs index 6decab4..d6577fe 100644 --- a/crates/i18n/src/lib.rs +++ b/crates/i18n/src/lib.rs @@ -6,11 +6,14 @@ //! //! | 模块 | 职责 | 不负责 | //! |---|---|---| -//! | [`lang`] | `Lang`(en / zh-CN)+ 系统 locale 探测(locale 源可注入) | 读配置(`ui.language` 由壳层读) | +//! | [`lang`] | `Lang`(en / zh-CN)+ locale 探测(源可注入)+ **四级优先级** [`lang::resolve`] | 读注册表 / 读配置 / spawn 进程 | //! | [`catalog`] | `Key` 枚举 + 中英两套常量表 + 取词 / 占位符渲染 + 缺译诊断 | 排版、字号、断行 | //! | [`font`] | 中文环境扫系统 CJK 字体的**判据** + 缺字体时的「需下载」信号 + 注入式下载器接口 | 联网、写文件、装字体 | //! | [`bridge`] | core / presenter 的状态串 → `Key` 的**示例契约**(文案边界规则全文在这里) | 改 core 的产出 | //! +//! 语言从哪来的优先级写在 [`lang::resolve`]:**`ui.language` 显式值 > 平台源 > POSIX 环境变量 > 英文**。 +//! 平台源(Windows 上 `reg.exe` 读注册表,零 FFI)在 UI 壳层实现并注入——本 crate 不认平台。 +//! //! ## 三条硬规矩(细则见 [`bridge`]) //! //! 1. **一句话 = 一个 key**。UI 侧不得把片段拼成一句话,尤其不得拼中文: