feat(design): 加 unlucky-design 令牌 crate(M3 色彩/排版/形状/动效/图标)
D26 把视觉基线锁到 Material 3 Expressive,本 crate 是「UI 整体性」的地基: 令牌唯一真源,后续 iced 页面只消费不新增。零第三方依赖、零 UI 框架依赖 (cargo tree -p unlucky-design --edges normal 只有它自己一行)。 - 色彩:49 个 M3 语义角色 × light/dark,种子色派生 6 条 tone 调色板; tone 轴严格用 CIELAB L*(偏差 ≤1),色相/彩度用 OKLCh 近似 CAM16 并在 模块文档里写明口径与已知偏差(不宣称是标准 HCT 动态取色) - 对比度:26 组文字对 × 6 组种子 × 2 模式全部 ≥4.5:1;3 条例外显式登记 (Outline 非文字 3:1、OutlineVariant 装饰、SurfaceVariant 是容器色) - 排版:M3 type scale 15 档 + 三平台字体族回退栈(只写族名,不内嵌受限字体) - 形状/高程/动效:Expressive 形状刻度 + shape morph 数据、elevation 级别与 surface container 映射、时长/缓动/弹簧(纯函数可采样) - 图标:24 个从 Slint 版迁移的 SVG path + 4 个新增,IconKind 冻结只可末尾追加; 写明 Slint 的 stroke-width 是绝对像素、SVG 是 viewBox 用户单位的语义差异, 换算封装成 Icon::stroke_width - 组件档位表:20 个组件的形状/高程/字体/动效一次取全 TODO(M63):elevation shadow 的 blur/offset/alpha 与四组弹簧参数是占位值, 接口先定、数值待玻璃视觉规格表(tower 已通告边界)。
This commit is contained in:
1 parent
fef0922073
commit
65ca0ed29a
25 files changed
+4622
-1
No files matched your search
@@ -0,0 +1,184 @@
|
||||
//! 图标几何数据:24 网格、1.5px 视觉描边的 SVG path 字符串。
|
||||
//!
|
||||
//! 源流:`DevDocs/ops/slint-reference/crates/app/ui/components/icons.slint`(M54 重画过一轮的
|
||||
//! Fluent 2 线性几何)。命令集沿用 `M/L/H/V/A/C/Q/Z`,与 SVG 路径语法一致,可直接交给
|
||||
//! 任何 SVG path 解析器(iced 侧是 `iced::widget::canvas` 的 `Path`/`lyon`)。
|
||||
//!
|
||||
//! 这里只有数据:不许在数据里写颜色、尺寸、dpi——那些由渲染层决定。
|
||||
|
||||
use super::{Icon, IconKind, IconPaint};
|
||||
|
||||
/// 全部图标几何,**下标必须等于 `IconKind as usize`**(由测试钉死)。
|
||||
pub(super) const GLYPHS: [Icon; 28] = [
|
||||
// 实例:2×2 圆角方块(原 Slint 版是 4 个 Rectangle 描边,这里折成一条 path)
|
||||
Icon::data(
|
||||
IconKind::Instances,
|
||||
"M 5 3 H 8 A 2 2 0 0 1 10 5 V 8 A 2 2 0 0 1 8 10 H 5 A 2 2 0 0 1 3 8 V 5 A 2 2 0 0 1 5 3 Z \
|
||||
M 16 3 H 19 A 2 2 0 0 1 21 5 V 8 A 2 2 0 0 1 19 10 H 16 A 2 2 0 0 1 14 8 V 5 A 2 2 0 0 1 16 3 Z \
|
||||
M 5 14 H 8 A 2 2 0 0 1 10 16 V 19 A 2 2 0 0 1 8 21 H 5 A 2 2 0 0 1 3 19 V 16 A 2 2 0 0 1 5 14 Z \
|
||||
M 16 14 H 19 A 2 2 0 0 1 21 16 V 19 A 2 2 0 0 1 19 21 H 16 A 2 2 0 0 1 14 19 V 16 A 2 2 0 0 1 16 14 Z",
|
||||
IconPaint::Stroke,
|
||||
),
|
||||
// 下载:箭头入托盘
|
||||
Icon::data(
|
||||
IconKind::Download,
|
||||
"M 12 3.5 V 15 M 7 10.5 L 12 15.5 L 17 10.5 M 4.5 20.5 H 19.5",
|
||||
IconPaint::Stroke,
|
||||
),
|
||||
// 日志:卷角文档 + 两行正文(不是三横——三横读作「菜单」)
|
||||
Icon::data(
|
||||
IconKind::Logs,
|
||||
"M 6 3.5 H 14 L 18.5 8 V 20.5 H 6 Z M 14 3.5 V 8 H 18.5 M 9 12.5 H 15.5 M 9 16.5 H 15.5",
|
||||
IconPaint::Stroke,
|
||||
),
|
||||
// 设置:八齿齿轮 + 中心孔
|
||||
Icon::data(
|
||||
IconKind::Settings,
|
||||
"M 18.89 14.11 L 21.51 13.34 L 21.51 10.66 L 18.89 9.89 A 7.2 7.2 0 0 1 18.36 8.62 \
|
||||
L 19.67 6.22 L 17.78 4.33 L 15.38 5.64 A 7.2 7.2 0 0 1 14.11 5.11 L 13.34 2.49 L 10.66 2.49 \
|
||||
L 9.89 5.11 A 7.2 7.2 0 0 1 8.62 5.64 L 6.22 4.33 L 4.33 6.22 L 5.64 8.62 A 7.2 7.2 0 0 1 5.11 9.89 \
|
||||
L 2.49 10.66 L 2.49 13.34 L 5.11 14.11 A 7.2 7.2 0 0 1 5.64 15.38 L 4.33 17.78 L 6.22 19.67 \
|
||||
L 8.62 18.36 A 7.2 7.2 0 0 1 9.89 18.89 L 10.66 21.51 L 13.34 21.51 L 14.11 18.89 A 7.2 7.2 0 0 1 15.38 18.36 \
|
||||
L 17.78 19.67 L 19.67 17.78 L 18.36 15.38 A 7.2 7.2 0 0 1 18.89 14.11 Z \
|
||||
M 8.8 12 A 3.2 3.2 0 1 0 15.2 12 A 3.2 3.2 0 1 0 8.8 12 Z",
|
||||
IconPaint::Stroke,
|
||||
),
|
||||
// AI:四角星(凹边四角星,不是直线菱形)
|
||||
Icon::data(
|
||||
IconKind::Ai,
|
||||
"M 12 3 Q 13.6 10.4 21 12 Q 13.6 13.6 12 21 Q 10.4 13.6 3 12 Q 10.4 10.4 12 3 Z",
|
||||
IconPaint::Stroke,
|
||||
),
|
||||
// 播放 / 停止:实心(与文字并排时不至于太轻)
|
||||
Icon::data(
|
||||
IconKind::Play,
|
||||
"M 7.5 4.5 L 19 12 L 7.5 19.5 Z",
|
||||
IconPaint::Fill,
|
||||
),
|
||||
Icon::data(
|
||||
IconKind::Stop,
|
||||
"M 8 7 H 16 A 1 1 0 0 1 17 8 V 16 A 1 1 0 0 1 16 17 H 8 A 1 1 0 0 1 7 16 V 8 A 1 1 0 0 1 8 7 Z",
|
||||
IconPaint::Fill,
|
||||
),
|
||||
// 搜索:放大镜
|
||||
Icon::data(
|
||||
IconKind::Search,
|
||||
"M 10.5 4 A 6.5 6.5 0 1 0 10.5 17 A 6.5 6.5 0 1 0 10.5 4 Z M 15.4 15.4 L 20.5 20.5",
|
||||
IconPaint::Stroke,
|
||||
),
|
||||
Icon::data(
|
||||
IconKind::Plus,
|
||||
"M 12 5 V 19 M 5 12 H 19",
|
||||
IconPaint::Stroke,
|
||||
),
|
||||
Icon::data(
|
||||
IconKind::Close,
|
||||
"M 6.5 6.5 L 17.5 17.5 M 17.5 6.5 L 6.5 17.5",
|
||||
IconPaint::Stroke,
|
||||
),
|
||||
Icon::data(IconKind::Minimize, "M 6 12 H 18", IconPaint::Stroke),
|
||||
Icon::data(
|
||||
IconKind::Maximize,
|
||||
"M 7.5 5.5 H 16.5 A 2 2 0 0 1 18.5 7.5 V 16.5 A 2 2 0 0 1 16.5 18.5 H 7.5 \
|
||||
A 2 2 0 0 1 5.5 16.5 V 7.5 A 2 2 0 0 1 7.5 5.5 Z",
|
||||
IconPaint::Stroke,
|
||||
),
|
||||
Icon::data(
|
||||
IconKind::ChevronLeft,
|
||||
"M 14.5 5.5 L 8 12 L 14.5 18.5",
|
||||
IconPaint::Stroke,
|
||||
),
|
||||
Icon::data(
|
||||
IconKind::ChevronRight,
|
||||
"M 9.5 5.5 L 16 12 L 9.5 18.5",
|
||||
IconPaint::Stroke,
|
||||
),
|
||||
Icon::data(
|
||||
IconKind::ChevronDown,
|
||||
"M 5.5 9.5 L 12 16 L 18.5 9.5",
|
||||
IconPaint::Stroke,
|
||||
),
|
||||
// 展开为独立页:双对角外箭头
|
||||
Icon::data(
|
||||
IconKind::Expand,
|
||||
"M 14 4 H 20 V 10 M 20 4 L 13.5 10.5 M 10 20 H 4 V 14 M 4 20 L 10.5 13.5",
|
||||
IconPaint::Stroke,
|
||||
),
|
||||
// 发送:纸飞机
|
||||
Icon::data(
|
||||
IconKind::Send,
|
||||
"M 21 3 L 3 10.5 L 10.2 13.6 L 13.4 20.5 Z M 10.2 13.6 L 21 3",
|
||||
IconPaint::Stroke,
|
||||
),
|
||||
Icon::data(
|
||||
IconKind::Check,
|
||||
"M 5 12.5 L 9.8 17.3 L 19 6.5",
|
||||
IconPaint::Stroke,
|
||||
),
|
||||
Icon::data(
|
||||
IconKind::Warn,
|
||||
"M 12 4.5 L 20.5 19.5 H 3.5 Z M 12 9.5 V 14 M 12 16.6 V 17",
|
||||
IconPaint::Stroke,
|
||||
),
|
||||
Icon::data(
|
||||
IconKind::Error,
|
||||
"M 12 4 A 8 8 0 1 0 12 20 A 8 8 0 1 0 12 4 Z M 12 7.5 V 13 M 12 16.6 V 17",
|
||||
IconPaint::Stroke,
|
||||
),
|
||||
// 更多:三圆点(实心,原 Slint 版是三个 filled Rectangle)
|
||||
Icon::data(
|
||||
IconKind::More,
|
||||
"M 7.5 12 A 1.5 1.5 0 1 0 4.5 12 A 1.5 1.5 0 1 0 7.5 12 Z \
|
||||
M 13.5 12 A 1.5 1.5 0 1 0 10.5 12 A 1.5 1.5 0 1 0 13.5 12 Z \
|
||||
M 19.5 12 A 1.5 1.5 0 1 0 16.5 12 A 1.5 1.5 0 1 0 19.5 12 Z",
|
||||
IconPaint::Fill,
|
||||
),
|
||||
// 侧栏:面板框 + 左侧分栏线
|
||||
Icon::data(
|
||||
IconKind::Sidebar,
|
||||
"M 6 5 H 18 A 2 2 0 0 1 20 7 V 17 A 2 2 0 0 1 18 19 H 6 A 2 2 0 0 1 4 17 V 7 \
|
||||
A 2 2 0 0 1 6 5 Z M 9.5 5.5 V 18.5",
|
||||
IconPaint::Stroke,
|
||||
),
|
||||
// 还原(已最大化时的标题栏按钮):前后两层方框
|
||||
Icon::data(
|
||||
IconKind::Restore,
|
||||
"M 9 4.5 H 19.5 V 15 M 15 8.5 H 4.5 V 19.5 H 15 Z",
|
||||
IconPaint::Stroke,
|
||||
),
|
||||
// 账户占位:头像剪影
|
||||
Icon::data(
|
||||
IconKind::User,
|
||||
"M 12 5.3 A 3.7 3.7 0 1 0 12 12.7 A 3.7 3.7 0 1 0 12 5.3 Z \
|
||||
M 4.5 20.5 C 4.5 16.9 8 15 12 15 C 16 15 19.5 16.9 19.5 20.5",
|
||||
IconPaint::Stroke,
|
||||
),
|
||||
// ── M61 新增(追加在末尾,不动既有次序;iced 页面需要而 Slint 版没有的)──
|
||||
// 刷新:顺时针单圈 + 右上角箭头
|
||||
Icon::data(
|
||||
IconKind::Refresh,
|
||||
"M 20 12 A 8 8 0 1 1 12 4 C 14.2 4 16.3 4.9 17.8 6.4 L 20 8.5 \
|
||||
M 20 4.5 V 8.5 H 16",
|
||||
IconPaint::Stroke,
|
||||
),
|
||||
// 筛选:漏斗
|
||||
Icon::data(
|
||||
IconKind::Filter,
|
||||
"M 4.5 5.5 H 19.5 L 14 12.5 V 19 L 10 16.5 V 12.5 Z",
|
||||
IconPaint::Stroke,
|
||||
),
|
||||
// 目录:文件夹(实例目录 / 日志目录入口)
|
||||
Icon::data(
|
||||
IconKind::Folder,
|
||||
"M 4 7.5 A 2 2 0 0 1 6 5.5 H 9.5 L 11.5 7.5 H 18 A 2 2 0 0 1 20 9.5 V 17 \
|
||||
A 2 2 0 0 1 18 19 H 6 A 2 2 0 0 1 4 17 Z",
|
||||
IconPaint::Stroke,
|
||||
),
|
||||
// 删除:垃圾桶
|
||||
Icon::data(
|
||||
IconKind::Trash,
|
||||
"M 4.5 7 H 19.5 M 9.5 7 V 4.5 H 14.5 V 7 M 6.8 7 L 7.8 19.5 H 16.2 L 17.2 7 \
|
||||
M 10.5 10.5 V 16 M 13.5 10.5 V 16",
|
||||
IconPaint::Stroke,
|
||||
),
|
||||
];
|
||||
@@ -0,0 +1,208 @@
|
||||
//! `IconKind` 枚举:**API 已冻结,只允许在末尾追加**(序号即持久化 id,插到中间会静默换图)。
|
||||
|
||||
use super::glyphs::GLYPHS;
|
||||
use super::Icon;
|
||||
|
||||
/// 图标种类。
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, PartialOrd, Ord)]
|
||||
#[non_exhaustive]
|
||||
pub enum IconKind {
|
||||
/// 实例(2×2 网格)。
|
||||
Instances,
|
||||
/// 下载。
|
||||
Download,
|
||||
/// 日志(文档 + 正文)。
|
||||
Logs,
|
||||
/// 设置(齿轮)。
|
||||
Settings,
|
||||
/// AI(四角星)。
|
||||
Ai,
|
||||
/// 播放(实心三角)。
|
||||
Play,
|
||||
/// 停止(实心圆角方)。
|
||||
Stop,
|
||||
/// 搜索(放大镜)。
|
||||
Search,
|
||||
/// 加号。
|
||||
Plus,
|
||||
/// 关闭(叉)。
|
||||
Close,
|
||||
/// 最小化(横线)。
|
||||
Minimize,
|
||||
/// 最大化(方框)。
|
||||
Maximize,
|
||||
/// 左尖角。
|
||||
ChevronLeft,
|
||||
/// 右尖角。
|
||||
ChevronRight,
|
||||
/// 下尖角。
|
||||
ChevronDown,
|
||||
/// 展开为独立页(双对角箭头)。
|
||||
Expand,
|
||||
/// 发送(纸飞机)。
|
||||
Send,
|
||||
/// 勾选。
|
||||
Check,
|
||||
/// 警告(三角感叹)。
|
||||
Warn,
|
||||
/// 错误(圆形感叹)。
|
||||
Error,
|
||||
/// 更多(三点,实心)。
|
||||
More,
|
||||
/// 侧栏开关。
|
||||
Sidebar,
|
||||
/// 还原窗口(双层方框)。
|
||||
Restore,
|
||||
/// 账户占位(头像剪影)。
|
||||
User,
|
||||
/// 刷新(M61 新增)。
|
||||
Refresh,
|
||||
/// 筛选(M61 新增)。
|
||||
Filter,
|
||||
/// 目录(M61 新增)。
|
||||
Folder,
|
||||
/// 删除(M61 新增)。
|
||||
Trash,
|
||||
}
|
||||
|
||||
impl IconKind {
|
||||
/// 全部图标,按枚举声明顺序。
|
||||
pub const ALL: [IconKind; 28] = [
|
||||
IconKind::Instances,
|
||||
IconKind::Download,
|
||||
IconKind::Logs,
|
||||
IconKind::Settings,
|
||||
IconKind::Ai,
|
||||
IconKind::Play,
|
||||
IconKind::Stop,
|
||||
IconKind::Search,
|
||||
IconKind::Plus,
|
||||
IconKind::Close,
|
||||
IconKind::Minimize,
|
||||
IconKind::Maximize,
|
||||
IconKind::ChevronLeft,
|
||||
IconKind::ChevronRight,
|
||||
IconKind::ChevronDown,
|
||||
IconKind::Expand,
|
||||
IconKind::Send,
|
||||
IconKind::Check,
|
||||
IconKind::Warn,
|
||||
IconKind::Error,
|
||||
IconKind::More,
|
||||
IconKind::Sidebar,
|
||||
IconKind::Restore,
|
||||
IconKind::User,
|
||||
IconKind::Refresh,
|
||||
IconKind::Filter,
|
||||
IconKind::Folder,
|
||||
IconKind::Trash,
|
||||
];
|
||||
|
||||
/// 取几何数据。
|
||||
pub fn icon(self) -> Icon {
|
||||
GLYPHS[self as usize]
|
||||
}
|
||||
|
||||
/// 稳定字符串名(kebab-case):写配置、日志、测试快照都用它,不要用 `Debug` 输出。
|
||||
pub const fn name(self) -> &'static str {
|
||||
match self {
|
||||
IconKind::Instances => "instances",
|
||||
IconKind::Download => "download",
|
||||
IconKind::Logs => "logs",
|
||||
IconKind::Settings => "settings",
|
||||
IconKind::Ai => "ai",
|
||||
IconKind::Play => "play",
|
||||
IconKind::Stop => "stop",
|
||||
IconKind::Search => "search",
|
||||
IconKind::Plus => "plus",
|
||||
IconKind::Close => "close",
|
||||
IconKind::Minimize => "minimize",
|
||||
IconKind::Maximize => "maximize",
|
||||
IconKind::ChevronLeft => "chevron-left",
|
||||
IconKind::ChevronRight => "chevron-right",
|
||||
IconKind::ChevronDown => "chevron-down",
|
||||
IconKind::Expand => "expand",
|
||||
IconKind::Send => "send",
|
||||
IconKind::Check => "check",
|
||||
IconKind::Warn => "warn",
|
||||
IconKind::Error => "error",
|
||||
IconKind::More => "more",
|
||||
IconKind::Sidebar => "sidebar",
|
||||
IconKind::Restore => "restore",
|
||||
IconKind::User => "user",
|
||||
IconKind::Refresh => "refresh",
|
||||
IconKind::Filter => "filter",
|
||||
IconKind::Folder => "folder",
|
||||
IconKind::Trash => "trash",
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::icons::IconPaint;
|
||||
|
||||
#[test]
|
||||
fn glyph_table_matches_enum_order() {
|
||||
for (i, icon) in GLYPHS.iter().enumerate() {
|
||||
assert_eq!(
|
||||
icon.kind as usize,
|
||||
i,
|
||||
"GLYPHS 第 {i} 项是 {},与 IconKind 顺序不一致",
|
||||
icon.kind.name()
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn all_kinds_resolve() {
|
||||
assert_eq!(IconKind::ALL.len(), GLYPHS.len());
|
||||
for kind in IconKind::ALL {
|
||||
let icon = kind.icon();
|
||||
assert_eq!(icon.kind, kind);
|
||||
assert!(!icon.path.is_empty());
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn names_are_unique_and_kebab() {
|
||||
let mut seen = std::collections::BTreeSet::new();
|
||||
for kind in IconKind::ALL {
|
||||
let name = kind.name();
|
||||
assert!(seen.insert(name), "图标名重复:{name}");
|
||||
assert!(name
|
||||
.chars()
|
||||
.all(|c| c.is_ascii_lowercase() || c.is_ascii_digit() || c == '-'));
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn migrated_kinds_keep_their_positions() {
|
||||
// 前 24 个是 Slint 版迁移来的(次序必须原样),后 4 个是 M61 追加的。
|
||||
assert_eq!(IconKind::ALL[0], IconKind::Instances);
|
||||
assert_eq!(IconKind::ALL[4], IconKind::Ai);
|
||||
assert_eq!(IconKind::ALL[20], IconKind::More);
|
||||
assert_eq!(IconKind::ALL[23], IconKind::User);
|
||||
assert_eq!(IconKind::ALL[24], IconKind::Refresh);
|
||||
assert_eq!(IconKind::ALL[27], IconKind::Trash);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn only_solid_icons_are_filled() {
|
||||
for kind in IconKind::ALL {
|
||||
let icon = kind.icon();
|
||||
let expect_fill = matches!(kind, IconKind::Play | IconKind::Stop | IconKind::More);
|
||||
assert_eq!(
|
||||
icon.paint,
|
||||
if expect_fill {
|
||||
IconPaint::Fill
|
||||
} else {
|
||||
IconPaint::Stroke
|
||||
},
|
||||
"{} 的 fill/stroke 判定不对",
|
||||
kind.name()
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,250 @@
|
||||
//! 图标令牌:`IconKind`(一次冻结,只允许在末尾追加)+ 24 网格几何数据。
|
||||
//!
|
||||
//! ```no_run
|
||||
//! use unlucky_design::icons::{IconKind, IconSize};
|
||||
//!
|
||||
//! let icon = IconKind::Settings.icon();
|
||||
//! let w = icon.stroke_width(IconSize::Nav.px());
|
||||
//! assert!((w - 1.8).abs() < 1e-6);
|
||||
//! ```
|
||||
//!
|
||||
//! ## 几何口径(三线统一,M54 定稿)
|
||||
//!
|
||||
//! - **网格 24×24**([`VIEWBOX`]),内容留 2~3 单位边距;
|
||||
//! - **视觉描边 1.5px**([`VISUAL_STROKE_PX`]):不管渲染多大,线宽看起来都是 1.5 逻辑像素;
|
||||
//! - **圆头端点 + 圆角连接**(`stroke-linecap: round` / `stroke-linejoin: round`),
|
||||
//! 适配层设这两个属性,数据里不表达;
|
||||
//! - **实心图形才用 fill**(播放/停止/三点),其余一律描边,保证与文字并排时重量一致;
|
||||
//! - 不用字体字符当图标(`+` `→` 一律禁止)。
|
||||
//!
|
||||
//! ## ⚠️ 描边语义差异:Slint 与 SVG **不是一回事**
|
||||
//!
|
||||
//! - Slint 的 `Path.stroke-width` 是**绝对像素**:只乘 DPI 缩放,**不乘 viewBox**。
|
||||
//! 所以老代码写 `stroke-width: 1.5px` 意味着「渲染出来就是 1.5px」,viewBox 缩放对它无影响
|
||||
//! (实证:femtovg 后端 `itemrenderer.rs:343`)。
|
||||
//! - SVG / iced canvas 的 `stroke-width` 是**用户单位**(viewBox 单位):渲染时会被
|
||||
//! `render_px / VIEWBOX` 缩放。同样的 `1.5` 在 20px 渲染下只有 1.25px 视觉宽度。
|
||||
//!
|
||||
//! 因此 iced 侧必须**按 SVG 语义重算**:要保持 1.5px 视觉线宽,viewBox 里的 stroke-width 得取
|
||||
//! `1.5 × 24 / render_px`(20px → 1.8、16px → 2.25、24px → 1.5)。[`Icon::stroke_width`] 就是这个
|
||||
//! 换算的纯函数——**不要在适配层手抄 1.5 这个数**,那是 Slint 口径。
|
||||
|
||||
mod glyphs;
|
||||
mod kinds;
|
||||
|
||||
pub use kinds::IconKind;
|
||||
|
||||
/// 图标网格边长(viewBox 宽高)。
|
||||
pub const VIEWBOX: f32 = 24.0;
|
||||
|
||||
/// 视觉描边宽度(逻辑像素,与渲染尺寸无关)。
|
||||
pub const VISUAL_STROKE_PX: f32 = 1.5;
|
||||
|
||||
/// 描边语义换算:给定渲染尺寸与目标视觉线宽,返回 **viewBox 单位**的 stroke-width。
|
||||
///
|
||||
/// `render_px <= 0` 时返回 `visual_px`(退化情形不产生除零/无穷大)。
|
||||
pub fn stroke_width_for(render_px: f32, visual_px: f32) -> f32 {
|
||||
if render_px <= 0.0 {
|
||||
return visual_px;
|
||||
}
|
||||
visual_px * VIEWBOX / render_px
|
||||
}
|
||||
|
||||
/// 反向换算:viewBox 单位的 stroke-width 在实际渲染尺寸下的视觉线宽。
|
||||
pub fn visual_stroke_px(render_px: f32, stroke_width: f32) -> f32 {
|
||||
stroke_width * render_px / VIEWBOX
|
||||
}
|
||||
|
||||
/// 图标的绘制方式。
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
|
||||
pub enum IconPaint {
|
||||
/// 只描边(默认,绝大多数图标)。
|
||||
Stroke,
|
||||
/// 填充(播放/停止/三点这类实心图形)。
|
||||
Fill,
|
||||
}
|
||||
|
||||
/// 图标的标准渲染尺寸(页面只从这几档里取,不许自定义零散值)。
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
|
||||
pub enum IconSize {
|
||||
/// 12px:行内小图标(徽标内)。
|
||||
Inline,
|
||||
/// 16px:按钮/标题栏按钮。
|
||||
Button,
|
||||
/// 20px:导航项、列表行。
|
||||
Nav,
|
||||
/// 24px:卡片头、工具栏。
|
||||
Toolbar,
|
||||
/// 32px:空态图标块。
|
||||
Empty,
|
||||
}
|
||||
|
||||
impl IconSize {
|
||||
/// 像素值。
|
||||
pub const fn px(self) -> f32 {
|
||||
match self {
|
||||
IconSize::Inline => 12.0,
|
||||
IconSize::Button => 16.0,
|
||||
IconSize::Nav => 20.0,
|
||||
IconSize::Toolbar => 24.0,
|
||||
IconSize::Empty => 32.0,
|
||||
}
|
||||
}
|
||||
|
||||
/// 全部档位,由小到大。
|
||||
pub const ALL: [IconSize; 5] = [
|
||||
IconSize::Inline,
|
||||
IconSize::Button,
|
||||
IconSize::Nav,
|
||||
IconSize::Toolbar,
|
||||
IconSize::Empty,
|
||||
];
|
||||
}
|
||||
|
||||
/// 一个图标的几何数据。
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub struct Icon {
|
||||
/// 图标种类。
|
||||
pub kind: IconKind,
|
||||
/// SVG path 数据(viewBox `0 0 24 24`,可能含多个子路径)。
|
||||
pub path: &'static str,
|
||||
/// 描边还是填充。
|
||||
pub paint: IconPaint,
|
||||
}
|
||||
|
||||
impl Icon {
|
||||
/// 表项构造器(只在 [`glyphs`] 的数据表里用)。
|
||||
pub(super) const fn data(kind: IconKind, path: &'static str, paint: IconPaint) -> Self {
|
||||
Self { kind, path, paint }
|
||||
}
|
||||
|
||||
/// 该图标在 `render_px` 下的 **viewBox 单位** stroke-width([`IconPaint::Fill`] 返回 0)。
|
||||
pub fn stroke_width(self, render_px: f32) -> f32 {
|
||||
match self.paint {
|
||||
IconPaint::Fill => 0.0,
|
||||
IconPaint::Stroke => stroke_width_for(render_px, VISUAL_STROKE_PX),
|
||||
}
|
||||
}
|
||||
|
||||
/// 是否实心填充。
|
||||
pub fn is_filled(self) -> bool {
|
||||
self.paint == IconPaint::Fill
|
||||
}
|
||||
|
||||
/// 路径里是否含曲线命令(适配层可选:纯直线路径走不走曲线细分)。
|
||||
pub fn has_curves(self) -> bool {
|
||||
self.path
|
||||
.bytes()
|
||||
.any(|b| matches!(b, b'C' | b'Q' | b'A' | b'c' | b'q' | b'a'))
|
||||
}
|
||||
|
||||
/// 子路径个数(`M` 命令计数):适配层可以据此一次性分配 `lyon` 的缓冲区。
|
||||
pub fn subpath_count(self) -> usize {
|
||||
self.path.matches('M').count() + self.path.matches('m').count()
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn paths_use_only_svg_commands() {
|
||||
const ALLOWED: &str = "MmLlHhVvAaCcQqZz0123456789 .,-";
|
||||
for kind in IconKind::ALL {
|
||||
let path = kind.icon().path;
|
||||
assert!(path.starts_with('M'), "{} 的路径必须以 M 开头", kind.name());
|
||||
for byte in path.bytes() {
|
||||
assert!(
|
||||
byte.is_ascii(),
|
||||
"{} 的路径含非 ASCII(不许用字体字符/中文)",
|
||||
kind.name()
|
||||
);
|
||||
}
|
||||
for ch in path.chars() {
|
||||
assert!(
|
||||
ALLOWED.contains(ch),
|
||||
"{} 的路径含非法字符 {ch:?}",
|
||||
kind.name()
|
||||
);
|
||||
}
|
||||
// 不许出现科学计数法(能解析但可读性差、易手误)。
|
||||
assert!(
|
||||
!path.contains('e') && !path.contains('E'),
|
||||
"{} 用了科学计数法",
|
||||
kind.name()
|
||||
);
|
||||
assert!(
|
||||
path.matches(' ').count() >= 2,
|
||||
"{} 的坐标分隔异常",
|
||||
kind.name()
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn coordinates_stay_inside_the_grid() {
|
||||
for kind in IconKind::ALL {
|
||||
for token in kind.icon().path.split([' ', ',']) {
|
||||
if let Ok(value) = token.parse::<f32>() {
|
||||
assert!(
|
||||
(-1.0..=VIEWBOX + 1.0).contains(&value),
|
||||
"{} 出现 {value}:超出 24 网格(弧半径/控制点也受这条约束)",
|
||||
kind.name()
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn subpaths_are_counted() {
|
||||
assert_eq!(IconKind::Plus.icon().subpath_count(), 2);
|
||||
assert_eq!(IconKind::Instances.icon().subpath_count(), 4);
|
||||
assert_eq!(IconKind::Play.icon().subpath_count(), 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn filled_icons_have_no_stroke() {
|
||||
for kind in IconKind::ALL {
|
||||
let icon = kind.icon();
|
||||
if icon.is_filled() {
|
||||
assert_eq!(icon.stroke_width(20.0), 0.0, "{}", kind.name());
|
||||
} else {
|
||||
assert!(icon.stroke_width(20.0) > 1.0, "{}", kind.name());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn stroke_width_follows_svg_semantics() {
|
||||
// SVG 语义:viewBox 单位 × (render/24) = 视觉像素。
|
||||
assert!((stroke_width_for(20.0, VISUAL_STROKE_PX) - 1.8).abs() < 1.0e-6);
|
||||
assert!((stroke_width_for(16.0, VISUAL_STROKE_PX) - 2.25).abs() < 1.0e-6);
|
||||
assert!((stroke_width_for(24.0, VISUAL_STROKE_PX) - 1.5).abs() < 1.0e-6);
|
||||
assert!((stroke_width_for(48.0, VISUAL_STROKE_PX) - 0.75).abs() < 1.0e-6);
|
||||
// 退化输入不炸、不产生无穷大(Slint 口径的 1.5 绝对值这里不能照抄)。
|
||||
assert_eq!(stroke_width_for(0.0, VISUAL_STROKE_PX), VISUAL_STROKE_PX);
|
||||
assert_eq!(stroke_width_for(-4.0, VISUAL_STROKE_PX), VISUAL_STROKE_PX);
|
||||
// 往返一致。
|
||||
for size in IconSize::ALL {
|
||||
let w = stroke_width_for(size.px(), VISUAL_STROKE_PX);
|
||||
assert!((visual_stroke_px(size.px(), w) - VISUAL_STROKE_PX).abs() < 1.0e-6);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn icon_sizes_are_ordered() {
|
||||
let px: Vec<f32> = IconSize::ALL.iter().map(|s| s.px()).collect();
|
||||
for pair in px.windows(2) {
|
||||
assert!(pair[0] < pair[1]);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn curve_flags_are_reported() {
|
||||
assert!(IconKind::Settings.icon().has_curves());
|
||||
assert!(!IconKind::Plus.icon().has_curves());
|
||||
assert!(!IconKind::ChevronDown.icon().has_curves());
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user