Files
Unlucky-Launcher/crates/app-iced/src/components/selection/shell.rs
T
AskaEth b69a2cdd7f 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。
2026-11-02 06:11:21 +08:00

157 lines
6.2 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
//! 交互外壳:`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)
}