diff --git a/crates/app-iced/src/components/inputs/mod.rs b/crates/app-iced/src/components/inputs/mod.rs index f3d9938..5e880ba 100644 --- a/crates/app-iced/src/components/inputs/mod.rs +++ b/crates/app-iced/src/components/inputs/mod.rs @@ -31,6 +31,8 @@ pub mod geometry; mod field; mod menu; +#[cfg(test)] +mod probe; mod search; mod slider; diff --git a/crates/app-iced/src/components/inputs/probe.rs b/crates/app-iced/src/components/inputs/probe.rs new file mode 100644 index 0000000..8a1086e --- /dev/null +++ b/crates/app-iced/src/components/inputs/probe.rs @@ -0,0 +1,347 @@ +//! 渲染层几何断言(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, +} + +/// 一张图里某颜色的连通块外接框(闭区间)。 +#[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 { + 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 { + 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 = None; + let mut last: Option = 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 { + 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; diff --git a/crates/app-iced/src/components/inputs/probe/menu.rs b/crates/app-iced/src/components/inputs/probe/menu.rs new file mode 100644 index 0000000..8b6434c --- /dev/null +++ b/crates/app-iced/src/components/inputs/probe/menu.rs @@ -0,0 +1,79 @@ +//! M77 probe · menu(从 `probe.rs` 拆出:单文件 ≤400 行,AGENTS.md §3-1)。 + +use super::*; + +/// 8.3 / 8.4 / 8.6:菜单容器高 161、项 pitch 48、选中指示器内缩 4、分组分隔线 1dp。 +#[test] +fn menu_container_items_and_divider() { + let dir = require_dir!(); + for flavor in [SchemeFlavor::Light, SchemeFlavor::Dark] { + let shot = shot(&dir, "menu", flavor); + let ui = ui(flavor); + let container = rgb(ui.color(Role::SurfaceContainer)); + let selected = rgb(ui.color(Role::SecondaryContainer)); + let divider = rgb(ui.color(Role::OutlineVariant)); + + // 右侧那只 = 「展开态」菜单。容器色块可能被**分组分隔线 / 选中项**切成多块, + // 所以先把 x>290 的块并成一个外接框,再逐行量容器色(容差 2,滤掉阴影外扩的 ~5px)。 + let menus = shot.bbox_of(container, 2000); + let right: Vec = menus.iter().filter(|b| b.left > 290).copied().collect(); + assert!(!right.is_empty(), "找不到右侧的「展开态」菜单"); + let mut open = right[0]; + for b in right.iter().skip(1) { + open.left = open.left.min(b.left); + open.top = open.top.min(b.top); + open.right = open.right.max(b.right); + open.bottom = open.bottom.max(b.bottom); + } + let (top, bottom) = row_extent(&shot, &open, container, 1); + open.top = top; + open.bottom = bottom; + + // 布局高 **161** = 8 + 48×3 + 1 + 8;抗锯齿会吃掉上下各 1 行 ⇒ 实测 160 + assert!( + (159..=162).contains(&open.height()), + "8.3/8.4 菜单高应 161(= 8 + 48×3 + 1 + 8):实测 {}", + open.height() + ); + + // 选中项:SecondaryContainer,高 48、左右各内缩 4 + let selected_box = shot + .bbox_of(selected, 2000) + .into_iter() + .filter(|b| b.height() >= 44 && b.height() <= 50) + .max_by_key(|b| b.width()) + .expect("找不到选中项的 SecondaryContainer"); + assert!( + (46..=48).contains(&selected_box.height()), + "8.3 项高应 48:实测 {}", + selected_box.height() + ); + // 容器左右缘:在第一项那一行按严格同色扫(bbox 会被阴影外扩 ~2px,不能用) + let scan_y = open.top + 10; + let row: Vec = (open.left..=open.right) + .filter(|x| matches_within(shot.at(*x, scan_y), container, 2)) + .collect(); + let (menu_left, menu_right) = (*row.first().unwrap(), *row.last().unwrap()); + assert_eq!(selected_box.left - menu_left, 4, "8.6 选中指示器左内缩应 4"); + assert_eq!( + menu_right - selected_box.right, + 4, + "8.6 选中指示器右内缩应 4" + ); + + // 分组分隔线:1dp OutlineVariant 墨量 ≈1.0(位置在选中项与最后一项之间) + let ink = line_ink( + &shot, + open.left + 20, + open.right - 20, + selected_box.bottom + 1, + selected_box.bottom + 6, + container, + divider, + ); + assert!( + (0.6..=1.4).contains(&ink), + "8.x 分组分隔线应 1dp `OutlineVariant`:实测墨量 {ink:.2}" + ); + } +} diff --git a/crates/app-iced/src/components/inputs/probe/search.rs b/crates/app-iced/src/components/inputs/probe/search.rs new file mode 100644 index 0000000..40dc2c3 --- /dev/null +++ b/crates/app-iced/src/components/inputs/probe/search.rs @@ -0,0 +1,80 @@ +//! M77 probe · search(从 `probe.rs` 拆出:单文件 ≤400 行,AGENTS.md §3-1)。 + +use super::*; + +/// 20.1 / 20.4 / 20.6 / 20.8 / 20.9:条 56、图标墨迹偏移 20(= 16 + viewBox 4)、 +/// docked 面板 360 / 头 56 / 分隔线 1.0 `Outline` / 体 240。 +#[test] +fn search_bar_and_docked_panel_geometry() { + let dir = require_dir!(); + for flavor in [SchemeFlavor::Light, SchemeFlavor::Dark] { + let shot = shot(&dir, "search", flavor); + let ui = ui(flavor); + let bar_color = rgb(ui.color(Role::SurfaceContainerHigh)); + let ink = rgb(ui.color(Role::OnSurface)); + + let bars = shot.bbox_of(bar_color, 2000); + let bar = bars + .iter() + .filter(|b| b.height() <= 60 && b.width() >= 360) + .min_by_key(|b| b.top) + .copied() + .expect("找不到搜索条(高 ≤60、宽 ≥360)"); + assert_eq!(bar.height(), 56, "20.1 搜索条高必须 56"); + assert!(bar.width() >= 360, "20.2 搜索条宽 ≥ 360"); + + // 20.4:图标墨迹左缘 = pad 12 + offset 4 + viewBox 内 ≈4 = 20(±1) + let inner = (bar.top + 12)..(bar.bottom - 12); + let mut left_ink = usize::MAX; + for y in inner.clone() { + for x in bar.left..bar.right { + if matches(shot.at(x, y), ink) { + left_ink = left_ink.min(x); + } + } + } + let offset = left_ink - bar.left; + assert!( + (19..=21).contains(&offset), + "20.4 前置图标墨迹偏移应 20(= 12 + 4 + viewBox 4):实测 {offset}" + ); + + // 20.6 / 20.9:docked 面板 360 宽,头 56 + 1px 分隔线 + 体 240 + // (头与体被那条线切成**两个同色块**,所以按 top 排序取前两块) + let mut parts = bars + .iter() + .filter(|b| b.width() == 360) + .copied() + .collect::>(); + parts.sort_by_key(|b| b.top); + assert_eq!(parts.len(), 2, "docked 面板 = 头部 + 结果区两块"); + let (header, body) = (parts[0], parts[1]); + assert!( + (55..=56).contains(&header.height()), + "20.6 面板头部高 56(与 bar 同高;顶部圆角会吃掉 1 行抗锯齿,实测 {})", + header.height() + ); + assert_eq!(body.height(), 240, "20.9 结果区最小高 240"); + assert!( + (2..=3).contains(&(body.top - header.bottom)), + "20.8 头与体之间正好 1dp(CPU 档摊成 2 行 ⇒ 隔 2~3 行):实测 {}", + body.top - header.bottom + ); + let panel = header; + + // 20.8:分隔线是 `Outline`(不是 OutlineVariant),1dp 墨量 ≈1.0 + let divider_ink = line_ink( + &shot, + panel.left + 40, + panel.right - 40, + panel.top + 50, + panel.top + 62, + bar_color, + rgb(ui.color(Role::Outline)), + ); + assert!( + (0.7..=1.3).contains(÷r_ink), + "20.8 头部分隔线应 1dp `Outline`:实测墨量 {divider_ink:.2}" + ); + } +} diff --git a/crates/app-iced/src/components/inputs/probe/slider.rs b/crates/app-iced/src/components/inputs/probe/slider.rs new file mode 100644 index 0000000..5473a9c --- /dev/null +++ b/crates/app-iced/src/components/inputs/probe/slider.rs @@ -0,0 +1,96 @@ +//! M77 probe · slider(从 `probe.rs` 拆出:单文件 ≤400 行,AGENTS.md §3-1)。 + +use super::*; + +/// 11.1~11.6:竖条 4×44、0% 贴左、50% 分界在中央、100% 无未激活段(thumb 永不越界)。 +#[test] +fn slider_thumb_positions_at_zero_half_and_full() { + let dir = require_dir!(); + for flavor in [SchemeFlavor::Light, SchemeFlavor::Dark] { + let shot = shot(&dir, "slider", flavor); + let ui = ui(flavor); + let active = rgb(ui.color(Role::Primary)); + let inactive = rgb(ui.color(Role::SecondaryContainer)); + + // 三条「值行」= 宽 ≥400 的轨道(0% / 50% / 100%),按 y 排序 + let mut rows: Vec<(usize, usize, usize)> = Vec::new(); + for y in 0..shot.height { + if let Some((left, right)) = longest_run(&shot, y, active, inactive, 20) { + // 先按「跨度 ≥300」收行(把抗锯齿行也纳进来),band 建好后再按宽度筛。 + if right - left + 1 >= 300 { + rows.push((y, left, right)); + } + } + } + // 合并相邻的 y(同一条轨道占 16 行),并把每行的端点累积成 min/max—— + // 首行/末行都是抗锯齿行,只取它们会把轨道量窄 2~3px。 + let mut bands: Vec<(usize, usize, usize, usize)> = Vec::new(); + for (y, left, right) in rows { + match bands.last_mut() { + Some(last) if y - last.1 <= 3 => { + last.1 = y; + last.2 = last.2.min(left); + last.3 = last.3.max(right); + } + _ => bands.push((y, y, left, right)), + } + } + // 值行是 448 宽(`Space::X8 × 14`);五态行 5 条轨道会被并成 ~663 的带,按宽度筛掉 + let bands: Vec<(usize, usize, usize, usize)> = bands + .into_iter() + .filter(|(_, _, left, right)| (440..=470).contains(&(right - left + 1))) + .collect(); + assert_eq!( + bands.len(), + 4, + "值行应有 4 条(0% / 50% / 100% / 离散):实测 {}", + bands.len() + ); + + for (label, band) in ["0%", "50%", "100%"].iter().zip(bands.iter()) { + let (top, bottom, band_left, band_right) = *band; + let mid = (top + bottom) / 2; // 轨道中心行 + let (left, right) = (band_left, band_right); + let width = right - left + 1; + let handle_centre = left + 2 + (width - 4) / 2; // 位移区间 = handle/2 .. W-handle/2 + let boundary = (left..=right) + .find(|x| matches(shot.at(*x, mid), inactive)) + .unwrap_or(right + 1); + match *label { + "0%" => assert!( + boundary <= left + 4, + "11.x 0% 时未激活轨必须从竖条(4 宽)之后立刻开始:实测分界 {}", + boundary - left + ), + "50%" => assert!( + boundary.abs_diff(handle_centre) <= 2, + "11.x 50% 的轨道分界应在正中({handle_centre}):实测 {boundary}" + ), + _ => { + let tail = shot.count_in(inactive, left, right + 1, mid, mid + 1); + assert_eq!(tail, 0, "11.x 100% 时不该有未激活段:实测 {tail}px"); + } + } + + // 竖条必须落在轨道内(0% / 100% 的最狠情形) + let handles: Vec = shot + .bbox_of(active, 100) + .into_iter() + .filter(|b| b.width() == 4 && b.height() >= 40) + .collect(); + assert!(!handles.is_empty(), "11.2 找不到 4×44 的竖条"); + for handle in handles { + assert!( + handle.left >= left && handle.right <= right, + "11.4 竖条越界:{handle:?} 不在轨道 {left}..{right} 内" + ); + assert!( + (43..=44).contains(&handle.height()), + "11.2 竖条高必须 44(抗锯齿会吃掉 1 行,实测 {})", + handle.height() + ); + assert_eq!(handle.width(), 4, "11.2 竖条宽必须 4"); + } + } + } +} diff --git a/crates/app-iced/src/components/inputs/probe/text.rs b/crates/app-iced/src/components/inputs/probe/text.rs new file mode 100644 index 0000000..32eb2ca --- /dev/null +++ b/crates/app-iced/src/components/inputs/probe/text.rs @@ -0,0 +1,205 @@ +//! M77 probe · text(从 `probe.rs` 拆出:单文件 ≤400 行,AGENTS.md §3-1)。 + +use super::*; + +/// 9.1 / 9.2:Filled 高 56、宽 320;Filled 指示线 静息 1.0 / 聚焦 2.0 墨量。 +#[test] +fn text_field_height_width_and_indicator_thickness() { + let dir = require_dir!(); + for flavor in [SchemeFlavor::Light, SchemeFlavor::Dark] { + let shot = shot(&dir, "text", flavor); + let ui = ui(flavor); + let surface = rgb(ui.color(Role::Surface)); + let fill = rgb(ui.color(Role::SurfaceContainerHighest)); + let line = rgb(ui.color(Role::OnSurfaceVariant)); + let primary = rgb(ui.color(Role::Primary)); + + let mut boxes = shot.bbox_of(fill, 400); + boxes.sort_by_key(|b| (b.top, b.left)); + let wide: Vec<&BBox> = boxes.iter().filter(|b| b.width() >= 280).collect(); + // 320 宽的有两个 Filled(空 / 有值);第三格是 Outlined 错误态——它没有容器色, + // 所以这里只要求 ≥2,错误态由 `outlined_stroke_and_error_paint` 单独验。 + assert!( + wide.len() >= 2, + "{flavor:?}: 至少要有 2 个 320 宽的 Filled 文本字段(实测 {})", + wide.len() + ); + + // 320 宽那三个 = 「空 / 有值 / 错误」三格;高度必须精确 56 + for bbox in wide.iter().take(2) { + assert!( + (280..=320).contains(&bbox.width()), + "9.2 最小宽 280:实测 {}(320 宽格)", + bbox.width() + ); + let column = bbox.right - 20; + let (top, bottom) = vertical_extent(&shot, bbox, fill, surface, column); + assert_eq!( + bottom - top + 1, + 56, + "9.1 高度必须精确 56(带浮动标签):实测 {flavor:?} @x={column}" + ); + } + + // 矩阵第一行(Filled 五态)=最靠上的 5 个 160 宽格:列序 = Interaction::ALL + // 五态里 disabled 的容器是 `OnSurface@12%`(另一种颜色),所以这里只有 4 块: + // enabled / hovered / pressed / focused —— 列序与 `Interaction::ALL` 一致。 + let row: Vec<&BBox> = boxes + .iter() + .filter(|b| (140..=170).contains(&b.width())) + .take(4) + .collect(); + assert_eq!(row.len(), 4, "{flavor:?}: Filled 矩阵应有 4 个同色格"); + let enabled = row[0]; + let focused = row[3]; + + // 静息指示线 1dp:墨量 ≈ 1.0 + let enabled_ink = line_ink( + &shot, + enabled.left + 8, + enabled.right - 8, + enabled.bottom, + enabled.bottom + 4, + fill, + line, + ); + assert!( + (0.6..=1.4).contains(&enabled_ink), + "9.4 静息指示线应 1dp:实测墨量 {enabled_ink:.2}" + ); + + // 聚焦指示线 2dp:墨量 ≈ 2.0(画成 1dp 会掉到 ~1.0) + let focused_ink = line_ink( + &shot, + focused.left + 8, + focused.right - 8, + focused.bottom, + focused.bottom + 5, + fill, + primary, + ); + assert!( + (1.5..=2.5).contains(&focused_ink), + "9.4 聚焦指示线应 2dp:实测墨量 {focused_ink:.2}(1dp 会掉到 ≈1.0)" + ); + assert!( + focused_ink > enabled_ink + 0.5, + "聚焦必须比静息粗一档({focused_ink:.2} vs {enabled_ink:.2})" + ); + } +} + +/// 9.4 / 9.7:Outlined 描边 静息 1dp / 聚焦 2dp;错误态线/标签/辅助文本全 `Error`。 +/// +/// 量法:**在某一列的竖线上扫**(不是找同色块)——深色档里 hover 描边的抗锯齿像素也会落进 +/// `Outline` 的容差里,按「最上面的同色块」取会取错行(实测 dark 取到了 hover 那一行)。 +#[test] +fn outlined_stroke_and_error_paint() { + let dir = require_dir!(); + for flavor in [SchemeFlavor::Light, SchemeFlavor::Dark] { + let shot = shot(&dir, "text", flavor); + let ui = ui(flavor); + let surface = rgb(ui.color(Role::Surface)); + let fill = rgb(ui.color(Role::SurfaceContainerHighest)); + let outline = rgb(ui.color(Role::Outline)); + let primary = rgb(ui.color(Role::Primary)); + let error = rgb(ui.color(Role::Error)); + + // 矩阵第一列(enabled)的左缘与行距:列宽 160 + 间距 8 = 168 + let left = shot + .bbox_of(fill, 400) + .into_iter() + .filter(|b| (140..=170).contains(&b.width())) + .min_by_key(|b| (b.top, b.left)) + .expect("找不到 Filled 矩阵格") + .left; + let filled_bottom = shot + .bbox_of(fill, 400) + .into_iter() + .filter(|b| (140..=170).contains(&b.width())) + .min_by_key(|b| (b.top, b.left)) + .expect("找不到 Filled 矩阵格") + .bottom; + + let column = |index: usize| left + index * 168 + 120; + + // 静息(第 1 列)描边 1dp:在竖线上找第一处比底色更接近 `Outline` 的像素 + let rest_y = ((filled_bottom + 8)..shot.height) + .find(|y| nearer(shot.at(column(0), *y), outline, surface)) + .expect("竖线上找不到 Outlined 静息描边"); + let rest_ink = line_ink( + &shot, + column(0) - 40, + column(0) + 40, + rest_y.saturating_sub(2), + rest_y + 3, + surface, + outline, + ); + // 描边的墨量随**亚像素相位**在 1.0~1.67 之间浮动(同一 1dp:整行对齐 = 1.0, + // 落在半像素上 = 1 行满 + 1 行 0.67)。所以静息只做宽松下限,**宽度差异由比值判**。 + assert!( + (0.8..=1.9).contains(&rest_ink), + "9.4 Outlined 静息描边应 1dp(实测墨量 {rest_ink:.2};2dp 会到 2.0)" + ); + + // 聚焦(第 4 列)描边 2dp + let focus_y = ((filled_bottom + 8)..shot.height) + .find(|y| nearer(shot.at(column(3), *y), primary, surface)) + .expect("竖线上找不到 Outlined 聚焦描边"); + let focus_ink = line_ink( + &shot, + column(3) - 40, + column(3) + 40, + focus_y.saturating_sub(2), + focus_y + 4, + surface, + primary, + ); + assert!( + (1.8..=2.3).contains(&focus_ink), + "9.4 Outlined 聚焦描边应 2dp(实测墨量 {focus_ink:.2};1dp 会掉到 1.67)" + ); + let ratio = focus_ink / rest_ink; + assert!( + (1.6..=2.5).contains(&ratio), + "聚焦必须正好粗一倍(2dp/1dp):实测比值 {ratio:.2}({focus_ink:.2} vs {rest_ink:.2})" + ); + + // 9.7:错误格——描边 2dp + 标签(框内上带)+ 辅助文本(框下)全 `Error` + let error_box = shot + .bbox_of(error, 2000) + .into_iter() + .filter(|b| b.width() >= 280) + .max_by_key(|b| b.width()) + .expect("找不到错误态字段"); + assert!( + error_box.height() >= 50, + "错误格应是一整个 320 宽的字段(实测 {}×{})", + error_box.width(), + error_box.height() + ); + let label_ink = shot.count_in( + error, + error_box.left, + error_box.right + 1, + error_box.top + 2, + error_box.top + 22, + ); + let supporting_ink = shot.count_in( + error, + error_box.left, + error_box.right + 1, + error_box.bottom + 1, + error_box.bottom + 30, + ); + assert!( + label_ink > 20, + "9.7 错误标签没变成 Error(框内上带墨迹 {label_ink}px)" + ); + assert!( + supporting_ink > 20, + "9.7 错误辅助文本没变成 Error(框下墨迹 {supporting_ink}px)" + ); + } +}