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

+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");