diff --git a/crates/app-iced/src/components/buttons/assemble.rs b/crates/app-iced/src/components/buttons/assemble.rs index ac1bafe..0b22b85 100644 --- a/crates/app-iced/src/components/buttons/assemble.rs +++ b/crates/app-iced/src/components/buttons/assemble.rs @@ -80,11 +80,13 @@ pub(super) fn assemble<'a, M: Clone + 'a>( let mut row = Row::new().align_y(Alignment::Center); if let Some(kind) = spec.leading { - // Q9:图标一律经固定尺寸容器(按钮图标 16、IconButton 24 都不例外) + // Q9:图标一律经固定尺寸容器(按钮图标 18、IconButton 24 都不例外)。 + // **必须走 `view_at`**:盒 18 ≠ `RENDER_PX`(24),用 24 烘焙再缩到 18 会把视觉线宽从 1.5 压到 1.1 + // (复审 r1 P2-2 实测 1.04~1.25px)。 row = row.push(layout::fixed( geometry.icon, geometry.icon, - icons::view::(kind, ink), + icons::view_at::(kind, ink, geometry.icon), )); } row = row.push(label); @@ -92,7 +94,7 @@ pub(super) fn assemble<'a, M: Clone + 'a>( row = row.push(layout::fixed( geometry.icon, geometry.icon, - icons::view::(kind, ink), + icons::view_at::(kind, ink, geometry.icon), )); } let row = row.spacing(geometry.gap); @@ -143,13 +145,13 @@ pub(super) fn content_color( disabled_content: Role, ) -> iced::Color { match forced { - // 有容器:38% 叠在**12% 容器**上(Filled / Tonal / Elevated) + // 有容器:38% 叠在**按钮家族 10% 容器**上(Filled / Tonal / Elevated) Some(Interaction::Disabled) if fill.is_some() => { - interaction::disabled_content_over_role(ui, disabled_content, true) + interaction::disabled_button_content(ui, disabled_content, true) } // 无容器:38% 直接叠在 **surface** 上(Text / Outlined / standard IconButton) Some(Interaction::Disabled) => { - interaction::disabled_content_over_role(ui, disabled_content, false) + interaction::disabled_button_content(ui, disabled_content, false) } _ => ui.color(content), } @@ -167,7 +169,7 @@ pub(super) fn style_closure( .unwrap_or_else(|| Interaction::from_status(status)); let (background, text_color) = if state.is_disabled() { - let (container, content) = interaction::disabled_colors(ui, Role::Surface); + let (container, content) = interaction::disabled_button_colors(ui, Role::Surface); // 透明容器在禁用态依然透明(官方只改内容透明度) (spec.fill.map(|_| container), content) } else { @@ -184,7 +186,7 @@ pub(super) fn style_closure( }; let border_color = if state.is_disabled() { - interaction::disabled_colors(ui, Role::Surface).0 + interaction::disabled_button_colors(ui, Role::Surface).0 } else { spec.border .map_or_else(|| ui.color(Role::OutlineVariant), |role| ui.color(role)) diff --git a/crates/app-iced/src/components/buttons/geometry.rs b/crates/app-iced/src/components/buttons/geometry.rs index 30cbc88..c9ddecd 100644 --- a/crates/app-iced/src/components/buttons/geometry.rs +++ b/crates/app-iced/src/components/buttons/geometry.rs @@ -6,20 +6,21 @@ //! | 圆角 | `ShapeScale::Full.radius_px(高)` | 20 `[源]` 12b 1.2 | //! | 左右内边距(无图标) | `Space::X6` | 24 / 24 `[源]` 12b 1.3 | //! | 左内边距(带前置图标) | `Space::X4` | 16 `[源]` 12b 1.4 | -//! | 图标边长 | `IconSize::Button` | 16(**官方 18 → G14 偏离**) | +//! | 图标边长 | `IconSize::Button` | 18 `[源]` AAR `ButtonDefaults.IconSize = 18.dp`(12b 1.6 同值) | //! | 图标↔文字 | `Space::X2` | 8 `[源]` 12b 1.6 | //! | 描边 | `unlucky_design::stroke::HAIRLINE`(`crate::theme::STROKE_PX` 同一真源) | 1 `[源]` rubric G7 | //! | IconButton 边长 | `LayoutMetrics::control_height` | 40 `[源]` 12b 2.1 | //! | IconButton 图标 / 边距 | `IconSize::Toolbar` / `Space::X2` | 24 / 8 `[源]` 12b 2.2 | //! -//! **没有落点的两个参考值**(登记在报告里,不在此处写数): +//! **没有落点的一个参考值**(登记在报告里,不在此处写数): //! //! - 12b 1.7 最小宽 **58** → `G15`:iced 的 `Length` 没有 min-width 语义;按「24 + 24 内边距 + -//! LabelLarge 二字」自然宽度 ≈ 76,实际不触发,故本 mission 不施加该约束; -//! - 12b 1.6 图标 **18** → `G14`:令牌层 `IconSize::Button` 是 16、没有 18 档, -//! 组件层又不许写字面量 → 先用 16 并登记 2px 偏离。 +//! LabelLarge 二字」自然宽度 ≈ 76,实际不触发,故本 mission 不施加该约束。 //! -//! 这两条是**能力缺口不是审美选择**,登记后由令牌层(`crates/design`)补档位。 +//! **G14 已收口(M86)**:这里曾登记「12b 1.6 图标 18 → 令牌只有 16,先用 16 并偏离 2px」。 +//! 令牌层现在有 18 档([`IconSize::Button`],出处在 `crates/design/src/icons/mod.rs`), +//! 所以本文件取 18 就是**回到规范值**,不再是偏离。**别按 `ButtonSmallTokens.IconSize = 20` 改**: +//! 那个 token 在 AAR 里零消费(没有任何类读它的 `getIconSize`),是死令牌。 use iced::Padding; diff --git a/crates/app-iced/src/components/buttons/mod.rs b/crates/app-iced/src/components/buttons/mod.rs index fe3561b..0686d89 100644 --- a/crates/app-iced/src/components/buttons/mod.rs +++ b/crates/app-iced/src/components/buttons/mod.rs @@ -9,14 +9,14 @@ //! | 1.3 | 水平内边距(无图标) | **24 / 24** | `Space::X6` | //! | 1.4 | 水平内边距(带前置图标) | **16(左)/ 24(右)** | `Space::X4` / `Space::X6` | //! | 1.5 | 字级 | **LabelLarge 14/20 w500** | `TypeRole::LabelLarge` 强调档 | -//! | 1.6 | 图标 18 + 图标↔文字 8 | 图标见 **G14 偏离**(令牌只有 16);间距 `Space::X2` | [`Geometry`] | +//! | 1.6 | 图标 18 + 图标↔文字 8 | **18** = `IconSize::Button`(AAR `ButtonDefaults.IconSize`);间距 `Space::X2` | [`Geometry`] | //! | 1.7 | 最小宽 58 | **G15**:iced 的 `Length` 无 min-width,不施加(实测见报告) | — | -//! | 1.8 | 状态层 + 禁用 | hover 8 / focus 10 / pressed 10;容器 12%(A1 是 10%,D8)& 内容 38%;**内容角色按变体**([`Variant::disabled_content`]) | [`super::interaction`] | +//! | 1.8 | 状态层 + 禁用 | hover 8 / focus 10 / pressed 10;容器 **10%**(官方 `DisabledContainerOpacity`;面状控件的 12% 是另一类)& 内容 38%;**内容角色按变体**([`Variant::disabled_content`]) | [`super::interaction`] | //! | 1.9 | 五式色彩角色 | Filled `Primary`/`OnPrimary`;Tonal `SecondaryContainer`/`OnSecondaryContainer`;Elevated `SurfaceContainerLow`/`Primary`;Outlined 透明 + 1dp `OutlineVariant` + `OnSurfaceVariant`;Text 透明 + `OnSurfaceVariant` | [`Variant::roles`] | //! | 2.1 | IconButton 容器 | **40×40**、圆角 full | [`IconGeometry`] | //! | 2.2 | IconButton 图标 / 边距 | 图标 **24**、边距 **8** | [`IconGeometry`] | //! | 2.4 | Standard 各态**图标色不变** | 只有状态层变化 | [`Variant::roles`] 与态无关 | -//! | 2.7 | IconButton 禁用 | 内容 **`OnSurface`@38%**(A1;见 [`IconVariant::disabled_content`]) | [`super::interaction::disabled_content_over_role`] | +//! | 2.7 | IconButton 禁用 | 内容 **`OnSurface`@38%**(A1;见 [`IconVariant::disabled_content`]) | [`super::interaction::disabled_button_content`](按钮族 10% 底) | //! | 2.8 | 命中区 | 容器 40 + 四周各 4 = **48** | `LM.touch_target`(按钮自身不撑高) | //! //! ## 与契约 §2 的签名差异(**已登记,请 tower 确认**) @@ -234,7 +234,7 @@ impl<'a, M> Button<'a, M> { } } - /// 一个禁用的按钮(无消息)。禁用不是「另一种颜色」,见 `interaction::disabled_colors`。 + /// 一个禁用的按钮(无消息)。禁用不是「另一种颜色」,见 `interaction::disabled_button_colors`。 pub fn disabled(label: &'a str) -> Self { Self { label, diff --git a/crates/app-iced/src/components/buttons/tests.rs b/crates/app-iced/src/components/buttons/tests.rs index bb5f9d5..c21a610 100644 --- a/crates/app-iced/src/components/buttons/tests.rs +++ b/crates/app-iced/src/components/buttons/tests.rs @@ -84,8 +84,17 @@ fn geometry_matches_the_reference_values() { assert_eq!(outlined.stroke, STROKE_PX, "rubric G7 全站 1px"); assert_eq!(outlined.stroke, 1.0); - // 图标 16 是**令牌值**;官方 18 是 G14 登记的偏离(不在这里假装相等) - assert_eq!(geometry.icon, 16.0, "IconSize::Button"); + // 图标 18 是**令牌值**(`IconSize::Button`,AAR `ButtonDefaults.IconSize`); + // 12b 1.6 的 18 与它同源。**别按 `ButtonSmallTokens.IconSize = 20` 改**:那是零消费者的死令牌。 + assert_eq!( + geometry.icon, 18.0, + "IconSize::Button(官方 ButtonDefaults.IconSize)" + ); + assert_eq!( + geometry.icon, + unlucky_design::icons::IconSize::Button.px(), + "组件侧必须只从令牌取值(不许写字面量)" + ); assert_eq!(geometry.height % 4.0, 0.0, "可见高必须在 4px 栅格上(D1)"); } @@ -269,9 +278,12 @@ fn outlined_style_draws_hairline_border_and_full_radius() { let disabled = style(&ui.theme(), button::Status::Disabled); let (container, content) = - interaction::disabled_colors(&ui, unlucky_design::color::Role::Surface); + interaction::disabled_button_colors(&ui, unlucky_design::color::Role::Surface); assert_eq!(disabled.text_color, content, "禁用内容 = 令牌 38%"); - assert_eq!(disabled.border.color, container, "禁用描边 = 令牌 12%"); + assert_eq!( + disabled.border.color, container, + "禁用描边 = 按钮族的令牌 10%(OutlinedButtonTokens.DisabledContainerOpacity)" + ); } #[test] diff --git a/crates/app-iced/src/components/gallery/buttons.rs b/crates/app-iced/src/components/gallery/buttons.rs index f20aa47..b6b1451 100644 --- a/crates/app-iced/src/components/gallery/buttons.rs +++ b/crates/app-iced/src/components/gallery/buttons.rs @@ -77,8 +77,8 @@ pub(super) fn section<'a>(ui: &'a Ui) -> Element<'a, DemoMessage> { heading( ui, "buttons · filled / tonal / elevated / outlined / text + icon", - "12b §1/§2:高 40 · 圆角 full(20) · 内边距 24(带图标 16/24)· 图标 16[G14] 间距 8 · \ - hover 8% / focus 10% / pressed 10% / disabled 容器 12% 内容 38%" + "12b §1/§2:高 40 · 圆角 full(20) · 内边距 24(带图标 16/24)· 图标 18 间距 8 · \ + hover 8% / focus 10% / pressed 10% / disabled 容器 10% 内容 38%" ), rows, iced::widget::text( diff --git a/crates/app-iced/src/components/gallery/probe.rs b/crates/app-iced/src/components/gallery/probe.rs index 945d7a0..73f927f 100644 --- a/crates/app-iced/src/components/gallery/probe.rs +++ b/crates/app-iced/src/components/gallery/probe.rs @@ -16,7 +16,7 @@ //! //! 量什么(每条对一个已发生过的错误):实心块高 **40**(12b 1.1)· 药丸顶行**左内缩 ≈14** ⇒ 圆角 full=20 //! (12b 1.2,8 圆角会接近通栏)· IconButton **40×40**(12b 2.1)· 字形墨迹 **16~26** -//! (Q9:24 盒被填满会到 40)· 分隔线 **1px**(22.1)· 禁用容器 **on-surface@12%** 存在(12b 1.8)。 +//! (Q9:24 盒被填满会到 40)· 分隔线 **1px**(22.1)· 禁用容器 **on-surface@10%**(按钮族)存在(12b 1.8)。 use unlucky_design::color::{Role, SchemeFlavor}; use unlucky_design::typography::Platform; @@ -289,12 +289,15 @@ fn gallery_geometry_matches_the_tokens() { // 上一版把两个 reviewer 都指出的重复块留在这里(`disabled_colors` 被调两次、 // `disabled_container` 被绑定两次),且只有一条「全图数墨迹」的护栏 —— 深色下阈值失效、 // 单行回退也抓不住。现在按矩阵的行逐条断言。 - let (disabled_container, _) = interaction::disabled_colors(&ui, Role::Surface); + // + // **按钮族**:容器 10%(官方 `*ButtonTokens.DisabledContainerOpacity = 0.1f`)、内容 38%。 + // M86 之前这里和 chip 共用同一个 12% 常量,所以断言与实现「一起错」、永远同源——拆族后才对得上。 + let (disabled_container, _) = interaction::disabled_button_colors(&ui, Role::Surface); assert!( shot.bbox_of(rgb(disabled_container)) .iter() .any(|b| b.width() > 60), - "没找到禁用态实心按钮(12b 1.8:容器 = on-surface@12%)" + "没找到禁用态实心按钮(12b 1.8:容器 = on-surface@10%)" ); // 矩阵行序(gallery/buttons.rs 的 Variant::ALL + IconVariant::ALL),行距 48 = 40 + 8 @@ -315,7 +318,10 @@ fn gallery_geometry_matches_the_tokens() { for (index, role, on_container, name) in rows { let y0 = label_button.top + index * row_step; let y1 = y0 + 44; - let ink = interaction::disabled_content_over_role(&ui, role, on_container); + // **与实现同源**:按钮族的内容色一律走 `disabled_button_content`(38% 叠在 10% 底上)。 + // 这里若用面状控件族的 `disabled_content_over_role`(12% 底),断言的底就比实现宽一档, + // 差的 1/255 会让「容器对了内容错了」这类错逃过门控——M86 明确要防这个。 + let ink = interaction::disabled_button_content(&ui, role, on_container); let count = shot.count_in(rgb(ink), column_from, shot.width, y0, y1); assert!( count > 40, @@ -323,7 +329,7 @@ fn gallery_geometry_matches_the_tokens() { ); } - // filled 禁用容器里不许出现满档 OnPrimary(旧实现是白字压在 12% 容器上 = 1.23:1) + // filled 禁用容器里不许出现满档 OnPrimary(旧实现是白字压在容器上 = 1.23:1) let full_content = rgb(ui.color(Role::OnPrimary)); for bbox in shot.bbox_of(rgb(disabled_container)) { if bbox.width() < 60 { @@ -346,7 +352,7 @@ fn gallery_geometry_matches_the_tokens() { 顶行左内缩 {}(r=20 应 ≈14;8 圆角 ≈4;直角 0) 实心块 {} 个(含 40×40 IconButton) 分隔线长行 {} 行(每条 1px) - 禁用容器 (on-surface@12%) 命中 · 逐行禁用内容断言 {} 行通过 + 禁用容器 (on-surface@10%,按钮族) 命中 · 逐行禁用内容断言 {} 行通过 分隔线墨量覆盖率 {:.2}(1dp 应 ≈1.0) 字形墨迹 {}px(24 盒应 16~26)", flavor, diff --git a/crates/app-iced/src/components/interaction/disabled.rs b/crates/app-iced/src/components/interaction/disabled.rs new file mode 100644 index 0000000..4e033c5 --- /dev/null +++ b/crates/app-iced/src/components/interaction/disabled.rs @@ -0,0 +1,305 @@ +//! 禁用态的颜色口径:**两个族、两套 α**(M86 收口)。 +//! +//! ## 为什么不是「一个 12%」 +//! +//! 官方禁用 α 分两类(A 级:`material3-android-1.4.0.aar` 的字节码 ``): +//! +//! | 族 | α | 官方 token | 消费者 | +//! |---|---|---|---| +//! | **按钮容器** | **0.10** | `*ButtonTokens.DisabledContainerOpacity`(`Filled` / `Outlined` / `Text` / `Elevated` / `Tonal` / `Baseline`) | `buttons::assemble` | +//! | **面状控件**容器与描边 | **0.12** | `AssistChipTokens.FlatDisabledOutlineOpacity`、`FilterChipTokens.FlatDisabled(SelectedContainer/UnselectedOutline)Opacity`、`SliderTokens.DisabledInactiveTrackOpacity`、`SwitchTokens.DisabledTrackOpacity` | chip / switch / slider / TextField 描边 | +//! | 内容(两族共用) | 0.38 | `Disabled{Icon,LabelText,Input}Opacity` | 全部 | +//! +//! 历史上这里只有一个 12% 常量供两族共用:**按钮那半边一直错、chip/slider/switch 那半边一直对**。 +//! 现在按族分开两个入口——取错入口的后果是「差 1/255」,肉眼看不见,只有像素 probe 会红, +//! 所以入口**必须按名字就能选对**。 +//! +//! ⚠️ TextField 的 **Filled 容器**不在这两族里:官方 +//! `FilledTextFieldTokens.FieldDisabledContainerOpacity = 0.04f`,我们仍在用面状族的值 +//! (既有 D10 偏离,未在本轮收口)。`inputs::field` 走 [`disabled_colors`] 的口径,行为未变。 +//! +//! ⚠️ **filled-tonal 是另一条已知偏离**:官方 +//! `FilledTonalButtonTokens.DisabledContainerOpacity = 0.12f`(`javap -p -constants` 可见它是个 +//! **编译期常量**,被内联进 `getDefaultFilledTonalButtonColors` 的 `ldc_w 0.12f`,所以调用点是常量、 +//! `getDisabledContainerOpacity()` 不会被调用);而本模块的 `disabled_button_*` **不区分变体**, +//! tonal 与 filled 共用 0.10 ⇒ tonal 实际画 10%。这不是对齐、是登记过的偏离;要修得让入口知道变体, +//! 本轮(M86)不做。 + +use iced::Color; + +use unlucky_design::color::state::{ + DISABLED_CONTAINER_ALPHA, DISABLED_CONTENT_ALPHA, DISABLED_SURFACE_ALPHA, +}; +use unlucky_design::color::{Role, Scheme}; + +use crate::theme::{to_iced, Ui}; + +/// 禁用态内容色(**按角色、按有没有容器**求值)——A1 口径的入口(**面状控件族**的底)。 +/// +/// - `role`:由变体决定(Filled/Outlined/Text/Elevated = `OnSurfaceVariant`; +/// FilledTonal 与三式 IconButton = `OnSurface`,见 `Variant::disabled_content`); +/// - `on_container = true`:叠在**面状控件族的 12% 容器**([`DISABLED_SURFACE_ALPHA`])上 +/// (Filled TextField / 有容器控件在 12% 底上求内容时); +/// `false`:直接叠在 **surface** 上(Text / Outlined / standard IconButton / 描边类控件的内容)。 +/// +/// **按钮家族不要用这个函数**(按钮容器是 10%):走 [`disabled_button_content`]。 +/// +/// 之前这里写死 `OnSurfaceVariant` 且只有一个「底」,导致 FilledTonal 与 IconButton 两族偏浅 +/// 15/255(视觉 r3 用像素量的)。 +pub fn disabled_content_over_role(ui: &Ui, role: Role, on_container: bool) -> Color { + let scheme = Scheme::baseline(ui.flavor()); + disabled_content_with_alpha(&scheme, role, on_container, DISABLED_SURFACE_ALPHA) +} + +/// 禁用态内容色——**按钮家族**(`on_container`:是否叠在本族 10% 容器上)。 +/// +/// 与 [`disabled_content_over_role`] 的差别只有「底」的 α:按钮 10%、面状控件 12%。 +/// 两个入口分开是 M86 的结论:合成一个数会让一族永远差 1/255,肉眼看不见、只有 probe 会红。 +pub fn disabled_button_content(ui: &Ui, role: Role, on_container: bool) -> Color { + let scheme = Scheme::baseline(ui.flavor()); + disabled_content_with_alpha(&scheme, role, on_container, DISABLED_CONTAINER_ALPHA) +} + +/// 两个族共用的内容色求值(「底」的 α 由调用方给)。 +fn disabled_content_with_alpha( + scheme: &Scheme, + role: Role, + on_container: bool, + container_alpha: f32, +) -> Color { + let base = if on_container { + scheme + .get(Role::OnSurface) + .with_alpha(container_alpha) + .over(scheme.get(Role::Surface)) + } else { + scheme.get(Role::Surface) + }; + + to_iced( + scheme + .get(role) + .with_alpha(DISABLED_CONTENT_ALPHA) + .over(base), + ) +} + +/// 禁用态的一对颜色 `(容器, 内容)`——**面状控件族**(12% 容器/描边)。 +/// +/// ⚠️ 这个名字是历史遗留(当年只有它一个),**语义是「面状控件族」**: +/// - chip 容器与描边(`AssistChipTokens.FlatDisabledOutlineOpacity = 0.12f` 等) +/// - switch 轨道(`SwitchTokens.DisabledTrackOpacity = 0.12f`) +/// - slider 未激活轨(`SliderTokens.DisabledInactiveTrackOpacity = 0.12f`) +/// - TextField 禁用描边(Filled 容器的 4% 是另一条 D10 偏离,不在这里收口) +/// +/// **按钮家族用 [`disabled_button_colors`]**(0.10,`*ButtonTokens.DisabledContainerOpacity = 0.1f`)。 +/// +/// `ui` 不暴露 [`Scheme`],这里按同一个 flavor 求一次基线方案——调的是 +/// `unlucky_design` 的**同一个**函数与同一套角色,不是第二份令牌。 +/// +/// 顺序必须「先容器后内容」:Compose 也是这么画的,先叠容器再叠 label, +/// 所以内容叠的角色是容器实色而不是底下的 surface。 +pub fn disabled_colors(ui: &Ui, surface: Role) -> (Color, Color) { + disabled_colors_with_alpha(ui, surface, DISABLED_SURFACE_ALPHA) +} + +/// 禁用态的一对颜色 `(容器, 内容)`——**按钮家族**(10% 容器)。 +/// +/// - 容器 = `OnSurface` @ 10%([`DISABLED_CONTAINER_ALPHA`])叠在 `surface` 上; +/// - 内容 = **默认** `OnSurfaceVariant` @ 38%;按变体的取值走 [`disabled_button_content`]。 +/// +/// 除了按钮家族,**没有别的消费者**:面状控件(chip / slider 轨道 / switch 轨道 / 描边)走 +/// [`disabled_colors`] 的 12%。 +pub fn disabled_button_colors(ui: &Ui, surface: Role) -> (Color, Color) { + disabled_colors_with_alpha(ui, surface, DISABLED_CONTAINER_ALPHA) +} + +/// 两个族共用的求值:容器 = `OnSurface` @ `container_alpha` 叠在 `surface` 上,内容 = 38% 叠在容器上。 +fn disabled_colors_with_alpha(ui: &Ui, surface: Role, container_alpha: f32) -> (Color, Color) { + let scheme = Scheme::baseline(ui.flavor()); + let base = scheme.get(surface); + let container = scheme + .get(Role::OnSurface) + .with_alpha(container_alpha) + .over(base); + // 这里是**默认口径**(OnSurfaceVariant);按变体取值请走 [`disabled_content_over_role`] + // ——FilledTonal 与三式 IconButton 的禁用内容是 `OnSurface`(A1)。 + let content = scheme + .get(Role::OnSurfaceVariant) + .with_alpha(DISABLED_CONTENT_ALPHA) + .over(container); + + (to_iced(container), to_iced(content)) +} + +#[cfg(test)] +mod tests { + use super::*; + use unlucky_design::color::SchemeFlavor; + use unlucky_design::typography::Platform; + + use crate::theme::Families; + + const FAMILIES: Families = Families { + text: "Segoe UI", + display: "Segoe UI", + mono: "Consolas", + }; + + fn ui(flavor: SchemeFlavor) -> Ui { + Ui::with_platform(flavor, Platform::Windows, FAMILIES) + } + + /// 两族的 α 必须都钉住(只钉一条,另一族被合并时会静默错 1/255)。 + #[test] + fn both_disabled_alpha_classes_are_pinned_to_the_tokens() { + // 绑定成局部变量再过断言:直接比两个常量会被 clippy 判成「常量断言」。 + let button_alpha = DISABLED_CONTAINER_ALPHA; + let surface_alpha = DISABLED_SURFACE_ALPHA; + let content_alpha = DISABLED_CONTENT_ALPHA; + // 按钮容器:官方 *ButtonTokens.DisabledContainerOpacity = 0.1f + assert!( + (button_alpha - 0.10).abs() < f32::EPSILON, + "按钮禁用容器 α 必须是 0.10(官方 DisabledContainerOpacity);\ + 0.12 是面状控件那一类,别混用" + ); + // 面状控件:chip 描边 / slider 未激活轨 / switch 轨道 = 0.12f + assert!( + (surface_alpha - 0.12).abs() < f32::EPSILON, + "面状控件禁用 α 必须是 0.12(官方 FlatDisabledOutlineOpacity / \ + DisabledInactiveTrackOpacity / DisabledTrackOpacity)" + ); + assert!((content_alpha - 0.38).abs() < f32::EPSILON); + assert!( + surface_alpha > button_alpha, + "两族必须可区分:面状控件比按钮容器更实({surface_alpha} > {button_alpha})" + ); + } + + /// **面状控件族**的那一对颜色 = 0.12 预混合(chip / switch / slider / TextField 描边)。 + /// + /// ⚠️ 期望值**写死 0.12 / 0.38**,不从 `DISABLED_SURFACE_ALPHA` 取: + /// 从被测常量求期望是**自指断言**——常量一改,expected 跟着改,永远相等(复审 r1 P2-1 实测: + /// 把 `DISABLED_SURFACE_ALPHA` 改成 0.10,旧版这条照样绿)。现在常量改坏必红。 + #[test] + fn surface_family_disabled_colors_are_the_12_percent_class() { + const EXPECTED_SURFACE_ALPHA: f32 = 0.12; + const EXPECTED_CONTENT_ALPHA: f32 = 0.38; + let actual_alpha = DISABLED_SURFACE_ALPHA; + assert_eq!( + actual_alpha, EXPECTED_SURFACE_ALPHA, + "面状控件族 α 必须是官方 0.12f;改常量会让下面每一条合成色断言同时红" + ); + + for flavor in [SchemeFlavor::Light, SchemeFlavor::Dark] { + let ui = ui(flavor); + let scheme = Scheme::baseline(flavor); + let base = scheme.get(Role::Surface); + let expected_container = scheme + .get(Role::OnSurface) + .with_alpha(EXPECTED_SURFACE_ALPHA) + .over(base); + + let (surface, surface_content) = disabled_colors(&ui, Role::Surface); + assert_eq!( + surface, + to_iced(expected_container), + "{flavor:?}:面状控件容器 = on-surface@12% 叠表面(期望值硬编码,不取自常量)" + ); + assert_eq!( + surface_content, + to_iced( + scheme + .get(Role::OnSurfaceVariant) + .with_alpha(EXPECTED_CONTENT_ALPHA) + .over(expected_container) + ), + "{flavor:?}:面状控件内容仍是 38%,只是叠的底不同" + ); + assert_ne!(surface, ui.color(Role::Surface), "12% 叠上去必须能看出来"); + assert_eq!(surface.a, 1.0, "禁用容器也是实色(CPU/GPU 一致)"); + assert_eq!(surface_content.a, 1.0, "禁用内容也是实色"); + // 内容叠在容器上 vs 叠在表面上,是两回事(probe 量的是前者) + assert_ne!( + surface_content, + to_iced( + scheme + .get(Role::OnSurfaceVariant) + .with_alpha(EXPECTED_CONTENT_ALPHA) + .over(base) + ), + "{flavor:?}:内容必须叠在容器上" + ); + // 跨族对照:面状族的合成色必须与按钮族不同(两族一旦合流,这条红) + let (button, _) = disabled_button_colors(&ui, Role::Surface); + assert_ne!( + surface, button, + "{flavor:?}:面状族 12% 与按钮族 10% 必须落在不同像素值上" + ); + } + } + + /// **按钮家族**的那一对颜色 = 0.10 预混合,且与面状控件族**必须落在不同像素值上**。 + /// + /// 期望值同样写死(0.10 / 0.38),理由见上一条:自指断言抓不住常量被改坏。 + #[test] + fn button_family_disabled_colors_are_the_10_percent_class() { + const EXPECTED_BUTTON_ALPHA: f32 = 0.10; + const EXPECTED_CONTENT_ALPHA: f32 = 0.38; + let actual_alpha = DISABLED_CONTAINER_ALPHA; + assert_eq!( + actual_alpha, EXPECTED_BUTTON_ALPHA, + "按钮族 α 必须是官方 0.1f(*ButtonTokens.DisabledContainerOpacity)" + ); + + for flavor in [SchemeFlavor::Light, SchemeFlavor::Dark] { + let ui = ui(flavor); + let scheme = Scheme::baseline(flavor); + let base = scheme.get(Role::Surface); + let expected_container = scheme + .get(Role::OnSurface) + .with_alpha(EXPECTED_BUTTON_ALPHA) + .over(base); + + let (button, button_content) = disabled_button_colors(&ui, Role::Surface); + assert_eq!( + button, + to_iced(expected_container), + "{flavor:?}:按钮容器 = on-surface@10% 叠表面(官方 DisabledContainerOpacity = 0.1f)" + ); + assert_eq!( + button_content, + to_iced( + scheme + .get(Role::OnSurfaceVariant) + .with_alpha(EXPECTED_CONTENT_ALPHA) + .over(expected_container) + ), + ); + + let (surface, _) = disabled_colors(&ui, Role::Surface); + assert_ne!( + button, surface, + "{flavor:?}:10% 与 12% 必须落在不同像素值上(合成一个数就是这次要防的回归)" + ); + + // 内容色同理:按钮 38% 叠 10% 底 ≠ 面状 38% 叠 12% 底 + assert_ne!( + button_content, + disabled_content_over_role(&ui, Role::OnSurfaceVariant, true), + "{flavor:?}:两族的「有容器」内容色不能同源" + ); + assert_eq!( + disabled_button_content(&ui, Role::OnSurfaceVariant, true), + button_content, + "{flavor:?}:按钮内容入口必须与按钮颜色入口同源" + ); + // 无容器时两族完全一致(都叠 surface)——差异只在「有容器」那一支 + assert_eq!( + disabled_button_content(&ui, Role::OnSurfaceVariant, false), + disabled_content_over_role(&ui, Role::OnSurfaceVariant, false), + "{flavor:?}:没有容器时不该有族差异" + ); + } + } +} diff --git a/crates/app-iced/src/components/interaction/mod.rs b/crates/app-iced/src/components/interaction/mod.rs index 6cf7b8e..8fd640a 100644 --- a/crates/app-iced/src/components/interaction/mod.rs +++ b/crates/app-iced/src/components/interaction/mod.rs @@ -13,9 +13,29 @@ //! | focus | 0.10 | [`StateLayer::Focus`] | //! | pressed | 0.10 | [`StateLayer::Pressed`] | //! | dragged | 0.16 | [`StateLayer::Dragged`](按钮家族不用,列表拖拽用) | -//! | disabled 容器 | 0.12 | [`DISABLED_CONTAINER_ALPHA`](**A1 是 0.10f**,本文件按令牌 0.12,差异登记 D8) | +//! | disabled 容器(**按钮家族**) | 0.10 | [`DISABLED_CONTAINER_ALPHA`](按钮 `*ButtonTokens.DisabledContainerOpacity`) | +//! | disabled 容器/描边(**面状控件族**) | 0.12 | [`DISABLED_SURFACE_ALPHA`](chip / slider 轨道 / switch 轨道 / TextField 禁用描边) | //! | disabled 内容 | 0.38 | [`DISABLED_CONTENT_ALPHA`] | //! +//! ## ⚠️ 禁用 α 有**两类**(M86 收口,别再合成一个数) +//! +//! 官方禁用 α 从来不是「一个 12%」: +//! +//! - **按钮容器 0.10**:`BaselineButtonTokens` / `FilledButtonTokens` / `OutlinedButtonTokens` / +//! `TextButtonTokens` / `ElevatedButtonTokens` / `TonalButtonTokens` 的 `DisabledContainerOpacity` +//! = `0.1f`(AAR 字节码 `` 里的 `ldc 0.1f`); +//! - **面状控件 0.12**:`AssistChipTokens.FlatDisabledOutlineOpacity` / `FilterChipTokens. +//! FlatDisabledSelectedContainerOpacity` / `SliderTokens.DisabledInactiveTrackOpacity` / +//! `SwitchTokens.DisabledTrackOpacity` 都是 `0.12f`。 +//! +//! 本模块过去只有一个 12% 常量供两条线共用:**按钮那半边一直错、chip/slider/switch 那半边一直对**。 +//! 现在按族分两个入口,谁也别再拿一个数服务两族: +//! +//! | 入口 | α | 谁用 | +//! |---|---|---| +//! | [`disabled_button_colors`] / [`disabled_button_content`] | 0.10 | **只有按钮家族**(`buttons::assemble`) | +//! | [`disabled_colors`] / [`disabled_content_over_role`] | 0.12 | **面状控件族**:chip 容器与描边、switch 轨道、slider 未激活轨、TextField 禁用描边(`disabled_colors` 这个名字是历史遗留,**语义是面状控件族**) | +//! //! 上表右列是**唯一**允许的取值口;左列只是给人看的,代码里一个数字都不许出现 //! (`components::gate` 会扫)。 //! @@ -33,10 +53,9 @@ use iced::widget::button; use iced::Color; -use unlucky_design::color::state::{DISABLED_CONTAINER_ALPHA, DISABLED_CONTENT_ALPHA}; -use unlucky_design::color::{Role, Scheme, StateLayer}; +use unlucky_design::color::{Role, StateLayer}; -use crate::theme::{to_iced, Ui}; +use crate::theme::Ui; mod tracker; @@ -63,7 +82,7 @@ pub enum Interaction { Focused, /// 按下:叠 10%。 Pressed, - /// 禁用:容器 12% / 内容 38%,**不叠状态层**。 + /// 禁用:按钮容器 10% / 面状控件 12% / 内容 38%,**不叠状态层**。 Disabled, } @@ -125,60 +144,11 @@ pub fn overlay_color(ui: &Ui, state: Interaction, content: Role, container: Role .map(|layer| ui.state_over(layer, content, container)) } -/// 禁用态内容色(**按角色、按有没有容器**求值)——A1 口径的唯一入口。 -/// -/// - `role`:由变体决定(Filled/Outlined/Text/Elevated = `OnSurfaceVariant`; -/// FilledTonal 与三式 IconButton = `OnSurface`,见 `Variant::disabled_content`); -/// - `on_container = true`:叠在 **12% 容器**上(有容器的三式); -/// `false`:直接叠在 **surface** 上(Text / Outlined / standard IconButton)。 -/// -/// 之前这里写死 `OnSurfaceVariant` 且只有一个「底」,导致 FilledTonal 与 IconButton 两族偏浅 -/// 15/255(视觉 r3 用像素量的)。 -pub fn disabled_content_over_role(ui: &Ui, role: Role, on_container: bool) -> Color { - let scheme = Scheme::baseline(ui.flavor()); - let base = if on_container { - scheme - .get(Role::OnSurface) - .with_alpha(DISABLED_CONTAINER_ALPHA) - .over(scheme.get(Role::Surface)) - } else { - scheme.get(Role::Surface) - }; +mod disabled; - to_iced( - scheme - .get(role) - .with_alpha(DISABLED_CONTENT_ALPHA) - .over(base), - ) -} - -/// 禁用态的一对颜色 `(容器, 内容)`,内容按令牌口径叠在**算好的容器**之上。 -/// -/// - 容器 = `OnSurface` @ 12%([`DISABLED_CONTAINER_ALPHA`];A1 是 0.10f,见 D8)叠在 `surface` 上; -/// - 内容 = **默认** `OnSurfaceVariant` @ 38%;按变体的取值走 [`disabled_content_over_role`]。 -/// -/// `ui` 不暴露 [`Scheme`],这里按同一个 flavor 求一次基线方案——调的是 -/// `unlucky_design` 的**同一个**函数与同一套角色,不是第二份令牌。 -/// -/// 顺序必须「先容器后内容」:Compose 也是这么画的,先叠容器再叠 label, -/// 所以内容叠的角色是容器实色而不是底下的 surface。 -pub fn disabled_colors(ui: &Ui, surface: Role) -> (Color, Color) { - let scheme = Scheme::baseline(ui.flavor()); - let base = scheme.get(surface); - let container = scheme - .get(Role::OnSurface) - .with_alpha(DISABLED_CONTAINER_ALPHA) - .over(base); - // 这里是**默认口径**(OnSurfaceVariant);按变体取值请走 [`disabled_content_over_role`] - // ——FilledTonal 与三式 IconButton 的禁用内容是 `OnSurface`(A1)。 - let content = scheme - .get(Role::OnSurfaceVariant) - .with_alpha(DISABLED_CONTENT_ALPHA) - .over(container); - - (to_iced(container), to_iced(content)) -} +pub use disabled::{ + disabled_button_colors, disabled_button_content, disabled_colors, disabled_content_over_role, +}; #[cfg(test)] mod tests { @@ -198,15 +168,16 @@ mod tests { Ui::with_platform(flavor, Platform::Windows, FAMILIES) } - /// 契约 §3 的 α 全表:**钉住令牌值**(改令牌会让这条红,改组件抓不到)。 + /// 契约 §3 的状态层 α 全表:**钉住令牌值**(改令牌会让这条红,改组件抓不到)。 + /// + /// 禁用态的两族 α(按钮 0.10 / 面状控件 0.12)在 [`super::disabled`] 的测试里, + /// 那边还负责「两族不能同源」的判据。 #[test] fn state_layer_alphas_are_the_contract_values() { assert!((StateLayer::Hover.alpha() - 0.08).abs() < f32::EPSILON); assert!((StateLayer::Focus.alpha() - 0.10).abs() < f32::EPSILON); assert!((StateLayer::Pressed.alpha() - 0.10).abs() < f32::EPSILON); assert!((StateLayer::Dragged.alpha() - 0.16).abs() < f32::EPSILON); - assert!((DISABLED_CONTAINER_ALPHA - 0.12).abs() < f32::EPSILON); - assert!((DISABLED_CONTENT_ALPHA - 0.38).abs() < f32::EPSILON); } #[test] @@ -265,34 +236,4 @@ mod tests { assert_ne!(hover, pressed, "8% 与 10% 必须能区分"); assert_eq!(hover.a, 1.0, "结果必须不透明(GPU/CPU 逐像素一致)"); } - - #[test] - fn disabled_colors_come_from_the_token_functions() { - let ui = ui(SchemeFlavor::Light); - let scheme = Scheme::baseline(SchemeFlavor::Light); - - let (container, content) = disabled_colors(&ui, Role::Surface); - let base = scheme.get(Role::Surface); - let expected_container = scheme - .get(Role::OnSurface) - .with_alpha(DISABLED_CONTAINER_ALPHA) - .over(base); - assert_eq!( - container, - to_iced(expected_container), - "容器 = on-surface@12% 叠表面" - ); - assert_eq!( - content, - to_iced( - scheme - .get(Role::OnSurfaceVariant) - .with_alpha(DISABLED_CONTENT_ALPHA) - .over(expected_container) - ), - ); - assert_ne!(container, ui.color(Role::Surface), "12% 叠上去必须能看出来"); - assert_eq!(container.a, 1.0, "禁用容器也是实色(CPU/GPU 一致)"); - assert_eq!(content.a, 1.0, "禁用内容也是实色"); - } } diff --git a/crates/app-iced/src/components/selection/chip.rs b/crates/app-iced/src/components/selection/chip.rs index c4a9750..e84353d 100644 --- a/crates/app-iced/src/components/selection/chip.rs +++ b/crates/app-iced/src/components/selection/chip.rs @@ -1,5 +1,5 @@ //! Chip(`research/12b` §13):高 **32** / 圆角 **8** / 水平内边距 **8** / 元素间距 **8** / -//! 图标 **16**(官方 18 → **G14**,与按钮家族同一登记)/ 描边 1px `OutlineVariant`。 +//! 图标 **18**(`IconSize::Button` = 官方 `AssistChipTokens.IconSize`)/ 描边 1px `OutlineVariant`。 //! //! ## 三类(§13 的 Assist / Filter / Input) //! @@ -122,11 +122,12 @@ pub fn chip<'a, M: Clone + 'a>(ui: &'a Ui, spec: Chip<'a, M>) -> Element<'a, M> let mut content = row![].align_y(Alignment::Center).spacing(geometry.gap); if let Some(kind) = spec.leading { - // Q9:图标一律经固定尺寸容器 + // Q9:图标一律经固定尺寸容器;盒 18 ≠ RENDER_PX(24),所以必须走 `view_at` 按 18 烘焙, + // 否则线宽会被布局缩水(复审 r1 P2-2)。 content = content.push(super::super::layout::centered_fixed( geometry.icon, geometry.icon, - icons::view::(kind, at_rest.icon), + icons::view_at::(kind, at_rest.icon, geometry.icon), )); } @@ -142,7 +143,7 @@ pub fn chip<'a, M: Clone + 'a>(ui: &'a Ui, spec: Chip<'a, M>) -> Element<'a, M> content = content.push(super::super::layout::centered_fixed( geometry.icon, geometry.icon, - icons::view::(kind, at_rest.icon), + icons::view_at::(kind, at_rest.icon, geometry.icon), )); } diff --git a/crates/app-iced/src/components/selection/geometry.rs b/crates/app-iced/src/components/selection/geometry.rs index d0a081b..3a6656e 100644 --- a/crates/app-iced/src/components/selection/geometry.rs +++ b/crates/app-iced/src/components/selection/geometry.rs @@ -18,7 +18,11 @@ //! `CHECKBOX/RADIO/SEARCH_BAR/NAVIGATION_DRAWER` 四组,**Switch 与 Chip 没有落点**。 //! 本文件把它们的量收成命名常量(每个都带 A1 出处),等令牌层补 //! `ComponentMetrics::SWITCH/CHIP` 后整段删除、改走 `Component::Switch.metrics()`。 -//! 图标 **18** 沿用按钮家族的 **G14** 偏离(令牌只有 `IconSize::Button = 16`)。 +//! +//! **G14 已收口(M86)**:Chip 图标取 `IconSize::Button` = **18**(AAR `AssistChipTokens.IconSize = +//! 18.0d`,与 12b 13.4 同值),不再是偏离。Switch 滑块内的图标是**另一个值 16** +//! (`SwitchTokens.SelectedIconSize / UnselectedIconSize = 16.0d`),所以走 `IconSize::SwitchThumb`: +//! 两者在官方就是两个 token,别为了省一个档位把它们合成一个。 use unlucky_design::component_map::{Component, ComponentMetrics}; use unlucky_design::icons::IconSize; @@ -139,7 +143,8 @@ pub struct SwitchGeometry { pub thumb_unselected: f32, /// 按下滑块(28)。 pub thumb_pressed: f32, - /// 滑块内图标(16,`SwitchTokens.SelectedIconSize`;令牌 `IconSize::Button` 同值)。 + /// 滑块内图标(16 = `IconSize::SwitchThumb`;AAR `SwitchTokens.SelectedIconSize / + /// UnselectedIconSize = 16.0d`。**不是按钮的 18**)。 pub icon: f32, /// 状态层直径(40)。 pub state_layer: f32, @@ -157,7 +162,7 @@ impl SwitchGeometry { thumb_selected: SWITCH_THUMB_SELECTED, thumb_unselected: SWITCH_THUMB_UNSELECTED, thumb_pressed: SWITCH_THUMB_PRESSED, - icon: IconSize::Button.px(), + icon: IconSize::SwitchThumb.px(), state_layer: SWITCH_STATE_LAYER, hit: ui.metrics().touch_target, } @@ -227,7 +232,7 @@ pub struct ChipGeometry { pub pad: f32, /// 元素间距(8,同上)。 pub gap: f32, - /// 图标边长(16;官方 18 → **G14** 偏离,与按钮家族同一登记)。 + /// 图标边长(18 = `IconSize::Button`;AAR `AssistChipTokens.IconSize = 18.0d`,12b 13.4 同值)。 pub icon: f32, } diff --git a/crates/app-iced/src/components/selection/mod.rs b/crates/app-iced/src/components/selection/mod.rs index 1fae516..c6c15cc 100644 --- a/crates/app-iced/src/components/selection/mod.rs +++ b/crates/app-iced/src/components/selection/mod.rs @@ -21,7 +21,10 @@ //! - 全部组件**对消息类型泛型**(`M: Clone`),不 import `crate::app::Message`; //! - hover / pressed / focus / disabled 的 α 只从 [`super::interaction`] 取(本目录零 α 字面量); //! - 精确尺寸只从 [`super::layout`](`centered_fixed` / `fixed`)与 `geometry` 取,不许手写; -//! - 图标一律 `icons::view` + 固定尺寸容器(Q9),圆角按 D32 分层(微控件 2 / 容器刻度 / full)。 +//! - 图标一律 `icons::view_at(kind, color, geometry.icon)` + 固定尺寸容器(Q9):本目录的盒是 +//! **18(chip)与 16(switch 滑块)**,都不是 `icons::RENDER_PX`(24),所以必须按盒烘焙——用 +//! `icons::view`(24 烘焙)会被布局缩到 1.1px 视觉线宽(复审 r1 P2-2)。圆角按 D32 分层 +//! (微控件 2 / 容器刻度 / full)。 mod checkbox; mod chip; diff --git a/crates/app-iced/src/components/selection/probe.rs b/crates/app-iced/src/components/selection/probe.rs index 787182e..7a7e697 100644 --- a/crates/app-iced/src/components/selection/probe.rs +++ b/crates/app-iced/src/components/selection/probe.rs @@ -265,7 +265,7 @@ fn selection_geometry_matches_the_reference() { ); assert!( chips[0].width() >= 58, - "13.1:chip 宽应 ≥58(图标 16 + 两侧各 8 + 文字),实测 {}", + "13.1:chip 宽应 ≥58(图标 18 + 两侧各 8 + 文字),实测 {}", chips[0].width() ); diff --git a/crates/app-iced/src/components/selection/switch.rs b/crates/app-iced/src/components/selection/switch.rs index d5523dc..b097cda 100644 --- a/crates/app-iced/src/components/selection/switch.rs +++ b/crates/app-iced/src/components/selection/switch.rs @@ -80,7 +80,8 @@ pub fn switch<'a, M: Clone + 'a>(ui: &'a Ui, spec: Switch<'a, M>) -> Element<'a, Some(ink) => super::super::layout::centered_fixed( geometry.icon, geometry.icon, - icons::view::(IconKind::Check, ink), + // 盒 16 ≠ RENDER_PX(24):必须按 16 烘焙,否则线宽被布局压细(复审 r1 P2-2)。 + icons::view_at::(IconKind::Check, ink, geometry.icon), ), None => Spacer::new().into(), }; diff --git a/crates/app-iced/src/components/selection/tests.rs b/crates/app-iced/src/components/selection/tests.rs index 66011ab..3bb995b 100644 --- a/crates/app-iced/src/components/selection/tests.rs +++ b/crates/app-iced/src/components/selection/tests.rs @@ -95,7 +95,15 @@ fn switch_geometry_matches_the_tokens() { 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.icon, 16.0, + "10.5:滑块内图标 16(官方 SwitchTokens.SelectedIconSize,令牌 IconSize::SwitchThumb)" + ); + assert_eq!( + g.icon, + unlucky_design::icons::IconSize::SwitchThumb.px(), + "滑块图标必须走 SwitchThumb 档,不许借按钮的 18" + ); assert_eq!(g.state_layer, 40.0, "10.6:状态层 Ø40"); assert_eq!(g.hit, 48.0, "命中区 48"); @@ -118,7 +126,7 @@ fn switch_geometry_matches_the_tokens() { } } -/// §13.1 / §13.3 / §13.4:Chip 高 32、圆角 8、内边距 8、图标 16(官方 18 → G14)。 +/// §13.1 / §13.3 / §13.4:Chip 高 32、圆角 8、内边距 8、图标 18(官方 `AssistChipTokens.IconSize`,G14 已收口)。 #[test] fn chip_geometry_matches_the_tokens() { let ui = ui(); @@ -127,7 +135,18 @@ fn chip_geometry_matches_the_tokens() { 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 偏离)"); + // 旧期望 16 记的是 G14 偏离(令牌层当时没有 18 档);令牌补齐后官方值 18 才是对的 + // ——AAR `AssistChipTokens.IconSize = 18.0d`,12b 13.4 也写 18。这不是「把期望改成实现」, + // 是偏离被取消。 + assert_eq!( + g.icon, 18.0, + "13.4:图标 18(官方 AssistChipTokens.IconSize)" + ); + assert_eq!( + g.icon, + unlucky_design::icons::IconSize::Button.px(), + "chip 图标与按钮图标同源(令牌 IconSize::Button)" + ); } /// §18.2 / A1:未选中 Checkbox 在 hover/focus/pressed 下描边提到 `OnSurface`(不是 `OnSurfaceVariant`)。 diff --git a/crates/app-iced/src/icons.rs b/crates/app-iced/src/icons.rs index 09ced9a..eff402c 100644 --- a/crates/app-iced/src/icons.rs +++ b/crates/app-iced/src/icons.rs @@ -10,29 +10,60 @@ //! //! 令牌的 `Icon::stroke_width(render_px)` 已经把「视觉 1.5px」折算成 **viewBox 单位** //! (`1.5 × 24 / render_px`),本模块**不重算也不手抄常数**。 -//! 本骨架所有图标都按 24px 渲染(12b 2.2 标题栏 / 5.5 导航),所以每个图标只有一份位图缓存。 +//! +//! ## ⚠️ 渲染尺寸**不是**一个全局值(M86 收口) +//! +//! **盒子多宽就按多宽烘焙**,规则只有两条: +//! +//! | 盒尺寸 | 用哪个入口 | +//! |---|---| +//! | **等于** [`RENDER_PX`](24) | [`view`](导航、卡片头、表单、IconButton…) | +//! | **不等于** 24(按钮 18 / chip 18 / switch 滑块 16 / 标题栏 caption 20) | **[`view_at`](必须)** | +//! +//! 尺寸只有一个来源:`unlucky_design::icons` 的令牌([`IconSize`] / +//! [`unlucky_design::icons::TITLEBAR_CAPTION_ICON_PX`])——本文件里不许出现 `18.0` / `20.0` / `24.0` +//! 这类字面量,也不许把某个组件的尺寸「凑」进 [`RENDER_PX`]。 +//! +//! **为什么必须按盒烘焙**:`stroke-width` 是 viewBox 单位,同一个 viewBox 线宽在更小的盒子里 +//! 视觉更细。把 24px 烘焙的位图塞进 18px 盒子,视觉线宽会从 1.5px 掉到 **1.125px** +//! (复审 r1 实测 1.04~1.25px,而同屏 caption 的 20px 走 [`view_at`] 实测正好 1.50px)。 +//! 所以:**每个用到的尺寸各缓存一份**,缓存键是 `(图标种类, 渲染尺寸)`——但「缓存支持多尺寸」 +//! 不等于「调用点都用上了」,**不等于 24 的盒子一律走 [`view_at`]** 才是这条的落点。 -use std::sync::OnceLock; +use std::collections::HashMap; +use std::sync::{Mutex, OnceLock}; use iced::widget::svg; use iced::Color; use unlucky_design::icons::{IconKind, IconPaint, IconSize}; -/// 骨架期图标的统一渲染尺寸(12b:标题栏与导航轨都是 24)。 +/// 图标缓存键里的尺寸精度(px × 100 取整),避免拿 f32 做哈希键。 +const PX_KEY_SCALE: f32 = 100.0; + +/// 骨架期图标的默认渲染尺寸(12b:标题栏与导航轨都是 24;`SmallIconButtonTokens.IconSize = 24`)。 pub const RENDER_PX: f32 = IconSize::Toolbar.px(); +/// 标题栏 caption 三键(最小化 / 最大化 / 关闭)的图标边长。 +/// +/// **项目偏离**(用户点名):官方 `SmallIconButtonTokens.IconSize = 24`,这里 20;容器 46×48 不动。 +/// 值只在令牌层定义一次(`unlucky_design::icons::TITLEBAR_CAPTION_ICON_PX`),本文件只是转口。 +pub const CAPTION_PX: f32 = unlucky_design::icons::TITLEBAR_CAPTION_ICON_PX; + /// 生成一个图标的 SVG 源码(`viewBox` 24×24,颜色用 `currentColor` 交给 iced 的色滤镜)。 /// /// 之所以用 `currentColor` 而不是把令牌色写进 SVG:色滤镜(`svg::Style::color`)会按 /// **alpha 蒙版**重涂 RGB,这样一份 SVG 位图能被浅/深两套主题复用,缓存不会翻倍。 -pub fn source(kind: IconKind) -> String { +/// +/// `render_px` 决定两件事:SVG 的 `width`/`height` 属性与 `stroke-width` 的 viewBox 折算, +/// 两者必须同源,否则线宽会被二次缩放(见模块文档最后一段)。 +pub fn source(kind: IconKind, render_px: f32) -> String { let icon = kind.icon(); let paint = match icon.paint { IconPaint::Stroke => format!( "fill=\"none\" stroke=\"currentColor\" stroke-width=\"{}\" \ stroke-linecap=\"round\" stroke-linejoin=\"round\"", - icon.stroke_width(RENDER_PX) + icon.stroke_width(render_px) ), IconPaint::Fill => "fill=\"currentColor\" stroke=\"none\"".to_owned(), }; @@ -40,20 +71,13 @@ pub fn source(kind: IconKind) -> String { format!( "", - RENDER_PX, icon.path + render_px, icon.path ) } -/// 图标的 SVG 句柄(按 `IconKind` 缓存;句柄 id 是内容哈希,所以 iced 侧也能命中缓存)。 -fn handle(kind: IconKind) -> svg::Handle { - static HANDLES: OnceLock> = OnceLock::new(); - - let handles = HANDLES.get_or_init(|| { - IconKind::ALL - .iter() - .map(|kind| svg::Handle::from_memory(source(*kind).into_bytes())) - .collect() - }); +/// 图标的 SVG 句柄(按 `(种类, 渲染尺寸)` 缓存;句柄 id 是内容哈希,所以 iced 侧也能命中缓存)。 +fn handle(kind: IconKind, render_px: f32) -> svg::Handle { + static HANDLES: OnceLock>> = OnceLock::new(); // 依赖「枚举下标 == IconKind::ALL 下标」(令牌层用 `GLYPHS[self as usize]` 同一约定)。 // 这条断言把约定钉在测试与 debug 构建里,`IconKind` 一旦乱序插入就会立刻炸。 @@ -63,14 +87,34 @@ fn handle(kind: IconKind) -> svg::Handle { "IconKind::ALL 必须与枚举声明顺序一致" ); - handles[kind as usize].clone() + let key = (kind, (render_px * PX_KEY_SCALE).round() as u32); + let cache = HANDLES.get_or_init(|| Mutex::new(HashMap::new())); + let mut cache = cache + .lock() + .unwrap_or_else(|poisoned| poisoned.into_inner()); + cache + .entry(key) + .or_insert_with(|| svg::Handle::from_memory(source(kind, render_px).into_bytes())) + .clone() } -/// 一个图标控件(尺寸走令牌,颜色走令牌角色)。 +/// 一个图标控件:按**令牌默认尺寸**([`RENDER_PX`])渲染,颜色走令牌角色。 pub fn view<'a, Message: 'a>(kind: IconKind, color: Color) -> iced::Element<'a, Message> { - svg(handle(kind)) - .width(RENDER_PX) - .height(RENDER_PX) + view_at(kind, color, RENDER_PX) +} + +/// 一个图标控件:按调用方给的**令牌尺寸**渲染(`render_px` 必须来自 `unlucky_design::icons`)。 +/// +/// 只有需要「组件自己的图标尺寸不同」时才用这个入口(目前只有标题栏 caption 三键)。 +/// 别为了摆一个位置而拿它调字面量:位置交给布局(`layout::fixed` / `centered_fixed`)。 +pub fn view_at<'a, Message: 'a>( + kind: IconKind, + color: Color, + render_px: f32, +) -> iced::Element<'a, Message> { + svg(handle(kind, render_px)) + .width(render_px) + .height(render_px) .style(move |_theme, _status| svg::Style { color: Some(color) }) .into() } @@ -82,7 +126,7 @@ mod tests { #[test] fn every_icon_produces_wellformed_svg() { for kind in IconKind::ALL { - let source = source(kind); + let source = source(kind, RENDER_PX); assert!(source.starts_with(""), "{} 的 SVG 尾不对", kind.name()); assert!(source.contains("viewBox=\"0 0 24 24\""), "{}", kind.name()); @@ -97,7 +141,7 @@ mod tests { #[test] fn stroke_icons_carry_the_token_stroke_width() { - let stroke = source(IconKind::Settings); + let stroke = source(IconKind::Settings, RENDER_PX); let expected = IconKind::Settings.icon().stroke_width(RENDER_PX); assert!( stroke.contains(&format!("stroke-width=\"{expected}\"")), @@ -108,19 +152,246 @@ mod tests { assert!((expected - 1.5).abs() < f32::EPSILON); } + /// 默认尺寸**只从令牌取**:谁把 `RENDER_PX` 改成字面量或改成别的档,这条会红。 + #[test] + fn default_render_size_comes_from_the_token() { + assert_eq!( + RENDER_PX, + IconSize::Toolbar.px(), + "默认 24 来自 IconSize::Toolbar" + ); + assert_eq!(RENDER_PX, 24.0); + assert!( + source(IconKind::Close, RENDER_PX).contains("width=\"24\""), + "默认渲染尺寸必须是 24" + ); + } + + /// `view_at` 必须**按请求尺寸烘焙**(复审 r1 P1-2/P2-2 的守门断言)。 + /// + /// 两条路径的差别就是这条测试的全部意义: + /// - **烘焙路径**:`source(kind, 盒)` → SVG `width=盒` + 线宽 `1.5×24/盒` ⇒ 视觉恒 **1.5px**; + /// - **缩放路径**(错的那条):24 烘焙(`stroke-width="1.5"`)塞进 18 盒 ⇒ 视觉 **1.125px**。 + #[test] + fn view_at_bakes_the_requested_size() { + // (盒, 该盒应有的 viewBox 线宽, 盒子来源)——caption 20 / 按钮 18 / switch 滑块 16。 + let cases = [ + (CAPTION_PX, 1.8, "caption"), + (IconSize::Button.px(), 2.0, "按钮"), + (IconSize::SwitchThumb.px(), 2.25, "switch 滑块"), + ]; + for (px, stroke, what) in cases { + let svg = source(IconKind::Close, px); + // `stroke-width` 由 `format!("{}", f32)` 渲染:2.0 会打印成 `2`,所以先比数值再比字符串。 + let baked = unlucky_design::icons::stroke_width_for(px, 1.5); + assert!( + (baked - stroke).abs() < 1.0e-6, + "{what}:{px}px 盒的 viewBox 线宽应为 {stroke},实际 {baked}" + ); + assert!( + svg.contains(&format!("width=\"{px}\"")) + && svg.contains(&format!("stroke-width=\"{baked}\"")), + "{what}:svg 必须按 {px} 烘焙:{svg}" + ); + let visual = unlucky_design::icons::visual_stroke_px(px, baked); + assert!( + (visual - 1.5).abs() < 1.0e-6, + "{what}:烘焙路径视觉线宽应为 1.5,实际 {visual}" + ); + } + + // 反证:拿 24 烘焙的那份去填 18 盒(= 用 `view` 装进小盒),视觉只剩 1.125px。 + let scaled = source(IconKind::Check, RENDER_PX); + assert!(scaled.contains("width=\"24\"")); + let scaled_visual = unlucky_design::icons::visual_stroke_px(IconSize::Button.px(), 1.5); + assert!( + (scaled_visual - 1.125).abs() < 1.0e-6, + "缩放路径只该得到 1.125px(这就是为什么盒 ≠ 24 必须走 view_at)" + ); + } + #[test] fn filled_icons_do_not_get_a_stroke() { // `more` 是三个填充圆点 - let filled = source(IconKind::More); + let filled = source(IconKind::More, RENDER_PX); assert!(filled.contains("fill=\"currentColor\"")); assert!(filled.contains("stroke=\"none\"")); } #[test] - fn handles_are_cached_per_kind() { - let first = handle(IconKind::Close); - let second = handle(IconKind::Close); - assert_eq!(first.id(), second.id()); - assert_ne!(first.id(), handle(IconKind::Minimize).id()); + fn handles_are_cached_per_kind_and_size() { + let first = handle(IconKind::Close, RENDER_PX); + let second = handle(IconKind::Close, RENDER_PX); + assert_eq!(first.id(), second.id(), "同尺寸同图标必须复用句柄"); + assert_ne!(first.id(), handle(IconKind::Minimize, RENDER_PX).id()); + // 同一图标、不同渲染尺寸 = 不同位图(线宽不同),不能共用。 + assert_ne!(first.id(), handle(IconKind::Close, CAPTION_PX).id()); + } + + /// **盒 ≠ `RENDER_PX`(24) 的调用点必须走 `view_at`**(复审 r1 P1-1)。 + /// + /// 判据 = 调用形状匹配 + **逐调用点计数**(不做「文件里出现过 view_at 就整文件放行」的短路): + /// 先剥注释,再认 `icons::view(` / `icons::view::<` / `crate::icons::view` / `super::icons::view` + /// 以及 `use …icons…view as v` / `{view, view_at}` 之后的 `v(` / `view(`; + /// 每个文件的计数必须**等于**白名单期望(未列出 = 0),所以「同文件改对 3 处、漏掉 1 处」也会红。 + #[test] + fn sub_24_boxes_go_through_view_at() { + const VIEW: &[(&str, usize)] = &[ + ("shell/nav.rs", 1), + ("components/inputs/field.rs", 2), + ("components/inputs/menu.rs", 2), + ("components/inputs/search.rs", 2), + ("components/buttons/mod.rs", 1), // 24 图标 + 40 外壳 + ]; + let root = std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("src"); + let mut views = 0usize; + let mut bad: Vec = Vec::new(); + for path in walk_rs(&root) { + let rel = path.strip_prefix(&root).expect("strip_prefix"); + let rel = rel.to_string_lossy().replace('\\', "/"); + if rel == "icons.rs" { + continue; // 实现自身 + } + let code = strip_comments(&std::fs::read_to_string(&path).expect("读组件源码")); + let view_alias = icon_view_aliases(&code); + let view = count_calls(&code, "view", &view_alias); + views += view; + let want_view = sites(VIEW, &rel); + if view != want_view { + bad.push(format!( + "{rel}: {view} 处 `icons::view`,期望 {want_view} —— 盒 ≠ 24 必须 \ + `icons::view_at(kind, color, 盒尺寸)`(24 烘焙再缩放只有 ~1.1px 线宽)" + )); + } + } + assert!(bad.is_empty(), "图标入口用法违规:\n{}", bad.join("\n")); + assert_eq!(views, 8, "全仓 `icons::view` 调用点总数变了"); + + fn sites(list: &[(&str, usize)], rel: &str) -> usize { + list.iter().find(|(p, _)| *p == rel).map_or(0, |(_, n)| *n) + } + fn walk_rs(dir: &std::path::Path) -> Vec { + let mut out = Vec::new(); + for entry in std::fs::read_dir(dir).expect("读目录") { + let path = entry.expect("目录项").path(); + if path.is_dir() { + out.extend(walk_rs(&path)); + } else if path.extension().is_some_and(|ext| ext == "rs") { + out.push(path); + } + } + out + } + /// 剥掉 `//` 行注释与 `/* */` 块注释;字符串里的 `//`(URL)不算注释。 + fn strip_comments(text: &str) -> String { + let mut out = String::with_capacity(text.len()); + let mut it = text.chars().peekable(); + let (mut line, mut block, mut string) = (false, false, false); + while let Some(c) = it.next() { + if line { + line = c != '\n'; + if c == '\n' { + out.push(c); + } + } else if block { + if c == '*' && it.peek() == Some(&'/') { + it.next(); + block = false; + } + } else if string { + out.push(c); + if c == '\\' { + if let Some(esc) = it.next() { + out.push(esc); + } + } else if c == '"' { + string = false; + } + } else if c == '"' { + string = true; + out.push(c); + } else if c == '/' && it.peek() == Some(&'/') { + it.next(); + line = true; + } else if c == '/' && it.peek() == Some(&'*') { + it.next(); + block = true; + } else { + out.push(c); + } + } + out + } + /// `use …icons…view …` 里 `view` 的本地名(含 `as` 别名与花括号列表)。 + fn icon_view_aliases(code: &str) -> Vec { + let mut names = Vec::new(); + for line in code.lines() { + let t = line.trim_start(); + if !t.starts_with("use ") || !t.contains("icons") { + continue; + } + for (idx, _) in t.match_indices("view") { + let end = idx + 4; + if !word_start(t, idx) || !word_end(t, end) { + continue; + } + let alias = alias_after(t, end).unwrap_or_else(|| "view".to_owned()); + if !names.contains(&alias) { + names.push(alias); + } + } + } + names + } + /// `use …::view as v;` 里的 `v`。 + fn alias_after(line: &str, from: usize) -> Option { + let rest = line[from..].trim_start().strip_prefix("as")?.trim_start(); + let name: String = rest + .chars() + .take_while(|c| c.is_alphanumeric() || *c == '_') + .collect(); + (!name.is_empty()).then_some(name) + } + /// 数调用点:路径形状(`icons::`,含 `crate::`/`super::` 前缀)与别名形状, + /// 后面必须紧跟 `(` 或 `::<`。 + fn count_calls(code: &str, name: &str, aliases: &[String]) -> usize { + let mut count = 0; + let path = format!("icons::{name}"); + for (idx, _) in code.match_indices(&path) { + if word_start(code, idx) && follows_call(code, idx + path.len()) { + count += 1; + } + } + for alias in aliases { + for (idx, _) in code.match_indices(alias.as_str()) { + let end = idx + alias.len(); + // `icons::view(` 里的 `view` 已由路径形状数过,别再数一次。 + if !(idx >= 7 && code[..idx].ends_with("icons::")) + && word_start(code, idx) + && word_end(code, end) + && follows_call(code, end) + { + count += 1; + } + } + } + count + } + fn follows_call(code: &str, from: usize) -> bool { + let rest = code[from..].trim_start_matches([' ', '\t']); + rest.starts_with('(') || rest.starts_with("::<") + } + fn word_start(code: &str, idx: usize) -> bool { + code[..idx] + .chars() + .next_back() + .is_none_or(|c| !(c.is_alphanumeric() || c == '_')) + } + fn word_end(code: &str, idx: usize) -> bool { + code[idx..] + .chars() + .next() + .is_none_or(|c| !(c.is_alphanumeric() || c == '_')) + } } } diff --git a/crates/app-iced/src/shell/mod.rs b/crates/app-iced/src/shell/mod.rs index 6b85b85..6dbc569 100644 --- a/crates/app-iced/src/shell/mod.rs +++ b/crates/app-iced/src/shell/mod.rs @@ -5,7 +5,7 @@ //! | 部件 | 参考 | 令牌落点 | //! |---|---|---| //! | 标题栏高度 | 16.2 桌面标题栏 **40**(登记偏离) | `LM.titlebar_height = 48`(M63 §1.4/R13 桌面档,令牌优先) | -//! | 标题栏按钮 | 16.5 图标 **24** | `IconSize::Toolbar` + `LM.titlebar_button_width = 46` | +//! | 标题栏按钮 | 16.5 官方图标 **24** → 本项目 **20**(M86 项目偏离,用户点名「图标稍大」) | `icons::CAPTION_PX`(令牌 `TITLEBAR_CAPTION_ICON_PX`)+ `LM.titlebar_button_width = 46` | //! | 页头 | 16.1 官方 AppBar **64**;本项目 **56** | `LM.page_header_height = 56`(已有偏离登记) | //! | 卡片 | 3.x Outlined:**Medium(12)**、L0、`OutlineVariant` 1px、`SurfaceContainerLow` | `Component::OutlinedCard.spec()` + `STROKE_PX` | //! | 字级 | 16.4 标题 `TitleLarge`;卡片 `TitleMedium` / `BodySmall` | `Ui::style` / `Ui::emphasized` | @@ -153,9 +153,13 @@ fn titlebar<'a>(ui: &'a Ui, title: &'a str, forced: Option) -> Elem /// 标题栏按钮(自绘最小化/最大化/关闭)。 /// -/// 图标必须**再包一层固定 24×24 的容器**:iced 的 `button` 在 layout 里给内容 Fill 约束, +/// 图标必须**再包一层固定尺寸的容器**:iced 的 `button` 在 layout 里给内容 Fill 约束, /// 直接把 `svg(...).width(24)` 塞进 button,usvg 会按 Contain 撑满内区(实测渲染盒 46 → 视觉线宽 /// 从 1.5 变 2.9,视觉复审 P2-2)。Container 尊重子元素的 Length,所以这层是「锁尺寸」用的。 +/// +/// **图标尺寸 = [`icons::CAPTION_PX`](20,项目偏离;官方 24),容器 46×48 不动**(M86): +/// 用户原话是「保持其容器尺寸不变的前提下把图标改小些」,所以缩放只落在图标盒与 SVG 位图上, +/// `metrics.titlebar_button_width` / `titlebar_height` 一个都不许跟着改。 fn caption<'a>( ui: &'a Ui, kind: IconKind, @@ -163,15 +167,17 @@ fn caption<'a>( forced: Option, ) -> Element<'a, Message> { let metrics = ui.metrics(); - // 用 `center(Length::Fill)` 而不是只设 `width(24).height(24)`:后者会让容器缩到 24×24 并贴到 + // 用 `center(Length::Fill)` 而不是只设 `width(20).height(20)`:后者会让容器缩到 20×20 并贴到 // button 内容的左上角(button 不居中「收缩后」的内容,r1 实测图标跑到 cell 左上 (left+11.5, 11.5)); - // `center(Fill)` = 撑满按钮内容区 + 内容居中,字形仍是 24 盒、命中区仍是 46×48。 - let icon = container(icons::view::( + // `center(Fill)` = 撑满按钮内容区 + **两轴**居中,字形仍是 20 盒、命中区仍是 46×48(Q21: + // 只设 `align_y` 不设 `align_x` 会让图标贴左)。 + let icon = container(icons::view_at::( kind, ui.color(Role::OnSurfaceVariant), + icons::CAPTION_PX, )) - .width(icons::RENDER_PX) - .height(icons::RENDER_PX) + .width(icons::CAPTION_PX) + .height(icons::CAPTION_PX) .center(Length::Fill); button(icon) @@ -346,4 +352,29 @@ mod tests { assert_eq!(crate::shot::flavor_slug(&ui(SchemeFlavor::Light)), "light"); assert_eq!(crate::shot::flavor_slug(&ui(SchemeFlavor::Dark)), "dark"); } + + /// M86:标题栏 caption 图标缩到 20 后,**容器一个数都没动**(46×48),图标也还在框里。 + #[test] + fn caption_icon_shrinks_without_touching_the_container() { + let ui = ui(SchemeFlavor::Light); + let metrics = ui.metrics(); + let caption = icons::CAPTION_PX; + assert_eq!(metrics.titlebar_button_width, 46.0, "容器宽不许跟着图标改"); + assert_eq!(metrics.titlebar_height, 48.0, "容器高不许跟着图标改"); + assert!( + caption < metrics.titlebar_button_width, + "图标 {caption} 必须小于按钮宽 {}", + metrics.titlebar_button_width + ); + assert!( + caption < metrics.titlebar_height, + "图标 {caption} 必须小于按钮高 {}", + metrics.titlebar_height + ); + // 官方口径 24 仍在令牌里(`IconSize::Toolbar`),偏离只发生在 caption 这一个入口。 + assert!( + caption < unlucky_design::icons::IconSize::Toolbar.px(), + "caption 图标必须小于官方 24(缩小是用户点名的)" + ); + } } diff --git a/crates/design/src/color/state.rs b/crates/design/src/color/state.rs index c76c08b..9fbb3e5 100644 --- a/crates/design/src/color/state.rs +++ b/crates/design/src/color/state.rs @@ -38,14 +38,53 @@ impl StateLayer { } } -/// 禁用态的透明度(M3):容器 12%、内容 38%。 +/// 禁用态的透明度(M3):**按钮家族**容器 10%、内容 38%。 /// /// 别用「灰化」近似——深色模式下灰化会降低对比度,alpha 叠加两种模式都成立。 -pub const DISABLED_CONTAINER_ALPHA: f32 = 0.12; +/// +/// ## 取值依据(每个 token 的消费者都用 `javap -c ButtonDefaults` 数过) +/// +/// - **按钮容器 10%**:`FilledButtonTokens` / `ElevatedButtonTokens` / `OutlinedButtonTokens` 的 +/// `DisabledContainerOpacity = 0.1f`,且 `ButtonDefaults.` 里**真的调了** +/// `getDisabledContainerOpacity()`(各 1 次)——活令牌。 +/// `TextButtonTokens` 同值但容器透明(`ButtonDefaults` 只读它的 `DisabledLabel*`); +/// `BaselineButtonTokens` 同值,它是**基准表**、运行时零消费者,只作参照。 +/// - ⚠️ `TonalButtonTokens` 也写 0.1f,但它在**整个 AAR 里零消费者**(`ButtonDefaults` 只引用 +/// `FilledTonalButtonTokens`;`grep` 命中的 `…TonalButtonTokens` 是后者的子串假象)。 +/// **别拿它当依据**——这正是本 mission 反复警告的死令牌坑(同 `ButtonSmallTokens.IconSize = 20`)。 +/// - **面状控件(chip 容器/描边、slider 轨道、switch 轨道)12%** 是另一类令牌,口径在 +/// `unlucky_app_iced::components::interaction::disabled_colors`。 +/// +/// ## ⚠️ 已知偏离:filled-tonal 的禁用容器**官方是 0.12f,我们走 0.10** +/// +/// A 级事实(`javap -p -constants androidx.compose.material3.tokens.FilledTonalButtonTokens`): +/// `public static final float DisabledContainerOpacity = 0.12f;` +/// ——它是**编译期常量**,被内联进 `ButtonDefaults.getDefaultFilledTonalButtonColors` 的字节码 +/// (所以那里看到的是 `ldc_w 0.12f`,`getDisabledContainerOpacity()` 根本不会被调用; +/// 在 `` 里也看不到它——那是「没在 clinit 里出现」,**不是「字段不存在」**)。 +/// +/// 我们的实现在 tonal 与 filled 之间**没有区分**,两者共用本常量 ⇒ **tonal 实际画的是 10%**。 +/// 这是**已登记的偏离,不是对齐**:要修得让 `disabled_button_colors` 知道「哪个变体」, +/// 并同步 `gallery/probe` 的逐行断言;本轮(M86)只做令牌一致性收口,不扩到变体分派。 +/// +/// 曾经的缺陷不是「12 是错的」,而是**同一个常量同时服务这两类**:按钮那半边一直错、另半边一直对。 +pub const DISABLED_CONTAINER_ALPHA: f32 = 0.10; /// 禁用态内容(文字/图标)透明度。 pub const DISABLED_CONTENT_ALPHA: f32 = 0.38; -/// 禁用态容器色:`on_surface` 12% 叠在 `surface` 上。 +/// 禁用态的透明度(M3):**面状控件族**容器与描边 12%。 +/// +/// 这一族是「一整面涂上去的容器或用它画出来的描边/轨道」,官方 token 各写一份但值统一是 `0.12f`: +/// `AssistChipTokens.FlatDisabledOutlineOpacity`、`FilterChipTokens.FlatDisabledSelectedContainerOpacity` +/// / `FlatDisabledUnselectedOutlineOpacity`、`SliderTokens.DisabledInactiveTrackOpacity`、 +/// `SwitchTokens.DisabledTrackOpacity`(AAR 字节码 ``)。 +/// +/// **不适用于按钮容器**:按钮是 [`DISABLED_CONTAINER_ALPHA`](10%,`*ButtonTokens.DisabledContainerOpacity`)。 +/// 更不适用于 TextField 的 Filled 容器——那是 `FilledTextFieldTokens.FieldDisabledContainerOpacity = 0.04f`, +/// 我们仍在用 12%(既有 D10 偏离,未在本轮收口)。 +pub const DISABLED_SURFACE_ALPHA: f32 = 0.12; + +/// 禁用态容器色:`on_surface` 10%(按钮家族的 `DisabledContainerOpacity`)叠在 `surface` 上。 pub fn disabled_container(scheme: &super::Scheme) -> Color { scheme .get(super::Role::OnSurface) @@ -93,4 +132,33 @@ mod tests { assert!(ratio > 1.5, "禁用态对比度过低:{ratio}"); assert!(ratio < 4.5, "禁用态不该和正文一样醒目:{ratio}"); } + + /// 两族 α 的**令牌层钉值**(M86):期望值硬编码,不从被测常量取——否则改坏常量也照样绿。 + /// + /// 出处(AAR `material3-android-1.4.0` 的 ``): + /// `*ButtonTokens.DisabledContainerOpacity = 0.1f`(按钮族)与 + /// `AssistChipTokens.FlatDisabledOutlineOpacity` / `SliderTokens.DisabledInactiveTrackOpacity` / + /// `SwitchTokens.DisabledTrackOpacity = 0.12f`(面状控件族)。 + #[test] + fn disabled_alpha_classes_are_pinned_at_the_token_layer() { + const EXPECTED_BUTTON: f32 = 0.10; + const EXPECTED_SURFACE: f32 = 0.12; + const EXPECTED_CONTENT: f32 = 0.38; + let button = DISABLED_CONTAINER_ALPHA; + let surface = DISABLED_SURFACE_ALPHA; + let content = DISABLED_CONTENT_ALPHA; + assert_eq!( + button, EXPECTED_BUTTON, + "按钮族禁用容器 α 必须是官方 0.1f(*ButtonTokens.DisabledContainerOpacity)" + ); + assert_eq!( + surface, EXPECTED_SURFACE, + "面状控件族 α 必须是官方 0.12f(chip 描边 / slider 轨道 / switch 轨道)" + ); + assert_eq!(content, EXPECTED_CONTENT, "禁用内容 0.38f"); + assert!( + surface > button, + "两族必须可区分({surface} > {button});合成一个数就是 M86 要防的回归" + ); + } } diff --git a/crates/design/src/icons/mod.rs b/crates/design/src/icons/mod.rs index 126b32e..c575ba3 100644 --- a/crates/design/src/icons/mod.rs +++ b/crates/design/src/icons/mod.rs @@ -27,8 +27,8 @@ //! `render_px / VIEWBOX` 缩放。同样的 `1.5` 在 20px 渲染下只有 1.25px 视觉宽度。 //! //! 因此 iced 侧必须**按 SVG 语义重算**:要保持 1.5px 视觉线宽,viewBox 里的 stroke-width 得取 -//! `1.5 × 24 / render_px`(20px → 1.8、16px → 2.25、24px → 1.5)。[`Icon::stroke_width`] 就是这个 -//! 换算的纯函数——**不要在适配层手抄 1.5 这个数**,那是 Slint 口径。 +//! `1.5 × 24 / render_px`(24px → 1.5、20px → 1.8、18px → 2.0、16px → 2.25)。[`Icon::stroke_width`] +//! 就是这个换算的纯函数——**不要在适配层手抄 1.5 这个数**,那是 Slint 口径。 mod glyphs; mod kinds; @@ -66,15 +66,38 @@ pub enum IconPaint { } /// 图标的标准渲染尺寸(页面只从这几档里取,不许自定义零散值)。 +/// +/// ## 每一档服务于哪个规格(出处全部是 A 级:`material3-android-1.4.0.aar` 的字节码) +/// +/// 判据顺序固定为:**先看官方组件实际消费哪个值,再看 token 类里写了什么**。两者冲突时以 +/// 「有消费者的那个」为准——1.4.0 里 `ButtonSmallTokens.IconSize = 20` 就是**零消费者**的死令牌 +/// (`javap` 全 AAR 搜 `getIconSize` 调用,没有任何类读它;`ButtonDefaults` 只借了同一 +/// token 类的 `IconLabelSpace` / `ContainerHeight` / `ContainerShapeRound`)。 #[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] pub enum IconSize { /// 12px:行内小图标(徽标内)。 Inline, - /// 16px:按钮/标题栏按钮。 + /// 16px:**控件内微字形**——Switch 滑块内的勾/图标。 + /// + /// A 级:`SwitchTokens.SelectedIconSize = 16.0d` / `UnselectedIconSize = 16.0d`。 + /// 别拿它当按钮图标用:按钮是 18(见 [`IconSize::Button`]),两者在官方是两个 token。 + SwitchThumb, + /// 18px:**按钮内前置/后置图标**,也用于 Chip 图标。 + /// + /// A 级:`ButtonDefaults.` 里 `bipush 18` → `IconSize = 18.dp`(12b 1.6 引的 + /// `ButtonDefaults.IconSize=18` 就是这一行);`AssistChipTokens.IconSize = 18.0d`(12b 13.4)。 + /// 死令牌说明:`BaselineButtonTokens.IconSize` / `ButtonSmallTokens.IconSize` 都写 20, + /// 但 AAR 里没有任何类调用它们的 `getIconSize`(`FilledTonalButtonTokens` 反而是 18)。 Button, - /// 20px:导航项、列表行。 + /// 20px:**目录档,当前无消费**。 + /// + /// ⚠️ 别按名字拿它当导航/列表图标:官方导航轨与列表行的图标是 **24** + /// (`AppBarTokens.IconSize = 24.0d`、12b 5.5 / 4.4),实现侧也一直是 24([`IconSize::Toolbar`])。 + /// 标题栏 caption 按钮的 20 不是这一档,见 [`TITLEBAR_CAPTION_ICON_PX`](那是项目偏离)。 Nav, - /// 24px:卡片头、工具栏。 + /// 24px:卡片头、工具栏、IconButton、标题栏 caption(官方口径)。 + /// + /// A 级:`SmallIconButtonTokens.IconSize = 24.0d`(12b 2.2 / 16.5)、`AppBarTokens.IconSize = 24.0d`。 Toolbar, /// 32px:空态图标块。 Empty, @@ -85,7 +108,8 @@ impl IconSize { pub const fn px(self) -> f32 { match self { IconSize::Inline => 12.0, - IconSize::Button => 16.0, + IconSize::SwitchThumb => 16.0, + IconSize::Button => 18.0, IconSize::Nav => 20.0, IconSize::Toolbar => 24.0, IconSize::Empty => 32.0, @@ -93,8 +117,9 @@ impl IconSize { } /// 全部档位,由小到大。 - pub const ALL: [IconSize; 5] = [ + pub const ALL: [IconSize; 6] = [ IconSize::Inline, + IconSize::SwitchThumb, IconSize::Button, IconSize::Nav, IconSize::Toolbar, @@ -102,6 +127,17 @@ impl IconSize { ]; } +/// 标题栏 caption 按钮(最小化 / 最大化 / 关闭)的图标边长——**用户点名的项目偏离**。 +/// +/// 官方 `SmallIconButtonTokens.IconSize = 24.0d`(AAR 字节码,12b 16.5 同值),我们的 24 +/// **本来就是对的**,所以这条不是「对齐规范」,是业主主观调整:用户原话「右上角三个金刚键的 +/// 图标显示稍大了些,能不能在**保持其容器尺寸不变**的前提下把图标改小些」。 +/// +/// 约束:**容器不动**(46×48 仍来自 `LayoutMetrics::titlebar_button_width` / +/// `titlebar_height`),只缩图标;且这条**只适用于 caption 三键**,不许扩散到导航/卡片/表单的 +/// 24px 图标(那些是 12b 2.2 / 4.4 / 5.5 / 9.6 / 16.5 的判据)。 +pub const TITLEBAR_CAPTION_ICON_PX: f32 = 20.0; + /// 一个图标的几何数据。 #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub struct Icon { @@ -248,4 +284,59 @@ mod tests { assert!(!IconKind::Plus.icon().has_curves()); assert!(!IconKind::ChevronDown.icon().has_curves()); } + + /// 每一档的像素值都被**钉在官方出处**上:改值必须同时改这条测试,改的人会看到出处。 + /// + /// 这不是「断言一个常量等于它自己」——它断言的是「我们读到的官方值」, + /// 所以下一个人按 `research/12b` 或按某个 token 类把 18 改回 16/20 时,这条会红并打印出处。 + #[test] + fn icon_sizes_are_pinned_to_their_official_sources() { + // A 级:ButtonDefaults. 的 `bipush 18` → IconSize = 18.dp + // (`AssistChipTokens.IconSize = 18.0d` 同值)。 + assert_eq!( + IconSize::Button.px(), + 18.0, + "按钮/Chip 图标 18:AAR ButtonDefaults.IconSize(12b 1.6 同值)。\ + ButtonSmallTokens.IconSize=20 是零消费者的死令牌,不要按它改回去" + ); + // A 级:SwitchTokens.SelectedIconSize / UnselectedIconSize = 16.0d + assert_eq!( + IconSize::SwitchThumb.px(), + 16.0, + "Switch 滑块图标 16:AAR SwitchTokens.Selected/UnselectedIconSize" + ); + // A 级:SmallIconButtonTokens.IconSize / AppBarTokens.IconSize = 24.0d + assert_eq!( + IconSize::Toolbar.px(), + 24.0, + "IconButton/标题栏/卡片头 24:AAR SmallIconButtonTokens / AppBarTokens" + ); + // 目录档(当前无消费):不许被当成 nav 图标用(nav 是 24)。 + assert_eq!(IconSize::Nav.px(), 20.0, "目录档 20(无消费)"); + assert_eq!(IconSize::Inline.px(), 12.0); + assert_eq!(IconSize::Empty.px(), 32.0); + } + + /// 标题栏 caption 的 20 是**项目偏离**,不是规范值;这条把「偏离的是哪两个数」钉住。 + #[test] + fn titlebar_caption_icon_is_a_registered_deviation() { + assert_eq!( + TITLEBAR_CAPTION_ICON_PX, 20.0, + "用户点名的偏离:官方 SmallIconButtonTokens.IconSize = 24,caption 三键缩到 20" + ); + // 绑定成局部变量再过断言:直接比两个常量会被 clippy 判成「常量断言」(assertions_on_constants)。 + let caption = TITLEBAR_CAPTION_ICON_PX; + let official = IconSize::Toolbar.px(); + assert!( + caption < official, + "偏离方向是「更小」:{caption} 应 < 官方 {official};若哪天反了,那就不是用户要的那件事了" + ); + // 20px 渲染时视觉线宽仍是 1.5(SVG 语义),这条防「缩图标顺手把线宽也带细」。 + let stroke = stroke_width_for(caption, VISUAL_STROKE_PX); + assert!( + (stroke - 1.8).abs() < 1.0e-6, + "20px 渲染应取 1.8 个 viewBox 单位" + ); + assert!((visual_stroke_px(caption, stroke) - VISUAL_STROKE_PX).abs() < 1.0e-6); + } } diff --git a/crates/design/tests/token_contract.rs b/crates/design/tests/token_contract.rs index cd4612b..0f1b50f 100644 --- a/crates/design/tests/token_contract.rs +++ b/crates/design/tests/token_contract.rs @@ -125,16 +125,34 @@ fn icon_kind_is_frozen_and_append_only() { #[test] fn icon_stroke_follows_svg_semantics_not_slint() { - // Slint 口径(绝对 1.5px)在 20px 渲染下会得到 1.5px 视觉线宽;SVG 口径要写 1.8。 + // 派生式(**这条测试钉的就是它**):`stroke = 1.5 × 24 / render_px`,1.5 是视觉线宽、24 是 viewBox。 + // + // 两个锚点,一个不随本次收敛变化、一个随令牌取值: + // - 20px(`IconSize::Nav`,本轮没动)→ 1.5 × 24 / 20 = **1.8** + // - 18px(`IconSize::Button`,M86 由 16 收敛到官方 18)→ 1.5 × 24 / 18 = **2.0** + // + // ⚠️ **这条只保证「烘焙路径」**(`crates/app-iced/src/icons.rs::view_at`:按盒尺寸烘焙 SVG, + // 于是一个 viewBox 单位的 stroke 落到盒子后正好是 1.5 视觉像素)。 + // 它**不**保证「24 烘焙 + 布局缩到 18」那条路径——那条会得到 `1.5 × 18/24 = 1.125px`, + // 正是复审 r1 P2-2 实测到的 1.04~1.25px 缺陷。所以组件侧凡是盒 ≠ `RENDER_PX`(24) 的调用点 + // 都必须走 `view_at`(见 `icons.rs::tests::view_at_bakes_the_requested_size` 与 + // `crates/app-iced/src/components/gate.rs` 的 Q9 扫描)。 + // + // 之所以写死字面量而不写成 `stroke_width_for(IconSize::Nav.px(), 1.5)`:后者等于拿实现算实现, + // 派生式一旦被改坏(例如把 ×24 写成 ×20)它照样绿。写死才会红。 + // Slint 口径(绝对 1.5px,不乘 viewBox)在这两档都会得到 1.5 —— 那正是这条要防的错。 let nav_width = IconKind::Settings.icon().stroke_width(IconSize::Nav.px()); assert!( (nav_width - 1.8).abs() < 1.0e-6, - "20px 渲染应取 1.8 个 viewBox 单位" + "20px 渲染应取 1.8 个 viewBox 单位(锚点,不随 IconSize 收敛变化)" ); let button_width = IconKind::Settings .icon() .stroke_width(IconSize::Button.px()); - assert!((button_width - 2.25).abs() < 1.0e-6); + assert!( + (button_width - 2.0).abs() < 1.0e-6, + "18px 渲染应取 2.0 个 viewBox 单位(M86:Button 由 16→18,旧期望 2.25 是 16px 的派生值)" + ); // 视觉宽度的往返一致性(这条是「别照抄 Slint 的 1.5」的守门测试)。 for size in IconSize::ALL { let w = stroke_width_for(size.px(), 1.5);