From eed1c9d3f722866f8c343bbabfedfc0ae32a868b Mon Sep 17 00:00:00 2001 From: AskaEth Date: Mon, 2 Nov 2026 03:34:05 +0800 Subject: [PATCH] =?UTF-8?q?fix(app-iced):=20M75=20r5=20=E2=80=94=E2=80=94?= =?UTF-8?q?=20=E7=A6=81=E7=94=A8=E5=86=85=E5=AE=B9=E8=A7=92=E8=89=B2?= =?UTF-8?q?=E6=8C=89=E5=8F=98=E4=BD=93=EF=BC=88A1=EF=BC=89+=20=E5=88=A0=20?= =?UTF-8?q?probe=20=E9=87=8D=E5=A4=8D=E5=9D=97=20+=20=E9=80=90=E8=A1=8C?= =?UTF-8?q?=E6=8A=A4=E6=A0=8F?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 1) A1 逐变体(AAR javap):Filled/Elevated/Outlined/Text = OnSurfaceVariant@0.38; FilledTonal = OnSurface@0.38(FilledTonalButtonTokens.DisabledLabelTextColor); Standard/Filled IconButton = OnSurface@0.38(*IconButtonTokens.DisabledColor)。 新增 Variant::disabled_content() / IconVariant::disabled_content(), interaction::disabled_content_over_role(ui, role, on_container) 成唯一入口。 2) probe.rs 删掉被两个 reviewer 点名的重复块(disabled_colors 调用两次、 disabled_container 绑定两次),并补 grep 证据。 3) 护栏改成**逐行**:矩阵 7 个有内容的变体行各自断言 disabled 列的 38% 墨迹 > 40 px (浅/深同一套阈值,单行回退就会红),取代原来按浅色标定的全局 500 阈值。 6) 删掉 interaction/mod.rs 里挂在 mod tests 头上的悬挂文档。 --- .../src/components/buttons/assemble.rs | 12 +++- crates/app-iced/src/components/buttons/mod.rs | 24 ++++++- .../app-iced/src/components/gallery/probe.rs | 65 ++++++++++--------- .../app-iced/src/components/gallery/scan.rs | 15 +++-- .../src/components/interaction/mod.rs | 34 ++++++---- 5 files changed, 99 insertions(+), 51 deletions(-) diff --git a/crates/app-iced/src/components/buttons/assemble.rs b/crates/app-iced/src/components/buttons/assemble.rs index 4c2f4e9..ac1bafe 100644 --- a/crates/app-iced/src/components/buttons/assemble.rs +++ b/crates/app-iced/src/components/buttons/assemble.rs @@ -68,7 +68,7 @@ pub(super) fn assemble<'a, M: Clone + 'a>( // 早先这里恒取 `ui.color(content)`,再被 `text(...).color(..)` 钉死,`Style.text_color` // 里的 38% 永远下发不出去 —— 表现为「text / standard icon 的 enabled 格与 disabled 格 // 逐像素相同」(视觉 reviewer 实测 changed_px = 0;filled 禁用标签 1.23:1)。 - let ink = content_color(ui, forced, content, fill); + let ink = content_color(ui, forced, content, fill, variant.disabled_content()); let label = text(spec.label) .size(ui.style(TypeRole::LabelLarge).size_px) @@ -132,19 +132,25 @@ pub(super) fn assemble<'a, M: Clone + 'a>( /// /// 单独成函数是为了让「内容色跟态走」这件事只有一处实现——`assemble`(文字按钮)与 `icon` /// (图标按钮)都调它,两条路径不会再各自钉死满档色。 +/// 内容色:**禁用时的角色由调用方按变体给**([`super::Variant::disabled_content`] / +/// [`super::IconVariant::disabled_content`])——A1 里 FilledTonal 与 IconButton 是 `OnSurface`, +/// 其余是 `OnSurfaceVariant`,不能在这里统一写死。 pub(super) fn content_color( ui: &Ui, forced: Option, content: Role, fill: Option, + disabled_content: Role, ) -> iced::Color { match forced { // 有容器:38% 叠在**12% 容器**上(Filled / Tonal / Elevated) Some(Interaction::Disabled) if fill.is_some() => { - interaction::disabled_colors(ui, Role::Surface).1 + interaction::disabled_content_over_role(ui, disabled_content, true) } // 无容器:38% 直接叠在 **surface** 上(Text / Outlined / standard IconButton) - Some(Interaction::Disabled) => interaction::disabled_content_over(ui, Role::Surface), + Some(Interaction::Disabled) => { + interaction::disabled_content_over_role(ui, disabled_content, false) + } _ => ui.color(content), } } diff --git a/crates/app-iced/src/components/buttons/mod.rs b/crates/app-iced/src/components/buttons/mod.rs index 771311d..b852647 100644 --- a/crates/app-iced/src/components/buttons/mod.rs +++ b/crates/app-iced/src/components/buttons/mod.rs @@ -117,6 +117,22 @@ impl Variant { self.component().spec().shape } + /// **禁用态的内容角色**(A1,逐变体不同——别在这里统一猜)。 + /// + /// | 变体 | 禁用内容 | + /// |---|---| + /// | Filled / Elevated / Outlined / Text | `OnSurfaceVariant` @0.38 | + /// | **FilledTonal** | **`OnSurface` @0.38** ← A1 的显式例外(`FilledTonalButtonTokens`) | + /// + /// 出处:AAR `material3-android:1.4.0` 的 `javap -c`,`FilledButtonTokens. + /// DisabledLabelTextColor = OnSurfaceVariant`、`FilledTonalButtonTokens` 同一字段 = `OnSurface`。 + pub(crate) const fn disabled_content(self) -> Role { + match self { + Variant::Tonal => Role::OnSurface, + _ => Role::OnSurfaceVariant, + } + } + /// 高程:只有 Elevated 是 L1(`ElevatedButtonTokens.ContainerElevation`),其余 L0。 /// /// **不画阴影**:rubric §7「L0/L1 不应有可见阴影(用 tone 差)」,所以 L1 只体现为 @@ -172,6 +188,12 @@ impl IconVariant { } } + /// **禁用态图标色**:三式都是 `OnSurface` @0.38 —— A1 + /// (`StandardIconButtonTokens.DisabledColor` / `FilledIconButtonTokens.DisabledColor`)。 + pub(crate) const fn disabled_content(self) -> Role { + Role::OnSurface + } + /// 色彩角色三元组(同 [`Variant::roles`] 的口径)。 pub(crate) const fn roles(self) -> (Option, Role, Option) { match self { @@ -295,7 +317,7 @@ pub fn icon<'a, M: Clone + 'a>( // 这里把盒子做成整个 40×40(而不是 24×24):`button::layout` 把内容放左上角、不居中, // 所以「居中」这件事必须由容器自己写死,别指望 button。 // 内容色跟态走(禁用 = on-surface@38%),见 `assemble::content_color` 的说明 - let glyph = assemble::content_color(ui, forced, content, fill); + let glyph = assemble::content_color(ui, forced, content, fill, variant.disabled_content()); let icon_box = layout::centered_fixed(geometry.side, geometry.side, icons::view::(kind, glyph)); diff --git a/crates/app-iced/src/components/gallery/probe.rs b/crates/app-iced/src/components/gallery/probe.rs index c5c2e6f..e53b38a 100644 --- a/crates/app-iced/src/components/gallery/probe.rs +++ b/crates/app-iced/src/components/gallery/probe.rs @@ -23,6 +23,8 @@ use unlucky_design::typography::Platform; use crate::theme::{Families, Ui}; +use crate::app::components::interaction; + use super::scan::{matches, nearer, rgb, Shot}; const FAMILIES: Families = Families { @@ -312,9 +314,12 @@ fn gallery_geometry_matches_the_tokens() { "单条分隔线墨量覆盖率应 ≈1.0(= 1dp),实测 {coverage:.2}(1.5dp 会到 ~1.5)" ); - // ---- 12b 1.8:禁用格必须与静息格不同 ------------------------------------ - let disabled_container = - crate::app::components::interaction::disabled_colors(&ui, Role::Surface).0; + // ---- 12b 1.8 禁用态:容器 + **每一行**的内容色(A1 逐变体不同)---------------- + // + // 上一版把两个 reviewer 都指出的重复块留在这里(`disabled_colors` 被调两次、 + // `disabled_container` 被绑定两次),且只有一条「全图数墨迹」的护栏 —— 深色下阈值失效、 + // 单行回退也抓不住。现在按矩阵的行逐条断言。 + let (disabled_container, _) = interaction::disabled_colors(&ui, Role::Surface); assert!( shot.bbox_of(rgb(disabled_container)) .iter() @@ -322,37 +327,36 @@ fn gallery_geometry_matches_the_tokens() { "没找到禁用态实心按钮(12b 1.8:容器 = on-surface@12%)" ); - // ---- 12b 1.8(视觉 reviewer 的 P1-A):禁用态的内容色必须真的下发 ---- - let (disabled_container, disabled_ink) = - crate::app::components::interaction::disabled_colors(&ui, Role::Surface); - let ink_px = count_pixels(&shot, rgb(disabled_ink)); - assert!( - ink_px > 200, - "**有容器**四式的禁用内容(OnSurfaceVariant@38% 叠 12% 容器)只出现 {ink_px} 像素: 说明 Filled/Tonal/Elevated 的字形没走 38%" - ); + // 矩阵行序(gallery/buttons.rs 的 Variant::ALL + IconVariant::ALL),行距 48 = 40 + 8 + let row_step = 48usize; + let rows: [(usize, Role, bool, &str); 7] = [ + (0, Role::OnSurfaceVariant, true, "filled"), + (1, Role::OnSurface, true, "tonal"), // A1:FilledTonal 是例外 + (2, Role::OnSurfaceVariant, true, "elevated"), + (3, Role::OnSurfaceVariant, false, "outlined"), + (4, Role::OnSurfaceVariant, false, "text"), + (5, Role::OnSurface, false, "icon·standard"), // A1:IconButton 全是 OnSurface + (6, Role::OnSurface, false, "icon·filled"), + ]; + let column_from = shot.width * 2 / 3; // 矩阵最右列 = disabled 列 + 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); + let count = shot.count_in(rgb(ink), column_from, shot.width, y0, y1); + assert!( + count > 40, + "{name} 行的禁用内容({role:?}@38%)在 disabled 列里只有 {count} px —— 说明这一行没走 A1 的禁用角色" + ); + } - // **无容器**三式(Text / Outlined / standard IconButton)的禁用内容叠在 surface 上, - // 是另一个色值;单查上面那个只会覆盖到有容器的四式(reviewer code-r2 p2-1:护栏漏一半)。 - let bare_ink = crate::app::components::interaction::disabled_content_over(&ui, Role::Surface); - // **只数 disabled 列**(矩阵最右那一列,x ≥ 全宽 2/3):整图计数会被别处的抗锯齿像素 - // 抬到 1000+,把该列涂回满档色也降不到阈值以下 —— 那正是「护栏拦不住」的成因。 - let bare_px = shot.count_pixels_in(rgb(bare_ink), shot.width * 2 / 3, shot.width); - // 阈值 500 的来历(实测,矩阵 8 行 × 5 列): - // 浅色正常 800 px、深色正常 1966 px;把 disabled 列的 38% 墨迹涂回满档色后只剩 348 px - // (剩下的都是别处抗锯齿像素恰好落进容差)——所以 500 是「真墨迹在 / 不在」的分界。 - assert!( - bare_px > 500, - "**无容器**三式在 disabled 列里的 38% 墨迹只出现 {bare_px} 像素: 说明 Text/Outlined/standard 的字形没走 38%" - ); - - // filled 禁用容器里不许出现满档 OnPrimary(旧实现是 #FFFFFF 压在 12% 容器上 = 1.23:1) + // filled 禁用容器里不许出现满档 OnPrimary(旧实现是白字压在 12% 容器上 = 1.23:1) let full_content = rgb(ui.color(Role::OnPrimary)); for bbox in shot.bbox_of(rgb(disabled_container)) { if bbox.width() < 60 { continue; } - // 只扫**内带**:圆角外的页面底色 (246,250,255) 与 OnPrimary(255,255,255) 在容差内, - // 贴边扫描会把圆角透出的底色当成「满档内容」(首版就在 (866,162) 误报)。 + // 只扫**内带**:圆角外的页面底色与 OnPrimary 在容差内,贴边扫描会误报。 for y in (bbox.top + 12)..=(bbox.bottom - 12) { for x in (bbox.left + 12)..=(bbox.right - 12) { assert!( @@ -369,7 +373,7 @@ fn gallery_geometry_matches_the_tokens() { 顶行左内缩 {}(r=20 应 ≈14;8 圆角 ≈4;直角 0) 实心块 {} 个(含 40×40 IconButton) 分隔线长行 {} 行(每条 1px) - 禁用容器 (on-surface@12%) 命中 · 禁用内容 38% 墨迹 {} px(有容器)/ {} px(无容器) + 禁用容器 (on-surface@12%) 命中 · 逐行禁用内容断言 {} 行通过 分隔线墨量覆盖率 {:.2}(1dp 应 ≈1.0) 字形墨迹 {}px(24 盒应 16~26)", flavor, @@ -378,8 +382,7 @@ fn gallery_geometry_matches_the_tokens() { top_inset, primary.len(), runs, - ink_px, - bare_px, + rows.len(), coverage, glyph_width ); diff --git a/crates/app-iced/src/components/gallery/scan.rs b/crates/app-iced/src/components/gallery/scan.rs index 10c1094..b33f933 100644 --- a/crates/app-iced/src/components/gallery/scan.rs +++ b/crates/app-iced/src/components/gallery/scan.rs @@ -93,11 +93,18 @@ impl Shot { boxes } - /// 只在 `x` 区间 `[from, to)` 内数某颜色的像素(矩阵按列取证时用)。 - pub(super) fn count_pixels_in(&self, color: [u8; 3], from: usize, to: usize) -> usize { - (0..self.height) + /// 在矩形 `[x0,x1) × [y0,y1)` 内数某颜色的像素(按行/列取证用)。 + pub(super) fn count_in( + &self, + color: [u8; 3], + x0: usize, + x1: usize, + y0: usize, + y1: usize, + ) -> usize { + (y0..y1.min(self.height)) .map(|y| { - (from..to.min(self.width)) + (x0..x1.min(self.width)) .filter(|x| matches(self.at(*x, y), color)) .count() }) diff --git a/crates/app-iced/src/components/interaction/mod.rs b/crates/app-iced/src/components/interaction/mod.rs index 0a0e73b..897885f 100644 --- a/crates/app-iced/src/components/interaction/mod.rs +++ b/crates/app-iced/src/components/interaction/mod.rs @@ -125,20 +125,31 @@ pub fn overlay_color(ui: &Ui, state: Interaction, content: Role, container: Role .map(|layer| ui.state_over(layer, content, container)) } -/// 禁用态内容色:`OnSurfaceVariant` @ 38% 叠在 **`base` 角色**上(不经过 12% 容器)。 +/// 禁用态内容色(**按角色、按有没有容器**求值)——A1 口径的唯一入口。 /// -/// 给**不画容器**的三式(Text / Outlined / standard IconButton)用:错用 [`disabled_colors`] -/// 的内容值(叠在 12% 容器上)会比令牌口径深 21/255。 -pub fn disabled_content_over(ui: &Ui, base: Role) -> Color { +/// - `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()); - // A1(AAR `FilledButtonTokens` javap):DisabledLabelTextColor/DisabledIconColor 都是 - // `OnSurfaceVariant`、Opacity 0.38f。设计层的 `disabled_content()` 用 OnSurface(与 A1 不符), - // 令牌线修好前这里按 A1 用角色 + 令牌 α 直接求值(不引入字面量)。 + let base = if on_container { + scheme + .get(Role::OnSurface) + .with_alpha(DISABLED_CONTAINER_ALPHA) + .over(scheme.get(Role::Surface)) + } else { + scheme.get(Role::Surface) + }; + to_iced( scheme - .get(Role::OnSurfaceVariant) + .get(role) .with_alpha(DISABLED_CONTENT_ALPHA) - .over(scheme.get(base)), + .over(base), ) } @@ -159,7 +170,8 @@ pub fn disabled_colors(ui: &Ui, surface: Role) -> (Color, Color) { .get(Role::OnSurface) .with_alpha(DISABLED_CONTAINER_ALPHA) .over(base); - // 内容角色按 A1 用 `OnSurfaceVariant`(见 `disabled_content_over` 的说明) + // 这里是**默认口径**(OnSurfaceVariant);按变体取值请走 [`disabled_content_over_role`] + // ——FilledTonal 与三式 IconButton 的禁用内容是 `OnSurface`(A1)。 let content = scheme .get(Role::OnSurfaceVariant) .with_alpha(DISABLED_CONTENT_ALPHA) @@ -168,8 +180,6 @@ pub fn disabled_colors(ui: &Ui, surface: Role) -> (Color, Color) { (to_iced(container), to_iced(content)) } -/// 自定义 `Widget` 的状态机:把 iced 事件折成 [`Interaction`]。 -/// #[cfg(test)] mod tests { use super::*;