WIP(app-iced): M75 组件基座 checkpoint —— 组件骨架/按钮五式/分隔线/gallery harness

已落地:
- components/{mod,interaction,layout,divider,gate}.rs + buttons/{mod,geometry,assemble,tests}.rs
- gallery/{mod,buttons,probe,scan}.rs + 四个家族桩(selection/inputs/feedback/navigation)
- cli.rs: --screen=shell|gallery + capture_flavor(截图默认 Light,删掉等系统主题的闸门)
- app.rs: gallery 路由 + DemoMessage 接线(400 行)

待办(下一个 commit):编译/测试全绿、浅深矩阵出图、1:1 并排与数值表。
This commit is contained in:
AskaEth committed 2026-11-02 04:50:44 +08:00
1 parent 22ddc74784
commit 4774e7b788
23 files changed
+3136 -133

No files matched your search

@@ -0,0 +1,217 @@
//! 渲染层几何断言:**对着真截图量像素**(M68 的教训:纯函数返回对,图可能是错的)。
//!
//! `buttons::tests` 只能证明「几何结构体里的数字是 40/20/24」——M68 的药丸正是这样:单测全绿、
//! 图上是 88×32(容器被 `center_x(Length::Fill)` 覆盖)。唯一能抓住这类错误的是**真渲染的像素**。
//! CI 没有桌面,所以这里不自己开窗:先用 `--screen=gallery --screenshot` 出真窗截图,本测试读
//! 那张 PNG 量几何、与令牌逐项比对。**没给图就跳过并大声说出来**(不静默通过)。
//!
//! 复现命令(在 worktree 根目录):
//!
//! ```text
//! cargo build -p unlucky-app-iced --bin unlucky-launcher-iced
//! ./target/debug/unlucky-launcher-iced --software-renderer --screen=gallery //! --screenshot target/m75/gallery-light.png --screenshot-theme=light
//! ULL_GALLERY_SHOT=target/m75/gallery-light.png cargo test -p unlucky-app-iced gallery::probe
//! ```
//!
//! 量什么(每条对一个已发生过的错误):实心块高 **40**(12b 1.1)· 药丸顶行宽 ≈9 ⇒ 圆角 full=20
//! (12b 1.2,8 圆角会接近通栏)· IconButton **40×40**(12b 2.1)· 字形墨迹 **16~26**
//! (Q9:24 盒被填满会到 40)· 分隔线 **1px**(22.1)· 禁用容器 **on-surface@12%** 存在(12b 1.8)。
use unlucky_design::color::{Role, SchemeFlavor};
use unlucky_design::typography::Platform;
use crate::theme::{Families, Ui};
use super::scan::{matches, nearer, rgb, run_at, Shot};
const FAMILIES: Families = Families {
text: "Segoe UI",
display: "Segoe UI",
mono: "Consolas",
};
fn ui(flavor: SchemeFlavor) -> Ui {
Ui::with_platform(flavor, Platform::Windows, FAMILIES)
}
/// 一条测试覆盖全部几何断言(每张图各跑一次)。
#[test]
fn gallery_geometry_matches_the_tokens() {
let Some(path) = shot_path() else {
eprintln!(
"[probe] SKIPPED:未设置 ULL_GALLERY_SHOT。这是**渲染层**几何断言,\
需要先出一张真窗截图(命令见本文件模块文档)。"
);
return;
};
let shot = Shot::load(&path).expect("读 gallery 截图");
assert_eq!(
(shot.width, shot.height),
(1180, 760),
"验收图必须是 D30 的 1180×760 @100% DPI"
);
// 用左上角背景色判定深浅(页面 padding 区就是 `Surface`)
let corner = shot.at(2, 2);
let flavor = if matches(corner, rgb(ui(SchemeFlavor::Light).color(Role::Surface))) {
SchemeFlavor::Light
} else {
SchemeFlavor::Dark
};
let ui = ui(flavor);
// ---- 12b 1.1 / 1.2:实心按钮 40 高、药丸圆角 20 ------------------------------
let primary = shot.bbox_of(rgb(ui.color(Role::Primary)));
assert!(
primary.len() >= 2,
"至少该找到 Filled 按钮与 Filled IconButton 两块实心(实际 {})",
primary.len()
);
let mut label_button = None;
for bbox in &primary {
assert!(
(39..=41).contains(&bbox.height()),
"实心块高 {}(参考 40,12b 1.1)@ {:?}",
bbox.height(),
(bbox.left, bbox.top)
);
if bbox.width() > 60 {
label_button = Some(*bbox);
}
if (39..=41).contains(&bbox.width()) {
// 12b 2.1:IconButton 容器 40×40
assert!(
(39..=41).contains(&bbox.width()),
"IconButton 应为 40×40,实测 {}×{}",
bbox.width(),
bbox.height()
);
}
}
let label_button = label_button.expect("没找到文字按钮的实心块");
assert!(
label_button.width() >= 58,
"12b 1.7:最小宽 58,实测 {}",
label_button.width()
);
// 含抗锯齿的真实外框:中心列上「更接近填充色而不是底色」的连续行
let fill = rgb(ui.color(Role::Primary));
let surface = rgb(ui.color(Role::Surface));
let center = (label_button.left + label_button.right) / 2;
let mut true_top = label_button.top;
while true_top > 0 && nearer(shot.at(center, true_top - 1), fill, surface) {
true_top -= 1;
}
let mut true_bottom = label_button.bottom;
while true_bottom + 1 < shot.height && nearer(shot.at(center, true_bottom + 1), fill, surface) {
true_bottom += 1;
}
let true_height = true_bottom - true_top + 1;
// 圆角档位:**量顶部那一行的宽度**。药丸(r = 高/2)顶部只有几像素宽;
// 若半径是 8(不是 full),顶行会是「几乎通栏」。这条比「数圆角行数」抗锯齿得多。
let top_run = run_at(
&shot,
label_button.top,
label_button.left,
label_button.right,
rgb(ui.color(Role::Primary)),
);
assert!(
(39..=41).contains(&true_height),
"可见高应 40(12b 1.1),实测 {true_height}"
);
assert!(
(4..=20).contains(&top_run),
"药丸圆角应 = 20(12b 1.2,顶部一行的填充宽约 sqrt(2*20*1)*2 ≈ 9),实测 {top_run}px; 若接近整宽说明半径是 8 而不是 full"
);
// ---- Q9:IconButton 的字形不许撑满 40 盒 --- // ---- Q9:IconButton 的字形不许撑满 40 盒 -----------------------------------
let tonal = rgb(ui.color(Role::SecondaryContainer));
let tonal_boxes = shot.bbox_of(tonal);
let icon_states = tonal_boxes
.iter()
.filter(|bbox| (39..=41).contains(&bbox.width()) && (39..=41).contains(&bbox.height()))
.count();
assert!(
icon_states >= 2,
"tonal IconButton 至少 2 个(实际 {icon_states})"
);
let gear = rgb(ui.color(Role::OnSecondaryContainer));
let mut measured_glyph = false;
let mut glyph_width = 0;
for bbox in tonal_boxes
.iter()
.filter(|bbox| (39..=41).contains(&bbox.width()))
{
if let Some(ink) = shot.ink_inside(bbox, gear) {
let width = ink.width();
if width > glyph_width {
glyph_width = width;
}
assert!(
(16..=26).contains(&width),
"图标盒 24 的字形墨迹应 16~26 宽,实测 {width}(Q9 回归时会接近 40)"
);
measured_glyph = true;
}
}
assert!(measured_glyph, "没量到 IconButton 的字形");
// ---- 22.1:分隔线 1px -----------------------------------------------------
let outline = rgb(ui.color(Role::OutlineVariant));
let mut runs = 0;
for y in 0..shot.height {
let mut longest = 0;
let mut current = 0;
for x in 0..shot.width {
if matches(shot.at(x, y), outline) {
current += 1;
longest = longest.max(current);
} else {
current = 0;
}
}
if longest > 300 {
runs += 1;
}
}
// 分隔线是 1px 高,所以「长横线」的行数 = 屏上分隔线条数(每条约 1 行)
assert!(runs >= 2, "至少该有 2 条分隔线(实际 {runs} 行)");
assert!(
runs <= 4,
"分隔线最多 2 条 × 1px,实测 {runs} 行(22.1:大于这个数说明线不是 1px)"
);
// ---- 12b 1.8:禁用格必须与静息格不同 ------------------------------------
let disabled_container =
crate::app::components::interaction::disabled_colors(&ui, Role::Surface).0;
assert!(
shot.bbox_of(rgb(disabled_container))
.iter()
.any(|b| b.width() > 60),
"没找到禁用态实心按钮(12b 1.8:容器 = on-surface@12%)"
);
eprintln!(
"[probe] OK flavor={:?}:
Filled 药丸 {}×{}(参考 高 40 / 宽 ≥58)
顶行填充宽 {}(full 圆角应为个位数,直角/8 圆角会接近整宽)
实心块 {} 个(含 40×40 IconButton)
分隔线长行 {} 行(每条 1px)
禁用容器 (on-surface@12%) 命中
字形墨迹 {}px(24 盒应 16~26)",
flavor,
label_button.width(),
true_height,
top_run,
primary.len(),
runs,
glyph_width
);
}