1use frust_text::{FontFamily, FontWeight, GenericSlot, LineHeight, TextStyle};
64
65#[derive(Clone, Debug, PartialEq)]
75pub struct TypeScale {
76 pub display_large: TextStyle,
77 pub display_medium: TextStyle,
78 pub display_small: TextStyle,
79 pub headline_large: TextStyle,
80 pub headline_medium: TextStyle,
81 pub headline_small: TextStyle,
82 pub title_large: TextStyle,
83 pub title_medium: TextStyle,
84 pub title_small: TextStyle,
85 pub body_large: TextStyle,
86 pub body_medium: TextStyle,
87 pub body_small: TextStyle,
88 pub label_large: TextStyle,
89 pub label_medium: TextStyle,
90 pub label_small: TextStyle,
91 pub display_large_emphasized: TextStyle,
92 pub display_medium_emphasized: TextStyle,
93 pub display_small_emphasized: TextStyle,
94 pub headline_large_emphasized: TextStyle,
95 pub headline_medium_emphasized: TextStyle,
96 pub headline_small_emphasized: TextStyle,
97 pub title_large_emphasized: TextStyle,
98 pub title_medium_emphasized: TextStyle,
99 pub title_small_emphasized: TextStyle,
100 pub body_large_emphasized: TextStyle,
101 pub body_medium_emphasized: TextStyle,
102 pub body_small_emphasized: TextStyle,
103 pub label_large_emphasized: TextStyle,
104 pub label_medium_emphasized: TextStyle,
105 pub label_small_emphasized: TextStyle,
106}
107
108type Token = (f32, f32, f32, FontWeight);
111
112const DISPLAY_LARGE: Token = (57.0, 64.0, -0.25, FontWeight::REGULAR);
113const DISPLAY_MEDIUM: Token = (45.0, 52.0, 0.0, FontWeight::REGULAR);
114const DISPLAY_SMALL: Token = (36.0, 44.0, 0.0, FontWeight::REGULAR);
115const HEADLINE_LARGE: Token = (32.0, 40.0, 0.0, FontWeight::REGULAR);
116const HEADLINE_MEDIUM: Token = (28.0, 36.0, 0.0, FontWeight::REGULAR);
117const HEADLINE_SMALL: Token = (24.0, 32.0, 0.0, FontWeight::REGULAR);
118const TITLE_LARGE: Token = (22.0, 28.0, 0.0, FontWeight::REGULAR);
119const TITLE_MEDIUM: Token = (16.0, 24.0, 0.15, FontWeight::MEDIUM);
120const TITLE_SMALL: Token = (14.0, 20.0, 0.1, FontWeight::MEDIUM);
121const BODY_LARGE: Token = (16.0, 24.0, 0.5, FontWeight::REGULAR);
122const BODY_MEDIUM: Token = (14.0, 20.0, 0.25, FontWeight::REGULAR);
123const BODY_SMALL: Token = (12.0, 16.0, 0.4, FontWeight::REGULAR);
124const LABEL_LARGE: Token = (14.0, 20.0, 0.1, FontWeight::MEDIUM);
125const LABEL_MEDIUM: Token = (12.0, 16.0, 0.5, FontWeight::MEDIUM);
126const LABEL_SMALL: Token = (11.0, 16.0, 0.5, FontWeight::MEDIUM);
127
128const DISPLAY_LARGE_EMPHASIZED: Token = (57.0, 64.0, 0.0, FontWeight::MEDIUM);
135const DISPLAY_MEDIUM_EMPHASIZED: Token = (45.0, 52.0, 0.0, FontWeight::MEDIUM);
136const DISPLAY_SMALL_EMPHASIZED: Token = (36.0, 44.0, 0.0, FontWeight::MEDIUM);
137const HEADLINE_LARGE_EMPHASIZED: Token = (32.0, 40.0, 0.0, FontWeight::MEDIUM);
138const HEADLINE_MEDIUM_EMPHASIZED: Token = (28.0, 36.0, 0.0, FontWeight::MEDIUM);
139const HEADLINE_SMALL_EMPHASIZED: Token = (24.0, 32.0, 0.0, FontWeight::MEDIUM);
140const TITLE_LARGE_EMPHASIZED: Token = (22.0, 28.0, 0.0, FontWeight::MEDIUM);
141const TITLE_MEDIUM_EMPHASIZED: Token = (16.0, 24.0, 0.15, FontWeight::BOLD);
142const TITLE_SMALL_EMPHASIZED: Token = (14.0, 20.0, 0.1, FontWeight::BOLD);
143const BODY_LARGE_EMPHASIZED: Token = (16.0, 24.0, 0.15, FontWeight::MEDIUM);
144const BODY_MEDIUM_EMPHASIZED: Token = (14.0, 20.0, 0.25, FontWeight::MEDIUM);
145const BODY_SMALL_EMPHASIZED: Token = (12.0, 16.0, 0.4, FontWeight::MEDIUM);
146const LABEL_LARGE_EMPHASIZED: Token = (14.0, 20.0, 0.1, FontWeight::BOLD);
147const LABEL_MEDIUM_EMPHASIZED: Token = (12.0, 16.0, 0.5, FontWeight::BOLD);
148const LABEL_SMALL_EMPHASIZED: Token = (11.0, 16.0, 0.5, FontWeight::BOLD);
149
150fn apply(base: &TextStyle, token: Token) -> TextStyle {
151 let (size, line_height_px, letter_spacing, weight) = token;
152 TextStyle {
153 size,
154 weight,
155 letter_spacing,
156 line_height: LineHeight::Absolute(line_height_px),
157 ..base.clone()
158 }
159}
160
161impl TypeScale {
162 pub fn neutral(base: &TextStyle) -> Self {
177 let family =
178 FontFamily::stack_with_generic(std::iter::empty::<&str>(), GenericSlot::SansSerif);
179 let base = TextStyle {
180 family,
181 ..base.clone()
182 };
183 Self {
184 display_large: apply(&base, DISPLAY_LARGE),
185 display_medium: apply(&base, DISPLAY_MEDIUM),
186 display_small: apply(&base, DISPLAY_SMALL),
187 headline_large: apply(&base, HEADLINE_LARGE),
188 headline_medium: apply(&base, HEADLINE_MEDIUM),
189 headline_small: apply(&base, HEADLINE_SMALL),
190 title_large: apply(&base, TITLE_LARGE),
191 title_medium: apply(&base, TITLE_MEDIUM),
192 title_small: apply(&base, TITLE_SMALL),
193 body_large: apply(&base, BODY_LARGE),
194 body_medium: apply(&base, BODY_MEDIUM),
195 body_small: apply(&base, BODY_SMALL),
196 label_large: apply(&base, LABEL_LARGE),
197 label_medium: apply(&base, LABEL_MEDIUM),
198 label_small: apply(&base, LABEL_SMALL),
199 display_large_emphasized: apply(&base, DISPLAY_LARGE_EMPHASIZED),
200 display_medium_emphasized: apply(&base, DISPLAY_MEDIUM_EMPHASIZED),
201 display_small_emphasized: apply(&base, DISPLAY_SMALL_EMPHASIZED),
202 headline_large_emphasized: apply(&base, HEADLINE_LARGE_EMPHASIZED),
203 headline_medium_emphasized: apply(&base, HEADLINE_MEDIUM_EMPHASIZED),
204 headline_small_emphasized: apply(&base, HEADLINE_SMALL_EMPHASIZED),
205 title_large_emphasized: apply(&base, TITLE_LARGE_EMPHASIZED),
206 title_medium_emphasized: apply(&base, TITLE_MEDIUM_EMPHASIZED),
207 title_small_emphasized: apply(&base, TITLE_SMALL_EMPHASIZED),
208 body_large_emphasized: apply(&base, BODY_LARGE_EMPHASIZED),
209 body_medium_emphasized: apply(&base, BODY_MEDIUM_EMPHASIZED),
210 body_small_emphasized: apply(&base, BODY_SMALL_EMPHASIZED),
211 label_large_emphasized: apply(&base, LABEL_LARGE_EMPHASIZED),
212 label_medium_emphasized: apply(&base, LABEL_MEDIUM_EMPHASIZED),
213 label_small_emphasized: apply(&base, LABEL_SMALL_EMPHASIZED),
214 }
215 }
216}
217
218#[cfg(test)]
219mod tests {
220 use super::*;
221 use peniko::Color;
222
223 #[test]
224 fn display_large_matches_table() {
225 let scale = TypeScale::neutral(&TextStyle::new(16.0, Color::BLACK));
226 assert_eq!(scale.display_large.size, 57.0);
227 assert_eq!(scale.display_large.line_height, LineHeight::Absolute(64.0));
228 assert_eq!(scale.display_large.letter_spacing, -0.25);
229 assert_eq!(scale.display_large.weight, FontWeight::REGULAR);
230 }
231
232 #[test]
233 fn title_medium_is_medium_weight() {
234 let scale = TypeScale::neutral(&TextStyle::new(16.0, Color::BLACK));
235 assert_eq!(scale.title_medium.weight, FontWeight::MEDIUM);
236 assert_eq!(scale.title_medium.size, 16.0);
237 assert_eq!(scale.title_medium.letter_spacing, 0.15);
238 }
239
240 #[test]
241 fn title_large_is_regular_weight() {
242 let scale = TypeScale::neutral(&TextStyle::new(16.0, Color::BLACK));
245 assert_eq!(scale.title_large.weight, FontWeight::REGULAR);
246 }
247
248 #[test]
249 fn label_small_matches_table() {
250 let scale = TypeScale::neutral(&TextStyle::new(16.0, Color::BLACK));
251 assert_eq!(scale.label_small.size, 11.0);
252 assert_eq!(scale.label_small.line_height, LineHeight::Absolute(16.0));
253 assert_eq!(scale.label_small.letter_spacing, 0.5);
254 assert_eq!(scale.label_small.weight, FontWeight::MEDIUM);
255 }
256
257 #[test]
263 fn every_emphasized_role_differs_in_weight_from_its_base() {
264 let scale = TypeScale::neutral(&TextStyle::new(16.0, Color::BLACK));
265 let pairs: [(&TextStyle, &TextStyle); 15] = [
266 (&scale.display_large, &scale.display_large_emphasized),
267 (&scale.display_medium, &scale.display_medium_emphasized),
268 (&scale.display_small, &scale.display_small_emphasized),
269 (&scale.headline_large, &scale.headline_large_emphasized),
270 (&scale.headline_medium, &scale.headline_medium_emphasized),
271 (&scale.headline_small, &scale.headline_small_emphasized),
272 (&scale.title_large, &scale.title_large_emphasized),
273 (&scale.title_medium, &scale.title_medium_emphasized),
274 (&scale.title_small, &scale.title_small_emphasized),
275 (&scale.body_large, &scale.body_large_emphasized),
276 (&scale.body_medium, &scale.body_medium_emphasized),
277 (&scale.body_small, &scale.body_small_emphasized),
278 (&scale.label_large, &scale.label_large_emphasized),
279 (&scale.label_medium, &scale.label_medium_emphasized),
280 (&scale.label_small, &scale.label_small_emphasized),
281 ];
282 for (base, emphasized) in pairs {
283 assert_ne!(
284 base.weight, emphasized.weight,
285 "expected emphasized weight to differ from base weight"
286 );
287 }
288 }
289
290 #[test]
291 fn emphasized_keeps_base_size_and_line_height() {
292 let scale = TypeScale::neutral(&TextStyle::new(16.0, Color::BLACK));
296 assert_eq!(
297 scale.display_large.size,
298 scale.display_large_emphasized.size
299 );
300 assert_eq!(
301 scale.display_large.line_height,
302 scale.display_large_emphasized.line_height
303 );
304 assert_eq!(scale.label_small.size, scale.label_small_emphasized.size);
305 assert_eq!(
306 scale.label_small.line_height,
307 scale.label_small_emphasized.line_height
308 );
309 }
310
311 #[test]
312 fn display_large_emphasized_is_medium_weight() {
313 let scale = TypeScale::neutral(&TextStyle::new(16.0, Color::BLACK));
314 assert_eq!(scale.display_large_emphasized.weight, FontWeight::MEDIUM);
315 assert_eq!(scale.display_large_emphasized.letter_spacing, 0.0);
316 }
317
318 #[test]
319 fn title_medium_emphasized_is_bold_weight() {
320 let scale = TypeScale::neutral(&TextStyle::new(16.0, Color::BLACK));
323 assert_eq!(scale.title_medium.weight, FontWeight::MEDIUM);
324 assert_eq!(scale.title_medium_emphasized.weight, FontWeight::BOLD);
325 }
326
327 #[test]
328 fn label_small_emphasized_is_bold_weight() {
329 let scale = TypeScale::neutral(&TextStyle::new(16.0, Color::BLACK));
330 assert_eq!(scale.label_small.weight, FontWeight::MEDIUM);
331 assert_eq!(scale.label_small_emphasized.weight, FontWeight::BOLD);
332 }
333
334 #[test]
335 fn body_large_emphasized_tracking_differs_from_base() {
336 let scale = TypeScale::neutral(&TextStyle::new(16.0, Color::BLACK));
340 assert_eq!(scale.body_large.letter_spacing, 0.5);
341 assert_eq!(scale.body_large_emphasized.letter_spacing, 0.15);
342 }
343
344 #[test]
347 fn neutral_uses_no_bundled_fonts() {
348 let scale = TypeScale::neutral(&TextStyle::new(16.0, Color::BLACK));
351 let expected =
352 FontFamily::stack_with_generic(std::iter::empty::<&str>(), GenericSlot::SansSerif);
353 for family in [
354 &scale.display_large.family,
355 &scale.headline_medium.family,
356 &scale.title_large.family,
357 &scale.body_large.family,
358 &scale.label_small.family,
359 &scale.body_large_emphasized.family,
360 ] {
361 assert_eq!(family, &expected);
362 match family {
363 FontFamily::NamedWithGeneric(parts) => {
364 assert!(
366 !parts
367 .iter()
368 .any(|p| matches!(p, frust_text::FamilyName::Named(_)))
369 );
370 }
371 other => panic!("expected a generic-only stack, got {other:?}"),
372 }
373 }
374 }
375
376 #[test]
377 fn neutral_preserves_base_color_but_overrides_family() {
378 let base = TextStyle {
379 family: FontFamily::named("Roboto"),
380 ..TextStyle::new(16.0, Color::from_rgb8(1, 2, 3))
381 };
382 let scale = TypeScale::neutral(&base);
383 assert_ne!(scale.body_large.family, base.family);
384 assert_eq!(scale.body_large.color, base.color);
385 }
386}