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:
1 parent
777d02e7cb
commit
89dcb6bdf3
25 files changed
+1456
-92
No files matched your search
@@ -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");
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user