frust_text/style.rs
1//! [`TextStyle`]: the styling knobs applied uniformly to a laid-out string —
2//! family, weight, style (italic/oblique), size, color, letter-spacing, and
3//! line-height (the M3 type-scale surface).
4//!
5//! Every type here is Frust-owned, not a parley re-export (scene-layer
6//! purity, see `docs/ARCHITECTURE.md`): [`FontFamily`]/[`FontWeight`]/
7//! [`FontStyle`]/[`LineHeight`] mirror parley 0.11's own semantics (see
8//! `parley::style`) so the conversion in [`crate::context`]/[`crate::editor`]
9//! is a straight match, but no parley type appears in this module's public
10//! API. Rich per-range styling and bundled/custom font registration are
11//! deferred.
12
13use peniko::Color;
14
15/// One element of a fallback stack: a concrete family name or a generic class.
16#[derive(Clone, Debug, PartialEq, Eq)]
17pub enum FamilyName {
18 /// A concrete named font family (e.g. "Inter", "IBM Plex Mono").
19 Named(String),
20 /// A generic fallback family (e.g. monospace, serif).
21 Generic(GenericSlot),
22}
23
24/// Generic font families supported as fallback slots.
25///
26/// This is a curated subset of parley's [`parley::GenericFamily`] covering
27/// the generic families Frust explicitly supports and exposes.
28#[derive(Clone, Copy, Debug, PartialEq, Eq)]
29pub enum GenericSlot {
30 /// A monospace (fixed-width) font family.
31 Monospace,
32 /// A sans-serif font family.
33 SansSerif,
34 /// A serif font family.
35 Serif,
36 /// The platform system UI font.
37 SystemUi,
38 /// Color emoji or symbol glyphs.
39 Emoji,
40}
41
42/// Font family selection.
43///
44/// The default, [`FontFamily::SystemUi`], resolves to the platform UI font
45/// (e.g. San Francisco on macOS) via fontique's system collection with no
46/// registration required. [`FontFamily::Named`] is an ordered fallback stack
47/// of named families, tried in order; a name that doesn't resolve on the
48/// current platform falls back per parley's own fallback behavior — no error
49/// surface is exposed here. [`FontFamily::NamedWithGeneric`] extends
50/// [`FontFamily::Named`] by allowing the stack to end with a generic
51/// fallback family (e.g. monospace, serif).
52#[derive(Clone, Debug, PartialEq, Eq, Default)]
53pub enum FontFamily {
54 /// The platform system UI font. Default.
55 #[default]
56 SystemUi,
57 /// An ordered fallback stack of named font families.
58 Named(Vec<String>),
59 /// An ordered fallback stack of named families ending in a generic fallback.
60 NamedWithGeneric(Vec<FamilyName>),
61}
62
63impl FontFamily {
64 /// A single named font family.
65 pub fn named(name: impl Into<String>) -> Self {
66 Self::Named(vec![name.into()])
67 }
68
69 /// An ordered fallback stack of named font families, tried in order.
70 pub fn stack(names: impl IntoIterator<Item = impl Into<String>>) -> Self {
71 Self::Named(names.into_iter().map(Into::into).collect())
72 }
73
74 /// An ordered fallback stack of named font families ending in a generic
75 /// fallback family.
76 ///
77 /// # Example
78 ///
79 /// ```
80 /// use frust_text::{FontFamily, GenericSlot};
81 ///
82 /// // Try "IBM Plex Mono" first, fall back to system monospace
83 /// let family = FontFamily::stack_with_generic(
84 /// ["IBM Plex Mono"],
85 /// GenericSlot::Monospace,
86 /// );
87 /// ```
88 pub fn stack_with_generic(
89 names: impl IntoIterator<Item = impl Into<String>>,
90 generic: GenericSlot,
91 ) -> Self {
92 let mut families: Vec<FamilyName> = names
93 .into_iter()
94 .map(|name| FamilyName::Named(name.into()))
95 .collect();
96 families.push(FamilyName::Generic(generic));
97 Self::NamedWithGeneric(families)
98 }
99}
100
101/// Visual weight of a font, on the standard 1-1000 CSS `font-weight` scale.
102///
103/// Mirrors parley/fontique's `FontWeight` (see `parley::FontWeight`) without
104/// leaking the type itself past this crate's boundary.
105#[derive(Clone, Copy, Debug, PartialEq, PartialOrd)]
106pub struct FontWeight(f32);
107
108impl FontWeight {
109 /// Weight value of 100.
110 pub const THIN: Self = Self(100.0);
111 /// Weight value of 200.
112 pub const EXTRA_LIGHT: Self = Self(200.0);
113 /// Weight value of 300.
114 pub const LIGHT: Self = Self(300.0);
115 /// Weight value of 400. Default.
116 pub const REGULAR: Self = Self(400.0);
117 /// Weight value of 500.
118 pub const MEDIUM: Self = Self(500.0);
119 /// Weight value of 600.
120 pub const SEMI_BOLD: Self = Self(600.0);
121 /// Weight value of 700.
122 pub const BOLD: Self = Self(700.0);
123 /// Weight value of 800.
124 pub const EXTRA_BOLD: Self = Self(800.0);
125 /// Weight value of 900.
126 pub const BLACK: Self = Self(900.0);
127
128 /// A custom weight value (1-1000 scale by convention; not clamped).
129 pub const fn new(value: f32) -> Self {
130 Self(value)
131 }
132
133 /// The underlying numeric weight value.
134 pub const fn value(self) -> f32 {
135 self.0
136 }
137}
138
139impl Default for FontWeight {
140 fn default() -> Self {
141 Self::REGULAR
142 }
143}
144
145/// Visual slant of a font.
146///
147/// Mirrors parley/fontique's `FontStyle` (see `parley::FontStyle`).
148#[derive(Clone, Copy, Debug, PartialEq, Default)]
149pub enum FontStyle {
150 /// An upright or "roman" style. Default.
151 #[default]
152 Normal,
153 /// A slanted style, generally with a different structure from the
154 /// normal style.
155 Italic,
156 /// A slanted style derived from the normal style, with an optional angle
157 /// in degrees (`None` uses the engine-specific default angle).
158 Oblique(Option<f32>),
159}
160
161/// Paragraph alignment: how each line is positioned within the layout's
162/// width.
163///
164/// Mirrors parley's `Alignment` semantics (see `parley::layout::Alignment`)
165/// without leaking the parley type. Only meaningful when the layout is
166/// wrapped to a `max_width` ([`crate::TextContext::layout`]'s third
167/// argument) — an unbounded layout's line width already equals its content
168/// width, so every alignment renders identically to [`TextAlign::Start`].
169///
170/// **Applies to [`crate::TextContext::layout`] only.** A live-edited
171/// [`crate::TextEditor`] (the engine behind `TextInput`) does not read this
172/// field — see that type's docs for why editable text is out of scope.
173#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, Default)]
174pub enum TextAlign {
175 /// Leading edge of the line's text direction (left for LTR, right for
176 /// RTL). Default — matches v1's hardcoded behavior exactly.
177 #[default]
178 Start,
179 /// Trailing edge of the line's text direction (right for LTR, left for
180 /// RTL).
181 End,
182 /// Always the left edge, regardless of text direction.
183 Left,
184 /// Each line centered within the layout's width.
185 Center,
186 /// Always the right edge, regardless of text direction.
187 Right,
188 /// Each line except the last is spaced out to fill the layout's width.
189 Justify,
190}
191
192/// How text that overflows a bounded [`TextContext::layout_bounded`]
193/// (`max_lines`) is handled.
194///
195/// Mirrors Flutter's `TextOverflow.clip`/`TextOverflow.ellipsis` shape (a
196/// deliberately small subset — no `fade`/`visible`). Only meaningful
197/// alongside `max_lines`: with no line cap, neither variant changes
198/// anything (there is nothing to overflow past).
199#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, Default)]
200pub enum TextOverflow {
201 /// Drop whole lines past `max_lines` outright; the last visible line's
202 /// own text is left exactly as the line-breaker assigned it, even if it
203 /// is itself wider than the box (an unbreakable run with no wrap
204 /// opportunity) — Frust does not character-trim in this mode. Default,
205 /// and a no-op when `max_lines` is unset (today's behavior).
206 #[default]
207 Clip,
208 /// Same line-dropping as [`Self::Clip`], plus: the last visible line is
209 /// character-truncated (UTF-8/char-boundary safe) and has `'…'`
210 /// appended so it fits the layout's `max_width`. With an unbounded
211 /// `max_width`, there is no width to truncate against, so the last
212 /// visible line's full text is kept with `'…'` simply appended.
213 Ellipsis,
214}
215
216/// Line height: how much vertical space each line of text occupies.
217///
218/// Mirrors parley's `LineHeight` semantics (see `parley::LineHeight`)
219/// without leaking the parley type. M3's type scale specifies line heights
220/// in absolute logical pixels ([`LineHeight::Absolute`]);
221/// [`LineHeight::FontSizeRelative`] is the documented M3-friendly choice when
222/// a consistent, font-independent ratio is preferred instead.
223#[derive(Clone, Copy, Debug, PartialEq)]
224pub enum LineHeight {
225 /// A multiple of the font's own metrics-derived line height (ascender +
226 /// descender + line gap/leading). `1.0` is the font's natural line
227 /// height.
228 MetricsRelative(f32),
229 /// A multiple of the font size — CSS's unitless `line-height`. Useful
230 /// for consistent line heights across platforms/fonts when using
231 /// system-defined generic families.
232 FontSizeRelative(f32),
233 /// An absolute line height in logical pixels.
234 Absolute(f32),
235}
236
237impl Default for LineHeight {
238 /// Matches parley's own default: the font's natural metrics-relative
239 /// line height.
240 fn default() -> Self {
241 Self::MetricsRelative(1.0)
242 }
243}
244
245/// Styling applied uniformly to a laid-out string.
246#[derive(Clone, Debug, PartialEq)]
247pub struct TextStyle {
248 /// Font family (or fallback stack).
249 pub family: FontFamily,
250 /// Font weight.
251 pub weight: FontWeight,
252 /// Font style (upright/italic/oblique).
253 pub style: FontStyle,
254 /// Font size in logical pixels.
255 pub size: f32,
256 /// Fill color for the glyphs.
257 pub color: Color,
258 /// Extra spacing between letters, in logical pixels.
259 pub letter_spacing: f32,
260 /// Line height.
261 pub line_height: LineHeight,
262 /// Paragraph alignment. Defaults to [`TextAlign::Start`].
263 pub align: TextAlign,
264}
265
266impl TextStyle {
267 /// A style with the given `size` and `color`; every other knob keeps its
268 /// [`Default`] value.
269 pub fn new(size: f32, color: Color) -> Self {
270 Self {
271 size,
272 color,
273 ..Self::default()
274 }
275 }
276}
277
278impl Default for TextStyle {
279 /// System UI family, 400 weight, upright, 16px, opaque black, no extra
280 /// letter-spacing, the font's natural line height.
281 fn default() -> Self {
282 Self {
283 family: FontFamily::default(),
284 weight: FontWeight::default(),
285 style: FontStyle::default(),
286 size: 16.0,
287 color: Color::BLACK,
288 letter_spacing: 0.0,
289 line_height: LineHeight::default(),
290 align: TextAlign::default(),
291 }
292 }
293}
294
295/// Converts a Frust [`FontFamily`] into parley's owned `'static`
296/// `FontFamily`. `pub(crate)` — parley types must not leak past the crate
297/// boundary (scene-layer purity); shared by [`crate::context`] and
298/// [`crate::editor`].
299pub(crate) fn to_parley_family(family: &FontFamily) -> parley::FontFamily<'static> {
300 match family {
301 FontFamily::SystemUi => parley::GenericFamily::SystemUi.into(),
302 FontFamily::Named(names) => {
303 let list: Vec<parley::FontFamilyName<'static>> = names
304 .iter()
305 .map(|name| parley::FontFamilyName::Named(name.clone().into()))
306 .collect();
307 parley::FontFamily::List(list.into())
308 }
309 FontFamily::NamedWithGeneric(families) => {
310 let list: Vec<parley::FontFamilyName<'static>> = families
311 .iter()
312 .map(|f| match f {
313 FamilyName::Named(name) => parley::FontFamilyName::Named(name.clone().into()),
314 FamilyName::Generic(slot) => {
315 parley::FontFamilyName::Generic(generic_slot_to_parley(*slot))
316 }
317 })
318 .collect();
319 parley::FontFamily::List(list.into())
320 }
321 }
322}
323
324/// Converts a Frust [`GenericSlot`] into parley's `GenericFamily`.
325/// `pub(crate)` — see [`to_parley_family`].
326fn generic_slot_to_parley(slot: GenericSlot) -> parley::GenericFamily {
327 match slot {
328 GenericSlot::Monospace => parley::GenericFamily::Monospace,
329 GenericSlot::SansSerif => parley::GenericFamily::SansSerif,
330 GenericSlot::Serif => parley::GenericFamily::Serif,
331 GenericSlot::SystemUi => parley::GenericFamily::SystemUi,
332 GenericSlot::Emoji => parley::GenericFamily::Emoji,
333 }
334}
335
336/// Converts a Frust [`FontWeight`] into parley's `FontWeight`. `pub(crate)`
337/// — see [`to_parley_family`].
338pub(crate) fn to_parley_weight(weight: FontWeight) -> parley::FontWeight {
339 parley::FontWeight::new(weight.0)
340}
341
342/// Converts a Frust [`FontStyle`] into parley's `FontStyle`. `pub(crate)`
343/// — see [`to_parley_family`].
344pub(crate) fn to_parley_style(style: FontStyle) -> parley::FontStyle {
345 match style {
346 FontStyle::Normal => parley::FontStyle::Normal,
347 FontStyle::Italic => parley::FontStyle::Italic,
348 FontStyle::Oblique(angle) => parley::FontStyle::Oblique(angle),
349 }
350}
351
352/// Converts a Frust [`LineHeight`] into parley's `LineHeight`.
353/// `pub(crate)` — see [`to_parley_family`].
354pub(crate) fn to_parley_line_height(line_height: LineHeight) -> parley::LineHeight {
355 match line_height {
356 LineHeight::MetricsRelative(v) => parley::LineHeight::MetricsRelative(v),
357 LineHeight::FontSizeRelative(v) => parley::LineHeight::FontSizeRelative(v),
358 LineHeight::Absolute(v) => parley::LineHeight::Absolute(v),
359 }
360}
361
362/// Converts a Frust [`TextAlign`] into parley's `layout::Alignment`.
363/// `pub(crate)` — see [`to_parley_family`]. Shared by [`crate::context`]
364/// (the initial shape+break) and [`crate::shape_cache`] (the width-change
365/// re-break path) — both must apply the same alignment, or a resized layout
366/// silently reverts to [`TextAlign::Start`] (see the shape cache's module
367/// docs).
368pub(crate) fn to_parley_align(align: TextAlign) -> parley::layout::Alignment {
369 match align {
370 TextAlign::Start => parley::layout::Alignment::Start,
371 TextAlign::End => parley::layout::Alignment::End,
372 TextAlign::Left => parley::layout::Alignment::Left,
373 TextAlign::Center => parley::layout::Alignment::Center,
374 TextAlign::Right => parley::layout::Alignment::Right,
375 TextAlign::Justify => parley::layout::Alignment::Justify,
376 }
377}
378
379#[cfg(test)]
380mod tests {
381 use super::*;
382
383 #[test]
384 fn default_preserves_old_v1_semantics() {
385 let style = TextStyle::default();
386 assert_eq!(style.family, FontFamily::SystemUi);
387 assert_eq!(style.weight, FontWeight::REGULAR);
388 assert_eq!(style.style, FontStyle::Normal);
389 assert_eq!(style.size, 16.0);
390 assert_eq!(style.color, Color::BLACK);
391 assert_eq!(style.letter_spacing, 0.0);
392 assert_eq!(style.line_height, LineHeight::MetricsRelative(1.0));
393 assert_eq!(style.align, TextAlign::Start);
394 }
395
396 #[test]
397 fn new_only_overrides_size_and_color() {
398 let style = TextStyle::new(24.0, Color::from_rgb8(0x11, 0x22, 0x33));
399 assert_eq!(style.size, 24.0);
400 assert_eq!(style.color, Color::from_rgb8(0x11, 0x22, 0x33));
401 assert_eq!(style.family, FontFamily::SystemUi);
402 assert_eq!(style.weight, FontWeight::REGULAR);
403 assert_eq!(style.style, FontStyle::Normal);
404 assert_eq!(style.align, TextAlign::Start);
405 }
406
407 #[test]
408 fn to_parley_align_maps_every_variant() {
409 assert_eq!(
410 to_parley_align(TextAlign::Start),
411 parley::layout::Alignment::Start
412 );
413 assert_eq!(
414 to_parley_align(TextAlign::End),
415 parley::layout::Alignment::End
416 );
417 assert_eq!(
418 to_parley_align(TextAlign::Left),
419 parley::layout::Alignment::Left
420 );
421 assert_eq!(
422 to_parley_align(TextAlign::Center),
423 parley::layout::Alignment::Center
424 );
425 assert_eq!(
426 to_parley_align(TextAlign::Right),
427 parley::layout::Alignment::Right
428 );
429 assert_eq!(
430 to_parley_align(TextAlign::Justify),
431 parley::layout::Alignment::Justify
432 );
433 }
434
435 #[test]
436 fn font_weight_consts_match_css_scale() {
437 assert_eq!(FontWeight::THIN.value(), 100.0);
438 assert_eq!(FontWeight::REGULAR.value(), 400.0);
439 assert_eq!(FontWeight::MEDIUM.value(), 500.0);
440 assert_eq!(FontWeight::BOLD.value(), 700.0);
441 assert_eq!(FontWeight::BLACK.value(), 900.0);
442 }
443
444 #[test]
445 fn family_named_and_stack_constructors() {
446 assert_eq!(
447 FontFamily::named("Inter"),
448 FontFamily::Named(vec!["Inter".to_string()])
449 );
450 assert_eq!(
451 FontFamily::stack(["Inter", "Roboto"]),
452 FontFamily::Named(vec!["Inter".to_string(), "Roboto".to_string()])
453 );
454 }
455
456 #[test]
457 fn stack_with_generic_creates_correct_variant() {
458 let family = FontFamily::stack_with_generic(["IBM Plex Mono"], GenericSlot::Monospace);
459 match family {
460 FontFamily::NamedWithGeneric(families) => {
461 assert_eq!(families.len(), 2);
462 assert_eq!(families[0], FamilyName::Named("IBM Plex Mono".to_string()));
463 assert_eq!(families[1], FamilyName::Generic(GenericSlot::Monospace));
464 }
465 _ => panic!("Expected NamedWithGeneric variant"),
466 }
467 }
468
469 #[test]
470 fn to_parley_family_named_then_generic() {
471 let family = FontFamily::stack_with_generic(["NoSuchFont"], GenericSlot::Monospace);
472 let parley_family = to_parley_family(&family);
473
474 // Verify it's a List (not a single GenericFamily)
475 match parley_family {
476 parley::FontFamily::List(_) => {
477 // Expected: the list should contain both the named font and the generic fallback
478 }
479 _ => panic!("Expected FontFamily::List variant"),
480 }
481 }
482
483 #[test]
484 fn generic_slot_maps_correctly_to_parley() {
485 let mono = generic_slot_to_parley(GenericSlot::Monospace);
486 assert_eq!(mono, parley::GenericFamily::Monospace);
487
488 let sans = generic_slot_to_parley(GenericSlot::SansSerif);
489 assert_eq!(sans, parley::GenericFamily::SansSerif);
490
491 let serif = generic_slot_to_parley(GenericSlot::Serif);
492 assert_eq!(serif, parley::GenericFamily::Serif);
493
494 let system = generic_slot_to_parley(GenericSlot::SystemUi);
495 assert_eq!(system, parley::GenericFamily::SystemUi);
496
497 let emoji = generic_slot_to_parley(GenericSlot::Emoji);
498 assert_eq!(emoji, parley::GenericFamily::Emoji);
499 }
500
501 /// Layout integration test: verify that a nonexistent font name followed by
502 /// a generic monospace fallback resolves to a monospace font. Monospace
503 /// fonts have equal character advance widths; we assert that 'i' and 'm'
504 /// have the same advance, which is true for monospace but not proportional
505 /// fonts.
506 #[test]
507 fn stack_with_generic_monospace_fallback_shapes_correctly() {
508 // Use a family name that almost certainly doesn't exist, ensuring
509 // the fallback must engage.
510 let family = FontFamily::stack_with_generic(
511 ["NonexistentFontFamilyName12345"],
512 GenericSlot::Monospace,
513 );
514
515 // Convert to parley's format and verify it contains both the named
516 // font and the generic fallback.
517 let parley_family = to_parley_family(&family);
518
519 // The list should be a multi-entry family list, not a single generic.
520 match parley_family {
521 parley::FontFamily::List(list) => {
522 // Should have at least 2 entries: the named font + the generic fallback.
523 assert!(
524 list.len() >= 2,
525 "expected at least 2 family entries (named + generic), got {}",
526 list.len()
527 );
528 // The last entry should be the generic family (Generic, not Named).
529 if let Some(last) = list.last() {
530 assert!(
531 matches!(last, parley::FontFamilyName::Generic(_)),
532 "expected last entry to be a generic family, got {:?}",
533 last
534 );
535 }
536 }
537 _ => panic!("expected FontFamily::List variant from stack_with_generic"),
538 }
539 }
540}