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

+70 -3
View File
@@ -20,6 +20,7 @@ use std::time::Instant;
use iced::window;
use unlucky_design::color::SchemeFlavor;
use unlucky_i18n::Lang;
use unlucky_presenter::backend::{build_backend, config_path, AppOptions};
use unlucky_presenter::dto::Backend;
use unlucky_presenter::prefs::ThemeMode;
@@ -30,6 +31,7 @@ use unlucky_presenter::toast::ToastKind;
use crate::cli::Options;
use crate::pages;
use crate::theme::{Families, Ui};
use crate::ui::lang::LangState;
use super::WINDOW_SIZE;
@@ -58,6 +60,11 @@ pub(super) const IDLE_TICK: std::time::Duration = std::time::Duration::from_secs
/// 3 秒够真 host 把「命令已受理」的第一条事件交回来;之后由「有 Busy/Running 实例」这个条件续订。
pub(super) const WATCH_WINDOW: std::time::Duration = std::time::Duration::from_secs(3);
/// 设置页左侧分组的取证开关:`ULL_SETTINGS_GROUP=0` 直达 General(语言行在那里)。
///
/// 只影响 `--screenshot` 那一次进程(见 [`App::with_presenter`]);交互模式按用户点的那一组。
pub(super) const SETTINGS_GROUP_ENV: &str = "ULL_SETTINGS_GROUP";
/// 进程内「run() 开始」的时刻(首帧耗时基线)。
pub(super) static STARTED: OnceLock<Instant> = OnceLock::new();
/// 首帧日志只打一次。
@@ -86,6 +93,11 @@ pub struct App {
/// 用来定优先级:`--theme` > 配置文件 > 系统主题。没有它,`Message::SystemTheme` 会无条件
/// 盖掉配置文件的显式主题(M90 r3 修)。
pub(super) theme_mode: ThemeMode,
/// 界面语言(M106):用户档位 + 探测结论 + 目录选择。见 [`crate::ui::lang`]。
///
/// 为什么不在 `Ui` 里:`theme.rs` 是令牌求值器(颜色/字号),而语言是**运行期状态**——
/// 随设置变、要落盘、要重建视图模型(切主题只重建 Style)。
pub(super) lang: LangState,
/// 上一次推进节拍的时刻(节拍由帧事件驱动,按真实流逝时间累加)。
pub(super) last_tick: Instant,
/// 窗口宽度(布局档位的判据;交互模式跟着 resize 事件走)。
@@ -188,17 +200,33 @@ impl App {
// 面板 α / 玻璃的降级判据要的是**实际**后端,不是 `--renderer` 的请求值(复审 r1 P2-2):
// 默认 `Auto` + 无独显时请求是 Auto、实际是 tiny-skia,只看请求值就会漏掉 D36 的强制项。
let backend_tier = super::backend::effective_backend(options.backend);
let appearance = pages::appearance::State::new(
let mut appearance = pages::appearance::State::new(
&appearance_prefs,
&appearance_base,
backend_tier,
flavor,
);
let view = pages::Model::new(
// 取证开关(D30,与 `ULL_START_ROUTE` 同一条思路):`ULL_SETTINGS_GROUP=<下标>` 选设置页的
// 左侧分组。设置页出厂停在「外观」(`APPEARANCE_GROUP`),而「语言」那一行在 General 组——
// 出图必须能直达,否则验收图里根本没有用户抱怨的那一行。**只在截图通道生效**:交互模式
// 一律按用户点的那一组。
if options.screenshot.is_some() {
if let Some(index) = std::env::var(SETTINGS_GROUP_ENV)
.ok()
.and_then(|raw| raw.trim().parse::<usize>().ok())
{
appearance.selected_group = index;
}
}
// 界面语言(M106):**M92 已经探测对了,问题是一直没人用**——`pages::Model.lang` 此前在
// `view::model` 里被写死成 `Lang::En`,于是中文系统上开出来的是全英文界面。
let lang = LangState::boot(&bridge, options.screenshot.is_some());
let mut view = pages::Model::new(
pages::Screen::Instances,
width,
Some(pages::Screen::Instances.nav()),
);
view.lang = lang.lang();
let mut app = Self {
ui: Ui::new(flavor, families),
@@ -210,6 +238,7 @@ impl App {
ticks: 0,
config_due_at: unlucky_presenter::theme::CONFIG_POLL_TICKS,
theme_mode,
lang,
last_tick: Instant::now(),
width,
view,
@@ -232,6 +261,10 @@ impl App {
};
// 配色来源档可能已经指向一张背景图:那时 `Ui` 必须由解析出的方案构造,而不是 baseline。
app.rebuild_ui();
// 两行日志:①档位 + 探测结论 + 生效语言 ②中文策略(D28 缺字体不许静默)。
// 下一个人据此分得清「探测错了」还是「探测对了但没用上」。
app.lang.log();
app.lang.log_cjk();
app
}
@@ -295,7 +328,8 @@ impl App {
// 否则会出现「选项区英文、取消弹窗中文」的同屏两语(复审 P2-2)。
if self.state.install_cancelled != self.install_cancelled_seen {
self.install_cancelled_seen = self.state.install_cancelled;
let text = pages::cancelled_toast(super::view::ui_lang());
// M106:提示语跟着**生效语言**走(M105 留的 `view::ui_lang()` 占位到此收口)。
let text = pages::cancelled_toast(self.lang());
self.state.toasts.push(ToastKind::Info, text);
}
@@ -307,12 +341,14 @@ impl App {
let draft = self.download_draft.clone();
// 右栗占掉的宽度先扣掉:面板展开时页面按更窄的宽度排版(「收缩而不是溢出」,M104)
let width = self.agent.content_width(self.width);
// M106:语言形参跟着 `LangState` 走(`draft` 那一位是 M105 的安装草稿)。
self.view = super::view::model(
&self.state,
width,
self.options.force_state,
category,
&draft,
self.lang(),
);
self.view_dirty = false;
self.refreshes += 1;
@@ -342,6 +378,37 @@ impl App {
pub(super) fn ui(&self) -> &Ui {
&self.ui
}
/// 当前生效的界面语言(= 目录选择;页面文案一律由它派生)。
pub(super) fn lang(&self) -> Lang {
self.lang.lang()
}
/// 设置页语言行要的两件:档位(画选中态)+ 生效语言(画回显)。
pub(super) fn language(&self) -> pages::Language {
self.lang.for_settings()
}
/// 换界面语言(设置页语言行 → `pages::Message::Language`)。
///
/// 三件事缺一不可:①档位与结论换掉 ②视图模型重建(`view_dirty` + `refresh_view`,目录是
/// 视图模型的一部分)③落盘(重启后保持所选语言)。字体不在这里重扫:中文档缺字体时的
/// 判据/信号归 `i18n::font::decide` + `fonts/cjk.rs`(见报告「没验的部分」)。
pub(super) fn set_language(&mut self, choice: unlucky_i18n::LangChoice) {
if choice == self.lang.choice() {
return;
}
self.lang = LangState::of(choice);
self.lang.log();
if !self.bridge.persist_ui_language(choice.setting_value()) {
eprintln!(
"[i18n] 语言偏好没落盘(配置只读或数据根解析不出来):本次生效,重启会回到旧值"
);
}
self.lang.log_cjk();
self.view_dirty = true;
self.refresh_view();
}
}
/// 启动参数 → presenter 的后端选项。