拆分(行为不变,截图四件套逐字节相同):
- 400 行的 app.rs 拆成 src/app/{mod,state,message,update,subscription,view,boot,tests}.rs,
每个文件 ≤ 300 行;components 的 #[path] 跟着搬到 src/app/mod.rs(逻辑路径仍是 crate::app::components),
可见性放到 pub(crate) 让页面层能用真组件。
- 拆分前后各出 shell/gallery 浅深四张图,cmp 全部 IDENTICAL。
presenter 接线(crate 文档 §4):
- App 持有 Box<dyn Backend> + SettingsBridge + AppState;启动 build_backend + SettingsBridge::open + AppState::new。
- 截图通道强制 mock(D30 可复现);交互模式走真 host,--data-dir 从 M90 起真的被消费。
- Boot 抽干首屏快照(第 3 帧落盘,等节拍会把空页面拍进图);节拍 = 帧事件 + 33ms 时间闸门
(iced::time::every 要 tokio/smol,见 finding),tick → drain_events → AppState::apply。
- 用户动作不乐观回显:Launch/Stop 走 Backend::dispatch,状态等事件回来才变。
- 路由:pages::Message::Navigate(Route) 直接进 AppState::navigate,选中项 = 路由栈顶(不再有本地副本);
首屏自动选中第一行(list-detail 桌面惯例),只做一次。
实例页打通(其余三页保留占位视觉 + Message/update + 路由):
- 列表行换成 components::navigation::ListItem(真五态/选中),页头与详情动作换成 components::buttons
(Play 是唯一的实心 accent),分隔线换成 components::divider。
- 数据全部来自 presenter 快照:VERSION/LOADER/ISOLATION/VERSION ID/FILES/LAST PLAYED + 运行状态行。
- 导航轨换成真 NavigationRail(88),5 项各自压/替换路由。
版面约束未破(实测):列表列 336、正文列 424(元信息卡 206+12+206,右缘 896)、rail 88(药丸 16..71)、
断点 600/840 走 frame_metrics;新增 pages/tests.rs 的 9 条数值断言钉住它们。
191 lines
6.1 KiB
Rust
191 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, 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::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()
|
||
}
|