Files
Unlucky-Launcher/crates/i18n/src/choice.rs
T
AskaEth 89dcb6bdf3 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 忽略,按仓内惯例作为本地验收产物)。
2026-11-04 22:50:09 +08:00

259 lines
11 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
//! 设置页的**界面语言档位**(M106 `I18NFOLLOW`):跟随系统 / 手选某一门。
//!
//! ## 为什么单独一个文件
//!
//! `lang.rs` 讲的是「系统说哪种语言」(locale → [`Lang`] 的探测,M92),本文件讲的是「用户要哪种
//! 语言」——两者的判据不一样:探测只能尽量理解别人给的字符串,档位是我们自己写进配置的,写错就该
//! 暴露。放在一起会让 `lang.rs` 顶破 400 行(任务书那条硬线要求按功能拆文件)。
//!
//! ## D28 的判定顺序(整条挂在 [`LangChoice::resolve`] 上)
//!
//! **手选 > 系统语言 > 环境变量 > 英文**。最后一级「英文」不是偷懒:纯英文环境不扫、不下载中文
//! 字体(零额外体积 / 零网络),这条由 [`crate::font::decide`] 兜住,真值表见下面
//! `english_never_needs_a_chinese_font_and_chinese_always_resolves_one`。
//!
//! ## 「跟随系统」怎么表达
//!
//! **不写 `ui.language` 这个键**([`LangChoice::setting_value`] 返回 `None`)。不写哨兵值是因为
//! 两件事:① schema 的允许值只有 `zh_cn` / `en_us`(`core::setting::keys::LANGUAGES`),写别的
//! 会被枚举校验拒掉;② 读的那一头(`SettingBridge::load_ui_language`)只认显式值,所以「键不存在」
//! 与「用户选了跟随系统」本来就是同一件事。
use crate::catalog::Key;
use crate::lang::{resolve, Detection, Lang, LocaleSource, ORIGIN_SETTING};
/// 设置页「界面语言」的三档选择。
///
/// 为什么不是 `Option<Lang>`:`None` 与「用户明确勾了跟随系统」在类型上分不开,而设置页要把那一档
/// **画成选中**。
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
pub enum LangChoice {
/// 跟随系统(= 配置里没有 `ui.language`)。
System,
/// 用户手选的固定语言。
Fixed(Lang),
}
impl LangChoice {
/// 全部档位(顺序即设置页语言行的按钮顺序)。
pub const ALL: &'static [LangChoice] = &[
LangChoice::System,
LangChoice::Fixed(Lang::ZhCn),
LangChoice::Fixed(Lang::En),
];
/// 配置里 `ui.language` 的原文 → 档位。
///
/// `None`(键不存在)与**不认识的值**都算「跟随系统」——与 [`resolve`] 跳过坏值的行为同源:
/// 配置被 AI / CLI 写脏时退回系统语言,而不是猜成某一种。
#[must_use]
pub fn from_setting(value: Option<&str>) -> LangChoice {
value
.and_then(Lang::from_setting)
.map_or(LangChoice::System, LangChoice::Fixed)
}
/// 落盘用:跟随系统 = `None`(**删掉那个键**,不写哨兵值——哨兵会让「跟随系统」永远失效)。
#[must_use]
pub const fn setting_value(self) -> Option<&'static str> {
match self {
LangChoice::System => None,
LangChoice::Fixed(lang) => Some(lang.setting_value()),
}
}
/// 这一档在设置页上的名字(key 在 `catalog/general.rs` 域)。
#[must_use]
pub const fn label_key(self) -> Key {
match self {
LangChoice::System => Key::SettingsLanguageFollowSystem,
LangChoice::Fixed(Lang::ZhCn) => Key::SettingsLanguageZhCn,
LangChoice::Fixed(Lang::En) => Key::SettingsLanguageEn,
}
}
/// 这一档最终生效的语言 + 来源(日志与设置页回显都用它)。
///
/// 跟随系统 → 走 [`resolve`] 的三级链;手选 → 直接赢,`origin = setting`
/// (`raw` 写配置值,日志里能看出「这是用户手选的」)。
#[must_use]
pub fn resolve(self, platform: &dyn LocaleSource, env: &dyn LocaleSource) -> Detection {
match self {
LangChoice::System => resolve(None, platform, env),
LangChoice::Fixed(lang) => Detection {
lang,
raw: Some(lang.setting_value().to_owned()),
origin: ORIGIN_SETTING,
},
}
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::lang::ORIGIN_DEFAULT;
#[test]
fn choice_round_trips_through_the_config_value() {
// 抓得住:①「跟随系统」被写成哨兵值——那会让 `Lang::from_setting` 认不出、下次启动退回
// 系统语言,「手选」这件事静默丢失;②档位顺序被调换(设置页按钮顺序)。
// 抓不住:配置文件里到底有没有这个键(那是 `SettingsBridge` 的读写,见 presenter 侧测试)。
assert_eq!(LangChoice::from_setting(None), LangChoice::System);
assert_eq!(
LangChoice::from_setting(Some("zh_cn")),
LangChoice::Fixed(Lang::ZhCn)
);
assert_eq!(
LangChoice::from_setting(Some("en_us")),
LangChoice::Fixed(Lang::En)
);
for bogus in ["zh-CN", "system", "", "fr_fr"] {
assert_eq!(
LangChoice::from_setting(Some(bogus)),
LangChoice::System,
"{bogus}"
);
}
assert_eq!(LangChoice::System.setting_value(), None, "跟随系统不写键");
for choice in LangChoice::ALL {
assert_eq!(
LangChoice::from_setting(choice.setting_value()),
*choice,
"{} 落盘后读不回来",
choice.setting_value().unwrap_or("<跟随系统>")
);
}
assert_eq!(
LangChoice::ALL,
&[
LangChoice::System,
LangChoice::Fixed(Lang::ZhCn),
LangChoice::Fixed(Lang::En)
][..]
);
}
#[test]
fn choice_resolution_truth_table() {
// D28 的四级优先级,**逐档逐源**列出来:手选 > 系统 > 环境变量 > 英文。
// 抓得住:优先级顺序被改(例如手选的英文被中文系统盖掉)、或「跟随系统」直接落英文。
// 抓不住:这些源在真机上读到什么(`SystemLocale` 的真机行为在 `app-iced` 侧)。
struct Named(&'static str, Option<&'static str>);
impl LocaleSource for Named {
fn locale(&self) -> Option<String> {
self.1.map(str::to_owned)
}
fn label(&self) -> &'static str {
self.0
}
}
let registry_zh = Named("registry", Some("zh-CN"));
let registry_en = Named("registry", Some("en-US"));
let nothing = Named("registry", None);
let env_zh = Named("env", Some("zh_CN.UTF-8"));
let env_en = Named("env", Some("en_US.UTF-8"));
let env_none = Named("env", None);
let follow = LangChoice::System;
let en_choice = LangChoice::Fixed(Lang::En);
let zh = LangChoice::Fixed(Lang::ZhCn);
// (档位, 平台源, 环境变量源) -> (生效语言, 来源)
let table: &[(LangChoice, &dyn LocaleSource, &dyn LocaleSource, Lang, &str)] = &[
// ① 手选压过一切(系统是中文、环境是中文,手选英文就画英文)
(en_choice, &registry_zh, &env_zh, Lang::En, ORIGIN_SETTING),
(zh, &registry_en, &env_en, Lang::ZhCn, ORIGIN_SETTING),
// ② 跟随系统 → 系统源
(follow, &registry_zh, &env_en, Lang::ZhCn, "registry"),
(follow, &registry_en, &env_zh, Lang::En, "registry"),
// ③ 系统源取不到 → 环境变量
(follow, &nothing, &env_zh, Lang::ZhCn, "env"),
(follow, &nothing, &env_en, Lang::En, "env"),
// ④ 都没有 → 英文 + default(D28:纯英文环境不用管)
(follow, &nothing, &env_none, Lang::En, ORIGIN_DEFAULT),
];
for (choice, platform, env, lang, origin) in table {
let detection = choice.resolve(*platform, *env);
assert_eq!(
(detection.lang, detection.origin),
(*lang, *origin),
"{choice:?} platform={:?} env={:?}",
platform.locale(),
env.locale()
);
}
// 手选档的 `raw` 是配置值(日志一眼看出「用户手选的」),跟随系统档的 `raw` 是系统原文。
assert_eq!(
zh.resolve(&registry_en, &env_en).describe(),
"lang=zh-CN locale=zh_cn source=setting"
);
assert_eq!(
follow.resolve(&registry_zh, &env_en).describe(),
"lang=zh-CN locale=zh-CN source=registry"
);
}
#[test]
fn english_never_needs_a_chinese_font_and_chinese_always_resolves_one() {
// D28 字体那一级的真值表(`font::decide` 是纯函数,这里把语言 × 系统字体库全组合扫一遍):
// 抓得住:①英文环境被接上中文字体/下载(「零额外体积/零网络」的直接违约);②中文环境扫不到
// 字体却不给下载信号(会白块);③扫到了却不用系统字体(会白下 20 MB)。
// 抓不住:本机到底装没装这些族(`app-iced` 的 `fonts/cjk.rs` 把真字体库喂进来)。
use crate::font::{self, Strategy, CJK_FAMILIES, NOTO_SANS_SC};
let has_font =
|families: &'static [&'static str]| move |family: &str| families.contains(&family);
for installed in [
has_font(&[]),
has_font(&["Segoe UI"]),
has_font(CJK_FAMILIES),
] {
assert_eq!(
font::decide(Lang::En, &installed),
Strategy::NotNeeded,
"英文环境不许要中文字体"
);
}
assert_eq!(
font::decide(Lang::ZhCn, &has_font(&["Segoe UI"])),
Strategy::NeedsDownload(&NOTO_SANS_SC)
);
let first = CJK_FAMILIES[0];
let installed = has_font(&["Segoe UI", "Microsoft YaHei UI"]);
assert_eq!(first, "Microsoft YaHei UI");
assert_eq!(
font::decide(Lang::ZhCn, &installed),
Strategy::System { family: first }
);
}
#[test]
fn every_choice_has_a_label_in_both_catalogs() {
// 抓得住:设置页语言行接了一个 catalog 里不存在的 key(画出来是 `⟪missing:…⟫`)。
// 抓不住:译文翻得对不对(金表 + `chinese_catalog_is_actually_chinese` 管那一层)。
let mut keys = Vec::new();
for choice in LangChoice::ALL {
let key = choice.label_key();
for lang in Lang::ALL {
assert!(
lang.catalog().get(key).is_some(),
"{key:?} 在 {} 目录里缺词",
lang.tag()
);
}
keys.push(key);
}
keys.sort_unstable();
keys.dedup();
assert_eq!(keys.len(), LangChoice::ALL.len(), "两档用了同一个 key");
}
}