Skip to main content

gpui_kit_material/tokens/
typography.rs

1//! Type scale in rems. 14px is 1rem at the 100% text scale.
2//!
3//! Reference priority is Figma Material 3 Design Kit > md3.io >
4//! m3e.kyralabs.dev, per value, and the kit defines the whole type family:
5//! sizes, line heights, weights, tracking, typefaces and the emphasized
6//! styles all come from its local text styles `M3/<group>/<size>` and
7//! `M3/<group>/<size>-emphasized` (Styles page, file key
8//! `ozddDB2Z1v51f1IFZlUuJ2`), which are bound to its `Typescale` and
9//! `Font theme` variable collections (mode Baseline). Fixture:
10//! `crates/material/tests/fixtures/figma-typography.json`.
11//!
12//! The kit's Baseline font theme sets both the brand and plain faces to
13//! Roboto, with Regular/Medium/SemiBold as its regular/medium/bold weights
14//! (so the emphasized title-medium, title-small and label styles are 600).
15//! md3.io agrees on every baseline metric but puts title-medium and
16//! title-small on the brand face (`tests/fixtures/md3-typography.json`);
17//! m3e's tracking,
18//! 700 emphasized weights and Google Sans Flex
19//! (`tests/fixtures/m3e-typography.json`) are outranked and kept only as
20//! secondary evidence. Applications can resolve [`TypefaceRole`] to any
21//! family.
22use gpui::{Rems, rems};
23
24/// The px size of 1rem at the 100% text scale.
25pub const BASE_PX: f32 = 14.;
26
27/// Express a 100%-scale px size in rems.
28pub const fn rem(px: f32) -> Rems {
29    rems(px / BASE_PX)
30}
31
32// Legacy size constants. Each is defined from the M3 role it maps to where
33// the sizes agree; prefer `TypeRole::<role>.scale()` in new code so line
34// height and weight come with the size.
35
36/// Supporting and auxiliary text: body-small (12px).
37pub const TEXT_AUX: Rems = TypeRole::BodySmall.scale().font_size;
38/// Body and control labels: body-medium (14px).
39pub const TEXT_BODY: Rems = TypeRole::BodyMedium.scale().font_size;
40/// Long-form reading: body-large (16px).
41pub const TEXT_READER: Rems = TypeRole::BodyLarge.scale().font_size;
42/// Card and dialog titles: title-large (22px).
43pub const TEXT_TITLE: Rems = TypeRole::TitleLarge.scale().font_size;
44/// Section headings on a page: title-large (22px).
45pub const TEXT_SECTION: Rems = TypeRole::TitleLarge.scale().font_size;
46/// Page titles use the reference headline-small role (24px / 32px).
47pub const TEXT_HEADLINE: Rems = TypeRole::HeadlineSmall.scale().font_size;
48pub const HEADLINE_LINE_HEIGHT: Rems = TypeRole::HeadlineSmall.scale().line_height;
49/// Component token: the label of a regular tile and of a choice tile
50/// ([`crate::components::TileScale::Regular`], [`crate::components::ChoiceTile`]),
51/// one step larger than navigation: body-large (16px).
52pub const TEXT_SETTINGS_BODY: Rems = TypeRole::BodyLarge.scale().font_size;
53/// Component token: the supporting text of a regular tile and of a choice
54/// tile: body-medium (14px).
55pub const TEXT_SETTINGS_AUX: Rems = TEXT_BODY;
56
57/// The M3 role each legacy size constant maps to, by constant name.
58pub const LEGACY_ROLES: [(&str, TypeRole); 8] = [
59    ("TEXT_AUX", TypeRole::BodySmall),
60    ("TEXT_BODY", TypeRole::BodyMedium),
61    ("TEXT_READER", TypeRole::BodyLarge),
62    ("TEXT_TITLE", TypeRole::TitleLarge),
63    ("TEXT_SECTION", TypeRole::TitleLarge),
64    ("TEXT_HEADLINE", TypeRole::HeadlineSmall),
65    ("TEXT_SETTINGS_BODY", TypeRole::BodyLarge),
66    ("TEXT_SETTINGS_AUX", TypeRole::BodyMedium),
67];
68
69/// A role in the reference Material type scale.
70#[derive(Clone, Copy, Debug, Eq, PartialEq)]
71pub enum TypeRole {
72    DisplayLarge,
73    DisplayMedium,
74    DisplaySmall,
75    HeadlineLarge,
76    HeadlineMedium,
77    HeadlineSmall,
78    TitleLarge,
79    TitleMedium,
80    TitleSmall,
81    BodyLarge,
82    BodyMedium,
83    BodySmall,
84    LabelLarge,
85    LabelMedium,
86    LabelSmall,
87}
88
89/// Independently customizable reference typeface families.
90#[derive(Clone, Copy, Debug, Eq, PartialEq)]
91pub enum TypefaceRole {
92    Brand,
93    Plain,
94}
95
96/// Reference typography metrics at 100% text scale.
97///
98/// Sizes and tracking are in rems so they follow the application's text scale.
99/// `letter_spacing` records the reference value; GPUI 0.3.10 does not expose
100/// tracking in its text style, so storing it does not apply it to rendered text.
101#[derive(Clone, Copy, Debug, PartialEq)]
102pub struct TypeScale {
103    pub typeface: TypefaceRole,
104    /// Default family; applications can resolve `typeface` to a custom family.
105    pub font_family: &'static str,
106    pub font_size: Rems,
107    pub line_height: Rems,
108    pub letter_spacing: Rems,
109    pub font_weight: u16,
110}
111
112impl TypeRole {
113    pub const ALL: [Self; 15] = [
114        Self::DisplayLarge,
115        Self::DisplayMedium,
116        Self::DisplaySmall,
117        Self::HeadlineLarge,
118        Self::HeadlineMedium,
119        Self::HeadlineSmall,
120        Self::TitleLarge,
121        Self::TitleMedium,
122        Self::TitleSmall,
123        Self::BodyLarge,
124        Self::BodyMedium,
125        Self::BodySmall,
126        Self::LabelLarge,
127        Self::LabelMedium,
128        Self::LabelSmall,
129    ];
130
131    pub const fn name(self) -> &'static str {
132        match self {
133            Self::DisplayLarge => "display-large",
134            Self::DisplayMedium => "display-medium",
135            Self::DisplaySmall => "display-small",
136            Self::HeadlineLarge => "headline-large",
137            Self::HeadlineMedium => "headline-medium",
138            Self::HeadlineSmall => "headline-small",
139            Self::TitleLarge => "title-large",
140            Self::TitleMedium => "title-medium",
141            Self::TitleSmall => "title-small",
142            Self::BodyLarge => "body-large",
143            Self::BodyMedium => "body-medium",
144            Self::BodySmall => "body-small",
145            Self::LabelLarge => "label-large",
146            Self::LabelMedium => "label-medium",
147            Self::LabelSmall => "label-small",
148        }
149    }
150
151    /// The kit's `Static/<Role>/Font` variables: display, headline and
152    /// title-large use the brand face; title-medium, title-small, body and
153    /// label the plain.
154    pub const fn typeface(self) -> TypefaceRole {
155        match self {
156            Self::TitleMedium
157            | Self::TitleSmall
158            | Self::BodyLarge
159            | Self::BodyMedium
160            | Self::BodySmall
161            | Self::LabelLarge
162            | Self::LabelMedium
163            | Self::LabelSmall => TypefaceRole::Plain,
164            _ => TypefaceRole::Brand,
165        }
166    }
167
168    /// The role's metrics (size, line height and tracking in px at 100%).
169    pub const fn scale(self) -> TypeScale {
170        let (size, height, tracking, weight) = match self {
171            Self::DisplayLarge => (57.0, 64.0, -0.25, 400),
172            Self::DisplayMedium => (45.0, 52.0, 0.0, 400),
173            Self::DisplaySmall => (36.0, 44.0, 0.0, 400),
174            Self::HeadlineLarge => (32.0, 40.0, 0.0, 400),
175            Self::HeadlineMedium => (28.0, 36.0, 0.0, 400),
176            Self::HeadlineSmall => (24.0, 32.0, 0.0, 400),
177            Self::TitleLarge => (22.0, 28.0, 0.0, 400),
178            Self::TitleMedium => (16.0, 24.0, 0.15, 500),
179            Self::TitleSmall => (14.0, 20.0, 0.1, 500),
180            Self::BodyLarge => (16.0, 24.0, 0.5, 400),
181            Self::BodyMedium => (14.0, 20.0, 0.25, 400),
182            Self::BodySmall => (12.0, 16.0, 0.4, 400),
183            Self::LabelLarge => (14.0, 20.0, 0.1, 500),
184            Self::LabelMedium => (12.0, 16.0, 0.5, 500),
185            Self::LabelSmall => (11.0, 16.0, 0.5, 500),
186        };
187        self.metrics(size, height, tracking, weight)
188    }
189
190    /// The role's M3 Expressive emphasized style (the kit's
191    /// `M3/<group>/<size>-emphasized`): the same face, size, line height and
192    /// tracking with the `Weight-emphasized` weight, Medium (500) for regular
193    /// roles and SemiBold (600) for medium ones.
194    pub const fn emphasized(self) -> TypeScale {
195        let base = self.scale();
196        TypeScale {
197            font_weight: if base.font_weight == 400 { 500 } else { 600 },
198            ..base
199        }
200    }
201
202    const fn metrics(self, size: f32, height: f32, tracking: f32, weight: u16) -> TypeScale {
203        TypeScale {
204            typeface: self.typeface(),
205            font_family: "Roboto",
206            font_size: rem(size),
207            line_height: rem(height),
208            letter_spacing: rem(tracking),
209            font_weight: weight,
210        }
211    }
212}
213
214#[cfg(all(test, not(target_family = "wasm")))]
215mod tests {
216    use super::*;
217
218    #[test]
219    fn legacy_sizes_are_their_mapped_roles() {
220        let sizes = [
221            TEXT_AUX,
222            TEXT_BODY,
223            TEXT_READER,
224            TEXT_TITLE,
225            TEXT_SECTION,
226            TEXT_HEADLINE,
227            TEXT_SETTINGS_BODY,
228            TEXT_SETTINGS_AUX,
229        ];
230        for (size, (name, role)) in sizes.into_iter().zip(LEGACY_ROLES) {
231            assert_eq!(size, role.scale().font_size, "{name}");
232        }
233    }
234
235    #[test]
236    fn all_roles_match_the_figma_kit_text_styles() {
237        let fixture: serde_json::Value =
238            serde_json::from_str(include_str!("../../tests/fixtures/figma-typography.json"))
239                .unwrap();
240        assert_eq!(fixture["font_theme"]["brand"], "Roboto");
241        assert_eq!(fixture["font_theme"]["plain"], "Roboto");
242        for (key, style) in [
243            ("roles", TypeRole::scale as fn(TypeRole) -> TypeScale),
244            ("emphasized", TypeRole::emphasized),
245        ] {
246            let roles = fixture[key].as_array().unwrap();
247            assert_eq!(roles.len(), TypeRole::ALL.len());
248            for role in TypeRole::ALL {
249                let expected = roles.iter().find(|row| row["name"] == role.name()).unwrap();
250                assert_matches(style(role), expected, role.name());
251            }
252        }
253    }
254
255    fn assert_matches(scale: TypeScale, expected: &serde_json::Value, name: &str) {
256        assert_eq!(
257            scale.typeface,
258            if expected["typeface"] == "brand" {
259                TypefaceRole::Brand
260            } else {
261                TypefaceRole::Plain
262            },
263            "{name} typeface"
264        );
265        assert_eq!(
266            scale.font_family,
267            expected["font_family"].as_str().unwrap(),
268            "{name} family"
269        );
270        assert_eq!(
271            scale.font_weight,
272            expected["font_weight"].as_u64().unwrap() as u16
273        );
274        for (actual, key) in [
275            (scale.font_size, "font_size"),
276            (scale.line_height, "line_height"),
277            (scale.letter_spacing, "letter_spacing"),
278        ] {
279            assert_eq!(
280                actual,
281                rem(expected[key].as_f64().unwrap() as f32),
282                "{name} {key}"
283            );
284        }
285    }
286}