From 7c274afd7c629214a20e46e8995df53676673725 Mon Sep 17 00:00:00 2001 From: AskaEth Date: Mon, 2 Nov 2026 12:24:20 +0800 Subject: [PATCH] =?UTF-8?q?fix(design,app-iced):=20=E5=AE=88=E9=97=A8?= =?UTF-8?q?=E6=94=B9=E6=88=90=E8=B0=83=E7=94=A8=E5=BD=A2=E7=8A=B6+?= =?UTF-8?q?=E9=80=90=E8=B0=83=E7=94=A8=E7=82=B9=E8=AE=A1=E6=95=B0=EF=BC=9B?= =?UTF-8?q?filled-tonal=20=E4=BA=8B=E5=AE=9E=E7=BA=A0=E6=AD=A3=EF=BC=88M86?= =?UTF-8?q?=20r2=20P1=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit P1-1(守门漏网):sub_24_boxes_go_through_view_at 原判据只认带 turbofish 的 icons::view::, 且 plain && !sized 让「同文件有 view_at」整文件放行,checked 还数文件不数调用点。现在: - 先剥注释(注释里写 icons::view 不再误报); - 按调用形状匹配:icons::view( / view::< / crate::icons::· / super::icons::· / use …view as v 与 {view} 之后的 v( / view(; - 逐调用点计数,每文件必须恰好等于白名单期望(未列出 = 0)——所以「同文件改对 3 处漏 1 处」也红。 P1-2(事实错误):FilledTonalButtonTokens.DisabledContainerOpacity = 0.12f 是存在的, javap -p -constants 可见;它是编译期常量、被内联进 getDefaultFilledTonalButtonColors,所以在 里看不到——我上一轮把「没在 clinit 出现」误写成「字段不存在」。同时把「TonalButtonTokens」 从 0.1f 家族的依据里剔除(它在整个 AAR 里零消费者,正是本 mission 警告的死令牌坑),改为只引用 ButtonDefaults 真的调了 getDisabledContainerOpacity 的 Filled/Elevated/Outlined,并注明 TextButton(容器透明)与 Baseline(基准表、运行时无消费者)。 --- .../src/components/interaction/disabled.rs | 10 +- crates/app-iced/src/icons.rs | 291 +++++++++++------- crates/design/src/color/state.rs | 33 +- 3 files changed, 197 insertions(+), 137 deletions(-) diff --git a/crates/app-iced/src/components/interaction/disabled.rs b/crates/app-iced/src/components/interaction/disabled.rs index 3aa52c6..4e033c5 100644 --- a/crates/app-iced/src/components/interaction/disabled.rs +++ b/crates/app-iced/src/components/interaction/disabled.rs @@ -18,10 +18,12 @@ //! `FilledTextFieldTokens.FieldDisabledContainerOpacity = 0.04f`,我们仍在用面状族的值 //! (既有 D10 偏离,未在本轮收口)。`inputs::field` 走 [`disabled_colors`] 的口径,行为未变。 //! -//! ⚠️ **filled-tonal 是另一条已知偏离**:官方 `ButtonDefaults.getDefaultFilledTonalButtonColors` -//! 里硬编码 `ldc_w 0.12f`(`FilledTonalButtonTokens` 没有 `DisabledContainerOpacity` 字段), -//! 而本模块的 `disabled_button_*` **不区分变体**,tonal 与 filled 共用 0.10 ⇒ tonal 实际画 10%。 -//! 这不是对齐、是登记过的偏离;要修得让入口知道变体,本轮(M86)不做。 +//! ⚠️ **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; diff --git a/crates/app-iced/src/icons.rs b/crates/app-iced/src/icons.rs index 514dd6e..eff402c 100644 --- a/crates/app-iced/src/icons.rs +++ b/crates/app-iced/src/icons.rs @@ -167,83 +167,47 @@ mod tests { ); } - /// caption 三键的 20:SVG 尺寸与线宽窗口**同源**,视觉线宽仍是 1.5px(不是 1.25)。 - #[test] - fn caption_icons_keep_the_visual_stroke_at_their_own_size() { - assert_eq!(CAPTION_PX, 20.0, "项目偏离值(官方 24)"); - let svg = source(IconKind::Close, CAPTION_PX); - assert!( - svg.contains("width=\"20\"") && svg.contains("height=\"20\""), - "caption 图标必须按 20 烘焙:{svg}" - ); - // 20px 渲染要 1.8 个 viewBox 单位(= 1.5px 视觉);拿 24px 的 1.5 塞进 20 盒只有 1.25px。 - assert!( - svg.contains("stroke-width=\"1.8\""), - "20px 渲染的 stroke-width 必须是 1.8:{svg}" - ); - let visual = unlucky_design::icons::visual_stroke_px(CAPTION_PX, 1.8); - assert!( - (visual - 1.5).abs() < 1.0e-6, - "视觉线宽应仍是 1.5,实际 {visual}" - ); - } - - /// `view_at` 必须**按请求尺寸烘焙**(复审 r1 P2-2 的守门断言)。 + /// `view_at` 必须**按请求尺寸烘焙**(复审 r1 P1-2/P2-2 的守门断言)。 /// /// 两条路径的差别就是这条测试的全部意义: - /// - **烘焙路径**:`source(kind, 18)` → SVG `width="18"` + `stroke-width="2.0"` ⇒ 视觉 **1.5px**; + /// - **烘焙路径**:`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() { - let button_px = IconSize::Button.px(); - assert_eq!(button_px, 18.0, "这条测试以按钮盒 18 为例"); - let baked = source(IconKind::Check, button_px); - assert!( - baked.contains("width=\"18\"") && baked.contains("height=\"18\""), - "按 18 烘焙时 SVG 的 width/height 必须是 18:{baked}" - ); - // `stroke-width` 的数值由 `format!(\"{}\", f32)` 渲染:2.0 会打印成 `2`, - // 所以这里比的是**数值**(2.0)而不是字符串 "2.0"。 - let baked_stroke = unlucky_design::icons::stroke_width_for(18.0, 1.5); - assert!( - (baked_stroke - 2.0).abs() < 1.0e-6, - "18px 的 viewBox 线宽必须是 2.0,实际 {baked_stroke}" - ); - assert!( - baked.contains(&format!("stroke-width=\"{baked_stroke}\"")), - "SVG 里必须真的写着 {baked_stroke}:{baked}" - ); - // 视觉线宽 = viewBox 单位 × 盒/viewBox = 2.0 × 18/24 = 1.5(达标) - let baked_visual = unlucky_design::icons::visual_stroke_px(button_px, baked_stroke); - assert!( - (baked_visual - 1.5).abs() < 1.0e-6, - "烘焙路径视觉线宽应 1.5,实际 {baked_visual}" - ); + // (盒, 该盒应有的 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(&format!( - "stroke-width=\"{}\"", - unlucky_design::icons::stroke_width_for(RENDER_PX, 1.5) - )) && scaled.contains("width=\"24\"") - ); - let scaled_visual = unlucky_design::icons::visual_stroke_px(button_px, 1.5); + 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)" ); - - // switch 滑块的 16 同理:按 16 烘焙要 1.5×24/16 = 2.25。 - let thumb_px = IconSize::SwitchThumb.px(); - let thumb_stroke = unlucky_design::icons::stroke_width_for(thumb_px, 1.5); - let thumb = source(IconKind::Check, thumb_px); - assert!( - thumb.contains("width=\"16\"") - && thumb.contains(&format!("stroke-width=\"{thumb_stroke}\"")), - "16px 盒要 {thumb_stroke} 个 viewBox 单位:{thumb}" - ); - assert!((thumb_stroke - 2.25).abs() < 1.0e-6); } #[test] @@ -264,70 +228,48 @@ mod tests { assert_ne!(first.id(), handle(IconKind::Close, CAPTION_PX).id()); } - /// **盒 ≠ `RENDER_PX`(24) 的调用点必须走 `view_at`**(复审 r1 P2-2 的守门断言)。 + /// **盒 ≠ `RENDER_PX`(24) 的调用点必须走 `view_at`**(复审 r1 P1-1)。 /// - /// 这是源码级扫描(不需要开窗):`icons::view`(24 烘焙)只允许出现在「图标盒 = 24」的文件里; - /// 其余文件的图标盒是 18 / 16 / 20,用 `view` 会被布局缩成 1.04~1.25px 视觉线宽。 - /// 谁把 `assemble.rs` / `chip.rs` / `switch.rs` 改回 `icons::view`,这条立刻红。 + /// 判据 = 调用形状匹配 + **逐调用点计数**(不做「文件里出现过 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() { - // 图标盒等于 `RENDER_PX`(24) 的文件——它们用 `view` 是对的。 - // (`buttons/mod.rs` 的 IconButton 是「24 图标 + 40 外壳」,图标本身仍是 24。) - const VIEW_OK: &[&str] = &[ - "shell/nav.rs", - "components/inputs/field.rs", - "components/inputs/menu.rs", - "components/inputs/search.rs", - "components/buttons/mod.rs", + 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 外壳 ]; - // 盒 ≠ 24、因此必须用 `view_at` 的文件。 - const VIEW_AT_REQUIRED: &[&str] = &[ - "components/buttons/assemble.rs", - "components/selection/chip.rs", - "components/selection/switch.rs", - "shell/mod.rs", - ]; - let root = std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("src"); - let mut checked = 0usize; - for entry in walk_rs(&root) { - let rel = entry - .strip_prefix(&root) - .expect("strip_prefix") - .to_string_lossy() - .replace('\\', "/"); - // 本模块自身就是实现(`view` / `view_at` 的定义与文档),不参与扫描。 + 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; + continue; // 实现自身 } - let text = std::fs::read_to_string(&entry).expect("读组件源码"); - let plain = text - .lines() - .any(|line| line.contains("icons::view::") || line.contains("icons::view (")); - let sized = text.contains("icons::view_at::"); - - if plain && !sized { - assert!( - VIEW_OK.contains(&rel.as_str()), - "{rel} 用了 icons::view(按 24 烘焙)——这个文件的图标盒不是 24,\ - 必须改 icons::view_at(kind, color, 盒尺寸),否则视觉线宽只有 1.1px 左右" - ); - } - if sized { - assert!( - VIEW_AT_REQUIRED.contains(&rel.as_str()), - "{rel} 用了 icons::view_at,但它不在本测试的必需清单里:\ - 若是新加的调用点,把它加进 VIEW_AT_REQUIRED(顺便确认盒尺寸确实 ≠ 24)" - ); - checked += 1; + 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_eq!( - checked, - VIEW_AT_REQUIRED.len(), - "view_at 调用点数量与清单不符(清单 {VIEW_AT_REQUIRED:?})" - ); + 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("读目录") { @@ -340,5 +282,116 @@ mod tests { } 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/design/src/color/state.rs b/crates/design/src/color/state.rs index 3791046..9fbb3e5 100644 --- a/crates/design/src/color/state.rs +++ b/crates/design/src/color/state.rs @@ -42,25 +42,30 @@ impl StateLayer { /// /// 别用「灰化」近似——深色模式下灰化会降低对比度,alpha 叠加两种模式都成立。 /// -/// ⚠️ 官方禁用 α 分**两类**,这个常量只属于第一类: -/// - **按钮容器 10%**:`BaselineButtonTokens` / `FilledButtonTokens` / `OutlinedButtonTokens` / -/// `TextButtonTokens` / `ElevatedButtonTokens` / `TonalButtonTokens` 的 `DisabledContainerOpacity` -/// = `0.1f`(AAR 字节码 ``); -/// - **面状控件(chip 容器/描边、slider 轨道、switch 轨道)12%**:那是另一类令牌 -/// (`AssistChipTokens.FlatDisabledOutlineOpacity` / `SliderTokens.DisabledInactiveTrackOpacity` / -/// `SwitchTokens.DisabledTrackOpacity` = `0.12f`),口径在 +/// ## 取值依据(每个 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** /// -/// 上面那行把 `TonalButtonTokens` 列进 0.1f 家族**只陈述它 `` 里写的 token 值**, -/// 不代表 tonal 的运行时行为是 10%:官方 `ButtonDefaults.getDefaultFilledTonalButtonColors` -/// 里**硬编码** `ldc_w 0.12f`(`FilledTonalButtonTokens` 根本没有 `DisabledContainerOpacity` 字段)。 -/// 我们的实现在 tonal 与 filled 之间**没有区分**,两者共用本常量 ⇒ **tonal 实际画的是 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 里出现」,**不是「字段不存在」**)。 /// -/// 这是**已登记的偏离,不是对齐**:将来若要修,需要按变体分派 α(`disabled_button_colors` 得长出 -/// 一个「哪个变体」的参数),并同步 `gallery/probe` 的逐行断言;本轮(M86)只做令牌一致性收口, -/// 不扩到变体分派。 +/// 我们的实现在 tonal 与 filled 之间**没有区分**,两者共用本常量 ⇒ **tonal 实际画的是 10%**。 +/// 这是**已登记的偏离,不是对齐**:要修得让 `disabled_button_colors` 知道「哪个变体」, +/// 并同步 `gallery/probe` 的逐行断言;本轮(M86)只做令牌一致性收口,不扩到变体分派。 /// /// 曾经的缺陷不是「12 是错的」,而是**同一个常量同时服务这两类**:按钮那半边一直错、另半边一直对。 pub const DISABLED_CONTAINER_ALPHA: f32 = 0.10;