feat(app-iced): M90 —— app.rs 拆成 src/app/ + presenter 真接线 + 实例页端到端
拆分(行为不变,截图四件套逐字节相同):
- 400 行的 app.rs 拆成 src/app/{mod,state,message,update,subscription,view,boot,tests}.rs,
每个文件 ≤ 300 行;components 的 #[path] 跟着搬到 src/app/mod.rs(逻辑路径仍是 crate::app::components),
可见性放到 pub(crate) 让页面层能用真组件。
- 拆分前后各出 shell/gallery 浅深四张图,cmp 全部 IDENTICAL。
presenter 接线(crate 文档 §4):
- App 持有 Box<dyn Backend> + SettingsBridge + AppState;启动 build_backend + SettingsBridge::open + AppState::new。
- 截图通道强制 mock(D30 可复现);交互模式走真 host,--data-dir 从 M90 起真的被消费。
- Boot 抽干首屏快照(第 3 帧落盘,等节拍会把空页面拍进图);节拍 = 帧事件 + 33ms 时间闸门
(iced::time::every 要 tokio/smol,见 finding),tick → drain_events → AppState::apply。
- 用户动作不乐观回显:Launch/Stop 走 Backend::dispatch,状态等事件回来才变。
- 路由:pages::Message::Navigate(Route) 直接进 AppState::navigate,选中项 = 路由栈顶(不再有本地副本);
首屏自动选中第一行(list-detail 桌面惯例),只做一次。
实例页打通(其余三页保留占位视觉 + Message/update + 路由):
- 列表行换成 components::navigation::ListItem(真五态/选中),页头与详情动作换成 components::buttons
(Play 是唯一的实心 accent),分隔线换成 components::divider。
- 数据全部来自 presenter 快照:VERSION/LOADER/ISOLATION/VERSION ID/FILES/LAST PLAYED + 运行状态行。
- 导航轨换成真 NavigationRail(88),5 项各自压/替换路由。
版面约束未破(实测):列表列 336、正文列 424(元信息卡 206+12+206,右缘 896)、rail 88(药丸 16..71)、
断点 600/840 走 frame_metrics;新增 pages/tests.rs 的 9 条数值断言钉住它们。
This commit is contained in:
1 parent
270026338f
commit
b484998fc9
17 files changed
+1990
-676
No files matched your search
+143
-112
@@ -1,17 +1,17 @@
|
||||
//! # 页面骨架 + 布局系统(P4 / M84 `PAGEFRAME`)
|
||||
//! # 页面层(P4):布局系统 + 四页 + **路由/真组件的接入口**(M84 `PAGEFRAME` → M90 `APPSKEL`)
|
||||
//!
|
||||
//! 本模块**不依赖组件库**(`components/` 由 M74/M75 并行建):它交付的是**布局系统**,
|
||||
//! 四个页面只是它的用例。控件全是同一份令牌下的占位实现,等组件家族合入后由另一条 mission 替换。
|
||||
//! ## 这一层交了什么
|
||||
//!
|
||||
//! ## 为什么要做这一层(历史判词)
|
||||
//! | 阶段 | 交付 | 判据 |
|
||||
//! |---|---|---|
|
||||
//! | M84 | **布局系统**(档位/版面/列宽)+ 四页占位视觉 | 下面的版面阈值表 |
|
||||
//! | **M90(本文件当前状态)** | ①视图模型 [`model`] ②导航轨换成真组件并接上 `presenter::router::Route` ③**实例页整页接真组件 + 真数据** | 「接真组件后版面约束一条没破」(见 `pages/tests.rs` 的数值断言) |
|
||||
//!
|
||||
//! M68 的骨架内容区是**一张全宽卡**。视觉复审的结论(用户认可):
|
||||
//! 实例页之外的三页**仍是 M84 的占位视觉**(下载/日志/设置),但它们已经:
|
||||
//! 走同一个 `Message` → `update` → `Route` 通道,导航轨点得动、路由栈按 `presenter::router` 的语义走。
|
||||
//! 页面美化是后续 mission 的事,本 mission 只保证「地基是通的」。
|
||||
//!
|
||||
//! > **不能把「全宽单卡」当页面模板** —— 正文单行 **712px**,那张卡里的 `BodySmall` 12px 文本
|
||||
//! > **实测装了 137 字符**(口径见 [`layout`] §3),远超舒适行宽;
|
||||
//! > 官方桌面口径是 **supporting-pane / list-detail**。
|
||||
//!
|
||||
//! 所以这里先立三条规矩,页面照着用:
|
||||
//! ## 版面规矩(M84 立的三条,M90 一条没动)
|
||||
//!
|
||||
//! 1. **宽度档位**:官方响应式断点 **600 / 840**([`layout::WidthClass`]);
|
||||
//! 2. **版面**:单列 / supporting-pane / list-detail,各自的成立条件与退化阈值写在 [`layout`];
|
||||
@@ -32,22 +32,33 @@
|
||||
//! | 文件 | 职责 |
|
||||
//! |---|---|
|
||||
//! | [`layout`] | **布局系统**:档位 / 版面 / 列宽 / 阈值,纯函数 + 单测(无 iced 控件树) |
|
||||
//! | [`frame`] | 页头 / 内容列 / 两列骨架 / 空态 / 卡片 / 分隔线(占位控件) |
|
||||
//! | `instances` | 实例页(list-detail) |
|
||||
//! | `settings` | 设置页(supporting-pane) |
|
||||
//! | `download` / `logs` | 下载 / 日志页(单列,两行页头的差异) |
|
||||
//! | [`model`] | **视图模型**:presenter 快照 → 页面要画的东西(页面层不认识 presenter 类型) |
|
||||
//! | [`frame`] | 页头 / 内容列 / 两列骨架 / 空态 / 卡片 / 分隔线 |
|
||||
//! | `instances` | 实例页(list-detail)——**M90 唯一打通到真组件 + 真数据真命令的页** |
|
||||
//! | `settings` | 设置页(supporting-pane,占位视觉) |
|
||||
//! | `download` / `logs` | 下载 / 日志页(单列,占位视觉) |
|
||||
//! | [`harness`] | 截图 harness(`examples/pages_demo.rs` 的入口,**不是** `cli.rs` 的一部分) |
|
||||
//!
|
||||
//! ## 偏离登记(D30 要求逐条写清;视觉复审 r1 要求把末两条补进来)
|
||||
//! ## 消息与路由(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`(现在写「一串线性进度项」是按单列定的) |
|
||||
//! | 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) | 真实标题栏与窗口拖拽/缩放热区在 `src/shell/mod.rs`,本 mission 的 scope 不含它;页面层拿不到「内容槽位」 | shell 暴露「内容槽位」的 mission(已报 tower) |
|
||||
//! | 8 | 实例页的「New instance / Import」仍是**没有后端的按钮**(M90) | presenter 没有「打开新建向导」的指令(`wizard` 只有状态机,`AppCommand` 里无对应项),已报 finding | 向导 mission 补 `AppCommand::OpenWizard` 之后 |
|
||||
//! | 9 | 实例页的正文段落只在**演示数据**里出现(M90) | 真快照里没有「正文描述」这种字段;M84 那段 361 字符的 `NOTE` 是给行宽测量当样本的,不能凭空造到真页面里 | i18n / 实例详情 mission 补真实描述字段 |
|
||||
//!
|
||||
//! ## 截图通道
|
||||
//!
|
||||
@@ -57,25 +68,34 @@
|
||||
//! --screenshot=out.png --screenshot-theme=light --software-renderer
|
||||
//! ```
|
||||
//!
|
||||
//! `cli.rs` 还没有 `--screen` / `--width`(M75 在改它),所以参数解析放在 [`harness`] 里,
|
||||
//! **本模块一行没碰 `cli.rs` / `app.rs`**。
|
||||
//! 产品二进制的截图走 `--screenshot`(页面树由 app 层按路由装配),本 harness 只出**单页**图,
|
||||
//! 用于页面级回归与宽度对照。
|
||||
|
||||
pub mod frame;
|
||||
pub mod harness;
|
||||
pub mod layout;
|
||||
pub mod model;
|
||||
|
||||
mod download;
|
||||
mod instances;
|
||||
mod logs;
|
||||
mod settings;
|
||||
|
||||
pub use layout::{FrameMetrics, PageLayout, PaneKit, WidthClass};
|
||||
#[cfg(test)]
|
||||
mod tests;
|
||||
|
||||
use iced::widget::{container, row, space};
|
||||
pub use layout::{FrameMetrics, PageLayout, PaneKit, WidthClass};
|
||||
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;
|
||||
@@ -83,24 +103,30 @@ use frame::filled;
|
||||
/// 页面消息。
|
||||
///
|
||||
/// **故意不用 `crate::app::Message`**(组件库契约 §2 的同一条理由):页面不绑 app 的状态层,
|
||||
/// 接线时由 app 层把 [`Message::Action`] 映射成自己的消息。骨架期的动作只有「点了哪个按钮」,
|
||||
/// 没有业务语义。
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
/// 接线时由 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),
|
||||
}
|
||||
|
||||
/// 页面动作清单(骨架期够用就行;接线时按业务语义重命名/扩充)。
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum Action {
|
||||
/// 新建实例。
|
||||
/// 新建实例(**还没有后端**,见模块文档偏离 #8)。
|
||||
NewInstance,
|
||||
/// 导入。
|
||||
/// 导入(同上)。
|
||||
Import,
|
||||
/// 返回实例列表(窄窗整页切换)。
|
||||
/// 返回实例列表(窄窗整页切换)。**已由 `Message::Navigate(Route::Instances)` 取代**,
|
||||
/// 这里保留枚举项是为了别把「返回」这件意图从动作表里抹掉(外壳回退路径仍可能用到)。
|
||||
Back,
|
||||
/// 启动。
|
||||
/// 启动(占位动作表里的「启动」;真启动走 `Message::Launch`)。
|
||||
Play,
|
||||
/// 更多。
|
||||
More,
|
||||
@@ -177,28 +203,77 @@ impl Screen {
|
||||
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,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// 单独渲染一页(截图 / 手看用,**不依赖业务状态机**)。
|
||||
/// 导航轨的固定项:图标 + 标签 + 目标路由(顺序 = 自上而下)。
|
||||
///
|
||||
/// 窗口宽度是**含外壳的窗口宽度**(档位判据就是它):返回的树里,左侧 88px 与顶部 48px 是
|
||||
/// **留白带**,代表真实外壳的导航轨与标题栏——`shell::nav` 是私有模块,本 mission 不复制它的实现
|
||||
/// (真正的 `NavigationDrawer` 由 M78 提供,接线时把这两条带换成外壳即可)。
|
||||
pub fn demo<'a>(ui: &'a Ui, screen: Screen, window_width: f32) -> Element<'a, Message> {
|
||||
let metrics = layout::frame_metrics(screen.kit(), window_width);
|
||||
/// 顺序与 `presenter::router::NavSection` 的声明顺序一致,改这里等于改导航栏。
|
||||
const NAV: [(Option<NavSection>, 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),
|
||||
];
|
||||
|
||||
let page = match screen {
|
||||
Screen::Instances => instances::view(ui, metrics, Some(instances::SELECTED)),
|
||||
/// 整窗:标题栏留白带 + 导航轨 + 当前路由的页面。
|
||||
///
|
||||
/// 视图模型由**调用方持有**(`App` 每拍重建一次 `Model`),这里只借用:`view()` 不能返回
|
||||
/// 一棵借用局部变量的树(iced 的 `view(&self)` 只借 `&self`)。
|
||||
pub fn app<'a>(ui: &'a Ui, model: &'a Model) -> Element<'a, Message> {
|
||||
let metrics = layout::frame_metrics(model.screen.kit(), model.window_width);
|
||||
|
||||
let page = match model.screen {
|
||||
Screen::Instances => instances::view(ui, metrics, &model.instances),
|
||||
Screen::Download => download::view(ui, metrics),
|
||||
Screen::Settings => settings::view(ui, metrics),
|
||||
Screen::Logs => logs::view(ui, metrics),
|
||||
};
|
||||
|
||||
let shell = row![rail_placeholder(ui), page]
|
||||
let shell = row![rail(ui, model.active), page]
|
||||
.width(Length::Fill)
|
||||
.height(Length::Fill);
|
||||
|
||||
let titled = iced::widget::column![titlebar_placeholder(ui), shell]
|
||||
let titled = column![titlebar_placeholder(ui), shell]
|
||||
.width(Length::Fill)
|
||||
.height(Length::Fill);
|
||||
|
||||
@@ -209,7 +284,37 @@ pub fn demo<'a>(ui: &'a Ui, screen: Screen, window_width: f32) -> Element<'a, Me
|
||||
.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();
|
||||
}
|
||||
model
|
||||
}
|
||||
|
||||
/// 导航轨:**真组件**(`components::navigation` 的 rail 形态,宽 = 令牌 `nav_rail_width` = 88)。
|
||||
///
|
||||
/// M84 这里是一条留白带;M90 换成 M78 的 `NavigationRail`,于是「导航项 → 路由」是真的可点。
|
||||
fn rail<'a>(ui: &'a Ui, active: Option<NavSection>) -> 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`)。
|
||||
///
|
||||
/// 真实标题栏在 `src/shell/mod.rs`(不在本 mission 的 scope 内),见模块文档偏离 #7。
|
||||
fn titlebar_placeholder<'a>(ui: &'a Ui) -> Element<'a, Message> {
|
||||
container(space::horizontal())
|
||||
.width(Length::Fill)
|
||||
@@ -217,77 +322,3 @@ fn titlebar_placeholder<'a>(ui: &'a Ui) -> Element<'a, Message> {
|
||||
.style(move |_theme| filled(ui.layer(MaterialLayer::Canvas)))
|
||||
.into()
|
||||
}
|
||||
|
||||
/// 导航轨留白带(宽 = `LayoutMetrics::nav_rail_width`)。
|
||||
fn rail_placeholder<'a>(ui: &'a Ui) -> Element<'a, Message> {
|
||||
container(space::horizontal())
|
||||
.width(Length::Fixed(layout::rail_width()))
|
||||
.height(Length::Fill)
|
||||
.style(move |_theme| filled(ui.layer(MaterialLayer::Canvas)))
|
||||
.into()
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use unlucky_design::color::SchemeFlavor;
|
||||
use unlucky_design::typography::Platform;
|
||||
|
||||
use crate::theme::Families;
|
||||
|
||||
const FAMILIES: Families = Families {
|
||||
text: "Segoe UI",
|
||||
display: "Segoe UI",
|
||||
mono: "Consolas",
|
||||
};
|
||||
|
||||
fn ui() -> Ui {
|
||||
Ui::with_platform(SchemeFlavor::Light, Platform::Windows, FAMILIES)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn four_screens_answer_the_mission_list() {
|
||||
assert_eq!(Screen::ALL.len(), 4);
|
||||
let slugs: Vec<&str> = Screen::ALL.iter().map(|screen| screen.slug()).collect();
|
||||
assert_eq!(slugs, vec!["instances", "download", "settings", "logs"]);
|
||||
for screen in Screen::ALL {
|
||||
assert_eq!(Screen::from_slug(screen.slug()), Some(screen));
|
||||
}
|
||||
assert_eq!(Screen::from_slug("gallery"), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn screens_declare_the_layout_their_page_needs() {
|
||||
assert_eq!(Screen::Instances.kit(), PaneKit::ListDetail);
|
||||
assert_eq!(Screen::Settings.kit(), PaneKit::Supporting);
|
||||
assert_eq!(Screen::Download.kit(), PaneKit::Single);
|
||||
assert_eq!(Screen::Logs.kit(), PaneKit::Single);
|
||||
}
|
||||
|
||||
/// 四个页面 × 三个对照宽度都能构图(不 panic、不依赖运行时)。
|
||||
#[test]
|
||||
fn every_screen_builds_at_the_three_comparison_widths() {
|
||||
let ui = ui();
|
||||
for width in [1180.0, 900.0, 700.0] {
|
||||
for screen in Screen::ALL {
|
||||
let _element: Element<'_, Message> = demo(&ui, screen, width);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// 版面对照:同一页在 1180 / 900 / 700 三个宽度上必须**真的换版面**。
|
||||
#[test]
|
||||
fn the_width_comparison_really_changes_the_layout() {
|
||||
let wide = layout::frame_metrics(Screen::Instances.kit(), 1180.0);
|
||||
let mid = layout::frame_metrics(Screen::Instances.kit(), 900.0);
|
||||
let narrow = layout::frame_metrics(Screen::Instances.kit(), 700.0);
|
||||
|
||||
assert_eq!(wide.layout, PageLayout::ListDetail);
|
||||
assert_eq!(mid.layout, PageLayout::ListDetail);
|
||||
assert_eq!(narrow.layout, PageLayout::SingleColumn);
|
||||
|
||||
// 900 与 1180 同为 list-detail,但详情列必须真的变窄(不是硬编码 1180 的宽度)
|
||||
assert!(mid.column < wide.column, "900 的详情列应窄于 1180");
|
||||
assert!(narrow.secondary.is_none(), "700 不许再有列表列");
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user