feat(app-iced): M84 页面骨架与布局系统(断点 / 行宽上限 / 三种版面)
视觉复审在 M68 的判词是不能把「全宽单卡」当页面模板(1180 宽下正文单行 712px ≈ 118 字符)。 本 mission 把它落成一套**布局系统**,四个页面只是它的用例: - `pages/layout.rs`:官方 600 / 840 两个档位 → 三种版面(单列 / supporting-pane / list-detail), 阈值由列宽和算出(设置 240+24+320 → 720;实例 336+24+320 → 816 ≤ 840),行宽硬上限 14 × 0.5em × 72 = **504px**,全部是纯函数,`pages/layout/tests.rs` 用绝对值和边界表钉死。 - `pages/frame.rs`:页头(56 / 两行 84)+ 内容列(≤504)+ 两列骨架 + 空态位; `pages/frame/pieces.rs` 是占位控件(卡片 / 分隔线 / 文本按钮 / 图标),令牌驱动、无完整状态机, 待 M75 的 components 家族合入后替换。 - 四页:实例(list-detail,列表列 336 独立滚动,窄窗退化成整页切换)、 设置(supporting-pane,240 分组导航 + ≤504 表单列)、下载 / 日志(单列,两行页头)。 - `pages/harness.rs` + `examples/pages_demo.rs`:自带 `--screen/--width/--screenshot-theme`, 不碰 `cli.rs` / `app.rs`(M75 在改)。 偏离与说明: - 布局用「页边距 16/24」而非 16/24/32,官方 expanded 也是 24(少一条偏离登记); - 实例空详情态一屏会有 5 档字号(22/16/24/12 + 14 的操作按钮),未截图的状态,已知并登记; - `lib.rs` 只加一行 `pub mod pages;`。
This commit is contained in:
1 parent
352dd7937e
commit
2c442585b1
15 files changed
+2979
No files matched your search
@@ -0,0 +1,281 @@
|
||||
//! # 页面骨架 + 布局系统(P4 / M84 `PAGEFRAME`)
|
||||
//!
|
||||
//! 本模块**不依赖组件库**(`components/` 由 M74/M75 并行建):它交付的是**布局系统**,
|
||||
//! 四个页面只是它的用例。控件全是同一份令牌下的占位实现,等组件家族合入后由另一条 mission 替换。
|
||||
//!
|
||||
//! ## 为什么要做这一层(历史判词)
|
||||
//!
|
||||
//! M68 的骨架内容区是**一张全宽卡**。视觉复审的结论(用户认可):
|
||||
//!
|
||||
//! > **不能把「全宽单卡」当页面模板** —— 正文单行 712px ≈ **118 字符**,远超舒适行宽;
|
||||
//! > 官方桌面口径是 **supporting-pane / list-detail**。
|
||||
//!
|
||||
//! 所以这里先立三条规矩,页面照着用:
|
||||
//!
|
||||
//! 1. **宽度档位**:官方响应式断点 **600 / 840**([`layout::WidthClass`]);
|
||||
//! 2. **版面**:单列 / supporting-pane / list-detail,各自的成立条件与退化阈值写在 [`layout`];
|
||||
//! 3. **行宽上限**:正文列硬上限 **504px ≈ 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 控件树) |
|
||||
//! | [`frame`] | 页头 / 内容列 / 两列骨架 / 空态 / 卡片 / 分隔线(占位控件) |
|
||||
//! | `instances` | 实例页(list-detail) |
|
||||
//! | `settings` | 设置页(supporting-pane) |
|
||||
//! | `download` / `logs` | 下载 / 日志页(单列,两行页头的差异) |
|
||||
//! | [`harness`] | 截图 harness(`examples/pages_demo.rs` 的入口,**不是** `cli.rs` 的一部分) |
|
||||
//!
|
||||
//! ## 截图通道
|
||||
//!
|
||||
//! ```text
|
||||
//! cargo run -p unlucky-app-iced --example pages_demo -- \
|
||||
//! --screen=instances --width=1180 --height=760 \
|
||||
//! --screenshot=out.png --screenshot-theme=light --software-renderer
|
||||
//! ```
|
||||
//!
|
||||
//! `cli.rs` 还没有 `--screen` / `--width`(M75 在改它),所以参数解析放在 [`harness`] 里,
|
||||
//! **本模块一行没碰 `cli.rs` / `app.rs`**。
|
||||
|
||||
pub mod frame;
|
||||
pub mod harness;
|
||||
pub mod layout;
|
||||
|
||||
mod download;
|
||||
mod instances;
|
||||
mod logs;
|
||||
mod settings;
|
||||
|
||||
pub use layout::{FrameMetrics, PageLayout, PaneKit, WidthClass};
|
||||
|
||||
use iced::widget::{container, row, space};
|
||||
use iced::{Element, Length};
|
||||
|
||||
use unlucky_design::elevation::MaterialLayer;
|
||||
|
||||
use crate::theme::Ui;
|
||||
|
||||
use frame::filled;
|
||||
|
||||
/// 页面消息。
|
||||
///
|
||||
/// **故意不用 `crate::app::Message`**(组件库契约 §2 的同一条理由):页面不绑 app 的状态层,
|
||||
/// 接线时由 app 层把 [`Message::Action`] 映射成自己的消息。骨架期的动作只有「点了哪个按钮」,
|
||||
/// 没有业务语义。
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum Message {
|
||||
/// 点了页面里的某个动作。
|
||||
Action(Action),
|
||||
}
|
||||
|
||||
/// 页面动作清单(骨架期够用就行;接线时按业务语义重命名/扩充)。
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum Action {
|
||||
/// 新建实例。
|
||||
NewInstance,
|
||||
/// 导入。
|
||||
Import,
|
||||
/// 返回实例列表(窄窗整页切换)。
|
||||
Back,
|
||||
/// 启动。
|
||||
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,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// 单独渲染一页(截图 / 手看用,**不依赖业务状态机**)。
|
||||
///
|
||||
/// 窗口宽度是**含外壳的窗口宽度**(档位判据就是它):返回的树里,左侧 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);
|
||||
|
||||
let page = match screen {
|
||||
Screen::Instances => instances::view(ui, metrics, Some(instances::SELECTED)),
|
||||
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]
|
||||
.width(Length::Fill)
|
||||
.height(Length::Fill);
|
||||
|
||||
let titled = iced::widget::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()
|
||||
}
|
||||
|
||||
/// 标题栏留白带(高 = `LayoutMetrics::titlebar_height`)。
|
||||
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()
|
||||
}
|
||||
|
||||
/// 导航轨留白带(宽 = `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