feat(app-iced): M84 页面骨架与布局系统(断点 / 行宽上限 / 三种版面)

视觉复审在 M68 的判词是不能把「全宽单卡」当页面模板(1180 宽下正文单行 712px ≈ 118 字符)。
本 mission 把它落成一套**布局系统**,四个页面只是它的用例:

- `pages/layout.rs`:官方 600 / 840 两个档位 → 三种版面(单列 / supporting-pane / list-detail),
  阈值由列宽和算出(设置 240+24+320 → 720;实例 336+24+320 → 816 ≤ 840),行宽硬上限
  14 × 0.5em × 72 = **504px**,全部是纯函数,`pages/layout/tests.rs` 用绝对值和边界表钉死。
- `pages/frame.rs`:页头(56 / 两行 84)+ 内容列(≤504)+ 两列骨架 + 空态位;
  `pages/frame/pieces.rs` 是占位控件(卡片 / 分隔线 / 文本按钮 / 图标),令牌驱动、无完整状态机,
  待 M75 的 components 家族合入后替换。
- 四页:实例(list-detail,列表列 336 独立滚动,窄窗退化成整页切换)、
  设置(supporting-pane,240 分组导航 + ≤504 表单列)、下载 / 日志(单列,两行页头)。
- `pages/harness.rs` + `examples/pages_demo.rs`:自带 `--screen/--width/--screenshot-theme`,
  不碰 `cli.rs` / `app.rs`(M75 在改)。

偏离与说明:
- 布局用「页边距 16/24」而非 16/24/32,官方 expanded 也是 24(少一条偏离登记);
- 实例空详情态一屏会有 5 档字号(22/16/24/12 + 14 的操作按钮),未截图的状态,已知并登记;
- `lib.rs` 只加一行 `pub mod pages;`。
This commit is contained in:
AskaEth committed 2026-11-01 23:27:28 +08:00
1 parent 352dd7937e
commit 2c442585b1
15 files changed
+2979

No files matched your search

+106
View File
@@ -0,0 +1,106 @@
//! 下载页:**单列**版面的用例(列宽 ≤504,水平居中)。
//!
//! 这一页永远单列,理由不是「简单」,而是官方对「一次只做一件事」的入口(下载队列)的口径:
//! 内容是一串线性进度项,分栏没有第二个阅读对象。窗口变宽时**正文列不变宽**(撞行宽上限),
//! 多出来的宽度留白——这就是 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 的全宽卡就是没有这段留白)"
);
}
}