diff --git a/crates/app-iced/src/components/buttons/assemble.rs b/crates/app-iced/src/components/buttons/assemble.rs index 1b4b1d7..0b22b85 100644 --- a/crates/app-iced/src/components/buttons/assemble.rs +++ b/crates/app-iced/src/components/buttons/assemble.rs @@ -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::(kind, ink), + icons::view_at::(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::(kind, ink), + icons::view_at::(kind, ink, geometry.icon), )); } let row = row.spacing(geometry.gap); diff --git a/crates/app-iced/src/components/selection/chip.rs b/crates/app-iced/src/components/selection/chip.rs index 9db4e13..e84353d 100644 --- a/crates/app-iced/src/components/selection/chip.rs +++ b/crates/app-iced/src/components/selection/chip.rs @@ -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::(kind, at_rest.icon), + icons::view_at::(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::(kind, at_rest.icon), + icons::view_at::(kind, at_rest.icon, geometry.icon), )); } diff --git a/crates/app-iced/src/components/selection/mod.rs b/crates/app-iced/src/components/selection/mod.rs index 1fae516..c6c15cc 100644 --- a/crates/app-iced/src/components/selection/mod.rs +++ b/crates/app-iced/src/components/selection/mod.rs @@ -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; diff --git a/crates/app-iced/src/components/selection/probe.rs b/crates/app-iced/src/components/selection/probe.rs index 787182e..7a7e697 100644 --- a/crates/app-iced/src/components/selection/probe.rs +++ b/crates/app-iced/src/components/selection/probe.rs @@ -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() ); diff --git a/crates/app-iced/src/components/selection/switch.rs b/crates/app-iced/src/components/selection/switch.rs index d5523dc..b097cda 100644 --- a/crates/app-iced/src/components/selection/switch.rs +++ b/crates/app-iced/src/components/selection/switch.rs @@ -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::(IconKind::Check, ink), + // 盒 16 ≠ RENDER_PX(24):必须按 16 烘焙,否则线宽被布局压细(复审 r1 P2-2)。 + icons::view_at::(IconKind::Check, ink, geometry.icon), ), None => Spacer::new().into(), }; diff --git a/crates/app-iced/src/icons.rs b/crates/app-iced/src/icons.rs index 57d584a..57102fc 100644 --- a/crates/app-iced/src/icons.rs +++ b/crates/app-iced/src/icons.rs @@ -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 { + 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 + } + } } diff --git a/crates/design/tests/token_contract.rs b/crates/design/tests/token_contract.rs index 9970d57..0f1b50f 100644 --- a/crates/design/tests/token_contract.rs +++ b/crates/design/tests/token_contract.rs @@ -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 —— 那正是这条要防的错。