fix(app-iced): M75 r5 —— 禁用内容角色按变体(A1)+ 删 probe 重复块 + 逐行护栏

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 头上的悬挂文档。
This commit is contained in:
AskaEth committed 2026-11-02 04:51:03 +08:00
1 parent b31a758cda
commit eed1c9d3f7
5 files changed
+99 -51

No files matched your search

@@ -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<Interaction>,
content: Role,
fill: Option<Role>,
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),
}
}
+23 -1
View File
@@ -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>, Role, Option<Role>) {
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::<M>(kind, glyph));
+34 -31
View File
@@ -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
);
+11 -4
View File
@@ -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()
})
@@ -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::*;