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

@@ -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()
);
}
}