r1 复审的两条 P1 都是假护栏:把修复原样回滚(A:frame::body 换回居中 424; C:内缩 X2→X1;D:行距加回 4px)后测试仍全绿,因为断言只喂 frame_metrics() 与常量重算, 既不观察 frame::body,也不引用页面常量。本轮把「摆法」做成可断言的东西: - 新增 `pages/layout/block.rs`:body_width(pane 宽封顶 1440)、body_offset(恒 0 = 贴左)、 prose_width(= metrics.column ≤424)三个纯量 + 单测。frame::body / frame::prose 从它们建盒, frame::body_width 改成 pub use(不另算一份)。 - 新增 `pages/layout/source.rs`(cfg(test)):把某个函数的实现文本切出来。iced 的 Element 没有几何内省,量不到「块摆在哪」,只能断言「照哪个数建的」;仓里已有 no_hardcoded_tokens 扫源码、download/tests 用 include_str! 扫 key 的先例。 - 新护栏(每条都做过变异实测,见报告): · frame_body_is_built_from_the_block_quantities(A 变异红) · frame_prose_is_built_from_the_prose_width(E 变异红) · list_column_spaces_rows_only_from_the_shared_constant(D 变异红) · instances 几何断言改用页面真源 LIST_BOTTOM_INSET.dp() / ROW_SPACING(C 变异红) · the_detail_note_goes_through_the_prose_box(散文接线) - P2:wrap::strip 的「会删原文自带 U+200B」写成限界 + 一条断言;docs 口径从 layout.rs 挪到 layout/block.rs(layout.rs 399 → 389)。 零像素变化:改后 9 张图与 r1 的 m102-after 逐张 cmp 完全相同;2560×760 新出三张证明块宽封顶 1440(112..1551)且仍贴左。
253 lines
9.8 KiB
Rust
253 lines
9.8 KiB
Rust
//! 页面骨架:**页头**(标题 + 副标题 + 可选操作区)+ **内容区**(列宽由 [`super::layout`] 决定)
|
||
//! + **空态位**。
|
||
//!
|
||
//! ## 边界
|
||
//!
|
||
//! 这里只放「页面级」的东西:页头、页边距、内容块、两列骨架、空态位的摆放。
|
||
//! 具体控件(卡片 / 分隔线 / 文本按钮 / 图标)在 [`pieces`],它们是**占位实现**
|
||
//! (同一份令牌,没有完整状态机),等 M75 的 `components/` 家族合入后由另一条 mission 换掉。
|
||
//!
|
||
//! ## 对齐口径(M102):页头标题左缘 = 正文块左缘
|
||
//!
|
||
//! 页头带与内容区共用同一个 `gutter`(`Padding::from([0.0, metrics.gutter])` / `padding(gutter)`),
|
||
//! 所以页头标题的左缘 = `rail 88 + gutter 24 = 112`。正文块必须从**同一条线**起:
|
||
//!
|
||
//! - 单列页用 [`body`](贴左 + 宽 `layout::block::body_width` = pane 宽,封顶 1440);
|
||
//! - 成段散文再套一层 [`prose`](宽 `layout::block::prose_width` ≤424,保证一行 ≤72 字符);
|
||
//! - 两列页的正文在 [`panes`] 的主列里,左缘 = `112 + 列表列 336 + 间隙 24 = 472`(有据的分栏,不是错位)。
|
||
//!
|
||
//! 旧 [`single_column`] 把 424 的块**居中**摆进 1044,于是左缘 422、标题 112——视觉复审的 ① 就是这个。
|
||
//! 它保留给设置页(`pages/settings/**`,几何归设置页线)。
|
||
//!
|
||
//! **这三个量都在 [`super::layout::block`](纯函数 + 单测)**;[`body`] / [`prose`] 只是把
|
||
//! 「从哪个数建盒」写死在一处,`pages/tests.rs` 有两条**源码结构断言**盯着这两个盒子
|
||
//! (iced 的 Element 没有几何内省,量不到摆法,只能断言「照哪个数建的」)。
|
||
//!
|
||
//! ## 判据落点(`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, text_role_owned, ICON_PX, ICON_SMALL_PX, SCROLLBAR_MARGIN,
|
||
SCROLLBAR_WIDTH,
|
||
};
|
||
|
||
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::elevation::MaterialLayer;
|
||
use unlucky_design::spacing::Space;
|
||
use unlucky_design::typography::TypeRole;
|
||
|
||
use crate::theme::Ui;
|
||
|
||
use super::layout::{self, block, 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);
|
||
|
||
// 页头带也要有面板底色(M93):否则「开背景图 + 面板 α<1」时标题/副标题直接压在图上,
|
||
// 浅色主题的副标题(OnSurfaceVariant)对比度掉得厉害。底色同源于 `Ui::layer`(含面板 α)。
|
||
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]))
|
||
.style(move |_theme| filled(ui.layer(MaterialLayer::Canvas)))
|
||
.into()
|
||
}
|
||
|
||
/// 单列版面的正文列:宽 = 行宽上限内的实际列宽,**水平居中**。
|
||
///
|
||
/// > **M102 起产品页不再用它**:居中会把 424 摆到 1044 的正中(左缘 422 对页头标题 112),
|
||
/// > 视觉复审判为「内容缩在中间一条窄栏」。设置页(`pages/settings/**`)仍走这条,
|
||
/// > 它的几何归设置页线,本 mission 不动;下载 / 日志 / 窄窗实例改用 [`body`]。
|
||
pub fn single_column<'a>(
|
||
metrics: FrameMetrics,
|
||
content: impl Into<Element<'a, Message>>,
|
||
) -> Element<'a, Message> {
|
||
centered(metrics.column, content)
|
||
}
|
||
|
||
/// **页面块宽度**:pane 宽(单列 = 可用宽度 / 两列 = 主列 pane)封顶 1440。
|
||
///
|
||
/// 真源在 [`super::layout::block::body_width`](纯量 + 单测);这里只是转发,**不另算一份**。
|
||
/// 与 [`FrameMetrics::column`](一句话多宽,≤424)是两个量。
|
||
pub use super::layout::block::body_width;
|
||
|
||
/// **正文块**:从页边距起(= 页头标题那条左缘)、宽 [`body_width`]、左内边距 [`body_offset`]。
|
||
///
|
||
/// 盒子**从纯量建**:宽度取 [`body_width`]、左内边距取 [`super::layout::block::body_offset`]
|
||
/// (恒 0)、对齐 `Start`。这三件事的顺序就是契约,`pages/tests.rs` 的
|
||
/// `frame_body_is_built_from_the_block_quantities` 逐条扫这段源码(iced 的 Element 没有几何
|
||
/// 内省,量不到「摆在哪」,只能断言「照哪个数建的」)。
|
||
pub fn body<'a>(
|
||
metrics: FrameMetrics,
|
||
content: impl Into<Element<'a, Message>>,
|
||
) -> Element<'a, Message> {
|
||
container(content)
|
||
.width(Length::Fixed(body_width(metrics)))
|
||
.height(Length::Fill)
|
||
.align_x(Alignment::Start)
|
||
.align_y(Alignment::Start)
|
||
.padding(Padding::default().left(block::body_offset(metrics)))
|
||
.into()
|
||
}
|
||
|
||
/// **散文块**:宽度锁在 [`super::layout::block::prose_width`](= `metrics.column`,≤424 ⇒ 一行 ≤72 字符)。
|
||
///
|
||
/// 只包「成段读的文字」(实例详情正文那样的 prose);卡片、列表、Tabs 吃的是 [`body_width`],
|
||
/// 它们的宽度不承担行宽承诺(列宽只影响每行放几个字段,不影响读一句话的舒适度)。
|
||
pub fn prose<'a>(
|
||
metrics: FrameMetrics,
|
||
content: impl Into<Element<'a, Message>>,
|
||
) -> Element<'a, Message> {
|
||
container(content)
|
||
.width(Length::Fixed(block::prose_width(metrics)))
|
||
.into()
|
||
}
|
||
|
||
/// 两列版面:左列 `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()
|
||
}
|