//! 令牌图标(`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`),本模块**不重算也不手抄常数**。
//!
//! ## ⚠️ 渲染尺寸**不是**一个全局值(M86 收口)
//!
//! **盒子多宽就按多宽烘焙**,规则只有两条:
//!
//! | 盒尺寸 | 用哪个入口 |
//! |---|---|
//! | **等于** [`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};
use iced::widget::svg;
use iced::Color;
use unlucky_design::icons::{IconKind, IconPaint, IconSize};
/// 图标缓存键里的尺寸精度(px × 100 取整),避免拿 f32 做哈希键。
const PX_KEY_SCALE: f32 = 100.0;
/// 骨架期图标的默认渲染尺寸(12b:标题栏与导航轨都是 24;`SmallIconButtonTokens.IconSize = 24`)。
pub const RENDER_PX: f32 = IconSize::Toolbar.px();
/// 标题栏 caption 三键(最小化 / 最大化 / 关闭)的图标边长。
///
/// **项目偏离**(用户点名):官方 `SmallIconButtonTokens.IconSize = 24`,这里 20;容器 46×48 不动。
/// 值只在令牌层定义一次(`unlucky_design::icons::TITLEBAR_CAPTION_ICON_PX`),本文件只是转口。
pub const CAPTION_PX: f32 = unlucky_design::icons::TITLEBAR_CAPTION_ICON_PX;
/// 生成一个图标的 SVG 源码(`viewBox` 24×24,颜色用 `currentColor` 交给 iced 的色滤镜)。
///
/// 之所以用 `currentColor` 而不是把令牌色写进 SVG:色滤镜(`svg::Style::color`)会按
/// **alpha 蒙版**重涂 RGB,这样一份 SVG 位图能被浅/深两套主题复用,缓存不会翻倍。
///
/// `render_px` 决定两件事:SVG 的 `width`/`height` 属性与 `stroke-width` 的 viewBox 折算,
/// 两者必须同源,否则线宽会被二次缩放(见模块文档最后一段)。
pub fn source(kind: IconKind, render_px: f32) -> 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 句柄(按 `(种类, 渲染尺寸)` 缓存;句柄 id 是内容哈希,所以 iced 侧也能命中缓存)。
fn handle(kind: IconKind, render_px: f32) -> svg::Handle {
static HANDLES: OnceLock>> = OnceLock::new();
// 依赖「枚举下标 == IconKind::ALL 下标」(令牌层用 `GLYPHS[self as usize]` 同一约定)。
// 这条断言把约定钉在测试与 debug 构建里,`IconKind` 一旦乱序插入就会立刻炸。
debug_assert_eq!(
IconKind::ALL[kind as usize],
kind,
"IconKind::ALL 必须与枚举声明顺序一致"
);
let key = (kind, (render_px * PX_KEY_SCALE).round() as u32);
let cache = HANDLES.get_or_init(|| Mutex::new(HashMap::new()));
let mut cache = cache
.lock()
.unwrap_or_else(|poisoned| poisoned.into_inner());
cache
.entry(key)
.or_insert_with(|| svg::Handle::from_memory(source(kind, render_px).into_bytes()))
.clone()
}
/// 一个图标控件:按**令牌默认尺寸**([`RENDER_PX`])渲染,颜色走令牌角色。
pub fn view<'a, Message: 'a>(kind: IconKind, color: Color) -> iced::Element<'a, Message> {
view_at(kind, color, RENDER_PX)
}
/// 一个图标控件:按调用方给的**令牌尺寸**渲染(`render_px` 必须来自 `unlucky_design::icons`)。
///
/// 只有需要「组件自己的图标尺寸不同」时才用这个入口(目前只有标题栏 caption 三键)。
/// 别为了摆一个位置而拿它调字面量:位置交给布局(`layout::fixed` / `centered_fixed`)。
pub fn view_at<'a, Message: 'a>(
kind: IconKind,
color: Color,
render_px: f32,
) -> iced::Element<'a, Message> {
svg(handle(kind, render_px))
.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, RENDER_PX);
assert!(source.starts_with("