Merge branch 'feat/p4-iced-page-frame-layout-system-breakpo'

This commit is contained in:
AskaEth committed 2026-11-02 04:44:53 +08:00
commit 22ddc74784
16 files changed
+3264 -5

No files matched your search

+37
View File
@@ -0,0 +1,37 @@
//! 页面骨架截图入口(M84 `PAGEFRAME` 的验收通道)。
//!
//! ```text
//! cargo run -p unlucky-app-iced --example pages_demo -- \
//! --screen=instances --width=1180 --height=760 \
//! --screenshot=out.png --screenshot-theme=light --software-renderer
//! ```
//!
//! 参数解析与 iced 接线都在 `unlucky_app_iced::pages::harness` 里:**本 example 只是入口**,
//! 它也刻意不接 `cli.rs`(那边归 M75)。详细口径见 `pages::harness` 的模块文档。
use std::process::ExitCode;
use unlucky_app_iced::pages::harness::{self, Request};
fn main() -> ExitCode {
let request = match Request::parse(std::env::args().skip(1)) {
Ok(request) => request,
Err(message) => {
eprintln!("[cli] {message}");
eprintln!(
"用法:pages_demo [--screen=instances|download|settings|logs] [--width <px>] \
[--height <px>] [--theme=light|dark] [--screenshot-theme=light|dark] \
[--screenshot <png>] [--renderer=auto|gpu|cpu] [--software-renderer]"
);
return ExitCode::from(2);
}
};
match harness::run(request) {
Ok(()) => ExitCode::SUCCESS,
Err(message) => {
eprintln!("[run] {message}");
ExitCode::FAILURE
}
}
}
+1
View File
@@ -24,6 +24,7 @@
//! 等价验收后翻牌(bin 改名 + 旧 crate 降级),步骤写在
//! `DevDocs/SubAgentsTalk/app-iced-骨架与翻牌.md`。
pub mod pages;
pub mod shot;
pub mod theme;
+106
View File
@@ -0,0 +1,106 @@
//! 下载页:**单列**版面的用例(列宽 ≤424,水平居中)。
//!
//! 这一页永远单列,理由不是「简单」,而是官方对「一次只做一件事」的入口(下载队列)的口径:
//! 内容是一串线性进度项,分栏没有第二个阅读对象。窗口变宽时**正文列不变宽**(撞行宽上限),
//! 多出来的宽度留白——这就是 M68「全宽单卡」的反面。
//!
//! 页头 1 个操作 + 空态 3 个快捷入口(12b §15.5:≤3)。
use iced::widget::{column, container, row};
use iced::{Alignment, Element, Length};
use unlucky_design::color::Role;
use unlucky_design::icons::IconKind;
use unlucky_design::spacing::Space;
use unlucky_design::typography::TypeRole;
use crate::theme::Ui;
use super::frame::{self, Header};
use super::layout::FrameMetrics;
use super::{Action, Message};
/// 整页。
pub fn view<'a>(ui: &'a Ui, metrics: FrameMetrics) -> Element<'a, Message> {
let header = Header::new("Download")
.subtitle("Versions, mod loaders and modpacks")
.action(frame::text_action(
ui,
"Refresh sources",
Action::RefreshSources,
));
let empty = frame::empty_state(
ui,
IconKind::Download,
"No downloads in progress",
"Pick a version or drop a modpack file here. Sources are probed per region and can be pinned \
in Settings → Downloads.",
vec![
frame::text_action(ui, "Browse versions", Action::BrowseVersions),
frame::text_action(ui, "Import modpack", Action::ImportModpack),
frame::text_action(ui, "Check sources", Action::CheckSources),
],
);
let body = frame::single_column(
metrics,
column![source_status(ui), empty]
.spacing(ui.space(Space::X4))
.width(Length::Fill)
.height(Length::Fill),
);
frame::page(ui, metrics, header, body)
}
/// 来源状态行(12pt,`OnSurfaceVariant`):说明这一页的数据从哪来,不是业务数据。
fn source_status<'a>(ui: &'a Ui) -> Element<'a, Message> {
container(
row![
frame::glyph(
IconKind::Check,
ui.color(Role::Secondary),
frame::ICON_SMALL_PX
),
frame::text_role(
ui,
TypeRole::BodySmall,
false,
"3 sources healthy · BMCLAPI pinned by region · 2 min ago",
ui.color(Role::OnSurfaceVariant),
),
]
.spacing(ui.space(Space::X2))
.align_y(Alignment::Center),
)
.width(Length::Fill)
.into()
}
#[cfg(test)]
mod tests {
use super::super::layout::{content_column_width, frame_metrics, measure_cap_px, PaneKit};
/// 单列页:任何窗口宽度都不许出现第二列,正文列不许超过行宽上限。
#[test]
fn download_is_single_column_at_every_width() {
for width in [600.0, 840.0, 900.0, 1180.0, 2400.0] {
let metrics = frame_metrics(PaneKit::Single, width);
assert!(metrics.secondary.is_none(), "{width} 宽下载页出现了第二列");
assert!(metrics.column <= measure_cap_px() + f32::EPSILON);
assert_eq!(metrics.column, content_column_width(width));
}
}
/// 宽窗下多出来的宽度必须留白(不是把卡片拉宽)——1180 的正文列 < 可用宽度。
#[test]
fn extra_width_goes_to_margin_not_to_the_column() {
let metrics = frame_metrics(PaneKit::Single, 1180.0);
assert_eq!(metrics.column, measure_cap_px());
assert!(
metrics.available - metrics.column >= 400.0,
"1180 宽下应留出大片留白(M68 的全宽卡就是没有这段留白)"
);
}
}
+189
View File
@@ -0,0 +1,189 @@
//! 页面骨架:**页头**(标题 + 副标题 + 可选操作区)+ **内容区**(列宽由 [`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()
}
+307
View File
@@ -0,0 +1,307 @@
//! 页面用的**占位控件**:卡片 / 分隔线 / 空态 / 文本按钮 / 图标 / 容器样式。
//!
//! 全部走令牌(颜色、圆角、排版、间距、一像素线),但**没有完整状态机**——
//! 这些是给页面骨架站稳版式用的临时件,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;
/// 滚动条轨宽(视觉复审 P2-2:原来用 iced 默认的 10px,压过了列表内容)。
///
/// 取 6:细到不抢视线,又还在命中/可见的下限之上;滑块做成 full 圆角药丸(半径 3)。
pub const SCROLLBAR_WIDTH: f32 = 6.0;
/// 滑块离卡片内边的留白(2px,避免贴边)。
pub const SCROLLBAR_MARGIN: f32 = 2.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)。
///
/// ## 图标语汇(视觉复审 P2-3)
///
/// 令牌里 **`Play` / `Stop` / `More` 是填充字形**,其余全是描边。页面骨架里**只用描边图标**:
/// 同屏把填充字形和描边字形混在一起,线宽语汇会打架(复审的原话是「Play 是填充字形,
/// 同屏其它图标是描边」)。真要做「主操作允许实心图标」这条例外,应当由组件线在
/// `components/` 里统一定义并只保留一处,而不是散落在页面里——所以这里干脆一个都不放。
/// 守门:`tests/no_hardcoded_tokens.rs` 的 `pages_use_only_stroked_icons`。
///
/// 描边宽度仍按令牌的 `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()
}
}
/// 滚动条配色(令牌角色):轨道透明、滑块 `OutlineVariant`(低对比,弱于正文),
/// 轨宽 `SCROLLBAR_WIDTH`(6px)+ `SCROLLBAR_MARGIN`(2px 内缩)——视觉 r1 P2-2 的修法。
///
/// 只在**独立滚动区**上挂(实例页列表列),不许挂到整页上——整页滚动会把页头一起滚走。
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 {
// 低对比:用 `OutlineVariant`(比 `Outline` 弱一档的容器描边角色)而不是 `Outline`,
// 让滑块明显弱于正文;纯色不透明,CPU/GPU 两档一致。
background: Background::Color(ui.color(Role::OutlineVariant)),
border: Border {
radius: ui.radius(ShapeScale::Full, SCROLLBAR_WIDTH),
..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;
+101
View File
@@ -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());
}
+399
View File
@@ -0,0 +1,399 @@
//! 截图 harness:把**一页**按给定窗口宽度 / 主题单独渲染并出图。
//!
//! 它是 `examples/pages_demo.rs` 的入口(该 example 只有三行)。**刻意不接 `cli.rs`**:
//! `cli.rs` 归 M75 改(它正在加 `--screen`),本 mission 不碰;等 `cli.rs` 具备 `--screen` /
//! `--window-width` 之后,这个 harness 应当删掉,改由正式启动参数出图。
//!
//! ## 用法
//!
//! ```text
//! cargo run -p unlucky-app-iced --example pages_demo -- \
//! --screen=instances --width=1180 --height=760 \
//! --screenshot=out.png --screenshot-theme=light --software-renderer
//! ```
//!
//! 参数:`--screen=instances|download|settings|logs`、`--width`、`--height`、
//! `--theme` / `--screenshot-theme`(后者优先)、`--screenshot`、`--renderer`、
//! `--software-renderer`、`--scale-factor`。没给 `--screenshot` 就只是开窗看。
//!
//! ## 为什么还要等 3 帧
//!
//! 与 `app.rs` 同一口径(M60 坑 #5):第一帧可能是启动时的旧尺寸,且字体首次上屏会抖动。
use std::path::PathBuf;
use std::time::Instant;
use iced::{window, Element, Subscription, Task};
use unlucky_design::color::SchemeFlavor;
use crate::cli::Backend;
use crate::fonts;
use crate::probe::{self, Probe};
use crate::shot;
use crate::theme::Ui;
use super::{demo, Screen};
/// 截图落盘的帧号(见模块文档)。
const CAPTURE_AT_FRAME: u32 = 3;
/// 验收窗口尺寸(D30:1180×760、100% DPI)。
const DEFAULT_SIZE: (f32, f32) = (1180.0, 760.0);
/// 一次渲染请求。
#[derive(Debug, Clone, PartialEq)]
pub struct Request {
/// 渲染哪一页。
pub screen: Screen,
/// 窗口宽度(**含外壳**:布局档位就是按它判的)。
pub width: f32,
/// 窗口高度。
pub height: f32,
/// 主题(`--screenshot-theme` > `--theme` > 浅色)。
pub flavor: SchemeFlavor,
/// 出图路径(`None` = 只开窗)。
pub screenshot: Option<PathBuf>,
/// 渲染后端请求。
pub backend: Backend,
/// 强制缩放因子(截图要 100% DPI)。
pub scale_factor: Option<f32>,
}
impl Default for Request {
fn default() -> Self {
Self {
screen: Screen::Instances,
width: DEFAULT_SIZE.0,
height: DEFAULT_SIZE.1,
flavor: SchemeFlavor::Light,
screenshot: None,
backend: Backend::Auto,
scale_factor: None,
}
}
}
impl Request {
/// 从进程参数解析(`argv[1..]`)。
///
/// # Errors
/// 未知参数、缺值、数字非法时返回人话。
pub fn parse(args: impl IntoIterator<Item = String>) -> Result<Self, String> {
let mut request = Self::default();
let mut theme: Option<SchemeFlavor> = None;
let mut shot_theme: Option<SchemeFlavor> = None;
let mut args = args.into_iter();
while let Some(arg) = args.next() {
let (key, inline) = match arg.split_once('=') {
Some((key, value)) => (key.to_owned(), Some(value.to_owned())),
None => (arg.clone(), None),
};
match key.as_str() {
"--screen" => {
let raw = take(&mut args, inline, "--screen")?;
request.screen = Screen::from_slug(&raw).ok_or_else(|| {
format!("--screen 只认 instances|download|settings|logs,收到 {raw:?}")
})?;
}
"--width" => {
request.width = number(&take(&mut args, inline, "--width")?, "--width")?
}
"--height" => {
request.height = number(&take(&mut args, inline, "--height")?, "--height")?;
}
"--theme" => theme = Some(flavor(&take(&mut args, inline, "--theme")?)?),
"--screenshot-theme" => {
shot_theme = Some(flavor(&take(&mut args, inline, "--screenshot-theme")?)?);
}
"--screenshot" => {
request.screenshot =
Some(PathBuf::from(take(&mut args, inline, "--screenshot")?));
}
"--software-renderer" => request.backend = Backend::Cpu,
"--renderer" => {
request.backend = match take(&mut args, inline, "--renderer")?.as_str() {
"auto" => Backend::Auto,
"gpu" => Backend::Gpu,
"cpu" => Backend::Cpu,
other => {
return Err(format!("--renderer 只认 auto|gpu|cpu,收到 {other:?}"))
}
};
}
"--scale-factor" => {
let raw = take(&mut args, inline, "--scale-factor")?;
let value = number(&raw, "--scale-factor")?;
if value <= 0.0 {
return Err(format!("--scale-factor 必须 > 0,收到 {raw:?}"));
}
request.scale_factor = Some(value);
}
other => return Err(format!("未知参数 {other:?}")),
}
}
request.flavor = shot_theme.or(theme).unwrap_or(SchemeFlavor::Light);
if request.width < 320.0 || request.height < 320.0 {
return Err(format!(
"窗口太小:{}×{}(至少 320×320)",
request.width, request.height
));
}
Ok(request)
}
/// 出图文件名片段(`<screen>-<class>-<flavor>`,归档用)。
pub fn slug(&self) -> String {
let class = super::layout::WidthClass::of(self.width);
let flavor = if self.flavor.is_dark() {
"dark"
} else {
"light"
};
format!(
"{}-{}-{flavor}-w{}",
self.screen.slug(),
class.name(),
self.width as u32
)
}
}
fn take(
args: &mut impl Iterator<Item = String>,
inline: Option<String>,
flag: &str,
) -> Result<String, String> {
inline
.or_else(|| args.next())
.ok_or_else(|| format!("{flag} 需要一个值"))
}
fn number(raw: &str, flag: &str) -> Result<f32, String> {
raw.parse::<f32>()
.map_err(|_| format!("{flag} 需要数字,收到 {raw:?}"))
.and_then(|value| {
if value.is_finite() && value > 0.0 {
Ok(value)
} else {
Err(format!("{flag} 必须是正数,收到 {raw:?}"))
}
})
}
fn flavor(raw: &str) -> Result<SchemeFlavor, String> {
match raw {
"light" => Ok(SchemeFlavor::Light),
"dark" => Ok(SchemeFlavor::Dark),
other => Err(format!("主题只认 light|dark,收到 {other:?}")),
}
}
/// 启动(阻塞到出图或关窗)。
///
/// # Errors
/// 后端探测/字体求值失败、截图落盘失败时返回人话。
pub fn run(request: Request) -> Result<(), String> {
let started = Instant::now();
let needs_probe = request.backend == Backend::Auto;
let probe = if needs_probe {
Probe::run()
} else {
Probe::default()
};
probe::apply(request.backend, &probe);
let resolved = fonts::resolve();
eprintln!("{}", resolved.describe());
let families = resolved.families();
let boot = request.clone();
let application = iced::application(
move || {
(
Harness {
ui: Ui::new(boot.flavor, families),
request: boot.clone(),
window: None,
frames: 0,
},
Task::done(Message::Boot),
)
},
Harness::update,
Harness::view,
)
.theme(Harness::theme)
.subscription(Harness::subscription)
.default_font(iced::Font {
family: iced::font::Family::Name(families.text),
..iced::Font::DEFAULT
})
.window(window::Settings {
size: iced::Size::new(request.width, request.height),
decorations: false,
resizable: true,
..window::Settings::default()
});
eprintln!(
"[harness] screen={} width={} height={} slug={}(布局档位 {})",
request.screen.slug(),
request.width,
request.height,
request.slug(),
super::layout::WidthClass::of(request.width).name()
);
match request.scale_factor {
Some(scale) => application.scale_factor(move |_| scale).run(),
None => application.run(),
}
.map_err(|error| format!("iced 启动失败:{error}"))?;
eprintln!(
"[harness] 用时 {:.0} ms",
started.elapsed().as_secs_f64() * 1000.0
);
Ok(())
}
struct Harness {
request: Request,
ui: Ui,
window: Option<window::Id>,
frames: u32,
}
#[derive(Debug, Clone)]
enum Message {
Boot,
WindowReady(Option<window::Id>),
Frame,
Captured(window::Screenshot),
/// 页面里的动作(骨架期只打印,接线后由 app 层处理)。
Page(super::Message),
}
impl Harness {
fn update(&mut self, message: Message) -> Task<Message> {
match message {
Message::Boot => window::latest().map(Message::WindowReady),
Message::WindowReady(id) => {
self.window = id;
Task::none()
}
Message::Frame => {
self.frames += 1;
if self.frames < CAPTURE_AT_FRAME || self.request.screenshot.is_none() {
return Task::none();
}
match self.window {
Some(id) => window::screenshot(id).map(Message::Captured),
None => Task::none(),
}
}
Message::Captured(screenshot) => {
let Some(path) = self.request.screenshot.clone() else {
return Task::none();
};
match shot::save(&screenshot, &path) {
Ok(summary) => {
eprintln!("{}", summary.describe());
eprintln!("[harness] slug={}", self.request.slug());
}
Err(error) => eprintln!("[harness] 截图失败:{error}"),
}
iced::exit()
}
Message::Page(message) => {
eprintln!("[harness] 页面动作:{message:?}(骨架期不做业务)");
Task::none()
}
}
}
fn subscription(&self) -> Subscription<Message> {
if self.request.screenshot.is_some() {
window::frames().map(|_| Message::Frame)
} else {
Subscription::none()
}
}
fn theme(&self) -> iced::Theme {
self.ui.theme()
}
fn view(&self) -> Element<'_, Message> {
demo(&self.ui, self.request.screen, self.request.width).map(Message::Page)
}
}
#[cfg(test)]
mod tests {
use super::*;
fn parse(args: &[&str]) -> Result<Request, String> {
Request::parse(args.iter().map(|arg| (*arg).to_owned()))
}
#[test]
fn defaults_are_the_acceptance_frame() {
let request = parse(&[]).expect("空参数要能解析");
assert_eq!(request.screen, Screen::Instances);
assert_eq!((request.width, request.height), (1180.0, 760.0));
assert_eq!(request.flavor, SchemeFlavor::Light);
assert!(request.screenshot.is_none());
}
#[test]
fn screen_and_width_parse_in_both_argv_forms() {
let spaced = parse(&["--screen", "settings", "--width", "900"]).unwrap();
let inline = parse(&["--screen=settings", "--width=900"]).unwrap();
assert_eq!(spaced, inline);
assert_eq!(spaced.screen, Screen::Settings);
assert_eq!(spaced.width, 900.0);
assert!(parse(&["--screen=gallery"]).is_err());
assert!(parse(&["--width=abc"]).is_err());
}
#[test]
fn screenshot_theme_wins_over_theme() {
let request = parse(&["--theme=dark", "--screenshot-theme=light"]).unwrap();
assert_eq!(request.flavor, SchemeFlavor::Light);
assert_eq!(parse(&["--theme=dark"]).unwrap().flavor, SchemeFlavor::Dark);
}
#[test]
fn software_renderer_is_an_alias_of_cpu() {
assert_eq!(
parse(&["--software-renderer"]).unwrap().backend,
Backend::Cpu
);
assert_eq!(parse(&["--renderer=cpu"]).unwrap().backend, Backend::Cpu);
assert_eq!(parse(&["--renderer=gpu"]).unwrap().backend, Backend::Gpu);
}
#[test]
fn slug_carries_screen_class_and_flavor() {
let request = parse(&[
"--screen=instances",
"--width=700",
"--screenshot-theme=dark",
])
.unwrap();
assert_eq!(request.slug(), "instances-medium-dark-w700");
}
#[test]
fn tiny_windows_are_rejected() {
assert!(parse(&["--width=200"]).is_err());
assert!(parse(&["--height=100"]).is_err());
}
}
+351
View File
@@ -0,0 +1,351 @@
//! 实例页:**list-detail** 的唯一用例。
//!
//! ## 版式(由 [`super::layout`] 决议,页面不自己算宽度)
//!
//! | 版面 | 结构 | 退化行为 |
//! |---|---|---|
//! | `ListDetail`(窗口 ≥840) | 左列 336 固定、**独立滚动**;右列详情,正文列 ≤424 | — |
//! | `SingleColumn`(<840) | 列表与详情**整页切换**:本页只渲染详情列(≤424,居中) | 顶部给「Back to instances」,列表退成上一屏 |
//!
//! 为什么窄窗不是「上下堆」:官方 compact 下 list-detail 的口径是一次只显示一个 pane(列表 → 详情,
//! 整页切换)。上下堆会把详情挤到折叠线以下,等于详情不存在。
//!
//! ## 判据
//!
//! 列表行 `Component::ListItem`(BodyLarge / BodySmall,圆角 `Small`),行高 = `row_height + Space::X3`
//! = 56(两行行文;`row_height` 44 是单行列表的节奏)。选中行 `SecondaryContainer` + full 圆角(M3 列表)。
mod data;
#[cfg(test)]
mod tests;
pub(super) use data::SELECTED;
use data::{Entry, DEMO, NOTE};
use iced::widget::{column, container, row, scrollable};
use iced::{Alignment, Background, Border, Element, Length, Padding};
use unlucky_design::color::Role;
use unlucky_design::component_map::Component;
use unlucky_design::elevation::MaterialLayer;
use unlucky_design::icons::IconKind;
use unlucky_design::shape::ShapeScale;
use unlucky_design::spacing::Space;
use unlucky_design::typography::TypeRole;
use crate::theme::Ui;
use super::frame::{self, Header};
use super::layout::{self, FrameMetrics, PageLayout};
use super::{Action, Message};
/// 整页(`selected = None` 时详情区显示空态)。
pub fn view<'a>(
ui: &'a Ui,
metrics: FrameMetrics,
selected: Option<usize>,
) -> Element<'a, Message> {
let header = Header::new("Instances")
.subtitle("Local versions and their mod loaders")
.action(frame::text_action(ui, "New instance", Action::NewInstance))
.action(frame::text_action(ui, "Import", Action::Import));
let body = match metrics.layout {
PageLayout::ListDetail => {
let detail = match selected {
Some(_) => detail(ui, metrics),
None => detail_empty(ui),
};
frame::panes(metrics, list_column(ui, selected), detail)
}
_ => match selected {
Some(_) => narrow_detail(ui, metrics),
None => frame::single_column(metrics, detail_empty(ui)),
},
};
frame::page(ui, metrics, header, body)
}
// --------------------------------------------------------------- 列表列
fn list_column<'a>(ui: &'a Ui, selected: Option<usize>) -> Element<'a, Message> {
let head = container(
row![
container(frame::text_role(
ui,
TypeRole::LabelMedium,
false,
"ALL INSTANCES",
ui.color(Role::OnSurfaceVariant),
))
.width(Length::Fill)
.align_y(Alignment::Center),
frame::text_role(
ui,
TypeRole::LabelMedium,
false,
"12",
ui.color(Role::OnSurfaceVariant),
),
]
.width(Length::Fill)
.align_y(Alignment::Center),
)
.width(Length::Fill)
.padding(Padding {
top: ui.space(Space::X3),
right: ui.space(Space::X3),
bottom: ui.space(Space::X1),
left: ui.space(Space::X3),
});
let rows = column(
DEMO.iter()
.enumerate()
.map(|(index, entry)| list_row(ui, entry, selected == Some(index)))
.collect::<Vec<_>>(),
)
.spacing(ui.space(Space::X1))
.padding(Padding::from([0.0, ui.space(Space::X2)]))
.width(Length::Fill);
// **独立滚动**:只有列表列这一条 scrollable,详情列不跟着滚。
let scroller = scrollable(rows)
.direction(scrollable::Direction::Vertical(
scrollable::Scrollbar::new()
.width(frame::SCROLLBAR_WIDTH)
.scroller_width(frame::SCROLLBAR_WIDTH)
.margin(frame::SCROLLBAR_MARGIN),
))
.height(Length::Fill)
.style(frame::scroll_style(ui));
container(column![head, scroller].height(Length::Fill))
.width(Length::Fill)
.height(Length::Fill)
.style(move |_theme| container::Style {
background: Some(Background::Color(ui.layer(MaterialLayer::Card))),
border: Border {
radius: ui.radius_dp(ShapeScale::Large),
..Border::default()
},
..container::Style::default()
})
.into()
}
fn list_row<'a>(ui: &'a Ui, entry: &'a Entry, selected: bool) -> Element<'a, Message> {
let spec = Component::ListItem.spec();
let height = ui.metrics().row_height + Space::X3.dp();
let name_color = if selected {
ui.color(Role::OnSecondaryContainer)
} else {
ui.color(Role::OnSurface)
};
let support_color = if selected {
ui.color(Role::OnSecondaryContainer)
} else {
ui.color(Role::OnSurfaceVariant)
};
let texts = column![
frame::text_role(ui, spec.text, false, entry.name, name_color),
frame::text_role(
ui,
spec.support.unwrap_or(TypeRole::BodySmall),
false,
entry.loader,
support_color,
),
]
.spacing(0.0)
.width(Length::Fill);
let inner = row![
frame::glyph_box(IconKind::Instances, support_color, frame::ICON_PX),
texts,
frame::glyph(IconKind::ChevronRight, support_color, frame::ICON_SMALL_PX),
]
.spacing(ui.space(Space::X3))
.width(Length::Fill)
.align_y(Alignment::Center);
container(inner)
.width(Length::Fill)
.height(height)
.padding(Padding::from([0.0, ui.space(Space::X2)]))
.align_y(Alignment::Center)
.style(move |_theme| container::Style {
background: selected.then(|| Background::Color(ui.color(Role::SecondaryContainer))),
border: Border {
radius: ui.radius(ShapeScale::Full, height),
..Border::default()
},
..container::Style::default()
})
.into()
}
// --------------------------------------------------------------- 详情列
fn detail<'a>(ui: &'a Ui, metrics: FrameMetrics) -> Element<'a, Message> {
let entry = &DEMO[SELECTED];
frame::left_aligned(
metrics.column,
column![
detail_head(ui, entry),
meta_grid(ui, metrics, entry),
frame::divider(ui),
body_paragraph(ui),
frame::divider(ui),
action_row(ui),
]
.spacing(ui.space(Space::X4))
.width(Length::Fill),
)
}
fn narrow_detail<'a>(ui: &'a Ui, metrics: FrameMetrics) -> Element<'a, Message> {
// 窄窗:列表退成上一屏,本屏只放详情(官方 compact 的 list-detail 口径)。
let entry = &DEMO[SELECTED];
frame::single_column(
metrics,
column![
row![frame::text_action(ui, "Back to instances", Action::Back)]
.width(Length::Fill)
.align_y(Alignment::Center),
detail_head(ui, entry),
meta_grid(ui, metrics, entry),
frame::divider(ui),
body_paragraph(ui),
frame::divider(ui),
frame::text_role(
ui,
TypeRole::BodySmall,
false,
"Below 840 px the list and the detail share one column, so opening an instance replaces \
this screen (full-page switch). Widen the window to see both panes again.",
ui.color(Role::OnSurfaceVariant),
),
action_row(ui),
]
.spacing(ui.space(Space::X4))
.width(Length::Fill),
)
}
fn detail_head<'a>(ui: &'a Ui, entry: &'a Entry) -> Element<'a, Message> {
let titles = column![
frame::text_role(
ui,
TypeRole::TitleLarge,
true,
entry.name,
ui.color(Role::OnSurface)
),
frame::text_role(
ui,
TypeRole::BodySmall,
false,
entry.version,
ui.color(Role::OnSurfaceVariant),
),
]
.spacing(ui.space(Space::X1))
.width(Length::Fill);
row![
titles,
frame::text_action(ui, "Play", Action::Play),
frame::text_action(ui, "More", Action::More),
]
.spacing(ui.space(Space::X2))
.width(Length::Fill)
.align_y(Alignment::Center)
.into()
}
/// 元信息网格:列数由 [`layout::meta_columns`](按正文列宽)决定 —— **1180 与 900 都是 2 列**。
///
/// 3 列需要 `3 × META_CARD_MIN_WIDTH + 2 × META_GAP` = 504px,而行宽上限是 424,**数学上不可行**;
/// 硬塞回 3 列会让 `NeoForge 20.4.237`(118px)这类值按词折行、同排卡底不齐。
fn meta_grid<'a>(ui: &'a Ui, metrics: FrameMetrics, entry: &'a Entry) -> Element<'a, Message> {
let cells = [
("VERSION", entry.version),
("LOADER", entry.loader),
("MEMORY", entry.memory),
("JAVA", entry.java),
("MODS", entry.mods),
("LAST PLAYED", entry.last_played),
];
let mut grid = column![].spacing(layout::META_GAP).width(Length::Fill);
let columns = metrics.meta_columns.clamp(1, cells.len());
for chunk in cells.chunks(columns) {
let mut cells_row = row![].spacing(layout::META_GAP).width(Length::Fill);
for (label, value) in chunk {
let cell = frame::card(
ui,
column![
frame::text_role(
ui,
TypeRole::LabelMedium,
false,
label,
ui.color(Role::OnSurfaceVariant),
),
frame::text_role(
ui,
TypeRole::BodyMedium,
false,
value,
ui.color(Role::OnSurface)
),
]
.spacing(ui.space(Space::X1))
.width(Length::Fill),
);
cells_row = cells_row.push(container(cell).width(Length::Fill));
}
grid = grid.push(cells_row);
}
grid.into()
}
fn body_paragraph<'a>(ui: &'a Ui) -> Element<'a, Message> {
frame::text_role(
ui,
TypeRole::BodyMedium,
false,
NOTE,
ui.color(Role::OnSurfaceVariant),
)
}
fn action_row<'a>(ui: &'a Ui) -> Element<'a, Message> {
row![
frame::text_action(ui, "Duplicate", Action::Duplicate),
frame::text_action(ui, "Open folder", Action::OpenFolder),
frame::text_action(ui, "Delete", Action::Delete),
]
.spacing(ui.space(Space::X2))
.align_y(Alignment::Center)
.into()
}
fn detail_empty<'a>(ui: &'a Ui) -> Element<'a, Message> {
frame::empty_state(
ui,
IconKind::Instances,
"No instance selected",
"Pick an instance on the left to see its version, loader and launch options.",
Vec::new(),
)
}
+136
View File
@@ -0,0 +1,136 @@
//! 实例页的**演示数据**(骨架期没有业务状态机,也没有业务数据)。
//!
//! 12 条,**故意比可视区高**:列表列的独立滚动条要能在截图里看见(单测钉住这条)。
//! 文案是英文基线:i18n 是后续 mission,干净 CI 上没有中文字体,写中文会变豆腐块。
/// 演示条目(骨架期没有业务数据;文案是英文基线,i18n 是后续 mission)。
pub(crate) struct Entry {
pub(crate) name: &'static str,
pub(crate) version: &'static str,
pub(crate) loader: &'static str,
pub(crate) memory: &'static str,
pub(crate) java: &'static str,
pub(crate) mods: &'static str,
pub(crate) last_played: &'static str,
}
/// 12 条演示数据:**比可视区高**,这样列表列的独立滚动条在截图里能看见。
pub(crate) const DEMO: [Entry; 12] = [
Entry {
name: "Fabulous World",
version: "1.20.4",
loader: "Fabric 0.15.7",
memory: "4 GiB",
java: "Temurin 21",
mods: "148 mods",
last_played: "2 hours ago",
},
Entry {
name: "Skyblock Origins",
version: "1.21.1",
loader: "NeoForge 21.1.72",
memory: "6 GiB",
java: "Temurin 21",
mods: "86 mods",
last_played: "yesterday",
},
Entry {
name: "Vanilla Survival",
version: "1.21.4",
loader: "Vanilla",
memory: "2 GiB",
java: "Temurin 21",
mods: "0 mods",
last_played: "3 days ago",
},
Entry {
name: "Create: Above and Beyond",
version: "1.18.2",
loader: "Forge 40.2.0",
memory: "8 GiB",
java: "Temurin 17",
mods: "312 mods",
last_played: "last week",
},
Entry {
name: "All the Mods 9",
version: "1.20.1",
loader: "NeoForge 20.4.237",
memory: "8 GiB",
java: "Temurin 17",
mods: "412 mods",
last_played: "2 weeks ago",
},
Entry {
name: "Better MC",
version: "1.20.1",
loader: "Fabric 0.14.24",
memory: "6 GiB",
java: "Temurin 17",
mods: "264 mods",
last_played: "last month",
},
Entry {
name: "RLCraft",
version: "1.12.2",
loader: "Forge 14.23.5",
memory: "4 GiB",
java: "Zulu 8",
mods: "184 mods",
last_played: "2 months ago",
},
Entry {
name: "Origins SMP",
version: "1.20.4",
loader: "Quilt 0.24.0",
memory: "4 GiB",
java: "Temurin 21",
mods: "57 mods",
last_played: "3 months ago",
},
Entry {
name: "Redstone Lab",
version: "1.19.4",
loader: "Fabric 0.15.2",
memory: "3 GiB",
java: "Temurin 17",
mods: "34 mods",
last_played: "April 12",
},
Entry {
name: "Pixelmon Reforged",
version: "1.16.5",
loader: "Forge 36.2.34",
memory: "6 GiB",
java: "Temurin 8",
mods: "121 mods",
last_played: "March 3",
},
Entry {
name: "Techopolis 2",
version: "1.20.1",
loader: "Forge 47.2.20",
memory: "8 GiB",
java: "Temurin 17",
mods: "198 mods",
last_played: "January 28",
},
Entry {
name: "Cobblemon",
version: "1.21.1",
loader: "Fabric 0.16.9",
memory: "5 GiB",
java: "Temurin 21",
mods: "74 mods",
last_played: "January 9",
},
];
/// 演示选中的条目(第一条)。
pub(crate) const SELECTED: usize = 0;
/// 详情正文(**故意写长**:这是行宽上限的视觉证据——1180 宽窗口下也只断在 ~72 字符)。
pub(crate) const NOTE: &str = "This instance keeps its own mods, configs and saves. The launcher resolves the version chain \
(instance → mod loader → vanilla) at launch time, verifies every jar it downloaded against the mirror \
manifest, and streams progress back as structured events so the UI never has to guess. Nothing on this \
page is wired to real data yet: the layout is, the numbers are.";
@@ -0,0 +1,84 @@
//! `instances` 的断言:演示数据与窄窗退化。
use super::*;
use super::super::layout::{frame_metrics, measure_cap_px, PaneKit, WidthClass};
#[test]
fn demo_rows_overflow_the_visible_list() {
// 列表列要能滚动:条目数必须让内容高过可视区(760 窗高 − 标题栏 48 − 页头 84 − 页边距)
let visible = 760.0 - 48.0 - 84.0 - 2.0 * WidthClass::Expanded.gutter();
let row_height = 44.0 + Space::X3.dp();
let content = DEMO.len() as f32 * row_height;
assert!(
content > visible,
"列表内容 {content} 不比可视区 {visible} 高,截图里看不到独立滚动条"
);
}
/// 元信息卡必须装得下**卡内最长的那条值**(视觉 r2:136 漏算 padding,版本串折行让卡底不齐)。
#[test]
fn meta_card_is_wide_enough_for_the_longest_value() {
use super::super::layout::{measure_cap_px, META_CARD_MIN_WIDTH, META_GAP};
use unlucky_design::spacing::LayoutMetrics;
// 实测(Segoe UI 14px):demo 数据里最宽的元信息值是加载器版本串
const LONGEST_VALUE: &str = "NeoForge 20.4.237";
const LONGEST_VALUE_PX: f32 = 118.0;
let padding = LayoutMetrics::DESKTOP.card_padding;
assert_eq!(padding, 20.0, "卡内边距来自令牌");
// 夹具与 demo 数据不许脱节:这个值必须真在 DEMO 里
assert!(
DEMO.iter().any(|entry| entry.loader == LONGEST_VALUE),
"夹具 {LONGEST_VALUE:?} 已经不在 DEMO 里了,请重新量最长值"
);
// ① 单卡外框减掉两侧内边距,必须放得下这条值(否则 text 会按词折行 → 卡底不齐)
let inner = META_CARD_MIN_WIDTH - 2.0 * padding;
assert!(
inner >= LONGEST_VALUE_PX,
"卡内可用 {inner}px 装不下 {LONGEST_VALUE_PX}px 的 {LONGEST_VALUE:?}(外框 {META_CARD_MIN_WIDTH} − 2×{padding})"
);
// ② 实际版面里每张卡的内宽(2 列时)也要够
let two_columns = (measure_cap_px() - META_GAP) / 2.0;
assert!(
two_columns - 2.0 * padding >= LONGEST_VALUE_PX,
"两列布局下卡内可用 {}px 装不下 {LONGEST_VALUE_PX}px",
two_columns - 2.0 * padding
);
}
#[test]
fn every_demo_entry_has_name_version_and_loader() {
for entry in &DEMO {
assert!(!entry.name.is_empty());
assert!(!entry.version.is_empty());
assert!(!entry.loader.is_empty());
}
assert!(SELECTED < DEMO.len());
}
#[test]
fn detail_paragraph_is_long_enough_to_wrap() {
// 行宽上限 ≈ 72 字符/行;写够几行才看得出断行位置
assert!(NOTE.len() > 200, "正文档太短,看不出行宽上限");
}
#[test]
fn narrow_widths_switch_the_page_to_one_column() {
// 1180 / 900 → 两列;700 → 单列(整页切换)
assert_eq!(
frame_metrics(PaneKit::ListDetail, 1180.0).layout,
PageLayout::ListDetail
);
assert_eq!(
frame_metrics(PaneKit::ListDetail, 700.0).layout,
PageLayout::SingleColumn
);
// 窄窗下详情不吃满窗口:正文列仍 ≤ 行宽上限
let narrow = frame_metrics(PaneKit::ListDetail, 700.0);
assert!(narrow.column <= measure_cap_px() + f32::EPSILON);
assert!(narrow.secondary.is_none());
}
+382
View File
@@ -0,0 +1,382 @@
//! 页面布局系统:**窗口宽度档位 → 版面 → 列宽**。纯函数,不碰 iced 控件树。
//!
//! 这是本 mission 的核心交付物,四个页面只是它的用例。`layout.rs` 是页面级几何的
//! **唯一定义点**:页面文件里不许再出现列宽字面量。
//!
//! ## 1. 宽度档位(官方响应式断点 600 / 840)
//!
//! 档位定义照官方 M3「Window size classes」(A 级:m3.material.io 的 app-quality/window-size-classes,
//! Compose `WindowSizeClass` 的同一组硬值):**600dp 与 840dp** 是两个分界。
//!
//! | 档位 | 窗口宽度(逻辑 px @100% DPI) | 页边距(gutter) |
//! |---|---|---|
//! | `Compact` | `< 600` | 16(`Space::X4`) |
//! | `Medium` | `600 ≤ w < 840` | 24(`Space::X6`) |
//! | `Expanded` | `≥ 840` | 24(`Space::X6`) |
//!
//! 页边距取 16/24 而不是 16/24/32:官方 expanded 页边距也是 24,写 32 会白白多一条偏离登记。
//!
//! > 本 crate 的真实窗口下限是 880(`app.rs` 的 `MIN_SIZE`),所以日常只有 `Expanded`;
//! > 600 以下的档位是给「将来允许更窄窗口 / 内嵌面板」准备的,也让布局规则**可测**。
//!
//! ## 2. 三种版面与「什么宽度用哪种」
//!
//! | 版面 | 含义 | 页面上用它 | 成立条件 | 退化到 |
//! |---|---|---|---|---|
//! | `SingleColumn` | 单列,列宽 ≤ 行宽上限 | 下载 / 日志(全部档位);实例 / 设置(窄窗) | 总是成立 | — |
//! | `SupportingPane` | 主列 + 窄侧列(侧列是辅助,主列是阅读对象) | 设置(分组导航 240 + 表单列) | `w ≥ 600` 且 `w ≥ 720` | `SingleColumn` |
//! | `ListDetail` | 固定列表列(320~360,**独立滚动**)+ 详情列 | 实例 | `w ≥ 840` 且 `w ≥ 816` | `SingleColumn` |
//!
//! 两个门槛都是**算出来的**,不是拍的(见 [`panes_min_window`]):
//!
//! - 设置:`240(drawer)+ 24(间隙)+ 320(表单列下限)+ 2×24(gutter)+ 88(rail)= 720`;
//! - 实例:`336(列表列)+ 24(间隙)+ 320(详情列下限)+ 2×24(gutter)+ 88(rail)= 816`。
//!
//! 实例的几何门槛 816 低于官方 expanded 断点 840 —— 所以**档位是更严的那一条**,
//! 单测 `expanded_is_wide_enough_for_list_detail` 把这条「余量 24px」钉死。
//!
//! ## 3. 行宽约束(历史事故:M68 的全宽单卡)
//!
//! M68 骨架的内容区是**一张全宽卡**:卡片横跨 x=112..1156(**1044px**),卡片内文本区 **1004px**
//! (边距 24 + 卡片内边距 20)。那张卡里的正文是 `BodySmall` 12px:步进 = 14px 实测值
//! 6.0526 × 12/14 = **5.19px/字符**,所以 137 字符 × 5.19 ≈ **711px** —— 与
//! `DevDocs/ops/screens-m68/shell-light-cpu.png` 里那一行**实测 ink 712px**(x=133..845)吻合。
//! 视觉复审的判词因此是「**不能把全宽单卡当页面模板**,官方桌面口径是 supporting-pane / list-detail」。
//!
//! > **两个订正记录**(都是 P2 复审抓出来的):
//! > ① **712 是那一行的墨迹宽度,不是列宽**。早期文档写成「1180 − 88 − 2×24 = 712」,
//! > 那个减法得 1044,712 是 ink——列宽是 1004,ink 只占其中的 71%。
//! > ② **字符数按实测算**:那一行是 **137 字符**(12px × 0.4323em = 5.19px/字符)。
//! > 早期口径拿 `0.5em` 估算的「14px 正文步进」换算,既高估步进、也对错了字号,把事故说轻了。
//! > 现在事故描述一律用**实测步进 × 实际字号**;这也是
//! > `line_length_is_capped_at_72_characters` 里那条「旧口径 504px 能放 83 字符」护栏的由来。
//!
//! ### 逐行口径:72 字符是**最长一行**的上界,不是平均(r2 复审的教训)
//!
//! 「平均步进 × 72」只能保证**平均**一行 72 字符:r2 复审在交付图里抓到一行 **73 字符**
//! (`guess. Nothing on this page is wired to real data yet: the layout is, the`,ink 428px)。
//! 所以上限**不是算术结果,而是逐行折行模拟的结果**——用 FreeType 量宽 + 贪心词折行,
//! 对实例页那段正文(361 字符)扫不同列宽:
//!
//! | 列宽 | 最长一行 | 该行 ink | 结论 |
//! |---|---|---|---|
//! | 435(= 平均口径上界) | 73 字符 | 428px | ✗ 破了 72 |
//! | 428 | 73 字符 | 428px | ✗ |
//! | 426 | 71 字符 | 426px | ✓ 但只剩 1 字符余量 |
//! | **424**(4px 栅格) | **67 字符** | **402px** | ✓ 取这个:满足上界 + 留 5 字符余量 |
//!
//! 于是 [`measure_cap_px`] = **424**(= `min(逐行安全宽 424, 平均口径上界 435)`),由
//! [`content_column_width`] 与 [`frame_metrics`] 无条件施加:任何窗口宽度、任何版面,
//! 正文列都不会超过它。1180 宽窗口下正文列是 **424**(不是 712)。
//!
//! **作用域**:这条承诺只覆盖 `BodyMedium` 14px 的正文。`BodySmall` 12px 的辅助文案
//! (空态副文案、窄窗提示)在同一列宽下每行可到 ~82 字符——它们都是 1~2 行的短句,不做逐行承诺;
//! 中文(全角 1em)另算,等 i18n mission 定。
//!
//! ### 步进是怎么量的(口径 + 可复现)
//!
//! - **测的是哪组字形**:`BodyMedium` 的字体栈(本机解析到 Segoe UI **Regular**,14px),
//! **无 letter-spacing**——令牌的 0.5px 字距 iced 0.14 不支持(已登记的令牌丢失),渲染里没有;
//! - **样本**:实例页详情那段正文(`pages/instances/data.rs` 的 `NOTE`,**361 字符**,
//! 大小写 / 标点 / 空格 / `→` 混排,是我们自己的真实正文,不是造出来的 `mmmm`);
//! - **量法**:FreeType `getlength`(PIL `ImageFont.truetype(segoeui.ttf, 14)`)求整段排版宽,
//! 再除以字符数 → **2185.00px / 361 = 6.0526 px/字符 = 0.43233em**;× 72 = 435.79(这是**平均**口径上界,逐行口径见下);
//! - **交叉验证**:同一段的首行(78 字符)渲染实测墨迹 **484px**,FreeType 对同一串给 479px
//! (差 ~1%,抗锯齿外扩),说明「步进」这个口径在 iced 渲染器与 FreeType 之间是一致的;
//! - **CJK 不适用**:全角字形是 1em(14px),中文的 72 字符是 1008px。页面文案走 i18n 之后
//! 要按语言分别定上限,这条留给 i18n mission;本模块只保证英文基线。
use unlucky_design::spacing::{LayoutMetrics, Space};
use unlucky_design::typography::TypeRole;
/// 官方 M3 窗口档的 medium 断点(600dp)。
pub const BREAKPOINT_MEDIUM: f32 = 600.0;
/// 官方 M3 窗口档的 expanded 断点(840dp)。
pub const BREAKPOINT_EXPANDED: f32 = 840.0;
/// 列表列宽(list-detail 左列)。任务区间 320~360,取 4px 栅格内的 **336**:
/// 落在区间中段,既放得下「名字 + 版本 + 尾随图标」,又给详情列多留 16px。
pub const LIST_COLUMN_WIDTH: f32 = 336.0;
/// 详情列下限:再窄就不再分栏。
///
/// 按实测步进算:45 字符 × 6.05px ≈ 272,取 4px 栅格并留余量 → **320**
/// (320 / 6.05 = 53 字符,仍在行宽下限之上)。
pub const DETAIL_MIN_WIDTH: f32 = 320.0;
/// 侧列宽(设置页分组导航)= D33 的 drawer 宽 240。
///
/// 复用 rail 的**尺寸语义**(88 ⇄ 240 同一容器的两种形态),但**不复制 rail 的实现**:
/// 分组导航是页内元素,没有 hover 药丸、没有二级标签。
pub const SIDE_NAV_WIDTH: f32 = LayoutMetrics::DESKTOP.nav_drawer_width;
/// 表单列下限(supporting-pane 主列):与详情列下限同源(320 ≈ 45 字符)。
pub const FORM_MIN_WIDTH: f32 = DETAIL_MIN_WIDTH;
/// 两列之间的间隙(`Space::X6` = 24)。
pub const PANES_GAP: f32 = Space::X6.dp();
/// 详情里元信息网格的**单卡最小宽度 = 由卡内最长内容反推**:**160**。
///
/// 推导(不是凑出来的):
/// - 卡内最长值是 demo 里的 `NeoForge 20.4.237`,Segoe UI 14px 实测 **118px**;
/// - 卡两侧内边距 `LayoutMetrics::card_padding` = 20,故最小外框 = 118 + 2×20 = **158**;
/// - 取 4px 栅格的 **160**(等价于旧值,但这次是**内容推出来的**,不是凑上限凑出来的)。
///
/// r2 复审抓到的回归:上一轮为了塞三列把这里收到 136,忘了算 padding ——
/// 卡内只剩 96px,`NeoForge 20.4.237`(118px)与 `NeoForge 21.1.72`(110px)都会折成两行,
/// 同排另两张卡仍一行 → **卡底不齐**。160 之下 3 列要 3×160 + 2×12 = 504,超出行宽上限,
/// 所以元信息网格在 1180 宽是 **2 列**(2×160 + 12 = 332,卡内可排 166px,装得下 118)。
/// 这也是有意的:mod 加载器版本串只会更长,2 列宽卡比 3 列挤卡更抗将来。
pub const META_CARD_MIN_WIDTH: f32 = 160.0;
/// 元信息网格卡片间距(`Space::X3` = 12)。
pub const META_GAP: f32 = Space::X3.dp();
/// 正文平均字符步进(em)——**实测值**,口径见模块文档 §3。
///
/// `BodyMedium` 14px / Segoe UI Regular / 361 字符英文混排正文,FreeType 实测
/// **6.0526px/字符 = 0.43233em**。
///
/// 旧的 `0.5`(`[估]`)把 72 字符的列宽算成 504px,而实测 504px 能塞 **83 字符**——
/// 「72 字符」这个名字和数字对不上的根因就在这里。换字体栈要重新量(这不是令牌)。
pub const CHAR_ADVANCE_EM: f32 = 0.43233;
/// 舒适行宽的字符数上限(任务建议 60~72,取上界 72)。
pub const MAX_MEASURE_CHARS: f32 = 72.0;
/// 行宽下限的字符数(再窄正文就碎成短行,45 是最低可读长度)。
pub const MIN_MEASURE_CHARS: f32 = 45.0;
/// 导航轨宽度(与 `shell::nav` 同一令牌值;布局系统只读它,不重定义)。
pub const fn rail_width() -> f32 {
LayoutMetrics::DESKTOP.nav_rail_width
}
/// **逐行**安全宽度(px):折行模拟下「最长一行 ≤72 字符」的最大 4px 栅格列宽 = **424**。
///
/// 见模块文档 §3 的模拟表:428 会出现 73 字符行,426 起降到 71,424 给到 67(留 5 字符余量)。
/// 这个数是**量出来的**,不是 `advance × 72` 算出来的——后者只保证平均。
pub const LINE_SAFE_WIDTH_PX: f32 = 424.0;
/// 平均口径上界(px):`floor(14px × 0.43233em × 72)` = **435**。
///
/// 只能保证**平均**一行 72 字符(实测 435 下会冒出 73 字符行),所以它是上界而不是取值。
pub fn measure_average_bound_px() -> f32 {
(TypeRole::BodyMedium.style().size_px * CHAR_ADVANCE_EM * MAX_MEASURE_CHARS).floor()
}
/// 正文列宽上限(px)= `min(逐行安全宽 424, 平均口径上界 435)` = **424**。
///
/// 硬上限,任何版面 / 任何窗口宽度都不许超;`1180` 窗宽下正文列就是它(而非 M68 的 712)。
pub fn measure_cap_px() -> f32 {
LINE_SAFE_WIDTH_PX.min(measure_average_bound_px())
}
/// 正文列宽下限(px):`floor(14 × 0.43233 × 45)` = **272**。
pub fn measure_floor_px() -> f32 {
(TypeRole::BodyMedium.style().size_px * CHAR_ADVANCE_EM * MIN_MEASURE_CHARS).floor()
}
/// 窗口宽度档位(官方 600 / 840 两个断点)。
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum WidthClass {
/// `< 600`
Compact,
/// `600 ≤ w < 840`
Medium,
/// `≥ 840`
Expanded,
}
impl WidthClass {
/// 按**窗口宽度**(含导航轨,逻辑 px)判档。
pub fn of(window_width: f32) -> Self {
if window_width < BREAKPOINT_MEDIUM {
Self::Compact
} else if window_width < BREAKPOINT_EXPANDED {
Self::Medium
} else {
Self::Expanded
}
}
/// 稳定名字(日志/测试/文件名用)。
pub const fn name(self) -> &'static str {
match self {
Self::Compact => "compact",
Self::Medium => "medium",
Self::Expanded => "expanded",
}
}
/// 页边距:compact 16 / medium 24 / expanded 24(官方 expanded 也是 24)。
pub const fn gutter(self) -> f32 {
match self {
Self::Compact => Space::X4.dp(),
Self::Medium | Self::Expanded => Space::X6.dp(),
}
}
}
/// 三种版面。
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum PageLayout {
/// 单列(列宽 ≤ 行宽上限,水平居中)。
SingleColumn,
/// 主列 + 辅助侧列。
SupportingPane,
/// 固定列表列 + 详情列。
ListDetail,
}
impl PageLayout {
/// 稳定名字。
pub const fn name(self) -> &'static str {
match self {
Self::SingleColumn => "single-column",
Self::SupportingPane => "supporting-pane",
Self::ListDetail => "list-detail",
}
}
/// 是否有两个列(侧列 + 主列)。
pub const fn is_two_pane(self) -> bool {
!matches!(self, Self::SingleColumn)
}
}
/// 页面声明的「最宽版面」——布局系统按窗口宽度降级。
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum PaneKit {
/// 单列页(下载 / 日志)。
Single,
/// 主列 + 侧列(设置)。
Supporting,
/// 列表 + 详情(实例)。
ListDetail,
}
/// 两列版面成立所需的最小窗口宽度(**公式**,不是拍值):
/// `Σ列宽 + 间隙 + 2×gutter + rail`。
pub fn panes_min_window(first: f32, second: f32) -> f32 {
first + second + PANES_GAP + 2.0 * WidthClass::Expanded.gutter() + rail_width()
}
/// `ListDetail` 的几何门槛:336 + 320 + 24 + 48 + 88 = **816**。
pub fn list_detail_min_window() -> f32 {
panes_min_window(LIST_COLUMN_WIDTH, DETAIL_MIN_WIDTH)
}
/// `SupportingPane` 的几何门槛:240 + 320 + 24 + 48 + 88 = **720**。
pub fn supporting_pane_min_window() -> f32 {
panes_min_window(SIDE_NAV_WIDTH, FORM_MIN_WIDTH)
}
/// 版面决议:档位与几何门槛**同时**满足才升到两列,否则退化单列。
pub fn resolve(kit: PaneKit, window_width: f32) -> PageLayout {
let class = WidthClass::of(window_width);
let wide_enough = match kit {
PaneKit::Single => false,
PaneKit::Supporting => {
class != WidthClass::Compact && window_width >= supporting_pane_min_window()
}
PaneKit::ListDetail => {
class == WidthClass::Expanded && window_width >= list_detail_min_window()
}
};
match (kit, wide_enough) {
(PaneKit::Single, _) | (_, false) => PageLayout::SingleColumn,
(PaneKit::Supporting, true) => PageLayout::SupportingPane,
(PaneKit::ListDetail, true) => PageLayout::ListDetail,
}
}
/// 页面可用宽度:窗口 − 导航轨 − 两侧页边距(下限 0)。
pub fn available_width(window_width: f32) -> f32 {
let class = WidthClass::of(window_width);
(window_width - rail_width() - 2.0 * class.gutter()).max(0.0)
}
/// **给定窗口宽度 → 内容(正文)列宽**。本 mission 的「行宽约束」入口。
///
/// - 单列页:就是正文列宽(水平居中摆放);
/// - 两列页:这是**主列**的正文列宽;实际值还要看 [`frame_metrics`](详情列会被列表列挤窄)。
///
/// 返回值永远 ≤ [`measure_cap_px`]:1180 窗宽下是 424(逐行 67 字符,≤72),不是 M68 的 712。
pub fn content_column_width(window_width: f32) -> f32 {
available_width(window_width).min(measure_cap_px())
}
/// 元信息网格的列数:3 列需要 504(超出行宽上限,1180 下用不到),2 列需要 332,否则 1 列。
pub fn meta_columns(width: f32) -> usize {
for columns in [3usize, 2] {
let needed = columns as f32 * META_CARD_MIN_WIDTH + (columns - 1) as f32 * META_GAP;
if width >= needed {
return columns;
}
}
1
}
/// 一屏的版面几何(页面直接消费,不再自己算列宽)。
#[derive(Debug, Clone, Copy, PartialEq)]
pub struct FrameMetrics {
/// 窗口宽度(输入)。
pub window_width: f32,
/// 宽度档位。
pub class: WidthClass,
/// 决议出来的版面。
pub layout: PageLayout,
/// 页边距。
pub gutter: f32,
/// 页面可用宽度(已扣导航轨与页边距)。
pub available: f32,
/// 正文列宽(≤ 行宽上限)。两列版面时是**主列**的正文列宽。
pub column: f32,
/// 第二列(list-detail 的列表列 / supporting-pane 的侧列)。
pub secondary: Option<f32>,
/// 主列所在 pane 的宽度(单列 = 正文列宽)。
pub main_pane: f32,
/// 详情元信息网格列数。
pub meta_columns: usize,
}
/// 一次算齐版面几何。
pub fn frame_metrics(kit: PaneKit, window_width: f32) -> FrameMetrics {
let class = WidthClass::of(window_width);
let gutter = class.gutter();
let available = available_width(window_width);
let layout = resolve(kit, window_width);
let (column, secondary, main_pane) = match layout {
PageLayout::SingleColumn => {
let column = available.min(measure_cap_px());
(column, None, column)
}
PageLayout::ListDetail => {
let list = LIST_COLUMN_WIDTH.min(available);
let pane = (available - list - PANES_GAP).max(0.0);
(pane.min(measure_cap_px()), Some(list), pane)
}
PageLayout::SupportingPane => {
let side = SIDE_NAV_WIDTH.min(available);
let pane = (available - side - PANES_GAP).max(0.0);
(pane.min(measure_cap_px()), Some(side), pane)
}
};
FrameMetrics {
window_width,
class,
layout,
gutter,
available,
column,
secondary,
main_pane,
meta_columns: meta_columns(column),
}
}
#[cfg(test)]
mod tests;
+367
View File
@@ -0,0 +1,367 @@
//! `layout` 的断言:断点表、版面决议、行宽上限、列宽几何。
//!
//! 这些是**门禁级**断言:阈值改错必须立刻红(tower 会故意改错来验)。
use super::*;
/// 断点表:官方 600 / 840 两个硬值,边界闭开区间逐点钉死。
#[test]
fn breakpoints_are_the_official_600_and_840() {
assert_eq!(BREAKPOINT_MEDIUM, 600.0, "官方 medium 断点");
assert_eq!(BREAKPOINT_EXPANDED, 840.0, "官方 expanded 断点");
for (width, expected) in [
(320.0, WidthClass::Compact),
(599.0, WidthClass::Compact),
(599.9, WidthClass::Compact),
(600.0, WidthClass::Medium),
(720.0, WidthClass::Medium),
(839.0, WidthClass::Medium),
(839.9, WidthClass::Medium),
(840.0, WidthClass::Expanded),
(900.0, WidthClass::Expanded),
(1180.0, WidthClass::Expanded),
(2560.0, WidthClass::Expanded),
] {
assert_eq!(
WidthClass::of(width),
expected,
"{width} 宽应该判为 {expected:?}"
);
}
}
/// 版面决议表(tower 会照这张表对文档):一列写死「宽度 → 版面」。
#[test]
fn layout_table_matches_the_documented_thresholds() {
// (窗口宽度, 实例页, 设置页, 下载/日志页)
let table = [
(
320.0,
PageLayout::SingleColumn,
PageLayout::SingleColumn,
PageLayout::SingleColumn,
),
(
599.0,
PageLayout::SingleColumn,
PageLayout::SingleColumn,
PageLayout::SingleColumn,
),
(
700.0,
PageLayout::SingleColumn,
PageLayout::SingleColumn,
PageLayout::SingleColumn,
),
(
720.0,
PageLayout::SingleColumn,
PageLayout::SupportingPane,
PageLayout::SingleColumn,
),
(
800.0,
PageLayout::SingleColumn,
PageLayout::SupportingPane,
PageLayout::SingleColumn,
),
(
839.0,
PageLayout::SingleColumn,
PageLayout::SupportingPane,
PageLayout::SingleColumn,
),
(
840.0,
PageLayout::ListDetail,
PageLayout::SupportingPane,
PageLayout::SingleColumn,
),
(
900.0,
PageLayout::ListDetail,
PageLayout::SupportingPane,
PageLayout::SingleColumn,
),
(
1180.0,
PageLayout::ListDetail,
PageLayout::SupportingPane,
PageLayout::SingleColumn,
),
(
1920.0,
PageLayout::ListDetail,
PageLayout::SupportingPane,
PageLayout::SingleColumn,
),
];
for (width, instances, settings, single) in table {
assert_eq!(
resolve(PaneKit::ListDetail, width),
instances,
"{width} 宽实例页版面不对"
);
assert_eq!(
resolve(PaneKit::Supporting, width),
settings,
"{width} 宽设置页版面不对"
);
assert_eq!(
resolve(PaneKit::Single, width),
single,
"{width} 宽单列页版面对不"
);
}
}
#[test]
fn pane_thresholds_are_the_sum_of_their_columns() {
// 336 + 320 + 24 + 2×24 + 88 = 816
assert_eq!(list_detail_min_window(), 816.0);
// 240 + 320 + 24 + 2×24 + 88 = 720
assert_eq!(supporting_pane_min_window(), 720.0);
// 官方 expanded 断点必须**宽于** list-detail 的几何门槛,否则「840 起用 list-detail」是空话
assert!(
BREAKPOINT_EXPANDED >= list_detail_min_window(),
"840 档必须放得下 336 + 24 + 320 的三列和"
);
// 设置页在 medium 档里就有能用的窗口宽度(720 < 840)
assert!(supporting_pane_min_window() < BREAKPOINT_EXPANDED);
assert!(WidthClass::of(supporting_pane_min_window()) == WidthClass::Medium);
}
/// 实测样本:**实例页详情那段正文**(`pages/instances/data.rs` 的 `NOTE`)。
///
/// 口径(`layout` 模块文档 §3):Segoe UI Regular @14px、无 letter-spacing、
/// FreeType `getlength` 求整段排版宽。**这是外部实测数据,不是从常量推出来的**——
/// 下面的断言拿它反算「一行能放几个字符」,所以把 `CHAR_ADVANCE_EM` 改回估算值会立刻红。
const MEASURE_SAMPLE_CHARS: f32 = 361.0;
const MEASURE_SAMPLE_WIDTH_PX: f32 = 2185.0;
/// 样本的实测平均步进(px/字符)。
fn measured_advance_px() -> f32 {
MEASURE_SAMPLE_WIDTH_PX / MEASURE_SAMPLE_CHARS
}
/// 给定列宽,按**实测步进**能放多少字符。
fn chars_per_line(width: f32) -> f32 {
width / measured_advance_px()
}
/// **逐行**折行模拟结果(FreeType `getlength` 量宽 + 贪心词折行,脚本见模块文档 §3)。
///
/// 四个数都是外部实测,口径要分清(视觉 r3 提醒过这里容易写糊):
/// - `SAFE_MAX_CHARS` / `SAFE_MAX_CHARS_INK`:cap=424 时**字符最多**的那一行 —— 67 字符,ink **402px**;
/// - `SAFE_WIDEST_INK`:cap=424 时**墨迹最宽**的那一行 —— **411px**(不是同一行!402 那行字符多但字形窄);
/// - `BREAK_INK` / `BREAK_CHARS`:第一档会冒出 73 字符行的宽度 428px(那行 ink 428px)。
///
/// 有了它,Rust 侧不用字体引擎也能把「上限必须夹在这些数之间」写成断言。
const SIM_SAFE_MAX_CHARS: f32 = 67.0;
const SIM_SAFE_MAX_CHARS_INK: f32 = 402.0;
const SIM_SAFE_WIDEST_INK: f32 = 411.0;
const SIM_BREAK_INK: f32 = 428.0;
const SIM_BREAK_CHARS: f32 = 73.0;
/// 行宽上限必须真的停在 72 字符:名字、常量、**逐行模拟**三者一致。
#[test]
fn line_length_is_capped_at_72_characters() {
// ① 常量对得上实测步进(14px 下的平均步进)
let body_size = TypeRole::BodyMedium.style().size_px;
assert_eq!(body_size, 14.0);
assert!(
(measured_advance_px() - body_size * CHAR_ADVANCE_EM).abs() < 0.02,
"CHAR_ADVANCE_EM = {CHAR_ADVANCE_EM} 与实测步进 {:.4}px/字符对不上",
measured_advance_px()
);
assert_eq!(measure_floor_px(), 272.0, "floor(14 × 0.43233 × 45)");
// ② 上限是**逐行**模拟出来的 424,不是「平均步进 × 72」的算术值 435
assert_eq!(measure_cap_px(), 424.0, "逐行安全宽(4px 栅格)");
assert_eq!(
measure_average_bound_px(),
435.0,
"floor(14 × 0.43233 × 72)"
);
assert!(
measure_cap_px() < measure_average_bound_px(),
"平均口径上界必须比逐行安全宽**更宽**——这说明 cap 不是拿平均算出来的"
);
// ③ 交付宽度下两条实测线都装得下:字符最多那行(402px)与墨迹最宽那行(411px)
assert!(
SIM_SAFE_MAX_CHARS_INK <= measure_cap_px(),
"字符最多那行 {}px 超过上限 {}px",
SIM_SAFE_MAX_CHARS_INK,
measure_cap_px()
);
assert!(
SIM_SAFE_WIDEST_INK <= measure_cap_px(),
"墨迹最宽那行 {}px 超过上限 {}px(余量只有 {}px)",
SIM_SAFE_WIDEST_INK,
measure_cap_px(),
measure_cap_px() - SIM_SAFE_WIDEST_INK
);
const { assert!(SIM_SAFE_MAX_CHARS <= MAX_MEASURE_CHARS) };
// ④ **两侧夹逼**:这是本条真正的护栏。
// 放大到 SIM_BREAK_INK(428)逐行模拟就会重新出现 73 字符行;
// 压到 SAFE 以下则纯属白丢宽度。两个方向都会红。
assert!(
measure_cap_px() < SIM_BREAK_INK,
"上限 ≥{SIM_BREAK_INK}px 时逐行模拟会再次出现 {SIM_BREAK_CHARS} 字符行——请重跑折行模拟"
);
assert!(
measure_cap_px() >= SIM_SAFE_WIDEST_INK,
"上限 {}px 比模拟出来的最宽行 {}px 还窄,白丢宽度",
measure_cap_px(),
SIM_SAFE_WIDEST_INK
);
// ⑤ 平均口径的回归护栏:旧 0.5em 的 504px 在实测步进下能放 83 字符
assert!(
chars_per_line(504.0) > MAX_MEASURE_CHARS + 10.0,
"504px 在实测步进下应能放 >82 字符(旧口径的破绽),现在算出来 {:.1}",
chars_per_line(504.0)
);
for width in [600.0, 840.0, 900.0, 1180.0, 1440.0, 2560.0] {
let column = content_column_width(width);
// 平均口径只是次要护栏(逐行口径见上面的模拟常数)
assert!(
chars_per_line(column) <= MAX_MEASURE_CHARS,
"{width} 宽窗口的平均行宽 {:.1} 字符,超过 {MAX_MEASURE_CHARS}",
chars_per_line(column)
);
assert!(column <= measure_cap_px() + f32::EPSILON);
}
// 1180 —— M68 就是在这条上翻的车(712px),现在必须是 424(= measure_cap_px())
let wide = content_column_width(1180.0);
assert_eq!(wide, measure_cap_px());
assert!(wide < 712.0, "不许回到 M68 的全宽单卡(712px)");
}
#[test]
fn line_length_tracks_width_until_it_hits_the_cap() {
// 窄窗:正文列跟着可用宽度走(480 宽还在上限以内),撞上上限就锁死
let narrow = content_column_width(480.0);
assert!(narrow < measure_cap_px(), "480 宽时正文列应窄于上限");
assert_eq!(narrow, available_width(480.0));
// 单调不减:窗口变宽,正文列不变窄
let mut previous = 0.0;
for width in [320.0, 480.0, 600.0, 720.0, 840.0, 1024.0, 1180.0, 1600.0] {
let column = content_column_width(width);
assert!(column >= previous, "{width} 宽的正文列比更窄时还小");
previous = column;
}
// 一旦撞上上限就锁死(宽窗不会继续变宽)
assert_eq!(content_column_width(1180.0), measure_cap_px());
assert_eq!(content_column_width(1920.0), measure_cap_px());
}
#[test]
fn list_detail_geometry_keeps_the_list_column_in_range() {
let metrics = frame_metrics(PaneKit::ListDetail, 1180.0);
assert_eq!(metrics.layout, PageLayout::ListDetail);
let list = metrics.secondary.expect("list-detail 必须有列表列");
assert!(
(320.0..=360.0).contains(&list),
"列表列 {list} 不在 320~360 参考区间"
);
assert_eq!(list, LIST_COLUMN_WIDTH);
// 详情正文列:≤ 行宽上限,且明显窄于「窗口全宽」
assert!(metrics.column <= measure_cap_px() + f32::EPSILON);
assert!(metrics.column < metrics.available, "详情列必须窄于可用宽度");
// 列表列 + 间隙 + 详情 pane = 可用宽度(不留缝)
assert_eq!(
list + PANES_GAP + metrics.main_pane,
metrics.available,
"两列几何必须铺满可用宽度"
);
}
#[test]
fn list_detail_degrades_to_one_column_below_the_threshold() {
for width in [320.0, 599.0, 700.0, 816.0, 839.0] {
assert_eq!(
frame_metrics(PaneKit::ListDetail, width).layout,
PageLayout::SingleColumn,
"{width} 宽应该退化成单列"
);
assert!(frame_metrics(PaneKit::ListDetail, width)
.secondary
.is_none());
}
// 840 = 官方 expanded 断点,list-detail 从这里成立
assert_eq!(
frame_metrics(PaneKit::ListDetail, 840.0).layout,
PageLayout::ListDetail
);
// 840 宽下详情正文列仍要够宽(≥ 下限),否则等于假分栏
let at_breakpoint = frame_metrics(PaneKit::ListDetail, 840.0);
assert!(
at_breakpoint.column >= measure_floor_px(),
"840 宽下详情列 {:?} 低于行宽下限",
at_breakpoint.column
);
}
#[test]
fn settings_pane_keeps_the_drawer_width_and_a_readable_form_column() {
let metrics = frame_metrics(PaneKit::Supporting, 1180.0);
assert_eq!(metrics.layout, PageLayout::SupportingPane);
assert_eq!(metrics.secondary, Some(SIDE_NAV_WIDTH));
assert_eq!(SIDE_NAV_WIDTH, 240.0, "D33:展开态是 240 的 drawer");
assert_eq!(metrics.column, measure_cap_px(), "表单列吃满行宽上限");
assert_eq!(measure_cap_px(), 424.0);
let narrow = frame_metrics(PaneKit::Supporting, 700.0);
assert_eq!(narrow.layout, PageLayout::SingleColumn);
assert!(narrow.secondary.is_none());
}
#[test]
fn meta_grid_drops_columns_as_the_detail_narrows() {
// 160 宽卡:3 列要 3×160 + 2×12 = 504(超出行宽上限),所以上限处就是 2 列;2 列要 332
assert_eq!(meta_columns(measure_cap_px()), 2, "2×160 + 12 = 332");
assert_eq!(meta_columns(340.0), 2);
assert_eq!(meta_columns(330.0), 1);
// 1180 与 900 都是 2 列(160 宽卡三列塞不进行宽上限),差别落在详情列宽
let wide = frame_metrics(PaneKit::ListDetail, 1180.0);
let mid = frame_metrics(PaneKit::ListDetail, 900.0);
assert_eq!(wide.meta_columns, 2);
assert_eq!(mid.meta_columns, 2);
assert!(mid.column < wide.column, "900 宽下详情列必须真的变窄");
}
#[test]
fn gutters_follow_the_class_and_are_on_the_grid() {
assert_eq!(WidthClass::Compact.gutter(), 16.0);
assert_eq!(WidthClass::Medium.gutter(), 24.0);
assert_eq!(WidthClass::Expanded.gutter(), 24.0);
for class in [
WidthClass::Compact,
WidthClass::Medium,
WidthClass::Expanded,
] {
assert_eq!(class.gutter() % 4.0, 0.0, "{class:?} 的页边距不在 4px 栅格");
}
}
#[test]
fn page_layout_names_are_stable() {
assert_eq!(PageLayout::SingleColumn.name(), "single-column");
assert_eq!(PageLayout::SupportingPane.name(), "supporting-pane");
assert_eq!(PageLayout::ListDetail.name(), "list-detail");
assert!(!PageLayout::SingleColumn.is_two_pane());
assert!(PageLayout::ListDetail.is_two_pane());
}
+145
View File
@@ -0,0 +1,145 @@
//! 日志页:**单列**版面的第二个用例(两行页头 84 + 正文列 ≤424)。
//!
//! 与下载页的区别在页头:日志页的页头是**两行**(标题 + 一行运行状态),
//! 高度走 `LayoutMetrics::page_header_tall_height`(84),这是 12b §16.1 那栏在骨架期的落地。
//!
//! 内容区给「日志源」一张卡(文件行)+ 空态位(当前没有输出)。真正的等宽日志视图属于
//! 后续「日志页接事件流」的 mission,本页只负责版式。
use iced::widget::{column, row};
use iced::{Alignment, Element, Length, Padding};
use unlucky_design::color::Role;
use unlucky_design::component_map::Component;
use unlucky_design::icons::IconKind;
use unlucky_design::spacing::Space;
use unlucky_design::typography::TypeRole;
use crate::theme::Ui;
use super::frame::{self, Header};
use super::layout::FrameMetrics;
use super::{Action, Message};
/// 演示日志源(文件名 + 大小/时间)。
const SOURCES: [(&str, &str); 3] = [
("launcher.log", "128 KiB · live"),
("launch-2026-11-01T09-14-02.log", "412 KiB · 09:14"),
("diagnose-2026-10-31.yaml", "18 KiB · yesterday"),
];
/// 整页。
pub fn view<'a>(ui: &'a Ui, metrics: FrameMetrics) -> Element<'a, Message> {
let header = Header::new("Logs")
.subtitle("Launcher and game output · nothing is running right now")
.action(frame::text_action(ui, "Open folder", Action::OpenLogs))
.action(frame::text_action(
ui,
"Copy diagnostics",
Action::CopyDiagnostics,
))
.action(frame::text_action(ui, "Clear", Action::ClearLogs));
let body = frame::single_column(
metrics,
column![
source_card(ui),
frame::empty_state(
ui,
IconKind::Logs,
"No output yet",
"Logs stream here while the launcher or the game is running. Start an instance to see \
the first lines appear.",
vec![frame::text_action(ui, "Start an instance", Action::Play)],
),
]
.spacing(ui.space(Space::X4))
.width(Length::Fill)
.height(Length::Fill),
);
frame::page(ui, metrics, header, body)
}
/// 日志源列表(12b 的 `ListItem` 档:行文 `BodyLarge`、副文案 `BodySmall`)。
fn source_card<'a>(ui: &'a Ui) -> Element<'a, Message> {
let spec = Component::ListItem.spec();
let mut rows = column![].spacing(0.0).width(Length::Fill);
for (index, (name, meta)) in SOURCES.iter().enumerate() {
if index > 0 {
rows = rows.push(frame::divider(ui));
}
rows = rows.push(
row![
frame::glyph_box(
IconKind::Logs,
ui.color(Role::OnSurfaceVariant),
frame::ICON_PX
),
column![
frame::text_role(ui, spec.text, false, name, ui.color(Role::OnSurface)),
frame::text_role(
ui,
spec.support.unwrap_or(TypeRole::BodySmall),
false,
meta,
ui.color(Role::OnSurfaceVariant),
),
]
.spacing(0.0)
.width(Length::Fill),
]
.spacing(ui.space(Space::X3))
.width(Length::Fill)
.align_y(Alignment::Center)
.padding(Padding::from([ui.space(Space::X2), ui.space(Space::X2)])),
);
}
frame::card(ui, rows)
}
#[cfg(test)]
mod tests {
use super::super::layout::{frame_metrics, measure_cap_px, PaneKit};
use super::*;
use unlucky_design::color::SchemeFlavor;
use unlucky_design::typography::Platform;
use crate::theme::Families;
#[test]
fn logs_page_is_single_column_and_capped() {
for width in [700.0, 1180.0, 1600.0] {
let metrics = frame_metrics(PaneKit::Single, width);
assert!(metrics.secondary.is_none());
assert!(metrics.column <= measure_cap_px() + f32::EPSILON);
}
}
#[test]
fn header_is_two_lines_because_of_the_status_subtitle() {
let ui = Ui::with_platform(
SchemeFlavor::Dark,
Platform::Windows,
Families {
text: "Segoe UI",
display: "Segoe UI",
mono: "Consolas",
},
);
assert_eq!(ui.metrics().header_height(true), 84.0);
assert!(ui.metrics().header_height(true) > ui.metrics().header_height(false));
}
#[test]
fn every_source_row_has_a_name_and_meta() {
assert_eq!(SOURCES.len(), 3);
for (name, meta) in SOURCES {
assert!(name.contains("log") || name.contains("yaml"));
assert!(!meta.is_empty());
}
}
}
+293
View File
@@ -0,0 +1,293 @@
//! # 页面骨架 + 布局系统(P4 / M84 `PAGEFRAME`)
//!
//! 本模块**不依赖组件库**(`components/` 由 M74/M75 并行建):它交付的是**布局系统**,
//! 四个页面只是它的用例。控件全是同一份令牌下的占位实现,等组件家族合入后由另一条 mission 替换。
//!
//! ## 为什么要做这一层(历史判词)
//!
//! M68 的骨架内容区是**一张全宽卡**。视觉复审的结论(用户认可):
//!
//! > **不能把「全宽单卡」当页面模板** —— 正文单行 **712px**,那张卡里的 `BodySmall` 12px 文本
//! > **实测装了 137 字符**(口径见 [`layout`] §3),远超舒适行宽;
//! > 官方桌面口径是 **supporting-pane / list-detail**。
//!
//! 所以这里先立三条规矩,页面照着用:
//!
//! 1. **宽度档位**:官方响应式断点 **600 / 840**([`layout::WidthClass`]);
//! 2. **版面**:单列 / supporting-pane / list-detail,各自的成立条件与退化阈值写在 [`layout`];
//! 3. **行宽上限**:正文列硬上限 **424px(逐行实测 67 字符 ≤72)**([`layout::measure_cap_px`]),
//! 由 [`layout::content_column_width`] / [`layout::frame_metrics`] **无条件施加**,
//! 页面拿不到「更宽」的选项。
//!
//! ## 版面阈值表(`layout` 模块文档是全文,这里给速查)
//!
//! | 版面 | 用它的页面 | 成立条件 | 退化 |
//! |---|---|---|---|
//! | `SingleColumn` | 下载 / 日志(永远);实例 / 设置(窄窗) | 总是 | — |
//! | `SupportingPane` | 设置 | `w ≥ 600` 且 `w ≥ 720`(240 + 24 + 320 + 2×24 + 88) | 单列(分组按组堆叠) |
//! | `ListDetail` | 实例 | `w ≥ 840`(官方 expanded)且 `w ≥ 816` | 单列(列表 ↔ 详情整页切换) |
//!
//! ## 模块
//!
//! | 文件 | 职责 |
//! |---|---|
//! | [`layout`] | **布局系统**:档位 / 版面 / 列宽 / 阈值,纯函数 + 单测(无 iced 控件树) |
//! | [`frame`] | 页头 / 内容列 / 两列骨架 / 空态 / 卡片 / 分隔线(占位控件) |
//! | `instances` | 实例页(list-detail) |
//! | `settings` | 设置页(supporting-pane) |
//! | `download` / `logs` | 下载 / 日志页(单列,两行页头的差异) |
//! | [`harness`] | 截图 harness(`examples/pages_demo.rs` 的入口,**不是** `cli.rs` 的一部分) |
//!
//! ## 偏离登记(D30 要求逐条写清;视觉复审 r1 要求把末两条补进来)
//!
//! | # | 偏离 | 为什么 | 什么时候收口 |
//! |---|---|---|---|
//! | 1 | 页头按 M3 口径**铺满页面宽度**(贴页边距),不与正文列对齐 | AppBar 本来就该满宽;1180 下页头右缘 **1156** 与详情列右缘 **896** 有 **260px 分歧** | 真 top bar 落地时按 M3 定语(页头右侧是操作区,不跟随列宽) |
//! | 2 | 空态标题 `HeadlineSmall 24` 比页标题 `TitleLarge 22` 大 | 12b §15.2 明确「空态是唯一允许 Headline 级的场景」,与 §16.4 的页标题档有 spec 张力 | 有意保留,不修 |
//! | 3 | 单列页(下载 / 日志)在 1180 下只用掉 **40.6%** 内容宽(424/1044) | 行宽上限的必然结果;空态居中不显空 | 版本浏览真正接数据后重新决议 `PaneKit`(现在写「一串线性进度项」是按单列定的) |
//! | 4 | 空态图标字形 **40px**(12b 15.1 是 `[估]` 32~48) | 令牌 `IconSize` 只有 24 一档,`frame::glyph` 按尺寸重算 stroke | 组件库的 `EmptyState`(M77)落地时 |
//! | 5 | 窄窗「Back to instances」没有前导箭头 | 占位文本按钮没有图标位 | 真 top bar / M78 的导航件落地时 |
//! | 6 | 实例页「未选中详情」空态**没有出图** | 演示数据固定选中第 1 条;空态代码路径在(`detail_empty`) | D30 状态矩阵补齐时补一张,不必重跑全套 |
//!
//! ## 截图通道
//!
//! ```text
//! cargo run -p unlucky-app-iced --example pages_demo -- \
//! --screen=instances --width=1180 --height=760 \
//! --screenshot=out.png --screenshot-theme=light --software-renderer
//! ```
//!
//! `cli.rs` 还没有 `--screen` / `--width`(M75 在改它),所以参数解析放在 [`harness`] 里,
//! **本模块一行没碰 `cli.rs` / `app.rs`**。
pub mod frame;
pub mod harness;
pub mod layout;
mod download;
mod instances;
mod logs;
mod settings;
pub use layout::{FrameMetrics, PageLayout, PaneKit, WidthClass};
use iced::widget::{container, row, space};
use iced::{Element, Length};
use unlucky_design::elevation::MaterialLayer;
use crate::theme::Ui;
use frame::filled;
/// 页面消息。
///
/// **故意不用 `crate::app::Message`**(组件库契约 §2 的同一条理由):页面不绑 app 的状态层,
/// 接线时由 app 层把 [`Message::Action`] 映射成自己的消息。骨架期的动作只有「点了哪个按钮」,
/// 没有业务语义。
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Message {
/// 点了页面里的某个动作。
Action(Action),
}
/// 页面动作清单(骨架期够用就行;接线时按业务语义重命名/扩充)。
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Action {
/// 新建实例。
NewInstance,
/// 导入。
Import,
/// 返回实例列表(窄窗整页切换)。
Back,
/// 启动。
Play,
/// 更多。
More,
/// 复制实例。
Duplicate,
/// 打开实例目录。
OpenFolder,
/// 删除实例。
Delete,
/// 重新探测下载源。
RefreshSources,
/// 浏览版本列表。
BrowseVersions,
/// 导入整合包。
ImportModpack,
/// 检查下载源。
CheckSources,
/// 打开日志目录。
OpenLogs,
/// 复制诊断信息。
CopyDiagnostics,
/// 清空日志。
ClearLogs,
/// 重置当前设置分组。
ResetSection,
/// 保存设置。
SaveSettings,
/// 新增逐实例覆盖。
AddOverride,
}
/// 四个页面。
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Screen {
/// 实例(list-detail)。
Instances,
/// 下载(单列)。
Download,
/// 设置(supporting-pane)。
Settings,
/// 日志(单列,两行页头)。
Logs,
}
impl Screen {
/// 全部页面(截图矩阵按这个顺序铺)。
pub const ALL: [Screen; 4] = [
Screen::Instances,
Screen::Download,
Screen::Settings,
Screen::Logs,
];
/// 稳定 slug(命令行参数与文件名用)。
pub const fn slug(self) -> &'static str {
match self {
Screen::Instances => "instances",
Screen::Download => "download",
Screen::Settings => "settings",
Screen::Logs => "logs",
}
}
/// 反向解析(`--screen=instances`)。
pub fn from_slug(slug: &str) -> Option<Self> {
Self::ALL.into_iter().find(|screen| screen.slug() == slug)
}
/// 页面声明的**最宽**版面;实际用哪个由 [`layout::resolve`] 按窗口宽度决定。
pub const fn kit(self) -> PaneKit {
match self {
Screen::Instances => PaneKit::ListDetail,
Screen::Settings => PaneKit::Supporting,
Screen::Download | Screen::Logs => PaneKit::Single,
}
}
}
/// 单独渲染一页(截图 / 手看用,**不依赖业务状态机**)。
///
/// 窗口宽度是**含外壳的窗口宽度**(档位判据就是它):返回的树里,左侧 88px 与顶部 48px 是
/// **留白带**,代表真实外壳的导航轨与标题栏——`shell::nav` 是私有模块,本 mission 不复制它的实现
/// (真正的 `NavigationDrawer` 由 M78 提供,接线时把这两条带换成外壳即可)。
pub fn demo<'a>(ui: &'a Ui, screen: Screen, window_width: f32) -> Element<'a, Message> {
let metrics = layout::frame_metrics(screen.kit(), window_width);
let page = match screen {
Screen::Instances => instances::view(ui, metrics, Some(instances::SELECTED)),
Screen::Download => download::view(ui, metrics),
Screen::Settings => settings::view(ui, metrics),
Screen::Logs => logs::view(ui, metrics),
};
let shell = row![rail_placeholder(ui), page]
.width(Length::Fill)
.height(Length::Fill);
let titled = iced::widget::column![titlebar_placeholder(ui), shell]
.width(Length::Fill)
.height(Length::Fill);
container(titled)
.width(Length::Fill)
.height(Length::Fill)
.style(move |_theme| filled(ui.layer(MaterialLayer::Canvas)))
.into()
}
/// 标题栏留白带(高 = `LayoutMetrics::titlebar_height`)。
fn titlebar_placeholder<'a>(ui: &'a Ui) -> Element<'a, Message> {
container(space::horizontal())
.width(Length::Fill)
.height(ui.metrics().titlebar_height)
.style(move |_theme| filled(ui.layer(MaterialLayer::Canvas)))
.into()
}
/// 导航轨留白带(宽 = `LayoutMetrics::nav_rail_width`)。
fn rail_placeholder<'a>(ui: &'a Ui) -> Element<'a, Message> {
container(space::horizontal())
.width(Length::Fixed(layout::rail_width()))
.height(Length::Fill)
.style(move |_theme| filled(ui.layer(MaterialLayer::Canvas)))
.into()
}
#[cfg(test)]
mod tests {
use super::*;
use unlucky_design::color::SchemeFlavor;
use unlucky_design::typography::Platform;
use crate::theme::Families;
const FAMILIES: Families = Families {
text: "Segoe UI",
display: "Segoe UI",
mono: "Consolas",
};
fn ui() -> Ui {
Ui::with_platform(SchemeFlavor::Light, Platform::Windows, FAMILIES)
}
#[test]
fn four_screens_answer_the_mission_list() {
assert_eq!(Screen::ALL.len(), 4);
let slugs: Vec<&str> = Screen::ALL.iter().map(|screen| screen.slug()).collect();
assert_eq!(slugs, vec!["instances", "download", "settings", "logs"]);
for screen in Screen::ALL {
assert_eq!(Screen::from_slug(screen.slug()), Some(screen));
}
assert_eq!(Screen::from_slug("gallery"), None);
}
#[test]
fn screens_declare_the_layout_their_page_needs() {
assert_eq!(Screen::Instances.kit(), PaneKit::ListDetail);
assert_eq!(Screen::Settings.kit(), PaneKit::Supporting);
assert_eq!(Screen::Download.kit(), PaneKit::Single);
assert_eq!(Screen::Logs.kit(), PaneKit::Single);
}
/// 四个页面 × 三个对照宽度都能构图(不 panic、不依赖运行时)。
#[test]
fn every_screen_builds_at_the_three_comparison_widths() {
let ui = ui();
for width in [1180.0, 900.0, 700.0] {
for screen in Screen::ALL {
let _element: Element<'_, Message> = demo(&ui, screen, width);
}
}
}
/// 版面对照:同一页在 1180 / 900 / 700 三个宽度上必须**真的换版面**。
#[test]
fn the_width_comparison_really_changes_the_layout() {
let wide = layout::frame_metrics(Screen::Instances.kit(), 1180.0);
let mid = layout::frame_metrics(Screen::Instances.kit(), 900.0);
let narrow = layout::frame_metrics(Screen::Instances.kit(), 700.0);
assert_eq!(wide.layout, PageLayout::ListDetail);
assert_eq!(mid.layout, PageLayout::ListDetail);
assert_eq!(narrow.layout, PageLayout::SingleColumn);
// 900 与 1180 同为 list-detail,但详情列必须真的变窄(不是硬编码 1180 的宽度)
assert!(mid.column < wide.column, "900 的详情列应窄于 1180");
assert!(narrow.secondary.is_none(), "700 不许再有列表列");
}
}
+313
View File
@@ -0,0 +1,313 @@
//! 设置页:**supporting-pane** 的用例(左侧分组导航 240 + 右侧表单列 ≤424)。
//!
//! ## 为什么是 supporting-pane 而不是 list-detail
//!
//! 分组导航是**辅助**(它不承载数据,只切换右侧表单的内容),右侧表单才是阅读对象;
//! 官方把这种「主列 + 窄侧列」叫 supporting pane。list-detail 的前提是左列本身是一份可浏览的数据集
//! (实例列表就是),设置页不满足。
//!
//! ## 侧列与 rail 的关系(D33)
//!
//! 宽度 240 直接取 `LayoutMetrics::nav_drawer_width`(D33:同一容器 rail 88 ⇄ drawer 240 的展开态),
//! 但**不复制 rail 的实现**:这里没有 hover 药丸、没有二级标签、没有 `Message::Select`。
//! 将来真正的 `NavigationDrawer` 由 M78 提供,本页换掉 `group_nav()` 即可。
//!
//! ## 退化(窗口 <720 或 compact 档)
//!
//! 侧列消失,分组导航退化成「按组堆叠」:同一列里每组一张卡 + 一段 12pt 组名。整页仍居中、正文列仍 ≤424。
use iced::widget::{column, container, row};
use iced::{Alignment, Background, Border, Element, Length, Padding};
use unlucky_design::color::Role;
use unlucky_design::component_map::Component;
use unlucky_design::elevation::MaterialLayer;
use unlucky_design::icons::IconKind;
use unlucky_design::shape::ShapeScale;
use unlucky_design::spacing::Space;
use unlucky_design::typography::TypeRole;
use crate::theme::Ui;
use super::frame::{self, Header};
use super::layout::{FrameMetrics, PageLayout};
use super::{Action, Message};
/// 一个设置分组:名字、图标、以及若干「标签 = 值」行。
struct Group {
name: &'static str,
icon: IconKind,
rows: &'static [(&'static str, &'static str)],
}
/// 演示分组(骨架期:值只是文本,真控件等 M76 的输入家族)。
const GROUPS: [Group; 4] = [
Group {
name: "General",
icon: IconKind::Settings,
rows: &[
("Language", "English (System)"),
("Theme", "Follow system"),
("Startup page", "Instances"),
("Close button", "Exit to tray"),
],
},
Group {
name: "Appearance",
icon: IconKind::Sidebar,
rows: &[
("Density", "Comfortable"),
("Corner style", "Rounded"),
("Motion", "Full"),
("Text scale", "100%"),
],
},
Group {
// 描边字形:令牌里 `Play` 是填充字形,和同屏其它描边图标语汇打架(视觉复审 P2-3);
// Java 分组讲的是运行时安装位置,用目录图标语义也对得上。
name: "Java",
icon: IconKind::Folder,
rows: &[
("Runtime", "Temurin 21.0.4"),
("Default memory", "4 GiB"),
("JVM arguments", "Automatic"),
],
},
Group {
name: "Downloads",
icon: IconKind::Download,
rows: &[
("Preferred source", "Automatic (region)"),
("Concurrency", "8 threads"),
("Verify checksums", "On"),
],
},
];
/// 演示选中的分组。
const SELECTED: usize = 0;
/// 空态位高度:给图标块 96 + 标题 + 副文案 + 按钮留够空间(`[估]`,按 12b §15 的元素最小高度累加)。
const EMPTY_SLOT_HEIGHT: f32 = 320.0;
/// 整页。
pub fn view<'a>(ui: &'a Ui, metrics: FrameMetrics) -> Element<'a, Message> {
let header = Header::new("Settings")
.subtitle("Launcher behaviour, Java runtimes and download sources")
.action(frame::text_action(
ui,
"Reset section",
Action::ResetSection,
))
.action(frame::text_action(ui, "Save", Action::SaveSettings));
let body = match metrics.layout {
PageLayout::SupportingPane => frame::panes(
metrics,
group_nav(ui),
frame::left_aligned(metrics.column, form(ui, Some(SELECTED))),
),
_ => frame::single_column(metrics, form(ui, None)),
};
frame::page(ui, metrics, header, body)
}
// --------------------------------------------------------------- 侧列
fn group_nav<'a>(ui: &'a Ui) -> Element<'a, Message> {
let items = column(
GROUPS
.iter()
.enumerate()
.map(|(index, group)| group_item(ui, group, index == SELECTED))
.collect::<Vec<_>>(),
)
.spacing(ui.space(Space::X1))
.padding(Padding::from([ui.space(Space::X2), ui.space(Space::X2)]))
.width(Length::Fill);
container(items)
.width(Length::Fill)
.height(Length::Fill)
.style(move |_theme| container::Style {
background: Some(Background::Color(ui.layer(MaterialLayer::Card))),
border: Border {
radius: ui.radius_dp(ShapeScale::Large),
..Border::default()
},
..container::Style::default()
})
.into()
}
fn group_item<'a>(ui: &'a Ui, group: &'a Group, selected: bool) -> Element<'a, Message> {
let spec = Component::NavigationItem.spec();
let height = ui.metrics().row_height;
let content_color = if selected {
ui.color(Role::OnSecondaryContainer)
} else {
ui.color(Role::OnSurfaceVariant)
};
let inner = row![
frame::glyph_box(group.icon, content_color, frame::ICON_PX),
frame::text_role(ui, spec.text, false, group.name, content_color),
]
.spacing(ui.space(Space::X3))
.width(Length::Fill)
.align_y(Alignment::Center);
container(inner)
.width(Length::Fill)
.height(height)
.padding(Padding::from([0.0, ui.space(Space::X3)]))
.align_y(Alignment::Center)
.style(move |_theme| container::Style {
background: selected.then(|| Background::Color(ui.color(Role::SecondaryContainer))),
border: Border {
radius: ui.radius(ShapeScale::Full, height),
..Border::default()
},
..container::Style::default()
})
.into()
}
// --------------------------------------------------------------- 表单列
/// 表单列(正文列宽 ≤424)。`only = Some(i)` 只渲染一组(supporting-pane),
/// `None` 渲染全部(窄窗:分组导航退化成按组堆叠)。
fn form<'a>(ui: &'a Ui, only: Option<usize>) -> Element<'a, Message> {
let mut stack = column![].spacing(ui.space(Space::X6)).width(Length::Fill);
for (index, group) in GROUPS.iter().enumerate() {
if only.is_some_and(|selected| selected != index) {
continue;
}
stack = stack.push(
column![
frame::text_role(
ui,
TypeRole::LabelMedium,
false,
group.name,
ui.color(Role::OnSurfaceVariant),
),
group_card(ui, group),
]
.spacing(ui.space(Space::X2))
.width(Length::Fill),
);
}
stack = stack.push(overrides_empty(ui));
stack.into()
}
/// 一组设置 = 一张卡,行间 1px 分隔线(M3 设置列表的常规做法)。
fn group_card<'a>(ui: &'a Ui, group: &'a Group) -> Element<'a, Message> {
let mut rows = column![].spacing(0.0).width(Length::Fill);
for (index, (label, value)) in group.rows.iter().enumerate() {
if index > 0 {
rows = rows.push(frame::divider(ui));
}
rows = rows.push(setting_row(ui, label, value));
}
frame::card(ui, rows)
}
fn setting_row<'a>(ui: &'a Ui, label: &'a str, value: &'a str) -> Element<'a, Message> {
let height = ui.metrics().control_height + Space::X2.dp();
row![
container(frame::text_role(
ui,
TypeRole::BodyMedium,
false,
label,
ui.color(Role::OnSurface),
))
.width(Length::Fill)
.align_y(Alignment::Center),
frame::text_role(
ui,
TypeRole::BodySmall,
false,
value,
ui.color(Role::OnSurfaceVariant),
),
]
.height(height)
.width(Length::Fill)
.align_y(Alignment::Center)
.into()
}
/// 空态位:没有逐实例覆盖时的样子(12b §15 的口径)。
fn overrides_empty<'a>(ui: &'a Ui) -> Element<'a, Message> {
container(frame::empty_state(
ui,
IconKind::Instances,
"No per-instance overrides",
"Overrides let one instance keep its own Java runtime, memory or JVM flags while the rest \
of the launcher keeps the defaults above.",
vec![frame::text_action(ui, "Add override", Action::AddOverride)],
))
.width(Length::Fill)
.height(Length::Fixed(EMPTY_SLOT_HEIGHT))
.into()
}
#[cfg(test)]
mod tests {
use super::super::layout::{self, frame_metrics, measure_cap_px, PaneKit, SIDE_NAV_WIDTH};
use super::*;
#[test]
fn side_nav_reuses_the_drawer_width() {
// D33:分组导航走 drawer 240(同一容器 rail 88 ⇄ drawer 240),不是 rail 的 88
assert_eq!(SIDE_NAV_WIDTH, 240.0);
let metrics = frame_metrics(PaneKit::Supporting, 1180.0);
assert_eq!(metrics.secondary, Some(240.0));
assert_ne!(metrics.secondary, Some(layout::rail_width()));
}
#[test]
fn form_column_never_exceeds_the_measure_cap() {
for width in [720.0, 840.0, 1180.0, 1920.0] {
let metrics = frame_metrics(PaneKit::Supporting, width);
assert!(
metrics.column <= measure_cap_px() + f32::EPSILON,
"{width} 宽下表单列 {:?} 超过行宽上限",
metrics.column
);
assert!(metrics.column >= layout::measure_floor_px());
}
}
#[test]
fn every_group_has_a_name_icon_and_rows() {
assert!(GROUPS.len() >= 3);
assert!(SELECTED < GROUPS.len());
for group in &GROUPS {
assert!(!group.name.is_empty());
assert!(!group.icon.icon().path.is_empty(), "{} 缺图标", group.name);
assert!(!group.rows.is_empty(), "{} 没有设置行", group.name);
for (label, value) in group.rows {
assert!(!label.is_empty() && !value.is_empty());
}
}
}
#[test]
fn narrow_window_stacks_the_groups_instead_of_the_side_nav() {
let narrow = frame_metrics(PaneKit::Supporting, 700.0);
assert_eq!(narrow.layout, PageLayout::SingleColumn);
assert!(narrow.secondary.is_none(), "窄窗不许留侧列");
}
}
+53 -5
View File
@@ -202,20 +202,26 @@ fn size_literal(argument: &str) -> Option<String> {
Some(number)
}
/// 尺寸字面量守门:`src/shell` + `src/pages` 都扫。
///
/// P2-3(M84 复审):原先只扫 `shell`,新增的 `pages/` **没被覆盖**(页面里写 `.width(24)` 没人发现)。
/// `pages/layout.rs` 允许有布局常量(它是布局系统的定义点),控件调用点上不许再出现字面量。
#[test]
fn shell_takes_every_size_from_tokens() {
fn layout_sources_take_every_size_from_tokens() {
let mut files = Vec::new();
rust_files(&manifest_dir().join("src").join("shell"), &mut files);
for dir in ["shell", "pages"] {
rust_files(&manifest_dir().join("src").join(dir), &mut files);
}
assert!(
files.len() >= 2,
"shell 模块至少有两个文件(mod.rs + nav.rs),实际扫到 {}",
files.len() >= 10,
"shell + pages 至少 10 个文件(shell 2 + pages 8+),实际扫到 {}:扫描逻辑坏了,这条门禁等于没跑",
files.len()
);
let mut offenders = Vec::new();
for path in files {
let text = std::fs::read_to_string(&path).expect("读 shell 源文件");
let text = std::fs::read_to_string(&path).expect("读布局源文件");
for (index, line) in text.lines().enumerate() {
// 文档注释是散文不是代码:里面写 `.width(24)` 是在解释坑,不该判违规
@@ -247,6 +253,48 @@ fn shell_takes_every_size_from_tokens() {
);
}
/// 图标语汇守门(视觉复审 P2-3):`pages/` 里只用**描边**字形。
///
/// 令牌层把 `Play` / `Stop` / `More` 定义成填充字形,其余是描边;页面骨架里混用会让同屏线宽
/// 语汇打架。将来要支持「主操作允许实心图标」,应由 `components/` 统一定义并只留一处。
#[test]
fn pages_use_only_stroked_icons() {
const FILLED: [&str; 3] = ["IconKind::Play", "IconKind::Stop", "IconKind::More"];
let mut files = Vec::new();
rust_files(&manifest_dir().join("src").join("pages"), &mut files);
assert!(
files.len() >= 8,
"pages 至少 8 个文件,实扫 {}",
files.len()
);
let mut offenders = Vec::new();
for path in files {
let text = std::fs::read_to_string(&path).expect("读 pages 源文件");
for (index, line) in text.lines().enumerate() {
if line.trim_start().starts_with("//") {
continue;
}
for kind in FILLED {
if line.contains(kind) {
offenders.push(format!("{}:{} {line:?}", path.display(), index + 1));
}
}
}
}
assert!(
offenders.is_empty(),
"pages 里出现了填充字形图标(令牌层 Play/Stop/More),与同屏描边语汇冲突:
{}",
offenders.join(
"
"
)
);
}
/// 依赖行里出现 `slint` 就算违规(注释行放过:说明并存期要提到 slint)。
fn manifest_has_slint_dependency(manifest: &str) -> Vec<String> {
manifest