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」
252 lines
8.9 KiB
Rust
252 lines
8.9 KiB
Rust
//! 图标令牌:`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());
|
||
}
|
||
}
|