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}