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`,我们仍在用面状族的值
//! (既有 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;
+172 -119
View File
@@ -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<String> = 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<std::path::PathBuf> {
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<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 叠加两种模式都成立。
///
/// ⚠️ 官方禁用 α 分**两类**,这个常量只属于第一类:
/// - **按钮容器 10%**:`BaselineButtonTokens` / `FilledButtonTokens` / `OutlinedButtonTokens` /
/// `TextButtonTokens` / `ElevatedButtonTokens` / `TonalButtonTokens` 的 `DisabledContainerOpacity`
/// = `0.1f`(AAR 字节码 `<clinit>`);
/// - **面状控件(chip 容器/描边、slider 轨道、switch 轨道)12%**:那是另一类令牌
/// (`AssistChipTokens.FlatDisabledOutlineOpacity` / `SliderTokens.DisabledInactiveTrackOpacity` /
/// `SwitchTokens.DisabledTrackOpacity` = `0.12f`),口径在
/// ## 取值依据(每个 token 的消费者都用 `javap -c ButtonDefaults` 数过)
///
/// - **按钮容器 10%**:`FilledButtonTokens` / `ElevatedButtonTokens` / `OutlinedButtonTokens` 的
/// `DisabledContainerOpacity = 0.1f`,且 `ButtonDefaults.<clinit>` 里**真的调了**
/// `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 家族**只陈述它 `<clinit>` 里写的 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>` 里也看不到它——那是「没在 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;