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, Debug, PartialEq)]
95pub struct TextStyle {
96 pub font_family: Option<String>,
102 pub font_size: f32,
103 pub font_weight: FontWeight,
104 pub font_style: FontStyle,
105 pub letter_spacing: f32,
107 pub line_height: Option<f32>,
110 pub align: TextAlign,
111 pub vertical_align: TextVerticalAlign,
112}
113
114impl TextStyle {
115 pub const DEFAULT_FONT_SIZE: f32 = 14.0;
118
119 pub fn new(font_size: f32) -> Self {
120 Self {
121 font_size,
122 ..Self::default()
123 }
124 }
125
126 pub fn with_font_family(mut self, family: impl Into<String>) -> Self {
127 let family = family.into();
128 self.font_family = (!family.is_empty()).then_some(family);
129 self
130 }
131
132 pub fn with_font_size(mut self, font_size: f32) -> Self {
133 self.font_size = font_size;
134 self
135 }
136
137 pub fn with_weight(mut self, weight: FontWeight) -> Self {
138 self.font_weight = weight;
139 self
140 }
141
142 pub fn with_style(mut self, style: FontStyle) -> Self {
143 self.font_style = style;
144 self
145 }
146
147 pub fn with_letter_spacing(mut self, letter_spacing: f32) -> Self {
148 self.letter_spacing = letter_spacing;
149 self
150 }
151
152 pub fn with_line_height(mut self, line_height: f32) -> Self {
153 self.line_height = line_height.is_finite().then_some(line_height);
154 self
155 }
156
157 pub fn with_align(mut self, align: TextAlign) -> Self {
158 self.align = align;
159 self
160 }
161
162 pub fn with_vertical_align(mut self, vertical_align: TextVerticalAlign) -> Self {
163 self.vertical_align = vertical_align;
164 self
165 }
166
167 pub fn resolved_font_size(&self) -> f32 {
171 if self.font_size.is_finite() && self.font_size > 0.0 {
172 self.font_size
173 } else {
174 Self::DEFAULT_FONT_SIZE
175 }
176 }
177
178 pub fn resolved_letter_spacing(&self) -> f32 {
180 if self.letter_spacing.is_finite() {
181 self.letter_spacing
182 } else {
183 0.0
184 }
185 }
186
187 pub fn resolved_line_height(&self, natural: f32) -> f32 {
190 match self.line_height {
191 Some(height) if height.is_finite() && height > 0.0 => height,
192 _ => natural,
193 }
194 }
195}
196
197impl Default for TextStyle {
198 fn default() -> Self {
199 Self {
200 font_family: None,
201 font_size: Self::DEFAULT_FONT_SIZE,
202 font_weight: FontWeight::NORMAL,
203 font_style: FontStyle::Normal,
204 letter_spacing: 0.0,
205 line_height: None,
206 align: TextAlign::Left,
207 vertical_align: TextVerticalAlign::Top,
208 }
209 }
210}
211
212#[derive(Clone, Copy, Debug, PartialEq)]
216pub struct TextMeasurement {
217 pub size: Size,
220 pub line_height: f32,
222 pub first_baseline: f32,
225 pub line_count: usize,
227}
228
229impl TextMeasurement {
230 pub fn empty(line_height: f32, first_baseline: f32) -> Self {
233 Self {
234 size: Size::ZERO,
235 line_height,
236 first_baseline,
237 line_count: 1,
238 }
239 }
240}
241
242pub trait DrawTextMeasurer {
253 fn measure_text(&self, text: &str, style: &TextStyle) -> TextMeasurement;
254}
255
256pub fn estimate_text_measurement(text: &str, style: &TextStyle) -> TextMeasurement {
263 const CHAR_WIDTH_RATIO: f32 = 0.6;
264 const ASCENT_RATIO: f32 = 0.8;
265 const NATURAL_LINE_HEIGHT_RATIO: f32 = 1.0;
266
267 let font_size = style.resolved_font_size();
268 let letter_spacing = style.resolved_letter_spacing().max(0.0);
269 let natural_line_height = font_size * NATURAL_LINE_HEIGHT_RATIO;
270 let line_height = style.resolved_line_height(font_size * 1.4);
271 let first_baseline = font_size * ASCENT_RATIO + (line_height - natural_line_height) * 0.5;
272
273 if text.is_empty() {
274 return TextMeasurement::empty(line_height, first_baseline);
275 }
276
277 let mut line_count = 0usize;
278 let mut width = 0.0f32;
279 for line in text.split('\n') {
280 line_count += 1;
281 let chars = line.chars().count();
282 let advance = chars as f32 * font_size * CHAR_WIDTH_RATIO
283 + chars.saturating_sub(1) as f32 * letter_spacing;
284 width = width.max(advance);
285 }
286
287 TextMeasurement {
288 size: Size::new(width, line_count as f32 * line_height),
289 line_height,
290 first_baseline,
291 line_count,
292 }
293}
294
295#[cfg(test)]
296mod tests {
297 use super::*;
298
299 #[test]
300 fn text_style_resolves_degenerate_sizes_to_the_framework_default() {
301 for size in [0.0, -12.0, f32::NAN, f32::INFINITY] {
302 assert_eq!(
303 TextStyle::new(size).resolved_font_size(),
304 TextStyle::DEFAULT_FONT_SIZE,
305 "font size {size} must not reach a font backend"
306 );
307 }
308 assert_eq!(TextStyle::new(19.0).resolved_font_size(), 19.0);
309 }
310
311 #[test]
312 fn text_style_line_height_falls_back_to_the_natural_one() {
313 let style = TextStyle::new(20.0);
314 assert_eq!(style.resolved_line_height(28.0), 28.0);
315 assert_eq!(
316 style
317 .clone()
318 .with_line_height(40.0)
319 .resolved_line_height(28.0),
320 40.0
321 );
322 assert_eq!(
324 style.with_line_height(f32::NAN).resolved_line_height(28.0),
325 28.0
326 );
327 }
328
329 #[test]
330 fn empty_font_family_name_means_the_default_family() {
331 assert_eq!(TextStyle::new(14.0).with_font_family("").font_family, None);
332 assert_eq!(
333 TextStyle::new(14.0)
334 .with_font_family("Fira Sans")
335 .font_family,
336 Some("Fira Sans".to_string())
337 );
338 }
339
340 #[test]
341 fn estimated_measurement_grows_with_the_longest_line() {
342 let style = TextStyle::new(10.0);
343 let one = estimate_text_measurement("AAAA", &style);
344 let two = estimate_text_measurement("AAAA\nAAAAAAAA", &style);
345 assert_eq!(one.line_count, 1);
346 assert_eq!(two.line_count, 2);
347 assert!(two.size.width > one.size.width);
348 assert!((two.size.height - one.size.height * 2.0).abs() < 1e-3);
349 }
350
351 #[test]
352 fn estimated_measurement_of_an_empty_string_keeps_one_line_of_metrics() {
353 let measurement = estimate_text_measurement("", &TextStyle::new(16.0));
354 assert_eq!(measurement.size, Size::ZERO);
355 assert_eq!(measurement.line_count, 1);
356 assert!(measurement.line_height > 0.0);
357 assert!(measurement.first_baseline > 0.0);
358 }
359
360 #[test]
361 fn estimated_baseline_sits_inside_the_line_slot() {
362 let measurement = estimate_text_measurement("Ag", &TextStyle::new(24.0));
363 assert!(measurement.first_baseline > 0.0);
364 assert!(measurement.first_baseline < measurement.line_height);
365 }
366}