feat(app-iced,i18n): M106 —— 界面跟随系统语言 + 设置页真实语言行

用户原话:「我系统语言是中文 为什么打开软件是英文 并且设置页里面显示的是 English(system)」。
两处根源:①M92 的探测链一直是对的,但 `pages::Model.lang` 在 `app/view.rs` 里写死成
`Lang::En`(`pages::render` 还把下载页写死成英文),探测结果没人用;②设置页语言行是硬编码
字面量 `("Language", "English (System)")`。

新增/改动(按层):
- i18n:新域 `catalog/general.rs`(24 条中英双份 key,装配链尾追加)+ `catalog/golden.rs`
  只追加不重排;`choice.rs`(`LangChoice`:跟随系统/手选 + D28 四级优先级真值表 + 字体优先级真值表)。
- app-iced 语言入口 `src/ui.rs` + `src/ui/lang.rs`:`LangState` = 用户档位 + 探测结论;
  启动判定 `ULL_LANG` > 配置 `ui.language` > 系统 locale > 英文;两行日志(探测/生效 + 中文策略)。
- `app/state.rs`:`App` 持有 `LangState`,切语言 = 换目录 + 落盘 + 重建视图模型;
  `app/view.rs` 的 `model()` 收 `lang`,不再写死英文。
- 设置页:语言行换真控件(跟随系统 / 简体中文 / 英文,回显「界面当前使用:简体中文」);
  页头、左列分组名、逐实例空态、导航轨标签全部走 catalog。
- presenter:`settings_bridge_lang.rs`(`load_ui_language` / `persist_ui_language`)。
  「跟随系统」= **删键**(schema 允许值只有 zh_cn/en_us),读的是 `doc().get()` 而不是
  `effective_str`(后者会把默认值 `zh_cn` 当成用户手选,英文机器一启动就被切成中文)。

验证:408 条 app-iced lib 测试 + 28 条 i18n 测试 + 全仓 `cargo test --workspace --exclude
unlucky-app` 全绿;fmt / clippy `-D warnings` 干净;出图 6 张(en/zh × 浅/深 + 下载页对照)
在 `DevDocs/artifacts/m106/`(该目录被 .gitignore 忽略,按仓内惯例作为本地验收产物)。
This commit is contained in:
AskaEth committed 2026-11-04 22:50:09 +08:00
1 parent 777d02e7cb
commit 89dcb6bdf3
25 files changed
+1456 -92

No files matched your search

+260
View File
@@ -0,0 +1,260 @@
//! 界面语言的**入口层**(M106 `I18NFOLLOW`):`Ui` 缺的那条语言入口就是这里。
//!
//! ## 它解决的是什么
//!
//! M92 把探测链做对了(启动日志里 `lang=zh-CN locale=zh-CN source=registry` 一直是对的),但
//! **没人消费它**:`app/view.rs` 的 `model()` 里 `Lang::En` 是写死的,于是中文系统上开出来的是
//! 全英文界面,设置页还写着 `English (System)`。本模块把「用户档位 + 探测结论」变成一个**值**
//! ([`LangState`]),`App` 持有它、`view::model` 只认它,页面侧 `pages::Model.lang` 决定目录。
//!
//! ## 判定的四级优先级([`LangState::boot`])
//!
//! | 级 | 来源 | 什么时候用 |
//! |---|---|---|
//! | 1 | `ULL_LANG`(`zh_cn` / `en_us`) | 截图矩阵要**可复现地**换语言(D30) |
//! | 2 | 配置里的 `ui.language` | 用户手选(设置页写入) |
//! | 3 | 系统 locale(注册表 / 环境变量) | 跟随系统 |
//! | 4 | 英文 | 一个源都没有 |
//!
//! **截图通道不读用户配置**(与 `appearance_prefs` 同一条 D30 纪律):同一份代码在两台机器上出
//! 不了同一张图,除非语言也由 `ULL_LANG` 显式给。
//!
//! ## 日志(两个下一个人要问的问题)
//!
//! 「探测错了吗」与「探测对了但没用上吗」在日志里必须分得开:
//!
//! ```text
//! [i18n] choice=system lang=zh-CN locale=zh-CN source=registry effective=zh-CN
//! [i18n] lang=zh-CN locale=zh-CN source=registry cjk=system:"Microsoft YaHei UI"
//! ```
//!
//! 第一行是**档位 + 探测结论 + 最终生效语言**([`LangState::log`]),第二行是中文策略(D28:
//! 缺字体是信号,不许静默;[`LangState::log_cjk`])。
//!
//! ## 诚实边界
//!
//! - **抓得住**:档位的选择顺序、坏值不猜、截图通道忽略配置、切档后生效语言跟着换;
//! - **抓不住**:系统 locale 的真机行为(那在 [`crate::platform::locale::SystemLocale`],Windows 上
//! 真的去跑 `reg.exe`);中文字体到底装没装(`fonts::cjk_for` 查 iced 的字体库)。
use unlucky_i18n::lang::{Detection, EnvLocale};
use unlucky_i18n::{Lang, LangChoice};
use unlucky_presenter::settings_bridge::SettingsBridge;
use crate::pages;
use crate::platform::locale::SystemLocale;
/// 截图通道的语言覆盖(D30 可复现):`ULL_LANG=zh_cn` / `ULL_LANG=en_us`。
///
/// 为什么走环境变量而不是 `--lang`:`cli.rs` 不在本 mission 的 scope 里,而截图矩阵要能**只**改
/// 一个环境变量就换语言(与 `ULL_APPEARANCE_*` 那批同一条做法)。
pub(crate) const LANG_ENV: &str = "ULL_LANG";
/// 这一次进程的界面语言:用户选的档 + 探测出来的结论。
///
/// 不是 `Copy`:结论里带着探测到的原始 locale 字符串(`Detection.raw`),而它只在切档/启动时
/// 构造一次——每帧问的是 [`LangState::lang`](一个 `Copy` 的枚举),不是整份状态。
#[derive(Debug, Clone, PartialEq, Eq)]
pub(crate) struct LangState {
/// 用户意图(决定设置页画哪个按钮、落盘写不写 `ui.language`)。
choice: LangChoice,
/// 生效结论(决定用哪套目录;`describe()` 就是日志那一段)。
detection: Detection,
}
impl LangState {
/// 启动判定:`ULL_LANG` > 配置 `ui.language` > 系统 locale > 英文。
pub(crate) fn boot(bridge: &SettingsBridge, screenshot: bool) -> Self {
let forced = std::env::var(LANG_ENV)
.ok()
.filter(|value| !value.trim().is_empty());
let setting = bridge.load_ui_language();
Self::of(choose_choice(
forced.as_deref(),
setting.as_deref(),
screenshot,
))
}
/// 换了档位之后的状态(重新走一遍 `resolve`,不保留旧结论)。
pub(crate) fn of(choice: LangChoice) -> Self {
Self {
choice,
detection: choice.resolve(&SystemLocale, &EnvLocale),
}
}
/// 当前档位。
pub(crate) fn choice(&self) -> LangChoice {
self.choice
}
/// 当前生效的语言(目录选择)。
pub(crate) fn lang(&self) -> Lang {
self.detection.lang
}
/// 设置页语言行要的两件(档位 + 生效语言)。
pub(crate) fn for_settings(&self) -> pages::Language {
pages::Language {
choice: self.choice,
effective: self.lang(),
}
}
/// 主日志行:档位 + 探测结论 + 最终生效语言。
pub(crate) fn log(&self) {
eprintln!(
"[i18n] choice={} {} effective={}",
self.choice.setting_value().unwrap_or("system"),
self.detection.describe(),
self.lang().tag()
);
}
/// 中文策略日志(D28):缺字体是**信号**,不许静默。
///
/// 为什么要单独一次(启动编排里已经打过一行 `[font]`):那一拍按**系统 locale** 判,不知道用户
/// 手选了什么。英文系统上手动切中文时,只有这一行会变成 `cjk=needs-download(...)`——白块那条路
/// 就是靠它被看见的。
pub(crate) fn log_cjk(&self) {
let report = crate::fonts::cjk_for(self.choice.setting_value());
eprintln!("[i18n] {}", report.describe());
if matches!(
report.strategy,
unlucky_i18n::font::Strategy::NeedsDownload(_)
) {
eprintln!(
"[i18n] 缺中文字体:界面会是中文但字形可能缺失(D28 的下载通道还是 PendingPin)"
);
}
}
}
/// 档位选择的**纯判据**(单测直接打这张表)。
///
/// | 输入 | 结果 |
/// |---|---|
/// | `ULL_LANG` 给了合法值 | 那一档(压过配置) |
/// | 没给 + 配置写了 `zh_cn` / `en_us` | 那一档 |
/// | 没给 + 配置没写(或写了坏值) | 跟随系统 |
/// | **截图通道** + 配置写了值 | 跟随系统(除非 `ULL_LANG` 另给) |
fn choose_choice(forced: Option<&str>, setting: Option<&str>, screenshot: bool) -> LangChoice {
let configured = if screenshot { None } else { setting };
LangChoice::from_setting(forced.or(configured))
}
#[cfg(test)]
mod tests {
use super::*;
/// 档位判据的真值表(D28 第一级 + D30 的截图可复现)。
///
/// 抓得住:①`ULL_LANG` 没压过配置(截图矩阵就换不了语言);②截图通道读了用户配置(同一份代码
/// 在两台机器上出不同的图);③坏值被猜成某种语言。
/// 抓不住:系统 locale 真探测成什么(那是 `SystemLocale` + `i18n::lang::resolve` 的事)。
#[test]
fn the_language_choice_is_ull_lang_then_config_then_system() {
let en = Some("en_us");
let zh = Some("zh_cn");
assert_eq!(choose_choice(None, None, false), LangChoice::System);
assert_eq!(
choose_choice(None, zh, false),
LangChoice::Fixed(Lang::ZhCn),
"配置里的手选要生效"
);
assert_eq!(
choose_choice(en, zh, false),
LangChoice::Fixed(Lang::En),
"ULL_LANG 压过配置(截图矩阵靠它换语言)"
);
assert_eq!(
choose_choice(zh, None, false),
LangChoice::Fixed(Lang::ZhCn)
);
assert_eq!(
choose_choice(None, zh, true),
LangChoice::System,
"截图通道必须忽略配置文件里的语言,否则同一份代码出不了同一张图"
);
assert_eq!(choose_choice(en, zh, true), LangChoice::Fixed(Lang::En));
for bogus in ["zh-CN", "system", "", "fr_fr"] {
assert_eq!(
choose_choice(Some(bogus), None, false),
LangChoice::System,
"{bogus}"
);
}
}
/// 「重启后保持所选语言」的**闭环证据**:真的落一个临时 `launcher.json`,再用**新开的** bridge
/// 读回来(等价于重启)。
///
/// 抓得住:①「跟随系统」被读成 schema 默认值 `zh_cn`(英文机器一启动就是中文);
/// ②「跟随系统」写了哨兵值(下次启动撒谎);③删键时把别的键一起删了。
/// 抓不住:数据根解析(`SettingsBridge::open` 那条路)——这里用的是显式路径。
#[test]
fn the_choice_round_trips_through_the_config_file() {
use unlucky_presenter::settings_bridge::SettingsBridge;
let dir = std::env::temp_dir().join(format!("ull-m106-lang-{}", std::process::id()));
std::fs::create_dir_all(dir.join("config")).expect("临时目录");
let path = dir.join("config/launcher.json");
std::fs::write(
&path,
r#"{"appearance":{"theme_mode":"dark"},"custom":{"keep":true}}"#,
)
.expect("写配置");
// ① 没写过 = 跟随系统(不是默认值 zh_cn)
let bridge = SettingsBridge::with_path(&path);
assert_eq!(
bridge.load_ui_language(),
None,
"没写过这个键 = 跟随系统,不许回落成 schema 默认值"
);
// ② 手选落盘 → 新开的 bridge(= 重启)读回来
let mut bridge = SettingsBridge::with_path(&path);
assert!(bridge.persist_ui_language(Some("zh_cn")));
assert_eq!(
SettingsBridge::with_path(&path)
.load_ui_language()
.as_deref(),
Some("zh_cn"),
"重启后要保持所选语言"
);
// ③ 切回跟随系统 = 删键,且不许顺手删别的键
let mut bridge = SettingsBridge::with_path(&path);
assert!(bridge.persist_ui_language(None));
assert_eq!(SettingsBridge::with_path(&path).load_ui_language(), None);
let text = std::fs::read_to_string(&path).expect("读配置");
assert!(text.contains("theme_mode"), "外观键被删了:{text}");
assert!(text.contains("custom"), "用户手写的未知键被删了:{text}");
// ④ 已经是跟随系统时再点一次是幂等的
let mut bridge = SettingsBridge::with_path(&path);
assert!(!bridge.persist_ui_language(None), "没有键可删 = 没有落盘");
std::fs::remove_dir_all(&dir).ok();
}
/// 手选档会赢过系统源,且 `raw`/`origin` 看得出来是手选的(日志可分诊)。
///
/// 抓得住:`of()` 忘了重算(切档后结论还停在旧语言)、手选被系统源盖掉。
/// 抓不住:真机上系统源读到什么(`SystemLocale` 跑 `reg.exe`)。
#[test]
fn a_fixed_choice_wins_over_the_system_locale() {
let zh = LangState::of(LangChoice::Fixed(Lang::ZhCn));
assert_eq!(zh.lang(), Lang::ZhCn);
assert_eq!(zh.for_settings().effective, Lang::ZhCn);
assert_eq!(zh.for_settings().choice, LangChoice::Fixed(Lang::ZhCn));
let en = LangState::of(LangChoice::Fixed(Lang::En));
assert_eq!(en.lang(), Lang::En, "手选英文压过任何中文系统源");
assert_eq!(en.detection.origin, "setting");
}
}