Files
Unlucky-Launcher/crates/app-iced/src/components/inputs/probe.rs
T
AskaEth 21f2734981 test(app-iced): M77 渲染层 probe —— 逐行几何断言(5 条,能被变异打红)
M68 的教训:纯函数返回对、图可能是错的。`inputs/probe.rs` 对着**真窗截图**量像素,
没给 `ULL_INPUTS_DIR` 就大声跳过(与 `gallery::probe` 同一口径)。

量什么(每条对应一次真实可能画错的形态):
- 文本框高**精确 56**(320 宽格)、Filled 指示线 静息 1dp / 聚焦 2dp 的墨量比;
- Outlined 描边 1dp / 2dp(描边墨量随亚像素相位在 1.0~1.67 浮动,所以用**比值**判);
- 错误态:框内标签与框下辅助文本都是 `Error` 墨迹;
- 搜索条高 56、前置图标墨迹偏移 20(= pad 12 + offset 4 + viewBox ≈4)、
  docked 面板 宽 360 / 头 56 / 1px `Outline` 分隔 / 结果区 240;
- Slider 竖条 4×44、0% 贴左、50% 分界在正中、100% 无未激活段(thumb 不越界);
- 菜单容器 161(= 8 + 48×3 + 1 + 8)、项高 48、选中指示器内缩 4、分组线 1dp。

像素工具因 `gallery::scan` 是私有模块而自带一份最小读图器(约 60 行),已报 finding。
2026-11-02 07:45:38 +08:00

348 lines
12 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.
//! 渲染层几何断言(D30 / M68 的教训:**纯函数对 ≠ 图画对**)。
//!
//! ## 为什么这个文件在 `inputs/` 而不是 `gallery/probe.rs`
//!
//! 像素工具 `gallery::scan` 是 `mod scan;`(**私有**于 `gallery`),我的 probe 拿不到它,
//! 而 `gallery/mod.rs` 不在本 mission 的 scope 里。所以这里自带一份最小的读图器
//! (约 40 行:读 PNG / 取点 / 连通块 / 行覆盖),判据全在本文件——已作为 finding 报给 tower
//! (建议把 `gallery::scan` 提升为 `pub(crate)`,四个家族就能共用一份)。
//!
//! ## 怎么跑(worktree 根目录)
//!
//! ```text
//! # ① 出图:副本工程只把 inputs 段提到首位(组件代码逐字节相同),见 target/m77/sync.py
//! python target/m77/sync.py && (cd /d/tmp/m77-shot && cargo build --no-default-features ...)
//! M77_PART=text ./target/debug/unlucky-launcher-iced.exe --software-renderer --screen=gallery \
//! --screenshot target/m77/text-light.png --screenshot-theme=light
//! # ② 量图
//! ULL_INPUTS_DIR=target/m77 cargo test -p unlucky-app-iced inputs::probe
//! ```
//!
//! 没给 `ULL_INPUTS_DIR` 就**大声跳过**(不静默通过),与 `gallery::probe` 的口径一致。
//!
//! ## 量什么(每条都对着一个「已经可能画错」的形态)
//!
//! | 断言 | 抓的错误 |
//! |---|---|
//! | 文本框高 **56**、宽 **320** | 高度被内容/内边距吃掉(不是 56 的 A 级值) |
//! | Filled 底部指示线 静息 **1.0** / 聚焦 **2.0** 墨量 | 把 2dp 画成 1dp(或反过来) |
//! | Outlined 描边 静息 **1.0** / 聚焦 **2.0** 墨量 | 同上 |
//! | 错误态:标签 / 线 / 辅助文本全 `Error` | 只把线染红、忘了标签与辅助文本 |
//! | 搜索条高 **56**、图标墨迹偏移 **20**(= 16 + viewBox 4) | 手抄 16/20 当图标边、或 pad 与 offset 少加一段 |
//! | docked 面板 宽 **360** / 头 **56** / 分隔线 **1.0 `Outline`** / 体 **240** | 用 `OutlineVariant`、或头部高度与 bar 不一致 |
//! | Slider 0%(竖条贴左)、50%(分界在中央)、100%(无未激活段) | 位移区间取错(0..W 而不是 handle/2..W−handle/2) |
//! | Slider 竖条 **4×44** | 画成圆点或撑满命中区 |
//! | 菜单项 pitch **48**、选中指示器内缩 **4**、容器高 **161** | 项高/内边距抄错 |
//! | 分组分隔线 **1.0** 墨量 | 分隔线变成 2px |
use std::path::{Path, PathBuf};
use unlucky_design::color::{Role, SchemeFlavor};
use unlucky_design::typography::Platform;
use crate::theme::{Families, Ui};
const FAMILIES: Families = Families {
text: "Segoe UI",
display: "Segoe UI",
mono: "Consolas",
};
/// 颜色比对容差(抗锯齿/取整)。
const TOLERANCE: u8 = 10;
type Rgb = [u8; 3];
fn ui(flavor: SchemeFlavor) -> Ui {
Ui::with_platform(flavor, Platform::Windows, FAMILIES)
}
fn rgb(color: iced::Color) -> Rgb {
[
(color.r * 255.0).round() as u8,
(color.g * 255.0).round() as u8,
(color.b * 255.0).round() as u8,
]
}
fn matches(pixel: Rgb, expected: Rgb) -> bool {
matches_within(pixel, expected, TOLERANCE)
}
/// 指定容差的同色判断(量**阴影边**时要用更严的容差:阴影叠上去会落在 10 的容差内)。
fn matches_within(pixel: Rgb, expected: Rgb, tolerance: u8) -> bool {
pixel
.iter()
.zip(expected)
.all(|(a, b)| a.abs_diff(b) <= tolerance)
}
/// 一张图的最小访问器(读 PNG / 取点 / 连通块)。
struct Shot {
width: usize,
height: usize,
pixels: Vec<Rgb>,
}
/// 一张图里某颜色的连通块外接框(闭区间)。
#[derive(Debug, Clone, Copy)]
struct BBox {
left: usize,
right: usize,
top: usize,
bottom: usize,
}
impl BBox {
fn width(&self) -> usize {
self.right - self.left + 1
}
fn height(&self) -> usize {
self.bottom - self.top + 1
}
}
impl Shot {
fn load(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(),
})
}
fn at(&self, x: usize, y: usize) -> Rgb {
self.pixels[y * self.width + x]
}
fn bbox_of(&self, color: Rgb, min_area: usize) -> Vec<BBox> {
let mut seen = vec![false; self.width * self.height];
let mut boxes = Vec::new();
for y in 0..self.height {
for x in 0..self.width {
if seen[y * self.width + x] || !matches(self.at(x, y), color) {
continue;
}
let mut stack = vec![(x, y)];
seen[y * self.width + x] = true;
let mut bbox = BBox {
left: x,
right: x,
top: y,
bottom: y,
};
while let Some((cx, cy)) = stack.pop() {
bbox.left = bbox.left.min(cx);
bbox.right = bbox.right.max(cx);
bbox.top = bbox.top.min(cy);
bbox.bottom = bbox.bottom.max(cy);
for (nx, ny) in [
(cx + 1, cy),
(cx.saturating_sub(1), cy),
(cx, cy + 1),
(cx, cy.saturating_sub(1)),
] {
if nx < self.width
&& ny < self.height
&& !seen[ny * self.width + nx]
&& matches(self.at(nx, ny), color)
{
seen[ny * self.width + nx] = true;
stack.push((nx, ny));
}
}
}
if bbox.width() * bbox.height() >= min_area {
boxes.push(bbox);
}
}
}
boxes
}
fn count_in(&self, color: Rgb, x0: usize, x1: usize, y0: usize, y1: usize) -> usize {
let mut total = 0;
for y in y0..y1.min(self.height) {
for x in x0..x1.min(self.width) {
if matches(self.at(x, y), color) {
total += 1;
}
}
}
total
}
}
/// 一行像素相对底色向 `line` 靠拢的比例(0..=1)——1dp 的线在各行上摊开时总和 ≈ 1.0。
fn coverage(pixel: Rgb, surface: Rgb, line: Rgb) -> f32 {
let channel = |i: usize| {
let full = f32::from(line[i]) - f32::from(surface[i]);
if full.abs() < 1.0 {
return 0.0;
}
((f32::from(pixel[i]) - f32::from(surface[i])) / full).clamp(0.0, 1.0)
};
(channel(0) + channel(1) + channel(2)) / 3.0
}
/// 竖直方向上的**线墨量**:`y0..y1` 每行在 x 区间上的平均覆盖之和(1dp ⇒ ≈1.0)。
fn line_ink(
shot: &Shot,
x0: usize,
x1: usize,
y0: usize,
y1: usize,
surface: Rgb,
line: Rgb,
) -> f32 {
let mut total = 0.0;
for y in y0..y1.min(shot.height) {
let mut sum = 0.0;
for x in x0..x1.min(shot.width) {
sum += coverage(shot.at(x, y), surface, line);
}
total += sum / (x1 - x0) as f32;
}
total
}
/// 某一行里「两色之一」的最长连续段 `(start, end)`,允许 ≤`max_gap` 的空隙。
///
/// 用它而不是「本行最左/最右」:深色档的说明文字里有零星像素落在令牌色容差内,
/// 取 min/max 会把轨道量成从 x=24 起(实测)。
fn longest_run(shot: &Shot, y: usize, a: Rgb, b: Rgb, max_gap: usize) -> Option<(usize, usize)> {
let mut best: Option<(usize, usize)> = None;
let mut start: Option<usize> = None;
let mut last: Option<usize> = None;
for x in 0..shot.width {
if matches(shot.at(x, y), a) || matches(shot.at(x, y), b) {
if start.is_none() {
start = Some(x);
}
last = Some(x);
} else if let (Some(from), Some(to)) = (start, last) {
if x - to > max_gap {
if best.is_none_or(|(p, q)| to - from > q - p) {
best = Some((from, to));
}
start = None;
last = None;
}
}
}
if let (Some(from), Some(to)) = (start, last) {
if best.is_none_or(|(p, q)| to - from > q - p) {
best = Some((from, to));
}
}
best
}
/// `bbox` 范围内「整行至少 `min_pixels` 个同色像素」的行区间(闭区间)。
///
/// 菜单的阴影会把 bbox 外扩 ~5px,而阴影行绝不会有 100 个容器色像素——用它把阴影滤掉。
fn row_extent(shot: &Shot, bbox: &BBox, color: Rgb, min_pixels: usize) -> (usize, usize) {
let mut first = None;
let mut last = None;
for y in bbox.top..=bbox.bottom {
let mut count = 0;
for x in bbox.left..=bbox.right {
// 容差收到 2:**阴影边**会落在默认容差 10 内,会把容器量高 ~5px
if matches_within(shot.at(x, y), color, 2) {
count += 1;
}
}
if count >= min_pixels {
first.get_or_insert(y);
last = Some(y);
}
}
(
first.expect("行内没有足够的同色像素"),
last.expect("行内没有足够的同色像素"),
)
}
/// `pixel` 更接近 `fill` 还是 `surface`(含平局都算 `fill`)。
fn nearer(pixel: Rgb, fill: Rgb, surface: Rgb) -> bool {
let dist = |a: Rgb, b: Rgb| -> u32 {
a.iter()
.zip(b)
.map(|(x, y)| (i32::from(*x) - i32::from(y)).unsigned_abs())
.sum()
};
dist(pixel, fill) <= dist(pixel, surface)
}
/// 截图目录(`ULL_INPUTS_DIR`);没给就返回 `None`(调用方大声跳过)。
fn shot_dir() -> Option<PathBuf> {
let raw = std::env::var_os("ULL_INPUTS_DIR")?;
let path = PathBuf::from(raw);
if path.is_absolute() || path.is_dir() {
return Some(path);
}
let root = PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("../..");
Some(root.join(path))
}
/// 取一张图;缺图直接 panic(给了目录就说明这轮要认真量)。
fn shot(dir: &Path, part: &str, flavor: SchemeFlavor) -> Shot {
let slug = match flavor {
SchemeFlavor::Light => "light",
SchemeFlavor::Dark => "dark",
};
let path = dir.join(format!("{part}-{slug}.png"));
let shot = Shot::load(&path).unwrap_or_else(|error| panic!("{error}"));
assert_eq!(
(shot.width, shot.height),
(1180, 760),
"验收图必须是 D30 的 1180×760 @100% DPI"
);
shot
}
/// 一个矩阵格在某列的竖直「实色高度」:从 `bbox.top-4` 往上/下走到不再接近 `fill` 为止。
fn vertical_extent(
shot: &Shot,
bbox: &BBox,
fill: Rgb,
surface: Rgb,
column: usize,
) -> (usize, usize) {
let mut top = bbox.top;
while top > 0 && nearer(shot.at(column, top - 1), fill, surface) {
top -= 1;
}
let mut bottom = bbox.bottom;
while bottom + 1 < shot.height && nearer(shot.at(column, bottom + 1), fill, surface) {
bottom += 1;
}
(top, bottom)
}
macro_rules! require_dir {
() => {
match shot_dir() {
Some(dir) => dir,
None => {
eprintln!(
"[inputs::probe] SKIPPED:未设置 ULL_INPUTS_DIR。这是**渲染层**断言,\
需要先按本文件模块文档出 8 张图(target/m77/*.png)。"
);
return;
}
}
};
}
mod menu;
mod search;
mod slider;
mod text;