feat(app-iced): M76 选择类家族 —— Checkbox / Radio / Switch / Chip(15 §18/§19 + 12b §10/§13)

- selection/:geometry(令牌→像素,缺档登记 G16)/ paint(Interaction×选中×错误→角色)/
  shell(48 透明外壳 + 帧内状态通道 + 勾形 SVG)/ checkbox·radio·switch·chip / matrix(12×5 状态矩阵)/ tests
- gallery/selection.rs:小节 + **渲染层像素断言**(20×20 选中与未选中同尺寸 · Radio 内点 10 ·
  Ø40 状态层是圆 · 勾形 14.8 · 短横 · 轨道 52×32 · Chip 32 · 禁用 38%),按颜色+墨迹块定位、不假设行号
- gallery/mod.rs:新增 ULL_GALLERY_SECTION 段落过滤(tower 批准,M77 零成本复用)+ 四个 helper 放宽到 pub(crate)

A1 证据(本机 AAR javap):未选中 hover/focus/pressed 描边是 OnSurface(不是 OnSurfaceVariant)、
Radio 选中态 hover 仍 Primary、Checkbox SelectedDisabledIconColor=Surface、Switch hover 滑块 PrimaryContainer。
This commit is contained in:
AskaEth committed 2026-11-02 06:11:21 +08:00
1 parent 35345f3399
commit b69a2cdd7f
12 files changed
+2585 -56

No files matched your search

+155 -14
View File
@@ -35,6 +35,32 @@
//! ```
//!
//! `--screenshot` 不带主题时**默认 Light**(可复现;见 `cli::Options::capture_flavor`)。
//!
//! ## 段落过滤:环境变量 `ULL_GALLERY_SECTION`(M76 起)
//!
//! 五段拼成一列、外面套 `scrollable`,而截图只截**首屏** `1180×760`。段数一多,后面的家族
//! 就落到折叠线以下(实测:selection 段从 y≈735 起,只剩标题一行)——那条线的
//! 「渲染层像素断言」就没有一个像素可量。所以这里加一个**段落选择器**:
//!
//! ```text
//! ULL_GALLERY_SECTION=all # 默认:五段全渲染(行为与加开关之前逐像素一致)
//! ULL_GALLERY_SECTION=buttons # 只渲染 buttons 段
//! ULL_GALLERY_SECTION=selection # 只渲染 selection 段(本 mission 的取证与 probe 用它)
//! ```
//!
//! 取值:`all` / `buttons` / `divider` / `selection` / `inputs` / `feedback` / `navigation`。
//! 语义是「**不构建**没被选中的段」(不是构建后隐藏):`column` 里根本不 push 它,
//! 于是该家族的 `view` 一行都不跑。未识别的值**回落 `all`** 并在 stderr 打一行提示
//! (静默回落会让人拿错图还以为拍的是那一段)。
//!
//! 为什么用环境变量而不是 `--screen=gallery:<family>`:`cli.rs` 不归组件线(M82 在改它),
//! 而环境变量既能被测试(`cargo test` 里 `std::env::set_var`)又不用跟别人的 CLI 解析抢文件。
//! 各家族的 `demo()` 签名**不因此改变**——本文件只决定「push 哪几段」。
//!
//! 实际渲染了哪几段会打一行 `[gallery] section=... → 渲染 ...`(首帧一次),
//! 拿它当出图日志的证据。
use std::sync::atomic::{AtomicBool, Ordering};
use iced::widget::{column, container, scrollable, text};
use iced::{Alignment, Background, Element, Length};
@@ -62,6 +88,107 @@ mod probe;
mod scan;
mod selection;
/// 段落过滤的环境变量名(语义见模块文档「段落过滤」)。
pub const SECTION_ENV: &str = "ULL_GALLERY_SECTION";
/// 段落开关:**构建**哪几段(`false` = 这一段连 `view` 都不跑)。
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
struct Sections {
buttons: bool,
divider: bool,
selection: bool,
inputs: bool,
feedback: bool,
navigation: bool,
}
impl Sections {
/// 五段全渲染(默认)。
const ALL: Self = Self {
buttons: true,
divider: true,
selection: true,
inputs: true,
feedback: true,
navigation: true,
};
/// 只有一段。
fn only(name: &str) -> Option<Self> {
let mut sections = Self {
buttons: false,
divider: false,
selection: false,
inputs: false,
feedback: false,
navigation: false,
};
match name {
"buttons" => sections.buttons = true,
"divider" => sections.divider = true,
"selection" => sections.selection = true,
"inputs" => sections.inputs = true,
"feedback" => sections.feedback = true,
"navigation" => sections.navigation = true,
_ => return None,
}
Some(sections)
}
/// 读 `ULL_GALLERY_SECTION`。
///
/// 未设置 / 空 / `all`(大小写不敏感)→ 全渲染;**未识别的值 → 回落全渲染 + stderr 一行**。
fn from_env() -> Self {
let Ok(raw) = std::env::var(SECTION_ENV) else {
return Self::ALL;
};
let name = raw.trim().to_ascii_lowercase();
if name.is_empty() || name == "all" {
return Self::ALL;
}
if let Some(sections) = Self::only(&name) {
return sections;
}
eprintln!(
"[gallery] {SECTION_ENV}={raw:?} 不是已知段(all|buttons|divider|selection|inputs|feedback|navigation),已回落 all"
);
Self::ALL
}
/// 本次渲染的段名(stderr 日志 / 测试断言用)。
fn names(self) -> Vec<&'static str> {
let mut names = Vec::new();
for (on, name) in [
(self.buttons, "buttons"),
(self.divider, "divider"),
(self.selection, "selection"),
(self.inputs, "inputs"),
(self.feedback, "feedback"),
(self.navigation, "navigation"),
] {
if on {
names.push(name);
}
}
names
}
}
/// `[gallery] ... 渲染 ...` 只打一次(`view` 每帧都会跑)。
static SECTION_LOGGED: AtomicBool = AtomicBool::new(false);
fn log_sections(sections: Sections) {
if SECTION_LOGGED.swap(true, Ordering::Relaxed) {
return;
}
let raw = std::env::var(SECTION_ENV).unwrap_or_else(|_| "all".to_owned());
eprintln!(
"[gallery] section={raw} → 渲染 {} 段:{}",
sections.names().len(),
sections.names().join(", ")
);
}
/// gallery 自己的消息(与业务 `Message` 解耦,契约 §2)。
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum DemoMessage {
@@ -100,16 +227,30 @@ pub fn view<'a>(ui: &'a Ui) -> Element<'a, DemoMessage> {
// 小节之间**不画分隔线**:rubric(15 §22.4)要求同屏同类线 ≤3,
// 而验收图里要给分隔线自己留证据(见 `divider_proof`)。分组靠 32px 留白 + 标题。
let sections = column![
buttons::section(ui),
divider_proof(ui),
selection::section(ui),
inputs::section(ui),
feedback::section(ui),
navigation::section(ui),
]
.spacing(ui.space(Space::X8))
.width(Length::Fill);
//
// 段落过滤(`ULL_GALLERY_SECTION`):**没被选中的段根本不 push**,于是该家族的
// `view` 一行都不跑——不是「构建后隐藏」。默认 `all` 与加开关之前逐像素一致。
let picked = Sections::from_env();
log_sections(picked);
let mut sections = column![].spacing(ui.space(Space::X8)).width(Length::Fill);
if picked.buttons {
sections = sections.push(buttons::section(ui));
}
if picked.divider {
sections = sections.push(divider_proof(ui));
}
if picked.selection {
sections = sections.push(selection::section(ui));
}
if picked.inputs {
sections = sections.push(inputs::section(ui));
}
if picked.feedback {
sections = sections.push(feedback::section(ui));
}
if picked.navigation {
sections = sections.push(navigation::section(ui));
}
// 滚动条:默认是主题给的 10px 深灰 thumb,压过组件本体(视觉 reviewer P2-4)。
// 这里把它改成 **透明轨 + OutlineVariant thumb**;宽度 10px 是 `scrollable` 的内部常量
@@ -184,7 +325,7 @@ fn divider_proof<'a>(ui: &'a Ui) -> Element<'a, DemoMessage> {
///
/// 五个家族文件共用(`pub(super)`),保证五段的节奏一致——这也是 rubric D4
/// 「一屏字号档 ≤4」的落点:小节只有 `TitleMedium` + `BodySmall` 两档。
pub(super) fn heading<'a>(ui: &'a Ui, title: &'a str, note: &'a str) -> Element<'a, DemoMessage> {
pub(crate) fn heading<'a>(ui: &'a Ui, title: &'a str, note: &'a str) -> Element<'a, DemoMessage> {
let note_style = ui.style(TypeRole::BodySmall);
column![
text(title)
@@ -203,7 +344,7 @@ pub(super) fn heading<'a>(ui: &'a Ui, title: &'a str, note: &'a str) -> Element<
/// 状态矩阵的列头:五态的固定次序(`Interaction::ALL` 的口径)。
///
/// 列宽 = [`cell_width`](**160** = `Space::X8 × 5`,是矩阵栅格,不是组件尺寸)。
pub(super) fn state_cells(ui: &Ui) -> Vec<(&'static str, f32)> {
pub(crate) fn state_cells(ui: &Ui) -> Vec<(&'static str, f32)> {
Interaction::ALL
.iter()
.map(|state| (state.name(), cell_width(ui)))
@@ -215,12 +356,12 @@ pub(super) fn state_cells(ui: &Ui) -> Vec<(&'static str, f32)> {
/// 160 = `Space::X8 × 5`:5 列 + 1 个标签列 + 4 个列间距 + 页左右各 24
/// = 6×160 + 4×8 + 48 = **1040 < 1180**,所以第 5 列(disabled)不再被窗口右缘裁掉。
/// 早先是 192(×6),总宽 1232 撑破窗口,disabled 列被裁掉一半(视觉 reviewer P2-3)。
pub(super) fn cell_width(ui: &Ui) -> f32 {
pub(crate) fn cell_width(ui: &Ui) -> f32 {
ui.space(Space::X8) * 5.0
}
/// 一个矩阵行的左侧标签(变体名)。
pub(super) fn row_label<'a>(ui: &'a Ui, name: &'a str) -> Element<'a, DemoMessage> {
pub(crate) fn row_label<'a>(ui: &'a Ui, name: &'a str) -> Element<'a, DemoMessage> {
let style = ui.style(TypeRole::BodySmall);
container(
text(name)
@@ -1,25 +1,396 @@
//! gallery 里的选择类小节(**桩**):后续 mission 只改本文件。
//! gallery 里的选择类小节(D30:浅/深 × 五态的状态矩阵)+ **渲染层几何断言**。
//!
//! 现在直接转调 `components::selection::demo`,把该家族的占位塞进五段结构里;
//! 填充时把 `demo(ui)` 换成本家族的状态矩阵即可(列头/单元格工具见 `gallery/mod.rs`)。
//! 矩阵本体的构造在 [`selection::matrix`](12 行 × 5 列:checkbox 四态 / radio / switch /
//! chip 三类);本文件只负责「摆进 gallery」,消息用 `()` 再映射成 `DemoMessage`(组件不碰业务)。
//!
//! ## 断言量什么(每条对一个具体错误,容差 ±1)
//!
//! Checkbox 外缘 20 且**选中/未选中同尺寸**(§18.3:描边向内收;向外画会变 22)· Radio 内点
//! **10 不是 12** 且环是真圆(§19.2/§19.6)· 状态层 **Ø40 且是圆**(§18.5/§19.7)· 勾形墨迹
//! ≈14.8(端点 12 + 方头外扩)与 indeterminate 是短横(§18.4/§18.6)· Switch 轨道 52×32
//! (§10.1)· Chip 高 32(§13.1)· 禁用墨迹是 38% 的 on-surface。
//!
//! 定位方式:**颜色 + 含抗锯齿的墨迹块外接框**,不假设行号——段落过滤或段落顺序变化后仍成立。
//!
//! 复现(worktree 根目录):
//!
//! ```text
//! cargo build -p unlucky-app-iced --bin unlucky-launcher-iced
//! ULL_GALLERY_SECTION=selection ./target/debug/unlucky-launcher-iced \
//! --software-renderer --screen=gallery \
//! --screenshot target/m76/selection-light.png --screenshot-theme=light
//! ULL_GALLERY_SHOT=target/m76/selection-light.png cargo test -p unlucky-app-iced selection
//! ```
use iced::widget::column;
use iced::Element;
use super::super::selection;
use crate::theme::Ui;
use super::{heading, DemoMessage};
use super::DemoMessage;
/// 选择类家族的小节。
/// 选择类家族的小节:标题 + 矩阵 + 图例。
pub(super) fn section<'a>(ui: &'a Ui) -> Element<'a, DemoMessage> {
iced::widget::column![
heading(
ui,
"selection · checkbox / radio / switch / chip",
"12b §13/§10 + 15 §18/§19 —— pending(后续 mission 填充本文件)"
),
selection::demo::<DemoMessage>(ui),
let pressed = |()| DemoMessage::Pressed("selection");
column![
selection::matrix::heading_and_note(ui).map(pressed),
selection::matrix::matrix(ui).map(pressed),
selection::matrix::legend(ui).map(pressed),
]
.spacing(ui.space(unlucky_design::spacing::Space::X2))
.into()
}
#[cfg(test)]
mod probe {
// 判据与复现命令见本文件顶部模块文档。
use std::path::PathBuf;
use iced::Color;
use unlucky_design::color::{Role, SchemeFlavor};
use unlucky_design::typography::Platform;
use crate::app::components::interaction::{self, Interaction};
use crate::theme::{Families, Ui};
use super::super::scan::{matches, rgb, BBox, 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)
}
fn center(bbox: &BBox) -> (usize, usize) {
((bbox.left + bbox.right) / 2, (bbox.top + bbox.bottom) / 2)
}
/// `ULL_GALLERY_SHOT` → 截图路径(相对路径按工作区根解释,与 `gallery/probe.rs` 同口径)。
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))
}
/// 「墨迹块」:`fill` 与「底色→fill 的混色」都算墨,再按外接框尺寸筛。
///
/// 踩过的两个坑:① 按「精确色」连通(`Shot::bbox_of`)会把圆形的环切成碎片(每片面积都不到
/// 200 而被丢掉,连内点都量不到);② 按「更接近 fill 还是底色」判会让**另一种深色墨**混进来
/// (`OnSurfaceVariant` 比 `Primary` 更接近深色,于是未选中的框被当成 20×20 的 Primary 块)。
/// 判据因此是「像素落在底色→fill 的混色线上」:抗锯齿像素在线,另一种墨色不在。
fn ink_boxes(shot: &Shot, color: Color, under: Color, min: usize, max: usize) -> Vec<BBox> {
let (fill, base) = (rgb(color), rgb(under));
let mut seen = vec![false; shot.width * shot.height];
let mut boxes = Vec::new();
for y in 0..shot.height {
for x in 0..shot.width {
if seen[y * shot.width + x] || !on_ramp(shot.at(x, y), fill, base) {
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]
&& on_ramp(shot.at(nx, ny), fill, base)
{
seen[ny * shot.width + nx] = true;
stack.push((nx, ny));
}
}
}
if (min..=max).contains(&bbox.width()) && (min..=max).contains(&bbox.height()) {
boxes.push(bbox);
}
}
}
boxes
}
/// 尺寸容差 ±1(rubric §6 的默认容差):控件常落在半像素上,实测会多一行抗锯齿。
fn near(value: usize, expected: usize) -> bool {
value.abs_diff(expected) <= 1
}
/// `bbox` 内缩 `inset` 后、落在 **`under → color` 混色线上**的墨迹框(量内点用)。
///
/// 内缩 4 而不是 3(描边内缘 AA 正好压在 3 上,会把 Ø10 量成 14);用混色线而不是精确色
/// (Ø10 的圆只有 ~8×8 是精确色,边缘 1px 全是抗锯齿)。
fn inner_ink(
shot: &Shot,
bbox: &BBox,
color: Color,
under: Color,
inset: usize,
) -> Option<BBox> {
let mut ink: Option<BBox> = None;
for y in (bbox.top + inset)..=(bbox.bottom - inset) {
for x in (bbox.left + inset)..=(bbox.right - inset) {
if on_ramp(shot.at(x, y), rgb(color), rgb(under)) {
match &mut ink {
Some(found) => found.include(x, y),
None => ink = Some(BBox::new(x, y)),
}
}
}
}
ink
}
fn dims(boxes: &[BBox]) -> String {
boxes
.iter()
.map(|bbox| format!("{}×{}", bbox.width(), bbox.height()))
.collect::<Vec<_>>()
.join(", ")
}
/// `pixel` 是否落在 `under → fill` 这条混色线上(覆盖率 ≥30%、偏离线 ≤14)。
///
/// 覆盖率 ≥30% 是为了把**底色本身**排除(否则整片背景会连成一块);
/// 偏离线 ≤14 是为了把「另一种深色墨」排除(它不在线上,见 [`ink_boxes`] 的坑 2)。
fn on_ramp(pixel: [u8; 3], fill: [u8; 3], under: [u8; 3]) -> bool {
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 false;
}
let t: f32 = (0..3).map(|i| axis[i] * offset[i]).sum::<f32>() / square;
// 覆盖率 ≥50%(半个像素,取几何边界;更低会把 30% 的抗锯齿也算进去,Ø10 量成 12)
(0.5..=1.0).contains(&t)
&& (0..3)
.map(|i| (offset[i] - t * axis[i]).powi(2))
.sum::<f32>()
.sqrt()
<= 14.0
}
/// 一条测试覆盖全部几何断言(浅/深各跑一次)。
#[test]
fn selection_geometry_matches_the_reference() {
let Some(path) = shot_path() else {
eprintln!(
"[selection-probe] SKIPPED:未设置 ULL_GALLERY_SHOT。这是**渲染层**几何断言,\
需要先出图(见本文件模块文档,注意 ULL_GALLERY_SECTION=selection)。"
);
return;
};
let shot = Shot::load(&path).expect("读 selection 截图");
assert_eq!(
(shot.width, shot.height),
(1180, 760),
"验收图必须 1180×760 @100% DPI"
);
let light = matches(
shot.at(2, 2),
rgb(ui(SchemeFlavor::Light).color(Role::Surface)),
);
let flavor = if light {
SchemeFlavor::Light
} else {
SchemeFlavor::Dark
};
let ui = ui(flavor);
let surface = ui.color(Role::Surface);
let primary = ui.color(Role::Primary);
let on_primary = ui.color(Role::OnPrimary);
let unselected = ui.color(Role::OnSurfaceVariant);
// ---- §18.1 / §18.3 / §19.1:20 的外缘,选中与未选中**同尺寸** -----------------
// 选中/部分选中(填 `Primary`)与 Radio 环都该是 20×20;**描边若向外画**,未选中会是 22。
let filled = ink_boxes(&shot, primary, surface, 15, 24);
let twenty: Vec<BBox> = filled
.iter()
.copied()
.filter(|bbox| near(bbox.width(), 20) && near(bbox.height(), 20))
.collect();
assert!(
twenty.len() >= 8,
"18.1:至少 8 个 20×20 的 Primary 块(checkbox 选中/部分选中 + radio 环),\
实际 {} 个,尺寸:{}",
twenty.len(),
dims(&filled)
);
let candidates = ink_boxes(&shot, unselected, surface, 17, 24);
let rings: Vec<BBox> = candidates
.iter()
.copied()
.filter(|bbox| !matches(shot.at(center(bbox).0, bbox.top + 3), rgb(unselected)))
.collect();
assert!(
!rings.is_empty(),
"18.2:没找到未选中的复选框(OnSurfaceVariant 描边、内部透明);17~24 候选:{}",
dims(&candidates)
);
for bbox in &rings {
assert!(
near(bbox.width(), 20) && near(bbox.height(), 20),
"18.3:未选中外缘必须与选中同尺寸 20(描边向内收),实测 {}×{} @ {:?}\
(22 = 描边画到框外,是历史错误)",
bbox.width(),
bbox.height(),
(bbox.left, bbox.top)
);
assert!(
!matches(shot.at(center(bbox).0, center(bbox).1), rgb(unselected)),
"18.2:未选中框内部必须透出底色"
);
}
// A1:hover/pressed/focused 三列的未选中描边提到 `OnSurface`
let on_surface = ui.color(Role::OnSurface);
let lifted = ink_boxes(&shot, on_surface, surface, 17, 24)
.iter()
.filter(|bbox| !matches(shot.at(center(bbox).0, bbox.top + 3), rgb(on_surface)))
.count();
assert!(
lifted >= 3,
"A1:未选中复选框在 hover/pressed/focused 的描边应是 OnSurface(3 个),实际 {lifted}"
);
// ---- §19.2:Radio 内点 **10 不是 12**;§19.6:环是真圆 ------------------------
// 环/实心看**上沿内缩 3px 的点**(环在那里是内孔);不看正中心——短横正好压中心。
let mut dots = 0;
for bbox in &twenty {
if matches(shot.at(center(bbox).0, bbox.top + 3), rgb(primary)) {
continue;
}
assert!(
!matches(shot.at(bbox.left + 1, bbox.top + 1), rgb(primary)),
"19.6:Radio 外圈必须是真圆(不是圆角方)@ {:?}",
(bbox.left, bbox.top)
);
let dot = inner_ink(&shot, bbox, primary, surface, 4).expect("选中的 radio 必须有内点");
assert!(
near(dot.width(), 10) && near(dot.height(), 10),
"19.2:Radio 内点可视直径 10(12 是含描边前的名义值),实测 {}×{}",
dot.width(),
dot.height()
);
assert_ne!(dot.width(), 12, "19.2:照名义值抄 12 会明显偏大");
dots += 1;
}
assert!(
dots >= 4,
"19.2:至少 4 个选中的 radio(四态),实际 {dots}"
);
// ---- §18.4 / §18.6:勾形墨迹跨度;indeterminate 是**短横**不是勾 --------------
let mut bars = 0;
let mut checks = 0;
for bbox in &twenty {
let Some(glyph) = inner_ink(&shot, bbox, on_primary, primary, 3) else {
continue;
};
if glyph.height() <= 4 {
bars += 1;
assert!(
glyph.width() >= 12,
"18.6:indeterminate 必须是居中短横,实测 {}×{}",
glyph.width(),
glyph.height()
);
} else {
checks += 1;
// 端点 12 + 方头两侧各 2/√2 ≈ 1.41 → 墨迹 ≈14.8(见 `ControlGeometry`)
assert!(
(13..=16).contains(&glyph.width()),
"18.4:勾形墨迹横向应 ≈14.8(端点 12 + 方头外扩),实测 {}",
glyph.width()
);
assert!(
glyph.height() >= 6,
"18.4:勾形有折点,实测高 {}",
glyph.height()
);
}
}
assert!(
bars >= 4 && checks >= 4,
"18.4/18.6:勾 {checks} 个、短横 {bars} 个(各应 ≥4)"
);
// ---- §18.5 / §19.7:状态层直径 40,且是**圆**(外接框四角不许有墨)-------------
let hover_layer = interaction::overlay_color(
&ui,
Interaction::Hovered,
Role::OnSurfaceVariant,
Role::Surface,
)
.expect("hover 有状态层");
let circles = ink_boxes(&shot, hover_layer, surface, 37, 43);
assert!(
circles
.iter()
.any(|bbox| near(bbox.width(), 40) && near(bbox.height(), 40)),
"18.5:没找到 Ø40 的状态层(颜色 {:?};实测尺寸:{})",
rgb(hover_layer),
dims(&circles)
);
for circle in circles
.iter()
.filter(|bbox| near(bbox.width(), 40) && near(bbox.height(), 40))
{
for (x, y) in [(circle.left, circle.top), (circle.right, circle.bottom)] {
assert!(
!matches(shot.at(x, y), rgb(hover_layer)),
"18.5:状态层必须是圆(外接框四角不该有墨)@ ({x},{y})"
);
}
}
// ---- §10.1 Switch 轨道 52×32 · §13.1 Chip 高 32 · §18.8 禁用 38% -------------
let on_tracks = ink_boxes(&shot, primary, surface, 28, 57);
assert!(
on_tracks
.iter()
.any(|bbox| near(bbox.width(), 52) && near(bbox.height(), 32)),
"10.1:选中的 switch 轨道也是 52×32,实测 {}",
dims(&on_tracks)
);
let chips = ink_boxes(&shot, ui.color(Role::SecondaryContainer), surface, 20, 140);
assert!(
chips
.iter()
.any(|bbox| near(bbox.height(), 32) && bbox.width() >= 58),
"13.1:选中的 filter chip 高必须 32,实测 {}",
dims(&chips)
);
let disabled_ink = interaction::disabled_content_over_role(&ui, Role::OnSurface, false);
let disabled = ink_boxes(&shot, disabled_ink, surface, 17, 24);
assert!(
disabled.len() >= 2,
"18.8:禁用态复选框/单选环应存在(on-surface@38%),实际 {} 个",
disabled.len()
);
eprintln!(
"[selection-probe] OK flavor={flavor:?}:20(±1) Primary 块 {} · 未选中环 {} · 内点 {dots} · \
勾 {checks} / 短横 {bars} · 状态层 {} · 禁用块 {}",
twenty.len(),
rings.len(),
circles.len(),
disabled.len()
);
}
}