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:
AskaEth committed 2026-11-01 23:27:28 +08:00
1 parent 352dd7937e
commit 2c442585b1
15 files changed
+2979

No files matched your search

+189
View File
@@ -0,0 +1,189 @@
//! 页面骨架:**页头**(标题 + 副标题 + 可选操作区)+ **内容区**(列宽由 [`super::layout`] 决定)
//! + **空态位**。
//!
//! ## 边界
//!
//! 这里只放「页面级」的东西:页头、页边距、内容列、两列骨架、空态位的摆放。
//! 具体控件(卡片 / 分隔线 / 文本按钮 / 图标)在 [`pieces`],它们是**占位实现**
//! (同一份令牌,没有完整状态机),等 M75 的 `components/` 家族合入后由另一条 mission 换掉。
//!
//! ## 判据落点(`research/12b`)
//!
//! | 部件 | 参考 | 落点 |
//! |---|---|---|
//! | 页头 | 16.1 AppBar 高 56(登记偏离官方 64)/ 两行 84 | `LayoutMetrics::header_height` |
//! | 页头标题 | 16.4 `TitleLarge 22/28` | `Component::TopAppBar.spec().text` |
//! | 页头副标题 | 16.4 `LabelMedium 12/16` `OnSurfaceVariant` | 同上 `.support` |
//! | 空态 | 15.1 图标块 96×96 / `Large(16)`;15.2 标题 `HeadlineSmall 24`;15.3 副文案 `BodySmall 12` | `Component::EmptyState` |
mod pieces;
#[cfg(test)]
mod tests;
pub use pieces::{
card, divider, empty_state, filled, gap, glyph, glyph_box, scroll_style, text_action,
text_role, text_role_centered, ICON_PX, ICON_SMALL_PX,
};
use iced::widget::{column, container, row};
use iced::{Alignment, Element, Length, Padding};
use unlucky_design::color::Role;
use unlucky_design::component_map::Component;
use unlucky_design::spacing::Space;
use unlucky_design::typography::TypeRole;
use crate::theme::Ui;
use super::layout::{self, FrameMetrics};
use super::Message;
/// 页头操作区之间的间隔(`Space::X2`)。
const HEADER_ACTION_GAP: f32 = Space::X2.dp();
/// 页头内容。
pub struct Header<'a> {
/// 标题(`TitleLarge`)。
pub title: &'a str,
/// 副标题(`LabelMedium`,给了页头就是两行高)。
pub subtitle: Option<&'a str>,
/// 右侧操作区(text button 占位,数量 ≤3)。
pub actions: Vec<Element<'a, Message>>,
}
impl<'a> Header<'a> {
/// 单行页头。
pub fn new(title: &'a str) -> Self {
Self {
title,
subtitle: None,
actions: Vec::new(),
}
}
/// 加副标题(页头自动变两行高)。
#[must_use]
pub fn subtitle(mut self, subtitle: &'a str) -> Self {
self.subtitle = Some(subtitle);
self
}
/// 追加一个右侧操作。
#[must_use]
pub fn action(mut self, action: Element<'a, Message>) -> Self {
self.actions.push(action);
self
}
}
/// 一整页:页头 + 内容区(页边距已由布局系统决定)。
pub fn page<'a>(
ui: &'a Ui,
metrics: FrameMetrics,
header: Header<'a>,
body: impl Into<Element<'a, Message>>,
) -> Element<'a, Message> {
let bar = header_bar(ui, metrics, header);
let content = container(body)
.width(Length::Fill)
.height(Length::Fill)
.padding(metrics.gutter);
column![bar, content]
.width(Length::Fill)
.height(Length::Fill)
.into()
}
fn header_bar<'a>(ui: &'a Ui, metrics: FrameMetrics, header: Header<'a>) -> Element<'a, Message> {
let spec = Component::TopAppBar.spec();
// 16.4:标题 TitleLarge(emphasized w500),副标题 LabelMedium + OnSurfaceVariant
let mut titles = column![text_role(
ui,
spec.text,
true,
header.title,
ui.color(Role::OnSurface)
)]
.spacing(ui.space(Space::X1));
if let Some(subtitle) = header.subtitle {
titles = titles.push(text_role(
ui,
spec.support.unwrap_or(TypeRole::LabelMedium),
false,
subtitle,
ui.color(Role::OnSurfaceVariant),
));
}
let actions = row(header.actions)
.spacing(HEADER_ACTION_GAP)
.align_y(Alignment::Center);
container(
row![titles.width(Length::Fill), actions]
.width(Length::Fill)
.align_y(Alignment::Center),
)
.width(Length::Fill)
.height(ui.metrics().header_height(header.subtitle.is_some()))
.align_y(Alignment::Center)
.padding(Padding::from([0.0, metrics.gutter]))
.into()
}
/// 单列版面的正文列:宽 = 行宽上限内的实际列宽,**水平居中**。
pub fn single_column<'a>(
metrics: FrameMetrics,
content: impl Into<Element<'a, Message>>,
) -> Element<'a, Message> {
centered(metrics.column, content)
}
/// 两列版面:左列 `secondary`(独立滚动区由调用方自己加 `scrollable`)+ 右列 `main`。
///
/// 两列都**显式**取布局系统给的宽度,不吃 Fill:列表列必须固定 336,
/// 详情列必须窄于可用宽度,否则又回到「全宽单卡」。
pub fn panes<'a>(
metrics: FrameMetrics,
secondary: impl Into<Element<'a, Message>>,
main: impl Into<Element<'a, Message>>,
) -> Element<'a, Message> {
let width = metrics.secondary.unwrap_or(0.0);
row![
container(secondary)
.width(Length::Fixed(width))
.height(Length::Fill),
container(main)
.width(Length::Fixed(metrics.main_pane))
.height(Length::Fill),
]
.spacing(layout::PANES_GAP)
.width(Length::Fill)
.height(Length::Fill)
.into()
}
/// 把内容塞进固定宽度并水平居中(`align_x` 不会覆盖子元素的宽度,见 Q8)。
pub fn centered<'a>(width: f32, content: impl Into<Element<'a, Message>>) -> Element<'a, Message> {
container(container(content).width(Length::Fixed(width)))
.width(Length::Fill)
.height(Length::Fill)
.align_x(Alignment::Center)
.align_y(Alignment::Start)
.into()
}
/// 与 [`centered`] 同宽,但**贴左**:列表右侧的详情列要贴着自己的 pane 左边,不再二次居中。
pub fn left_aligned<'a>(
width: f32,
content: impl Into<Element<'a, Message>>,
) -> Element<'a, Message> {
container(container(content).width(Length::Fixed(width)))
.width(Length::Fill)
.height(Length::Fill)
.align_x(Alignment::Start)
.align_y(Alignment::Start)
.into()
}