fix(app-iced): IconButton Outlined 真的画环 + ELEVATED CARD 值串单行(M229 复审 P1)
P1-1:`icon()` 把 `StyleSpec.stroke` 写死 `0.0`,`IconVariant::Outlined` 的描边角色只到 `roles()` 为止、没画出来 —— Outlined 与 Plain 逐像素相同。改成「有描边角色就是 1px」, 并把「角色 → 填充/内容/描边」抽成 `icon_style_spec()`(`icon()` 直接吃它),单测因此能断言 **接线**而不只是断言 `icon_stroke()` 这个纯函数(第一版正是这样漏掉调用点的)。 回归门两条:纯函数/接线(`buttons/tests/icon.rs`,变异回 0.0 时红)+ 渲染层 (`gallery/probe/rows.rs`:图标按钮左缘 4px 带里 Outlined 有环墨、Plain 必须 0 px; 变异后重出图再跑会红)。环在软渲染里是 Outline 叠画布的抗锯齿混色(实测 110/124/110, 本体 123/138/123),所以判据是「这一带有墨/没墨」而不是颜色匹配。 P1-2:ELEVATED CARD 内距 24 + 值 16px 让「2026-10-29 19:22 UTC」折行,同排卡底不齐; 旧测试还断言 20/14(不变量 118+2×padding ≤ 160 变成 166 > 160,假绿)。 按塔裁决「收内距/字号、不破卡片宽度契约」:`PAD` 24 → 20(= card_padding)、值字级 → BodySmall 12。 量测:时戳 14px 墨水宽 138px(160 外框内宽 120 放不下)、12px 为 118px(放得下,余 2px)。 测试改成四条绑一起(字级 12 / 内距 20 / 160−2×PAD ≥ 118 / 2 列实际宽度也 ≥ 118), 并反证「14px 确实放不下」防登记被当噪音删掉;变异回 14px 时红。 P2(顺带): - 截图通道的主题档由 `--screenshot-theme` 钉死(此前读机器 launcher.json,设置页主题行会漂) - `gallery::probe` 只认 gallery sheet(必须给 `ULL_GALLERY_SECTION`,否则整页图的 chip 会被当按钮量)、 行表 8 → 9 行(四式图标按钮)、分隔线判据只在渲染了 divider 段时执行、逐行阈值 40 → 30 并加 「满档色像素必须少于 38% 像素」的反向判据、probe 拆出 `probe/rows.rs` 守 400 行 - FAB extended 的重复分支合成一处,右内距提取 `EXTENDED_PAD_END`;iced 0.14 无 `Length::Min`, 「最小宽随标签增长」退化为固定宽已登记 - 五处注释谎言改真(theme/tests、general/theme、elevated、launch_args、app/tests/theme) `buttons/tests.rs` 加用例后 402 行 ⇒ 图标按钮那组拆到 `buttons/tests/icon.rs`。
This commit is contained in:
1 parent
6ed26626db
commit
8958b7f67d
18 files changed
+398
-124
No files matched your search
@@ -27,6 +27,8 @@ use crate::app::components::interaction;
|
||||
|
||||
use super::scan::{matches, nearer, rgb, Shot};
|
||||
|
||||
mod rows;
|
||||
|
||||
const FAMILIES: Families = Families {
|
||||
text: "Segoe UI",
|
||||
display: "Segoe UI",
|
||||
@@ -83,8 +85,18 @@ fn delta_ratio(pixel: [u8; 3], surface: [u8; 3], line: [u8; 3]) -> f32 {
|
||||
}
|
||||
|
||||
/// `ULL_GALLERY_SHOT` → 截图路径(相对路径按**工作区根**解释:`cargo test` 的 cwd 是 crate 目录)。
|
||||
///
|
||||
/// **只认 `buttons` 段的 sheet**(M229 复审 P2):这份 probe 的每条判据都假设画面上是
|
||||
/// 「5 行文字按钮 + 4 行图标按钮」的矩阵。把整页图(下载页/设置页)指给它时,页面自己的实心块
|
||||
/// (高 21 的 chip、卡片)会被当成按钮量,得到的是**误导性的红**而不是真缺陷。所以这里要求
|
||||
/// `ULL_GALLERY_SECTION=buttons`,不满足就明确失败——「跳过」会把「指错了图」变成静默通过。
|
||||
fn shot_path() -> Option<std::path::PathBuf> {
|
||||
let raw = std::env::var_os("ULL_GALLERY_SHOT")?;
|
||||
let section = std::env::var("ULL_GALLERY_SECTION").unwrap_or_default();
|
||||
assert!(
|
||||
!section.trim().is_empty(),
|
||||
"这份 probe 只认 `--screen=gallery` 出的 sheet:请同时给 `ULL_GALLERY_SECTION` (`all` 或 `buttons`)。整页图(页面自己的 chip / 卡片,实心块高 21)会被当成按钮量, 得到的是误导性的红。"
|
||||
);
|
||||
let path = std::path::PathBuf::from(raw);
|
||||
if path.is_absolute() || path.exists() {
|
||||
return Some(path);
|
||||
@@ -252,37 +264,50 @@ fn gallery_geometry_matches_the_tokens() {
|
||||
assert!(measured_glyph, "没量到 IconButton 的字形");
|
||||
|
||||
// ---- 22.1:分隔线 1px(按「非底色的长横线」判,不按精确色:线被抗锯齿摊到两行)----
|
||||
//
|
||||
// **只在分隔线段被渲染时判**:`ULL_GALLERY_SECTION=buttons` 的 sheet 里没有分隔线,
|
||||
// 那一段是「没画」而不是「画错了」,断言只会误报(复审 P2 的同一类问题)。
|
||||
let section = std::env::var("ULL_GALLERY_SECTION")
|
||||
.unwrap_or_default()
|
||||
.trim()
|
||||
.to_ascii_lowercase();
|
||||
let has_divider = section.is_empty() || section == "all" || section == "divider";
|
||||
let surface = rgb(ui.color(Role::Surface));
|
||||
let mut runs = 0;
|
||||
for y in 0..shot.height {
|
||||
let mut longest = 0;
|
||||
let mut current = 0;
|
||||
for x in 0..shot.width {
|
||||
if !matches(shot.at(x, y), surface) {
|
||||
current += 1;
|
||||
longest = longest.max(current);
|
||||
} else {
|
||||
current = 0;
|
||||
let mut runs = 0usize;
|
||||
let mut coverage = f32::NAN;
|
||||
if has_divider {
|
||||
for y in 0..shot.height {
|
||||
let mut longest = 0;
|
||||
let mut current = 0;
|
||||
for x in 0..shot.width {
|
||||
if !matches(shot.at(x, y), surface) {
|
||||
current += 1;
|
||||
longest = longest.max(current);
|
||||
} else {
|
||||
current = 0;
|
||||
}
|
||||
}
|
||||
if longest > 300 {
|
||||
runs += 1;
|
||||
}
|
||||
}
|
||||
if longest > 300 {
|
||||
runs += 1;
|
||||
}
|
||||
// 两条线 × 各摊 1~2 行 ⇒ 2..=4 行
|
||||
assert!(
|
||||
(2..=4).contains(&runs),
|
||||
"分隔线应 2 条(每条 1px,抗锯齿最多摊 2 行)⇒ 2..=4 行,实测 {runs} 行"
|
||||
);
|
||||
// **墨量覆盖率**:一条 1dp 的线,其各行相对底色的不透明覆盖率之和应 ≈1.0。
|
||||
// 1.5dp 的线会到 ≈1.5,2dp 会到 ≈2.0 —— 这是「容忍 1.5 倍粗」的补丁。
|
||||
let full = rgb(ui.color(Role::OutlineVariant));
|
||||
// 屏上正好 2 条线(通栏 + 列表内缩),所以把总墨量除以 2 得到「单条线的覆盖率」
|
||||
coverage = line_coverage(&shot, surface, full) / 2.0;
|
||||
assert!(
|
||||
(0.8..=1.3).contains(&coverage),
|
||||
"单条分隔线墨量覆盖率应 ≈1.0(= 1dp),实测 {coverage:.2}(1.5dp 会到 ~1.5)"
|
||||
);
|
||||
} else {
|
||||
eprintln!("[probe] section={section:?} 未渲染 divider 段,跳过分隔线判据");
|
||||
}
|
||||
// 两条线 × 各摊 1~2 行 ⇒ 2..=4 行
|
||||
assert!(
|
||||
(2..=4).contains(&runs),
|
||||
"分隔线应 2 条(每条 1px,抗锯齿最多摊 2 行)⇒ 2..=4 行,实测 {runs} 行"
|
||||
);
|
||||
// **墨量覆盖率**:一条 1dp 的线,其各行相对底色的不透明覆盖率之和应 ≈1.0。
|
||||
// 1.5dp 的线会到 ≈1.5,2dp 会到 ≈2.0 —— 这是「容忍 1.5 倍粗」的补丁。
|
||||
let full = rgb(ui.color(Role::OutlineVariant));
|
||||
// 屏上正好 2 条线(通栏 + 列表内缩),所以把总墨量除以 2 得到「单条线的覆盖率」
|
||||
let coverage = line_coverage(&shot, surface, full) / 2.0;
|
||||
assert!(
|
||||
(0.8..=1.3).contains(&coverage),
|
||||
"单条分隔线墨量覆盖率应 ≈1.0(= 1dp),实测 {coverage:.2}(1.5dp 会到 ~1.5)"
|
||||
);
|
||||
|
||||
// ---- 12b 1.8 禁用态:容器 + **每一行**的内容色(A1 逐变体不同)----------------
|
||||
//
|
||||
@@ -300,34 +325,8 @@ fn gallery_geometry_matches_the_tokens() {
|
||||
"没找到禁用态实心按钮(12b 1.8:容器 = on-surface@10%)"
|
||||
);
|
||||
|
||||
// 矩阵行序(gallery/buttons.rs 的 Variant::ALL + IconVariant::ALL),行距 48 = 40 + 8
|
||||
let row_step = 48usize;
|
||||
let rows: [(usize, Role, bool, &str); 8] = [
|
||||
(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"),
|
||||
// 第 7 行(矩阵最后一行的 tonal IconButton)上一版漏了 —— reviewer 把它的禁用墨迹涂回
|
||||
// 满档色时 probe 全绿。A1 同样是 OnSurface(`FilledTonalIconButtonTokens`)。
|
||||
(7, Role::OnSurface, true, "icon·tonal"),
|
||||
];
|
||||
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;
|
||||
// **与实现同源**:按钮族的内容色一律走 `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,
|
||||
"{name} 行的禁用内容({role:?}@38%)在 disabled 列里只有 {count} px —— 说明这一行没走 A1 的禁用角色"
|
||||
);
|
||||
}
|
||||
// 禁用态与描边环是**按矩阵行**判的,逐行表与判据搬去了 `probe/rows.rs`(守 400 行硬线)。
|
||||
rows::assert_matrix_rows(&shot, &ui, label_button.top, label_button.left);
|
||||
|
||||
// filled 禁用容器里不许出现满档 OnPrimary(旧实现是白字压在容器上 = 1.23:1)
|
||||
let full_content = rgb(ui.color(Role::OnPrimary));
|
||||
@@ -351,7 +350,7 @@ fn gallery_geometry_matches_the_tokens() {
|
||||
Filled 药丸 {}×{}(参考 高 40 / 宽 ≥58)
|
||||
顶行左内缩 {}(r=20 应 ≈14;8 圆角 ≈4;直角 0)
|
||||
实心块 {} 个(含 40×40 IconButton)
|
||||
分隔线长行 {} 行(每条 1px)
|
||||
分隔线长行 {} 行(每条 1px;未渲染 divider 段时为 0)
|
||||
禁用容器 (on-surface@10%,按钮族) 命中 · 逐行禁用内容断言 {} 行通过
|
||||
分隔线墨量覆盖率 {:.2}(1dp 应 ≈1.0)
|
||||
字形墨迹 {}px(24 盒应 16~26)",
|
||||
@@ -361,7 +360,7 @@ fn gallery_geometry_matches_the_tokens() {
|
||||
top_inset,
|
||||
primary.len(),
|
||||
runs,
|
||||
rows.len(),
|
||||
rows::ROW_COUNT,
|
||||
coverage,
|
||||
glyph_width
|
||||
);
|
||||
|
||||
@@ -0,0 +1,91 @@
|
||||
//! 矩阵**逐行**判据:禁用态内容色(12b 1.8)× 9 行 + 图标按钮第四式(Outlined)的 1px 环。
|
||||
//!
|
||||
//! 从 `probe.rs` 纯搬移(M229 复审 P2:加第 9 行与环断言后 `probe.rs` 409 行,超 400 硬线)。
|
||||
//! 判据一行未改,只有入口从「行内代码」变成 [`assert_matrix_rows`]。
|
||||
|
||||
use unlucky_design::color::Role;
|
||||
|
||||
use super::super::scan::{rgb, Shot};
|
||||
use crate::app::components::interaction;
|
||||
use crate::theme::Ui;
|
||||
|
||||
/// 矩阵行距(40 高按钮 + 8 间距)。
|
||||
const ROW_STEP: usize = 48;
|
||||
/// 行内扫描高度(可见 40 + 上下各 2 的余量)。
|
||||
const ROW_HEIGHT: usize = 44;
|
||||
/// 矩阵行数(5 个文字变体 + 4 个图标变体):probe 的汇总日志用它。
|
||||
pub(super) const ROW_COUNT: usize = 9;
|
||||
|
||||
/// 逐行断言:①每一行的禁用内容色(A1 逐变体不同)②Outlined 图标行有环、Plain 行没有。
|
||||
pub(super) fn assert_matrix_rows(shot: &Shot, ui: &Ui, label_top: usize, label_left: usize) {
|
||||
// 行序 = `gallery/buttons.rs` 先 `Variant::ALL`(5 行)再 `IconVariant::ALL`(**4 行**,M229 起
|
||||
// outlined 是第四式):共 **9 行**。`on_container` 必须与 `assemble::content_color` 一致
|
||||
// ——图标按钮的 filled/tonal 有容器(`fill.is_some()`),outlined/plain 没有。
|
||||
let rows: [(usize, Role, bool, &str); 9] = [
|
||||
(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, true, "icon·filled"), // A1:IconButton 全是 OnSurface
|
||||
(6, Role::OnSurface, true, "icon·tonal"),
|
||||
(7, Role::OnSurface, false, "icon·outlined"),
|
||||
(8, Role::OnSurface, false, "icon·plain"),
|
||||
];
|
||||
let column_from = shot.width * 2 / 3; // 矩阵最右列 = disabled 列
|
||||
for (index, role, on_container, name) in rows {
|
||||
let y0 = label_top + index * ROW_STEP;
|
||||
let y1 = y0 + ROW_HEIGHT;
|
||||
// **与实现同源**:按钮族的内容色一律走 `disabled_button_content`(38% 叠在 10% 底上)。
|
||||
// 用面状控件族的 12% 底会让断言比实现宽一档,差的 1/255 会放跑「容器对内容错」。
|
||||
let ink = interaction::disabled_button_content(ui, role, on_container);
|
||||
let count = shot.count_in(rgb(ink), column_from, shot.width, y0, y1);
|
||||
// 阈值 **30**:最窄的一行是只有 4 个字符的 `text` 变体(实测 38 px 墨迹,M229 复审后
|
||||
// 按钮内距 24→20 让标签左移,余量只剩 2 px)。角色接错时会掉到个位数甚至 0。
|
||||
assert!(
|
||||
count > 30,
|
||||
"{name} 行的禁用内容({role:?}@38%)在 disabled 列里只有 {count} px —— 这一行没走 A1 的禁用角色"
|
||||
);
|
||||
// 反向判据(不靠阈值):**满档**内容色不许比 38% 的还多——那样说明这一行没降透明度。
|
||||
let full = shot.count_in(rgb(ui.color(role)), column_from, shot.width, y0, y1);
|
||||
assert!(
|
||||
full < count,
|
||||
"{name} 行里满档 {role:?} 有 {full} px、38% 只有 {count} px:这一行的禁用透明度没生效"
|
||||
);
|
||||
}
|
||||
|
||||
// ---- M229 复审 P1-1:Outlined 图标按钮必须有 1px 环,Plain 必须没有 ----
|
||||
//
|
||||
// 只有渲染层能证:`icon()` 曾把 `StyleSpec.stroke` 写死 0,Outlined 与 Plain 逐像素相同。
|
||||
// 纯函数那条在 `buttons::tests::only_the_outlined_icon_variant_draws_a_ring`(证明「传了 1.0」),
|
||||
// 这条证明「画出来了」。
|
||||
// **只能扫「环带」,且只能按「有没有墨」判**:1px 环在软渲染里是 Outline 叠画布的抗锯齿
|
||||
// 混色(实测左缘 `(110,124,110)`,而 Outline 本体是 `(123,138,123)`)——按颜色匹配会漏,
|
||||
// 而且 `Outline` 与「字形压在画布上的边缘」在容差内几乎同色(扫整格会把 Plain 行数成 16 px 假环)。
|
||||
//
|
||||
// 环带的判据是**几何 + 墨量**:40×40 圆按钮的左缘在 x = left…left+4(半径 20 的弧垂直中线处
|
||||
// 贴左),而 24×24 字形从 left+8 才开始 —— Plain 行这一段实测是纯画布色 0 px。
|
||||
let surface = rgb(ui.color(Role::Surface));
|
||||
let is_ink = |pixel: [u8; 3]| pixel.iter().zip(surface).any(|(a, b)| a.abs_diff(b) > 8);
|
||||
let band_ink = |y0: usize| {
|
||||
let mut n = 0;
|
||||
for y in (y0 + 14)..(y0 + 26) {
|
||||
for x in label_left..(label_left + 4) {
|
||||
if is_ink(shot.at(x, y)) {
|
||||
n += 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
n
|
||||
};
|
||||
let outlined = band_ink(label_top + 7 * ROW_STEP);
|
||||
let plain = band_ink(label_top + 8 * ROW_STEP);
|
||||
assert!(
|
||||
outlined >= 4,
|
||||
"Outlined 图标按钮这一行左缘没有环墨({outlined} px):`icon()` 的 stroke 又被写死了?"
|
||||
);
|
||||
assert_eq!(
|
||||
plain, 0,
|
||||
"Plain 图标按钮这一行左缘出现了 {plain} px 环墨(Plain 不该有描边)"
|
||||
);
|
||||
}
|
||||
Reference in new issue
Block a user