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: FontWeight = FontWeight(100);
32 /// Extra light, weight 200.
33 pub const EXTRA_LIGHT: FontWeight = FontWeight(200);
34 /// Light, weight 300.
35 pub const LIGHT: FontWeight = FontWeight(300);
36 /// Normal, weight 400.
37 pub const NORMAL: FontWeight = FontWeight(400);
38 /// Medium, weight 500.
39 pub const MEDIUM: FontWeight = FontWeight(500);
40 /// Semi bold, weight 600.
41 pub const SEMI_BOLD: FontWeight = FontWeight(600);
42 /// Bold, weight 700.
43 pub const BOLD: FontWeight = FontWeight(700);
44 /// Extra bold, weight 800.
45 pub const EXTRA_BOLD: FontWeight = FontWeight(800);
46 /// Black, weight 900.
47 pub const BLACK: FontWeight = FontWeight(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 font's extent, whatever was requested.
143 Tight,
144}
145
146/// How a line of text sits inside the height it was given, and what a
147/// paragraph gives back at its edges.
148///
149/// Text is laid out by AOSP's `StaticLayout` rule, as Jetpack Compose lays it
150/// out: whole-pixel metrics, the leading placed by `alignment`, and the
151/// leading above the first line and below the last given back by `trim`. The
152/// [`Default`] is Compose's own, which is what a style that names none gets.
153#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)]
154pub struct LineHeightStyle {
155 /// Where the leading of each line is spent.
156 pub alignment: LineHeightAlignment,
157 /// Which edges of the text block give their leading back.
158 pub trim: LineHeightTrim,
159 /// What a requested line height means when the font does not fit inside it.
160 pub mode: LineHeightMode,
161}
162
163impl Default for LineHeightStyle {
164 fn default() -> Self {
165 Self {
166 alignment: LineHeightAlignment::Proportional,
167 trim: LineHeightTrim::Both,
168 mode: LineHeightMode::Fixed,
169 }
170 }
171}
172
173/// Everything [`crate::DrawScope`] needs to measure and draw a run of text.
174///
175/// A style is a plain value: sizes are already in scope (logical) units, and
176/// every field is resolved — there is no inheritance or theme lookup at draw
177/// time. Build one once and reuse it; measurement is cached on the
178/// `(text, style)` pair, so a style rebuilt with identical values still hits
179/// the cache.
180#[derive(Clone, Debug, PartialEq)]
181pub struct DrawTextStyle {
182 /// Family name to resolve against the fonts the app registered. `None`
183 /// asks for the framework's default family.
184 ///
185 /// Only *named* families resolve: file-backed families are loaded by the
186 /// app at startup and looked up by the name in their font tables.
187 pub font_family: Option<String>,
188 /// Size of the text, in scope units. A non-positive or non-finite value uses
189 /// `DEFAULT_FONT_SIZE`.
190 pub font_size: f32,
191 /// Weight of the face, from 1 to 1000.
192 pub font_weight: FontWeight,
193 /// Normal or italic face.
194 pub font_style: FontStyle,
195 /// Extra advance inserted between characters, in scope units.
196 pub letter_spacing: f32,
197 /// Distance between consecutive baselines. `None` uses the font's natural
198 /// line height.
199 pub line_height: Option<f32>,
200 /// How the line sits inside that height. `None` takes
201 /// [`LineHeightStyle::default`], as a composed `Text` does.
202 pub line_height_style: Option<LineHeightStyle>,
203 /// How the text block is placed horizontally inside its box.
204 pub align: TextAlign,
205 /// How the text block is placed vertically inside its box.
206 pub vertical_align: TextVerticalAlign,
207}
208
209impl DrawTextStyle {
210 /// The size used when a style carries a non-positive or non-finite one.
211 /// Matches the framework-wide text default.
212 pub const DEFAULT_FONT_SIZE: f32 = 14.0;
213
214 /// Creates a style with the given font size, in scope units, and defaults for every other
215 /// field.
216 pub fn new(font_size: f32) -> Self {
217 Self {
218 font_size,
219 ..Self::default()
220 }
221 }
222
223 /// Sets the font family by name. An empty name selects the framework default family.
224 pub fn with_font_family(mut self, family: impl Into<String>) -> Self {
225 let family = family.into();
226 self.font_family = (!family.is_empty()).then_some(family);
227 self
228 }
229
230 /// Sets the font size, in scope units.
231 pub fn with_font_size(mut self, font_size: f32) -> Self {
232 self.font_size = font_size;
233 self
234 }
235
236 /// Sets the font weight.
237 pub fn with_weight(mut self, weight: FontWeight) -> Self {
238 self.font_weight = weight;
239 self
240 }
241
242 /// Sets the font style.
243 pub fn with_style(mut self, style: FontStyle) -> Self {
244 self.font_style = style;
245 self
246 }
247
248 /// Sets the extra advance between characters, in scope units.
249 pub fn with_letter_spacing(mut self, letter_spacing: f32) -> Self {
250 self.letter_spacing = letter_spacing;
251 self
252 }
253
254 /// Sets the distance between baselines, in scope units. A non-finite value clears it.
255 pub fn with_line_height(mut self, line_height: f32) -> Self {
256 self.line_height = line_height.is_finite().then_some(line_height);
257 self
258 }
259
260 /// Asks for a line-height policy other than the default one.
261 pub fn with_line_height_style(mut self, line_height_style: LineHeightStyle) -> Self {
262 self.line_height_style = Some(line_height_style);
263 self
264 }
265
266 /// Sets how the text block is placed horizontally inside its box.
267 pub fn with_align(mut self, align: TextAlign) -> Self {
268 self.align = align;
269 self
270 }
271
272 /// Sets how the text block is placed vertically inside its box.
273 pub fn with_vertical_align(mut self, vertical_align: TextVerticalAlign) -> Self {
274 self.vertical_align = vertical_align;
275 self
276 }
277
278 /// The font size a measurer/rasterizer will actually use. Non-finite and
279 /// non-positive sizes fall back to [`DrawTextStyle::DEFAULT_FONT_SIZE`] instead
280 /// of producing NaN geometry.
281 pub fn resolved_font_size(&self) -> f32 {
282 if self.font_size.is_finite() && self.font_size > 0.0 {
283 self.font_size
284 } else {
285 Self::DEFAULT_FONT_SIZE
286 }
287 }
288
289 /// The letter spacing a measurer will actually use.
290 pub fn resolved_letter_spacing(&self) -> f32 {
291 if self.letter_spacing.is_finite() {
292 self.letter_spacing
293 } else {
294 0.0
295 }
296 }
297
298 /// The line height a measurer will actually use, given the font's natural
299 /// one. `natural` is only consulted when the style leaves it unset.
300 pub fn resolved_line_height(&self, natural: f32) -> f32 {
301 match self.line_height {
302 Some(height) if height.is_finite() && height > 0.0 => height,
303 _ => natural,
304 }
305 }
306}
307
308impl Default for DrawTextStyle {
309 fn default() -> Self {
310 Self {
311 font_family: None,
312 font_size: Self::DEFAULT_FONT_SIZE,
313 font_weight: FontWeight::NORMAL,
314 font_style: FontStyle::Normal,
315 letter_spacing: 0.0,
316 line_height: None,
317 line_height_style: None,
318 align: TextAlign::Left,
319 vertical_align: TextVerticalAlign::Top,
320 }
321 }
322}
323
324/// What a string occupies once laid out — the answer
325/// [`DrawScope::measure_text`](crate::DrawScope::measure_text) gives, and
326/// exactly the box `draw_text` fills.
327#[derive(Clone, Copy, Debug, PartialEq)]
328pub struct TextMeasurement {
329 /// Tight block size: the widest line by its total advance, and
330 /// `line_count * line_height` tall.
331 pub size: Size,
332 /// Baseline-to-baseline distance.
333 pub line_height: f32,
334 /// Distance from the top of the block down to the first line's baseline.
335 /// Subtract it from a baseline y to get the top-left a `_at` draw wants.
336 pub first_baseline: f32,
337 /// Number of laid-out lines. `1` for an empty string.
338 pub line_count: usize,
339}
340
341impl TextMeasurement {
342 /// The measurement of an empty string: no extent, but still one line's
343 /// worth of vertical metrics so callers can lay out an empty label.
344 pub fn empty(line_height: f32, first_baseline: f32) -> Self {
345 Self {
346 size: Size::ZERO,
347 line_height,
348 first_baseline,
349 line_count: 1,
350 }
351 }
352}
353
354/// Font-backed measurement, injected into a [`crate::DrawScopeDefault`] by the
355/// UI layer.
356///
357/// This crate holds no fonts, so a draw scope cannot measure text on its own.
358/// `cranpose-ui` installs an implementation that forwards to the very text
359/// stack the `Text` composable uses, which is what keeps
360/// [`DrawScope::measure_text`](crate::DrawScope::measure_text) and the glyphs
361/// the renderer rasterizes in agreement. Without one installed a scope falls
362/// back to [`estimate_text_measurement`], which is good enough for layout
363/// smoke tests and wrong for anything that has to line up with real glyphs.
364pub trait DrawTextMeasurer {
365 /// Returns the size and line metrics of `text` laid out in `style`.
366 fn measure_text(&self, text: &str, style: &DrawTextStyle) -> TextMeasurement;
367}
368
369/// Font-free estimate used when no [`DrawTextMeasurer`] is installed.
370///
371/// Assumes a 0.6 em advance per character and the 0.8/-0.2 em ascent/descent
372/// split typical of a UI sans face, laid out by the default
373/// [`LineHeightStyle`]: a line without a requested height is the face's own
374/// 1 em, and the leading a requested one adds is split in the face's
375/// proportions and given back above the first line and below the last. So the
376/// shape of the result matches what a real font measurer returns even though
377/// the numbers do not.
378pub fn estimate_text_measurement(text: &str, style: &DrawTextStyle) -> TextMeasurement {
379 const CHAR_WIDTH_RATIO: f32 = 0.6;
380 const ASCENT_RATIO: f32 = 0.8;
381 const NATURAL_LINE_HEIGHT_RATIO: f32 = 1.0;
382
383 let font_size = style.resolved_font_size();
384 let letter_spacing = style.resolved_letter_spacing().max(0.0);
385 let natural_line_height = font_size * NATURAL_LINE_HEIGHT_RATIO;
386 let line_height = style.resolved_line_height(natural_line_height);
387 let leading = line_height - natural_line_height;
388 let first_baseline = font_size * ASCENT_RATIO;
389
390 if text.is_empty() {
391 return TextMeasurement::empty(line_height, first_baseline);
392 }
393
394 let mut line_count = 0usize;
395 let mut width = 0.0f32;
396 for line in text.split('\n') {
397 line_count += 1;
398 let chars = line.chars().count();
399 let advance = chars as f32 * (font_size * CHAR_WIDTH_RATIO + letter_spacing);
400 width = width.max(advance);
401 }
402
403 TextMeasurement {
404 size: Size::new(width, line_count as f32 * line_height - leading),
405 line_height,
406 first_baseline,
407 line_count,
408 }
409}
410
411#[cfg(test)]
412#[path = "tests/typography_tests.rs"]
413mod tests;