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」
347 lines
13 KiB
Rust
347 lines
13 KiB
Rust
//! 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::{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 / Expressive 强调的正文与展示类。
|
||
Medium,
|
||
/// 600:本项目的「区块标题」档(介于 Medium 与 Bold 之间)。
|
||
SemiBold,
|
||
/// 700:页面标题 / 空态主文案 / Expressive 强调的 label 与 title-small-medium。
|
||
Bold,
|
||
}
|
||
|
||
impl Weight {
|
||
/// 数值字重(CSS/OpenType 口径)。
|
||
pub const fn value(self) -> u16 {
|
||
match self {
|
||
Weight::Regular => 400,
|
||
Weight::Medium => 500,
|
||
Weight::SemiBold => 600,
|
||
Weight::Bold => 700,
|
||
}
|
||
}
|
||
|
||
/// 再重一档(最重封顶 Bold)。
|
||
pub const fn heavier(self) -> Self {
|
||
match self {
|
||
Weight::Regular => Weight::Medium,
|
||
Weight::Medium => Weight::SemiBold,
|
||
Weight::SemiBold | Weight::Bold => Weight::Bold,
|
||
}
|
||
}
|
||
}
|
||
|
||
/// 一条排版样式(已解析成渲染要用的四个数)。
|
||
#[derive(Debug, Clone, Copy, PartialEq)]
|
||
pub struct TypeStyle {
|
||
/// 字号(px;1.0 缩放时与 dp 同值)。
|
||
pub size_px: f32,
|
||
/// 行高(px)。
|
||
pub line_height_px: f32,
|
||
/// 字重。
|
||
pub weight: Weight,
|
||
/// 字间距(px;M3 的 type scale 用 px 表达,中文场景下正字距更易读)。
|
||
pub letter_spacing_px: f32,
|
||
/// 用哪条字体栈。
|
||
pub family: FontFamily,
|
||
}
|
||
|
||
impl TypeStyle {
|
||
/// 按用户字号缩放(`factor` 1.0 = 标准;行高与字距同比例,夹取到 0.5..2.0)。
|
||
pub fn scaled(self, factor: f32) -> Self {
|
||
let f = factor.clamp(0.5, 2.0);
|
||
Self {
|
||
size_px: self.size_px * f,
|
||
line_height_px: self.line_height_px * f,
|
||
letter_spacing_px: self.letter_spacing_px * f,
|
||
..self
|
||
}
|
||
}
|
||
|
||
/// 该样式的字体族回退栈(栈项带字重覆盖,见 [`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 个角色。
|
||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
|
||
pub enum TypeRole {
|
||
/// 57/64:落地页大标题(本启动器基本不用)。
|
||
DisplayLarge,
|
||
/// 45/52。
|
||
DisplayMedium,
|
||
/// 36/44。
|
||
DisplaySmall,
|
||
/// 32/40:空态主标题的大档。
|
||
HeadlineLarge,
|
||
/// 28/36。
|
||
HeadlineMedium,
|
||
/// 24/32。
|
||
HeadlineSmall,
|
||
/// 22/28:页面标题(导航栏下的主标题)。
|
||
TitleLarge,
|
||
/// 16/24:卡片标题。
|
||
TitleMedium,
|
||
/// 14/20:行标题。
|
||
TitleSmall,
|
||
/// 16/24:正文大档(说明文字)。
|
||
BodyLarge,
|
||
/// 14/20:默认正文。
|
||
BodyMedium,
|
||
/// 12/16:辅助说明。
|
||
BodySmall,
|
||
/// 14/20:按钮文字。
|
||
LabelLarge,
|
||
/// 12/16:标签、徽标。
|
||
LabelMedium,
|
||
/// 11/16:最小标签(谨慎使用,中文 11px 可读性差)。
|
||
LabelSmall,
|
||
}
|
||
|
||
impl TypeRole {
|
||
/// 全部 15 档,按 M3 的族序排列。
|
||
pub const ALL: [TypeRole; 15] = [
|
||
TypeRole::DisplayLarge,
|
||
TypeRole::DisplayMedium,
|
||
TypeRole::DisplaySmall,
|
||
TypeRole::HeadlineLarge,
|
||
TypeRole::HeadlineMedium,
|
||
TypeRole::HeadlineSmall,
|
||
TypeRole::TitleLarge,
|
||
TypeRole::TitleMedium,
|
||
TypeRole::TitleSmall,
|
||
TypeRole::BodyLarge,
|
||
TypeRole::BodyMedium,
|
||
TypeRole::BodySmall,
|
||
TypeRole::LabelLarge,
|
||
TypeRole::LabelMedium,
|
||
TypeRole::LabelSmall,
|
||
];
|
||
|
||
/// 稳定字符串名(`display-large` … `label-small`)。
|
||
pub const fn name(self) -> &'static str {
|
||
match self {
|
||
TypeRole::DisplayLarge => "display-large",
|
||
TypeRole::DisplayMedium => "display-medium",
|
||
TypeRole::DisplaySmall => "display-small",
|
||
TypeRole::HeadlineLarge => "headline-large",
|
||
TypeRole::HeadlineMedium => "headline-medium",
|
||
TypeRole::HeadlineSmall => "headline-small",
|
||
TypeRole::TitleLarge => "title-large",
|
||
TypeRole::TitleMedium => "title-medium",
|
||
TypeRole::TitleSmall => "title-small",
|
||
TypeRole::BodyLarge => "body-large",
|
||
TypeRole::BodyMedium => "body-medium",
|
||
TypeRole::BodySmall => "body-small",
|
||
TypeRole::LabelLarge => "label-large",
|
||
TypeRole::LabelMedium => "label-medium",
|
||
TypeRole::LabelSmall => "label-small",
|
||
}
|
||
}
|
||
|
||
/// **baseline MD3** 样式(官方 type scale 值:字号 / 行高 / 字重 / 字距 + 字体族分类)。
|
||
pub const fn style(self) -> TypeStyle {
|
||
match self {
|
||
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),
|
||
TypeRole::HeadlineMedium => {
|
||
style(28.0, 36.0, Weight::Regular, 0.0, FontFamily::Display)
|
||
}
|
||
TypeRole::HeadlineSmall => style(24.0, 32.0, Weight::Regular, 0.0, FontFamily::Display),
|
||
// 官方 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.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),
|
||
TypeRole::LabelSmall => style(11.0, 16.0, Weight::Medium, 0.5, FontFamily::Text),
|
||
}
|
||
}
|
||
|
||
/// **M3 Expressive** 官方强调档(15/15 全有值,A 级来源)。
|
||
///
|
||
/// 规则(官方):字号与行高不变;字重升到 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(),
|
||
..base
|
||
}
|
||
}
|
||
}
|
||
|
||
/// `const fn` 用的构造器。
|
||
const fn style(
|
||
size: f32,
|
||
line_height: f32,
|
||
weight: Weight,
|
||
tracking_px: f32,
|
||
family: FontFamily,
|
||
) -> TypeStyle {
|
||
TypeStyle {
|
||
size_px: size,
|
||
line_height_px: line_height,
|
||
weight,
|
||
letter_spacing_px: tracking_px,
|
||
family,
|
||
}
|
||
}
|
||
|
||
#[cfg(test)]
|
||
mod tests {
|
||
use super::*;
|
||
|
||
#[test]
|
||
fn every_role_has_sane_metrics() {
|
||
for role in TypeRole::ALL {
|
||
let s = role.style();
|
||
assert!(s.size_px >= 11.0, "{} 字号过小", role.name());
|
||
assert!(
|
||
s.line_height_px >= s.size_px,
|
||
"{} 行高小于字号({} < {})",
|
||
role.name(),
|
||
s.line_height_px,
|
||
s.size_px
|
||
);
|
||
assert!(
|
||
(1.1..=1.8).contains(&(s.line_height_px / s.size_px)),
|
||
"{} 行高倍数 {} 超出 1.1..1.8(M3 的行高是 1.12~1.5 量级)",
|
||
role.name(),
|
||
s.line_height_px / s.size_px
|
||
);
|
||
assert!(s.letter_spacing_px.abs() < 1.0, "{} 字距过大", role.name());
|
||
}
|
||
}
|
||
|
||
#[test]
|
||
fn each_family_is_monotone_by_size() {
|
||
// M3 的 type scale 是「五族 × 三档」,跨族不保证单调(title-small 14 → body-large 16),
|
||
// 但族内必须由大到小。
|
||
for family in [
|
||
[
|
||
TypeRole::DisplayLarge,
|
||
TypeRole::DisplayMedium,
|
||
TypeRole::DisplaySmall,
|
||
],
|
||
[
|
||
TypeRole::HeadlineLarge,
|
||
TypeRole::HeadlineMedium,
|
||
TypeRole::HeadlineSmall,
|
||
],
|
||
[
|
||
TypeRole::TitleLarge,
|
||
TypeRole::TitleMedium,
|
||
TypeRole::TitleSmall,
|
||
],
|
||
[
|
||
TypeRole::BodyLarge,
|
||
TypeRole::BodyMedium,
|
||
TypeRole::BodySmall,
|
||
],
|
||
[
|
||
TypeRole::LabelLarge,
|
||
TypeRole::LabelMedium,
|
||
TypeRole::LabelSmall,
|
||
],
|
||
] {
|
||
for pair in family.windows(2) {
|
||
assert!(
|
||
pair[0].style().size_px > pair[1].style().size_px,
|
||
"{} 应大于 {}",
|
||
pair[0].name(),
|
||
pair[1].name()
|
||
);
|
||
}
|
||
}
|
||
}
|
||
|
||
#[test]
|
||
fn names_are_unique_and_kebab() {
|
||
let mut seen = std::collections::BTreeSet::new();
|
||
for role in TypeRole::ALL {
|
||
let name = role.name();
|
||
assert!(seen.insert(name), "排版角色名重复:{name}");
|
||
assert!(name.chars().all(|c| c.is_ascii_lowercase() || c == '-'));
|
||
}
|
||
}
|
||
|
||
#[test]
|
||
fn simplified_emphasis_only_bumps_weight() {
|
||
for role in TypeRole::ALL {
|
||
let base = role.style();
|
||
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();
|
||
let big = base.scaled(1.25);
|
||
assert!((big.size_px - 17.5).abs() < 1.0e-4);
|
||
assert!((big.line_height_px - 25.0).abs() < 1.0e-4);
|
||
assert_eq!(base.scaled(10.0).size_px, base.size_px * 2.0);
|
||
assert_eq!(base.scaled(0.1).size_px, base.size_px * 0.5);
|
||
}
|
||
}
|