//! # 页面层(P4):布局系统 + 四页 + **路由/真组件的接入口**(M84 `PAGEFRAME` → M90 `APPSKEL`) //! //! ## 这一层交了什么 //! //! | 阶段 | 交付 | 判据 | //! |---|---|---| //! | M84 | **布局系统**(档位/版面/列宽)+ 四页占位视觉 | 下面的版面阈值表 | //! | **M90(本文件当前状态)** | ①视图模型 [`model`] ②导航项接上 `presenter::router::Route` ③**实例页整页接真组件 + 真数据** | 「接真组件后版面约束一条没破」(见 `pages/tests.rs` 的数值断言) | //! //! **导航轨归属(M90 r3 订正,复审 P2-1)**:产品路径(`app/view.rs`)走 //! `shell::view_with_content`,导航轨由 **`shell/nav.rs` 的手搓实现**画——它与 M78 组件几何同值 //! (宽 88、药丸 56×32),复审逐像素量过「r1 产品图(真组件 rail)vs r2 产品图(shell rail) //! 在 y≥48 区域差异为 0」;组件版的 [`rail`] 现在只服务 `pages::app`(`pages_demo` 出口图、 //! harness)与单测。为什么不是「把 rail 挪进内容槽位」:那需要把 `shell` 的消息类型泛型化/映射, //! 成本远大于收益(见偏离表 #10)。 //! //! 实例页之外的三页**仍是 M84 的占位视觉**(下载/日志/设置),但它们已经: //! 走同一个 `Message` → `update` → `Route` 通道,导航轨点得动、路由栈按 `presenter::router` 的语义走。 //! 页面美化是后续 mission 的事,本 mission 只保证「地基是通的」。 //! //! ## 版面规矩(M84 立的三条,M90 一条没动) //! //! 1. **宽度档位**:官方响应式断点 **600 / 840**([`layout::WidthClass`]); //! 2. **版面**:单列 / supporting-pane / list-detail,各自的成立条件与退化阈值写在 [`layout`]; //! 3. **行宽上限**:正文列硬上限 **424px(逐行实测 67 字符 ≤72)**([`layout::measure_cap_px`]), //! 由 [`layout::content_column_width`] / [`layout::frame_metrics`] **无条件施加**, //! 页面拿不到「更宽」的选项。 //! //! ## 版面阈值表(`layout` 模块文档是全文,这里给速查) //! //! | 版面 | 用它的页面 | 成立条件 | 退化 | //! |---|---|---|---| //! | `SingleColumn` | 下载 / 日志(永远);实例 / 设置(窄窗) | 总是 | — | //! | `SupportingPane` | 设置 | `w ≥ 600` 且 `w ≥ 720`(240 + 24 + 320 + 2×24 + 88) | 单列(分组按组堆叠) | //! | `ListDetail` | 实例 | `w ≥ 840`(官方 expanded)且 `w ≥ 816` | 单列(列表 ↔ 详情整页切换) | //! //! ## 模块 //! //! | 文件 | 职责 | //! |---|---| //! | [`layout`] | **布局系统**:档位 / 版面 / 列宽 / 阈值,纯函数 + 单测(无 iced 控件树) | //! | [`model`] | **视图模型**:presenter 快照 → 页面要画的东西(页面层不认识 presenter 类型) | //! | [`frame`] | 页头 / 内容列 / 两列骨架 / 空态 / 卡片 / 分隔线 | //! | `instances` | 实例页(list-detail)——**M90 唯一打通到真组件 + 真数据真命令的页** | //! | `settings` | 设置页(supporting-pane,占位视觉) | //! | `download` / `logs` | 下载 / 日志页(单列,占位视觉) | //! | [`harness`] | 截图 harness(`examples/pages_demo.rs` 的入口,**不是** `cli.rs` 的一部分) | //! //! ## 消息与路由(M90 新增) //! //! 页面**不 import `crate::app::Message`**(组件契约 §2 的同一条纪律):它有自己的 //! [`Message`],app 层只做一次 `.map`。路由跳转的载荷是 `presenter::router::Route` 本身, //! 于是「页面里点一行」= 「压一层栈」,语义与 presenter 的 `Router` 天然一致, //! 中间不再有一层「页面自定义的选中项」需要同步。 //! //! ## 偏离登记(D30 要求逐条写清;M84 六条 + M90 三条) //! //! | # | 偏离 | 为什么 | 什么时候收口 | //! |---|---|---|---| //! | 1 | 页头按 M3 口径**铺满页面宽度**(贴页边距),不与正文列对齐 | AppBar 本来就该满宽;1180 下页头右缘 **1156** 与详情列右缘 **896** 有 **260px 分歧** | 真 top bar 落地时按 M3 定语(页头右侧是操作区,不跟随列宽) | //! | 2 | 空态标题 `HeadlineSmall 24` 比页标题 `TitleLarge 22` 大 | 12b §15.2 明确「空态是唯一允许 Headline 级的场景」,与 §16.4 的页标题档有 spec 张力 | 有意保留,不修 | //! | 3 | 单列页(下载 / 日志)在 1180 下只用掉 **40.6%** 内容宽(424/1044) | 行宽上限的必然结果;空态居中不显空 | 版本浏览真正接数据后重新决议 `PaneKit` | //! | 4 | 空态图标字形 **40px**(12b 15.1 是 `[估]` 32~48) | 令牌 `IconSize` 只有 24 一档,`frame::glyph` 按尺寸重算 stroke | 组件库的 `EmptyState`(M77)落地时 | //! | 5 | 窄窗「Back to instances」没有前导箭头 | 占位文本按钮没有图标位 | 真 top bar / M78 的导航件落地时 | //! | 6 | 实例页「未选中详情」空态**没有出图** | 演示数据固定选中第 1 条;空态代码路径在(`detail_empty`) | D30 状态矩阵补齐时补一张,不必重跑全套 | //! | 7 | ~~标题栏仍是 48px 留白带~~ → **已于 M90 r2 修**:产品页现在走 `shell::view_with_content`,标题栏 / 拖动热区 / 两条缩放热区 / 三个 caption 键全在 | r1 的实质问题是**交互面缺失**(无边框窗口 + 产品页没有任何窗口装饰 ⇒ 拖不动、缩不了、点不到关闭),不是「少画了一条栏」 | ✅ 已收口(见下) | //! | 8 | 实例页的「New instance / Import」仍是**没有后端的按钮**(M90) | presenter 没有「打开新建向导」的指令(`wizard` 只有状态机,`AppCommand` 里无对应项),已报 finding | 向导 mission 补 `AppCommand::OpenWizard` 之后 | //! | 10 | **两套 rail 各测各的**(M90 r3):产品走 `shell/nav.rs`,组件版只给 harness/单测 | r2 把产品页接进真外壳后,rail 自然落到 `shell/nav.rs`(像素等价,见上)。但两边的守门是分开的:`shell/nav.rs` 自带 indicator 56×32 的单测,`pages/tests.rs` 守的是组件 rail 的 88 ⇒ **将来哪边先漂没人拦**。统一要泛型化 shell 的消息类型,成本大于收益 | 谁做「rail 单一实现」的 mission(已在报告登记);在那之前任何改 rail 几何的改动都要两处一起改并各自跑守门 | //! | 9 | 实例页的正文段落只在**演示数据**里出现(M90) | 真快照里没有「正文描述」这种字段;M84 那段 361 字符的 `NOTE` 是给行宽测量当样本的,不能凭空造到真页面里 | i18n / 实例详情 mission 补真实描述字段 | //! //! ### 偏离 #7 的收口记录(M90 r2 / P1-1) //! //! r1 的产品页渲染的是 `pages::app`:一条**只有高度、没有任何控件**的标题栏留白带。而 //! `DragWindow` / `ResizeStart` / `Minimize` / `ToggleMaximize` / `Close` 五种消息的**构造点只有 //! `shell/mod.rs`**,窗口又是 `decorations: false` ⇒ 交互模式下拖不动、缩不了、点不到关闭。 //! //! 修法:`shell::view_with_content(ui, nav, title, content, forced)` 把外壳(标题栏 + 三条热区 + //! 导航轨)与内容区拆开,产品页把 [`page`] 的树塞进内容槽位;`shell::view`(M68 骨架卡)保留给 //! `Route::Agent` 回落 —— 删了会让私有模块里的 `view` / `nav::rail` 变死代码,`-D warnings` 会红。 //! //! [`app`](本文件)现在**只服务 harness**(`pages_demo` example 与单测):它的标题栏仍是留白带, //! 因为 `shell` 的回调类型是 `crate::app::Message`,套不进 `pages::Message` 的树里。 //! //! ## 截图通道 //! //! ```text //! cargo run -p unlucky-app-iced --example pages_demo -- \ //! --screen=instances --width=1180 --height=760 \ //! --screenshot=out.png --screenshot-theme=light --software-renderer //! ``` //! //! 产品二进制的截图走 `--screenshot`(页面树由 app 层按路由装配),本 harness 只出**单页**图, //! 用于页面级回归与宽度对照。 pub mod appearance; pub mod frame; pub mod harness; pub mod layout; pub mod model; mod download; mod instances; mod logs; mod settings; #[cfg(test)] mod tests; pub use download::{Category, Download}; pub use layout::{FrameMetrics, PageLayout, PaneKit, WidthClass}; pub use logs::{Finding, Level, Line, Logs, Outcome, Severity}; pub use model::{Detail, Instances, Meta, Model, Row}; use iced::widget::{column, container, row, space}; use iced::{Element, Length}; use unlucky_design::elevation::MaterialLayer; use unlucky_design::icons::IconKind; use unlucky_presenter::router::{NavSection, Route}; use crate::app::components::navigation::drawer::{self, Navigation}; use crate::app::components::navigation::navigation; 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 日志页**:诊断卡片的一行摘要)。 /// /// 文本由页面造(它是唯一同时看得见规则 id / 分类 / 置信度的地方),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, } } } /// 导航轨的固定项:图标 + 标签 + 目标路由(顺序 = 自上而下)。 /// /// 顺序与 `presenter::router::NavSection` 的声明顺序一致,改这里等于改导航栏。 const NAV: [(Option, IconKind, &str, Route); 5] = [ ( Some(NavSection::Instances), IconKind::Instances, "Instances", Route::Instances, ), ( Some(NavSection::Download), IconKind::Download, "Download", Route::Download, ), (Some(NavSection::Logs), IconKind::Logs, "Logs", Route::Logs), ( Some(NavSection::Settings), IconKind::Settings, "Settings", Route::Settings, ), // AI 独立页没有导航高亮项(`Route::nav()` 对它返回 `None`) (None, IconKind::Ai, "Assistant", Route::Agent), ]; /// **单页内容**(不含外壳):交给 `shell::view_with_content` 塞进真标题栏与导航轨之间。 /// /// 产品路径(`app/view.rs`)走 [`page_with_appearance`]:外壳由 `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) } /// 同 [`page`],但带上**外观状态**(设置页的「外观」分组要读当前值与诊断)。 /// /// 为什么另开一个入口而不是给 `Model` 加字段:`pages/model.rs` 是页面与 presenter 之间的展示 /// 契约(`Model` 只装「要画什么」),外观是**运行时状态**(滑块请求值、已解码的背景图、诊断), /// 塞进去会让演示数据(`pages::demo`)也得伪造一份外观状态。 pub fn page_with_appearance<'a>( ui: &'a Ui, model: &'a Model, appearance: &'a appearance::State, ) -> Element<'a, Message> { render(ui, model, Some(appearance)) } fn render<'a>( ui: &'a Ui, model: &'a Model, appearance: Option<&'a appearance::State>, ) -> 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::Logs => logs::view(ui, metrics, &model.logs, model.lang), } } /// 整窗(**harness 专用**):标题栏留白带 + 导航轨 + 当前路由的页面。 /// /// 产品路径**不走这里**(它走 [`page`] + `shell::view_with_content`,用真标题栏)。这个入口留给 /// `pages_demo` example 与单测:它们要一张「只有页面、不接 app 消息」的独立图,而 `shell` 的 /// 回调类型是 `crate::app::Message`,套不进 `pages::Message` 的树里。 pub fn app<'a>(ui: &'a Ui, model: &'a Model) -> Element<'a, Message> { let body = page(ui, model); let shell = row![rail(ui, model.active), body] .width(Length::Fill) .height(Length::Fill); let titled = column![titlebar_placeholder(ui), shell] .width(Length::Fill) .height(Length::Fill); container(titled) .width(Length::Fill) .height(Length::Fill) .style(move |_theme| filled(ui.layer(MaterialLayer::Canvas))) .into() } /// 演示模型(`pages_demo` example 与单测用;真页面走 `app/view.rs` 的 `AppState` 转换)。 /// /// 只有实例页带数据,其余三页是占位视觉(不读数据)。 pub fn demo_model(screen: Screen, window_width: f32) -> Model { let mut model = Model::new(screen, window_width, Some(screen.nav())); if screen == Screen::Instances { model.instances = instances::demo(); } if screen == Screen::Download { model.download = download::demo(unlucky_i18n::Lang::En.catalog()); } if screen == Screen::Logs { model.logs = logs::demo(model.lang); } model } /// 导航轨:**真组件**(`components::navigation` 的 rail 形态,宽 = 令牌 `nav_rail_width` = 88)。 /// /// M84 这里是一条留白带;M90 换成 M78 的 `NavigationRail`,于是「导航项 → 路由」是真的可点。 fn rail<'a>(ui: &'a Ui, active: Option) -> Element<'a, Message> { let mut group = drawer::NavGroup::new(); for (section, icon, label, route) in NAV { let mut item = drawer::NavItem::new(label, icon, Message::Navigate(route)); if section.is_some() && active == section { item = item.selected(); } group = group.item(item); } navigation(ui, Navigation::rail().group(group)) } /// 标题栏留白带(高 = `LayoutMetrics::titlebar_height`)——**只给 [`app`] 的 harness 路径用**。 /// /// 产品路径的标题栏是 `shell::view_with_content` 画的那个真的(三个 caption 键 + 拖动热区), /// 见模块文档偏离 #7(M90 r2 已修)。 fn titlebar_placeholder<'a>(ui: &'a Ui) -> Element<'a, Message> { container(space::horizontal()) .width(Length::Fill) .height(ui.metrics().titlebar_height) .style(move |_theme| filled(ui.layer(MaterialLayer::Canvas))) .into() }