feat(m179): 外观分组接真语言并拆成五段文件(只接管子,不补文案)

M172 审计:`pages/appearance/**` 中文界面下真正本地化 0/27,两个根因各断一刀。

① 拆文件(纯搬移):`section.rs` 395 行 → `section/` 五个文件,行数守卫只剩 5 行余量,
   不拆就加不了任何东西。
   - `section/mod.rs`(壳:section() + card() + 五个行控件 + 取词口 t)
   - `section/window.rs`(① 整窗 α + ② 圆角,含 notice/说明段)
   - `section/panel.rs`(③ 面板 α)、`section/bg_image.rs`(④⑤⑥⑦⑧)、
     `section/colour_source.rs`(⑨)
   取证:每段与原文件做「去空白/去尾逗号」diff,4/4 相同;只有五处机械改写
   (`rows = rows.push(…)`→`rows.push(…)`、`t(Key::X)`→`t(Key::X, lang)`、
   `super::notice`→`super::super::notice`、一处 `.into()`、rustfmt 换行)。

② 语言接到渲染层:`settings::Body` 从 `fn(&Ui, States)` 变成 `fn(&Ui, States, Lang)`,
   `form` 传 `language.effective`,`settings/appearance.rs` 收 `lang` 转给
   `pages::appearance::section(ui, state, lang)`,`section::t(key, lang)` 取词。
   选「接进唯一那个槽位」而不是再给 `form` 加一条按下标分发的 `else if`:`settings/mod.rs`
   贴着 400 行硬顶(M195 还要在此落地),这条路净增 0 行且不可能与分发表漂开。
   验收点:`pages/appearance/**` 里已经搜不到 `Lang::En`。

③ 能变红的守卫:`the_appearance_group_speaks_chinese_when_the_interface_does`
   —— 走 `settings::view` 真分发渲染一帧,断言取词记录里含汉字、英文那帧不含汉字且同一 key
   中英译文不同。取证口是 `appearance::probe`(`#[cfg(test)]` thread_local):iced 0.14
   没有可遍历控件树的公开接口(`Widget::children()` 返回状态 Tree;`operate` 要 Renderer),
   所以取证点退到「字进树之前」的唯一取词口。变异证明:把 `t()` 改回 `Lang::En.catalog()`
   ⇒ 该用例红(失败信息逐条列出四条英文);还原 ⇒ 绿。

只接语言管子,不补文案:本组其余英文仍是字面量(行号见 M179 报告)。真实窗口截图、
macOS/Linux 字体差异未验。
This commit is contained in:
AskaEth committed 2026-11-08 20:42:28 +08:00
1 parent e386cfbeee
commit b457f0acc8
14 files changed
+745 -422

No files matched your search

@@ -5,20 +5,25 @@
//! 「Appearance」这个名字**;分组在左列的下标仍由 `crate::pages::appearance::APPEARANCE_GROUP`
//! 声明,`settings/tests/mod.rs` 拿它和本文件比对(顺序被调换会红)。
//!
//! **这一组归 language+appearance 线**:真控件的实现全在 `pages/appearance/section.rs`(本文件只负责
//! **这一组归 language+appearance 线**:真控件的实现全在 `pages/appearance/section/`(本文件只负责
//! 把它挂进注册表),所以改控件时不用碰设置页目录。
//!
//! M101 r2:本组的渲染器签名改成收 [`States`](组状态 + 外观状态),于是「这一组要读哪些状态」
//! 由本文件决定——`settings/mod.rs` 不再认识 `appearance::State`。
//!
//! M179 `I18NSEAM`:签名再加一个 `lang`(`Group.body` 的槽位本身就带语言,见 `settings/mod.rs` 的
//! `Body` 类型别名),于是这一组的中文界面文案真的走词表——此前它只能写死 `Lang::En`,
//! 因为渲染器拿不到「这一次生效的语言」。
use iced::Element;
use unlucky_design::icons::IconKind;
use unlucky_i18n::Lang;
use crate::theme::Ui;
use super::{Group, Message, States};
/// Appearance 分组。`body` 指着 [`body`](它再转给 `pages/appearance/section.rs` 的真控件);
/// Appearance 分组。`body` 指着 [`body`](它再转给 `pages/appearance/section/` 的真控件);
/// 没有外观运行期状态时(harness / `pages_demo`)返回 `None`,由 `form` 退回下面的演示行。
pub(super) const GROUP: Group = Group {
name: "Appearance",
@@ -33,8 +38,10 @@ pub(super) const GROUP: Group = Group {
};
/// 本组的真控件渲染器:有外观状态就画真控件(M93),没有(harness)返回 `None`。
fn body<'a>(ui: &'a Ui, states: States<'a>) -> Option<Element<'a, Message>> {
///
/// `lang` 一路递给 `pages::appearance::section`——它是这一组唯一知道「界面现在是什么语言」的东西。
fn body<'a>(ui: &'a Ui, states: States<'a>, lang: Lang) -> Option<Element<'a, Message>> {
states
.appearance
.map(|state| crate::pages::appearance::section(ui, state))
.map(|state| crate::pages::appearance::section(ui, state, lang))
}
+15 -12
View File
@@ -25,7 +25,7 @@
//! | 分组 | 文件(正文 / 状态 / 消息) | 谁会动它 |
//! |---|---|---|
//! | General | [`general`] | language+appearance 线(语言下拉 + 主题 / 启动页) |
//! | Appearance | [`appearance`] | language+appearance 线(真控件已在 `pages/appearance/section.rs`) |
//! | Appearance | [`appearance`] | language+appearance 线(真控件已在 `pages/appearance/section/`) |
//! | Java | [`java`] | java 线 |
//! | Downloads | [`download`] | download 线 |
//! | Updates | [`update`] | update 线(M159,按下标分发) |
@@ -117,7 +117,7 @@
//! 与 M106 同形状),要么读 `Groups` 里本线的字段。两种形状都是「谁先撞谁付一次钱」的一次性代价,
//! 不是永久性四路撞车。
//! 5. **首帧取数 / 异步发起**(同上,r2 复审 P1 补)—— [`Body`] 是
//! `fn(&Ui, States) -> Option<Element>`,返回不了 `Task`;而且 `settings::view` → `pages::render`
//! `fn(&Ui, States, Lang) -> Option<Element>`,返回不了 `Task`;而且 `settings::view` → `pages::render`
//! → `App::view` **一路都返回 `Element`**(iced 0.14 的 `view` 也只返回 `Element`,`view(&self)`
//! 又改不了状态),**view 期间产出的 `Task` 没有出口**。所以只有一条路:在 `app/update.rs` 的
//! `Message::Boot` 分支补一次首帧派发(**1 个共享文件**——`on_boot` 本来就在派
@@ -193,13 +193,16 @@ impl Default for Language {
}
}
/// 一个分组正文的**真控件渲染器**:入参是「画一帧时那份只读状态」(组状态 + 外观状态),
/// 返回 `None` = 这一组现在没东西可画(例如外观分组在 harness 路径下没有外观状态),
/// 由 [`form`] 退回演示行。
/// 一个分组正文的**真控件渲染器**:入参是「画一帧时那份只读状态」(组状态 + 外观状态)
/// 与**这一次生效的语言**,返回 `None` = 这一组现在没东西可画(例如外观分组在 harness 路径下
/// 没有外观状态),由 [`form`] 退回演示行。
///
/// 各组自己的渲染器**定义在自己文件里**,签名是这一条:于是「这一组要读哪些状态」由它自己决定,
/// 本文件不需要认识任何一组的字段(r1 复审 P1 要的正是这一点)。
type Body = for<'a> fn(&'a Ui, States<'a>) -> Option<Element<'a, Message>>;
///
/// `lang` 是 M179 加的:槽位此前拿不到语言,唯一挂了真控件的外观组只能写死英文;接进**唯一的那个
/// 槽位**比再给 [`form`] 加一条按下标分发的 `else if` 更省行数(本文件贴着 400 行硬顶)。
type Body = for<'a> fn(&'a Ui, States<'a>, Lang) -> Option<Element<'a, Message>>;
/// 一个设置分组:名字、图标、演示行、(可选)把这一组正文画成真控件的渲染器。
///
@@ -236,9 +239,9 @@ const SELECTED: usize = 0;
/// General 分组在 [`GROUPS`] 里的下标(0)。它与 [`SELECTED`] 同值**是巧合,不是依赖**。
///
/// 为什么需要它:语言行要的是「当前档位 + 生效语言」([`Language`]),而 `Group.body` 的签名只带
/// [`States`](外观 + 组状态)——给 `States` 加字段要动 `settings/state.rs` 与 4 处 struct literal
/// (见本文件文档「仍然共享」第 4 条),给 `Body` 加形参会令另外三组各自的 `body` 函数编译不过。
/// 为什么需要它:语言行要的是「当前档位 + 生效语言」([`Language`]),而 `Group.body` 只带
/// [`States`] 与**生效语言**(`Lang`,M179)——档位 `LangChoice` 装不进去(`States` 也装不下:
/// 加字段要动 `settings/state.rs` 与 4 处 struct literal,见本文件文档「仍然共享」第 4 条)。
/// 所以这里做一次**按下标的分发**:代价是一行 `if`,换来 `general::GROUP.body` 仍是 `None`
/// (`tests/mod.rs` 的「只有外观组带真控件 body」照样成立)。
const GENERAL_GROUP: usize = 0;
@@ -315,8 +318,8 @@ pub fn view<'a>(
///
/// **分发点(两条路,M106 起就并存)**:
///
/// 1. `GROUP.body`——`Some(f)` 让 `f` 自己去 [`States`] 里取它要的数据(`None` 返回值 ⇒ 退回
/// 演示行),`None` 直接画演示行([`frame::demo_card`]);
/// 1. `GROUP.body`——`Some(f)` 让 `f` 自己去 [`States`] 里取它要的数据、并从形参拿**生效语言**
/// (`None` 返回值 ⇒ 退回演示行),`None` 直接画演示行([`frame::demo_card`]);
/// 2. **按下标分发**——General(要语言档位)、Update(要 presenter 快照)、Assistant(要 `Lang`
/// 与测试结论)、Java(要 `Lang` 与扫描槽)四组在下面各有 `else if`,它们的 `GROUP.body`
/// 仍是 `None`(理由见各自的 `<组>_GROUP` 常量)。
@@ -357,7 +360,7 @@ fn form<'a>(
} else {
group
.body
.and_then(|body| body(ui, states))
.and_then(|body| body(ui, states, language.effective))
.unwrap_or_else(|| frame::demo_card(ui, group.rows))
};
@@ -2,12 +2,25 @@
//! 一条 section 断言都没有;这 3 条是新增,不是搬移)。
//!
//! **归 language+appearance 线**:这是设置页唯一接了真控件的一组,真控件实现全在
//! `pages/appearance/section.rs`;本文件只守「注册表这一侧的接线」。
//! `pages/appearance/section/`;本文件只守「注册表这一侧的接线」。
//! 本组已经把 `body` 接上了;`tests/mod.rs` 那条「只有一组有真控件」的**聚合绊线已在 M101 r2 删除**
//! (被四条 per-group 断言完全蕴含——代价是「唯一性」从此没人守,那段说明写在 `tests/mod.rs` 同处)。
//!
//! M179 `I18NSEAM` 加的第 4 条守的是**语言真的进到了渲染层**(此前这一组在中文界面上 0/27 条本地化)。
use super::super::appearance;
use super::super::{demo_groups, view, Language, States};
use unlucky_design::color::SchemeFlavor;
use unlucky_design::icons::IconKind;
use unlucky_i18n::catalog::Key;
use unlucky_i18n::{Lang, LangChoice};
use unlucky_presenter::prefs::{AppearanceBase, AppearancePrefs};
use crate::cli::Backend;
use crate::pages::appearance::{probe, State as AppearanceState};
use crate::pages::layout::{frame_metrics, PaneKit};
use crate::pages::Update;
use crate::theme::{Families, Ui};
/// 这一组的**演示行**(没有外观运行期状态时才画)。
///
@@ -50,3 +63,109 @@ fn the_appearance_group_keeps_a_real_control_body() {
`body` 是本文件那个本地 fn;具体渲染器由那一行源码 + 截图担保,函数指针相等性断言不了)"
);
}
// --------------------------------------------------------------- 语言(M179 I18NSEAM)
/// **本组的语言绊线**:中文界面下,外观分组渲染出来的字里**必须含汉字**。
///
/// 走的是真路:`settings::view`(真语言分发)→ `Group.body(lang)` → `pages::appearance::section`
/// → `section::t(key, lang)`;取词口 `t` 把每条译文记进 `pages::appearance::probe`,测试断言这批字。
///
/// 为什么不在 `Element` 上遍历:iced 0.14 **没有**可遍历控件树的公开接口(`Widget::children()`
/// 返回的是状态 `Tree`;走 `operate` 要 `Tree` + 布局 + `Renderer`,单测里没有渲染器)。取证点
/// 因此退到「字进树之前」的唯一取词口,代价是**看不见像素**——字体能不能画出汉字要截图判
/// (M179 报告「没验的部分」列了这条)。
///
/// 抓得住:① `section::t` 退回写死 `Lang::En`(M179 之前正是这一行 ⇒ 中文界面上这一组 0/27
/// 条本地化);② `settings::view` 不把 `language.effective` 递给 `Group.body`(语言到不了渲染层);
/// ③ 五个 key 的译文在金表里丢了中文。
/// 抓不住:本组其余文案仍是英文**字面量**(没有 key,M172 口径的 27 条里占 22 条)——这条守的是
/// 语言的**管子**,不是文案;补文案是下一条 mission(M179 报告逐行列了那些字面量)。
#[test]
fn the_appearance_group_speaks_chinese_when_the_interface_does() {
let zh = appearance_texts(Lang::ZhCn);
assert!(
!zh.is_empty(),
"外观分组一条词表文案都没取到:渲染路径没走到 `section::t`(语言管子断了?)"
);
assert!(
zh.iter()
.any(|(key, _)| *key == Key::SettingsAppearanceCorner),
"渲染路径没画到「窗口圆角」那一行:取词记录里没有它的 key,{zh:?}"
);
let han: Vec<&str> = zh
.iter()
.map(|(_, text)| *text)
.filter(|text| text.chars().any(is_han))
.collect();
assert!(
!han.is_empty(),
"中文界面下外观分组渲染出来的字全是非汉字(`section::t` 没跟随语言):{zh:?}"
);
// 反向:英文界面下同一批字里不许出现汉字(否则「含汉字」可能来自写死的中文,而不是语言管子)。
let en = appearance_texts(Lang::En);
assert!(
en.iter().all(|(_, text)| !text.chars().any(is_han)),
"英文界面下外观分组出现了汉字:{en:?}"
);
let corner = |texts: &[(Key, &'static str)]| {
texts
.iter()
.find(|(key, _)| *key == Key::SettingsAppearanceCorner)
.map(|(_, text)| *text)
};
assert_ne!(
corner(&zh),
corner(&en),
"同一个 key 在中英两种语言下取到了同一条译文:语言没生效"
);
}
/// 画一帧设置页(选中「外观」分组)并取走这一帧从词表取到的全部译文。
fn appearance_texts(lang: Lang) -> Vec<(Key, &'static str)> {
const FAMILIES: Families = Families {
text: "Segoe UI",
display: "Segoe UI",
mono: "Consolas",
};
let ui = Ui::new(SchemeFlavor::Light, FAMILIES);
let state = AppearanceState::new(
&AppearancePrefs::factory(),
&AppearanceBase::factory(),
Backend::Auto,
SchemeFlavor::Light,
);
assert_eq!(
state.selected_group,
crate::pages::appearance::APPEARANCE_GROUP,
"工厂态默认选中「外观」分组(否则这一帧根本画不到本组)"
);
// 前面几条用例不取词,这里先清一次,避免把别的渲染留下的记录算进来。
probe::take();
let metrics = frame_metrics(PaneKit::Supporting, 1180.0);
let update = Update::default();
let _tree = view(
&ui,
metrics,
States {
appearance: Some(&state),
groups: demo_groups(),
},
Language {
choice: LangChoice::System,
effective: lang,
},
&update,
);
probe::take()
}
/// 有没有汉字(判据与 `app/view/tests/launch.rs` 的 `is_cjk` 同一段码位)。
fn is_han(ch: char) -> bool {
('\u{4e00}'..='\u{9fff}').contains(&ch)
}
@@ -8,8 +8,8 @@
//! 假值不许回来,语言行的三档必须真的能在两套目录里取到词。
//!
//! 接线形状:语言行的正文由 [`general::body`] 画(入口是 `pages::settings` 的 `GENERAL_GROUP`
//! 按下标分发),`GROUP.body` 仍是 `None`——那个槽位的签名是 `fn(&Ui, States) -> Option<Element>`,
//! `States` 只有 `appearance` + `groups` 两格,装不下「当前语言档位」。所以 M101 r2 的
//! 按下标分发),`GROUP.body` 仍是 `None`——那个槽位现在带**生效语言**(`Lang`,M179)但仍是
//! `States` + 语言,装不下「当前语言档位」(`LangChoice`)。所以 M101 r2 的
//! 「本组接真控件只需改两个文件」这条口径,在语言行上**仍然是两个文件**。
use super::super::general;
@@ -187,8 +187,8 @@ fn the_language_row_offers_three_choices_in_both_languages() {
/// **绊线**:这一组仍不走 `Group.body`。
///
/// 为什么不是「接了真控件」:语言行的入口是 `pages::Language`(`GENERAL_GROUP` 那条分发),
/// 而 `Group.body` 那个槽位的签名是 `fn(&Ui, &pages::appearance::State)`——装不下「当前语言档位」。
/// 谁把语言行塞进 `body`,就得先给它换签名(那会改到另外三组的文件),这条断言逼他先想一遍。
/// 而 `Group.body` 那个槽位只拿到 `States` 与**生效语言**(`Lang`,M179)——装不下「当前语言档位」。
/// 谁把语言行塞进 `body`,就得先给它换签名(那会改到别人那个组的文件),这条断言逼他先想一遍。
#[test]
fn the_general_group_does_not_use_the_appearance_body_slot() {
assert!(
@@ -40,7 +40,8 @@ fn the_java_group_keeps_the_folder_icon_from_the_visual_review() {
/// `body` 槽位仍是 `None`:本组靠 `settings/mod.rs` 的按下标分发([`java::JAVA_GROUP`])画正文。
///
/// 抓得住:谁把 `body: Some(..)` 挂上来却不给它 `Lang`(那样中文界面上这一组会退回英文)。
/// 抓得住:谁把 `body: Some(..)` 挂上来(M179 之后槽位自带 `Lang`,所以「挂了 body 却没语言」
/// 这种漂移不再可能——挂上来就必然拿到语言;这条守的是**分发形状**别变)。
/// 抓不住:分发那一行本身有没有被删(`settings/mod.rs` 的 `form` 里有 `else if`;删了这组就空白)。
#[test]
fn the_java_group_is_dispatched_by_index_not_by_body() {
@@ -163,9 +163,10 @@ fn the_split_kept_the_left_column_order() {
/// 抓不住:各组的**字段**(那些活在各组自己的文件里,本文件本来就不该知道,也就没有可断言的东西)。
///
/// **M106 的例外要读清楚**:General 已经接了真控件(语言行),但它**不走 `Group.body`**——
/// 那个槽位的签名是 `fn(&Ui, States) -> Option<Element>`,`States` 只有 `appearance` + `groups`
/// 两格,装不下「当前语言档位 + 生效语言」。它走 `settings::view` 的 `language` 形参 +
/// `GENERAL_GROUP` 那条按下标分发,`body` 仍是 `None`,所以本断言不动。
/// 那个槽位的签名是 `fn(&Ui, States, Lang) -> Option<Element>`(`Lang` 是 M179 加的),
/// `States` 只有 `appearance` + `groups` 两格、槽位只有**生效语言**,都装不下「当前语言档位 +
/// 生效语言」这一对。它走 `settings::view` 的 `language` 形参 + `GENERAL_GROUP` 那条按下标分发,
/// `body` 仍是 `None`,所以本断言不动。
/// 判据没变:**`body: Some(..)` 仍然只有外观组**。
#[test]
fn the_settings_module_does_not_name_any_group_state() {