Skip to main content

guido/widgets/
font.rs

1//! Font family and weight types for text styling.
2//!
3//! These types allow configuring font family and weight on text widgets.
4
5use std::sync::{LazyLock, RwLock};
6
7use cosmic_text::{Family, Weight};
8use rustc_hash::FxHashMap;
9
10/// Font family specification.
11///
12/// `Copy`, and eight bytes: a named family is an [`FamilyId`] into a table of
13/// names rather than a string of its own. That is what lets a text property
14/// hold one directly — a property stores its value inline since #450, so
15/// `Prop<T>` is `Copy` only where `T` is, and declaring a family no longer
16/// claims a signal slot to hold a value that cannot change.
17///
18/// # Examples
19///
20/// ```no_run
21/// # use guido::prelude::*;
22/// text("Hello").font_family(FontFamily::Monospace);
23/// text("Hello").font_family(FontFamily::name("Inter"));
24/// ```
25#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Default)]
26pub enum FontFamily {
27    /// Sans-serif font (default system sans-serif)
28    #[default]
29    SansSerif,
30    /// Serif font (default system serif)
31    Serif,
32    /// Monospace font (default system monospace)
33    Monospace,
34    /// Cursive font
35    Cursive,
36    /// Fantasy font
37    Fantasy,
38    /// Custom font by name, interned. Build one with [`FontFamily::name`].
39    Name(FamilyId),
40}
41
42/// Every family name this process has been asked for, and never fewer.
43///
44/// **Not** a `thread_local!`, and not a field of `AppState` either, although
45/// `docs/ARCHITECTURE.md` says new ambient state belongs in the latter. Both
46/// have the same fault from opposite directions, and a `FontFamily` outlives
47/// them both:
48///
49/// - `AppState` is safe because `reset` empties *all* of it between
50///   applications, and `tests/ambient_state_inventory.rs` refuses a field that
51///   opts out — opting out is how #372 happened. But a `FontFamily` an
52///   application still holds is an index into this table, so a recycled slot
53///   would silently rename a family.
54/// - A thread's table would be worse. `FontFamily` is `Copy` and therefore
55///   `Send`, `create_signal` requires `Send`, and `WriteSignal` is `Send` on
56///   purpose so a background task can write — so
57///   `writer.set(FontFamily::name("Inter"))` from a service would mint an
58///   index in the worker's table and apply it on the main thread, where it
59///   names a different family or none at all. `to_cosmic` turns "none" into
60///   sans-serif, so the failure would be a silently wrong font.
61///
62/// Per-process is what Blink's `AtomicString` table is, for this reason.
63///
64/// The names are leaked on the way in, which is what makes reading one back a
65/// `&'static str` — a lifetime that needs nowhere to live, on any thread. An
66/// application names a handful of families and never unnames one, so the table
67/// is bounded by the program text rather than by what it runs.
68///
69/// The lock is not on a hot path: interning happens where a widget is built,
70/// and reading happens on the shaping paths, each of which is a cache miss
71/// about to run cosmic-text.
72static FAMILIES: LazyLock<RwLock<FamilyTable>> =
73    LazyLock::new(|| RwLock::new(FamilyTable::default()));
74
75#[derive(Default)]
76struct FamilyTable {
77    names: Vec<&'static str>,
78    ids: FxHashMap<&'static str, FamilyId>,
79}
80
81/// A name in the family table, which is what a named [`FontFamily`] holds.
82///
83/// Opaque and `Copy`. Equality is the whole point: two declarations of the
84/// same name compare as one integer rather than as bytes, and that comparison
85/// is on the text cache's key path.
86#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
87pub struct FamilyId(u32);
88
89impl FontFamily {
90    /// A family by name, interned.
91    ///
92    /// The same name always gives the same family, for the life of the
93    /// process — on any thread, and across an `App` being dropped and another
94    /// built. See the `FAMILIES` table for why it has to be both.
95    ///
96    /// ```no_run
97    /// # use guido::prelude::*;
98    /// text("Hello").font_family(FontFamily::name("Inter"));
99    /// ```
100    pub fn name(name: &str) -> Self {
101        if let Some(&id) = FAMILIES.read().expect("family table").ids.get(name) {
102            return FontFamily::Name(id);
103        }
104        let mut table = FAMILIES.write().expect("family table");
105        // Read again: another thread may have interned it between the two.
106        if let Some(&id) = table.ids.get(name) {
107            return FontFamily::Name(id);
108        }
109        let leaked: &'static str = String::leak(name.to_owned());
110        let id = FamilyId(table.names.len() as u32);
111        table.names.push(leaked);
112        table.ids.insert(leaked, id);
113        FontFamily::Name(id)
114    }
115
116    /// The name this family was interned under, or `None` for a generic one.
117    pub fn family_name(self) -> Option<&'static str> {
118        match self {
119            FontFamily::Name(FamilyId(index)) => FAMILIES
120                .read()
121                .expect("family table")
122                .names
123                .get(index as usize)
124                .copied(),
125            _ => None,
126        }
127    }
128
129    /// Convert to cosmic-text Family type for rendering.
130    pub fn to_cosmic(self) -> Family<'static> {
131        match self {
132            FontFamily::SansSerif => Family::SansSerif,
133            FontFamily::Serif => Family::Serif,
134            FontFamily::Monospace => Family::Monospace,
135            FontFamily::Cursive => Family::Cursive,
136            FontFamily::Fantasy => Family::Fantasy,
137            FontFamily::Name(_) => self.family_name().map_or(Family::SansSerif, Family::Name),
138        }
139    }
140}
141
142/// Font weight on a 100-900 scale, matching CSS font-weight values.
143///
144/// # Examples
145///
146/// ```no_run
147/// # use guido::prelude::*;
148/// text("Hello").font_weight(FontWeight::BOLD);
149/// text("Hello").font_weight(FontWeight(600));
150/// ```
151#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Default)]
152pub struct FontWeight(pub u16);
153
154impl FontWeight {
155    /// Thin weight (100)
156    pub const THIN: Self = Self(100);
157    /// Extra-light weight (200)
158    pub const EXTRA_LIGHT: Self = Self(200);
159    /// Light weight (300)
160    pub const LIGHT: Self = Self(300);
161    /// Normal/regular weight (400) - default
162    pub const NORMAL: Self = Self(400);
163    /// Medium weight (500)
164    pub const MEDIUM: Self = Self(500);
165    /// Semi-bold weight (600)
166    pub const SEMI_BOLD: Self = Self(600);
167    /// Bold weight (700)
168    pub const BOLD: Self = Self(700);
169    /// Extra-bold weight (800)
170    pub const EXTRA_BOLD: Self = Self(800);
171    /// Black/heavy weight (900)
172    pub const BLACK: Self = Self(900);
173
174    /// Convert to cosmic-text Weight type for rendering.
175    pub fn to_cosmic(self) -> Weight {
176        Weight(self.0)
177    }
178}
179
180/// How tall each line of a text is.
181///
182/// A bare number is a factor of the font size, as CSS's `line-height`, iced
183/// and Flutter all read it: `line_height(1.5)` is `Relative(1.5)`. A height in
184/// pixels is spelled `Absolute`.
185///
186/// # Examples
187///
188/// ```no_run
189/// # use guido::prelude::*;
190/// text("Notes").line_height(1.5);
191/// text("Sign in").font_size(14.0).line_height(LineHeight::Absolute(20.0));
192/// ```
193#[derive(Debug, Clone, Copy, Default, PartialEq)]
194pub enum LineHeight {
195    /// The font's own line height: its ascent, descent and line gap, scaled to
196    /// the size. Read from the face that draws the family at this weight — a
197    /// fallback face where the family's own font is not installed — and a
198    /// fallback glyph later in the line does not change it.
199    #[default]
200    Normal,
201    /// A multiple of the font size. A bare number means this.
202    Relative(f32),
203    /// Logical pixels.
204    Absolute(f32),
205}
206
207impl From<f32> for LineHeight {
208    fn from(factor: f32) -> Self {
209        LineHeight::Relative(factor)
210    }
211}
212
213/// So a bare literal, which Rust types as `f64` when nothing narrows it,
214/// is a factor too.
215impl From<f64> for LineHeight {
216    fn from(factor: f64) -> Self {
217        LineHeight::Relative(factor as f32)
218    }
219}
220
221crate::reactive::converts!(
222    f32 => LineHeight,
223    f64 => LineHeight,
224);
225
226impl LineHeight {
227    /// The line height as something a cache can key by: the number by its
228    /// bits, as every float in the text caches' keys is held.
229    pub(crate) fn key(self) -> (u8, u32) {
230        match self {
231            LineHeight::Normal => (0, 0),
232            LineHeight::Relative(factor) => (1, factor.to_bits()),
233            LineHeight::Absolute(height) => (2, height.to_bits()),
234        }
235    }
236}
237
238#[cfg(test)]
239mod tests {
240    use super::*;
241
242    /// A bare number is a factor of the font size, whichever width of float
243    /// the literal came out as.
244    #[test]
245    fn a_bare_number_is_a_factor() {
246        assert_eq!(LineHeight::from(1.5_f32), LineHeight::Relative(1.5));
247        assert_eq!(LineHeight::from(1.5_f64), LineHeight::Relative(1.5));
248    }
249
250    #[test]
251    fn font_family_default() {
252        assert_eq!(FontFamily::default(), FontFamily::SansSerif);
253    }
254
255    #[test]
256    fn font_weight_default() {
257        assert_eq!(FontWeight::default(), FontWeight(0));
258    }
259
260    #[test]
261    fn font_weight_constants() {
262        assert_eq!(FontWeight::NORMAL.0, 400);
263        assert_eq!(FontWeight::BOLD.0, 700);
264    }
265
266    /// A family is a number, so a property can hold one without a heap.
267    ///
268    /// Eight bytes is what makes `Prop<FontFamily>` weigh exactly what a
269    /// `Signal` weighs, so naming a family reactively wastes nothing; `Copy`
270    /// is what lets `TextStyle` hold a `Prop` at all. The type's own doc says
271    /// why both matter.
272    #[test]
273    fn a_family_is_eight_bytes_and_a_property_holding_one_is_twelve() {
274        use std::mem::size_of;
275        assert_eq!(size_of::<FontFamily>(), 8, "a discriminant and an id");
276        assert_eq!(
277            size_of::<crate::reactive::Prop<FontFamily>>(),
278            size_of::<crate::reactive::Signal<FontFamily>>(),
279            "so a reactive family costs the signal and not a byte more"
280        );
281
282        // `Copy`, said in a way that fails to compile if it is ever lost.
283        fn only_for_copy<T: Copy>(_: T) {}
284        let family = FontFamily::name("Inter");
285        only_for_copy(family);
286        only_for_copy(family);
287    }
288
289    /// The same name is the same family, and a different name is not.
290    ///
291    /// What interning has to get right: the id is the identity, so two
292    /// declarations of "Inter" must compare equal without comparing bytes —
293    /// that equality is on the text cache key path.
294    #[test]
295    fn one_name_is_one_id_however_often_it_is_named() {
296        assert_eq!(FontFamily::name("Inter"), FontFamily::name("Inter"));
297        assert_ne!(FontFamily::name("Inter"), FontFamily::name("Iosevka"));
298        assert_ne!(FontFamily::name("Inter"), FontFamily::Monospace);
299
300        // And the name comes back, because the renderer needs it to shape.
301        assert_eq!(FontFamily::name("Inter").family_name(), Some("Inter"));
302        assert_eq!(FontFamily::Monospace.family_name(), None);
303    }
304
305    /// An id outlives the `App` that minted it, and the thread that minted it.
306    ///
307    /// The two invariants the table's placement exists for — see `FAMILIES`,
308    /// which argues both. Without the first, a `FontFamily` held across an
309    /// application boundary silently starts naming something else; without the
310    /// second, one written from a background task through a `WriteSignal` —
311    /// which is `Send` on purpose — names something else on the thread that
312    /// reads it.
313    #[test]
314    fn an_id_survives_the_application_and_the_thread_that_minted_it() {
315        // A decoy first, so a worker with a table of its own would number
316        // "Iosevka" from zero and disagree — without it the two could agree by
317        // accident, and the assertion below would pin nothing.
318        let _decoy = FontFamily::name("a name nothing else in the suite uses");
319        let before = FontFamily::name("Iosevka");
320        crate::app_state::reset();
321        assert_eq!(
322            before,
323            FontFamily::name("Iosevka"),
324            "the same name still means the same family"
325        );
326        assert_eq!(before.family_name(), Some("Iosevka"));
327
328        let elsewhere = std::thread::spawn(|| FontFamily::name("Iosevka"))
329            .join()
330            .expect("the worker interned a name");
331        assert_eq!(
332            elsewhere, before,
333            "and a family named on another thread is the same family here"
334        );
335        assert_eq!(elsewhere.family_name(), Some("Iosevka"));
336    }
337}