Merge branch 'feat/m101-settingsmsg-settings-group-message'

This commit is contained in:
AskaEth committed 2026-11-04 00:05:46 +08:00
commit 3f228ae092
28 files changed
+1188 -261

No files matched your search

+7
View File
@@ -119,6 +119,12 @@ pub struct App {
pub(super) appearance_prefs: unlucky_presenter::prefs::AppearancePrefs,
/// 窗口句柄(`Message::WindowHandle` 之后才有;非 Windows 平台恒 `None`)。
pub(super) hwnd: Option<isize>,
/// 设置页各组自己的运行期状态(M101 r2 的**状态漏斗**)。
///
/// 所有者在 `pages/settings/state.rs` 的 `Groups`:加/改某一组的字段只改那一组自己的文件,
/// 本字段与 `app/view.rs` 都不用动(`settings::States` 只看 `&Groups`,不看任何一组的字段)。
/// 放在字段列表**末尾**:后续 mission 若也要往这里加字段,冲突面最小。
pub(super) settings: pages::settings::Groups,
}
impl App {
@@ -202,6 +208,7 @@ impl App {
appearance,
appearance_prefs,
hwnd: None,
settings: pages::settings::Groups::default(),
};
// 配色来源档可能已经指向一张背景图:那时 `Ui` 必须由解析出的方案构造,而不是 baseline。
app.rebuild_ui();
+1
View File
@@ -44,6 +44,7 @@ use super::CAPTURE_AT_FRAME;
mod download;
mod page_messages;
mod settings;
impl App {
/// 一条消息进来,改状态并返回后续任务。
@@ -38,6 +38,9 @@ impl App {
pages::Message::Launch(id) => self.dispatch(AppCommand::LaunchInstance(id)),
pages::Message::Stop(id) => self.dispatch(AppCommand::StopInstance(id)),
pages::Message::Appearance(change) => self.on_appearance(change),
// M101:设置页的四条填页线共用一个投递口;组内变体在 `update/settings.rs` 里穷尽分发
// (那一层没有 `_ =>`,见本文件末尾与 `update/settings/tests.rs` 的两条守门)
pages::Message::Settings(group) => return self.on_settings(group),
// M95:摘要落剪贴板(文本由页面造,这里只搬)
pages::Message::CopySummary(text) => return iced::clipboard::write(text),
// 下面四支已由 `on_download` 提前消化;显式列出而不是 `_ => {}`,见模块文档
@@ -0,0 +1,55 @@
//! 设置页分组消息的**二级分发**(M101 `SETTINGSMSG`):
//! `pages::Message::Settings(GroupMessage)` → 四条填页线各自的 handler。
//!
//! ## 为什么分两级
//!
//! `app/update/page_messages.rs` 对 [`pages::Message`] 做穷尽匹配(没有 `_ =>`,有
//! `the_page_match_has_no_wildcard_fallback` 守门)。设置页的四条填页线若各自往 `pages::Message`
//! 里加变体,就都得改那个文件——四路并行必然冲突。M101 把它们收进一个投递口
//! `pages::Message::Settings(GroupMessage)`,本文件再把 `GroupMessage` 分给四条线:
//!
//! ```text
//! GroupMessage::Java(m) → settings/java.rs
//! GroupMessage::Download(m) → settings/download.rs
//! GroupMessage::LanguageAppearance(m) → settings/language_appearance.rs
//! GroupMessage::AiAbout(m) → settings/ai_about.rs
//! ```
//!
//! ## 穷尽性:抓得住什么 / 抓不住什么
//!
//! - **抓得住**:某条线给 `GroupMessage` 加了载荷变体(= 在自己文件里加了一支消息)却忘了在本
//! match 里接线 → **E0004**;四个 handler 里对空枚举的 `match message {}` 一旦有人写成
//! `_ => {}`,`the_group_dispatch_has_no_wildcard_fallback` 会红(兜底会把「漏接线」从编译
//! 错误降级成「点了没反应」,`page_messages.rs` 的同类守卫是这么写的,这里照抄)。
//! - **抓不住**:handler 忽略自己的消息(`let _ = message; Task::none()`)、两个 handler 身体写反
//! 或写成一样——类型对得上就编得过,只有人读能看出来。也因此「接错了组」的一半是编译期兜住的
//! (载荷类型不同 ⇒ 编不过),另一半(接了个空壳)不是。
//!
//! ## 加一支设置页消息要改哪
//!
//! 自己那条线的两个文件:消息口(`pages/settings/<线>.rs`)+ 落点(本目录 `<线>.rs`)。
//! 本文件的 match 只在 `GroupMessage` **加变体**时才动,而那意味着新增一条线,不是加一支消息。
use super::*;
impl App {
/// 设置页的分组消息:分给四条线各自的文件,本文件不认识任何一支具体消息。
pub(super) fn on_settings(&mut self, message: pages::settings::GroupMessage) -> Task<Message> {
match message {
pages::settings::GroupMessage::Java(message) => self.on_settings_java(message),
pages::settings::GroupMessage::Download(message) => self.on_settings_download(message),
pages::settings::GroupMessage::LanguageAppearance(message) => {
self.on_settings_language_appearance(message)
}
pages::settings::GroupMessage::AiAbout(message) => self.on_settings_ai_about(message),
}
}
}
mod ai_about;
mod download;
mod java;
mod language_appearance;
#[cfg(test)]
mod tests;
@@ -0,0 +1,17 @@
//! AI / About 分组消息的落点(M101 `SETTINGSMSG`):**只归 ai+about 线**。
//!
//! 消息口在 `pages/settings/ai_about.rs`(`pages::settings::ai_about::Message`)。
//!
//! 首版是空枚举上的 `match message {}`:这是**穷尽**的写法(不是兜底),加了变体不接线就报 E0004。
use super::super::*;
impl App {
/// AI / About 分组的一条消息。首版没有可处理的东西(空枚举)。
pub(super) fn on_settings_ai_about(
&mut self,
message: pages::settings::ai_about::Message,
) -> Task<Message> {
match message {}
}
}
@@ -0,0 +1,18 @@
//! Downloads 分组消息的落点(M101 `SETTINGSMSG`):**只归 download 线**。
//!
//! 消息口在 `pages/settings/download.rs`(`pages::settings::download::Message`)。与下载页
//! (`pages/download*` 与 `app/update/download.rs`)无关:那是「装哪个版本」,这一组是「怎么下」。
//!
//! 首版是空枚举上的 `match message {}`:这是**穷尽**的写法(不是兜底),加了变体不接线就报 E0004。
use super::super::*;
impl App {
/// Downloads 分组的一条消息。首版没有可处理的东西(空枚举)。
pub(super) fn on_settings_download(
&mut self,
message: pages::settings::download::Message,
) -> Task<Message> {
match message {}
}
}
@@ -0,0 +1,33 @@
//! Java 分组消息的落点(M101 `SETTINGSMSG`):**只归 java 线**。
//!
//! 消息口在 `pages/settings/java.rs`(`pages::settings::java::Message`)。加一支消息 = 改那两个
//! 文件,`pages/mod.rs` / `page_messages.rs` / `app/update/settings.rs` 都不用动。
//!
//! 消息进来的通道有两条,都不经过共享文件:
//! 1. **页面点击**:`pages::Message::Settings(GroupMessage::Java(..))` → `on_page` → `on_settings`;
//! 2. **异步结果**:本文件自己构造 `Message::Page(pages::Message::Settings(GroupMessage::Java(..)))`
//! (`app::Message::Page` 是既有通道,不用加新变体)。
//!
//! 穷尽性:这里对 `java::Message` 做**穷尽**匹配(没有 `_ =>`、没有 `other =>`)。加了变体不接线
//! 立刻 E0004;写成兜底会把编译错误降级成「点了没反应」,`app/update/settings/tests.rs` 的
//! `the_group_dispatch_has_no_catch_all_arm` 会红。
use super::super::*;
impl App {
/// Java 分组的一条消息。
pub(super) fn on_settings_java(
&mut self,
message: pages::settings::java::Message,
) -> Task<Message> {
match message {
// M101 r2 的**活的端到端证据**:只有测试构造这一支(java 线接第一个真控件时换成真变体)。
// 它真的改一个 app 状态,所以「消息从外壳走到 handler」这件事有运行时证据,不只是编译期。
#[cfg(test)]
pages::settings::java::Message::Ping => {
self.view_dirty = true;
Task::none()
}
}
}
}
@@ -0,0 +1,19 @@
//! 语言 + 外观两组消息的落点(M101 `SETTINGSMSG`):**只归 language+appearance 线**。
//!
//! 消息口在 `pages/settings/language_appearance.rs`(`pages::settings::language_appearance::Message`)。
//! 为什么两组共用一个消息口:它俩本就是同一条线的两个分组(General 的语言 / 主题 / 启动页,
//! Appearance 的密度 / 圆角 / 动效 / 文字缩放),`GroupMessage` 只给这条线留一个变体。
//!
//! 首版是空枚举上的 `match message {}`:这是**穷尽**的写法(不是兜底),加了变体不接线就报 E0004。
use super::super::*;
impl App {
/// 语言 / 外观线的一条消息。首版没有可处理的东西(空枚举)。
pub(super) fn on_settings_language_appearance(
&mut self,
message: pages::settings::language_appearance::Message,
) -> Task<Message> {
match message {}
}
}
@@ -0,0 +1,267 @@
//! 设置页分组消息分发的**守门测试**(M101 `SETTINGSMSG`)。
//!
//! 判据与 `app/update/page_messages.rs` 里那条 `the_page_match_has_no_wildcard_fallback` 同源:
//! 兜底会把「加了变体忘了接线」从**编译错误**降级成「点了没反应」。两级 dispatch 各守一条:
//! 那一层守 `pages::Message`,本文件守 `GroupMessage` 与四个 handler。
//!
//! ## 判据(r2 复审 P2-3 之后收窄过一次)
//!
//! 上一版的判据是「除已知前缀外,任何含 `=>` 的行都算兜底」——**过宽**:往 handler 里加一个
//! **没有兜底臂**的嵌套 match(`match true { true => …, false => … }`),或者 rustfmt 把长
//! or-pattern 折行成 `| X =>`,都会让它假红。四条填页线一写真实逻辑就撞,守门反而推高成本。
//!
//! 现在的判据是**结构化的**:只找 `match message {` 这一层(用大括号深度计数把嵌套 match 排除
//! 在外),**只看深度 1 的臂**——
//!
//! - `app/update/settings.rs` 的 `on_settings`:恰好 **4 条**臂,每条以 `pages::settings::GroupMessage::`
//! (或短写 `GroupMessage::`)开头;
//! - 四个 handler:每条臂以该组自己的 `pages::settings::<线>::Message::`(或短写 `Message::`)开头,
//! 还没有臂的(空枚举 `match message {}`)一条臂都没有;
//! - 以 `|` 开头的折行 or-pattern 续行放行(它不可能是兜底)。
//!
//! ## 抓得住什么 / 抓不住什么
//!
//! 抓得住:
//! 1. `on_settings` 里加 `_ =>` 或 binding 臂 `other =>`(`GroupMessage` 不再穷尽);
//! 2. handler 把 `match message {}` 改成 `_ => Task::none()` / `other => { … }`;
//! 3. `on_settings` 的臂数不是 4(删了一条、或把两条合并);
//! 4. handler 的臂写成别的组的变体路径(前缀不对)。
//!
//! 抓不住(老实列出来):
//! 1. **嵌套 match 里的兜底**——深度 ≥2 的臂不看。这是刻意的:嵌套 match 的穷尽性由它自己的类型
//! 保证,跟 `GroupMessage` 漏接线无关;代价是没人守「嵌套 match 自己写了个 `_ =>`」;
//! 2. **跨行 pattern 分两种,别当一种**:以 `|` 开头的 or-pattern 续行**放行**(不误红);
//! 但**花括号形状**的多行 pattern(例如 `Message::SetRuntime {` 折成多行、`=>` 落在后面)
//! 会被**静默跳过**——那一行不带 `=>`,于是既不算臂也不算违规(臂数 0、零违规),是**漏检**
//! 而不是误红。两种都是洞,当前代码风格都不出现;真要用这种写法时把规则补上,别把守门删掉;
//! 3. **字符串/字符字面量里的 `{` `}`** 会扰乱深度计数(本文件与四个 handler 都没有这种写法);
//! 4. handler 忽略消息(`let _ = message; Task::none()`)、两个 handler 身体写反或写成一样
//! ——类型对得上就编得过,只有人读能看出来;
//! 5. `match message` 被改名或换变量名时找不到匹配,臂数为 0 —— 这时
//! `the_group_dispatch_has_exactly_four_arms` 会红(不是静默通过)。
//!
//! 站位:本文件是 `app::update::settings::tests`,`include_str!` 的路径相对本文件所在目录。
use unlucky_design::color::SchemeFlavor;
use unlucky_presenter::mock::MockBackend;
use unlucky_presenter::settings_bridge::SettingsBridge;
use crate::app::message::Message;
use crate::app::state::App;
use crate::cli::Options;
use crate::pages;
use crate::theme::Families;
/// `on_settings` 的臂允许的前缀。
const GROUP_ARMS: &[&str] = &["pages::settings::GroupMessage::", "GroupMessage::"];
/// 一行的净大括号增量(`{` 加、`}` 减)。
fn brace_delta(line: &str) -> i32 {
let open = line.matches('{').count() as i32;
let close = line.matches('}').count() as i32;
open - close
}
/// 扫一条 `match message { … }`,只看**本层**(深度 1)的臂。
///
/// 返回 `(本层的合法臂数, 违规行)`:违规 = 本层里含 `=>`、又不是「已知前缀开头」的行
/// (`|` 开头的折行 or-pattern 续行放行)。嵌套 match 的臂在深度 ≥2,整段跳过。
fn dispatch_arms(path: &str, source: &str, allowed: &[&str]) -> (usize, Vec<String>) {
let mut arms = 0usize;
let mut offending = Vec::new();
let mut depth = 0i32;
let mut inside = false;
for (index, line) in source.lines().enumerate() {
let trimmed = line.trim();
if trimmed.starts_with("//") {
continue;
}
if !inside {
if trimmed.contains("match message {") {
inside = true;
depth = 1;
}
continue;
}
if depth == 1 && trimmed.contains("=>") {
let pattern = trimmed.trim_start_matches('|').trim_start();
if allowed.iter().any(|prefix| pattern.starts_with(prefix)) {
arms += 1;
} else if !trimmed.starts_with('|') {
offending.push(format!("{path}:{}: {trimmed}", index + 1));
}
}
depth += brace_delta(trimmed);
if depth <= 0 {
break;
}
}
(arms, offending)
}
/// 不碰磁盘的 App(mock 后端 + 内存设置桥),与 `app/tests.rs` 的 `headless` 同一副骨架。
fn headless() -> App {
const FAMILIES: Families = Families {
text: "Segoe UI",
display: "Segoe UI",
mono: "Consolas",
};
App::with_presenter(
Options::default(),
FAMILIES,
SchemeFlavor::Light,
Box::new(MockBackend::new()),
SettingsBridge::memory_only(),
)
}
// --------------------------------------------------------------- 守门本体
/// `on_settings` 与四个 handler 的**本层**臂里不许出现任何形式的兜底(`_ =>` 或 `other =>`)。
#[test]
fn the_group_dispatch_has_no_catch_all_arm() {
let sources: [(&str, &str, &[&str]); 5] = [
(
"app/update/settings.rs",
include_str!("../settings.rs"),
GROUP_ARMS,
),
(
"app/update/settings/java.rs",
include_str!("java.rs"),
&["pages::settings::java::Message::", "Message::"],
),
(
"app/update/settings/download.rs",
include_str!("download.rs"),
&["pages::settings::download::Message::", "Message::"],
),
(
"app/update/settings/language_appearance.rs",
include_str!("language_appearance.rs"),
&[
"pages::settings::language_appearance::Message::",
"Message::",
],
),
(
"app/update/settings/ai_about.rs",
include_str!("ai_about.rs"),
&["pages::settings::ai_about::Message::", "Message::"],
),
];
let mut offending = Vec::new();
for (path, source, allowed) in sources {
let (_, bad) = dispatch_arms(path, source, allowed);
offending.extend(bad);
}
assert!(
offending.is_empty(),
"设置页分组消息的 dispatch 本层出现了兜底臂({offending:?}):\
任何 catch-all / binding 臂都会取消 `GroupMessage` 的穷尽性,\
新变体漏接线时编译器不再报 E0004"
);
}
/// `on_settings` 必须**恰好四条**臂,一条一条显式写全(不是「至少」)。
///
/// 抓得住:删掉一条臂、把两条合并、或者把 `match message` 改名(改名后扫不到臂 ⇒ 0 ≠ 4)。
/// 抓不住:臂的顺序(顺序不影响语义)、每条臂接到了哪个 handler(类型对得上就编得过)。
#[test]
fn the_group_dispatch_has_exactly_four_arms() {
let (arms, _) = dispatch_arms(
"app/update/settings.rs",
include_str!("../settings.rs"),
GROUP_ARMS,
);
assert_eq!(
arms, 4,
"`on_settings` 应该恰好四条臂(java / download / language_appearance / ai_about),实际 {arms} 条"
);
}
/// **守门自身不误伤**:嵌套 match 与折行的 or-pattern 都不算兜底(r2 复审 P2-3 的探针)。
///
/// 这条是上一版判据假红的回归测试:复审往 handler 里加 `match true { true => …, false => … }`
/// 就让它 FAILED;现在这类写法必须放行。
#[test]
fn the_guard_ignores_nested_matches_and_folded_or_patterns() {
let synthetic = "\
impl App {
fn on_settings_java(&mut self, message: pages::settings::java::Message) -> Task<Message> {
match message {
pages::settings::java::Message::Probe => {
let nested = match true { true => 10u8, false => 11u8 };
let _ = nested;
Task::none()
}
| pages::settings::java::Message::Retry => Task::none(),
}
}
}
";
let (arms, offending) = dispatch_arms(
"synthetic",
synthetic,
&["pages::settings::java::Message::"],
);
assert!(
offending.is_empty(),
"正常代码被误判成兜底(这正是 r2 复审 P2-3 的假红):{offending:?}"
);
assert_eq!(arms, 2, "嵌套 match 里的两条臂不算本层臂");
}
/// **守门仍然有牙**:`_ =>` 与 binding 臂 `other =>` 都要被本层判成兜底。
#[test]
fn the_guard_still_catches_both_catch_all_shapes() {
for arm in [
"_ => Task::none(),",
"other => { let _ = other; Task::none() }",
] {
let synthetic = format!(
"impl App {{\n fn on_settings_java(&mut self, message: M) -> Task<Message> {{\n match message {{\n {arm}\n }}\n }}\n}}\n"
);
let (_, offending) = dispatch_arms("synthetic", &synthetic, GROUP_ARMS);
assert_eq!(
offending.len(),
1,
"`{arm}` 是兜底,必须被判红(实际 {offending:?})"
);
}
}
// --------------------------------------------------------------- 活的端到端证据
/// **活的端到端证据**:一条组消息真的从外壳那条既有通道走到该组的 handler(r1 复审要求)。
///
/// 路径:`app::Message::Page` → `on_page` → `pages::Message::Settings` → `on_settings` →
/// `on_settings_java`,最后改到 `App` 的状态。r1 之前这条链只有编译期证据(`GroupMessage` 是
/// uninhabited,运行时是死代码);现在至少有一支真的跑得起来。
///
/// 抓得住:漏斗某一环接断了(`on_page` 没转交、`on_settings` 转错组、handler 忽略消息)——那时
/// `view_dirty` 不会翻。抓不住:真实用户点击(本条没有 UI 控件,构造点只有测试)。
#[test]
fn a_group_message_reaches_its_handler_through_the_funnel() {
let mut app = headless();
app.view_dirty = false;
let _ = app.update(Message::Page(pages::Message::Settings(
pages::settings::GroupMessage::Java(pages::settings::java::Message::Ping),
)));
assert!(
app.view_dirty,
"java 线的消息没有走到 `on_settings_java`:消息漏斗在某一环断了"
);
}
+13 -3
View File
@@ -49,7 +49,8 @@ impl App {
// `Close` 五个消息只在外壳里构造 ⇒ 产品页「拖不动、缩不了、点不到关闭」。
//
// M93:三样东西从外观状态进来——①背景层(垫在最底下)②面板 α(外壳层的底色)③设置页要读
// 的那份状态本身(`page_with_appearance`)。
// 的那份状态本身。M101 r2 起 ③ 走 `page_with_settings`:外观状态 + 各组自己的状态
// (`App.settings`)一起递进去,四条填页线加字段不用动这一行。
match pages::Screen::of(self.state.route()) {
Some(_) => {
let background =
@@ -61,8 +62,17 @@ impl App {
nav_item_of(self.state.route()),
APP_TITLE,
background,
pages::page_with_appearance(&self.ui, &self.view, &self.appearance)
.map(Message::Page),
// M101 r2:设置页的只读状态(外观运行期状态 + 各组自己的状态)一次递进去。
// `settings` 的所有者是 `App`;这里只借一帧,加/改某一组的字段不用动这行。
pages::page_with_settings(
&self.ui,
&self.view,
pages::settings::States {
appearance: Some(&self.appearance),
groups: &self.settings,
},
)
.map(Message::Page),
self.options.force_state,
)
}
+94
View File
@@ -0,0 +1,94 @@
//! 页面消息与动作表(M101 r2 从 `pages/mod.rs` **原样搬出**)。
//!
//! 为什么拆:合并 M100/M101 之后 `pages/mod.rs` 是 397 行,只剩 3 行余量,下一条线往枚举里加
//! 一个变体(含文档)就顶破 400 硬线。拆法与 M99 的 `pages/nav.rs` 同一种范式:**纯搬移**、
//! 一行不改、路径靠 `pages/mod.rs` 的 `pub use` 再导出保持不变(`pages::Message` / `pages::Action`)。
//!
//! `pages::Message` 是页面层的自有消息类型(**不** import `crate::app::Message`,组件契约 §2 的
//! 同一条纪律);app 层只做一次 `.map`。
use unlucky_presenter::router::Route;
use super::appearance;
use super::download::Category;
use super::settings;
/// 页面消息。
///
/// **故意不用 `crate::app::Message`**(组件库契约 §2 的同一条理由):页面不绑 app 的状态层,
/// 接线时由 app 层把这里的三类意图翻成 presenter 的 `AppCommand` / `Route`。
#[derive(Debug, Clone)]
pub enum Message {
/// 骨架期动作(三页的占位按钮 + 实例页上还没接后端的两个页头动作)。
Action(Action),
/// 路由跳转:导航轨、列表行、窄窗「返回」都走这一条(栈语义归 `presenter::router`)。
Navigate(Route),
/// 启动某实例(app 层翻成 `AppCommand::LaunchInstance`)。
Launch(String),
/// 停止某实例(app 层翻成 `AppCommand::StopInstance`)。
Stop(String),
/// 外观项改动(设置页「外观」分组 → `App` 的状态与落盘)。
///
/// 载荷是**用户请求值**(未钳位):钳位与 CPU 档降级在 `appearance::alpha` 里做,
/// 页面只表达意图(与组件库「不持有业务状态」同一条纪律)。
Appearance(appearance::Change),
/// 选中下载页的一个版本(M94):**页面内选中**,app 层只改视图模型,不 dispatch。
SelectVersion(String),
/// 安装指定版本(M94;app 层翻成 `AppCommand::InstallVersion`)。
InstallVersion(String),
/// 取消正在跑的安装(M94;`AppCommand::CancelInstall`)。
CancelInstall,
/// 切换下载页分类(M94;app 层翻成 `AppCommand::ListVersions`,`All` 只画现有快照)。
SetCategory(Category),
/// 把现成文本塞进剪贴板(**M95**:诊断卡片的一行摘要,文本由页面造,app 只搬)。
CopySummary(String),
/// 设置页的一组消息(**M101 `SETTINGSMSG`**):设置页的**唯一**投递口。
///
/// 四条填页线(java / download / language+appearance / ai+about)各自在自己的文件里定义
/// 消息枚举,本枚举**不再**为设置页增加第二个变体——「加一支设置页消息」从此不碰本文件,
/// 也不碰 `app/update/page_messages.rs` 那条不许 `_ =>` 的 match。加变体与落点见
/// `pages/settings/message.rs`(`app/update/settings.rs` 对它做穷尽分发,漏接线报 E0004)。
Settings(settings::GroupMessage),
}
/// 页面动作清单(骨架期够用就行;接线时按业务语义重命名/扩充)。
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Action {
/// 新建实例(**还没有后端**,见模块文档偏离 #8)。
NewInstance,
/// 导入(同上)。
Import,
/// 返回实例列表(窄窗整页切换)。**已由 `Message::Navigate(Route::Instances)` 取代**,
/// 这里保留枚举项是为了别把「返回」这件意图从动作表里抹掉(外壳回退路径仍可能用到)。
Back,
/// 启动(占位动作表里的「启动」;真启动走 `Message::Launch`)。
Play,
/// 更多。
More,
/// 复制实例。
Duplicate,
/// 打开实例目录。
OpenFolder,
/// 删除实例。
Delete,
/// 重新探测下载源。
RefreshSources,
/// 浏览版本列表。
BrowseVersions,
/// 导入整合包。
ImportModpack,
/// 检查下载源。
CheckSources,
/// 打开日志目录。
OpenLogs,
/// 复制诊断信息。
CopyDiagnostics,
/// 清空日志。
ClearLogs,
/// 重置当前设置分组。
ResetSection,
/// 保存设置。
SaveSettings,
/// 新增逐实例覆盖。
AddOverride,
}
+59 -152
View File
@@ -43,6 +43,7 @@
//! | [`model`] | **视图模型**:presenter 快照 → 页面要画的东西(页面层不认识 presenter 类型) |
//! | [`frame`] | 页头 / 内容列 / 两列骨架 / 空态 / 卡片 / 分隔线 |
//! | [`nav`] | 导航轨的 `NAV` 表 + 部件版 `rail()`(harness / 单测;产品走 `shell/nav.rs`) |
//! | [`message`] / [`screen`] | `pages::Message` / `Action` / `Screen`(M101 r2 从本文件原样搬出) |
//! | `instances` | 实例页(list-detail)——**M90 唯一打通到真组件 + 真数据真命令的页** |
//! | `settings` | 设置页(supporting-pane,占位视觉):分组注册表 + 每个分组一个文件 |
//! | `download` / `logs` | 下载 / 日志页(单列,占位视觉) |
@@ -55,6 +56,14 @@
//! 于是「页面里点一行」= 「压一层栈」,语义与 presenter 的 `Router` 天然一致,
//! 中间不再有一层「页面自定义的选中项」需要同步。
//!
//! ### 设置页只有一个投递口(M101 `SETTINGSMSG`)
//!
//! 设置页的四条填页线各自在自己的文件里定义消息与状态,本文件的 [`Message`] 只留一个
//! [`Message::Settings`](详见 `pages/settings/mod.rs` 的「消息 / 状态」两节);`Message` 与
//! `Screen` 本身也在 M101 r2 搬到 [`message`] / [`screen`](`pages/mod.rs` 合并后 397 行,只剩
//! 3 行余量),这里的 `pub use` 保证了 `pages::Message` / `pages::Action` / `pages::Screen`
//! 三条路径一字未变。
//!
//! ## 偏离登记(D30 要求逐条写清;M84 六条 + M90 三条)
//!
//! | # | 偏离 | 为什么 | 什么时候收口 |
@@ -103,179 +112,50 @@ pub mod model;
mod download;
mod instances;
mod logs;
mod message;
mod nav;
mod settings;
mod screen;
// M101:设置页的两条漏斗(消息 / 状态)都从这个模块树里出——app 层要构造
// `settings::GroupMessage`、借用 `settings::Groups`,所以最小暴露面是 `pub(crate)`。
// **不是** `pub`:`private_interfaces` 按**类型自身的声明可见性**判(四个 `Message` 枚举与
// `GroupMessage` 声明成 `pub` 就够),把整个设置页模块树暴露成 crate 公开 API 没有回报。
pub(crate) mod settings;
#[cfg(test)]
mod tests;
pub use download::{Category, Download};
pub use layout::{FrameMetrics, PageLayout, PaneKit, WidthClass};
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;
use iced::widget::{column, container, row, space};
use iced::{Element, Length};
use unlucky_design::elevation::MaterialLayer;
use unlucky_presenter::router::{NavSection, Route};
use crate::theme::Ui;
use frame::filled;
/// 页面消息。
///
/// **故意不用 `crate::app::Message`**(组件库契约 §2 的同一条理由):页面不绑 app 的状态层,
/// 接线时由 app 层把这里的三类意图翻成 presenter 的 `AppCommand` / `Route`。
#[derive(Debug, Clone)]
pub enum Message {
/// 骨架期动作(三页的占位按钮 + 实例页上还没接后端的两个页头动作)。
Action(Action),
/// 路由跳转:导航轨、列表行、窄窗「返回」都走这一条(栈语义归 `presenter::router`)。
Navigate(Route),
/// 启动某实例(app 层翻成 `AppCommand::LaunchInstance`)。
Launch(String),
/// 停止某实例(app 层翻成 `AppCommand::StopInstance`)。
Stop(String),
/// 外观项改动(设置页「外观」分组 → `App` 的状态与落盘)。
///
/// 载荷是**用户请求值**(未钳位):钳位与 CPU 档降级在 `appearance::alpha` 里做,
/// 页面只表达意图(与组件库「不持有业务状态」同一条纪律)。
Appearance(appearance::Change),
/// 选中下载页的一个版本(M94):**页面内选中**,app 层只改视图模型,不 dispatch。
SelectVersion(String),
/// 安装指定版本(M94;app 层翻成 `AppCommand::InstallVersion`)。
InstallVersion(String),
/// 取消正在跑的安装(M94;`AppCommand::CancelInstall`)。
CancelInstall,
/// 切换下载页分类(M94;app 层翻成 `AppCommand::ListVersions`,`All` 只画现有快照)。
SetCategory(Category),
/// 把现成文本塞进剪贴板(**M95**:诊断卡片的一行摘要,文本由页面造,app 只搬)。
CopySummary(String),
}
/// 页面动作清单(骨架期够用就行;接线时按业务语义重命名/扩充)。
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Action {
/// 新建实例(**还没有后端**,见模块文档偏离 #8)。
NewInstance,
/// 导入(同上)。
Import,
/// 返回实例列表(窄窗整页切换)。**已由 `Message::Navigate(Route::Instances)` 取代**,
/// 这里保留枚举项是为了别把「返回」这件意图从动作表里抹掉(外壳回退路径仍可能用到)。
Back,
/// 启动(占位动作表里的「启动」;真启动走 `Message::Launch`)。
Play,
/// 更多。
More,
/// 复制实例。
Duplicate,
/// 打开实例目录。
OpenFolder,
/// 删除实例。
Delete,
/// 重新探测下载源。
RefreshSources,
/// 浏览版本列表。
BrowseVersions,
/// 导入整合包。
ImportModpack,
/// 检查下载源。
CheckSources,
/// 打开日志目录。
OpenLogs,
/// 复制诊断信息。
CopyDiagnostics,
/// 清空日志。
ClearLogs,
/// 重置当前设置分组。
ResetSection,
/// 保存设置。
SaveSettings,
/// 新增逐实例覆盖。
AddOverride,
}
/// 四个页面。
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Screen {
/// 实例(list-detail)。
Instances,
/// 下载(单列)。
Download,
/// 设置(supporting-pane)。
Settings,
/// 日志(单列,两行页头)。
Logs,
}
impl Screen {
/// 全部页面(截图矩阵按这个顺序铺)。
pub const ALL: [Screen; 4] = [
Screen::Instances,
Screen::Download,
Screen::Settings,
Screen::Logs,
];
/// 稳定 slug(命令行参数与文件名用)。
pub const fn slug(self) -> &'static str {
match self {
Screen::Instances => "instances",
Screen::Download => "download",
Screen::Settings => "settings",
Screen::Logs => "logs",
}
}
/// 反向解析(`--screen=instances`)。
pub fn from_slug(slug: &str) -> Option<Self> {
Self::ALL.into_iter().find(|screen| screen.slug() == slug)
}
/// 页面声明的**最宽**版面;实际用哪个由 [`layout::resolve`] 按窗口宽度决定。
pub const fn kit(self) -> PaneKit {
match self {
Screen::Instances => PaneKit::ListDetail,
Screen::Settings => PaneKit::Supporting,
Screen::Download | Screen::Logs => PaneKit::Single,
}
}
/// 路由落在哪一页上(`None` = 这一页还没有实现,由 app 层决定回退)。
///
/// `Route::Detail(_)` 仍属实例页(窄窗时是「整页切换」的那一屏)。
pub fn of(route: &Route) -> Option<Self> {
Some(match route {
Route::Instances | Route::Detail(_) => Screen::Instances,
Route::Download => Screen::Download,
Route::Logs => Screen::Logs,
Route::Settings => Screen::Settings,
Route::Agent => return None,
})
}
/// 该页高亮的导航项(与 `Route::nav` 同源,但这里是「按页问」的口径)。
pub const fn nav(self) -> NavSection {
match self {
Screen::Instances => NavSection::Instances,
Screen::Download => NavSection::Download,
Screen::Logs => NavSection::Logs,
Screen::Settings => NavSection::Settings,
}
}
}
/// **单页内容**(不含外壳):交给 `shell::view_with_content` 塞进真标题栏与导航轨之间。
///
/// 产品路径(`app/view.rs`)走 [`page_with_appearance`]:外壳由 `shell` 画(拖动/缩放/三个
/// 产品路径(`app/view.rs`)走 [`page_with_settings`]:外壳由 `shell` 画(拖动/缩放/三个
/// caption 键、rail 88),这里只画「页头 + 内容列」。列宽仍按**整个窗口宽**算
/// (`layout::frame_metrics` 内部会扣掉 rail 88 与页边距),所以外壳换了不影响版面判据。
///
/// 视图模型由**调用方持有**(`App` 在状态变化时重建一次 `Model`),这里只借用:
/// `view(&self)` 不能返回一棵借用局部变量的树。
pub fn page<'a>(ui: &'a Ui, model: &'a Model) -> Element<'a, Message> {
render(ui, model, None)
render(
ui,
model,
settings::States {
appearance: None,
groups: settings::demo_groups(),
},
)
}
/// 同 [`page`],但带上**外观状态**(设置页的「外观」分组要读当前值与诊断)。
@@ -283,25 +163,44 @@ pub fn page<'a>(ui: &'a Ui, model: &'a Model) -> Element<'a, Message> {
/// 为什么另开一个入口而不是给 `Model` 加字段:`pages/model.rs` 是页面与 presenter 之间的展示
/// 契约(`Model` 只装「要画什么」),外观是**运行时状态**(滑块请求值、已解码的背景图、诊断),
/// 塞进去会让演示数据(`pages::demo`)也得伪造一份外观状态。
///
/// 这条入口拿的是**演示组状态**([`settings::demo_groups`]):页面层没有真组状态可用,
/// 产品路径走 [`page_with_settings`]。
pub fn page_with_appearance<'a>(
ui: &'a Ui,
model: &'a Model,
appearance: &'a appearance::State,
) -> Element<'a, Message> {
render(ui, model, Some(appearance))
render(
ui,
model,
settings::States {
appearance: Some(appearance),
groups: settings::demo_groups(),
},
)
}
fn render<'a>(
/// 产品路径:带上**设置页那份只读状态**(外观运行期状态 + 各组自己的状态)。
///
/// 为什么要单独一个入口:组状态的所有者是 `App`(`app/state.rs` 的 `settings` 字段),页面层
/// 只借用一帧。加/改某一组的字段不改变这条签名(`States` 里只有 `&Groups`),所以四条填页线
/// 都不必来这里动一行。
pub fn page_with_settings<'a>(
ui: &'a Ui,
model: &'a Model,
appearance: Option<&'a appearance::State>,
states: settings::States<'a>,
) -> Element<'a, Message> {
render(ui, model, states)
}
fn render<'a>(ui: &'a Ui, model: &'a Model, states: settings::States<'a>) -> 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, appearance),
Screen::Settings => settings::view(ui, metrics, states),
Screen::Logs => logs::view(ui, metrics, &model.logs, model.lang),
}
}
@@ -326,7 +225,15 @@ pub fn app_with_appearance<'a>(
model: &'a Model,
appearance: Option<&'a appearance::State>,
) -> Element<'a, Message> {
let body = render(ui, model, appearance);
// harness 路径没有真组状态:用演示状态([`settings::demo_groups`]),外观状态照传。
let body = render(
ui,
model,
settings::States {
appearance,
groups: settings::demo_groups(),
},
);
let shell = row![nav::rail(ui, model.active), body]
.width(Length::Fill)
+78
View File
@@ -0,0 +1,78 @@
//! 页面枚举 `Screen` 与它的三张对照表(版面 / 导航项 / 路由;M101 r2 从 `pages/mod.rs` **原样搬出**)。
//!
//! 为什么拆:合并 M100/M101 之后 `pages/mod.rs` 是 397 行,只剩 3 行余量。纯搬移,路径靠
//! `pages/mod.rs` 的 `pub use screen::Screen;` 保持不变(`pages::Screen`)。
use unlucky_presenter::router::{NavSection, Route};
use super::layout::PaneKit;
/// 四个页面。
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Screen {
/// 实例(list-detail)。
Instances,
/// 下载(单列)。
Download,
/// 设置(supporting-pane)。
Settings,
/// 日志(单列,两行页头)。
Logs,
}
impl Screen {
/// 全部页面(截图矩阵按这个顺序铺)。
pub const ALL: [Screen; 4] = [
Screen::Instances,
Screen::Download,
Screen::Settings,
Screen::Logs,
];
/// 稳定 slug(命令行参数与文件名用)。
pub const fn slug(self) -> &'static str {
match self {
Screen::Instances => "instances",
Screen::Download => "download",
Screen::Settings => "settings",
Screen::Logs => "logs",
}
}
/// 反向解析(`--screen=instances`)。
pub fn from_slug(slug: &str) -> Option<Self> {
Self::ALL.into_iter().find(|screen| screen.slug() == slug)
}
/// 页面声明的**最宽**版面;实际用哪个由 [`layout::resolve`](super::layout) 按窗口宽度决定。
pub const fn kit(self) -> PaneKit {
match self {
Screen::Instances => PaneKit::ListDetail,
Screen::Settings => PaneKit::Supporting,
Screen::Download | Screen::Logs => PaneKit::Single,
}
}
/// 路由落在哪一页上(`None` = 这一页还没有实现,由 app 层决定回退)。
///
/// `Route::Detail(_)` 仍属实例页(窄窗时是「整页切换」的那一屏)。
pub fn of(route: &Route) -> Option<Self> {
Some(match route {
Route::Instances | Route::Detail(_) => Screen::Instances,
Route::Download => Screen::Download,
Route::Logs => Screen::Logs,
Route::Settings => Screen::Settings,
Route::Agent => return None,
})
}
/// 该页高亮的导航项(与 `Route::nav` 同源,但这里是「按页问」的口径)。
pub const fn nav(self) -> NavSection {
match self {
Screen::Instances => NavSection::Instances,
Screen::Download => NavSection::Download,
Screen::Logs => NavSection::Logs,
Screen::Settings => NavSection::Settings,
}
}
}
-13
View File
@@ -1,13 +0,0 @@
//! 设置页「AI / About」分组:**预留**(页面上还没有这个分组,左列 4 项里没有它)。
//!
//! 为什么先留一个空文件:设置页正文接下来由四条线并行接(java / download / language+appearance /
//! ai+about)。文件位置先占好,四条线就不会在同一天抢同一个新文件名;本文件现在是**只有文档**的
//! 空模块(不产生任何代码,`-D warnings` 不会红)。
//!
//! ## 接线步骤(ai+about 线照做)
//!
//! 1. 在本文件写 `pub(super) const GROUP: Group = Group { … }`,形状抄 `java.rs`;
//! 2. 到 `settings/mod.rs` 的 `GROUPS` 里加一行 `ai::GROUP,`,并把数组长度 `4` 改成 `5`;
//! 3. 文案 key 写进 `crates/i18n/src/catalog/settings/ai.rs`(那边已经接进装配链,同样只改一个文件)。
//!
//! 界面上「多了一组」这件事本身是行为变化,所以**只在真有那一组内容时才做**,不要为占位先加。
@@ -0,0 +1,39 @@
//! 设置页「AI / About」分组:**预留**(页面上还没有这个分组,左列 4 项里没有它)。
//!
//! 为什么先留一个空文件:设置页正文接下来由四条线并行接(java / download / language+appearance /
//! ai+about)。文件位置先占好,四条线就不会在同一天抢同一个新文件名;本文件现在是**只有文档**的
//! 空模块(不产生任何代码,`-D warnings` 不会红)。
//!
//! ## 接线步骤(ai+about 线照做)
//!
//! 1. 在本文件写 `pub(super) const GROUP: Group = Group { … }`,形状抄 `java.rs`;
//! 2. 到 `settings/mod.rs` 的 `GROUPS` 里加一行 `ai_about::GROUP,`,并把数组长度 `4` 改成 `5`;
//! 3. 文案 key 写进 `crates/i18n/src/catalog/settings/ai.rs`(那边已经接进装配链,同样只改一个文件)。
//!
//! 界面上「多了一组」这件事本身是行为变化,所以**只在真有那一组内容时才做**,不要为占位先加。
//!
//! ## M101 起本文件还装本线的状态与消息口
//!
//! - 状态:[`State`](`Groups.ai_about` 的载荷,加字段只改本文件);
//! - 消息:[`Message`](落点 `app/update/settings/ai_about.rs`)。
//!
//! 上第 5 组时**额外**要动的共享文件(按上面的步骤 2 + 测试装配):
//! - `settings/mod.rs` 的 `GROUPS`(加一行,长度 +1);
//! - `settings/tests/mod.rs` 顶部加一行 `mod ai_section;`(新测试文件不挂 `mod` 就不编译),
//! 并且左列顺序那条字面量断言会红——那是刻意的(页面上多一组就是观感变了,要人确认)。
//!
//! 注意 i18n 那边是按**分组**命名的,文案文件叫 `ai.rs` 而不是 `ai_about.rs`。
/// AI / About 分组的运行期状态(M101 r2:**只归 ai+about 线**)。
///
/// 加字段只改本文件:`Groups` 只认 `Groups.ai_about` 这一个字段。
#[derive(Debug, Default)]
pub struct State {}
/// AI / About 线的消息(**只归本线**)。
///
/// 首版是空枚举:这一条线的分组还没上页面。空枚举的好处是
/// `app/update/settings/ai_about.rs` 里的 `match message {}` 天然穷尽——加了变体不接线就报
/// E0004,不会静默。
#[derive(Debug, Clone)]
pub enum Message {}
@@ -1,19 +1,25 @@
//! 设置页「Appearance」分组:演示行 + **真控件的接线**(M93 / APPEARWIRE)。
//!
//! 这一组是设置页唯一接了真控件的:`body = Some(crate::pages::appearance::section)`。
//! `settings/mod.rs` 只按 `group.body` 分发,**不认识「Appearance」这个名字**;分组在左列的下标仍由
//! `crate::pages::appearance::APPEARANCE_GROUP` 声明,`settings/tests.rs` 拿它和本文件比对(顺序被
//! 调换会红)。
//! 这一组是设置页唯一接了真控件的:`body = Some(body)`,`body` 直接调
//! `crate::pages::appearance::section`。`settings/mod.rs` 只按 `group.body` 分发,**不认识
//! 「Appearance」这个名字**;分组在左列的下标仍由 `crate::pages::appearance::APPEARANCE_GROUP`
//! 声明,`settings/tests/mod.rs` 拿它和本文件比对(顺序被调换会红)。
//!
//! **这一组归 language+appearance 线**:真控件的实现全在 `pages/appearance/section.rs`(本文件只负责
//! 把它挂进注册表),所以改控件时不用碰设置页目录。
//!
//! M101 r2:本组的渲染器签名改成收 [`States`](组状态 + 外观状态),于是「这一组要读哪些状态」
//! 由本文件决定——`settings/mod.rs` 不再认识 `appearance::State`。
use iced::Element;
use unlucky_design::icons::IconKind;
use super::Group;
use crate::theme::Ui;
/// Appearance 分组。`body` 指着 `pages/appearance/section.rs` 的真控件;没有外观运行期状态时
/// (harness / `pages_demo`)退回下面的演示行。
use super::{Group, Message, States};
/// Appearance 分组。`body` 指着 [`body`](它再转给 `pages/appearance/section.rs` 的真控件);
/// 没有外观运行期状态时(harness / `pages_demo`)返回 `None`,由 `form` 退回下面的演示行。
pub(super) const GROUP: Group = Group {
name: "Appearance",
icon: IconKind::Sidebar,
@@ -23,5 +29,12 @@ pub(super) const GROUP: Group = Group {
("Motion", "Full"),
("Text scale", "100%"),
],
body: Some(crate::pages::appearance::section),
body: Some(body),
};
/// 本组的真控件渲染器:有外观状态就画真控件(M93),没有(harness)返回 `None`。
fn body<'a>(ui: &'a Ui, states: States<'a>) -> Option<Element<'a, Message>> {
states
.appearance
.map(|state| crate::pages::appearance::section(ui, state))
}
+15 -1
View File
@@ -1,6 +1,6 @@
//! 设置页「Downloads」分组:首选源 / 并发数 / 校验(演示行)。
//!
//! **这一组归 download 线**:接真控件时在本文件把 `body` 换成 `Some(...)`(渲染器也写在本文件),
//! **这一组归 download 线**:接真控件时在本文件把 `body` 换成 `Some(download::body)`(渲染器也写在本文件),
//! `settings/mod.rs` 的注册表不用动。
//!
//! 注意与下载页(`pages/download*`)的区别:那是「装哪个版本」,这里是「怎么下」的默认值。
@@ -20,3 +20,17 @@ pub(super) const GROUP: Group = Group {
],
body: None,
};
/// Downloads 分组的运行期状态(M101 r2:**只归 download 线**)。
///
/// 加字段只改本文件:`Groups` 只认 `Groups.download` 这一个字段。
#[derive(Debug, Default)]
pub struct State {}
/// Downloads 分组的消息(M101:**只归 download 线**,加变体只改本文件)。
///
/// 落点是 `app/update/settings/download.rs`;`pages/mod.rs` 与 `page_messages.rs` 都不用动(它们只认
/// [`GroupMessage`](super::GroupMessage))。异步结果(例如「测这个源可用」)在自己那条落点里构造
/// `Message::Page(pages::Message::Settings(GroupMessage::Download(…)))` 回来即可。
#[derive(Debug, Clone)]
pub enum Message {}
@@ -1,8 +1,13 @@
//! 设置页「General」分组:语言 / 主题 / 启动页 / 关闭按钮(演示行)。
//!
//! **这一组归 language+appearance 线**:语言那一行将来换真下拉(`pages::Message` 侧还需一条
//! 「设置项改动」的消息,见 `pages/mod.rs` 的 `Message`),改这里 + 那一处消息即可,
//! `settings/mod.rs` 的注册表不用动。
//! **这一组归 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` 的注册表也不用动。
//!
//! 要显示**页外真值**(例如「当前语言」的唯一来源是 `pages::Model.lang`)或首帧就要取数时,
//! 那两条仍有代价——见 `settings/mod.rs` 文档「仍然共享」小节里的第 **4、5** 条。
//!
//! 分组元数据只有一份(这里的 [`GROUP`]):`settings/mod.rs` 的 `GROUPS` 直接引用它。
+22 -1
View File
@@ -1,6 +1,6 @@
//! 设置页「Java」分组:运行时 / 默认内存 / JVM 参数(演示行)。
//!
//! **这一组归 java 线**:接真控件时在本文件把 `body` 换成 `Some(...)`(渲染器也写在本文件),
//! **这一组归 java 线**:接真控件时在本文件把 `body` 换成 `Some(java::body)`(渲染器也写在本文件),
//! 再把 `rows` 换成真数据源给的文本;`settings/mod.rs` 的注册表不用动。
//!
//! 图标说明(视觉复审 P2-3):令牌里 `Play` 是填充字形,和同屏其它描边图标语汇打架;
@@ -21,3 +21,24 @@ pub(super) const GROUP: Group = Group {
],
body: None,
};
/// Java 分组的运行期状态(M101 r2:**只归 java 线**)。
///
/// 加字段只改本文件:`Groups` 只认 `Groups.java`(一个字段),不认这个类型里的任何字段,
/// 所以 `settings/mod.rs` / `app/state.rs` / `app/view.rs` 都不用动。
#[derive(Debug, Default)]
pub struct State {}
/// Java 分组的消息(M101:**只归 java 线**,加变体只改本文件)。
///
/// 落点是 `app/update/settings/java.rs`;`pages/mod.rs` 与 `page_messages.rs` 都不用动(它们只认
/// [`GroupMessage`](super::GroupMessage))。
#[derive(Debug, Clone)]
pub enum Message {
/// **只有测试构造它**:M101 r2 留的「活的端到端证据」——外壳消息 →
/// `on_page` → `on_settings` → `on_settings_java` 这条路在运行时真的跑通,不是只靠编译期穷尽。
/// java 线接第一个真控件时把这一支换成真变体:`tests/java_section.rs` 的
/// `the_java_group_is_still_demo_rows` 就是那条会提醒你的绊线(它断言 `body.is_none()`)。
#[cfg(test)]
Ping,
}
@@ -0,0 +1,35 @@
//! 设置页 language+appearance 线的**消息口 + 状态口**(M101 `SETTINGSMSG`)。
//!
//! 为什么另开一个文件而不是塞进 `general.rs`:这一条线管**两个分组**(General 的语言 / 主题 /
//! 启动页 / 关闭按钮,Appearance 的密度 / 圆角 / 动效 / 文字缩放)。两个分组各有自己的正文文件
//! (`settings/general.rs`、`settings/appearance.rs`),消息与状态却只能有一个类型挂到
//! [`GroupMessage::LanguageAppearance`](super::GroupMessage::LanguageAppearance) /
//! [`Groups::language_appearance`](super::Groups) 上——否则 `GroupMessage` 与 `Groups` 会多出第五个
//! 槽位,本线就要改 `settings/message.rs` / `settings/state.rs`(共享文件)。
//!
//! ## 本线的文件集合(只归本线,四条线两两不相交)
//!
//! | 文件 | 作用 |
//! |---|---|
//! | 本文件 | 消息枚举 + 状态结构(加一支消息 / 一个字段只改这里) |
//! | `settings/general.rs` / `settings/appearance.rs` | 两个分组各自的 `GROUP`(名字 / 图标 / 行 / body) |
//! | `settings/tests/general_section.rs` / `settings/tests/appearance_section.rs` | 两组的独立断言 |
//! | `crates/i18n/src/catalog/settings/general.rs` / `appearance.rs` | 两组的文案 key |
//! | `crates/app-iced/src/app/update/settings/language_appearance.rs` | 消息的落点 |
//!
//! 真控件那半边在 `pages/appearance/section.rs`(`appearance.rs` 只负责把 `body` 挂上去)。
/// 语言 + 外观线的状态(**只归本线**)。
///
/// 首版是空结构:这一条线还没有自己的运行期状态(外观那份状态是 `pages::appearance::State`,
/// 归 M93,走 `States::appearance` 那条既有通道,不在这里)。加字段只改本文件。
#[derive(Debug, Default)]
pub struct State {}
/// 语言 + 外观线的消息(**只归本线**)。
///
/// 首版是空枚举:这一条线还没有接真下拉 / 真控件之前,没有可点的东西。空枚举的好处是
/// `app/update/settings/language_appearance.rs` 里的 `match message {}` 天然穷尽——加了变体
/// 不接线就报 E0004,不会静默。
#[derive(Debug, Clone)]
pub enum Message {}
@@ -0,0 +1,106 @@
//! 设置页的**分组消息投递口**(M101 `SETTINGSMSG`):四条填页线各写各的变体,不再抢
//! `pages/mod.rs` 的枚举尾部与 `app/update/page_messages.rs` 那条不许兜底的 match。
//!
//! ## 这一层为什么存在
//!
//! M100 把设置页的**文件**按分区拆开了,但四条线真正的交汇处仍是同一处追加锚点:
//! `pages::Message` 的枚举尾部 + `page_messages.rs` 的 match 臂(该 match 有
//! `the_page_match_has_no_wildcard_fallback` 守门,不许 `_ =>`)。四个变体、四条线、两个共享
//! 文件 ⇒ 四路并行必然四路冲突。M101 只做一件事:把「加消息」从共享文件搬进各组自己的文件——
//! `pages::Message` 从此只有**这一个**设置页变体。
//!
//! ```text
//! pages::Message::Settings(GroupMessage)
//! ├─ Java(java::Message) → settings/java.rs
//! ├─ Download(download::Message) → settings/download.rs
//! ├─ LanguageAppearance(language_appearance::Message) → settings/language_appearance.rs
//! └─ AiAbout(ai_about::Message) → settings/ai_about.rs
//! ```
//!
//! 四个载荷**必须**是四个不同类型、分别定义在四个文件里;否则「两条线往同一个枚举里加变体」的
//! 冲突只是从 `pages/mod.rs` 搬到了这里。`each_group_owns_its_own_message_type` 用 `type_name`
//! 里的模块路径守这一条。
//!
//! ## 加一支设置页消息要改几个文件(M101 r2 之后)
//!
//! **2 个**:你自己那条线的消息口文件(`settings/<线>.rs`)加一个变体,加上你那条落点
//! (`app/update/settings/<线>.rs`)接一条臂——两处都只归你。`pages/mod.rs`、
//! `page_messages.rs`、`settings/message.rs`、`settings/state.rs`、`app/update/settings.rs` 都不用动。
//!
//! ## 穷尽性:抓得住什么 / 抓不住什么
//!
//! - **抓得住**:某条线加了变体却忘了接线 → 编译器在 `app/update/settings.rs` 的 match 上报
//! **E0004**(那里对 [`GroupMessage`] 做穷尽匹配,没有 `_ =>`)。注意 `page_messages.rs`
//! 那条守卫只认 `pages::Message`,**它抓不住组内变体**——把守卫下放到 `settings.rs` 正是本层
//! 的第二个目的,两个 dispatch 各守一层。
//! - **抓不住**:handler 忽略消息(`let _ = message; Task::none()`)或两个 handler 的**身体**被
//! 写成一样——类型对得上就编得过。那要人读,或者等真接业务后由「点了没反应」的用例抓。
//!
//! 类型还顺手挡掉了另一种错:把 `Java(m)` 转给 `on_settings_download` 编不过(载荷类型不同),
//! 所以「接错组」这一半是编译期兜住的,「接了个空壳」那一半不是。
//!
//! ## 测试
//!
//! 本文件的 [`tests::each_group_owns_its_own_message_type`];dispatch 侧的
//! `the_group_dispatch_has_no_catch_all_arm` 在 `app/update/settings/tests.rs`(`app/update/settings.rs`
//! 只是 `#[cfg(test)] mod tests;` 的声明点,别去那里找)。
use super::{ai_about, download, java, language_appearance};
/// 设置页分组消息:**唯一**一个进 `pages::Message` 的投递口。
///
/// 每个变体只归一条线,载荷类型定义在那条线自己的文件里(见模块文档的四个箭头)。
/// 加变体 = 改自己那个文件 + 改 `app/update/settings/<线>.rs`,两处都只归你。
#[derive(Debug, Clone)]
pub enum GroupMessage {
/// Java 分组(`settings/java.rs`)。
Java(java::Message),
/// Downloads 分组(`settings/download.rs`)。
Download(download::Message),
/// 语言 + 外观两组的共同消息口(`settings/language_appearance.rs`)。
LanguageAppearance(language_appearance::Message),
/// AI / About 分组(`settings/ai_about.rs`)。
AiAbout(ai_about::Message),
}
#[cfg(test)]
mod tests {
use super::*;
/// 四条线的载荷**必须**住在四个不同文件里。
///
/// 抓得住:① 为了少建一个文件,把两个组的 `Message` 塞进同一个文件(四条线又会撞同一个
/// 追加锚点,本 mission 的目的当场作废);② 谁把某个分组文件改名后忘了同步(`type_name`
/// 里带模块路径,改名就红)。
/// 抓不住:变体装得对不对(那是 `app/update/settings.rs` 的 E0004 与其守卫的事),也抓不住
/// handler 身体写空/写反(类型对得上就编得过)。
#[test]
fn each_group_owns_its_own_message_type() {
let homes = [
(
std::any::type_name::<java::Message>(),
"settings::java::Message",
),
(
std::any::type_name::<download::Message>(),
"settings::download::Message",
),
(
std::any::type_name::<language_appearance::Message>(),
"settings::language_appearance::Message",
),
(
std::any::type_name::<ai_about::Message>(),
"settings::ai_about::Message",
),
];
for (actual, expected) in homes {
assert!(
actual.ends_with(expected),
"载荷类型 {actual} 不在它自己的文件里(应为 {expected}):\
两个组共用一个消息类型,四条线又会撞同一个追加锚点"
);
}
}
}
+116 -34
View File
@@ -18,16 +18,17 @@
//!
//! ## 文件地图(M99 `SETTINGSFRAME`:**纯搬移**,行为与视觉零变化)
//!
//! 本文件只做两件事:**分组注册表**([`GROUPS`])+ **内容分发**([`form`] 按分组挑渲染器)。
//! 一组的正文、文案、演示行都在它自己的文件里,于是设置页接下来的四条 section 线各占一个文件:
//! 本文件只做三件事:**分组注册表**([`GROUPS`])、**内容分发**([`form`] 按分组挑渲染器)、
//! 以及 M101 加的两条漏斗的**转发**(消息 → [`message`],状态 → [`state`])。
//! 一组的正文、文案、演示行、状态、消息都在它自己的文件里:
//!
//! | 分组 | 文件 | 谁会动它 |
//! | 分组 | 文件(正文 / 状态 / 消息) | 谁会动它 |
//! |---|---|---|
//! | General | [`general`] | language+appearance 线(语言下拉 + 主题 / 启动页) |
//! | Appearance | [`appearance`] | language+appearance 线(真控件已在 `pages/appearance/section.rs`) |
//! | Java | [`java`] | java 线 |
//! | Downloads | [`download`] | download 线 |
//! | (页面上还没有这个分组) | [`ai`] | ai+about 线:先建分组再接线 |
//! | (页面上还没有这个分组) | [`ai_about`] | ai+about 线:先建分组再接线 |
//! | 页头 / 侧列 / 滚动区 / 演示卡片 / 空态位 | [`frame`] | 谁都不该改(改了就是改版面) |
//!
//! ## 测试也按 section 分(M100)
@@ -40,24 +41,104 @@
//! | `tests/appearance_section.rs` | Appearance 自己的行 / 图标 / body 现状 | language+appearance 线 |
//! | `tests/java_section.rs` | Java 自己的行 / 图标 / body 现状 | java 线 |
//! | `tests/download_section.rs` | Downloads 自己的行 / 图标 / body 现状 | download 线 |
//! | `tests/mod.rs` | **跨 section**:版面契约、行宽上限、窄窗退化、注册表顺序 / 分组下标 / 「只有一组有真控件」 | 谁都别随手改(每条都写了为什么不能下放) |
//! | `tests/mod.rs` | **跨 section**:版面契约、行宽上限、窄窗退化、注册表顺序 / 分组下标 / 每条线的漏斗安装情况 | 谁都别随手改(每条都写了为什么不能下放) |
//!
//! 判据:**给某一组加一条新断言 = 只改它自己那个测试文件**。
//!
//! **加一个分组要动哪里**:① 在它自己的文件里写一个 `pub(super) const GROUP`(名字 / 图标 / 演示行 /
//! 可选的真控件渲染器 `body`);② 本文件的 [`GROUPS`] 加一行、数组长度 +1。**不需要**动 `frame.rs`、
//! 可选的真控件渲染器 `body`);② 本文件的 [`GROUPS`] 加一行、数组长度 +1;③ [`state::Groups`] 加一个
//! 字段、[`message::GroupMessage`] 加一个变体、`app/update/settings.rs` 加一条臂。**不需要**动 `frame.rs`、
//! 别的分组文件或 `pages/mod.rs`。
//!
//! ## 消息:一个投递口,四条线各写各的(M101 `SETTINGSMSG`)
//!
//! 四条填页线都要往 `pages::Message` 里加变体,而那是**一个**枚举加上
//! `app/update/page_messages.rs` 里一条不许 `_ =>` 的 match —— 四路并行必然四路冲突。
//! M101 把这件事收成一个投递口:
//!
//! ```text
//! pages::Message::Settings(GroupMessage) ← 全 crate 只有这一个设置页变体
//! ├─ Java(java::Message) → settings/java.rs
//! ├─ Download(download::Message) → settings/download.rs
//! ├─ LanguageAppearance(language_appearance::Message) → settings/language_appearance.rs
//! └─ AiAbout(ai_about::Message) → settings/ai_about.rs
//! ```
//!
//! 加一支消息 = 改自己那个消息口文件 + 自己那条落点(`app/update/settings/<线>.rs`),2 个文件、
//! 0 个共享文件。四个消息口枚举声明成 `pub` 是为了过 `private_interfaces`:那条 lint 按**类型自身
//! 的声明可见性**判,所以模块用最小的 `pub(crate)`(`pages/mod.rs` 的 `pub(crate) mod settings;`)
//! 就够,不必把整个设置页模块树暴露成 crate 公开 API。
//!
//! ## 状态:与消息同构的状态漏斗(M101 r2,r1 复审 P1)
//!
//! ```text
//! App.settings: state::Groups ← app/state.rs 一行(M101 加一次,四条线都不碰)
//! ├─ java / download / language_appearance / ai_about:各自的 State 在各自文件里
//! States { appearance, groups } ← 只读视图,按值传给 view;每组的 GROUP.body 自己取自己要的那份
//! ```
//!
//! **加/改某一组的字段**只改 `pages/settings/<组>.rs`;**异步结果**在自己那条落点里构造
//! `Message::Page(pages::Message::Settings(GroupMessage::<线>(…)))` 回来——`Page` 这条既有通道就是
//! 异步结果的入口,`app::Message` / `app/update.rs` 都不用动。
//!
//! ### 仍然共享的点(四条线都可能碰到,只能靠 tower 串行合并,或者由专人一次性做掉)
//!
//! 1. `crates/i18n/src/catalog/golden.rs` —— 每加一条文案 key 都要按声明顺序往金表里插一行
//! (`split_keeps_every_translation` 逐条比 key + 英/中译文)。**注意**:`catalog.rs` 的 `Key`
//! 枚举由 `catalog!` 宏从各域文件累积生成,加 key **不用**改 `catalog.rs`,也不用改
//! `catalog/settings/mod.rs`(五个域已全部接进装配链)。
//! 2. 本文件的 [`GROUPS`] —— 只有 ai+about 线**上第 5 组**时加一行(页面上多一组本来就是整页的变化)。
//! 3. `tests/mod.rs` —— ai+about 线新建 `tests/ai_section.rs` 时要加一行 `mod ai_section;`
//! (测试文件不挂 `mod` 就不编译),以及「左列顺序」那条字面量断言会连带红,需要人确认。
//! 4. **读页外真值**(r2 复审 P1 补的)—— [`States`] 只有 `appearance` + `groups` 两格,组正文读不到
//! `pages::Model`(「当前语言」的值在 `Model.lang`,`pages/model.rs`),也读不到 presenter 的设置
//! 快照。两条路的**真实成本**(`States` 没有 `Default`,加字段必然 E0063,已实测):
//! - 把 `model` **塞进 [`States`]** ⇒ struct literal 有 **4 处**(`pages/mod.rs:154/177/232` 与
//! `app/view.rs:70`),四处都要补字段 ⇒ **3 个共享文件**:`settings/state.rs`、`pages/mod.rs`、
//! **`app/view.rs`**(最后这个正是 M104 的落点,本 mission 反复设锚点要避开的就是它);
//! - 给 `settings::view` **加一个 `model: &Model` 形参**(`pages/mod.rs` 的 `render` 本来就拿着
//! `model`,直接往下传)⇒ **2 个共享文件**(`settings/mod.rs` + `pages/mod.rs`),
//! `app/view.rs` 不用动 —— **推荐这个形状**。
//!
//! 顺带订正 r3 之前那句「在 `app/state.rs` 用真值初始化 `Groups.language_appearance`(1 个文件)」:
//! 今天 `App` / `app/state.rs` 里**没有**任何语言真值源(`presenter::prefs::AppearancePrefs` 无
//! language 字段;唯一的值是 `app/view.rs:128` 里硬编码的 `Lang::En`),要先有真值源才谈得上
//! 初始化位置。两种形状都是「谁先撞谁付一次钱」的一次性代价,不是永久性四路撞车。
//! 5. **首帧取数 / 异步发起**(同上,r2 复审 P1 补)—— [`Body`] 是
//! `fn(&Ui, States) -> 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` 本来就在派
//! `AppCommand::RefreshAll`)。想让「组自己」发起首帧探测,得先在 app 侧另造一条旁路
//! (`settings/mod.rs` + `pages/mod.rs` + `app/view.rs` 三处都要动),**不建议**。不改的代价:
//! 首屏显示演示值、等用户点一次才变真值——四条线都能接受就不必动。
//! 6. **隐性代价:`Groups` 必须是 `Sync + Default`**(r2 复审 P3 指出)——
//! [`demo_groups`] 用 `static LazyLock<Groups>` 撑着 harness 路径,所以某个组的 `State` 里一旦
//! 放了 `Rc` 这类非 `Sync` 字段,编译会在**本模块**(`settings/state.rs`)红。选择 `LazyLock`
//! 是为了让 `page()` / `page_with_appearance()` 的返回 `Element<'a>` 借得到一个 `'static` 的默认
//! 状态;如果哪天必须放非 `Sync` 状态,改法是把默认状态改成「每个调用点自己造一个 `Groups`」
//! ——`pages/mod.rs` 的**三个**调用点都要改(`page` / `page_with_appearance` /
//! `app_with_appearance`,行 156 / 179 / 234),同样是**一次性**代价。
mod ai;
// 四条填页线的文件要能被**各自那条落点**(`app/update/settings/<线>.rs`)与状态架子
// (`state::Groups`)命名,所以是 `pub(crate)`:最小面,不是 crate 公开 API。
// 分组正文文件 `appearance` / `frame` / `general` 只在设置页内部用,保持私有。
pub(crate) mod ai_about;
mod appearance;
mod download;
pub(crate) mod download;
mod frame;
mod general;
mod java;
pub(crate) mod java;
pub(crate) mod language_appearance;
mod message;
mod state;
#[cfg(test)]
mod tests;
pub use message::GroupMessage;
pub use state::{demo_groups, Groups, States};
use iced::widget::column;
use iced::{Element, Length};
@@ -66,18 +147,23 @@ use unlucky_design::icons::IconKind;
use unlucky_design::spacing::Space;
use unlucky_design::typography::TypeRole;
use crate::pages::appearance::State;
use crate::theme::Ui;
use super::layout::{FrameMetrics, PageLayout};
use super::Message;
/// 一个分组正文的**真控件渲染器**:需要外观的运行期状态(现在只有「外观」分组用得上)。
type Body = for<'a> fn(&'a Ui, &'a State) -> Element<'a, Message>;
/// 一个分组正文的**真控件渲染器**:入参是「画一帧时那份只读状态」(组状态 + 外观状态),
/// 返回 `None` = 这一组现在没东西可画(例如外观分组在 harness 路径下没有外观状态),
/// 由 [`form`] 退回演示行。
///
/// 各组自己的渲染器**定义在自己文件里**,签名是这一条:于是「这一组要读哪些状态」由它自己决定,
/// 本文件不需要认识任何一组的字段(r1 复审 P1 要的正是这一点)。
type Body = for<'a> fn(&'a Ui, States<'a>) -> Option<Element<'a, Message>>;
/// 一个设置分组:名字、图标、演示行、(可选)把这一组正文画成真控件的渲染器。
///
/// 除 `name`/`icon`/`rows` 外没有别的字段:**分组自己的状态归分组自己**,这里只装「怎么画」。
/// 除 `name`/`icon`/`rows`/`body` 外没有别的字段:**分组自己的状态归分组自己**(见 [`state`]),
/// 这里只装「怎么画」。
struct Group {
/// 左列显示名(也是表单列的分组标题)。
name: &'static str,
@@ -104,22 +190,21 @@ const SELECTED: usize = 0;
/// 整页。
///
/// `appearance` 是 M93 接进来的外观运行期状态(`None` = harness / 演示路径,设置页仍画占位行)。
pub fn view<'a>(
ui: &'a Ui,
metrics: FrameMetrics,
appearance: Option<&'a State>,
) -> Element<'a, Message> {
/// `states` 是「画一帧时那份只读状态」:[`States::appearance`] 是 M93 接进来的外观运行期状态
/// (`None` = harness / 演示路径,设置页仍画占位行),[`States::groups`] 是各组自己的状态。
pub fn view<'a>(ui: &'a Ui, metrics: FrameMetrics, states: States<'a>) -> Element<'a, Message> {
// 选中分组:有真实外观状态时看状态(点左列可切),否则退回演示值 0。
let selected = appearance.map_or(SELECTED, |state| state.selected_group);
let selected = states
.appearance
.map_or(SELECTED, |state| state.selected_group);
let body = match metrics.layout {
PageLayout::SupportingPane => frame::panes(
metrics,
frame::group_nav(ui, selected),
frame::left_aligned(metrics.column, form(ui, Some(selected), appearance)),
frame::left_aligned(metrics.column, form(ui, Some(selected), states)),
),
_ => frame::single_column(metrics, form(ui, None, appearance)),
_ => frame::single_column(metrics, form(ui, None, states)),
};
frame::page(ui, metrics, frame::header(ui), body)
@@ -130,13 +215,10 @@ pub fn view<'a>(
/// 表单列(正文列宽 ≤424)。`only = Some(i)` 只渲染一组(supporting-pane),
/// `None` 渲染全部(窄窗:分组导航退化成按组堆叠)。
///
/// **分发点**:一组的正文由它自己文件里的 `GROUP.body` 决定——`Some` 画真控件(现在只有「外观」),
/// `None` 画演示行([`frame::demo_card`])。这里不出现任何一个分组名,也不比较下标。
fn form<'a>(
ui: &'a Ui,
only: Option<usize>,
appearance: Option<&'a State>,
) -> Element<'a, Message> {
/// **分发点**:一组的正文由它自己文件里的 `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> {
let mut stack = column![].spacing(ui.space(Space::X6)).width(Length::Fill);
for (index, group) in GROUPS.iter().enumerate() {
@@ -145,11 +227,11 @@ fn form<'a>(
}
// 「外观」分组:有真实外观状态时画真控件(M93),否则退回演示行(harness)。
// 这两条的差别现在由 `GROUP.body` 表达,而不是「下标 == APPEARANCE_GROUP」的硬比较。
let card = match (group.body, appearance) {
(Some(body), Some(state)) => body(ui, state),
_ => frame::demo_card(ui, group.rows),
};
// 这两条的差别由 `GROUP.body` 的返回值表达,而不是「下标 == APPEARANCE_GROUP」的硬比较。
let card = group
.body
.and_then(|body| body(ui, states))
.unwrap_or_else(|| frame::demo_card(ui, group.rows));
stack = stack.push(
column![
@@ -0,0 +1,85 @@
//! 设置页的**状态漏斗**(M101 r2):与 [`super::message`] 的消息漏斗同构——一个组的状态归它自己的
//! 文件,[`Groups`] 只是一个按组排开的架子。
//!
//! ## 为什么要这一层(r1 复审的 P1)
//!
//! 消息漏斗解决了「加一支消息要改哪些文件」,但一组要画**真值**就得有数据进渲染器:r1 之前
//! `settings::view` 只收 `Option<&appearance::State>`,于是 java / download / language+appearance
//! 三条线要显示「当前运行时 / 当前并发数 / 当前语言」时,**都得改 `settings/mod.rs`**;带运行期状态
//! 的还要改 `pages/mod.rs` / `app/view.rs` / `app/state.rs`。四条线一铺开就是第二处四路撞车。
//!
//! 现在的形状:
//!
//! ```text
//! App.settings: pages::settings::Groups ← 唯一的所有者(app/state.rs 一行)
//! ├─ java: java::State → pages/settings/java.rs
//! ├─ download: download::State → pages/settings/download.rs
//! ├─ language_appearance: language_appearance::State → pages/settings/language_appearance.rs
//! └─ ai_about: ai_about::State → pages/settings/ai_about.rs
//!
//! settings::view(ui, metrics, States { appearance, groups }) ← 只读视图,按值传
//! └─ 每组的 GROUP.body(ui, states) 自己从 states 里取自己要的那份
//! ```
//!
//! **加/改某一组的字段** = 只改 `pages/settings/<组>.rs`(状态类型在它自己文件里)+ 用它自己的
//! handler 更新(`app/update/settings/<线>.rs`)。本文件、`pages/mod.rs`、`app/state.rs`、
//! `app/view.rs` **都不用动**——本 mission 的这半边判据就是这么验的(见报告「变异证据」)。
//!
//! **加一整条新线**(多一个组)才需要:本文件加一个字段、`message.rs` 加一个变体、
//! `app/update/settings.rs` 加一条臂。那是「加一条线」,不是「加一个字段」。
//!
//! ## 外观那条真控件路径(M93)没有被挤掉
//!
//! `appearance::State` 仍是 `App` 的字段(不复用、不搬走),[`States::appearance`] 原样借用它;
//! 「没有外观状态(harness)⇒ 画演示行」这条判据现在由 `appearance` 组自己的 `GROUP.body`
//! 表达(返回 `None` ⇒ [`super::frame::demo_card`]),与 r1 之前逐像素等价。
use std::sync::LazyLock;
use crate::pages::appearance;
use super::{ai_about, download, java, language_appearance};
/// 各组自己拥有的运行期状态:**一个组一个字段**,字段类型定义在那条线自己的文件里。
///
/// 字段的**内部**结构归各组(加字段只改它自己的文件);架子(有哪几个组)归这里。
#[derive(Debug, Default)]
pub struct Groups {
/// Java 分组的状态(`pages/settings/java.rs`)。
pub java: java::State,
/// Downloads 分组的状态(`pages/settings/download.rs`)。
pub download: download::State,
/// 语言 + 外观两组的状态(`pages/settings/language_appearance.rs`)。
pub language_appearance: language_appearance::State,
/// AI / About 分组的状态(`pages/settings/ai_about.rs`)。
pub ai_about: ai_about::State,
}
/// 画一帧时**按值**递给各组渲染器的只读视图:组状态 + 外观运行期状态(两个引用,`Copy`)。
///
/// 为什么按值而不是按引用:`Settings::view` 返回的 `Element<'a>` 会借走它,按引用传会逼着
/// harness 那条路径([`super::demo_groups`] 撑着的 `page()` / `page_with_appearance()`)为一份
/// **临时构造**的 `States` 造 `'static`,或者把默认状态塞进 `Model`(两份真相)。按值传没有
/// 这层借用纠缠,调用方写一行就够。
#[derive(Debug, Clone, Copy)]
pub struct States<'a> {
/// 外观运行期状态(M93):`None` = harness / 演示路径(设置页画演示行)。
pub appearance: Option<&'a appearance::State>,
/// 各组自己的状态(见 [`Groups`])。
pub groups: &'a Groups,
}
/// 演示 / harness 路径的默认组状态(`pages::page` 与 `pages::page_with_appearance` 用它)。
///
/// `'static` 是有意的:那两条入口不持有任何组状态,而返回的 `Element<'a>` 会借走这份 `Groups`;
/// 让它活得比调用方长,借用就不会把返回值钉在函数栈上。
///
/// **隐性代价(r2 复审 P3)**:`static` 要求 `Groups: Sync + Default`,于是某个组的 `State` 里一旦
/// 放了 `Rc` 这类非 `Sync` 字段,编译会红在**本文件**(共享)而不是那一组自己的文件。真需要
/// 非 `Sync` 状态时的改法:去掉这个 `static`,改成每个调用点自己造一份 `Groups`——`pages/mod.rs` 的
/// **三个**调用点都要改(`page` / `page_with_appearance` / `app_with_appearance`,行 156 / 179 / 234),
/// 一次性代价,见 [`super`] 文档「仍然共享」第 6 条。
pub fn demo_groups() -> &'static Groups {
static DEMO: LazyLock<Groups> = LazyLock::new(Groups::default);
&DEMO
}
@@ -3,7 +3,8 @@
//!
//! **归 language+appearance 线**:这是设置页唯一接了真控件的一组,真控件实现全在
//! `pages/appearance/section.rs`;本文件只守「注册表这一侧的接线」。
//! 本组已经把 `body` 接上了,所以 `tests/mod.rs` 那条「只有一组有真控件」的绊线不用再动。
//! 本组已经把 `body` 接上了;`tests/mod.rs` 那条「只有一组有真控件」的**聚合绊线已在 M101 r2 删除**
//! (被四条 per-group 断言完全蕴含——代价是「唯一性」从此没人守,那段说明写在 `tests/mod.rs` 同处)。
use super::super::appearance;
use unlucky_design::icons::IconKind;
@@ -45,6 +46,7 @@ fn the_appearance_group_keeps_its_icon() {
fn the_appearance_group_keeps_a_real_control_body() {
assert!(
appearance::GROUP.body.is_some(),
"外观分组必须是挂了真控件的那一组(`body = Some(crate::pages::appearance::section)`)"
"外观分组必须是挂了真控件的那一组(`settings/appearance.rs` 里是 `body: Some(body)`,\
`body` 是本文件那个本地 fn;具体渲染器由那一行源码 + 截图担保,函数指针相等性断言不了)"
);
}
@@ -1,8 +1,8 @@
//! 「Downloads」分组自己的断言(M100 **新增**:`settings/tests.rs` 那 7 条全是跨 section 的,
//! 一条 section 断言都没有;这 3 条是新增,不是搬移)。
//!
//! **归 download 线**。接真控件那天要改**三个**文件:本文件 `the_downloads_group_is_still_demo_rows`
//! (`body.is_none()` 绊线)+ `settings/download.rs` + `tests/mod.rs` 的跨 section 绊线。
//! **归 download 线**。接真控件那天要改**两个**文件(M101 r2 删掉了原先的第 3 处跨 section 绊线):
//! `settings/download.rs` + 本文件 `the_downloads_group_is_still_demo_rows`。
//!
//! 别把它和下载页(`pages/download*`)搞混:那是「装哪个版本」,这一组是「怎么下」的默认值。
@@ -38,8 +38,8 @@ fn the_downloads_group_keeps_its_icon() {
/// 抓得住:download 线接上了 `body` 却没更新这里的期望(那会让本文件静默变成假绿)。
/// 抓不住:界面上真控件长什么样。
///
/// 接真控件时:把 `is_none()` 改成 `is_some()`,**只改这一行**;再去 `tests/mod.rs` 的
/// `only_the_appearance_group_carries_a_real_control_body` 那条绊线上手工确认一次(那条是四组共用的)。
/// 接真控件时:把 `is_none()` 改成 `is_some()`,**只改这一行**(M101 r2 起不必再去
/// `tests/mod.rs` 报到:那条跨 section 聚合绊线已删)。
#[test]
fn the_downloads_group_is_still_demo_rows() {
assert!(
@@ -1,9 +1,8 @@
//! 「General」分组自己的断言(M100 **新增**:`settings/tests.rs` 那 7 条全是跨 section 的,
//! 一条 section 断言都没有;这 3 条是新增,不是搬移)。
//!
//! **归 language+appearance 线**。接真控件那天要改**三个**文件:本文件
//! `the_general_group_is_still_demo_rows`(`body.is_none()` 绊线)+ `settings/general.rs` +
//! `tests/mod.rs` 的跨 section 绊线。
//! **归 language+appearance 线**。接真控件那天要改**两个**文件(M101 r2 删掉了原先的第 3 处跨
//! section 绊线):`settings/general.rs` + 本文件 `the_general_group_is_still_demo_rows`。
use super::super::general;
use unlucky_design::icons::IconKind;
@@ -38,8 +37,8 @@ fn the_general_group_keeps_its_icon() {
/// 抓得住:language+appearance 线接上了 `body` 却没更新这里的期望(那会让本文件静默变成假绿)。
/// 抓不住:界面上真控件长什么样。
///
/// 接真控件时:把 `is_none()` 改成 `is_some()`,**只改这一行**;再去 `tests/mod.rs` 的
/// `only_the_appearance_group_carries_a_real_control_body` 那条绊线上手工确认一次(那条是四组共用的)。
/// 接真控件时:把 `is_none()` 改成 `is_some()`,**只改这一行**(M101 r2 起不必再去
/// `tests/mod.rs` 报到:那条跨 section 聚合绊线已删)。
#[test]
fn the_general_group_is_still_demo_rows() {
assert!(
@@ -1,10 +1,10 @@
//! 「Java」分组自己的断言(M100 **新增**:`settings/tests.rs` 里一条 section 断言都没有,
//! 那 7 条全是跨 section 的;所以这 3 条是新增,不是搬移)。
//!
//! **归 java 线**。接真控件那天要改**三个**文件:
//! 1. 本文件 `the_java_group_is_still_demo_rows`(它断言 `body.is_none()`,接上就红);
//! 2. `settings/java.rs`(写 `body = Some(..)`);
//! 3. `tests/mod.rs` 的 `only_the_appearance_group_carries_a_real_control_body`(跨 section 绊线)。
//! **归 java 线**。接真控件那天要改**两个**文件(M101 r2 删掉了原先的第 3 处跨 section 绊线,
//! 见 `tests/mod.rs` 的 `every_line_can_be_wired_alone` 说明):
//! 1. `settings/java.rs`(`body = Some(java::body)` + 渲染器);
//! 2. 本文件 `the_java_group_is_still_demo_rows`(把 `is_none()` 改成 `is_some()`)。
//!
//! download / general 同型。
//!
@@ -46,8 +46,8 @@ fn the_java_group_keeps_the_folder_icon_from_the_visual_review() {
/// 抓得住:java 线接上了 `body` 却没更新这里的期望(那会让本文件静默变成假绿)。
/// 抓不住:界面上真控件长什么样。
///
/// 接真控件时:把 `is_none()` 改成 `is_some()`,**只改这一行**;再去 `tests/mod.rs` 的
/// `only_the_appearance_group_carries_a_real_control_body` 那条绊线上手工确认一次(那条是四组共用的)。
/// 接真控件时:把 `is_none()` 改成 `is_some()`,**只改这一行**(M101 r2 起不必再去
/// `tests/mod.rs` 报到:那条跨 section 聚合绊线已删,见那里的 `every_line_can_be_wired_alone`)。
#[test]
fn the_java_group_is_still_demo_rows() {
assert!(
+59 -29
View File
@@ -12,10 +12,11 @@
//! 判据(M100 报告里正面回答的那条):**给 java 加一条新断言,改几个文件?** —— 1 个
//! (`tests/java_section.rs`)。本文件不动。
//!
//! 但「java 线**接真控件**那天改几个文件」是另一回事,答案是 **3 个**(M100 r1 复审 p3-3 订正,
//! 我最初只写成 2 个):`settings/java.rs`(写 body)+ `tests/java_section.rs` 的
//! `the_java_group_is_still_demo_rows`(它断言 `body.is_none()`,接上就红)+ 本文件末尾那条
//! `only_the_appearance_group_carries_a_real_control_body`。download / general 同型。
//! 但「java 线**接真控件**那天改几个文件」是另一回事,答案是 **2 个**(M101 r2 订正:
//! M100 之后曾是 3 个,第 3 处就是本文件末尾那条聚合绊线
//! `only_the_appearance_group_carries_a_real_control_body`,它被四条 per-group 断言完全蕴含、
//! 已在 M101 r2 删除):`settings/java.rs`(写 body)+ `tests/java_section.rs` 的
//! `the_java_group_is_still_demo_rows`(它断言 `body.is_none()`,接上就红)。download / general 同型。
//!
//! ## ai+about 线要额外改本文件的两处(M100 r1 复审 p3-4)
//!
@@ -130,33 +131,62 @@ fn the_split_kept_the_left_column_order() {
assert_eq!(names, vec!["General", "Appearance", "Java", "Downloads"]);
}
/// M99:「外观」是**当前唯一**接了真控件的那一组。
/// M101 r2:`settings/mod.rs` 里**不许出现任何一组的 `State` 类型**(r1 复审 P1 的判据钉在这里)。
///
/// 抓得住:拆分时把 `body: Some(…)` 搬丢 / 搬到了别的组(那会让真控件消失,退回演示行——
/// 而截图对这种「只在有外观状态时出现」的差别不一定抓得住,见报告「没验的部分」);
/// 也抓「别组偷偷接了 body 却没人知道」。
/// 抓不住:`body` 指向的那个渲染器画得对不对(那是 `pages::appearance` 的事)。
///
/// **为什么不能下放**(任务书点名要求写清):这条断言的全部内容是**四组的 body 现状对比**
/// ——「只有第 2 组是 Some,其余三组都是 None」。放进 `appearance_section.rs` 它就得替 java /
/// download / general 三组作证(那三条线一接真控件,本文件以外的地方无缘无故变红,且责任方
/// 不是改代码的人);拆成四份「我这组是 None」又会丢掉「唯一」这个要点。
/// 它是**绊线**:哪条线接上真控件,就必须来本文件手工改这一条(T1:绊线保持字面量,
/// 动态取活值等于把绊线剪了)。
/// 抓得住:谁把状态漏斗拆回去——让 `view` / `form` 直接收 `appearance::State` 或某一组的 `State`
/// (那正是四条线「加一个字段就要改本文件」的形状,本次复审要求修掉的就是它)。
/// 抓不住:各组的**字段**(那些活在各组自己的文件里,本文件本来就不该知道,也就没有可断言的东西)。
#[test]
fn only_the_appearance_group_carries_a_real_control_body() {
fn the_settings_module_does_not_name_any_group_state() {
let source = include_str!("../mod.rs");
let forbidden = [
"appearance::State",
"java::State",
"download::State",
"language_appearance::State",
"ai_about::State",
];
let hits: Vec<&str> = forbidden
.into_iter()
.filter(|needle| source.contains(needle))
.collect();
assert!(
GROUPS[APPEARANCE_GROUP].body.is_some(),
"外观分组必须是接了真控件的那一组"
hits.is_empty(),
"`settings/mod.rs` 里出现了具体的组状态类型 {hits:?}:状态漏斗被打回去了——\
四条填页线又要改本文件。状态应该只在 `settings/state.rs` 的 `Groups` 与各组自己的\
`State` 定义里出现,本文件只转发 `States`"
);
for (index, group) in GROUPS.iter().enumerate() {
if index == APPEARANCE_GROUP {
continue;
}
assert!(
group.body.is_none(),
"{} 现在应该还是演示行(真控件由它自己那条 section 线接)",
group.name
);
}
}
///
/// 为什么能删:`tests/appearance_section.rs` 断言外观组 `body.is_some()`,
/// `tests/{general,java,download}_section.rs` 各自断言本组 `body.is_none()`——四条合起来与
/// 原来那条聚合断言等价(成员与顺序另有 [`the_split_kept_the_left_column_order`] 钉住)。
/// 删掉之后每条线「接真控件」只需改自己那 2 个文件,不必来本文件报到。
///
/// ## 删掉之后**抓不住什么**(tower 要求写明)
///
/// 那四条都只看**本组**,没有任何一条再断言「唯一性」——「四组里恰好一组挂了真控件」这件事
/// **现在没有守卫**。具体漏法:
///
/// | 情形 | 还红吗 |
/// |---|---|
/// | 某条线接上 `body` 却忘了翻自己那条 `is_none()` | **红**(本组断言还在) |
/// | 两条线的断言被同时改成 `is_some()`,页面上两组同时长真控件 | **绿着过去**(没人指认「唯一」) |
/// | 谁把 `body: Some(..)` 挂到一个不该有的组、又顺手改了它那条断言 | **绿着过去** |
///
/// 接受这个损失的理由:接真控件是**每组各自的一次性动作**,谁做谁翻自己那一条;而「唯一」在
/// 四条线并行的阶段是最容易假红的约束(一条线接上就替另外三条红)。想要回这条守卫,等四条线
/// 都落地后在这里加一条**计数**断言(`GROUPS.iter().filter(|g| g.body.is_some()).count()`),
/// 而不是现在钉死某个下标。
///
/// 这条 `#[test]` 本体已删;保留这段文档是为了让后来的人知道「这里原本有一条绊线」。
#[test]
fn every_line_can_be_wired_alone() {
// 判据是「每条线只改自己的文件」,不是任何一条具体断言:这里只钉住架子本身的两件事——
// ① 每组都有 `GROUP`(成员齐);② 演示下标合法。真正的 per-group 断言在各自文件里。
assert_eq!(GROUPS.len(), 4, "四条线各一个分组");
assert!(SELECTED < GROUPS.len(), "演示下标仍在范围内");
}