Skip to main content

cranpose_ui_graphics/
typography.rs

1//! Typography data structures (font styles, weights, text styles)
2//!
3//! These are the *drawing-side* text types: the smallest description of a run
4//! of text that [`crate::DrawScope`] can hand to a renderer. The full typography
5//! model (annotated strings, span/paragraph styles, decorations, hyphenation)
6//! lives in `cranpose-ui`, which is above this crate in the dependency graph —
7//! `cranpose-ui` maps a [`TextStyle`] onto that richer model, and both
8//! measurement and rasterization go through that one mapping.
9
10use crate::geometry::Size;
11
12/// Font style (normal, italic, oblique)
13#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Hash)]
14pub enum FontStyle {
15    #[default]
16    Normal,
17    Italic,
18    /// Rendered as [`FontStyle::Italic`]; no font in the stack ships a separate
19    /// oblique face.
20    Oblique,
21}
22
23/// Font weight (100-900)
24#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, PartialOrd, Ord)]
25pub struct FontWeight(pub u16);
26
27impl FontWeight {
28    pub const THIN: FontWeight = FontWeight(100);
29    pub const EXTRA_LIGHT: FontWeight = FontWeight(200);
30    pub const LIGHT: FontWeight = FontWeight(300);
31    pub const NORMAL: FontWeight = FontWeight(400);
32    pub const MEDIUM: FontWeight = FontWeight(500);
33    pub const SEMI_BOLD: FontWeight = FontWeight(600);
34    pub const BOLD: FontWeight = FontWeight(700);
35    pub const EXTRA_BOLD: FontWeight = FontWeight(800);
36    pub const BLACK: FontWeight = FontWeight(900);
37
38    /// Clamps to the `1..=1000` range every font backend accepts.
39    pub const fn new(weight: u16) -> Self {
40        if weight < 1 {
41            Self(1)
42        } else if weight > 1000 {
43            Self(1000)
44        } else {
45            Self(weight)
46        }
47    }
48
49    pub const fn value(self) -> u16 {
50        self.0
51    }
52}
53
54impl Default for FontWeight {
55    fn default() -> Self {
56        Self::NORMAL
57    }
58}
59
60/// Horizontal placement of the text block inside the box it is drawn in.
61///
62/// This aligns the *block*, not the individual lines: every line of a
63/// multi-line string starts at the block's left edge, matching how the
64/// framework's `Text` composable is laid out.
65#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Hash)]
66pub enum TextAlign {
67    #[default]
68    Left,
69    Center,
70    Right,
71}
72
73/// Vertical placement of the text block inside the box it is drawn in.
74#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Hash)]
75pub enum TextVerticalAlign {
76    #[default]
77    Top,
78    Center,
79    Bottom,
80    /// The box's **top edge** is the first line's baseline. Use this when a
81    /// layout is specified in baselines rather than boxes; the text then
82    /// extends above the edge by
83    /// [`TextMeasurement::first_baseline`](crate::TextMeasurement::first_baseline).
84    Baseline,
85}
86
87/// Everything [`crate::DrawScope`] needs to measure and draw a run of text.
88///
89/// A style is a plain value: sizes are already in scope (logical) units, and
90/// every field is resolved — there is no inheritance or theme lookup at draw
91/// time. Build one once and reuse it; measurement is cached on the
92/// `(text, style)` pair, so a style rebuilt with identical values still hits
93/// the cache.
94#[derive(Clone, Debug, PartialEq)]
95pub struct TextStyle {
96    /// Family name to resolve against the fonts the app registered. `None`
97    /// asks for the framework's default family.
98    ///
99    /// Only *named* families resolve: file-backed families are loaded by the
100    /// app at startup and looked up by the name in their font tables.
101    pub font_family: Option<String>,
102    pub font_size: f32,
103    pub font_weight: FontWeight,
104    pub font_style: FontStyle,
105    /// Extra advance inserted between characters, in scope units.
106    pub letter_spacing: f32,
107    /// Distance between consecutive baselines. `None` uses the font's natural
108    /// line height.
109    pub line_height: Option<f32>,
110    pub align: TextAlign,
111    pub vertical_align: TextVerticalAlign,
112}
113
114impl TextStyle {
115    /// The size used when a style carries a non-positive or non-finite one.
116    /// Matches the framework-wide text default.
117    pub const DEFAULT_FONT_SIZE: f32 = 14.0;
118
119    pub fn new(font_size: f32) -> Self {
120        Self {
121            font_size,
122            ..Self::default()
123        }
124    }
125
126    pub fn with_font_family(mut self, family: impl Into<String>) -> Self {
127        let family = family.into();
128        self.font_family = (!family.is_empty()).then_some(family);
129        self
130    }
131
132    pub fn with_font_size(mut self, font_size: f32) -> Self {
133        self.font_size = font_size;
134        self
135    }
136
137    pub fn with_weight(mut self, weight: FontWeight) -> Self {
138        self.font_weight = weight;
139        self
140    }
141
142    pub fn with_style(mut self, style: FontStyle) -> Self {
143        self.font_style = style;
144        self
145    }
146
147    pub fn with_letter_spacing(mut self, letter_spacing: f32) -> Self {
148        self.letter_spacing = letter_spacing;
149        self
150    }
151
152    pub fn with_line_height(mut self, line_height: f32) -> Self {
153        self.line_height = line_height.is_finite().then_some(line_height);
154        self
155    }
156
157    pub fn with_align(mut self, align: TextAlign) -> Self {
158        self.align = align;
159        self
160    }
161
162    pub fn with_vertical_align(mut self, vertical_align: TextVerticalAlign) -> Self {
163        self.vertical_align = vertical_align;
164        self
165    }
166
167    /// The font size a measurer/rasterizer will actually use. Non-finite and
168    /// non-positive sizes fall back to [`TextStyle::DEFAULT_FONT_SIZE`] instead
169    /// of producing NaN geometry.
170    pub fn resolved_font_size(&self) -> f32 {
171        if self.font_size.is_finite() && self.font_size > 0.0 {
172            self.font_size
173        } else {
174            Self::DEFAULT_FONT_SIZE
175        }
176    }
177
178    /// The letter spacing a measurer will actually use.
179    pub fn resolved_letter_spacing(&self) -> f32 {
180        if self.letter_spacing.is_finite() {
181            self.letter_spacing
182        } else {
183            0.0
184        }
185    }
186
187    /// The line height a measurer will actually use, given the font's natural
188    /// one. `natural` is only consulted when the style leaves it unset.
189    pub fn resolved_line_height(&self, natural: f32) -> f32 {
190        match self.line_height {
191            Some(height) if height.is_finite() && height > 0.0 => height,
192            _ => natural,
193        }
194    }
195}
196
197impl Default for TextStyle {
198    fn default() -> Self {
199        Self {
200            font_family: None,
201            font_size: Self::DEFAULT_FONT_SIZE,
202            font_weight: FontWeight::NORMAL,
203            font_style: FontStyle::Normal,
204            letter_spacing: 0.0,
205            line_height: None,
206            align: TextAlign::Left,
207            vertical_align: TextVerticalAlign::Top,
208        }
209    }
210}
211
212/// What a string occupies once laid out — the answer
213/// [`DrawScope::measure_text`](crate::DrawScope::measure_text) gives, and
214/// exactly the box `draw_text` fills.
215#[derive(Clone, Copy, Debug, PartialEq)]
216pub struct TextMeasurement {
217    /// Tight block size: the widest line by its total advance, and
218    /// `line_count * line_height` tall.
219    pub size: Size,
220    /// Baseline-to-baseline distance.
221    pub line_height: f32,
222    /// Distance from the top of the block down to the first line's baseline.
223    /// Subtract it from a baseline y to get the top-left a `_at` draw wants.
224    pub first_baseline: f32,
225    /// Number of laid-out lines. `1` for an empty string.
226    pub line_count: usize,
227}
228
229impl TextMeasurement {
230    /// The measurement of an empty string: no extent, but still one line's
231    /// worth of vertical metrics so callers can lay out an empty label.
232    pub fn empty(line_height: f32, first_baseline: f32) -> Self {
233        Self {
234            size: Size::ZERO,
235            line_height,
236            first_baseline,
237            line_count: 1,
238        }
239    }
240}
241
242/// Font-backed measurement, injected into a [`crate::DrawScopeDefault`] by the
243/// UI layer.
244///
245/// This crate holds no fonts, so a draw scope cannot measure text on its own.
246/// `cranpose-ui` installs an implementation that forwards to the very text
247/// stack the `Text` composable uses, which is what keeps
248/// [`DrawScope::measure_text`](crate::DrawScope::measure_text) and the glyphs
249/// the renderer rasterizes in agreement. Without one installed a scope falls
250/// back to [`estimate_text_measurement`], which is good enough for layout
251/// smoke tests and wrong for anything that has to line up with real glyphs.
252pub trait DrawTextMeasurer {
253    fn measure_text(&self, text: &str, style: &TextStyle) -> TextMeasurement;
254}
255
256/// Font-free estimate used when no [`DrawTextMeasurer`] is installed.
257///
258/// Assumes a 0.6 em advance per character and the 0.8/-0.2 em ascent/descent
259/// split typical of a UI sans face, so the shape of the result (and the
260/// baseline formula) matches what a real font measurer returns even though the
261/// numbers do not.
262pub fn estimate_text_measurement(text: &str, style: &TextStyle) -> TextMeasurement {
263    const CHAR_WIDTH_RATIO: f32 = 0.6;
264    const ASCENT_RATIO: f32 = 0.8;
265    const NATURAL_LINE_HEIGHT_RATIO: f32 = 1.0;
266
267    let font_size = style.resolved_font_size();
268    let letter_spacing = style.resolved_letter_spacing().max(0.0);
269    let natural_line_height = font_size * NATURAL_LINE_HEIGHT_RATIO;
270    let line_height = style.resolved_line_height(font_size * 1.4);
271    let first_baseline = font_size * ASCENT_RATIO + (line_height - natural_line_height) * 0.5;
272
273    if text.is_empty() {
274        return TextMeasurement::empty(line_height, first_baseline);
275    }
276
277    let mut line_count = 0usize;
278    let mut width = 0.0f32;
279    for line in text.split('\n') {
280        line_count += 1;
281        let chars = line.chars().count();
282        let advance = chars as f32 * font_size * CHAR_WIDTH_RATIO
283            + chars.saturating_sub(1) as f32 * letter_spacing;
284        width = width.max(advance);
285    }
286
287    TextMeasurement {
288        size: Size::new(width, line_count as f32 * line_height),
289        line_height,
290        first_baseline,
291        line_count,
292    }
293}
294
295#[cfg(test)]
296mod tests {
297    use super::*;
298
299    #[test]
300    fn text_style_resolves_degenerate_sizes_to_the_framework_default() {
301        for size in [0.0, -12.0, f32::NAN, f32::INFINITY] {
302            assert_eq!(
303                TextStyle::new(size).resolved_font_size(),
304                TextStyle::DEFAULT_FONT_SIZE,
305                "font size {size} must not reach a font backend"
306            );
307        }
308        assert_eq!(TextStyle::new(19.0).resolved_font_size(), 19.0);
309    }
310
311    #[test]
312    fn text_style_line_height_falls_back_to_the_natural_one() {
313        let style = TextStyle::new(20.0);
314        assert_eq!(style.resolved_line_height(28.0), 28.0);
315        assert_eq!(
316            style
317                .clone()
318                .with_line_height(40.0)
319                .resolved_line_height(28.0),
320            40.0
321        );
322        // A non-finite request is refused at the builder, not silently kept.
323        assert_eq!(
324            style.with_line_height(f32::NAN).resolved_line_height(28.0),
325            28.0
326        );
327    }
328
329    #[test]
330    fn empty_font_family_name_means_the_default_family() {
331        assert_eq!(TextStyle::new(14.0).with_font_family("").font_family, None);
332        assert_eq!(
333            TextStyle::new(14.0)
334                .with_font_family("Fira Sans")
335                .font_family,
336            Some("Fira Sans".to_string())
337        );
338    }
339
340    #[test]
341    fn estimated_measurement_grows_with_the_longest_line() {
342        let style = TextStyle::new(10.0);
343        let one = estimate_text_measurement("AAAA", &style);
344        let two = estimate_text_measurement("AAAA\nAAAAAAAA", &style);
345        assert_eq!(one.line_count, 1);
346        assert_eq!(two.line_count, 2);
347        assert!(two.size.width > one.size.width);
348        assert!((two.size.height - one.size.height * 2.0).abs() < 1e-3);
349    }
350
351    #[test]
352    fn estimated_measurement_of_an_empty_string_keeps_one_line_of_metrics() {
353        let measurement = estimate_text_measurement("", &TextStyle::new(16.0));
354        assert_eq!(measurement.size, Size::ZERO);
355        assert_eq!(measurement.line_count, 1);
356        assert!(measurement.line_height > 0.0);
357        assert!(measurement.first_baseline > 0.0);
358    }
359
360    #[test]
361    fn estimated_baseline_sits_inside_the_line_slot() {
362        let measurement = estimate_text_measurement("Ag", &TextStyle::new(24.0));
363        assert!(measurement.first_baseline > 0.0);
364        assert!(measurement.first_baseline < measurement.line_height);
365    }
366}