1use crate::geometry::Size;
11
12#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Hash)]
14pub enum FontStyle {
15 #[default]
16 Normal,
17 Italic,
18 Oblique,
21}
22
23#[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 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#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Hash)]
66pub enum TextAlign {
67 #[default]
68 Left,
69 Center,
70 Right,
71}
72
73#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Hash)]
75pub enum TextVerticalAlign {
76 #[default]
77 Top,
78 Center,
79 Bottom,
80 Baseline,
85}
86
87#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, Default)]
90pub enum LineHeightAlignment {
91 Top,
93 Center,
95 #[default]
96 Proportional,
98 Bottom,
100}
101
102#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, Default)]
104pub enum LineHeightTrim {
105 FirstLineTop,
106 LastLineBottom,
107 #[default]
108 Both,
109 None,
110}
111
112#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, Default)]
114pub enum LineHeightMode {
115 #[default]
117 Fixed,
118 Minimum,
120 Tight,
122}
123
124#[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#[derive(Clone, Debug, PartialEq)]
159pub struct TextStyle {
160 pub font_family: Option<String>,
166 pub font_size: f32,
167 pub font_weight: FontWeight,
168 pub font_style: FontStyle,
169 pub letter_spacing: f32,
171 pub line_height: Option<f32>,
174 pub line_height_style: Option<LineHeightStyle>,
178 pub align: TextAlign,
179 pub vertical_align: TextVerticalAlign,
180}
181
182impl TextStyle {
183 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 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 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 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 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#[derive(Clone, Copy, Debug, PartialEq)]
292pub struct TextMeasurement {
293 pub size: Size,
296 pub line_height: f32,
298 pub first_baseline: f32,
301 pub line_count: usize,
303}
304
305impl TextMeasurement {
306 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
318pub trait DrawTextMeasurer {
329 fn measure_text(&self, text: &str, style: &TextStyle) -> TextMeasurement;
330}
331
332pub 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 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 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}