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 DrawTextStyle {
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 DrawTextStyle {
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 DrawTextStyle {
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: &DrawTextStyle) -> TextMeasurement;
330}
331
332pub fn estimate_text_measurement(text: &str, style: &DrawTextStyle) -> 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);
359 width = width.max(advance);
360 }
361
362 TextMeasurement {
363 size: Size::new(width, line_count as f32 * line_height),
364 line_height,
365 first_baseline,
366 line_count,
367 }
368}
369
370#[cfg(test)]
371mod tests {
372
373 #[test]
374 fn letter_spacing_resolves_to_something_a_measurer_can_use() {
375 let style = DrawTextStyle::default().with_font_size(18.0);
376 assert_eq!(style.font_size, 18.0);
377 assert_eq!(style.resolved_font_size(), 18.0);
378 assert_eq!(style.resolved_letter_spacing(), 0.0);
379
380 assert_eq!(
381 style
382 .clone()
383 .with_letter_spacing(1.5)
384 .resolved_letter_spacing(),
385 1.5
386 );
387 assert_eq!(
388 style
389 .clone()
390 .with_letter_spacing(-0.5)
391 .resolved_letter_spacing(),
392 -0.5,
393 "tightening is a real request, not an error"
394 );
395 for broken in [f32::NAN, f32::INFINITY, f32::NEG_INFINITY] {
396 assert_eq!(
397 style
398 .clone()
399 .with_letter_spacing(broken)
400 .resolved_letter_spacing(),
401 0.0
402 );
403 }
404
405 for broken in [0.0, -12.0, f32::NAN, f32::INFINITY] {
406 assert_eq!(
407 DrawTextStyle::default()
408 .with_font_size(broken)
409 .resolved_font_size(),
410 DrawTextStyle::DEFAULT_FONT_SIZE
411 );
412 }
413 }
414 use super::*;
415
416 #[test]
417 fn text_style_resolves_degenerate_sizes_to_the_framework_default() {
418 for size in [0.0, -12.0, f32::NAN, f32::INFINITY] {
419 assert_eq!(
420 DrawTextStyle::new(size).resolved_font_size(),
421 DrawTextStyle::DEFAULT_FONT_SIZE,
422 "font size {size} must not reach a font backend"
423 );
424 }
425 assert_eq!(DrawTextStyle::new(19.0).resolved_font_size(), 19.0);
426 }
427
428 #[test]
429 fn text_style_line_height_falls_back_to_the_natural_one() {
430 let style = DrawTextStyle::new(20.0);
431 assert_eq!(style.resolved_line_height(28.0), 28.0);
432 assert_eq!(
433 style
434 .clone()
435 .with_line_height(40.0)
436 .resolved_line_height(28.0),
437 40.0
438 );
439 assert_eq!(
440 style.with_line_height(f32::NAN).resolved_line_height(28.0),
441 28.0
442 );
443 }
444
445 #[test]
446 fn empty_font_family_name_means_the_default_family() {
447 assert_eq!(
448 DrawTextStyle::new(14.0).with_font_family("").font_family,
449 None
450 );
451 assert_eq!(
452 DrawTextStyle::new(14.0)
453 .with_font_family("Fira Sans")
454 .font_family,
455 Some("Fira Sans".to_string())
456 );
457 }
458
459 #[test]
460 fn estimated_measurement_grows_with_the_longest_line() {
461 let style = DrawTextStyle::new(10.0);
462 let one = estimate_text_measurement("AAAA", &style);
463 let two = estimate_text_measurement("AAAA\nAAAAAAAA", &style);
464 assert_eq!(one.line_count, 1);
465 assert_eq!(two.line_count, 2);
466 assert!(two.size.width > one.size.width);
467 assert!((two.size.height - one.size.height * 2.0).abs() < 1e-3);
468 }
469
470 #[test]
471 fn estimated_measurement_of_an_empty_string_keeps_one_line_of_metrics() {
472 let measurement = estimate_text_measurement("", &DrawTextStyle::new(16.0));
473 assert_eq!(measurement.size, Size::ZERO);
474 assert_eq!(measurement.line_count, 1);
475 assert!(measurement.line_height > 0.0);
476 assert!(measurement.first_baseline > 0.0);
477 }
478
479 #[test]
480 fn estimated_baseline_sits_inside_the_line_slot() {
481 let measurement = estimate_text_measurement("Ag", &DrawTextStyle::new(24.0));
482 assert!(measurement.first_baseline > 0.0);
483 assert!(measurement.first_baseline < measurement.line_height);
484 }
485}