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
+1367
-234
No files matched your search
@@ -57,6 +57,12 @@ pub struct Palettes {
|
||||
pub neutral_variant: TonalPalette,
|
||||
/// 错误调色板。
|
||||
pub error: TonalPalette,
|
||||
/// 彩度压缩的**共享基准**(取建方案时的 primary 彩度,强调色覆盖时**不变**)。
|
||||
///
|
||||
/// 为什么单独存一份:`from_accent` 换掉 primary 后,中性表面不该跟着变色调
|
||||
/// (否则「换强调色不动底色」这条语义就破了),而彩度压缩又必须用同一个基准才能
|
||||
/// 保证 primary/secondary 之间的相对灰度——两者靠这个字段同时成立。
|
||||
reference_chroma: f32,
|
||||
}
|
||||
|
||||
impl Palettes {
|
||||
@@ -77,6 +83,7 @@ impl Palettes {
|
||||
neutral: primary.with_chroma(NEUTRAL_CHROMA),
|
||||
neutral_variant: primary.with_chroma(NEUTRAL_VARIANT_CHROMA),
|
||||
error: TonalPalette::new(ERROR_HUE_DEG, ERROR_CHROMA),
|
||||
reference_chroma: primary.chroma(),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -84,6 +91,7 @@ impl Palettes {
|
||||
///
|
||||
/// 这是「用户自定义强调色」的正确语义——换强调色不该让整个界面底色变脏;
|
||||
/// 也因为 tone 轴固定,任何色相都能自动满足 WCAG AA(不需要再算一遍对比度)。
|
||||
/// `reference_chroma` 也保持原值,所以中性色与 baseline 逐像素一致(有测试钉住)。
|
||||
pub fn with_accent(self, accent: Color) -> Self {
|
||||
let accent_primary = TonalPalette::from_color(accent);
|
||||
Self {
|
||||
@@ -107,6 +115,18 @@ impl Palettes {
|
||||
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)]
|
||||
|
||||
@@ -191,7 +191,7 @@ impl Scheme {
|
||||
SchemeFlavor::Light => entry.light,
|
||||
SchemeFlavor::Dark => entry.dark,
|
||||
};
|
||||
palettes.slot(entry.slot).tone(tone)
|
||||
palettes.tone(entry.slot, tone)
|
||||
});
|
||||
Self { flavor, colors }
|
||||
}
|
||||
|
||||
@@ -7,6 +7,9 @@ use super::Color;
|
||||
/// gamut 判定容差:线性 RGB 允许露出这么点负值/溢出(浮点误差),别因此误判「越界」。
|
||||
const GAMUT_EPS: f32 = 1.0e-4;
|
||||
|
||||
/// sRGB 里能取到的 OKLCh 彩度上限(超过它没有意义,二分搜索的初始上界)。
|
||||
pub(crate) const GAMUT_CHROMA_CAP: f32 = 0.5;
|
||||
|
||||
/// sRGB 编码值(0..1)→ 线性光强。
|
||||
pub(crate) fn srgb_to_linear(c: f32) -> f32 {
|
||||
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
|
||||
}
|
||||
|
||||
/// 该 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)。
|
||||
#[derive(Debug, Clone, Copy, PartialEq)]
|
||||
pub struct Oklch {
|
||||
|
||||
@@ -59,6 +59,17 @@ impl TonalPalette {
|
||||
/// 二分裁剪——所以 tone 0 恒为纯黑、tone 100 恒为纯白,且同 tone 的 WCAG 对比度
|
||||
/// 与色相无关(这是对比度门禁能跨种子稳定的原因)。
|
||||
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);
|
||||
// 端点直接给纯黑/纯白:近黑处的 gamut 容差会放进微小彩度,导致 tone 0 偏色。
|
||||
if lstar <= 0.0 {
|
||||
@@ -67,7 +78,13 @@ impl TonalPalette {
|
||||
if lstar >= 100.0 {
|
||||
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);
|
||||
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]
|
||||
fn only_primary_actions_use_filled_emphasis_and_one_dialog_layer() {
|
||||
// 契约 §1:状态色不复用 accent、每屏最多一个实心 accent → 只有 primary-button 是
|
||||
|
||||
@@ -1,10 +1,19 @@
|
||||
//! 组件档位数据表(**下标必须等于 `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 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::typography::TypeRole;
|
||||
|
||||
@@ -54,7 +63,7 @@ pub(super) const SPECS: [ComponentSpec; 20] = [
|
||||
Some(ShapeMorph::new(
|
||||
ShapeScale::Large,
|
||||
ShapeScale::Full,
|
||||
SPRING_SPATIAL_FAST,
|
||||
SPRING_EXPRESSIVE_SPATIAL_FAST,
|
||||
)),
|
||||
),
|
||||
spec(
|
||||
@@ -98,13 +107,14 @@ pub(super) const SPECS: [ComponentSpec; 20] = [
|
||||
Some(ShapeMorph::new(
|
||||
ShapeScale::Medium,
|
||||
ShapeScale::Full,
|
||||
SPRING_SPATIAL_FAST,
|
||||
SPRING_EXPRESSIVE_SPATIAL_FAST,
|
||||
)),
|
||||
),
|
||||
// FilledCardTokens.ContainerElevation = Level0(hover 才到 L1、dragged 到 L3)。
|
||||
spec(
|
||||
Component::OutlinedCard,
|
||||
ShapeScale::Medium,
|
||||
Elevation::Level1,
|
||||
Elevation::Level0,
|
||||
TypeRole::TitleMedium,
|
||||
Some(TypeRole::BodySmall),
|
||||
Easing::Standard,
|
||||
@@ -114,7 +124,7 @@ pub(super) const SPECS: [ComponentSpec; 20] = [
|
||||
spec(
|
||||
Component::FilledCard,
|
||||
ShapeScale::Medium,
|
||||
Elevation::Level2,
|
||||
Elevation::Level0,
|
||||
TypeRole::TitleMedium,
|
||||
Some(TypeRole::BodySmall),
|
||||
Easing::Standard,
|
||||
@@ -122,7 +132,7 @@ pub(super) const SPECS: [ComponentSpec; 20] = [
|
||||
Some(ShapeMorph::new(
|
||||
ShapeScale::Medium,
|
||||
ShapeScale::Large,
|
||||
SPRING_SPATIAL_DEFAULT,
|
||||
SPRING_EXPRESSIVE_SPATIAL_DEFAULT,
|
||||
)),
|
||||
),
|
||||
spec(
|
||||
@@ -166,7 +176,7 @@ pub(super) const SPECS: [ComponentSpec; 20] = [
|
||||
Some(ShapeMorph::new(
|
||||
ShapeScale::ExtraSmall,
|
||||
ShapeScale::Small,
|
||||
SPRING_SPATIAL_FAST,
|
||||
SPRING_EXPRESSIVE_SPATIAL_FAST,
|
||||
)),
|
||||
),
|
||||
spec(
|
||||
@@ -199,10 +209,11 @@ pub(super) const SPECS: [ComponentSpec; 20] = [
|
||||
DurationToken::Long2,
|
||||
None,
|
||||
),
|
||||
// DialogTokens.ContainerElevation = Level3,CornerExtraLarge = 28。
|
||||
spec(
|
||||
Component::Dialog,
|
||||
ShapeScale::ExtraLarge,
|
||||
Elevation::Level5,
|
||||
Elevation::Level3,
|
||||
TypeRole::HeadlineSmall,
|
||||
Some(TypeRole::BodyMedium),
|
||||
Easing::EmphasizedDecelerate,
|
||||
@@ -210,13 +221,14 @@ pub(super) const SPECS: [ComponentSpec; 20] = [
|
||||
Some(ShapeMorph::new(
|
||||
ShapeScale::Large,
|
||||
ShapeScale::ExtraLarge,
|
||||
SPRING_SPATIAL_DEFAULT,
|
||||
SPRING_EXPRESSIVE_SPATIAL_DEFAULT,
|
||||
)),
|
||||
),
|
||||
// MenuTokens:CornerExtraSmall(4) + Level2。
|
||||
spec(
|
||||
Component::Menu,
|
||||
ShapeScale::Small,
|
||||
Elevation::Level4,
|
||||
ShapeScale::ExtraSmall,
|
||||
Elevation::Level2,
|
||||
TypeRole::BodyMedium,
|
||||
None,
|
||||
Easing::StandardDecelerate,
|
||||
|
||||
@@ -21,7 +21,8 @@
|
||||
//!
|
||||
//! - Slint 的 `Path.stroke-width` 是**绝对像素**:只乘 DPI 缩放,**不乘 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 单位):渲染时会被
|
||||
//! `render_px / VIEWBOX` 缩放。同样的 `1.5` 在 20px 渲染下只有 1.25px 视觉宽度。
|
||||
//!
|
||||
|
||||
@@ -39,6 +39,45 @@
|
||||
//! let stack = Platform::current().font_stack(FontFamily::Display);
|
||||
//! 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)]
|
||||
|
||||
|
||||
@@ -12,8 +12,11 @@
|
||||
mod spring;
|
||||
|
||||
pub use spring::{
|
||||
MotionScale, Spring, SPRING_EFFECTS_DEFAULT, SPRING_EFFECTS_FAST, SPRING_SPATIAL_DEFAULT,
|
||||
SPRING_SPATIAL_FAST,
|
||||
spring, MotionScale, 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,
|
||||
};
|
||||
|
||||
/// 动效时长档位(M3 动效令牌,单位毫秒)。
|
||||
|
||||
@@ -1,26 +1,27 @@
|
||||
//! 弹簧参数(M3 Expressive 动效的物理表达)与动效时长缩放。
|
||||
//!
|
||||
//! **来源 A 级**:`androidx.compose.material3.tokens.ExpressiveMotionTokens`(v0_14_0)给出六组
|
||||
//! `Spring{Damping, Stiffness}`——注意 Compose 的 `Damping` 是**阻尼比 ζ**(不是绝对阻尼系数),
|
||||
//! 所以本模块的 [`Spring`] 也按 (stiffness, damping_ratio, mass) 存,两者可直接对照。
|
||||
//! 阶跃响应用标准二阶系统闭式解(欠阻尼 / 临界 / 过阻尼三分支),是纯数学、无状态。
|
||||
|
||||
/// 弹簧参数。
|
||||
///
|
||||
/// **口径说明**:Material 的弹簧预置是「按体感标定」的一组 token,官方没有公开统一公式
|
||||
/// (Android 侧是 `SpringForce` 参数 + 阻尼比)。[`Spring::sample`] 走的是标准二阶系统阶跃响应
|
||||
/// **闭式解**——数学是真的,参数是我们的标定值(待 M63 视觉规格对齐)。
|
||||
/// 弹簧参数(Compose 口径:刚度 + 阻尼比 + 质量)。
|
||||
#[derive(Debug, Clone, Copy, PartialEq)]
|
||||
pub struct Spring {
|
||||
/// 刚度(越大越快)。
|
||||
/// 刚度 k(越大越快)。
|
||||
pub stiffness: f32,
|
||||
/// 阻尼系数(临界阻尼 = 2·√(k·m))。
|
||||
pub damping: f32,
|
||||
/// 质量。
|
||||
/// 阻尼比 ζ(1.0 = 临界阻尼,< 1 会轻微弹过)。
|
||||
pub damping_ratio: f32,
|
||||
/// 质量 m。
|
||||
pub mass: f32,
|
||||
}
|
||||
|
||||
impl Spring {
|
||||
/// 构造(`mass` 取 1.0 时 stiffness/damping 就是常见标定值)。
|
||||
pub const fn new(stiffness: f32, damping: f32, mass: f32) -> Self {
|
||||
/// 按 M3 令牌口径构造(刚度 + 阻尼比 + 质量)。
|
||||
pub const fn from_ratio(stiffness: f32, damping_ratio: f32, mass: f32) -> Self {
|
||||
Self {
|
||||
stiffness,
|
||||
damping,
|
||||
damping_ratio,
|
||||
mass,
|
||||
}
|
||||
}
|
||||
@@ -30,14 +31,9 @@ impl Spring {
|
||||
(self.stiffness / self.mass).sqrt()
|
||||
}
|
||||
|
||||
/// 阻尼比 ζ(1.0 = 临界阻尼;< 1 会轻微弹过)。
|
||||
pub fn damping_ratio(self) -> f32 {
|
||||
let denom = 2.0 * (self.stiffness * self.mass).sqrt();
|
||||
if denom <= 0.0 {
|
||||
1.0
|
||||
} else {
|
||||
self.damping / denom
|
||||
}
|
||||
/// 绝对阻尼系数 c = 2·ζ·√(k·m)(需要喂给「阻尼系数」式积分器时用)。
|
||||
pub fn damping_coefficient(self) -> f32 {
|
||||
2.0 * self.damping_ratio * (self.stiffness * self.mass).sqrt()
|
||||
}
|
||||
|
||||
/// 阶跃响应:`t_ms` 毫秒后的进度(0..1,欠阻尼时会轻微超过 1 = 回弹)。
|
||||
@@ -47,7 +43,7 @@ impl Spring {
|
||||
}
|
||||
let t = t_ms / 1000.0;
|
||||
let wn = self.natural_frequency();
|
||||
let zeta = self.damping_ratio();
|
||||
let zeta = self.damping_ratio;
|
||||
if wn <= 0.0 {
|
||||
return 1.0;
|
||||
}
|
||||
@@ -73,7 +69,7 @@ impl Spring {
|
||||
/// 收敛到 `threshold`(0.1% 是常用的「停下来」判定)所需毫秒数。
|
||||
pub fn settle_ms(self, threshold: f32) -> f32 {
|
||||
let wn = self.natural_frequency();
|
||||
let zeta = self.damping_ratio();
|
||||
let zeta = self.damping_ratio;
|
||||
if wn <= 0.0 {
|
||||
return 0.0;
|
||||
}
|
||||
@@ -92,22 +88,118 @@ impl Spring {
|
||||
}
|
||||
}
|
||||
|
||||
/// 空间类快速弹簧(位移/尺寸):紧、几乎不弹。
|
||||
/// 动效方案(Expressive 把弹簧打包成 standard / expressive **两套**,不是一组)。
|
||||
///
|
||||
/// TODO(M63): 取值待视觉规格(四组弹簧是我们的临时标定,M63 的动效表落地后对齐)。
|
||||
pub const SPRING_SPATIAL_FAST: Spring = Spring::new(1400.0, 76.0, 1.0);
|
||||
/// 空间类默认弹簧(面板、卡片):标准手感。
|
||||
/// - [`MotionSchemeKind::Standard`]:保守、几乎不弹(spatial ζ=0.9);
|
||||
/// - [`MotionSchemeKind::Expressive`]:D26 的视觉基线,回弹明显(spatial ζ=0.6~0.8)。
|
||||
///
|
||||
/// TODO(M63): 取值待视觉规格。
|
||||
pub const SPRING_SPATIAL_DEFAULT: Spring = Spring::new(800.0, 55.0, 1.0);
|
||||
/// 效应类快速弹簧(透明度/颜色):不带过冲。
|
||||
///
|
||||
/// TODO(M63): 取值待视觉规格。
|
||||
pub const SPRING_EFFECTS_FAST: Spring = Spring::new(3800.0, 120.0, 1.0);
|
||||
/// 效应类默认弹簧。
|
||||
///
|
||||
/// TODO(M63): 取值待视觉规格。
|
||||
pub const SPRING_EFFECTS_DEFAULT: Spring = Spring::new(1600.0, 80.0, 1.0);
|
||||
/// `effects`(透明度/颜色类)在两套方案里数值相同——效应类不该有可见回弹。
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
|
||||
pub enum MotionSchemeKind {
|
||||
/// 标准方案(保守)。
|
||||
Standard,
|
||||
/// Expressive 方案(本项目默认)。
|
||||
Expressive,
|
||||
}
|
||||
|
||||
/// 弹簧类别:空间类管**位移/尺寸**,效应类管**颜色/透明度**。
|
||||
#[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 路径关动画时用)。
|
||||
#[derive(Debug, Clone, Copy, PartialEq)]
|
||||
@@ -156,33 +248,100 @@ mod tests {
|
||||
use super::*;
|
||||
use crate::motion::DurationToken;
|
||||
|
||||
const PRESETS: [Spring; 4] = [
|
||||
SPRING_SPATIAL_FAST,
|
||||
SPRING_SPATIAL_DEFAULT,
|
||||
const PRESETS: [Spring; 9] = [
|
||||
SPRING_STANDARD_SPATIAL_FAST,
|
||||
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_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]
|
||||
fn spring_presets_behave() {
|
||||
for spring in PRESETS {
|
||||
assert!(spring.damping_ratio() > 0.5, "弹簧应明显欠阻尼/近临界");
|
||||
assert_eq!(spring.sample(0.0), 0.0);
|
||||
let settle = spring.settle_ms(0.001);
|
||||
assert!(
|
||||
(60.0..1200.0).contains(&settle),
|
||||
"收敛时间 {settle}ms 不合理"
|
||||
(60.0..1500.0).contains(&settle),
|
||||
"收敛时间 {settle}ms 不合理(k={} ζ={})",
|
||||
spring.stiffness,
|
||||
spring.damping_ratio
|
||||
);
|
||||
let end = spring.sample(settle);
|
||||
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]
|
||||
fn critically_damped_spring_does_not_overshoot() {
|
||||
let wn = SPRING_SPATIAL_DEFAULT.natural_frequency();
|
||||
let critical = Spring::new(wn * wn, 2.0 * wn, 1.0);
|
||||
assert!((critical.damping_ratio() - 1.0).abs() < 1.0e-3);
|
||||
let wn = SPRING_EXPRESSIVE_SPATIAL_DEFAULT.natural_frequency();
|
||||
let critical = Spring::from_ratio(wn * wn, 1.0, 1.0);
|
||||
for step in 1..=40 {
|
||||
let v = critical.sample(step as f32 * 5.0);
|
||||
assert!(v <= 1.0 + 1.0e-3, "临界阻尼不应过冲:{v}");
|
||||
@@ -191,9 +350,8 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn overdamped_spring_converges_slowly_without_overshoot() {
|
||||
let wn = SPRING_SPATIAL_DEFAULT.natural_frequency();
|
||||
let over = Spring::new(wn * wn, 4.0 * wn, 1.0);
|
||||
assert!(over.damping_ratio() > 1.5);
|
||||
let wn = SPRING_EXPRESSIVE_SPATIAL_DEFAULT.natural_frequency();
|
||||
let over = Spring::from_ratio(wn * wn, 2.0, 1.0);
|
||||
let settle = over.settle_ms(0.001);
|
||||
assert!((over.sample(settle) - 1.0).abs() < 0.01);
|
||||
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]
|
||||
fn motion_scale_scales_or_kills_duration() {
|
||||
assert_eq!(MotionScale::STANDARD.ms(DurationToken::Medium2), 300.0);
|
||||
@@ -209,6 +375,6 @@ mod tests {
|
||||
assert!(MotionScale::NONE.is_off());
|
||||
assert_eq!(MotionScale::new(5.0).factor(), 1.0);
|
||||
assert_eq!(MotionScale::new(-1.0).factor(), 0.0);
|
||||
assert!(MotionScale::STANDARD.spring_ms(SPRING_SPATIAL_DEFAULT) > 0.0);
|
||||
assert!(MotionScale::STANDARD.spring_ms(SPRING_EXPRESSIVE_SPATIAL_DEFAULT) > 0.0);
|
||||
}
|
||||
}
|
||||
@@ -118,7 +118,7 @@ impl ShapeMorph {
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::motion::SPRING_SPATIAL_DEFAULT;
|
||||
use crate::motion::SPRING_EXPRESSIVE_SPATIAL_DEFAULT;
|
||||
|
||||
#[test]
|
||||
fn scale_ladder_is_monotone() {
|
||||
@@ -156,7 +156,7 @@ mod tests {
|
||||
let morph = ShapeMorph::new(
|
||||
ShapeScale::Small,
|
||||
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, 1.0), 20.0);
|
||||
|
||||
@@ -77,11 +77,11 @@ pub struct LayoutMetrics {
|
||||
pub page_header_tall_height: f32,
|
||||
/// 运行中状态条高度。
|
||||
pub running_strip_height: f32,
|
||||
/// 列表行高。
|
||||
/// 列表行高(桌面密集列表;见模块文档的「触控目标」说明)。
|
||||
pub row_height: f32,
|
||||
/// 标准控件高(按钮/输入框)。
|
||||
/// 标准控件可见高度(按钮/输入框)。
|
||||
pub control_height: f32,
|
||||
/// 最小可点区域。
|
||||
/// 最小**命中**区域(M3 Expressive 的无障碍下限)。
|
||||
pub touch_target: f32,
|
||||
/// 卡片内边距。
|
||||
pub card_padding: f32,
|
||||
@@ -90,20 +90,29 @@ pub struct 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 {
|
||||
titlebar_height: 40.0,
|
||||
titlebar_height: 48.0,
|
||||
titlebar_button_width: 46.0,
|
||||
nav_expanded_width: 88.0,
|
||||
nav_collapsed_width: 48.0,
|
||||
nav_item_height: 56.0,
|
||||
nav_item_height: 44.0,
|
||||
nav_item_gap: 4.0,
|
||||
page_header_height: 56.0,
|
||||
page_header_tall_height: 84.0,
|
||||
running_strip_height: 32.0,
|
||||
row_height: 44.0,
|
||||
control_height: 40.0,
|
||||
touch_target: 44.0,
|
||||
touch_target: 48.0,
|
||||
card_padding: 20.0,
|
||||
ai_panel_width: 320.0,
|
||||
};
|
||||
@@ -134,16 +143,43 @@ mod tests {
|
||||
#[test]
|
||||
fn layout_values_are_on_the_grid() {
|
||||
let m = LayoutMetrics::DESKTOP;
|
||||
// 受 4px 栅格约束的字段(间距/高度/宽度类)。
|
||||
// `titlebar_button_width` 刻意不在表内:46 对齐 Windows 11 原生 caption 按钮宽度,
|
||||
// 属 OS 惯例值,单列一条断言钉住,不让它静默漂移。
|
||||
for (name, value) in [
|
||||
("titlebar_height", m.titlebar_height),
|
||||
("nav_item_height", m.nav_item_height),
|
||||
("nav_item_gap", m.nav_item_gap),
|
||||
("page_header_height", m.page_header_height),
|
||||
("page_header_tall_height", m.page_header_tall_height),
|
||||
("running_strip_height", m.running_strip_height),
|
||||
("row_height", m.row_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!(
|
||||
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]
|
||||
|
||||
@@ -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)。每条栈的**最后一项必须是通用族名**,
|
||||
//! 否则干净安装的系统上会退化成豆腐块。
|
||||
//!
|
||||
//! ## 为什么要表达「这个族有哪几档字重」
|
||||
//!
|
||||
//! 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)]
|
||||
@@ -14,6 +44,179 @@ pub enum Platform {
|
||||
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 {
|
||||
/// 当前编译目标对应的平台(运行时用户覆盖请显式传 [`Platform`])。
|
||||
pub const fn current() -> Self {
|
||||
@@ -36,146 +239,66 @@ impl Platform {
|
||||
}
|
||||
|
||||
/// 字体族回退栈(由首选到兜底,最后一项是通用族名)。
|
||||
///
|
||||
/// iced/cosmic-text 按顺序找第一个装了的族;`sans-serif` / `monospace` 是通用兜底,
|
||||
/// 保证「一台干净安装的系统」也不会渲染成豆腐块。
|
||||
pub const fn font_stack(self, family: FontFamily) -> &'static [&'static str] {
|
||||
pub const fn font_stack(self, family: FontFamily) -> &'static [FontFace] {
|
||||
match (self, family) {
|
||||
// Win10 起 Segoe UI 一定在;Win11 有光学尺寸分离的 Variable 版。
|
||||
(Platform::Windows, FontFamily::Display) => &[
|
||||
"Segoe UI Variable Display",
|
||||
"Segoe UI",
|
||||
"Microsoft YaHei UI",
|
||||
"Microsoft YaHei",
|
||||
"sans-serif",
|
||||
],
|
||||
(Platform::Windows, FontFamily::Text) => &[
|
||||
"Segoe UI Variable Text",
|
||||
"Segoe UI",
|
||||
"Microsoft YaHei UI",
|
||||
"Microsoft YaHei",
|
||||
"sans-serif",
|
||||
],
|
||||
(Platform::Windows, FontFamily::Mono) => {
|
||||
&["Cascadia Mono", "Consolas", "Courier New", "monospace"]
|
||||
}
|
||||
// macOS 12+:SF Pro 分 Display/Text 两种光学尺寸;中文走苹方。
|
||||
(Platform::MacOs, FontFamily::Display) => &[
|
||||
"SF Pro Display",
|
||||
"Helvetica Neue",
|
||||
"PingFang SC",
|
||||
"sans-serif",
|
||||
],
|
||||
(Platform::MacOs, FontFamily::Text) => {
|
||||
&["SF Pro Text", "Helvetica Neue", "PingFang SC", "sans-serif"]
|
||||
}
|
||||
(Platform::MacOs, FontFamily::Mono) => &["SF Mono", "Menlo", "Monaco", "monospace"],
|
||||
// Linux:不假设发行版预装什么,Roboto/Noto 是最常见的两套。
|
||||
(Platform::Linux, FontFamily::Display) | (Platform::Linux, FontFamily::Text) => &[
|
||||
"Roboto",
|
||||
"Noto Sans",
|
||||
"Noto Sans CJK SC",
|
||||
"DejaVu Sans",
|
||||
"sans-serif",
|
||||
],
|
||||
(Platform::Linux, FontFamily::Mono) => &[
|
||||
"JetBrains Mono",
|
||||
"Noto Sans Mono",
|
||||
"DejaVu Sans Mono",
|
||||
"monospace",
|
||||
],
|
||||
(Platform::Windows, FontFamily::Display) => &WIN_DISPLAY,
|
||||
(Platform::Windows, FontFamily::Text) => &WIN_TEXT,
|
||||
(Platform::Windows, FontFamily::Mono) => &WIN_MONO,
|
||||
// macOS 12+:SF Pro 分 Display/Text 光学尺寸;中文走苹方(只到 600)。
|
||||
(Platform::MacOs, FontFamily::Display) => &MAC_DISPLAY,
|
||||
(Platform::MacOs, FontFamily::Text) => &MAC_TEXT,
|
||||
(Platform::MacOs, FontFamily::Mono) => &MAC_MONO,
|
||||
// Linux:不假设发行版预装什么,Roboto/Noto 是最常见的两套(Display 与 Text 同栈)。
|
||||
(Platform::Linux, FontFamily::Display | FontFamily::Text) => &LINUX_SANS,
|
||||
(Platform::Linux, FontFamily::Mono) => &LINUX_MONO,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// 字体族的用途分类(决定取哪条栈)。
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
|
||||
pub enum FontFamily {
|
||||
/// 大字号展示(display/headline):光学尺寸更大。
|
||||
Display,
|
||||
/// 正文(title/body/label)。
|
||||
Text,
|
||||
/// 等宽(日志、代码、版本号)。
|
||||
Mono,
|
||||
}
|
||||
/// 该平台的**中文**兜底族(CJK 字形实际落到的那个)。
|
||||
///
|
||||
/// 注意:栈里的 CJK 族只是「缺字形时的候选」,最终由文本引擎逐字形回退决定;
|
||||
/// 这个函数把它单独暴露出来,好让调用方按模块文档的降级顺序处理字重。
|
||||
pub const fn cjk_face(self) -> FontFace {
|
||||
match self {
|
||||
Platform::Windows => FontFace::new("Microsoft YaHei UI", W_STATIC_YAHEI),
|
||||
Platform::MacOs => FontFace::new("PingFang SC", W_STATIC_PINGFANG),
|
||||
Platform::Linux => FontFace::new("Noto Sans CJK SC", W_VARIABLE_NOTO),
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
const PLATFORMS: [Platform; 3] = [Platform::Windows, Platform::MacOs, Platform::Linux];
|
||||
const FAMILIES: [FontFamily; 3] = [FontFamily::Display, FontFamily::Text, FontFamily::Mono];
|
||||
|
||||
#[test]
|
||||
fn font_stacks_are_well_formed() {
|
||||
for platform in PLATFORMS {
|
||||
for family in FAMILIES {
|
||||
let stack = platform.font_stack(family);
|
||||
assert!(
|
||||
!stack.is_empty(),
|
||||
"{} 的 {family:?} 栈为空",
|
||||
platform.name()
|
||||
);
|
||||
let last = stack[stack.len() - 1];
|
||||
let generic = match family {
|
||||
FontFamily::Mono => "monospace",
|
||||
_ => "sans-serif",
|
||||
/// 按栈顺序解析某档字重的落点(纯函数,不查系统)。
|
||||
///
|
||||
/// 规则:**优先原生**(栈里第一个真有的族)→ 都做不到时取首选族的最近档,
|
||||
/// 差距 ≥200 判为 [`WeightMatch::Synthetic`]。
|
||||
pub fn resolve_weight(
|
||||
self,
|
||||
family: FontFamily,
|
||||
weight: crate::typography::Weight,
|
||||
) -> FontChoice {
|
||||
let stack = self.font_stack(family);
|
||||
let requested = weight.value();
|
||||
for face in stack {
|
||||
if face.weights.supports(requested) {
|
||||
return FontChoice {
|
||||
family: face.family,
|
||||
requested,
|
||||
available: requested,
|
||||
quality: WeightMatch::Native,
|
||||
};
|
||||
assert_eq!(
|
||||
last,
|
||||
generic,
|
||||
"{} 的 {family:?} 栈必须以通用族名兜底",
|
||||
platform.name()
|
||||
);
|
||||
for name in stack {
|
||||
assert!(!name.is_empty());
|
||||
assert!(
|
||||
!name.contains(['/', '\\', '.', ':']),
|
||||
"字体栈只允许族名,不得出现路径/文件名:{name}"
|
||||
);
|
||||
assert!(
|
||||
name.is_ascii(),
|
||||
"族名应为 ASCII(非 ASCII 名交给系统匹配):{name}"
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn display_and_text_stacks_differ_on_windows_and_macos() {
|
||||
assert_ne!(
|
||||
Platform::Windows.font_stack(FontFamily::Display)[0],
|
||||
Platform::Windows.font_stack(FontFamily::Text)[0]
|
||||
);
|
||||
assert_ne!(
|
||||
Platform::MacOs.font_stack(FontFamily::Display)[0],
|
||||
Platform::MacOs.font_stack(FontFamily::Text)[0]
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn chinese_fallback_is_present_but_not_first() {
|
||||
// 中文族名必须在栈里(否则 CJK 走系统随机匹配),但不能排第一(纯英文环境不加载中文字体)。
|
||||
for platform in PLATFORMS {
|
||||
let stack = platform.font_stack(FontFamily::Text);
|
||||
let cjk = ["Microsoft YaHei UI", "PingFang SC", "Noto Sans CJK SC"];
|
||||
assert!(
|
||||
stack.iter().any(|name| cjk.contains(name)),
|
||||
"{} 的正文栈缺 CJK 兜底",
|
||||
platform.name()
|
||||
);
|
||||
assert!(
|
||||
!cjk.contains(&stack[0]),
|
||||
"{} 的首选字体不该是 CJK 族",
|
||||
platform.name()
|
||||
);
|
||||
let first = stack[0];
|
||||
let available = first.weights.nearest(requested);
|
||||
FontChoice {
|
||||
family: first.family,
|
||||
requested,
|
||||
available,
|
||||
quality: if available.abs_diff(requested) >= 200 {
|
||||
WeightMatch::Synthetic
|
||||
} else {
|
||||
WeightMatch::Nearest
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
#[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 都受各自许可约束,
|
||||
//! 内嵌再分发是违规的;引用系统已安装的族名是合规且体积为零的做法——这是 D14 与 D28 的共同要求)。
|
||||
//! 中文由系统字体匹配:栈里显式列出 CJK 族名,纯英文环境不会去加载任何中文字体(D28)。
|
||||
|
||||
mod emphasized;
|
||||
mod fonts;
|
||||
|
||||
pub use fonts::{FontFamily, Platform};
|
||||
pub use fonts::{FontChoice, FontFace, FontFamily, Platform, WeightCoverage, WeightMatch};
|
||||
|
||||
/// 字重(M3 只用这四档;可变字体按连续值渲染时取 [`Weight::value`])。
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
|
||||
pub enum Weight {
|
||||
/// 400:正文。
|
||||
Regular,
|
||||
/// 500:label / title-medium。
|
||||
/// 500:label / title-medium / Expressive 强调的正文与展示类。
|
||||
Medium,
|
||||
/// 600:强调标题(本项目的「区块标题」档)。
|
||||
/// 600:本项目的「区块标题」档(介于 Medium 与 Bold 之间)。
|
||||
SemiBold,
|
||||
/// 700:页面标题 / 空态主文案。
|
||||
/// 700:页面标题 / 空态主文案 / Expressive 强调的 label 与 title-small-medium。
|
||||
Bold,
|
||||
}
|
||||
|
||||
@@ -32,7 +40,7 @@ impl Weight {
|
||||
}
|
||||
}
|
||||
|
||||
/// 再重一档(Expressive 的 emphasized 用得上,最重封顶 Bold)。
|
||||
/// 再重一档(最重封顶 Bold)。
|
||||
pub const fn heavier(self) -> Self {
|
||||
match self {
|
||||
Weight::Regular => Weight::Medium,
|
||||
@@ -69,10 +77,15 @@ impl TypeStyle {
|
||||
}
|
||||
}
|
||||
|
||||
/// 该样式的字体族回退栈。
|
||||
pub fn font_stack(self, platform: Platform) -> &'static [&'static str] {
|
||||
/// 该样式的字体族回退栈(栈项带字重覆盖,见 [`FontFace`])。
|
||||
pub fn font_stack(self, platform: Platform) -> &'static [FontFace] {
|
||||
platform.font_stack(self.family)
|
||||
}
|
||||
|
||||
/// 该样式在这台平台上「字重最终落到哪个族」——中文缺档时看它判断是否走降级。
|
||||
pub fn resolve(self, platform: Platform) -> FontChoice {
|
||||
platform.resolve_weight(self.family, self.weight)
|
||||
}
|
||||
}
|
||||
|
||||
/// M3 type scale 的 15 个角色。
|
||||
@@ -151,12 +164,10 @@ impl TypeRole {
|
||||
}
|
||||
}
|
||||
|
||||
/// M3 基线样式(字号 / 行高 / 字重 / 字距,官方 type scale 值)。
|
||||
/// **baseline MD3** 样式(官方 type scale 值:字号 / 行高 / 字重 / 字距 + 字体族分类)。
|
||||
pub const fn style(self) -> TypeStyle {
|
||||
match self {
|
||||
TypeRole::DisplayLarge => {
|
||||
style(57.0, 64.0, Weight::Regular, -0.25, FontFamily::Display)
|
||||
}
|
||||
TypeRole::DisplayLarge => style(57.0, 64.0, Weight::Regular, -0.2, FontFamily::Display),
|
||||
TypeRole::DisplayMedium => style(45.0, 52.0, Weight::Regular, 0.0, FontFamily::Display),
|
||||
TypeRole::DisplaySmall => style(36.0, 44.0, Weight::Regular, 0.0, FontFamily::Display),
|
||||
TypeRole::HeadlineLarge => style(32.0, 40.0, Weight::Regular, 0.0, FontFamily::Display),
|
||||
@@ -164,11 +175,12 @@ impl TypeRole {
|
||||
style(28.0, 36.0, Weight::Regular, 0.0, FontFamily::Display)
|
||||
}
|
||||
TypeRole::HeadlineSmall => style(24.0, 32.0, Weight::Regular, 0.0, FontFamily::Display),
|
||||
TypeRole::TitleLarge => style(22.0, 28.0, Weight::Regular, 0.0, FontFamily::Text),
|
||||
TypeRole::TitleMedium => style(16.0, 24.0, Weight::Medium, 0.15, FontFamily::Text),
|
||||
// 官方 TitleLarge 用 Brand 族(与 Display 同栈),其余 title 用 Plain(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::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::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),
|
||||
@@ -176,12 +188,20 @@ impl TypeRole {
|
||||
}
|
||||
}
|
||||
|
||||
/// Expressive 强调档:**字重升一级,字号/行高/字距不变**。
|
||||
/// **M3 Expressive** 官方强调档(15/15 全有值,A 级来源)。
|
||||
///
|
||||
/// 口径声明:M3 Expressive 确实有 emphasized 排版档,但官方没公开与这 15 档一一对应的数值;
|
||||
/// 这里定的是**我们自己的规则**(加粗一档),目的是「同一 role 的强调态只改一个字重」,
|
||||
/// 保证页面不会因为强调而改变布局高度。要更强的层级请换更大 role,不要继续加字重。
|
||||
/// 规则(官方):字号与行高不变;字重升到 500(展示/正文类)或 700(label 与小标题);
|
||||
/// 字距按官方表微调(display-large −0.2→0、body-large 0.5→0.15、title-medium 0.2→0.15、
|
||||
/// body-medium 0.2→0.25)。这是**官方值,不是我们的近似**。
|
||||
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();
|
||||
TypeStyle {
|
||||
weight: base.weight.heavier(),
|
||||
@@ -286,16 +306,34 @@ mod tests {
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn emphasized_only_bumps_weight() {
|
||||
fn simplified_emphasis_only_bumps_weight() {
|
||||
for role in TypeRole::ALL {
|
||||
let base = role.style();
|
||||
let emph = role.emphasized();
|
||||
let emph = role.emphasized_simplified();
|
||||
assert_eq!(base.size_px, emph.size_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);
|
||||
}
|
||||
}
|
||||
|
||||
#[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]
|
||||
fn scaling_is_proportional_and_bounded() {
|
||||
let base = TypeRole::BodyMedium.style();
|
||||
|
||||
@@ -122,7 +122,7 @@ fn exception_list_is_honest_about_its_thresholds() {
|
||||
let scheme = Scheme::from_seed(flavor, seed);
|
||||
for (fg, bg, threshold, reason) in EXCEPTIONS {
|
||||
assert!(
|
||||
!fg.is_foreground() || fg == Role::Outline,
|
||||
!fg.is_foreground(),
|
||||
"例外 {} 不该是文字前景色({reason})",
|
||||
fg.name()
|
||||
);
|
||||
|
||||
@@ -9,7 +9,9 @@ use unlucky_design::elevation::{Elevation, MaterialLayer};
|
||||
use unlucky_design::icons::{
|
||||
stroke_width_for, visual_stroke_px, IconKind, IconPaint, IconSize, VIEWBOX,
|
||||
};
|
||||
use unlucky_design::motion::{DurationToken, Easing, MotionScale, SPRING_SPATIAL_DEFAULT};
|
||||
use unlucky_design::motion::{
|
||||
DurationToken, Easing, MotionScale, SPRING_EXPRESSIVE_SPATIAL_DEFAULT,
|
||||
};
|
||||
use unlucky_design::shape::ShapeScale;
|
||||
use unlucky_design::spacing::{LayoutMetrics, Space};
|
||||
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] {
|
||||
let stack = platform.font_stack(family);
|
||||
assert!(!stack.is_empty());
|
||||
for name in stack {
|
||||
let lower = name.to_ascii_lowercase();
|
||||
for face in stack {
|
||||
let lower = face.family.to_ascii_lowercase();
|
||||
for banned in [".ttf", ".otf", ".ttc", "woff", "file"] {
|
||||
assert!(
|
||||
!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]
|
||||
fn icon_kind_is_frozen_and_append_only() {
|
||||
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!((Easing::Standard.sample(0.0)).abs() < 1.0e-6);
|
||||
assert!((Easing::Standard.sample(1.0) - 1.0).abs() < 1.0e-6);
|
||||
let settle = SPRING_SPATIAL_DEFAULT.settle_ms(0.001);
|
||||
let settle = SPRING_EXPRESSIVE_SPATIAL_DEFAULT.settle_ms(0.001);
|
||||
assert!(settle > 0.0 && settle < 2000.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