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/// Where the leading — the difference between a line's box and the font's own
88/// ascent-plus-descent extent — is spent.
89#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, Default)]
90pub enum LineHeightAlignment {
91    /// All of it below the glyphs.
92    Top,
93    /// Split evenly, with the odd whole pixel below the baseline.
94    Center,
95    #[default]
96    /// Split in the font's own ascent-to-descent ratio.
97    Proportional,
98    /// All of it above the glyphs.
99    Bottom,
100}
101
102/// Which edges of a text block give their leading back.
103#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, Default)]
104pub enum LineHeightTrim {
105    FirstLineTop,
106    LastLineBottom,
107    #[default]
108    Both,
109    None,
110}
111
112/// What a requested line height means when the font does not fit inside it.
113#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, Default)]
114pub enum LineHeightMode {
115    /// The request wins and the glyphs overflow their box.
116    #[default]
117    Fixed,
118    /// The font's own extent is a floor. This is what Android does.
119    Minimum,
120    /// The font's extent, whatever was requested.
121    Tight,
122}
123
124/// How a line of text sits inside the height it was given.
125///
126/// A style that names one is laid out by AOSP's `StaticLayout` rule — whole-pixel
127/// metrics, a font that a short line height cannot shrink, and the odd pixel of
128/// leading below the baseline. A style that names none keeps the framework's
129/// plain arithmetic: the box is exactly the requested height with the leading
130/// split evenly. The two disagree by a device pixel on most faces, so a screen
131/// that draws through a [`crate::DrawScope`] and composes `Text` in the same
132/// frame has to state the same policy on both or the two sets of rows will not
133/// line up.
134#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)]
135pub struct LineHeightStyle {
136    pub alignment: LineHeightAlignment,
137    pub trim: LineHeightTrim,
138    pub mode: LineHeightMode,
139}
140
141impl Default for LineHeightStyle {
142    fn default() -> Self {
143        Self {
144            alignment: LineHeightAlignment::Proportional,
145            trim: LineHeightTrim::Both,
146            mode: LineHeightMode::Fixed,
147        }
148    }
149}
150
151/// Everything [`crate::DrawScope`] needs to measure and draw a run of text.
152///
153/// A style is a plain value: sizes are already in scope (logical) units, and
154/// every field is resolved — there is no inheritance or theme lookup at draw
155/// time. Build one once and reuse it; measurement is cached on the
156/// `(text, style)` pair, so a style rebuilt with identical values still hits
157/// the cache.
158#[derive(Clone, Debug, PartialEq)]
159pub struct TextStyle {
160    /// Family name to resolve against the fonts the app registered. `None`
161    /// asks for the framework's default family.
162    ///
163    /// Only *named* families resolve: file-backed families are loaded by the
164    /// app at startup and looked up by the name in their font tables.
165    pub font_family: Option<String>,
166    pub font_size: f32,
167    pub font_weight: FontWeight,
168    pub font_style: FontStyle,
169    /// Extra advance inserted between characters, in scope units.
170    pub letter_spacing: f32,
171    /// Distance between consecutive baselines. `None` uses the font's natural
172    /// line height.
173    pub line_height: Option<f32>,
174    /// How the line sits inside that height. `None` takes the framework's plain
175    /// split; naming one asks for the platform rule, and is what makes a drawn
176    /// run land on the same rows as a composed `Text` of the same style.
177    pub line_height_style: Option<LineHeightStyle>,
178    pub align: TextAlign,
179    pub vertical_align: TextVerticalAlign,
180}
181
182impl TextStyle {
183    /// The size used when a style carries a non-positive or non-finite one.
184    /// Matches the framework-wide text default.
185    pub const DEFAULT_FONT_SIZE: f32 = 14.0;
186
187    pub fn new(font_size: f32) -> Self {
188        Self {
189            font_size,
190            ..Self::default()
191        }
192    }
193
194    pub fn with_font_family(mut self, family: impl Into<String>) -> Self {
195        let family = family.into();
196        self.font_family = (!family.is_empty()).then_some(family);
197        self
198    }
199
200    pub fn with_font_size(mut self, font_size: f32) -> Self {
201        self.font_size = font_size;
202        self
203    }
204
205    pub fn with_weight(mut self, weight: FontWeight) -> Self {
206        self.font_weight = weight;
207        self
208    }
209
210    pub fn with_style(mut self, style: FontStyle) -> Self {
211        self.font_style = style;
212        self
213    }
214
215    pub fn with_letter_spacing(mut self, letter_spacing: f32) -> Self {
216        self.letter_spacing = letter_spacing;
217        self
218    }
219
220    pub fn with_line_height(mut self, line_height: f32) -> Self {
221        self.line_height = line_height.is_finite().then_some(line_height);
222        self
223    }
224
225    /// Asks for a line-height policy, which is what makes this run resolve its
226    /// line box by the same rule a `Text` composable of the same style does.
227    pub fn with_line_height_style(mut self, line_height_style: LineHeightStyle) -> Self {
228        self.line_height_style = Some(line_height_style);
229        self
230    }
231
232    pub fn with_align(mut self, align: TextAlign) -> Self {
233        self.align = align;
234        self
235    }
236
237    pub fn with_vertical_align(mut self, vertical_align: TextVerticalAlign) -> Self {
238        self.vertical_align = vertical_align;
239        self
240    }
241
242    /// The font size a measurer/rasterizer will actually use. Non-finite and
243    /// non-positive sizes fall back to [`TextStyle::DEFAULT_FONT_SIZE`] instead
244    /// of producing NaN geometry.
245    pub fn resolved_font_size(&self) -> f32 {
246        if self.font_size.is_finite() && self.font_size > 0.0 {
247            self.font_size
248        } else {
249            Self::DEFAULT_FONT_SIZE
250        }
251    }
252
253    /// The letter spacing a measurer will actually use.
254    pub fn resolved_letter_spacing(&self) -> f32 {
255        if self.letter_spacing.is_finite() {
256            self.letter_spacing
257        } else {
258            0.0
259        }
260    }
261
262    /// The line height a measurer will actually use, given the font's natural
263    /// one. `natural` is only consulted when the style leaves it unset.
264    pub fn resolved_line_height(&self, natural: f32) -> f32 {
265        match self.line_height {
266            Some(height) if height.is_finite() && height > 0.0 => height,
267            _ => natural,
268        }
269    }
270}
271
272impl Default for TextStyle {
273    fn default() -> Self {
274        Self {
275            font_family: None,
276            font_size: Self::DEFAULT_FONT_SIZE,
277            font_weight: FontWeight::NORMAL,
278            font_style: FontStyle::Normal,
279            letter_spacing: 0.0,
280            line_height: None,
281            line_height_style: None,
282            align: TextAlign::Left,
283            vertical_align: TextVerticalAlign::Top,
284        }
285    }
286}
287
288/// What a string occupies once laid out — the answer
289/// [`DrawScope::measure_text`](crate::DrawScope::measure_text) gives, and
290/// exactly the box `draw_text` fills.
291#[derive(Clone, Copy, Debug, PartialEq)]
292pub struct TextMeasurement {
293    /// Tight block size: the widest line by its total advance, and
294    /// `line_count * line_height` tall.
295    pub size: Size,
296    /// Baseline-to-baseline distance.
297    pub line_height: f32,
298    /// Distance from the top of the block down to the first line's baseline.
299    /// Subtract it from a baseline y to get the top-left a `_at` draw wants.
300    pub first_baseline: f32,
301    /// Number of laid-out lines. `1` for an empty string.
302    pub line_count: usize,
303}
304
305impl TextMeasurement {
306    /// The measurement of an empty string: no extent, but still one line's
307    /// worth of vertical metrics so callers can lay out an empty label.
308    pub fn empty(line_height: f32, first_baseline: f32) -> Self {
309        Self {
310            size: Size::ZERO,
311            line_height,
312            first_baseline,
313            line_count: 1,
314        }
315    }
316}
317
318/// Font-backed measurement, injected into a [`crate::DrawScopeDefault`] by the
319/// UI layer.
320///
321/// This crate holds no fonts, so a draw scope cannot measure text on its own.
322/// `cranpose-ui` installs an implementation that forwards to the very text
323/// stack the `Text` composable uses, which is what keeps
324/// [`DrawScope::measure_text`](crate::DrawScope::measure_text) and the glyphs
325/// the renderer rasterizes in agreement. Without one installed a scope falls
326/// back to [`estimate_text_measurement`], which is good enough for layout
327/// smoke tests and wrong for anything that has to line up with real glyphs.
328pub trait DrawTextMeasurer {
329    fn measure_text(&self, text: &str, style: &TextStyle) -> TextMeasurement;
330}
331
332/// Font-free estimate used when no [`DrawTextMeasurer`] is installed.
333///
334/// Assumes a 0.6 em advance per character and the 0.8/-0.2 em ascent/descent
335/// split typical of a UI sans face, so the shape of the result (and the
336/// baseline formula) matches what a real font measurer returns even though the
337/// numbers do not.
338pub fn estimate_text_measurement(text: &str, style: &TextStyle) -> TextMeasurement {
339    const CHAR_WIDTH_RATIO: f32 = 0.6;
340    const ASCENT_RATIO: f32 = 0.8;
341    const NATURAL_LINE_HEIGHT_RATIO: f32 = 1.0;
342
343    let font_size = style.resolved_font_size();
344    let letter_spacing = style.resolved_letter_spacing().max(0.0);
345    let natural_line_height = font_size * NATURAL_LINE_HEIGHT_RATIO;
346    let line_height = style.resolved_line_height(font_size * 1.4);
347    let first_baseline = font_size * ASCENT_RATIO + (line_height - natural_line_height) * 0.5;
348
349    if text.is_empty() {
350        return TextMeasurement::empty(line_height, first_baseline);
351    }
352
353    let mut line_count = 0usize;
354    let mut width = 0.0f32;
355    for line in text.split('\n') {
356        line_count += 1;
357        let chars = line.chars().count();
358        // One letter space per character, not per gap: Android's Minikin puts
359        // half a letter space on each side of every cluster, so a run of `n`
360        // characters carries `n` of them. See `run_tracking` in
361        // `cranpose-render-common`'s `software_text_raster`.
362        let advance = chars as f32 * (font_size * CHAR_WIDTH_RATIO + letter_spacing);
363        width = width.max(advance);
364    }
365
366    TextMeasurement {
367        size: Size::new(width, line_count as f32 * line_height),
368        line_height,
369        first_baseline,
370        line_count,
371    }
372}
373
374#[cfg(test)]
375mod tests {
376    use super::*;
377
378    #[test]
379    fn text_style_resolves_degenerate_sizes_to_the_framework_default() {
380        for size in [0.0, -12.0, f32::NAN, f32::INFINITY] {
381            assert_eq!(
382                TextStyle::new(size).resolved_font_size(),
383                TextStyle::DEFAULT_FONT_SIZE,
384                "font size {size} must not reach a font backend"
385            );
386        }
387        assert_eq!(TextStyle::new(19.0).resolved_font_size(), 19.0);
388    }
389
390    #[test]
391    fn text_style_line_height_falls_back_to_the_natural_one() {
392        let style = TextStyle::new(20.0);
393        assert_eq!(style.resolved_line_height(28.0), 28.0);
394        assert_eq!(
395            style
396                .clone()
397                .with_line_height(40.0)
398                .resolved_line_height(28.0),
399            40.0
400        );
401        // A non-finite request is refused at the builder, not silently kept.
402        assert_eq!(
403            style.with_line_height(f32::NAN).resolved_line_height(28.0),
404            28.0
405        );
406    }
407
408    #[test]
409    fn empty_font_family_name_means_the_default_family() {
410        assert_eq!(TextStyle::new(14.0).with_font_family("").font_family, None);
411        assert_eq!(
412            TextStyle::new(14.0)
413                .with_font_family("Fira Sans")
414                .font_family,
415            Some("Fira Sans".to_string())
416        );
417    }
418
419    #[test]
420    fn estimated_measurement_grows_with_the_longest_line() {
421        let style = TextStyle::new(10.0);
422        let one = estimate_text_measurement("AAAA", &style);
423        let two = estimate_text_measurement("AAAA\nAAAAAAAA", &style);
424        assert_eq!(one.line_count, 1);
425        assert_eq!(two.line_count, 2);
426        assert!(two.size.width > one.size.width);
427        assert!((two.size.height - one.size.height * 2.0).abs() < 1e-3);
428    }
429
430    #[test]
431    fn estimated_measurement_of_an_empty_string_keeps_one_line_of_metrics() {
432        let measurement = estimate_text_measurement("", &TextStyle::new(16.0));
433        assert_eq!(measurement.size, Size::ZERO);
434        assert_eq!(measurement.line_count, 1);
435        assert!(measurement.line_height > 0.0);
436        assert!(measurement.first_baseline > 0.0);
437    }
438
439    #[test]
440    fn estimated_baseline_sits_inside_the_line_slot() {
441        let measurement = estimate_text_measurement("Ag", &TextStyle::new(24.0));
442        assert!(measurement.first_baseline > 0.0);
443        assert!(measurement.first_baseline < measurement.line_height);
444    }
445}