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:
1 parent
352dd7937e
commit
2c442585b1
15 files changed
+2979
No files matched your search
@@ -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());
|
||||
}
|
||||
Reference in new issue
Block a user