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

+280
View File
@@ -0,0 +1,280 @@
//! `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);
}
/// 行宽上限:M68 的事故(1180 宽正文 712px ≈ 118 字符)不许再出现。
#[test]
fn line_length_is_capped_at_72_characters() {
let char_px = TypeRole::BodyMedium.style().size_px * CHAR_ADVANCE_EM;
assert_eq!(measure_cap_px(), 504.0, "14 × 0.5 × 72");
assert_eq!(measure_floor_px(), 315.0, "14 × 0.5 × 45");
for width in [600.0, 840.0, 900.0, 1180.0, 1440.0, 2560.0] {
let column = content_column_width(width);
let chars = column / char_px;
assert!(
chars <= MAX_MEASURE_CHARS + 0.001,
"{width} 宽窗口的正文行宽 {chars:.1} 字符,超过 {MAX_MEASURE_CHARS} 上限"
);
assert!(column <= measure_cap_px() + f32::EPSILON);
}
// 1180 —— M68 就是在这条上翻的车:当年 712px(118 字符),现在必须是 504(72 字符)
let wide = content_column_width(1180.0);
assert!(
wide <= 72.0 * char_px,
"1180 宽下正文列 {wide}px 超过 72 字符换算值"
);
assert!(wide < 712.0, "不许回到 M68 的全宽单卡(712px)");
}
#[test]
fn line_length_tracks_width_until_it_hits_the_cap() {
// 窄窗:正文列跟着可用宽度走,但永远不低于「下限以下就不该再压缩」
let narrow = content_column_width(600.0);
assert!(narrow < measure_cap_px(), "600 宽时正文列应窄于上限");
assert_eq!(narrow, available_width(600.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(), "表单列吃满行宽上限");
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() {
assert_eq!(meta_columns(measure_cap_px()), 3, "504 = 3×160 + 2×12");
assert_eq!(meta_columns(340.0), 2);
assert_eq!(meta_columns(300.0), 1);
// 与三张对照图的详情列宽对应:1180 → 3 列,900 → 2 列
let wide = frame_metrics(PaneKit::ListDetail, 1180.0);
let mid = frame_metrics(PaneKit::ListDetail, 900.0);
assert_eq!(wide.meta_columns, 3);
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());
}