Skip to main content

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}