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