From 954a129e01cd19b5241037e12ff0071bea72ff56 Mon Sep 17 00:00:00 2001 From: AskaEth Date: Wed, 4 Nov 2026 18:29:43 +0800 Subject: [PATCH] =?UTF-8?q?fix(app-iced):=20M106=20r2=20=E2=80=94=E2=80=94?= =?UTF-8?q?=20=E4=B8=A4=E5=A4=84=E3=80=8C=E6=94=B9=E4=BA=86=E8=A1=8C?= =?UTF-8?q?=E4=B8=BA=E3=80=81=E6=97=A7=E5=8F=A3=E5=BE=84=E8=BF=98=E5=9C=A8?= =?UTF-8?q?=E6=A0=91=E9=87=8C=E3=80=8D=EF=BC=88=E5=A4=8D=E5=AE=A1=20p2-1?= =?UTF-8?q?=20/=20p2-2=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit p2-1(它在撒谎):`renderer/acceptance/locale.rs::Chain::line()` 还打 `ui_text=pinned-en`, 注释写着「pages/mod.rs::render 里下载页固定传 Lang::En」、`setting` 回退 `<未接线>`——M106 之后 两句同时为假,于是 `ULL_ACCEPTANCE=1` 时启动日志两行互相矛盾(旧行说「文案钉在英文」,新行说 「生效中文」)。修法:`Chain` 增 `machine`(只看平台源 + 环境变量,供 `[env] language=` 用)与 `detection`(含 `ui.language` 手选,与 App 的 `[i18n] choice=…` 同源);`host()` 真读 `SettingsBridge::load_ui_language()`,`line()` 删掉 `ui_text`、`setting` 打真实值。 实测三行现在一致: [i18n] setting=<未设置=跟随系统> platform=registry:zh-CN env=env:<未设置> -> lang=zh-CN locale=zh-CN source=registry [i18n] choice=system lang=zh-CN locale=zh-CN source=registry effective=zh-CN `locale_tests.rs` 把钉假串的那条改成「不许出现 pinned-en」的回归绊线,并新增 `i18n_line_honours_the_explicit_setting_without_rewriting_the_machine_language`。 p2-2(doc-only):`pages/model.rs` 的「只是留口,产品路径先给 Lang::En」与 `pages/download.rs` 的「产品路径现传 Lang::En,接上探测结果时只改调用点」都已被实际代码推翻(`app/state.rs` + `refresh_view` 在填、调用点已是 `model.lang`),按事实重写。顺带修掉 `fonts/cjk.rs` 里同样的 「设置层的读取点尚未接线」。 门禁:`cargo fmt --all -- --check` 退出码 0;`cargo test -p unlucky-app-iced --lib` 退出码 0 (409 passed, 0 failed)。全改动文件 ≤400 行(最大 269)。 --- crates/app-iced/src/app/boot/diagnostics.rs | 2 +- crates/app-iced/src/app/state.rs | 56 ++------- .../src/app/update/download_wiring.rs | 10 +- .../app-iced/src/app/update/page_messages.rs | 45 +++++++ crates/app-iced/src/app/view.rs | 21 +--- crates/app-iced/src/fonts/cjk.rs | 6 +- crates/app-iced/src/pages/download.rs | 4 +- crates/app-iced/src/pages/model.rs | 7 +- crates/app-iced/src/pages/settings/mod.rs | 9 +- .../src/renderer/acceptance/locale.rs | 86 +++++++++++--- .../src/renderer/acceptance/locale_tests.rs | 112 +++++++++++++++++- .../app-iced/src/renderer/acceptance/mod.rs | 6 +- crates/app-iced/src/shell/mod.rs | 4 + crates/app-iced/src/ui/lang.rs | 25 +++- 14 files changed, 286 insertions(+), 107 deletions(-) diff --git a/crates/app-iced/src/app/boot/diagnostics.rs b/crates/app-iced/src/app/boot/diagnostics.rs index 9ac2c7f..071fb7b 100644 --- a/crates/app-iced/src/app/boot/diagnostics.rs +++ b/crates/app-iced/src/app/boot/diagnostics.rs @@ -64,7 +64,7 @@ pub(super) fn report_boot(options: &Options, probe: &Probe, decision: &Decision) } acceptance::report_backend_plan(decision, options.backend); if acceptance::enabled() { - acceptance::report_startup(probe, options.scale_factor); + acceptance::report_startup(probe, options.scale_factor, options.screenshot.is_some()); } } diff --git a/crates/app-iced/src/app/state.rs b/crates/app-iced/src/app/state.rs index b53c2f6..d50a12c 100644 --- a/crates/app-iced/src/app/state.rs +++ b/crates/app-iced/src/app/state.rs @@ -20,7 +20,6 @@ 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; @@ -60,11 +59,6 @@ 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 = OnceLock::new(); /// 首帧日志只打一次。 @@ -206,17 +200,11 @@ impl App { backend_tier, flavor, ); - // 取证开关(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::().ok()) - { - appearance.selected_group = index; - } + // 取证开关(D30):`ULL_SETTINGS_GROUP=<下标>` 直达设置页的某一组(出图要看见语言行)。 + if let Some(index) = + crate::ui::lang::screenshot_settings_group(options.screenshot.is_some()) + { + appearance.selected_group = index; } // 界面语言(M106):**M92 已经探测对了,问题是一直没人用**——`pages::Model.lang` 此前在 // `view::model` 里被写死成 `Lang::En`,于是中文系统上开出来的是全英文界面。 @@ -328,7 +316,8 @@ impl App { // 否则会出现「选项区英文、取消弹窗中文」的同屏两语(复审 P2-2)。 if self.state.install_cancelled != self.install_cancelled_seen { self.install_cancelled_seen = self.state.install_cancelled; - // M106:提示语跟着**生效语言**走(M105 留的 `view::ui_lang()` 占位到此收口)。 + // M106:提示语跟着**生效语言**走(M105 留的 `view::ui_lang()` 占位已删——它恒英文, + // 中文界面会弹出英文提示,那正是 M105 r1 复审 P2-2 要堵的同屏两语)。 let text = pages::cancelled_toast(self.lang()); self.state.toasts.push(ToastKind::Info, text); } @@ -378,37 +367,6 @@ 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 的后端选项。 diff --git a/crates/app-iced/src/app/update/download_wiring.rs b/crates/app-iced/src/app/update/download_wiring.rs index 30b262f..cc6ade4 100644 --- a/crates/app-iced/src/app/update/download_wiring.rs +++ b/crates/app-iced/src/app/update/download_wiring.rs @@ -180,7 +180,8 @@ fn cancelling_gives_the_panel_back_to_the_install_options() { /// /// 抓得住:谁把中文写回 presenter(类型上已经做不到——它没有 i18n 依赖)、 /// 或者 app 层每次 `refresh_view` 都重复弹(下面的 `items().len() == 1`)。 -/// 抓不住:`Lang` 本身接得对不对(`view::ui_lang()` 现恒为英文,那是 M106 的范围)。 +/// 抓不住:`Lang` 本身接得对不对(M106 起语言来自 `App::lang()`,本测试**显式钉**成英文, +/// 否则这条断言会跟着跑测试那台机器的 locale 变——那是假绿)。 #[test] fn cancelling_announces_once_in_the_page_language() { let log: Rc>> = Rc::new(RefCell::new(Vec::new())); @@ -198,6 +199,11 @@ fn cancelling_announces_once_in_the_page_language() { ); assert!(app.state.toasts.is_empty(), "出厂态不该有 Toast"); + // M106:语言显式钉成英文。不钉的话「页面语言」就是跑测试那台机器的 locale(英文 CI 绿、 + // 中文机器红)——那种测试证明不了任何事。钉住之后期望值仍从目录取,守的是同一条原意: + // **提示语来自 i18n 目录,不是 presenter 写死的中文**。 + app.set_language(unlucky_i18n::LangChoice::Fixed(unlucky_i18n::Lang::En)); + app.state.apply(AppEvent::Core( unlucky_presenter::dto::CoreEvent::InstallCancelled, )); @@ -207,7 +213,7 @@ fn cancelling_announces_once_in_the_page_language() { let text = items[0].text.clone(); assert_eq!( text, - pages::cancelled_toast(super::super::view::ui_lang()), + pages::cancelled_toast(unlucky_i18n::Lang::En), "提示语必须来自 i18n 目录(不是 presenter 写死的中文)" ); assert!( diff --git a/crates/app-iced/src/app/update/page_messages.rs b/crates/app-iced/src/app/update/page_messages.rs index 1aa0f78..ad442d8 100644 --- a/crates/app-iced/src/app/update/page_messages.rs +++ b/crates/app-iced/src/app/update/page_messages.rs @@ -19,8 +19,20 @@ //! - 抓不住:加一个 `pages::Action` 变体 —— 它落进 `Action(action)` 的占位分支(打印一行 //! `[page] 占位动作 …`),编译期不会报。这是占位期的既有设计,不是本文件能守的。 +use unlucky_i18n::{Lang, LangChoice}; + +use crate::pages; +use crate::ui::lang::LangState; + use super::*; +// --------------------------------------------------------------- 界面语言(M106) +// +// 这三个方法住在本文件而不是 `app/state.rs`:`state.rs` 贴着 400 行硬线(M104 的 `agent`、 +// M105 的 `download_draft` 都往那儿长),而 `set_language` 的唯一调用点就是本文件的 +// `pages::Message::Language` 分支——落点与调用点放一起,读的人不用来回跳。 +// 与 M93 把 appearance 那批 `App` 方法放进 `app/appearance.rs` 是同一手法。 + impl App { /// 页面消息:导航走路由栈,真业务命令派发,纯视图态改本地呈现。 pub(super) fn on_page(&mut self, message: pages::Message) -> Task { @@ -72,6 +84,39 @@ impl App { } } +impl App { + /// 当前生效的界面语言(= 目录选择;页面文案一律由它派生)。 + pub(in crate::app) fn lang(&self) -> Lang { + self.lang.lang() + } + + /// 设置页语言行要的两件:档位(画选中态)+ 生效语言(画回显)。 + pub(in crate::app) fn language(&self) -> pages::Language { + self.lang.for_settings() + } + + /// 换界面语言(设置页语言行 → `pages::Message::Language`)。 + /// + /// 三件事缺一不可:①档位与结论换掉 ②视图模型重建(`view_dirty` + `refresh_view`,目录是 + /// 视图模型的一部分)③落盘(重启后保持所选语言)。字体不在这里重扫:中文档缺字体时的 + /// 判据/信号归 `i18n::font::decide` + `fonts/cjk.rs`(见报告「没验的部分」)。 + pub(in crate::app) fn set_language(&mut self, choice: 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(); + } +} + #[cfg(test)] mod tests { use unlucky_design::color::SchemeFlavor; diff --git a/crates/app-iced/src/app/view.rs b/crates/app-iced/src/app/view.rs index ef7e502..72e2981 100644 --- a/crates/app-iced/src/app/view.rs +++ b/crates/app-iced/src/app/view.rs @@ -97,16 +97,6 @@ impl App { // --------------------------------------------------------------- 快照 → 视图模型 -/// **测试用的英文基线**(M105 留的占位在这里收口)。 -/// -/// 产品路径的界面语言由 `App::lang()` 经 `model(…, lang)` 与 `pages::cancelled_toast(lang)` -/// 递进去(M106),这个函数只剩测试还在用(`app/update/download_wiring.rs` 的取消提示断言要一个 -/// 与产品无关的语言常量)。`#[cfg(test)]`:非测试构建里它没有调用点,留着就是死代码。 -#[cfg(test)] -pub(super) fn ui_lang() -> Lang { - Lang::En -} - /// 把 presenter 的快照转成页面视图模型(**唯一**的展示转换点)。 /// /// `forced` 是 `--force-state` 的落点(P3-1):它只影响**画出来的**状态层,不改任何状态机。 @@ -289,15 +279,8 @@ mod tests { /// 也抓不住不消费 `model.forced` 的裸 `iced::button`(设置页「外观」区就是)。 /// 测试用的薄包装:默认宽度 / 默认分类 / 空草稿。 fn at(state: &AppState, forced: Option) -> pages::Model { - let draft = pages::InstallDraft::default(); - model( - state, - 1180.0, - forced, - pages::Category::All, - &draft, - Lang::En, - ) + let (draft, lang) = (pages::InstallDraft::default(), Lang::En); + model(state, 1180.0, forced, pages::Category::All, &draft, lang) } #[test] diff --git a/crates/app-iced/src/fonts/cjk.rs b/crates/app-iced/src/fonts/cjk.rs index df0eed9..346f6f4 100644 --- a/crates/app-iced/src/fonts/cjk.rs +++ b/crates/app-iced/src/fonts/cjk.rs @@ -67,9 +67,9 @@ pub fn report(detection: Detection, faces: Option<&[Face]>) -> CjkReport { /// 探测本机:优先级见 [`unlucky_i18n::lang::resolve`]。 /// -/// `setting` 是配置里 `ui.language` 的原文(`zh_cn` / `en_us`);`None` = **还没读配置** -/// (设置层的读取点尚未接线,见模块文档的诚实边界)。注意 `None` 与 `Some("不认识的值")` -/// 效果相同:都不当显式选择,继续往下走。 +/// `setting` 是配置里 `ui.language` 的原文(`zh_cn` / `en_us`);`None` = **配置里没写过这个键** +/// (= 跟随系统,不是「没读」)。注意 `None` 与 `Some("不认识的值")` 效果相同:都不当显式选择, +/// 继续往下走。 pub fn probe_with(setting: Option<&str>, faces: Option<&[Face]>) -> CjkReport { report(lang::resolve(setting, &SystemLocale, &EnvLocale), faces) } diff --git a/crates/app-iced/src/pages/download.rs b/crates/app-iced/src/pages/download.rs index f5a245f..6e150b3 100644 --- a/crates/app-iced/src/pages/download.rs +++ b/crates/app-iced/src/pages/download.rs @@ -95,8 +95,8 @@ use super::{Action, Message}; /// 整页(**唯一**的真数据入口:`model` 由 `app/view.rs` 从 presenter 快照建)。 /// -/// `lang` 是 i18n 的入参:`theme::Ui` 还没有 language 字段(那条在 M93 手里),产品路径现传 -/// [`Lang::En`](D28 的英文基线零中文负担);接上探测结果时只改调用点,页面不用动。 +/// `lang` 是 i18n 的入参,调用点传的是 `pages::Model.lang`(M106 起 = `App` 的生效语言, +/// 见 `ui::lang::LangState`);页面自己不读全局、不猜语言。 pub fn view<'a>( ui: &'a Ui, metrics: FrameMetrics, diff --git a/crates/app-iced/src/pages/model.rs b/crates/app-iced/src/pages/model.rs index 30be09e..293d824 100644 --- a/crates/app-iced/src/pages/model.rs +++ b/crates/app-iced/src/pages/model.rs @@ -89,10 +89,11 @@ pub struct Model { pub download: Download, /// 日志页的数据(M95:presenter 快照 → 日志行 + 诊断结论,见 [`super::logs`])。 pub logs: Logs, - /// 界面语言(M95):页面文案走 i18n key,语言由 app 层决定后灌进来。 + /// 界面语言(M106):页面文案走 i18n key,语言由 app 层决定后灌进来。 /// - /// 这里只是**留口**:`theme.rs` 还没有「当前语言」的入口(那条线归 M93), - /// 产品路径先给 `Lang::En`;将来 `Ui` 拿到语言后,这个字段改由它填。 + /// 谁填:`app/state.rs` 的 `App::refresh_view`(`LangState::lang()` → `view::model(..)`), + /// 即「用户档位 / 系统 locale → 生效语言」这条链的结论。**不是**基线值:M95 以前这里是 + /// `Lang::En` 占位,M106 之后中文系统上灌进来的是 `Lang::ZhCn`。 pub lang: Lang, /// 取证用的强制交互态(`--force-state`):`Some` 时页面里的控件按该态画。 /// diff --git a/crates/app-iced/src/pages/settings/mod.rs b/crates/app-iced/src/pages/settings/mod.rs index a9ce8b7..d06d737 100644 --- a/crates/app-iced/src/pages/settings/mod.rs +++ b/crates/app-iced/src/pages/settings/mod.rs @@ -101,9 +101,12 @@ //! `app/view.rs` 不用动 —— **推荐这个形状**。 //! //! 顺带订正 r3 之前那句「在 `app/state.rs` 用真值初始化 `Groups.language_appearance`(1 个文件)」: -//! 今天 `App` / `app/state.rs` 里**没有**任何语言真值源(`presenter::prefs::AppearancePrefs` 无 -//! language 字段;唯一的值是 `app/view.rs:128` 里硬编码的 `Lang::En`),要先有真值源才谈得上 -//! 初始化位置。两种形状都是「谁先撞谁付一次钱」的一次性代价,不是永久性四路撞车。 +//! **M106 起语言已经有真值源**——`App::lang: LangState`(`crates/app-iced/src/ui/lang.rs`,用户档位 +//! `LangChoice` + 探测结论 `Detection`),由 `App::refresh_view` 经 `view::model(…, lang)` 灌进 +//! `pages::Model.lang`,设置页再从 `settings::view` 的 `language` 形参拿到它。所以下一条线要给某一组 +//! 读「当前语言」时,**不需要**再造一层状态:要么走 `Model.lang`(给 `settings::view` 加形参, +//! 与 M106 同形状),要么读 `Groups` 里本线的字段。两种形状都是「谁先撞谁付一次钱」的一次性代价, +//! 不是永久性四路撞车。 //! 5. **首帧取数 / 异步发起**(同上,r2 复审 P1 补)—— [`Body`] 是 //! `fn(&Ui, States) -> Option`,返回不了 `Task`;而且 `settings::view` → `pages::render` //! → `App::view` **一路都返回 `Element`**(iced 0.14 的 `view` 也只返回 `Element`,`view(&self)` diff --git a/crates/app-iced/src/renderer/acceptance/locale.rs b/crates/app-iced/src/renderer/acceptance/locale.rs index a0284f1..64c73b5 100644 --- a/crates/app-iced/src/renderer/acceptance/locale.rs +++ b/crates/app-iced/src/renderer/acceptance/locale.rs @@ -7,6 +7,7 @@ //! [`AppleLanguages`] 先落在这里。**它该搬去 `platform/locale.rs`**——两处并存会漂(见交接报告)。 use unlucky_i18n::lang::{self, Detection, LocaleSource}; +use unlucky_presenter::settings_bridge::SettingsBridge; use crate::Probe; @@ -83,21 +84,43 @@ pub fn tz() -> Option { /// 到底是「用户就是英文」还是「一个源都没取到」根本分不出来。 #[derive(Debug, Clone, PartialEq, Eq)] pub struct Chain { - /// `ui.language` 的显式值(当前没有读取点,恒 `None`)。 + /// `ULL_LANG` 的显式覆盖(截图矩阵的注入值);`None` = 没设。 + /// + /// 它与 [`Chain::setting`] 是**两个不同的源**:一个是我们为了出图钉的值,一个是用户配置里存的。 + /// 判定顺序里前者压后者(见 [`Chain::read`]),日志里分开打,读的人不用猜。 + pub forced: Option, + /// `ui.language` 的显式值(`None` = 文件里**没写过** = 跟随系统)。 + /// + /// 与 `App` 启动判定同一个读法(`SettingsBridge::load_ui_language`:只认显式值,不走 schema + /// 默认值),所以「没写过」在这里与 `ui::lang` 里是同一个意思。 pub setting: Option, /// (源自称的名字, 原始 locale)。 pub platform: (&'static str, Option), /// (源自称的名字, 原始 locale)。 pub env: (&'static str, Option), - /// 结论(由 [`lang::resolve`] 算,不在本模块重写一遍优先级)。 + /// 只按**平台源 + 环境变量**算出来的「这台机器说什么语言」(**不看** `ui.language`)。 + /// + /// `[env] language=` 用的是它:那一行回答的是「本机是什么」,用户手选英文不该改写机器的事实。 + pub machine: Detection, + /// 最终结论:**与 `App` 启动日志的 `[i18n] choice=…` 用同一个判据函数** + /// (`ui::lang::choose_choice` + `LangChoice::resolve`),所以两行不可能说的是两种语言。 pub detection: Detection, + /// 这一次是不是**截图通道**(D30):`true` 时配置那一级按设计被忽略,日志里打出来, + /// 否则「配置写了英文、结论是中文」看起来像 bug。 + pub screenshot: bool, } impl Chain { - /// 用三个源探测一次。**每个源只读一次**(`defaults` / `reg.exe` 都是子进程)。 + /// 用全部四个源探测一次。**每个源只读一次**(`defaults` / `reg.exe` 都是子进程)。 + /// + /// 优先级**不在这里重写**:档位由 [`crate::ui::lang::choose_choice`] 出(与 `App` 启动判定 + /// 同一个函数),结论由 `LangChoice::resolve` 出(与 `App` 同一个 `resolve`)。 + /// 顺序即 **`ULL_LANG` > 配置(`screenshot == false` 才算) > 平台源 > 环境变量 > 英文**。 #[must_use] pub fn read( + forced: Option<&str>, setting: Option<&str>, + screenshot: bool, platform: &dyn LocaleSource, env: &dyn LocaleSource, ) -> Self { @@ -109,45 +132,70 @@ impl Chain { label: env.label(), raw: env.locale(), }; - // 优先级仍由 `resolve` 单点决定:这里把**已读到的值**包成同标签的源喂回去, - // 不复制一遍 if/else(复制就会漂)。 - let detection = lang::resolve(setting, &platform, &env); + // 「机器说什么语言」:与 `ui.language` 无关,`[env] language=` 用它。 + let machine = lang::resolve(None, &platform, &env); + // 「这一次界面用什么语言」:**调产品路径那个函数**,不在这里复制一遍优先级表。 + let detection = + crate::ui::lang::choose_choice(forced, setting, screenshot).resolve(&platform, &env); Self { + forced: forced.map(str::to_owned), setting: setting.map(str::to_owned), platform: (platform.label, platform.raw), env: (env.label, env.raw), + machine, detection, + screenshot, } } - /// 本机(平台源 + 环境变量;配置未接线)。 + /// 本机链路(`ULL_LANG` + 配置 + 平台源 + 环境变量)。 + /// + /// `screenshot` 由调用方从 `Options` 递进来(`report_boot` 手上有):**截图通道按 D30 忽略配置**, + /// 这一级判断必须与产品路径同源,否则又会出现「一行说 zh-CN、下一行说 effective=en」。 + /// + /// 诚实边界:这里 `SettingsBridge::open()` 走**默认数据根**;同一次运行若带了 `--data-dir`, + /// 配置可能来自另一份文件(此时以 `App` 那行 `[i18n] choice=…` 为准,它用的是 App 自己的桥)。 #[must_use] - pub fn host() -> Self { - Self::read(None, platform_locale_source(), &lang::EnvLocale) + pub fn host(screenshot: bool) -> Self { + let forced = std::env::var(crate::ui::lang::LANG_ENV) + .ok() + .filter(|value| !value.trim().is_empty()); + let setting = SettingsBridge::open().load_ui_language(); + Self::read( + forced.as_deref(), + setting.as_deref(), + screenshot, + platform_locale_source(), + &lang::EnvLocale, + ) } - /// 结论语言标签(`zh-CN` / `en`)。 + /// **机器的**语言标签(`zh-CN` / `en`)——不含 `ui.language` 的手选。 #[must_use] pub fn language(&self) -> &'static str { - self.detection.lang.tag() + self.machine.lang.tag() } - /// `[i18n]` 一行:四级链 + 结论。 + /// `[i18n]` 一行:四个源 + 结论。 /// - /// `ui_text=pinned-en` 是**今天的事实**:`pages/mod.rs::render` 里下载页固定传 `Lang::En`、 - /// 日志页传 `model.lang`(默认也是 `En`),也就是**文案还没接这条结论**。不写这一格的话, - /// 日志会让人以为「语言已经是中文了」——那是新的假绿。 + /// 顺序即 `ULL_LANG` > 配置(`shot=false` 才参与) > 平台源 > 环境变量 > 英文;`->` 后面那一段 + /// 就是**最终生效语言 + 它来自哪一级**,与 `App` 的 `[i18n] choice=…` **用同一个判据函数**算出来。 + /// + /// 为什么把 `forced=` 与 `shot=` 也打出来:`shot=true` 时配置按 D30 被忽略,不写这一格的话 + /// 「配置里明明写了 en_us,结论却是 zh-CN」看起来像 bug(r4 复审 p2-1 的两组复现就是这两种)。 + /// + /// 曾经这里钉着 `ui_text=pinned-en`(那会儿 `pages/mod.rs::render` 真的把下载页写死成英文)。 #[must_use] pub fn line(&self) -> String { format!( - "[i18n] setting={} platform={}:{} env={}:{} -> {} ui_text=pinned-en", - // `<未接线>` 而不是 `<未设置>`:配置侧的读取点**还不存在**(M98 r1 p3-3)。 - // 写「未设置」会让读的人以为用户没配过;真实情况是我们没读。 - self.setting.as_deref().unwrap_or("<未接线>"), + "[i18n] forced={} setting={} platform={}:{} env={}:{} shot={} -> {}", + self.forced.as_deref().unwrap_or("<未设置>"), + self.setting.as_deref().unwrap_or("<未设置=跟随系统>"), self.platform.0, show(self.platform.1.as_deref()), self.env.0, show(self.env.1.as_deref()), + self.screenshot, self.detection.describe() ) } diff --git a/crates/app-iced/src/renderer/acceptance/locale_tests.rs b/crates/app-iced/src/renderer/acceptance/locale_tests.rs index ecdf8b1..3e9cbb7 100644 --- a/crates/app-iced/src/renderer/acceptance/locale_tests.rs +++ b/crates/app-iced/src/renderer/acceptance/locale_tests.rs @@ -71,6 +71,8 @@ fn i18n_line_shows_every_rung_and_the_resolved_language() { let chain = Chain::read( None, + None, + false, &Fixed(Some("zh-Hans-CN"), Some("applelanguages")), &Fixed(None, Some("env")), ); @@ -81,11 +83,115 @@ fn i18n_line_shows_every_rung_and_the_resolved_language() { "平台源原始值必须打出来:{line}" ); assert!(line.contains("env=env:<未设置>"), "{line}"); + assert!(line.contains("forced=<未设置>"), "{line}"); + assert!(line.contains("setting=<未设置=跟随系统>"), "{line}"); + assert!(line.contains("shot=false"), "{line}"); assert!( line.contains("-> lang=zh-CN locale=zh-Hans-CN source=applelanguages"), "{line}" ); - assert!(line.contains("ui_text=pinned-en"), "{line}"); + // **回归绊线(M106 r1 p2-1)**:这行曾经钉着 `ui_text=pinned-en`,在文案真的接了 + // `model.lang` 之后变成一句假话(「界面钉在英文」)。它不许回来。 + assert!( + !line.contains("pinned-en"), + "`ui_text=pinned-en` 是 M106 之前的旧事实,现在会与 App 的 [i18n] 行自相矛盾:{line}" + ); +} + +/// 配置的手选必须**同时**体现在结论与 `[env]` 的分工上(M106 r1 p2-1 的修法证据)。 +/// +/// 抓得住:①`ui.language` 这一级被丢掉(那行 `->` 的结论会退回系统语言,与 `App` 的 +/// `[i18n] choice=…` 矛盾);②把「机器语言」也改成手选值(`[env] language=` 答的是「本机是什么」, +/// 用户手选英文不该改写机器的事实)。 +#[test] +fn i18n_line_honours_the_explicit_setting_without_rewriting_the_machine_language() { + struct Fixed(Option<&'static str>); + impl LocaleSource for Fixed { + fn locale(&self) -> Option { + self.0.map(str::to_owned) + } + fn label(&self) -> &'static str { + "registry" + } + } + + let chain = Chain::read( + None, + Some("en_us"), + false, + &Fixed(Some("zh-CN")), + &Fixed(None), + ); + assert_eq!(chain.language(), "zh-CN", "[env] 报的是机器语言"); + assert_eq!(chain.detection.lang, unlucky_i18n::Lang::En, "手选压过系统"); + let line = chain.line(); + assert!(line.contains("setting=en_us"), "{line}"); + assert!(line.contains("source=setting"), "{line}"); + assert!(line.contains("lang=en"), "{line}"); +} + +/// **两个 `[i18n]` 行不许打架**(r4 复审 p2-1 的回归绊线)。 +/// +/// 复审实测的两组复现,现在必须是同一个结论: +/// - ① 只设 `ULL_LANG=en_us`、配置干净 ⇒ 结论 = en(`forced` 压过系统中文); +/// - ② 配置 `ui.language=en_us` + **截图通道** ⇒ 结论 = zh-CN(D30:截图不读用户配置); +/// - ③ 配置 `ui.language=en_us` + 交互通道 ⇒ 结论 = en(配置这一级只在非截图时参与)。 +/// +/// 抓得住:①`ULL_LANG` 这一级被丢掉;②截图通道又去读配置(「一行说 zh-CN、下一行说 effective=en」 +/// 就是这么来的);③acceptance 侧自己复制了一份优先级表 —— 每条都用 +/// `crate::ui::lang::choose_choice`(产品路径那张表)算出**期望结论**再与 `Chain` 比,谁复制谁红。 +/// 抓不住:真机上 `reg.exe` / `defaults` 读到什么(那是真机验收),以及 `App` 那行日志本身 +/// (它由 `ui/lang.rs` 的单测守)。 +#[test] +fn i18n_line_and_the_app_agree_on_every_channel_and_override() { + use unlucky_i18n::Lang; + + struct Fixed(Option<&'static str>); + impl LocaleSource for Fixed { + fn locale(&self) -> Option { + self.0.map(str::to_owned) + } + fn label(&self) -> &'static str { + "registry" + } + } + + let registry_zh = Fixed(Some("zh-CN")); + let no_env = Fixed(None); + + /// 一行真值表:`(ULL_LANG, 配置, 截图通道) -> (期望语言, 期望来源)`。 + type Case<'a> = (Option<&'a str>, Option<&'a str>, bool, Lang, &'a str); + let table: &[Case<'_>] = &[ + (Some("en_us"), None, false, Lang::En, "setting"), + // 复审组合 ①:`ULL_LANG` 在截图通道里同样压过系统中文 + (Some("en_us"), None, true, Lang::En, "setting"), + // 复审组合 ②:截图通道**忽略配置**,于是退回系统语言 + (None, Some("en_us"), true, Lang::ZhCn, "registry"), + // 组合 ③:非截图通道,配置这一级生效 + (None, Some("en_us"), false, Lang::En, "setting"), + (None, None, false, Lang::ZhCn, "registry"), + ]; + + for (forced, setting, screenshot, lang, origin) in table { + let chain = Chain::read(*forced, *setting, *screenshot, ®istry_zh, &no_env); + let line = chain.line(); + assert_eq!( + (chain.detection.lang, chain.detection.origin), + (*lang, *origin), + "forced={forced:?} setting={setting:?} shot={screenshot} 结论不对:{line}" + ); + // `[env] language=` 只报机器语言,任何手选/覆盖都不该改写它。 + assert_eq!(chain.language(), "zh-CN", "{line}"); + + // **同一张表**的结构性钉子:期望结论取自产品路径那个函数,谁在 acceptance 侧另写一份就红。 + let expected = crate::ui::lang::choose_choice(*forced, *setting, *screenshot) + .resolve(®istry_zh, &no_env); + assert_eq!(chain.detection, expected, "两侧的判据表漂了:{line}"); + assert!( + line.contains(&format!("shot={screenshot}")), + "截图通道必须在行里写出来:{line}" + ); + } } /// 抓得住 macOS 最典型的失败形态:**一个源都没有**(Finder 启动的 GUI 进程既没有 `LANG` @@ -102,7 +208,7 @@ fn i18n_line_distinguishes_no_source_from_english_source() { } } - let empty = Chain::read(None, &Nothing, &Nothing); + let empty = Chain::read(None, None, false, &Nothing, &Nothing); assert_eq!(empty.language(), "en"); assert!(empty.line().contains("source=default"), "{}", empty.line()); @@ -116,7 +222,7 @@ fn i18n_line_distinguishes_no_source_from_english_source() { } } - let env = Chain::read(None, &Nothing, &EnvEnglish); + let env = Chain::read(None, None, false, &Nothing, &EnvEnglish); assert!(env.line().contains("source=env"), "{}", env.line()); assert!( env.line().contains("locale=en_US.UTF-8"), diff --git a/crates/app-iced/src/renderer/acceptance/mod.rs b/crates/app-iced/src/renderer/acceptance/mod.rs index 523a2f5..0ad2048 100644 --- a/crates/app-iced/src/renderer/acceptance/mod.rs +++ b/crates/app-iced/src/renderer/acceptance/mod.rs @@ -69,8 +69,10 @@ pub fn announce() { /// /// 两行都吃**同一次**适配器枚举与**同一次**语言链(`defaults` / `reg.exe` 是子进程,读两次就是 /// 两次开销,而且两次之间值可能变)。 -pub fn report_startup(probe: &Probe, scale: Option) { - let chain = Chain::host(); +pub fn report_startup(probe: &Probe, scale: Option, screenshot: bool) { + // `screenshot` 一路递到 `Chain`:截图通道按 D30 忽略配置,判定必须与产品路径同源 + // (两行日志用的都是 `ui::lang::choose_choice` 那一张表,见 `locale::Chain::read`)。 + let chain = Chain::host(screenshot); eprintln!( "{}", env_line(probe, chain.language(), scale, tz().as_deref()) diff --git a/crates/app-iced/src/shell/mod.rs b/crates/app-iced/src/shell/mod.rs index 02b5d6a..f769d64 100644 --- a/crates/app-iced/src/shell/mod.rs +++ b/crates/app-iced/src/shell/mod.rs @@ -51,6 +51,10 @@ use crate::theme::Ui; /// /// `background` 由 `pages::appearance::layer` 产出(已与 `Message::Page` 映射过);垫在 `stack!` /// 最底层,于是「面板 α < 1」时能真的看见它——这就是 D34 在像素上的落点。 +// 9 个形参:M104 的 `right`/`overlay` 之后 M106 又加了 `lang`(导航轨标签要按语言取词)。 +// 拆成结构体要动 `app/view.rs` 的两处调用点,而那正是 M104/M105 反复设锚点的地方—— +// 等 M105 那轮 rebase(`draft` 与 `lang` 两个形参都要留)时一并结构体化,先就地 `allow`。 +#[allow(clippy::too_many_arguments)] pub fn view_with_background<'a>( ui: &'a Ui, selected: NavItem, diff --git a/crates/app-iced/src/ui/lang.rs b/crates/app-iced/src/ui/lang.rs index 888c411..1ef28d5 100644 --- a/crates/app-iced/src/ui/lang.rs +++ b/crates/app-iced/src/ui/lang.rs @@ -50,6 +50,21 @@ use crate::platform::locale::SystemLocale; /// 一个环境变量就换语言(与 `ULL_APPEARANCE_*` 那批同一条做法)。 pub(crate) const LANG_ENV: &str = "ULL_LANG"; +/// 设置页左侧分组的取证开关:`ULL_SETTINGS_GROUP=0` 直达 General(语言行在那里)。 +/// +/// 只影响 `--screenshot` 那一次进程(设置页出厂停在「外观」,而用户抱怨的语言行在 General 组; +/// 出图必须能直达)。交互模式一律按用户点的那一组,所以这里拿 `screenshot` 当闸门。 +/// 为什么住在 `ui/lang.rs`:它与 [`LANG_ENV`] 是同一族「出图取证开关」,一起放在语言入口层, +/// `App` 那边只留一行调用(`app/state.rs` 贴着 400 行硬线)。 +pub(crate) fn screenshot_settings_group(screenshot: bool) -> Option { + if !screenshot { + return None; + } + std::env::var("ULL_SETTINGS_GROUP") + .ok() + .and_then(|raw| raw.trim().parse::().ok()) +} + /// 这一次进程的界面语言:用户选的档 + 探测出来的结论。 /// /// 不是 `Copy`:结论里带着探测到的原始 locale 字符串(`Detection.raw`),而它只在切档/启动时 @@ -133,13 +148,21 @@ impl LangState { /// 档位选择的**纯判据**(单测直接打这张表)。 /// +/// **产品路径与验收路径共用它**:`LangState::boot` 用它决定目录,`renderer::acceptance::locale` +/// 的 `[i18n]` 行也用它算结论——两行日志因此不可能再说出不同的语言(r4 复审 p2-1 就是 +/// 「两处各算一遍」造成的)。 +/// /// | 输入 | 结果 | /// |---|---| /// | `ULL_LANG` 给了合法值 | 那一档(压过配置) | /// | 没给 + 配置写了 `zh_cn` / `en_us` | 那一档 | /// | 没给 + 配置没写(或写了坏值) | 跟随系统 | /// | **截图通道** + 配置写了值 | 跟随系统(除非 `ULL_LANG` 另给) | -fn choose_choice(forced: Option<&str>, setting: Option<&str>, screenshot: bool) -> LangChoice { +pub(crate) 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)) }