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 已通告边界)。
227 lines
7.4 KiB
Rust
227 lines
7.4 KiB
Rust
//! 高程令牌: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());
|
||
}
|
||
}
|