diff --git a/crates/app-iced/src/components/gallery/mod.rs b/crates/app-iced/src/components/gallery/mod.rs index 16ddaaf..f83897a 100644 --- a/crates/app-iced/src/components/gallery/mod.rs +++ b/crates/app-iced/src/components/gallery/mod.rs @@ -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:`:`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 { + 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![ text(title) @@ -203,7 +344,7 @@ pub(super) fn heading<'a>(ui: &'a Ui, title: &'a str, note: &'a str) -> Element< /// 状态矩阵的列头:五态的固定次序(`Interaction::ALL` 的口径)。 /// /// 列宽 = [`cell_width`](**160** = `Space::X8 × 5`,是矩阵栅格,不是组件尺寸)。 -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) diff --git a/crates/app-iced/src/components/gallery/selection.rs b/crates/app-iced/src/components/gallery/selection.rs index 848d8e7..c8fb0c8 100644 --- a/crates/app-iced/src/components/gallery/selection.rs +++ b/crates/app-iced/src/components/gallery/selection.rs @@ -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::(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 { + 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 { + 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 { + let mut ink: Option = 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::>() + .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::() / square; + // 覆盖率 ≥50%(半个像素,取几何边界;更低会把 30% 的抗锯齿也算进去,Ø10 量成 12) + (0.5..=1.0).contains(&t) + && (0..3) + .map(|i| (offset[i] - t * axis[i]).powi(2)) + .sum::() + .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 = 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 = 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() + ); + } +} diff --git a/crates/app-iced/src/components/selection/checkbox.rs b/crates/app-iced/src/components/selection/checkbox.rs new file mode 100644 index 0000000..b20c20c --- /dev/null +++ b/crates/app-iced/src/components/selection/checkbox.rs @@ -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, + error: bool, + state: Option, +} + +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(), + ) +} diff --git a/crates/app-iced/src/components/selection/chip.rs b/crates/app-iced/src/components/selection/chip.rs new file mode 100644 index 0000000..036a797 --- /dev/null +++ b/crates/app-iced/src/components/selection/chip.rs @@ -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, + trailing: Option, + on_press: Option, + state: Option, +} + +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::(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::(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(), + } +} diff --git a/crates/app-iced/src/components/selection/geometry.rs b/crates/app-iced/src/components/selection/geometry.rs new file mode 100644 index 0000000..4914a0d --- /dev/null +++ b/crates/app-iced/src/components/selection/geometry.rs @@ -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 的消费入口)")) +} diff --git a/crates/app-iced/src/components/selection/matrix.rs b/crates/app-iced/src/components/selection/matrix.rs new file mode 100644 index 0000000..d5112c8 --- /dev/null +++ b/crates/app-iced/src/components/selection/matrix.rs @@ -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/.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::>(); + + 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() +} diff --git a/crates/app-iced/src/components/selection/mod.rs b/crates/app-iced/src/components/selection/mod.rs index 80796fa..de52f1d 100644 --- a/crates/app-iced/src/components/selection/mod.rs +++ b/crates/app-iced/src/components/selection/mod.rs @@ -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 +//! .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}; diff --git a/crates/app-iced/src/components/selection/paint.rs b/crates/app-iced/src/components/selection/paint.rs new file mode 100644 index 0000000..0563864 --- /dev/null +++ b/crates/app-iced/src/components/selection/paint.rs @@ -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, + /// 描边色;`None` = 不画描边。 + pub outline: Option, + /// 描边宽度(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, +} + +#[derive(Debug, Clone, Copy, PartialEq)] +pub struct SwitchPaint { + /// 轨道(52×32)。 + pub track: Frame, + /// 滑块。 + pub thumb: Color, + /// 滑块内图标(16);`None` = 不画(未选中时官方色与轨道同色,画了也看不见)。 + pub icon: Option, +} + +#[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 { + 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 { + 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 + } +} diff --git a/crates/app-iced/src/components/selection/radio.rs b/crates/app-iced/src/components/selection/radio.rs new file mode 100644 index 0000000..f0fb6e0 --- /dev/null +++ b/crates/app-iced/src/components/selection/radio.rs @@ -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, + state: Option, +} + +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(), + ) +} diff --git a/crates/app-iced/src/components/selection/shell.rs b/crates/app-iced/src/components/selection/shell.rs new file mode 100644 index 0000000..0034cdf --- /dev/null +++ b/crates/app-iced/src/components/selection/shell.rs @@ -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, + live: Option>>, + 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, 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, + 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!( + "", + 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> { + super::super::layout::centered_fixed(side, side, content) +} diff --git a/crates/app-iced/src/components/selection/switch.rs b/crates/app-iced/src/components/selection/switch.rs new file mode 100644 index 0000000..d5523dc --- /dev/null +++ b/crates/app-iced/src/components/selection/switch.rs @@ -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, + state: Option, +} + +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::(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(), + ) +} diff --git a/crates/app-iced/src/components/selection/tests.rs b/crates/app-iced/src/components/selection/tests.rs new file mode 100644 index 0000000..0b3c3ed --- /dev/null +++ b/crates/app-iced/src/components/selection/tests.rs @@ -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); +}