feat(design): 加 unlucky-design 令牌 crate(M3 色彩/排版/形状/动效/图标)
D26 把视觉基线锁到 Material 3 Expressive,本 crate 是「UI 整体性」的地基: 令牌唯一真源,后续 iced 页面只消费不新增。零第三方依赖、零 UI 框架依赖 (cargo tree -p unlucky-design --edges normal 只有它自己一行)。 - 色彩:49 个 M3 语义角色 × light/dark,种子色派生 6 条 tone 调色板; tone 轴严格用 CIELAB L*(偏差 ≤1),色相/彩度用 OKLCh 近似 CAM16 并在 模块文档里写明口径与已知偏差(不宣称是标准 HCT 动态取色) - 对比度:26 组文字对 × 6 组种子 × 2 模式全部 ≥4.5:1;3 条例外显式登记 (Outline 非文字 3:1、OutlineVariant 装饰、SurfaceVariant 是容器色) - 排版:M3 type scale 15 档 + 三平台字体族回退栈(只写族名,不内嵌受限字体) - 形状/高程/动效:Expressive 形状刻度 + shape morph 数据、elevation 级别与 surface container 映射、时长/缓动/弹簧(纯函数可采样) - 图标:24 个从 Slint 版迁移的 SVG path + 4 个新增,IconKind 冻结只可末尾追加; 写明 Slint 的 stroke-width 是绝对像素、SVG 是 viewBox 用户单位的语义差异, 换算封装成 Icon::stroke_width - 组件档位表:20 个组件的形状/高程/字体/动效一次取全 TODO(M63):elevation shadow 的 blur/offset/alpha 与四组弹簧参数是占位值, 接口先定、数值待玻璃视觉规格表(tower 已通告边界)。
This commit is contained in:
1 parent
fef0922073
commit
65ca0ed29a
25 files changed
+4622
-1
No files matched your search
@@ -0,0 +1,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);
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user