Files
Unlucky-Launcher/crates/app-iced/src/components/selection/geometry.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

246 lines
11 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.
//! 选择类家族的**几何**:值要么来自令牌,要么在此登记 G 编号(不散落在组件里)。
//!
//! ## 参考值 → 落点
//!
//! | # | 量 | 参考值 | 来源 | 落点 |
//! |---|---|---|---|---|
//! | 18.1 | Checkbox 外框 / 圆角 | **20×20** / **2** | `[源]` `Checkbox.kt:737-738`(`COMPONENT_WIDTH/HEIGHT=18` 是死值,见 §26.2) | `Component::Checkbox.metrics()` + `ComponentRadius::Micro` |
//! | 18.2 | Checkbox 描边 | **2**(选中时 0) | `[源]` `UnselectedOutlineWidth` | `ComponentMetrics::CHECKBOX.stroke` |
//! | 18.5 | Checkbox 状态层 | **Ø40** | `[源]` `StateLayerSize` | `ComponentMetrics::CHECKBOX.state_layer` |
//! | 18.9 | 命中区 / 标签间距 | **48** / **8** | `[源]` `minimumInteractiveComponentSize` / 间距 `[估]` | `LayoutMetrics::touch_target` / `metrics.gap` |
//! | 19.1 | Radio 外圈 / 环线 | **20** / **2** | `[源]` `IconSize` / `RadioStrokeWidth` | `Component::Radio.metrics()` |
//! | 19.2 | Radio 内点 | **10**(不是 12) | `[源]` `DotSize=12`,绘制半径 12/2−2/2=5 | `ComponentMetrics::RADIO.inner_dot` |
//! | 10.1 | Switch 轨道 | **52×32**,圆角 full(16) | `[源]` `TrackWidth/TrackHeight/TrackShape` | [`SwitchGeometry`](**G16**) |
//! | 10.2 | Switch 滑块 | 选中 **24** / 未选 **16** / 按下 **28** | `[源]` `Selected/Unselected/PressedHandleWidth` | [`SwitchGeometry`](**G16**) |
//! | 13.1 | Chip 高 / 圆角 | **32** / **8** | `[源]` `ContainerHeight` / `ContainerShape=CornerSmall` | `Space::X8` + `Component::Chip.spec().shape` |
//!
//! **G16 登记**(续 `research/15` §25 的 G13):`ComponentMetrics` 只有
//! `CHECKBOX/RADIO/SEARCH_BAR/NAVIGATION_DRAWER` 四组,**Switch 与 Chip 没有落点**。
//! 本文件把它们的量收成命名常量(每个都带 A1 出处),等令牌层补
//! `ComponentMetrics::SWITCH/CHIP` 后整段删除、改走 `Component::Switch.metrics()`。
//! 图标 **18** 沿用按钮家族的 **G14** 偏离(令牌只有 `IconSize::Button = 16`)。
use unlucky_design::component_map::{Component, ComponentMetrics};
use unlucky_design::icons::IconSize;
use unlucky_design::spacing::Space;
use crate::theme::Ui;
/// Switch 轨道宽(`SwitchTokens.TrackWidth = 52.0`,**G16**:令牌层无此档)。
const SWITCH_TRACK_WIDTH: f32 = 52.0;
/// Switch 轨道描边(`SwitchTokens.TrackOutlineWidth = 2.0`,**G16**)。
const SWITCH_TRACK_STROKE: f32 = 2.0;
/// Switch 选中态滑块(`SwitchTokens.SelectedHandleWidth = 24.0`,**G16**)。
const SWITCH_THUMB_SELECTED: f32 = 24.0;
/// Switch 未选中态滑块(`SwitchTokens.UnselectedHandleWidth = 16.0`,**G16**)。
const SWITCH_THUMB_UNSELECTED: f32 = 16.0;
/// Switch 按下态滑块(`SwitchTokens.PressedHandleWidth = 28.0`,**G16**)。
const SWITCH_THUMB_PRESSED: f32 = 28.0;
/// Switch 状态层(`SwitchTokens.StateLayerSize = 40.0`,**G16**;与 Checkbox 同值)。
const SWITCH_STATE_LAYER: f32 = 40.0;
/// Checkbox / Radio 共用的方框几何(两控件同尺寸、不同形状语言,§18 / §19)。
#[derive(Debug, Clone, Copy, PartialEq)]
pub struct ControlGeometry {
/// 可见外框边长(20)。
pub side: f32,
/// 描边 / 环线宽度(2;**禁用态不变细**,§18.8)。
pub stroke: f32,
/// 状态层圆的直径(40)。
pub state_layer: f32,
/// 最小命中区(48,`LayoutMetrics::touch_target`)。
pub hit: f32,
/// 框 ↔ 标签间距(8,团队口径 `[估]`)。
pub gap: f32,
/// 圆角半径:Checkbox = 2(`ComponentRadius::Micro`),Radio = 10(`ShapeScale::Full` 的真圆)。
pub radius: f32,
/// Radio 内点直径(10);Checkbox 为 0(没有内点)。
pub inner_dot: f32,
}
impl ControlGeometry {
/// Checkbox:20×20 / 圆角 2 / 描边 2 / 状态层 Ø40(全部 `[源]`,见模块表)。
pub fn checkbox(ui: &Ui) -> Self {
let metrics = metrics(Component::Checkbox);
Self {
side: metrics.width,
stroke: metrics.stroke,
state_layer: metrics.state_layer,
hit: ui.metrics().touch_target,
gap: metrics.gap,
// 微控件层(D32):2dp 不在 `ShapeScale` 刻度里,只能走 `ComponentRadius`。
// 传 20 拿不到值说明有人把它挪到容器层了——那是 D32 违规,`unwrap_or` 会悄悄
// 变成直角,所以这里用 `expect` 直接炸(比图画错好查)。
radius: metrics
.radius
.expect("Checkbox 的圆角必须在微控件层(ComponentSpec::micro_radius)")
.radius_px(metrics.width)
.expect("Checkbox 20dp ≤ 微控件上限 24dp"),
// Checkbox 没有内点(内点是 radio 的语言,§19.6 明令两者不许混用)
inner_dot: metrics.inner_dot,
}
}
/// Radio:20 / 环线 2 / 内点 10 / 状态层 Ø40;圆角是**真圆**(`ShapeScale::Full`)。
pub fn radio(ui: &Ui) -> Self {
let metrics = metrics(Component::Radio);
Self {
side: metrics.width,
stroke: metrics.stroke,
state_layer: metrics.state_layer,
hit: ui.metrics().touch_target,
gap: metrics.gap,
radius: Component::Radio.spec().shape.radius_px(metrics.width),
// 内点 **10 不是 12**(§19.2:12 是含描边前的名义值,绘制半径 = 12/2 − 2/2 = 5)
inner_dot: metrics.inner_dot,
}
}
/// 勾形横向端点跨度(20 画布内 0.2→0.8):`[源]` `Checkbox.kt:496-514` 的路径比例。
///
/// 这是 research/15 §25 明确允许的「组件级常量」(路径比例 0.2/0.4/0.5/0.7/0.8 不进令牌层)。
/// 只在取证/测试里用(生产路径由 `shell::glyph` 直接按比例构路径)。
#[cfg(test)]
pub fn check_span(&self) -> f32 {
self.side * (0.8 - 0.2)
}
/// 方头(`StrokeCap.Square`)渲染后墨迹相对端点跨度的**外扩量**:两侧各 `1/√2 × 线宽`。
///
/// 只用于把「参考值 12 是端点跨度、实测是墨迹跨度」这件事写进断言:勾的两端是 45° 斜线,
/// 方头沿斜向外扩,投影到 x 轴正好是 `stroke / √2`(每端)。`#[cfg(test)]`:同 `check_span`。
#[cfg(test)]
pub fn square_cap_bleed(&self) -> f32 {
self.stroke * std::f32::consts::FRAC_1_SQRT_2
}
}
/// Switch 的几何(`research/12b` §10;数值标 **G16** 的部分令牌层暂无档位)。
#[derive(Debug, Clone, Copy, PartialEq)]
pub struct SwitchGeometry {
/// 轨道宽(52)。
pub track_width: f32,
/// 轨道高(32 = `Space::X8`)。
pub track_height: f32,
/// 轨道描边(2,仅未选中态画)。
pub track_stroke: f32,
/// 滑块:选中 / 未选中 / 按下三档(24 / 16 / 28)。
pub thumb_selected: f32,
/// 未选中滑块(16)。
pub thumb_unselected: f32,
/// 按下滑块(28)。
pub thumb_pressed: f32,
/// 滑块内图标(16,`SwitchTokens.SelectedIconSize`;令牌 `IconSize::Button` 同值)。
pub icon: f32,
/// 状态层直径(40)。
pub state_layer: f32,
/// 最小命中区(48)。
pub hit: f32,
}
impl SwitchGeometry {
/// 从令牌 + G16 常量求值。
pub fn of(ui: &Ui) -> Self {
Self {
track_width: SWITCH_TRACK_WIDTH,
track_height: ui.space(Space::X8),
track_stroke: SWITCH_TRACK_STROKE,
thumb_selected: SWITCH_THUMB_SELECTED,
thumb_unselected: SWITCH_THUMB_UNSELECTED,
thumb_pressed: SWITCH_THUMB_PRESSED,
icon: IconSize::Button.px(),
state_layer: SWITCH_STATE_LAYER,
hit: ui.metrics().touch_target,
}
}
/// 滑块直径:按下态胀到 28,其余按选中与否取 24 / 16(§10.2)。
pub fn thumb(&self, selected: bool, pressed: bool) -> f32 {
if pressed {
self.thumb_pressed
} else if selected {
self.thumb_selected
} else {
self.thumb_unselected
}
}
/// 滑块离轨道边的内距 = `(轨道高 − 滑块) / 2`(三个档位都成立:32−24=8→4,32−16=16→8,32−28=4→2)。
pub fn thumb_inset(&self, thumb: f32) -> f32 {
(self.track_height - thumb) / 2.0
}
/// 状态层相对轨道的横向外扩:状态层比轨道高,未选中时滑块又靠左,
/// 所以状态层会**超出轨道左侧**——绘制区因此要多留这一段(见 [`Self::layer_offset`])。
pub fn bleed(&self) -> f32 {
(self.state_layer - self.track_height) / 2.0
}
/// 滑块圆心到状态层左缘的距离(= 状态层半径)。
pub fn layer_radius(&self) -> f32 {
self.state_layer / 2.0
}
/// 滑块圆心离轨道左缘的距离:未选中 = 内距 + 半滑块;选中 = 轨道宽 − 内距 − 半滑块。
pub fn thumb_center(&self, selected: bool, thumb: f32) -> f32 {
let inset = self.thumb_inset(thumb);
if selected {
self.track_width - inset - thumb / 2.0
} else {
inset + thumb / 2.0
}
}
/// 状态层左缘相对**绘制区**的位置:绘制区左缘 = 轨道左缘 − [`Self::bleed`]。
pub fn layer_offset(&self, selected: bool, thumb: f32) -> f32 {
self.thumb_center(selected, thumb) + self.bleed() - self.layer_radius()
}
/// 绘制区宽(轨道 + 两侧各一段状态层外扩)。
pub fn draw_width(&self) -> f32 {
self.track_width + self.bleed() * 2.0
}
/// 绘制区高(= 状态层直径,轨道在中间垂直居中)。
pub fn draw_height(&self) -> f32 {
self.state_layer
}
}
/// Chip 的几何(`research/12b` §13)。
#[derive(Debug, Clone, Copy, PartialEq)]
pub struct ChipGeometry {
/// 容器高(32 = `Space::X8`,`AssistChipTokens.ContainerHeight`)。
pub height: f32,
/// 圆角(8 = `Component::Chip.spec().shape = ShapeScale::Small`)。
pub radius: f32,
/// 水平内边距(8 = `Space::X2`,`Chip.kt HorizontalElementsPadding`)。
pub pad: f32,
/// 元素间距(8,同上)。
pub gap: f32,
/// 图标边长(16;官方 18 → **G14** 偏离,与按钮家族同一登记)。
pub icon: f32,
}
impl ChipGeometry {
/// 从令牌求值(`shape` 档位来自组件表,半径按边长折算)。
pub fn of(ui: &Ui) -> Self {
let height = ui.space(Space::X8);
let shape = Component::Chip.spec().shape;
Self {
height,
radius: shape.radius_px(height),
pad: ui.space(Space::X2),
gap: ui.space(Space::X2),
icon: IconSize::Button.px(),
}
}
}
/// 组件表里的微度量;缺组 = 令牌层还没建这一档(G16 的判据)。
fn metrics(component: Component) -> ComponentMetrics {
component
.metrics()
.unwrap_or_else(|| panic!("{component:?} 缺 ComponentMetrics(G10 的消费入口)"))
}