use crate::geometry::Size;
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Hash)]
pub enum FontStyle {
#[default]
Normal,
Italic,
Oblique,
}
#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, PartialOrd, Ord)]
pub struct FontWeight(pub u16);
impl FontWeight {
pub const THIN: FontWeight = FontWeight(100);
pub const EXTRA_LIGHT: FontWeight = FontWeight(200);
pub const LIGHT: FontWeight = FontWeight(300);
pub const NORMAL: FontWeight = FontWeight(400);
pub const MEDIUM: FontWeight = FontWeight(500);
pub const SEMI_BOLD: FontWeight = FontWeight(600);
pub const BOLD: FontWeight = FontWeight(700);
pub const EXTRA_BOLD: FontWeight = FontWeight(800);
pub const BLACK: FontWeight = FontWeight(900);
pub const fn new(weight: u16) -> Self {
if weight < 1 {
Self(1)
} else if weight > 1000 {
Self(1000)
} else {
Self(weight)
}
}
pub const fn value(self) -> u16 {
self.0
}
}
impl Default for FontWeight {
fn default() -> Self {
Self::NORMAL
}
}
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Hash)]
pub enum TextAlign {
#[default]
Left,
Center,
Right,
}
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Hash)]
pub enum TextVerticalAlign {
#[default]
Top,
Center,
Bottom,
Baseline,
}
#[derive(Clone, Debug, PartialEq)]
pub struct TextStyle {
pub font_family: Option<String>,
pub font_size: f32,
pub font_weight: FontWeight,
pub font_style: FontStyle,
pub letter_spacing: f32,
pub line_height: Option<f32>,
pub align: TextAlign,
pub vertical_align: TextVerticalAlign,
}
impl TextStyle {
pub const DEFAULT_FONT_SIZE: f32 = 14.0;
pub fn new(font_size: f32) -> Self {
Self {
font_size,
..Self::default()
}
}
pub fn with_font_family(mut self, family: impl Into<String>) -> Self {
let family = family.into();
self.font_family = (!family.is_empty()).then_some(family);
self
}
pub fn with_font_size(mut self, font_size: f32) -> Self {
self.font_size = font_size;
self
}
pub fn with_weight(mut self, weight: FontWeight) -> Self {
self.font_weight = weight;
self
}
pub fn with_style(mut self, style: FontStyle) -> Self {
self.font_style = style;
self
}
pub fn with_letter_spacing(mut self, letter_spacing: f32) -> Self {
self.letter_spacing = letter_spacing;
self
}
pub fn with_line_height(mut self, line_height: f32) -> Self {
self.line_height = line_height.is_finite().then_some(line_height);
self
}
pub fn with_align(mut self, align: TextAlign) -> Self {
self.align = align;
self
}
pub fn with_vertical_align(mut self, vertical_align: TextVerticalAlign) -> Self {
self.vertical_align = vertical_align;
self
}
pub fn resolved_font_size(&self) -> f32 {
if self.font_size.is_finite() && self.font_size > 0.0 {
self.font_size
} else {
Self::DEFAULT_FONT_SIZE
}
}
pub fn resolved_letter_spacing(&self) -> f32 {
if self.letter_spacing.is_finite() {
self.letter_spacing
} else {
0.0
}
}
pub fn resolved_line_height(&self, natural: f32) -> f32 {
match self.line_height {
Some(height) if height.is_finite() && height > 0.0 => height,
_ => natural,
}
}
}
impl Default for TextStyle {
fn default() -> Self {
Self {
font_family: None,
font_size: Self::DEFAULT_FONT_SIZE,
font_weight: FontWeight::NORMAL,
font_style: FontStyle::Normal,
letter_spacing: 0.0,
line_height: None,
align: TextAlign::Left,
vertical_align: TextVerticalAlign::Top,
}
}
}
#[derive(Clone, Copy, Debug, PartialEq)]
pub struct TextMeasurement {
pub size: Size,
pub line_height: f32,
pub first_baseline: f32,
pub line_count: usize,
}
impl TextMeasurement {
pub fn empty(line_height: f32, first_baseline: f32) -> Self {
Self {
size: Size::ZERO,
line_height,
first_baseline,
line_count: 1,
}
}
}
pub trait DrawTextMeasurer {
fn measure_text(&self, text: &str, style: &TextStyle) -> TextMeasurement;
}
pub fn estimate_text_measurement(text: &str, style: &TextStyle) -> TextMeasurement {
const CHAR_WIDTH_RATIO: f32 = 0.6;
const ASCENT_RATIO: f32 = 0.8;
const NATURAL_LINE_HEIGHT_RATIO: f32 = 1.0;
let font_size = style.resolved_font_size();
let letter_spacing = style.resolved_letter_spacing().max(0.0);
let natural_line_height = font_size * NATURAL_LINE_HEIGHT_RATIO;
let line_height = style.resolved_line_height(font_size * 1.4);
let first_baseline = font_size * ASCENT_RATIO + (line_height - natural_line_height) * 0.5;
if text.is_empty() {
return TextMeasurement::empty(line_height, first_baseline);
}
let mut line_count = 0usize;
let mut width = 0.0f32;
for line in text.split('\n') {
line_count += 1;
let chars = line.chars().count();
let advance = chars as f32 * font_size * CHAR_WIDTH_RATIO
+ chars.saturating_sub(1) as f32 * letter_spacing;
width = width.max(advance);
}
TextMeasurement {
size: Size::new(width, line_count as f32 * line_height),
line_height,
first_baseline,
line_count,
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn text_style_resolves_degenerate_sizes_to_the_framework_default() {
for size in [0.0, -12.0, f32::NAN, f32::INFINITY] {
assert_eq!(
TextStyle::new(size).resolved_font_size(),
TextStyle::DEFAULT_FONT_SIZE,
"font size {size} must not reach a font backend"
);
}
assert_eq!(TextStyle::new(19.0).resolved_font_size(), 19.0);
}
#[test]
fn text_style_line_height_falls_back_to_the_natural_one() {
let style = TextStyle::new(20.0);
assert_eq!(style.resolved_line_height(28.0), 28.0);
assert_eq!(
style
.clone()
.with_line_height(40.0)
.resolved_line_height(28.0),
40.0
);
assert_eq!(
style.with_line_height(f32::NAN).resolved_line_height(28.0),
28.0
);
}
#[test]
fn empty_font_family_name_means_the_default_family() {
assert_eq!(TextStyle::new(14.0).with_font_family("").font_family, None);
assert_eq!(
TextStyle::new(14.0)
.with_font_family("Fira Sans")
.font_family,
Some("Fira Sans".to_string())
);
}
#[test]
fn estimated_measurement_grows_with_the_longest_line() {
let style = TextStyle::new(10.0);
let one = estimate_text_measurement("AAAA", &style);
let two = estimate_text_measurement("AAAA\nAAAAAAAA", &style);
assert_eq!(one.line_count, 1);
assert_eq!(two.line_count, 2);
assert!(two.size.width > one.size.width);
assert!((two.size.height - one.size.height * 2.0).abs() < 1e-3);
}
#[test]
fn estimated_measurement_of_an_empty_string_keeps_one_line_of_metrics() {
let measurement = estimate_text_measurement("", &TextStyle::new(16.0));
assert_eq!(measurement.size, Size::ZERO);
assert_eq!(measurement.line_count, 1);
assert!(measurement.line_height > 0.0);
assert!(measurement.first_baseline > 0.0);
}
#[test]
fn estimated_baseline_sits_inside_the_line_slot() {
let measurement = estimate_text_measurement("Ag", &TextStyle::new(24.0));
assert!(measurement.first_baseline > 0.0);
assert!(measurement.first_baseline < measurement.line_height);
}
}