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,156 @@
//! 交互外壳:`button` 的四态 → 内部自绘容器的样式;以及勾形的 SVG。
//!
//! ## 为什么需要 [`StateSource`] 这条「帧内通道」
//!
//! M3 的 Checkbox / Radio / Switch 状态层是**Ø40 的圆**,而命中区是 **48**
//! (`research/15` §18.5 / §18.9)——两者尺寸不同,所以状态层不可能就是 `button` 自己的
//! 矩形四角(`button` 只能给自己的 bounds 上色)。正确画法是「48 的透明按钮里放一个 40 的圆」,
//! 但这个圆的颜色要跟着**按钮的状态**走,而 iced 的 `container` 样式闭包只有 `&Theme`、拿不到
//! `button::Status`。
//!
//! 所以这里留一个帧内通道:`button` 的样式闭包先跑(父先于子绘制,`iced_widget::button::draw`
//! 第 382 行 `fill_quad` 在 `content.draw` 之前),把状态写进 [`StateSource`];子容器的样式闭包
//! 随后读到**本帧**的状态。α 仍然只从 [`super::super::interaction`] 取,这里不做任何颜色算术。
//!
//! 取证(gallery 状态矩阵)时用 [`StateSource::forced`] 直接钉住某态,通道根本不参与。
use std::cell::Cell;
use std::rc::Rc;
use iced::widget::{button, svg};
use iced::{Color, Element, Length, Padding};
use super::super::interaction::Interaction;
use super::geometry::ControlGeometry;
/// 当前视觉态的取值口:**外部钉住**(取证)或**跟随 `button` 的实况**(运行期)。
#[derive(Debug, Clone)]
pub struct StateSource {
forced: Option<Interaction>,
live: Option<Rc<Cell<Interaction>>>,
enabled: bool,
}
impl StateSource {
/// `forced = Some(..)`:钉住某态(gallery 用);`None`:跟随 `button`。
///
/// `enabled = false`(没有消息 = 禁用)时直接返回 [`Interaction::Disabled`]:iced 的
/// `Button::status` 在**首帧**还是 `None`(→ 按 `Disabled` 画),靠它判断禁用会让首帧
/// 画错色,所以禁用这件事由调用方给定,通道只管 hover/pressed。
pub fn new(forced: Option<Interaction>, enabled: bool) -> Self {
Self {
forced,
live: (forced.is_none() && enabled).then(|| Rc::new(Cell::new(Interaction::Enabled))),
enabled,
}
}
/// 本帧要画的态。
pub fn get(&self) -> Interaction {
if let Some(forced) = self.forced {
return forced;
}
if !self.enabled {
return Interaction::Disabled;
}
self.live
.as_ref()
.map_or(Interaction::Enabled, |live| live.get())
}
/// `button` 的样式闭包里调(把实况写进通道)。
fn record(&self, status: button::Status) {
if let (Some(live), true) = (&self.live, self.enabled) {
live.set(Interaction::from_status(status));
}
}
/// 一个 `container::Style` 闭包:每次绘制按「本帧的态」求值。
pub fn container_style<'a, F>(
&self,
build: F,
) -> impl Fn(&iced::Theme) -> iced::widget::container::Style + 'a
where
F: Fn(Interaction) -> iced::widget::container::Style + 'a,
{
let source = self.clone();
move |_theme| build(source.get())
}
}
/// 透明的交互外壳:尺寸由调用方给定,视觉**全部**由内部容器负责。
///
/// 交互外壳自己不画任何东西——它有且只有一个职责:把 48 的命中区交出去,并把
/// `button` 的状态写进 [`StateSource`]。
pub(super) fn shell<'a, M: Clone + 'a>(
source: &StateSource,
width: Length,
height: Length,
on_press: Option<M>,
content: Element<'a, M>,
) -> Element<'a, M> {
let recorder = source.clone();
let element = button(content)
.width(width)
.height(height)
.padding(Padding::default())
.style(move |_theme, status| {
recorder.record(status);
button::Style {
background: None,
text_color: Color::TRANSPARENT,
..button::Style::default()
}
});
match on_press {
Some(message) => element.on_press(message).into(),
None => element.into(),
}
}
/// 勾形 / 短横的 SVG(`research/15` §18.4 / §18.6)。
///
/// 为什么用 SVG 而不是自绘:勾形是**斜线**,iced 的 `fill_quad` 只能画轴对齐矩形,而
/// `canvas` feature 没开(`crates/app-iced/Cargo.toml` 不在本 mission 的 scope)。
/// `stroke-linecap="square"` 与 Compose 的 `StrokeCap.Square` 同义;路径比例
/// (0.2 / 0.4 / 0.5 / 0.7 / 0.8)是 `research/15` §25 明确允许的组件级常量。
///
/// `indeterminate` = 三点共线于 `y = 0.5`(`crossCenterGravitation = 1`)→ **形变**成一条居中短横,
/// **不新增图标**(§18.6)。
pub(super) fn glyph<'a, M: 'a>(
geometry: ControlGeometry,
indeterminate: bool,
ink: Color,
) -> Element<'a, M> {
let side = geometry.side;
let point = |x: f32, y: f32| (side * x, side * y);
let path = if indeterminate {
// 三个控制点被拉到同一条水平线(勾形消失,只剩短横)
let (x0, y0) = point(0.2, 0.5);
let (x1, y1) = point(0.8, 0.5);
format!("M {x0:.2} {y0:.2} L {x1:.2} {y1:.2}")
} else {
let (x0, y0) = point(0.2, 0.5);
let (x1, y1) = point(0.4, 0.7);
let (x2, y2) = point(0.8, 0.3);
format!("M {x0:.2} {y0:.2} L {x1:.2} {y1:.2} L {x2:.2} {y2:.2}")
};
let source = format!(
"<svg xmlns=\"http://www.w3.org/2000/svg\" width=\"{side}\" height=\"{side}\" \
viewBox=\"0 0 {side} {side}\"><path d=\"{path}\" fill=\"none\" stroke=\"currentColor\" \
stroke-width=\"{}\" stroke-linecap=\"square\" stroke-linejoin=\"miter\"/></svg>",
geometry.stroke
);
svg(svg::Handle::from_memory(source.into_bytes()))
.width(Length::Fixed(side))
.height(Length::Fixed(side))
.style(move |_theme, _status| svg::Style { color: Some(ink) })
.into()
}
/// 一个固定边长的正方形(内部再居中):Q9 的纪律,字形一律锁在盒子里。
pub(super) fn square<'a, M: 'a>(side: f32, content: impl Into<Element<'a, M>>) -> Element<'a, M> {
super::super::layout::centered_fixed(side, side, content)
}