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:
AskaEth committed 2026-11-01 15:34:04 +08:00
1 parent fef0922073
commit 65ca0ed29a
25 files changed
+4622 -1

No files matched your search

Generated
+4
View File
@@ -6070,6 +6070,10 @@ dependencies = [
"zip",
]
[[package]]
name = "unlucky-design"
version = "0.1.0"
[[package]]
name = "unsafe-libyaml"
version = "0.2.11"
+7 -1
View File
@@ -1,6 +1,12 @@
[workspace]
resolver = "2"
members = ["crates/core", "crates/agent", "crates/app", "crates/cli"]
members = [
"crates/core",
"crates/agent",
"crates/app",
"crates/cli",
"crates/design",
]
[workspace.package]
version = "0.1.0"
+19
View File
@@ -0,0 +1,19 @@
[package]
name = "unlucky-design"
description = "Unlucky-Launcher 设计令牌:M3 Expressive 色彩/排版/形状/高程/动效/图标,纯数据 + 纯函数,零 UI 依赖"
version.workspace = true
edition.workspace = true
license.workspace = true
authors.workspace = true
repository.workspace = true
rust-version.workspace = true
publish.workspace = true
# 零 UI 依赖是硬约束(D3 纪律 + M61):本 crate 是渲染无关的**数据与纯函数**,
# 不得出现 iced / slint / winit / wgpu / gtk / egui / tauri 等 UI 或渲染框架,
# 也不引入任何第三方 crate(连 serde 都不加——序列化由消费方适配层自己做)。
# 证据命令:cargo tree -p unlucky-design --edges normal
[dependencies]
[lints]
workspace = true
+253
View File
@@ -0,0 +1,253 @@
//! # 色彩令牌(M3 color roles)
//!
//! 49 个 M3 语义色角色 × light/dark 两套,全部由**一个种子色**派生([`Palettes`]),
//! 再按官方 tone 映射表解析成具体色值([`Scheme`])。
//!
//! ```no_run
//! use unlucky_design::color::{Color, Role, Scheme, SchemeFlavor};
//!
//! let light = Scheme::baseline(SchemeFlavor::Light);
//! let surface = light.get(Role::Surface);
//! let on_surface = light.get(Role::OnSurface);
//! assert!(on_surface.contrast_ratio(surface) > 4.5);
//!
//! // 用户自定义强调色:表面中性色不动,只换强调族。
//! let custom = Scheme::from_accent(SchemeFlavor::Light, Color::from_hex("#006A5C").unwrap());
//! assert_eq!(custom.get(Role::Surface), surface);
//! ```
//!
//! ## 口径声明:这是 M3 **近似**,不是标准 HCT 动态取色
//!
//! 标准 M3 的调色板建在 **HCT**(CAM16 色相/彩度 + CIELAB `L*`)上。本 crate 不实现 CAM16,
//! 用的是 **OKLCh**(色相/彩度)+ **CIELAB `L*`**(tone 轴)。具体差异:
//!
//! | 轴 | 标准 M3 | 本实现 | 影响 |
//! |---|---|---|---|
//! | tone | CIELAB `L*` | CIELAB `L*`(二分反解 OKLab 明度,误差 ≤1 tone) | **无偏差**:tone 定义严格对齐 |
//! | 色相 | CAM16 hue | OKLCh hue(种子色相原样保留) | 绝对色相角不同,但「同族色相恒定」这个性质成立;派生色(tertiary = +60°)偏移量与 M3 不完全相同 |
//! | 彩度 | CAM16 chroma(如 primary 36 / secondary 16 / neutral 6) | OKLCh chroma(标定值见 [`palettes`]) | 数值量纲不同,语义等价;饱和度观感与官方色板会有可见差别(不是抄错,是量纲换算不通用) |
//! | gamut | CAM16 gamut mapping(按色相压缩彩度) | 按 tone 二分压 OKLCh 彩度进 sRGB | 极端 tone(5/95+)的饱和度可能比官方色板略低 |
//! | 动态色 | Material You 从壁纸取 5 组种子 | 只用**一个**种子 + 固定派生规则 | 没有「多色壁纸取色」能力;本启动器用不到(壁纸取色是安卓端特性) |
//! | 对比度模式 | Dynamic Contrast 三档(standard/medium/high) | 只有 standard | 不提供高对比方案;WCAG AA 在 standard 下已满足(见 [`super::color::contrast`] 与测试) |
//!
//! 结论:**不许对外宣称「标准 M3 动态取色」**。要对比官方色板时按上表解释差异,
//! 不要试图逐值对齐 material-color-utilities 的输出(那需要 CAM16 + HCT,是另一个量级的实现)。
//!
//! ## 对比度纪律(WCAG AA)
//!
//! - 所有 `on-*` 与其承载色(`*-container`、`primary`、`surface`)都满足 **≥ 4.5:1**;
//! 这一条由 `tests/contrast_aa.rs` 全表验证,且因为 tone 轴固定,**换任何种子色都成立**
//! (WCAG 对比度只取决于相对亮度,而 tone 直接钉住 L*)。
//! - 例外(显式登记,非缺陷):
//! - [`Role::Outline`] 对 [`Role::Surface`]:M3 定义的**非文字**元素(输入框描边),
//! 目标 3:1,实测 light 4.29:1 / dark 6.5:1;
//! - [`Role::OutlineVariant`] 对 [`Role::Surface`]:纯装饰分隔线,M3 不要求对比度下限;
//! - [`Role::SurfaceVariant`]:它是**容器**色(不是前景),不参与对比度门禁。
//! - 禁用态按 M3 的 alpha 叠加([`state::DISABLED_CONTENT_ALPHA`]),刻意低于 AA。
pub mod palettes;
pub mod roles;
pub mod scheme;
pub mod space;
pub mod state;
pub mod tonal;
pub use palettes::{PaletteSlot, Palettes, BASELINE_SEED};
pub use roles::Role;
pub use scheme::{Scheme, SchemeFlavor, SCRIM_ALPHA, SHADOW_ALPHA};
pub use state::StateLayer;
pub use tonal::TonalPalette;
/// sRGB 颜色(8 bit/通道 + alpha),刻意不用第三方颜色类型:本 crate 零依赖。
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
pub struct Color {
/// 红通道。
pub r: u8,
/// 绿通道。
pub g: u8,
/// 蓝通道。
pub b: u8,
/// alpha(0 = 全透明,255 = 不透明)。
pub a: u8,
}
impl Color {
/// 不透明颜色。
pub const fn rgb(r: u8, g: u8, b: u8) -> Self {
Self { r, g, b, a: 255 }
}
/// 带 alpha 的颜色(`a` 为 0..=255)。
pub const fn rgba(r: u8, g: u8, b: u8, a: u8) -> Self {
Self { r, g, b, a }
}
/// 纯黑。
pub const BLACK: Color = Color::rgb(0, 0, 0);
/// 纯白。
pub const WHITE: Color = Color::rgb(255, 255, 255);
/// 从 `#RRGGBB` / `#RGB` / `#AARRGGBB` 解析(不合法返回 `None`)。
///
/// 只给配置读取与测试用——UI 代码里**不许出现色字面量**,一律走 [`Role`]。
pub fn from_hex(text: &str) -> Option<Self> {
let hex = text.trim().trim_start_matches('#');
let parse = |s: &str| u8::from_str_radix(s, 16).ok();
match hex.len() {
3 => {
let d = |i: usize| {
let v = parse(&hex[i..i + 1])?;
Some(v * 17)
};
Some(Self::rgb(d(0)?, d(1)?, d(2)?))
}
6 => Some(Self::rgb(
parse(&hex[0..2])?,
parse(&hex[2..4])?,
parse(&hex[4..6])?,
)),
8 => Some(Self::rgba(
parse(&hex[2..4])?,
parse(&hex[4..6])?,
parse(&hex[6..8])?,
parse(&hex[0..2])?,
)),
_ => None,
}
}
/// 不透明部分转 `#RRGGBB`(alpha < 255 时附 `#AARRGGBB` 形式)。
pub fn to_hex(self) -> String {
if self.a == 255 {
format!("#{:02X}{:02X}{:02X}", self.r, self.g, self.b)
} else {
format!("#{:02X}{:02X}{:02X}{:02X}", self.a, self.r, self.g, self.b)
}
}
/// 改 alpha(`alpha` 0..1,越界夹取)。
pub fn with_alpha(self, alpha: f32) -> Self {
Self {
a: (alpha.clamp(0.0, 1.0) * 255.0).round() as u8,
..self
}
}
/// alpha 归一化到 0..1。
pub fn alpha(self) -> f32 {
f32::from(self.a) / 255.0
}
/// 是否完全不透明。
pub fn is_opaque(self) -> bool {
self.a == 255
}
/// WCAG 相对亮度(0..1,按 sRGB→XYZ 的 Y 行计算)。
pub fn relative_luminance(self) -> f32 {
let lin = |c: u8| space::srgb_to_linear(f32::from(c) / 255.0);
space::luminance([lin(self.r), lin(self.g), lin(self.b)])
}
/// CIELAB `L*`(0..100)——这就是 M3 的 **tone**。
///
/// 调色板按这条轴生成,验收也按这条轴断言;想知道某个色值「是几号 tone」就用它。
pub fn lstar(self) -> f32 {
space::lstar_from_luminance(self.relative_luminance())
}
/// WCAG 对比度(1..21)。`self` 与 `other` 都不透明时才严格;带 alpha 时先算「不计 alpha」的值。
pub fn contrast_ratio(self, other: Self) -> f32 {
let a = self.relative_luminance();
let b = other.relative_luminance();
let (hi, lo) = if a > b { (a, b) } else { (b, a) };
(hi + 0.05) / (lo + 0.05)
}
/// alpha 合成:`self` 叠在 `bg` 之上,返回不透明结果。
///
/// 这是 M3 状态层/禁用态/遮罩的唯一合成口径(非预乘 alpha)。
pub fn over(self, bg: Self) -> Self {
let sa = self.alpha();
if sa >= 1.0 {
return self;
}
let blend = |fg: u8, b: u8| {
let v = f32::from(fg) * sa + f32::from(b) * (1.0 - sa);
v.round().clamp(0.0, 255.0) as u8
};
let a = sa + bg.alpha() * (1.0 - sa);
Self {
r: blend(self.r, bg.r),
g: blend(self.g, bg.g),
b: blend(self.b, bg.b),
a: (a * 255.0).round().clamp(0.0, 255.0) as u8,
}
}
/// 两色线性插值(`t` 0..1),用于动画中间帧(GPU 路径的 shape morph 用得上)。
pub fn lerp(self, other: Self, t: f32) -> Self {
let t = t.clamp(0.0, 1.0);
let mix = |a: u8, b: u8| {
(f32::from(a) + (f32::from(b) - f32::from(a)) * t)
.round()
.clamp(0.0, 255.0) as u8
};
Self {
r: mix(self.r, other.r),
g: mix(self.g, other.g),
b: mix(self.b, other.b),
a: mix(self.a, other.a),
}
}
/// 从线性 RGB(0..1,可越界)构造:先夹取再 sRGB 编码。
fn from_linear_f32(rgb: [f32; 3]) -> Self {
let enc = |c: f32| {
(space::linear_to_srgb(c.clamp(0.0, 1.0)) * 255.0)
.round()
.clamp(0.0, 255.0) as u8
};
Self::rgb(enc(rgb[0]), enc(rgb[1]), enc(rgb[2]))
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn hex_roundtrip() {
let c = Color::from_hex("#6750A4").expect("合法 hex");
assert_eq!(c, Color::rgb(0x67, 0x50, 0xA4));
assert_eq!(c.to_hex(), "#6750A4");
assert_eq!(Color::from_hex("6750a4"), Some(c));
assert_eq!(Color::from_hex("#FFF"), Some(Color::WHITE));
assert_eq!(Color::from_hex("#80FF0000").map(|c| c.a), Some(0x80));
assert_eq!(Color::from_hex("#12345"), None);
assert_eq!(Color::from_hex("zzzzzz"), None);
}
#[test]
fn contrast_extremes() {
assert!((Color::BLACK.contrast_ratio(Color::WHITE) - 21.0).abs() < 0.01);
assert!((Color::WHITE.contrast_ratio(Color::WHITE) - 1.0).abs() < 0.001);
}
#[test]
fn over_composites_correctly() {
let black_50 = Color::BLACK.with_alpha(0.5);
let got = black_50.over(Color::WHITE);
// with_alpha 量化到 128/255 = 0.50196,所以结果落在 127(不是 128)。
assert_eq!((got.r, got.a), (127, 255));
}
#[test]
fn lerp_hits_endpoints() {
let a = Color::rgb(0, 0, 0);
let b = Color::rgb(255, 255, 255);
assert_eq!(a.lerp(b, 0.0), a);
assert_eq!(a.lerp(b, 1.0), b);
assert_eq!(a.lerp(b, 0.5).r, 128);
}
}
+141
View File
@@ -0,0 +1,141 @@
//! 六条 tone 调色板的集合(M3 scheme 的原料)。
//!
//! 角色 → 调色板槽位 → tone 的三段映射在 [`super::scheme`];这里只负责「种子 → 六条调色板」。
use super::tonal::TonalPalette;
use super::Color;
/// M3 baseline 种子色(与 material-color-utilities 的默认基线同值)。
pub const BASELINE_SEED: Color = Color::rgb(0x67, 0x50, 0xA4);
/// 调色板槽位(M3 的六条 tonal palette)。
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum PaletteSlot {
/// 主调色板(种子色相)。
Primary,
/// 次调色板(同色相、低彩度)。
Secondary,
/// 第三调色板(种子色相 +60°,中彩度)。
Tertiary,
/// 中性调色板(近零彩度的表面色)。
Neutral,
/// 中性变体调色板(比中性略彩色,做分隔与次级容器)。
NeutralVariant,
/// 错误调色板(固定红相)。
Error,
}
// 下面几个常数是**我们的标定值**(OKLCh 彩度单位),不是 M3 的 CAM16 chroma:
// CAM16 的 chroma 36/16/24/6/8 量纲与本实现不同,直接照抄会得到过饱和的色板。
/// 次色的彩度上限。
const SECONDARY_CHROMA: f32 = 0.055;
/// 第三色的彩度上限。
const TERTIARY_CHROMA: f32 = 0.085;
/// 中性的彩度上限(几乎是灰)。
const NEUTRAL_CHROMA: f32 = 0.008;
/// 中性变体的彩度上限。
const NEUTRAL_VARIANT_CHROMA: f32 = 0.016;
/// 错误色的色相(OKLCh 红相)。
const ERROR_HUE_DEG: f32 = 25.0;
/// 错误色的彩度上限。
const ERROR_CHROMA: f32 = 0.18;
/// 第三色调色板的色相偏移。
const TERTIARY_HUE_SHIFT_DEG: f32 = 60.0;
/// 一套完整的六条调色板。
#[derive(Debug, Clone, Copy, PartialEq)]
pub struct Palettes {
/// 主要调色板。
pub primary: TonalPalette,
/// 次要调色板。
pub secondary: TonalPalette,
/// 第三调色板。
pub tertiary: TonalPalette,
/// 中性调色板。
pub neutral: TonalPalette,
/// 中性变体调色板。
pub neutral_variant: TonalPalette,
/// 错误调色板。
pub error: TonalPalette,
}
impl Palettes {
/// 出厂基线(种子 `#6750A4`,等价 M3 baseline)。
pub fn baseline() -> Self {
Self::from_seed(BASELINE_SEED)
}
/// 从一个种子色派生全套调色板。
pub fn from_seed(seed: Color) -> Self {
let primary = TonalPalette::from_color(seed);
Self {
primary,
secondary: primary.limited_chroma(SECONDARY_CHROMA),
tertiary: primary
.rotated(TERTIARY_HUE_SHIFT_DEG)
.limited_chroma(TERTIARY_CHROMA),
neutral: primary.with_chroma(NEUTRAL_CHROMA),
neutral_variant: primary.with_chroma(NEUTRAL_VARIANT_CHROMA),
error: TonalPalette::new(ERROR_HUE_DEG, ERROR_CHROMA),
}
}
/// 只换强调色:primary/secondary/tertiary 跟着新强调色走,**表面中性色保持不变**。
///
/// 这是「用户自定义强调色」的正确语义——换强调色不该让整个界面底色变脏;
/// 也因为 tone 轴固定,任何色相都能自动满足 WCAG AA(不需要再算一遍对比度)。
pub fn with_accent(self, accent: Color) -> Self {
let accent_primary = TonalPalette::from_color(accent);
Self {
primary: accent_primary,
secondary: accent_primary.limited_chroma(SECONDARY_CHROMA),
tertiary: accent_primary
.rotated(TERTIARY_HUE_SHIFT_DEG)
.limited_chroma(TERTIARY_CHROMA),
..self
}
}
/// 按槽位取调色板。
pub fn slot(&self, slot: PaletteSlot) -> TonalPalette {
match slot {
PaletteSlot::Primary => self.primary,
PaletteSlot::Secondary => self.secondary,
PaletteSlot::Tertiary => self.tertiary,
PaletteSlot::Neutral => self.neutral,
PaletteSlot::NeutralVariant => self.neutral_variant,
PaletteSlot::Error => self.error,
}
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn baseline_seed_is_the_primary_palette_hue() {
let palettes = Palettes::baseline();
let seed_lch = super::super::space::Oklch::from_color(BASELINE_SEED);
let delta = (palettes.primary.hue_deg() - seed_lch.hue_deg).abs();
assert!(delta < 0.01, "primary 调色板应保持种子色相,偏差 {delta}°");
}
#[test]
fn accent_override_keeps_surfaces() {
let base = Palettes::baseline();
let accent = Color::rgb(0x00, 0x6A, 0x5C);
let swapped = base.with_accent(accent);
assert_eq!(swapped.neutral, base.neutral);
assert_eq!(swapped.neutral_variant, base.neutral_variant);
assert_eq!(swapped.error, base.error);
assert_ne!(swapped.primary.hue_deg(), base.primary.hue_deg());
}
#[test]
fn neutral_variant_is_less_chromatic_than_primary() {
let palettes = Palettes::baseline();
assert!(palettes.neutral.chroma() < palettes.neutral_variant.chroma());
assert!(palettes.neutral_variant.chroma() < palettes.secondary.chroma());
}
}
+276
View File
@@ -0,0 +1,276 @@
//! M3 语义色角色:`Role` 枚举是**唯一的名字清单**(49 个,M3 基础集全覆盖)。
//!
//! 后续 iced 页面不许另起名字(如 `bg-card` / `fg-primary`)——Fluent 时代的旧名到此为止,
//! 要表达「卡片底」就用 [`Role::SurfaceContainerLow`],要表达「次要文字」就用
//! [`Role::OnSurfaceVariant`](对应关系见 `DevDocs/SubAgentsTalk/design-令牌约定.md`)。
/// M3 语义色角色(49 个)。
///
/// 顺序即 [`Role::ALL`] 的顺序,也是 [`super::Scheme`] 内部数组的下标:
/// **只允许在末尾追加**,插到中间会静默改变已存盘的颜色映射。
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)]
pub enum Role {
/// 主色:主操作填充、选中指示。
Primary,
/// 主色之上的前景(文字/图标)。
OnPrimary,
/// 主色容器:次级强调块、选中片。
PrimaryContainer,
/// 主色容器之上的前景。
OnPrimaryContainer,
/// 次色:低频强调、图表。
Secondary,
/// 次色之上的前景。
OnSecondary,
/// 次色容器。
SecondaryContainer,
/// 次色容器之上的前景。
OnSecondaryContainer,
/// 第三色:与主/次形成对比的装饰强调。
Tertiary,
/// 第三色之上的前景。
OnTertiary,
/// 第三色容器。
TertiaryContainer,
/// 第三色容器之上的前景。
OnTertiaryContainer,
/// 错误色:错误文字、危险操作填充。
Error,
/// 错误色之上的前景。
OnError,
/// 错误容器:错误提示底。
ErrorContainer,
/// 错误容器之上的前景。
OnErrorContainer,
/// 表面:窗口/内容底色(M3 最新口径里取代 background)。
Surface,
/// 表面之上的主体文字。
OnSurface,
/// 表面变体:中性分隔/次级容器(**不是**文字色)。
SurfaceVariant,
/// 表面变体之上的次级文字/图标。
OnSurfaceVariant,
/// 背景(M3 已并入 surface,保留以兼容旧值)。
Background,
/// 背景之上的文本(同上,保留兼容)。
OnBackground,
/// 变暗的表面(模态背后的压暗层)。
SurfaceDim,
/// 变亮的表面(深色下的抬高底)。
SurfaceBright,
/// 最低层容器(比 surface 更低)。
SurfaceContainerLowest,
/// 低层容器:卡片。
SurfaceContainerLow,
/// 标准容器:卡片/面板。
SurfaceContainer,
/// 高层容器:浮层。
SurfaceContainerHigh,
/// 最高层容器:对话框/菜单。
SurfaceContainerHighest,
/// 描边:输入框边框等(非文字,3:1 即可)。
Outline,
/// 弱描边:分隔线(纯装饰)。
OutlineVariant,
/// 反相表面:Snackbar / Tooltip 底。
InverseSurface,
/// 反相表面之上的文字。
InverseOnSurface,
/// 反相表面之上的强调。
InversePrimary,
/// 阴影色(通常带 alpha,见 [`super::Scheme::shadow`])。
Shadow,
/// 遮罩色(模态背景,通常带 alpha)。
Scrim,
/// 表面着色(M3 旧规范的 elevation tint,现已被 surface container 取代)。
SurfaceTint,
/// 固定主色(深浅模式同值,用于需要跨模式稳定的块)。
PrimaryFixed,
/// 固定主色的暗档。
PrimaryFixedDim,
/// 固定主色之上的前景。
OnPrimaryFixed,
/// 固定主色暗档之上的前景。
OnPrimaryFixedVariant,
/// 固定次色。
SecondaryFixed,
/// 固定次色的暗档。
SecondaryFixedDim,
/// 固定次色之上的前景。
OnSecondaryFixed,
/// 固定次色暗档之上的前景。
OnSecondaryFixedVariant,
/// 固定第三色。
TertiaryFixed,
/// 固定第三色的暗档。
TertiaryFixedDim,
/// 固定第三色之上的前景。
OnTertiaryFixed,
/// 固定第三色暗档之上的前景。
OnTertiaryFixedVariant,
}
impl Role {
/// 全部 49 个角色,按 `Role as usize` 顺序排列。
pub const ALL: [Role; 49] = [
Role::Primary,
Role::OnPrimary,
Role::PrimaryContainer,
Role::OnPrimaryContainer,
Role::Secondary,
Role::OnSecondary,
Role::SecondaryContainer,
Role::OnSecondaryContainer,
Role::Tertiary,
Role::OnTertiary,
Role::TertiaryContainer,
Role::OnTertiaryContainer,
Role::Error,
Role::OnError,
Role::ErrorContainer,
Role::OnErrorContainer,
Role::Surface,
Role::OnSurface,
Role::SurfaceVariant,
Role::OnSurfaceVariant,
Role::Background,
Role::OnBackground,
Role::SurfaceDim,
Role::SurfaceBright,
Role::SurfaceContainerLowest,
Role::SurfaceContainerLow,
Role::SurfaceContainer,
Role::SurfaceContainerHigh,
Role::SurfaceContainerHighest,
Role::Outline,
Role::OutlineVariant,
Role::InverseSurface,
Role::InverseOnSurface,
Role::InversePrimary,
Role::Shadow,
Role::Scrim,
Role::SurfaceTint,
Role::PrimaryFixed,
Role::PrimaryFixedDim,
Role::OnPrimaryFixed,
Role::OnPrimaryFixedVariant,
Role::SecondaryFixed,
Role::SecondaryFixedDim,
Role::OnSecondaryFixed,
Role::OnSecondaryFixedVariant,
Role::TertiaryFixed,
Role::TertiaryFixedDim,
Role::OnTertiaryFixed,
Role::OnTertiaryFixedVariant,
];
/// 稳定字符串名(kebab-case),用于日志、配置、快照对拍与文档表格。
pub const fn name(self) -> &'static str {
match self {
Role::Primary => "primary",
Role::OnPrimary => "on-primary",
Role::PrimaryContainer => "primary-container",
Role::OnPrimaryContainer => "on-primary-container",
Role::Secondary => "secondary",
Role::OnSecondary => "on-secondary",
Role::SecondaryContainer => "secondary-container",
Role::OnSecondaryContainer => "on-secondary-container",
Role::Tertiary => "tertiary",
Role::OnTertiary => "on-tertiary",
Role::TertiaryContainer => "tertiary-container",
Role::OnTertiaryContainer => "on-tertiary-container",
Role::Error => "error",
Role::OnError => "on-error",
Role::ErrorContainer => "error-container",
Role::OnErrorContainer => "on-error-container",
Role::Surface => "surface",
Role::OnSurface => "on-surface",
Role::SurfaceVariant => "surface-variant",
Role::OnSurfaceVariant => "on-surface-variant",
Role::Background => "background",
Role::OnBackground => "on-background",
Role::SurfaceDim => "surface-dim",
Role::SurfaceBright => "surface-bright",
Role::SurfaceContainerLowest => "surface-container-lowest",
Role::SurfaceContainerLow => "surface-container-low",
Role::SurfaceContainer => "surface-container",
Role::SurfaceContainerHigh => "surface-container-high",
Role::SurfaceContainerHighest => "surface-container-highest",
Role::Outline => "outline",
Role::OutlineVariant => "outline-variant",
Role::InverseSurface => "inverse-surface",
Role::InverseOnSurface => "inverse-on-surface",
Role::InversePrimary => "inverse-primary",
Role::Shadow => "shadow",
Role::Scrim => "scrim",
Role::SurfaceTint => "surface-tint",
Role::PrimaryFixed => "primary-fixed",
Role::PrimaryFixedDim => "primary-fixed-dim",
Role::OnPrimaryFixed => "on-primary-fixed",
Role::OnPrimaryFixedVariant => "on-primary-fixed-variant",
Role::SecondaryFixed => "secondary-fixed",
Role::SecondaryFixedDim => "secondary-fixed-dim",
Role::OnSecondaryFixed => "on-secondary-fixed",
Role::OnSecondaryFixedVariant => "on-secondary-fixed-variant",
Role::TertiaryFixed => "tertiary-fixed",
Role::TertiaryFixedDim => "tertiary-fixed-dim",
Role::OnTertiaryFixed => "on-tertiary-fixed",
Role::OnTertiaryFixedVariant => "on-tertiary-fixed-variant",
}
}
/// 该角色是否是「文字/图标前景色」(`on-*` 与 `inverse-on-surface` 一类)。
///
/// 用于对比度门禁区分两类要求:前景对容器 ≥ 4.5:1(WCAG AA 正文),
/// 非文字元素 ≥ 3:1。
pub const fn is_foreground(self) -> bool {
matches!(
self,
Role::OnPrimary
| Role::OnPrimaryContainer
| Role::OnSecondary
| Role::OnSecondaryContainer
| Role::OnTertiary
| Role::OnTertiaryContainer
| Role::OnError
| Role::OnErrorContainer
| Role::OnSurface
| Role::OnSurfaceVariant
| Role::OnBackground
| Role::InverseOnSurface
| Role::InversePrimary
| Role::OnPrimaryFixed
| Role::OnPrimaryFixedVariant
| Role::OnSecondaryFixed
| Role::OnSecondaryFixedVariant
| Role::OnTertiaryFixed
| Role::OnTertiaryFixedVariant
)
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn all_is_in_enum_order() {
for (i, role) in Role::ALL.iter().enumerate() {
assert_eq!(*role as usize, i, "Role::ALL 顺序必须等于枚举声明顺序");
}
}
#[test]
fn names_are_unique_and_kebab() {
let mut seen = std::collections::BTreeSet::new();
for role in Role::ALL {
let name = role.name();
assert!(seen.insert(name), "角色名重复:{name}");
assert!(
name.chars()
.all(|c| c.is_ascii_lowercase() || c.is_ascii_digit() || c == '-'),
"角色名必须是 kebab-case:{name}"
);
}
}
}
+313
View File
@@ -0,0 +1,313 @@
//! 一套完整的 M3 语义色方案(`Scheme`):角色 → 调色板槽位 → tone 的三段映射。
//!
//! tone 表取自 M3 基础方案的官方映射(light/dark 各一列)。固定角色
//! (`*-fixed*`)深浅同值,因为它们的用途就是「跨模式稳定」。
use super::palettes::{PaletteSlot, Palettes};
use super::roles::Role;
use super::Color;
/// 深浅模式。
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
pub enum SchemeFlavor {
/// 浅色(亮底暗字)。
Light,
/// 深色(暗底亮字)。
Dark,
}
impl SchemeFlavor {
/// 是否深色模式。
pub const fn is_dark(self) -> bool {
matches!(self, SchemeFlavor::Dark)
}
/// 反过来那一个。
pub const fn toggled(self) -> Self {
match self {
SchemeFlavor::Light => SchemeFlavor::Dark,
SchemeFlavor::Dark => SchemeFlavor::Light,
}
}
/// 稳定字符串名(`light` / `dark`),写配置与日志用。
pub const fn name(self) -> &'static str {
match self {
SchemeFlavor::Light => "light",
SchemeFlavor::Dark => "dark",
}
}
}
/// 阴影色透明度(M3 阴影 = 纯黑 + 这个 alpha;绘制阴影时叠在背景上)。
pub const SHADOW_ALPHA: f32 = 0.20;
/// 遮罩色透明度(模态背景压暗层)。
pub const SCRIM_ALPHA: f32 = 0.32;
/// 一条「角色 → 槽位 + tone」映射。
struct ToneRow {
role: Role,
slot: PaletteSlot,
light: f32,
dark: f32,
}
/// 49 个角色的 tone 映射表,**下标必须等于 `Role as usize`**(由测试钉死)。
const TONE_TABLE: [ToneRow; 49] = [
row(Role::Primary, PaletteSlot::Primary, 40.0, 80.0),
row(Role::OnPrimary, PaletteSlot::Primary, 100.0, 20.0),
row(Role::PrimaryContainer, PaletteSlot::Primary, 90.0, 30.0),
row(Role::OnPrimaryContainer, PaletteSlot::Primary, 10.0, 90.0),
row(Role::Secondary, PaletteSlot::Secondary, 40.0, 80.0),
row(Role::OnSecondary, PaletteSlot::Secondary, 100.0, 20.0),
row(Role::SecondaryContainer, PaletteSlot::Secondary, 90.0, 30.0),
row(
Role::OnSecondaryContainer,
PaletteSlot::Secondary,
10.0,
90.0,
),
row(Role::Tertiary, PaletteSlot::Tertiary, 40.0, 80.0),
row(Role::OnTertiary, PaletteSlot::Tertiary, 100.0, 20.0),
row(Role::TertiaryContainer, PaletteSlot::Tertiary, 90.0, 30.0),
row(Role::OnTertiaryContainer, PaletteSlot::Tertiary, 10.0, 90.0),
row(Role::Error, PaletteSlot::Error, 40.0, 80.0),
row(Role::OnError, PaletteSlot::Error, 100.0, 20.0),
row(Role::ErrorContainer, PaletteSlot::Error, 90.0, 30.0),
row(Role::OnErrorContainer, PaletteSlot::Error, 10.0, 90.0),
row(Role::Surface, PaletteSlot::Neutral, 98.0, 6.0),
row(Role::OnSurface, PaletteSlot::Neutral, 10.0, 90.0),
row(
Role::SurfaceVariant,
PaletteSlot::NeutralVariant,
90.0,
30.0,
),
row(
Role::OnSurfaceVariant,
PaletteSlot::NeutralVariant,
30.0,
80.0,
),
row(Role::Background, PaletteSlot::Neutral, 99.0, 10.0),
row(Role::OnBackground, PaletteSlot::Neutral, 10.0, 90.0),
row(Role::SurfaceDim, PaletteSlot::Neutral, 87.0, 6.0),
row(Role::SurfaceBright, PaletteSlot::Neutral, 98.0, 24.0),
row(
Role::SurfaceContainerLowest,
PaletteSlot::Neutral,
100.0,
4.0,
),
row(Role::SurfaceContainerLow, PaletteSlot::Neutral, 96.0, 10.0),
row(Role::SurfaceContainer, PaletteSlot::Neutral, 94.0, 12.0),
row(Role::SurfaceContainerHigh, PaletteSlot::Neutral, 92.0, 17.0),
row(
Role::SurfaceContainerHighest,
PaletteSlot::Neutral,
90.0,
22.0,
),
row(Role::Outline, PaletteSlot::NeutralVariant, 50.0, 60.0),
row(
Role::OutlineVariant,
PaletteSlot::NeutralVariant,
80.0,
30.0,
),
row(Role::InverseSurface, PaletteSlot::Neutral, 20.0, 90.0),
row(Role::InverseOnSurface, PaletteSlot::Neutral, 95.0, 20.0),
row(Role::InversePrimary, PaletteSlot::Primary, 80.0, 40.0),
row(Role::Shadow, PaletteSlot::Neutral, 0.0, 0.0),
row(Role::Scrim, PaletteSlot::Neutral, 0.0, 0.0),
row(Role::SurfaceTint, PaletteSlot::Primary, 40.0, 80.0),
row(Role::PrimaryFixed, PaletteSlot::Primary, 90.0, 90.0),
row(Role::PrimaryFixedDim, PaletteSlot::Primary, 80.0, 80.0),
row(Role::OnPrimaryFixed, PaletteSlot::Primary, 10.0, 10.0),
row(
Role::OnPrimaryFixedVariant,
PaletteSlot::Primary,
30.0,
30.0,
),
row(Role::SecondaryFixed, PaletteSlot::Secondary, 90.0, 90.0),
row(Role::SecondaryFixedDim, PaletteSlot::Secondary, 80.0, 80.0),
row(Role::OnSecondaryFixed, PaletteSlot::Secondary, 10.0, 10.0),
row(
Role::OnSecondaryFixedVariant,
PaletteSlot::Secondary,
30.0,
30.0,
),
row(Role::TertiaryFixed, PaletteSlot::Tertiary, 90.0, 90.0),
row(Role::TertiaryFixedDim, PaletteSlot::Tertiary, 80.0, 80.0),
row(Role::OnTertiaryFixed, PaletteSlot::Tertiary, 10.0, 10.0),
row(
Role::OnTertiaryFixedVariant,
PaletteSlot::Tertiary,
30.0,
30.0,
),
];
/// `const` 友好的行构造器(数组初始化里不能调用非 const fn 之外的东西)。
const fn row(role: Role, slot: PaletteSlot, light: f32, dark: f32) -> ToneRow {
ToneRow {
role,
slot,
light,
dark,
}
}
/// 一套语义色(49 个角色,全部已解析成具体颜色)。
#[derive(Debug, Clone, Copy, PartialEq)]
pub struct Scheme {
flavor: SchemeFlavor,
colors: [Color; 49],
}
impl Scheme {
/// 出厂基线方案(种子 `#6750A4`)。
pub fn baseline(flavor: SchemeFlavor) -> Self {
Self::from_palettes(&Palettes::baseline(), flavor)
}
/// 指定种子色的方案(表面色与强调色同源)。
pub fn from_seed(flavor: SchemeFlavor, seed: Color) -> Self {
Self::from_palettes(&Palettes::from_seed(seed), flavor)
}
/// 只覆盖强调色(表面中性色仍走基线,见 [`Palettes::with_accent`])。
pub fn from_accent(flavor: SchemeFlavor, accent: Color) -> Self {
Self::from_palettes(&Palettes::baseline().with_accent(accent), flavor)
}
/// 直接从调色板集合构建(高级入口:整仓测试与自定义 scheme 用)。
pub fn from_palettes(palettes: &Palettes, flavor: SchemeFlavor) -> Self {
let colors = std::array::from_fn(|i| {
let entry = &TONE_TABLE[i];
let tone = match flavor {
SchemeFlavor::Light => entry.light,
SchemeFlavor::Dark => entry.dark,
};
palettes.slot(entry.slot).tone(tone)
});
Self { flavor, colors }
}
/// 该角色在指定模式下的 tone 值(M3 语义;文档表格、调试与测试用)。
pub fn tone_of(role: Role, flavor: SchemeFlavor) -> f32 {
let entry = &TONE_TABLE[role as usize];
debug_assert_eq!(entry.role, role, "TONE_TABLE 下标必须等于角色序号");
match flavor {
SchemeFlavor::Light => entry.light,
SchemeFlavor::Dark => entry.dark,
}
}
/// 取角色色值。
pub fn get(&self, role: Role) -> Color {
self.colors[role as usize]
}
/// 当前模式。
pub fn flavor(&self) -> SchemeFlavor {
self.flavor
}
/// 按角色顺序遍历(`for (role, color) in scheme.iter()`)。
pub fn iter(&self) -> impl Iterator<Item = (Role, Color)> + '_ {
Role::ALL.iter().copied().map(|role| (role, self.get(role)))
}
/// 阴影色(纯黑 + [`SHADOW_ALPHA`])。
pub fn shadow(&self) -> Color {
self.get(Role::Shadow).with_alpha(SHADOW_ALPHA)
}
/// 遮罩色(纯黑 + [`SCRIM_ALPHA`])。
pub fn scrim(&self) -> Color {
self.get(Role::Scrim).with_alpha(SCRIM_ALPHA)
}
/// 两个角色的 WCAG 对比度(1..21)。
pub fn contrast(&self, fg: Role, bg: Role) -> f32 {
self.get(fg).contrast_ratio(self.get(bg))
}
}
impl std::ops::Index<Role> for Scheme {
type Output = Color;
fn index(&self, role: Role) -> &Color {
&self.colors[role as usize]
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn tone_table_matches_role_order() {
for (i, entry) in TONE_TABLE.iter().enumerate() {
assert_eq!(
entry.role as usize,
i,
"TONE_TABLE 第 {i} 行是 {},顺序与 Role 枚举不一致",
entry.role.name()
);
}
}
#[test]
fn fixed_roles_are_identical_across_flavors() {
let light = Scheme::baseline(SchemeFlavor::Light);
let dark = Scheme::baseline(SchemeFlavor::Dark);
for role in [
Role::PrimaryFixed,
Role::PrimaryFixedDim,
Role::OnPrimaryFixed,
Role::OnPrimaryFixedVariant,
Role::SecondaryFixed,
Role::TertiaryFixedDim,
] {
assert_eq!(
light.get(role),
dark.get(role),
"{} 应跨模式同值",
role.name()
);
}
}
#[test]
fn light_and_dark_differ_on_text_pairs() {
let light = Scheme::baseline(SchemeFlavor::Light);
let dark = Scheme::baseline(SchemeFlavor::Dark);
assert_ne!(light.get(Role::OnSurface), dark.get(Role::OnSurface));
assert!(light.get(Role::Surface).relative_luminance() > 0.8);
assert!(dark.get(Role::Surface).relative_luminance() < 0.05);
}
#[test]
fn shadow_and_scrim_carry_alpha() {
let scheme = Scheme::baseline(SchemeFlavor::Light);
assert_eq!(scheme.shadow().a, (SHADOW_ALPHA * 255.0).round() as u8);
assert_eq!(scheme.scrim().a, (SCRIM_ALPHA * 255.0).round() as u8);
}
#[test]
fn accent_override_changes_primary_only() {
let base = Scheme::baseline(SchemeFlavor::Light);
let teal = Scheme::from_accent(SchemeFlavor::Light, Color::rgb(0x00, 0x6A, 0x5C));
assert_ne!(base.get(Role::Primary), teal.get(Role::Primary));
assert_eq!(base.get(Role::Surface), teal.get(Role::Surface));
assert_eq!(
base.get(Role::Outline),
teal.get(Role::Outline),
"强调色覆盖不该动中性描边"
);
}
}
+190
View File
@@ -0,0 +1,190 @@
//! 色彩空间转换:sRGB ↔ 线性 RGB ↔ CIELAB(tone 轴)+ OKLab/OKLCh(色相/彩度轴)。
//!
//! 这里只放**纯数学**,不认识任何语义色角色。口径说明见 [`super`] 的模块文档。
use super::Color;
/// gamut 判定容差:线性 RGB 允许露出这么点负值/溢出(浮点误差),别因此误判「越界」。
const GAMUT_EPS: f32 = 1.0e-4;
/// sRGB 编码值(0..1)→ 线性光强。
pub(crate) fn srgb_to_linear(c: f32) -> f32 {
if c <= 0.040_45 {
c / 12.92
} else {
((c + 0.055) / 1.055).powf(2.4)
}
}
/// 线性光强 → sRGB 编码值(0..1),结果不夹取,越界由调用方处理。
pub(crate) fn linear_to_srgb(c: f32) -> f32 {
if c <= 0.003_130_8 {
c * 12.92
} else {
1.055 * c.powf(1.0 / 2.4) - 0.055
}
}
/// 线性 RGB → CIE XYZ(D65 白点,sRGB 原色)。
pub(crate) fn linear_to_xyz(rgb: [f32; 3]) -> [f32; 3] {
let [r, g, b] = rgb;
[
0.412_456_4 * r + 0.357_576_1 * g + 0.180_437_5 * b,
0.212_672_9 * r + 0.715_152_2 * g + 0.072_175_0 * b,
0.019_333_9 * r + 0.119_192 * g + 0.950_304_1 * b,
]
}
/// 相对亮度 Y(WCAG 与 CIELAB 共用这条 Y,白点 1.0)。
pub(crate) fn luminance(rgb: [f32; 3]) -> f32 {
linear_to_xyz(rgb)[1]
}
/// CIELAB `L*`(M3 的 **tone** 就是这个轴,0 = 黑 / 100 = 白)。
pub(crate) fn lstar_from_luminance(y: f32) -> f32 {
let t = y.max(0.0);
if t > 0.008_856 {
116.0 * t.cbrt() - 16.0
} else {
903.296_3 * t
}
}
/// `L*` → 相对亮度 Y([`lstar_from_luminance`] 的逆)。
pub(crate) fn luminance_from_lstar(lstar: f32) -> f32 {
let l = lstar.clamp(0.0, 100.0);
if l > 8.0 {
((l + 16.0) / 116.0).powi(3)
} else {
l / 903.296_3
}
}
/// 线性 RGB → OKLab。
pub(crate) fn linear_to_oklab(rgb: [f32; 3]) -> [f32; 3] {
let [r, g, b] = rgb;
let l = 0.412_221_47 * r + 0.536_332_55 * g + 0.051_445_99 * b;
let m = 0.211_903_5 * r + 0.680_699_5 * g + 0.107_396_96 * b;
let s = 0.088_302_46 * r + 0.281_718_84 * g + 0.629_978_7 * b;
let (l_, m_, s_) = (l.cbrt(), m.cbrt(), s.cbrt());
[
0.210_454_26 * l_ + 0.793_617_8 * m_ - 0.004_072_047 * s_,
1.977_998_5 * l_ - 2.428_592_2 * m_ + 0.450_593_7 * s_,
0.025_904_037 * l_ + 0.782_771_77 * m_ - 0.808_675_77 * s_,
]
}
/// OKLab → 线性 RGB(可能越界,负值表示超出 sRGB gamut)。
pub(crate) fn oklab_to_linear(lab: [f32; 3]) -> [f32; 3] {
let [l, a, b] = lab;
let l_ = l + 0.396_337_78 * a + 0.215_803_76 * b;
let m_ = l - 0.105_561_346 * a - 0.063_854_17 * b;
let s_ = l - 0.089_484_18 * a - 1.291_485_5 * b;
let (l3, m3, s3) = (l_ * l_ * l_, m_ * m_ * m_, s_ * s_ * s_);
[
4.076_741_7 * l3 - 3.307_711_6 * m3 + 0.230_969_94 * s3,
-1.268_438 * l3 + 2.609_757_4 * m3 - 0.341_319_38 * s3,
-0.004_196_086_3 * l3 - 0.703_418_6 * m3 + 1.707_614_7 * s3,
]
}
/// OKLCh → 线性 RGB。
pub(crate) fn oklch_to_linear(l: f32, chroma: f32, hue_deg: f32) -> [f32; 3] {
let h = hue_deg.to_radians();
oklab_to_linear([l, chroma * h.cos(), chroma * h.sin()])
}
/// 线性 RGB 是否落在 sRGB 立方体内([`GAMUT_EPS`] 容差)。
pub(crate) fn in_gamut(rgb: [f32; 3]) -> bool {
rgb.iter()
.all(|c| *c >= -GAMUT_EPS && *c <= 1.0 + GAMUT_EPS)
}
/// 求「在保证 `L* = lstar` 的前提下,OKLab 明度该取多少」。
///
/// OKLab 的 L 不是 Y 的纯函数(彩色会偏离),所以给定 tone 与色相/彩度时用二分反解,
/// 让最终颜色的 **CIELAB L\* 精确等于 tone**——M3 的 tone 定义在 L\* 上,这一点必须对齐。
pub(crate) fn oklab_lightness_for_tone(chroma: f32, hue_deg: f32, lstar: f32) -> f32 {
let target = luminance_from_lstar(lstar);
let mut lo = 0.0_f32;
let mut hi = 1.0_f32;
for _ in 0..32 {
let mid = 0.5 * (lo + hi);
if luminance(oklch_to_linear(mid, chroma, hue_deg)) < target {
lo = mid;
} else {
hi = mid;
}
}
0.5 * (lo + hi)
}
/// 在该 tone / 色相下,把彩度压进 sRGB gamut 后能取到的最大值(上限 `chroma_max`)。
pub(crate) fn chroma_in_gamut(lstar: f32, hue_deg: f32, chroma_max: f32) -> f32 {
let hi0 = chroma_max.max(0.0);
if in_gamut(oklch_to_linear(
oklab_lightness_for_tone(hi0, hue_deg, lstar),
hi0,
hue_deg,
)) {
return hi0;
}
let mut lo = 0.0_f32;
let mut hi = hi0;
for _ in 0..24 {
let mid = 0.5 * (lo + hi);
let ok = in_gamut(oklch_to_linear(
oklab_lightness_for_tone(mid, hue_deg, lstar),
mid,
hue_deg,
));
if ok {
lo = mid;
} else {
hi = mid;
}
}
lo
}
/// OKLCh 坐标(色相用度,0..360)。
#[derive(Debug, Clone, Copy, PartialEq)]
pub struct Oklch {
/// 明度 0..1(中性灰时 ≈ 相对亮度的立方根)。
pub lightness: f32,
/// 彩度(0 = 中性灰)。
pub chroma: f32,
/// 色相角,单位度,0..360。
pub hue_deg: f32,
}
impl Oklch {
/// 从一个 sRGB 颜色取色相/彩度(用作调色板的种子)。
pub fn from_color(color: Color) -> Self {
let lin = color.to_linear_f32();
let [l, a, b] = linear_to_oklab(lin);
let mut hue = b.atan2(a).to_degrees();
if hue < 0.0 {
hue += 360.0;
}
Self {
lightness: l,
chroma: (a * a + b * b).sqrt(),
hue_deg: hue,
}
}
/// 在色相环上旋转(用于派生 tertiary 这类同族但不同色的调色板)。
pub fn rotated(mut self, deg: f32) -> Self {
self.hue_deg = (self.hue_deg + deg).rem_euclid(360.0);
self
}
}
impl Color {
/// 线性光强三元组(0..1,越界不夹取)。
fn to_linear_f32(self) -> [f32; 3] {
let f = |c: u8| srgb_to_linear(f32::from(c) / 255.0);
[f(self.r), f(self.g), f(self.b)]
}
}
+96
View File
@@ -0,0 +1,96 @@
//! M3 状态层(state layer)与禁用态透明度。
//!
//! M3 的 hover/pressed 不是「另一种灰」,而是**内容色自身 + 固定 alpha** 叠在容器上——
//! 所以同一套令牌在任何底色上都成立,不必为每种底色各配一套(这条从 Fluent 时代沿用,
//! 见 `DevDocs/SubAgentsTalk/ui-integration-contract.md` §2 的三态叠加层思路)。
use super::Color;
/// 状态层的四种状态与对应 alpha(M3 规范值)。
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum StateLayer {
/// 悬停:内容色 8%。
Hover,
/// 键盘焦点:内容色 10%。
Focus,
/// 按下:内容色 10%。
Pressed,
/// 拖拽中:内容色 16%。
Dragged,
}
impl StateLayer {
/// 叠加透明度(0..1)。
pub const fn alpha(self) -> f32 {
match self {
StateLayer::Hover => 0.08,
StateLayer::Focus | StateLayer::Pressed => 0.10,
StateLayer::Dragged => 0.16,
}
}
/// 把状态层叠到容器色上,得到**最终不透明色**。
///
/// `content` 一般是容器上的前景角色(例如 FilledButton 用 `on-primary`,
/// 中性表面上的按钮用 `on-surface`)。
pub fn over(self, content: Color, container: Color) -> Color {
content.with_alpha(self.alpha()).over(container)
}
}
/// 禁用态的透明度(M3):容器 12%、内容 38%。
///
/// 别用「灰化」近似——深色模式下灰化会降低对比度,alpha 叠加两种模式都成立。
pub const DISABLED_CONTAINER_ALPHA: f32 = 0.12;
/// 禁用态内容(文字/图标)透明度。
pub const DISABLED_CONTENT_ALPHA: f32 = 0.38;
/// 禁用态容器色:`on_surface` 12% 叠在 `surface` 上。
pub fn disabled_container(scheme: &super::Scheme) -> Color {
scheme
.get(super::Role::OnSurface)
.with_alpha(DISABLED_CONTAINER_ALPHA)
.over(scheme.get(super::Role::Surface))
}
/// 禁用态内容色:`on_surface` 38% 叠在容器上。
pub fn disabled_content(scheme: &super::Scheme, container: Color) -> Color {
scheme
.get(super::Role::OnSurface)
.with_alpha(DISABLED_CONTENT_ALPHA)
.over(container)
}
#[cfg(test)]
mod tests {
use super::*;
use crate::color::{Role, Scheme, SchemeFlavor};
#[test]
fn hover_is_lighter_than_pressed_on_dark_container() {
let scheme = Scheme::baseline(SchemeFlavor::Dark);
let container = scheme.get(Role::SurfaceContainer);
let content = scheme.get(Role::OnSurface);
let hover = StateLayer::Hover.over(content, container);
let pressed = StateLayer::Pressed.over(content, container);
assert_eq!(hover.a, 255);
assert!(pressed.relative_luminance() > hover.relative_luminance());
}
#[test]
fn state_layer_alpha_matches_m3() {
assert!((StateLayer::Hover.alpha() - 0.08).abs() < f32::EPSILON);
assert!((StateLayer::Dragged.alpha() - 0.16).abs() < f32::EPSILON);
}
#[test]
fn disabled_content_stays_visible() {
let scheme = Scheme::baseline(SchemeFlavor::Light);
let container = disabled_container(&scheme);
let content = disabled_content(&scheme, container);
// 禁用态不追求 AA(规范就是这么定的),但必须仍可辨(> 1.5:1)。
let ratio = content.contrast_ratio(container);
assert!(ratio > 1.5, "禁用态对比度过低:{ratio}");
assert!(ratio < 4.5, "禁用态不该和正文一样醒目:{ratio}");
}
}
+146
View File
@@ -0,0 +1,146 @@
//! tone 调色板:一个种子色 → 一条「固定色相/彩度、tone 任取」的调色板。
//!
//! 这是 M3 tonal palette 的**近似实现**:色相恒定、按 tone 取色、按 tone 压彩度进 sRGB gamut。
//! 口径与已知偏差见 [`super`] 模块文档(OKLCh 而非 CAM16/HCT)。
use super::space::{self, Oklch};
use super::Color;
/// 一条 tone 调色板(色相固定 + 彩度上限)。
#[derive(Debug, Clone, Copy, PartialEq)]
pub struct TonalPalette {
hue_deg: f32,
chroma: f32,
}
impl TonalPalette {
/// 直接给色相(度)与彩度上限(OKLCh 单位,0 = 中性灰)建调色板。
pub fn new(hue_deg: f32, chroma: f32) -> Self {
Self {
hue_deg: hue_deg.rem_euclid(360.0),
chroma: chroma.max(0.0),
}
}
/// 从种子色取色相与彩度(等价 M3 的 `palette from seed`)。
pub fn from_color(seed: Color) -> Self {
let lch = Oklch::from_color(seed);
Self::new(lch.hue_deg, lch.chroma)
}
/// 固定色相,改彩度上限(用于把种子彩度压到 secondary/neutral 档)。
pub fn with_chroma(self, chroma: f32) -> Self {
Self::new(self.hue_deg, chroma)
}
/// 彩度不超过 `limit`(种子本身更灰时保持更灰,不硬拉饱和)。
pub fn limited_chroma(self, limit: f32) -> Self {
self.with_chroma(self.chroma.min(limit))
}
/// 旋转色相(derived hue)。
pub fn rotated(self, deg: f32) -> Self {
Self::new(self.hue_deg + deg, self.chroma)
}
/// 该调色板的色相(度)。
pub fn hue_deg(self) -> f32 {
self.hue_deg
}
/// 该调色板的彩度上限(OKLCh 单位)。
pub fn chroma(self) -> f32 {
self.chroma
}
/// 取某个 tone 的颜色(tone 0..100,超出夹取)。
///
/// 实现口径:CIELAB `L*` 精确等于 tone(M3 的 tone 定义),彩度按该 tone 的 gamut 上限
/// 二分裁剪——所以 tone 0 恒为纯黑、tone 100 恒为纯白,且同 tone 的 WCAG 对比度
/// 与色相无关(这是对比度门禁能跨种子稳定的原因)。
pub fn tone(self, tone: f32) -> Color {
let lstar = tone.clamp(0.0, 100.0);
// 端点直接给纯黑/纯白:近黑处的 gamut 容差会放进微小彩度,导致 tone 0 偏色。
if lstar <= 0.0 {
return Color::BLACK;
}
if lstar >= 100.0 {
return Color::WHITE;
}
let chroma = space::chroma_in_gamut(lstar, self.hue_deg, self.chroma);
let lightness = space::oklab_lightness_for_tone(chroma, self.hue_deg, lstar);
Color::from_linear_f32(space::oklch_to_linear(lightness, chroma, self.hue_deg))
}
}
#[cfg(test)]
mod tests {
use super::*;
const SEED: Color = Color::rgb(0x67, 0x50, 0xA4);
fn lstar_of(color: Color) -> f32 {
color.lstar()
}
#[test]
fn tone_matches_lstar_within_one_unit() {
let palette = TonalPalette::from_color(SEED);
for tone in [
0.0_f32, 6.0, 10.0, 30.0, 40.0, 50.0, 80.0, 90.0, 95.0, 98.0, 100.0,
] {
let got = lstar_of(palette.tone(tone));
assert!(
(got - tone).abs() <= 1.0,
"tone {tone} 的 L* 是 {got},偏差超过 1(量化误差上限)"
);
}
}
#[test]
fn extremes_are_pure_black_and_white() {
let palette = TonalPalette::from_color(SEED);
assert_eq!(palette.tone(0.0), Color::rgb(0, 0, 0));
assert_eq!(palette.tone(100.0), Color::rgb(255, 255, 255));
assert_eq!(palette.tone(-5.0), Color::rgb(0, 0, 0));
assert_eq!(palette.tone(120.0), Color::rgb(255, 255, 255));
}
#[test]
fn seed_hue_is_preserved_across_tones() {
let palette = TonalPalette::from_color(SEED);
for tone in [30.0_f32, 50.0, 70.0] {
let lch = Oklch::from_color(palette.tone(tone));
let delta = (lch.hue_deg - palette.hue_deg()).abs();
let delta = delta.min(360.0 - delta);
assert!(delta < 3.0, "tone {tone} 色相偏移 {delta}° 过大");
}
}
#[test]
fn neutral_palette_is_gray() {
let neutral = TonalPalette::new(120.0, 0.0);
let c = neutral.tone(50.0);
let spread = f32::from(c.r.max(c.g).max(c.b) - c.r.min(c.g).min(c.b));
assert!(
spread <= 1.0,
"彩度 0 的调色板应当接近中性灰,实测通道差 {spread}"
);
}
#[test]
fn chroma_never_leaves_gamut() {
// 高彩度种子在极端 tone 上必须被裁到 gamut 内(不出现「负 RGB」削顶后的色带)。
let hot = TonalPalette::new(30.0, 0.4);
for tone in [5.0_f32, 20.0, 40.0, 60.0, 80.0, 95.0] {
let lch = Oklch::from_color(hot.tone(tone));
assert!(
lch.chroma <= 0.4 + 1.0e-3,
"tone {tone} 彩度 {} 超出请求上限",
lch.chroma
);
let got = lstar_of(hot.tone(tone));
assert!((got - tone).abs() <= 1.0, "tone {tone} 的 L* 是 {got}");
}
}
}
+216
View File
@@ -0,0 +1,216 @@
//! 组件 → 档位映射表:哪个组件用哪档形状 / 高程 / 字体 / 动效。
//!
//! 目的不是「规定 M3 官方长什么样」(官方只给组件规格,不给启动器里的档位表),而是
//! **给 iced 页面线一个不需要再谈判的默认值**:同一个组件在五个页面里长得一样,
//! 才有用户要的「UI 整体性」。
//!
//! 口径:
//! - 形状档位见 [`crate::shape::ShapeScale`],高程见 [`crate::elevation::Elevation`];
//! - `text` 是主要文字角色,`support` 是辅助文字(没有就 `None`);
//! - `motion` + `duration` 是状态过渡(hover/press/展开),不是页面切换;
//! - `morph` 是按下/选中时的形状形变(Expressive 特征),不需要就 `None`。
//!
//! **改这里的值 = 改全站观感**,必须同步 `DevDocs/SubAgentsTalk/design-令牌约定.md`;
//! 页面线不许在页面里覆盖这些档位。
mod table;
use crate::elevation::Elevation;
use crate::motion::{DurationToken, Easing};
use crate::shape::{ShapeMorph, ShapeScale};
use crate::typography::TypeRole;
/// 组件的排版 + 形状 + 动效档位。
#[derive(Debug, Clone, Copy, PartialEq)]
pub struct ComponentSpec {
/// 该档位属于哪个组件(表自描述,便于测试与文档生成)。
pub component: Component,
/// 圆角档位。
pub shape: ShapeScale,
/// 高程级别(决定 surface container 角色与是否画阴影)。
pub elevation: Elevation,
/// 主要文字角色。
pub text: TypeRole,
/// 辅助文字角色。
pub support: Option<TypeRole>,
/// 状态过渡缓动。
pub motion: Easing,
/// 状态过渡时长。
pub duration: DurationToken,
/// 按下/选中时的形状形变。
pub morph: Option<ShapeMorph>,
}
/// 需要固定档位的组件清单(M53 页面骨架里实际用到的那些)。
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
pub enum Component {
/// 顶部标题栏(第一层 chrome)。
TopAppBar,
/// 导航项(侧栏一行)。
NavigationItem,
/// 主操作按钮(实心强调色,每屏最多一个)。
PrimaryButton,
/// 次操作按钮(tonal)。
SecondaryButton,
/// 文本按钮(低权重)。
TextButton,
/// 图标按钮。
IconButton,
/// 描边卡片。
OutlinedCard,
/// 填充卡片(层级高于描边卡)。
FilledCard,
/// 列表行。
ListItem,
/// 文本输入框。
TextField,
/// 分段按钮(磁贴/列表切换、小中大三档)。
SegmentedButton,
/// 贴片/徽标。
Chip,
/// 开关。
Switch,
/// 滑块。
Slider,
/// 进度条。
ProgressIndicator,
/// 对话框。
Dialog,
/// 下拉菜单。
Menu,
/// 底部提示条。
Snackbar,
/// 悬浮提示。
Tooltip,
/// 空态(图标 + 标题 + 说明 + 标准按钮)。
EmptyState,
}
impl Component {
/// 全部组件。
pub const ALL: [Component; 20] = [
Component::TopAppBar,
Component::NavigationItem,
Component::PrimaryButton,
Component::SecondaryButton,
Component::TextButton,
Component::IconButton,
Component::OutlinedCard,
Component::FilledCard,
Component::ListItem,
Component::TextField,
Component::SegmentedButton,
Component::Chip,
Component::Switch,
Component::Slider,
Component::ProgressIndicator,
Component::Dialog,
Component::Menu,
Component::Snackbar,
Component::Tooltip,
Component::EmptyState,
];
/// 该组件的档位。
pub fn spec(self) -> ComponentSpec {
table::SPECS[self as usize]
}
/// 稳定字符串名(kebab-case)。
pub const fn name(self) -> &'static str {
match self {
Component::TopAppBar => "top-app-bar",
Component::NavigationItem => "navigation-item",
Component::PrimaryButton => "primary-button",
Component::SecondaryButton => "secondary-button",
Component::TextButton => "text-button",
Component::IconButton => "icon-button",
Component::OutlinedCard => "outlined-card",
Component::FilledCard => "filled-card",
Component::ListItem => "list-item",
Component::TextField => "text-field",
Component::SegmentedButton => "segmented-button",
Component::Chip => "chip",
Component::Switch => "switch",
Component::Slider => "slider",
Component::ProgressIndicator => "progress-indicator",
Component::Dialog => "dialog",
Component::Menu => "menu",
Component::Snackbar => "snackbar",
Component::Tooltip => "tooltip",
Component::EmptyState => "empty-state",
}
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn table_matches_component_order() {
for (i, spec) in table::SPECS.iter().enumerate() {
assert_eq!(
spec.component as usize, i,
"SPECS 第 {i} 行顺序与枚举不一致"
);
}
}
#[test]
fn every_component_resolves() {
assert_eq!(Component::ALL.len(), table::SPECS.len());
for component in Component::ALL {
let spec = component.spec();
assert_eq!(spec.component, component);
assert!(spec.text.style().size_px >= 11.0);
}
}
#[test]
fn names_are_unique() {
let mut seen = std::collections::BTreeSet::new();
for component in Component::ALL {
assert!(
seen.insert(component.name()),
"组件名重复:{}",
component.name()
);
}
}
#[test]
fn only_primary_actions_use_filled_emphasis_and_one_dialog_layer() {
// 契约 §1:状态色不复用 accent、每屏最多一个实心 accent → 只有 primary-button 是
// 「实心强调」,其余按钮的形态由页面决定但不能是 Full + Level0 + Emphasized 的组合。
let primary = Component::PrimaryButton.spec();
assert_eq!(primary.shape, ShapeScale::Full);
assert_eq!(primary.text, TypeRole::LabelLarge);
// 浮层类必须比卡片层高。
assert!(Component::Dialog.spec().elevation > Component::OutlinedCard.spec().elevation);
assert!(Component::Menu.spec().elevation > Component::FilledCard.spec().elevation);
}
#[test]
fn morphs_always_expand_upwards() {
for component in Component::ALL {
if let Some(morph) = component.spec().morph {
assert_ne!(
morph.from,
morph.to,
"{} 的形变起止不能相同",
component.name()
);
// 方向必须「沿阶梯胀开」(morph 对 `Full` 也没问题,rank 把它排在末尾),
// 不比较像素跨度:`Full` 的半径依赖边长,用固定边长比会被 `None` 之外的大档拉爆。
assert!(
morph.from.rank() < morph.to.rank(),
"{} 的形变方向反了:{:?} → {:?}",
component.name(),
morph.from,
morph.to
);
}
}
}
}
+256
View File
@@ -0,0 +1,256 @@
//! 组件档位数据表(**下标必须等于 `Component as usize`**,由测试钉死)。
//!
//! 一行一个组件。改值就是在改全站观感——同步协作文档再改。
use super::{Component, ComponentSpec};
use crate::elevation::Elevation;
use crate::motion::{DurationToken, Easing, SPRING_SPATIAL_DEFAULT, SPRING_SPATIAL_FAST};
use crate::shape::{ShapeMorph, ShapeScale};
use crate::typography::TypeRole;
/// 一个组件的档位行(`const fn` 便捷构造,字段名在 [`ComponentSpec`] 里)。
const fn spec(
component: Component,
shape: ShapeScale,
elevation: Elevation,
text: TypeRole,
support: Option<TypeRole>,
motion: Easing,
duration: DurationToken,
morph: Option<ShapeMorph>,
) -> ComponentSpec {
ComponentSpec {
component,
shape,
elevation,
text,
support,
motion,
duration,
morph,
}
}
/// 20 个组件的档位表。
pub(super) const SPECS: [ComponentSpec; 20] = [
spec(
Component::TopAppBar,
ShapeScale::None,
Elevation::Level0,
TypeRole::TitleLarge,
Some(TypeRole::BodySmall),
Easing::Standard,
DurationToken::Medium2,
None,
),
spec(
Component::NavigationItem,
ShapeScale::Full,
Elevation::Level0,
TypeRole::LabelLarge,
None,
Easing::Emphasized,
DurationToken::Medium2,
Some(ShapeMorph::new(
ShapeScale::Large,
ShapeScale::Full,
SPRING_SPATIAL_FAST,
)),
),
spec(
Component::PrimaryButton,
ShapeScale::Full,
Elevation::Level0,
TypeRole::LabelLarge,
None,
Easing::Emphasized,
DurationToken::Short4,
None,
),
spec(
Component::SecondaryButton,
ShapeScale::Full,
Elevation::Level0,
TypeRole::LabelLarge,
None,
Easing::Standard,
DurationToken::Short4,
None,
),
spec(
Component::TextButton,
ShapeScale::Full,
Elevation::Level0,
TypeRole::LabelLarge,
None,
Easing::Standard,
DurationToken::Short4,
None,
),
spec(
Component::IconButton,
ShapeScale::Full,
Elevation::Level0,
TypeRole::LabelMedium,
None,
Easing::Standard,
DurationToken::Short3,
Some(ShapeMorph::new(
ShapeScale::Medium,
ShapeScale::Full,
SPRING_SPATIAL_FAST,
)),
),
spec(
Component::OutlinedCard,
ShapeScale::Medium,
Elevation::Level1,
TypeRole::TitleMedium,
Some(TypeRole::BodySmall),
Easing::Standard,
DurationToken::Medium1,
None,
),
spec(
Component::FilledCard,
ShapeScale::Medium,
Elevation::Level2,
TypeRole::TitleMedium,
Some(TypeRole::BodySmall),
Easing::Standard,
DurationToken::Medium1,
Some(ShapeMorph::new(
ShapeScale::Medium,
ShapeScale::Large,
SPRING_SPATIAL_DEFAULT,
)),
),
spec(
Component::ListItem,
ShapeScale::Small,
Elevation::Level0,
TypeRole::BodyLarge,
Some(TypeRole::BodySmall),
Easing::Standard,
DurationToken::Short4,
None,
),
spec(
Component::TextField,
ShapeScale::ExtraSmall,
Elevation::Level0,
TypeRole::BodyLarge,
Some(TypeRole::BodySmall),
Easing::Standard,
DurationToken::Short4,
None,
),
spec(
Component::SegmentedButton,
ShapeScale::Full,
Elevation::Level0,
TypeRole::LabelLarge,
None,
Easing::Emphasized,
DurationToken::Medium2,
None,
),
spec(
Component::Chip,
ShapeScale::Small,
Elevation::Level0,
TypeRole::LabelMedium,
None,
Easing::Standard,
DurationToken::Short3,
Some(ShapeMorph::new(
ShapeScale::ExtraSmall,
ShapeScale::Small,
SPRING_SPATIAL_FAST,
)),
),
spec(
Component::Switch,
ShapeScale::Full,
Elevation::Level0,
TypeRole::BodyMedium,
Some(TypeRole::BodySmall),
Easing::Emphasized,
DurationToken::Medium2,
None,
),
spec(
Component::Slider,
ShapeScale::Full,
Elevation::Level0,
TypeRole::BodyMedium,
Some(TypeRole::BodySmall),
Easing::Standard,
DurationToken::Short2,
None,
),
spec(
Component::ProgressIndicator,
ShapeScale::Full,
Elevation::Level0,
TypeRole::LabelMedium,
None,
Easing::Linear,
DurationToken::Long2,
None,
),
spec(
Component::Dialog,
ShapeScale::ExtraLarge,
Elevation::Level5,
TypeRole::HeadlineSmall,
Some(TypeRole::BodyMedium),
Easing::EmphasizedDecelerate,
DurationToken::Long4,
Some(ShapeMorph::new(
ShapeScale::Large,
ShapeScale::ExtraLarge,
SPRING_SPATIAL_DEFAULT,
)),
),
spec(
Component::Menu,
ShapeScale::Small,
Elevation::Level4,
TypeRole::BodyMedium,
None,
Easing::StandardDecelerate,
DurationToken::Short4,
None,
),
spec(
Component::Snackbar,
ShapeScale::ExtraSmall,
Elevation::Level3,
TypeRole::BodyMedium,
None,
Easing::EmphasizedDecelerate,
DurationToken::Medium2,
None,
),
spec(
Component::Tooltip,
ShapeScale::ExtraSmall,
Elevation::Level2,
TypeRole::BodySmall,
None,
Easing::StandardDecelerate,
DurationToken::Short2,
None,
),
spec(
Component::EmptyState,
ShapeScale::Large,
Elevation::Level0,
TypeRole::HeadlineSmall,
Some(TypeRole::BodySmall),
Easing::Standard,
DurationToken::Medium2,
None,
),
];
+226
View File
@@ -0,0 +1,226 @@
//! 高程令牌:elevation level、阴影参数、surface container 映射。
//!
//! M3 在 2024 之后改了「怎么表达高程」:**不再**往表面上叠半透明的 surface tint,
//! 而是用 `surface-container-*` 这一族中性容器色表达层级(GPU 路径再叠阴影做空间感)。
//! 本模块两种口径都给出,旧口径显式标为 deprecated,避免页面线混用。
use crate::color::Role;
/// M3 高程级别 0..5(对应 0 / 1 / 3 / 6 / 8 / 12 dp)。
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, PartialOrd, Ord)]
pub enum Elevation {
/// 0dp:贴底内容(窗口底、内联区)。
Level0,
/// 1dp:卡片、可抬起的列表项。
Level1,
/// 3dp:可交互卡片、导航栏。
Level2,
/// 6dp:FAB、侧栏、浮动工具条。
Level3,
/// 8dp:菜单、popover。
Level4,
/// 12dp:对话框、模态。
Level5,
}
impl Elevation {
/// 全部级别。
pub const ALL: [Elevation; 6] = [
Elevation::Level0,
Elevation::Level1,
Elevation::Level2,
Elevation::Level3,
Elevation::Level4,
Elevation::Level5,
];
/// 标称高度(dp)。
pub const fn dp(self) -> f32 {
match self {
Elevation::Level0 => 0.0,
Elevation::Level1 => 1.0,
Elevation::Level2 => 3.0,
Elevation::Level3 => 6.0,
Elevation::Level4 => 8.0,
Elevation::Level5 => 12.0,
}
}
/// 该级别应使用的表面容器色角色(M3 现行口径)。
///
/// 层级靠**换底色**表达,不靠 tint;浅深模式都成立。
pub const fn surface_role(self) -> Role {
match self {
Elevation::Level0 => Role::SurfaceContainerLowest,
Elevation::Level1 => Role::SurfaceContainerLow,
Elevation::Level2 => Role::SurfaceContainer,
Elevation::Level3 => Role::SurfaceContainerHigh,
Elevation::Level4 | Elevation::Level5 => Role::SurfaceContainerHighest,
}
}
/// 阴影参数(**占位值,待 M63 视觉规格** — 见下面的 TODO)。
///
/// TODO(M63): 取值待视觉规格。官方只给 dp 与设计稿示意,没有可移植的 blur/alpha 公式;
/// 这里的 blur/offset/alpha 是临时占位(按「blur ≈ dp × 1.3 + 1.5」凑的数),
/// M63 的玻璃规格表落地后以那张表为准,别在页面里引用这些绝对值。
pub const fn shadow(self) -> ShadowSpec {
match self {
Elevation::Level0 => ShadowSpec {
blur_px: 0.0,
offset_y_px: 0.0,
alpha: 0.0,
},
Elevation::Level1 => ShadowSpec {
blur_px: 3.0,
offset_y_px: 1.0,
alpha: 0.12,
},
Elevation::Level2 => ShadowSpec {
blur_px: 6.0,
offset_y_px: 2.0,
alpha: 0.14,
},
Elevation::Level3 => ShadowSpec {
blur_px: 9.0,
offset_y_px: 4.0,
alpha: 0.16,
},
Elevation::Level4 => ShadowSpec {
blur_px: 12.0,
offset_y_px: 6.0,
alpha: 0.18,
},
Elevation::Level5 => ShadowSpec {
blur_px: 17.0,
offset_y_px: 8.0,
alpha: 0.20,
},
}
}
/// 旧口径(M3 2022):把 `surface-tint` 按这个 alpha 叠在 `surface` 上表达高程。
///
/// **deprecated**——现行 M3 用 [`Elevation::surface_role`];保留只为读旧稿时能对上数,
/// 新代码不要用。
pub const fn deprecated_tint_alpha(self) -> f32 {
match self {
Elevation::Level0 => 0.0,
Elevation::Level1 => 0.05,
Elevation::Level2 => 0.08,
Elevation::Level3 => 0.11,
Elevation::Level4 => 0.12,
Elevation::Level5 => 0.14,
}
}
}
/// 一层阴影的绘制参数。
///
/// TODO(M63): 取值待视觉规格(这份结构只是「接口预留」,数值由 M63 的玻璃规格表填充)。
#[derive(Debug, Clone, Copy, PartialEq)]
pub struct ShadowSpec {
/// 高斯模糊半径(px)。
pub blur_px: f32,
/// 向下偏移(px)。
pub offset_y_px: f32,
/// 阴影色 alpha(叠在 [`crate::color::Scheme::shadow`] 之上)。
pub alpha: f32,
}
impl ShadowSpec {
/// 是否可见(0 表示这一级不画阴影)。
pub fn is_visible(&self) -> bool {
self.alpha > 0.0 && self.blur_px > 0.0
}
}
/// 三个「材质层」的语义(沿用 `ui-integration-contract.md` §2 的三层,映射到 M3 令牌)。
///
/// 页面线按层取色,不许自己决定「这一层比那一层深一点」。
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum MaterialLayer {
/// 底层(canvas):窗口底,永远不透明。
Canvas,
/// 卡片层:页面内容/设置分组/AI 面板。
Card,
/// 浮层:对话框、菜单、Toast。
Overlay,
}
impl MaterialLayer {
/// 该层的底色角色。
pub const fn surface_role(self) -> Role {
match self {
MaterialLayer::Canvas => Role::Surface,
MaterialLayer::Card => Role::SurfaceContainerLow,
MaterialLayer::Overlay => Role::SurfaceContainerHigh,
}
}
/// 该层的高程级别。
pub const fn elevation(self) -> Elevation {
match self {
MaterialLayer::Canvas => Elevation::Level0,
MaterialLayer::Card => Elevation::Level1,
MaterialLayer::Overlay => Elevation::Level3,
}
}
/// 该层是否允许半透明(D27:**只有 GPU 路径**才允许;CPU 路径一律纯色)。
pub const fn allows_translucency(self) -> bool {
matches!(self, MaterialLayer::Card | MaterialLayer::Overlay)
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn elevation_dp_ladder() {
let ladder: Vec<f32> = Elevation::ALL.iter().map(|e| e.dp()).collect();
assert_eq!(ladder, vec![0.0, 1.0, 3.0, 6.0, 8.0, 12.0]);
}
#[test]
fn shadow_grows_with_elevation() {
let mut prev = Elevation::Level0.shadow();
for level in &Elevation::ALL[1..] {
let cur = level.shadow();
assert!(cur.blur_px > prev.blur_px, "{level:?} 的模糊应更大");
assert!(cur.offset_y_px >= prev.offset_y_px);
assert!(cur.alpha >= prev.alpha);
assert!(cur.is_visible());
prev = cur;
}
assert!(!Elevation::Level0.shadow().is_visible());
}
#[test]
fn higher_levels_use_higher_surface_containers() {
assert_eq!(
Elevation::Level5.surface_role(),
Role::SurfaceContainerHighest
);
assert_eq!(
Elevation::Level0.surface_role(),
Role::SurfaceContainerLowest
);
}
#[test]
fn material_layers_are_ordered() {
assert_eq!(MaterialLayer::Canvas.surface_role(), Role::Surface);
assert_eq!(
MaterialLayer::Card.surface_role(),
Role::SurfaceContainerLow
);
assert_eq!(
MaterialLayer::Overlay.surface_role(),
Role::SurfaceContainerHigh
);
assert!(!MaterialLayer::Canvas.allows_translucency());
assert!(MaterialLayer::Overlay.allows_translucency());
}
}
+184
View File
@@ -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,
),
];
+208
View File
@@ -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()
);
}
}
}
+250
View File
@@ -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());
}
}
+95
View File
@@ -0,0 +1,95 @@
//! # unlucky-design · ULL 设计令牌唯一真源
//!
//! 视觉基线 = **Material 3 Expressive**(D26 锁定,风格与框架不再更换)。这个 crate 只装
//! **渲染无关的数据与纯函数**:色彩角色、排版 ramp、形状刻度、高程、动效参数、图标几何。
//! 它不依赖任何 UI / 渲染框架(iced / slint / winit / wgpu / gtk …),也不依赖任何第三方 crate,
//! 因此 iced 迁移、CLI、甚至离屏截图工具都能吃同一套值——这是「UI 整体性」的地基。
//!
//! ## 边界(谁负责什么)
//!
//! | 层 | 负责 | 不负责 |
//! |---|---|---|
//! | `unlucky-design`(本 crate) | 令牌的值与它们之间的纯函数关系(tone→色、type role→字号/行高/字重、shape→半径、spring→时长) | 布局、控件、绘制、字体加载、图片、动画驱动 |
//! | iced 适配层(后续 mission) | 把令牌翻成 iced 的类型(`Color` / `Length` / `Font` / 动画时钟) | 自己发明新令牌名或新取值 |
//! | 页面 mission | 消费令牌与 [`component_map`] 的档位约定 | 硬编码色值 / 字号 / 半径 / 时长 |
//!
//! ## 分模块
//!
//! - [`color`]:M3 语义色角色(49 个)× light/dark 双套 + tone 调色板(OKLCh 近似,口径见模块文档)
//! - [`typography`]:M3 type scale 15 档 + 三平台字体族回退栈
//! - [`shape`]:M3 Expressive 形状刻度 + shape morph 数据
//! - [`elevation`]:elevation level 与 surface tint / surface container 规则
//! - [`motion`]:时长、easing(可采样)、Expressive 弹簧参数
//! - [`spacing`]:4px 栅格与骨架尺寸(原 `metrics.slint` 的框架无关部分)
//! - [`icons`]:24 网格图标几何(SVG path 字符串)+ 描边语义
//! - [`component_map`]:组件 → 档位映射表(哪个组件用哪档形状/高程/动效/字体)
//!
//! ## 最小用法
//!
//! ```
//! use unlucky_design::color::{Role, Scheme, SchemeFlavor};
//! use unlucky_design::typography::{FontFamily, Platform, TypeRole};
//!
//! let light = Scheme::baseline(SchemeFlavor::Light);
//! let ratio = light.contrast(Role::OnSurface, Role::Surface);
//! assert!(ratio >= 4.5, "正文对表面必须满足 WCAG AA");
//!
//! let title = TypeRole::TitleLarge.style();
//! assert_eq!(title.size_px, 22.0);
//! let stack = Platform::current().font_stack(FontFamily::Display);
//! assert!(!stack.is_empty());
//! ```
#![forbid(unsafe_code)]
pub mod color;
pub mod component_map;
pub mod elevation;
pub mod icons;
pub mod motion;
pub mod shape;
pub mod spacing;
pub mod typography;
pub use color::{Color, Role, Scheme, SchemeFlavor};
pub use motion::MotionScale;
pub use typography::TypeRole;
/// 设计令牌的整体入口:一份「当前该长什么样」的聚合快照。
///
/// iced 适配层只持有这个结构(深浅切换 = 重新 `DesignTokens::baseline(mode)`),
/// 不要在适配层里缓存散装色值——那样深浅热切会有一半画对一半画错。
#[derive(Debug, Clone, Copy)]
pub struct DesignTokens {
/// 当前深浅模式。
pub flavor: SchemeFlavor,
/// 当前模式的 M3 语义色角色全表。
pub colors: Scheme,
/// 动效时长的全局缩放(`1.0` = 标准;用户关掉动画时适配层传 `0.0`)。
pub motion_scale: MotionScale,
}
impl DesignTokens {
/// 按模式给一套出厂令牌(baseline seed `#6750A4`,等价 M3 baseline 调色板)。
pub fn baseline(flavor: SchemeFlavor) -> Self {
Self {
flavor,
colors: Scheme::baseline(flavor),
motion_scale: MotionScale::STANDARD,
}
}
/// 按用户强调色重建一套令牌(表面中性色仍走 baseline,强调色只驱动 primary/secondary/tertiary)。
pub fn with_accent(flavor: SchemeFlavor, accent: Color) -> Self {
Self {
flavor,
colors: Scheme::from_accent(flavor, accent),
motion_scale: MotionScale::STANDARD,
}
}
/// 便捷取色:`tokens.color(Role::Surface)`。
pub fn color(&self, role: Role) -> Color {
self.colors.get(role)
}
}
+305
View File
@@ -0,0 +1,305 @@
//! 动效令牌:时长、缓动(可采样)、M3 Expressive 弹簧参数。
//!
//! 三件东西分开表达,因为适配层要的接口不同:
//! - [`DurationToken`]:M3 的 short/medium/long/extra-long 十六档,给「固定时长 + easing」用;
//! - [`Easing`]:cubic-bezier 曲线,纯函数采样(适配层自己决定帧率);
//! - [`Spring`](见 [`spring`]):Expressive 的物理参数(stiffness/damping/mass),
//! 可用于实时求解或预估时长。
//!
//! **CPU / 减少动效路径**:把 [`MotionScale`] 设成 [`MotionScale::NONE`],
//! 所有时长归零、`Easing::sample` 仍返回 0/1 端值(适配层按 `t >= 1` 直接落终态)。
mod spring;
pub use spring::{
MotionScale, Spring, SPRING_EFFECTS_DEFAULT, SPRING_EFFECTS_FAST, SPRING_SPATIAL_DEFAULT,
SPRING_SPATIAL_FAST,
};
/// 动效时长档位(M3 动效令牌,单位毫秒)。
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
pub enum DurationToken {
/// 50ms:最小状态回弹。
Short1,
/// 100ms:小图标/勾选。
Short2,
/// 150ms:开关、小尺寸 fade。
Short3,
/// 200ms:按钮状态、hover。
Short4,
/// 250ms:小卡片展开。
Medium1,
/// 300ms:导航指示条、tab 切换。
Medium2,
/// 350ms:卡片展开、列表重排。
Medium3,
/// 400ms:面板开合。
Medium4,
/// 450ms:页面切换。
Long1,
/// 500ms:大面板进出。
Long2,
/// 550ms:全屏浮层。
Long3,
/// 600ms:对话框进出。
Long4,
/// 700ms:结构化过渡。
ExtraLong1,
/// 800ms:结构化过渡。
ExtraLong2,
/// 900ms:结构化过渡。
ExtraLong3,
/// 1000ms:整屏形变(shape morph 的慢档)。
ExtraLong4,
}
impl DurationToken {
/// 毫秒值。
pub const fn ms(self) -> u32 {
match self {
DurationToken::Short1 => 50,
DurationToken::Short2 => 100,
DurationToken::Short3 => 150,
DurationToken::Short4 => 200,
DurationToken::Medium1 => 250,
DurationToken::Medium2 => 300,
DurationToken::Medium3 => 350,
DurationToken::Medium4 => 400,
DurationToken::Long1 => 450,
DurationToken::Long2 => 500,
DurationToken::Long3 => 550,
DurationToken::Long4 => 600,
DurationToken::ExtraLong1 => 700,
DurationToken::ExtraLong2 => 800,
DurationToken::ExtraLong3 => 900,
DurationToken::ExtraLong4 => 1000,
}
}
/// 稳定字符串名(`short1` … `extra-long4`),写配置与日志用。
pub const fn name(self) -> &'static str {
match self {
DurationToken::Short1 => "short1",
DurationToken::Short2 => "short2",
DurationToken::Short3 => "short3",
DurationToken::Short4 => "short4",
DurationToken::Medium1 => "medium1",
DurationToken::Medium2 => "medium2",
DurationToken::Medium3 => "medium3",
DurationToken::Medium4 => "medium4",
DurationToken::Long1 => "long1",
DurationToken::Long2 => "long2",
DurationToken::Long3 => "long3",
DurationToken::Long4 => "long4",
DurationToken::ExtraLong1 => "extra-long1",
DurationToken::ExtraLong2 => "extra-long2",
DurationToken::ExtraLong3 => "extra-long3",
DurationToken::ExtraLong4 => "extra-long4",
}
}
}
/// 三次贝塞尔缓动曲线(CSS `cubic-bezier` 同样的参数含义)。
#[derive(Debug, Clone, Copy, PartialEq)]
pub struct CubicBezier {
/// 控制点 1 的 x(0..1)。
pub x1: f32,
/// 控制点 1 的 y。
pub y1: f32,
/// 控制点 2 的 x(0..1)。
pub x2: f32,
/// 控制点 2 的 y。
pub y2: f32,
}
impl CubicBezier {
/// 线性。
pub const LINEAR: Self = Self {
x1: 0.0,
y1: 0.0,
x2: 1.0,
y2: 1.0,
};
/// 给定时间参数 `t`(0..1),返回进度(0..1,标准曲线不越界)。
pub fn sample(self, t: f32) -> f32 {
let t = t.clamp(0.0, 1.0);
if t == 0.0 || t == 1.0 {
return t;
}
// 二分求 x(s) = t 的曲线参数 s,再取 y(s)。
let mut lo = 0.0_f32;
let mut hi = 1.0_f32;
let mut s = t;
for _ in 0..24 {
s = 0.5 * (lo + hi);
let x = bezier_axis(self.x1, self.x2, s);
if x < t {
lo = s;
} else {
hi = s;
}
}
bezier_axis(self.y1, self.y2, s)
}
}
/// 一维三次贝塞尔:端点固定为 0/1,控制点为 `c1`/`c2`。
fn bezier_axis(c1: f32, c2: f32, s: f32) -> f32 {
let u = 1.0 - s;
3.0 * u * u * s * c1 + 3.0 * u * s * s * c2 + s * s * s
}
/// M3 缓动档位(值取自 Material 动效令牌)。
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
pub enum Easing {
/// 线性(进度指示器)。
Linear,
/// 标准:起止都平滑。
Standard,
/// 标准减速:进场(出现在屏幕上)。
StandardDecelerate,
/// 标准加速:退场。
StandardAccelerate,
/// 强调:注意力转移的主曲线。
Emphasized,
/// 强调减速:强调类进场。
EmphasizedDecelerate,
/// 强调加速:强调类退场。
EmphasizedAccelerate,
}
impl Easing {
/// 曲线参数。
pub const fn curve(self) -> CubicBezier {
match self {
Easing::Linear => CubicBezier::LINEAR,
Easing::Standard | Easing::Emphasized => CubicBezier {
x1: 0.2,
y1: 0.0,
x2: 0.0,
y2: 1.0,
},
Easing::StandardDecelerate => CubicBezier {
x1: 0.0,
y1: 0.0,
x2: 0.0,
y2: 1.0,
},
Easing::StandardAccelerate => CubicBezier {
x1: 0.3,
y1: 0.0,
x2: 1.0,
y2: 1.0,
},
Easing::EmphasizedDecelerate => CubicBezier {
x1: 0.05,
y1: 0.7,
x2: 0.1,
y2: 1.0,
},
Easing::EmphasizedAccelerate => CubicBezier {
x1: 0.3,
y1: 0.0,
x2: 0.8,
y2: 0.15,
},
}
}
/// 采样:`t` 0..1 → 进度 0..1。
pub fn sample(self, t: f32) -> f32 {
self.curve().sample(t)
}
/// 该曲线搭配的常用时长(我们的组件映射口径,非规范值)。
pub const fn default_duration(self) -> DurationToken {
match self {
Easing::Linear => DurationToken::Long2,
_ => DurationToken::Medium2,
}
}
}
#[cfg(test)]
mod tests {
use super::*;
const ALL_EASINGS: [Easing; 7] = [
Easing::Linear,
Easing::Standard,
Easing::StandardDecelerate,
Easing::StandardAccelerate,
Easing::Emphasized,
Easing::EmphasizedDecelerate,
Easing::EmphasizedAccelerate,
];
#[test]
fn durations_are_monotone() {
let ladder = [
DurationToken::Short1,
DurationToken::Short2,
DurationToken::Short3,
DurationToken::Short4,
DurationToken::Medium1,
DurationToken::Medium2,
DurationToken::Medium3,
DurationToken::Medium4,
DurationToken::Long1,
DurationToken::Long2,
DurationToken::Long3,
DurationToken::Long4,
DurationToken::ExtraLong1,
DurationToken::ExtraLong2,
DurationToken::ExtraLong3,
DurationToken::ExtraLong4,
];
assert_eq!(ladder.len(), 16);
for pair in ladder.windows(2) {
assert!(pair[0].ms() < pair[1].ms());
}
assert_eq!(DurationToken::Short1.ms(), 50);
assert_eq!(DurationToken::ExtraLong4.ms(), 1000);
// 名字唯一
let mut names = std::collections::BTreeSet::new();
for token in ladder {
assert!(names.insert(token.name()), "时长名重复:{}", token.name());
}
}
#[test]
fn easing_hits_endpoints_and_stays_in_range() {
for easing in ALL_EASINGS {
assert!(
(easing.sample(0.0) - 0.0).abs() < 1.0e-6,
"{easing:?} 起点不为 0"
);
assert!(
(easing.sample(1.0) - 1.0).abs() < 1.0e-6,
"{easing:?} 终点不为 1"
);
let mut prev = 0.0;
for step in 1..=20 {
let v = easing.sample(step as f32 / 20.0);
assert!((-0.001..=1.001).contains(&v), "{easing:?} 采样越界:{v}");
assert!(v + 1.0e-3 >= prev, "{easing:?} 采样非单调:{prev} → {v}");
prev = v;
}
}
}
#[test]
fn emphasized_decelerate_is_faster_early_than_accelerate() {
let early_decel = Easing::EmphasizedDecelerate.sample(0.25);
let early_accel = Easing::EmphasizedAccelerate.sample(0.25);
assert!(early_decel > early_accel);
}
#[test]
fn default_durations_are_registered_tokens() {
for easing in ALL_EASINGS {
assert!(easing.default_duration().ms() >= 250);
}
}
}
+214
View File
@@ -0,0 +1,214 @@
//! 弹簧参数(M3 Expressive 动效的物理表达)与动效时长缩放。
/// 弹簧参数。
///
/// **口径说明**:Material 的弹簧预置是「按体感标定」的一组 token,官方没有公开统一公式
/// (Android 侧是 `SpringForce` 参数 + 阻尼比)。[`Spring::sample`] 走的是标准二阶系统阶跃响应
/// **闭式解**——数学是真的,参数是我们的标定值(待 M63 视觉规格对齐)。
#[derive(Debug, Clone, Copy, PartialEq)]
pub struct Spring {
/// 刚度(越大越快)。
pub stiffness: f32,
/// 阻尼系数(临界阻尼 = 2·√(k·m))。
pub damping: f32,
/// 质量。
pub mass: f32,
}
impl Spring {
/// 构造(`mass` 取 1.0 时 stiffness/damping 就是常见标定值)。
pub const fn new(stiffness: f32, damping: f32, mass: f32) -> Self {
Self {
stiffness,
damping,
mass,
}
}
/// 自然角频率 ωn(rad/s)。
pub fn natural_frequency(self) -> f32 {
(self.stiffness / self.mass).sqrt()
}
/// 阻尼比 ζ(1.0 = 临界阻尼;< 1 会轻微弹过)。
pub fn damping_ratio(self) -> f32 {
let denom = 2.0 * (self.stiffness * self.mass).sqrt();
if denom <= 0.0 {
1.0
} else {
self.damping / denom
}
}
/// 阶跃响应:`t_ms` 毫秒后的进度(0..1,欠阻尼时会轻微超过 1 = 回弹)。
pub fn sample(self, t_ms: f32) -> f32 {
if t_ms <= 0.0 {
return 0.0;
}
let t = t_ms / 1000.0;
let wn = self.natural_frequency();
let zeta = self.damping_ratio();
if wn <= 0.0 {
return 1.0;
}
if zeta < 1.0 {
let wd = wn * (1.0 - zeta * zeta).sqrt();
let decay = (-zeta * wn * t).exp();
1.0 - decay * ((wd * t).cos() + (zeta * wn / wd) * (wd * t).sin())
} else if (zeta - 1.0).abs() < 1.0e-4 {
let decay = (-wn * t).exp();
1.0 - decay * (1.0 + wn * t)
} else {
let root = (zeta * zeta - 1.0).sqrt();
let r1 = -wn * (zeta - root);
let r2 = -wn * (zeta + root);
let denom = r2 - r1;
if denom.abs() < f32::EPSILON {
return 1.0;
}
1.0 - (r2 * (r1 * t).exp() - r1 * (r2 * t).exp()) / denom
}
}
/// 收敛到 `threshold`(0.1% 是常用的「停下来」判定)所需毫秒数。
pub fn settle_ms(self, threshold: f32) -> f32 {
let wn = self.natural_frequency();
let zeta = self.damping_ratio();
if wn <= 0.0 {
return 0.0;
}
let thr = threshold.clamp(1.0e-6, 0.5);
let seconds = if zeta < 1.0 {
let wd = (1.0 - zeta * zeta).sqrt().max(1.0e-4);
-(thr * wd).ln() / (zeta * wn)
} else if (zeta - 1.0).abs() < 1.0e-4 {
-(thr).ln() / wn
} else {
let root = (zeta * zeta - 1.0).sqrt();
let slow = wn * (zeta - root);
-(thr).ln() / slow
};
(seconds * 1000.0).max(0.0)
}
}
/// 空间类快速弹簧(位移/尺寸):紧、几乎不弹。
///
/// TODO(M63): 取值待视觉规格(四组弹簧是我们的临时标定,M63 的动效表落地后对齐)。
pub const SPRING_SPATIAL_FAST: Spring = Spring::new(1400.0, 76.0, 1.0);
/// 空间类默认弹簧(面板、卡片):标准手感。
///
/// TODO(M63): 取值待视觉规格。
pub const SPRING_SPATIAL_DEFAULT: Spring = Spring::new(800.0, 55.0, 1.0);
/// 效应类快速弹簧(透明度/颜色):不带过冲。
///
/// TODO(M63): 取值待视觉规格。
pub const SPRING_EFFECTS_FAST: Spring = Spring::new(3800.0, 120.0, 1.0);
/// 效应类默认弹簧。
///
/// TODO(M63): 取值待视觉规格。
pub const SPRING_EFFECTS_DEFAULT: Spring = Spring::new(1600.0, 80.0, 1.0);
/// 动效时长的全局缩放(用户「减少动效」/CPU 路径关动画时用)。
#[derive(Debug, Clone, Copy, PartialEq)]
pub struct MotionScale {
factor: f32,
}
impl MotionScale {
/// 标准(1.0)。
pub const STANDARD: MotionScale = MotionScale { factor: 1.0 };
/// 关闭动效(时长归零;适配层应直接落终态)。
pub const NONE: MotionScale = MotionScale { factor: 0.0 };
/// 半速(弱动效机器/低帧率兜底)。
pub const HALF: MotionScale = MotionScale { factor: 0.5 };
/// 自定义缩放(夹取到 0.0..=1.0;不放大,避免慢到不可用)。
pub fn new(factor: f32) -> Self {
Self {
factor: factor.clamp(0.0, 1.0),
}
}
/// 缩放系数。
pub fn factor(self) -> f32 {
self.factor
}
/// 是否完全关闭(适配层据此跳过动画)。
pub fn is_off(self) -> bool {
self.factor <= 0.0
}
/// 把某一档时长换算成实际毫秒。
pub fn ms(self, token: super::DurationToken) -> f32 {
token.ms() as f32 * self.factor
}
/// 把弹簧的收敛时间换算成实际毫秒。
pub fn spring_ms(self, spring: Spring) -> f32 {
spring.settle_ms(0.001) * self.factor
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::motion::DurationToken;
const PRESETS: [Spring; 4] = [
SPRING_SPATIAL_FAST,
SPRING_SPATIAL_DEFAULT,
SPRING_EFFECTS_FAST,
SPRING_EFFECTS_DEFAULT,
];
#[test]
fn spring_presets_behave() {
for spring in PRESETS {
assert!(spring.damping_ratio() > 0.5, "弹簧应明显欠阻尼/近临界");
assert_eq!(spring.sample(0.0), 0.0);
let settle = spring.settle_ms(0.001);
assert!(
(60.0..1200.0).contains(&settle),
"收敛时间 {settle}ms 不合理"
);
let end = spring.sample(settle);
assert!((end - 1.0).abs() < 0.01, "收敛后应回到 1.0,实测 {end}");
}
}
#[test]
fn critically_damped_spring_does_not_overshoot() {
let wn = SPRING_SPATIAL_DEFAULT.natural_frequency();
let critical = Spring::new(wn * wn, 2.0 * wn, 1.0);
assert!((critical.damping_ratio() - 1.0).abs() < 1.0e-3);
for step in 1..=40 {
let v = critical.sample(step as f32 * 5.0);
assert!(v <= 1.0 + 1.0e-3, "临界阻尼不应过冲:{v}");
}
}
#[test]
fn overdamped_spring_converges_slowly_without_overshoot() {
let wn = SPRING_SPATIAL_DEFAULT.natural_frequency();
let over = Spring::new(wn * wn, 4.0 * wn, 1.0);
assert!(over.damping_ratio() > 1.5);
let settle = over.settle_ms(0.001);
assert!((over.sample(settle) - 1.0).abs() < 0.01);
for step in 1..=40 {
assert!(over.sample(step as f32 * 10.0) <= 1.0 + 1.0e-3);
}
}
#[test]
fn motion_scale_scales_or_kills_duration() {
assert_eq!(MotionScale::STANDARD.ms(DurationToken::Medium2), 300.0);
assert_eq!(MotionScale::HALF.ms(DurationToken::Medium2), 150.0);
assert_eq!(MotionScale::NONE.ms(DurationToken::Medium2), 0.0);
assert!(MotionScale::NONE.is_off());
assert_eq!(MotionScale::new(5.0).factor(), 1.0);
assert_eq!(MotionScale::new(-1.0).factor(), 0.0);
assert!(MotionScale::STANDARD.spring_ms(SPRING_SPATIAL_DEFAULT) > 0.0);
}
}
+168
View File
@@ -0,0 +1,168 @@
//! M3 Expressive 形状刻度 + shape morph 数据。
//!
//! 形状是「唯一真源」的一部分:组件不许写裸半径(`border_radius(8)`),一律引用
//! [`ShapeScale`];需要动起来的(按下、选中、展开)用 [`ShapeMorph`] 的 from→to 数据。
use crate::motion::Spring;
/// M3 形状刻度(含 Expressive 新增档)。
///
/// 基础档(M3):none 0 / extra-small 4 / small 8 / medium 12 / large 16 / extra-large 28 / full。
/// Expressive 新增档:`large-increased` 20 / `extra-large-increased` 32 / `extra-extra-large` 48。
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
pub enum ShapeScale {
/// 直角(0dp):仅用于贴边容器/分隔。
None,
/// 4dp:小控件(chip、小徽标)。
ExtraSmall,
/// 8dp:控件(按钮、输入框、菜单项)。
Small,
/// 12dp:卡片、列表容器(Expressive 里卡片默认升到这一档)。
Medium,
/// 16dp:大卡片、面板。
Large,
/// 20dp(Expressive):按下/选中时的「胀开」档。
LargeIncreased,
/// 28dp:浮层、对话框。
ExtraLarge,
/// 32dp(Expressive):大浮层、FAB 展开态。
ExtraLargeIncreased,
/// 48dp(Expressive):超大容器、模态全屏卡。
ExtraExtraLarge,
/// 药丸/圆:按边长取半径一半(导航指示条、头像、switch 轨道)。
Full,
}
impl ShapeScale {
/// 半径(dp;[`ShapeScale::Full`] 返回 `f32::INFINITY`,由 [`ShapeScale::radius_px`] 折算)。
pub const fn dp(self) -> f32 {
match self {
ShapeScale::None => 0.0,
ShapeScale::ExtraSmall => 4.0,
ShapeScale::Small => 8.0,
ShapeScale::Medium => 12.0,
ShapeScale::Large => 16.0,
ShapeScale::LargeIncreased => 20.0,
ShapeScale::ExtraLarge => 28.0,
ShapeScale::ExtraLargeIncreased => 32.0,
ShapeScale::ExtraExtraLarge => 48.0,
ShapeScale::Full => f32::INFINITY,
}
}
/// 实际渲染半径:`Full` 取边长一半,其余不超过边长一半(避免方角变圆角溢出)。
pub fn radius_px(self, size_px: f32) -> f32 {
let half = size_px.max(0.0) / 2.0;
if self == ShapeScale::Full {
half
} else {
self.dp().min(half)
}
}
/// 是否是 Expressive 才有的档位(基础 M3 没有)。
pub const fn is_expressive(self) -> bool {
matches!(
self,
ShapeScale::LargeIncreased
| ShapeScale::ExtraLargeIncreased
| ShapeScale::ExtraExtraLarge
)
}
/// 在形状阶梯上的序号(0 = None,9 = Full),用于断言形变方向是「胀开」而不是「收紧」。
pub const fn rank(self) -> u8 {
match self {
ShapeScale::None => 0,
ShapeScale::ExtraSmall => 1,
ShapeScale::Small => 2,
ShapeScale::Medium => 3,
ShapeScale::Large => 4,
ShapeScale::LargeIncreased => 5,
ShapeScale::ExtraLarge => 6,
ShapeScale::ExtraLargeIncreased => 7,
ShapeScale::ExtraExtraLarge => 8,
ShapeScale::Full => 9,
}
}
}
/// 一次形状形变(Expressive 的「按下胀开」「展开成药丸」等)。
///
/// 数据形态是纯的:起止档位 + 弹簧参数。适配层负责跑时钟,`progress` 由弹簧/缓动给出。
#[derive(Debug, Clone, Copy, PartialEq)]
pub struct ShapeMorph {
/// 静息档位。
pub from: ShapeScale,
/// 目标档位。
pub to: ShapeScale,
/// 驱动这次形变的弹簧。
pub spring: Spring,
}
impl ShapeMorph {
/// 恒定弹簧的形变。
pub const fn new(from: ShapeScale, to: ShapeScale, spring: Spring) -> Self {
Self { from, to, spring }
}
/// 在 `progress`(0..1,已含缓动)处的半径。`Full` 端点按 `size_px` 折算。
pub fn radius_px(&self, size_px: f32, progress: f32) -> f32 {
let t = progress.clamp(0.0, 1.0);
let a = self.from.radius_px(size_px);
let b = self.to.radius_px(size_px);
a + (b - a) * t
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::motion::SPRING_SPATIAL_DEFAULT;
#[test]
fn scale_ladder_is_monotone() {
let ladder = [
ShapeScale::None,
ShapeScale::ExtraSmall,
ShapeScale::Small,
ShapeScale::Medium,
ShapeScale::Large,
ShapeScale::LargeIncreased,
ShapeScale::ExtraLarge,
ShapeScale::ExtraLargeIncreased,
ShapeScale::ExtraExtraLarge,
];
for pair in ladder.windows(2) {
assert!(
pair[0].dp() < pair[1].dp(),
"{:?} 应小于 {:?}",
pair[0],
pair[1]
);
}
}
#[test]
fn full_is_half_of_edge_and_clamped() {
assert_eq!(ShapeScale::Full.radius_px(48.0), 24.0);
assert_eq!(ShapeScale::ExtraLarge.radius_px(20.0), 10.0);
assert_eq!(ShapeScale::ExtraLarge.radius_px(200.0), 28.0);
assert_eq!(ShapeScale::None.radius_px(200.0), 0.0);
}
#[test]
fn morph_hits_both_endpoints() {
let morph = ShapeMorph::new(
ShapeScale::Small,
ShapeScale::LargeIncreased,
SPRING_SPATIAL_DEFAULT,
);
assert_eq!(morph.radius_px(200.0, 0.0), 8.0);
assert_eq!(morph.radius_px(200.0, 1.0), 20.0);
assert_eq!(morph.radius_px(200.0, 0.5), 14.0);
// 越界 progress 夹取,不产生负半径/超大半径。
assert_eq!(morph.radius_px(200.0, -2.0), 8.0);
assert_eq!(morph.radius_px(200.0, 3.0), 20.0);
}
}
+162
View File
@@ -0,0 +1,162 @@
//! 形状 / 高程 / 动效之外的「尺子」:4px 栅格与骨架尺寸。
//!
//! 源流有两条:`DevDocs/ops/slint-reference/crates/app/ui/theme/metrics.slint`(M52 定稿的骨架节奏),
//! 以及 `ui-integration-contract.md` §3「间距只允许 4/8/12/16/24/32」。M3 同样用 4dp 栅格,
//! 所以这层**不用改口径**,只是把值搬进唯一真源。
/// 栅格基数(dp)。
pub const GRID: f32 = 4.0;
/// 间距档位(M3/M2 通用 4px 栅格)。禁止 6/10/14 这类非整倍数值。
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
pub enum Space {
/// 4dp:图标与文字之间。
X1,
/// 8dp:控件内边距、行内元素间距。
X2,
/// 12dp:控件间距。
X3,
/// 16dp:区块内行距、卡片内边距下限。
X4,
/// 24dp:区块间距、页面内边距。
X6,
/// 32dp:页级大留白。
X8,
}
impl Space {
/// 间距值(dp)。
pub const fn dp(self) -> f32 {
match self {
Space::X1 => 4.0,
Space::X2 => 8.0,
Space::X3 => 12.0,
Space::X4 => 16.0,
Space::X6 => 24.0,
Space::X8 => 32.0,
}
}
/// 全部档位,由小到大。
pub const ALL: [Space; 6] = [
Space::X1,
Space::X2,
Space::X3,
Space::X4,
Space::X6,
Space::X8,
];
}
/// 把任意数值对齐到 4px 栅格(最近档)。
pub fn snap_to_grid(value: f32) -> f32 {
(value / GRID).round() * GRID
}
/// 骨架尺寸:窗口结构(标题栏、导航、页头、行高)的单一来源。
///
/// 这些值不是 M3 规范值(M3 只规定组件内部节奏),而是本启动器的**布局契约**,
/// 由页面/组件两条线共同消费——任何页面自己写 `56px` 都是重复定义。
#[derive(Debug, Clone, Copy, PartialEq)]
pub struct LayoutMetrics {
/// 标题栏高度。
pub titlebar_height: f32,
/// 标题栏按钮宽度(关闭/最大化/最小化)。
pub titlebar_button_width: f32,
/// 展开态导航宽度。
pub nav_expanded_width: f32,
/// 收起态导航宽度。
pub nav_collapsed_width: f32,
/// 导航项高度。
pub nav_item_height: f32,
/// 导航项间距。
pub nav_item_gap: f32,
/// 单行页头高度。
pub page_header_height: f32,
/// 两行页头高度(日志页)。
pub page_header_tall_height: f32,
/// 运行中状态条高度。
pub running_strip_height: f32,
/// 列表行高。
pub row_height: f32,
/// 标准控件高(按钮/输入框)。
pub control_height: f32,
/// 最小可点区域。
pub touch_target: f32,
/// 卡片内边距。
pub card_padding: f32,
/// AI 面板宽度。
pub ai_panel_width: f32,
}
impl LayoutMetrics {
/// 桌面基线(1180×760 窗口,M53 页面骨架的取值)。
pub const DESKTOP: Self = Self {
titlebar_height: 40.0,
titlebar_button_width: 46.0,
nav_expanded_width: 88.0,
nav_collapsed_width: 48.0,
nav_item_height: 56.0,
nav_item_gap: 4.0,
page_header_height: 56.0,
page_header_tall_height: 84.0,
running_strip_height: 32.0,
row_height: 44.0,
control_height: 40.0,
touch_target: 44.0,
card_padding: 20.0,
ai_panel_width: 320.0,
};
}
impl LayoutMetrics {
/// 页头是否两行(日志页),决定主区与 AI 面板分隔线对齐的高度。
pub fn header_height(&self, two_line: bool) -> f32 {
if two_line {
self.page_header_tall_height
} else {
self.page_header_height
}
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn every_space_is_on_the_grid() {
for space in Space::ALL {
assert_eq!(space.dp() % GRID, 0.0, "{space:?} 不在 4px 栅格上");
}
}
#[test]
fn layout_values_are_on_the_grid() {
let m = LayoutMetrics::DESKTOP;
for (name, value) in [
("titlebar_height", m.titlebar_height),
("nav_item_height", m.nav_item_height),
("page_header_height", m.page_header_height),
("page_header_tall_height", m.page_header_tall_height),
("row_height", m.row_height),
("control_height", m.control_height),
] {
assert_eq!(value % GRID, 0.0, "{name} = {value} 不在 4px 栅格上");
}
}
#[test]
fn snap_rounds_to_nearest_grid_step() {
assert_eq!(snap_to_grid(5.0), 4.0);
assert_eq!(snap_to_grid(6.5), 8.0);
assert_eq!(snap_to_grid(0.0), 0.0);
}
#[test]
fn header_height_follows_line_count() {
let m = LayoutMetrics::DESKTOP;
assert_eq!(m.header_height(false), 56.0);
assert_eq!(m.header_height(true), 84.0);
}
}
+181
View File
@@ -0,0 +1,181 @@
//! 三平台字体族回退栈(Win10+ / macOS 12+ / Linux)。
//!
//! 只写族名,不写文件路径、不内嵌字体(D14 + D28)。每条栈的**最后一项必须是通用族名**,
//! 否则干净安装的系统上会退化成豆腐块。
/// 平台(决定字体族回退栈)。
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
pub enum Platform {
/// Windows 10/11。
Windows,
/// macOS 12+。
MacOs,
/// Linux(X11/Wayland,桌面发行版)。
Linux,
}
impl Platform {
/// 当前编译目标对应的平台(运行时用户覆盖请显式传 [`Platform`])。
pub const fn current() -> Self {
if cfg!(target_os = "windows") {
Platform::Windows
} else if cfg!(target_os = "macos") {
Platform::MacOs
} else {
Platform::Linux
}
}
/// 稳定字符串名。
pub const fn name(self) -> &'static str {
match self {
Platform::Windows => "windows",
Platform::MacOs => "macos",
Platform::Linux => "linux",
}
}
/// 字体族回退栈(由首选到兜底,最后一项是通用族名)。
///
/// iced/cosmic-text 按顺序找第一个装了的族;`sans-serif` / `monospace` 是通用兜底,
/// 保证「一台干净安装的系统」也不会渲染成豆腐块。
pub const fn font_stack(self, family: FontFamily) -> &'static [&'static str] {
match (self, family) {
// Win10 起 Segoe UI 一定在;Win11 有光学尺寸分离的 Variable 版。
(Platform::Windows, FontFamily::Display) => &[
"Segoe UI Variable Display",
"Segoe UI",
"Microsoft YaHei UI",
"Microsoft YaHei",
"sans-serif",
],
(Platform::Windows, FontFamily::Text) => &[
"Segoe UI Variable Text",
"Segoe UI",
"Microsoft YaHei UI",
"Microsoft YaHei",
"sans-serif",
],
(Platform::Windows, FontFamily::Mono) => {
&["Cascadia Mono", "Consolas", "Courier New", "monospace"]
}
// macOS 12+:SF Pro 分 Display/Text 两种光学尺寸;中文走苹方。
(Platform::MacOs, FontFamily::Display) => &[
"SF Pro Display",
"Helvetica Neue",
"PingFang SC",
"sans-serif",
],
(Platform::MacOs, FontFamily::Text) => {
&["SF Pro Text", "Helvetica Neue", "PingFang SC", "sans-serif"]
}
(Platform::MacOs, FontFamily::Mono) => &["SF Mono", "Menlo", "Monaco", "monospace"],
// Linux:不假设发行版预装什么,Roboto/Noto 是最常见的两套。
(Platform::Linux, FontFamily::Display) | (Platform::Linux, FontFamily::Text) => &[
"Roboto",
"Noto Sans",
"Noto Sans CJK SC",
"DejaVu Sans",
"sans-serif",
],
(Platform::Linux, FontFamily::Mono) => &[
"JetBrains Mono",
"Noto Sans Mono",
"DejaVu Sans Mono",
"monospace",
],
}
}
}
/// 字体族的用途分类(决定取哪条栈)。
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
pub enum FontFamily {
/// 大字号展示(display/headline):光学尺寸更大。
Display,
/// 正文(title/body/label)。
Text,
/// 等宽(日志、代码、版本号)。
Mono,
}
#[cfg(test)]
mod tests {
use super::*;
const PLATFORMS: [Platform; 3] = [Platform::Windows, Platform::MacOs, Platform::Linux];
const FAMILIES: [FontFamily; 3] = [FontFamily::Display, FontFamily::Text, FontFamily::Mono];
#[test]
fn font_stacks_are_well_formed() {
for platform in PLATFORMS {
for family in FAMILIES {
let stack = platform.font_stack(family);
assert!(
!stack.is_empty(),
"{} 的 {family:?} 栈为空",
platform.name()
);
let last = stack[stack.len() - 1];
let generic = match family {
FontFamily::Mono => "monospace",
_ => "sans-serif",
};
assert_eq!(
last,
generic,
"{} 的 {family:?} 栈必须以通用族名兜底",
platform.name()
);
for name in stack {
assert!(!name.is_empty());
assert!(
!name.contains(['/', '\\', '.', ':']),
"字体栈只允许族名,不得出现路径/文件名:{name}"
);
assert!(
name.is_ascii(),
"族名应为 ASCII(非 ASCII 名交给系统匹配):{name}"
);
}
}
}
}
#[test]
fn display_and_text_stacks_differ_on_windows_and_macos() {
assert_ne!(
Platform::Windows.font_stack(FontFamily::Display)[0],
Platform::Windows.font_stack(FontFamily::Text)[0]
);
assert_ne!(
Platform::MacOs.font_stack(FontFamily::Display)[0],
Platform::MacOs.font_stack(FontFamily::Text)[0]
);
}
#[test]
fn chinese_fallback_is_present_but_not_first() {
// 中文族名必须在栈里(否则 CJK 走系统随机匹配),但不能排第一(纯英文环境不加载中文字体)。
for platform in PLATFORMS {
let stack = platform.font_stack(FontFamily::Text);
let cjk = ["Microsoft YaHei UI", "PingFang SC", "Noto Sans CJK SC"];
assert!(
stack.iter().any(|name| cjk.contains(name)),
"{} 的正文栈缺 CJK 兜底",
platform.name()
);
assert!(
!cjk.contains(&stack[0]),
"{} 的首选字体不该是 CJK 族",
platform.name()
);
}
}
#[test]
fn current_platform_returns_a_stack() {
let platform = Platform::current();
assert!(!platform.font_stack(FontFamily::Text).is_empty());
}
}
+308
View File
@@ -0,0 +1,308 @@
//! M3 type scale(15 档)+ 三平台字体族回退栈。
//!
//! **只写字体族名,不内嵌任何字体文件**(Segoe UI / SF Pro / Roboto 都受各自许可约束,
//! 内嵌再分发是违规的;引用系统已安装的族名是合规且体积为零的做法——这是 D14 与 D28 的共同要求)。
//! 中文由系统字体匹配:栈里显式列出 CJK 族名,纯英文环境不会去加载任何中文字体(D28)。
mod fonts;
pub use fonts::{FontFamily, Platform};
/// 字重(M3 只用这四档;可变字体按连续值渲染时取 [`Weight::value`])。
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
pub enum Weight {
/// 400:正文。
Regular,
/// 500:label / title-medium。
Medium,
/// 600:强调标题(本项目的「区块标题」档)。
SemiBold,
/// 700:页面标题 / 空态主文案。
Bold,
}
impl Weight {
/// 数值字重(CSS/OpenType 口径)。
pub const fn value(self) -> u16 {
match self {
Weight::Regular => 400,
Weight::Medium => 500,
Weight::SemiBold => 600,
Weight::Bold => 700,
}
}
/// 再重一档(Expressive 的 emphasized 用得上,最重封顶 Bold)。
pub const fn heavier(self) -> Self {
match self {
Weight::Regular => Weight::Medium,
Weight::Medium => Weight::SemiBold,
Weight::SemiBold | Weight::Bold => Weight::Bold,
}
}
}
/// 一条排版样式(已解析成渲染要用的四个数)。
#[derive(Debug, Clone, Copy, PartialEq)]
pub struct TypeStyle {
/// 字号(px;1.0 缩放时与 dp 同值)。
pub size_px: f32,
/// 行高(px)。
pub line_height_px: f32,
/// 字重。
pub weight: Weight,
/// 字间距(px;M3 的 type scale 用 px 表达,中文场景下正字距更易读)。
pub letter_spacing_px: f32,
/// 用哪条字体栈。
pub family: FontFamily,
}
impl TypeStyle {
/// 按用户字号缩放(`factor` 1.0 = 标准;行高与字距同比例,夹取到 0.5..2.0)。
pub fn scaled(self, factor: f32) -> Self {
let f = factor.clamp(0.5, 2.0);
Self {
size_px: self.size_px * f,
line_height_px: self.line_height_px * f,
letter_spacing_px: self.letter_spacing_px * f,
..self
}
}
/// 该样式的字体族回退栈。
pub fn font_stack(self, platform: Platform) -> &'static [&'static str] {
platform.font_stack(self.family)
}
}
/// M3 type scale 的 15 个角色。
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
pub enum TypeRole {
/// 57/64:落地页大标题(本启动器基本不用)。
DisplayLarge,
/// 45/52。
DisplayMedium,
/// 36/44。
DisplaySmall,
/// 32/40:空态主标题的大档。
HeadlineLarge,
/// 28/36。
HeadlineMedium,
/// 24/32。
HeadlineSmall,
/// 22/28:页面标题(导航栏下的主标题)。
TitleLarge,
/// 16/24:卡片标题。
TitleMedium,
/// 14/20:行标题。
TitleSmall,
/// 16/24:正文大档(说明文字)。
BodyLarge,
/// 14/20:默认正文。
BodyMedium,
/// 12/16:辅助说明。
BodySmall,
/// 14/20:按钮文字。
LabelLarge,
/// 12/16:标签、徽标。
LabelMedium,
/// 11/16:最小标签(谨慎使用,中文 11px 可读性差)。
LabelSmall,
}
impl TypeRole {
/// 全部 15 档,按 M3 的族序排列。
pub const ALL: [TypeRole; 15] = [
TypeRole::DisplayLarge,
TypeRole::DisplayMedium,
TypeRole::DisplaySmall,
TypeRole::HeadlineLarge,
TypeRole::HeadlineMedium,
TypeRole::HeadlineSmall,
TypeRole::TitleLarge,
TypeRole::TitleMedium,
TypeRole::TitleSmall,
TypeRole::BodyLarge,
TypeRole::BodyMedium,
TypeRole::BodySmall,
TypeRole::LabelLarge,
TypeRole::LabelMedium,
TypeRole::LabelSmall,
];
/// 稳定字符串名(`display-large` … `label-small`)。
pub const fn name(self) -> &'static str {
match self {
TypeRole::DisplayLarge => "display-large",
TypeRole::DisplayMedium => "display-medium",
TypeRole::DisplaySmall => "display-small",
TypeRole::HeadlineLarge => "headline-large",
TypeRole::HeadlineMedium => "headline-medium",
TypeRole::HeadlineSmall => "headline-small",
TypeRole::TitleLarge => "title-large",
TypeRole::TitleMedium => "title-medium",
TypeRole::TitleSmall => "title-small",
TypeRole::BodyLarge => "body-large",
TypeRole::BodyMedium => "body-medium",
TypeRole::BodySmall => "body-small",
TypeRole::LabelLarge => "label-large",
TypeRole::LabelMedium => "label-medium",
TypeRole::LabelSmall => "label-small",
}
}
/// M3 基线样式(字号 / 行高 / 字重 / 字距,官方 type scale 值)。
pub const fn style(self) -> TypeStyle {
match self {
TypeRole::DisplayLarge => {
style(57.0, 64.0, Weight::Regular, -0.25, FontFamily::Display)
}
TypeRole::DisplayMedium => style(45.0, 52.0, Weight::Regular, 0.0, FontFamily::Display),
TypeRole::DisplaySmall => style(36.0, 44.0, Weight::Regular, 0.0, FontFamily::Display),
TypeRole::HeadlineLarge => style(32.0, 40.0, Weight::Regular, 0.0, FontFamily::Display),
TypeRole::HeadlineMedium => {
style(28.0, 36.0, Weight::Regular, 0.0, FontFamily::Display)
}
TypeRole::HeadlineSmall => style(24.0, 32.0, Weight::Regular, 0.0, FontFamily::Display),
TypeRole::TitleLarge => style(22.0, 28.0, Weight::Regular, 0.0, FontFamily::Text),
TypeRole::TitleMedium => style(16.0, 24.0, Weight::Medium, 0.15, FontFamily::Text),
TypeRole::TitleSmall => style(14.0, 20.0, Weight::Medium, 0.1, FontFamily::Text),
TypeRole::BodyLarge => style(16.0, 24.0, Weight::Regular, 0.5, FontFamily::Text),
TypeRole::BodyMedium => style(14.0, 20.0, Weight::Regular, 0.25, FontFamily::Text),
TypeRole::BodySmall => style(12.0, 16.0, Weight::Regular, 0.4, FontFamily::Text),
TypeRole::LabelLarge => style(14.0, 20.0, Weight::Medium, 0.1, FontFamily::Text),
TypeRole::LabelMedium => style(12.0, 16.0, Weight::Medium, 0.5, FontFamily::Text),
TypeRole::LabelSmall => style(11.0, 16.0, Weight::Medium, 0.5, FontFamily::Text),
}
}
/// Expressive 强调档:**字重升一级,字号/行高/字距不变**。
///
/// 口径声明:M3 Expressive 确实有 emphasized 排版档,但官方没公开与这 15 档一一对应的数值;
/// 这里定的是**我们自己的规则**(加粗一档),目的是「同一 role 的强调态只改一个字重」,
/// 保证页面不会因为强调而改变布局高度。要更强的层级请换更大 role,不要继续加字重。
pub const fn emphasized(self) -> TypeStyle {
let base = self.style();
TypeStyle {
weight: base.weight.heavier(),
..base
}
}
}
/// `const fn` 用的构造器。
const fn style(
size: f32,
line_height: f32,
weight: Weight,
tracking_px: f32,
family: FontFamily,
) -> TypeStyle {
TypeStyle {
size_px: size,
line_height_px: line_height,
weight,
letter_spacing_px: tracking_px,
family,
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn every_role_has_sane_metrics() {
for role in TypeRole::ALL {
let s = role.style();
assert!(s.size_px >= 11.0, "{} 字号过小", role.name());
assert!(
s.line_height_px >= s.size_px,
"{} 行高小于字号({} < {})",
role.name(),
s.line_height_px,
s.size_px
);
assert!(
(1.1..=1.8).contains(&(s.line_height_px / s.size_px)),
"{} 行高倍数 {} 超出 1.1..1.8(M3 的行高是 1.12~1.5 量级)",
role.name(),
s.line_height_px / s.size_px
);
assert!(s.letter_spacing_px.abs() < 1.0, "{} 字距过大", role.name());
}
}
#[test]
fn each_family_is_monotone_by_size() {
// M3 的 type scale 是「五族 × 三档」,跨族不保证单调(title-small 14 → body-large 16),
// 但族内必须由大到小。
for family in [
[
TypeRole::DisplayLarge,
TypeRole::DisplayMedium,
TypeRole::DisplaySmall,
],
[
TypeRole::HeadlineLarge,
TypeRole::HeadlineMedium,
TypeRole::HeadlineSmall,
],
[
TypeRole::TitleLarge,
TypeRole::TitleMedium,
TypeRole::TitleSmall,
],
[
TypeRole::BodyLarge,
TypeRole::BodyMedium,
TypeRole::BodySmall,
],
[
TypeRole::LabelLarge,
TypeRole::LabelMedium,
TypeRole::LabelSmall,
],
] {
for pair in family.windows(2) {
assert!(
pair[0].style().size_px > pair[1].style().size_px,
"{} 应大于 {}",
pair[0].name(),
pair[1].name()
);
}
}
}
#[test]
fn names_are_unique_and_kebab() {
let mut seen = std::collections::BTreeSet::new();
for role in TypeRole::ALL {
let name = role.name();
assert!(seen.insert(name), "排版角色名重复:{name}");
assert!(name.chars().all(|c| c.is_ascii_lowercase() || c == '-'));
}
}
#[test]
fn emphasized_only_bumps_weight() {
for role in TypeRole::ALL {
let base = role.style();
let emph = role.emphasized();
assert_eq!(base.size_px, emph.size_px);
assert_eq!(base.line_height_px, emph.line_height_px);
assert!(emph.weight.value() > base.weight.value() || base.weight == Weight::Bold);
}
}
#[test]
fn scaling_is_proportional_and_bounded() {
let base = TypeRole::BodyMedium.style();
let big = base.scaled(1.25);
assert!((big.size_px - 17.5).abs() < 1.0e-4);
assert!((big.line_height_px - 25.0).abs() < 1.0e-4);
assert_eq!(base.scaled(10.0).size_px, base.size_px * 2.0);
assert_eq!(base.scaled(0.1).size_px, base.size_px * 0.5);
}
}
+199
View File
@@ -0,0 +1,199 @@
//! WCAG AA 对比度门禁(M61 任务:「on-* 与其承载色至少满足 4.5:1,不达标的角色要显式标注例外」)。
//!
//! 三个性质让这条门禁可以做到「**换任何种子色都成立**」,而不是只对出厂紫成立:
//! 1. 颜色建在 CIELAB `L*`(tone)轴上,`TonalPalette::tone` 精确命中 tone(误差 ≤1);
//! 2. WCAG 相对亮度只取决于同一条 Y 轴(`L*` ↔ Y 是双射);
//! 3. 角色 → tone 的映射与色相/彩度无关。
//!
//! 所以对比度主要由「两个角色的 tone 差」决定,与 user 选的强调色无关——下面对多组种子
//! 逐一验证,就是在钉这个性质。
use unlucky_design::color::{Color, Role, Scheme, SchemeFlavor};
/// 正文文字:要求 ≥ 4.5:1(WCAG AA normal text)。
const AA_TEXT: f32 = 4.5;
/// 非文字元素(描边、图标块):要求 ≥ 3:1(WCAG AA non-text)。
const AA_NON_TEXT: f32 = 3.0;
/// 文字/图标对(前景, 承载色)。两个模式都要过。
const TEXT_PAIRS: [(Role, Role); 26] = [
(Role::OnPrimary, Role::Primary),
(Role::OnPrimaryContainer, Role::PrimaryContainer),
(Role::OnSecondary, Role::Secondary),
(Role::OnSecondaryContainer, Role::SecondaryContainer),
(Role::OnTertiary, Role::Tertiary),
(Role::OnTertiaryContainer, Role::TertiaryContainer),
(Role::OnError, Role::Error),
(Role::OnErrorContainer, Role::ErrorContainer),
(Role::OnSurface, Role::Surface),
(Role::OnSurfaceVariant, Role::SurfaceVariant),
(Role::OnBackground, Role::Background),
(Role::InverseOnSurface, Role::InverseSurface),
(Role::InversePrimary, Role::InverseSurface),
(Role::OnPrimaryFixed, Role::PrimaryFixed),
(Role::OnPrimaryFixedVariant, Role::PrimaryFixedDim),
(Role::OnSecondaryFixed, Role::SecondaryFixed),
(Role::OnSecondaryFixedVariant, Role::SecondaryFixedDim),
(Role::OnTertiaryFixed, Role::TertiaryFixed),
(Role::OnTertiaryFixedVariant, Role::TertiaryFixedDim),
// 卡片/浮层容器上的文字(M3 现行口径:层级靠 surface-container-* 表达)
(Role::OnSurface, Role::SurfaceContainerLowest),
(Role::OnSurface, Role::SurfaceContainerLow),
(Role::OnSurface, Role::SurfaceContainer),
(Role::OnSurface, Role::SurfaceContainerHigh),
(Role::OnSurface, Role::SurfaceContainerHighest),
(Role::OnSurfaceVariant, Role::SurfaceContainerHighest),
(Role::OnSurfaceVariant, Role::SurfaceContainerLow),
];
/// 显式登记的例外:`(前景, 承载色, 目标阈值, 原因)`。
///
/// 这些不是缺陷,是 M3 定义决定的(非文字元素 3:1、装饰件无要求)。
const EXCEPTIONS: [(Role, Role, f32, &str); 3] = [
(
Role::Outline,
Role::Surface,
AA_NON_TEXT,
"输入框/卡片描边是 M3 定义的非文字元素,规范目标 3:1(light 实测 4.29:1、dark 6.5:1 已高于目标)",
),
(
Role::OutlineVariant,
Role::Surface,
AA_NON_TEXT * 0.5,
"分隔线是纯装饰,M3 不要求对比度下限;1.6:1 是「几乎看不见但要能数出层级」的刻意取值",
),
(
Role::SurfaceVariant,
Role::Surface,
1.0,
"surface-variant 是容器色而不是前景色,不参与对比度门禁(M3 已不推荐用它做容器,保留仅为兼容旧值)",
),
];
fn seeds() -> Vec<Color> {
vec![
// 出厂基线(M3 baseline 紫)
Color::from_hex("#6750A4").expect("基线种子"),
// 冷蓝(原 Fluent 强调色,用户大概率会选这一族)
Color::from_hex("#0078D4").expect("蓝"),
// 青绿
Color::from_hex("#006A5C").expect("青"),
// 暖橙(高彩度,gamut 压力大)
Color::from_hex("#E8710A").expect("橙"),
// 亮黄(最容易被白字吃掉的极端)
Color::from_hex("#FFD400").expect("黄"),
// 近灰(低彩度种子,检验 neutral 不塌)
Color::from_hex("#6E6E6E").expect("灰"),
]
}
#[test]
fn text_pairs_meet_wcag_aa_for_every_seed_and_flavor() {
let mut failures = Vec::new();
for seed in seeds() {
for flavor in [SchemeFlavor::Light, SchemeFlavor::Dark] {
let scheme = Scheme::from_seed(flavor, seed);
for (fg, bg) in TEXT_PAIRS {
let ratio = scheme.contrast(fg, bg);
if ratio < AA_TEXT {
failures.push(format!(
"seed {} / {} / {} on {} = {ratio:.2}",
seed.to_hex(),
flavor.name(),
fg.name(),
bg.name()
));
}
}
}
}
assert!(
failures.is_empty(),
"以下角色对未达 WCAG AA(4.5:1):\n{}",
failures.join("\n")
);
}
#[test]
fn exception_list_is_honest_about_its_thresholds() {
// 例外必须**真的**是「非文字级」,不能悄悄把低于 3:1 的文字对塞进来。
for seed in seeds() {
for flavor in [SchemeFlavor::Light, SchemeFlavor::Dark] {
let scheme = Scheme::from_seed(flavor, seed);
for (fg, bg, threshold, reason) in EXCEPTIONS {
assert!(
!fg.is_foreground() || fg == Role::Outline,
"例外 {} 不该是文字前景色({reason})",
fg.name()
);
let ratio = scheme.contrast(fg, bg);
assert!(
ratio >= threshold,
"例外 {} on {} 实测 {ratio:.2},低于登记阈值 {threshold}(seed {} / {})",
fg.name(),
bg.name(),
seed.to_hex(),
flavor.name()
);
}
}
}
}
#[test]
fn baseline_light_and_dark_both_pass() {
for flavor in [SchemeFlavor::Light, SchemeFlavor::Dark] {
let scheme = Scheme::baseline(flavor);
for (fg, bg) in TEXT_PAIRS {
let ratio = scheme.contrast(fg, bg);
assert!(
ratio >= AA_TEXT,
"baseline {} 的 {} on {} 只有 {ratio:.2}",
flavor.name(),
fg.name(),
bg.name()
);
}
}
}
#[test]
fn accent_override_cannot_break_contrast() {
// 换了强调色之后,primary 族仍必须过 AA——这是「tone 轴固定」带来的结构性保证。
let base = Scheme::baseline(SchemeFlavor::Light);
for seed in seeds() {
let custom = Scheme::from_accent(SchemeFlavor::Light, seed);
for (fg, bg) in [
(Role::OnPrimary, Role::Primary),
(Role::OnPrimaryContainer, Role::PrimaryContainer),
(Role::OnSecondary, Role::Secondary),
(Role::OnTertiary, Role::Tertiary),
] {
let ratio = custom.contrast(fg, bg);
assert!(
ratio >= AA_TEXT,
"强调色 {} 下 {} on {} 只有 {ratio:.2}",
seed.to_hex(),
fg.name(),
bg.name()
);
}
// 表面色不受强调色影响(这条同时是 `from_accent` 的语义断言)。
assert_eq!(custom.get(Role::Surface), base.get(Role::Surface));
}
}
#[test]
fn outline_is_registered_as_non_text_exception() {
// 把「为什么 outline 不是 AA 例外失败项」钉死:它在 light 下 4.29:1(低于 4.5 但高于 3:1)。
let light = Scheme::baseline(SchemeFlavor::Light);
let ratio = light.contrast(Role::Outline, Role::Surface);
assert!(
ratio < AA_TEXT,
"light 的 outline 若已达 4.5 应把例外删掉:{ratio:.2}"
);
assert!(
ratio >= AA_NON_TEXT,
"light 的 outline 低于非文字 3:1:{ratio:.2}"
);
}
+205
View File
@@ -0,0 +1,205 @@
//! 令牌契约测试:从**消费方视角**(只用公开 API)钉住 M61 承诺的那些性质。
//!
//! 这份测试的作用是「冻结」:`IconKind`/`Role` 的次序与个数、type scale 的取值、形状/高程阶梯、
//! 描边的 SVG 语义换算。后续 iced 页面线靠它保证不会因为一次「顺手改动」而悄悄换观感。
use unlucky_design::color::{Role, Scheme, SchemeFlavor, TonalPalette};
use unlucky_design::component_map::Component;
use unlucky_design::elevation::{Elevation, MaterialLayer};
use unlucky_design::icons::{
stroke_width_for, visual_stroke_px, IconKind, IconPaint, IconSize, VIEWBOX,
};
use unlucky_design::motion::{DurationToken, Easing, MotionScale, SPRING_SPATIAL_DEFAULT};
use unlucky_design::shape::ShapeScale;
use unlucky_design::spacing::{LayoutMetrics, Space};
use unlucky_design::typography::{FontFamily, Platform, TypeRole, Weight};
use unlucky_design::DesignTokens;
#[test]
fn color_role_table_is_frozen() {
assert_eq!(Role::ALL.len(), 49, "M3 基础角色集共 49 个");
assert_eq!(Role::ALL[0], Role::Primary);
assert_eq!(Role::ALL[16], Role::Surface);
assert_eq!(Role::ALL[48], Role::OnTertiaryFixedVariant);
// 每个角色都能从两套 scheme 里取到值,且前景角色都带 on- 语义。
let light = Scheme::baseline(SchemeFlavor::Light);
let dark = Scheme::baseline(SchemeFlavor::Dark);
for role in Role::ALL {
assert!(
light.get(role).is_opaque(),
"{} 应当是不透明色",
role.name()
);
assert!(dark.get(role).is_opaque());
if role.name().starts_with("on-") {
assert!(role.is_foreground(), "{} 应被判为前景色", role.name());
}
}
assert!(light.get(Role::Primary).is_opaque());
}
#[test]
fn tone_mapping_is_the_m3_table() {
// 抽查几行关键映射(light / dark),防止有人改 tone 表时静默换观感。
assert_eq!(Scheme::tone_of(Role::Primary, SchemeFlavor::Light), 40.0);
assert_eq!(Scheme::tone_of(Role::Surface, SchemeFlavor::Light), 98.0);
assert_eq!(Scheme::tone_of(Role::Surface, SchemeFlavor::Dark), 6.0);
assert_eq!(
Scheme::tone_of(Role::OnPrimaryContainer, SchemeFlavor::Dark),
90.0
);
assert_eq!(Scheme::tone_of(Role::Outline, SchemeFlavor::Light), 50.0);
}
#[test]
fn type_scale_values_are_the_m3_baseline() {
assert_eq!(TypeRole::ALL.len(), 15);
let title = TypeRole::TitleLarge.style();
assert_eq!((title.size_px, title.line_height_px), (22.0, 28.0));
let body = TypeRole::BodyMedium.style();
assert_eq!((body.size_px, body.line_height_px), (14.0, 20.0));
assert_eq!(body.weight, Weight::Regular);
let label = TypeRole::LabelLarge.style();
assert_eq!(label.weight, Weight::Medium);
assert_eq!(TypeRole::BodySmall.style().size_px, 12.0);
// 强调档只动字重。
assert_eq!(TypeRole::TitleLarge.emphasized().size_px, 22.0);
assert!(TypeRole::TitleMedium.emphasized().weight.value() > Weight::Medium.value());
}
#[test]
fn font_stacks_never_embed_font_files() {
for platform in [Platform::Windows, Platform::MacOs, Platform::Linux] {
for family in [FontFamily::Display, FontFamily::Text, FontFamily::Mono] {
let stack = platform.font_stack(family);
assert!(!stack.is_empty());
for name in stack {
let lower = name.to_ascii_lowercase();
for banned in [".ttf", ".otf", ".ttc", "woff", "file"] {
assert!(
!lower.contains(banned),
"{} 的字体栈出现文件引用 {name}(只允许族名,禁止内嵌受限字体)",
platform.name()
);
}
}
}
}
}
#[test]
fn icon_kind_is_frozen_and_append_only() {
assert_eq!(IconKind::ALL.len(), 28, "M61 冻结 28 个图标");
// 迁移来的 24 个必须保持原次序(序号即持久化 id)。
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);
// 新增的 4 个追加在末尾。
assert_eq!(IconKind::ALL[24], IconKind::Refresh);
assert_eq!(IconKind::ALL[27], IconKind::Trash);
for kind in IconKind::ALL {
assert!(kind.icon().path.starts_with('M'));
assert_eq!(kind.icon().kind, kind);
}
}
#[test]
fn icon_stroke_follows_svg_semantics_not_slint() {
// Slint 口径(绝对 1.5px)在 20px 渲染下会得到 1.5px 视觉线宽;SVG 口径要写 1.8。
let nav_width = IconKind::Settings.icon().stroke_width(IconSize::Nav.px());
assert!(
(nav_width - 1.8).abs() < 1.0e-6,
"20px 渲染应取 1.8 个 viewBox 单位"
);
let button_width = IconKind::Settings
.icon()
.stroke_width(IconSize::Button.px());
assert!((button_width - 2.25).abs() < 1.0e-6);
// 视觉宽度的往返一致性(这条是「别照抄 Slint 的 1.5」的守门测试)。
for size in IconSize::ALL {
let w = stroke_width_for(size.px(), 1.5);
assert!((visual_stroke_px(size.px(), w) - 1.5).abs() < 1.0e-6);
}
assert!((VIEWBOX - 24.0).abs() < f32::EPSILON);
// 实心图标不描边。
assert_eq!(IconKind::Play.icon().paint, IconPaint::Fill);
assert_eq!(IconKind::Play.icon().stroke_width(24.0), 0.0);
}
#[test]
fn shape_and_elevation_ladders() {
assert_eq!(ShapeScale::ExtraSmall.dp(), 4.0);
assert_eq!(ShapeScale::Medium.dp(), 12.0);
assert_eq!(ShapeScale::ExtraLarge.dp(), 28.0);
assert_eq!(ShapeScale::Full.radius_px(40.0), 20.0);
assert!(ShapeScale::ExtraLargeIncreased.is_expressive());
let dp: Vec<f32> = Elevation::ALL.iter().map(|e| e.dp()).collect();
assert_eq!(dp, vec![0.0, 1.0, 3.0, 6.0, 8.0, 12.0]);
assert_eq!(
MaterialLayer::Card.surface_role(),
Role::SurfaceContainerLow
);
}
#[test]
fn motion_tokens_are_usable_as_data() {
assert_eq!(DurationToken::Medium2.ms(), 300);
assert!((Easing::Standard.sample(0.0)).abs() < 1.0e-6);
assert!((Easing::Standard.sample(1.0) - 1.0).abs() < 1.0e-6);
let settle = SPRING_SPATIAL_DEFAULT.settle_ms(0.001);
assert!(settle > 0.0 && settle < 2000.0);
assert_eq!(MotionScale::NONE.ms(DurationToken::Long4), 0.0);
}
#[test]
fn spacing_and_layout_metrics_are_shared() {
assert_eq!(Space::X4.dp(), 16.0);
let metrics = LayoutMetrics::DESKTOP;
assert_eq!(metrics.header_height(false), 56.0);
assert_eq!(metrics.header_height(true), 84.0);
}
#[test]
fn component_map_covers_the_page_skeleton() {
assert_eq!(Component::ALL.len(), 20);
for component in Component::ALL {
let spec = component.spec();
assert_eq!(spec.component, component);
assert!(spec.text.style().size_px >= 11.0);
}
assert_eq!(Component::PrimaryButton.spec().shape, ShapeScale::Full);
// 浮层必须高于卡片层。
assert!(Component::Dialog.spec().elevation > Component::OutlinedCard.spec().elevation);
}
#[test]
fn design_tokens_entry_point() {
let light = DesignTokens::baseline(SchemeFlavor::Light);
let dark = DesignTokens::baseline(SchemeFlavor::Dark);
assert_eq!(light.flavor, SchemeFlavor::Light);
assert!(light.color(Role::Surface).relative_luminance() > 0.8);
assert!(dark.color(Role::Surface).relative_luminance() < 0.05);
assert!(!light.motion_scale.is_off());
let accent = DesignTokens::with_accent(
SchemeFlavor::Light,
unlucky_design::Color::from_hex("#006A5C").unwrap(),
);
assert_ne!(accent.color(Role::Primary), light.color(Role::Primary));
assert_eq!(accent.color(Role::Surface), light.color(Role::Surface));
}
#[test]
fn tonal_palette_hits_requested_tones() {
let palette = TonalPalette::new(150.0, 0.1);
for tone in [20.0_f32, 40.0, 60.0, 80.0] {
let color = palette.tone(tone);
assert!(
(color.lstar() - tone).abs() <= 1.0,
"tone {tone} 实际 {}",
color.lstar()
);
}
}