Files
Unlucky-Launcher/crates/design/src/icons/mod.rs
T
AskaEth 83e19e58f0 fix(design): M61 r1 收口 —— 官方 emphasized/弹簧/高程 + 冻结测试
reviewer r1 判 p2-2items(fix-then-merge),本轮按条目收口:

P2-1 冻结测试补全(变异实测证明原来可静默漂移):
- tests/type_scale_pins.rs:baseline 15 档 (size,line_height,weight,tracking,family)
  与 Expressive 强调 15 档逐值对拍,期望值独立于实现重写
- tests/token_pins.rs:49 条 tone(light+dark 全表)、7 条 easing 曲线参数、
  16 档时长、9 组弹簧、28 个 IconKind 下标、形状阶梯
- 自证:TitleMedium 16→17 与 EmphasizedDecelerate 换曲线两次变异均 FAILED

P2-2 emphasized 口径事实错误(用户点名):
- 取回 A 级官方值(androidx TypeScaleTokens v0_103 的 *Emphasized* 四元组):
  label-*/title-medium/title-small 强调是 700(此前给 600)、body-large 0.5→0.15、
  title-medium 0.2→0.15、body-medium 0.2→0.25、display-large -0.2→0
- TypeRole::emphasized() 改为官方表(typography/emphasized.rs);
  原「只加一档字重」规则改名 emphasized_simplified() 并标注非官方
- 顺带按官方订正 baseline tracking(display-large -0.2、body-medium 0.2、
  title-medium 0.2)与 TitleLarge 的 Brand 族归属
- crate 文档新增「baseline MD3 vs M3 Expressive 差异清单」+ 未支持能力 gap

弹簧改为两套 token 结构(用户追加令):
- motion::spring(scheme × category × speed) 二维表 12 格:
  standard spatial ζ=0.9 (k 1400/700/300)、expressive spatial ζ=0.6~0.8 (k 800/380/200)、
  effects 两套同值 ζ=1.0 (k 3800/1600/800),均取 A 级 MotionTokens 原值
- Spring 改存阻尼比(与 Compose 令牌同口径),9 个具名常量

中文字重覆盖(用户追加令):
- FontFace 带 WeightCoverage(可变区间/静态表),Platform::resolve_weight 给
  Native/Nearest/Synthetic 三档结论;雅黑无 500、苹方无 700 两处 gap 显式登记

component_map 高程按官方回调(P2 并入 finding):
- FilledCard L2→L0、OutlinedCard L1→L0、Menu L4→L2、Dialog L5→L3(Menu 圆角同时
  按 MenuTokens 改 4dp);新增官方值 pin 测试 + R4「全屏都在飘」守门断言

P3 四条:
- 同族容器撞色:彩度按共享基准等比压缩(Palettes.reference_chroma),
  primary/secondary-container 不再同值,且强调色覆盖时表面逐像素不变
- femtovg 引用改函数名(行号已漂);删掉 contrast_aa 的死代码后门
- 骨架数值跟 M63:nav 56→44、titlebar 40→48,control 40/touch 48 换算依据入文档
- 图标折算口径订正为「居中描边,视觉外扩 <1px」
2026-11-01 15:58:19 +08:00

252 lines
8.9 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.
//! 图标令牌:`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 后端 `PathItemRenderer::render` 里的
//! `paint.set_line_width(stroke_width * scale_factor)`——只有 DPI 缩放因子,没有 viewBox 项)。
//! - 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());
}
}