diff --git a/crates/app-iced/src/app/state.rs b/crates/app-iced/src/app/state.rs index af60e6e..b53c2f6 100644 --- a/crates/app-iced/src/app/state.rs +++ b/crates/app-iced/src/app/state.rs @@ -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 = 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::().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 的后端选项。 diff --git a/crates/app-iced/src/app/update/page_messages.rs b/crates/app-iced/src/app/update/page_messages.rs index ef409d9..1aa0f78 100644 --- a/crates/app-iced/src/app/update/page_messages.rs +++ b/crates/app-iced/src/app/update/page_messages.rs @@ -41,6 +41,10 @@ impl App { // M101:设置页的四条填页线共用一个投递口;组内变体在 `update/settings.rs` 里穷尽分发 // (那一层没有 `_ =>`,见本文件末尾与 `update/settings/tests.rs` 的两条守门) pages::Message::Settings(group) => return self.on_settings(group), + // M106:换界面语言(落盘 + 重建视图模型;档位不是业务命令,不派 `AppCommand`)。 + // 这是**唯一**一处绕开 M101 投递口的页面消息:语言档位不是「某一组的状态」, + // 它要落盘到 `ui.language` 并重建整棵视图模型(设置页的语言行只是它的第一个出口)。 + pages::Message::Language(choice) => self.set_language(choice), // M95:摘要落剪贴板(文本由页面造,这里只搬) pages::Message::CopySummary(text) => return iced::clipboard::write(text), // 下面四支已由 `on_download` 提前消化;显式列出而不是 `_ => {}`,见模块文档 @@ -70,6 +74,76 @@ impl App { #[cfg(test)] mod tests { + use unlucky_design::color::SchemeFlavor; + use unlucky_i18n::LangChoice; + use unlucky_presenter::mock::MockBackend; + use unlucky_presenter::settings_bridge::SettingsBridge; + + use crate::app::state::App; + use crate::cli::Options; + use crate::pages; + use crate::theme::Families; + + const FAMILIES: Families = Families { + text: "Segoe UI", + display: "Segoe UI", + mono: "Consolas", + }; + + /// 不碰磁盘的 App(mock 后端 + 内存设置桥),与 `app/tests.rs` 的 `headless` 同型。 + fn headless() -> App { + App::with_presenter( + Options::default(), + FAMILIES, + SchemeFlavor::Light, + Box::new(MockBackend::new()), + SettingsBridge::memory_only(), + ) + } + + /// M106:设置页语言行点一下 → 生效语言与视图模型都跟着换。 + /// + /// 抓得住:①`pages::Message::Language` 没接进 `on_page`(点了没反应,编译器不会报——它接得住, + /// 但接错了分支就静默);②`set_language` 只改字段不重建视图(界面要等下一次刷新才变); + /// ③档位与生效语言各说各话(设置页会画错选中态)。 + /// 抓不住:界面真的画成中文了没有(那要截图,见 `DevDocs/artifacts/m106/`)。 + #[test] + fn the_language_message_switches_the_catalog_and_rebuilds_the_view() { + let mut app = headless(); + assert_eq!( + app.view.lang, + app.lang(), + "首帧的视图模型要已经按生效语言建好" + ); + + let _ = app.update(crate::app::message::Message::Page( + pages::Message::Language(LangChoice::Fixed(unlucky_i18n::Lang::ZhCn)), + )); + assert_eq!(app.lang(), unlucky_i18n::Lang::ZhCn); + assert_eq!(app.view.lang, unlucky_i18n::Lang::ZhCn, "目录要跟着换"); + assert_eq!( + app.language(), + pages::Language { + choice: LangChoice::Fixed(unlucky_i18n::Lang::ZhCn), + effective: unlucky_i18n::Lang::ZhCn, + } + ); + + let refreshes = app.refreshes; + let _ = app.update(crate::app::message::Message::Page( + pages::Message::Language(LangChoice::Fixed(unlucky_i18n::Lang::En)), + )); + assert_eq!(app.view.lang, unlucky_i18n::Lang::En); + assert_eq!(app.refreshes, refreshes + 1, "换语言要重建一次视图模型"); + + // 同一档再点一次:什么都不该发生(不白重建) + let refreshes = app.refreshes; + let _ = app.update(crate::app::message::Message::Page( + pages::Message::Language(LangChoice::Fixed(unlucky_i18n::Lang::En)), + )); + assert_eq!(app.refreshes, refreshes, "同一档不该再重建"); + } + /// 抓得住:有人给 `on_page` 的 match 重新加 `_ => {}` 兜底 —— 那正是 M94 复审的 p3: /// 兜底让「加了变体忘了接线」从**编译错误**退化成「点了没反应」,这条测试把它拽回来。 /// 抓不住:别的兜底写法(把 match 拆成 if-else 链、给子枚举加通配之后再漏变体), diff --git a/crates/app-iced/src/app/view.rs b/crates/app-iced/src/app/view.rs index 774e1b6..ef7e502 100644 --- a/crates/app-iced/src/app/view.rs +++ b/crates/app-iced/src/app/view.rs @@ -69,12 +69,15 @@ impl App { appearance: Some(&self.appearance), groups: &self.settings, }, + // M106:设置页 General 组要画「当前档位 + 生效语言」。 + self.language(), ) .map(Message::Page), agent::docked(&self.ui, &self.agent, self.width) .map(|element| element.map(Message::Agent)), overlay, self.options.force_state, + self.lang(), ), // `Route::Agent`:独立页与右栗共用同一个 `conversation::view`(`agent::page` 内部调的它)。 None => shell::view_with_background( @@ -86,6 +89,7 @@ impl App { None, overlay, self.options.force_state, + self.lang(), ), } } @@ -93,9 +97,12 @@ impl App { // --------------------------------------------------------------- 快照 → 视图模型 -/// **产品路径此刻的界面语言**(唯一入口):语言还没接到设置(M106 的范围),所以恒为英文。 -/// 收成一处是为了别让第二个地方(如取消提示)各硬编码一次;M106 只改这里。 -/// **M106 合并前中文译文在产品路径上不可达**(页面与提示语都是英文)。 +/// **测试用的英文基线**(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 } @@ -113,6 +120,7 @@ pub(super) fn model( forced: Option, category: pages::Category, draft: &pages::InstallDraft, + lang: Lang, ) -> pages::Model { let route = state.route().clone(); let screen = pages::Screen::of(&route).unwrap_or(pages::Screen::Instances); @@ -137,19 +145,12 @@ pub(super) fn model( _ => None, }; - let lang = ui_lang(); - pages::Model { screen, window_width: width, active: route.nav(), instances: pages::Instances { rows, detail }, - download: pages::Download::from_snapshot( - state, - category, - draft, - unlucky_i18n::Lang::En.catalog(), - ), + download: pages::Download::from_snapshot(state, category, draft, lang.catalog()), logs: logs_model(state).with_status(lang), lang, forced: forced.map(|state| match state { @@ -289,7 +290,14 @@ mod tests { /// 测试用的薄包装:默认宽度 / 默认分类 / 空草稿。 fn at(state: &AppState, forced: Option) -> pages::Model { let draft = pages::InstallDraft::default(); - model(state, 1180.0, forced, pages::Category::All, &draft) + model( + state, + 1180.0, + forced, + pages::Category::All, + &draft, + Lang::En, + ) } #[test] diff --git a/crates/app-iced/src/fonts.rs b/crates/app-iced/src/fonts.rs index b43761e..74c5b5e 100644 --- a/crates/app-iced/src/fonts.rs +++ b/crates/app-iced/src/fonts.rs @@ -131,7 +131,16 @@ impl Resolved { } /// 求值本机字体(会触发 iced 全局字体系统的首次初始化,约 0.1~0.8s)。 +/// +/// 中文那一级按**系统 locale** 判:启动编排(`app/boot.rs`)这一拍还没有配置读取点。真正的 +/// `ui.language` 在 [`crate::app::state`] 构造 `App` 时读到,随后用 [`cjk_for`] 重算一次并打 +/// 日志——所以「英文系统上手动切中文」不会停在 `NotNeeded`(见 [`cjk_for`])。 pub fn resolve() -> Resolved { + resolve_with(None) +} + +/// 同 [`resolve`],但把配置里 `ui.language` 的显式值喂给中文策略(D28 的第一级)。 +pub fn resolve_with(setting: Option<&str>) -> Resolved { let platform = Platform::current(); let faces = system_faces(); @@ -166,7 +175,7 @@ pub fn resolve() -> Resolved { // D28 后半:语言 + 中文环境的中文字体策略。`faces` 的 `None` 在这里是「字体库读不出来」, // 适配层会把它翻成 `Unprobeable` 而不是「缺字体」(否则会误触发一次 CDN 下载)。 - let cjk_report = cjk::probe(faces.as_deref()); + let cjk_report = cjk::probe_with(setting, faces.as_deref()); Resolved { platform, @@ -176,6 +185,17 @@ pub fn resolve() -> Resolved { } } +/// 语言切换后的中文策略重算(M106):`setting` 是配置里 `ui.language` 的**显式值** +/// (`None` = 跟随系统,由 [`crate::platform::locale::SystemLocale`] 决定)。 +/// +/// 为什么需要它:启动那一轮([`resolve`])拿不到配置,只按系统 locale 判。用户在英文系统上 +/// 手选「简体中文」时,若不复算,策略会停在 `NotNeeded`——界面文字是中文、字体却没有中文字形, +/// 那就是白块。复算出的 `NeedsDownload` 是 D28 的**信号**(下载器仍是 `Source::PendingPin`, +/// 见 `unlucky_i18n::font`),本 crate 只负责把它打进日志、不让它静默。 +pub fn cjk_for(setting: Option<&str>) -> CjkReport { + cjk::probe_with(setting, system_faces().as_deref()) +} + /// 一条系统字面(族名 + 字重)。 #[derive(Debug, Clone, PartialEq, Eq)] struct Face { diff --git a/crates/app-iced/src/fonts/cjk.rs b/crates/app-iced/src/fonts/cjk.rs index a4dd5eb..df0eed9 100644 --- a/crates/app-iced/src/fonts/cjk.rs +++ b/crates/app-iced/src/fonts/cjk.rs @@ -20,7 +20,13 @@ //! - **抓得住**:语言 + 字体库族名 → 策略(含 `Unprobeable` 这一支);优先级顺序(注入的假源可测); //! - **抓不住**:①族名存在 ≠ 字形覆盖(`SimSun` 也可能是个残缺子集)。真判据要开 `ttf-parser` //! 查 cmap,本 mission 不引依赖(M60 Q3 spike 做过,结论见 `DevDocs`); -//! ②`ui.language` 这一级目前**没有接线**(设置层还没有读取点),调用点传 `None`。 +//! ②下载与注册(`Strategy::NeedsDownload` 只是信号,`Source::PendingPin` 还没钉 URL/哈希)。 +//! +//! ## `ui.language` 的接线(M106) +//! +//! 启动编排(`app/boot.rs`)那一拍只按系统 locale 判(`fonts::resolve` → `probe_with(None, ..)`);真正的 +//! `ui.language` 在 `App` 构造/切语言时读到,随后走 `fonts::cjk_for(setting)` 复算一次并打日志。 +//! 所以「英文系统上手动切中文」也会得到 `needs-download` 而不是 `not-needed`——白块那条路被堵住了。 use unlucky_i18n::font::{self, Strategy}; use unlucky_i18n::lang::{self, Detection, EnvLocale}; @@ -68,11 +74,6 @@ pub fn probe_with(setting: Option<&str>, faces: Option<&[Face]>) -> CjkReport { report(lang::resolve(setting, &SystemLocale, &EnvLocale), faces) } -/// 不读配置时的入口(启动路径用)。 -pub fn probe(faces: Option<&[Face]>) -> CjkReport { - probe_with(None, faces) -} - #[cfg(test)] mod tests { use super::*; @@ -164,7 +165,7 @@ mod tests { fn explicit_setting_overrides_the_platform_source() { // 抓得住:`ui.language` 显式值被平台源盖掉(用户手选中文/英文必须赢过系统语言); // 也让「平台源真的接在链路上」有个不依赖本机语言的证据。 - // 抓不住:`ui.language` 在真机上读到什么(设置层还没接线,调用点传 None)。 + // 抓不住:`ui.language` 在真机上读到什么(那一级由 `App` 读配置后经 `fonts::cjk_for` 喂进来)。 let installed = faces(&["Microsoft YaHei"]); let forced_english = probe_with(Some("en_us"), Some(&installed)); diff --git a/crates/app-iced/src/lib.rs b/crates/app-iced/src/lib.rs index e26a5c5..2638a5a 100644 --- a/crates/app-iced/src/lib.rs +++ b/crates/app-iced/src/lib.rs @@ -13,6 +13,7 @@ //! | `app` / `shell` | Elm 三件套 + 自绘标题栏 / 导航轨 / 内容骨架 | 业务逻辑 | //! | `shot` | `window::screenshot` → PNG(验收通道) | 图像比对 | //! | `icons` | 令牌里的 SVG path → iced `svg` 控件 | 画新图标 | +//! | [`ui`] | 界面语言的入口层(M106):`LangState` = 用户档位 + 探测结论,目录由它选 | 翻译(那是 `unlucky-i18n`) | //! | `platform` | 平台窗口能力(D36):整窗 α 的纯判据 + win32 FFI(补分层样式 + 下发 α)—— **全仓唯一** `unsafe` 落点 | 窗口创建 / 业务开关 | //! //! ## 消费纪律 @@ -38,6 +39,7 @@ mod fonts; mod icons; mod probe; mod shell; +mod ui; pub use cli::{Backend, Options}; pub use probe::Probe; diff --git a/crates/app-iced/src/pages/message.rs b/crates/app-iced/src/pages/message.rs index 4a57559..342c8a8 100644 --- a/crates/app-iced/src/pages/message.rs +++ b/crates/app-iced/src/pages/message.rs @@ -7,6 +7,7 @@ //! `pages::Message` 是页面层的自有消息类型(**不** import `crate::app::Message`,组件契约 §2 的 //! 同一条纪律);app 层只做一次 `.map`。 +use unlucky_i18n::LangChoice; use unlucky_presenter::router::Route; use super::appearance; @@ -59,6 +60,16 @@ pub enum Message { /// 也不碰 `app/update/page_messages.rs` 那条不许 `_ =>` 的 match。加变体与落点见 /// `pages/settings/message.rs`(`app/update/settings.rs` 对它做穷尽分发,漏接线报 E0004)。 Settings(settings::GroupMessage), + /// 换界面语言(**M106**:设置页 General 分组的语言行 → `App::set_language`)。 + /// + /// 载荷是**用户意图**(跟随系统 / 手选某一门语言),不是「生效语言」:生效语言由 + /// `LangChoice::resolve` 按 D28 的顺序算出来,页面不参与计算。 + /// + /// 为什么不折进 [`Message::Settings`] 那个投递口:语言档位不是「某一组的状态」——它要**落盘** + /// 到 `ui.language` 并**重建整棵视图模型**(目录是 `pages::Model` 的一部分),而那条落点 + /// (`app/update/settings/language_appearance.rs`)是 language+appearance 线的文件。 + /// 代价是本文件多一个变体;收口方向写在 `settings/general.rs` 的文档里。 + Language(LangChoice), } /// 页面动作清单(骨架期够用就行;接线时按业务语义重命名/扩充)。 diff --git a/crates/app-iced/src/pages/mod.rs b/crates/app-iced/src/pages/mod.rs index f782377..c03ce8c 100644 --- a/crates/app-iced/src/pages/mod.rs +++ b/crates/app-iced/src/pages/mod.rs @@ -129,6 +129,7 @@ pub use logs::{Confidence, Finding, Level, Line, Logs, Outcome}; pub use message::{Action, Message}; pub use model::{Detail, Instances, Meta, Model, Row}; pub use screen::Screen; +pub use settings::Language; use iced::widget::{column, container, row, space}; use iced::{Element, Length}; @@ -155,6 +156,7 @@ pub fn page<'a>(ui: &'a Ui, model: &'a Model) -> Element<'a, Message> { appearance: None, groups: settings::demo_groups(), }, + Language::default(), ) } @@ -178,6 +180,7 @@ pub fn page_with_appearance<'a>( appearance: Some(appearance), groups: settings::demo_groups(), }, + Language::default(), ) } @@ -186,21 +189,31 @@ pub fn page_with_appearance<'a>( /// 为什么要单独一个入口:组状态的所有者是 `App`(`app/state.rs` 的 `settings` 字段),页面层 /// 只借用一帧。加/改某一组的字段不改变这条签名(`States` 里只有 `&Groups`),所以四条填页线 /// 都不必来这里动一行。 +/// `language` 是 M106 加的:「当前档位 + 生效语言」只有 `App` 知道(`App::language()`),而设置页 +/// 的 General 组要画它。唯一调用点是 `app/view.rs`;harness 那三条入口([`page`] / +/// [`page_with_appearance`] / [`app_with_appearance`])仍用 [`Language::default`]。 pub fn page_with_settings<'a>( ui: &'a Ui, model: &'a Model, states: settings::States<'a>, + language: Language, ) -> Element<'a, Message> { - render(ui, model, states) + render(ui, model, states, language) } -fn render<'a>(ui: &'a Ui, model: &'a Model, states: settings::States<'a>) -> Element<'a, Message> { +fn render<'a>( + ui: &'a Ui, + model: &'a Model, + states: settings::States<'a>, + language: Language, +) -> Element<'a, Message> { let metrics = layout::frame_metrics(model.screen.kit(), model.window_width); match model.screen { Screen::Instances => instances::view(ui, metrics, model), - Screen::Download => download::view(ui, metrics, &model.download, unlucky_i18n::Lang::En), - Screen::Settings => settings::view(ui, metrics, states), + // M106:下载页此前写死成 `Lang::En`,现在跟 `model.lang`(页面文案跟着界面语言走)。 + Screen::Download => download::view(ui, metrics, &model.download, model.lang), + Screen::Settings => settings::view(ui, metrics, states, language), Screen::Logs => logs::view(ui, metrics, &model.logs, model.lang), } } @@ -233,6 +246,7 @@ pub fn app_with_appearance<'a>( appearance, groups: settings::demo_groups(), }, + Language::default(), ); let shell = row![nav::rail(ui, model.active), body] diff --git a/crates/app-iced/src/pages/settings/frame.rs b/crates/app-iced/src/pages/settings/frame.rs index d3f99ce..438744d 100644 --- a/crates/app-iced/src/pages/settings/frame.rs +++ b/crates/app-iced/src/pages/settings/frame.rs @@ -22,6 +22,7 @@ use unlucky_design::icons::IconKind; use unlucky_design::shape::ShapeScale; use unlucky_design::spacing::Space; use unlucky_design::typography::TypeRole; +use unlucky_i18n::{Key, Lang}; use crate::pages::appearance::Change; use crate::pages::{Action, Message}; @@ -33,22 +34,40 @@ use super::Group; const EMPTY_SLOT_HEIGHT: f32 = 320.0; /// 页头:标题 + 副标题 + 两个占位动作。 -pub(super) fn header(ui: &Ui) -> Header<'_> { - Header::new("Settings") - .subtitle("Launcher behaviour, Java runtimes and download sources") - .action(text_action(ui, "Reset section", Action::ResetSection)) - .action(text_action(ui, "Save", Action::SaveSettings)) +/// +/// M106:三条文案走 catalog(`lang` 由 `settings::view` 传下来)。页标题复用 `NavSettings`—— +/// 左列与页头说的是同一件事,不该有第二条译文。 +pub(super) fn header<'a>(ui: &'a Ui, lang: Lang) -> Header<'a> { + let catalog = lang.catalog(); + Header::new(catalog.text(Key::NavSettings)) + .subtitle(catalog.text(Key::SettingsSubtitle)) + .action(text_action( + ui, + catalog.text(Key::SettingsResetSection), + Action::ResetSection, + )) + .action(text_action( + ui, + catalog.text(Key::SettingsSave), + Action::SaveSettings, + )) } // --------------------------------------------------------------- 侧列 /// 左列分组导航(supporting-pane 的辅助列,宽 = 令牌 `nav_drawer_width` = 240)。 -pub(super) fn group_nav<'a>(ui: &'a Ui, selected: usize) -> Element<'a, Message> { +pub(super) fn group_nav<'a>(ui: &'a Ui, selected: usize, lang: Lang) -> Element<'a, Message> { + let catalog = lang.catalog(); let items = column( super::GROUPS .iter() .enumerate() - .map(|(index, group)| group_item(ui, group, index, index == selected)) + .map(|(index, group)| { + let label = super::GROUP_LABELS + .get(index) + .map_or(group.name, |key| catalog.text(*key)); + group_item(ui, group, label, index, index == selected) + }) .collect::>(), ) .spacing(ui.space(Space::X1)) @@ -72,6 +91,7 @@ pub(super) fn group_nav<'a>(ui: &'a Ui, selected: usize) -> Element<'a, Message> fn group_item<'a>( ui: &'a Ui, group: &'a Group, + label: &'a str, index: usize, selected: bool, ) -> Element<'a, Message> { @@ -86,7 +106,7 @@ fn group_item<'a>( let inner = row![ glyph_box(group.icon, content_color, ICON_PX), - text_role(ui, spec.text, false, group.name, content_color), + text_role(ui, spec.text, false, label, content_color), ] .spacing(ui.space(Space::X3)) .width(Length::Fill) @@ -180,14 +200,18 @@ fn setting_row<'a>(ui: &'a Ui, label: &'a str, value: &'a str) -> Element<'a, Me } /// 空态位:没有逐实例覆盖时的样子(12b §15 的口径)。 -pub(super) fn overrides_empty<'a>(ui: &'a Ui) -> Element<'a, Message> { +pub(super) fn overrides_empty<'a>(ui: &'a Ui, lang: Lang) -> Element<'a, Message> { + let catalog = lang.catalog(); container(empty_state( ui, IconKind::Instances, - "No per-instance overrides", - "Overrides let one instance keep its own Java runtime, memory or JVM flags while the rest \ - of the launcher keeps the defaults above.", - vec![text_action(ui, "Add override", Action::AddOverride)], + catalog.text(Key::SettingsOverridesEmptyTitle), + catalog.text(Key::SettingsOverridesEmptyBody), + vec![text_action( + ui, + catalog.text(Key::SettingsOverridesAdd), + Action::AddOverride, + )], )) .width(Length::Fill) .height(Length::Fixed(EMPTY_SLOT_HEIGHT)) diff --git a/crates/app-iced/src/pages/settings/general.rs b/crates/app-iced/src/pages/settings/general.rs index 72e97a7..9ece9c3 100644 --- a/crates/app-iced/src/pages/settings/general.rs +++ b/crates/app-iced/src/pages/settings/general.rs @@ -1,29 +1,225 @@ -//! 设置页「General」分组:语言 / 主题 / 启动页 / 关闭按钮(演示行)。 +//! 设置页「General」分组:语言选择器 + 主题 / 启动页 / 关闭按钮(M106 接真控件)。 //! -//! **这一组归 language+appearance 线**:语言那一行将来换真下拉时,消息加在本线的消息口 -//! `settings/language_appearance.rs` 的 `Message` 里,落点是 -//! `app/update/settings/language_appearance.rs`——**不要**去改 `pages/mod.rs` / `pages/message.rs` / -//! `app/update/page_messages.rs`(那三个正是 M101 收掉的共享锚点,改了就把本 mission 的目的掀掉); -//! `settings/mod.rs` 的注册表也不用动。 +//! **这一组归 language+appearance 线**(M101 的分线口径)。 //! -//! 要显示**页外真值**(例如「当前语言」的唯一来源是 `pages::Model.lang`)或首帧就要取数时, -//! 那两条仍有代价——见 `settings/mod.rs` 文档「仍然共享」小节里的第 **4、5** 条。 +//! ## 这一组为什么特殊 +//! +//! 它**不走** `Group::body`:那个渲染器的签名是 `fn(&Ui, States) -> Option`,`States` 只有 +//! `appearance` + `groups` 两格,装不下「当前语言档位 + 生效语言」。语言行要的两件在 +//! `pages::Language` 里,由 `pages::settings::view` 的 `language` 形参一路递进来、`form` 按下标 +//! 转交 [`body`];所以 `GROUP.body` 仍是 `None`,`tests/mod.rs` 那条「只有外观组带真控件」照样成立。 +//! +//! ## 语言切换**没有**走 M101 的 `GroupMessage` 投递口(偏离,已登记) +//! +//! `settings/mod.rs` 的文档说本线要加消息就加到 `settings/language_appearance.rs` 的 `Message` 上。 +//! M106 走的是 `pages::Message::Language(choice)` 这条独立增量,理由:语言档位不是「某一组的状态」, +//! 它要**落盘**(`ui.language`)+ **重建整棵视图模型**(目录是 `pages::Model` 的一部分),而落点 +//! `app/update/settings/language_appearance.rs` 与消息口 `language_appearance.rs` 都不在本 mission 的 +//! scope 内。代价:`page_messages.rs` 多一支(那是 M101 想收掉的共享锚点)。**收口方向**:等本线 +//! 再动设置页时,把 `Message::Language` 折进 `GroupMessage::LanguageAppearance(SetChoice)`,并把 +//! `pages/mod.rs` 的这支删掉——finding 已登记。 +//! +//! ## 语言行的三个事实 +//! +//! 1. **选中态显示的是档位**(跟随系统 / 简体中文 / 英文),不是「猜出来的语言」——用户点过什么 +//! 就得画回什么,否则「我明明选了跟随系统」这件事在界面上无从确认; +//! 2. **旁边回显生效语言**(`界面当前使用:简体中文`):中文系统 + 跟随系统时,这一行是「界面 +//! 真的用了中文」的唯一可见证据(用户报的 bug 正是「识别到了但没用上」); +//! 3. **文案全部走 catalog**([`Key`]),三档选项名跟着界面语言走(见 `catalog/general.rs` +//! 的头注释:为什么不写语言自称)。 +//! +//! ## 其余三行仍只读 +//! +//! 主题 / 启动页 / 关闭按钮还没有后端(`pages::Action` 里是占位动作),所以它们是**只读行**; +//! 但标签与取值已经进 catalog,中文界面下不会再冒出英文。 //! //! 分组元数据只有一份(这里的 [`GROUP`]):`settings/mod.rs` 的 `GROUPS` 直接引用它。 -use unlucky_design::icons::IconKind; +use iced::widget::{button, column, container, row}; +use iced::{Alignment, Background, Border, Element, Length, Padding, Shadow, Theme as IcedTheme}; +use unlucky_design::color::{Role, StateLayer}; +use unlucky_design::icons::IconKind; +use unlucky_design::shape::ShapeScale; +use unlucky_design::spacing::Space; +use unlucky_design::typography::TypeRole; +use unlucky_i18n::catalog::Key; +use unlucky_i18n::LangChoice; + +use crate::pages::{frame, Language, Message}; +use crate::theme::Ui; + +use super::frame::{card, divider}; use super::Group; -/// General 分组(演示行仍是 M84 的文本行;真控件接进来时把 `body` 换成 `Some(...)`)。 +/// General 分组(左列名固定英文:左列是**导航**,与导航轨同源,不进 catalog)。 pub(super) const GROUP: Group = Group { name: "General", icon: IconKind::Settings, + // 只给 harness(`pages_demo` 走 `appearance: None` 时)用的演示行:产品路径走 [`body`]。 + // 语言那一行**不在**这里——它的值是要跟着语言变的,写死在静态表里就是用户看到的那条假值。 rows: &[ - ("Language", "English (System)"), ("Theme", "Follow system"), ("Startup page", "Instances"), ("Close button", "Exit to tray"), ], body: None, }; + +/// 这一组的正文(**产品路径**)。 +pub(super) fn body<'a>(ui: &'a Ui, language: Language) -> Element<'a, Message> { + let catalog = language.effective.catalog(); + let mut stacked = column![].spacing(0.0).width(Length::Fill); + stacked = stacked.push(language_row(ui, language)); + + // 主题 / 启动页 / 关闭按钮:还没有后端,只读;文案已经进 catalog。 + for (label, value) in [ + (Key::SettingsTheme, Key::SettingsThemeFollowSystem), + (Key::SettingsStartupPage, Key::SettingsStartupInstances), + (Key::SettingsCloseButton, Key::SettingsCloseToTray), + ] { + stacked = stacked.push(divider(ui)); + stacked = stacked.push(setting_row(ui, catalog.text(label), catalog.text(value))); + } + + card(ui, stacked) +} + +/// 语言行:标题 + 生效语言回显 + 三档互斥按钮 + 一句说明。 +fn language_row<'a>(ui: &'a Ui, language: Language) -> Element<'a, Message> { + let catalog = language.effective.catalog(); + let effective = LangChoice::Fixed(language.effective).label_key(); + + let head = row![ + container(frame::text_role( + ui, + TypeRole::BodyMedium, + false, + catalog.text(Key::SettingsLanguage), + ui.color(Role::OnSurface), + )) + .width(Length::Fill) + .align_y(Alignment::Center), + frame::text_role_owned( + ui, + TypeRole::LabelLarge, + true, + catalog.render( + Key::SettingsLanguageEffective, + &[("language", catalog.text(effective))], + ), + ui.color(Role::OnPrimaryFixedVariant), + ), + ] + .width(Length::Fill) + .align_y(Alignment::Center); + + let chips = row(LangChoice::ALL + .iter() + .map(|choice| { + chip( + ui, + catalog.text(choice.label_key()), + *choice == language.choice, + *choice, + ) + }) + .collect::>()) + .spacing(ui.space(Space::X1)) + .align_y(Alignment::Center); + + column![ + head, + chips, + frame::text_role( + ui, + TypeRole::BodySmall, + false, + catalog.text(Key::SettingsLanguageHint), + ui.color(Role::OnSurfaceVariant), + ), + ] + .spacing(ui.space(Space::X1)) + .width(Length::Fill) + .padding(Padding::from([ui.space(Space::X2), 0.0])) + .into() +} + +/// 一个只读的「标签 + 取值」行(与 `frame::demo_card` 的一行同高同排版)。 +fn setting_row<'a>(ui: &'a Ui, label: &'a str, value: &'a str) -> Element<'a, Message> { + let height = ui.metrics().control_height + Space::X2.dp(); + + row![ + container(frame::text_role( + ui, + TypeRole::BodyMedium, + false, + label, + ui.color(Role::OnSurface), + )) + .width(Length::Fill) + .align_y(Alignment::Center), + frame::text_role( + ui, + TypeRole::BodySmall, + false, + value, + ui.color(Role::OnSurfaceVariant), + ), + ] + .height(height) + .width(Length::Fill) + .align_y(Alignment::Center) + .into() +} + +/// 一个互斥按钮(选中态 `SecondaryContainer`,未选中透明 + `OnSurfaceVariant`)。 +/// +/// 与 `pages/appearance/section.rs` 的 `choice` 同款,但文案来自 catalog(那份是英文基线)。 +fn chip<'a>(ui: &'a Ui, text: &'a str, selected: bool, choice: LangChoice) -> Element<'a, Message> { + let height = ui.metrics().control_height; + let content_color = if selected { + ui.color(Role::OnSecondaryContainer) + } else { + ui.color(Role::OnSurfaceVariant) + }; + + button( + container(frame::text_role( + ui, + TypeRole::LabelLarge, + selected, + text, + content_color, + )) + .align_y(Alignment::Center), + ) + .height(height) + .padding(Padding::from([0.0, ui.space(Space::X3)])) + .style(move |_theme: &IcedTheme, status| { + let background = if selected { + Some(Background::Color(ui.color(Role::SecondaryContainer))) + } else if matches!(status, button::Status::Hovered | button::Status::Pressed) { + Some(Background::Color(ui.state_over( + StateLayer::Hover, + Role::OnSurface, + Role::Surface, + ))) + } else { + None + }; + + button::Style { + background, + text_color: content_color, + border: Border { + radius: ui.radius(ShapeScale::Full, height), + ..Border::default() + }, + shadow: Shadow::default(), + snap: true, + } + }) + .on_press(Message::Language(choice)) + .into() +} diff --git a/crates/app-iced/src/pages/settings/mod.rs b/crates/app-iced/src/pages/settings/mod.rs index ce36e2d..a9ce8b7 100644 --- a/crates/app-iced/src/pages/settings/mod.rs +++ b/crates/app-iced/src/pages/settings/mod.rs @@ -146,12 +146,38 @@ use unlucky_design::color::Role; use unlucky_design::icons::IconKind; use unlucky_design::spacing::Space; use unlucky_design::typography::TypeRole; +use unlucky_i18n::{Lang, LangChoice}; use crate::theme::Ui; use super::layout::{FrameMetrics, PageLayout}; use super::Message; +/// 设置页 General 分组要的两件(M106):用户选的档位 + 这一次生效的语言。 +/// +/// 为什么不塞进 `pages::Model`:那是页面与 presenter 之间的**展示契约**(由快照派生),而档位是 +/// **用户意图**(`App` 的字段);塞进去会让 `pages::demo_model` 也得伪造一个档位。 +/// 为什么不是单个 `Lang`:语言行要画**选中态**——「我明明选了跟随系统」得在界面上看得出来。 +/// 为什么不塞进 [`States`]:那条路要改 `settings/state.rs` 并补齐 4 处 struct literal +/// (`settings/mod.rs` 文档「仍然共享」第 4 条已算过账),而 `view` 多一个形参只动 2 个文件。 +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct Language { + /// 用户选的那一档(跟随系统 / 手选)。 + pub choice: LangChoice, + /// 这一次真正生效的语言(目录选的就是它)。 + pub effective: Lang, +} + +impl Default for Language { + /// harness / 演示路径的默认值:跟随系统 + 英文基线(与 M84 起的截图基线一致)。 + fn default() -> Self { + Self { + choice: LangChoice::System, + effective: Lang::En, + } + } +} + /// 一个分组正文的**真控件渲染器**:入参是「画一帧时那份只读状态」(组状态 + 外观状态), /// 返回 `None` = 这一组现在没东西可画(例如外观分组在 harness 路径下没有外观状态), /// 由 [`form`] 退回演示行。 @@ -188,11 +214,36 @@ const GROUPS: [Group; 4] = [ /// 演示选中的分组(没有真实外观状态时——`pages_demo` harness——用它)。 const SELECTED: usize = 0; +/// General 分组在 [`GROUPS`] 里的下标(0)。它与 [`SELECTED`] 同值**是巧合,不是依赖**。 +/// +/// 为什么需要它:语言行要的是「当前档位 + 生效语言」([`Language`]),而 `Group.body` 的签名只带 +/// [`States`](外观 + 组状态)——给 `States` 加字段要动 `settings/state.rs` 与 4 处 struct literal +/// (见本文件文档「仍然共享」第 4 条),给 `Body` 加形参会令另外三组各自的 `body` 函数编译不过。 +/// 所以这里做一次**按下标的分发**:代价是一行 `if`,换来 `general::GROUP.body` 仍是 `None` +/// (`tests/mod.rs` 的「只有外观组带真控件 body」照样成立)。 +const GENERAL_GROUP: usize = 0; + +/// 左列分组的**显示名** key(下标与 [`GROUPS`] 一一对应,M106)。 +/// +/// 为什么不放进 [`Group`]:那会让另外三组的 `GROUP` 字面量(各自文件、各自填页线)编译不过—— +/// 与 [`GENERAL_GROUP`] 同一条理由。`Group.name` 因此退化为**id**(测试按它认组;显示走这张表)。 +pub(super) const GROUP_LABELS: [unlucky_i18n::Key; 4] = [ + unlucky_i18n::Key::SettingsGroupGeneral, + unlucky_i18n::Key::SettingsGroupAppearance, + unlucky_i18n::Key::SettingsGroupJava, + unlucky_i18n::Key::SettingsGroupDownloads, +]; + /// 整页。 /// /// `states` 是「画一帧时那份只读状态」:[`States::appearance`] 是 M93 接进来的外观运行期状态 /// (`None` = harness / 演示路径,设置页仍画占位行),[`States::groups`] 是各组自己的状态。 -pub fn view<'a>(ui: &'a Ui, metrics: FrameMetrics, states: States<'a>) -> Element<'a, Message> { +pub fn view<'a>( + ui: &'a Ui, + metrics: FrameMetrics, + states: States<'a>, + language: Language, +) -> Element<'a, Message> { // 选中分组:有真实外观状态时看状态(点左列可切),否则退回演示值 0。 let selected = states .appearance @@ -201,13 +252,13 @@ pub fn view<'a>(ui: &'a Ui, metrics: FrameMetrics, states: States<'a>) -> Elemen let body = match metrics.layout { PageLayout::SupportingPane => frame::panes( metrics, - frame::group_nav(ui, selected), - frame::left_aligned(metrics.column, form(ui, Some(selected), states)), + frame::group_nav(ui, selected, language.effective), + frame::left_aligned(metrics.column, form(ui, Some(selected), states, language)), ), - _ => frame::single_column(metrics, form(ui, None, states)), + _ => frame::single_column(metrics, form(ui, None, states, language)), }; - frame::page(ui, metrics, frame::header(ui), body) + frame::page(ui, metrics, frame::header(ui, language.effective), body) } // --------------------------------------------------------------- 内容分发 @@ -218,7 +269,13 @@ pub fn view<'a>(ui: &'a Ui, metrics: FrameMetrics, states: States<'a>) -> Elemen /// **分发点**:一组的正文由它自己文件里的 `GROUP.body` 决定——`Some(f)` 让 `f` 自己去 /// [`States`] 里取它要的数据(`None` 返回值 ⇒ 退回演示行),`None` 直接画演示行 /// ([`frame::demo_card`])。这里不出现任何一个分组名,也不比较下标,更不认识任何一组的字段。 -fn form<'a>(ui: &'a Ui, only: Option, states: States<'a>) -> Element<'a, Message> { +fn form<'a>( + ui: &'a Ui, + only: Option, + states: States<'a>, + language: Language, +) -> Element<'a, Message> { + let catalog = language.effective.catalog(); let mut stack = column![].spacing(ui.space(Space::X6)).width(Length::Fill); for (index, group) in GROUPS.iter().enumerate() { @@ -228,10 +285,18 @@ fn form<'a>(ui: &'a Ui, only: Option, states: States<'a>) -> Element<'a, // 「外观」分组:有真实外观状态时画真控件(M93),否则退回演示行(harness)。 // 这两条的差别由 `GROUP.body` 的返回值表达,而不是「下标 == APPEARANCE_GROUP」的硬比较。 - let card = group - .body - .and_then(|body| body(ui, states)) - .unwrap_or_else(|| frame::demo_card(ui, group.rows)); + // + // M106:General 是**唯一一处按下标分发**的例外(理由见 [`GENERAL_GROUP`])——语言行的 + // 档位与生效语言不在 [`States`] 里,只能从 `pages::Language` 进来;它与 `appearance` 在不在场 + // 无关(harness 那条路径也画得出真控件)。 + let card = if index == GENERAL_GROUP { + general::body(ui, language) + } else { + group + .body + .and_then(|body| body(ui, states)) + .unwrap_or_else(|| frame::demo_card(ui, group.rows)) + }; stack = stack.push( column![ @@ -239,7 +304,10 @@ fn form<'a>(ui: &'a Ui, only: Option, states: States<'a>) -> Element<'a, ui, TypeRole::LabelMedium, false, - group.name, + // 卡片标题与左列同一个词源(`GROUP_LABELS`):`Group.name` 只是 id。 + GROUP_LABELS + .get(index) + .map_or(group.name, |key| catalog.text(*key)), ui.color(Role::OnSurfaceVariant), ), card, @@ -249,7 +317,7 @@ fn form<'a>(ui: &'a Ui, only: Option, states: States<'a>) -> Element<'a, ); } - stack = stack.push(frame::overrides_empty(ui)); + stack = stack.push(frame::overrides_empty(ui, language.effective)); frame::scroll(ui, stack) } diff --git a/crates/app-iced/src/pages/settings/tests/general_section.rs b/crates/app-iced/src/pages/settings/tests/general_section.rs index dc3c8b0..8825de1 100644 --- a/crates/app-iced/src/pages/settings/tests/general_section.rs +++ b/crates/app-iced/src/pages/settings/tests/general_section.rs @@ -1,24 +1,33 @@ -//! 「General」分组自己的断言(M100 **新增**:`settings/tests.rs` 那 7 条全是跨 section 的, -//! 一条 section 断言都没有;这 3 条是新增,不是搬移)。 +//! 「General」分组自己的断言(M106 `I18NFOLLOW` 重写:语言行换成真控件)。 //! //! **归 language+appearance 线**。接真控件那天要改**两个**文件(M101 r2 删掉了原先的第 3 处跨 //! section 绊线):`settings/general.rs` + 本文件 `the_general_group_is_still_demo_rows`。 +//! +//! M106(`I18NFOLLOW`)已经接了这一组的语言行。用户报的 bug 有两半:**界面全英文**(探测对了 +//! 但没人用)与**设置页写着 `English (System)`**(一行硬编码假值)。本文件的断言守后半:那行 +//! 假值不许回来,语言行的三档必须真的能在两套目录里取到词。 +//! +//! 接线形状:语言行的正文由 [`general::body`] 画(入口是 `pages::settings` 的 `GENERAL_GROUP` +//! 按下标分发),`GROUP.body` 仍是 `None`——那个槽位的签名是 `fn(&Ui, States) -> Option`, +//! `States` 只有 `appearance` + `groups` 两格,装不下「当前语言档位」。所以 M101 r2 的 +//! 「本组接真控件只需改两个文件」这条口径,在语言行上**仍然是两个文件**。 use super::super::general; use unlucky_design::icons::IconKind; +use unlucky_i18n::LangChoice; +use unlucky_i18n::{Key, Lang}; -/// 这一组的行集合(标签 + 值)。改文案就是改观感,所以逐条钉住,而不是「非空就行」。 +/// 这一组的**静态演示行**:语言那一行已经换真控件,所以只剩三行。 /// -/// 抓得住:① 四条线并行合并时把别人的行粘到本文件(四份 `GROUP` 长得几乎一样,最容易整块贴错); -/// ② 漏掉/多出一条行;③ 标签与值错位(`("Theme", "Follow system")` 被写成 `("Theme", "Instances")`)。 -/// 抓不住:值对不对(`"Follow system"` 是不是当前系统主题的真相)——那是接线后真数据源的事。 +/// 抓得住:①四条线并行合并时把别人的行粘到本文件;②语言行被「顺手」加回静态表—— +/// 那等于把用户看到的假值又写回去(它的值必须跟着语言变,静态表做不到)。 +/// 抓不住:真控件画出来长什么样(那是截图验收的事,见 `DevDocs/artifacts/m106/`)。 #[test] -fn the_general_group_shows_language_theme_startup_and_close() { +fn the_general_group_keeps_theme_startup_and_close_as_static_rows() { let rows: Vec<(&str, &str)> = general::GROUP.rows.to_vec(); assert_eq!( rows, vec![ - ("Language", "English (System)"), ("Theme", "Follow system"), ("Startup page", "Instances"), ("Close button", "Exit to tray"), @@ -32,17 +41,82 @@ fn the_general_group_keeps_its_icon() { assert_eq!(general::GROUP.icon, IconKind::Settings); } -/// **绊线**:这一组还没接真控件,画的是演示行。 +/// **回归绊线(用户原话)**:`"English (System)"` 那条硬编码字面量不许回来。 /// -/// 抓得住:language+appearance 线接上了 `body` 却没更新这里的期望(那会让本文件静默变成假绿)。 -/// 抓不住:界面上真控件长什么样。 +/// 抓得住:①原来那条「这一组还没接真控件」在接上之后没更新(静默变假绿); +/// ②语言行**退回静态演示行**(哪怕换个说法,只要还写着「English」就是同一个 bug 的复盘)。 +/// 抓不住:界面上真控件长什么样、三档选项名对不对(下一条与截图各管一段)。 /// -/// 接真控件时:把 `is_none()` 改成 `is_some()`,**只改这一行**(M101 r2 起不必再去 -/// `tests/mod.rs` 报到:那条跨 section 聚合绊线已删)。 +/// 现状(M106):`body` 仍是 `None`(语言行走 `GENERAL_GROUP` 的按下标分发),但这一组**已经** +/// 有真控件了——所以这里断言的**不再是「还没接」**,而是「不许回到静态行」。 #[test] -fn the_general_group_is_still_demo_rows() { +fn the_language_row_is_not_a_static_string() { + for (label, value) in general::GROUP.rows { + assert_ne!( + *label, "Language", + "语言行必须走真控件(pages::Language),不许回到静态演示行" + ); + assert!( + !value.contains("English"), + "静态行里不许再出现写死的语言名:{value}" + ); + } +} + +/// 语言行**三档**,且在**两套目录**里都有词(中文界面不该出现 `⟪missing⟫`)。 +/// +/// 抓得住:①少一档(用户就没法切回去了);②标签 key 只加进了中文表(英文界面上一档变成 +/// `⟪missing⟫`);③回显那句的占位符名字与调用点对不上(会原样印出 `{language}`)。 +/// 抓不住:译文翻得对不对(金表 + `chinese_catalog_is_actually_chinese` 管那一层)。 +#[test] +fn the_language_row_offers_three_choices_in_both_languages() { + assert_eq!( + LangChoice::ALL.len(), + 3, + "跟随系统 / 简体中文 / 英文,少一档就切不回来" + ); + + for lang in Lang::ALL { + let catalog = lang.catalog(); + for choice in LangChoice::ALL { + assert!( + catalog.get(choice.label_key()).is_some(), + "{} 在 {} 目录里没词", + choice.label_key().name(), + lang.tag() + ); + } + + // 「界面当前使用:⟨语言名⟩」——中文系统下这句必须真的出现「简体中文」。 + let effective = catalog.text(LangChoice::Fixed(*lang).label_key()); + let line = catalog.render(Key::SettingsLanguageEffective, &[("language", effective)]); + assert!(line.contains(effective), "回显没带上语言名:{line}"); + assert!(!line.contains('{'), "占位符没被替换:{line}"); + } + + // 中文系统 + 跟随系统 ⇒ 回显是「简体中文」(用户报的 bug 的直接判据)。 + let zh = Lang::ZhCn.catalog(); + assert_eq!( + zh.render( + Key::SettingsLanguageEffective, + &[( + "language", + zh.text(LangChoice::Fixed(Lang::ZhCn).label_key()) + )] + ), + "界面当前使用:简体中文" + ); +} + +/// **绊线**:这一组仍不走 `Group.body`。 +/// +/// 为什么不是「接了真控件」:语言行的入口是 `pages::Language`(`GENERAL_GROUP` 那条分发), +/// 而 `Group.body` 那个槽位的签名是 `fn(&Ui, &pages::appearance::State)`——装不下「当前语言档位」。 +/// 谁把语言行塞进 `body`,就得先给它换签名(那会改到另外三组的文件),这条断言逼他先想一遍。 +#[test] +fn the_general_group_does_not_use_the_appearance_body_slot() { assert!( general::GROUP.body.is_none(), - "General 还没接真控件:接了就改本断言" + "General 的真控件走 pages::Language 入口(见 settings/mod.rs 的 GENERAL_GROUP),不是 body" ); } diff --git a/crates/app-iced/src/pages/settings/tests/mod.rs b/crates/app-iced/src/pages/settings/tests/mod.rs index e258d5b..3159dfc 100644 --- a/crates/app-iced/src/pages/settings/tests/mod.rs +++ b/crates/app-iced/src/pages/settings/tests/mod.rs @@ -136,6 +136,12 @@ fn the_split_kept_the_left_column_order() { /// 抓得住:谁把状态漏斗拆回去——让 `view` / `form` 直接收 `appearance::State` 或某一组的 `State` /// (那正是四条线「加一个字段就要改本文件」的形状,本次复审要求修掉的就是它)。 /// 抓不住:各组的**字段**(那些活在各组自己的文件里,本文件本来就不该知道,也就没有可断言的东西)。 +/// +/// **M106 的例外要读清楚**:General 已经接了真控件(语言行),但它**不走 `Group.body`**—— +/// 那个槽位的签名是 `fn(&Ui, States) -> Option`,`States` 只有 `appearance` + `groups` +/// 两格,装不下「当前语言档位 + 生效语言」。它走 `settings::view` 的 `language` 形参 + +/// `GENERAL_GROUP` 那条按下标分发,`body` 仍是 `None`,所以本断言不动。 +/// 判据没变:**`body: Some(..)` 仍然只有外观组**。 #[test] fn the_settings_module_does_not_name_any_group_state() { let source = include_str!("../mod.rs"); diff --git a/crates/app-iced/src/shell/mod.rs b/crates/app-iced/src/shell/mod.rs index 800f280..02b5d6a 100644 --- a/crates/app-iced/src/shell/mod.rs +++ b/crates/app-iced/src/shell/mod.rs @@ -40,6 +40,8 @@ use unlucky_design::icons::IconKind; use unlucky_design::spacing::Space; use unlucky_design::typography::TypeRole; +use unlucky_i18n::Lang; + use crate::app::Message; use crate::cli::ForcedState; use crate::icons; @@ -58,10 +60,12 @@ pub fn view_with_background<'a>( right: Option>, overlay: Option>, forced: Option, + lang: Lang, ) -> Element<'a, Message> { // 右栗(M104)是 `row` 的**第三个兄弟**而不是叠层:它是布局宽度的一部分,压在上面会让 // 中栗以为自己还有那么宽(那就是任务里禁止的「溢出」)。它的宽度由 `agent::docked` 自己声明。 - let mut work = row![nav::rail(ui, selected, forced), content] + // M106:导航轨标签按当前界面语言取词(`lang` 一路从 `App::lang()` 递进来)。 + let mut work = row![nav::rail(ui, selected, forced, lang), content] .width(Length::Fill) .height(Length::Fill); if let Some(right) = right { diff --git a/crates/app-iced/src/shell/nav.rs b/crates/app-iced/src/shell/nav.rs index 892bd5e..110cf76 100644 --- a/crates/app-iced/src/shell/nav.rs +++ b/crates/app-iced/src/shell/nav.rs @@ -21,6 +21,7 @@ use unlucky_design::icons::IconKind; use unlucky_design::shape::ShapeScale; use unlucky_design::spacing::{LayoutMetrics, Space}; use unlucky_design::typography::TypeRole; +use unlucky_i18n::{Key, Lang}; use crate::app::Message; use crate::cli::ForcedState; @@ -53,17 +54,25 @@ impl NavItem { NavItem::Ai, ]; - /// 显示名(i18n 是后续 mission;本 mission 用英文基线文案,避免干净 CI 上缺中文变豆腐块)。 - pub const fn label(self) -> &'static str { + /// 显示名的 key(M106 起走 catalog;两套目录都有词由 `catalog` 的完整性测试守)。 + pub const fn label_key(self) -> Key { match self { - NavItem::Instances => "Instances", - NavItem::Download => "Download", - NavItem::Logs => "Logs", - NavItem::Settings => "Settings", - NavItem::Ai => "Assistant", + NavItem::Instances => Key::NavInstances, + NavItem::Download => Key::NavDownload, + NavItem::Logs => Key::NavLogs, + NavItem::Settings => Key::NavSettings, + NavItem::Ai => Key::NavAi, } } + /// 显示名(按当前界面语言)。 + /// + /// 为什么带 `lang` 而不是读全局:这个函数要被单测与 harness 调,全局状态会让「同一份代码在 + /// 不同语言下画什么」不可断言。 + pub fn label(self, lang: Lang) -> &'static str { + lang.catalog().text(self.label_key()) + } + /// 令牌图标。 pub const fn icon(self) -> IconKind { match self { @@ -81,13 +90,14 @@ pub(super) fn rail<'a>( ui: &'a Ui, selected: NavItem, forced: Option, + lang: Lang, ) -> Element<'a, Message> { let metrics = ui.metrics(); let indicator = indicator_size(metrics); let items = NavItem::ALL .iter() - .map(|item| rail_item(ui, *item, *item == selected, indicator, forced)) + .map(|item| rail_item(ui, *item, *item == selected, indicator, forced, lang)) .collect::>(); // 轨顶留白走 container padding,而不是「先塞一个 spacer 元素」:后者会被 column 的 @@ -108,6 +118,7 @@ fn rail_item<'a>( selected: bool, indicator: (f32, f32), forced: Option, + lang: Lang, ) -> Element<'a, Message> { let (indicator_width, indicator_height) = indicator; @@ -145,7 +156,13 @@ fn rail_item<'a>( // 药丸会贴左缘(x=0..55 而不是 16..71)——r1 实测踩到的第二个坑。 let stack = column![ pill, - label(ui, TypeRole::LabelMedium, false, item.label(), label_color), + label( + ui, + TypeRole::LabelMedium, + false, + item.label(lang), + label_color + ), ] .spacing(ui.space(Space::X1)) .width(nav_rail_width(ui.metrics())) @@ -253,7 +270,7 @@ mod tests { fn every_nav_item_has_a_label_and_an_icon() { assert!((4..=6).contains(&NavItem::ALL.len())); for item in NavItem::ALL { - assert!(!item.label().is_empty(), "{item:?} 没有文案"); + assert!(!item.label(Lang::En).is_empty(), "{item:?} 没有文案"); assert!(!item.icon().icon().path.is_empty(), "{item:?} 没有图标"); } } diff --git a/crates/app-iced/src/ui.rs b/crates/app-iced/src/ui.rs new file mode 100644 index 0000000..8fbbf70 --- /dev/null +++ b/crates/app-iced/src/ui.rs @@ -0,0 +1,17 @@ +//! # 界面语言入口(M106 `I18NFOLLOW`) +//! +//! 用户报的原话:「我系统语言是中文 为什么打开软件是英文 并且设置页里面显示的是 English (System)」。 +//! 前半是**探测对了但没人用**(`app/view.rs` 里 `Lang::En` 写死),后半是一行硬编码假值。 +//! +//! 这个模块就是那条缺失的入口:把「用户选的档位 + M92 的探测结论」变成一个值,交给 +//! `pages::Model.lang`(目录)与设置页语言行(档位回显)。 +//! +//! | 文件 | 是什么 | +//! |---|---| +//! | [`lang`] | [`lang::LangState`]:启动判定(`ULL_LANG` > 配置 > 系统 locale > 英文)、切档、两行日志 | +//! +//! 为什么不叫 `theme::Ui` 的字段:`theme.rs` 是令牌求值器(颜色/字号/间距),语言不是令牌—— +//! 它是**运行期状态**,随设置变化,还要落盘。混进 `Ui` 会让「切主题」与「切语言」共享一条重建路径, +//! 而它们该重建的东西根本不是同一批(一个重建 Style,一个重建视图模型)。 + +pub mod lang; diff --git a/crates/app-iced/src/ui/lang.rs b/crates/app-iced/src/ui/lang.rs new file mode 100644 index 0000000..888c411 --- /dev/null +++ b/crates/app-iced/src/ui/lang.rs @@ -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"); + } +} diff --git a/crates/i18n/src/catalog.rs b/crates/i18n/src/catalog.rs index f6c70de..aee0d21 100644 --- a/crates/i18n/src/catalog.rs +++ b/crates/i18n/src/catalog.rs @@ -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, {} } diff --git a/crates/i18n/src/catalog/general.rs b/crates/i18n/src/catalog/general.rs new file mode 100644 index 0000000..8724c3a --- /dev/null +++ b/crates/i18n/src/catalog/general.rs @@ -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; diff --git a/crates/i18n/src/catalog/golden.rs b/crates/i18n/src/catalog/golden.rs index 589310b..486c956 100644 --- a/crates/i18n/src/catalog/golden.rs +++ b/crates/i18n/src/catalog/golden.rs @@ -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 的骨架回退。"), ]; diff --git a/crates/i18n/src/choice.rs b/crates/i18n/src/choice.rs new file mode 100644 index 0000000..f503a01 --- /dev/null +++ b/crates/i18n/src/choice.rs @@ -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`:`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 { + 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, ®istry_zh, &env_zh, Lang::En, ORIGIN_SETTING), + (zh, ®istry_en, &env_en, Lang::ZhCn, ORIGIN_SETTING), + // ② 跟随系统 → 系统源 + (follow, ®istry_zh, &env_en, Lang::ZhCn, "registry"), + (follow, ®istry_en, &env_zh, Lang::En, "registry"), + // ③ 系统源取不到 → 环境变量 + (follow, ¬hing, &env_zh, Lang::ZhCn, "env"), + (follow, ¬hing, &env_en, Lang::En, "env"), + // ④ 都没有 → 英文 + default(D28:纯英文环境不用管) + (follow, ¬hing, &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(®istry_en, &env_en).describe(), + "lang=zh-CN locale=zh_cn source=setting" + ); + assert_eq!( + follow.resolve(®istry_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"); + } +} diff --git a/crates/i18n/src/lib.rs b/crates/i18n/src/lib.rs index d6577fe..55c194e 100644 --- a/crates/i18n/src/lib.rs +++ b/crates/i18n/src/lib.rs @@ -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; diff --git a/crates/presenter/src/lib.rs b/crates/presenter/src/lib.rs index 937ee7a..8c8d669 100644 --- a/crates/presenter/src/lib.rs +++ b/crates/presenter/src/lib.rs @@ -113,6 +113,7 @@ pub mod mock_data; pub mod prefs; pub mod router; pub mod settings_bridge; +mod settings_bridge_lang; pub mod state; pub mod theme; pub mod toast; diff --git a/crates/presenter/src/settings_bridge.rs b/crates/presenter/src/settings_bridge.rs index e510c1b..50df91c 100644 --- a/crates/presenter/src/settings_bridge.rs +++ b/crates/presenter/src/settings_bridge.rs @@ -33,10 +33,11 @@ const DATA_ROOT_KEYS: [&str; 4] = [ #[derive(Debug)] pub struct SettingsBridge { /// `config/launcher.json`;`None` = 数据根解析不出来(内存态运行,不落盘) - path: Option, - store: Option, + pub(super) path: Option, + /// 设置文档(schema 校验 + 原子写的唯一入口) + pub(super) store: Option, /// 上次读/写之后的文件 `mtime`(热切探测用) - last_mtime: Option, + pub(super) last_mtime: Option, } impl SettingsBridge { diff --git a/crates/presenter/src/settings_bridge_lang.rs b/crates/presenter/src/settings_bridge_lang.rs new file mode 100644 index 0000000..b433302 --- /dev/null +++ b/crates/presenter/src/settings_bridge_lang.rs @@ -0,0 +1,150 @@ +//! 界面语言这一个键的读写(M106):`ui.language` 的**显式值**。 +//! +//! ## 为什么单独一个文件 +//! +//! `settings_bridge.rs` 是外观 / 主题 / 数据根那一摊的落点,已经贴着 400 行硬线;语言的读写只有 +//! 两个方法 + 一个「删键」辅助,独立出来之后两边都还能长。`impl SettingsBridge` 写在别的模块是 +//! Rust 允许的(同一 crate 的固有实现),只需要主文件把三个字段放成 `pub(super)`。 +//! +//! ## 两条判据(都跟 schema 的默认值有关) +//! +//! 1. **读的是显式值**,不是 `effective_str`:`ui.language` 的 schema 默认值是 `zh_cn`,用它会把 +//! 「没配过」读成「用户手选中文」——英文机器一启动就被强行切成中文(D28 违约)。 +//! 2. **「跟随系统」= 删掉这个键**,不是写哨兵值:允许值只有 `zh_cn` / `en_us`(枚举校验会拒别的), +//! 而「跟随系统」的语义本来就是「没有显式选择」。 +//! +//! 这两条都在 `.tower/comms/findings/` 里登了记(建议把 schema 默认值改成 `null` 或加 +//! `follow_system` 取值,那时本文件的删键路径可以简化成一次 `store.set`)。 + +use unlucky_core::setting::document::SettingDocument; +use unlucky_core::setting::keys::launcher_schema; +use unlucky_core::setting::store::SettingStore; + +use crate::settings_bridge::SettingsBridge; + +/// 界面语言键(M106):`zh_cn` / `en_us`(允许值见 `core::setting::keys::LANGUAGES`)。 +/// +/// **不写这个键 = 跟随系统**,所以它没有「system」这种取值,见 [`SettingsBridge::persist_ui_language`]。 +const UI_LANGUAGE: &str = "ui.language"; + +impl SettingsBridge { + /// 配置里 `ui.language` 的**显式值**(M106):`None` = 没写过 = **跟随系统**。 + /// + /// 不用 `effective_str`:那会把 schema 默认值(`zh_cn`)当成「用户手选中文」,于是**没配过** + /// 的英文机器会一启动就被强行切成中文(D28 的「纯英文环境不用管」直接违约)。 + #[must_use] + pub fn load_ui_language(&self) -> Option { + let store = self.store.as_ref()?; + store + .doc() + .get(UI_LANGUAGE) + .and_then(serde_json::Value::as_str) + .map(str::to_owned) + } + + /// 写 `ui.language`:`Some("zh_cn")` / `Some("en_us")` 手选,`None` = **跟随系统 = 删键**。 + /// + /// 返回是否真的落盘(只读 / 内存态时是 `false`,配置写不进去不该把 UI 卡住)。 + pub fn persist_ui_language(&mut self, value: Option<&str>) -> bool { + let Some(store) = self.store.as_mut() else { + return false; + }; + let changed = match value { + Some(language) => store + .set(UI_LANGUAGE, serde_json::Value::String(language.to_owned())) + .is_ok(), + None => store.doc().get(UI_LANGUAGE).is_some() && remove_ui_language(store), + }; + if !changed { + return false; + } + let saved = store.save().is_ok(); + if let Some(path) = self.path() { + self.last_mtime = std::fs::metadata(path) + .ok() + .and_then(|meta| meta.modified().ok()); + } + saved + } +} + +/// 把 `ui.language` 从文档里摘掉:`SettingDocument` 没有 remove API,而落盘写的是**全量视图**, +/// 所以把全量视图里的那个键去掉再 load 回一份文档就等价于删键(用户手写的未知键也原样带回)。 +fn remove_ui_language(store: &mut SettingStore) -> bool { + let mut value = store.doc().to_value(); + let removed = value + .get_mut("ui") + .and_then(serde_json::Value::as_object_mut) + .and_then(|ui| ui.remove("language")) + .is_some(); + if !removed { + return false; + } + + let (mut doc, _problems) = SettingDocument::load(&launcher_schema(), &value); + // 落盘的闸门是 `is_dirty()`(`write_document` 写全量视图,不是脏字段补丁)。 + doc.mark_all_dirty(); + *store.doc_mut() = doc; + true +} + +#[cfg(test)] +mod tests { + use super::*; + use std::path::PathBuf; + + fn temp_config(label: &str) -> PathBuf { + let dir = std::env::temp_dir().join(format!( + "ull-bridge-lang-{label}-{}-{}", + std::process::id(), + line!() + )); + std::fs::create_dir_all(dir.join("config")).expect("临时目录"); + let path = dir.join("config/launcher.json"); + let _ = std::fs::remove_file(&path); + path + } + + /// 抓得住:①把 `effective_str` 当读法(英文机器没配过 → 被默认值 `zh_cn` 强行切成中文); + /// ②「跟随系统」写成哨兵值(枚举校验会拒、下次启动会撒谎);③删键时连别的键一起删。 + /// 抓不住:UI 是否真的按这个值选目录(那是 `app-iced/src/ui/lang.rs` 的往返测试)。 + #[test] + fn ui_language_round_trips_and_follow_system_removes_only_that_key() { + let path = temp_config("roundtrip"); + 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); + + // 手选落盘 → 新开的 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)); + + // 内存态桥不许 panic + assert_eq!(SettingsBridge::memory_only().load_ui_language(), None); + std::fs::remove_dir_all(path.parent().unwrap().parent().unwrap()).ok(); + } +}