frus_core/text_style.rs
1//! [`TextStyle`]: the typographic attributes of a piece of text (size, weight,
2//! italic, colour), independent of any widget or theme.
3//!
4//! A `TextStyle` is a pure `Copy` value. Its `color` is optional, and `None`
5//! means "inherit" — the widget resolves it to the theme colour at paint time.
6//! Named typographic scales, in the style of a Material `TextTheme`, are built
7//! out of this type.
8
9use crate::Color;
10
11/// How the lines of a piece of text are **aligned inside the box** it was given.
12///
13/// It is not the same question as where the box goes. A centred paragraph and a centred
14/// box look identical while the text is one line and stop looking identical the moment it
15/// wraps, which is when the setting starts to matter.
16#[derive(Clone, Copy, Debug, PartialEq, Eq, Default)]
17pub enum TextAlign {
18 /// The start of the reading direction: the left in a left-to-right script, the right
19 /// in a right-to-left one. The default, and the only one that follows the text.
20 #[default]
21 Start,
22 /// The end of the reading direction.
23 End,
24 /// Centred in the box.
25 Center,
26 /// Both edges flush, the space stretched between the words. Every line but the last.
27 Justify,
28 /// The left edge, whatever the script. A column of figures wants this and not
29 /// [`TextAlign::Start`], because the figures do not change direction with the prose
30 /// around them.
31 Left,
32 /// The right edge, whatever the script.
33 Right,
34}
35
36/// What becomes of text that does not fit the box it was given.
37#[derive(Clone, Copy, Debug, PartialEq, Eq, Default)]
38pub enum TextOverflow {
39 /// Cut at the edge of the box. The default, and the quiet one: nothing marks the
40 /// place where the words stopped.
41 #[default]
42 Clip,
43 /// Cut, with the last line ending in an ellipsis. The only mode that tells the reader
44 /// something is missing, which is usually the one wanted.
45 Ellipsis,
46 /// Cut, with the last line fading out into the background rather than stopping at a
47 /// hard edge.
48 Fade,
49 /// Draw past the box. Not an accident: a badge or a decoration that is *meant* to
50 /// spill says so this way, rather than by being given a box it does not fit.
51 Visible,
52}
53
54/// Font weight — a useful subset, mapped onto the CSS/OpenType weights.
55#[derive(Clone, Copy, Debug, PartialEq, Eq, Default)]
56pub enum FontWeight {
57 /// 400 — regular.
58 #[default]
59 Regular,
60 /// 500.
61 Medium,
62 /// 600.
63 SemiBold,
64 /// 700 — bold.
65 Bold,
66}
67
68impl FontWeight {
69 /// The numeric OpenType weight (400/500/600/700).
70 pub fn to_u16(self) -> u16 {
71 match self {
72 FontWeight::Regular => 400,
73 FontWeight::Medium => 500,
74 FontWeight::SemiBold => 600,
75 FontWeight::Bold => 700,
76 }
77 }
78
79 /// The weight `t` of the way from `self` to `other` — **the nearest of the four**,
80 /// not a number between them.
81 ///
82 /// A weight is drawn with a face, and there are four faces. Interpolating the
83 /// *number* would ask for a 437 that no font has, so what moves is the choice: the
84 /// numeric weights are interpolated and the result snaps to whichever of the four is
85 /// closest. Halfway from regular to bold is semi-bold, and a run of words thickens in
86 /// steps rather than gliding.
87 ///
88 /// The reference does exactly this over its nine weights, and this is its method
89 /// rather than a lookalike: the **position in the list** is interpolated and rounded,
90 /// not the OpenType number. The two agree here because the four are evenly spaced,
91 /// and the list is what stays right if a fifth face is ever added between two of them.
92 ///
93 /// **A tie goes to the heavier face.** Exactly halfway from regular to bold is 550,
94 /// which has no nearest — rounding a half up the list answers semi-bold, going in
95 /// either direction, so a run of words thickening and one thinning pass through the
96 /// same face at the same moment instead of missing each other by one step.
97 ///
98 /// Four steps make the stepping easier to see than nine would. That is a reason to
99 /// say so here, not a reason to do something else: a variable-font axis is the thing
100 /// that would make a weight glide, and this framework does not have one yet.
101 #[must_use]
102 pub fn lerp(self, other: FontWeight, t: f32) -> FontWeight {
103 const FACES: [FontWeight; 4] = [
104 FontWeight::Regular,
105 FontWeight::Medium,
106 FontWeight::SemiBold,
107 FontWeight::Bold,
108 ];
109 fn place(weight: FontWeight) -> f32 {
110 match weight {
111 FontWeight::Regular => 0.0,
112 FontWeight::Medium => 1.0,
113 FontWeight::SemiBold => 2.0,
114 FontWeight::Bold => 3.0,
115 }
116 }
117 let a = place(self);
118 let b = place(other);
119 // `round` takes a half away from zero and every place is positive, so a tie goes
120 // up the list — to the heavier face, as documented above.
121 let step = (a + (b - a) * t.clamp(0.0, 1.0)).round().clamp(0.0, 3.0);
122 FACES[step as usize]
123 }
124}
125
126/// A text's **decoration** lines, which combine with one another. They have no
127/// effect on measurement: the text's geometry does not change, and the lines are
128/// drawn by the backend from the baseline metrics.
129#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
130pub struct TextDecoration {
131 /// Underline, below the baseline.
132 pub underline: bool,
133 /// A line above the text.
134 pub overline: bool,
135 /// Struck through, at the middle of the x-height.
136 pub strikethrough: bool,
137}
138
139impl TextDecoration {
140 /// No decoration at all.
141 pub const NONE: Self = Self {
142 underline: false,
143 overline: false,
144 strikethrough: false,
145 };
146 /// Underline only.
147 pub const UNDERLINE: Self = Self {
148 underline: true,
149 ..Self::NONE
150 };
151 /// Overline only.
152 pub const OVERLINE: Self = Self {
153 overline: true,
154 ..Self::NONE
155 };
156 /// Strikethrough only.
157 pub const STRIKETHROUGH: Self = Self {
158 strikethrough: true,
159 ..Self::NONE
160 };
161
162 /// `true` when no line at all is asked for.
163 pub const fn is_none(self) -> bool {
164 !self.underline && !self.overline && !self.strikethrough
165 }
166
167 /// Combines two decorations — the union of their lines.
168 pub const fn combine(self, other: Self) -> Self {
169 Self {
170 underline: self.underline || other.underline,
171 overline: self.overline || other.overline,
172 strikethrough: self.strikethrough || other.strikethrough,
173 }
174 }
175}
176
177/// The font size a text ends up at when nothing anywhere in the chain named one.
178pub const DEFAULT_TEXT_SIZE: f32 = 16.0;
179
180/// A **font family**, as a style names it.
181///
182/// `Copy`, and `Named` holds a `&'static str`, so that a [`TextStyle`] stays `Copy` and can
183/// be handed down a subtree by value like every other field. Family names come from
184/// `frus_text::add_font`, which registers them for the life of the program, so a borrowed
185/// name outliving the style is the normal case rather than a restriction.
186#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)]
187pub enum FontFamily {
188 /// The application's default sans-serif — what text gets when it says nothing.
189 SansSerif,
190 /// A serif face, if the platform or the application has one.
191 Serif,
192 /// The registered monospaced family: code, keys, anything whose columns must line up.
193 Monospace,
194 /// A family **by name**, as registered with `frus_text::add_font`.
195 Named(&'static str),
196}
197
198/// The line height a style that says nothing gets, as a **multiple of the font size**.
199///
200/// 1.2 is what the bundled faces want, and what every part of the framework used before it
201/// was expressible at all — see [`TextStyle::height`].
202pub const DEFAULT_LINE_HEIGHT: f32 = 1.2;
203
204/// The typographic attributes of a single line of text — **every field optional**.
205///
206/// `None` is not a missing value but an answer: *this style does not say*. That is what
207/// makes a style inheritable field by field, and it is the reference's shape — every field
208/// of its `TextStyle` is nullable for exactly this reason.
209///
210/// It matters more than it looks. `TextStyle::new(20.0)` says a size and **nothing else**,
211/// so a text wearing it still takes its weight from whatever subtree it is in. Before this
212/// was optional, a style named all three at once and *size 20, inherit the weight* was
213/// simply not expressible.
214///
215/// Ask [`TextStyle::resolved`] for the concrete numbers to measure and draw with.
216#[derive(Clone, Copy, Debug, Default, PartialEq)]
217pub struct TextStyle {
218 /// Font size, in logical pixels. Unset, [`DEFAULT_TEXT_SIZE`].
219 pub size: Option<f32>,
220 /// Weight. Unset, [`FontWeight::Regular`].
221 pub weight: Option<FontWeight>,
222 /// Italic. Unset, upright.
223 pub italic: Option<bool>,
224 /// Colour. Unset, the widget resolves it against the theme at paint.
225 pub color: Option<Color>,
226 /// Decoration lines (underline, strikethrough, and so on). Unset, none — and set to
227 /// [`TextDecoration::NONE`] is a **different** answer, being how a caller takes an
228 /// underline back off a run of words a subtree underlined.
229 pub decoration: Option<TextDecoration>,
230 /// Decoration colour; unset, the text's own.
231 pub decoration_color: Option<Color>,
232 /// The line's height, as a **multiple of the font size** — the reference's `height`.
233 ///
234 /// `1.0` sets each line box to exactly the font size, `1.5` to half again; unset says
235 /// nothing, and a style that says nothing inherits whatever a subtree handed down, or
236 /// [`DEFAULT_LINE_HEIGHT`] at the end of that chain.
237 ///
238 /// It is a **ratio, not a length**, for the reason the reference gives: a paragraph
239 /// keeps its rhythm when the reader turns the type up, because the leading grows with
240 /// the letters instead of staying where a designer left it.
241 pub height: Option<f32>,
242 /// The font family — the reference's `fontFamily`. Unset, the application's default.
243 ///
244 /// **It does not always get the last word, and that is deliberate.** A run containing
245 /// Arabic is drawn with the registered Arabic face even when a family was named,
246 /// because cosmic-text does not fall back across families on Android, where the
247 /// platform fallback lists are empty: a named family without Arabic coverage renders
248 /// *nothing at all*. Text in an unexpected face is a smaller failure than no text, and
249 /// naming the Arabic family itself still works.
250 pub family: Option<FontFamily>,
251 /// The room added after every character, in **logical pixels** — the reference's
252 /// `letterSpacing`; negative draws them closer (milestone 623). Unset, none.
253 ///
254 /// A length, not a ratio, as the reference has it, and **not** grown by the reader's
255 /// font setting: the reference scales a style's font size and leaves its letter spacing
256 /// as it was written.
257 pub letter_spacing: Option<f32>,
258}
259
260/// A [`TextStyle`] with every question answered: what to measure with, and what to draw.
261///
262/// The colour stays optional, and deliberately. Size, weight and slant have a *framework*
263/// default that is right everywhere; a colour's last word belongs to the theme, which this
264/// type cannot see. A widget resolves it at paint against `on_surface`.
265#[derive(Clone, Copy, Debug, PartialEq)]
266pub struct ResolvedTextStyle {
267 /// Font size, in logical pixels.
268 pub size: f32,
269 /// Weight.
270 pub weight: FontWeight,
271 /// Italic.
272 pub italic: bool,
273 /// Explicit colour; `None` means the widget resolves it against the theme.
274 pub color: Option<Color>,
275 /// Decoration lines.
276 pub decoration: TextDecoration,
277 /// Decoration colour; `None` means the text's own colour.
278 pub decoration_color: Option<Color>,
279 /// The line's height as a **multiple of the font size**; `None` means
280 /// [`DEFAULT_LINE_HEIGHT`]. Read it through [`line_height`](Self::line_height).
281 pub height: Option<f32>,
282 /// The font family; `None` means the application's default. See
283 /// [`TextStyle::family`] for why it does not always win.
284 pub family: Option<FontFamily>,
285 /// The room added after every character, in logical pixels; `0` for none.
286 pub letter_spacing: f32,
287}
288
289impl ResolvedTextStyle {
290 /// The height of one line, in logical pixels.
291 ///
292 /// **The one place this number is decided.** It used to be a `LINE_HEIGHT_FACTOR`
293 /// constant in `frus-text` and *another* in `frus-gpu`, which is two constants that
294 /// had to agree: a measure and a paint disagreeing about how tall a line is puts the
295 /// second line of every paragraph somewhere the layout did not reserve.
296 pub fn line_height(&self) -> f32 {
297 self.size * self.height.unwrap_or(DEFAULT_LINE_HEIGHT)
298 }
299 /// A style of **exactly** this size — regular, upright, undecorated, and *not* put
300 /// through the reader's font setting.
301 ///
302 /// For glyphs whose size is geometry rather than type: an icon on its 24 px grid, a
303 /// marker that has to stay inside a circle drawn beside it. The reference's `Icon` is
304 /// sized by `IconTheme` and ignores the text scaler for the same reason — an icon that
305 /// grew with the type would leave its own box.
306 ///
307 /// Everything a reader *reads* wants
308 /// [`TextStyle::new(size).resolved()`](TextStyle::resolved) instead. The two are one
309 /// keyword apart on purpose: the choice is worth stating at every call, which is why
310 /// there is no third form that guesses.
311 pub const fn exact(size: f32) -> Self {
312 Self {
313 size,
314 weight: FontWeight::Regular,
315 italic: false,
316 color: None,
317 decoration: TextDecoration::NONE,
318 decoration_color: None,
319 height: None,
320 family: None,
321 letter_spacing: 0.0,
322 }
323 }
324}
325
326impl TextStyle {
327 /// A style that says **nothing at all** — every field inherited.
328 pub const NONE: Self = Self {
329 size: None,
330 weight: None,
331 italic: None,
332 color: None,
333 decoration: None,
334 decoration_color: None,
335 height: None,
336 family: None,
337 letter_spacing: None,
338 };
339
340 /// A style that names a `size` and nothing else.
341 pub const fn new(size: f32) -> Self {
342 Self {
343 size: Some(size),
344 ..Self::NONE
345 }
346 }
347
348 /// Sets the weight.
349 pub const fn weight(mut self, weight: FontWeight) -> Self {
350 self.weight = Some(weight);
351 self
352 }
353
354 /// Switches to italic.
355 pub const fn italic(mut self) -> Self {
356 self.italic = Some(true);
357 self
358 }
359
360 /// Sets the size.
361 pub const fn size(mut self, size: f32) -> Self {
362 self.size = Some(size);
363 self
364 }
365
366 /// Sets the colour.
367 pub const fn color(mut self, color: Color) -> Self {
368 self.color = Some(color);
369 self
370 }
371
372 /// Sets the decoration lines.
373 pub const fn decoration(mut self, decoration: TextDecoration) -> Self {
374 self.decoration = Some(decoration);
375 self
376 }
377
378 /// Adds an underline, combining with any other decoration **this style** already
379 /// carries — never with an inherited one, which this style cannot see.
380 pub const fn underline(mut self) -> Self {
381 let current = match self.decoration {
382 Some(d) => d,
383 None => TextDecoration::NONE,
384 };
385 self.decoration = Some(current.combine(TextDecoration::UNDERLINE));
386 self
387 }
388
389 /// Adds a strikethrough, combining with any other decoration this style carries.
390 pub const fn strikethrough(mut self) -> Self {
391 let current = match self.decoration {
392 Some(d) => d,
393 None => TextDecoration::NONE,
394 };
395 self.decoration = Some(current.combine(TextDecoration::STRIKETHROUGH));
396 self
397 }
398
399 /// Sets the decoration colour; otherwise the text's colour is used.
400 pub const fn decoration_color(mut self, color: Color) -> Self {
401 self.decoration_color = Some(color);
402 self
403 }
404
405 /// Sets the line's height, as a **multiple of the font size** — `2.0` is double
406 /// leading, not two pixels. See the [field](Self::height) for why it is a ratio.
407 pub const fn height(mut self, height: f32) -> Self {
408 self.height = Some(height);
409 self
410 }
411
412 /// Names the font family. See the [field](Self::family) for the one case where a
413 /// named family does not get the last word.
414 pub const fn family(mut self, family: FontFamily) -> Self {
415 self.family = Some(family);
416 self
417 }
418
419 /// Sets the room added after every character, in logical pixels; negative draws them
420 /// closer. See the [field](Self::letter_spacing).
421 pub const fn letter_spacing(mut self, spacing: f32) -> Self {
422 self.letter_spacing = Some(spacing);
423 self
424 }
425
426 /// **Merges** `over` on top of `self`, **field by field**: where `over` said nothing,
427 /// this one's answer survives.
428 ///
429 /// The cascade, and it is one operation now rather than three. Until every field could
430 /// say "unset" this codebase carried the same idea three times — a private `Overrides`
431 /// struct for rich-text spans, a `Chosen` record of booleans beside a `Text`'s style,
432 /// and half of the widgets' `DefaultTextStyle` — because a whole-style merge could only
433 /// replace the typography wholesale and each caller needed the other behaviour.
434 #[must_use]
435 pub fn merge(self, over: TextStyle) -> TextStyle {
436 TextStyle {
437 size: over.size.or(self.size),
438 weight: over.weight.or(self.weight),
439 italic: over.italic.or(self.italic),
440 color: over.color.or(self.color),
441 decoration: over.decoration.or(self.decoration),
442 decoration_color: over.decoration_color.or(self.decoration_color),
443 height: over.height.or(self.height),
444 family: over.family.or(self.family),
445 letter_spacing: over.letter_spacing.or(self.letter_spacing),
446 }
447 }
448
449 /// The style `t` of the way from `self` to `other` — what an animated default text
450 /// style hands its subtree mid-flight.
451 ///
452 /// Three rules, one per kind of field, and the kinds are not a taxonomy — each is a
453 /// different answer to *what is halfway between these two*:
454 ///
455 /// - **Sizes and ratios travel** (`size`, `height`, `letter_spacing`): a number between the two.
456 /// - **A field set at only one end does not travel.** It holds the one value it has,
457 /// from the first frame to the last. `None` here means *unset* — a question passed
458 /// further up the cascade — and there is no number between "18 pixels" and "ask
459 /// somebody else". Interpolating towards a value nobody has stated would be
460 /// interpolating towards whatever the theme happens to say, which changes what the
461 /// movement looks like when the theme changes and is not what either end asked for.
462 /// The reference lands on the same rule from the other end, taking the stated value
463 /// for *both* of its own, and it is the rule an animated pin follows for the same
464 /// reason: **a movement needs both ends to say where they are.**
465 /// - **Faces and lines swap at the halfway point** (`italic`, `decoration`, `family`):
466 /// there is no half-italic face and no two-thirds of an underline, so the change
467 /// happens once, in the middle, where it is least noticeable. The reference's rule
468 /// again, and `weight` is the near miss — see [`FontWeight::lerp`], which steps
469 /// through the four faces rather than swapping once.
470 ///
471 /// **The colours diverge from the reference deliberately.** It fades an unset colour
472 /// to transparent, because there `null` means *no colour was painted*. Here `None`
473 /// means *the widget resolves it against the theme at paint* — `on_surface`, a
474 /// perfectly visible colour — so fading it out would make a run of words vanish
475 /// halfway through a movement that was only ever about its size. An unset colour
476 /// therefore follows the rule above: it does not travel.
477 #[must_use]
478 pub fn lerp(self, other: TextStyle, t: f32) -> TextStyle {
479 let t = t.clamp(0.0, 1.0);
480 /// A number, where both ends stated one. Where only one did, that one — held.
481 fn number(a: Option<f32>, b: Option<f32>, t: f32) -> Option<f32> {
482 match (a, b) {
483 (Some(a), Some(b)) => Some(a + (b - a) * t),
484 (a, b) => a.or(b),
485 }
486 }
487 /// The same rule for a colour, and for the same reason.
488 fn color(a: Option<Color>, b: Option<Color>, t: f32) -> Option<Color> {
489 match (a, b) {
490 (Some(a), Some(b)) => Some(a.lerp(b, t)),
491 (a, b) => a.or(b),
492 }
493 }
494 /// A choice, not a quantity: the near side until halfway, the far side after.
495 fn swap<T>(a: Option<T>, b: Option<T>, t: f32) -> Option<T> {
496 if t < 0.5 {
497 a
498 } else {
499 b
500 }
501 }
502 TextStyle {
503 size: number(self.size, other.size, t),
504 weight: match (self.weight, other.weight) {
505 (Some(a), Some(b)) => Some(a.lerp(b, t)),
506 (a, b) => a.or(b),
507 },
508 italic: swap(self.italic, other.italic, t),
509 color: color(self.color, other.color, t),
510 decoration: swap(self.decoration, other.decoration, t),
511 decoration_color: color(self.decoration_color, other.decoration_color, t),
512 height: number(self.height, other.height, t),
513 family: swap(self.family, other.family, t),
514 letter_spacing: number(self.letter_spacing, other.letter_spacing, t),
515 }
516 }
517
518 /// Every question answered: the framework's own default wherever nothing in the chain
519 /// said anything, and **the reader's font size applied**.
520 ///
521 /// This is where the chain **stops**. Above it a style may leave a field open for a
522 /// subtree or a theme to answer; below it a shaper needs a number, and there is nobody
523 /// left to ask.
524 ///
525 /// Which is exactly why the user's *Font size* setting is applied here and nowhere
526 /// else. A phone's slider goes to 1.3 on Android and past 3 with iOS's larger
527 /// accessibility sizes, and an interface that ignores it is one a lot of people cannot
528 /// read. Scaling it at the one place a size becomes a number is what keeps the
529 /// **measurement and the paint agreeing**: text measured at one size and drawn at
530 /// another is a layout that is wrong everywhere at once, with nothing in the picture to
531 /// say which of the two numbers was the mistake.
532 ///
533 /// See [`with_text_scale`]. Outside any scope the scale is 1 and this is the identity.
534 /// Caps how far the **reader's font setting** may enlarge this style.
535 ///
536 /// The size is declared smaller in exact proportion, so that resolving it lands at
537 /// `max_scale` times the size asked for and no more. Below `max_scale` nothing changes.
538 ///
539 /// This is the reference's second answer to a reader who turns the system type up, and
540 /// it belongs to **chrome**. A component grows: its default height is a floor and the
541 /// content wins. A toolbar cannot — it would push the whole screen down — so the
542 /// reference keeps its bar at `kToolbarHeight` and clamps the title's scaler to 1.34
543 /// instead, "to keep the visual hierarchy the same even with larger font sizes".
544 ///
545 /// It reads the ambient scale, so it must be called where that scale is in force —
546 /// which for a widget means while it is being built, the same place it reads a theme.
547 #[must_use]
548 pub fn clamp_scale(mut self, max_scale: f32) -> Self {
549 let scale = text_scale();
550 if scale > max_scale && scale > 0.0 {
551 if let Some(size) = self.size {
552 self.size = Some(size * max_scale / scale);
553 }
554 }
555 self
556 }
557
558 /// Fills every unset field with its default, applying the ambient text scale to
559 /// the size on the way.
560 pub fn resolved(self) -> ResolvedTextStyle {
561 ResolvedTextStyle {
562 size: self.size.unwrap_or(DEFAULT_TEXT_SIZE) * text_scale(),
563 weight: self.weight.unwrap_or(FontWeight::Regular),
564 italic: self.italic.unwrap_or(false),
565 color: self.color,
566 decoration: self.decoration.unwrap_or(TextDecoration::NONE),
567 decoration_color: self.decoration_color,
568 height: self.height,
569 family: self.family,
570 letter_spacing: self.letter_spacing.unwrap_or(0.0),
571 }
572 }
573}
574
575/// The reader's font-size setting in force on this thread, or `1.0` outside any
576/// [`with_text_scale`].
577///
578/// Read once, by [`TextStyle::resolved`]. Nothing else should consult it: a second reader
579/// is a second chance to scale a size twice, or to scale one a sibling did not.
580#[inline(never)]
581pub fn text_scale() -> f32 {
582 TEXT_SCALE.with(|s| s.get())
583}
584
585/// Runs `f` with the reader's font-size setting in force, restoring whatever was there
586/// before — including while a panic unwinds, so one bad frame cannot leave a stale scale
587/// installed for every frame after it.
588///
589/// **Ambient rather than threaded**, and that is the decision worth stating. Passing a
590/// scaler down would mean every widget that measures a label remembering to apply it, and
591/// the one that forgot would draw text the layout never measured. There is no diagnostic
592/// for that — only a screen that is subtly wrong. Ambient makes forgetting impossible: the
593/// only place a size becomes a number already reads it.
594///
595/// The framework installs this around `view` from `MediaQuery::text_scaler`; an
596/// application does not normally call it. Scales at or below zero are ignored, a font of
597/// no size being a screen with no words on it.
598///
599/// **A closure is not always a long enough life.** A size becomes a number during layout
600/// and again during paint, which happen after the widgets have been built — see
601/// [`install_text_scale`], which the shell uses to cover a whole frame.
602pub fn with_text_scale<R>(scale: f32, f: impl FnOnce() -> R) -> R {
603 let guard = install_text_scale(scale);
604 let out = f();
605 drop(guard);
606 out
607}
608
609/// Installs the reader's font-size setting **until the returned guard is dropped**.
610///
611/// [`with_text_scale`] covers a closure, which is right for a subtree and wrong for a
612/// frame: a widget is *built* first, then measured, laid out and painted, and every one of
613/// those later steps resolves sizes too. Scoping only the build leaves the scale at 1 for
614/// the two steps that decide how big the text actually is — so the layout measures one
615/// size and the renderer draws another, which is the exact failure
616/// [`TextStyle::resolved`] exists to make impossible.
617///
618/// That is not a hypothetical either: the shell wrapped `view` alone, and the setting
619/// reached a device without changing a single pixel (milestone 407).
620///
621/// Scales at or below zero are ignored, as in [`with_text_scale`].
622#[inline(never)]
623#[must_use = "the scale is restored the moment the guard is dropped"]
624pub fn install_text_scale(scale: f32) -> TextScaleGuard {
625 let scale = if scale > 0.0 { scale } else { 1.0 };
626 TextScaleGuard(TEXT_SCALE.with(|s| s.replace(scale)))
627}
628
629/// Puts back the scale that was in force before [`install_text_scale`], when dropped —
630/// including while a panic unwinds, so one bad frame cannot leave a stale scale installed
631/// for every frame after it.
632pub struct TextScaleGuard(f32);
633
634impl Drop for TextScaleGuard {
635 #[inline(never)]
636 fn drop(&mut self) {
637 TEXT_SCALE.with(|s| s.set(self.0));
638 }
639}
640
641thread_local! {
642 /// The reader's font-size setting on this thread. `Cell`, not `RefCell`: an `f32` is
643 /// `Copy` and every access is a whole get or a whole set.
644 static TEXT_SCALE: std::cell::Cell<f32> = const { std::cell::Cell::new(1.0) };
645}
646
647/// A **rich-text tree**: each node carries a fragment of text, *partial* style
648/// overrides — whatever is unspecified **inherits** from the parent, so a
649/// `.bold()` child keeps its parent's size — and children of its own.
650///
651/// ```
652/// # use frus_core::{TextSpan, TextStyle};
653/// let span = TextSpan::new("Hello ")
654/// .child(TextSpan::new("bold").bold())
655/// .child(TextSpan::new(" world"));
656/// let runs = span.flatten(TextStyle::new(20.0));
657/// assert_eq!(runs.len(), 3);
658/// assert_eq!(runs[1].1.size, 20.0); // the bold child inherits the size
659/// ```
660#[derive(Clone, Debug, Default, PartialEq)]
661pub struct TextSpan {
662 text: String,
663 overrides: TextStyle,
664 children: Vec<TextSpan>,
665}
666
667impl TextSpan {
668 /// A text fragment with no overrides — it inherits everything.
669 pub fn new(text: impl Into<String>) -> Self {
670 Self {
671 text: text.into(),
672 overrides: TextStyle::NONE,
673 children: Vec::new(),
674 }
675 }
676
677 /// Makes this subtree bold.
678 pub fn bold(mut self) -> Self {
679 self.overrides.weight = Some(FontWeight::Bold);
680 self
681 }
682
683 /// Sets this subtree's weight.
684 pub fn weight(mut self, weight: FontWeight) -> Self {
685 self.overrides.weight = Some(weight);
686 self
687 }
688
689 /// Makes this subtree italic.
690 pub fn italic(mut self) -> Self {
691 self.overrides.italic = Some(true);
692 self
693 }
694
695 /// Sets this subtree's size.
696 pub fn size(mut self, size: f32) -> Self {
697 self.overrides.size = Some(size);
698 self
699 }
700
701 /// Sets this subtree's colour.
702 pub fn color(mut self, color: Color) -> Self {
703 self.overrides.color = Some(color);
704 self
705 }
706
707 /// Underlines this subtree. It combines with the decoration already set on
708 /// this node, but not with the parent's.
709 pub fn underline(mut self) -> Self {
710 self.overrides = self.overrides.underline();
711 self
712 }
713
714 /// Strikes through this subtree.
715 pub fn strikethrough(mut self) -> Self {
716 self.overrides = self.overrides.strikethrough();
717 self
718 }
719
720 /// Sets this subtree's decoration lines.
721 pub fn decoration(mut self, decoration: TextDecoration) -> Self {
722 self.overrides.decoration = Some(decoration);
723 self
724 }
725
726 /// Sets this subtree's decoration colour.
727 pub fn decoration_color(mut self, color: Color) -> Self {
728 self.overrides.decoration_color = Some(color);
729 self
730 }
731
732 /// Applies a whole [`TextStyle`] as this subtree's override, **replacing** whatever
733 /// this span had said before.
734 ///
735 /// It used to force every typographic field to *answered*, because a `TextStyle` could
736 /// not say otherwise. It no longer has to: a style naming only a size now overrides
737 /// only the size, and the weight goes on being inherited from the parent span.
738 pub fn style(mut self, style: TextStyle) -> Self {
739 self.overrides = style;
740 self
741 }
742
743 /// Adds a child, rendered after this node's own text.
744 pub fn child(mut self, child: TextSpan) -> Self {
745 self.children.push(child);
746 self
747 }
748
749 /// Flattens the tree into **resolved runs** `(text, style)`, in reading order,
750 /// cascading the overrides down from `base`, the paragraph's default style.
751 /// Nodes with no text of their own produce no empty run.
752 pub fn flatten(&self, base: TextStyle) -> Vec<(String, ResolvedTextStyle)> {
753 let mut runs = Vec::new();
754 self.collect(base, &mut runs);
755 runs
756 }
757
758 /// Mixes into `hasher` everything that affects the tree's **measurement**:
759 /// text, sizes, weights and italics. Colours and **decorations** are excluded,
760 /// having no effect on geometry. This serves as the measurement fingerprint
761 /// (`measure_key`) without having to flatten the tree.
762 pub fn measure_hash<H: std::hash::Hasher>(&self, hasher: &mut H) {
763 use std::hash::Hash;
764 self.text.hash(hasher);
765 self.overrides.size.map(f32::to_bits).hash(hasher);
766 self.overrides.weight.map(FontWeight::to_u16).hash(hasher);
767 self.overrides.italic.hash(hasher);
768 self.children.len().hash(hasher);
769 for child in &self.children {
770 child.measure_hash(hasher);
771 }
772 }
773
774 fn collect(&self, inherited: TextStyle, out: &mut Vec<(String, ResolvedTextStyle)>) {
775 let effective = inherited.merge(self.overrides);
776 if !self.text.is_empty() {
777 out.push((self.text.clone(), effective.resolved()));
778 }
779 // The children inherit the **unresolved** style, not the run's numbers. Handing
780 // them the resolved one would turn every framework default into an answer, and a
781 // child that wanted to inherit a weight nobody had named would find one waiting.
782 for child in &self.children {
783 child.collect(effective, out);
784 }
785 }
786}
787
788/// A text run **ready to render**: a fragment plus its typographic attributes and
789/// colour, all **resolved** — nothing left to inherit.
790#[derive(Clone, Debug, PartialEq)]
791pub struct TextRun {
792 /// The run's text.
793 pub text: String,
794 /// The font size, in logical pixels.
795 pub size: f32,
796 /// The font weight.
797 pub weight: FontWeight,
798 /// Whether the run is italic.
799 pub italic: bool,
800 /// The run's colour.
801 pub color: Color,
802 /// The run's decoration lines (no effect on measurement).
803 pub decoration: TextDecoration,
804 /// Decoration colour; `None` means the run's own colour.
805 pub decoration_color: Option<Color>,
806 /// The room added after every character, in logical pixels; `0` for none.
807 pub letter_spacing: f32,
808}
809
810#[cfg(test)]
811mod tests {
812 use super::*;
813
814 /// **Letter spacing cascades, travels, and is not grown by the reader's setting**
815 /// (milestone 623): a style that says nothing inherits it, two that say something
816 /// meet halfway, and a larger text scale grows the size and leaves the spacing as
817 /// it was written.
818 #[test]
819 fn letter_spacing_cascades_travels_and_stays_written() {
820 let spaced = TextStyle::new(14.0).letter_spacing(0.5);
821 assert_eq!(spaced.merge(TextStyle::new(20.0)).letter_spacing, Some(0.5));
822 assert_eq!(
823 spaced
824 .merge(TextStyle::NONE.letter_spacing(-1.0))
825 .letter_spacing,
826 Some(-1.0)
827 );
828 let half = spaced.lerp(TextStyle::new(14.0).letter_spacing(1.5), 0.5);
829 assert_eq!(half.letter_spacing, Some(1.0));
830 assert_eq!(
831 TextStyle::new(14.0).resolved().letter_spacing,
832 0.0,
833 "none by default"
834 );
835 let scaled = with_text_scale(2.0, || spaced.resolved());
836 assert_eq!(scaled.size, 28.0, "the size grows");
837 assert_eq!(scaled.letter_spacing, 0.5, "the spacing does not");
838 }
839
840 #[test]
841 fn weights_map_to_opentype() {
842 assert_eq!(FontWeight::Regular.to_u16(), 400);
843 assert_eq!(FontWeight::Bold.to_u16(), 700);
844 }
845
846 #[test]
847 fn builders_compose() {
848 let s = TextStyle::new(20.0).weight(FontWeight::Bold).italic();
849 assert_eq!(s.size, Some(20.0));
850 assert_eq!(s.weight, Some(FontWeight::Bold));
851 assert_eq!(s.italic, Some(true));
852 assert_eq!(s.color, None);
853 }
854
855 /// **A style can name one thing and leave the rest open**, which is the whole reason
856 /// the fields are optional.
857 ///
858 /// `TextStyle::new(20.0)` used to answer a size, a weight *and* a slant, because the
859 /// type had nowhere to put "unset" — so *size 20, inherit the weight* was not
860 /// expressible, however the caller wrote it. The reference writes it
861 /// `TextStyle(fontSize: 20)`, and now so do we.
862 #[test]
863 fn a_style_may_name_one_thing_and_leave_the_rest_open() {
864 let only_size = TextStyle::new(20.0);
865 assert_eq!(only_size.size, Some(20.0));
866 assert_eq!(only_size.weight, None, "and says nothing about the weight");
867
868 let inherited = TextStyle::NONE.weight(FontWeight::Bold).merge(only_size);
869 assert_eq!(inherited.size, Some(20.0), "the size it named");
870 assert_eq!(
871 inherited.weight,
872 Some(FontWeight::Bold),
873 "the weight it did not"
874 );
875 }
876
877 /// The chain **stops** somewhere: a shaper needs a number and there is nobody left to
878 /// ask. Everything unanswered lands on the framework's own default.
879 #[test]
880 fn a_style_that_says_nothing_resolves_to_the_frameworks_own() {
881 let r = TextStyle::NONE.resolved();
882 assert_eq!(r.size, DEFAULT_TEXT_SIZE);
883 assert_eq!(r.weight, FontWeight::Regular);
884 assert!(!r.italic);
885 assert_eq!(r.decoration, TextDecoration::NONE);
886 // The colour is the one thing left open, its last word belonging to a theme this
887 // type cannot see.
888 assert_eq!(r.color, None);
889 }
890
891 /// The reader's font size lands on **the one place a size becomes a number**.
892 #[test]
893 fn the_readers_font_size_reaches_a_resolved_style() {
894 assert_eq!(
895 TextStyle::new(10.0).resolved().size,
896 10.0,
897 "unscaled by default"
898 );
899 with_text_scale(1.5, || {
900 assert_eq!(TextStyle::new(10.0).resolved().size, 15.0);
901 // A style that named no size scales the framework's own, not nothing.
902 assert_eq!(
903 TextStyle::NONE.resolved().size,
904 DEFAULT_TEXT_SIZE * 1.5,
905 "the default is a size like any other"
906 );
907 });
908 assert_eq!(
909 TextStyle::new(10.0).resolved().size,
910 10.0,
911 "and the scope put it back"
912 );
913 }
914
915 /// Nothing but the size moves. A reader asking for larger text has not asked for a
916 /// different typeface, and scaling a weight or a slant is not a thing to do.
917 #[test]
918 fn only_the_size_is_scaled() {
919 with_text_scale(2.0, || {
920 let r = TextStyle::new(10.0)
921 .weight(FontWeight::Bold)
922 .italic()
923 .resolved();
924 assert_eq!(r.size, 20.0);
925 assert_eq!(r.weight, FontWeight::Bold);
926 assert!(r.italic);
927 });
928 }
929
930 /// A scale of zero is **ignored**, not obeyed. A font of no size is a screen with no
931 /// words on it, and a platform reporting one is a platform to disbelieve rather than a
932 /// user to accommodate.
933 #[test]
934 fn a_scale_of_nothing_is_disbelieved() {
935 with_text_scale(0.0, || {
936 assert_eq!(TextStyle::new(10.0).resolved().size, 10.0);
937 });
938 with_text_scale(-2.0, || {
939 assert_eq!(TextStyle::new(10.0).resolved().size, 10.0);
940 });
941 }
942
943 #[test]
944 fn merge_overrides_type_but_inherits_missing_colour() {
945 let base = TextStyle::new(16.0).color(Color::WHITE);
946 // `over` changes size and weight but specifies no colour.
947 let over = TextStyle::new(24.0).weight(FontWeight::Bold);
948 let merged = base.merge(over);
949 assert_eq!(merged.size, Some(24.0));
950 assert_eq!(merged.weight, Some(FontWeight::Bold));
951 assert_eq!(merged.color, Some(Color::WHITE), "colour inherited");
952
953 // When `over` does specify a colour, it wins.
954 let over2 = TextStyle::new(24.0).color(Color::BLACK);
955 assert_eq!(base.merge(over2).color, Some(Color::BLACK));
956 }
957
958 #[test]
959 fn span_children_inherit_unspecified_attributes() {
960 // "Hello **bold** _red italic_": the bold part inherits size and colour, the
961 // red italic inherits size and weight.
962 let span = TextSpan::new("Hello ")
963 .child(TextSpan::new("bold").bold())
964 .child(
965 TextSpan::new("red italic")
966 .italic()
967 .color(Color::rgb(1.0, 0.0, 0.0)),
968 );
969 let base = TextStyle::new(20.0).color(Color::WHITE);
970 let runs = span.flatten(base);
971
972 assert_eq!(runs.len(), 3);
973 let (t0, s0) = &runs[0];
974 assert_eq!(
975 (t0.as_str(), s0.size, s0.weight),
976 ("Hello ", 20.0, FontWeight::Regular)
977 );
978 let (t1, s1) = &runs[1];
979 assert_eq!((t1.as_str(), s1.weight), ("bold", FontWeight::Bold));
980 assert_eq!(s1.size, 20.0, "bold inherits the size");
981 assert_eq!(s1.color, Some(Color::WHITE), "bold inherits the colour");
982 let (t2, s2) = &runs[2];
983 assert_eq!(t2, "red italic");
984 assert!(s2.italic);
985 assert_eq!(s2.weight, FontWeight::Regular, "italic inherits the weight");
986 assert_eq!(s2.color, Some(Color::rgb(1.0, 0.0, 0.0)));
987 }
988
989 #[test]
990 fn nested_spans_cascade_depth_first() {
991 // A bold subtree in which a grandchild puts the size back to 12.
992 let span = TextSpan::new("a").child(
993 TextSpan::new("b")
994 .bold()
995 .child(TextSpan::new("c").size(12.0)),
996 );
997 let runs = span.flatten(TextStyle::new(16.0));
998 assert_eq!(runs.len(), 3);
999 assert_eq!(runs[2].0, "c");
1000 assert_eq!(runs[2].1.size, 12.0);
1001 assert_eq!(
1002 runs[2].1.weight,
1003 FontWeight::Bold,
1004 "inherits bold from the parent"
1005 );
1006 }
1007
1008 #[test]
1009 fn decorations_combine_and_cascade() {
1010 // Combining: underline plus strikethrough on the same style.
1011 let s = TextStyle::new(16.0).underline().strikethrough().resolved();
1012 assert!(s.decoration.underline && s.decoration.strikethrough);
1013 assert!(!s.decoration.overline);
1014 assert!(!TextDecoration::UNDERLINE.is_none());
1015 assert!(TextDecoration::NONE.is_none());
1016
1017 // Cascade: the child inherits the parent's decoration when it specifies
1018 // nothing, and an explicit `decoration(NONE)` cancels it (Some(NONE) is not
1019 // the same as absent).
1020 let span = TextSpan::new("a")
1021 .underline()
1022 .decoration_color(Color::rgb(1.0, 0.0, 0.0))
1023 .child(TextSpan::new("b"))
1024 .child(TextSpan::new("c").decoration(TextDecoration::NONE));
1025 let runs = span.flatten(TextStyle::new(16.0));
1026 assert!(runs[0].1.decoration.underline);
1027 assert!(
1028 runs[1].1.decoration.underline,
1029 "the child inherits the underline"
1030 );
1031 assert_eq!(
1032 runs[1].1.decoration_color,
1033 Some(Color::rgb(1.0, 0.0, 0.0)),
1034 "the decoration colour cascades"
1035 );
1036 assert!(runs[2].1.decoration.is_none(), "explicitly cancelled");
1037
1038 // merge: decoration is a typographic attribute, so `over`'s wins, while its
1039 // colour inherits the way the text colour does.
1040 let base = TextStyle::new(16.0)
1041 .underline()
1042 .decoration_color(Color::BLACK);
1043 let merged = base.merge(TextStyle::new(20.0).strikethrough()).resolved();
1044 assert!(merged.decoration.strikethrough && !merged.decoration.underline);
1045 assert_eq!(merged.decoration_color, Some(Color::BLACK));
1046
1047 // And a style that names **no** decoration leaves the parent's alone, where it
1048 // used to erase it: `over.decoration` was a value the type could not withhold.
1049 let quiet = base.merge(TextStyle::new(20.0)).resolved();
1050 assert!(quiet.decoration.underline, "the underline survived");
1051 }
1052
1053 #[test]
1054 fn empty_nodes_produce_no_run() {
1055 // A "group" node with no text of its own only exists to style its children.
1056 let span = TextSpan::new("").bold().child(TextSpan::new("x"));
1057 let runs = span.flatten(TextStyle::new(16.0));
1058 assert_eq!(runs.len(), 1);
1059 assert_eq!(runs[0].1.weight, FontWeight::Bold);
1060 }
1061
1062 /// A style that says nothing about its line height gets the framework's default, and
1063 /// one that names a ratio gets that ratio **of its own size**.
1064 #[test]
1065 fn a_line_height_is_a_ratio_of_the_size_that_asked_for_it() {
1066 assert_eq!(TextStyle::new(20.0).resolved().line_height(), 24.0);
1067 let open = TextStyle {
1068 height: Some(1.5),
1069 ..TextStyle::new(20.0)
1070 };
1071 assert_eq!(open.resolved().line_height(), 30.0);
1072 let packed = TextStyle {
1073 height: Some(1.0),
1074 ..TextStyle::new(20.0)
1075 };
1076 assert_eq!(packed.resolved().line_height(), 20.0);
1077 }
1078
1079 /// **The leading grows with the letters.** A ratio rather than a length is what makes
1080 /// a paragraph keep its rhythm when the reader turns the type up: a `height` of 1.5 is
1081 /// 30 px at a size of 20 and 60 px when that 20 has been doubled for a reader who
1082 /// asked for larger text. A length would have stayed at 30 and closed the paragraph up
1083 /// exactly when it needed opening.
1084 #[test]
1085 fn the_leading_grows_with_the_reader() {
1086 let style = TextStyle {
1087 height: Some(1.5),
1088 ..TextStyle::new(20.0)
1089 };
1090 assert_eq!(style.resolved().line_height(), 30.0);
1091 with_text_scale(2.0, || {
1092 let resolved = style.resolved();
1093 assert_eq!(resolved.size, 40.0);
1094 assert_eq!(resolved.line_height(), 60.0);
1095 });
1096 }
1097
1098 /// It inherits like every other field, and a nearer style still wins.
1099 #[test]
1100 fn a_line_height_is_inherited_and_overridable() {
1101 let handed_down = TextStyle {
1102 height: Some(1.8),
1103 ..TextStyle::NONE
1104 };
1105 let asks_only_a_size = TextStyle::new(10.0);
1106 assert_eq!(
1107 handed_down.merge(asks_only_a_size).resolved().line_height(),
1108 18.0,
1109 "the size is the near style's and the height the inherited one"
1110 );
1111 let asks_for_both = TextStyle {
1112 height: Some(1.0),
1113 ..TextStyle::new(10.0)
1114 };
1115 assert_eq!(
1116 handed_down.merge(asks_for_both).resolved().line_height(),
1117 10.0
1118 );
1119 }
1120
1121 // --- Interpolation (milestone 498) ---
1122
1123 /// **A size travels.** The plain case, and the only field of a style for which
1124 /// "halfway between" is an obvious answer.
1125 #[test]
1126 fn a_size_travels() {
1127 let a = TextStyle::NONE.size(12.0);
1128 let b = TextStyle::NONE.size(24.0);
1129 assert_eq!(a.lerp(b, 0.0).size, Some(12.0));
1130 assert_eq!(a.lerp(b, 0.5).size, Some(18.0));
1131 assert_eq!(a.lerp(b, 1.0).size, Some(24.0));
1132 }
1133
1134 /// **A field only one end states does not travel** — it holds the one value it has,
1135 /// from the first frame to the last.
1136 ///
1137 /// `None` is not nought and not transparent: it means *this style does not say*, and
1138 /// the question passes further up the cascade. There is no number between "24 pixels"
1139 /// and "ask somebody else", and interpolating towards whatever the theme happens to
1140 /// answer would make the movement depend on the theme, which neither end asked for.
1141 #[test]
1142 fn a_field_only_one_end_states_holds_still() {
1143 let unset = TextStyle::NONE;
1144 let stated = TextStyle::NONE.size(24.0);
1145 for t in [0.0, 0.25, 0.5, 0.75, 1.0] {
1146 assert_eq!(unset.lerp(stated, t).size, Some(24.0), "arriving, at {t}");
1147 assert_eq!(stated.lerp(unset, t).size, Some(24.0), "leaving, at {t}");
1148 }
1149 assert_eq!(
1150 unset.lerp(unset, 0.5).size,
1151 None,
1152 "and unset at both ends stays so"
1153 );
1154 }
1155
1156 /// **An unset colour does not fade out**, which is where this diverges from the
1157 /// reference on purpose.
1158 ///
1159 /// There, a null colour means *nothing was painted*, so fading towards it is right.
1160 /// Here it means *the widget resolves it against the theme* — `on_surface`, a
1161 /// perfectly visible colour — so fading it out would make a run of words disappear
1162 /// halfway through a movement that was only ever about its size.
1163 #[test]
1164 fn an_unset_colour_does_not_fade_out() {
1165 let red = Color::rgb(1.0, 0.0, 0.0);
1166 let stated = TextStyle::NONE.color(red);
1167 let mid = TextStyle::NONE.lerp(stated, 0.5).color.expect("a colour");
1168 assert_eq!(mid, red, "held whole, not at half alpha");
1169 assert_eq!(TextStyle::NONE.lerp(TextStyle::NONE, 0.5).color, None);
1170 }
1171
1172 /// **A colour stated at both ends travels**, on the same path an animated background
1173 /// takes, so two things changing colour together stay together.
1174 #[test]
1175 fn a_colour_stated_at_both_ends_travels() {
1176 let a = TextStyle::NONE.color(Color::rgb(0.0, 0.0, 0.0));
1177 let b = TextStyle::NONE.color(Color::rgb(1.0, 1.0, 1.0));
1178 let mid = a.lerp(b, 0.5).color.expect("a colour");
1179 assert_eq!(
1180 mid,
1181 Color::rgb(0.0, 0.0, 0.0).lerp(Color::rgb(1.0, 1.0, 1.0), 0.5)
1182 );
1183 }
1184
1185 /// **Faces and lines swap at the halfway point.** There is no half-italic face and no
1186 /// two-thirds of an underline, so the change happens once, in the middle.
1187 #[test]
1188 fn faces_and_lines_swap_in_the_middle() {
1189 let upright = TextStyle {
1190 italic: Some(false),
1191 ..TextStyle::NONE.decoration(TextDecoration::NONE)
1192 };
1193 let slanted = TextStyle::NONE
1194 .italic()
1195 .decoration(TextDecoration::UNDERLINE);
1196 assert_eq!(upright.lerp(slanted, 0.49).italic, Some(false));
1197 assert_eq!(upright.lerp(slanted, 0.51).italic, Some(true));
1198 assert_eq!(
1199 upright.lerp(slanted, 0.49).decoration,
1200 Some(TextDecoration::NONE)
1201 );
1202 assert_eq!(
1203 upright.lerp(slanted, 0.51).decoration,
1204 Some(TextDecoration::UNDERLINE)
1205 );
1206 }
1207
1208 /// **A weight steps through the faces it has.** Interpolating the number would ask
1209 /// for a 437 no font can draw, so what moves is the choice of face: regular to bold
1210 /// passes through semi-bold in the middle rather than gliding.
1211 #[test]
1212 fn a_weight_steps_through_the_faces() {
1213 use FontWeight::{Bold, Medium, Regular, SemiBold};
1214 assert_eq!(Regular.lerp(Bold, 0.0), Regular);
1215 assert_eq!(Regular.lerp(Bold, 0.5), SemiBold, "550 is nearest 600");
1216 assert_eq!(Regular.lerp(Bold, 1.0), Bold);
1217 assert_eq!(Regular.lerp(Bold, 0.2), Medium, "460 is nearest 500");
1218 assert_eq!(Bold.lerp(Regular, 0.5), SemiBold, "and the same going back");
1219 }
1220}