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

@@ -0,0 +1,307 @@
//! 选择类家族的单测:几何取自令牌、角色取自 A1(`#[cfg(test)]` 下的纯值断言)。
//!
//! **这些测试测的是「值与映射」**——它们抓不住「函数对、图错」(M68 的教训),
//! 像素级几何在 `components/gallery/selection.rs` 的 `probe` 里对着真截图量。
use unlucky_design::color::{Role, SchemeFlavor};
use unlucky_design::typography::Platform;
use super::super::interaction::Interaction;
use super::geometry::{ChipGeometry, ControlGeometry, SwitchGeometry};
use super::paint::{self, ChipVariant};
use crate::theme::{Families, Ui};
const FAMILIES: Families = Families {
text: "Segoe UI",
display: "Segoe UI",
mono: "Consolas",
};
fn ui() -> Ui {
Ui::with_platform(SchemeFlavor::Light, Platform::Windows, FAMILIES)
}
/// §18.1 / §18.5 / §18.9:Checkbox 的四个参考值全部来自令牌层。
#[test]
fn checkbox_geometry_comes_from_the_token_layer() {
let ui = ui();
let g = ControlGeometry::checkbox(&ui);
assert_eq!(g.side, 20.0, "18.1:容器 20×20(ContainerWidth=18 是死值)");
assert_eq!(g.radius, 2.0, "18.1:圆角 2(ComponentRadius::Micro)");
assert_eq!(g.stroke, 2.0, "18.2:未选中描边 2px");
assert_eq!(g.state_layer, 40.0, "18.5:状态层 Ø40");
assert_eq!(g.hit, 48.0, "18.9:命中区 48");
assert_eq!(g.gap, 8.0, "18.9:框↔标签 8([估] 团队口径)");
assert_eq!(g.inner_dot, 0.0, "Checkbox 没有内点");
// 18.4:勾形横向端点跨度 = 0.8−0.2 = 0.6 × 20 = 12
assert!((g.check_span() - 12.0).abs() < 0.001, "18.4:勾横向 12dp");
// 方头外扩:每端 2/√2 ≈ 1.41 → 墨迹跨度 ≈ 14.8(参考值 12 是端点跨度,不是墨迹)
let bleed = g.square_cap_bleed();
assert!(
(bleed - std::f32::consts::SQRT_2).abs() < 0.001,
"方头外扩 1/√2×线宽"
);
assert!(
(g.check_span() + bleed * 2.0 - 14.83).abs() < 0.05,
"墨迹跨度 ≈14.83"
);
}
/// §19.1 / §19.2:Radio 外圈 20、环线 2、**内点 10 不是 12**、真圆。
#[test]
fn radio_inner_dot_is_ten_not_twelve() {
let ui = ui();
let g = ControlGeometry::radio(&ui);
assert_eq!(g.side, 20.0, "19.1:外圈直径 20");
assert_eq!(g.stroke, 2.0, "19.1:环线 2px");
assert_eq!(g.radius, 10.0, "19.1:真圆(半径 = 边长/2)");
assert_eq!(g.inner_dot, 10.0, "19.2:内点可视直径 10");
assert_ne!(
g.inner_dot, 12.0,
"19.2:12 是含描边前的名义值,抄 12 会明显偏大"
);
assert_eq!(g.state_layer, 40.0, "19.7:状态层 Ø40");
assert_eq!(g.hit, 48.0, "19.7:命中区 48");
}
/// §10.1 / §10.2 / §10.6:Switch 的三档滑块与状态层,以及「状态层必然溢出轨道」这条布局不变量。
#[test]
fn switch_geometry_matches_the_tokens() {
let ui = ui();
let g = SwitchGeometry::of(&ui);
assert_eq!(g.track_width, 52.0, "10.1:轨道宽 52");
assert_eq!(g.track_height, 32.0, "10.1:轨道高 32");
assert_eq!(g.track_stroke, 2.0, "10.3:未选中态 2px Outline");
assert_eq!(g.thumb(false, false), 16.0, "10.2:未选中滑块 16");
assert_eq!(g.thumb(true, false), 24.0, "10.2:选中滑块 24");
assert_eq!(g.thumb(false, true), 28.0, "10.2:按下滑块 28");
assert_eq!(g.thumb(true, true), 28.0, "10.2:按下档与选中无关");
assert_eq!(g.icon, 16.0, "10.5:滑块内图标 16");
assert_eq!(g.state_layer, 40.0, "10.6:状态层 Ø40");
assert_eq!(g.hit, 48.0, "命中区 48");
// 内距公式在三个档位都成立(32−24=8→4、32−16=16→8、32−28=4→2)
for thumb in [24.0, 16.0, 28.0] {
assert!((g.thumb_inset(thumb) * 2.0 + thumb - g.track_height).abs() < 0.001);
}
// 状态层必然溢出轨道左右:未选中时滑块靠左、选中时靠右
assert_eq!(g.bleed(), 4.0, "状态层比轨道高 → 上下各溢出 4");
assert_eq!(g.draw_width(), 60.0, "绘制区宽 = 52 + 2×4");
for (selected, thumb) in [(false, 16.0), (true, 24.0), (false, 28.0), (true, 28.0)] {
let offset = g.layer_offset(selected, thumb);
assert!(
offset >= 0.0,
"状态层左缘不得为负(selected={selected} thumb={thumb} → {offset})"
);
// 圆心与滑块同心:offset + 半径 = 滑块圆心 + 外扩
let center = offset + g.layer_radius();
assert!((center - (g.thumb_center(selected, thumb) + g.bleed())).abs() < 0.001);
}
}
/// §13.1 / §13.3 / §13.4:Chip 高 32、圆角 8、内边距 8、图标 16(官方 18 → G14)。
#[test]
fn chip_geometry_matches_the_tokens() {
let ui = ui();
let g = ChipGeometry::of(&ui);
assert_eq!(g.height, 32.0, "13.1:容器高 32");
assert_eq!(g.radius, 8.0, "13.1:圆角 8(ContainerShape=CornerSmall)");
assert_eq!(g.pad, 8.0, "13.3:水平内边距 8");
assert_eq!(g.gap, 8.0, "13.3:元素间距 8");
assert_eq!(g.icon, 16.0, "13.4:图标 16(官方 18 → G14 偏离)");
}
/// §18.2 / A1:未选中 Checkbox 在 hover/focus/pressed 下描边提到 `OnSurface`(不是 `OnSurfaceVariant`)。
#[test]
fn checkbox_unselected_lifts_the_outline_to_on_surface() {
let ui = ui();
let g = ControlGeometry::checkbox(&ui);
let at_rest = paint::checkbox(&ui, false, false, Interaction::Enabled, g.radius, g.stroke);
assert_eq!(
at_rest.frame.outline,
Some(ui.color(Role::OnSurfaceVariant))
);
for state in [
Interaction::Hovered,
Interaction::Focused,
Interaction::Pressed,
] {
let lifted = paint::checkbox(&ui, false, false, state, g.radius, g.stroke);
assert_eq!(
lifted.frame.outline,
Some(ui.color(Role::OnSurface)),
"A1:{state:?} 的 UnselectedOutlineColor = OnSurface"
);
assert_eq!(lifted.frame.stroke, 2.0, "18.8:描边宽度不随态变");
}
// 错误态优先于 hover
let error = paint::checkbox(&ui, false, true, Interaction::Hovered, g.radius, g.stroke);
assert_eq!(error.frame.outline, Some(ui.color(Role::Error)));
}
/// §18.8:禁用不是「变淡变细」——描边仍 2;选中禁用是 `OnSurface`@38% 容器 + `Surface` 勾。
#[test]
fn checkbox_disabled_keeps_the_stroke_and_uses_the_a1_roles() {
let ui = ui();
let g = ControlGeometry::checkbox(&ui);
let unchecked = paint::checkbox(&ui, false, false, Interaction::Disabled, g.radius, g.stroke);
assert_eq!(unchecked.frame.stroke, 2.0, "18.8:禁用描边仍 2px");
assert_eq!(unchecked.frame.fill, None, "18.8:未选中禁用内部仍透明");
assert!(
unchecked
.frame
.outline
.is_some_and(|color| color != ui.color(Role::OnSurfaceVariant)),
"18.8:禁用描边是 OnSurface@38%,不是满档角色色"
);
let checked = paint::checkbox(&ui, true, false, Interaction::Disabled, g.radius, g.stroke);
assert_eq!(
checked.glyph,
ui.color(Role::Surface),
"A1:SelectedDisabledIconColor = Surface"
);
assert!(checked.frame.fill.is_some());
assert_ne!(
checked.frame.fill,
Some(ui.color(Role::Primary)),
"禁用不保留 Primary"
);
}
/// §19.4:**选中态 hover/pressed 不换环色**(只有状态层变)。
#[test]
fn radio_keeps_primary_on_hover_when_selected() {
let ui = ui();
let g = ControlGeometry::radio(&ui);
let primary = Some(ui.color(Role::Primary));
for state in Interaction::ALL {
if state.is_disabled() {
continue;
}
let paint = paint::radio(&ui, true, state, g.radius, g.stroke);
assert_eq!(
paint.ring.outline, primary,
"19.4:{state:?} 环仍是 Primary"
);
assert_eq!(paint.dot, primary, "19.4:{state:?} 点仍是 Primary");
}
// 未选中:静息 OnSurfaceVariant → hover/focus/pressed 提到 OnSurface
let at_rest = paint::radio(&ui, false, Interaction::Enabled, g.radius, g.stroke);
assert_eq!(at_rest.dot, None, "未选中不画内点");
assert_eq!(at_rest.ring.outline, Some(ui.color(Role::OnSurfaceVariant)));
let lifted = paint::radio(&ui, false, Interaction::Hovered, g.radius, g.stroke);
assert_eq!(lifted.ring.outline, Some(ui.color(Role::OnSurface)));
}
/// §10.4 + A1:选中态 hover 的滑块是 `PrimaryContainer`;禁用选中滑块是 `Surface`(不透明度 1)。
#[test]
fn switch_handle_roles_follow_the_tokens() {
let ui = ui();
let at_rest = paint::switch(&ui, true, Interaction::Enabled);
assert_eq!(at_rest.track.fill, Some(ui.color(Role::Primary)));
assert_eq!(at_rest.thumb, ui.color(Role::OnPrimary));
assert_eq!(at_rest.icon, Some(ui.color(Role::OnPrimaryContainer)));
let hovered = paint::switch(&ui, true, Interaction::Hovered);
assert_eq!(
hovered.thumb,
ui.color(Role::PrimaryContainer),
"A1:选中 hover 滑块 PrimaryContainer"
);
assert_eq!(
hovered.track.fill,
Some(ui.color(Role::Primary)),
"轨道不随 hover 变"
);
let off = paint::switch(&ui, false, Interaction::Enabled);
assert_eq!(
off.track.outline,
Some(ui.color(Role::Outline)),
"10.3:未选中 2px Outline"
);
assert_eq!(off.track.stroke, 2.0);
assert_eq!(
off.thumb,
ui.color(Role::Outline),
"10.4:未选中滑块 Outline"
);
assert_eq!(off.icon, None, "未选中态图标与轨道同色(不画)");
let disabled = paint::switch(&ui, true, Interaction::Disabled);
assert_eq!(
disabled.thumb,
ui.color(Role::Surface),
"A1:DisabledSelectedHandleColor=Surface"
);
}
/// §13.5 / §13.6 / §13.7:选中态**无描边**、Assist 不可选中、禁用文字 @38%。
#[test]
fn chip_roles_follow_the_tokens() {
let ui = ui();
let radius = ChipGeometry::of(&ui).radius;
let selected = paint::chip(&ui, ChipVariant::Filter, true, Interaction::Enabled, radius);
assert_eq!(
selected.frame.fill,
Some(ui.color(Role::SecondaryContainer))
);
assert_eq!(selected.frame.outline, None, "13.5:选中描边 0dp");
assert_eq!(selected.label, ui.color(Role::OnSecondaryContainer));
let unselected = paint::chip(
&ui,
ChipVariant::Filter,
false,
Interaction::Enabled,
radius,
);
assert_eq!(
unselected.frame.outline,
Some(ui.color(Role::OutlineVariant))
);
assert_eq!(unselected.label, ui.color(Role::OnSurfaceVariant));
// Assist 没有选中态:即使传 selected = true 也不会变 SecondaryContainer
let assist = paint::chip(&ui, ChipVariant::Assist, true, Interaction::Enabled, radius);
assert_ne!(assist.frame.fill, Some(ui.color(Role::SecondaryContainer)));
assert_eq!(
assist.icon,
ui.color(Role::Primary),
"13.4:Assist 前置图标 Primary"
);
let disabled = paint::chip(
&ui,
ChipVariant::Filter,
false,
Interaction::Disabled,
radius,
);
assert_ne!(disabled.label, unselected.label, "13.7:禁用文字降到 38%");
}
/// 状态层只在三态存在,且**不是**满档角色色(α 由 `interaction` 给)。
#[test]
fn state_layer_only_exists_on_lifted_states() {
let ui = ui();
for (state, expected) in [
(Interaction::Enabled, false),
(Interaction::Hovered, true),
(Interaction::Focused, true),
(Interaction::Pressed, true),
(Interaction::Disabled, false),
] {
let layer = paint::state_layer(&ui, state, Role::OnSurfaceVariant);
assert_eq!(layer.is_some(), expected, "{state:?} 的状态层有无");
if let Some(color) = layer {
assert_eq!(color.a, 1.0, "状态层必须是实色(D27:CPU/GPU 一致)");
}
}
// 同态下 hover 比 pressed 浅(8% < 10%)
let hover = paint::state_layer(&ui, Interaction::Hovered, Role::OnSurface).unwrap();
let pressed = paint::state_layer(&ui, Interaction::Pressed, Role::OnSurface).unwrap();
assert_ne!(hover, pressed);
}