Skip to main content

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}
252
253/// A [`TextStyle`] with every question answered: what to measure with, and what to draw.
254///
255/// The colour stays optional, and deliberately. Size, weight and slant have a *framework*
256/// default that is right everywhere; a colour's last word belongs to the theme, which this
257/// type cannot see. A widget resolves it at paint against `on_surface`.
258#[derive(Clone, Copy, Debug, PartialEq)]
259pub struct ResolvedTextStyle {
260    /// Font size, in logical pixels.
261    pub size: f32,
262    /// Weight.
263    pub weight: FontWeight,
264    /// Italic.
265    pub italic: bool,
266    /// Explicit colour; `None` means the widget resolves it against the theme.
267    pub color: Option<Color>,
268    /// Decoration lines.
269    pub decoration: TextDecoration,
270    /// Decoration colour; `None` means the text's own colour.
271    pub decoration_color: Option<Color>,
272    /// The line's height as a **multiple of the font size**; `None` means
273    /// [`DEFAULT_LINE_HEIGHT`]. Read it through [`line_height`](Self::line_height).
274    pub height: Option<f32>,
275    /// The font family; `None` means the application's default. See
276    /// [`TextStyle::family`] for why it does not always win.
277    pub family: Option<FontFamily>,
278}
279
280impl ResolvedTextStyle {
281    /// The height of one line, in logical pixels.
282    ///
283    /// **The one place this number is decided.** It used to be a `LINE_HEIGHT_FACTOR`
284    /// constant in `frus-text` and *another* in `frus-gpu`, which is two constants that
285    /// had to agree: a measure and a paint disagreeing about how tall a line is puts the
286    /// second line of every paragraph somewhere the layout did not reserve.
287    pub fn line_height(&self) -> f32 {
288        self.size * self.height.unwrap_or(DEFAULT_LINE_HEIGHT)
289    }
290    /// A style of **exactly** this size — regular, upright, undecorated, and *not* put
291    /// through the reader's font setting.
292    ///
293    /// For glyphs whose size is geometry rather than type: an icon on its 24 px grid, a
294    /// marker that has to stay inside a circle drawn beside it. The reference's `Icon` is
295    /// sized by `IconTheme` and ignores the text scaler for the same reason — an icon that
296    /// grew with the type would leave its own box.
297    ///
298    /// Everything a reader *reads* wants
299    /// [`TextStyle::new(size).resolved()`](TextStyle::resolved) instead. The two are one
300    /// keyword apart on purpose: the choice is worth stating at every call, which is why
301    /// there is no third form that guesses.
302    pub const fn exact(size: f32) -> Self {
303        Self {
304            size,
305            weight: FontWeight::Regular,
306            italic: false,
307            color: None,
308            decoration: TextDecoration::NONE,
309            decoration_color: None,
310            height: None,
311            family: None,
312        }
313    }
314}
315
316impl TextStyle {
317    /// A style that says **nothing at all** — every field inherited.
318    pub const NONE: Self = Self {
319        size: None,
320        weight: None,
321        italic: None,
322        color: None,
323        decoration: None,
324        decoration_color: None,
325        height: None,
326        family: None,
327    };
328
329    /// A style that names a `size` and nothing else.
330    pub const fn new(size: f32) -> Self {
331        Self {
332            size: Some(size),
333            ..Self::NONE
334        }
335    }
336
337    /// Sets the weight.
338    pub const fn weight(mut self, weight: FontWeight) -> Self {
339        self.weight = Some(weight);
340        self
341    }
342
343    /// Switches to italic.
344    pub const fn italic(mut self) -> Self {
345        self.italic = Some(true);
346        self
347    }
348
349    /// Sets the size.
350    pub const fn size(mut self, size: f32) -> Self {
351        self.size = Some(size);
352        self
353    }
354
355    /// Sets the colour.
356    pub const fn color(mut self, color: Color) -> Self {
357        self.color = Some(color);
358        self
359    }
360
361    /// Sets the decoration lines.
362    pub const fn decoration(mut self, decoration: TextDecoration) -> Self {
363        self.decoration = Some(decoration);
364        self
365    }
366
367    /// Adds an underline, combining with any other decoration **this style** already
368    /// carries — never with an inherited one, which this style cannot see.
369    pub const fn underline(mut self) -> Self {
370        let current = match self.decoration {
371            Some(d) => d,
372            None => TextDecoration::NONE,
373        };
374        self.decoration = Some(current.combine(TextDecoration::UNDERLINE));
375        self
376    }
377
378    /// Adds a strikethrough, combining with any other decoration this style carries.
379    pub const fn strikethrough(mut self) -> Self {
380        let current = match self.decoration {
381            Some(d) => d,
382            None => TextDecoration::NONE,
383        };
384        self.decoration = Some(current.combine(TextDecoration::STRIKETHROUGH));
385        self
386    }
387
388    /// Sets the decoration colour; otherwise the text's colour is used.
389    pub const fn decoration_color(mut self, color: Color) -> Self {
390        self.decoration_color = Some(color);
391        self
392    }
393
394    /// Sets the line's height, as a **multiple of the font size** — `2.0` is double
395    /// leading, not two pixels. See the [field](Self::height) for why it is a ratio.
396    pub const fn height(mut self, height: f32) -> Self {
397        self.height = Some(height);
398        self
399    }
400
401    /// Names the font family. See the [field](Self::family) for the one case where a
402    /// named family does not get the last word.
403    pub const fn family(mut self, family: FontFamily) -> Self {
404        self.family = Some(family);
405        self
406    }
407
408    /// **Merges** `over` on top of `self`, **field by field**: where `over` said nothing,
409    /// this one's answer survives.
410    ///
411    /// The cascade, and it is one operation now rather than three. Until every field could
412    /// say "unset" this codebase carried the same idea three times — a private `Overrides`
413    /// struct for rich-text spans, a `Chosen` record of booleans beside a `Text`'s style,
414    /// and half of the widgets' `DefaultTextStyle` — because a whole-style merge could only
415    /// replace the typography wholesale and each caller needed the other behaviour.
416    #[must_use]
417    pub fn merge(self, over: TextStyle) -> TextStyle {
418        TextStyle {
419            size: over.size.or(self.size),
420            weight: over.weight.or(self.weight),
421            italic: over.italic.or(self.italic),
422            color: over.color.or(self.color),
423            decoration: over.decoration.or(self.decoration),
424            decoration_color: over.decoration_color.or(self.decoration_color),
425            height: over.height.or(self.height),
426            family: over.family.or(self.family),
427        }
428    }
429
430    /// The style `t` of the way from `self` to `other` — what an animated default text
431    /// style hands its subtree mid-flight.
432    ///
433    /// Three rules, one per kind of field, and the kinds are not a taxonomy — each is a
434    /// different answer to *what is halfway between these two*:
435    ///
436    /// - **Sizes and ratios travel** (`size`, `height`): a number between the two.
437    /// - **A field set at only one end does not travel.** It holds the one value it has,
438    ///   from the first frame to the last. `None` here means *unset* — a question passed
439    ///   further up the cascade — and there is no number between "18 pixels" and "ask
440    ///   somebody else". Interpolating towards a value nobody has stated would be
441    ///   interpolating towards whatever the theme happens to say, which changes what the
442    ///   movement looks like when the theme changes and is not what either end asked for.
443    ///   The reference lands on the same rule from the other end, taking the stated value
444    ///   for *both* of its own, and it is the rule an animated pin follows for the same
445    ///   reason: **a movement needs both ends to say where they are.**
446    /// - **Faces and lines swap at the halfway point** (`italic`, `decoration`, `family`):
447    ///   there is no half-italic face and no two-thirds of an underline, so the change
448    ///   happens once, in the middle, where it is least noticeable. The reference's rule
449    ///   again, and `weight` is the near miss — see [`FontWeight::lerp`], which steps
450    ///   through the four faces rather than swapping once.
451    ///
452    /// **The colours diverge from the reference deliberately.** It fades an unset colour
453    /// to transparent, because there `null` means *no colour was painted*. Here `None`
454    /// means *the widget resolves it against the theme at paint* — `on_surface`, a
455    /// perfectly visible colour — so fading it out would make a run of words vanish
456    /// halfway through a movement that was only ever about its size. An unset colour
457    /// therefore follows the rule above: it does not travel.
458    #[must_use]
459    pub fn lerp(self, other: TextStyle, t: f32) -> TextStyle {
460        let t = t.clamp(0.0, 1.0);
461        /// A number, where both ends stated one. Where only one did, that one — held.
462        fn number(a: Option<f32>, b: Option<f32>, t: f32) -> Option<f32> {
463            match (a, b) {
464                (Some(a), Some(b)) => Some(a + (b - a) * t),
465                (a, b) => a.or(b),
466            }
467        }
468        /// The same rule for a colour, and for the same reason.
469        fn color(a: Option<Color>, b: Option<Color>, t: f32) -> Option<Color> {
470            match (a, b) {
471                (Some(a), Some(b)) => Some(a.lerp(b, t)),
472                (a, b) => a.or(b),
473            }
474        }
475        /// A choice, not a quantity: the near side until halfway, the far side after.
476        fn swap<T>(a: Option<T>, b: Option<T>, t: f32) -> Option<T> {
477            if t < 0.5 {
478                a
479            } else {
480                b
481            }
482        }
483        TextStyle {
484            size: number(self.size, other.size, t),
485            weight: match (self.weight, other.weight) {
486                (Some(a), Some(b)) => Some(a.lerp(b, t)),
487                (a, b) => a.or(b),
488            },
489            italic: swap(self.italic, other.italic, t),
490            color: color(self.color, other.color, t),
491            decoration: swap(self.decoration, other.decoration, t),
492            decoration_color: color(self.decoration_color, other.decoration_color, t),
493            height: number(self.height, other.height, t),
494            family: swap(self.family, other.family, t),
495        }
496    }
497
498    /// Every question answered: the framework's own default wherever nothing in the chain
499    /// said anything, and **the reader's font size applied**.
500    ///
501    /// This is where the chain **stops**. Above it a style may leave a field open for a
502    /// subtree or a theme to answer; below it a shaper needs a number, and there is nobody
503    /// left to ask.
504    ///
505    /// Which is exactly why the user's *Font size* setting is applied here and nowhere
506    /// else. A phone's slider goes to 1.3 on Android and past 3 with iOS's larger
507    /// accessibility sizes, and an interface that ignores it is one a lot of people cannot
508    /// read. Scaling it at the one place a size becomes a number is what keeps the
509    /// **measurement and the paint agreeing**: text measured at one size and drawn at
510    /// another is a layout that is wrong everywhere at once, with nothing in the picture to
511    /// say which of the two numbers was the mistake.
512    ///
513    /// See [`with_text_scale`]. Outside any scope the scale is 1 and this is the identity.
514    /// Caps how far the **reader's font setting** may enlarge this style.
515    ///
516    /// The size is declared smaller in exact proportion, so that resolving it lands at
517    /// `max_scale` times the size asked for and no more. Below `max_scale` nothing changes.
518    ///
519    /// This is the reference's second answer to a reader who turns the system type up, and
520    /// it belongs to **chrome**. A component grows: its default height is a floor and the
521    /// content wins. A toolbar cannot — it would push the whole screen down — so the
522    /// reference keeps its bar at `kToolbarHeight` and clamps the title's scaler to 1.34
523    /// instead, "to keep the visual hierarchy the same even with larger font sizes".
524    ///
525    /// It reads the ambient scale, so it must be called where that scale is in force —
526    /// which for a widget means while it is being built, the same place it reads a theme.
527    #[must_use]
528    pub fn clamp_scale(mut self, max_scale: f32) -> Self {
529        let scale = text_scale();
530        if scale > max_scale && scale > 0.0 {
531            if let Some(size) = self.size {
532                self.size = Some(size * max_scale / scale);
533            }
534        }
535        self
536    }
537
538    /// Fills every unset field with its default, applying the ambient text scale to
539    /// the size on the way.
540    pub fn resolved(self) -> ResolvedTextStyle {
541        ResolvedTextStyle {
542            size: self.size.unwrap_or(DEFAULT_TEXT_SIZE) * text_scale(),
543            weight: self.weight.unwrap_or(FontWeight::Regular),
544            italic: self.italic.unwrap_or(false),
545            color: self.color,
546            decoration: self.decoration.unwrap_or(TextDecoration::NONE),
547            decoration_color: self.decoration_color,
548            height: self.height,
549            family: self.family,
550        }
551    }
552}
553
554/// The reader's font-size setting in force on this thread, or `1.0` outside any
555/// [`with_text_scale`].
556///
557/// Read once, by [`TextStyle::resolved`]. Nothing else should consult it: a second reader
558/// is a second chance to scale a size twice, or to scale one a sibling did not.
559pub fn text_scale() -> f32 {
560    TEXT_SCALE.with(|s| s.get())
561}
562
563/// Runs `f` with the reader's font-size setting in force, restoring whatever was there
564/// before — including while a panic unwinds, so one bad frame cannot leave a stale scale
565/// installed for every frame after it.
566///
567/// **Ambient rather than threaded**, and that is the decision worth stating. Passing a
568/// scaler down would mean every widget that measures a label remembering to apply it, and
569/// the one that forgot would draw text the layout never measured. There is no diagnostic
570/// for that — only a screen that is subtly wrong. Ambient makes forgetting impossible: the
571/// only place a size becomes a number already reads it.
572///
573/// The framework installs this around `view` from `MediaQuery::text_scaler`; an
574/// application does not normally call it. Scales at or below zero are ignored, a font of
575/// no size being a screen with no words on it.
576///
577/// **A closure is not always a long enough life.** A size becomes a number during layout
578/// and again during paint, which happen after the widgets have been built — see
579/// [`install_text_scale`], which the shell uses to cover a whole frame.
580pub fn with_text_scale<R>(scale: f32, f: impl FnOnce() -> R) -> R {
581    let guard = install_text_scale(scale);
582    let out = f();
583    drop(guard);
584    out
585}
586
587/// Installs the reader's font-size setting **until the returned guard is dropped**.
588///
589/// [`with_text_scale`] covers a closure, which is right for a subtree and wrong for a
590/// frame: a widget is *built* first, then measured, laid out and painted, and every one of
591/// those later steps resolves sizes too. Scoping only the build leaves the scale at 1 for
592/// the two steps that decide how big the text actually is — so the layout measures one
593/// size and the renderer draws another, which is the exact failure
594/// [`TextStyle::resolved`](TextStyle::resolved) exists to make impossible.
595///
596/// That is not a hypothetical either: the shell wrapped `view` alone, and the setting
597/// reached a device without changing a single pixel (milestone 407).
598///
599/// Scales at or below zero are ignored, as in [`with_text_scale`].
600#[must_use = "the scale is restored the moment the guard is dropped"]
601pub fn install_text_scale(scale: f32) -> TextScaleGuard {
602    let scale = if scale > 0.0 { scale } else { 1.0 };
603    TextScaleGuard(TEXT_SCALE.with(|s| s.replace(scale)))
604}
605
606/// Puts back the scale that was in force before [`install_text_scale`], when dropped —
607/// including while a panic unwinds, so one bad frame cannot leave a stale scale installed
608/// for every frame after it.
609pub struct TextScaleGuard(f32);
610
611impl Drop for TextScaleGuard {
612    fn drop(&mut self) {
613        TEXT_SCALE.with(|s| s.set(self.0));
614    }
615}
616
617thread_local! {
618    /// The reader's font-size setting on this thread. `Cell`, not `RefCell`: an `f32` is
619    /// `Copy` and every access is a whole get or a whole set.
620    static TEXT_SCALE: std::cell::Cell<f32> = const { std::cell::Cell::new(1.0) };
621}
622
623/// A **rich-text tree**: each node carries a fragment of text, *partial* style
624/// overrides — whatever is unspecified **inherits** from the parent, so a
625/// `.bold()` child keeps its parent's size — and children of its own.
626///
627/// ```
628/// # use frus_core::{TextSpan, TextStyle};
629/// let span = TextSpan::new("Hello ")
630///     .child(TextSpan::new("bold").bold())
631///     .child(TextSpan::new(" world"));
632/// let runs = span.flatten(TextStyle::new(20.0));
633/// assert_eq!(runs.len(), 3);
634/// assert_eq!(runs[1].1.size, 20.0); // the bold child inherits the size
635/// ```
636#[derive(Clone, Debug, Default, PartialEq)]
637pub struct TextSpan {
638    text: String,
639    overrides: TextStyle,
640    children: Vec<TextSpan>,
641}
642
643impl TextSpan {
644    /// A text fragment with no overrides — it inherits everything.
645    pub fn new(text: impl Into<String>) -> Self {
646        Self {
647            text: text.into(),
648            overrides: TextStyle::NONE,
649            children: Vec::new(),
650        }
651    }
652
653    /// Makes this subtree bold.
654    pub fn bold(mut self) -> Self {
655        self.overrides.weight = Some(FontWeight::Bold);
656        self
657    }
658
659    /// Sets this subtree's weight.
660    pub fn weight(mut self, weight: FontWeight) -> Self {
661        self.overrides.weight = Some(weight);
662        self
663    }
664
665    /// Makes this subtree italic.
666    pub fn italic(mut self) -> Self {
667        self.overrides.italic = Some(true);
668        self
669    }
670
671    /// Sets this subtree's size.
672    pub fn size(mut self, size: f32) -> Self {
673        self.overrides.size = Some(size);
674        self
675    }
676
677    /// Sets this subtree's colour.
678    pub fn color(mut self, color: Color) -> Self {
679        self.overrides.color = Some(color);
680        self
681    }
682
683    /// Underlines this subtree. It combines with the decoration already set on
684    /// this node, but not with the parent's.
685    pub fn underline(mut self) -> Self {
686        self.overrides = self.overrides.underline();
687        self
688    }
689
690    /// Strikes through this subtree.
691    pub fn strikethrough(mut self) -> Self {
692        self.overrides = self.overrides.strikethrough();
693        self
694    }
695
696    /// Sets this subtree's decoration lines.
697    pub fn decoration(mut self, decoration: TextDecoration) -> Self {
698        self.overrides.decoration = Some(decoration);
699        self
700    }
701
702    /// Sets this subtree's decoration colour.
703    pub fn decoration_color(mut self, color: Color) -> Self {
704        self.overrides.decoration_color = Some(color);
705        self
706    }
707
708    /// Applies a whole [`TextStyle`] as this subtree's override, **replacing** whatever
709    /// this span had said before.
710    ///
711    /// It used to force every typographic field to *answered*, because a `TextStyle` could
712    /// not say otherwise. It no longer has to: a style naming only a size now overrides
713    /// only the size, and the weight goes on being inherited from the parent span.
714    pub fn style(mut self, style: TextStyle) -> Self {
715        self.overrides = style;
716        self
717    }
718
719    /// Adds a child, rendered after this node's own text.
720    pub fn child(mut self, child: TextSpan) -> Self {
721        self.children.push(child);
722        self
723    }
724
725    /// Flattens the tree into **resolved runs** `(text, style)`, in reading order,
726    /// cascading the overrides down from `base`, the paragraph's default style.
727    /// Nodes with no text of their own produce no empty run.
728    pub fn flatten(&self, base: TextStyle) -> Vec<(String, ResolvedTextStyle)> {
729        let mut runs = Vec::new();
730        self.collect(base, &mut runs);
731        runs
732    }
733
734    /// Mixes into `hasher` everything that affects the tree's **measurement**:
735    /// text, sizes, weights and italics. Colours and **decorations** are excluded,
736    /// having no effect on geometry. This serves as the measurement fingerprint
737    /// (`measure_key`) without having to flatten the tree.
738    pub fn measure_hash<H: std::hash::Hasher>(&self, hasher: &mut H) {
739        use std::hash::Hash;
740        self.text.hash(hasher);
741        self.overrides.size.map(f32::to_bits).hash(hasher);
742        self.overrides.weight.map(FontWeight::to_u16).hash(hasher);
743        self.overrides.italic.hash(hasher);
744        self.children.len().hash(hasher);
745        for child in &self.children {
746            child.measure_hash(hasher);
747        }
748    }
749
750    fn collect(&self, inherited: TextStyle, out: &mut Vec<(String, ResolvedTextStyle)>) {
751        let effective = inherited.merge(self.overrides);
752        if !self.text.is_empty() {
753            out.push((self.text.clone(), effective.resolved()));
754        }
755        // The children inherit the **unresolved** style, not the run's numbers. Handing
756        // them the resolved one would turn every framework default into an answer, and a
757        // child that wanted to inherit a weight nobody had named would find one waiting.
758        for child in &self.children {
759            child.collect(effective, out);
760        }
761    }
762}
763
764/// A text run **ready to render**: a fragment plus its typographic attributes and
765/// colour, all **resolved** — nothing left to inherit.
766#[derive(Clone, Debug, PartialEq)]
767pub struct TextRun {
768    /// The run's text.
769    pub text: String,
770    /// The font size, in logical pixels.
771    pub size: f32,
772    /// The font weight.
773    pub weight: FontWeight,
774    /// Whether the run is italic.
775    pub italic: bool,
776    /// The run's colour.
777    pub color: Color,
778    /// The run's decoration lines (no effect on measurement).
779    pub decoration: TextDecoration,
780    /// Decoration colour; `None` means the run's own colour.
781    pub decoration_color: Option<Color>,
782}
783
784#[cfg(test)]
785mod tests {
786    use super::*;
787
788    #[test]
789    fn weights_map_to_opentype() {
790        assert_eq!(FontWeight::Regular.to_u16(), 400);
791        assert_eq!(FontWeight::Bold.to_u16(), 700);
792    }
793
794    #[test]
795    fn builders_compose() {
796        let s = TextStyle::new(20.0).weight(FontWeight::Bold).italic();
797        assert_eq!(s.size, Some(20.0));
798        assert_eq!(s.weight, Some(FontWeight::Bold));
799        assert_eq!(s.italic, Some(true));
800        assert_eq!(s.color, None);
801    }
802
803    /// **A style can name one thing and leave the rest open**, which is the whole reason
804    /// the fields are optional.
805    ///
806    /// `TextStyle::new(20.0)` used to answer a size, a weight *and* a slant, because the
807    /// type had nowhere to put "unset" — so *size 20, inherit the weight* was not
808    /// expressible, however the caller wrote it. The reference writes it
809    /// `TextStyle(fontSize: 20)`, and now so do we.
810    #[test]
811    fn a_style_may_name_one_thing_and_leave_the_rest_open() {
812        let only_size = TextStyle::new(20.0);
813        assert_eq!(only_size.size, Some(20.0));
814        assert_eq!(only_size.weight, None, "and says nothing about the weight");
815
816        let inherited = TextStyle::NONE.weight(FontWeight::Bold).merge(only_size);
817        assert_eq!(inherited.size, Some(20.0), "the size it named");
818        assert_eq!(
819            inherited.weight,
820            Some(FontWeight::Bold),
821            "the weight it did not"
822        );
823    }
824
825    /// The chain **stops** somewhere: a shaper needs a number and there is nobody left to
826    /// ask. Everything unanswered lands on the framework's own default.
827    #[test]
828    fn a_style_that_says_nothing_resolves_to_the_frameworks_own() {
829        let r = TextStyle::NONE.resolved();
830        assert_eq!(r.size, DEFAULT_TEXT_SIZE);
831        assert_eq!(r.weight, FontWeight::Regular);
832        assert!(!r.italic);
833        assert_eq!(r.decoration, TextDecoration::NONE);
834        // The colour is the one thing left open, its last word belonging to a theme this
835        // type cannot see.
836        assert_eq!(r.color, None);
837    }
838
839    /// The reader's font size lands on **the one place a size becomes a number**.
840    #[test]
841    fn the_readers_font_size_reaches_a_resolved_style() {
842        assert_eq!(
843            TextStyle::new(10.0).resolved().size,
844            10.0,
845            "unscaled by default"
846        );
847        with_text_scale(1.5, || {
848            assert_eq!(TextStyle::new(10.0).resolved().size, 15.0);
849            // A style that named no size scales the framework's own, not nothing.
850            assert_eq!(
851                TextStyle::NONE.resolved().size,
852                DEFAULT_TEXT_SIZE * 1.5,
853                "the default is a size like any other"
854            );
855        });
856        assert_eq!(
857            TextStyle::new(10.0).resolved().size,
858            10.0,
859            "and the scope put it back"
860        );
861    }
862
863    /// Nothing but the size moves. A reader asking for larger text has not asked for a
864    /// different typeface, and scaling a weight or a slant is not a thing to do.
865    #[test]
866    fn only_the_size_is_scaled() {
867        with_text_scale(2.0, || {
868            let r = TextStyle::new(10.0)
869                .weight(FontWeight::Bold)
870                .italic()
871                .resolved();
872            assert_eq!(r.size, 20.0);
873            assert_eq!(r.weight, FontWeight::Bold);
874            assert!(r.italic);
875        });
876    }
877
878    /// A scale of zero is **ignored**, not obeyed. A font of no size is a screen with no
879    /// words on it, and a platform reporting one is a platform to disbelieve rather than a
880    /// user to accommodate.
881    #[test]
882    fn a_scale_of_nothing_is_disbelieved() {
883        with_text_scale(0.0, || {
884            assert_eq!(TextStyle::new(10.0).resolved().size, 10.0);
885        });
886        with_text_scale(-2.0, || {
887            assert_eq!(TextStyle::new(10.0).resolved().size, 10.0);
888        });
889    }
890
891    #[test]
892    fn merge_overrides_type_but_inherits_missing_colour() {
893        let base = TextStyle::new(16.0).color(Color::WHITE);
894        // `over` changes size and weight but specifies no colour.
895        let over = TextStyle::new(24.0).weight(FontWeight::Bold);
896        let merged = base.merge(over);
897        assert_eq!(merged.size, Some(24.0));
898        assert_eq!(merged.weight, Some(FontWeight::Bold));
899        assert_eq!(merged.color, Some(Color::WHITE), "colour inherited");
900
901        // When `over` does specify a colour, it wins.
902        let over2 = TextStyle::new(24.0).color(Color::BLACK);
903        assert_eq!(base.merge(over2).color, Some(Color::BLACK));
904    }
905
906    #[test]
907    fn span_children_inherit_unspecified_attributes() {
908        // "Hello **bold** _red italic_": the bold part inherits size and colour, the
909        // red italic inherits size and weight.
910        let span = TextSpan::new("Hello ")
911            .child(TextSpan::new("bold").bold())
912            .child(
913                TextSpan::new("red italic")
914                    .italic()
915                    .color(Color::rgb(1.0, 0.0, 0.0)),
916            );
917        let base = TextStyle::new(20.0).color(Color::WHITE);
918        let runs = span.flatten(base);
919
920        assert_eq!(runs.len(), 3);
921        let (t0, s0) = &runs[0];
922        assert_eq!(
923            (t0.as_str(), s0.size, s0.weight),
924            ("Hello ", 20.0, FontWeight::Regular)
925        );
926        let (t1, s1) = &runs[1];
927        assert_eq!((t1.as_str(), s1.weight), ("bold", FontWeight::Bold));
928        assert_eq!(s1.size, 20.0, "bold inherits the size");
929        assert_eq!(s1.color, Some(Color::WHITE), "bold inherits the colour");
930        let (t2, s2) = &runs[2];
931        assert_eq!(t2, "red italic");
932        assert!(s2.italic);
933        assert_eq!(s2.weight, FontWeight::Regular, "italic inherits the weight");
934        assert_eq!(s2.color, Some(Color::rgb(1.0, 0.0, 0.0)));
935    }
936
937    #[test]
938    fn nested_spans_cascade_depth_first() {
939        // A bold subtree in which a grandchild puts the size back to 12.
940        let span = TextSpan::new("a").child(
941            TextSpan::new("b")
942                .bold()
943                .child(TextSpan::new("c").size(12.0)),
944        );
945        let runs = span.flatten(TextStyle::new(16.0));
946        assert_eq!(runs.len(), 3);
947        assert_eq!(runs[2].0, "c");
948        assert_eq!(runs[2].1.size, 12.0);
949        assert_eq!(
950            runs[2].1.weight,
951            FontWeight::Bold,
952            "inherits bold from the parent"
953        );
954    }
955
956    #[test]
957    fn decorations_combine_and_cascade() {
958        // Combining: underline plus strikethrough on the same style.
959        let s = TextStyle::new(16.0).underline().strikethrough().resolved();
960        assert!(s.decoration.underline && s.decoration.strikethrough);
961        assert!(!s.decoration.overline);
962        assert!(!TextDecoration::UNDERLINE.is_none());
963        assert!(TextDecoration::NONE.is_none());
964
965        // Cascade: the child inherits the parent's decoration when it specifies
966        // nothing, and an explicit `decoration(NONE)` cancels it (Some(NONE) is not
967        // the same as absent).
968        let span = TextSpan::new("a")
969            .underline()
970            .decoration_color(Color::rgb(1.0, 0.0, 0.0))
971            .child(TextSpan::new("b"))
972            .child(TextSpan::new("c").decoration(TextDecoration::NONE));
973        let runs = span.flatten(TextStyle::new(16.0));
974        assert!(runs[0].1.decoration.underline);
975        assert!(
976            runs[1].1.decoration.underline,
977            "the child inherits the underline"
978        );
979        assert_eq!(
980            runs[1].1.decoration_color,
981            Some(Color::rgb(1.0, 0.0, 0.0)),
982            "the decoration colour cascades"
983        );
984        assert!(runs[2].1.decoration.is_none(), "explicitly cancelled");
985
986        // merge: decoration is a typographic attribute, so `over`'s wins, while its
987        // colour inherits the way the text colour does.
988        let base = TextStyle::new(16.0)
989            .underline()
990            .decoration_color(Color::BLACK);
991        let merged = base.merge(TextStyle::new(20.0).strikethrough()).resolved();
992        assert!(merged.decoration.strikethrough && !merged.decoration.underline);
993        assert_eq!(merged.decoration_color, Some(Color::BLACK));
994
995        // And a style that names **no** decoration leaves the parent's alone, where it
996        // used to erase it: `over.decoration` was a value the type could not withhold.
997        let quiet = base.merge(TextStyle::new(20.0)).resolved();
998        assert!(quiet.decoration.underline, "the underline survived");
999    }
1000
1001    #[test]
1002    fn empty_nodes_produce_no_run() {
1003        // A "group" node with no text of its own only exists to style its children.
1004        let span = TextSpan::new("").bold().child(TextSpan::new("x"));
1005        let runs = span.flatten(TextStyle::new(16.0));
1006        assert_eq!(runs.len(), 1);
1007        assert_eq!(runs[0].1.weight, FontWeight::Bold);
1008    }
1009
1010    /// A style that says nothing about its line height gets the framework's default, and
1011    /// one that names a ratio gets that ratio **of its own size**.
1012    #[test]
1013    fn a_line_height_is_a_ratio_of_the_size_that_asked_for_it() {
1014        assert_eq!(TextStyle::new(20.0).resolved().line_height(), 24.0);
1015        let open = TextStyle {
1016            height: Some(1.5),
1017            ..TextStyle::new(20.0)
1018        };
1019        assert_eq!(open.resolved().line_height(), 30.0);
1020        let packed = TextStyle {
1021            height: Some(1.0),
1022            ..TextStyle::new(20.0)
1023        };
1024        assert_eq!(packed.resolved().line_height(), 20.0);
1025    }
1026
1027    /// **The leading grows with the letters.** A ratio rather than a length is what makes
1028    /// a paragraph keep its rhythm when the reader turns the type up: a `height` of 1.5 is
1029    /// 30 px at a size of 20 and 60 px when that 20 has been doubled for a reader who
1030    /// asked for larger text. A length would have stayed at 30 and closed the paragraph up
1031    /// exactly when it needed opening.
1032    #[test]
1033    fn the_leading_grows_with_the_reader() {
1034        let style = TextStyle {
1035            height: Some(1.5),
1036            ..TextStyle::new(20.0)
1037        };
1038        assert_eq!(style.resolved().line_height(), 30.0);
1039        with_text_scale(2.0, || {
1040            let resolved = style.resolved();
1041            assert_eq!(resolved.size, 40.0);
1042            assert_eq!(resolved.line_height(), 60.0);
1043        });
1044    }
1045
1046    /// It inherits like every other field, and a nearer style still wins.
1047    #[test]
1048    fn a_line_height_is_inherited_and_overridable() {
1049        let handed_down = TextStyle {
1050            height: Some(1.8),
1051            ..TextStyle::NONE
1052        };
1053        let asks_only_a_size = TextStyle::new(10.0);
1054        assert_eq!(
1055            handed_down.merge(asks_only_a_size).resolved().line_height(),
1056            18.0,
1057            "the size is the near style's and the height the inherited one"
1058        );
1059        let asks_for_both = TextStyle {
1060            height: Some(1.0),
1061            ..TextStyle::new(10.0)
1062        };
1063        assert_eq!(
1064            handed_down.merge(asks_for_both).resolved().line_height(),
1065            10.0
1066        );
1067    }
1068
1069    // --- Interpolation (milestone 498) ---
1070
1071    /// **A size travels.** The plain case, and the only field of a style for which
1072    /// "halfway between" is an obvious answer.
1073    #[test]
1074    fn a_size_travels() {
1075        let a = TextStyle::NONE.size(12.0);
1076        let b = TextStyle::NONE.size(24.0);
1077        assert_eq!(a.lerp(b, 0.0).size, Some(12.0));
1078        assert_eq!(a.lerp(b, 0.5).size, Some(18.0));
1079        assert_eq!(a.lerp(b, 1.0).size, Some(24.0));
1080    }
1081
1082    /// **A field only one end states does not travel** — it holds the one value it has,
1083    /// from the first frame to the last.
1084    ///
1085    /// `None` is not nought and not transparent: it means *this style does not say*, and
1086    /// the question passes further up the cascade. There is no number between "24 pixels"
1087    /// and "ask somebody else", and interpolating towards whatever the theme happens to
1088    /// answer would make the movement depend on the theme, which neither end asked for.
1089    #[test]
1090    fn a_field_only_one_end_states_holds_still() {
1091        let unset = TextStyle::NONE;
1092        let stated = TextStyle::NONE.size(24.0);
1093        for t in [0.0, 0.25, 0.5, 0.75, 1.0] {
1094            assert_eq!(unset.lerp(stated, t).size, Some(24.0), "arriving, at {t}");
1095            assert_eq!(stated.lerp(unset, t).size, Some(24.0), "leaving, at {t}");
1096        }
1097        assert_eq!(
1098            unset.lerp(unset, 0.5).size,
1099            None,
1100            "and unset at both ends stays so"
1101        );
1102    }
1103
1104    /// **An unset colour does not fade out**, which is where this diverges from the
1105    /// reference on purpose.
1106    ///
1107    /// There, a null colour means *nothing was painted*, so fading towards it is right.
1108    /// Here it means *the widget resolves it against the theme* — `on_surface`, a
1109    /// perfectly visible colour — so fading it out would make a run of words disappear
1110    /// halfway through a movement that was only ever about its size.
1111    #[test]
1112    fn an_unset_colour_does_not_fade_out() {
1113        let red = Color::rgb(1.0, 0.0, 0.0);
1114        let stated = TextStyle::NONE.color(red);
1115        let mid = TextStyle::NONE.lerp(stated, 0.5).color.expect("a colour");
1116        assert_eq!(mid, red, "held whole, not at half alpha");
1117        assert_eq!(TextStyle::NONE.lerp(TextStyle::NONE, 0.5).color, None);
1118    }
1119
1120    /// **A colour stated at both ends travels**, on the same path an animated background
1121    /// takes, so two things changing colour together stay together.
1122    #[test]
1123    fn a_colour_stated_at_both_ends_travels() {
1124        let a = TextStyle::NONE.color(Color::rgb(0.0, 0.0, 0.0));
1125        let b = TextStyle::NONE.color(Color::rgb(1.0, 1.0, 1.0));
1126        let mid = a.lerp(b, 0.5).color.expect("a colour");
1127        assert_eq!(
1128            mid,
1129            Color::rgb(0.0, 0.0, 0.0).lerp(Color::rgb(1.0, 1.0, 1.0), 0.5)
1130        );
1131    }
1132
1133    /// **Faces and lines swap at the halfway point.** There is no half-italic face and no
1134    /// two-thirds of an underline, so the change happens once, in the middle.
1135    #[test]
1136    fn faces_and_lines_swap_in_the_middle() {
1137        let upright = TextStyle {
1138            italic: Some(false),
1139            ..TextStyle::NONE.decoration(TextDecoration::NONE)
1140        };
1141        let slanted = TextStyle::NONE
1142            .italic()
1143            .decoration(TextDecoration::UNDERLINE);
1144        assert_eq!(upright.lerp(slanted, 0.49).italic, Some(false));
1145        assert_eq!(upright.lerp(slanted, 0.51).italic, Some(true));
1146        assert_eq!(
1147            upright.lerp(slanted, 0.49).decoration,
1148            Some(TextDecoration::NONE)
1149        );
1150        assert_eq!(
1151            upright.lerp(slanted, 0.51).decoration,
1152            Some(TextDecoration::UNDERLINE)
1153        );
1154    }
1155
1156    /// **A weight steps through the faces it has.** Interpolating the number would ask
1157    /// for a 437 no font can draw, so what moves is the choice of face: regular to bold
1158    /// passes through semi-bold in the middle rather than gliding.
1159    #[test]
1160    fn a_weight_steps_through_the_faces() {
1161        use FontWeight::{Bold, Medium, Regular, SemiBold};
1162        assert_eq!(Regular.lerp(Bold, 0.0), Regular);
1163        assert_eq!(Regular.lerp(Bold, 0.5), SemiBold, "550 is nearest 600");
1164        assert_eq!(Regular.lerp(Bold, 1.0), Bold);
1165        assert_eq!(Regular.lerp(Bold, 0.2), Medium, "460 is nearest 500");
1166        assert_eq!(Bold.lerp(Regular, 0.5), SemiBold, "and the same going back");
1167    }
1168}