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 [`DrawTextStyle`] 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    /// Upright letterforms.
16    #[default]
17    Normal,
18    /// Slanted letterforms from the face's italic design.
19    Italic,
20    /// Rendered as [`FontStyle::Italic`]; no font in the stack ships a separate
21    /// oblique face.
22    Oblique,
23}
24
25/// Font weight (100-900)
26#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, PartialOrd, Ord)]
27pub struct FontWeight(pub u16);
28
29impl FontWeight {
30    /// Thin, weight 100.
31    pub const THIN: Self = Self(100);
32    /// Extra light, weight 200.
33    pub const EXTRA_LIGHT: Self = Self(200);
34    /// Light, weight 300.
35    pub const LIGHT: Self = Self(300);
36    /// Normal, weight 400.
37    pub const NORMAL: Self = Self(400);
38    /// Medium, weight 500.
39    pub const MEDIUM: Self = Self(500);
40    /// Semi bold, weight 600.
41    pub const SEMI_BOLD: Self = Self(600);
42    /// Bold, weight 700.
43    pub const BOLD: Self = Self(700);
44    /// Extra bold, weight 800.
45    pub const EXTRA_BOLD: Self = Self(800);
46    /// Black, weight 900.
47    pub const BLACK: Self = Self(900);
48
49    /// Clamps to the `1..=1000` range every font backend accepts.
50    pub const fn new(weight: u16) -> Self {
51        if weight < 1 {
52            Self(1)
53        } else if weight > 1000 {
54            Self(1000)
55        } else {
56            Self(weight)
57        }
58    }
59
60    /// Returns the weight as a number from 1 to 1000.
61    pub const fn value(self) -> u16 {
62        self.0
63    }
64}
65
66impl Default for FontWeight {
67    fn default() -> Self {
68        Self::NORMAL
69    }
70}
71
72/// Horizontal placement of the text block inside the box it is drawn in.
73///
74/// This aligns the *block*, not the individual lines: every line of a
75/// multi-line string starts at the block's left edge, matching how the
76/// framework's `Text` composable is laid out.
77#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Hash)]
78pub enum TextAlign {
79    /// The block starts at the left edge of the box.
80    #[default]
81    Left,
82    /// The block is centered horizontally in the box.
83    Center,
84    /// The block ends at the right edge of the box.
85    Right,
86}
87
88/// Vertical placement of the text block inside the box it is drawn in.
89#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Hash)]
90pub enum TextVerticalAlign {
91    /// The text block starts at the top of the box.
92    #[default]
93    Top,
94    /// The text block is centered vertically in the box.
95    Center,
96    /// The text block ends at the bottom of the box.
97    Bottom,
98    /// The box's **top edge** is the first line's baseline. Use this when a
99    /// layout is specified in baselines rather than boxes; the text then
100    /// extends above the edge by
101    /// [`TextMeasurement::first_baseline`](crate::TextMeasurement::first_baseline).
102    Baseline,
103}
104
105/// Where the leading — the difference between a line's box and the font's own
106/// ascent-plus-descent extent — is spent.
107#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, Default)]
108pub enum LineHeightAlignment {
109    /// All of it below the glyphs.
110    Top,
111    /// Split evenly, with the odd whole pixel below the baseline.
112    Center,
113    #[default]
114    /// Split in the font's own ascent-to-descent ratio.
115    Proportional,
116    /// All of it above the glyphs.
117    Bottom,
118}
119
120/// Which edges of a text block give their leading back.
121#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, Default)]
122pub enum LineHeightTrim {
123    /// The first line gives back its leading above the glyphs.
124    FirstLineTop,
125    /// The last line gives back its leading below the glyphs.
126    LastLineBottom,
127    /// The first line gives back its leading above the glyphs and the last line below them.
128    #[default]
129    Both,
130    /// Neither edge gives its leading back.
131    None,
132}
133
134/// What a requested line height means when the font does not fit inside it.
135#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, Default)]
136pub enum LineHeightMode {
137    /// The request wins and the glyphs overflow their box.
138    #[default]
139    Fixed,
140    /// The font's own extent is a floor. This is what Android does.
141    Minimum,
142    /// The request on every line, as [`Fixed`](Self::Fixed) asks, but a trimmed
143    /// paragraph edge gets no room back where the font needs more, so its
144    /// glyphs may clip there.
145    Tight,
146}
147
148/// How a line of text sits inside the height it was given, and what a
149/// paragraph gives back at its edges.
150///
151/// Text is laid out by AOSP's `StaticLayout` rule, as Jetpack Compose lays it
152/// out: whole-pixel metrics, the leading placed by `alignment`, and the
153/// leading above the first line and below the last given back by `trim`. The
154/// [`Default`] is Compose's own, which is what a style that names none gets.
155#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)]
156pub struct LineHeightStyle {
157    /// Where the leading of each line is spent.
158    pub alignment: LineHeightAlignment,
159    /// Which edges of the text block give their leading back.
160    pub trim: LineHeightTrim,
161    /// What a requested line height means when the font does not fit inside it.
162    pub mode: LineHeightMode,
163}
164
165impl Default for LineHeightStyle {
166    fn default() -> Self {
167        Self {
168            alignment: LineHeightAlignment::Proportional,
169            trim: LineHeightTrim::Both,
170            mode: LineHeightMode::Fixed,
171        }
172    }
173}
174
175/// Everything [`crate::DrawScope`] needs to measure and draw a run of text.
176///
177/// A style is a plain value: sizes are already in scope (logical) units, and
178/// every field is resolved — there is no inheritance or theme lookup at draw
179/// time. Build one once and reuse it; measurement is cached on the
180/// `(text, style)` pair, so a style rebuilt with identical values still hits
181/// the cache.
182#[derive(Clone, Debug, PartialEq)]
183pub struct DrawTextStyle {
184    /// Family name to resolve against the fonts the app registered. `None`
185    /// asks for the framework's default family.
186    ///
187    /// Only *named* families resolve: file-backed families are loaded by the
188    /// app at startup and looked up by the name in their font tables.
189    pub font_family: Option<String>,
190    /// Size of the text, in scope units. A non-positive or non-finite value uses
191    /// `DEFAULT_FONT_SIZE`.
192    pub font_size: f32,
193    /// Weight of the face, from 1 to 1000.
194    pub font_weight: FontWeight,
195    /// Normal or italic face.
196    pub font_style: FontStyle,
197    /// Extra advance inserted between characters, in scope units.
198    pub letter_spacing: f32,
199    /// Distance between consecutive baselines. `None` uses the font's natural
200    /// line height.
201    pub line_height: Option<f32>,
202    /// How the line sits inside that height. `None` takes
203    /// [`LineHeightStyle::default`], as a composed `Text` does.
204    pub line_height_style: Option<LineHeightStyle>,
205    /// How the text block is placed horizontally inside its box.
206    pub align: TextAlign,
207    /// How the text block is placed vertically inside its box.
208    pub vertical_align: TextVerticalAlign,
209}
210
211impl DrawTextStyle {
212    /// The size used when a style carries a non-positive or non-finite one.
213    /// Matches the framework-wide text default.
214    pub const DEFAULT_FONT_SIZE: f32 = 14.0;
215
216    /// Creates a style with the given font size, in scope units, and defaults for every other
217    /// field.
218    pub fn new(font_size: f32) -> Self {
219        Self {
220            font_size,
221            ..Self::default()
222        }
223    }
224
225    /// Sets the font family by name. An empty name selects the framework default family.
226    pub fn with_font_family(mut self, family: impl Into<String>) -> Self {
227        let family = family.into();
228        self.font_family = (!family.is_empty()).then_some(family);
229        self
230    }
231
232    /// Sets the font size, in scope units.
233    pub fn with_font_size(mut self, font_size: f32) -> Self {
234        self.font_size = font_size;
235        self
236    }
237
238    /// Sets the font weight.
239    pub fn with_weight(mut self, weight: FontWeight) -> Self {
240        self.font_weight = weight;
241        self
242    }
243
244    /// Sets the font style.
245    pub fn with_style(mut self, style: FontStyle) -> Self {
246        self.font_style = style;
247        self
248    }
249
250    /// Sets the extra advance between characters, in scope units.
251    pub fn with_letter_spacing(mut self, letter_spacing: f32) -> Self {
252        self.letter_spacing = letter_spacing;
253        self
254    }
255
256    /// Sets the distance between baselines, in scope units. A non-finite value clears it.
257    pub fn with_line_height(mut self, line_height: f32) -> Self {
258        self.line_height = line_height.is_finite().then_some(line_height);
259        self
260    }
261
262    /// Asks for a line-height policy other than the default one.
263    pub fn with_line_height_style(mut self, line_height_style: LineHeightStyle) -> Self {
264        self.line_height_style = Some(line_height_style);
265        self
266    }
267
268    /// Sets how the text block is placed horizontally inside its box.
269    pub fn with_align(mut self, align: TextAlign) -> Self {
270        self.align = align;
271        self
272    }
273
274    /// Sets how the text block is placed vertically inside its box.
275    pub fn with_vertical_align(mut self, vertical_align: TextVerticalAlign) -> Self {
276        self.vertical_align = vertical_align;
277        self
278    }
279
280    /// The font size a measurer/rasterizer will actually use. Non-finite and
281    /// non-positive sizes fall back to [`DrawTextStyle::DEFAULT_FONT_SIZE`] instead
282    /// of producing NaN geometry.
283    pub fn resolved_font_size(&self) -> f32 {
284        if self.font_size.is_finite() && self.font_size > 0.0 {
285            self.font_size
286        } else {
287            Self::DEFAULT_FONT_SIZE
288        }
289    }
290
291    /// The letter spacing a measurer will actually use.
292    pub fn resolved_letter_spacing(&self) -> f32 {
293        if self.letter_spacing.is_finite() {
294            self.letter_spacing
295        } else {
296            0.0
297        }
298    }
299
300    /// The line height a measurer will actually use, given the font's natural
301    /// one. `natural` is only consulted when the style leaves it unset.
302    pub fn resolved_line_height(&self, natural: f32) -> f32 {
303        match self.line_height {
304            Some(height) if height.is_finite() && height > 0.0 => height,
305            _ => natural,
306        }
307    }
308}
309
310impl Default for DrawTextStyle {
311    fn default() -> Self {
312        Self {
313            font_family: None,
314            font_size: Self::DEFAULT_FONT_SIZE,
315            font_weight: FontWeight::NORMAL,
316            font_style: FontStyle::Normal,
317            letter_spacing: 0.0,
318            line_height: None,
319            line_height_style: None,
320            align: TextAlign::Left,
321            vertical_align: TextVerticalAlign::Top,
322        }
323    }
324}
325
326/// What a string occupies once laid out — the answer
327/// [`DrawScope::measure_text`](crate::DrawScope::measure_text) gives, and
328/// exactly the box `draw_text` fills.
329#[derive(Clone, Copy, Debug, PartialEq)]
330pub struct TextMeasurement {
331    /// Tight block size: the widest line by its total advance, and
332    /// `line_count * line_height` tall.
333    pub size: Size,
334    /// Baseline-to-baseline distance.
335    pub line_height: f32,
336    /// Distance from the top of the block down to the first line's baseline.
337    /// Subtract it from a baseline y to get the top-left a `_at` draw wants.
338    pub first_baseline: f32,
339    /// Number of laid-out lines. `1` for an empty string.
340    pub line_count: usize,
341}
342
343impl TextMeasurement {
344    /// The measurement of an empty string: no extent, but still one line's
345    /// worth of vertical metrics so callers can lay out an empty label.
346    pub fn empty(line_height: f32, first_baseline: f32) -> Self {
347        Self {
348            size: Size::ZERO,
349            line_height,
350            first_baseline,
351            line_count: 1,
352        }
353    }
354}
355
356/// Font-backed measurement, injected into a [`crate::DrawScopeDefault`] by the
357/// UI layer.
358///
359/// This crate holds no fonts, so a draw scope cannot measure text on its own.
360/// `cranpose-ui` installs an implementation that forwards to the very text
361/// stack the `Text` composable uses, which is what keeps
362/// [`DrawScope::measure_text`](crate::DrawScope::measure_text) and the glyphs
363/// the renderer rasterizes in agreement. Without one installed a scope falls
364/// back to [`estimate_text_measurement`], which is good enough for layout
365/// smoke tests and wrong for anything that has to line up with real glyphs.
366pub trait DrawTextMeasurer {
367    /// `text` laid out in `style`: its size and line metrics, and the layout
368    /// measured.
369    fn measure_text(&self, text: &str, style: &DrawTextStyle) -> MeasuredText;
370}
371
372/// A text a [`DrawTextMeasurer`] measured.
373#[derive(Clone)]
374pub struct MeasuredText {
375    /// The size and line metrics.
376    pub measurement: TextMeasurement,
377    /// The layout measured, which a draw scope hands its renderer so the
378    /// glyphs drawn are the ones measured. `None` from a measurer with no
379    /// layouts.
380    pub layout: Option<TextLayoutHandle>,
381}
382
383/// The layout the text system made of a text. It is opaque here, below the
384/// text system; a renderer reads it back as the text system's layout type.
385/// Two handles are equal when they hold the same layout.
386#[derive(Clone)]
387pub struct TextLayoutHandle(std::sync::Arc<dyn std::any::Any + Send + Sync>);
388
389impl<T: std::any::Any + Send + Sync> From<std::sync::Arc<T>> for TextLayoutHandle {
390    fn from(layout: std::sync::Arc<T>) -> Self {
391        Self(layout)
392    }
393}
394
395impl TextLayoutHandle {
396    /// The layout as a `T`, `None` when it is of another type.
397    pub fn downcast<T: std::any::Any + Send + Sync>(&self) -> Option<std::sync::Arc<T>> {
398        std::sync::Arc::clone(&self.0).downcast::<T>().ok()
399    }
400}
401
402impl PartialEq for TextLayoutHandle {
403    fn eq(&self, other: &Self) -> bool {
404        std::ptr::addr_eq(
405            std::sync::Arc::as_ptr(&self.0),
406            std::sync::Arc::as_ptr(&other.0),
407        )
408    }
409}
410
411impl std::fmt::Debug for TextLayoutHandle {
412    fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
413        formatter.write_str("TextLayoutHandle")
414    }
415}
416
417/// Font-free estimate used when no [`DrawTextMeasurer`] is installed.
418///
419/// Assumes a 0.6 em advance per character and the 0.8/-0.2 em ascent/descent
420/// split typical of a UI sans face, laid out by the default
421/// [`LineHeightStyle`]: a line without a requested height is the face's own
422/// 1 em, and the leading a requested one adds is split in the face's
423/// proportions and given back above the first line and below the last. So the
424/// shape of the result matches what a real font measurer returns even though
425/// the numbers do not.
426pub fn estimate_text_measurement(text: &str, style: &DrawTextStyle) -> TextMeasurement {
427    const CHAR_WIDTH_RATIO: f32 = 0.6;
428    const ASCENT_RATIO: f32 = 0.8;
429    const NATURAL_LINE_HEIGHT_RATIO: f32 = 1.0;
430
431    let font_size = style.resolved_font_size();
432    let letter_spacing = style.resolved_letter_spacing().max(0.0);
433    let natural_line_height = font_size * NATURAL_LINE_HEIGHT_RATIO;
434    let line_height = style.resolved_line_height(natural_line_height);
435    let leading = line_height - natural_line_height;
436    let first_baseline = font_size * ASCENT_RATIO;
437
438    if text.is_empty() {
439        return TextMeasurement::empty(line_height, first_baseline);
440    }
441
442    let mut line_count = 0usize;
443    let mut width = 0.0f32;
444    for line in text.split('\n') {
445        line_count += 1;
446        let chars = line.chars().count();
447        let advance = chars as f32 * (font_size * CHAR_WIDTH_RATIO + letter_spacing);
448        width = width.max(advance);
449    }
450
451    TextMeasurement {
452        size: Size::new(width, line_count as f32 * line_height - leading),
453        line_height,
454        first_baseline,
455        line_count,
456    }
457}
458
459#[cfg(test)]
460#[path = "tests/typography_tests.rs"]
461mod tests;