feat(app-iced,i18n): M106 —— 界面跟随系统语言 + 设置页真实语言行

用户原话:「我系统语言是中文 为什么打开软件是英文 并且设置页里面显示的是 English(system)」。
两处根源:①M92 的探测链一直是对的,但 `pages::Model.lang` 在 `app/view.rs` 里写死成
`Lang::En`(`pages::render` 还把下载页写死成英文),探测结果没人用;②设置页语言行是硬编码
字面量 `("Language", "English (System)")`。

新增/改动(按层):
- i18n:新域 `catalog/general.rs`(24 条中英双份 key,装配链尾追加)+ `catalog/golden.rs`
  只追加不重排;`choice.rs`(`LangChoice`:跟随系统/手选 + D28 四级优先级真值表 + 字体优先级真值表)。
- app-iced 语言入口 `src/ui.rs` + `src/ui/lang.rs`:`LangState` = 用户档位 + 探测结论;
  启动判定 `ULL_LANG` > 配置 `ui.language` > 系统 locale > 英文;两行日志(探测/生效 + 中文策略)。
- `app/state.rs`:`App` 持有 `LangState`,切语言 = 换目录 + 落盘 + 重建视图模型;
  `app/view.rs` 的 `model()` 收 `lang`,不再写死英文。
- 设置页:语言行换真控件(跟随系统 / 简体中文 / 英文,回显「界面当前使用:简体中文」);
  页头、左列分组名、逐实例空态、导航轨标签全部走 catalog。
- presenter:`settings_bridge_lang.rs`(`load_ui_language` / `persist_ui_language`)。
  「跟随系统」= **删键**(schema 允许值只有 zh_cn/en_us),读的是 `doc().get()` 而不是
  `effective_str`(后者会把默认值 `zh_cn` 当成用户手选,英文机器一启动就被切成中文)。

验证:408 条 app-iced lib 测试 + 28 条 i18n 测试 + 全仓 `cargo test --workspace --exclude
unlucky-app` 全绿;fmt / clippy `-D warnings` 干净;出图 6 张(en/zh × 浅/深 + 下载页对照)
在 `DevDocs/artifacts/m106/`(该目录被 .gitignore 忽略,按仓内惯例作为本地验收产物)。
This commit is contained in:
AskaEth committed 2026-11-04 22:50:09 +08:00
1 parent 777d02e7cb
commit 89dcb6bdf3
25 files changed
+1456 -92

No files matched your search

+70 -3
View File
@@ -20,6 +20,7 @@ use std::time::Instant;
use iced::window;
use unlucky_design::color::SchemeFlavor;
use unlucky_i18n::Lang;
use unlucky_presenter::backend::{build_backend, config_path, AppOptions};
use unlucky_presenter::dto::Backend;
use unlucky_presenter::prefs::ThemeMode;
@@ -30,6 +31,7 @@ use unlucky_presenter::toast::ToastKind;
use crate::cli::Options;
use crate::pages;
use crate::theme::{Families, Ui};
use crate::ui::lang::LangState;
use super::WINDOW_SIZE;
@@ -58,6 +60,11 @@ pub(super) const IDLE_TICK: std::time::Duration = std::time::Duration::from_secs
/// 3 秒够真 host 把「命令已受理」的第一条事件交回来;之后由「有 Busy/Running 实例」这个条件续订。
pub(super) const WATCH_WINDOW: std::time::Duration = std::time::Duration::from_secs(3);
/// 设置页左侧分组的取证开关:`ULL_SETTINGS_GROUP=0` 直达 General(语言行在那里)。
///
/// 只影响 `--screenshot` 那一次进程(见 [`App::with_presenter`]);交互模式按用户点的那一组。
pub(super) const SETTINGS_GROUP_ENV: &str = "ULL_SETTINGS_GROUP";
/// 进程内「run() 开始」的时刻(首帧耗时基线)。
pub(super) static STARTED: OnceLock<Instant> = OnceLock::new();
/// 首帧日志只打一次。
@@ -86,6 +93,11 @@ pub struct App {
/// 用来定优先级:`--theme` > 配置文件 > 系统主题。没有它,`Message::SystemTheme` 会无条件
/// 盖掉配置文件的显式主题(M90 r3 修)。
pub(super) theme_mode: ThemeMode,
/// 界面语言(M106):用户档位 + 探测结论 + 目录选择。见 [`crate::ui::lang`]。
///
/// 为什么不在 `Ui` 里:`theme.rs` 是令牌求值器(颜色/字号),而语言是**运行期状态**——
/// 随设置变、要落盘、要重建视图模型(切主题只重建 Style)。
pub(super) lang: LangState,
/// 上一次推进节拍的时刻(节拍由帧事件驱动,按真实流逝时间累加)。
pub(super) last_tick: Instant,
/// 窗口宽度(布局档位的判据;交互模式跟着 resize 事件走)。
@@ -188,17 +200,33 @@ impl App {
// 面板 α / 玻璃的降级判据要的是**实际**后端,不是 `--renderer` 的请求值(复审 r1 P2-2):
// 默认 `Auto` + 无独显时请求是 Auto、实际是 tiny-skia,只看请求值就会漏掉 D36 的强制项。
let backend_tier = super::backend::effective_backend(options.backend);
let appearance = pages::appearance::State::new(
let mut appearance = pages::appearance::State::new(
&appearance_prefs,
&appearance_base,
backend_tier,
flavor,
);
let view = pages::Model::new(
// 取证开关(D30,与 `ULL_START_ROUTE` 同一条思路):`ULL_SETTINGS_GROUP=<下标>` 选设置页的
// 左侧分组。设置页出厂停在「外观」(`APPEARANCE_GROUP`),而「语言」那一行在 General 组——
// 出图必须能直达,否则验收图里根本没有用户抱怨的那一行。**只在截图通道生效**:交互模式
// 一律按用户点的那一组。
if options.screenshot.is_some() {
if let Some(index) = std::env::var(SETTINGS_GROUP_ENV)
.ok()
.and_then(|raw| raw.trim().parse::<usize>().ok())
{
appearance.selected_group = index;
}
}
// 界面语言(M106):**M92 已经探测对了,问题是一直没人用**——`pages::Model.lang` 此前在
// `view::model` 里被写死成 `Lang::En`,于是中文系统上开出来的是全英文界面。
let lang = LangState::boot(&bridge, options.screenshot.is_some());
let mut view = pages::Model::new(
pages::Screen::Instances,
width,
Some(pages::Screen::Instances.nav()),
);
view.lang = lang.lang();
let mut app = Self {
ui: Ui::new(flavor, families),
@@ -210,6 +238,7 @@ impl App {
ticks: 0,
config_due_at: unlucky_presenter::theme::CONFIG_POLL_TICKS,
theme_mode,
lang,
last_tick: Instant::now(),
width,
view,
@@ -232,6 +261,10 @@ impl App {
};
// 配色来源档可能已经指向一张背景图:那时 `Ui` 必须由解析出的方案构造,而不是 baseline。
app.rebuild_ui();
// 两行日志:①档位 + 探测结论 + 生效语言 ②中文策略(D28 缺字体不许静默)。
// 下一个人据此分得清「探测错了」还是「探测对了但没用上」。
app.lang.log();
app.lang.log_cjk();
app
}
@@ -295,7 +328,8 @@ impl App {
// 否则会出现「选项区英文、取消弹窗中文」的同屏两语(复审 P2-2)。
if self.state.install_cancelled != self.install_cancelled_seen {
self.install_cancelled_seen = self.state.install_cancelled;
let text = pages::cancelled_toast(super::view::ui_lang());
// M106:提示语跟着**生效语言**走(M105 留的 `view::ui_lang()` 占位到此收口)。
let text = pages::cancelled_toast(self.lang());
self.state.toasts.push(ToastKind::Info, text);
}
@@ -307,12 +341,14 @@ impl App {
let draft = self.download_draft.clone();
// 右栗占掉的宽度先扣掉:面板展开时页面按更窄的宽度排版(「收缩而不是溢出」,M104)
let width = self.agent.content_width(self.width);
// M106:语言形参跟着 `LangState` 走(`draft` 那一位是 M105 的安装草稿)。
self.view = super::view::model(
&self.state,
width,
self.options.force_state,
category,
&draft,
self.lang(),
);
self.view_dirty = false;
self.refreshes += 1;
@@ -342,6 +378,37 @@ impl App {
pub(super) fn ui(&self) -> &Ui {
&self.ui
}
/// 当前生效的界面语言(= 目录选择;页面文案一律由它派生)。
pub(super) fn lang(&self) -> Lang {
self.lang.lang()
}
/// 设置页语言行要的两件:档位(画选中态)+ 生效语言(画回显)。
pub(super) fn language(&self) -> pages::Language {
self.lang.for_settings()
}
/// 换界面语言(设置页语言行 → `pages::Message::Language`)。
///
/// 三件事缺一不可:①档位与结论换掉 ②视图模型重建(`view_dirty` + `refresh_view`,目录是
/// 视图模型的一部分)③落盘(重启后保持所选语言)。字体不在这里重扫:中文档缺字体时的
/// 判据/信号归 `i18n::font::decide` + `fonts/cjk.rs`(见报告「没验的部分」)。
pub(super) fn set_language(&mut self, choice: unlucky_i18n::LangChoice) {
if choice == self.lang.choice() {
return;
}
self.lang = LangState::of(choice);
self.lang.log();
if !self.bridge.persist_ui_language(choice.setting_value()) {
eprintln!(
"[i18n] 语言偏好没落盘(配置只读或数据根解析不出来):本次生效,重启会回到旧值"
);
}
self.lang.log_cjk();
self.view_dirty = true;
self.refresh_view();
}
}
/// 启动参数 → presenter 的后端选项。
@@ -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 链、给子枚举加通配之后再漏变体),
+20 -12
View File
@@ -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<crate::cli::ForcedState>,
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<crate::cli::ForcedState>) -> 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]
+21 -1
View File
@@ -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 {
+8 -7
View File
@@ -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));
+2
View File
@@ -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;
+11
View File
@@ -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),
}
/// 页面动作清单(骨架期够用就行;接线时按业务语义重命名/扩充)。
+18 -4
View File
@@ -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]
+37 -13
View File
@@ -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::<Vec<_>>(),
)
.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))
+207 -11
View File
@@ -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<Element>`,`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::<Vec<_>>())
.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()
}
+80 -12
View File
@@ -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<usize>, states: States<'a>) -> Element<'a, Message> {
fn form<'a>(
ui: &'a Ui,
only: Option<usize>,
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<usize>, 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<usize>, 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<usize>, 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)
}
@@ -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<Element>`,
//! `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"
);
}
@@ -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<Element>`,`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");
+5 -1
View File
@@ -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<Element<'a, Message>>,
overlay: Option<Element<'a, Message>>,
forced: Option<ForcedState>,
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 {
+27 -10
View File
@@ -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<ForcedState>,
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::<Vec<_>>();
// 轨顶留白走 container padding,而不是「先塞一个 spacer 元素」:后者会被 column 的
@@ -108,6 +118,7 @@ fn rail_item<'a>(
selected: bool,
indicator: (f32, f32),
forced: Option<ForcedState>,
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:?} 没有图标");
}
}
+17
View File
@@ -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;
+260
View File
@@ -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");
}
}
+4
View File
@@ -34,6 +34,7 @@
//! | [`nav`] / [`instances`] / [`loader`] / [`launch`] / [`font`] / [`core_text`] | 页面与运行时文案 | 各自页面 |
//! | [`download`] / [`logs`] | 下载页 / 日志页(M94 / M95) | 那两页 |
//! | `settings/`(`general` / `appearance` / `java` / `download` / `ai`) | 设置页,**一个分组一个文件** | 设置页四条线 |
//! | [`general`] | 设置页 General 分组的**真控件**文案(语言选择器三档 + 主题/启动页/关闭三行) | M106 `I18NFOLLOW`(挂在链尾,既有顺序不动) |
//!
//! ### 域文件的形状(加 key 时照抄)
//!
@@ -115,6 +116,7 @@ mod agent;
mod core_text;
mod download;
mod font;
mod general;
mod instances;
mod launch;
mod loader;
@@ -132,6 +134,7 @@ use agent::agent_entries;
use core_text::core_text_entries;
use download::download_entries;
use font::font_entries;
use general::general_entries;
use instances::instances_entries;
use launch::launch_entries;
use loader::loader_entries;
@@ -158,6 +161,7 @@ nav_entries! {
download_entries,
logs_entries,
agent_entries,
general_entries,
finish,
{}
}
+60
View File
@@ -0,0 +1,60 @@
//! 设置页 General 分组**真控件**的文案(M106 `I18NFOLLOW`):语言选择器 + 其余三行。
//!
//! ## 与 `catalog/settings/general.rs` 的分工
//!
//! `catalog/settings/general.rs`(M99 建的)只有两条**标题**([`SettingsLanguage`] /
//! [`SettingsLanguageHint`]),是设置页五域装配的一部分;本文件是 M106 接真控件时新增的
//! **选择器文案**:三档选项名、选中后的「界面当前使用」回显,以及 General 分组里另外三行
//! (主题 / 启动页 / 关闭按钮)。
//!
//! 为什么要分两个文件:`catalog.rs` 的装配链是**追加**式的,新域挂在链尾就不会动到既有
//! `Key` 的顺序(= `catalog/golden.rs` 那张逐字节金表)。两个文件同属 General 分组,读代码时
//! 按「标题 vs 选项」区分即可;真要合并也是一个文件搬十行的活,不急着在接线的 mission 里做。
//!
//! ## 选项名为什么不是「语言自称」
//!
//! 常见做法是三档都写 `简体中文` / `English` 这样的自称。这里没有:`catalog/tests.rs` 有一条
//! `chinese_catalog_is_actually_chinese`(英文表与中文表逐 key 必须不同),自称会让「中文表里
//! 写着 English」被判成漏译。所以选项名**跟着界面语言走**:中文界面「跟随系统 / 简体中文 / 英文」,
//! 英文界面 "Follow system / Chinese (Simplified) / English"——这也是多数启动器的做法。
//!
//! 域文件的形状见 [`crate::catalog`] 的「域装配」一节。
macro_rules! general_entries {
($next:ident, $($rest:ident,)* { $($acc:tt)* }) => {
$next! { $($rest,)* {
$($acc)*
// ── General 分组(语言选择器 + 其余三行)
SettingsLanguageFollowSystem => ("Follow system", "跟随系统"),
SettingsLanguageZhCn => ("Chinese (Simplified)", "简体中文"),
SettingsLanguageEn => ("English", "英文"),
SettingsLanguageEffective => ("Interface right now: {language}", "界面当前使用:{language}"),
SettingsTheme => ("Theme", "主题"),
SettingsThemeFollowSystem => ("Follow system", "跟随系统"),
SettingsStartupPage => ("Startup page", "启动页"),
SettingsStartupInstances => ("Instances", "实例"),
SettingsCloseButton => ("Close button", "关闭按钮"),
SettingsCloseToTray => ("Exit to tray", "退出到托盘"),
// 设置页页头(M106):标题复用 `NavSettings`,这三条是副标题与两个动作。
SettingsSubtitle => ("Launcher behaviour, Java runtimes and download sources", "启动器行为、Java 运行时与下载源"),
SettingsResetSection => ("Reset section", "重置本组"),
SettingsSave => ("Save", "保存"),
// 第五条导航项(M106 给导航轨补的最后一条)。放在本域而不是 `catalog/nav.rs`:
// 金表按**位置**逐字节比,插进 nav 域会把既有 4 条导航行整体平移一位。
NavAi => ("Assistant", "助手"),
// 设置页左列的分组名(M106)。`Group.name` 是**id**(测试按它认组),显示名走这里。
SettingsGroupGeneral => ("General", "通用"),
SettingsGroupAppearance => ("Appearance", "外观"),
SettingsGroupJava => ("Java", "Java 运行时"),
SettingsGroupDownloads => ("Downloads", "下载"),
// 逐实例覆盖的空态(M106)
SettingsOverridesEmptyTitle => ("No per-instance overrides", "没有逐实例覆盖"),
SettingsOverridesEmptyBody => ("Overrides let one instance keep its own Java runtime, memory or JVM flags while the rest of the launcher keeps the defaults above.", "逐实例覆盖让某个实例保留自己的 Java 运行时、内存或 JVM 参数,其余实例沿用上面的默认值。"),
SettingsOverridesAdd => ("Add override", "新增覆盖"),
// `Route::Agent` 的回退卡正文(M106:`app/view.rs` 里最后一条硬编码英文)
AgentFallbackBody => ("The assistant page is not built yet. Chat lives in the docked panel once the AI mission lands; this screen is the M68 skeleton fallback.", "助手页还没做。AI 接线落地后对话会出现在右侧停靠面板;这一屏是 M68 的骨架回退。"),
} }
};
}
pub(super) use general_entries;
+24
View File
@@ -124,4 +124,28 @@ pub(super) const GOLDEN: &[(&str, &str, &str)] = &[
("AgentRoleYou", "You", "你"),
("AgentRoleAssistant", "Assistant", "助手"),
("AgentRoleNotice", "Notice", "提示"),
// ── M106 `I18NFOLLOW`:General 分组真控件(语言选择器 + 其余三行)。**只追加在链尾**,
// 上一行到 `LogsEmptyAction` 的既有顺序一字未动(金表要证明的正是这件事)。
("SettingsLanguageFollowSystem", "Follow system", "跟随系统"),
("SettingsLanguageZhCn", "Chinese (Simplified)", "简体中文"),
("SettingsLanguageEn", "English", "英文"),
("SettingsLanguageEffective", "Interface right now: {language}", "界面当前使用:{language}"),
("SettingsTheme", "Theme", "主题"),
("SettingsThemeFollowSystem", "Follow system", "跟随系统"),
("SettingsStartupPage", "Startup page", "启动页"),
("SettingsStartupInstances", "Instances", "实例"),
("SettingsCloseButton", "Close button", "关闭按钮"),
("SettingsCloseToTray", "Exit to tray", "退出到托盘"),
("SettingsSubtitle", "Launcher behaviour, Java runtimes and download sources", "启动器行为、Java 运行时与下载源"),
("SettingsResetSection", "Reset section", "重置本组"),
("SettingsSave", "Save", "保存"),
("NavAi", "Assistant", "助手"),
("SettingsGroupGeneral", "General", "通用"),
("SettingsGroupAppearance", "Appearance", "外观"),
("SettingsGroupJava", "Java", "Java 运行时"),
("SettingsGroupDownloads", "Downloads", "下载"),
("SettingsOverridesEmptyTitle", "No per-instance overrides", "没有逐实例覆盖"),
("SettingsOverridesEmptyBody", "Overrides let one instance keep its own Java runtime, memory or JVM flags while the rest of the launcher keeps the defaults above.", "逐实例覆盖让某个实例保留自己的 Java 运行时、内存或 JVM 参数,其余实例沿用上面的默认值。"),
("SettingsOverridesAdd", "Add override", "新增覆盖"),
("AgentFallbackBody", "The assistant page is not built yet. Chat lives in the docked panel once the AI mission lands; this screen is the M68 skeleton fallback.", "助手页还没做。AI 接线落地后对话会出现在右侧停靠面板;这一屏是 M68 的骨架回退。"),
];
+258
View File
@@ -0,0 +1,258 @@
//! 设置页的**界面语言档位**(M106 `I18NFOLLOW`):跟随系统 / 手选某一门。
//!
//! ## 为什么单独一个文件
//!
//! `lang.rs` 讲的是「系统说哪种语言」(locale → [`Lang`] 的探测,M92),本文件讲的是「用户要哪种
//! 语言」——两者的判据不一样:探测只能尽量理解别人给的字符串,档位是我们自己写进配置的,写错就该
//! 暴露。放在一起会让 `lang.rs` 顶破 400 行(任务书那条硬线要求按功能拆文件)。
//!
//! ## D28 的判定顺序(整条挂在 [`LangChoice::resolve`] 上)
//!
//! **手选 > 系统语言 > 环境变量 > 英文**。最后一级「英文」不是偷懒:纯英文环境不扫、不下载中文
//! 字体(零额外体积 / 零网络),这条由 [`crate::font::decide`] 兜住,真值表见下面
//! `english_never_needs_a_chinese_font_and_chinese_always_resolves_one`。
//!
//! ## 「跟随系统」怎么表达
//!
//! **不写 `ui.language` 这个键**([`LangChoice::setting_value`] 返回 `None`)。不写哨兵值是因为
//! 两件事:① schema 的允许值只有 `zh_cn` / `en_us`(`core::setting::keys::LANGUAGES`),写别的
//! 会被枚举校验拒掉;② 读的那一头(`SettingBridge::load_ui_language`)只认显式值,所以「键不存在」
//! 与「用户选了跟随系统」本来就是同一件事。
use crate::catalog::Key;
use crate::lang::{resolve, Detection, Lang, LocaleSource, ORIGIN_SETTING};
/// 设置页「界面语言」的三档选择。
///
/// 为什么不是 `Option<Lang>`:`None` 与「用户明确勾了跟随系统」在类型上分不开,而设置页要把那一档
/// **画成选中**。
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
pub enum LangChoice {
/// 跟随系统(= 配置里没有 `ui.language`)。
System,
/// 用户手选的固定语言。
Fixed(Lang),
}
impl LangChoice {
/// 全部档位(顺序即设置页语言行的按钮顺序)。
pub const ALL: &'static [LangChoice] = &[
LangChoice::System,
LangChoice::Fixed(Lang::ZhCn),
LangChoice::Fixed(Lang::En),
];
/// 配置里 `ui.language` 的原文 → 档位。
///
/// `None`(键不存在)与**不认识的值**都算「跟随系统」——与 [`resolve`] 跳过坏值的行为同源:
/// 配置被 AI / CLI 写脏时退回系统语言,而不是猜成某一种。
#[must_use]
pub fn from_setting(value: Option<&str>) -> LangChoice {
value
.and_then(Lang::from_setting)
.map_or(LangChoice::System, LangChoice::Fixed)
}
/// 落盘用:跟随系统 = `None`(**删掉那个键**,不写哨兵值——哨兵会让「跟随系统」永远失效)。
#[must_use]
pub const fn setting_value(self) -> Option<&'static str> {
match self {
LangChoice::System => None,
LangChoice::Fixed(lang) => Some(lang.setting_value()),
}
}
/// 这一档在设置页上的名字(key 在 `catalog/general.rs` 域)。
#[must_use]
pub const fn label_key(self) -> Key {
match self {
LangChoice::System => Key::SettingsLanguageFollowSystem,
LangChoice::Fixed(Lang::ZhCn) => Key::SettingsLanguageZhCn,
LangChoice::Fixed(Lang::En) => Key::SettingsLanguageEn,
}
}
/// 这一档最终生效的语言 + 来源(日志与设置页回显都用它)。
///
/// 跟随系统 → 走 [`resolve`] 的三级链;手选 → 直接赢,`origin = setting`
/// (`raw` 写配置值,日志里能看出「这是用户手选的」)。
#[must_use]
pub fn resolve(self, platform: &dyn LocaleSource, env: &dyn LocaleSource) -> Detection {
match self {
LangChoice::System => resolve(None, platform, env),
LangChoice::Fixed(lang) => Detection {
lang,
raw: Some(lang.setting_value().to_owned()),
origin: ORIGIN_SETTING,
},
}
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::lang::ORIGIN_DEFAULT;
#[test]
fn choice_round_trips_through_the_config_value() {
// 抓得住:①「跟随系统」被写成哨兵值——那会让 `Lang::from_setting` 认不出、下次启动退回
// 系统语言,「手选」这件事静默丢失;②档位顺序被调换(设置页按钮顺序)。
// 抓不住:配置文件里到底有没有这个键(那是 `SettingsBridge` 的读写,见 presenter 侧测试)。
assert_eq!(LangChoice::from_setting(None), LangChoice::System);
assert_eq!(
LangChoice::from_setting(Some("zh_cn")),
LangChoice::Fixed(Lang::ZhCn)
);
assert_eq!(
LangChoice::from_setting(Some("en_us")),
LangChoice::Fixed(Lang::En)
);
for bogus in ["zh-CN", "system", "", "fr_fr"] {
assert_eq!(
LangChoice::from_setting(Some(bogus)),
LangChoice::System,
"{bogus}"
);
}
assert_eq!(LangChoice::System.setting_value(), None, "跟随系统不写键");
for choice in LangChoice::ALL {
assert_eq!(
LangChoice::from_setting(choice.setting_value()),
*choice,
"{} 落盘后读不回来",
choice.setting_value().unwrap_or("<跟随系统>")
);
}
assert_eq!(
LangChoice::ALL,
&[
LangChoice::System,
LangChoice::Fixed(Lang::ZhCn),
LangChoice::Fixed(Lang::En)
][..]
);
}
#[test]
fn choice_resolution_truth_table() {
// D28 的四级优先级,**逐档逐源**列出来:手选 > 系统 > 环境变量 > 英文。
// 抓得住:优先级顺序被改(例如手选的英文被中文系统盖掉)、或「跟随系统」直接落英文。
// 抓不住:这些源在真机上读到什么(`SystemLocale` 的真机行为在 `app-iced` 侧)。
struct Named(&'static str, Option<&'static str>);
impl LocaleSource for Named {
fn locale(&self) -> Option<String> {
self.1.map(str::to_owned)
}
fn label(&self) -> &'static str {
self.0
}
}
let registry_zh = Named("registry", Some("zh-CN"));
let registry_en = Named("registry", Some("en-US"));
let nothing = Named("registry", None);
let env_zh = Named("env", Some("zh_CN.UTF-8"));
let env_en = Named("env", Some("en_US.UTF-8"));
let env_none = Named("env", None);
let follow = LangChoice::System;
let en_choice = LangChoice::Fixed(Lang::En);
let zh = LangChoice::Fixed(Lang::ZhCn);
// (档位, 平台源, 环境变量源) -> (生效语言, 来源)
let table: &[(LangChoice, &dyn LocaleSource, &dyn LocaleSource, Lang, &str)] = &[
// ① 手选压过一切(系统是中文、环境是中文,手选英文就画英文)
(en_choice, &registry_zh, &env_zh, Lang::En, ORIGIN_SETTING),
(zh, &registry_en, &env_en, Lang::ZhCn, ORIGIN_SETTING),
// ② 跟随系统 → 系统源
(follow, &registry_zh, &env_en, Lang::ZhCn, "registry"),
(follow, &registry_en, &env_zh, Lang::En, "registry"),
// ③ 系统源取不到 → 环境变量
(follow, &nothing, &env_zh, Lang::ZhCn, "env"),
(follow, &nothing, &env_en, Lang::En, "env"),
// ④ 都没有 → 英文 + default(D28:纯英文环境不用管)
(follow, &nothing, &env_none, Lang::En, ORIGIN_DEFAULT),
];
for (choice, platform, env, lang, origin) in table {
let detection = choice.resolve(*platform, *env);
assert_eq!(
(detection.lang, detection.origin),
(*lang, *origin),
"{choice:?} platform={:?} env={:?}",
platform.locale(),
env.locale()
);
}
// 手选档的 `raw` 是配置值(日志一眼看出「用户手选的」),跟随系统档的 `raw` 是系统原文。
assert_eq!(
zh.resolve(&registry_en, &env_en).describe(),
"lang=zh-CN locale=zh_cn source=setting"
);
assert_eq!(
follow.resolve(&registry_zh, &env_en).describe(),
"lang=zh-CN locale=zh-CN source=registry"
);
}
#[test]
fn english_never_needs_a_chinese_font_and_chinese_always_resolves_one() {
// D28 字体那一级的真值表(`font::decide` 是纯函数,这里把语言 × 系统字体库全组合扫一遍):
// 抓得住:①英文环境被接上中文字体/下载(「零额外体积/零网络」的直接违约);②中文环境扫不到
// 字体却不给下载信号(会白块);③扫到了却不用系统字体(会白下 20 MB)。
// 抓不住:本机到底装没装这些族(`app-iced` 的 `fonts/cjk.rs` 把真字体库喂进来)。
use crate::font::{self, Strategy, CJK_FAMILIES, NOTO_SANS_SC};
let has_font =
|families: &'static [&'static str]| move |family: &str| families.contains(&family);
for installed in [
has_font(&[]),
has_font(&["Segoe UI"]),
has_font(CJK_FAMILIES),
] {
assert_eq!(
font::decide(Lang::En, &installed),
Strategy::NotNeeded,
"英文环境不许要中文字体"
);
}
assert_eq!(
font::decide(Lang::ZhCn, &has_font(&["Segoe UI"])),
Strategy::NeedsDownload(&NOTO_SANS_SC)
);
let first = CJK_FAMILIES[0];
let installed = has_font(&["Segoe UI", "Microsoft YaHei UI"]);
assert_eq!(first, "Microsoft YaHei UI");
assert_eq!(
font::decide(Lang::ZhCn, &installed),
Strategy::System { family: first }
);
}
#[test]
fn every_choice_has_a_label_in_both_catalogs() {
// 抓得住:设置页语言行接了一个 catalog 里不存在的 key(画出来是 `⟪missing:…⟫`)。
// 抓不住:译文翻得对不对(金表 + `chinese_catalog_is_actually_chinese` 管那一层)。
let mut keys = Vec::new();
for choice in LangChoice::ALL {
let key = choice.label_key();
for lang in Lang::ALL {
assert!(
lang.catalog().get(key).is_some(),
"{key:?} 在 {} 目录里缺词",
lang.tag()
);
}
keys.push(key);
}
keys.sort_unstable();
keys.dedup();
assert_eq!(keys.len(), LangChoice::ALL.len(), "两档用了同一个 key");
}
}
+3
View File
@@ -7,6 +7,7 @@
//! | 模块 | 职责 | 不负责 |
//! |---|---|---|
//! | [`lang`] | `Lang`(en / zh-CN)+ locale 探测(源可注入)+ **四级优先级** [`lang::resolve`] | 读注册表 / 读配置 / spawn 进程 |
//! | [`choice`] | `LangChoice`(跟随系统 / 手选):设置页那一档 + 它到生效语言的映射 | 落盘(那是 `presenter::settings_bridge`) |
//! | [`catalog`] | `Key` 枚举 + 中英两套常量表 + 取词 / 占位符渲染 + 缺译诊断 | 排版、字号、断行 |
//! | [`font`] | 中文环境扫系统 CJK 字体的**判据** + 缺字体时的「需下载」信号 + 注入式下载器接口 | 联网、写文件、装字体 |
//! | [`bridge`] | core / presenter 的状态串 → `Key` 的**示例契约**(文案边界规则全文在这里) | 改 core 的产出 |
@@ -35,8 +36,10 @@
pub mod bridge;
pub mod catalog;
pub mod choice;
pub mod font;
pub mod lang;
pub use catalog::{Catalog, Key};
pub use choice::LangChoice;
pub use lang::Lang;
+1
View File
@@ -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;
+4 -3
View File
@@ -33,10 +33,11 @@ const DATA_ROOT_KEYS: [&str; 4] = [
#[derive(Debug)]
pub struct SettingsBridge {
/// `config/launcher.json`;`None` = 数据根解析不出来(内存态运行,不落盘)
path: Option<PathBuf>,
store: Option<SettingStore>,
pub(super) path: Option<PathBuf>,
/// 设置文档(schema 校验 + 原子写的唯一入口)
pub(super) store: Option<SettingStore>,
/// 上次读/写之后的文件 `mtime`(热切探测用)
last_mtime: Option<SystemTime>,
pub(super) last_mtime: Option<SystemTime>,
}
impl SettingsBridge {
@@ -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<String> {
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();
}
}