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

+4
View File
@@ -34,6 +34,7 @@
//! | [`nav`] / [`instances`] / [`loader`] / [`launch`] / [`font`] / [`core_text`] | 页面与运行时文案 | 各自页面 |
//! | [`download`] / [`logs`] | 下载页 / 日志页(M94 / M95) | 那两页 |
//! | `settings/`(`general` / `appearance` / `java` / `download` / `ai`) | 设置页,**一个分组一个文件** | 设置页四条线 |
//! | [`general`] | 设置页 General 分组的**真控件**文案(语言选择器三档 + 主题/启动页/关闭三行) | M106 `I18NFOLLOW`(挂在链尾,既有顺序不动) |
//!
//! ### 域文件的形状(加 key 时照抄)
//!
@@ -115,6 +116,7 @@ mod agent;
mod core_text;
mod download;
mod font;
mod general;
mod instances;
mod launch;
mod loader;
@@ -132,6 +134,7 @@ use agent::agent_entries;
use core_text::core_text_entries;
use download::download_entries;
use font::font_entries;
use general::general_entries;
use instances::instances_entries;
use launch::launch_entries;
use loader::loader_entries;
@@ -158,6 +161,7 @@ nav_entries! {
download_entries,
logs_entries,
agent_entries,
general_entries,
finish,
{}
}
+60
View File
@@ -0,0 +1,60 @@
//! 设置页 General 分组**真控件**的文案(M106 `I18NFOLLOW`):语言选择器 + 其余三行。
//!
//! ## 与 `catalog/settings/general.rs` 的分工
//!
//! `catalog/settings/general.rs`(M99 建的)只有两条**标题**([`SettingsLanguage`] /
//! [`SettingsLanguageHint`]),是设置页五域装配的一部分;本文件是 M106 接真控件时新增的
//! **选择器文案**:三档选项名、选中后的「界面当前使用」回显,以及 General 分组里另外三行
//! (主题 / 启动页 / 关闭按钮)。
//!
//! 为什么要分两个文件:`catalog.rs` 的装配链是**追加**式的,新域挂在链尾就不会动到既有
//! `Key` 的顺序(= `catalog/golden.rs` 那张逐字节金表)。两个文件同属 General 分组,读代码时
//! 按「标题 vs 选项」区分即可;真要合并也是一个文件搬十行的活,不急着在接线的 mission 里做。
//!
//! ## 选项名为什么不是「语言自称」
//!
//! 常见做法是三档都写 `简体中文` / `English` 这样的自称。这里没有:`catalog/tests.rs` 有一条
//! `chinese_catalog_is_actually_chinese`(英文表与中文表逐 key 必须不同),自称会让「中文表里
//! 写着 English」被判成漏译。所以选项名**跟着界面语言走**:中文界面「跟随系统 / 简体中文 / 英文」,
//! 英文界面 "Follow system / Chinese (Simplified) / English"——这也是多数启动器的做法。
//!
//! 域文件的形状见 [`crate::catalog`] 的「域装配」一节。
macro_rules! general_entries {
($next:ident, $($rest:ident,)* { $($acc:tt)* }) => {
$next! { $($rest,)* {
$($acc)*
// ── General 分组(语言选择器 + 其余三行)
SettingsLanguageFollowSystem => ("Follow system", "跟随系统"),
SettingsLanguageZhCn => ("Chinese (Simplified)", "简体中文"),
SettingsLanguageEn => ("English", "英文"),
SettingsLanguageEffective => ("Interface right now: {language}", "界面当前使用:{language}"),
SettingsTheme => ("Theme", "主题"),
SettingsThemeFollowSystem => ("Follow system", "跟随系统"),
SettingsStartupPage => ("Startup page", "启动页"),
SettingsStartupInstances => ("Instances", "实例"),
SettingsCloseButton => ("Close button", "关闭按钮"),
SettingsCloseToTray => ("Exit to tray", "退出到托盘"),
// 设置页页头(M106):标题复用 `NavSettings`,这三条是副标题与两个动作。
SettingsSubtitle => ("Launcher behaviour, Java runtimes and download sources", "启动器行为、Java 运行时与下载源"),
SettingsResetSection => ("Reset section", "重置本组"),
SettingsSave => ("Save", "保存"),
// 第五条导航项(M106 给导航轨补的最后一条)。放在本域而不是 `catalog/nav.rs`:
// 金表按**位置**逐字节比,插进 nav 域会把既有 4 条导航行整体平移一位。
NavAi => ("Assistant", "助手"),
// 设置页左列的分组名(M106)。`Group.name` 是**id**(测试按它认组),显示名走这里。
SettingsGroupGeneral => ("General", "通用"),
SettingsGroupAppearance => ("Appearance", "外观"),
SettingsGroupJava => ("Java", "Java 运行时"),
SettingsGroupDownloads => ("Downloads", "下载"),
// 逐实例覆盖的空态(M106)
SettingsOverridesEmptyTitle => ("No per-instance overrides", "没有逐实例覆盖"),
SettingsOverridesEmptyBody => ("Overrides let one instance keep its own Java runtime, memory or JVM flags while the rest of the launcher keeps the defaults above.", "逐实例覆盖让某个实例保留自己的 Java 运行时、内存或 JVM 参数,其余实例沿用上面的默认值。"),
SettingsOverridesAdd => ("Add override", "新增覆盖"),
// `Route::Agent` 的回退卡正文(M106:`app/view.rs` 里最后一条硬编码英文)
AgentFallbackBody => ("The assistant page is not built yet. Chat lives in the docked panel once the AI mission lands; this screen is the M68 skeleton fallback.", "助手页还没做。AI 接线落地后对话会出现在右侧停靠面板;这一屏是 M68 的骨架回退。"),
} }
};
}
pub(super) use general_entries;
+24
View File
@@ -124,4 +124,28 @@ pub(super) const GOLDEN: &[(&str, &str, &str)] = &[
("AgentRoleYou", "You", "你"),
("AgentRoleAssistant", "Assistant", "助手"),
("AgentRoleNotice", "Notice", "提示"),
// ── M106 `I18NFOLLOW`:General 分组真控件(语言选择器 + 其余三行)。**只追加在链尾**,
// 上一行到 `LogsEmptyAction` 的既有顺序一字未动(金表要证明的正是这件事)。
("SettingsLanguageFollowSystem", "Follow system", "跟随系统"),
("SettingsLanguageZhCn", "Chinese (Simplified)", "简体中文"),
("SettingsLanguageEn", "English", "英文"),
("SettingsLanguageEffective", "Interface right now: {language}", "界面当前使用:{language}"),
("SettingsTheme", "Theme", "主题"),
("SettingsThemeFollowSystem", "Follow system", "跟随系统"),
("SettingsStartupPage", "Startup page", "启动页"),
("SettingsStartupInstances", "Instances", "实例"),
("SettingsCloseButton", "Close button", "关闭按钮"),
("SettingsCloseToTray", "Exit to tray", "退出到托盘"),
("SettingsSubtitle", "Launcher behaviour, Java runtimes and download sources", "启动器行为、Java 运行时与下载源"),
("SettingsResetSection", "Reset section", "重置本组"),
("SettingsSave", "Save", "保存"),
("NavAi", "Assistant", "助手"),
("SettingsGroupGeneral", "General", "通用"),
("SettingsGroupAppearance", "Appearance", "外观"),
("SettingsGroupJava", "Java", "Java 运行时"),
("SettingsGroupDownloads", "Downloads", "下载"),
("SettingsOverridesEmptyTitle", "No per-instance overrides", "没有逐实例覆盖"),
("SettingsOverridesEmptyBody", "Overrides let one instance keep its own Java runtime, memory or JVM flags while the rest of the launcher keeps the defaults above.", "逐实例覆盖让某个实例保留自己的 Java 运行时、内存或 JVM 参数,其余实例沿用上面的默认值。"),
("SettingsOverridesAdd", "Add override", "新增覆盖"),
("AgentFallbackBody", "The assistant page is not built yet. Chat lives in the docked panel once the AI mission lands; this screen is the M68 skeleton fallback.", "助手页还没做。AI 接线落地后对话会出现在右侧停靠面板;这一屏是 M68 的骨架回退。"),
];
+258
View File
@@ -0,0 +1,258 @@
//! 设置页的**界面语言档位**(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");
}
}
+3
View File
@@ -7,6 +7,7 @@
//! | 模块 | 职责 | 不负责 |
//! |---|---|---|
//! | [`lang`] | `Lang`(en / zh-CN)+ locale 探测(源可注入)+ **四级优先级** [`lang::resolve`] | 读注册表 / 读配置 / spawn 进程 |
//! | [`choice`] | `LangChoice`(跟随系统 / 手选):设置页那一档 + 它到生效语言的映射 | 落盘(那是 `presenter::settings_bridge`) |
//! | [`catalog`] | `Key` 枚举 + 中英两套常量表 + 取词 / 占位符渲染 + 缺译诊断 | 排版、字号、断行 |
//! | [`font`] | 中文环境扫系统 CJK 字体的**判据** + 缺字体时的「需下载」信号 + 注入式下载器接口 | 联网、写文件、装字体 |
//! | [`bridge`] | core / presenter 的状态串 → `Key` 的**示例契约**(文案边界规则全文在这里) | 改 core 的产出 |
@@ -35,8 +36,10 @@
pub mod bridge;
pub mod catalog;
pub mod choice;
pub mod font;
pub mod lang;
pub use catalog::{Catalog, Key};
pub use choice::LangChoice;
pub use lang::Lang;