Files
Unlucky-Launcher/crates/app-iced/src/components/feedback/pixel.rs
T
AskaEth 36f58d7904 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 失败)
2026-11-02 15:56:42 +08:00

281 lines
10 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
//! 像素取证的小工具(**仅测试**):读 PNG、按「底色 → 墨色」的覆盖率找连通块与长条。
//!
//! 为什么不复用 `gallery/scan.rs`:那个模块的可见性是
//! `pub(in crate::app::components::gallery)`,从家族目录拿不到(`selection/pixel.rs` 也是
//! 同样的理由自己写了一份)。这里只重写本家族真正用到的几个纯函数。
//!
//! **判据口径 1:覆盖率而不是精确色。** 4px 厚的进度条、28 圆角的卡片,边缘全是抗锯齿;
//! 按「精确色」连通会把它们切碎(实测进度条只剩中间 1~2 行)。
//! **判据口径 2:覆盖率 ≥50%。** 50% 是几何边界(30% 会把半格抗锯齿也算进去,4px 量成 6)。
use std::path::PathBuf;
use iced::Color;
/// 8bit RGB。
pub(super) fn rgb(color: Color) -> [u8; 3] {
[
(color.r * 255.0).round() as u8,
(color.g * 255.0).round() as u8,
(color.b * 255.0).round() as u8,
]
}
/// 容差比较(抗锯齿/取整)。
pub(super) fn matches(pixel: [u8; 3], expected: [u8; 3]) -> bool {
pixel.iter().zip(expected).all(|(a, b)| a.abs_diff(b) <= 10)
}
/// `ULL_GALLERY_SHOT` → 截图路径(相对路径按**工作区根**解释:`cargo test` 的 cwd 是 crate 目录)。
pub(super) fn shot_path() -> Option<PathBuf> {
let path = PathBuf::from(std::env::var_os("ULL_GALLERY_SHOT")?);
if path.is_absolute() || path.exists() {
return Some(path);
}
let root = PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("../..");
Some(root.join(path))
}
/// 一张图的像素访问器。
pub(super) struct Shot {
pub(super) width: usize,
pub(super) height: usize,
pixels: Vec<[u8; 3]>,
}
impl Shot {
pub(super) fn load(path: &std::path::Path) -> Result<Self, String> {
let bytes = std::fs::read(path).map_err(|error| format!("读不动 {path:?}:{error}"))?;
let image = image::load_from_memory(&bytes)
.map_err(|error| format!("解 PNG 失败:{error}"))?
.to_rgb8();
let (width, height) = (image.width() as usize, image.height() as usize);
Ok(Self {
width,
height,
pixels: image.pixels().map(|p| p.0).collect(),
})
}
pub(super) fn at(&self, x: usize, y: usize) -> [u8; 3] {
self.pixels[y * self.width + x]
}
}
/// 一个外接框(闭区间)。
#[derive(Debug, Clone, Copy)]
pub(super) struct BBox {
pub(super) left: usize,
pub(super) right: usize,
pub(super) top: usize,
pub(super) bottom: usize,
}
impl BBox {
pub(super) fn new(x: usize, y: usize) -> Self {
Self {
left: x,
right: x,
top: y,
bottom: y,
}
}
pub(super) fn include(&mut self, x: usize, y: usize) {
self.left = self.left.min(x);
self.right = self.right.max(x);
self.top = self.top.min(y);
self.bottom = self.bottom.max(y);
}
pub(super) fn width(&self) -> usize {
self.right - self.left + 1
}
pub(super) fn height(&self) -> usize {
self.bottom - self.top + 1
}
}
/// 尺寸容差 ±1(rubric §6 的默认容差:控件常落在半像素上,实测会多一行抗锯齿)。
pub(super) fn near(value: usize, expected: usize) -> bool {
value.abs_diff(expected) <= 1
}
pub(super) fn dims(boxes: &[BBox]) -> String {
boxes
.iter()
.map(|bbox| format!("{}×{}", bbox.width(), bbox.height()))
.collect::<Vec<_>>()
.join(", ")
}
/// `pixel` 在「`under` → `fill`」这条混色线上的**覆盖率**(0..1;≥0.5 视为「算这块墨」)。
///
/// 投影到混色向量上取比例:抗锯齿像素正好落在 0..1 之间,另一种墨(文字、别的容器色)
/// 会落在线的两侧偏远处,比例被夹到 0 或 1 之外 → 用「偏离线距离」再筛一道。
pub(super) fn coverage(pixel: [u8; 3], fill: [u8; 3], under: [u8; 3]) -> f32 {
let axis = [0, 1, 2].map(|i| f32::from(fill[i]) - f32::from(under[i]));
let offset = [0, 1, 2].map(|i| f32::from(pixel[i]) - f32::from(under[i]));
let square: f32 = axis.iter().map(|a| a * a).sum();
if square <= f32::EPSILON {
return 0.0;
}
let t: f32 = (0..3).map(|i| axis[i] * offset[i]).sum::<f32>() / square;
let distance = (0..3)
.map(|i| (offset[i] - t * axis[i]).powi(2))
.sum::<f32>()
.sqrt();
// 8.0 而不是 16.0:深色填充(如 InverseSurface)与浅底之间的混色轴很长,深色**文字**
// (OnSurfaceVariant)投影上去残差只有 ≈9 —— 16 的阈值会把文字行算成填充,于是 48 高的
// 提示条被切成好几段(实测 46 + 1 + 1)。8.0 只排除文字,保留抗锯齿边(残差 0~3)。
if distance > 8.0 {
return 0.0;
}
t.clamp(0.0, 1.0)
}
/// 第 `y` 行上所有「算 `fill` 那块墨」的连续段 `(起点, 长度)`。
pub(super) fn runs(shot: &Shot, y: usize, fill: Color, under: Color) -> Vec<(usize, usize)> {
let (ink, base) = (rgb(fill), rgb(under));
let mut found = Vec::new();
let mut start: Option<usize> = None;
for x in 0..shot.width {
let hit = coverage(shot.at(x, y), ink, base) >= 0.5;
match (hit, start) {
(true, None) => start = Some(x),
(false, Some(from)) => {
found.push((from, x - from));
start = None;
}
_ => {}
}
}
if let Some(from) = start {
found.push((from, shot.width - from));
}
found
}
/// 一行里有多少「算 `fill` 那块墨」的像素(按覆盖率 ≥50% 判)。
///
/// 用**计数**而不是「最长段」:一条 344 宽的提示条,里面一行中文字会把它切成几段,
/// 按最长段判会把中间那些行漏掉(实测 48 高被切成 6 + 6 两段)。
pub(super) fn row_ink(shot: &Shot, y: usize, fill: Color, under: Color) -> usize {
let (ink, base) = (rgb(fill), rgb(under));
(0..shot.width)
.filter(|x| coverage(shot.at(*x, y), ink, base) >= 0.5)
.count()
}
/// 连续行里「墨量 ≥ `min_ink`」的那些行 → 聚成**竖带** `(top, bottom)`。
///
/// 用途:一条 4px 的进度条在图上就是「4 个连续行、每行都有 ~320 个墨点」;
/// 一个 48 高的提示条就是「48 个连续行」。比按精确色找 bbox 稳得多。
pub(super) fn bands(shot: &Shot, fill: Color, under: Color, min_ink: usize) -> Vec<(usize, usize)> {
let mut bands: Vec<(usize, usize)> = Vec::new();
let mut current: Option<usize> = None;
for y in 0..shot.height {
let hit = row_ink(shot, y, fill, under) >= min_ink;
match (hit, current) {
(true, None) => current = Some(y),
(false, Some(top)) => {
bands.push((top, y - 1));
current = None;
}
_ => {}
}
}
if let Some(top) = current {
bands.push((top, shot.height - 1));
}
bands
}
/// 竖带里最宽的一行的段长:取「每行最长连通段」的最大值。
///
/// 为什么要取最大而不是某一行:矩形控件上下有留白行(没文字),只有那几行量得到全宽;
/// 文字会把中间行切成几段。两条同色矩形并排时,`runs` 分别返回各自的长段,所以也不会串。
pub(super) fn band_width(shot: &Shot, band: (usize, usize), fill: Color, under: Color) -> usize {
(band.0..=band.1)
.map(|y| {
runs(shot, y, fill, under)
.into_iter()
.map(|(_, len)| len)
.max()
.unwrap_or(0)
})
.max()
.unwrap_or(0)
}
/// 某种颜色的**精确色**连通块(只用于「找着一大块实心」这种粗定位)。
pub(super) fn blobs(shot: &Shot, color: Color, min_side: usize) -> Vec<BBox> {
let target = rgb(color);
let mut seen = vec![false; shot.width * shot.height];
let mut found = Vec::new();
for y in 0..shot.height {
for x in 0..shot.width {
if seen[y * shot.width + x] || !matches(shot.at(x, y), target) {
continue;
}
let mut stack = vec![(x, y)];
seen[y * shot.width + x] = true;
let mut bbox = BBox::new(x, y);
while let Some((cx, cy)) = stack.pop() {
bbox.include(cx, cy);
for (nx, ny) in [
(cx + 1, cy),
(cx.saturating_sub(1), cy),
(cx, cy + 1),
(cx, cy.saturating_sub(1)),
] {
let inside = nx < shot.width && ny < shot.height;
if inside && !seen[ny * shot.width + nx] && matches(shot.at(nx, ny), target) {
seen[ny * shot.width + nx] = true;
stack.push((nx, ny));
}
}
}
if bbox.width() >= min_side && bbox.height() >= min_side {
found.push(bbox);
}
}
}
found
}
/// 在 `bbox` 内找「最接近 `ink`(而不是 `under`)」的像素外接框(量文字/图标的内缩)。
pub(super) fn ink_inside(
shot: &Shot,
bbox: &BBox,
ink: Color,
under: Color,
margin: usize,
) -> Option<BBox> {
let (target, base) = (rgb(ink), rgb(under));
let mut found: Option<BBox> = None;
for y in (bbox.top + margin)..=(bbox.bottom.saturating_sub(margin)) {
for x in (bbox.left + margin)..=(bbox.right.saturating_sub(margin)) {
let pixel = shot.at(x, y);
let to_ink = pixel
.iter()
.zip(target)
.map(|(a, b)| u32::from(a.abs_diff(b)))
.sum::<u32>();
let to_base = pixel
.iter()
.zip(base)
.map(|(a, b)| u32::from(a.abs_diff(b)))
.sum::<u32>();
if to_ink < to_base {
match &mut found {
Some(bbox) => bbox.include(x, y),
None => found = Some(BBox::new(x, y)),
}
}
}
}
found
}