From 029ece3f1720c0e67c16a7e8a33c3ce293864d37 Mon Sep 17 00:00:00 2001 From: AskaEth Date: Tue, 3 Nov 2026 20:57:08 +0800 Subject: [PATCH] =?UTF-8?q?feat(app-iced):=20M101=20r2=20=E8=AE=BE?= =?UTF-8?q?=E7=BD=AE=E9=A1=B5=E7=8A=B6=E6=80=81=E6=BC=8F=E6=96=97=20+=20pa?= =?UTF-8?q?ges/mod.rs=20=E6=8B=86=E5=88=86=20+=20r1=20=E5=A4=8D=E5=AE=A1?= =?UTF-8?q?=E8=AE=A2=E6=AD=A3?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit r1 复审的 P1:消息漏斗只解决了「加一支消息」,一组要画真值仍要改 `settings/mod.rs` (`Group.body` 与 `view` 只认 `appearance::State`)。本 commit 补齐与消息漏斗同构的状态漏斗: - 新增 `pages/settings/state.rs`:`Groups`(一个组一个字段,字段类型在组自己文件里)+ `States`(按值传给渲染器的只读视图:外观状态 + 组状态)+ `demo_groups()`(harness 的 `'static` 默认状态)。 - `Group.body` 签名改成 `for<'a> fn(&'a Ui, States<'a>) -> Option`:每组自己从 `States` 取自己要的数据,「没有可画的状态」返回 `None` 由 `form` 退回演示行。 `appearance` 组照旧接 `pages/appearance/section.rs`(M93 路径行为不变)。 - `App.settings: pages::settings::Groups`(`app/state.rs` 字段列表末尾)+ `app/view.rs` 构造 `States` 走新入口 `pages::page_with_settings`。加/改某一组的字段只改那一组的文件。 - 四个组各自的 `State` 落在各自文件;`java::Message` 留一支只有测试构造的 `Ping`,配 `app/update/settings/tests.rs` 的 `a_group_message_reaches_its_handler_through_the_funnel` ——消息漏斗第一次有**运行时**证据。 r1 其余条目: - P2-1 删掉 `only_the_appearance_group_carries_a_real_control_body`(被四条 per-group 断言蕴含), 并在同处写明删掉之后「唯一性」没人守;三份 section 文档从「改 3 个文件」订正为 2 个。 - P2-2 `pub mod settings` → `pub(crate) mod settings`(四个消息口模块 `pub(crate)`), 并订正两处理由(lint 按类型自身可见性判,不是可达性)。 - P2-3 `pages/mod.rs` 397 行 → 拆出 `pages/message.rs` / `pages/screen.rs`(纯搬移,`pub use` 保证 `pages::Message` / `Action` / `Screen` 路径不变),并删掉 M101 自己那 18 行重复文档。 - P3-1 订正共享点清单(`Key` 由宏从域文件累积生成,加 key 不改 `catalog.rs`)。 - P3-2 守门改成「除已知臂外任何含 `=>` 的行都算兜底」(`other =>` 也拦),并加「恰好四臂」断言。 - P3-3 `ai.rs` → `ai_about.rs` 单独一条 R100 改名提交,`git log --follow` 不断链。 - P3-4 `message.rs` 的「加一支消息改 1 个文件」订正为 2 个。 --- crates/app-iced/src/app/state.rs | 7 + crates/app-iced/src/app/update.rs | 1 + .../app-iced/src/app/update/page_messages.rs | 3 + crates/app-iced/src/app/update/settings.rs | 55 +++++ .../src/app/update/settings/ai_about.rs | 17 ++ .../src/app/update/settings/download.rs | 18 ++ .../app-iced/src/app/update/settings/java.rs | 33 +++ .../update/settings/language_appearance.rs | 19 ++ .../app-iced/src/app/update/settings/tests.rs | 149 +++++++++++++ crates/app-iced/src/app/view.rs | 16 +- crates/app-iced/src/pages/message.rs | 94 ++++++++ crates/app-iced/src/pages/mod.rs | 211 +++++------------- crates/app-iced/src/pages/screen.rs | 78 +++++++ .../app-iced/src/pages/settings/ai_about.rs | 28 ++- .../app-iced/src/pages/settings/appearance.rs | 29 ++- .../app-iced/src/pages/settings/download.rs | 16 +- crates/app-iced/src/pages/settings/java.rs | 22 +- .../src/pages/settings/language_appearance.rs | 35 +++ crates/app-iced/src/pages/settings/message.rs | 105 +++++++++ crates/app-iced/src/pages/settings/mod.rs | 121 +++++++--- crates/app-iced/src/pages/settings/state.rs | 79 +++++++ .../pages/settings/tests/download_section.rs | 8 +- .../pages/settings/tests/general_section.rs | 9 +- .../src/pages/settings/tests/java_section.rs | 12 +- .../app-iced/src/pages/settings/tests/mod.rs | 79 ++++--- 25 files changed, 1004 insertions(+), 240 deletions(-) create mode 100644 crates/app-iced/src/app/update/settings.rs create mode 100644 crates/app-iced/src/app/update/settings/ai_about.rs create mode 100644 crates/app-iced/src/app/update/settings/download.rs create mode 100644 crates/app-iced/src/app/update/settings/java.rs create mode 100644 crates/app-iced/src/app/update/settings/language_appearance.rs create mode 100644 crates/app-iced/src/app/update/settings/tests.rs create mode 100644 crates/app-iced/src/pages/message.rs create mode 100644 crates/app-iced/src/pages/screen.rs create mode 100644 crates/app-iced/src/pages/settings/language_appearance.rs create mode 100644 crates/app-iced/src/pages/settings/message.rs create mode 100644 crates/app-iced/src/pages/settings/state.rs diff --git a/crates/app-iced/src/app/state.rs b/crates/app-iced/src/app/state.rs index 13e33d3..6fadc95 100644 --- a/crates/app-iced/src/app/state.rs +++ b/crates/app-iced/src/app/state.rs @@ -119,6 +119,12 @@ pub struct App { pub(super) appearance_prefs: unlucky_presenter::prefs::AppearancePrefs, /// 窗口句柄(`Message::WindowHandle` 之后才有;非 Windows 平台恒 `None`)。 pub(super) hwnd: Option, + /// 设置页各组自己的运行期状态(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(); diff --git a/crates/app-iced/src/app/update.rs b/crates/app-iced/src/app/update.rs index 3495bf5..a422b1a 100644 --- a/crates/app-iced/src/app/update.rs +++ b/crates/app-iced/src/app/update.rs @@ -44,6 +44,7 @@ use super::CAPTURE_AT_FRAME; mod download; mod page_messages; +mod settings; impl App { /// 一条消息进来,改状态并返回后续任务。 diff --git a/crates/app-iced/src/app/update/page_messages.rs b/crates/app-iced/src/app/update/page_messages.rs index 5183a65..034d1a8 100644 --- a/crates/app-iced/src/app/update/page_messages.rs +++ b/crates/app-iced/src/app/update/page_messages.rs @@ -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` 提前消化;显式列出而不是 `_ => {}`,见模块文档 diff --git a/crates/app-iced/src/app/update/settings.rs b/crates/app-iced/src/app/update/settings.rs new file mode 100644 index 0000000..5cd00f8 --- /dev/null +++ b/crates/app-iced/src/app/update/settings.rs @@ -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 { + 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; diff --git a/crates/app-iced/src/app/update/settings/ai_about.rs b/crates/app-iced/src/app/update/settings/ai_about.rs new file mode 100644 index 0000000..4ac6bb3 --- /dev/null +++ b/crates/app-iced/src/app/update/settings/ai_about.rs @@ -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 { + match message {} + } +} diff --git a/crates/app-iced/src/app/update/settings/download.rs b/crates/app-iced/src/app/update/settings/download.rs new file mode 100644 index 0000000..2bb52fb --- /dev/null +++ b/crates/app-iced/src/app/update/settings/download.rs @@ -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 { + match message {} + } +} diff --git a/crates/app-iced/src/app/update/settings/java.rs b/crates/app-iced/src/app/update/settings/java.rs new file mode 100644 index 0000000..a8f3fe7 --- /dev/null +++ b/crates/app-iced/src/app/update/settings/java.rs @@ -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 { + match message { + // M101 r2 的**活的端到端证据**:只有测试构造这一支(java 线接第一个真控件时换成真变体)。 + // 它真的改一个 app 状态,所以「消息从外壳走到 handler」这件事有运行时证据,不只是编译期。 + #[cfg(test)] + pages::settings::java::Message::Ping => { + self.view_dirty = true; + Task::none() + } + } + } +} diff --git a/crates/app-iced/src/app/update/settings/language_appearance.rs b/crates/app-iced/src/app/update/settings/language_appearance.rs new file mode 100644 index 0000000..b2c18eb --- /dev/null +++ b/crates/app-iced/src/app/update/settings/language_appearance.rs @@ -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 { + match message {} + } +} diff --git a/crates/app-iced/src/app/update/settings/tests.rs b/crates/app-iced/src/app/update/settings/tests.rs new file mode 100644 index 0000000..3c8c14e --- /dev/null +++ b/crates/app-iced/src/app/update/settings/tests.rs @@ -0,0 +1,149 @@ +//! 设置页分组消息分发的**守门测试**(M101 `SETTINGSMSG`)。 +//! +//! 判据与 `app/update/page_messages.rs` 里那条 `the_page_match_has_no_wildcard_fallback` 同源: +//! 兜底会把「加了变体忘了接线」从**编译错误**降级成「点了没反应」。两级 dispatch 各守一条: +//! 那一层守 `pages::Message`,本文件守 `GroupMessage` 与四个 handler。 +//! +//! r1 复审的 P3-2 指出上一版有洞:只拦 `_ =>` 的话,`other => { … }` 这种 **binding 兜底** +//! 照样取消穷尽性、照样静默。本版判据反过来写——**除已知臂外,任何含 `=>` 的行都算兜底**: +//! `settings.rs` 里每条臂都必须以 `pages::settings::GroupMessage::` 开头且恰好 4 条;handler 里 +//! 每条臂都必须以该组自己的变体路径开头;三个还是空枚举的 handler 一行 `=>` 都不许有。 +//! +//! 站位:本文件是 `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; + +/// 不碰磁盘的 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(), + ) +} + +/// 收集「不该存在的臂」:非注释行里出现 `=>`,但不以该文件允许的前缀开头。 +fn offending_arms(path: &str, source: &str, allowed: Option<&str>) -> Vec { + source + .lines() + .enumerate() + .filter_map(|(index, line)| { + let trimmed = line.trim(); + if trimmed.starts_with("//") || !trimmed.contains("=>") { + return None; + } + match allowed { + Some(prefix) if trimmed.starts_with(prefix) => None, + _ => Some(format!("{path}:{}: {trimmed}", index + 1)), + } + }) + .collect() +} + +/// `on_settings` 与四个 handler 里都**不许**出现任何形式的兜底臂(`_ =>` 或 `other =>`)。 +/// +/// 抓得住:① 给 `on_settings` 加 `_ =>` 或 `other =>`(`GroupMessage` 不再穷尽); +/// ② 四个 handler 把 `match message {}` 改成 `_ => Task::none()` / `other => { … }`(同样取消穷尽性); +/// ③ 给 settings.rs 多接一条不认识的臂(前缀不对)。 +/// 抓不住:handler 忽略消息(`let _ = message; Task::none()`)、两个 handler 身体写反或写成一样 +/// —— 类型对得上就编得过;也抓不住「新变体接了个空壳」。那两条只有人读能看得出来。 +#[test] +fn the_group_dispatch_has_no_catch_all_arm() { + let sources: [(&str, &str, Option<&str>); 5] = [ + ( + "app/update/settings.rs", + include_str!("../settings.rs"), + Some("pages::settings::GroupMessage::"), + ), + ( + "app/update/settings/java.rs", + include_str!("java.rs"), + Some("pages::settings::java::Message::"), + ), + ( + "app/update/settings/download.rs", + include_str!("download.rs"), + None, + ), + ( + "app/update/settings/language_appearance.rs", + include_str!("language_appearance.rs"), + None, + ), + ( + "app/update/settings/ai_about.rs", + include_str!("ai_about.rs"), + None, + ), + ]; + + let mut offending = Vec::new(); + for (path, source, allowed) in sources { + offending.extend(offending_arms(path, source, allowed)); + } + + assert!( + offending.is_empty(), + "设置页分组消息的 dispatch 里出现了兜底臂({offending:?}):\ + 任何 catch-all / binding 臂都会取消 `GroupMessage` 的穷尽性,\ + 新变体漏接线时编译器不再报 E0004" + ); +} + +/// `on_settings` 必须**恰好四条**臂,一条一条显式写全(不是「至少」)。 +/// +/// 抓得住:有人删掉一条臂、或者把两条合到一起(`A(..) | B(..) =>` 这种也过不了上面那条前缀判据, +/// 这里是第二条独立判据)。 +/// 抓不住:臂的顺序(顺序不影响语义)。 +#[test] +fn the_group_dispatch_has_exactly_four_arms() { + let source = include_str!("../settings.rs"); + let arms = source + .lines() + .filter(|line| line.trim().starts_with("pages::settings::GroupMessage::")) + .count(); + + assert_eq!( + arms, 4, + "`on_settings` 应该恰好四条臂(java / download / language_appearance / ai_about),实际 {arms} 条" + ); +} + +/// **活的端到端证据**:一条组消息真的从外壳那条既有通道走到该组的 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`:消息漏斗在某一环断了" + ); +} diff --git a/crates/app-iced/src/app/view.rs b/crates/app-iced/src/app/view.rs index bbdc8c3..54584cb 100644 --- a/crates/app-iced/src/app/view.rs +++ b/crates/app-iced/src/app/view.rs @@ -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, ) } diff --git a/crates/app-iced/src/pages/message.rs b/crates/app-iced/src/pages/message.rs new file mode 100644 index 0000000..8616eac --- /dev/null +++ b/crates/app-iced/src/pages/message.rs @@ -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, +} diff --git a/crates/app-iced/src/pages/mod.rs b/crates/app-iced/src/pages/mod.rs index c00624e..e77e1b5 100644 --- a/crates/app-iced/src/pages/mod.rs +++ b/crates/app-iced/src/pages/mod.rs @@ -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::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 { - 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) diff --git a/crates/app-iced/src/pages/screen.rs b/crates/app-iced/src/pages/screen.rs new file mode 100644 index 0000000..1026eeb --- /dev/null +++ b/crates/app-iced/src/pages/screen.rs @@ -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::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 { + 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, + } + } +} diff --git a/crates/app-iced/src/pages/settings/ai_about.rs b/crates/app-iced/src/pages/settings/ai_about.rs index f7c62f5..6d5aa62 100644 --- a/crates/app-iced/src/pages/settings/ai_about.rs +++ b/crates/app-iced/src/pages/settings/ai_about.rs @@ -7,7 +7,33 @@ //! ## 接线步骤(ai+about 线照做) //! //! 1. 在本文件写 `pub(super) const GROUP: Group = Group { … }`,形状抄 `java.rs`; -//! 2. 到 `settings/mod.rs` 的 `GROUPS` 里加一行 `ai::GROUP,`,并把数组长度 `4` 改成 `5`; +//! 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 {} diff --git a/crates/app-iced/src/pages/settings/appearance.rs b/crates/app-iced/src/pages/settings/appearance.rs index 85e0b41..a58dbed 100644 --- a/crates/app-iced/src/pages/settings/appearance.rs +++ b/crates/app-iced/src/pages/settings/appearance.rs @@ -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> { + states + .appearance + .map(|state| crate::pages::appearance::section(ui, state)) +} diff --git a/crates/app-iced/src/pages/settings/download.rs b/crates/app-iced/src/pages/settings/download.rs index 1bb7420..b3d8a09 100644 --- a/crates/app-iced/src/pages/settings/download.rs +++ b/crates/app-iced/src/pages/settings/download.rs @@ -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 {} diff --git a/crates/app-iced/src/pages/settings/java.rs b/crates/app-iced/src/pages/settings/java.rs index fe2bda7..f312af6 100644 --- a/crates/app-iced/src/pages/settings/java.rs +++ b/crates/app-iced/src/pages/settings/java.rs @@ -1,6 +1,6 @@ //! 设置页「Java」分组:运行时 / 默认内存 / JVM 参数(演示行)。 //! -//! **这一组归 java 线**:接真控件时在本文件把 `body` 换成 `Some(...)`(渲染器也写在本文件), +//! **这一组归 java 线**:接真控件时在本文件把 `body` 换成 `Some(java::body)`(渲染器也写在本文件), //! 再把 `rows` 换成真数据源给的文本;`settings/mod.rs` 的注册表不用动。 //! //! 图标说明(视觉复审 P2-3):令牌里 `Play` 是填充字形,和同屏其它描边图标语汇打架; @@ -21,3 +21,23 @@ 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` 会提醒)。 + #[cfg(test)] + Ping, +} diff --git a/crates/app-iced/src/pages/settings/language_appearance.rs b/crates/app-iced/src/pages/settings/language_appearance.rs new file mode 100644 index 0000000..f167c03 --- /dev/null +++ b/crates/app-iced/src/pages/settings/language_appearance.rs @@ -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 {} diff --git a/crates/app-iced/src/pages/settings/message.rs b/crates/app-iced/src/pages/settings/message.rs new file mode 100644 index 0000000..1e9e557 --- /dev/null +++ b/crates/app-iced/src/pages/settings/message.rs @@ -0,0 +1,105 @@ +//! 设置页的**分组消息投递口**(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_wildcard_fallback` 在 `app/update/settings.rs`。 + +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::(), + "settings::java::Message", + ), + ( + std::any::type_name::(), + "settings::download::Message", + ), + ( + std::any::type_name::(), + "settings::language_appearance::Message", + ), + ( + std::any::type_name::(), + "settings::ai_about::Message", + ), + ]; + + for (actual, expected) in homes { + assert!( + actual.ends_with(expected), + "载荷类型 {actual} 不在它自己的文件里(应为 {expected}):\ + 两个组共用一个消息类型,四条线又会撞同一个追加锚点" + ); + } + } +} diff --git a/crates/app-iced/src/pages/settings/mod.rs b/crates/app-iced/src/pages/settings/mod.rs index 95c067b..e73d94c 100644 --- a/crates/app-iced/src/pages/settings/mod.rs +++ b/crates/app-iced/src/pages/settings/mod.rs @@ -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,75 @@ //! | `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` 都不用动。 +//! +//! ### 仍然共享、本 mission 无法消除的点(四条线都可能碰到,只能靠 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` 就不编译),以及「左列顺序」那条字面量断言会连带红,需要人确认。 -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 +118,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>; /// 一个设置分组:名字、图标、演示行、(可选)把这一组正文画成真控件的渲染器。 /// -/// 除 `name`/`icon`/`rows` 外没有别的字段:**分组自己的状态归分组自己**,这里只装「怎么画」。 +/// 除 `name`/`icon`/`rows`/`body` 外没有别的字段:**分组自己的状态归分组自己**(见 [`state`]), +/// 这里只装「怎么画」。 struct Group { /// 左列显示名(也是表单列的分组标题)。 name: &'static str, @@ -104,22 +161,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 +186,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, - 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, 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 +198,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![ diff --git a/crates/app-iced/src/pages/settings/state.rs b/crates/app-iced/src/pages/settings/state.rs new file mode 100644 index 0000000..7310b0b --- /dev/null +++ b/crates/app-iced/src/pages/settings/state.rs @@ -0,0 +1,79 @@ +//! 设置页的**状态漏斗**(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`; +/// 让它活得比调用方长,借用就不会把返回值钉在函数栈上。 +pub fn demo_groups() -> &'static Groups { + static DEMO: LazyLock = LazyLock::new(Groups::default); + &DEMO +} diff --git a/crates/app-iced/src/pages/settings/tests/download_section.rs b/crates/app-iced/src/pages/settings/tests/download_section.rs index 8404c39..f3ca8bc 100644 --- a/crates/app-iced/src/pages/settings/tests/download_section.rs +++ b/crates/app-iced/src/pages/settings/tests/download_section.rs @@ -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!( diff --git a/crates/app-iced/src/pages/settings/tests/general_section.rs b/crates/app-iced/src/pages/settings/tests/general_section.rs index 4bc8834..dc3c8b0 100644 --- a/crates/app-iced/src/pages/settings/tests/general_section.rs +++ b/crates/app-iced/src/pages/settings/tests/general_section.rs @@ -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!( diff --git a/crates/app-iced/src/pages/settings/tests/java_section.rs b/crates/app-iced/src/pages/settings/tests/java_section.rs index a08df1b..0dbcef4 100644 --- a/crates/app-iced/src/pages/settings/tests/java_section.rs +++ b/crates/app-iced/src/pages/settings/tests/java_section.rs @@ -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!( diff --git a/crates/app-iced/src/pages/settings/tests/mod.rs b/crates/app-iced/src/pages/settings/tests/mod.rs index 665b3a4..a166939 100644 --- a/crates/app-iced/src/pages/settings/tests/mod.rs +++ b/crates/app-iced/src/pages/settings/tests/mod.rs @@ -130,33 +130,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(), "演示下标仍在范围内"); }