feat(iced): 反馈类家族四件(Progress / Snackbar / Dialog / EmptyState)

D39 五家族并行的反馈类线(M78 / COMPFEED)。全部对消息类型泛型、不 import app::Message,
数值来自令牌或登记为 G17(令牌层没有反馈类的 ComponentMetrics 与骨架位)。

- Progress:linear(确定态 FillPortion 占比 + 官方 1750ms 双段不定态 keyframes)与
  circular(40/4 环,弧长 = 数值×360°,不定态走官方 6000ms 弧长+旋转 keyframes)。
  环形用几何 SVG(canvas feature 没开),组件不计时,相位由调用方给。
- Snackbar:48/68 两种高、上限宽 600(A1 ContainerMaxWidth)、12 外边距、贴顶/贴底、
  action(InversePrimary)+ 关闭键(InverseOnSurface);**组件不计时**,超时归调用方。
  iced 的 Length 没有 min-width ⇒ 344 的最小宽由宿主保证(登记 G17-d,与按钮 G15 同墙)。
- Dialog:scrim(黑@0.32 预合成实色,D27)+ R28 + SurfaceContainerHigh + pad 24 +
  标题/正文/动作三区 + 无动作/单动作/双动作;官方 280..560 区间内取 480。
- EmptyState:**官方没有这个组件**,96 图标块/32 图标/HeadlineSmall 全部标 [估]。
- action.rs:Dialog/Snackbar 共用的文字/图标动作(内容角色作参数,状态层仍走 interaction)。
- gallery/feedback.rs:状态矩阵;家族内再切一段 ULL_FEEDBACK_PART(all|progress|snackbar|
  dialog|empty),开关本身住在 feedback/gallery_parts.rs,不动 gallery/mod.rs。
- feedback/probe.rs:对着真截图量像素(渲染层断言),四类几何各逐项钉住。
- feedback/tests.rs:几何/节奏/角色/scrim 的纯函数断言。

gates: fmt --all --check · clippy -p unlucky-app-iced --all-targets -D warnings ·
test -p unlucky-app-iced(144 通过 / 0 失败)
This commit is contained in:
AskaEth committed 2026-11-02 15:56:42 +08:00
1 parent 27f0d2a551
commit 36f58d7904
14 files changed
+2900 -39

No files matched your search

@@ -0,0 +1,369 @@
//! 反馈家族的**渲染层**几何断言:对着真截图量像素(M68 的教训:纯函数对、图可能是错的)。
//!
//! 为什么住在 `feedback/` 而不是 `gallery/`:`gallery/feedback.rs` 只负责把矩阵摆进 harness,
//! 断言加上去会把它顶到 400 行红线(与 `selection/probe.rs` 同一处置)。像素工具在同级
//! [`super::pixel`](`cfg(test)`)。
//!
//! ## 量什么(每条对一个具体错误,打红它的是「变异」那一列)
//!
//! | 类 | 判据 | 打红它的变异 |
//! |---|---|---|
//! | 线性厚度 / 圆角 | 轨道带**恰好 4 行**;条的四角**透出底色** | 厚度 4→6 / `Full`→`None` |
//! | Snackbar 宽上下限 | 宿主 800→**600**、344→**344**、300→**300**(无下限 G17-d) | 上限 600→560 |
//! | Snackbar 高 | 单行带 **48** / 双行带 **68** | 48→40 |
//! | Dialog 宽 / 内距 | 最宽行 **480**(在 280..560 内)· 标题墨迹左缩 **≈24** | 480→560 / pad 24→16 |
//! | 环形 / 空态块 / scrim | 环 40 且四角无墨 · 块 **96×96** · 遮罩带颜色 = 预合成实色 | 40→48 / 96→88 |
//!
//! ## 复现(worktree 根目录;四个部分各一张图,浅/深各一轮)
//!
//! ```text
//! for part in progress snackbar dialog empty; do
//! ULL_GALLERY_SECTION=feedback ULL_FEEDBACK_PART=$part \
//! ./target/debug/unlucky-launcher-iced --software-renderer --screen=gallery \
//! --screenshot target/m78/$part-light.png --screenshot-theme=light
//! ULL_FEEDBACK_PART=$part ULL_GALLERY_SHOT=target/m78/$part-light.png \
//! cargo test -p unlucky-app-iced feedback::probe
//! done
//! ```
use unlucky_design::color::{Role, SchemeFlavor};
use unlucky_design::elevation::MaterialLayer;
use unlucky_design::typography::Platform;
use super::dialog::scrim;
use super::pixel::{
band_width, bands, blobs, dims, ink_inside, matches, near, rgb, row_ink, runs, shot_path, BBox,
Shot,
};
use crate::theme::{Families, Ui};
const FAMILIES: Families = Families {
text: "Segoe UI",
display: "Segoe UI",
mono: "Consolas",
};
fn ui(flavor: SchemeFlavor) -> Ui {
Ui::with_platform(flavor, Platform::Windows, FAMILIES)
}
/// 一条测试覆盖当前截图对应的那一段(段由 `ULL_FEEDBACK_PART` 指定,默认 `all`)。
#[test]
fn feedback_geometry_matches_the_reference() {
let Some(path) = shot_path() else {
eprintln!(
"[feedback-probe] SKIPPED:未设置 ULL_GALLERY_SHOT。这是**渲染层**几何断言,\
需要先出图(命令见本文件模块文档,注意 ULL_GALLERY_SECTION=feedback + ULL_FEEDBACK_PART)。"
);
return;
};
let shot = Shot::load(&path).expect("读 feedback 截图");
assert_eq!(
(shot.width, shot.height),
(1180, 760),
"验收图必须 1180×760 @100% DPI"
);
let flavor = if matches(
shot.at(2, 2),
rgb(ui(SchemeFlavor::Light).color(Role::Surface)),
) {
SchemeFlavor::Light
} else {
SchemeFlavor::Dark
};
let ui = ui(flavor);
let part = std::env::var("ULL_FEEDBACK_PART").unwrap_or_else(|_| "all".to_owned());
let part = part.trim().to_ascii_lowercase();
match part.as_str() {
"progress" => check_progress(&shot, &ui),
"snackbar" => check_snackbar(&shot, &ui),
"dialog" => check_dialog(&shot, &ui),
"empty" => check_empty(&shot, &ui),
other => {
// 整屏时只查最不容易被折叠线切掉的那一类;分图才是逐项判据的场合。
check_progress(&shot, &ui);
eprintln!("[feedback-probe] part={other:?} 只查了 progress 那一类;其余请在分图里查");
}
}
eprintln!("[feedback-probe] OK flavor={flavor:?} part={part}");
}
/// §12:线性厚度 4 / 圆角 full;环形 40 且真圆。
fn check_progress(shot: &Shot, ui: &Ui) {
// 参考值写字面量(A1):**不能**从 `LinearGeometry` 读回期望值——那样把 4 改成 6 之后
// 「实测 6 == 期望 6」会照样绿,等于没有断言(复审 P2-4 的同一类坑)。
let surface = ui.color(Role::Surface);
let track = ui.color(Role::SecondaryContainer);
let active = ui.color(Role::Primary);
// ---- 12.1 厚度:轨道带恰好 4 行(宿主 320 宽 ⇒ 每行 ~320 个轨道墨点)--------
let bar_bands = bands(shot, track, surface, 300);
assert!(
!bar_bands.is_empty(),
"没找到线性进度轨道(宿主宽 320,应有 ≥300 个墨点的行)"
);
for (top, bottom) in &bar_bands {
let height = bottom - top + 1;
assert!(
near(height, 4),
"12.1:线性进度厚必须 4(A1 Height;实测 {height} @ 行 {top})"
);
}
// ---- 12.1 圆角 full:条的四角必须透出底色(直角会把角填满)------------------
let (top, _) = bar_bands[0];
let (left, len) = runs(shot, top + 1, track, surface)
.into_iter()
.find(|(_, len)| *len >= 300)
.expect("轨道那一行必须有 ≥300 的长段");
assert!(
!matches(shot.at(left, top), rgb(track)),
"12.1:圆角 full(2) 的左上角必须透出底色(直角会填满)"
);
assert!(
matches(shot.at(left + len / 2, top + 1), rgb(track)),
"条的中间必须有墨(否则是没画出来,不是圆角)"
);
// ---- 12.3 环形:40 的外接框,且四角无墨(真圆)------------------------------
let rings: Vec<BBox> = blobs(shot, active, 30)
.into_iter()
.filter(|bbox| near(bbox.width(), 40) && near(bbox.height(), 40))
.collect();
assert!(
!rings.is_empty(),
"12.3:没找到 40(±1) 的环形激活层;候选:{}",
dims(&blobs(shot, active, 10))
);
for ring in &rings {
for (x, y) in [
(ring.left, ring.top),
(ring.right, ring.top),
(ring.left, ring.bottom),
(ring.right, ring.bottom),
] {
assert!(
!matches(shot.at(x, y), rgb(active)),
"12.3:环必须是真圆(外接框四角不该有墨)@ ({x},{y})"
);
}
}
eprintln!(
"[feedback-probe] progress:轨道带 {} 条(高 {:?})· 环 {} 个 40(±1)",
bar_bands.len(),
bar_bands
.iter()
.map(|(top, bottom)| bottom - top + 1)
.collect::<Vec<_>>(),
rings.len()
);
}
/// §14:宽度上下限 + 单/双行高。
fn check_snackbar(shot: &Shot, ui: &Ui) {
// 参考值写字面量(A1):600 = SnackbarKt.ContainerMaxWidth,48/68 = SnackbarTokens。
let surface = ui.color(Role::Surface);
let bar = ui.color(Role::InverseSurface);
// 行墨量下限 200:最窄的那条只有 300 宽,里面还有 ~60px 的文字 ⇒ 每行 ~240 个实心点。
// 高度 ≥40 才算提示条(标题行偶有一两行落在这条混色线上,那类 1 行的带要滤掉)。
let found: Vec<(usize, usize)> = bands(shot, bar, surface, 200)
.into_iter()
.filter(|(top, bottom)| bottom - top + 1 >= 40)
.collect();
assert!(
found.len() >= 3,
"14.x:应有 ≥3 条提示条(上限宽 / 双行 / 两条窄的),实测 {}",
found.len()
);
// 宽度集 = 每条带里最宽的那一行(留白行没有文字,量得到全宽)。
// **注意**:并排的两条窄条落在同一组行里,所以这一屏的宽度集只能证「存在 344 与 300」;
// 要把其中一条单独改坏,变异必须同时改所有实例(报告「变异」一节里写明)。
let mut widths: Vec<usize> = found
.iter()
.map(|band| band_width(shot, *band, bar, surface))
.collect();
let mut heights: Vec<usize> = found.iter().map(|(top, bottom)| bottom - top + 1).collect();
widths.sort_unstable();
heights.sort_unstable();
assert!(
!widths.iter().any(|w| *w > 601),
"没有一条可以超过上限 600:实测 {widths:?}"
);
assert!(
widths.iter().any(|w| near(*w, 600)),
"上限宽必须是 600(SnackbarKt.ContainerMaxWidth,宿主 800 时夹取),实测宽度集 {widths:?}"
);
// 最小宽的取值口径 344,以及「没有下限」的反证 300
assert!(
widths.iter().any(|w| near(*w, 344)),
"宿区 344 那格必须量到 344(G17-d [估] 最小宽口径),实测 {widths:?}"
);
assert!(
widths.iter().any(|w| near(*w, 300)),
"宿区 300 那格必须量到 300(组件没有 min-width,G17-d),实测 {widths:?}"
);
// 高度 48 / 68
assert!(
heights.iter().any(|h| near(*h, 48)),
"14.3:单行高 48,实测高度集 {heights:?}"
);
assert!(
heights.iter().any(|h| near(*h, 68)),
"14.3:双行高 68,实测高度集 {heights:?}"
);
// 14.6 动作色:`InversePrimary` 必须出现在提示条内(不是按钮家族的 OnSurfaceVariant)
let action = rgb(ui.color(Role::InversePrimary));
let action_pixels: usize = (0..shot.height)
.map(|y| {
(0..shot.width)
.filter(|x| matches(shot.at(*x, y), action))
.count()
})
.sum();
assert!(
action_pixels > 40,
"14.6:动作标签应是 InversePrimary(实测该色只有 {action_pixels} px)"
);
eprintln!(
"[feedback-probe] snackbar:宽度集 {widths:?} · 高度集 {heights:?} · InversePrimary {action_pixels}px"
);
}
/// §7:卡片宽 480(官方 280..560 内)+ 内距 24 + scrim 毯。
fn check_dialog(shot: &Shot, ui: &Ui) {
// 参考值写字面量(A1 AlertDialogKt):280 / 560 / 480 / pad 24。
let surface = ui.color(Role::Surface);
let card = ui.layer(MaterialLayer::Overlay);
// ---- 7.4 宽:所有行里最宽的一段(卡片上下留白那几行没有文字,量得到全宽)------
let mut widest = (0usize, 0usize, 0usize); // (len, x, y)
for y in 0..shot.height {
for (x, len) in runs(shot, y, card, surface) {
if len > widest.0 {
widest = (len, x, y);
}
}
}
let (width, card_left, card_row) = widest;
assert!(
near(width, 480),
"7.4:对话框宽必须是 480(G17-e 项目取值),实测 {width}"
);
// 该行上下扩成卡片带(同一张卡片的连续行;两张卡并排时各自成带)
let mut top = card_row;
while top > 0 && row_ink(shot, top - 1, card, surface) >= width / 2 {
top -= 1;
}
let mut bottom = card_row;
while bottom + 1 < shot.height && row_ink(shot, bottom + 1, card, surface) >= width / 2 {
bottom += 1;
}
// ---- 7.3 左右内距:标题墨迹距卡片左缘 ≈ 24(外加 24px 字形的 bearing,22..28)----
let bbox = BBox {
left: card_left,
right: card_left + width - 1,
top,
bottom,
};
let title =
ink_inside(shot, &bbox, ui.color(Role::OnSurface), card, 2).expect("卡片里必须有标题墨迹");
let inset = title.left.saturating_sub(card_left);
assert!(
(22..=28).contains(&inset),
"7.3:四边内距 24 ⇒ 标题墨迹左缩 ≈24(含 bearing 22..28),实测 {inset}"
);
let top_inset = title.top.saturating_sub(top);
assert!(
(24..=40).contains(&top_inset),
"7.3:上内距 24 ⇒ 标题墨迹顶缩 ≈24+行内 bearing,实测 {top_inset}"
);
// ---- 7.7 scrim:一块 ≥700 宽的遮罩带,颜色 = 预合成实色 ----------------------
let backdrop = scrim(ui);
let scrim_bands = bands(shot, backdrop, surface, 700);
assert!(
!scrim_bands.is_empty(),
"7.7:没找到 ≥700 宽的遮罩带(宿主 800 宽那格)"
);
// ---- 7.6 动作色:Dialog 动作是 `Primary`(不是 OnSurfaceVariant)-------------
// 按「更靠近 Primary 而不是卡片」判,不按精确色:14px 的中文字形只有边缘几个像素能命中
// 精确色(实测 0),而 Primary 与卡片色的距离很大,这个判据又稳又不误判。
let action = rgb(ui.color(Role::Primary));
let card_rgb = rgb(card);
let action_pixels = (top..=bottom)
.map(|y| {
(card_left..(card_left + width).min(shot.width))
.filter(|x| {
let pixel = shot.at(*x, y);
let to_action: u32 = pixel
.iter()
.zip(action)
.map(|(a, b)| u32::from(a.abs_diff(b)))
.sum();
let to_card: u32 = pixel
.iter()
.zip(card_rgb)
.map(|(a, b)| u32::from(a.abs_diff(b)))
.sum();
to_action < to_card
})
.count()
})
.sum::<usize>();
assert!(
action_pixels > 40,
"7.6:动作文本应是 Primary(实测该色只有 {action_pixels} px)"
);
eprintln!(
"[feedback-probe] dialog:宽 {width} · 标题左缩 {inset} · 顶缩 {top_inset} · \
卡片 y {top}..{bottom} · scrim 带 {} 条 · Primary {action_pixels}px",
scrim_bands.len()
);
}
/// §15:空态图标块 96(`[估]`)+ CTA 40 高。
fn check_empty(shot: &Shot, ui: &Ui) {
// 参考值写字面量:96/16/32 全是 `[估]`(项目自定,官方无此组件)。
let surface = ui.color(Role::Surface);
let block = ui.layer(MaterialLayer::Card);
// 下限 40 而不是 88:块只有 96 宽,中间的图标(32)会把那几行的块色墨量压到 ≈64。
let found = bands(shot, block, surface, 40);
let (top, bottom) = found
.iter()
.copied()
.max_by_key(|(top, bottom)| bottom - top)
.filter(|(top, bottom)| near(bottom - top + 1, 96))
.unwrap_or_else(|| {
panic!("15.1:没找到高 96 的图标块(`[估]` 项目自定值),实测量到的带:{found:?}")
});
let width = band_width(shot, (top, bottom), block, surface);
assert!(near(width, 96), "15.1:图标块必须 96×96,实测宽 {width}");
// 15.6 CTA:标准 Filled 按钮 40 高(`Primary` 实心带)。
// 下限 20 而不是 60:按钮约 100 宽,文字那几行只剩 ≈44 个实心像素(文字不是实心色)。
let cta = bands(shot, ui.color(Role::Primary), surface, 20);
assert!(
cta.iter().any(|(top, bottom)| near(bottom - top + 1, 40)),
"15.6:CTA 应是标准按钮(可见高 40),实测带 {cta:?}"
);
eprintln!(
"[feedback-probe] empty:块 y {top}..{bottom}(高 {})· 宽 {width} · CTA 带 {cta:?}",
bottom - top + 1
);
}