fix(design): M61 r1 收口 —— 官方 emphasized/弹簧/高程 + 冻结测试
reviewer r1 判 p2-2items(fix-then-merge),本轮按条目收口: P2-1 冻结测试补全(变异实测证明原来可静默漂移): - tests/type_scale_pins.rs:baseline 15 档 (size,line_height,weight,tracking,family) 与 Expressive 强调 15 档逐值对拍,期望值独立于实现重写 - tests/token_pins.rs:49 条 tone(light+dark 全表)、7 条 easing 曲线参数、 16 档时长、9 组弹簧、28 个 IconKind 下标、形状阶梯 - 自证:TitleMedium 16→17 与 EmphasizedDecelerate 换曲线两次变异均 FAILED P2-2 emphasized 口径事实错误(用户点名): - 取回 A 级官方值(androidx TypeScaleTokens v0_103 的 *Emphasized* 四元组): label-*/title-medium/title-small 强调是 700(此前给 600)、body-large 0.5→0.15、 title-medium 0.2→0.15、body-medium 0.2→0.25、display-large -0.2→0 - TypeRole::emphasized() 改为官方表(typography/emphasized.rs); 原「只加一档字重」规则改名 emphasized_simplified() 并标注非官方 - 顺带按官方订正 baseline tracking(display-large -0.2、body-medium 0.2、 title-medium 0.2)与 TitleLarge 的 Brand 族归属 - crate 文档新增「baseline MD3 vs M3 Expressive 差异清单」+ 未支持能力 gap 弹簧改为两套 token 结构(用户追加令): - motion::spring(scheme × category × speed) 二维表 12 格: standard spatial ζ=0.9 (k 1400/700/300)、expressive spatial ζ=0.6~0.8 (k 800/380/200)、 effects 两套同值 ζ=1.0 (k 3800/1600/800),均取 A 级 MotionTokens 原值 - Spring 改存阻尼比(与 Compose 令牌同口径),9 个具名常量 中文字重覆盖(用户追加令): - FontFace 带 WeightCoverage(可变区间/静态表),Platform::resolve_weight 给 Native/Nearest/Synthetic 三档结论;雅黑无 500、苹方无 700 两处 gap 显式登记 component_map 高程按官方回调(P2 并入 finding): - FilledCard L2→L0、OutlinedCard L1→L0、Menu L4→L2、Dialog L5→L3(Menu 圆角同时 按 MenuTokens 改 4dp);新增官方值 pin 测试 + R4「全屏都在飘」守门断言 P3 四条: - 同族容器撞色:彩度按共享基准等比压缩(Palettes.reference_chroma), primary/secondary-container 不再同值,且强调色覆盖时表面逐像素不变 - femtovg 引用改函数名(行号已漂);删掉 contrast_aa 的死代码后门 - 骨架数值跟 M63:nav 56→44、titlebar 40→48,control 40/touch 48 换算依据入文档 - 图标折算口径订正为「居中描边,视觉外扩 <1px」
This commit is contained in:
1 parent
65ca0ed29a
commit
83e19e58f0
20 files changed
+1368
-235
No files matched your search
@@ -57,6 +57,12 @@ pub struct Palettes {
|
|||||||
pub neutral_variant: TonalPalette,
|
pub neutral_variant: TonalPalette,
|
||||||
/// 错误调色板。
|
/// 错误调色板。
|
||||||
pub error: TonalPalette,
|
pub error: TonalPalette,
|
||||||
|
/// 彩度压缩的**共享基准**(取建方案时的 primary 彩度,强调色覆盖时**不变**)。
|
||||||
|
///
|
||||||
|
/// 为什么单独存一份:`from_accent` 换掉 primary 后,中性表面不该跟着变色调
|
||||||
|
/// (否则「换强调色不动底色」这条语义就破了),而彩度压缩又必须用同一个基准才能
|
||||||
|
/// 保证 primary/secondary 之间的相对灰度——两者靠这个字段同时成立。
|
||||||
|
reference_chroma: f32,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl Palettes {
|
impl Palettes {
|
||||||
@@ -77,6 +83,7 @@ impl Palettes {
|
|||||||
neutral: primary.with_chroma(NEUTRAL_CHROMA),
|
neutral: primary.with_chroma(NEUTRAL_CHROMA),
|
||||||
neutral_variant: primary.with_chroma(NEUTRAL_VARIANT_CHROMA),
|
neutral_variant: primary.with_chroma(NEUTRAL_VARIANT_CHROMA),
|
||||||
error: TonalPalette::new(ERROR_HUE_DEG, ERROR_CHROMA),
|
error: TonalPalette::new(ERROR_HUE_DEG, ERROR_CHROMA),
|
||||||
|
reference_chroma: primary.chroma(),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -84,6 +91,7 @@ impl Palettes {
|
|||||||
///
|
///
|
||||||
/// 这是「用户自定义强调色」的正确语义——换强调色不该让整个界面底色变脏;
|
/// 这是「用户自定义强调色」的正确语义——换强调色不该让整个界面底色变脏;
|
||||||
/// 也因为 tone 轴固定,任何色相都能自动满足 WCAG AA(不需要再算一遍对比度)。
|
/// 也因为 tone 轴固定,任何色相都能自动满足 WCAG AA(不需要再算一遍对比度)。
|
||||||
|
/// `reference_chroma` 也保持原值,所以中性色与 baseline 逐像素一致(有测试钉住)。
|
||||||
pub fn with_accent(self, accent: Color) -> Self {
|
pub fn with_accent(self, accent: Color) -> Self {
|
||||||
let accent_primary = TonalPalette::from_color(accent);
|
let accent_primary = TonalPalette::from_color(accent);
|
||||||
Self {
|
Self {
|
||||||
@@ -107,6 +115,18 @@ impl Palettes {
|
|||||||
PaletteSlot::Error => self.error,
|
PaletteSlot::Error => self.error,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// 方案级取色:以 [`Palettes::reference_chroma`] 为**共享基准**压所有调色板(见
|
||||||
|
/// [`TonalPalette::tone_scaled`]),保证同 tone 下 primary/secondary/tertiary 不会撞色,
|
||||||
|
/// 且换强调色时中性表面逐像素不变。
|
||||||
|
pub fn tone(&self, slot: PaletteSlot, tone: f32) -> Color {
|
||||||
|
self.slot(slot).tone_scaled(tone, self.reference_chroma)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 彩度压缩的共享基准(调试/测试用)。
|
||||||
|
pub fn reference_chroma(&self) -> f32 {
|
||||||
|
self.reference_chroma
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
|
|||||||
@@ -191,7 +191,7 @@ impl Scheme {
|
|||||||
SchemeFlavor::Light => entry.light,
|
SchemeFlavor::Light => entry.light,
|
||||||
SchemeFlavor::Dark => entry.dark,
|
SchemeFlavor::Dark => entry.dark,
|
||||||
};
|
};
|
||||||
palettes.slot(entry.slot).tone(tone)
|
palettes.tone(entry.slot, tone)
|
||||||
});
|
});
|
||||||
Self { flavor, colors }
|
Self { flavor, colors }
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -7,6 +7,9 @@ use super::Color;
|
|||||||
/// gamut 判定容差:线性 RGB 允许露出这么点负值/溢出(浮点误差),别因此误判「越界」。
|
/// gamut 判定容差:线性 RGB 允许露出这么点负值/溢出(浮点误差),别因此误判「越界」。
|
||||||
const GAMUT_EPS: f32 = 1.0e-4;
|
const GAMUT_EPS: f32 = 1.0e-4;
|
||||||
|
|
||||||
|
/// sRGB 里能取到的 OKLCh 彩度上限(超过它没有意义,二分搜索的初始上界)。
|
||||||
|
pub(crate) const GAMUT_CHROMA_CAP: f32 = 0.5;
|
||||||
|
|
||||||
/// sRGB 编码值(0..1)→ 线性光强。
|
/// sRGB 编码值(0..1)→ 线性光强。
|
||||||
pub(crate) fn srgb_to_linear(c: f32) -> f32 {
|
pub(crate) fn srgb_to_linear(c: f32) -> f32 {
|
||||||
if c <= 0.040_45 {
|
if c <= 0.040_45 {
|
||||||
@@ -147,6 +150,11 @@ pub(crate) fn chroma_in_gamut(lstar: f32, hue_deg: f32, chroma_max: f32) -> f32
|
|||||||
lo
|
lo
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// 该 tone / 色相下 sRGB 能表达的最大彩度(P3.2 的「按比例压彩度」用它做基准)。
|
||||||
|
pub(crate) fn max_chroma(lstar: f32, hue_deg: f32) -> f32 {
|
||||||
|
chroma_in_gamut(lstar, hue_deg, GAMUT_CHROMA_CAP)
|
||||||
|
}
|
||||||
|
|
||||||
/// OKLCh 坐标(色相用度,0..360)。
|
/// OKLCh 坐标(色相用度,0..360)。
|
||||||
#[derive(Debug, Clone, Copy, PartialEq)]
|
#[derive(Debug, Clone, Copy, PartialEq)]
|
||||||
pub struct Oklch {
|
pub struct Oklch {
|
||||||
|
|||||||
@@ -59,6 +59,17 @@ impl TonalPalette {
|
|||||||
/// 二分裁剪——所以 tone 0 恒为纯黑、tone 100 恒为纯白,且同 tone 的 WCAG 对比度
|
/// 二分裁剪——所以 tone 0 恒为纯黑、tone 100 恒为纯白,且同 tone 的 WCAG 对比度
|
||||||
/// 与色相无关(这是对比度门禁能跨种子稳定的原因)。
|
/// 与色相无关(这是对比度门禁能跨种子稳定的原因)。
|
||||||
pub fn tone(self, tone: f32) -> Color {
|
pub fn tone(self, tone: f32) -> Color {
|
||||||
|
self.tone_scaled(tone, self.chroma)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 带「共享彩度基准」的取色:**所有调色板按同一比例压彩度进 gamut**。
|
||||||
|
///
|
||||||
|
/// `reference_chroma` 一般是方案里最饱和那条调色板(primary)的彩度。压缩系数
|
||||||
|
/// `f = min(1, C_max(tone,hue) / reference)`,每条调色板取 `C_req × f`——
|
||||||
|
/// 这样 `primary` 与 `secondary` 在高 tone(tone 90/96 这类浅容器)上不会被**一起裁到**
|
||||||
|
/// 同一个 gamut 上限而撞色,`secondary` 永远比 `primary` 更灰(M3 的语义就靠这个)。
|
||||||
|
/// 单独用 [`TonalPalette::tone`] 时没有基准,等价于 `reference = 自身彩度`(直接裁剪)。
|
||||||
|
pub fn tone_scaled(self, tone: f32, reference_chroma: f32) -> Color {
|
||||||
let lstar = tone.clamp(0.0, 100.0);
|
let lstar = tone.clamp(0.0, 100.0);
|
||||||
// 端点直接给纯黑/纯白:近黑处的 gamut 容差会放进微小彩度,导致 tone 0 偏色。
|
// 端点直接给纯黑/纯白:近黑处的 gamut 容差会放进微小彩度,导致 tone 0 偏色。
|
||||||
if lstar <= 0.0 {
|
if lstar <= 0.0 {
|
||||||
@@ -67,7 +78,13 @@ impl TonalPalette {
|
|||||||
if lstar >= 100.0 {
|
if lstar >= 100.0 {
|
||||||
return Color::WHITE;
|
return Color::WHITE;
|
||||||
}
|
}
|
||||||
let chroma = space::chroma_in_gamut(lstar, self.hue_deg, self.chroma);
|
let c_max = space::max_chroma(lstar, self.hue_deg);
|
||||||
|
let factor = if reference_chroma > 1.0e-6 {
|
||||||
|
(c_max / reference_chroma).min(1.0)
|
||||||
|
} else {
|
||||||
|
1.0
|
||||||
|
};
|
||||||
|
let chroma = (self.chroma * factor).min(c_max).max(0.0);
|
||||||
let lightness = space::oklab_lightness_for_tone(chroma, self.hue_deg, lstar);
|
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))
|
Color::from_linear_f32(space::oklch_to_linear(lightness, chroma, self.hue_deg))
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -179,6 +179,62 @@ mod tests {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn elevation_matches_official_component_tokens() {
|
||||||
|
// A 级来源(androidx.compose.material3.tokens):
|
||||||
|
// FilledCardTokens/OutlinedCardTokens.ContainerElevation = Level0
|
||||||
|
// MenuTokens.ContainerElevation = Level2
|
||||||
|
// DialogTokens.ContainerElevation = Level3
|
||||||
|
// SnackbarTokens.ContainerElevation = Level3
|
||||||
|
// AppBarTokens = Level0、ListTokens.ItemContainerElevation = Level0
|
||||||
|
// 偏离只允许出现在有登记的地方(Tooltip 是 B 级:token 文件里没有 Elevation 行)。
|
||||||
|
for (component, expect) in [
|
||||||
|
(Component::FilledCard, Elevation::Level0),
|
||||||
|
(Component::OutlinedCard, Elevation::Level0),
|
||||||
|
(Component::Menu, Elevation::Level2),
|
||||||
|
(Component::Dialog, Elevation::Level3),
|
||||||
|
(Component::Snackbar, Elevation::Level3),
|
||||||
|
(Component::TopAppBar, Elevation::Level0),
|
||||||
|
(Component::ListItem, Elevation::Level0),
|
||||||
|
(Component::TextField, Elevation::Level0),
|
||||||
|
] {
|
||||||
|
assert_eq!(
|
||||||
|
component.spec().elevation,
|
||||||
|
expect,
|
||||||
|
"{} 的高程与官方组件令牌不一致",
|
||||||
|
component.name()
|
||||||
|
);
|
||||||
|
}
|
||||||
|
// R4「全屏都在飘」的守门:一屏常见元素里带阴影(>Level0)的只允许是浮层。
|
||||||
|
for component in Component::ALL {
|
||||||
|
if component.spec().elevation > Elevation::Level0 {
|
||||||
|
assert!(
|
||||||
|
matches!(
|
||||||
|
component,
|
||||||
|
Component::Menu
|
||||||
|
| Component::Dialog
|
||||||
|
| Component::Snackbar
|
||||||
|
| Component::Tooltip
|
||||||
|
),
|
||||||
|
"{} 不该有阴影(只有浮层允许)",
|
||||||
|
component.name()
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn shapes_match_official_component_tokens() {
|
||||||
|
// 官方:卡片 CornerMedium(12)、对话框 CornerExtraLarge(28)、菜单 CornerExtraSmall(4)、
|
||||||
|
// 按钮/导航指示条 CornerFull、chip CornerSmall(8)。
|
||||||
|
assert_eq!(Component::FilledCard.spec().shape, ShapeScale::Medium);
|
||||||
|
assert_eq!(Component::OutlinedCard.spec().shape, ShapeScale::Medium);
|
||||||
|
assert_eq!(Component::Dialog.spec().shape, ShapeScale::ExtraLarge);
|
||||||
|
assert_eq!(Component::Menu.spec().shape, ShapeScale::ExtraSmall);
|
||||||
|
assert_eq!(Component::PrimaryButton.spec().shape, ShapeScale::Full);
|
||||||
|
assert_eq!(Component::Chip.spec().shape, ShapeScale::Small);
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn only_primary_actions_use_filled_emphasis_and_one_dialog_layer() {
|
fn only_primary_actions_use_filled_emphasis_and_one_dialog_layer() {
|
||||||
// 契约 §1:状态色不复用 accent、每屏最多一个实心 accent → 只有 primary-button 是
|
// 契约 §1:状态色不复用 accent、每屏最多一个实心 accent → 只有 primary-button 是
|
||||||
|
|||||||
@@ -1,10 +1,19 @@
|
|||||||
//! 组件档位数据表(**下标必须等于 `Component as usize`**,由测试钉死)。
|
//! 组件档位数据表(**下标必须等于 `Component as usize`**,由测试钉死)。
|
||||||
//!
|
//!
|
||||||
//! 一行一个组件。改值就是在改全站观感——同步协作文档再改。
|
//! 一行一个组件。改值就是在改全站观感——同步协作文档再改。
|
||||||
|
//!
|
||||||
|
//! **高程/形状取值来源(A 级)**:`androidx.compose.material3.tokens` 的各组件 `<X>Tokens.kt`
|
||||||
|
//! (FilledCard/OutlinedCard/ElevatedCard/Menu/Dialog/Snackbar/AppBar/List)。已登记的两处偏离:
|
||||||
|
//! - `Tooltip`:`PlainTooltipTokens.kt` 只有 Corner 行、**没有 Elevation 行**,Table 的 Level2 是
|
||||||
|
//! B 级(沿用 M3 旧规范「浮层 3dp」的直觉),有官方值后以官方为准;
|
||||||
|
//! - `ListItem`:官方 `ListTokens` 用 `CornerNone`(直角行),本项目取 `Small(8)`——列表行在本
|
||||||
|
//! 启动器里是**卡片内的独立可点块**(不是贴边通栏行),圆角与卡片同族更协调;偏差 8dp 已登记。
|
||||||
|
|
||||||
use super::{Component, ComponentSpec};
|
use super::{Component, ComponentSpec};
|
||||||
use crate::elevation::Elevation;
|
use crate::elevation::Elevation;
|
||||||
use crate::motion::{DurationToken, Easing, SPRING_SPATIAL_DEFAULT, SPRING_SPATIAL_FAST};
|
use crate::motion::{
|
||||||
|
DurationToken, Easing, SPRING_EXPRESSIVE_SPATIAL_DEFAULT, SPRING_EXPRESSIVE_SPATIAL_FAST,
|
||||||
|
};
|
||||||
use crate::shape::{ShapeMorph, ShapeScale};
|
use crate::shape::{ShapeMorph, ShapeScale};
|
||||||
use crate::typography::TypeRole;
|
use crate::typography::TypeRole;
|
||||||
|
|
||||||
@@ -54,7 +63,7 @@ pub(super) const SPECS: [ComponentSpec; 20] = [
|
|||||||
Some(ShapeMorph::new(
|
Some(ShapeMorph::new(
|
||||||
ShapeScale::Large,
|
ShapeScale::Large,
|
||||||
ShapeScale::Full,
|
ShapeScale::Full,
|
||||||
SPRING_SPATIAL_FAST,
|
SPRING_EXPRESSIVE_SPATIAL_FAST,
|
||||||
)),
|
)),
|
||||||
),
|
),
|
||||||
spec(
|
spec(
|
||||||
@@ -98,13 +107,14 @@ pub(super) const SPECS: [ComponentSpec; 20] = [
|
|||||||
Some(ShapeMorph::new(
|
Some(ShapeMorph::new(
|
||||||
ShapeScale::Medium,
|
ShapeScale::Medium,
|
||||||
ShapeScale::Full,
|
ShapeScale::Full,
|
||||||
SPRING_SPATIAL_FAST,
|
SPRING_EXPRESSIVE_SPATIAL_FAST,
|
||||||
)),
|
)),
|
||||||
),
|
),
|
||||||
|
// FilledCardTokens.ContainerElevation = Level0(hover 才到 L1、dragged 到 L3)。
|
||||||
spec(
|
spec(
|
||||||
Component::OutlinedCard,
|
Component::OutlinedCard,
|
||||||
ShapeScale::Medium,
|
ShapeScale::Medium,
|
||||||
Elevation::Level1,
|
Elevation::Level0,
|
||||||
TypeRole::TitleMedium,
|
TypeRole::TitleMedium,
|
||||||
Some(TypeRole::BodySmall),
|
Some(TypeRole::BodySmall),
|
||||||
Easing::Standard,
|
Easing::Standard,
|
||||||
@@ -114,7 +124,7 @@ pub(super) const SPECS: [ComponentSpec; 20] = [
|
|||||||
spec(
|
spec(
|
||||||
Component::FilledCard,
|
Component::FilledCard,
|
||||||
ShapeScale::Medium,
|
ShapeScale::Medium,
|
||||||
Elevation::Level2,
|
Elevation::Level0,
|
||||||
TypeRole::TitleMedium,
|
TypeRole::TitleMedium,
|
||||||
Some(TypeRole::BodySmall),
|
Some(TypeRole::BodySmall),
|
||||||
Easing::Standard,
|
Easing::Standard,
|
||||||
@@ -122,7 +132,7 @@ pub(super) const SPECS: [ComponentSpec; 20] = [
|
|||||||
Some(ShapeMorph::new(
|
Some(ShapeMorph::new(
|
||||||
ShapeScale::Medium,
|
ShapeScale::Medium,
|
||||||
ShapeScale::Large,
|
ShapeScale::Large,
|
||||||
SPRING_SPATIAL_DEFAULT,
|
SPRING_EXPRESSIVE_SPATIAL_DEFAULT,
|
||||||
)),
|
)),
|
||||||
),
|
),
|
||||||
spec(
|
spec(
|
||||||
@@ -166,7 +176,7 @@ pub(super) const SPECS: [ComponentSpec; 20] = [
|
|||||||
Some(ShapeMorph::new(
|
Some(ShapeMorph::new(
|
||||||
ShapeScale::ExtraSmall,
|
ShapeScale::ExtraSmall,
|
||||||
ShapeScale::Small,
|
ShapeScale::Small,
|
||||||
SPRING_SPATIAL_FAST,
|
SPRING_EXPRESSIVE_SPATIAL_FAST,
|
||||||
)),
|
)),
|
||||||
),
|
),
|
||||||
spec(
|
spec(
|
||||||
@@ -199,10 +209,11 @@ pub(super) const SPECS: [ComponentSpec; 20] = [
|
|||||||
DurationToken::Long2,
|
DurationToken::Long2,
|
||||||
None,
|
None,
|
||||||
),
|
),
|
||||||
|
// DialogTokens.ContainerElevation = Level3,CornerExtraLarge = 28。
|
||||||
spec(
|
spec(
|
||||||
Component::Dialog,
|
Component::Dialog,
|
||||||
ShapeScale::ExtraLarge,
|
ShapeScale::ExtraLarge,
|
||||||
Elevation::Level5,
|
Elevation::Level3,
|
||||||
TypeRole::HeadlineSmall,
|
TypeRole::HeadlineSmall,
|
||||||
Some(TypeRole::BodyMedium),
|
Some(TypeRole::BodyMedium),
|
||||||
Easing::EmphasizedDecelerate,
|
Easing::EmphasizedDecelerate,
|
||||||
@@ -210,13 +221,14 @@ pub(super) const SPECS: [ComponentSpec; 20] = [
|
|||||||
Some(ShapeMorph::new(
|
Some(ShapeMorph::new(
|
||||||
ShapeScale::Large,
|
ShapeScale::Large,
|
||||||
ShapeScale::ExtraLarge,
|
ShapeScale::ExtraLarge,
|
||||||
SPRING_SPATIAL_DEFAULT,
|
SPRING_EXPRESSIVE_SPATIAL_DEFAULT,
|
||||||
)),
|
)),
|
||||||
),
|
),
|
||||||
|
// MenuTokens:CornerExtraSmall(4) + Level2。
|
||||||
spec(
|
spec(
|
||||||
Component::Menu,
|
Component::Menu,
|
||||||
ShapeScale::Small,
|
ShapeScale::ExtraSmall,
|
||||||
Elevation::Level4,
|
Elevation::Level2,
|
||||||
TypeRole::BodyMedium,
|
TypeRole::BodyMedium,
|
||||||
None,
|
None,
|
||||||
Easing::StandardDecelerate,
|
Easing::StandardDecelerate,
|
||||||
|
|||||||
@@ -21,7 +21,8 @@
|
|||||||
//!
|
//!
|
||||||
//! - Slint 的 `Path.stroke-width` 是**绝对像素**:只乘 DPI 缩放,**不乘 viewBox**。
|
//! - Slint 的 `Path.stroke-width` 是**绝对像素**:只乘 DPI 缩放,**不乘 viewBox**。
|
||||||
//! 所以老代码写 `stroke-width: 1.5px` 意味着「渲染出来就是 1.5px」,viewBox 缩放对它无影响
|
//! 所以老代码写 `stroke-width: 1.5px` 意味着「渲染出来就是 1.5px」,viewBox 缩放对它无影响
|
||||||
//! (实证:femtovg 后端 `itemrenderer.rs:343`)。
|
//! (实证:femtovg 后端 `PathItemRenderer::render` 里的
|
||||||
|
//! `paint.set_line_width(stroke_width * scale_factor)`——只有 DPI 缩放因子,没有 viewBox 项)。
|
||||||
//! - SVG / iced canvas 的 `stroke-width` 是**用户单位**(viewBox 单位):渲染时会被
|
//! - SVG / iced canvas 的 `stroke-width` 是**用户单位**(viewBox 单位):渲染时会被
|
||||||
//! `render_px / VIEWBOX` 缩放。同样的 `1.5` 在 20px 渲染下只有 1.25px 视觉宽度。
|
//! `render_px / VIEWBOX` 缩放。同样的 `1.5` 在 20px 渲染下只有 1.25px 视觉宽度。
|
||||||
//!
|
//!
|
||||||
|
|||||||
@@ -39,6 +39,45 @@
|
|||||||
//! let stack = Platform::current().font_stack(FontFamily::Display);
|
//! let stack = Platform::current().font_stack(FontFamily::Display);
|
||||||
//! assert!(!stack.is_empty());
|
//! assert!(!stack.is_empty());
|
||||||
//! ```
|
//! ```
|
||||||
|
//!
|
||||||
|
//! ## baseline MD3 vs M3 Expressive 差异清单
|
||||||
|
//!
|
||||||
|
//! 「M3 Expressive」不是「M3 换皮」——它真的加了一批令牌。本 crate 两代都提供,差异如下
|
||||||
|
//! (来源等级:**A** = androidx 源码 / 官方 token 文件;**B** = m3.material.io 文档表格;**C** = 社区转述)。
|
||||||
|
//!
|
||||||
|
//! | 领域 | baseline MD3 | M3 Expressive 新增/不同 | 本 crate 落点 | 等级 |
|
||||||
|
//! |---|---|---|---|---|
|
||||||
|
//! | 排版 | 15 档 type role,只有 Regular/Medium 两种字重档 | **每档多一条 `*Emphasized`**:字号/行高不变,字重升到 Medium(500) 或 Bold(700),字距按表微调(display-large −0.2→0、body-large 0.5→0.15、title-medium 0.2→0.15、body-medium 0.2→0.25) | [`typography::TypeRole::emphasized`](官方值);[`typography::TypeRole::emphasized_simplified`] 是我们的「只加一档字重」简化规则,**非官方** | A |
|
||||||
|
//! | 形状 | ExtraSmall 4 / Small 8 / Medium 12 / Large 16 / **ExtraLarge 28** / Full | 多出 **LargeIncreased 20 / ExtraLargeIncreased 32 / ExtraExtraLarge 48**,并引入 **shape morph**(按下/选中时档位之间弹性过渡) | [`shape::ShapeScale`](10 档)+ [`shape::ShapeMorph`] + [`component_map`] 的 `morph` 列 | A |
|
||||||
|
//! | 动效 | 16 档 duration + 7 条 cubic-bezier easing(时间驱动) | 把弹簧打包成 **standard / expressive 两套方案**,每套各有 spatial(位移,会回弹)与 effects(颜色/透明度,不回弹)× fast/default/slow。standard spatial ζ=0.9(k 1400/700/300),expressive spatial ζ=0.6~0.8(k 800/380/200),effects 两套同值 ζ=1.0(k 3800/1600/800) | [`motion::spring`] 的 **方案 × 类别 × 速度**二维表(12 格,`StandardMotionTokens`/`ExpressiveMotionTokens` 原值)+ 9 个具名常量;duration/easing 两代共用 | A |
|
||||||
|
//! | 组件尺寸 | 触控目标 48dp(无障碍下限本来就是 48) | Expressive 把按钮/列表的整体节奏放大(列表项 56/72/88),并明确 48dp 为**可点下限** | [`spacing::LayoutMetrics::DESKTOP`]:`touch_target = 48`(照规范)+ `control_height = 40`(可见高度,上下各 4px 内边距凑满 48)+ `row_height`/`nav_item_height` = 44、`titlebar_height` = 48(M63 §1.4+R13 的桌面档) | A/B |
|
||||||
|
//! | 字体 | 只有 400/500/700 的「概念」字重,没规定字体族要覆盖几档 | Expressive 的强调档**真的会要 700**(label-*、title-medium/small),中文字体覆盖差异巨大:雅黑只有 400/700(无 500)、苹方到 600 断(无 700)、Noto/思源可变字体全覆盖 | [`typography::FontFace`] 的 `WeightCoverage`(可变区间 / 静态表)+ [`typography::Platform::resolve_weight`] 纯函数给出「原生 / 最近档 / 合成加粗」三档结论 | B |
|
||||||
|
//! | 色彩 | 49 个角色(primary/secondary/tertiary/error 族、surface-container 系、`*-fixed*` 系、outline、inverse、scrim/shadow) | **没有新增颜色角色**;变化在用法:surface-container 系(Lowest…Highest)取代旧的 elevation tint 叠色,表达层级 | [`color::Role`] 全 49 个;[`elevation::Elevation::surface_role`] 给出层级映射,`deprecated_tint_alpha` 保留旧口径作对照 | A |
|
||||||
|
//! | 高程 | Level0..5 = 0/1/3/6/8/12dp | 数值不变;组件默认值不同(卡片 L0、菜单 L2、对话框 L3,见 [`component_map`]) | [`elevation::Elevation`] | A |
|
||||||
|
//!
|
||||||
|
//! ### 两条由此衍生的项目纪律
|
||||||
|
//!
|
||||||
|
//! 1. **shape morph 是克制的状态信号,不是每屏装饰**:Expressive 官方鼓励「形状即状态」,但
|
||||||
|
//! 桌面端信息密度高,圆角到处变会显得凌乱(社区已有人批评控制中心这么做)。本项目的约束是
|
||||||
|
//! **一屏最多 1 处形变、且只用于状态切换信号**;静态元素一律用 [`shape::ShapeScale`] 的固定档。
|
||||||
|
//! [`component_map`] 的 `morph` 列就是允许清单——不在表里的组件不许自己加形变。
|
||||||
|
//! 2. **中文字重必须真的有三档**:Expressive 的 700 落在中文字体上,雅黑缺 500、苹方缺 700,
|
||||||
|
//! 缺档就走「合成加粗」= 观感廉价。所以字体栈带 `WeightCoverage`,并在
|
||||||
|
//! [`typography::Platform::resolve_weight`] 里把降级路径显式化(合成加粗是最后一档)。
|
||||||
|
//!
|
||||||
|
//! 已知与文档的 0.05px 级差异:Compose 这一代 `TypeScaleTokens` 的 tracking 与 m3.material.io
|
||||||
|
//! 网页表格不同(display-large −0.2 vs −0.25、body-medium 0.2 vs 0.25、title-medium 0.2 vs 0.15),
|
||||||
|
//! 本项目**跟 Compose**(那是真正渲染出来的值,A 级)。
|
||||||
|
//!
|
||||||
|
//! ## 未支持的官方能力(gap 登记,不要以为已经有了)
|
||||||
|
//!
|
||||||
|
//! - **对比度等级**:Android 16 的「默认 / 中 / 高」三档对比度本 crate **不支持**。将来扩法是
|
||||||
|
//! **按等级偏移角色 tone 表**(例如高对比把 `on-*` 与承载色的 tone 差拉大 5~10),
|
||||||
|
//! 而不是新增一批角色——`Role` 的数量保持 49 不变,`Scheme` 多一个 `contrast_level` 维度。
|
||||||
|
//! (M63 的 G4 只说「高对比模式走 CPU 档」,那是渲染档位,不等价于支持对比度等级。)
|
||||||
|
//! - **动态壁纸取色**:Material You 的多色种子派生未实现(本启动器用不到)。
|
||||||
|
//! - **M3 的 elevation tint 叠色**:现行 M3 已改用 surface-container 表达层级,旧的
|
||||||
|
//! `deprecated_tint_alpha()` 只为读旧稿保留。
|
||||||
|
|
||||||
#![forbid(unsafe_code)]
|
#![forbid(unsafe_code)]
|
||||||
|
|
||||||
|
|||||||
@@ -12,8 +12,11 @@
|
|||||||
mod spring;
|
mod spring;
|
||||||
|
|
||||||
pub use spring::{
|
pub use spring::{
|
||||||
MotionScale, Spring, SPRING_EFFECTS_DEFAULT, SPRING_EFFECTS_FAST, SPRING_SPATIAL_DEFAULT,
|
spring, MotionScale, MotionSchemeKind, Spring, SpringCategory, SpringSpeed,
|
||||||
SPRING_SPATIAL_FAST,
|
SPRING_EFFECTS_DEFAULT, SPRING_EFFECTS_FAST, SPRING_EFFECTS_SLOW,
|
||||||
|
SPRING_EXPRESSIVE_SPATIAL_DEFAULT, SPRING_EXPRESSIVE_SPATIAL_FAST,
|
||||||
|
SPRING_EXPRESSIVE_SPATIAL_SLOW, SPRING_STANDARD_SPATIAL_DEFAULT, SPRING_STANDARD_SPATIAL_FAST,
|
||||||
|
SPRING_STANDARD_SPATIAL_SLOW,
|
||||||
};
|
};
|
||||||
|
|
||||||
/// 动效时长档位(M3 动效令牌,单位毫秒)。
|
/// 动效时长档位(M3 动效令牌,单位毫秒)。
|
||||||
|
|||||||
@@ -1,26 +1,27 @@
|
|||||||
//! 弹簧参数(M3 Expressive 动效的物理表达)与动效时长缩放。
|
//! 弹簧参数(M3 Expressive 动效的物理表达)与动效时长缩放。
|
||||||
|
//!
|
||||||
|
//! **来源 A 级**:`androidx.compose.material3.tokens.ExpressiveMotionTokens`(v0_14_0)给出六组
|
||||||
|
//! `Spring{Damping, Stiffness}`——注意 Compose 的 `Damping` 是**阻尼比 ζ**(不是绝对阻尼系数),
|
||||||
|
//! 所以本模块的 [`Spring`] 也按 (stiffness, damping_ratio, mass) 存,两者可直接对照。
|
||||||
|
//! 阶跃响应用标准二阶系统闭式解(欠阻尼 / 临界 / 过阻尼三分支),是纯数学、无状态。
|
||||||
|
|
||||||
/// 弹簧参数。
|
/// 弹簧参数(Compose 口径:刚度 + 阻尼比 + 质量)。
|
||||||
///
|
|
||||||
/// **口径说明**:Material 的弹簧预置是「按体感标定」的一组 token,官方没有公开统一公式
|
|
||||||
/// (Android 侧是 `SpringForce` 参数 + 阻尼比)。[`Spring::sample`] 走的是标准二阶系统阶跃响应
|
|
||||||
/// **闭式解**——数学是真的,参数是我们的标定值(待 M63 视觉规格对齐)。
|
|
||||||
#[derive(Debug, Clone, Copy, PartialEq)]
|
#[derive(Debug, Clone, Copy, PartialEq)]
|
||||||
pub struct Spring {
|
pub struct Spring {
|
||||||
/// 刚度(越大越快)。
|
/// 刚度 k(越大越快)。
|
||||||
pub stiffness: f32,
|
pub stiffness: f32,
|
||||||
/// 阻尼系数(临界阻尼 = 2·√(k·m))。
|
/// 阻尼比 ζ(1.0 = 临界阻尼,< 1 会轻微弹过)。
|
||||||
pub damping: f32,
|
pub damping_ratio: f32,
|
||||||
/// 质量。
|
/// 质量 m。
|
||||||
pub mass: f32,
|
pub mass: f32,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl Spring {
|
impl Spring {
|
||||||
/// 构造(`mass` 取 1.0 时 stiffness/damping 就是常见标定值)。
|
/// 按 M3 令牌口径构造(刚度 + 阻尼比 + 质量)。
|
||||||
pub const fn new(stiffness: f32, damping: f32, mass: f32) -> Self {
|
pub const fn from_ratio(stiffness: f32, damping_ratio: f32, mass: f32) -> Self {
|
||||||
Self {
|
Self {
|
||||||
stiffness,
|
stiffness,
|
||||||
damping,
|
damping_ratio,
|
||||||
mass,
|
mass,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -30,14 +31,9 @@ impl Spring {
|
|||||||
(self.stiffness / self.mass).sqrt()
|
(self.stiffness / self.mass).sqrt()
|
||||||
}
|
}
|
||||||
|
|
||||||
/// 阻尼比 ζ(1.0 = 临界阻尼;< 1 会轻微弹过)。
|
/// 绝对阻尼系数 c = 2·ζ·√(k·m)(需要喂给「阻尼系数」式积分器时用)。
|
||||||
pub fn damping_ratio(self) -> f32 {
|
pub fn damping_coefficient(self) -> f32 {
|
||||||
let denom = 2.0 * (self.stiffness * self.mass).sqrt();
|
2.0 * self.damping_ratio * (self.stiffness * self.mass).sqrt()
|
||||||
if denom <= 0.0 {
|
|
||||||
1.0
|
|
||||||
} else {
|
|
||||||
self.damping / denom
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// 阶跃响应:`t_ms` 毫秒后的进度(0..1,欠阻尼时会轻微超过 1 = 回弹)。
|
/// 阶跃响应:`t_ms` 毫秒后的进度(0..1,欠阻尼时会轻微超过 1 = 回弹)。
|
||||||
@@ -47,7 +43,7 @@ impl Spring {
|
|||||||
}
|
}
|
||||||
let t = t_ms / 1000.0;
|
let t = t_ms / 1000.0;
|
||||||
let wn = self.natural_frequency();
|
let wn = self.natural_frequency();
|
||||||
let zeta = self.damping_ratio();
|
let zeta = self.damping_ratio;
|
||||||
if wn <= 0.0 {
|
if wn <= 0.0 {
|
||||||
return 1.0;
|
return 1.0;
|
||||||
}
|
}
|
||||||
@@ -73,7 +69,7 @@ impl Spring {
|
|||||||
/// 收敛到 `threshold`(0.1% 是常用的「停下来」判定)所需毫秒数。
|
/// 收敛到 `threshold`(0.1% 是常用的「停下来」判定)所需毫秒数。
|
||||||
pub fn settle_ms(self, threshold: f32) -> f32 {
|
pub fn settle_ms(self, threshold: f32) -> f32 {
|
||||||
let wn = self.natural_frequency();
|
let wn = self.natural_frequency();
|
||||||
let zeta = self.damping_ratio();
|
let zeta = self.damping_ratio;
|
||||||
if wn <= 0.0 {
|
if wn <= 0.0 {
|
||||||
return 0.0;
|
return 0.0;
|
||||||
}
|
}
|
||||||
@@ -92,22 +88,118 @@ impl Spring {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// 空间类快速弹簧(位移/尺寸):紧、几乎不弹。
|
/// 动效方案(Expressive 把弹簧打包成 standard / expressive **两套**,不是一组)。
|
||||||
///
|
///
|
||||||
/// TODO(M63): 取值待视觉规格(四组弹簧是我们的临时标定,M63 的动效表落地后对齐)。
|
/// - [`MotionSchemeKind::Standard`]:保守、几乎不弹(spatial ζ=0.9);
|
||||||
pub const SPRING_SPATIAL_FAST: Spring = Spring::new(1400.0, 76.0, 1.0);
|
/// - [`MotionSchemeKind::Expressive`]:D26 的视觉基线,回弹明显(spatial ζ=0.6~0.8)。
|
||||||
/// 空间类默认弹簧(面板、卡片):标准手感。
|
|
||||||
///
|
///
|
||||||
/// TODO(M63): 取值待视觉规格。
|
/// `effects`(透明度/颜色类)在两套方案里数值相同——效应类不该有可见回弹。
|
||||||
pub const SPRING_SPATIAL_DEFAULT: Spring = Spring::new(800.0, 55.0, 1.0);
|
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
|
||||||
/// 效应类快速弹簧(透明度/颜色):不带过冲。
|
pub enum MotionSchemeKind {
|
||||||
///
|
/// 标准方案(保守)。
|
||||||
/// TODO(M63): 取值待视觉规格。
|
Standard,
|
||||||
pub const SPRING_EFFECTS_FAST: Spring = Spring::new(3800.0, 120.0, 1.0);
|
/// Expressive 方案(本项目默认)。
|
||||||
/// 效应类默认弹簧。
|
Expressive,
|
||||||
///
|
}
|
||||||
/// TODO(M63): 取值待视觉规格。
|
|
||||||
pub const SPRING_EFFECTS_DEFAULT: Spring = Spring::new(1600.0, 80.0, 1.0);
|
/// 弹簧类别:空间类管**位移/尺寸**,效应类管**颜色/透明度**。
|
||||||
|
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
|
||||||
|
pub enum SpringCategory {
|
||||||
|
/// 位移/尺寸(会产生可见回弹)。
|
||||||
|
Spatial,
|
||||||
|
/// 颜色/透明度(不允许过冲)。
|
||||||
|
Effects,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 弹簧速度档。
|
||||||
|
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
|
||||||
|
pub enum SpringSpeed {
|
||||||
|
/// 快速:hover/press、小元素。
|
||||||
|
Fast,
|
||||||
|
/// 默认:面板、卡片、按下形变。
|
||||||
|
Default,
|
||||||
|
/// 慢速:大位移(>120px 的面板/抽屉)。
|
||||||
|
Slow,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 二维表查询:`方案 × 类别 × 速度 → 弹簧`(A 级:`StandardMotionTokens` 与
|
||||||
|
/// `ExpressiveMotionTokens`,经 `MotionScheme.standard()/expressive()` 组装)。
|
||||||
|
pub const fn spring(
|
||||||
|
scheme: MotionSchemeKind,
|
||||||
|
category: SpringCategory,
|
||||||
|
speed: SpringSpeed,
|
||||||
|
) -> Spring {
|
||||||
|
use MotionSchemeKind::{Expressive, Standard};
|
||||||
|
use SpringCategory::{Effects, Spatial};
|
||||||
|
use SpringSpeed::{Default, Fast, Slow};
|
||||||
|
match (scheme, category, speed) {
|
||||||
|
(Standard, Spatial, Fast) => Spring::from_ratio(1400.0, 0.9, 1.0),
|
||||||
|
(Standard, Spatial, Default) => Spring::from_ratio(700.0, 0.9, 1.0),
|
||||||
|
(Standard, Spatial, Slow) => Spring::from_ratio(300.0, 0.9, 1.0),
|
||||||
|
(Expressive, Spatial, Fast) => Spring::from_ratio(800.0, 0.6, 1.0),
|
||||||
|
(Expressive, Spatial, Default) => Spring::from_ratio(380.0, 0.8, 1.0),
|
||||||
|
(Expressive, Spatial, Slow) => Spring::from_ratio(200.0, 0.8, 1.0),
|
||||||
|
// effects 两套同值:ζ=1.0(临界阻尼,不过冲)。
|
||||||
|
(_, Effects, Fast) => Spring::from_ratio(3800.0, 1.0, 1.0),
|
||||||
|
(_, Effects, Default) => Spring::from_ratio(1600.0, 1.0, 1.0),
|
||||||
|
(_, Effects, Slow) => Spring::from_ratio(800.0, 1.0, 1.0),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 标准方案 · 空间 · 快速。
|
||||||
|
pub const SPRING_STANDARD_SPATIAL_FAST: Spring = spring(
|
||||||
|
MotionSchemeKind::Standard,
|
||||||
|
SpringCategory::Spatial,
|
||||||
|
SpringSpeed::Fast,
|
||||||
|
);
|
||||||
|
/// 标准方案 · 空间 · 默认。
|
||||||
|
pub const SPRING_STANDARD_SPATIAL_DEFAULT: Spring = spring(
|
||||||
|
MotionSchemeKind::Standard,
|
||||||
|
SpringCategory::Spatial,
|
||||||
|
SpringSpeed::Default,
|
||||||
|
);
|
||||||
|
/// 标准方案 · 空间 · 慢速。
|
||||||
|
pub const SPRING_STANDARD_SPATIAL_SLOW: Spring = spring(
|
||||||
|
MotionSchemeKind::Standard,
|
||||||
|
SpringCategory::Spatial,
|
||||||
|
SpringSpeed::Slow,
|
||||||
|
);
|
||||||
|
/// Expressive 方案 · 空间 · 快速(D26 基线的默认选择)。
|
||||||
|
pub const SPRING_EXPRESSIVE_SPATIAL_FAST: Spring = spring(
|
||||||
|
MotionSchemeKind::Expressive,
|
||||||
|
SpringCategory::Spatial,
|
||||||
|
SpringSpeed::Fast,
|
||||||
|
);
|
||||||
|
/// Expressive 方案 · 空间 · 默认:面板、卡片、按下形变。
|
||||||
|
pub const SPRING_EXPRESSIVE_SPATIAL_DEFAULT: Spring = spring(
|
||||||
|
MotionSchemeKind::Expressive,
|
||||||
|
SpringCategory::Spatial,
|
||||||
|
SpringSpeed::Default,
|
||||||
|
);
|
||||||
|
/// Expressive 方案 · 空间 · 慢速:大位移(>120px)。
|
||||||
|
pub const SPRING_EXPRESSIVE_SPATIAL_SLOW: Spring = spring(
|
||||||
|
MotionSchemeKind::Expressive,
|
||||||
|
SpringCategory::Spatial,
|
||||||
|
SpringSpeed::Slow,
|
||||||
|
);
|
||||||
|
/// 效应类 · 快速(两套方案同值):hover/press 颜色。
|
||||||
|
pub const SPRING_EFFECTS_FAST: Spring = spring(
|
||||||
|
MotionSchemeKind::Expressive,
|
||||||
|
SpringCategory::Effects,
|
||||||
|
SpringSpeed::Fast,
|
||||||
|
);
|
||||||
|
/// 效应类 · 默认(两套方案同值)。
|
||||||
|
pub const SPRING_EFFECTS_DEFAULT: Spring = spring(
|
||||||
|
MotionSchemeKind::Expressive,
|
||||||
|
SpringCategory::Effects,
|
||||||
|
SpringSpeed::Default,
|
||||||
|
);
|
||||||
|
/// 效应类 · 慢速(两套方案同值)。
|
||||||
|
pub const SPRING_EFFECTS_SLOW: Spring = spring(
|
||||||
|
MotionSchemeKind::Expressive,
|
||||||
|
SpringCategory::Effects,
|
||||||
|
SpringSpeed::Slow,
|
||||||
|
);
|
||||||
|
|
||||||
/// 动效时长的全局缩放(用户「减少动效」/CPU 路径关动画时用)。
|
/// 动效时长的全局缩放(用户「减少动效」/CPU 路径关动画时用)。
|
||||||
#[derive(Debug, Clone, Copy, PartialEq)]
|
#[derive(Debug, Clone, Copy, PartialEq)]
|
||||||
@@ -156,33 +248,100 @@ mod tests {
|
|||||||
use super::*;
|
use super::*;
|
||||||
use crate::motion::DurationToken;
|
use crate::motion::DurationToken;
|
||||||
|
|
||||||
const PRESETS: [Spring; 4] = [
|
const PRESETS: [Spring; 9] = [
|
||||||
SPRING_SPATIAL_FAST,
|
SPRING_STANDARD_SPATIAL_FAST,
|
||||||
SPRING_SPATIAL_DEFAULT,
|
SPRING_STANDARD_SPATIAL_DEFAULT,
|
||||||
|
SPRING_STANDARD_SPATIAL_SLOW,
|
||||||
|
SPRING_EXPRESSIVE_SPATIAL_FAST,
|
||||||
|
SPRING_EXPRESSIVE_SPATIAL_DEFAULT,
|
||||||
|
SPRING_EXPRESSIVE_SPATIAL_SLOW,
|
||||||
SPRING_EFFECTS_FAST,
|
SPRING_EFFECTS_FAST,
|
||||||
SPRING_EFFECTS_DEFAULT,
|
SPRING_EFFECTS_DEFAULT,
|
||||||
|
SPRING_EFFECTS_SLOW,
|
||||||
];
|
];
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn every_cell_of_the_two_dimensional_table_resolves() {
|
||||||
|
for scheme in [MotionSchemeKind::Standard, MotionSchemeKind::Expressive] {
|
||||||
|
for category in [SpringCategory::Spatial, SpringCategory::Effects] {
|
||||||
|
for speed in [SpringSpeed::Fast, SpringSpeed::Default, SpringSpeed::Slow] {
|
||||||
|
let spring = spring(scheme, category, speed);
|
||||||
|
assert!(spring.stiffness > 0.0);
|
||||||
|
assert!(spring.damping_ratio > 0.0);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// effects 两套方案同值(效应类不该有可见回弹差异)。
|
||||||
|
for speed in [SpringSpeed::Fast, SpringSpeed::Default, SpringSpeed::Slow] {
|
||||||
|
assert_eq!(
|
||||||
|
spring(MotionSchemeKind::Standard, SpringCategory::Effects, speed),
|
||||||
|
spring(MotionSchemeKind::Expressive, SpringCategory::Effects, speed)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
// expressive 的空间类更弹(阻尼比更低)。
|
||||||
|
for speed in [SpringSpeed::Fast, SpringSpeed::Default, SpringSpeed::Slow] {
|
||||||
|
assert!(
|
||||||
|
spring(MotionSchemeKind::Expressive, SpringCategory::Spatial, speed).damping_ratio
|
||||||
|
< spring(MotionSchemeKind::Standard, SpringCategory::Spatial, speed)
|
||||||
|
.damping_ratio
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn spring_presets_behave() {
|
fn spring_presets_behave() {
|
||||||
for spring in PRESETS {
|
for spring in PRESETS {
|
||||||
assert!(spring.damping_ratio() > 0.5, "弹簧应明显欠阻尼/近临界");
|
|
||||||
assert_eq!(spring.sample(0.0), 0.0);
|
assert_eq!(spring.sample(0.0), 0.0);
|
||||||
let settle = spring.settle_ms(0.001);
|
let settle = spring.settle_ms(0.001);
|
||||||
assert!(
|
assert!(
|
||||||
(60.0..1200.0).contains(&settle),
|
(60.0..1500.0).contains(&settle),
|
||||||
"收敛时间 {settle}ms 不合理"
|
"收敛时间 {settle}ms 不合理(k={} ζ={})",
|
||||||
|
spring.stiffness,
|
||||||
|
spring.damping_ratio
|
||||||
);
|
);
|
||||||
let end = spring.sample(settle);
|
let end = spring.sample(settle);
|
||||||
assert!((end - 1.0).abs() < 0.01, "收敛后应回到 1.0,实测 {end}");
|
assert!((end - 1.0).abs() < 0.01, "收敛后应回到 1.0,实测 {end}");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn effects_presets_are_critically_damped_and_never_overshoot() {
|
||||||
|
for spring in [
|
||||||
|
SPRING_EFFECTS_FAST,
|
||||||
|
SPRING_EFFECTS_DEFAULT,
|
||||||
|
SPRING_EFFECTS_SLOW,
|
||||||
|
] {
|
||||||
|
assert!((spring.damping_ratio - 1.0).abs() < 1.0e-6);
|
||||||
|
for step in 1..=60 {
|
||||||
|
let v = spring.sample(step as f32 * 5.0);
|
||||||
|
assert!(v <= 1.0 + 1.0e-3, "效应类弹簧不应过冲:{v}");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn spatial_presets_overdamp_into_settle_without_wild_overshoot() {
|
||||||
|
for spring in [
|
||||||
|
SPRING_EXPRESSIVE_SPATIAL_FAST,
|
||||||
|
SPRING_EXPRESSIVE_SPATIAL_DEFAULT,
|
||||||
|
SPRING_EXPRESSIVE_SPATIAL_SLOW,
|
||||||
|
] {
|
||||||
|
assert!(
|
||||||
|
spring.damping_ratio < 1.0,
|
||||||
|
"空间类弹簧欠阻尼(允许轻微过冲)"
|
||||||
|
);
|
||||||
|
let mut peak: f32 = 0.0;
|
||||||
|
for step in 1..=200 {
|
||||||
|
peak = peak.max(spring.sample(step as f32 * 2.0));
|
||||||
|
}
|
||||||
|
assert!(peak < 1.15, "过冲 {peak} 太大");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn critically_damped_spring_does_not_overshoot() {
|
fn critically_damped_spring_does_not_overshoot() {
|
||||||
let wn = SPRING_SPATIAL_DEFAULT.natural_frequency();
|
let wn = SPRING_EXPRESSIVE_SPATIAL_DEFAULT.natural_frequency();
|
||||||
let critical = Spring::new(wn * wn, 2.0 * wn, 1.0);
|
let critical = Spring::from_ratio(wn * wn, 1.0, 1.0);
|
||||||
assert!((critical.damping_ratio() - 1.0).abs() < 1.0e-3);
|
|
||||||
for step in 1..=40 {
|
for step in 1..=40 {
|
||||||
let v = critical.sample(step as f32 * 5.0);
|
let v = critical.sample(step as f32 * 5.0);
|
||||||
assert!(v <= 1.0 + 1.0e-3, "临界阻尼不应过冲:{v}");
|
assert!(v <= 1.0 + 1.0e-3, "临界阻尼不应过冲:{v}");
|
||||||
@@ -191,9 +350,8 @@ mod tests {
|
|||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn overdamped_spring_converges_slowly_without_overshoot() {
|
fn overdamped_spring_converges_slowly_without_overshoot() {
|
||||||
let wn = SPRING_SPATIAL_DEFAULT.natural_frequency();
|
let wn = SPRING_EXPRESSIVE_SPATIAL_DEFAULT.natural_frequency();
|
||||||
let over = Spring::new(wn * wn, 4.0 * wn, 1.0);
|
let over = Spring::from_ratio(wn * wn, 2.0, 1.0);
|
||||||
assert!(over.damping_ratio() > 1.5);
|
|
||||||
let settle = over.settle_ms(0.001);
|
let settle = over.settle_ms(0.001);
|
||||||
assert!((over.sample(settle) - 1.0).abs() < 0.01);
|
assert!((over.sample(settle) - 1.0).abs() < 0.01);
|
||||||
for step in 1..=40 {
|
for step in 1..=40 {
|
||||||
@@ -201,6 +359,14 @@ mod tests {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn damping_coefficient_matches_ratio() {
|
||||||
|
let spring = SPRING_EXPRESSIVE_SPATIAL_DEFAULT;
|
||||||
|
let c = spring.damping_coefficient();
|
||||||
|
let zeta = c / (2.0 * (spring.stiffness * spring.mass).sqrt());
|
||||||
|
assert!((zeta - spring.damping_ratio).abs() < 1.0e-5);
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn motion_scale_scales_or_kills_duration() {
|
fn motion_scale_scales_or_kills_duration() {
|
||||||
assert_eq!(MotionScale::STANDARD.ms(DurationToken::Medium2), 300.0);
|
assert_eq!(MotionScale::STANDARD.ms(DurationToken::Medium2), 300.0);
|
||||||
@@ -209,6 +375,6 @@ mod tests {
|
|||||||
assert!(MotionScale::NONE.is_off());
|
assert!(MotionScale::NONE.is_off());
|
||||||
assert_eq!(MotionScale::new(5.0).factor(), 1.0);
|
assert_eq!(MotionScale::new(5.0).factor(), 1.0);
|
||||||
assert_eq!(MotionScale::new(-1.0).factor(), 0.0);
|
assert_eq!(MotionScale::new(-1.0).factor(), 0.0);
|
||||||
assert!(MotionScale::STANDARD.spring_ms(SPRING_SPATIAL_DEFAULT) > 0.0);
|
assert!(MotionScale::STANDARD.spring_ms(SPRING_EXPRESSIVE_SPATIAL_DEFAULT) > 0.0);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -118,7 +118,7 @@ impl ShapeMorph {
|
|||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
mod tests {
|
mod tests {
|
||||||
use super::*;
|
use super::*;
|
||||||
use crate::motion::SPRING_SPATIAL_DEFAULT;
|
use crate::motion::SPRING_EXPRESSIVE_SPATIAL_DEFAULT;
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn scale_ladder_is_monotone() {
|
fn scale_ladder_is_monotone() {
|
||||||
@@ -156,7 +156,7 @@ mod tests {
|
|||||||
let morph = ShapeMorph::new(
|
let morph = ShapeMorph::new(
|
||||||
ShapeScale::Small,
|
ShapeScale::Small,
|
||||||
ShapeScale::LargeIncreased,
|
ShapeScale::LargeIncreased,
|
||||||
SPRING_SPATIAL_DEFAULT,
|
SPRING_EXPRESSIVE_SPATIAL_DEFAULT,
|
||||||
);
|
);
|
||||||
assert_eq!(morph.radius_px(200.0, 0.0), 8.0);
|
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, 1.0), 20.0);
|
||||||
|
|||||||
@@ -77,11 +77,11 @@ pub struct LayoutMetrics {
|
|||||||
pub page_header_tall_height: f32,
|
pub page_header_tall_height: f32,
|
||||||
/// 运行中状态条高度。
|
/// 运行中状态条高度。
|
||||||
pub running_strip_height: f32,
|
pub running_strip_height: f32,
|
||||||
/// 列表行高。
|
/// 列表行高(桌面密集列表;见模块文档的「触控目标」说明)。
|
||||||
pub row_height: f32,
|
pub row_height: f32,
|
||||||
/// 标准控件高(按钮/输入框)。
|
/// 标准控件可见高度(按钮/输入框)。
|
||||||
pub control_height: f32,
|
pub control_height: f32,
|
||||||
/// 最小可点区域。
|
/// 最小**命中**区域(M3 Expressive 的无障碍下限)。
|
||||||
pub touch_target: f32,
|
pub touch_target: f32,
|
||||||
/// 卡片内边距。
|
/// 卡片内边距。
|
||||||
pub card_padding: f32,
|
pub card_padding: f32,
|
||||||
@@ -90,20 +90,29 @@ pub struct LayoutMetrics {
|
|||||||
}
|
}
|
||||||
|
|
||||||
impl LayoutMetrics {
|
impl LayoutMetrics {
|
||||||
/// 桌面基线(1180×760 窗口,M53 页面骨架的取值)。
|
/// 桌面基线(1180×760 窗口)。
|
||||||
|
///
|
||||||
|
/// **触控目标的换算依据**(reviewer P3-1:不许和 `metrics.slint` 沉默地不一致):
|
||||||
|
/// - 规范:M3 Expressive 要求可点目标 ≥**48dp**(无障碍下限,手机与桌面同标准)。
|
||||||
|
/// - 我们取 `touch_target = 48`(照规范),`control_height = 40`(**可见**高度):
|
||||||
|
/// 两者差 8,正好由控件上下各 4px 的内边距补足,于是「看起来 40 高的按钮」有 48 的命中区。
|
||||||
|
/// - `row_height = 44` / `nav_item_height = 44` 是 M63 §R13 + §1.4 表定的桌面密集档
|
||||||
|
/// (`metrics.slint` 的导航 56 是 Fluent 2 时代的旧节奏,D25 已终止):可见高度 44,
|
||||||
|
/// 点击命中也按 `touch_target`(48)判定,不靠行高本身。
|
||||||
|
/// - `titlebar_height = 48` 取自 M63 §1.4 的 S2 面板表(旧值 40 是 Fluent 口径)。
|
||||||
pub const DESKTOP: Self = Self {
|
pub const DESKTOP: Self = Self {
|
||||||
titlebar_height: 40.0,
|
titlebar_height: 48.0,
|
||||||
titlebar_button_width: 46.0,
|
titlebar_button_width: 46.0,
|
||||||
nav_expanded_width: 88.0,
|
nav_expanded_width: 88.0,
|
||||||
nav_collapsed_width: 48.0,
|
nav_collapsed_width: 48.0,
|
||||||
nav_item_height: 56.0,
|
nav_item_height: 44.0,
|
||||||
nav_item_gap: 4.0,
|
nav_item_gap: 4.0,
|
||||||
page_header_height: 56.0,
|
page_header_height: 56.0,
|
||||||
page_header_tall_height: 84.0,
|
page_header_tall_height: 84.0,
|
||||||
running_strip_height: 32.0,
|
running_strip_height: 32.0,
|
||||||
row_height: 44.0,
|
row_height: 44.0,
|
||||||
control_height: 40.0,
|
control_height: 40.0,
|
||||||
touch_target: 44.0,
|
touch_target: 48.0,
|
||||||
card_padding: 20.0,
|
card_padding: 20.0,
|
||||||
ai_panel_width: 320.0,
|
ai_panel_width: 320.0,
|
||||||
};
|
};
|
||||||
@@ -134,16 +143,43 @@ mod tests {
|
|||||||
#[test]
|
#[test]
|
||||||
fn layout_values_are_on_the_grid() {
|
fn layout_values_are_on_the_grid() {
|
||||||
let m = LayoutMetrics::DESKTOP;
|
let m = LayoutMetrics::DESKTOP;
|
||||||
|
// 受 4px 栅格约束的字段(间距/高度/宽度类)。
|
||||||
|
// `titlebar_button_width` 刻意不在表内:46 对齐 Windows 11 原生 caption 按钮宽度,
|
||||||
|
// 属 OS 惯例值,单列一条断言钉住,不让它静默漂移。
|
||||||
for (name, value) in [
|
for (name, value) in [
|
||||||
("titlebar_height", m.titlebar_height),
|
("titlebar_height", m.titlebar_height),
|
||||||
("nav_item_height", m.nav_item_height),
|
("nav_item_height", m.nav_item_height),
|
||||||
|
("nav_item_gap", m.nav_item_gap),
|
||||||
("page_header_height", m.page_header_height),
|
("page_header_height", m.page_header_height),
|
||||||
("page_header_tall_height", m.page_header_tall_height),
|
("page_header_tall_height", m.page_header_tall_height),
|
||||||
|
("running_strip_height", m.running_strip_height),
|
||||||
("row_height", m.row_height),
|
("row_height", m.row_height),
|
||||||
("control_height", m.control_height),
|
("control_height", m.control_height),
|
||||||
|
("touch_target", m.touch_target),
|
||||||
|
("card_padding", m.card_padding),
|
||||||
|
("ai_panel_width", m.ai_panel_width),
|
||||||
] {
|
] {
|
||||||
assert_eq!(value % GRID, 0.0, "{name} = {value} 不在 4px 栅格上");
|
assert_eq!(value % GRID, 0.0, "{name} = {value} 不在 4px 栅格上");
|
||||||
}
|
}
|
||||||
|
assert_eq!(
|
||||||
|
m.titlebar_button_width, 46.0,
|
||||||
|
"标题栏按钮宽度应对齐 Win11 原生 caption"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn touch_target_meets_the_expressive_48dp_minimum() {
|
||||||
|
let m = LayoutMetrics::DESKTOP;
|
||||||
|
// M3 Expressive 无障碍下限:可点目标 ≥48dp。
|
||||||
|
assert_eq!(m.touch_target, 48.0);
|
||||||
|
// 可见控件高 + 上下各 4px 内边距 = 命中区。
|
||||||
|
assert_eq!(m.control_height + 2.0 * Space::X1.dp(), m.touch_target);
|
||||||
|
// 密集行(M63 §1.4/R13 的桌面档)可见高度 44,命中仍按 touch_target。
|
||||||
|
assert_eq!(m.row_height, 44.0);
|
||||||
|
assert_eq!(m.nav_item_height, 44.0);
|
||||||
|
assert!(m.row_height <= m.touch_target);
|
||||||
|
// M63 §1.4 的 S2 面板表:标题栏 48。
|
||||||
|
assert_eq!(m.titlebar_height, 48.0);
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
|
|||||||
@@ -0,0 +1,88 @@
|
|||||||
|
//! 官方 M3 **Expressive** 强调排版表(15 档,A 级来源)。
|
||||||
|
//!
|
||||||
|
//! 来源:`androidx.compose.material3.tokens.TypeScaleTokens` v0_103 的
|
||||||
|
//! `<Role>Emphasized{Size,LineHeight,Tracking,Weight}` 四元组。要点:
|
||||||
|
//!
|
||||||
|
//! - **字号与行高与 baseline 完全相同**(Expressive 的强调不改变排版高度);
|
||||||
|
//! - 只动**字重**与**字距**:display/headline/title-large/body-* → Medium(500);
|
||||||
|
//! title-medium / title-small / label-* → Bold(700)(baseline 是 500);
|
||||||
|
//! - display-large 的字距从 −0.2 回 0;body-large 0.5 → 0.15;title-medium 0.2 → 0.15;
|
||||||
|
//! body-medium 0.2 → 0.25(唯一一个「强调后字距变大」的档)。
|
||||||
|
|
||||||
|
use super::{style, FontFamily, TypeRole, TypeStyle, Weight};
|
||||||
|
|
||||||
|
/// 取某档的官方强调样式。非 [`TypeRole`] 的每个变体都有值(15/15,无缺口)。
|
||||||
|
pub(super) const fn style_of(role: TypeRole) -> TypeStyle {
|
||||||
|
match role {
|
||||||
|
TypeRole::DisplayLarge => style(57.0, 64.0, Weight::Medium, 0.0, FontFamily::Display),
|
||||||
|
TypeRole::DisplayMedium => style(45.0, 52.0, Weight::Medium, 0.0, FontFamily::Display),
|
||||||
|
TypeRole::DisplaySmall => style(36.0, 44.0, Weight::Medium, 0.0, FontFamily::Display),
|
||||||
|
TypeRole::HeadlineLarge => style(32.0, 40.0, Weight::Medium, 0.0, FontFamily::Display),
|
||||||
|
TypeRole::HeadlineMedium => style(28.0, 36.0, Weight::Medium, 0.0, FontFamily::Display),
|
||||||
|
TypeRole::HeadlineSmall => style(24.0, 32.0, Weight::Medium, 0.0, FontFamily::Display),
|
||||||
|
// TitleLarge 官方用 Brand 族(与 Display 同栈)。
|
||||||
|
TypeRole::TitleLarge => style(22.0, 28.0, Weight::Medium, 0.0, FontFamily::Display),
|
||||||
|
TypeRole::TitleMedium => style(16.0, 24.0, Weight::Bold, 0.15, FontFamily::Text),
|
||||||
|
TypeRole::TitleSmall => style(14.0, 20.0, Weight::Bold, 0.1, FontFamily::Text),
|
||||||
|
TypeRole::BodyLarge => style(16.0, 24.0, Weight::Medium, 0.15, FontFamily::Text),
|
||||||
|
TypeRole::BodyMedium => style(14.0, 20.0, Weight::Medium, 0.25, FontFamily::Text),
|
||||||
|
TypeRole::BodySmall => style(12.0, 16.0, Weight::Medium, 0.4, FontFamily::Text),
|
||||||
|
TypeRole::LabelLarge => style(14.0, 20.0, Weight::Bold, 0.1, FontFamily::Text),
|
||||||
|
TypeRole::LabelMedium => style(12.0, 16.0, Weight::Bold, 0.5, FontFamily::Text),
|
||||||
|
TypeRole::LabelSmall => style(11.0, 16.0, Weight::Bold, 0.5, FontFamily::Text),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn emphasized_keeps_size_and_line_height() {
|
||||||
|
for role in TypeRole::ALL {
|
||||||
|
let base = role.style();
|
||||||
|
let emph = style_of(role);
|
||||||
|
assert_eq!(
|
||||||
|
base.size_px,
|
||||||
|
emph.size_px,
|
||||||
|
"{} 强调档不该改字号",
|
||||||
|
role.name()
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
base.line_height_px,
|
||||||
|
emph.line_height_px,
|
||||||
|
"{} 强调档不该改行高",
|
||||||
|
role.name()
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn emphasized_weights_follow_the_official_split() {
|
||||||
|
// 官方两档:正文/展示类 → Medium(500);小标题与标签类 → Bold(700)。
|
||||||
|
for role in TypeRole::ALL {
|
||||||
|
let expect = match role {
|
||||||
|
TypeRole::TitleMedium
|
||||||
|
| TypeRole::TitleSmall
|
||||||
|
| TypeRole::LabelLarge
|
||||||
|
| TypeRole::LabelMedium
|
||||||
|
| TypeRole::LabelSmall => Weight::Bold,
|
||||||
|
_ => Weight::Medium,
|
||||||
|
};
|
||||||
|
assert_eq!(
|
||||||
|
style_of(role).weight,
|
||||||
|
expect,
|
||||||
|
"{} 强调档字重不对",
|
||||||
|
role.name()
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn display_large_tracking_returns_to_zero() {
|
||||||
|
assert_eq!(TypeRole::DisplayLarge.style().letter_spacing_px, -0.2);
|
||||||
|
assert_eq!(TypeRole::DisplayLarge.emphasized().letter_spacing_px, 0.0);
|
||||||
|
assert_eq!(TypeRole::BodyLarge.style().letter_spacing_px, 0.5);
|
||||||
|
assert_eq!(TypeRole::BodyLarge.emphasized().letter_spacing_px, 0.15);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,7 +1,37 @@
|
|||||||
//! 三平台字体族回退栈(Win10+ / macOS 12+ / Linux)。
|
//! 三平台字体族回退栈(Win10+ / macOS 12+ / Linux)+ **字重覆盖**模型。
|
||||||
//!
|
//!
|
||||||
//! 只写族名,不写文件路径、不内嵌字体(D14 + D28)。每条栈的**最后一项必须是通用族名**,
|
//! 只写族名,不写文件路径、不内嵌字体(D14 + D28)。每条栈的**最后一项必须是通用族名**,
|
||||||
//! 否则干净安装的系统上会退化成豆腐块。
|
//! 否则干净安装的系统上会退化成豆腐块。
|
||||||
|
//!
|
||||||
|
//! ## 为什么要表达「这个族有哪几档字重」
|
||||||
|
//!
|
||||||
|
//! M3 Expressive 的强调档会要 **700**(title-medium/title-small/label-*)与 **500**
|
||||||
|
//! (display/headline/body-*)。而中文字体的字重覆盖差别很大:
|
||||||
|
//! **Windows 的 Microsoft YaHei UI 只有 400 / 700 两档**,没有 500;macOS 苹方到 600 就断了、
|
||||||
|
//! 没有 700;只有 Linux 常见的 Noto Sans CJK / 思源黑体(可变字体)才 100~900 全覆盖。
|
||||||
|
//!
|
||||||
|
//! 所以栈项不是「一个族名」,而是 `(族名, 字重覆盖)`([`FontFace`]),并且提供一个纯函数
|
||||||
|
//! [`Platform::resolve_weight`] 说明「这一档字重最终落到哪个族、是原生还是降级」:
|
||||||
|
//!
|
||||||
|
//! | 结果 | 含义 |
|
||||||
|
//! |---|---|
|
||||||
|
//! | [`WeightMatch::Native`] | 该族有这一档(可变字体在区间内也算原生) |
|
||||||
|
//! | [`WeightMatch::Nearest`] | 该族没有这一档,取最近的静态档(视觉上是「淡一点/粗一点」,不假) |
|
||||||
|
//! | [`WeightMatch::Synthetic`] | 最近的档差 ≥2 级(如只有 400 却要 700)→ 引擎**合成加粗**,观感廉价 |
|
||||||
|
//!
|
||||||
|
//! **合成加粗是降级,不是正常路径**(用户令:不许拿合成加粗当「还原到位」)。
|
||||||
|
//! iced/cosmic-text 侧机制:`Font { family, weight, .. }` 由 cosmic-text 按 (family, weight)
|
||||||
|
//! 选面,同族缺档时选最近字重;glyphon 另可用 swash 的合成(fake bold)兜底。
|
||||||
|
//! 中文缺档时的降级顺序:**① 换下一个有该档的族 → ② 用可变字体的 weight 轴 → ③ 最近静态档
|
||||||
|
//! → ④ 最后才是合成加粗**(并且不要用 700 排长中文正文)。
|
||||||
|
//!
|
||||||
|
//! ## 待确认(D28 联动,不在本 mission 拍板)
|
||||||
|
//!
|
||||||
|
//! 将来从 CDN 下载中文字体时,「一个 Regular 文件」不够——至少要 400/500/700 三档子集,
|
||||||
|
//! 或者一个可变字体(体积更大)。这个差异登记在这里,等 D28 的字体下载 mission 决定。
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests;
|
||||||
|
|
||||||
/// 平台(决定字体族回退栈)。
|
/// 平台(决定字体族回退栈)。
|
||||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
|
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
|
||||||
@@ -14,6 +44,179 @@ pub enum Platform {
|
|||||||
Linux,
|
Linux,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// 字重覆盖(一个族实际提供了哪些字重)。
|
||||||
|
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||||
|
pub enum WeightCoverage {
|
||||||
|
/// 可变字体:一个连续区间(weight 轴)。
|
||||||
|
Variable {
|
||||||
|
/// 最细一档。
|
||||||
|
min: u16,
|
||||||
|
/// 最粗一档。
|
||||||
|
max: u16,
|
||||||
|
},
|
||||||
|
/// 静态字体:离散字重表。
|
||||||
|
Static(&'static [u16]),
|
||||||
|
}
|
||||||
|
|
||||||
|
impl WeightCoverage {
|
||||||
|
/// 是否原生覆盖某档字重。
|
||||||
|
pub fn supports(&self, weight: u16) -> bool {
|
||||||
|
match self {
|
||||||
|
WeightCoverage::Variable { min, max } => (*min..=*max).contains(&weight),
|
||||||
|
WeightCoverage::Static(list) => list.contains(&weight),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 最近的可选字重(静态表取绝对值最近;可变字体夹取到区间内)。
|
||||||
|
pub fn nearest(&self, weight: u16) -> u16 {
|
||||||
|
match self {
|
||||||
|
WeightCoverage::Variable { min, max } => weight.clamp(*min, *max),
|
||||||
|
WeightCoverage::Static(list) => {
|
||||||
|
let mut best = list[0];
|
||||||
|
let mut best_delta = u16::MAX;
|
||||||
|
for candidate in *list {
|
||||||
|
let delta = candidate.abs_diff(weight);
|
||||||
|
if delta < best_delta {
|
||||||
|
best = *candidate;
|
||||||
|
best_delta = delta;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
best
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 一条字体栈项:族名 + 字重覆盖。
|
||||||
|
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||||
|
pub struct FontFace {
|
||||||
|
/// 族名(只写名字,不带路径/文件名)。
|
||||||
|
pub family: &'static str,
|
||||||
|
/// 该族提供的字重。
|
||||||
|
pub weights: WeightCoverage,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl FontFace {
|
||||||
|
/// 构造。
|
||||||
|
pub const fn new(family: &'static str, weights: WeightCoverage) -> Self {
|
||||||
|
Self { family, weights }
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 该族是否原生覆盖某档字重。
|
||||||
|
pub fn supports(&self, weight: crate::typography::Weight) -> bool {
|
||||||
|
self.weights.supports(weight.value())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 字重匹配的质量。
|
||||||
|
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||||
|
pub enum WeightMatch {
|
||||||
|
/// 原生有这一档。
|
||||||
|
Native,
|
||||||
|
/// 同族最近档(差 <2 级)。
|
||||||
|
Nearest,
|
||||||
|
/// 差 ≥2 级,引擎只能合成加粗/减细(降级)。
|
||||||
|
Synthetic,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 字重解析结果:最终用哪个族、哪个字重、是原生还是降级。
|
||||||
|
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||||
|
pub struct FontChoice {
|
||||||
|
/// 选中的族名。
|
||||||
|
pub family: &'static str,
|
||||||
|
/// 请求的字重。
|
||||||
|
pub requested: u16,
|
||||||
|
/// 实际可用的字重。
|
||||||
|
pub available: u16,
|
||||||
|
/// 匹配质量。
|
||||||
|
pub quality: WeightMatch,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl FontChoice {
|
||||||
|
/// 是否走了合成加粗(降级路径)。
|
||||||
|
pub fn is_synthetic(&self) -> bool {
|
||||||
|
self.quality == WeightMatch::Synthetic
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 字体族的用途分类(决定取哪条栈)。
|
||||||
|
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
|
||||||
|
pub enum FontFamily {
|
||||||
|
/// 大字号展示(display/headline/title-large):光学尺寸更大。
|
||||||
|
Display,
|
||||||
|
/// 正文(title/body/label)。
|
||||||
|
Text,
|
||||||
|
/// 等宽(日志、代码、版本号)。
|
||||||
|
Mono,
|
||||||
|
}
|
||||||
|
|
||||||
|
// 字重覆盖来源等级:**B**(各平台字体规格/厂商文档;没有一份官方 token 文件能一次给全,
|
||||||
|
// 所以按「常见安装」标定,并用测试钉死——改值要连带改测试与协作文档)。
|
||||||
|
const W_VARIABLE_SEGOE: WeightCoverage = WeightCoverage::Variable { min: 300, max: 700 };
|
||||||
|
const W_VARIABLE_SF: WeightCoverage = WeightCoverage::Variable { min: 100, max: 900 };
|
||||||
|
const W_VARIABLE_NOTO: WeightCoverage = WeightCoverage::Variable { min: 100, max: 900 };
|
||||||
|
const W_VARIABLE_CASCADIA: WeightCoverage = WeightCoverage::Variable { min: 200, max: 700 };
|
||||||
|
const W_VARIABLE_JETBRAINS: WeightCoverage = WeightCoverage::Variable { min: 100, max: 800 };
|
||||||
|
const W_STATIC_SEGOE: WeightCoverage = WeightCoverage::Static(&[300, 400, 600, 700]);
|
||||||
|
const W_STATIC_YAHEI: WeightCoverage = WeightCoverage::Static(&[400, 700]);
|
||||||
|
const W_STATIC_HELVETICA: WeightCoverage = WeightCoverage::Static(&[300, 400, 500, 700]);
|
||||||
|
const W_STATIC_PINGFANG: WeightCoverage = WeightCoverage::Static(&[100, 200, 300, 400, 500, 600]);
|
||||||
|
const W_STATIC_MENLO: WeightCoverage = WeightCoverage::Static(&[400, 700]);
|
||||||
|
const W_STATIC_DEJAVU: WeightCoverage = WeightCoverage::Static(&[400, 700]);
|
||||||
|
|
||||||
|
// 各平台字体栈(写成 const 数组,好让 `font_stack` 的 `const fn` 直接返回 `&'static [_]`)。
|
||||||
|
const WIN_DISPLAY: [FontFace; 5] = [
|
||||||
|
FontFace::new("Segoe UI Variable Display", W_VARIABLE_SEGOE),
|
||||||
|
FontFace::new("Segoe UI", W_STATIC_SEGOE),
|
||||||
|
FontFace::new("Microsoft YaHei UI", W_STATIC_YAHEI),
|
||||||
|
FontFace::new("Microsoft YaHei", W_STATIC_YAHEI),
|
||||||
|
FontFace::new("sans-serif", W_VARIABLE_NOTO),
|
||||||
|
];
|
||||||
|
const WIN_TEXT: [FontFace; 5] = [
|
||||||
|
FontFace::new("Segoe UI Variable Text", W_VARIABLE_SEGOE),
|
||||||
|
FontFace::new("Segoe UI", W_STATIC_SEGOE),
|
||||||
|
FontFace::new("Microsoft YaHei UI", W_STATIC_YAHEI),
|
||||||
|
FontFace::new("Microsoft YaHei", W_STATIC_YAHEI),
|
||||||
|
FontFace::new("sans-serif", W_VARIABLE_NOTO),
|
||||||
|
];
|
||||||
|
const WIN_MONO: [FontFace; 4] = [
|
||||||
|
FontFace::new("Cascadia Mono", W_VARIABLE_CASCADIA),
|
||||||
|
FontFace::new("Consolas", W_STATIC_SEGOE),
|
||||||
|
FontFace::new("Courier New", W_STATIC_MENLO),
|
||||||
|
FontFace::new("monospace", W_STATIC_DEJAVU),
|
||||||
|
];
|
||||||
|
const MAC_DISPLAY: [FontFace; 4] = [
|
||||||
|
FontFace::new("SF Pro Display", W_VARIABLE_SF),
|
||||||
|
FontFace::new("Helvetica Neue", W_STATIC_HELVETICA),
|
||||||
|
FontFace::new("PingFang SC", W_STATIC_PINGFANG),
|
||||||
|
FontFace::new("sans-serif", W_VARIABLE_NOTO),
|
||||||
|
];
|
||||||
|
const MAC_TEXT: [FontFace; 4] = [
|
||||||
|
FontFace::new("SF Pro Text", W_VARIABLE_SF),
|
||||||
|
FontFace::new("Helvetica Neue", W_STATIC_HELVETICA),
|
||||||
|
FontFace::new("PingFang SC", W_STATIC_PINGFANG),
|
||||||
|
FontFace::new("sans-serif", W_VARIABLE_NOTO),
|
||||||
|
];
|
||||||
|
const MAC_MONO: [FontFace; 4] = [
|
||||||
|
FontFace::new("SF Mono", W_VARIABLE_SF),
|
||||||
|
FontFace::new("Menlo", W_STATIC_MENLO),
|
||||||
|
FontFace::new("Monaco", W_STATIC_MENLO),
|
||||||
|
FontFace::new("monospace", W_STATIC_DEJAVU),
|
||||||
|
];
|
||||||
|
const LINUX_SANS: [FontFace; 5] = [
|
||||||
|
FontFace::new("Roboto", W_VARIABLE_NOTO),
|
||||||
|
FontFace::new("Noto Sans", W_VARIABLE_NOTO),
|
||||||
|
FontFace::new("Noto Sans CJK SC", W_VARIABLE_NOTO),
|
||||||
|
FontFace::new("DejaVu Sans", W_STATIC_DEJAVU),
|
||||||
|
FontFace::new("sans-serif", W_VARIABLE_NOTO),
|
||||||
|
];
|
||||||
|
const LINUX_MONO: [FontFace; 4] = [
|
||||||
|
FontFace::new("JetBrains Mono", W_VARIABLE_JETBRAINS),
|
||||||
|
FontFace::new("Noto Sans Mono", W_VARIABLE_NOTO),
|
||||||
|
FontFace::new("DejaVu Sans Mono", W_STATIC_DEJAVU),
|
||||||
|
FontFace::new("monospace", W_STATIC_DEJAVU),
|
||||||
|
];
|
||||||
|
|
||||||
impl Platform {
|
impl Platform {
|
||||||
/// 当前编译目标对应的平台(运行时用户覆盖请显式传 [`Platform`])。
|
/// 当前编译目标对应的平台(运行时用户覆盖请显式传 [`Platform`])。
|
||||||
pub const fn current() -> Self {
|
pub const fn current() -> Self {
|
||||||
@@ -36,146 +239,66 @@ impl Platform {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// 字体族回退栈(由首选到兜底,最后一项是通用族名)。
|
/// 字体族回退栈(由首选到兜底,最后一项是通用族名)。
|
||||||
///
|
pub const fn font_stack(self, family: FontFamily) -> &'static [FontFace] {
|
||||||
/// iced/cosmic-text 按顺序找第一个装了的族;`sans-serif` / `monospace` 是通用兜底,
|
|
||||||
/// 保证「一台干净安装的系统」也不会渲染成豆腐块。
|
|
||||||
pub const fn font_stack(self, family: FontFamily) -> &'static [&'static str] {
|
|
||||||
match (self, family) {
|
match (self, family) {
|
||||||
// Win10 起 Segoe UI 一定在;Win11 有光学尺寸分离的 Variable 版。
|
// Win10 起 Segoe UI 一定在;Win11 有光学尺寸分离的 Variable 版。
|
||||||
(Platform::Windows, FontFamily::Display) => &[
|
(Platform::Windows, FontFamily::Display) => &WIN_DISPLAY,
|
||||||
"Segoe UI Variable Display",
|
(Platform::Windows, FontFamily::Text) => &WIN_TEXT,
|
||||||
"Segoe UI",
|
(Platform::Windows, FontFamily::Mono) => &WIN_MONO,
|
||||||
"Microsoft YaHei UI",
|
// macOS 12+:SF Pro 分 Display/Text 光学尺寸;中文走苹方(只到 600)。
|
||||||
"Microsoft YaHei",
|
(Platform::MacOs, FontFamily::Display) => &MAC_DISPLAY,
|
||||||
"sans-serif",
|
(Platform::MacOs, FontFamily::Text) => &MAC_TEXT,
|
||||||
],
|
(Platform::MacOs, FontFamily::Mono) => &MAC_MONO,
|
||||||
(Platform::Windows, FontFamily::Text) => &[
|
// Linux:不假设发行版预装什么,Roboto/Noto 是最常见的两套(Display 与 Text 同栈)。
|
||||||
"Segoe UI Variable Text",
|
(Platform::Linux, FontFamily::Display | FontFamily::Text) => &LINUX_SANS,
|
||||||
"Segoe UI",
|
(Platform::Linux, FontFamily::Mono) => &LINUX_MONO,
|
||||||
"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",
|
|
||||||
],
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// 字体族的用途分类(决定取哪条栈)。
|
/// 该平台的**中文**兜底族(CJK 字形实际落到的那个)。
|
||||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
|
///
|
||||||
pub enum FontFamily {
|
/// 注意:栈里的 CJK 族只是「缺字形时的候选」,最终由文本引擎逐字形回退决定;
|
||||||
/// 大字号展示(display/headline):光学尺寸更大。
|
/// 这个函数把它单独暴露出来,好让调用方按模块文档的降级顺序处理字重。
|
||||||
Display,
|
pub const fn cjk_face(self) -> FontFace {
|
||||||
/// 正文(title/body/label)。
|
match self {
|
||||||
Text,
|
Platform::Windows => FontFace::new("Microsoft YaHei UI", W_STATIC_YAHEI),
|
||||||
/// 等宽(日志、代码、版本号)。
|
Platform::MacOs => FontFace::new("PingFang SC", W_STATIC_PINGFANG),
|
||||||
Mono,
|
Platform::Linux => FontFace::new("Noto Sans CJK SC", W_VARIABLE_NOTO),
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
#[cfg(test)]
|
/// 按栈顺序解析某档字重的落点(纯函数,不查系统)。
|
||||||
mod tests {
|
///
|
||||||
use super::*;
|
/// 规则:**优先原生**(栈里第一个真有的族)→ 都做不到时取首选族的最近档,
|
||||||
|
/// 差距 ≥200 判为 [`WeightMatch::Synthetic`]。
|
||||||
const PLATFORMS: [Platform; 3] = [Platform::Windows, Platform::MacOs, Platform::Linux];
|
pub fn resolve_weight(
|
||||||
const FAMILIES: [FontFamily; 3] = [FontFamily::Display, FontFamily::Text, FontFamily::Mono];
|
self,
|
||||||
|
family: FontFamily,
|
||||||
#[test]
|
weight: crate::typography::Weight,
|
||||||
fn font_stacks_are_well_formed() {
|
) -> FontChoice {
|
||||||
for platform in PLATFORMS {
|
let stack = self.font_stack(family);
|
||||||
for family in FAMILIES {
|
let requested = weight.value();
|
||||||
let stack = platform.font_stack(family);
|
for face in stack {
|
||||||
assert!(
|
if face.weights.supports(requested) {
|
||||||
!stack.is_empty(),
|
return FontChoice {
|
||||||
"{} 的 {family:?} 栈为空",
|
family: face.family,
|
||||||
platform.name()
|
requested,
|
||||||
);
|
available: requested,
|
||||||
let last = stack[stack.len() - 1];
|
quality: WeightMatch::Native,
|
||||||
let generic = match family {
|
|
||||||
FontFamily::Mono => "monospace",
|
|
||||||
_ => "sans-serif",
|
|
||||||
};
|
};
|
||||||
assert_eq!(
|
}
|
||||||
last,
|
}
|
||||||
generic,
|
let first = stack[0];
|
||||||
"{} 的 {family:?} 栈必须以通用族名兜底",
|
let available = first.weights.nearest(requested);
|
||||||
platform.name()
|
FontChoice {
|
||||||
);
|
family: first.family,
|
||||||
for name in stack {
|
requested,
|
||||||
assert!(!name.is_empty());
|
available,
|
||||||
assert!(
|
quality: if available.abs_diff(requested) >= 200 {
|
||||||
!name.contains(['/', '\\', '.', ':']),
|
WeightMatch::Synthetic
|
||||||
"字体栈只允许族名,不得出现路径/文件名:{name}"
|
} else {
|
||||||
);
|
WeightMatch::Nearest
|
||||||
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());
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -0,0 +1,149 @@
|
|||||||
|
//! 字体栈与字重解析的测试(独立文件是为了守住单文件 ≤400 行的硬约束)。
|
||||||
|
|
||||||
|
use super::*;
|
||||||
|
use crate::typography::Weight;
|
||||||
|
|
||||||
|
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].family;
|
||||||
|
let generic = match family {
|
||||||
|
FontFamily::Mono => "monospace",
|
||||||
|
_ => "sans-serif",
|
||||||
|
};
|
||||||
|
assert_eq!(
|
||||||
|
last,
|
||||||
|
generic,
|
||||||
|
"{} 的 {family:?} 栈必须以通用族名兜底",
|
||||||
|
platform.name()
|
||||||
|
);
|
||||||
|
for face in stack {
|
||||||
|
assert!(!face.family.is_empty());
|
||||||
|
assert!(
|
||||||
|
!face.family.contains(['/', '\\', '.', ':']),
|
||||||
|
"字体栈只允许族名,不得出现路径/文件名:{}",
|
||||||
|
face.family
|
||||||
|
);
|
||||||
|
assert!(
|
||||||
|
face.family.is_ascii(),
|
||||||
|
"族名应为 ASCII(非 ASCII 名交给系统匹配):{}",
|
||||||
|
face.family
|
||||||
|
);
|
||||||
|
// 每条栈项都必须声明字重覆盖(连 400 都没有的族写进栈没有意义)。
|
||||||
|
assert!(
|
||||||
|
face.supports(Weight::Regular),
|
||||||
|
"{} 连 400 都没有,写它没意义",
|
||||||
|
face.family
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn display_and_text_stacks_differ_on_windows_and_macos() {
|
||||||
|
assert_ne!(
|
||||||
|
Platform::Windows.font_stack(FontFamily::Display)[0].family,
|
||||||
|
Platform::Windows.font_stack(FontFamily::Text)[0].family
|
||||||
|
);
|
||||||
|
assert_ne!(
|
||||||
|
Platform::MacOs.font_stack(FontFamily::Display)[0].family,
|
||||||
|
Platform::MacOs.font_stack(FontFamily::Text)[0].family
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn chinese_fallback_is_present_but_not_first() {
|
||||||
|
// 中文族名必须在栈里,但不能排第一(纯英文环境不加载中文字体,D28)。
|
||||||
|
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(|face| cjk.contains(&face.family)),
|
||||||
|
"{} 的正文栈缺 CJK 兜底",
|
||||||
|
platform.name()
|
||||||
|
);
|
||||||
|
assert!(
|
||||||
|
!cjk.contains(&stack[0].family),
|
||||||
|
"{} 的首选字体不该是 CJK 族",
|
||||||
|
platform.name()
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn cjk_weight_coverage_is_registered() {
|
||||||
|
// 用户点名的坑:中文字重覆盖三平台差别很大。
|
||||||
|
let win = Platform::Windows.cjk_face();
|
||||||
|
assert!(win.supports(Weight::Regular) && win.supports(Weight::Bold));
|
||||||
|
assert!(
|
||||||
|
!win.supports(Weight::Medium),
|
||||||
|
"雅黑没有 500——这条断言就是「中文 500 会掉到降级路径」的登记"
|
||||||
|
);
|
||||||
|
let mac = Platform::MacOs.cjk_face();
|
||||||
|
assert!(mac.supports(Weight::Medium));
|
||||||
|
assert!(
|
||||||
|
!mac.supports(Weight::Bold),
|
||||||
|
"苹方到 600 为止,700 要合成/换族"
|
||||||
|
);
|
||||||
|
let linux = Platform::Linux.cjk_face();
|
||||||
|
for weight in [Weight::Regular, Weight::Medium, Weight::Bold] {
|
||||||
|
assert!(
|
||||||
|
linux.supports(weight),
|
||||||
|
"思源黑体/Noto CJK 应覆盖 400/500/700"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn resolve_prefers_native_and_flags_synthetic() {
|
||||||
|
// Windows 正文 700:Segoe UI Variable 覆盖 → 原生。
|
||||||
|
let bold = Platform::Windows.resolve_weight(FontFamily::Text, Weight::Bold);
|
||||||
|
assert_eq!(bold.quality, WeightMatch::Native);
|
||||||
|
assert_eq!(bold.available, 700);
|
||||||
|
assert!(!bold.is_synthetic());
|
||||||
|
// Linux 正文 500:Roboto 可变 → 原生。
|
||||||
|
assert_eq!(
|
||||||
|
Platform::Linux
|
||||||
|
.resolve_weight(FontFamily::Text, Weight::Medium)
|
||||||
|
.quality,
|
||||||
|
WeightMatch::Native
|
||||||
|
);
|
||||||
|
// 静态族缺档:只有 400 的族请求 700 → nearest 400,差 300 → 判为合成降级。
|
||||||
|
let only_regular = FontFace::new("Courier New", WeightCoverage::Static(&[400]));
|
||||||
|
assert_eq!(only_regular.weights.nearest(700), 400);
|
||||||
|
let synthetic = FontChoice {
|
||||||
|
family: only_regular.family,
|
||||||
|
requested: 700,
|
||||||
|
available: only_regular.weights.nearest(700),
|
||||||
|
quality: WeightMatch::Synthetic,
|
||||||
|
};
|
||||||
|
assert!(synthetic.is_synthetic());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn nearest_picks_closest_static_weight() {
|
||||||
|
let coverage = WeightCoverage::Static(&[300, 400, 600, 700]);
|
||||||
|
assert_eq!(coverage.nearest(500), 400);
|
||||||
|
assert_eq!(coverage.nearest(650), 600);
|
||||||
|
assert_eq!(coverage.nearest(900), 700);
|
||||||
|
let variable = WeightCoverage::Variable { min: 100, max: 900 };
|
||||||
|
assert_eq!(variable.nearest(500), 500);
|
||||||
|
assert_eq!(variable.nearest(50), 100);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn current_platform_returns_a_stack() {
|
||||||
|
let platform = Platform::current();
|
||||||
|
assert!(!platform.font_stack(FontFamily::Text).is_empty());
|
||||||
|
}
|
||||||
@@ -1,23 +1,31 @@
|
|||||||
//! M3 type scale(15 档)+ 三平台字体族回退栈。
|
//! M3 type scale(15 档,baseline + Expressive 强调两套)+ 三平台字体族回退栈。
|
||||||
|
//!
|
||||||
|
//! **数值来源 A 级**:`androidx.compose.material3.tokens.TypeScaleTokens`(v0_103)。
|
||||||
|
//! baseline 四元组取 `<Role>{Size,LineHeight,Tracking,Weight}`,Expressive 强调档取
|
||||||
|
//! `<Role>Emphasized{...}`(见 [`emphasized`] 模块)。注意 Compose 这一代 token 的 tracking 与
|
||||||
|
//! m3.material.io 网页表格有 0.05px 级别的差异(display-large −0.2 vs −0.25、body-medium 0.2 vs
|
||||||
|
//! 0.25、title-medium 0.2 vs 0.15),本项目跟 **Compose(实际渲染值)**,差异登记在
|
||||||
|
//! crate 文档「baseline vs Expressive 差异清单」里。
|
||||||
//!
|
//!
|
||||||
//! **只写字体族名,不内嵌任何字体文件**(Segoe UI / SF Pro / Roboto 都受各自许可约束,
|
//! **只写字体族名,不内嵌任何字体文件**(Segoe UI / SF Pro / Roboto 都受各自许可约束,
|
||||||
//! 内嵌再分发是违规的;引用系统已安装的族名是合规且体积为零的做法——这是 D14 与 D28 的共同要求)。
|
//! 内嵌再分发是违规的;引用系统已安装的族名是合规且体积为零的做法——这是 D14 与 D28 的共同要求)。
|
||||||
//! 中文由系统字体匹配:栈里显式列出 CJK 族名,纯英文环境不会去加载任何中文字体(D28)。
|
//! 中文由系统字体匹配:栈里显式列出 CJK 族名,纯英文环境不会去加载任何中文字体(D28)。
|
||||||
|
|
||||||
|
mod emphasized;
|
||||||
mod fonts;
|
mod fonts;
|
||||||
|
|
||||||
pub use fonts::{FontFamily, Platform};
|
pub use fonts::{FontChoice, FontFace, FontFamily, Platform, WeightCoverage, WeightMatch};
|
||||||
|
|
||||||
/// 字重(M3 只用这四档;可变字体按连续值渲染时取 [`Weight::value`])。
|
/// 字重(M3 只用这四档;可变字体按连续值渲染时取 [`Weight::value`])。
|
||||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
|
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
|
||||||
pub enum Weight {
|
pub enum Weight {
|
||||||
/// 400:正文。
|
/// 400:正文。
|
||||||
Regular,
|
Regular,
|
||||||
/// 500:label / title-medium。
|
/// 500:label / title-medium / Expressive 强调的正文与展示类。
|
||||||
Medium,
|
Medium,
|
||||||
/// 600:强调标题(本项目的「区块标题」档)。
|
/// 600:本项目的「区块标题」档(介于 Medium 与 Bold 之间)。
|
||||||
SemiBold,
|
SemiBold,
|
||||||
/// 700:页面标题 / 空态主文案。
|
/// 700:页面标题 / 空态主文案 / Expressive 强调的 label 与 title-small-medium。
|
||||||
Bold,
|
Bold,
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -32,7 +40,7 @@ impl Weight {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// 再重一档(Expressive 的 emphasized 用得上,最重封顶 Bold)。
|
/// 再重一档(最重封顶 Bold)。
|
||||||
pub const fn heavier(self) -> Self {
|
pub const fn heavier(self) -> Self {
|
||||||
match self {
|
match self {
|
||||||
Weight::Regular => Weight::Medium,
|
Weight::Regular => Weight::Medium,
|
||||||
@@ -69,10 +77,15 @@ impl TypeStyle {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// 该样式的字体族回退栈。
|
/// 该样式的字体族回退栈(栈项带字重覆盖,见 [`FontFace`])。
|
||||||
pub fn font_stack(self, platform: Platform) -> &'static [&'static str] {
|
pub fn font_stack(self, platform: Platform) -> &'static [FontFace] {
|
||||||
platform.font_stack(self.family)
|
platform.font_stack(self.family)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// 该样式在这台平台上「字重最终落到哪个族」——中文缺档时看它判断是否走降级。
|
||||||
|
pub fn resolve(self, platform: Platform) -> FontChoice {
|
||||||
|
platform.resolve_weight(self.family, self.weight)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// M3 type scale 的 15 个角色。
|
/// M3 type scale 的 15 个角色。
|
||||||
@@ -151,12 +164,10 @@ impl TypeRole {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// M3 基线样式(字号 / 行高 / 字重 / 字距,官方 type scale 值)。
|
/// **baseline MD3** 样式(官方 type scale 值:字号 / 行高 / 字重 / 字距 + 字体族分类)。
|
||||||
pub const fn style(self) -> TypeStyle {
|
pub const fn style(self) -> TypeStyle {
|
||||||
match self {
|
match self {
|
||||||
TypeRole::DisplayLarge => {
|
TypeRole::DisplayLarge => style(57.0, 64.0, Weight::Regular, -0.2, FontFamily::Display),
|
||||||
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::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::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::HeadlineLarge => style(32.0, 40.0, Weight::Regular, 0.0, FontFamily::Display),
|
||||||
@@ -164,11 +175,12 @@ impl TypeRole {
|
|||||||
style(28.0, 36.0, Weight::Regular, 0.0, FontFamily::Display)
|
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::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),
|
// 官方 TitleLarge 用 Brand 族(与 Display 同栈),其余 title 用 Plain(Text)。
|
||||||
TypeRole::TitleMedium => style(16.0, 24.0, Weight::Medium, 0.15, FontFamily::Text),
|
TypeRole::TitleLarge => style(22.0, 28.0, Weight::Regular, 0.0, FontFamily::Display),
|
||||||
|
TypeRole::TitleMedium => style(16.0, 24.0, Weight::Medium, 0.2, FontFamily::Text),
|
||||||
TypeRole::TitleSmall => style(14.0, 20.0, Weight::Medium, 0.1, 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::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::BodyMedium => style(14.0, 20.0, Weight::Regular, 0.2, FontFamily::Text),
|
||||||
TypeRole::BodySmall => style(12.0, 16.0, Weight::Regular, 0.4, 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::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::LabelMedium => style(12.0, 16.0, Weight::Medium, 0.5, FontFamily::Text),
|
||||||
@@ -176,12 +188,20 @@ impl TypeRole {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Expressive 强调档:**字重升一级,字号/行高/字距不变**。
|
/// **M3 Expressive** 官方强调档(15/15 全有值,A 级来源)。
|
||||||
///
|
///
|
||||||
/// 口径声明:M3 Expressive 确实有 emphasized 排版档,但官方没公开与这 15 档一一对应的数值;
|
/// 规则(官方):字号与行高不变;字重升到 500(展示/正文类)或 700(label 与小标题);
|
||||||
/// 这里定的是**我们自己的规则**(加粗一档),目的是「同一 role 的强调态只改一个字重」,
|
/// 字距按官方表微调(display-large −0.2→0、body-large 0.5→0.15、title-medium 0.2→0.15、
|
||||||
/// 保证页面不会因为强调而改变布局高度。要更强的层级请换更大 role,不要继续加字重。
|
/// body-medium 0.2→0.25)。这是**官方值,不是我们的近似**。
|
||||||
pub const fn emphasized(self) -> TypeStyle {
|
pub const fn emphasized(self) -> TypeStyle {
|
||||||
|
emphasized::style_of(self)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// **简化强调规则(非官方)**:只把字重升一级,字号/行高/字距全不动。
|
||||||
|
///
|
||||||
|
/// 用途:需要在「同一 role 内加强语气但不想引入任何字距变化」的场合(例如同屏多行标题
|
||||||
|
/// 需要严格对齐基线网格时)。**优先用 [`TypeRole::emphasized`]**——那才是 Expressive 的定义。
|
||||||
|
pub const fn emphasized_simplified(self) -> TypeStyle {
|
||||||
let base = self.style();
|
let base = self.style();
|
||||||
TypeStyle {
|
TypeStyle {
|
||||||
weight: base.weight.heavier(),
|
weight: base.weight.heavier(),
|
||||||
@@ -286,16 +306,34 @@ mod tests {
|
|||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn emphasized_only_bumps_weight() {
|
fn simplified_emphasis_only_bumps_weight() {
|
||||||
for role in TypeRole::ALL {
|
for role in TypeRole::ALL {
|
||||||
let base = role.style();
|
let base = role.style();
|
||||||
let emph = role.emphasized();
|
let emph = role.emphasized_simplified();
|
||||||
assert_eq!(base.size_px, emph.size_px);
|
assert_eq!(base.size_px, emph.size_px);
|
||||||
assert_eq!(base.line_height_px, emph.line_height_px);
|
assert_eq!(base.line_height_px, emph.line_height_px);
|
||||||
|
assert_eq!(base.letter_spacing_px, emph.letter_spacing_px);
|
||||||
assert!(emph.weight.value() > base.weight.value() || base.weight == Weight::Bold);
|
assert!(emph.weight.value() > base.weight.value() || base.weight == Weight::Bold);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn official_emphasis_is_at_least_as_heavy_as_baseline() {
|
||||||
|
for role in TypeRole::ALL {
|
||||||
|
assert!(
|
||||||
|
role.emphasized().weight.value() >= role.style().weight.value(),
|
||||||
|
"{} 的官方强调档不该比 baseline 更轻",
|
||||||
|
role.name()
|
||||||
|
);
|
||||||
|
assert_ne!(
|
||||||
|
role.emphasized(),
|
||||||
|
role.style(),
|
||||||
|
"{} 强调档与 baseline 相同",
|
||||||
|
role.name()
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn scaling_is_proportional_and_bounded() {
|
fn scaling_is_proportional_and_bounded() {
|
||||||
let base = TypeRole::BodyMedium.style();
|
let base = TypeRole::BodyMedium.style();
|
||||||
|
|||||||
@@ -122,7 +122,7 @@ fn exception_list_is_honest_about_its_thresholds() {
|
|||||||
let scheme = Scheme::from_seed(flavor, seed);
|
let scheme = Scheme::from_seed(flavor, seed);
|
||||||
for (fg, bg, threshold, reason) in EXCEPTIONS {
|
for (fg, bg, threshold, reason) in EXCEPTIONS {
|
||||||
assert!(
|
assert!(
|
||||||
!fg.is_foreground() || fg == Role::Outline,
|
!fg.is_foreground(),
|
||||||
"例外 {} 不该是文字前景色({reason})",
|
"例外 {} 不该是文字前景色({reason})",
|
||||||
fg.name()
|
fg.name()
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -9,7 +9,9 @@ use unlucky_design::elevation::{Elevation, MaterialLayer};
|
|||||||
use unlucky_design::icons::{
|
use unlucky_design::icons::{
|
||||||
stroke_width_for, visual_stroke_px, IconKind, IconPaint, IconSize, VIEWBOX,
|
stroke_width_for, visual_stroke_px, IconKind, IconPaint, IconSize, VIEWBOX,
|
||||||
};
|
};
|
||||||
use unlucky_design::motion::{DurationToken, Easing, MotionScale, SPRING_SPATIAL_DEFAULT};
|
use unlucky_design::motion::{
|
||||||
|
DurationToken, Easing, MotionScale, SPRING_EXPRESSIVE_SPATIAL_DEFAULT,
|
||||||
|
};
|
||||||
use unlucky_design::shape::ShapeScale;
|
use unlucky_design::shape::ShapeScale;
|
||||||
use unlucky_design::spacing::{LayoutMetrics, Space};
|
use unlucky_design::spacing::{LayoutMetrics, Space};
|
||||||
use unlucky_design::typography::{FontFamily, Platform, TypeRole, Weight};
|
use unlucky_design::typography::{FontFamily, Platform, TypeRole, Weight};
|
||||||
@@ -73,13 +75,14 @@ fn font_stacks_never_embed_font_files() {
|
|||||||
for family in [FontFamily::Display, FontFamily::Text, FontFamily::Mono] {
|
for family in [FontFamily::Display, FontFamily::Text, FontFamily::Mono] {
|
||||||
let stack = platform.font_stack(family);
|
let stack = platform.font_stack(family);
|
||||||
assert!(!stack.is_empty());
|
assert!(!stack.is_empty());
|
||||||
for name in stack {
|
for face in stack {
|
||||||
let lower = name.to_ascii_lowercase();
|
let lower = face.family.to_ascii_lowercase();
|
||||||
for banned in [".ttf", ".otf", ".ttc", "woff", "file"] {
|
for banned in [".ttf", ".otf", ".ttc", "woff", "file"] {
|
||||||
assert!(
|
assert!(
|
||||||
!lower.contains(banned),
|
!lower.contains(banned),
|
||||||
"{} 的字体栈出现文件引用 {name}(只允许族名,禁止内嵌受限字体)",
|
"{} 的字体栈出现文件引用 {}(只允许族名,禁止内嵌受限字体)",
|
||||||
platform.name()
|
platform.name(),
|
||||||
|
face.family
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -87,6 +90,22 @@ fn font_stacks_never_embed_font_files() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn cjk_weight_gap_is_registered() {
|
||||||
|
// 中文 700(Expressive 强调档要用的)在三平台都要能找到原生落点,否则就是合成加粗降级。
|
||||||
|
use unlucky_design::typography::{Weight, WeightMatch};
|
||||||
|
// Windows:Segoe UI Variable 覆盖 700(原生);CJK 落点是雅黑 700(原生,雅黑有 Bold)。
|
||||||
|
let win = Platform::Windows.resolve_weight(FontFamily::Text, Weight::Bold);
|
||||||
|
assert_eq!(win.quality, WeightMatch::Native);
|
||||||
|
assert!(Platform::Windows.cjk_face().supports(Weight::Bold));
|
||||||
|
// macOS:苹方只到 600 → CJK 的 700 只能合成/换族,这条 gap 已在文档登记。
|
||||||
|
assert!(!Platform::MacOs.cjk_face().supports(Weight::Bold));
|
||||||
|
// Linux:Noto Sans CJK 可变字体全覆盖。
|
||||||
|
for weight in [Weight::Regular, Weight::Medium, Weight::Bold] {
|
||||||
|
assert!(Platform::Linux.cjk_face().supports(weight));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn icon_kind_is_frozen_and_append_only() {
|
fn icon_kind_is_frozen_and_append_only() {
|
||||||
assert_eq!(IconKind::ALL.len(), 28, "M61 冻结 28 个图标");
|
assert_eq!(IconKind::ALL.len(), 28, "M61 冻结 28 个图标");
|
||||||
@@ -148,7 +167,7 @@ fn motion_tokens_are_usable_as_data() {
|
|||||||
assert_eq!(DurationToken::Medium2.ms(), 300);
|
assert_eq!(DurationToken::Medium2.ms(), 300);
|
||||||
assert!((Easing::Standard.sample(0.0)).abs() < 1.0e-6);
|
assert!((Easing::Standard.sample(0.0)).abs() < 1.0e-6);
|
||||||
assert!((Easing::Standard.sample(1.0) - 1.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);
|
let settle = SPRING_EXPRESSIVE_SPATIAL_DEFAULT.settle_ms(0.001);
|
||||||
assert!(settle > 0.0 && settle < 2000.0);
|
assert!(settle > 0.0 && settle < 2000.0);
|
||||||
assert_eq!(MotionScale::NONE.ms(DurationToken::Long4), 0.0);
|
assert_eq!(MotionScale::NONE.ms(DurationToken::Long4), 0.0);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,252 @@
|
|||||||
|
//! 官方数值钉死(二):**色彩 tone 表 49 条**、7 条 easing 曲线、16 档时长、28 个图标下标、
|
||||||
|
//! 形状阶梯,以及「同族容器不撞色」的回归(reviewer P3-2)。
|
||||||
|
//!
|
||||||
|
//! 期望值全部独立于实现重写;实现改一个数、表不改,这里就红。
|
||||||
|
|
||||||
|
use unlucky_design::color::{Role, Scheme, SchemeFlavor};
|
||||||
|
use unlucky_design::icons::IconKind;
|
||||||
|
use unlucky_design::motion::{DurationToken, Easing};
|
||||||
|
use unlucky_design::shape::ShapeScale;
|
||||||
|
|
||||||
|
fn expect_tone(role: Role, light: f32, dark: f32) {
|
||||||
|
assert_eq!(
|
||||||
|
Scheme::tone_of(role, SchemeFlavor::Light),
|
||||||
|
light,
|
||||||
|
"{} 的 light tone 被改动了",
|
||||||
|
role.name()
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
Scheme::tone_of(role, SchemeFlavor::Dark),
|
||||||
|
dark,
|
||||||
|
"{} 的 dark tone 被改动了",
|
||||||
|
role.name()
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn all_forty_nine_tones_are_pinned() {
|
||||||
|
expect_tone(Role::Primary, 40.0, 80.0);
|
||||||
|
expect_tone(Role::OnPrimary, 100.0, 20.0);
|
||||||
|
expect_tone(Role::PrimaryContainer, 90.0, 30.0);
|
||||||
|
expect_tone(Role::OnPrimaryContainer, 10.0, 90.0);
|
||||||
|
expect_tone(Role::Secondary, 40.0, 80.0);
|
||||||
|
expect_tone(Role::OnSecondary, 100.0, 20.0);
|
||||||
|
expect_tone(Role::SecondaryContainer, 90.0, 30.0);
|
||||||
|
expect_tone(Role::OnSecondaryContainer, 10.0, 90.0);
|
||||||
|
expect_tone(Role::Tertiary, 40.0, 80.0);
|
||||||
|
expect_tone(Role::OnTertiary, 100.0, 20.0);
|
||||||
|
expect_tone(Role::TertiaryContainer, 90.0, 30.0);
|
||||||
|
expect_tone(Role::OnTertiaryContainer, 10.0, 90.0);
|
||||||
|
expect_tone(Role::Error, 40.0, 80.0);
|
||||||
|
expect_tone(Role::OnError, 100.0, 20.0);
|
||||||
|
expect_tone(Role::ErrorContainer, 90.0, 30.0);
|
||||||
|
expect_tone(Role::OnErrorContainer, 10.0, 90.0);
|
||||||
|
expect_tone(Role::Surface, 98.0, 6.0);
|
||||||
|
expect_tone(Role::OnSurface, 10.0, 90.0);
|
||||||
|
expect_tone(Role::SurfaceVariant, 90.0, 30.0);
|
||||||
|
expect_tone(Role::OnSurfaceVariant, 30.0, 80.0);
|
||||||
|
expect_tone(Role::Background, 99.0, 10.0);
|
||||||
|
expect_tone(Role::OnBackground, 10.0, 90.0);
|
||||||
|
expect_tone(Role::SurfaceDim, 87.0, 6.0);
|
||||||
|
expect_tone(Role::SurfaceBright, 98.0, 24.0);
|
||||||
|
expect_tone(Role::SurfaceContainerLowest, 100.0, 4.0);
|
||||||
|
expect_tone(Role::SurfaceContainerLow, 96.0, 10.0);
|
||||||
|
expect_tone(Role::SurfaceContainer, 94.0, 12.0);
|
||||||
|
expect_tone(Role::SurfaceContainerHigh, 92.0, 17.0);
|
||||||
|
expect_tone(Role::SurfaceContainerHighest, 90.0, 22.0);
|
||||||
|
expect_tone(Role::Outline, 50.0, 60.0);
|
||||||
|
expect_tone(Role::OutlineVariant, 80.0, 30.0);
|
||||||
|
expect_tone(Role::InverseSurface, 20.0, 90.0);
|
||||||
|
expect_tone(Role::InverseOnSurface, 95.0, 20.0);
|
||||||
|
expect_tone(Role::InversePrimary, 80.0, 40.0);
|
||||||
|
expect_tone(Role::Shadow, 0.0, 0.0);
|
||||||
|
expect_tone(Role::Scrim, 0.0, 0.0);
|
||||||
|
expect_tone(Role::SurfaceTint, 40.0, 80.0);
|
||||||
|
expect_tone(Role::PrimaryFixed, 90.0, 90.0);
|
||||||
|
expect_tone(Role::PrimaryFixedDim, 80.0, 80.0);
|
||||||
|
expect_tone(Role::OnPrimaryFixed, 10.0, 10.0);
|
||||||
|
expect_tone(Role::OnPrimaryFixedVariant, 30.0, 30.0);
|
||||||
|
expect_tone(Role::SecondaryFixed, 90.0, 90.0);
|
||||||
|
expect_tone(Role::SecondaryFixedDim, 80.0, 80.0);
|
||||||
|
expect_tone(Role::OnSecondaryFixed, 10.0, 10.0);
|
||||||
|
expect_tone(Role::OnSecondaryFixedVariant, 30.0, 30.0);
|
||||||
|
expect_tone(Role::TertiaryFixed, 90.0, 90.0);
|
||||||
|
expect_tone(Role::TertiaryFixedDim, 80.0, 80.0);
|
||||||
|
expect_tone(Role::OnTertiaryFixed, 10.0, 10.0);
|
||||||
|
expect_tone(Role::OnTertiaryFixedVariant, 30.0, 30.0);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn easing_curves_are_pinned() {
|
||||||
|
let cases: [(Easing, f32, f32, f32, f32); 7] = [
|
||||||
|
(Easing::Linear, 0.0, 0.0, 1.0, 1.0),
|
||||||
|
(Easing::Standard, 0.2, 0.0, 0.0, 1.0),
|
||||||
|
(Easing::StandardDecelerate, 0.0, 0.0, 0.0, 1.0),
|
||||||
|
(Easing::StandardAccelerate, 0.3, 0.0, 1.0, 1.0),
|
||||||
|
(Easing::Emphasized, 0.2, 0.0, 0.0, 1.0),
|
||||||
|
(Easing::EmphasizedDecelerate, 0.05, 0.7, 0.1, 1.0),
|
||||||
|
(Easing::EmphasizedAccelerate, 0.3, 0.0, 0.8, 0.15),
|
||||||
|
];
|
||||||
|
for (easing, x1, y1, x2, y2) in cases {
|
||||||
|
let curve = easing.curve();
|
||||||
|
assert_eq!(
|
||||||
|
(curve.x1, curve.y1, curve.x2, curve.y2),
|
||||||
|
(x1, y1, x2, y2),
|
||||||
|
"{easing:?} 的曲线参数被改动了"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn durations_are_pinned() {
|
||||||
|
let cases: [(DurationToken, u32); 16] = [
|
||||||
|
(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),
|
||||||
|
];
|
||||||
|
for (token, ms) in cases {
|
||||||
|
assert_eq!(token.ms(), ms, "{} 的毫秒值被改动了", token.name());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn spring_presets_are_pinned() {
|
||||||
|
use unlucky_design::motion::{
|
||||||
|
spring, MotionSchemeKind, Spring, SpringCategory, SpringSpeed, SPRING_EFFECTS_DEFAULT,
|
||||||
|
SPRING_EFFECTS_FAST, SPRING_EFFECTS_SLOW, SPRING_EXPRESSIVE_SPATIAL_DEFAULT,
|
||||||
|
SPRING_EXPRESSIVE_SPATIAL_FAST, SPRING_EXPRESSIVE_SPATIAL_SLOW,
|
||||||
|
SPRING_STANDARD_SPATIAL_DEFAULT, SPRING_STANDARD_SPATIAL_FAST,
|
||||||
|
SPRING_STANDARD_SPATIAL_SLOW,
|
||||||
|
};
|
||||||
|
// A 级:StandardMotionTokens / ExpressiveMotionTokens(damping = 阻尼比 ζ,stiffness = k)。
|
||||||
|
let cases: [(Spring, f32, f32); 9] = [
|
||||||
|
// standard 方案 spatial:ζ=0.9(保守)
|
||||||
|
(SPRING_STANDARD_SPATIAL_FAST, 1400.0, 0.9),
|
||||||
|
(SPRING_STANDARD_SPATIAL_DEFAULT, 700.0, 0.9),
|
||||||
|
(SPRING_STANDARD_SPATIAL_SLOW, 300.0, 0.9),
|
||||||
|
// expressive 方案 spatial:ζ=0.6/0.8(回弹明显)
|
||||||
|
(SPRING_EXPRESSIVE_SPATIAL_FAST, 800.0, 0.6),
|
||||||
|
(SPRING_EXPRESSIVE_SPATIAL_DEFAULT, 380.0, 0.8),
|
||||||
|
(SPRING_EXPRESSIVE_SPATIAL_SLOW, 200.0, 0.8),
|
||||||
|
// effects 两套同值:ζ=1.0(不过冲)
|
||||||
|
(SPRING_EFFECTS_FAST, 3800.0, 1.0),
|
||||||
|
(SPRING_EFFECTS_DEFAULT, 1600.0, 1.0),
|
||||||
|
(SPRING_EFFECTS_SLOW, 800.0, 1.0),
|
||||||
|
];
|
||||||
|
for (entry, stiffness, damping_ratio) in cases {
|
||||||
|
assert_eq!(entry.stiffness, stiffness, "弹簧刚度被改动");
|
||||||
|
assert_eq!(entry.damping_ratio, damping_ratio, "弹簧阻尼比被改动");
|
||||||
|
}
|
||||||
|
// 二维表查询与常量必须一致(改了表结构也别想绕过 pin)。
|
||||||
|
assert_eq!(
|
||||||
|
spring(
|
||||||
|
MotionSchemeKind::Expressive,
|
||||||
|
SpringCategory::Spatial,
|
||||||
|
SpringSpeed::Default
|
||||||
|
),
|
||||||
|
SPRING_EXPRESSIVE_SPATIAL_DEFAULT
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
spring(
|
||||||
|
MotionSchemeKind::Standard,
|
||||||
|
SpringCategory::Spatial,
|
||||||
|
SpringSpeed::Fast
|
||||||
|
),
|
||||||
|
SPRING_STANDARD_SPATIAL_FAST
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn icon_indices_are_pinned() {
|
||||||
|
let names = [
|
||||||
|
"instances",
|
||||||
|
"download",
|
||||||
|
"logs",
|
||||||
|
"settings",
|
||||||
|
"ai",
|
||||||
|
"play",
|
||||||
|
"stop",
|
||||||
|
"search",
|
||||||
|
"plus",
|
||||||
|
"close",
|
||||||
|
"minimize",
|
||||||
|
"maximize",
|
||||||
|
"chevron-left",
|
||||||
|
"chevron-right",
|
||||||
|
"chevron-down",
|
||||||
|
"expand",
|
||||||
|
"send",
|
||||||
|
"check",
|
||||||
|
"warn",
|
||||||
|
"error",
|
||||||
|
"more",
|
||||||
|
"sidebar",
|
||||||
|
"restore",
|
||||||
|
"user",
|
||||||
|
"refresh",
|
||||||
|
"filter",
|
||||||
|
"folder",
|
||||||
|
"trash",
|
||||||
|
];
|
||||||
|
assert_eq!(IconKind::ALL.len(), names.len());
|
||||||
|
for (i, kind) in IconKind::ALL.iter().enumerate() {
|
||||||
|
assert_eq!(
|
||||||
|
kind.name(),
|
||||||
|
names[i],
|
||||||
|
"第 {i} 个图标被换位/改名了(序号即持久化 id)"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn shape_ladder_is_pinned() {
|
||||||
|
let cases: [(ShapeScale, f32); 9] = [
|
||||||
|
(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),
|
||||||
|
];
|
||||||
|
for (scale, dp) in cases {
|
||||||
|
assert_eq!(scale.dp(), dp, "{scale:?} 的半径档位被改动了");
|
||||||
|
}
|
||||||
|
assert_eq!(ShapeScale::Full.radius_px(64.0), 32.0);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn same_family_containers_do_not_collide() {
|
||||||
|
// P3-2 回归:light 模式下 primary-container / secondary-container 曾经被 gamut 裁剪成同色。
|
||||||
|
let light = Scheme::baseline(SchemeFlavor::Light);
|
||||||
|
let primary = light.get(Role::PrimaryContainer);
|
||||||
|
let secondary = light.get(Role::SecondaryContainer);
|
||||||
|
assert_ne!(
|
||||||
|
primary, secondary,
|
||||||
|
"同族容器撞色(彩度压缩没生效):{primary:?}"
|
||||||
|
);
|
||||||
|
// secondary-container 必须比 primary-container 更灰(M3 的「次级」语义)。
|
||||||
|
let c_primary = unlucky_design::color::space::Oklch::from_color(primary).chroma;
|
||||||
|
let c_secondary = unlucky_design::color::space::Oklch::from_color(secondary).chroma;
|
||||||
|
assert!(
|
||||||
|
c_secondary < c_primary,
|
||||||
|
"secondary-container 彩度 {c_secondary} 应低于 primary-container {c_primary}"
|
||||||
|
);
|
||||||
|
// tertiary 与 primary 也该可区分(不同色相)。
|
||||||
|
assert_ne!(light.get(Role::TertiaryContainer), primary);
|
||||||
|
}
|
||||||
@@ -0,0 +1,106 @@
|
|||||||
|
//! 官方数值钉死(一):**排版两张表**(baseline 15 档 + Expressive 强调 15 档)。
|
||||||
|
//!
|
||||||
|
//! 表的来源是 A 级 `androidx.compose.material3.tokens.TypeScaleTokens`(v0_103)。这里的期望值是
|
||||||
|
//! **独立于实现重写的一份**:任何人改动 `TypeRole::style()` / `emphasized()` 的数值,都必须同时改
|
||||||
|
//! 这张表才会同时绿——这就是「冻结」的含义(reviewer P2-1 的变异测试就是打这里)。
|
||||||
|
|
||||||
|
use unlucky_design::typography::{FontFamily, TypeRole, Weight};
|
||||||
|
|
||||||
|
/// baseline 单档对拍。
|
||||||
|
fn expect_baseline(
|
||||||
|
role: TypeRole,
|
||||||
|
size: f32,
|
||||||
|
line_height: f32,
|
||||||
|
weight: Weight,
|
||||||
|
tracking: f32,
|
||||||
|
family: FontFamily,
|
||||||
|
) {
|
||||||
|
let got = role.style();
|
||||||
|
assert_eq!(
|
||||||
|
(
|
||||||
|
got.size_px,
|
||||||
|
got.line_height_px,
|
||||||
|
got.weight,
|
||||||
|
got.letter_spacing_px,
|
||||||
|
got.family
|
||||||
|
),
|
||||||
|
(size, line_height, weight, tracking, family),
|
||||||
|
"{} 的 baseline 值被改动了(官方 TypeScaleTokens)",
|
||||||
|
role.name()
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Expressive 强调档单档对拍。
|
||||||
|
fn expect_emphasized(role: TypeRole, size: f32, line_height: f32, weight: Weight, tracking: f32) {
|
||||||
|
let got = role.emphasized();
|
||||||
|
assert_eq!(
|
||||||
|
(
|
||||||
|
got.size_px,
|
||||||
|
got.line_height_px,
|
||||||
|
got.weight,
|
||||||
|
got.letter_spacing_px
|
||||||
|
),
|
||||||
|
(size, line_height, weight, tracking),
|
||||||
|
"{} 的 Expressive 强调档值被改动了(官方 TypeScaleTokens *Emphasized*)",
|
||||||
|
role.name()
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn baseline_type_scale_matches_official_tokens() {
|
||||||
|
use FontFamily::{Display, Text};
|
||||||
|
use Weight::{Bold, Medium, Regular};
|
||||||
|
expect_baseline(TypeRole::DisplayLarge, 57.0, 64.0, Regular, -0.2, Display);
|
||||||
|
expect_baseline(TypeRole::DisplayMedium, 45.0, 52.0, Regular, 0.0, Display);
|
||||||
|
expect_baseline(TypeRole::DisplaySmall, 36.0, 44.0, Regular, 0.0, Display);
|
||||||
|
expect_baseline(TypeRole::HeadlineLarge, 32.0, 40.0, Regular, 0.0, Display);
|
||||||
|
expect_baseline(TypeRole::HeadlineMedium, 28.0, 36.0, Regular, 0.0, Display);
|
||||||
|
expect_baseline(TypeRole::HeadlineSmall, 24.0, 32.0, Regular, 0.0, Display);
|
||||||
|
expect_baseline(TypeRole::TitleLarge, 22.0, 28.0, Regular, 0.0, Display);
|
||||||
|
expect_baseline(TypeRole::TitleMedium, 16.0, 24.0, Medium, 0.2, Text);
|
||||||
|
expect_baseline(TypeRole::TitleSmall, 14.0, 20.0, Medium, 0.1, Text);
|
||||||
|
expect_baseline(TypeRole::BodyLarge, 16.0, 24.0, Regular, 0.5, Text);
|
||||||
|
expect_baseline(TypeRole::BodyMedium, 14.0, 20.0, Regular, 0.2, Text);
|
||||||
|
expect_baseline(TypeRole::BodySmall, 12.0, 16.0, Regular, 0.4, Text);
|
||||||
|
expect_baseline(TypeRole::LabelLarge, 14.0, 20.0, Medium, 0.1, Text);
|
||||||
|
expect_baseline(TypeRole::LabelMedium, 12.0, 16.0, Medium, 0.5, Text);
|
||||||
|
expect_baseline(TypeRole::LabelSmall, 11.0, 16.0, Medium, 0.5, Text);
|
||||||
|
let _ = Bold;
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn expressive_emphasis_matches_official_tokens() {
|
||||||
|
use Weight::{Bold, Medium};
|
||||||
|
// 展示/正文类强调 → Medium(500);label 与小标题 → Bold(700)。
|
||||||
|
expect_emphasized(TypeRole::DisplayLarge, 57.0, 64.0, Medium, 0.0);
|
||||||
|
expect_emphasized(TypeRole::DisplayMedium, 45.0, 52.0, Medium, 0.0);
|
||||||
|
expect_emphasized(TypeRole::DisplaySmall, 36.0, 44.0, Medium, 0.0);
|
||||||
|
expect_emphasized(TypeRole::HeadlineLarge, 32.0, 40.0, Medium, 0.0);
|
||||||
|
expect_emphasized(TypeRole::HeadlineMedium, 28.0, 36.0, Medium, 0.0);
|
||||||
|
expect_emphasized(TypeRole::HeadlineSmall, 24.0, 32.0, Medium, 0.0);
|
||||||
|
expect_emphasized(TypeRole::TitleLarge, 22.0, 28.0, Medium, 0.0);
|
||||||
|
expect_emphasized(TypeRole::TitleMedium, 16.0, 24.0, Bold, 0.15);
|
||||||
|
expect_emphasized(TypeRole::TitleSmall, 14.0, 20.0, Bold, 0.1);
|
||||||
|
expect_emphasized(TypeRole::BodyLarge, 16.0, 24.0, Medium, 0.15);
|
||||||
|
expect_emphasized(TypeRole::BodyMedium, 14.0, 20.0, Medium, 0.25);
|
||||||
|
expect_emphasized(TypeRole::BodySmall, 12.0, 16.0, Medium, 0.4);
|
||||||
|
expect_emphasized(TypeRole::LabelLarge, 14.0, 20.0, Bold, 0.1);
|
||||||
|
expect_emphasized(TypeRole::LabelMedium, 12.0, 16.0, Bold, 0.5);
|
||||||
|
expect_emphasized(TypeRole::LabelSmall, 11.0, 16.0, Bold, 0.5);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn both_tables_cover_all_fifteen_roles() {
|
||||||
|
// 防止「表里少写一档」:15 个角色都必须被上面两张表摸到。
|
||||||
|
let mut names = std::collections::BTreeSet::new();
|
||||||
|
for role in TypeRole::ALL {
|
||||||
|
names.insert(role.name());
|
||||||
|
assert_ne!(
|
||||||
|
role.style(),
|
||||||
|
role.emphasized(),
|
||||||
|
"{} 强调档等同 baseline",
|
||||||
|
role.name()
|
||||||
|
);
|
||||||
|
}
|
||||||
|
assert_eq!(names.len(), 15);
|
||||||
|
}
|
||||||
Reference in new issue
Block a user