//! 令牌图标(`unlucky_design::icons` 的 24 网格 SVG path)→ iced `svg` 控件。 //! //! ## 为什么走 SVG 而不是自绘 canvas //! //! 令牌里存的是 **SVG path 字符串**(含 `A` 弧线命令),自绘要自己写 path parser + 弧线细分, //! 那是一份将来只有自己在维护的代码;iced 的 `svg` 控件已经带 usvg 解析与按 id 缓存的位图。 //! 代价是二进制多一份 resvg/usvg(体积基线里会单列出来)。 //! //! ## 描边语义(`design-令牌约定.md` §4) //! //! 令牌的 `Icon::stroke_width(render_px)` 已经把「视觉 1.5px」折算成 **viewBox 单位** //! (`1.5 × 24 / render_px`),本模块**不重算也不手抄常数**。 //! 本骨架所有图标都按 24px 渲染(12b 2.2 标题栏 / 5.5 导航),所以每个图标只有一份位图缓存。 use std::sync::OnceLock; use iced::widget::svg; use iced::Color; use unlucky_design::icons::{IconKind, IconPaint, IconSize}; /// 骨架期图标的统一渲染尺寸(12b:标题栏与导航轨都是 24)。 pub const RENDER_PX: f32 = IconSize::Toolbar.px(); /// 生成一个图标的 SVG 源码(`viewBox` 24×24,颜色用 `currentColor` 交给 iced 的色滤镜)。 /// /// 之所以用 `currentColor` 而不是把令牌色写进 SVG:色滤镜(`svg::Style::color`)会按 /// **alpha 蒙版**重涂 RGB,这样一份 SVG 位图能被浅/深两套主题复用,缓存不会翻倍。 pub fn source(kind: IconKind) -> String { let icon = kind.icon(); let paint = match icon.paint { IconPaint::Stroke => format!( "fill=\"none\" stroke=\"currentColor\" stroke-width=\"{}\" \ stroke-linecap=\"round\" stroke-linejoin=\"round\"", icon.stroke_width(RENDER_PX) ), IconPaint::Fill => "fill=\"currentColor\" stroke=\"none\"".to_owned(), }; format!( "", RENDER_PX, icon.path ) } /// 图标的 SVG 句柄(按 `IconKind` 缓存;句柄 id 是内容哈希,所以 iced 侧也能命中缓存)。 fn handle(kind: IconKind) -> svg::Handle { static HANDLES: OnceLock> = OnceLock::new(); let handles = HANDLES.get_or_init(|| { IconKind::ALL .iter() .map(|kind| svg::Handle::from_memory(source(*kind).into_bytes())) .collect() }); // 依赖「枚举下标 == IconKind::ALL 下标」(令牌层用 `GLYPHS[self as usize]` 同一约定)。 // 这条断言把约定钉在测试与 debug 构建里,`IconKind` 一旦乱序插入就会立刻炸。 debug_assert_eq!( IconKind::ALL[kind as usize], kind, "IconKind::ALL 必须与枚举声明顺序一致" ); handles[kind as usize].clone() } /// 一个图标控件(尺寸走令牌,颜色走令牌角色)。 pub fn view<'a, Message: 'a>(kind: IconKind, color: Color) -> iced::Element<'a, Message> { svg(handle(kind)) .width(RENDER_PX) .height(RENDER_PX) .style(move |_theme, _status| svg::Style { color: Some(color) }) .into() } #[cfg(test)] mod tests { use super::*; #[test] fn every_icon_produces_wellformed_svg() { for kind in IconKind::ALL { let source = source(kind); assert!(source.starts_with(""), "{} 的 SVG 尾不对", kind.name()); assert!(source.contains("viewBox=\"0 0 24 24\""), "{}", kind.name()); assert!( source.contains(kind.icon().path), "{} 的 path 没写进去", kind.name() ); assert!(!source.contains('#'), "{} 里出现了色值字面量", kind.name()); } } #[test] fn stroke_icons_carry_the_token_stroke_width() { let stroke = source(IconKind::Settings); let expected = IconKind::Settings.icon().stroke_width(RENDER_PX); assert!( stroke.contains(&format!("stroke-width=\"{expected}\"")), "{stroke}" ); assert!(stroke.contains("stroke-linecap=\"round\"")); // 24px 渲染时的视觉线宽必须是 1.5(M54 结论) assert!((expected - 1.5).abs() < f32::EPSILON); } #[test] fn filled_icons_do_not_get_a_stroke() { // `more` 是三个填充圆点 let filled = source(IconKind::More); assert!(filled.contains("fill=\"currentColor\"")); assert!(filled.contains("stroke=\"none\"")); } #[test] fn handles_are_cached_per_kind() { let first = handle(IconKind::Close); let second = handle(IconKind::Close); assert_eq!(first.id(), second.id()); assert_ne!(first.id(), handle(IconKind::Minimize).id()); } }