Files
Unlucky-Launcher/crates/app-iced/src/pages/frame.rs
T
AskaEth b484998fc9 feat(app-iced): M90 —— app.rs 拆成 src/app/ + presenter 真接线 + 实例页端到端
拆分(行为不变,截图四件套逐字节相同):
- 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 条数值断言钉住它们。
2026-11-02 15:56:57 +08:00

191 lines
6.1 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
//! 页面骨架:**页头**(标题 + 副标题 + 可选操作区)+ **内容区**(列宽由 [`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()
}