fix(design,app-iced): 守门改成调用形状+逐调用点计数;filled-tonal 事实纠正(M86 r2 P1)

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> 里看不到——我上一轮把「没在 clinit 出现」误写成「字段不存在」。同时把「TonalButtonTokens」
从 0.1f 家族的依据里剔除(它在整个 AAR 里零消费者,正是本 mission 警告的死令牌坑),改为只引用
ButtonDefaults 真的调了 getDisabledContainerOpacity 的 Filled/Elevated/Outlined,并注明
TextButton(容器透明)与 Baseline(基准表、运行时无消费者)。
This commit is contained in:
AskaEth committed 2026-11-02 13:12:18 +08:00
1 parent 896edf07fe
commit 7c274afd7c
3 files changed
+197 -137

No files matched your search

@@ -18,10 +18,12 @@
//! `FilledTextFieldTokens.FieldDisabledContainerOpacity = 0.04f`,我们仍在用面状族的值 //! `FilledTextFieldTokens.FieldDisabledContainerOpacity = 0.04f`,我们仍在用面状族的值
//! (既有 D10 偏离,未在本轮收口)。`inputs::field` 走 [`disabled_colors`] 的口径,行为未变。 //! (既有 D10 偏离,未在本轮收口)。`inputs::field` 走 [`disabled_colors`] 的口径,行为未变。
//! //!
//! ⚠️ **filled-tonal 是另一条已知偏离**:官方 `ButtonDefaults.getDefaultFilledTonalButtonColors` //! ⚠️ **filled-tonal 是另一条已知偏离**:官方
//! 里硬编码 `ldc_w 0.12f`(`FilledTonalButtonTokens` 没有 `DisabledContainerOpacity` 字段), //! `FilledTonalButtonTokens.DisabledContainerOpacity = 0.12f`(`javap -p -constants` 可见它是个
//! 而本模块的 `disabled_button_*` **不区分变体**,tonal 与 filled 共用 0.10 ⇒ tonal 实际画 10%。 //! **编译期常量**,被内联进 `getDefaultFilledTonalButtonColors` 的 `ldc_w 0.12f`,所以调用点是常量、
//! 这不是对齐、是登记过的偏离;要修得让入口知道变体,本轮(M86)不做。 //! `getDisabledContainerOpacity()` 不会被调用);而本模块的 `disabled_button_*` **不区分变体**,
//! tonal 与 filled 共用 0.10 ⇒ tonal 实际画 10%。这不是对齐、是登记过的偏离;要修得让入口知道变体,
//! 本轮(M86)不做。
use iced::Color; use iced::Color;
+172 -119
View File
@@ -167,83 +167,47 @@ mod tests {
); );
} }
/// caption 三键的 20:SVG 尺寸与线宽窗口**同源**,视觉线宽仍是 1.5px(不是 1.25)。 /// `view_at` 必须**按请求尺寸烘焙**(复审 r1 P1-2/P2-2 的守门断言)。
#[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 的守门断言)。
/// ///
/// 两条路径的差别就是这条测试的全部意义: /// 两条路径的差别就是这条测试的全部意义:
/// - **烘焙路径**:`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**。 /// - **缩放路径**(错的那条):24 烘焙(`stroke-width="1.5"`)塞进 18 盒 ⇒ 视觉 **1.125px**。
#[test] #[test]
fn view_at_bakes_the_requested_size() { fn view_at_bakes_the_requested_size() {
let button_px = IconSize::Button.px(); // (盒, 该盒应有的 viewBox 线宽, 盒子来源)——caption 20 / 按钮 18 / switch 滑块 16。
assert_eq!(button_px, 18.0, "这条测试以按钮盒 18 为例"); let cases = [
let baked = source(IconKind::Check, button_px); (CAPTION_PX, 1.8, "caption"),
assert!( (IconSize::Button.px(), 2.0, "按钮"),
baked.contains("width=\"18\"") && baked.contains("height=\"18\""), (IconSize::SwitchThumb.px(), 2.25, "switch 滑块"),
"按 18 烘焙时 SVG 的 width/height 必须是 18:{baked}" ];
); for (px, stroke, what) in cases {
// `stroke-width` 的数值由 `format!(\"{}\", f32)` 渲染:2.0 会打印成 `2`, let svg = source(IconKind::Close, px);
// 所以这里比的是**数值**(2.0)而不是字符串 "2.0"。 // `stroke-width` 由 `format!("{}", f32)` 渲染:2.0 会打印成 `2`,所以先比数值再比字符串。
let baked_stroke = unlucky_design::icons::stroke_width_for(18.0, 1.5); let baked = unlucky_design::icons::stroke_width_for(px, 1.5);
assert!( assert!(
(baked_stroke - 2.0).abs() < 1.0e-6, (baked - stroke).abs() < 1.0e-6,
"18px 的 viewBox 线宽必须是 2.0,实际 {baked_stroke}" "{what}:{px}px 盒的 viewBox 线宽应为 {stroke},实际 {baked}"
); );
assert!( assert!(
baked.contains(&format!("stroke-width=\"{baked_stroke}\"")), svg.contains(&format!("width=\"{px}\""))
"SVG 里必须真的写着 {baked_stroke}:{baked}" && svg.contains(&format!("stroke-width=\"{baked}\"")),
); "{what}:svg 必须按 {px} 烘焙:{svg}"
// 视觉线宽 = viewBox 单位 × 盒/viewBox = 2.0 × 18/24 = 1.5(达标) );
let baked_visual = unlucky_design::icons::visual_stroke_px(button_px, baked_stroke); let visual = unlucky_design::icons::visual_stroke_px(px, baked);
assert!( assert!(
(baked_visual - 1.5).abs() < 1.0e-6, (visual - 1.5).abs() < 1.0e-6,
"烘焙路径视觉线宽应 1.5,实际 {baked_visual}" "{what}:烘焙路径视觉线宽应为 1.5,实际 {visual}"
); );
}
// 反证:拿 24 烘焙的那份去填 18 盒(= 用 `view` 装进小盒),视觉只剩 1.125px。 // 反证:拿 24 烘焙的那份去填 18 盒(= 用 `view` 装进小盒),视觉只剩 1.125px。
let scaled = source(IconKind::Check, RENDER_PX); let scaled = source(IconKind::Check, RENDER_PX);
assert!( assert!(scaled.contains("width=\"24\""));
scaled.contains(&format!( let scaled_visual = unlucky_design::icons::visual_stroke_px(IconSize::Button.px(), 1.5);
"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!( assert!(
(scaled_visual - 1.125).abs() < 1.0e-6, (scaled_visual - 1.125).abs() < 1.0e-6,
"缩放路径只该得到 1.125px(这就是为什么盒 ≠ 24 必须走 view_at)" "缩放路径只该得到 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] #[test]
@@ -264,70 +228,48 @@ mod tests {
assert_ne!(first.id(), handle(IconKind::Close, CAPTION_PX).id()); 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」的文件里; /// 判据 = 调用形状匹配 + **逐调用点计数**(不做「文件里出现过 view_at 就整文件放行」的短路):
/// 其余文件的图标盒是 18 / 16 / 20,用 `view` 会被布局缩成 1.04~1.25px 视觉线宽。 /// 先剥注释,再认 `icons::view(` / `icons::view::<` / `crate::icons::view` / `super::icons::view`
/// 谁把 `assemble.rs` / `chip.rs` / `switch.rs` 改回 `icons::view`,这条立刻红。 /// 以及 `use …icons…view as v` / `{view, view_at}` 之后的 `v(` / `view(`;
/// 每个文件的计数必须**等于**白名单期望(未列出 = 0),所以「同文件改对 3 处、漏掉 1 处」也会红。
#[test] #[test]
fn sub_24_boxes_go_through_view_at() { fn sub_24_boxes_go_through_view_at() {
// 图标盒等于 `RENDER_PX`(24) 的文件——它们用 `view` 是对的。 const VIEW: &[(&str, usize)] = &[
// (`buttons/mod.rs` 的 IconButton 是「24 图标 + 40 外壳」,图标本身仍是 24。) ("shell/nav.rs", 1),
const VIEW_OK: &[&str] = &[ ("components/inputs/field.rs", 2),
"shell/nav.rs", ("components/inputs/menu.rs", 2),
"components/inputs/field.rs", ("components/inputs/search.rs", 2),
"components/inputs/menu.rs", ("components/buttons/mod.rs", 1), // 24 图标 + 40 外壳
"components/inputs/search.rs",
"components/buttons/mod.rs",
]; ];
// 盒 ≠ 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 root = std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("src");
let mut checked = 0usize; let mut views = 0usize;
for entry in walk_rs(&root) { let mut bad: Vec<String> = Vec::new();
let rel = entry for path in walk_rs(&root) {
.strip_prefix(&root) let rel = path.strip_prefix(&root).expect("strip_prefix");
.expect("strip_prefix") let rel = rel.to_string_lossy().replace('\\', "/");
.to_string_lossy()
.replace('\\', "/");
// 本模块自身就是实现(`view` / `view_at` 的定义与文档),不参与扫描。
if rel == "icons.rs" { if rel == "icons.rs" {
continue; continue; // 实现自身
} }
let text = std::fs::read_to_string(&entry).expect("读组件源码"); let code = strip_comments(&std::fs::read_to_string(&path).expect("读组件源码"));
let plain = text let view_alias = icon_view_aliases(&code);
.lines() let view = count_calls(&code, "view", &view_alias);
.any(|line| line.contains("icons::view::") || line.contains("icons::view (")); views += view;
let sized = text.contains("icons::view_at::"); let want_view = sites(VIEW, &rel);
if view != want_view {
if plain && !sized { bad.push(format!(
assert!( "{rel}: {view} 处 `icons::view`,期望 {want_view} —— 盒 ≠ 24 必须 \
VIEW_OK.contains(&rel.as_str()), `icons::view_at(kind, color, 盒尺寸)`(24 烘焙再缩放只有 ~1.1px 线宽)"
"{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;
} }
} }
assert_eq!( assert!(bad.is_empty(), "图标入口用法违规:\n{}", bad.join("\n"));
checked, assert_eq!(views, 8, "全仓 `icons::view` 调用点总数变了");
VIEW_AT_REQUIRED.len(),
"view_at 调用点数量与清单不符(清单 {VIEW_AT_REQUIRED:?})"
);
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<std::path::PathBuf> { fn walk_rs(dir: &std::path::Path) -> Vec<std::path::PathBuf> {
let mut out = Vec::new(); let mut out = Vec::new();
for entry in std::fs::read_dir(dir).expect("读目录") { for entry in std::fs::read_dir(dir).expect("读目录") {
@@ -340,5 +282,116 @@ mod tests {
} }
out 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<String> {
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<String> {
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::<name>`,含 `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 == '_'))
}
} }
} }
+19 -14
View File
@@ -42,25 +42,30 @@ impl StateLayer {
/// ///
/// 别用「灰化」近似——深色模式下灰化会降低对比度,alpha 叠加两种模式都成立。 /// 别用「灰化」近似——深色模式下灰化会降低对比度,alpha 叠加两种模式都成立。
/// ///
/// ⚠️ 官方禁用 α 分**两类**,这个常量只属于第一类: /// ## 取值依据(每个 token 的消费者都用 `javap -c ButtonDefaults` 数过)
/// - **按钮容器 10%**:`BaselineButtonTokens` / `FilledButtonTokens` / `OutlinedButtonTokens` / ///
/// `TextButtonTokens` / `ElevatedButtonTokens` / `TonalButtonTokens` 的 `DisabledContainerOpacity` /// - **按钮容器 10%**:`FilledButtonTokens` / `ElevatedButtonTokens` / `OutlinedButtonTokens` 的
/// = `0.1f`(AAR 字节码 `<clinit>`); /// `DisabledContainerOpacity = 0.1f`,且 `ButtonDefaults.<clinit>` 里**真的调了**
/// - **面状控件(chip 容器/描边、slider 轨道、switch 轨道)12%**:那是另一类令牌 /// `getDisabledContainerOpacity()`(各 1 次)——活令牌。
/// (`AssistChipTokens.FlatDisabledOutlineOpacity` / `SliderTokens.DisabledInactiveTrackOpacity` / /// `TextButtonTokens` 同值但容器透明(`ButtonDefaults` 只读它的 `DisabledLabel*`);
/// `SwitchTokens.DisabledTrackOpacity` = `0.12f`),口径在 /// `BaselineButtonTokens` 同值,它是**基准表**、运行时零消费者,只作参照。
/// - ⚠️ `TonalButtonTokens` 也写 0.1f,但它在**整个 AAR 里零消费者**(`ButtonDefaults` 只引用
/// `FilledTonalButtonTokens`;`grep` 命中的 `…TonalButtonTokens` 是后者的子串假象)。
/// **别拿它当依据**——这正是本 mission 反复警告的死令牌坑(同 `ButtonSmallTokens.IconSize = 20`)。
/// - **面状控件(chip 容器/描边、slider 轨道、switch 轨道)12%** 是另一类令牌,口径在
/// `unlucky_app_iced::components::interaction::disabled_colors`。 /// `unlucky_app_iced::components::interaction::disabled_colors`。
/// ///
/// ## ⚠️ 已知偏离:filled-tonal 的禁用容器**官方是 0.12f,我们走 0.10** /// ## ⚠️ 已知偏离:filled-tonal 的禁用容器**官方是 0.12f,我们走 0.10**
/// ///
/// 上面那行把 `TonalButtonTokens` 列进 0.1f 家族**只陈述它 `<clinit>` 里写的 token 值**, /// A 级事实(`javap -p -constants androidx.compose.material3.tokens.FilledTonalButtonTokens`):
/// 不代表 tonal 的运行时行为是 10%:官方 `ButtonDefaults.getDefaultFilledTonalButtonColors` /// `public static final float DisabledContainerOpacity = 0.12f;`
/// 里**硬编码** `ldc_w 0.12f`(`FilledTonalButtonTokens` 根本没有 `DisabledContainerOpacity` 字段)。 /// ——它是**编译期常量**,被内联进 `ButtonDefaults.getDefaultFilledTonalButtonColors` 的字节码
/// 我们的实现在 tonal 与 filled 之间**没有区分**,两者共用本常量 ⇒ **tonal 实际画的是 10%**。 /// (所以那里看到的是 `ldc_w 0.12f`,`getDisabledContainerOpacity()` 根本不会被调用;
/// 在 `<clinit>` 里也看不到它——那是「没在 clinit 里出现」,**不是「字段不存在」**)。
/// ///
/// 这是**已登记的偏离,不是对齐**:将来若要修,需要按变体分派 α(`disabled_button_colors` 得长出 /// 我们的实现在 tonal 与 filled 之间**没有区分**,两者共用本常量 ⇒ **tonal 实际画的是 10%**。
/// 一个「哪个变体」的参数),并同步 `gallery/probe` 的逐行断言;本轮(M86)只做令牌一致性收口, /// 这是**已登记的偏离,不是对齐**:要修得让 `disabled_button_colors` 知道「哪个变体」,
/// 不扩到变体分派。 /// 并同步 `gallery/probe` 的逐行断言;本轮(M86)只做令牌一致性收口,不扩到变体分派。
/// ///
/// 曾经的缺陷不是「12 是错的」,而是**同一个常量同时服务这两类**:按钮那半边一直错、另半边一直对。 /// 曾经的缺陷不是「12 是错的」,而是**同一个常量同时服务这两类**:按钮那半边一直错、另半边一直对。
pub const DISABLED_CONTAINER_ALPHA: f32 = 0.10; pub const DISABLED_CONTAINER_ALPHA: f32 = 0.10;