feat(app-iced): M101 r2 设置页状态漏斗 + pages/mod.rs 拆分 + r1 复审订正

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<Element>`:每组自己从
  `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 个。
This commit is contained in:
AskaEth committed 2026-11-03 20:57:08 +08:00
1 parent 423ea77758
commit 029ece3f17
25 files changed
+1004 -240

No files matched your search

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