视觉复审两条: - **P2-2 滚动条偏重**:原来直接用 iced 默认 10px 轨 + `Role::Outline`(那份灰绿在浅色下压过列表内容)。 改 **6px 轨 + 2px 外边距 + `Role::OutlineVariant` 滑块**(比 `Outline` 弱一档的容器描边角色), 圆角由 full 按 6px 折算成 3。功能不变(仍是列表列独立滚动、仍只有这一条 scrollable)。 - **P2-3 图标线宽语汇不一致**:设置页「Java」分组用的是令牌里的 `Play`——它是**填充**字形, 同屏其它图标都是描边。改成描边的 `IconKind::Folder`(Java 分组讲的是运行时安装位置,语义也对), 并在 `pieces.rs` 写明规矩:页面骨架**只用描边图标**,将来若要「主操作允许实心图标」, 由 `components/` 统一定义并只留一处。守门见上一条 commit 的 `pages_use_only_stroked_icons`。
190 lines
6.1 KiB
Rust
190 lines
6.1 KiB
Rust
//! 页面骨架:**页头**(标题 + 副标题 + 可选操作区)+ **内容区**(列宽由 [`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, 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::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()
|
||
}
|