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:
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(),
|
||||
};
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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 —— 那正是这条要防的错。
|
||||
|
||||
Reference in new issue
Block a user