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,292 @@
|
||||
//! 页面用的**占位控件**:卡片 / 分隔线 / 空态 / 文本按钮 / 图标 / 容器样式。
|
||||
//!
|
||||
//! 全部走令牌(颜色、圆角、排版、间距、一像素线),但**没有完整状态机**——
|
||||
//! 这些是给页面骨架站稳版式用的临时件,M74/M75 的 `components/` 家族合入后由另一条 mission 替换。
|
||||
//! 每个函数都在文档里点明它对应哪个组件的档位(12b 的参考值)。
|
||||
|
||||
use iced::widget::text::LineHeight;
|
||||
use iced::widget::{button, column, container, row, rule, scrollable, space, svg};
|
||||
use iced::Theme as IcedTheme;
|
||||
use iced::{Alignment, Background, Border, Color, Element, Length, Padding, Pixels, Shadow};
|
||||
|
||||
use unlucky_design::color::{Role, StateLayer};
|
||||
use unlucky_design::component_map::Component;
|
||||
use unlucky_design::elevation::MaterialLayer;
|
||||
use unlucky_design::icons::{IconKind, IconPaint};
|
||||
use unlucky_design::shape::ShapeScale;
|
||||
use unlucky_design::spacing::Space;
|
||||
use unlucky_design::typography::{TypeRole, TypeStyle};
|
||||
|
||||
use crate::icons;
|
||||
use crate::pages::{Action, Message};
|
||||
use crate::theme::Ui;
|
||||
|
||||
/// 空态图标块边长(12b 15.1:96×96,圆角 `Large`)。
|
||||
pub(super) const EMPTY_ICON_BLOCK: f32 = 96.0;
|
||||
/// 空态字形渲染尺寸(12b 15.1:图标 32~48 `[估]`,取 40)。
|
||||
pub(super) const EMPTY_ICON_GLYPH: f32 = 40.0;
|
||||
/// 滚动条宽度(iced 默认 10px 的令牌化名字,用于把滑块做成药丸)。
|
||||
const SCROLLBAR_TRACK: f32 = 10.0;
|
||||
|
||||
/// 描边卡片(占位:M75 的 `components` 会提供真正的 `OutlinedCard`)。
|
||||
pub fn card<'a>(ui: &'a Ui, content: impl Into<Element<'a, Message>>) -> Element<'a, Message> {
|
||||
let spec = Component::OutlinedCard.spec();
|
||||
container(content)
|
||||
.width(Length::Fill)
|
||||
.padding(ui.metrics().card_padding)
|
||||
.style(move |_theme| container::Style {
|
||||
background: Some(Background::Color(ui.layer(MaterialLayer::Card))),
|
||||
border: ui.outlined(spec.shape),
|
||||
..container::Style::default()
|
||||
})
|
||||
.into()
|
||||
}
|
||||
|
||||
/// 一像素分隔线(`OutlineVariant`,厚度取令牌)。
|
||||
pub fn divider<'a>(ui: &'a Ui) -> Element<'a, Message> {
|
||||
rule::horizontal::<IcedTheme>(ui.metrics().divider_thickness)
|
||||
.style(move |_theme| rule::Style {
|
||||
color: ui.color(Role::OutlineVariant),
|
||||
radius: ui.radius_dp(ShapeScale::None),
|
||||
fill_mode: rule::FillMode::Full,
|
||||
snap: true,
|
||||
})
|
||||
.into()
|
||||
}
|
||||
|
||||
/// 空态(12b §15):图标块 96 + 24 标题 + 12 副文案 + ≤3 个快捷入口,**整块垂直居中**。
|
||||
pub fn empty_state<'a>(
|
||||
ui: &'a Ui,
|
||||
icon: IconKind,
|
||||
title: &'a str,
|
||||
body: &'a str,
|
||||
actions: Vec<Element<'a, Message>>,
|
||||
) -> Element<'a, Message> {
|
||||
let spec = Component::EmptyState.spec();
|
||||
|
||||
let block = container(glyph(
|
||||
icon,
|
||||
ui.color(Role::OnSurfaceVariant),
|
||||
EMPTY_ICON_GLYPH,
|
||||
))
|
||||
.width(EMPTY_ICON_BLOCK)
|
||||
.height(EMPTY_ICON_BLOCK)
|
||||
.align_x(Alignment::Center)
|
||||
.align_y(Alignment::Center)
|
||||
.style(move |_theme| container::Style {
|
||||
background: Some(Background::Color(ui.color(Role::SurfaceContainerHigh))),
|
||||
border: Border {
|
||||
radius: ui.radius_dp(spec.shape),
|
||||
..Border::default()
|
||||
},
|
||||
..container::Style::default()
|
||||
});
|
||||
|
||||
let mut stack = column![
|
||||
block,
|
||||
text_role_centered(ui, spec.text, false, title, ui.color(Role::OnSurface)),
|
||||
text_role_centered(
|
||||
ui,
|
||||
spec.support.unwrap_or(TypeRole::BodySmall),
|
||||
false,
|
||||
body,
|
||||
ui.color(Role::OnSurfaceVariant),
|
||||
),
|
||||
]
|
||||
.spacing(ui.space(Space::X3))
|
||||
.align_x(Alignment::Center);
|
||||
|
||||
if !actions.is_empty() {
|
||||
stack = stack.push(
|
||||
row(actions)
|
||||
.spacing(ui.space(Space::X2))
|
||||
.align_y(Alignment::Center),
|
||||
);
|
||||
}
|
||||
|
||||
container(stack)
|
||||
.width(Length::Fill)
|
||||
.height(Length::Fill)
|
||||
.align_x(Alignment::Center)
|
||||
.align_y(Alignment::Center)
|
||||
.into()
|
||||
}
|
||||
|
||||
/// 文本按钮占位(TextButton 档:透明底、`OnSurfaceVariant`、40 高、full 圆角)。
|
||||
///
|
||||
/// 只画静息、hover、pressed 三态(focus/disabled 归 M75 的 `interaction.rs`);
|
||||
/// 消息是页面自己的 [`Message::Action`],与 app 的 `Message` 完全解耦。
|
||||
pub fn text_action<'a>(ui: &'a Ui, label: &'a str, action: Action) -> Element<'a, Message> {
|
||||
let spec = Component::TextButton.spec();
|
||||
let height = ui.metrics().control_height;
|
||||
|
||||
let content = text_role(
|
||||
ui,
|
||||
spec.text,
|
||||
false,
|
||||
label,
|
||||
ui.color(Role::OnSurfaceVariant),
|
||||
);
|
||||
|
||||
button(container(content).align_y(Alignment::Center))
|
||||
.height(height)
|
||||
.padding(Padding::from([0.0, ui.space(Space::X4)]))
|
||||
.style(move |_theme: &IcedTheme, status| {
|
||||
let background = match status {
|
||||
button::Status::Hovered => Some(Background::Color(ui.state_over(
|
||||
StateLayer::Hover,
|
||||
Role::OnSurface,
|
||||
Role::Surface,
|
||||
))),
|
||||
button::Status::Pressed => Some(Background::Color(ui.state_over(
|
||||
StateLayer::Pressed,
|
||||
Role::OnSurface,
|
||||
Role::Surface,
|
||||
))),
|
||||
button::Status::Active | button::Status::Disabled => None,
|
||||
};
|
||||
|
||||
button::Style {
|
||||
background,
|
||||
text_color: ui.color(Role::OnSurfaceVariant),
|
||||
border: Border {
|
||||
radius: ui.radius(ShapeScale::Full, height),
|
||||
..Border::default()
|
||||
},
|
||||
shadow: Shadow::default(),
|
||||
snap: true,
|
||||
}
|
||||
})
|
||||
.on_press(Message::Action(action))
|
||||
.into()
|
||||
}
|
||||
|
||||
/// 令牌排版档 → 文字元素(字号 / 行高 / 字重全来自令牌,不写字面量)。
|
||||
pub fn text_role<'a>(
|
||||
ui: &'a Ui,
|
||||
role: TypeRole,
|
||||
emphasized: bool,
|
||||
content: &'a str,
|
||||
color: Color,
|
||||
) -> Element<'a, Message> {
|
||||
text_widget(ui, role, emphasized, content, color).into()
|
||||
}
|
||||
|
||||
/// 同 [`text_role`],但**文字自身居中**(空态的标题与副文案:图标块是居中锚点)。
|
||||
pub fn text_role_centered<'a>(
|
||||
ui: &'a Ui,
|
||||
role: TypeRole,
|
||||
emphasized: bool,
|
||||
content: &'a str,
|
||||
color: Color,
|
||||
) -> Element<'a, Message> {
|
||||
text_widget(ui, role, emphasized, content, color)
|
||||
.align_x(iced::widget::text::Alignment::Center)
|
||||
.into()
|
||||
}
|
||||
|
||||
fn text_widget<'a>(
|
||||
ui: &'a Ui,
|
||||
role: TypeRole,
|
||||
emphasized: bool,
|
||||
content: &'a str,
|
||||
color: Color,
|
||||
) -> iced::widget::Text<'a> {
|
||||
let style: TypeStyle = if emphasized {
|
||||
ui.emphasized(role)
|
||||
} else {
|
||||
ui.style(role)
|
||||
};
|
||||
|
||||
iced::widget::text(content)
|
||||
.size(style.size_px)
|
||||
.line_height(LineHeight::Absolute(Pixels(style.line_height_px)))
|
||||
.font(ui.font_of(style))
|
||||
.color(color)
|
||||
}
|
||||
|
||||
/// 任意尺寸的令牌图标(`icons::RENDER_PX` 只有 24 一档;空态图标要 40)。
|
||||
///
|
||||
/// 描边宽度仍按令牌的 `Icon::stroke_width(render_px)` 折算,所以视觉线宽在任何尺寸下都是 1.5。
|
||||
pub fn glyph(kind: IconKind, color: Color, render_px: f32) -> Element<'static, Message> {
|
||||
let icon = kind.icon();
|
||||
let paint = match icon.paint {
|
||||
IconPaint::Stroke => format!(
|
||||
"fill=\"none\" stroke=\"currentColor\" stroke-width=\"{}\" \
|
||||
stroke-linecap=\"round\" stroke-linejoin=\"round\"",
|
||||
icon.stroke_width(render_px)
|
||||
),
|
||||
IconPaint::Fill => "fill=\"currentColor\" stroke=\"none\"".to_owned(),
|
||||
};
|
||||
|
||||
let source = format!(
|
||||
"<svg xmlns=\"http://www.w3.org/2000/svg\" width=\"{0}\" height=\"{0}\" \
|
||||
viewBox=\"0 0 24 24\"><path d=\"{1}\" {paint}/></svg>",
|
||||
render_px, icon.path
|
||||
);
|
||||
|
||||
svg(svg::Handle::from_memory(source.into_bytes()))
|
||||
.width(render_px)
|
||||
.height(render_px)
|
||||
.style(move |_theme, _status| svg::Style { color: Some(color) })
|
||||
.into()
|
||||
}
|
||||
|
||||
/// 固定宽 × 固定高的图标容器(Q9:按钮给的 Fill 约束会撑大字形,必须中间夹一层)。
|
||||
pub fn glyph_box(kind: IconKind, color: Color, px: f32) -> Element<'static, Message> {
|
||||
container(glyph(kind, color, px))
|
||||
.width(px)
|
||||
.height(px)
|
||||
.align_x(Alignment::Center)
|
||||
.align_y(Alignment::Center)
|
||||
.into()
|
||||
}
|
||||
|
||||
/// 竖直留白(布局拼装用;值来自令牌栅格)。
|
||||
pub fn gap<'a>(px: f32) -> Element<'a, Message> {
|
||||
space::vertical().height(px).into()
|
||||
}
|
||||
|
||||
/// 纯色容器样式(页面底色 / 留白带)。
|
||||
pub fn filled(color: Color) -> container::Style {
|
||||
container::Style {
|
||||
background: Some(Background::Color(color)),
|
||||
..container::Style::default()
|
||||
}
|
||||
}
|
||||
|
||||
/// 滚动条配色(令牌角色):轨道透明,滑块 `Outline`,尺寸取 iced 默认。
|
||||
///
|
||||
/// 只在**独立滚动区**上挂(实例页列表列),不许挂到整页上——整页滚动会把页头一起滚走。
|
||||
pub fn scroll_style(ui: &Ui) -> impl Fn(&IcedTheme, scrollable::Status) -> scrollable::Style + '_ {
|
||||
let rail = scrollable::Rail {
|
||||
background: None,
|
||||
border: Border::default(),
|
||||
scroller: scrollable::Scroller {
|
||||
background: Background::Color(ui.color(Role::Outline)),
|
||||
border: Border {
|
||||
radius: ui.radius(ShapeScale::Full, SCROLLBAR_TRACK),
|
||||
..Border::default()
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
move |_theme, _status| scrollable::Style {
|
||||
container: container::Style::default(),
|
||||
vertical_rail: rail,
|
||||
horizontal_rail: rail,
|
||||
gap: None,
|
||||
auto_scroll: scrollable::AutoScroll {
|
||||
background: Background::Color(ui.color(Role::SurfaceContainerHigh)),
|
||||
border: Border::default(),
|
||||
shadow: Shadow::default(),
|
||||
icon: ui.color(Role::OnSurfaceVariant),
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
/// `crate::icons::RENDER_PX` 的再导出(页面里 24px 图标走这个常量,不写字面量)。
|
||||
pub const ICON_PX: f32 = icons::RENDER_PX;
|
||||
|
||||
/// 小号图标(行尾 chevron 之类的辅助指示,16px)。
|
||||
pub const ICON_SMALL_PX: f32 = 16.0;
|
||||
@@ -0,0 +1,101 @@
|
||||
//! `frame` 的断言:空态 / 卡片 / 页头规格与一屏字号档。
|
||||
use super::pieces::{EMPTY_ICON_BLOCK, EMPTY_ICON_GLYPH};
|
||||
use super::*;
|
||||
|
||||
use unlucky_design::color::SchemeFlavor;
|
||||
use unlucky_design::shape::ShapeScale;
|
||||
use unlucky_design::typography::Platform;
|
||||
|
||||
use crate::pages::layout::{frame_metrics, PaneKit};
|
||||
use crate::theme::Families;
|
||||
|
||||
fn ui() -> Ui {
|
||||
Ui::with_platform(
|
||||
SchemeFlavor::Light,
|
||||
Platform::Windows,
|
||||
Families {
|
||||
text: "Segoe UI",
|
||||
display: "Segoe UI",
|
||||
mono: "Consolas",
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn empty_state_follows_the_12b_reference() {
|
||||
// 15.1 图标块 96×96、圆角 Large(16);15.2 标题 HeadlineSmall;15.3 副文案 BodySmall
|
||||
let spec = Component::EmptyState.spec();
|
||||
assert_eq!(EMPTY_ICON_BLOCK, 96.0);
|
||||
assert_eq!(spec.shape, ShapeScale::Large);
|
||||
assert_eq!(spec.text, TypeRole::HeadlineSmall);
|
||||
assert_eq!(ui().style(spec.text).size_px, 24.0);
|
||||
assert_eq!(spec.support, Some(TypeRole::BodySmall));
|
||||
assert_eq!(ui().style(TypeRole::BodySmall).size_px, 12.0);
|
||||
// 字形 32~48([估])
|
||||
assert!((32.0..=48.0).contains(&EMPTY_ICON_GLYPH));
|
||||
const { assert!(EMPTY_ICON_GLYPH < EMPTY_ICON_BLOCK) };
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn card_uses_the_outlined_card_spec() {
|
||||
let spec = Component::OutlinedCard.spec();
|
||||
assert_eq!(spec.shape, ShapeScale::Medium);
|
||||
assert_eq!(ui().radius_dp(spec.shape).top_left, 12.0);
|
||||
assert_eq!(spec.text, TypeRole::TitleMedium);
|
||||
assert_eq!(spec.support, Some(TypeRole::BodySmall));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn page_header_uses_the_two_documented_heights() {
|
||||
let ui = ui();
|
||||
assert_eq!(ui.metrics().header_height(false), 56.0);
|
||||
assert_eq!(ui.metrics().header_height(true), 84.0);
|
||||
}
|
||||
|
||||
/// 一屏字号 ≤4 档(rubric D4)。规格表来源:12b §16.4 / §15 / 3.x OutlinedCard。
|
||||
#[test]
|
||||
fn every_screen_keeps_at_most_four_text_sizes() {
|
||||
let ui = ui();
|
||||
|
||||
let header = ui.emphasized(Component::TopAppBar.spec().text).size_px;
|
||||
let row_title = ui.style(Component::ListItem.spec().text).size_px;
|
||||
let row_support = ui
|
||||
.style(Component::ListItem.spec().support.unwrap())
|
||||
.size_px;
|
||||
let action = ui.style(Component::TextButton.spec().text).size_px;
|
||||
let body = ui.style(TypeRole::BodyMedium).size_px;
|
||||
let empty_title = ui.style(Component::EmptyState.spec().text).size_px;
|
||||
|
||||
let screens: [(&str, &[f32]); 3] = [
|
||||
// 实例页(list-detail 与窄窗单列):页头 + 行标题 + 行副文案 + 操作/正文
|
||||
("instances", &[header, row_title, row_support, action, body]),
|
||||
// 设置页:页头 + 分组项/操作 + 行标签 + 行值 + 空态标题
|
||||
(
|
||||
"settings",
|
||||
&[header, action, row_support, body, empty_title],
|
||||
),
|
||||
// 下载 / 日志:页头 + 空态标题 + 空态副文案 + 操作
|
||||
("empty-pages", &[header, empty_title, row_support, action]),
|
||||
];
|
||||
|
||||
for (name, sizes) in screens {
|
||||
let mut unique = sizes.to_vec();
|
||||
unique.sort_by(f32::total_cmp);
|
||||
unique.dedup();
|
||||
assert!(
|
||||
unique.len() <= 4,
|
||||
"{name} 一屏用了 {} 档字号 {unique:?},超过 rubric D4 的 4 档",
|
||||
unique.len()
|
||||
);
|
||||
assert!(unique[0] >= 12.0, "{name} 出现小于 12px 的字号");
|
||||
assert!(unique[unique.len() - 1] <= 24.0, "{name} 字号过大");
|
||||
}
|
||||
}
|
||||
|
||||
/// 版面几何是页面唯一宽度来源:两列版面的正文列必须窄于可用宽度。
|
||||
#[test]
|
||||
fn two_pane_body_is_narrower_than_the_available_width() {
|
||||
let wide = frame_metrics(PaneKit::ListDetail, 1180.0);
|
||||
assert!(wide.column < wide.available);
|
||||
assert!(wide.secondary.is_some());
|
||||
}
|
||||
Reference in new issue
Block a user