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,176 @@
//! Switch(`research/12b` §10):轨道 **52×32** / 圆角 full(16) / 未选中 2px `Outline` 描边 /
//! 滑块 **24·16**(按下 28,见偏离 D-SW1)/ 滑块内图标 16 / **Ø40 状态层**。
//!
//! ## 状态层为什么跟滑块走
//!
//! M3 的 switch 状态层是围着**滑块**的 Ø40 圆(`StateLayerSize = 40`),而轨道只有 32 高、
//! 52 宽、滑块最小 16——圆会同时超出轨道的上下与左右。所以绘制区比轨道大一圈:
//! 宽 = 52 + 2×4([`SwitchGeometry::draw_width`])、高 = 40,轨道在中间,滑块与状态层浮在上面。
//!
//! ## 偏离 D-SW1(登记在报告里)
//!
//! `PressedHandleWidth = 28` 的「按下胀开」**未做**:iced 的 `button` 只在事件时重绘,
//! 不会重建视图,而滑块尺寸是**布局量**(不是颜色)。要做它必须写自定义 `Widget`
//! (`interaction::Tracker` 那条路),本 mission 的时间盒留给四条线的像素断言与视觉证据。
//! 按下的可判定反馈仍在:状态层 α 从 8% 升到 10%(`StateLayer::Pressed`)。
use iced::widget::{container, row, text, Space as Spacer};
use iced::{Alignment, Element, Length, Padding};
use unlucky_design::icons::IconKind;
use unlucky_design::spacing::Space;
use unlucky_design::typography::TypeRole;
use crate::icons;
use crate::theme::Ui;
use super::super::interaction::Interaction;
use super::geometry::SwitchGeometry;
use super::paint::{self, Frame};
use super::shell::{self, StateSource};
/// 一个开关(轨道 + 滑块 + 可选标签;`on_toggle` 为 `None` = 禁用)。
#[derive(Debug, Clone)]
pub struct Switch<'a, M> {
label: &'a str,
value: bool,
on_toggle: Option<M>,
state: Option<Interaction>,
}
impl<'a, M> Switch<'a, M> {
/// 一个可切换的开关。
pub fn new(label: &'a str, value: bool, on_toggle: M) -> Self {
Self {
label,
value,
on_toggle: Some(on_toggle),
state: None,
}
}
/// 禁用的开关(无消息)。
pub fn disabled(label: &'a str, value: bool) -> Self {
Self {
label,
value,
on_toggle: None,
state: None,
}
}
/// 强制视觉态(gallery 取证用;运行期不要调)。
pub fn state(mut self, state: Interaction) -> Self {
self.state = Some(state);
self
}
}
/// 画一个开关(轨道 + 滑块 + 标签)。
pub fn switch<'a, M: Clone + 'a>(ui: &'a Ui, spec: Switch<'a, M>) -> Element<'a, M> {
let geometry = SwitchGeometry::of(ui);
let selected = spec.value;
let thumb_side = geometry.thumb(selected, false);
let source = StateSource::new(spec.state, spec.on_toggle.is_some());
// 滑块内的勾:颜色只随选中/禁用变(A1 的 `Selected*IconColor` 都是 `OnPrimaryContainer`),
// 所以静态求值;未选中态官方图标色 = 轨道色(画了也看不见),因此不画。
let at_rest = paint::switch(ui, selected, source.get());
let glyph: Element<'a, M> = match at_rest.icon {
Some(ink) => super::super::layout::centered_fixed(
geometry.icon,
geometry.icon,
icons::view::<M>(IconKind::Check, ink),
),
None => Spacer::new().into(),
};
// 滑块(颜色跟态走:hover/pressed 是 `PrimaryContainer`)
let thumb = container(glyph)
.width(Length::Fixed(thumb_side))
.height(Length::Fixed(thumb_side))
.align_x(Alignment::Center)
.align_y(Alignment::Center)
.style(source.container_style(move |state| {
Frame {
fill: Some(paint::switch(ui, selected, state).thumb),
outline: None,
stroke: 0.0,
radius: thumb_side / 2.0,
}
.container_style()
}));
// Ø40 状态层(围着滑块,圆心与滑块同心)
let layer_side = geometry.state_layer;
let layer = container(thumb)
.width(Length::Fixed(layer_side))
.height(Length::Fixed(layer_side))
.align_x(Alignment::Center)
.align_y(Alignment::Center)
.style(source.container_style(move |state| {
Frame {
fill: paint::switch_layer(ui, state, selected),
outline: None,
stroke: 0.0,
radius: layer_side / 2.0,
}
.container_style()
}));
// 轨道(52×32;未选中态有 2px `Outline` 描边)
let track = container(Spacer::new())
.width(Length::Fixed(geometry.track_width))
.height(Length::Fixed(geometry.track_height))
.style(source.container_style(move |state| {
paint::switch(ui, selected, state).track.container_style()
}));
// 绘制区 = 轨道 + 状态层外扩;两层叠放(层序:轨道在下,滑块与状态层在上)
let draw_width = geometry.draw_width();
let draw_height = geometry.draw_height();
let area = iced::widget::stack![
container(track)
.width(Length::Fixed(draw_width))
.height(Length::Fixed(draw_height))
.padding(Padding {
left: geometry.bleed(),
..Padding::default()
})
.align_y(Alignment::Center),
container(layer)
.width(Length::Fixed(draw_width))
.height(Length::Fixed(draw_height))
.padding(Padding {
left: geometry.layer_offset(selected, thumb_side),
..Padding::default()
}),
]
.width(Length::Fixed(draw_width))
.height(Length::Fixed(draw_height));
let mut content = row![area]
.align_y(Alignment::Center)
.spacing(ui.space(Space::X3));
if !spec.label.is_empty() {
let style = ui.style(TypeRole::BodyMedium);
content = content.push(
text(spec.label)
.size(style.size_px)
.font(ui.font_of(style))
.color(paint::label(ui, source.get())),
);
}
let body = container(content)
.height(Length::Fill)
.align_y(Alignment::Center);
shell::shell(
&source,
Length::Shrink,
Length::Fixed(geometry.hit),
spec.on_toggle,
body.into(),
)
}