fix(app-iced): 盒 ≠ RENDER_PX 的图标改走 view_at,线宽回到 1.5px(M86 r1 P2)

r1 视觉/代码复审撞上同一件事:本轮把按钮/chip/switch 的盒子改成 18/18/16,但 SVG 仍按 24
烘焙、靠布局盒缩下去 ⇒ 实测线宽 1.04~1.25px(令牌要 1.5px)。label 的对照证据是同屏 caption
(20px,走了 view_at)实测正好 1.50px——差别只在有没有走按盒烘焙。

- buttons/assemble.rs(前置/后置)、selection/chip.rs(前置/后置)、selection/switch.rs 滑块勾
  共 5 处改为 icons::view_at(kind, color, geometry.icon);
- 新增单测 view_at_bakes_the_requested_size:source(18) 必须是 width=18 + stroke-width=2.0
  (视觉 1.5px),并反证「24 烘焙塞进 18 盒只有 1.125px」;
- 新增源码级守门 sub_24_boxes_go_through_view_at:icons::view 只允许出现在图标盒=24 的文件,
  否则红——谁把上面 5 处改回 view 立刻被抓;
- 三处会带偏人的文字订正:icons.rs 模块文档改成「24 盒走 view、其余一律 view_at」;
  selection/mod.rs 的「图标一律 icons::view」同改;token_contract.rs 明说那条只保证烘焙路径。
This commit is contained in:
AskaEth committed 2026-11-02 13:12:14 +08:00
1 parent fa40d798e2
commit da1fe3aa4f
7 files changed
+155 -16

No files matched your search

@@ -80,11 +80,13 @@ pub(super) fn assemble<'a, M: Clone + 'a>(
let mut row = Row::new().align_y(Alignment::Center);
if let Some(kind) = spec.leading {
// Q9:图标一律经固定尺寸容器(按钮图标 16、IconButton 24 都不例外)
// Q9:图标一律经固定尺寸容器(按钮图标 18、IconButton 24 都不例外)。
// **必须走 `view_at`**:盒 18 ≠ `RENDER_PX`(24),用 24 烘焙再缩到 18 会把视觉线宽从 1.5 压到 1.1
// (复审 r1 P2-2 实测 1.04~1.25px)。
row = row.push(layout::fixed(
geometry.icon,
geometry.icon,
icons::view::<M>(kind, ink),
icons::view_at::<M>(kind, ink, geometry.icon),
));
}
row = row.push(label);
@@ -92,7 +94,7 @@ pub(super) fn assemble<'a, M: Clone + 'a>(
row = row.push(layout::fixed(
geometry.icon,
geometry.icon,
icons::view::<M>(kind, ink),
icons::view_at::<M>(kind, ink, geometry.icon),
));
}
let row = row.spacing(geometry.gap);
@@ -122,11 +122,12 @@ pub fn chip<'a, M: Clone + 'a>(ui: &'a Ui, spec: Chip<'a, M>) -> Element<'a, M>
let mut content = row![].align_y(Alignment::Center).spacing(geometry.gap);
if let Some(kind) = spec.leading {
// Q9:图标一律经固定尺寸容器
// Q9:图标一律经固定尺寸容器;盒 18 ≠ RENDER_PX(24),所以必须走 `view_at` 按 18 烘焙,
// 否则线宽会被布局缩水(复审 r1 P2-2)。
content = content.push(super::super::layout::centered_fixed(
geometry.icon,
geometry.icon,
icons::view::<M>(kind, at_rest.icon),
icons::view_at::<M>(kind, at_rest.icon, geometry.icon),
));
}
@@ -142,7 +143,7 @@ pub fn chip<'a, M: Clone + 'a>(ui: &'a Ui, spec: Chip<'a, M>) -> Element<'a, M>
content = content.push(super::super::layout::centered_fixed(
geometry.icon,
geometry.icon,
icons::view::<M>(kind, at_rest.icon),
icons::view_at::<M>(kind, at_rest.icon, geometry.icon),
));
}
@@ -21,7 +21,10 @@
//! - 全部组件**对消息类型泛型**(`M: Clone`),不 import `crate::app::Message`;
//! - hover / pressed / focus / disabled 的 α 只从 [`super::interaction`] 取(本目录零 α 字面量);
//! - 精确尺寸只从 [`super::layout`](`centered_fixed` / `fixed`)与 `geometry` 取,不许手写;
//! - 图标一律 `icons::view` + 固定尺寸容器(Q9),圆角按 D32 分层(微控件 2 / 容器刻度 / full)。
//! - 图标一律 `icons::view_at(kind, color, geometry.icon)` + 固定尺寸容器(Q9):本目录的盒是
//! **18(chip)与 16(switch 滑块)**,都不是 `icons::RENDER_PX`(24),所以必须按盒烘焙——用
//! `icons::view`(24 烘焙)会被布局缩到 1.1px 视觉线宽(复审 r1 P2-2)。圆角按 D32 分层
//! (微控件 2 / 容器刻度 / full)。
mod checkbox;
mod chip;
@@ -265,7 +265,7 @@ fn selection_geometry_matches_the_reference() {
);
assert!(
chips[0].width() >= 58,
"13.1:chip 宽应 ≥58(图标 16 + 两侧各 8 + 文字),实测 {}",
"13.1:chip 宽应 ≥58(图标 18 + 两侧各 8 + 文字),实测 {}",
chips[0].width()
);
@@ -80,7 +80,8 @@ pub fn switch<'a, M: Clone + 'a>(ui: &'a Ui, spec: Switch<'a, M>) -> Element<'a,
Some(ink) => super::super::layout::centered_fixed(
geometry.icon,
geometry.icon,
icons::view::<M>(IconKind::Check, ink),
// 盒 16 ≠ RENDER_PX(24):必须按 16 烘焙,否则线宽被布局压细(复审 r1 P2-2)。
icons::view_at::<M>(IconKind::Check, ink, geometry.icon),
),
None => Spacer::new().into(),
};
+132 -7
View File
@@ -13,14 +13,22 @@
//!
//! ## ⚠️ 渲染尺寸**不是**一个全局值(M86 收口)
//!
//! 骨架里绝大多数图标按 [`RENDER_PX`](24)渲染,但标题栏 caption 三键按
//! [`CAPTION_PX`](20,项目偏离)渲染。**尺寸只有一个来源:`unlucky_design::icons` 的令牌**
//! ([`IconSize`] / [`unlucky_design::icons::TITLEBAR_CAPTION_ICON_PX`])——
//! 本文件里不许出现 `20.0` / `24.0` 这类字面量,也不许把某个组件的尺寸「凑」进 [`RENDER_PX`]。
//! **盒子多宽就按多宽烘焙**,规则只有两条:
//!
//! 之所以要按尺寸分别烘焙 SVG:`stroke-width` 是 viewBox 单位,同一个 `1.5` 在 20px 与 24px 渲染下
//! 视觉线宽不同(20px 要 1.8、24px 要 1.5)。**把 24px 烘焙的位图塞进 20px 盒子里只会得到 1.25px**,
//! 这就是「缩图标顺手把线宽带细」的老毛病,所以每个渲染尺寸各缓存一份。
//! | 盒尺寸 | 用哪个入口 |
//! |---|---|
//! | **等于** [`RENDER_PX`](24) | [`view`](导航、卡片头、表单、IconButton…) |
//! | **不等于** 24(按钮 18 / chip 18 / switch 滑块 16 / 标题栏 caption 20) | **[`view_at`](必须)** |
//!
//! 尺寸只有一个来源:`unlucky_design::icons` 的令牌([`IconSize`] /
//! [`unlucky_design::icons::TITLEBAR_CAPTION_ICON_PX`])——本文件里不许出现 `18.0` / `20.0` / `24.0`
//! 这类字面量,也不许把某个组件的尺寸「凑」进 [`RENDER_PX`]。
//!
//! **为什么必须按盒烘焙**:`stroke-width` 是 viewBox 单位,同一个 viewBox 线宽在更小的盒子里
//! 视觉更细。把 24px 烘焙的位图塞进 18px 盒子,视觉线宽会从 1.5px 掉到 **1.125px**
//! (复审 r1 实测 1.04~1.25px,而同屏 caption 的 20px 走 [`view_at`] 实测正好 1.50px)。
//! 所以:**每个用到的尺寸各缓存一份**,缓存键是 `(图标种类, 渲染尺寸)`——但「缓存支持多尺寸」
//! 不等于「调用点都用上了」,**不等于 24 的盒子一律走 [`view_at`]** 才是这条的落点。
use std::collections::HashMap;
use std::sync::{Mutex, OnceLock};
@@ -180,6 +188,49 @@ mod tests {
);
}
/// `view_at` 必须**按请求尺寸烘焙**(复审 r1 P2-2 的守门断言)。
///
/// 两条路径的差别就是这条测试的全部意义:
/// - **烘焙路径**:`source(kind, 18)` → SVG `width="18"` + `stroke-width="2.0"` ⇒ 视觉 **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}"
);
assert!(
baked.contains("stroke-width=\"2.0\""),
"18px 渲染的 stroke-width 必须是 1.5×24/18 = 2.0:{baked}"
);
// 视觉线宽 = viewBox 单位 × 盒/viewBox = 2.0 × 18/24 = 1.5(达标)
let baked_visual = unlucky_design::icons::visual_stroke_px(button_px, 2.0);
assert!(
(baked_visual - 1.5).abs() < 1.0e-6,
"烘焙路径视觉线宽应 1.5,实际 {baked_visual}"
);
// 反证:拿 24 烘焙的那份去填 18 盒(= 用 `view` 装进小盒),视觉只剩 1.125px。
let scaled = source(IconKind::Check, RENDER_PX);
assert!(scaled.contains("stroke-width=\"1.5\"") && scaled.contains("width=\"24\""));
let scaled_visual = unlucky_design::icons::visual_stroke_px(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 = source(IconKind::Check, thumb_px);
assert!(
thumb.contains("width=\"16\"") && thumb.contains("stroke-width=\"2.25\""),
"16px 盒要 2.25 个 viewBox 单位:{thumb}"
);
}
#[test]
fn filled_icons_do_not_get_a_stroke() {
// `more` 是三个填充圆点
@@ -197,4 +248,78 @@ mod tests {
// 同一图标、不同渲染尺寸 = 不同位图(线宽不同),不能共用。
assert_ne!(first.id(), handle(IconKind::Close, CAPTION_PX).id());
}
/// **盒 ≠ `RENDER_PX`(24) 的调用点必须走 `view_at`**(复审 r1 P2-2 的守门断言)。
///
/// 这是源码级扫描(不需要开窗):`icons::view`(24 烘焙)只允许出现在「图标盒 = 24」的文件里;
/// 其余文件的图标盒是 18 / 16 / 20,用 `view` 会被布局缩成 1.04~1.25px 视觉线宽。
/// 谁把 `assemble.rs` / `chip.rs` / `switch.rs` 改回 `icons::view`,这条立刻红。
#[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",
"inputs/field.rs",
"inputs/menu.rs",
"inputs/search.rs",
"buttons/mod.rs",
];
// 盒 ≠ 24、因此必须用 `view_at` 的文件。
const VIEW_AT_REQUIRED: &[&str] = &[
"buttons/assemble.rs",
"selection/chip.rs",
"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('\\', "/");
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;
}
}
assert_eq!(
checked,
VIEW_AT_REQUIRED.len(),
"view_at 调用点数量与清单不符(清单 {VIEW_AT_REQUIRED:?})"
);
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("读目录") {
let path = entry.expect("目录项").path();
if path.is_dir() {
out.extend(walk_rs(&path));
} else if path.extension().is_some_and(|ext| ext == "rs") {
out.push(path);
}
}
out
}
}
}
+7
View File
@@ -131,6 +131,13 @@ fn icon_stroke_follows_svg_semantics_not_slint() {
// - 20px(`IconSize::Nav`,本轮没动)→ 1.5 × 24 / 20 = **1.8**
// - 18px(`IconSize::Button`,M86 由 16 收敛到官方 18)→ 1.5 × 24 / 18 = **2.0**
//
// ⚠️ **这条只保证「烘焙路径」**(`crates/app-iced/src/icons.rs::view_at`:按盒尺寸烘焙 SVG,
// 于是一个 viewBox 单位的 stroke 落到盒子后正好是 1.5 视觉像素)。
// 它**不**保证「24 烘焙 + 布局缩到 18」那条路径——那条会得到 `1.5 × 18/24 = 1.125px`,
// 正是复审 r1 P2-2 实测到的 1.04~1.25px 缺陷。所以组件侧凡是盒 ≠ `RENDER_PX`(24) 的调用点
// 都必须走 `view_at`(见 `icons.rs::tests::view_at_bakes_the_requested_size` 与
// `crates/app-iced/src/components/gate.rs` 的 Q9 扫描)。
//
// 之所以写死字面量而不写成 `stroke_width_for(IconSize::Nav.px(), 1.5)`:后者等于拿实现算实现,
// 派生式一旦被改坏(例如把 ×24 写成 ×20)它照样绿。写死才会红。
// Slint 口径(绝对 1.5px,不乘 viewBox)在这两档都会得到 1.5 —— 那正是这条要防的错。