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:
1 parent
35345f3399
commit
b69a2cdd7f
12 files changed
+2585
-56
No files matched your search
@@ -35,6 +35,32 @@
|
||||
//! ```
|
||||
//!
|
||||
//! `--screenshot` 不带主题时**默认 Light**(可复现;见 `cli::Options::capture_flavor`)。
|
||||
//!
|
||||
//! ## 段落过滤:环境变量 `ULL_GALLERY_SECTION`(M76 起)
|
||||
//!
|
||||
//! 五段拼成一列、外面套 `scrollable`,而截图只截**首屏** `1180×760`。段数一多,后面的家族
|
||||
//! 就落到折叠线以下(实测:selection 段从 y≈735 起,只剩标题一行)——那条线的
|
||||
//! 「渲染层像素断言」就没有一个像素可量。所以这里加一个**段落选择器**:
|
||||
//!
|
||||
//! ```text
|
||||
//! ULL_GALLERY_SECTION=all # 默认:五段全渲染(行为与加开关之前逐像素一致)
|
||||
//! ULL_GALLERY_SECTION=buttons # 只渲染 buttons 段
|
||||
//! ULL_GALLERY_SECTION=selection # 只渲染 selection 段(本 mission 的取证与 probe 用它)
|
||||
//! ```
|
||||
//!
|
||||
//! 取值:`all` / `buttons` / `divider` / `selection` / `inputs` / `feedback` / `navigation`。
|
||||
//! 语义是「**不构建**没被选中的段」(不是构建后隐藏):`column` 里根本不 push 它,
|
||||
//! 于是该家族的 `view` 一行都不跑。未识别的值**回落 `all`** 并在 stderr 打一行提示
|
||||
//! (静默回落会让人拿错图还以为拍的是那一段)。
|
||||
//!
|
||||
//! 为什么用环境变量而不是 `--screen=gallery:<family>`:`cli.rs` 不归组件线(M82 在改它),
|
||||
//! 而环境变量既能被测试(`cargo test` 里 `std::env::set_var`)又不用跟别人的 CLI 解析抢文件。
|
||||
//! 各家族的 `demo()` 签名**不因此改变**——本文件只决定「push 哪几段」。
|
||||
//!
|
||||
//! 实际渲染了哪几段会打一行 `[gallery] section=... → 渲染 ...`(首帧一次),
|
||||
//! 拿它当出图日志的证据。
|
||||
|
||||
use std::sync::atomic::{AtomicBool, Ordering};
|
||||
|
||||
use iced::widget::{column, container, scrollable, text};
|
||||
use iced::{Alignment, Background, Element, Length};
|
||||
@@ -62,6 +88,107 @@ mod probe;
|
||||
mod scan;
|
||||
mod selection;
|
||||
|
||||
/// 段落过滤的环境变量名(语义见模块文档「段落过滤」)。
|
||||
pub const SECTION_ENV: &str = "ULL_GALLERY_SECTION";
|
||||
|
||||
/// 段落开关:**构建**哪几段(`false` = 这一段连 `view` 都不跑)。
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
struct Sections {
|
||||
buttons: bool,
|
||||
divider: bool,
|
||||
selection: bool,
|
||||
inputs: bool,
|
||||
feedback: bool,
|
||||
navigation: bool,
|
||||
}
|
||||
|
||||
impl Sections {
|
||||
/// 五段全渲染(默认)。
|
||||
const ALL: Self = Self {
|
||||
buttons: true,
|
||||
divider: true,
|
||||
selection: true,
|
||||
inputs: true,
|
||||
feedback: true,
|
||||
navigation: true,
|
||||
};
|
||||
|
||||
/// 只有一段。
|
||||
fn only(name: &str) -> Option<Self> {
|
||||
let mut sections = Self {
|
||||
buttons: false,
|
||||
divider: false,
|
||||
selection: false,
|
||||
inputs: false,
|
||||
feedback: false,
|
||||
navigation: false,
|
||||
};
|
||||
match name {
|
||||
"buttons" => sections.buttons = true,
|
||||
"divider" => sections.divider = true,
|
||||
"selection" => sections.selection = true,
|
||||
"inputs" => sections.inputs = true,
|
||||
"feedback" => sections.feedback = true,
|
||||
"navigation" => sections.navigation = true,
|
||||
_ => return None,
|
||||
}
|
||||
Some(sections)
|
||||
}
|
||||
|
||||
/// 读 `ULL_GALLERY_SECTION`。
|
||||
///
|
||||
/// 未设置 / 空 / `all`(大小写不敏感)→ 全渲染;**未识别的值 → 回落全渲染 + stderr 一行**。
|
||||
fn from_env() -> Self {
|
||||
let Ok(raw) = std::env::var(SECTION_ENV) else {
|
||||
return Self::ALL;
|
||||
};
|
||||
let name = raw.trim().to_ascii_lowercase();
|
||||
if name.is_empty() || name == "all" {
|
||||
return Self::ALL;
|
||||
}
|
||||
if let Some(sections) = Self::only(&name) {
|
||||
return sections;
|
||||
}
|
||||
eprintln!(
|
||||
"[gallery] {SECTION_ENV}={raw:?} 不是已知段(all|buttons|divider|selection|inputs|feedback|navigation),已回落 all"
|
||||
);
|
||||
Self::ALL
|
||||
}
|
||||
|
||||
/// 本次渲染的段名(stderr 日志 / 测试断言用)。
|
||||
fn names(self) -> Vec<&'static str> {
|
||||
let mut names = Vec::new();
|
||||
for (on, name) in [
|
||||
(self.buttons, "buttons"),
|
||||
(self.divider, "divider"),
|
||||
(self.selection, "selection"),
|
||||
(self.inputs, "inputs"),
|
||||
(self.feedback, "feedback"),
|
||||
(self.navigation, "navigation"),
|
||||
] {
|
||||
if on {
|
||||
names.push(name);
|
||||
}
|
||||
}
|
||||
names
|
||||
}
|
||||
}
|
||||
|
||||
/// `[gallery] ... 渲染 ...` 只打一次(`view` 每帧都会跑)。
|
||||
static SECTION_LOGGED: AtomicBool = AtomicBool::new(false);
|
||||
|
||||
fn log_sections(sections: Sections) {
|
||||
if SECTION_LOGGED.swap(true, Ordering::Relaxed) {
|
||||
return;
|
||||
}
|
||||
let raw = std::env::var(SECTION_ENV).unwrap_or_else(|_| "all".to_owned());
|
||||
eprintln!(
|
||||
"[gallery] section={raw} → 渲染 {} 段:{}",
|
||||
sections.names().len(),
|
||||
sections.names().join(", ")
|
||||
);
|
||||
}
|
||||
|
||||
/// gallery 自己的消息(与业务 `Message` 解耦,契约 §2)。
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub enum DemoMessage {
|
||||
@@ -100,16 +227,30 @@ pub fn view<'a>(ui: &'a Ui) -> Element<'a, DemoMessage> {
|
||||
|
||||
// 小节之间**不画分隔线**:rubric(15 §22.4)要求同屏同类线 ≤3,
|
||||
// 而验收图里要给分隔线自己留证据(见 `divider_proof`)。分组靠 32px 留白 + 标题。
|
||||
let sections = column![
|
||||
buttons::section(ui),
|
||||
divider_proof(ui),
|
||||
selection::section(ui),
|
||||
inputs::section(ui),
|
||||
feedback::section(ui),
|
||||
navigation::section(ui),
|
||||
]
|
||||
.spacing(ui.space(Space::X8))
|
||||
.width(Length::Fill);
|
||||
//
|
||||
// 段落过滤(`ULL_GALLERY_SECTION`):**没被选中的段根本不 push**,于是该家族的
|
||||
// `view` 一行都不跑——不是「构建后隐藏」。默认 `all` 与加开关之前逐像素一致。
|
||||
let picked = Sections::from_env();
|
||||
log_sections(picked);
|
||||
let mut sections = column![].spacing(ui.space(Space::X8)).width(Length::Fill);
|
||||
if picked.buttons {
|
||||
sections = sections.push(buttons::section(ui));
|
||||
}
|
||||
if picked.divider {
|
||||
sections = sections.push(divider_proof(ui));
|
||||
}
|
||||
if picked.selection {
|
||||
sections = sections.push(selection::section(ui));
|
||||
}
|
||||
if picked.inputs {
|
||||
sections = sections.push(inputs::section(ui));
|
||||
}
|
||||
if picked.feedback {
|
||||
sections = sections.push(feedback::section(ui));
|
||||
}
|
||||
if picked.navigation {
|
||||
sections = sections.push(navigation::section(ui));
|
||||
}
|
||||
|
||||
// 滚动条:默认是主题给的 10px 深灰 thumb,压过组件本体(视觉 reviewer P2-4)。
|
||||
// 这里把它改成 **透明轨 + OutlineVariant thumb**;宽度 10px 是 `scrollable` 的内部常量
|
||||
@@ -184,7 +325,7 @@ fn divider_proof<'a>(ui: &'a Ui) -> Element<'a, DemoMessage> {
|
||||
///
|
||||
/// 五个家族文件共用(`pub(super)`),保证五段的节奏一致——这也是 rubric D4
|
||||
/// 「一屏字号档 ≤4」的落点:小节只有 `TitleMedium` + `BodySmall` 两档。
|
||||
pub(super) fn heading<'a>(ui: &'a Ui, title: &'a str, note: &'a str) -> Element<'a, DemoMessage> {
|
||||
pub(crate) fn heading<'a>(ui: &'a Ui, title: &'a str, note: &'a str) -> Element<'a, DemoMessage> {
|
||||
let note_style = ui.style(TypeRole::BodySmall);
|
||||
column。
|
||||
pub(super) fn state_cells(ui: &Ui) -> Vec<(&'static str, f32)> {
|
||||
pub(crate) fn state_cells(ui: &Ui) -> Vec<(&'static str, f32)> {
|
||||
Interaction::ALL
|
||||
.iter()
|
||||
.map(|state| (state.name(), cell_width(ui)))
|
||||
@@ -215,12 +356,12 @@ pub(super) fn state_cells(ui: &Ui) -> Vec<(&'static str, f32)> {
|
||||
/// 160 = `Space::X8 × 5`:5 列 + 1 个标签列 + 4 个列间距 + 页左右各 24
|
||||
/// = 6×160 + 4×8 + 48 = **1040 < 1180**,所以第 5 列(disabled)不再被窗口右缘裁掉。
|
||||
/// 早先是 192(×6),总宽 1232 撑破窗口,disabled 列被裁掉一半(视觉 reviewer P2-3)。
|
||||
pub(super) fn cell_width(ui: &Ui) -> f32 {
|
||||
pub(crate) fn cell_width(ui: &Ui) -> f32 {
|
||||
ui.space(Space::X8) * 5.0
|
||||
}
|
||||
|
||||
/// 一个矩阵行的左侧标签(变体名)。
|
||||
pub(super) fn row_label<'a>(ui: &'a Ui, name: &'a str) -> Element<'a, DemoMessage> {
|
||||
pub(crate) fn row_label<'a>(ui: &'a Ui, name: &'a str) -> Element<'a, DemoMessage> {
|
||||
let style = ui.style(TypeRole::BodySmall);
|
||||
container(
|
||||
text(name)
|
||||
|
||||
@@ -1,25 +1,396 @@
|
||||
//! gallery 里的选择类小节(**桩**):后续 mission 只改本文件。
|
||||
//! gallery 里的选择类小节(D30:浅/深 × 五态的状态矩阵)+ **渲染层几何断言**。
|
||||
//!
|
||||
//! 现在直接转调 `components::selection::demo`,把该家族的占位塞进五段结构里;
|
||||
//! 填充时把 `demo(ui)` 换成本家族的状态矩阵即可(列头/单元格工具见 `gallery/mod.rs`)。
|
||||
//! 矩阵本体的构造在 [`selection::matrix`](12 行 × 5 列:checkbox 四态 / radio / switch /
|
||||
//! chip 三类);本文件只负责「摆进 gallery」,消息用 `()` 再映射成 `DemoMessage`(组件不碰业务)。
|
||||
//!
|
||||
//! ## 断言量什么(每条对一个具体错误,容差 ±1)
|
||||
//!
|
||||
//! Checkbox 外缘 20 且**选中/未选中同尺寸**(§18.3:描边向内收;向外画会变 22)· Radio 内点
|
||||
//! **10 不是 12** 且环是真圆(§19.2/§19.6)· 状态层 **Ø40 且是圆**(§18.5/§19.7)· 勾形墨迹
|
||||
//! ≈14.8(端点 12 + 方头外扩)与 indeterminate 是短横(§18.4/§18.6)· Switch 轨道 52×32
|
||||
//! (§10.1)· Chip 高 32(§13.1)· 禁用墨迹是 38% 的 on-surface。
|
||||
//!
|
||||
//! 定位方式:**颜色 + 含抗锯齿的墨迹块外接框**,不假设行号——段落过滤或段落顺序变化后仍成立。
|
||||
//!
|
||||
//! 复现(worktree 根目录):
|
||||
//!
|
||||
//! ```text
|
||||
//! cargo build -p unlucky-app-iced --bin unlucky-launcher-iced
|
||||
//! ULL_GALLERY_SECTION=selection ./target/debug/unlucky-launcher-iced \
|
||||
//! --software-renderer --screen=gallery \
|
||||
//! --screenshot target/m76/selection-light.png --screenshot-theme=light
|
||||
//! ULL_GALLERY_SHOT=target/m76/selection-light.png cargo test -p unlucky-app-iced selection
|
||||
//! ```
|
||||
|
||||
use iced::widget::column;
|
||||
use iced::Element;
|
||||
|
||||
use super::super::selection;
|
||||
use crate::theme::Ui;
|
||||
|
||||
use super::{heading, DemoMessage};
|
||||
use super::DemoMessage;
|
||||
|
||||
/// 选择类家族的小节。
|
||||
/// 选择类家族的小节:标题 + 矩阵 + 图例。
|
||||
pub(super) fn section<'a>(ui: &'a Ui) -> Element<'a, DemoMessage> {
|
||||
iced::widget::column![
|
||||
heading(
|
||||
ui,
|
||||
"selection · checkbox / radio / switch / chip",
|
||||
"12b §13/§10 + 15 §18/§19 —— pending(后续 mission 填充本文件)"
|
||||
),
|
||||
selection::demo::<DemoMessage>(ui),
|
||||
let pressed = |()| DemoMessage::Pressed("selection");
|
||||
column![
|
||||
selection::matrix::heading_and_note(ui).map(pressed),
|
||||
selection::matrix::matrix(ui).map(pressed),
|
||||
selection::matrix::legend(ui).map(pressed),
|
||||
]
|
||||
.spacing(ui.space(unlucky_design::spacing::Space::X2))
|
||||
.into()
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod probe {
|
||||
// 判据与复现命令见本文件顶部模块文档。
|
||||
use std::path::PathBuf;
|
||||
|
||||
use iced::Color;
|
||||
use unlucky_design::color::{Role, SchemeFlavor};
|
||||
use unlucky_design::typography::Platform;
|
||||
|
||||
use crate::app::components::interaction::{self, Interaction};
|
||||
use crate::theme::{Families, Ui};
|
||||
|
||||
use super::super::scan::{matches, rgb, BBox, Shot};
|
||||
|
||||
const FAMILIES: Families = Families {
|
||||
text: "Segoe UI",
|
||||
display: "Segoe UI",
|
||||
mono: "Consolas",
|
||||
};
|
||||
|
||||
fn ui(flavor: SchemeFlavor) -> Ui {
|
||||
Ui::with_platform(flavor, Platform::Windows, FAMILIES)
|
||||
}
|
||||
|
||||
fn center(bbox: &BBox) -> (usize, usize) {
|
||||
((bbox.left + bbox.right) / 2, (bbox.top + bbox.bottom) / 2)
|
||||
}
|
||||
|
||||
/// `ULL_GALLERY_SHOT` → 截图路径(相对路径按工作区根解释,与 `gallery/probe.rs` 同口径)。
|
||||
fn shot_path() -> Option<PathBuf> {
|
||||
let path = PathBuf::from(std::env::var_os("ULL_GALLERY_SHOT")?);
|
||||
if path.is_absolute() || path.exists() {
|
||||
return Some(path);
|
||||
}
|
||||
let root = PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("../..");
|
||||
Some(root.join(path))
|
||||
}
|
||||
|
||||
/// 「墨迹块」:`fill` 与「底色→fill 的混色」都算墨,再按外接框尺寸筛。
|
||||
///
|
||||
/// 踩过的两个坑:① 按「精确色」连通(`Shot::bbox_of`)会把圆形的环切成碎片(每片面积都不到
|
||||
/// 200 而被丢掉,连内点都量不到);② 按「更接近 fill 还是底色」判会让**另一种深色墨**混进来
|
||||
/// (`OnSurfaceVariant` 比 `Primary` 更接近深色,于是未选中的框被当成 20×20 的 Primary 块)。
|
||||
/// 判据因此是「像素落在底色→fill 的混色线上」:抗锯齿像素在线,另一种墨色不在。
|
||||
fn ink_boxes(shot: &Shot, color: Color, under: Color, min: usize, max: usize) -> Vec<BBox> {
|
||||
let (fill, base) = (rgb(color), rgb(under));
|
||||
let mut seen = vec![false; shot.width * shot.height];
|
||||
let mut boxes = Vec::new();
|
||||
|
||||
for y in 0..shot.height {
|
||||
for x in 0..shot.width {
|
||||
if seen[y * shot.width + x] || !on_ramp(shot.at(x, y), fill, base) {
|
||||
continue;
|
||||
}
|
||||
let mut stack = vec![(x, y)];
|
||||
seen[y * shot.width + x] = true;
|
||||
let mut bbox = BBox::new(x, y);
|
||||
while let Some((cx, cy)) = stack.pop() {
|
||||
bbox.include(cx, cy);
|
||||
for (nx, ny) in [
|
||||
(cx + 1, cy),
|
||||
(cx.saturating_sub(1), cy),
|
||||
(cx, cy + 1),
|
||||
(cx, cy.saturating_sub(1)),
|
||||
] {
|
||||
let inside = nx < shot.width && ny < shot.height;
|
||||
if inside
|
||||
&& !seen[ny * shot.width + nx]
|
||||
&& on_ramp(shot.at(nx, ny), fill, base)
|
||||
{
|
||||
seen[ny * shot.width + nx] = true;
|
||||
stack.push((nx, ny));
|
||||
}
|
||||
}
|
||||
}
|
||||
if (min..=max).contains(&bbox.width()) && (min..=max).contains(&bbox.height()) {
|
||||
boxes.push(bbox);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
boxes
|
||||
}
|
||||
|
||||
/// 尺寸容差 ±1(rubric §6 的默认容差):控件常落在半像素上,实测会多一行抗锯齿。
|
||||
fn near(value: usize, expected: usize) -> bool {
|
||||
value.abs_diff(expected) <= 1
|
||||
}
|
||||
|
||||
/// `bbox` 内缩 `inset` 后、落在 **`under → color` 混色线上**的墨迹框(量内点用)。
|
||||
///
|
||||
/// 内缩 4 而不是 3(描边内缘 AA 正好压在 3 上,会把 Ø10 量成 14);用混色线而不是精确色
|
||||
/// (Ø10 的圆只有 ~8×8 是精确色,边缘 1px 全是抗锯齿)。
|
||||
fn inner_ink(
|
||||
shot: &Shot,
|
||||
bbox: &BBox,
|
||||
color: Color,
|
||||
under: Color,
|
||||
inset: usize,
|
||||
) -> Option<BBox> {
|
||||
let mut ink: Option<BBox> = None;
|
||||
for y in (bbox.top + inset)..=(bbox.bottom - inset) {
|
||||
for x in (bbox.left + inset)..=(bbox.right - inset) {
|
||||
if on_ramp(shot.at(x, y), rgb(color), rgb(under)) {
|
||||
match &mut ink {
|
||||
Some(found) => found.include(x, y),
|
||||
None => ink = Some(BBox::new(x, y)),
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
ink
|
||||
}
|
||||
|
||||
fn dims(boxes: &[BBox]) -> String {
|
||||
boxes
|
||||
.iter()
|
||||
.map(|bbox| format!("{}×{}", bbox.width(), bbox.height()))
|
||||
.collect::<Vec<_>>()
|
||||
.join(", ")
|
||||
}
|
||||
|
||||
/// `pixel` 是否落在 `under → fill` 这条混色线上(覆盖率 ≥30%、偏离线 ≤14)。
|
||||
///
|
||||
/// 覆盖率 ≥30% 是为了把**底色本身**排除(否则整片背景会连成一块);
|
||||
/// 偏离线 ≤14 是为了把「另一种深色墨」排除(它不在线上,见 [`ink_boxes`] 的坑 2)。
|
||||
fn on_ramp(pixel: [u8; 3], fill: [u8; 3], under: [u8; 3]) -> bool {
|
||||
let axis = [0, 1, 2].map(|i| f32::from(fill[i]) - f32::from(under[i]));
|
||||
let offset = [0, 1, 2].map(|i| f32::from(pixel[i]) - f32::from(under[i]));
|
||||
let square: f32 = axis.iter().map(|a| a * a).sum();
|
||||
if square <= f32::EPSILON {
|
||||
return false;
|
||||
}
|
||||
let t: f32 = (0..3).map(|i| axis[i] * offset[i]).sum::<f32>() / square;
|
||||
// 覆盖率 ≥50%(半个像素,取几何边界;更低会把 30% 的抗锯齿也算进去,Ø10 量成 12)
|
||||
(0.5..=1.0).contains(&t)
|
||||
&& (0..3)
|
||||
.map(|i| (offset[i] - t * axis[i]).powi(2))
|
||||
.sum::<f32>()
|
||||
.sqrt()
|
||||
<= 14.0
|
||||
}
|
||||
|
||||
/// 一条测试覆盖全部几何断言(浅/深各跑一次)。
|
||||
#[test]
|
||||
fn selection_geometry_matches_the_reference() {
|
||||
let Some(path) = shot_path() else {
|
||||
eprintln!(
|
||||
"[selection-probe] SKIPPED:未设置 ULL_GALLERY_SHOT。这是**渲染层**几何断言,\
|
||||
需要先出图(见本文件模块文档,注意 ULL_GALLERY_SECTION=selection)。"
|
||||
);
|
||||
return;
|
||||
};
|
||||
let shot = Shot::load(&path).expect("读 selection 截图");
|
||||
assert_eq!(
|
||||
(shot.width, shot.height),
|
||||
(1180, 760),
|
||||
"验收图必须 1180×760 @100% DPI"
|
||||
);
|
||||
let light = matches(
|
||||
shot.at(2, 2),
|
||||
rgb(ui(SchemeFlavor::Light).color(Role::Surface)),
|
||||
);
|
||||
let flavor = if light {
|
||||
SchemeFlavor::Light
|
||||
} else {
|
||||
SchemeFlavor::Dark
|
||||
};
|
||||
let ui = ui(flavor);
|
||||
let surface = ui.color(Role::Surface);
|
||||
let primary = ui.color(Role::Primary);
|
||||
let on_primary = ui.color(Role::OnPrimary);
|
||||
let unselected = ui.color(Role::OnSurfaceVariant);
|
||||
|
||||
// ---- §18.1 / §18.3 / §19.1:20 的外缘,选中与未选中**同尺寸** -----------------
|
||||
// 选中/部分选中(填 `Primary`)与 Radio 环都该是 20×20;**描边若向外画**,未选中会是 22。
|
||||
let filled = ink_boxes(&shot, primary, surface, 15, 24);
|
||||
let twenty: Vec<BBox> = filled
|
||||
.iter()
|
||||
.copied()
|
||||
.filter(|bbox| near(bbox.width(), 20) && near(bbox.height(), 20))
|
||||
.collect();
|
||||
assert!(
|
||||
twenty.len() >= 8,
|
||||
"18.1:至少 8 个 20×20 的 Primary 块(checkbox 选中/部分选中 + radio 环),\
|
||||
实际 {} 个,尺寸:{}",
|
||||
twenty.len(),
|
||||
dims(&filled)
|
||||
);
|
||||
|
||||
let candidates = ink_boxes(&shot, unselected, surface, 17, 24);
|
||||
let rings: Vec<BBox> = candidates
|
||||
.iter()
|
||||
.copied()
|
||||
.filter(|bbox| !matches(shot.at(center(bbox).0, bbox.top + 3), rgb(unselected)))
|
||||
.collect();
|
||||
assert!(
|
||||
!rings.is_empty(),
|
||||
"18.2:没找到未选中的复选框(OnSurfaceVariant 描边、内部透明);17~24 候选:{}",
|
||||
dims(&candidates)
|
||||
);
|
||||
for bbox in &rings {
|
||||
assert!(
|
||||
near(bbox.width(), 20) && near(bbox.height(), 20),
|
||||
"18.3:未选中外缘必须与选中同尺寸 20(描边向内收),实测 {}×{} @ {:?}\
|
||||
(22 = 描边画到框外,是历史错误)",
|
||||
bbox.width(),
|
||||
bbox.height(),
|
||||
(bbox.left, bbox.top)
|
||||
);
|
||||
assert!(
|
||||
!matches(shot.at(center(bbox).0, center(bbox).1), rgb(unselected)),
|
||||
"18.2:未选中框内部必须透出底色"
|
||||
);
|
||||
}
|
||||
// A1:hover/pressed/focused 三列的未选中描边提到 `OnSurface`
|
||||
let on_surface = ui.color(Role::OnSurface);
|
||||
let lifted = ink_boxes(&shot, on_surface, surface, 17, 24)
|
||||
.iter()
|
||||
.filter(|bbox| !matches(shot.at(center(bbox).0, bbox.top + 3), rgb(on_surface)))
|
||||
.count();
|
||||
assert!(
|
||||
lifted >= 3,
|
||||
"A1:未选中复选框在 hover/pressed/focused 的描边应是 OnSurface(3 个),实际 {lifted}"
|
||||
);
|
||||
|
||||
// ---- §19.2:Radio 内点 **10 不是 12**;§19.6:环是真圆 ------------------------
|
||||
// 环/实心看**上沿内缩 3px 的点**(环在那里是内孔);不看正中心——短横正好压中心。
|
||||
let mut dots = 0;
|
||||
for bbox in &twenty {
|
||||
if matches(shot.at(center(bbox).0, bbox.top + 3), rgb(primary)) {
|
||||
continue;
|
||||
}
|
||||
assert!(
|
||||
!matches(shot.at(bbox.left + 1, bbox.top + 1), rgb(primary)),
|
||||
"19.6:Radio 外圈必须是真圆(不是圆角方)@ {:?}",
|
||||
(bbox.left, bbox.top)
|
||||
);
|
||||
let dot = inner_ink(&shot, bbox, primary, surface, 4).expect("选中的 radio 必须有内点");
|
||||
assert!(
|
||||
near(dot.width(), 10) && near(dot.height(), 10),
|
||||
"19.2:Radio 内点可视直径 10(12 是含描边前的名义值),实测 {}×{}",
|
||||
dot.width(),
|
||||
dot.height()
|
||||
);
|
||||
assert_ne!(dot.width(), 12, "19.2:照名义值抄 12 会明显偏大");
|
||||
dots += 1;
|
||||
}
|
||||
assert!(
|
||||
dots >= 4,
|
||||
"19.2:至少 4 个选中的 radio(四态),实际 {dots}"
|
||||
);
|
||||
|
||||
// ---- §18.4 / §18.6:勾形墨迹跨度;indeterminate 是**短横**不是勾 --------------
|
||||
let mut bars = 0;
|
||||
let mut checks = 0;
|
||||
for bbox in &twenty {
|
||||
let Some(glyph) = inner_ink(&shot, bbox, on_primary, primary, 3) else {
|
||||
continue;
|
||||
};
|
||||
if glyph.height() <= 4 {
|
||||
bars += 1;
|
||||
assert!(
|
||||
glyph.width() >= 12,
|
||||
"18.6:indeterminate 必须是居中短横,实测 {}×{}",
|
||||
glyph.width(),
|
||||
glyph.height()
|
||||
);
|
||||
} else {
|
||||
checks += 1;
|
||||
// 端点 12 + 方头两侧各 2/√2 ≈ 1.41 → 墨迹 ≈14.8(见 `ControlGeometry`)
|
||||
assert!(
|
||||
(13..=16).contains(&glyph.width()),
|
||||
"18.4:勾形墨迹横向应 ≈14.8(端点 12 + 方头外扩),实测 {}",
|
||||
glyph.width()
|
||||
);
|
||||
assert!(
|
||||
glyph.height() >= 6,
|
||||
"18.4:勾形有折点,实测高 {}",
|
||||
glyph.height()
|
||||
);
|
||||
}
|
||||
}
|
||||
assert!(
|
||||
bars >= 4 && checks >= 4,
|
||||
"18.4/18.6:勾 {checks} 个、短横 {bars} 个(各应 ≥4)"
|
||||
);
|
||||
|
||||
// ---- §18.5 / §19.7:状态层直径 40,且是**圆**(外接框四角不许有墨)-------------
|
||||
let hover_layer = interaction::overlay_color(
|
||||
&ui,
|
||||
Interaction::Hovered,
|
||||
Role::OnSurfaceVariant,
|
||||
Role::Surface,
|
||||
)
|
||||
.expect("hover 有状态层");
|
||||
let circles = ink_boxes(&shot, hover_layer, surface, 37, 43);
|
||||
assert!(
|
||||
circles
|
||||
.iter()
|
||||
.any(|bbox| near(bbox.width(), 40) && near(bbox.height(), 40)),
|
||||
"18.5:没找到 Ø40 的状态层(颜色 {:?};实测尺寸:{})",
|
||||
rgb(hover_layer),
|
||||
dims(&circles)
|
||||
);
|
||||
for circle in circles
|
||||
.iter()
|
||||
.filter(|bbox| near(bbox.width(), 40) && near(bbox.height(), 40))
|
||||
{
|
||||
for (x, y) in [(circle.left, circle.top), (circle.right, circle.bottom)] {
|
||||
assert!(
|
||||
!matches(shot.at(x, y), rgb(hover_layer)),
|
||||
"18.5:状态层必须是圆(外接框四角不该有墨)@ ({x},{y})"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// ---- §10.1 Switch 轨道 52×32 · §13.1 Chip 高 32 · §18.8 禁用 38% -------------
|
||||
let on_tracks = ink_boxes(&shot, primary, surface, 28, 57);
|
||||
assert!(
|
||||
on_tracks
|
||||
.iter()
|
||||
.any(|bbox| near(bbox.width(), 52) && near(bbox.height(), 32)),
|
||||
"10.1:选中的 switch 轨道也是 52×32,实测 {}",
|
||||
dims(&on_tracks)
|
||||
);
|
||||
let chips = ink_boxes(&shot, ui.color(Role::SecondaryContainer), surface, 20, 140);
|
||||
assert!(
|
||||
chips
|
||||
.iter()
|
||||
.any(|bbox| near(bbox.height(), 32) && bbox.width() >= 58),
|
||||
"13.1:选中的 filter chip 高必须 32,实测 {}",
|
||||
dims(&chips)
|
||||
);
|
||||
let disabled_ink = interaction::disabled_content_over_role(&ui, Role::OnSurface, false);
|
||||
let disabled = ink_boxes(&shot, disabled_ink, surface, 17, 24);
|
||||
assert!(
|
||||
disabled.len() >= 2,
|
||||
"18.8:禁用态复选框/单选环应存在(on-surface@38%),实际 {} 个",
|
||||
disabled.len()
|
||||
);
|
||||
|
||||
eprintln!(
|
||||
"[selection-probe] OK flavor={flavor:?}:20(±1) Primary 块 {} · 未选中环 {} · 内点 {dots} · \
|
||||
勾 {checks} / 短横 {bars} · 状态层 {} · 禁用块 {}",
|
||||
twenty.len(),
|
||||
rings.len(),
|
||||
circles.len(),
|
||||
disabled.len()
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,179 @@
|
||||
//! Checkbox(`research/15` §18):20×20 / 圆角 2 / 描边向内收的 2px 框 / 勾形 12dp 方头 /
|
||||
//! indeterminate 形变为居中短横 / Ø40 状态层 / 48 命中区。
|
||||
//!
|
||||
//! ## 三条容易画错的(每条都有对应的像素断言或单测)
|
||||
//!
|
||||
//! 1. **描边向内**:外缘恒 20(§18.3)。iced 的 quad 渲染器把描边路径**向内缩半个线宽**
|
||||
//! (`iced_tiny_skia::engine::draw_quad`:`border_bounds + border_width / 2`),与 Compose
|
||||
//! `drawBox` 的 `topLeft = stroke/2, size = 20 − stroke` 同一口径,所以选中/未选中外缘同尺寸。
|
||||
//! 2. **勾不是图标**:勾形按路径比例画(`shell::glyph`),代码里**没有第二个 `IconKind`**(§18.6)。
|
||||
//! 3. **状态层是 Ø40 的圆**、命中区是 48:两者不同尺寸,所以外壳是透明的 48 按钮,
|
||||
//! 里面套一个 40 的圆容器,再套 20 的方框(见 `shell` 的模块文档)。
|
||||
|
||||
use iced::widget::{container, row, text};
|
||||
use iced::{Alignment, Element, Length};
|
||||
|
||||
use unlucky_design::typography::TypeRole;
|
||||
|
||||
use crate::theme::Ui;
|
||||
|
||||
use super::super::interaction::Interaction;
|
||||
use super::geometry::ControlGeometry;
|
||||
use super::paint::{self, Frame};
|
||||
use super::shell::{self, StateSource};
|
||||
|
||||
/// 复选框的三种取值(§18.6:indeterminate 不是第四种「选中」,是「部分选中」)。
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum CheckValue {
|
||||
/// 未选中:透明内部 + 2px `OnSurfaceVariant` 描边。
|
||||
Unchecked,
|
||||
/// 选中:`Primary` 实心 + 勾。
|
||||
Checked,
|
||||
/// 部分选中:**同样是实心**,勾形变成一条居中短横(不换图标)。
|
||||
Indeterminate,
|
||||
}
|
||||
|
||||
impl CheckValue {
|
||||
/// 框是否**填充**(选中与 indeterminate 都填,后者只是把勾换成短横)。
|
||||
pub const fn filled(self) -> bool {
|
||||
!matches!(self, CheckValue::Unchecked)
|
||||
}
|
||||
|
||||
/// 是否要画短横而不是勾。
|
||||
pub const fn indeterminate(self) -> bool {
|
||||
matches!(self, CheckValue::Indeterminate)
|
||||
}
|
||||
}
|
||||
|
||||
/// 一个复选框(框 + 可选标签;`on_toggle` 为 `None` = 禁用)。
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct Checkbox<'a, M> {
|
||||
label: &'a str,
|
||||
value: CheckValue,
|
||||
on_toggle: Option<M>,
|
||||
error: bool,
|
||||
state: Option<Interaction>,
|
||||
}
|
||||
|
||||
impl<'a, M> Checkbox<'a, M> {
|
||||
/// 一个二态复选框。
|
||||
pub fn new(label: &'a str, selected: bool, on_toggle: M) -> Self {
|
||||
Self::tri(
|
||||
label,
|
||||
if selected {
|
||||
CheckValue::Checked
|
||||
} else {
|
||||
CheckValue::Unchecked
|
||||
},
|
||||
on_toggle,
|
||||
)
|
||||
}
|
||||
|
||||
/// 三态复选框(`indeterminate` 由调用方给值)。
|
||||
pub fn tri(label: &'a str, value: CheckValue, on_toggle: M) -> Self {
|
||||
Self {
|
||||
label,
|
||||
value,
|
||||
on_toggle: Some(on_toggle),
|
||||
error: false,
|
||||
state: None,
|
||||
}
|
||||
}
|
||||
|
||||
/// 禁用的复选框(无消息;禁用不是「另一种颜色」,见 `paint::checkbox` 的禁用分支)。
|
||||
pub fn disabled(label: &'a str, value: CheckValue) -> Self {
|
||||
Self {
|
||||
label,
|
||||
value,
|
||||
on_toggle: None,
|
||||
error: false,
|
||||
state: None,
|
||||
}
|
||||
}
|
||||
|
||||
/// 校验失败态(§18.7:未选中描边 `Error`,选中填 `Error` + 勾 `OnError`)。
|
||||
pub fn error(mut self) -> Self {
|
||||
self.error = true;
|
||||
self
|
||||
}
|
||||
|
||||
/// 强制视觉态(`--screen=gallery` 取证用;运行期不要调)。
|
||||
pub fn state(mut self, state: Interaction) -> Self {
|
||||
self.state = Some(state);
|
||||
self
|
||||
}
|
||||
}
|
||||
|
||||
/// 画一个复选框(框 + 标签)。
|
||||
pub fn checkbox<'a, M: Clone + 'a>(ui: &'a Ui, spec: Checkbox<'a, M>) -> Element<'a, M> {
|
||||
let geometry = ControlGeometry::checkbox(ui);
|
||||
let filled = spec.value.filled();
|
||||
let indeterminate = spec.value.indeterminate();
|
||||
let error = spec.error;
|
||||
let radius = geometry.radius;
|
||||
let stroke = geometry.stroke;
|
||||
let source = StateSource::new(spec.state, spec.on_toggle.is_some());
|
||||
|
||||
// 勾的颜色与 hover/pressed 无关(A1:`SelectedHover/Focus/PressedIconColor` 都是 `OnPrimary`),
|
||||
// 只随「选中 + 禁用」变 —— 所以这里用**静态**态求值(SVG 的颜色在构建期就要定)。
|
||||
let at_rest = paint::checkbox(ui, filled, error, source.get(), radius, stroke);
|
||||
let glyph: Element<'a, M> = if filled {
|
||||
shell::square(
|
||||
geometry.side,
|
||||
shell::glyph(geometry, indeterminate, at_rest.glyph),
|
||||
)
|
||||
} else {
|
||||
iced::widget::Space::new().into()
|
||||
};
|
||||
|
||||
// 20 方框:填/描边(跟态走)+ 勾
|
||||
let boxed = container(glyph)
|
||||
.width(Length::Fixed(geometry.side))
|
||||
.height(Length::Fixed(geometry.side))
|
||||
.style(source.container_style(move |state| {
|
||||
paint::checkbox(ui, filled, error, state, radius, stroke)
|
||||
.frame
|
||||
.container_style()
|
||||
}));
|
||||
|
||||
// Ø40 状态层:圆(半径 = 直径/2,`ShapeScale::Full` 的等价写法)
|
||||
let layer_side = geometry.state_layer;
|
||||
let layer = container(boxed)
|
||||
.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::state_layer(ui, state, paint::checkbox_layer_role(filled, error)),
|
||||
outline: None,
|
||||
stroke: 0.0,
|
||||
radius: layer_side / 2.0,
|
||||
}
|
||||
.container_style()
|
||||
}));
|
||||
|
||||
let mut content = row![layer].align_y(Alignment::Center).spacing(geometry.gap);
|
||||
if !spec.label.is_empty() {
|
||||
let style = ui.style(TypeRole::BodyLarge);
|
||||
content = content.push(
|
||||
text(spec.label)
|
||||
.size(style.size_px)
|
||||
.font(ui.font_of(style))
|
||||
.color(paint::label(ui, source.get())),
|
||||
);
|
||||
}
|
||||
|
||||
// 命中区 48:方框 20 + 状态层 40 都在里面垂直居中
|
||||
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(),
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,188 @@
|
||||
//! Chip(`research/12b` §13):高 **32** / 圆角 **8** / 水平内边距 **8** / 元素间距 **8** /
|
||||
//! 图标 **16**(官方 18 → **G14**,与按钮家族同一登记)/ 描边 1px `OutlineVariant`。
|
||||
//!
|
||||
//! ## 三类(§13 的 Assist / Filter / Input)
|
||||
//!
|
||||
//! | 类 | 选中态 | 容器 | 文字 | 前置图标 |
|
||||
//! |---|---|---|---|---|
|
||||
//! | Assist | **无**(一次动作,不是开关) | 透明 + 1px 描边 | `OnSurface` | `Primary` |
|
||||
//! | Filter | 有 | 未选透明+描边;选中 `SecondaryContainer` **无描边** | `OnSurfaceVariant` / `OnSecondaryContainer` | 选中态才给勾 |
|
||||
//! | Input | 有 | 同 Filter | 同 Filter | 可给 + 后置关闭图标 |
|
||||
//!
|
||||
//! ## 为什么 Chip 不用 `selection::shell` 那条透明外壳
|
||||
//!
|
||||
//! M3 的 chip **没有圆形状态层**:hover / pressed 就是把状态层叠在**自己的容器**上,
|
||||
//! 而容器尺寸 = 命中区尺寸。所以 chip 直接走 `button` 的样式闭包(与按钮家族同一条产线),
|
||||
//! 不需要「48 外壳 + 40 圆」那套。
|
||||
|
||||
use iced::widget::{button, container, row, text};
|
||||
use iced::{Alignment, Background, Border, Element, Length, Padding, Shadow};
|
||||
|
||||
use unlucky_design::icons::IconKind;
|
||||
use unlucky_design::typography::TypeRole;
|
||||
|
||||
use crate::icons;
|
||||
use crate::theme::Ui;
|
||||
|
||||
use super::super::interaction::Interaction;
|
||||
use super::geometry::ChipGeometry;
|
||||
use super::paint::{self, ChipVariant};
|
||||
|
||||
/// 一个 chip(可带前后图标;`on_press` 为 `None` = 禁用)。
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct Chip<'a, M> {
|
||||
label: &'a str,
|
||||
variant: ChipVariant,
|
||||
selected: bool,
|
||||
leading: Option<IconKind>,
|
||||
trailing: Option<IconKind>,
|
||||
on_press: Option<M>,
|
||||
state: Option<Interaction>,
|
||||
}
|
||||
|
||||
impl<'a, M> Chip<'a, M> {
|
||||
/// Assist chip:辅助动作,**没有**选中态。
|
||||
pub fn assist(label: &'a str, on_press: M) -> Self {
|
||||
Self::new(label, ChipVariant::Assist, false, on_press)
|
||||
}
|
||||
|
||||
/// Filter chip:筛选开关(`selected` 决定容器是否 `SecondaryContainer`)。
|
||||
pub fn filter(label: &'a str, selected: bool, on_press: M) -> Self {
|
||||
Self::new(label, ChipVariant::Filter, selected, on_press)
|
||||
}
|
||||
|
||||
/// Input chip:用户输入的实体(可选中 + 可带关闭图标)。
|
||||
pub fn input(label: &'a str, on_press: M) -> Self {
|
||||
Self::new(label, ChipVariant::Input, false, on_press)
|
||||
}
|
||||
|
||||
/// 禁用的 chip(无消息)。
|
||||
pub fn disabled(label: &'a str, variant: ChipVariant) -> Self {
|
||||
Self {
|
||||
label,
|
||||
variant,
|
||||
selected: false,
|
||||
leading: None,
|
||||
trailing: None,
|
||||
on_press: None,
|
||||
state: None,
|
||||
}
|
||||
}
|
||||
|
||||
fn new(label: &'a str, variant: ChipVariant, selected: bool, on_press: M) -> Self {
|
||||
Self {
|
||||
label,
|
||||
variant,
|
||||
selected,
|
||||
leading: None,
|
||||
trailing: None,
|
||||
on_press: Some(on_press),
|
||||
state: None,
|
||||
}
|
||||
}
|
||||
|
||||
/// 前置图标(Assist / Filter 用)。
|
||||
pub fn leading(mut self, kind: IconKind) -> Self {
|
||||
self.leading = Some(kind);
|
||||
self
|
||||
}
|
||||
|
||||
/// 后置图标(Input chip 的关闭键)。
|
||||
pub fn trailing(mut self, kind: IconKind) -> Self {
|
||||
self.trailing = Some(kind);
|
||||
self
|
||||
}
|
||||
|
||||
/// 强制视觉态(gallery 取证用;运行期不要调)。
|
||||
pub fn state(mut self, state: Interaction) -> Self {
|
||||
self.state = Some(state);
|
||||
self
|
||||
}
|
||||
}
|
||||
|
||||
/// 画一个 chip。
|
||||
pub fn chip<'a, M: Clone + 'a>(ui: &'a Ui, spec: Chip<'a, M>) -> Element<'a, M> {
|
||||
let geometry = ChipGeometry::of(ui);
|
||||
let variant = spec.variant;
|
||||
let selected = spec.selected;
|
||||
let disabled = spec.on_press.is_none();
|
||||
let forced = spec.state;
|
||||
|
||||
// 文字/图标色只看「选中 + 禁用」(A1:Filter/Input 的 label 色在 hover/focus/pressed 不变),
|
||||
// 所以这里静态求值;容器(含状态层)在样式闭包里跟态走。
|
||||
let at_rest = paint::chip(
|
||||
ui,
|
||||
variant,
|
||||
selected,
|
||||
if disabled {
|
||||
Interaction::Disabled
|
||||
} else {
|
||||
forced.unwrap_or(Interaction::Enabled)
|
||||
},
|
||||
geometry.radius,
|
||||
);
|
||||
|
||||
let mut content = row![].align_y(Alignment::Center).spacing(geometry.gap);
|
||||
if let Some(kind) = spec.leading {
|
||||
// Q9:图标一律经固定尺寸容器
|
||||
content = content.push(super::super::layout::centered_fixed(
|
||||
geometry.icon,
|
||||
geometry.icon,
|
||||
icons::view::<M>(kind, at_rest.icon),
|
||||
));
|
||||
}
|
||||
|
||||
let style = ui.style(TypeRole::LabelLarge);
|
||||
content = content.push(
|
||||
text(spec.label)
|
||||
.size(style.size_px)
|
||||
.font(ui.font_of(ui.emphasized(TypeRole::LabelLarge)))
|
||||
.color(at_rest.label),
|
||||
);
|
||||
|
||||
if let Some(kind) = spec.trailing {
|
||||
content = content.push(super::super::layout::centered_fixed(
|
||||
geometry.icon,
|
||||
geometry.icon,
|
||||
icons::view::<M>(kind, at_rest.icon),
|
||||
));
|
||||
}
|
||||
|
||||
// 垂直居中必须在内容侧做(`button` 把内容放左上角)
|
||||
let body = container(content)
|
||||
.height(Length::Fill)
|
||||
.align_y(Alignment::Center);
|
||||
|
||||
let element = button(body)
|
||||
.height(Length::Fixed(geometry.height))
|
||||
.padding(Padding {
|
||||
top: 0.0,
|
||||
right: geometry.pad,
|
||||
bottom: 0.0,
|
||||
left: geometry.pad,
|
||||
})
|
||||
.style(move |_theme, status| {
|
||||
let state = forced.unwrap_or_else(|| Interaction::from_status(status));
|
||||
let paint = paint::chip(ui, variant, selected, state, geometry.radius);
|
||||
button::Style {
|
||||
background: paint.frame.fill.map(Background::Color),
|
||||
text_color: paint.label,
|
||||
border: Border {
|
||||
color: paint.frame.outline.unwrap_or(iced::Color::TRANSPARENT),
|
||||
width: if paint.frame.outline.is_some() {
|
||||
paint.frame.stroke
|
||||
} else {
|
||||
0.0
|
||||
},
|
||||
radius: iced::border::Radius::new(paint.frame.radius),
|
||||
},
|
||||
shadow: Shadow::default(),
|
||||
snap: true,
|
||||
}
|
||||
});
|
||||
|
||||
match spec.on_press {
|
||||
Some(message) => element.on_press(message).into(),
|
||||
None => element.into(),
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,245 @@
|
||||
//! 选择类家族的**几何**:值要么来自令牌,要么在此登记 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 的消费入口)"))
|
||||
}
|
||||
@@ -0,0 +1,212 @@
|
||||
//! 选择类家族的**状态矩阵**(D30:浅/深 × 五态):12 行 × 5 列。
|
||||
//!
|
||||
//! ```text
|
||||
//! | enabled | hovered | pressed | focused | disabled |
|
||||
//! checkbox·off/on/indet/error · radio·off/on · switch·off/on
|
||||
//! chip·assist / chip·filter / chip·filter·on / chip·input
|
||||
//! ```
|
||||
//!
|
||||
//! 每格都**强制**成该态(`Checkbox::state(..)` 等取证开关):截图时不能让 60 个控件同时 hover,
|
||||
//! 而强制值只影响「画什么」,不动任何状态机——所以矩阵图里的五态与运行期五态逐像素同源。
|
||||
//!
|
||||
//! 为什么住在 `selection/` 而不是 `gallery/`:`gallery/<family>.rs` 只负责「怎么摆」,本文件负责
|
||||
//! 「摆什么」,混在一起会顶到 400 行红线(像素断言在 `gallery/selection.rs`)。消息类型是 `()`
|
||||
//! (矩阵只供视觉验收,点击不产生业务效果),由 gallery 侧一次 `.map(..)` 接成 `DemoMessage`。
|
||||
|
||||
use iced::widget::{column, container, row, text};
|
||||
use iced::{Element, Length};
|
||||
|
||||
use unlucky_design::color::Role;
|
||||
use unlucky_design::icons::IconKind;
|
||||
use unlucky_design::spacing::Space;
|
||||
use unlucky_design::typography::TypeRole;
|
||||
|
||||
use crate::theme::Ui;
|
||||
|
||||
use super::super::gallery::{cell_width, heading, row_label, state_cells};
|
||||
use super::super::interaction::Interaction;
|
||||
use super::{self as selection, CheckValue, Checkbox, Chip, ChipVariant, Radio, Switch};
|
||||
|
||||
/// 整段矩阵(列头 + 12 行);消息类型是 `()`。
|
||||
pub fn matrix<'a>(ui: &'a Ui) -> Element<'a, ()> {
|
||||
let mut rows = column![header_row(ui)].spacing(ui.space(Space::X1));
|
||||
for spec in Row::ALL {
|
||||
let cells = Interaction::ALL
|
||||
.iter()
|
||||
.map(|state| cell(ui, draw(ui, spec, *state)))
|
||||
.collect::<Vec<_>>();
|
||||
|
||||
rows = rows.push(
|
||||
row![
|
||||
row_label(ui, spec.name()).map(|_| ()),
|
||||
row(cells).spacing(ui.space(Space::X2))
|
||||
]
|
||||
.spacing(ui.space(Space::X2)),
|
||||
);
|
||||
}
|
||||
|
||||
rows.into()
|
||||
}
|
||||
|
||||
/// 家族小节的标题 + 判据说明(与矩阵分开,好让 gallery 侧决定顺序)。
|
||||
pub fn heading_and_note<'a>(ui: &'a Ui) -> Element<'a, ()> {
|
||||
heading(
|
||||
ui,
|
||||
"selection · checkbox / radio / switch / chip",
|
||||
"15 §18/§19 + 12b §10/§13:checkbox 20×20 圆角 2 描边 2(向内收)勾 12 方头 · \
|
||||
radio 外圈 20 环线 2 内点 10 · switch 轨道 52×32 滑块 24/16 · chip 高 32 圆角 8 · \
|
||||
Ø40 状态层 · 命中 48",
|
||||
)
|
||||
.map(|_| ())
|
||||
}
|
||||
|
||||
/// 图例(一行说明五态口径)。
|
||||
pub fn legend<'a>(ui: &'a Ui) -> Element<'a, ()> {
|
||||
let style = ui.style(TypeRole::BodySmall);
|
||||
text(
|
||||
"图例:列 = 五态(focus 与 pressed 同 α 10%,看起来一样是规范要求);行 = 组件×取值。\
|
||||
复选框第三行是 indeterminate(勾形变成居中短横,不换图标);第四行是错误态。",
|
||||
)
|
||||
.size(style.size_px)
|
||||
.font(ui.font_of(style))
|
||||
.color(ui.color(Role::OnSurfaceVariant))
|
||||
.into()
|
||||
}
|
||||
|
||||
/// 矩阵的一行(组件 × 取值)。
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
enum Row {
|
||||
/// 三态复选框;布尔位 = 错误态。
|
||||
Checkbox(CheckValue, bool),
|
||||
/// 单选框;布尔位 = 选中。
|
||||
Radio(bool),
|
||||
/// 开关;布尔位 = 打开。
|
||||
Switch(bool),
|
||||
/// Chip;`(类, 选中)`。
|
||||
Chip(ChipVariant, bool),
|
||||
}
|
||||
|
||||
impl Row {
|
||||
/// 行序(矩阵自上而下)。
|
||||
const ALL: [Row; 12] = [
|
||||
Row::Checkbox(CheckValue::Unchecked, false),
|
||||
Row::Checkbox(CheckValue::Checked, false),
|
||||
Row::Checkbox(CheckValue::Indeterminate, false),
|
||||
Row::Checkbox(CheckValue::Unchecked, true),
|
||||
Row::Radio(false),
|
||||
Row::Radio(true),
|
||||
Row::Switch(false),
|
||||
Row::Switch(true),
|
||||
Row::Chip(ChipVariant::Assist, false),
|
||||
Row::Chip(ChipVariant::Filter, false),
|
||||
Row::Chip(ChipVariant::Filter, true),
|
||||
Row::Chip(ChipVariant::Input, false),
|
||||
];
|
||||
|
||||
/// 左栏标签。
|
||||
fn name(self) -> &'static str {
|
||||
match self {
|
||||
Row::Checkbox(CheckValue::Unchecked, false) => "checkbox·off",
|
||||
Row::Checkbox(CheckValue::Checked, false) => "checkbox·on",
|
||||
Row::Checkbox(CheckValue::Indeterminate, false) => "checkbox·indet",
|
||||
Row::Checkbox(..) => "checkbox·error",
|
||||
Row::Radio(false) => "radio·off",
|
||||
Row::Radio(true) => "radio·on",
|
||||
Row::Switch(false) => "switch·off",
|
||||
Row::Switch(true) => "switch·on",
|
||||
Row::Chip(ChipVariant::Assist, _) => "chip·assist",
|
||||
Row::Chip(ChipVariant::Filter, false) => "chip·filter",
|
||||
Row::Chip(ChipVariant::Filter, true) => "chip·filter·on",
|
||||
Row::Chip(..) => "chip·input",
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// 一格的组件(强制成 `state`)。
|
||||
fn draw(ui: &Ui, spec: Row, state: Interaction) -> Element<'_, ()> {
|
||||
let disabled = state.is_disabled();
|
||||
|
||||
match spec {
|
||||
// 五态共用同一套装配:只有「有没有消息」随禁用变(语义上的禁用,不是画成禁用)
|
||||
Row::Checkbox(value, error) => {
|
||||
let mut checkbox = match (disabled, value) {
|
||||
(true, value) => Checkbox::disabled("", value),
|
||||
(false, CheckValue::Indeterminate) => Checkbox::tri("", value, ()),
|
||||
(false, CheckValue::Checked) => Checkbox::new("", true, ()),
|
||||
(false, CheckValue::Unchecked) => Checkbox::new("", false, ()),
|
||||
}
|
||||
.state(state);
|
||||
if error {
|
||||
checkbox = checkbox.error();
|
||||
}
|
||||
selection::checkbox(ui, checkbox)
|
||||
}
|
||||
Row::Radio(selected) => {
|
||||
let radio = if disabled {
|
||||
Radio::disabled("", selected)
|
||||
} else {
|
||||
Radio::new("", selected, ())
|
||||
};
|
||||
selection::radio(ui, radio.state(state))
|
||||
}
|
||||
Row::Switch(value) => {
|
||||
let switch = if disabled {
|
||||
Switch::disabled("", value)
|
||||
} else {
|
||||
Switch::new("", value, ())
|
||||
};
|
||||
selection::switch(ui, switch.state(state))
|
||||
}
|
||||
Row::Chip(variant, selected) => {
|
||||
let label = match variant {
|
||||
ChipVariant::Assist => "Assist",
|
||||
ChipVariant::Filter => "Filter",
|
||||
ChipVariant::Input => "Input",
|
||||
};
|
||||
let chip = if disabled {
|
||||
Chip::disabled(label, variant)
|
||||
} else {
|
||||
match variant {
|
||||
ChipVariant::Assist => Chip::assist(label, ()).leading(IconKind::Download),
|
||||
// Filter 的勾只在**选中**时给(选中态才需要「已筛选」的强提示)
|
||||
ChipVariant::Filter if selected => {
|
||||
Chip::filter(label, true, ()).leading(IconKind::Check)
|
||||
}
|
||||
ChipVariant::Filter => Chip::filter(label, false, ()).leading(IconKind::Filter),
|
||||
// Input chip:前置图标 + 后置关闭键(avatar 位用图标代替)
|
||||
ChipVariant::Input => Chip::input(label, ())
|
||||
.leading(IconKind::Folder)
|
||||
.trailing(IconKind::Close),
|
||||
}
|
||||
};
|
||||
selection::chip(ui, chip.state(state))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// 列头(五态名)。
|
||||
fn header_row(ui: &Ui) -> Element<'_, ()> {
|
||||
let style = ui.style(TypeRole::LabelSmall);
|
||||
let mut head = row![row_label(ui, "").map(|_| ())].spacing(ui.space(Space::X2));
|
||||
|
||||
for (name, width) in state_cells(ui) {
|
||||
head = head.push(
|
||||
container(
|
||||
text(name)
|
||||
.size(style.size_px)
|
||||
.font(ui.font_of(style))
|
||||
.color(ui.color(Role::OnSurfaceVariant)),
|
||||
)
|
||||
.width(Length::Fixed(width)),
|
||||
);
|
||||
}
|
||||
|
||||
head.into()
|
||||
}
|
||||
|
||||
/// 一个矩阵单元格(固定宽,内容左对齐)。
|
||||
fn cell<'a>(ui: &Ui, content: Element<'a, ()>) -> Element<'a, ()> {
|
||||
container(content)
|
||||
.width(Length::Fixed(cell_width(ui)))
|
||||
.into()
|
||||
}
|
||||
@@ -1,37 +1,43 @@
|
||||
//! **选择类家族(桩)**:由后续 mission 填充(§18 Checkbox / §19 Radio / §10 Switch / §13 Chip)。
|
||||
//! **选择类家族**(D39 §1 的 ownership 切片):Checkbox / Radio / Switch / Chip。
|
||||
//!
|
||||
//! 本文件目前只有占位 `demo()`。**后续 mission 只写本目录内的文件,不改 `components/mod.rs`**:
|
||||
//! 家族模块已经在 `mod.rs` 里声明好,`gallery/selection.rs` 也已经在 `gallery/mod.rs` 里声明好,
|
||||
//! 所以「加组件」不需要碰任何共享文件(D39 §1 的并行地基)。
|
||||
//! | 组件 | 参考章节 | 尺寸一句话 |
|
||||
//! |---|---|---|
|
||||
//! | [`Checkbox`] | `research/15` §18 | 20×20 / 圆角 2 / 描边 2(向内收)/ 勾 12dp 方头 / Ø40 状态层 / 命中 48 |
|
||||
//! | [`Radio`] | `research/15` §19 | 外圈 20 / 环线 2 / 内点 **10**(不是 12)/ Ø40 状态层 |
|
||||
//! | [`Switch`] | `research/12b` §10 | 轨道 52×32 / 滑块 24·16 / Ø40 状态层 |
|
||||
//! | [`Chip`] | `research/12b` §13 | 高 32 / 圆角 8 / 内边距 8 / Assist·Filter·Input |
|
||||
//!
|
||||
//! 填充时请照抄按钮家族的分层:
|
||||
//! ## 分层(照抄按钮家族,审起来只需看四处)
|
||||
//!
|
||||
//! - 几何进 `selection/geometry.rs`(数值一律从 `ComponentMetrics` / `Space` / `ShapeScale` 取,
|
||||
//! 缺令牌登记 G 编号);
|
||||
//! - 状态层只能走 [`crate::app::components::interaction`](α 一处定义);
|
||||
//! - 精确尺寸用 [`crate::app::components::layout`](Q8/Q9 的解法),别手写 `.width(x)`。
|
||||
//! ```text
|
||||
//! geometry.rs 令牌 → 像素(唯一把 ComponentMetrics / Space / ShapeScale 读成数字的地方)
|
||||
//! paint.rs Interaction × 选中 × 错误 → 角色 → 颜色(唯一写角色名的地方)
|
||||
//! shell.rs button 四态 → 内部自绘样式的帧内通道 + 勾形 SVG
|
||||
//! <component>.rs 布局与装配(一行一个 Element)
|
||||
//! ```
|
||||
//!
|
||||
//! ## 纪律(D39 契约 §2 / §3)
|
||||
//!
|
||||
//! - 全部组件**对消息类型泛型**(`M: Clone`),不 import `crate::app::Message`;
|
||||
//! - hover / pressed / focus / disabled 的 α 只从 [`super::interaction`] 取(本目录零 α 字面量);
|
||||
//! - 精确尺寸只从 [`super::layout`](`centered_fixed` / `fixed`)与 `geometry` 取,不许手写;
|
||||
//! - 图标一律 `icons::view` + 固定尺寸容器(Q9),圆角按 D32 分层(微控件 2 / 容器刻度 / full)。
|
||||
|
||||
use iced::widget::{container, text};
|
||||
use iced::Element;
|
||||
mod checkbox;
|
||||
mod chip;
|
||||
mod radio;
|
||||
mod shell;
|
||||
mod switch;
|
||||
|
||||
use unlucky_design::color::Role;
|
||||
use unlucky_design::spacing::Space;
|
||||
use unlucky_design::typography::TypeRole;
|
||||
pub mod geometry;
|
||||
pub mod matrix;
|
||||
pub mod paint;
|
||||
|
||||
use crate::theme::Ui;
|
||||
#[cfg(test)]
|
||||
mod tests;
|
||||
|
||||
/// 占位 demo:一句 "pending",供 `--screen=gallery` 保持五段结构完整。
|
||||
///
|
||||
/// 后续 mission 把本函数换成真组件矩阵即可(`gallery/selection.rs` 调的就是它)。
|
||||
/// 文案用 ASCII:干净 CI 上可能没有中文字体(`fonts` 只保证拉丁族名),豆腐块会污染验收图。
|
||||
pub fn demo<'a, M: 'a>(ui: &'a Ui) -> Element<'a, M> {
|
||||
let style = ui.style(TypeRole::BodySmall);
|
||||
container(
|
||||
text("selection · checkbox / radio / switch / chip — pending")
|
||||
.size(style.size_px)
|
||||
.font(ui.font_of(style))
|
||||
.color(ui.color(Role::OnSurfaceVariant)),
|
||||
)
|
||||
.padding(ui.space(Space::X2))
|
||||
.into()
|
||||
}
|
||||
pub use checkbox::{checkbox, CheckValue, Checkbox};
|
||||
pub use chip::{chip, Chip};
|
||||
pub use paint::ChipVariant;
|
||||
pub use radio::{radio, Radio};
|
||||
pub use switch::{switch, Switch};
|
||||
@@ -0,0 +1,400 @@
|
||||
//! 选择类家族的**角色映射**:`Interaction × 选中 × 错误 → 颜色`。
|
||||
//!
|
||||
//! ## 出处(全部 A1:`material3-android:1.4.0` 的 token 类,本机 AAR `javap` 复核,见报告)
|
||||
//!
|
||||
//! | 组件 | 静息 | hover / focus / pressed | 禁用 |
|
||||
//! |---|---|---|---|
|
||||
//! | Checkbox 未选中 | 描边 `OnSurfaceVariant` 2px | 描边提到 **`OnSurface`**(宽度仍 2) | 描边 `OnSurface`@38% |
|
||||
//! | Checkbox 选中 | 填 `Primary` + 勾 `OnPrimary` | 填 `Primary`(不变) | 填 `OnSurface`@38% + 勾 `Surface` |
|
||||
//! | Radio | 环 `OnSurfaceVariant` / 选中环+点 `Primary` | 未选中环提到 **`OnSurface`**;**选中态仍是 `Primary`** | 环+点 `OnSurface`@38% |
|
||||
//! | Switch | 轨 `Primary`(选中)/`SurfaceContainerHighest`+2px `Outline`;滑块 `OnPrimary`/`Outline` | 滑块 `PrimaryContainer`/`OnSurfaceVariant` | 轨 `OnSurface`@12%;滑块 `Surface`/@38% |
|
||||
//! | Chip | 未选中:透明+1px `OutlineVariant`,文字 `OnSurfaceVariant`;选中:`SecondaryContainer` **无描边** | 描边不变,状态层成为底色 | 描边 `OnSurface`@12%;文字/图标 @38% |
|
||||
//!
|
||||
//! **A1 改过参考表的三处**:`UnselectedHover/Focus/PressedOutlineColor` 与 Radio 的
|
||||
//! `UnselectedHover/Focus/PressedIconColor` 都是 `OnSurface`(不是 `OnSurfaceVariant`);
|
||||
//! `SelectedDisabledIconColor` 是 `Surface`。α 一律从 [`interaction`] 取。
|
||||
|
||||
use iced::widget::container;
|
||||
use iced::{Background, Border, Color};
|
||||
|
||||
use unlucky_design::color::Role;
|
||||
use unlucky_design::shape::ShapeScale;
|
||||
|
||||
use crate::theme::{Ui, STROKE_PX};
|
||||
|
||||
use super::super::interaction::{self, Interaction};
|
||||
use super::geometry::SwitchGeometry;
|
||||
|
||||
/// 一个「方框」的外观:填色 / 描边 / 线宽 / 半径。
|
||||
///
|
||||
/// Checkbox 的框、Radio 的环、Switch 的轨道、Chip 的容器都用它——差别只在颜色与线宽。
|
||||
#[derive(Debug, Clone, Copy, PartialEq)]
|
||||
pub struct Frame {
|
||||
/// 填充色;`None` = 透明(未选中的 Checkbox / Radio 外圈、Chip 的 flat 底)。
|
||||
pub fill: Option<Color>,
|
||||
/// 描边色;`None` = 不画描边。
|
||||
pub outline: Option<Color>,
|
||||
/// 描边宽度(Checkbox/Radio 2,Chip 1,Switch 未选中 2)。
|
||||
pub stroke: f32,
|
||||
/// 圆角半径(Checkbox 2 / Radio 10 / Switch 16 / Chip 8)。
|
||||
pub radius: f32,
|
||||
}
|
||||
|
||||
impl Frame {
|
||||
/// 翻成 iced 的 `container::Style`(唯一把 Frame 变成像素的地方)。
|
||||
pub fn container_style(self) -> container::Style {
|
||||
container::Style {
|
||||
background: self.fill.map(Background::Color),
|
||||
border: Border {
|
||||
color: self.outline.unwrap_or(Color::TRANSPARENT),
|
||||
width: self.outline.map_or(0.0, |_| self.stroke),
|
||||
radius: iced::border::Radius::new(self.radius),
|
||||
},
|
||||
..container::Style::default()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Checkbox 的外观:框 + 勾的颜色。
|
||||
#[derive(Debug, Clone, Copy, PartialEq)]
|
||||
pub struct CheckboxPaint {
|
||||
/// 框(20×20)。
|
||||
pub frame: Frame,
|
||||
/// 勾 / 短横。
|
||||
pub glyph: Color,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq)]
|
||||
pub struct RadioPaint {
|
||||
/// 外圈(20×20,环线向内收 → 外缘恒 20)。
|
||||
pub ring: Frame,
|
||||
/// 内点(Ø10);`None` = 未选中(不画点)。
|
||||
pub dot: Option<Color>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq)]
|
||||
pub struct SwitchPaint {
|
||||
/// 轨道(52×32)。
|
||||
pub track: Frame,
|
||||
/// 滑块。
|
||||
pub thumb: Color,
|
||||
/// 滑块内图标(16);`None` = 不画(未选中时官方色与轨道同色,画了也看不见)。
|
||||
pub icon: Option<Color>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq)]
|
||||
pub struct ChipPaint {
|
||||
/// 容器。
|
||||
pub frame: Frame,
|
||||
/// 文字。
|
||||
pub label: Color,
|
||||
/// 前/后置图标。
|
||||
pub icon: Color,
|
||||
}
|
||||
|
||||
/// 状态层颜色:`content` 角色 @ 该态的 α 叠在 `surface` 上(不透明;α 的唯一实现点是
|
||||
/// [`interaction::overlay_color`],本文件零 α 字面量)。
|
||||
pub fn state_layer(ui: &Ui, state: Interaction, content: Role) -> Option<Color> {
|
||||
interaction::overlay_color(ui, state, content, Role::Surface)
|
||||
}
|
||||
|
||||
/// `OnSurface`@12% 叠 surface(**容器**禁用色:Switch 轨道、Chip 选中底)。
|
||||
fn disabled_container(ui: &Ui) -> Color {
|
||||
interaction::disabled_colors(ui, Role::Surface).0
|
||||
}
|
||||
|
||||
/// 内容禁用色:`role` @38% 叠在 surface 上(`on_container = false`,见 [`disabled_container`])。
|
||||
fn disabled_ink(ui: &Ui, role: Role) -> Color {
|
||||
interaction::disabled_content_over_role(ui, role, false)
|
||||
}
|
||||
|
||||
fn lifted(state: Interaction) -> bool {
|
||||
matches!(
|
||||
state,
|
||||
Interaction::Hovered | Interaction::Focused | Interaction::Pressed
|
||||
)
|
||||
}
|
||||
|
||||
/// Checkbox 的外观(§18.2 / §18.7 / §18.8)。
|
||||
///
|
||||
/// **描边宽度恒 2**(禁用态也是 2,§18.8:禁用不是「变淡变细」);
|
||||
/// 选中态没有描边(`SelectedOutlineWidth = 0`,走填充分支)。
|
||||
pub fn checkbox(
|
||||
ui: &Ui,
|
||||
selected: bool,
|
||||
error: bool,
|
||||
state: Interaction,
|
||||
radius: f32,
|
||||
stroke: f32,
|
||||
) -> CheckboxPaint {
|
||||
if state.is_disabled() {
|
||||
return CheckboxPaint {
|
||||
frame: Frame {
|
||||
fill: selected.then(|| disabled_container(ui)),
|
||||
outline: (!selected).then(|| disabled_ink(ui, Role::OnSurface)),
|
||||
stroke,
|
||||
radius,
|
||||
},
|
||||
// `SelectedDisabledIconColor = Surface`:勾是「透出底色」的浅色
|
||||
glyph: ui.color(Role::Surface),
|
||||
};
|
||||
}
|
||||
|
||||
match (selected, error) {
|
||||
(true, false) => CheckboxPaint {
|
||||
frame: Frame {
|
||||
fill: Some(ui.color(Role::Primary)),
|
||||
outline: None,
|
||||
stroke: 0.0,
|
||||
radius,
|
||||
},
|
||||
glyph: ui.color(Role::OnPrimary),
|
||||
},
|
||||
(true, true) => CheckboxPaint {
|
||||
frame: Frame {
|
||||
fill: Some(ui.color(Role::Error)),
|
||||
outline: None,
|
||||
stroke: 0.0,
|
||||
radius,
|
||||
},
|
||||
glyph: ui.color(Role::OnError),
|
||||
},
|
||||
(false, error) => {
|
||||
let role = if error {
|
||||
Role::Error
|
||||
} else if lifted(state) {
|
||||
// `UnselectedHover/Focus/PressedOutlineColor = OnSurface`(A1;不是 OnSurfaceVariant)
|
||||
Role::OnSurface
|
||||
} else {
|
||||
Role::OnSurfaceVariant
|
||||
};
|
||||
CheckboxPaint {
|
||||
frame: Frame {
|
||||
fill: None,
|
||||
outline: Some(ui.color(role)),
|
||||
stroke,
|
||||
radius,
|
||||
},
|
||||
glyph: ui.color(role),
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Radio 的外观(§19.3 / §19.4 / §19.5)。
|
||||
///
|
||||
/// **选中态 hover 不换色**:环与点在四态里都是 `Primary`(只有状态层变)。
|
||||
pub fn radio(ui: &Ui, selected: bool, state: Interaction, radius: f32, stroke: f32) -> RadioPaint {
|
||||
let ink = if state.is_disabled() {
|
||||
disabled_ink(ui, Role::OnSurface)
|
||||
} else if selected {
|
||||
ui.color(Role::Primary)
|
||||
} else if lifted(state) {
|
||||
ui.color(Role::OnSurface)
|
||||
} else {
|
||||
ui.color(Role::OnSurfaceVariant)
|
||||
};
|
||||
|
||||
RadioPaint {
|
||||
ring: Frame {
|
||||
fill: None,
|
||||
outline: Some(ink),
|
||||
stroke,
|
||||
radius,
|
||||
},
|
||||
dot: selected.then_some(ink),
|
||||
}
|
||||
}
|
||||
|
||||
/// Switch 的外观(§10.3 / §10.4 / §10.7)。
|
||||
pub fn switch(ui: &Ui, selected: bool, state: Interaction) -> SwitchPaint {
|
||||
let geometry = SwitchGeometry::of(ui);
|
||||
let radius = ShapeScale::Full.radius_px(geometry.track_height);
|
||||
|
||||
if state.is_disabled() {
|
||||
let container = disabled_container(ui);
|
||||
return if selected {
|
||||
SwitchPaint {
|
||||
track: Frame {
|
||||
fill: Some(container),
|
||||
outline: None,
|
||||
stroke: 0.0,
|
||||
radius,
|
||||
},
|
||||
// `DisabledSelectedHandleColor = Surface` 且不透明度 1(A1 的 fconst_1)
|
||||
thumb: ui.color(Role::Surface),
|
||||
icon: Some(disabled_ink(ui, Role::OnSurface)),
|
||||
}
|
||||
} else {
|
||||
SwitchPaint {
|
||||
track: Frame {
|
||||
fill: Some(ui.color(Role::SurfaceContainerHighest)),
|
||||
outline: Some(container),
|
||||
stroke: geometry.track_stroke,
|
||||
radius,
|
||||
},
|
||||
thumb: disabled_ink(ui, Role::OnSurface),
|
||||
icon: None,
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
if selected {
|
||||
SwitchPaint {
|
||||
track: Frame {
|
||||
fill: Some(ui.color(Role::Primary)),
|
||||
outline: None,
|
||||
stroke: 0.0,
|
||||
radius,
|
||||
},
|
||||
// `SelectedHover/Focus/PressedHandleColor = PrimaryContainer`(A1,1.4.0 起)
|
||||
thumb: if lifted(state) {
|
||||
ui.color(Role::PrimaryContainer)
|
||||
} else {
|
||||
ui.color(Role::OnPrimary)
|
||||
},
|
||||
icon: Some(ui.color(Role::OnPrimaryContainer)),
|
||||
}
|
||||
} else {
|
||||
SwitchPaint {
|
||||
track: Frame {
|
||||
fill: Some(ui.color(Role::SurfaceContainerHighest)),
|
||||
outline: Some(ui.color(Role::Outline)),
|
||||
stroke: geometry.track_stroke,
|
||||
radius,
|
||||
},
|
||||
// `UnselectedHover/Focus/PressedHandleColor = OnSurfaceVariant`(A1)
|
||||
thumb: if lifted(state) {
|
||||
ui.color(Role::OnSurfaceVariant)
|
||||
} else {
|
||||
ui.color(Role::Outline)
|
||||
},
|
||||
icon: None,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Chip 的三种(§13.6 / §13.7):Assist / Filter / Input。
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum ChipVariant {
|
||||
/// Assist:**永远未选中**,前置图标 `Primary`(`AssistChipTokens.IconColor`)。
|
||||
Assist,
|
||||
/// Filter:可选中(`SecondaryContainer`),选中态无描边。
|
||||
Filter,
|
||||
/// Input:可选中 + 后置关闭图标。
|
||||
Input,
|
||||
}
|
||||
|
||||
/// Chip 的外观。
|
||||
///
|
||||
/// 与 Checkbox/Radio 的区别:Chip 的**状态层就是它自己的容器**(M3 的 chip 没有圆形状态层),
|
||||
/// 所以未选中 + hover 的 fill 是「状态层叠在 surface 上」的实色。
|
||||
pub fn chip(
|
||||
ui: &Ui,
|
||||
variant: ChipVariant,
|
||||
selected: bool,
|
||||
state: Interaction,
|
||||
radius: f32,
|
||||
) -> ChipPaint {
|
||||
let selected = selected && !matches!(variant, ChipVariant::Assist);
|
||||
let label_role = if selected {
|
||||
Role::OnSecondaryContainer
|
||||
} else if matches!(variant, ChipVariant::Assist) {
|
||||
Role::OnSurface
|
||||
} else {
|
||||
Role::OnSurfaceVariant
|
||||
};
|
||||
|
||||
if state.is_disabled() {
|
||||
let ink = disabled_ink(ui, Role::OnSurface);
|
||||
return ChipPaint {
|
||||
frame: Frame {
|
||||
fill: selected.then(|| disabled_container(ui)),
|
||||
outline: (!selected).then(|| disabled_container(ui)),
|
||||
stroke: STROKE_PX,
|
||||
radius,
|
||||
},
|
||||
label: ink,
|
||||
icon: ink,
|
||||
};
|
||||
}
|
||||
|
||||
let outline = (!selected).then(|| ui.color(Role::OutlineVariant));
|
||||
let fill = if selected {
|
||||
Some(ui.color(Role::SecondaryContainer))
|
||||
} else {
|
||||
state_layer(ui, state, label_role)
|
||||
};
|
||||
// Assist / 选中态的前置图标是 `Primary`(`AssistChipTokens.IconColor` /
|
||||
// `InputChipTokens.SelectedLeadingIconColor`);其余跟文字同色。
|
||||
let icon_role = if matches!(variant, ChipVariant::Assist) || selected {
|
||||
Role::Primary
|
||||
} else {
|
||||
label_role
|
||||
};
|
||||
|
||||
ChipPaint {
|
||||
frame: Frame {
|
||||
fill,
|
||||
outline,
|
||||
stroke: STROKE_PX,
|
||||
radius,
|
||||
},
|
||||
label: ui.color(label_role),
|
||||
icon: ui.color(icon_role),
|
||||
}
|
||||
}
|
||||
|
||||
/// 状态层的 content 角色(选中 = 滑块色 `OnPrimary`,未选中 = `Outline`)。
|
||||
pub fn switch_layer_role(selected: bool) -> Role {
|
||||
if selected {
|
||||
Role::OnPrimary
|
||||
} else {
|
||||
Role::Outline
|
||||
}
|
||||
}
|
||||
|
||||
/// Switch 状态层的颜色:**叠在轨道色上**,不是叠在 surface 上。
|
||||
///
|
||||
/// switch 的状态层圆(Ø40)有一大半压在轨道(52×32)上:若照别的控件那样叠 surface,
|
||||
/// 圆会在轨道里画出一块「更亮的补丁」(实测第一版就是这个样子,轨道像被啃掉一块)。
|
||||
pub fn switch_layer(ui: &Ui, state: Interaction, selected: bool) -> Option<Color> {
|
||||
let track = if selected {
|
||||
Role::Primary
|
||||
} else {
|
||||
Role::SurfaceContainerHighest
|
||||
};
|
||||
interaction::overlay_color(ui, state, switch_layer_role(selected), track)
|
||||
}
|
||||
|
||||
/// Checkbox 状态层的 content 角色(选中/部分选中 = `Primary`,错误 = `Error`,否则 `OnSurfaceVariant`)。
|
||||
///
|
||||
/// 出处:Compose 的 ripple 色就是 `selectedContainerColor` / `unselectedOutlineColor`。
|
||||
pub fn checkbox_layer_role(filled: bool, error: bool) -> Role {
|
||||
match (filled, error) {
|
||||
(true, false) => Role::Primary,
|
||||
(true, true) | (false, true) => Role::Error,
|
||||
(false, false) => Role::OnSurfaceVariant,
|
||||
}
|
||||
}
|
||||
|
||||
/// 控件旁的标签色:`OnSurface`;禁用 → `OnSurface`@38%。
|
||||
///
|
||||
/// A1 里 checkbox/radio 都没有「标签色随 hover 变」的字段(标签不属于控件本体),
|
||||
/// 所以这里只看禁用与否。
|
||||
pub fn label(ui: &Ui, state: Interaction) -> Color {
|
||||
if state.is_disabled() {
|
||||
disabled_ink(ui, Role::OnSurface)
|
||||
} else {
|
||||
ui.color(Role::OnSurface)
|
||||
}
|
||||
}
|
||||
|
||||
/// Radio 状态层的 content 角色(选中 = `Primary`,未选中 = `OnSurfaceVariant`)。
|
||||
pub fn radio_layer_role(selected: bool) -> Role {
|
||||
if selected {
|
||||
Role::Primary
|
||||
} else {
|
||||
Role::OnSurfaceVariant
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,148 @@
|
||||
//! Radio button(`research/15` §19):外圈 **20** / 环线 **2** / 内点 **10**(不是 12)/
|
||||
//! 选中环+点 `Primary` / 未选中环 `OnSurfaceVariant`(hover 提到 `OnSurface`)/ Ø40 状态层。
|
||||
//!
|
||||
//! ## 为什么它不是「基于 Checkbox 画的」
|
||||
//!
|
||||
//! §19.6 明令:Radio **只能**是「2px 环 + 实心内点」,Checkbox **只能**是「2px 方框 + 勾/横」;
|
||||
//! 两者尺寸相同(20)但形状语言不同,禁止混用。所以本文件是独立实现,**没有** import
|
||||
//! `super::checkbox`(gallery 的 probe 另有一条断言:radio 的墨迹里不得出现方角像素)。
|
||||
//!
|
||||
//! ## 几何怎么落到像素
|
||||
//!
|
||||
//! 环:20×20 容器 + `Border { width: 2, radius: 10 }`。iced 的 quad 渲染器把描边路径向内缩
|
||||
//! 半个线宽(半径 10−1=9、路径外接 18),再以 2 描边 → 外缘正好 20、内缘 16。这与 Compose
|
||||
//! `RadioButton.kt` 的「环半径 = 10 − 1 = 9 且描边居中 → 外缘正好 20」逐像素同源。
|
||||
//! 内点:独立 10×10 圆容器居中在环里(绘制半径 12/2 − 2/2 = 5,即**直径 10**,§19.2)。
|
||||
|
||||
use iced::widget::{container, row, text};
|
||||
use iced::{Alignment, Element, Length};
|
||||
|
||||
use unlucky_design::typography::TypeRole;
|
||||
|
||||
use crate::theme::Ui;
|
||||
|
||||
use super::super::interaction::Interaction;
|
||||
use super::geometry::ControlGeometry;
|
||||
use super::paint::{self, Frame};
|
||||
use super::shell::{self, StateSource};
|
||||
|
||||
/// 一个单选框(环 + 可选标签;`on_select` 为 `None` = 禁用)。
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct Radio<'a, M> {
|
||||
label: &'a str,
|
||||
selected: bool,
|
||||
on_select: Option<M>,
|
||||
state: Option<Interaction>,
|
||||
}
|
||||
|
||||
impl<'a, M> Radio<'a, M> {
|
||||
/// 一个可选的单选框。
|
||||
pub fn new(label: &'a str, selected: bool, on_select: M) -> Self {
|
||||
Self {
|
||||
label,
|
||||
selected,
|
||||
on_select: Some(on_select),
|
||||
state: None,
|
||||
}
|
||||
}
|
||||
|
||||
/// 禁用的单选框(无消息)。
|
||||
pub fn disabled(label: &'a str, selected: bool) -> Self {
|
||||
Self {
|
||||
label,
|
||||
selected,
|
||||
on_select: None,
|
||||
state: None,
|
||||
}
|
||||
}
|
||||
|
||||
/// 强制视觉态(gallery 取证用;运行期不要调)。
|
||||
pub fn state(mut self, state: Interaction) -> Self {
|
||||
self.state = Some(state);
|
||||
self
|
||||
}
|
||||
}
|
||||
|
||||
/// 画一个单选框(环 + 内点 + 标签)。
|
||||
pub fn radio<'a, M: Clone + 'a>(ui: &'a Ui, spec: Radio<'a, M>) -> Element<'a, M> {
|
||||
let geometry = ControlGeometry::radio(ui);
|
||||
let selected = spec.selected;
|
||||
let radius = geometry.radius;
|
||||
let stroke = geometry.stroke;
|
||||
let dot_side = geometry.inner_dot;
|
||||
let source = StateSource::new(spec.state, spec.on_select.is_some());
|
||||
|
||||
// 内点与环的颜色与 hover/pressed 无关(A1:选中态四态都是 `Primary`),
|
||||
// 只随「选中 + 禁用」变 —— 所以点的颜色在这里静态求值即可。
|
||||
let at_rest = paint::radio(ui, selected, source.get(), radius, stroke);
|
||||
|
||||
// Ø10 内点(半径 = 直径/2)
|
||||
let dot: Element<'a, M> = match at_rest.dot {
|
||||
Some(ink) => container(iced::widget::Space::new())
|
||||
.width(Length::Fixed(dot_side))
|
||||
.height(Length::Fixed(dot_side))
|
||||
.style(move |_theme| {
|
||||
Frame {
|
||||
fill: Some(ink),
|
||||
outline: None,
|
||||
stroke: 0.0,
|
||||
radius: dot_side / 2.0,
|
||||
}
|
||||
.container_style()
|
||||
})
|
||||
.into(),
|
||||
None => iced::widget::Space::new().into(),
|
||||
};
|
||||
|
||||
// 20 外圈:环(未选中时环就是唯一的墨迹;选中时环 + 点同色)
|
||||
let ring = container(dot)
|
||||
.width(Length::Fixed(geometry.side))
|
||||
.height(Length::Fixed(geometry.side))
|
||||
.align_x(Alignment::Center)
|
||||
.align_y(Alignment::Center)
|
||||
.style(source.container_style(move |state| {
|
||||
paint::radio(ui, selected, state, radius, stroke)
|
||||
.ring
|
||||
.container_style()
|
||||
}));
|
||||
|
||||
// Ø40 状态层
|
||||
let layer_side = geometry.state_layer;
|
||||
let layer = container(ring)
|
||||
.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::state_layer(ui, state, paint::radio_layer_role(selected)),
|
||||
outline: None,
|
||||
stroke: 0.0,
|
||||
radius: layer_side / 2.0,
|
||||
}
|
||||
.container_style()
|
||||
}));
|
||||
|
||||
let mut content = row![layer].align_y(Alignment::Center).spacing(geometry.gap);
|
||||
if !spec.label.is_empty() {
|
||||
let style = ui.style(TypeRole::BodyLarge);
|
||||
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_select,
|
||||
body.into(),
|
||||
)
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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(),
|
||||
)
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
Reference in new issue
Block a user