Files
Unlucky-Launcher/crates/design/src/typography/mod.rs
T
AskaEth 1105a86448
CI / runtime smoke (macos arm64, probe) (push) Skipped
CI / fmt (rustfmt --check) (push) Canceled after 0s
CI / clippy (ubuntu-latest) (push) Canceled after 0s
CI / clippy (windows-latest) (push) Canceled after 0s
CI / test (ubuntu-latest) (push) Canceled after 0s
CI / test (windows-latest) (push) Canceled after 0s
CI / build release (ubuntu-latest) (push) Canceled after 0s
CI / build release (windows-latest) (push) Canceled after 0s
CI / runtime smoke (ubuntu x64, xvfb) (push) Canceled after 0s
Guards / core 零 UI 守门 (push) Canceled after 0s
Guards / presenter 零 UI 守门 (push) Canceled after 0s
Guards / design 零第三方依赖守门 (push) Canceled after 0s
Guards / unsafe 落点守门(集合必须恰好一个文件) (push) Canceled after 0s
Guards / 文件行数 ≤400 守门 (push) Canceled after 0s
Guards / 依赖许可与来源(cargo-deny) (push) Canceled after 0s
Release / 选槽位(默认两个 Windows) (push) Canceled after 0s
Release / build (${{ matrix.slot }}) (push) Canceled after 0s
Release / GitHub Release (push) Canceled after 0s
Unlucky Launcher v0.2.2
2026-11-07 08:46:11 +08:00

347 lines
13 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
//! 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);
}
}