Files
Unlucky-Launcher/crates/app-iced/src/icons.rs
T
AskaEth 006eb6869a fix(app-iced): 图标守门白名单补导航族(M79 进主线后的语义错配)
M86 的 sub_24_boxes_go_through_view_at 写于 M79 之前,白名单「未列出 = 0」把
导航族四处 view 调用判成违规。实测这四处 geometry.icon = IconSize::Toolbar = 24,
view 是正确入口,故补白名单(逐文件精确计数,仍能抓「同文件多一处」),总数 8→13。
icons.rs 399 行。
2026-11-02 13:40:21 +08:00

400 lines
18 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
//! 令牌图标(`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!(
"<svg xmlns=\"http://www.w3.org/2000/svg\" width=\"{0}\" height=\"{0}\" \
viewBox=\"0 0 24 24\"><path d=\"{1}\" {paint}/></svg>",
render_px, icon.path
)
}
/// 图标的 SVG 句柄(按 `(种类, 渲染尺寸)` 缓存;句柄 id 是内容哈希,所以 iced 侧也能命中缓存)。
fn handle(kind: IconKind, render_px: f32) -> svg::Handle {
static HANDLES: OnceLock<Mutex<HashMap<(IconKind, u32), svg::Handle>>> = 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("<svg"), "{} 的 SVG 头不对", kind.name());
assert!(source.ends_with("</svg>"), "{} 的 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, RENDER_PX);
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);
}
/// 默认尺寸**只从令牌取**:谁把 `RENDER_PX` 改成字面量或改成别的档,这条会红。
#[test]
fn default_render_size_comes_from_the_token() {
assert_eq!(
RENDER_PX,
IconSize::Toolbar.px(),
"默认 24 来自 IconSize::Toolbar"
);
assert_eq!(RENDER_PX, 24.0);
assert!(
source(IconKind::Close, RENDER_PX).contains("width=\"24\""),
"默认渲染尺寸必须是 24"
);
}
/// `view_at` 必须**按请求尺寸烘焙**(复审 r1 P1-2/P2-2 的守门断言)。两条路径的差别就是全部意义:
/// - **烘焙路径**:`source(kind, 盒)` → SVG `width=盒` + 线宽 `1.5×24/盒` ⇒ 视觉恒 **1.5px**;
/// - **缩放路径**(错的那条):24 烘焙(`stroke-width="1.5"`)塞进 18 盒 ⇒ 视觉 **1.125px**。
#[test]
fn view_at_bakes_the_requested_size() {
// (盒, 该盒应有的 viewBox 线宽, 盒子来源)——caption 20 / 按钮 18 / switch 滑块 16。
let cases = [
(CAPTION_PX, 1.8, "caption"),
(IconSize::Button.px(), 2.0, "按钮"),
(IconSize::SwitchThumb.px(), 2.25, "switch 滑块"),
];
for (px, stroke, what) in cases {
let svg = source(IconKind::Close, px);
// `stroke-width` 由 `format!("{}", f32)` 渲染:2.0 会打印成 `2`,所以先比数值再比字符串。
let baked = unlucky_design::icons::stroke_width_for(px, 1.5);
assert!(
(baked - stroke).abs() < 1.0e-6,
"{what}:{px}px 盒的 viewBox 线宽应为 {stroke},实际 {baked}"
);
assert!(
svg.contains(&format!("width=\"{px}\""))
&& svg.contains(&format!("stroke-width=\"{baked}\"")),
"{what}:svg 必须按 {px} 烘焙:{svg}"
);
let visual = unlucky_design::icons::visual_stroke_px(px, baked);
assert!(
(visual - 1.5).abs() < 1.0e-6,
"{what}:烘焙路径视觉线宽应为 1.5,实际 {visual}"
);
}
// 反证:拿 24 烘焙的那份去填 18 盒(= 用 `view` 装进小盒),视觉只剩 1.125px。
let scaled = source(IconKind::Check, RENDER_PX);
assert!(scaled.contains("width=\"24\""));
let scaled_visual = unlucky_design::icons::visual_stroke_px(IconSize::Button.px(), 1.5);
assert!(
(scaled_visual - 1.125).abs() < 1.0e-6,
"缩放路径只该得到 1.125px(这就是为什么盒 ≠ 24 必须走 view_at)"
);
}
#[test]
fn filled_icons_do_not_get_a_stroke() {
// `more` 是三个填充圆点
let filled = source(IconKind::More, RENDER_PX);
assert!(filled.contains("fill=\"currentColor\""));
assert!(filled.contains("stroke=\"none\""));
}
#[test]
fn handles_are_cached_per_kind_and_size() {
let first = handle(IconKind::Close, RENDER_PX);
let second = handle(IconKind::Close, RENDER_PX);
assert_eq!(first.id(), second.id(), "同尺寸同图标必须复用句柄");
assert_ne!(first.id(), handle(IconKind::Minimize, RENDER_PX).id());
// 同一图标、不同渲染尺寸 = 不同位图(线宽不同),不能共用。
assert_ne!(first.id(), handle(IconKind::Close, CAPTION_PX).id());
}
/// **盒 ≠ `RENDER_PX`(24) 的调用点必须走 `view_at`**(复审 r1 P1-1)。判据 = 调用形状匹配 +
/// **逐调用点计数**(不做「文件里出现过 view_at 就整文件放行」的短路):先剥注释,再认
/// `icons::view(` / `icons::view::<` / `crate::`·`super::` 前缀 / `use …view as v` 的 `v(`。
/// 每个文件的计数必须**等于**白名单期望(未列出 = 0),所以「同文件改对 3 处、漏掉 1 处」也会红。
#[test]
fn sub_24_boxes_go_through_view_at() {
const VIEW: &[(&str, usize)] = &[
("shell/nav.rs", 1),
("components/inputs/field.rs", 2),
("components/inputs/menu.rs", 2),
("components/inputs/search.rs", 2),
("components/buttons/mod.rs", 1), // 24 图标 + 40 外壳
// 导航族(M79)全是 `IconSize::Toolbar = 24`,`view` 正确;本白名单写于 M79 之前,
// 「未列出 = 0」会误判它们。补进来后这几族再出现多余 `view` 仍会红。
("components/navigation/app_bar.rs", 1),
("components/navigation/drawer/item.rs", 1),
("components/navigation/list_item.rs", 2),
("components/navigation/tabs.rs", 1),
];
let root = std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("src");
let mut views = 0usize;
let mut bad: Vec<String> = Vec::new();
for path in walk_rs(&root) {
let rel = path.strip_prefix(&root).expect("strip_prefix");
let rel = rel.to_string_lossy().replace('\\', "/");
if rel == "icons.rs" {
continue; // 实现自身
}
let code = strip_comments(&std::fs::read_to_string(&path).expect("读组件源码"));
let view_alias = icon_view_aliases(&code);
let view = count_calls(&code, "view", &view_alias);
views += view;
let want_view = sites(VIEW, &rel);
if view != want_view {
bad.push(format!(
"{rel}: {view} 处 `icons::view`,期望 {want_view} —— 盒 ≠ 24 必须 \
`icons::view_at(kind, color, 盒尺寸)`(24 烘焙再缩放只有 ~1.1px 线宽)"
));
}
}
assert!(bad.is_empty(), "图标入口用法违规:\n{}", bad.join("\n"));
assert_eq!(views, 13, "全仓 `icons::view` 调用点总数变了");
fn sites(list: &[(&str, usize)], rel: &str) -> usize {
list.iter().find(|(p, _)| *p == rel).map_or(0, |(_, n)| *n)
}
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
}
/// 剥掉 `//` 行注释与 `/* */` 块注释;字符串里的 `//`(URL)不算注释。
fn strip_comments(text: &str) -> String {
let mut out = String::with_capacity(text.len());
let mut it = text.chars().peekable();
let (mut line, mut block, mut string) = (false, false, false);
while let Some(c) = it.next() {
if line {
line = c != '\n';
if c == '\n' {
out.push(c);
}
} else if block {
if c == '*' && it.peek() == Some(&'/') {
it.next();
block = false;
}
} else if string {
out.push(c);
if c == '\\' {
if let Some(esc) = it.next() {
out.push(esc);
}
} else if c == '"' {
string = false;
}
} else if c == '"' {
string = true;
out.push(c);
} else if c == '/' && it.peek() == Some(&'/') {
it.next();
line = true;
} else if c == '/' && it.peek() == Some(&'*') {
it.next();
block = true;
} else {
out.push(c);
}
}
out
}
/// `use …icons…view …` 里 `view` 的本地名(含 `as` 别名与花括号列表)。
fn icon_view_aliases(code: &str) -> Vec<String> {
let mut names = Vec::new();
for line in code.lines() {
let t = line.trim_start();
if !t.starts_with("use ") || !t.contains("icons") {
continue;
}
for (idx, _) in t.match_indices("view") {
let end = idx + 4;
if !word_start(t, idx) || !word_end(t, end) {
continue;
}
let alias = alias_after(t, end).unwrap_or_else(|| "view".to_owned());
if !names.contains(&alias) {
names.push(alias);
}
}
}
names
}
/// `use …::view as v;` 里的 `v`。
fn alias_after(line: &str, from: usize) -> Option<String> {
let rest = line[from..].trim_start().strip_prefix("as")?.trim_start();
let name: String = rest
.chars()
.take_while(|c| c.is_alphanumeric() || *c == '_')
.collect();
(!name.is_empty()).then_some(name)
}
/// 数调用点:路径形状(`icons::<name>`,含 `crate::`/`super::` 前缀)与别名形状,
/// 后面必须紧跟 `(` 或 `::<`。
fn count_calls(code: &str, name: &str, aliases: &[String]) -> usize {
let mut count = 0;
let path = format!("icons::{name}");
for (idx, _) in code.match_indices(&path) {
if word_start(code, idx) && follows_call(code, idx + path.len()) {
count += 1;
}
}
for alias in aliases {
for (idx, _) in code.match_indices(alias.as_str()) {
let end = idx + alias.len();
// `icons::view(` 里的 `view` 已由路径形状数过,别再数一次。
if !(idx >= 7 && code[..idx].ends_with("icons::"))
&& word_start(code, idx)
&& word_end(code, end)
&& follows_call(code, end)
{
count += 1;
}
}
}
count
}
fn follows_call(code: &str, from: usize) -> bool {
let rest = code[from..].trim_start_matches([' ', '\t']);
rest.starts_with('(') || rest.starts_with("::<")
}
fn word_start(code: &str, idx: usize) -> bool {
code[..idx]
.chars()
.next_back()
.is_none_or(|c| !(c.is_alphanumeric() || c == '_'))
}
fn word_end(code: &str, idx: usize) -> bool {
code[idx..]
.chars()
.next()
.is_none_or(|c| !(c.is_alphanumeric() || c == '_'))
}
}
}