use crate::Color;
#[derive(Clone, Copy, Debug, PartialEq, Eq, Default)]
pub enum TextAlign {
#[default]
Start,
End,
Center,
Justify,
Left,
Right,
}
#[derive(Clone, Copy, Debug, PartialEq, Eq, Default)]
pub enum TextOverflow {
#[default]
Clip,
Ellipsis,
Fade,
Visible,
}
#[derive(Clone, Copy, Debug, PartialEq, Eq, Default)]
pub enum FontWeight {
#[default]
Regular,
Medium,
SemiBold,
Bold,
}
impl FontWeight {
pub fn to_u16(self) -> u16 {
match self {
FontWeight::Regular => 400,
FontWeight::Medium => 500,
FontWeight::SemiBold => 600,
FontWeight::Bold => 700,
}
}
#[must_use]
pub fn lerp(self, other: FontWeight, t: f32) -> FontWeight {
const FACES: [FontWeight; 4] = [
FontWeight::Regular,
FontWeight::Medium,
FontWeight::SemiBold,
FontWeight::Bold,
];
fn place(weight: FontWeight) -> f32 {
match weight {
FontWeight::Regular => 0.0,
FontWeight::Medium => 1.0,
FontWeight::SemiBold => 2.0,
FontWeight::Bold => 3.0,
}
}
let a = place(self);
let b = place(other);
let step = (a + (b - a) * t.clamp(0.0, 1.0)).round().clamp(0.0, 3.0);
FACES[step as usize]
}
}
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
pub struct TextDecoration {
pub underline: bool,
pub overline: bool,
pub strikethrough: bool,
}
impl TextDecoration {
pub const NONE: Self = Self {
underline: false,
overline: false,
strikethrough: false,
};
pub const UNDERLINE: Self = Self {
underline: true,
..Self::NONE
};
pub const OVERLINE: Self = Self {
overline: true,
..Self::NONE
};
pub const STRIKETHROUGH: Self = Self {
strikethrough: true,
..Self::NONE
};
pub const fn is_none(self) -> bool {
!self.underline && !self.overline && !self.strikethrough
}
pub const fn combine(self, other: Self) -> Self {
Self {
underline: self.underline || other.underline,
overline: self.overline || other.overline,
strikethrough: self.strikethrough || other.strikethrough,
}
}
}
pub const DEFAULT_TEXT_SIZE: f32 = 16.0;
#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)]
pub enum FontFamily {
SansSerif,
Serif,
Monospace,
Named(&'static str),
}
pub const DEFAULT_LINE_HEIGHT: f32 = 1.2;
#[derive(Clone, Copy, Debug, Default, PartialEq)]
pub struct TextStyle {
pub size: Option<f32>,
pub weight: Option<FontWeight>,
pub italic: Option<bool>,
pub color: Option<Color>,
pub decoration: Option<TextDecoration>,
pub decoration_color: Option<Color>,
pub height: Option<f32>,
pub family: Option<FontFamily>,
pub letter_spacing: Option<f32>,
}
#[derive(Clone, Copy, Debug, PartialEq)]
pub struct ResolvedTextStyle {
pub size: f32,
pub weight: FontWeight,
pub italic: bool,
pub color: Option<Color>,
pub decoration: TextDecoration,
pub decoration_color: Option<Color>,
pub height: Option<f32>,
pub family: Option<FontFamily>,
pub letter_spacing: f32,
}
impl ResolvedTextStyle {
pub fn line_height(&self) -> f32 {
self.size * self.height.unwrap_or(DEFAULT_LINE_HEIGHT)
}
pub const fn exact(size: f32) -> Self {
Self {
size,
weight: FontWeight::Regular,
italic: false,
color: None,
decoration: TextDecoration::NONE,
decoration_color: None,
height: None,
family: None,
letter_spacing: 0.0,
}
}
}
impl TextStyle {
pub const NONE: Self = Self {
size: None,
weight: None,
italic: None,
color: None,
decoration: None,
decoration_color: None,
height: None,
family: None,
letter_spacing: None,
};
pub const fn new(size: f32) -> Self {
Self {
size: Some(size),
..Self::NONE
}
}
pub const fn weight(mut self, weight: FontWeight) -> Self {
self.weight = Some(weight);
self
}
pub const fn italic(mut self) -> Self {
self.italic = Some(true);
self
}
pub const fn size(mut self, size: f32) -> Self {
self.size = Some(size);
self
}
pub const fn color(mut self, color: Color) -> Self {
self.color = Some(color);
self
}
pub const fn decoration(mut self, decoration: TextDecoration) -> Self {
self.decoration = Some(decoration);
self
}
pub const fn underline(mut self) -> Self {
let current = match self.decoration {
Some(d) => d,
None => TextDecoration::NONE,
};
self.decoration = Some(current.combine(TextDecoration::UNDERLINE));
self
}
pub const fn strikethrough(mut self) -> Self {
let current = match self.decoration {
Some(d) => d,
None => TextDecoration::NONE,
};
self.decoration = Some(current.combine(TextDecoration::STRIKETHROUGH));
self
}
pub const fn decoration_color(mut self, color: Color) -> Self {
self.decoration_color = Some(color);
self
}
pub const fn height(mut self, height: f32) -> Self {
self.height = Some(height);
self
}
pub const fn family(mut self, family: FontFamily) -> Self {
self.family = Some(family);
self
}
pub const fn letter_spacing(mut self, spacing: f32) -> Self {
self.letter_spacing = Some(spacing);
self
}
#[must_use]
pub fn merge(self, over: TextStyle) -> TextStyle {
TextStyle {
size: over.size.or(self.size),
weight: over.weight.or(self.weight),
italic: over.italic.or(self.italic),
color: over.color.or(self.color),
decoration: over.decoration.or(self.decoration),
decoration_color: over.decoration_color.or(self.decoration_color),
height: over.height.or(self.height),
family: over.family.or(self.family),
letter_spacing: over.letter_spacing.or(self.letter_spacing),
}
}
#[must_use]
pub fn lerp(self, other: TextStyle, t: f32) -> TextStyle {
let t = t.clamp(0.0, 1.0);
fn number(a: Option<f32>, b: Option<f32>, t: f32) -> Option<f32> {
match (a, b) {
(Some(a), Some(b)) => Some(a + (b - a) * t),
(a, b) => a.or(b),
}
}
fn color(a: Option<Color>, b: Option<Color>, t: f32) -> Option<Color> {
match (a, b) {
(Some(a), Some(b)) => Some(a.lerp(b, t)),
(a, b) => a.or(b),
}
}
fn swap<T>(a: Option<T>, b: Option<T>, t: f32) -> Option<T> {
if t < 0.5 {
a
} else {
b
}
}
TextStyle {
size: number(self.size, other.size, t),
weight: match (self.weight, other.weight) {
(Some(a), Some(b)) => Some(a.lerp(b, t)),
(a, b) => a.or(b),
},
italic: swap(self.italic, other.italic, t),
color: color(self.color, other.color, t),
decoration: swap(self.decoration, other.decoration, t),
decoration_color: color(self.decoration_color, other.decoration_color, t),
height: number(self.height, other.height, t),
family: swap(self.family, other.family, t),
letter_spacing: number(self.letter_spacing, other.letter_spacing, t),
}
}
#[must_use]
pub fn clamp_scale(mut self, max_scale: f32) -> Self {
let scale = text_scale();
if scale > max_scale && scale > 0.0 {
if let Some(size) = self.size {
self.size = Some(size * max_scale / scale);
}
}
self
}
pub fn resolved(self) -> ResolvedTextStyle {
ResolvedTextStyle {
size: self.size.unwrap_or(DEFAULT_TEXT_SIZE) * text_scale(),
weight: self.weight.unwrap_or(FontWeight::Regular),
italic: self.italic.unwrap_or(false),
color: self.color,
decoration: self.decoration.unwrap_or(TextDecoration::NONE),
decoration_color: self.decoration_color,
height: self.height,
family: self.family,
letter_spacing: self.letter_spacing.unwrap_or(0.0),
}
}
}
#[inline(never)]
pub fn text_scale() -> f32 {
TEXT_SCALE.with(|s| s.get())
}
pub fn with_text_scale<R>(scale: f32, f: impl FnOnce() -> R) -> R {
let guard = install_text_scale(scale);
let out = f();
drop(guard);
out
}
#[inline(never)]
#[must_use = "the scale is restored the moment the guard is dropped"]
pub fn install_text_scale(scale: f32) -> TextScaleGuard {
let scale = if scale > 0.0 { scale } else { 1.0 };
TextScaleGuard(TEXT_SCALE.with(|s| s.replace(scale)))
}
pub struct TextScaleGuard(f32);
impl Drop for TextScaleGuard {
#[inline(never)]
fn drop(&mut self) {
TEXT_SCALE.with(|s| s.set(self.0));
}
}
thread_local! {
static TEXT_SCALE: std::cell::Cell<f32> = const { std::cell::Cell::new(1.0) };
}
#[derive(Clone, Debug, Default, PartialEq)]
pub struct TextSpan {
text: String,
overrides: TextStyle,
children: Vec<TextSpan>,
}
impl TextSpan {
pub fn new(text: impl Into<String>) -> Self {
Self {
text: text.into(),
overrides: TextStyle::NONE,
children: Vec::new(),
}
}
pub fn bold(mut self) -> Self {
self.overrides.weight = Some(FontWeight::Bold);
self
}
pub fn weight(mut self, weight: FontWeight) -> Self {
self.overrides.weight = Some(weight);
self
}
pub fn italic(mut self) -> Self {
self.overrides.italic = Some(true);
self
}
pub fn size(mut self, size: f32) -> Self {
self.overrides.size = Some(size);
self
}
pub fn color(mut self, color: Color) -> Self {
self.overrides.color = Some(color);
self
}
pub fn underline(mut self) -> Self {
self.overrides = self.overrides.underline();
self
}
pub fn strikethrough(mut self) -> Self {
self.overrides = self.overrides.strikethrough();
self
}
pub fn decoration(mut self, decoration: TextDecoration) -> Self {
self.overrides.decoration = Some(decoration);
self
}
pub fn decoration_color(mut self, color: Color) -> Self {
self.overrides.decoration_color = Some(color);
self
}
pub fn style(mut self, style: TextStyle) -> Self {
self.overrides = style;
self
}
pub fn child(mut self, child: TextSpan) -> Self {
self.children.push(child);
self
}
pub fn flatten(&self, base: TextStyle) -> Vec<(String, ResolvedTextStyle)> {
let mut runs = Vec::new();
self.collect(base, &mut runs);
runs
}
pub fn measure_hash<H: std::hash::Hasher>(&self, hasher: &mut H) {
use std::hash::Hash;
self.text.hash(hasher);
self.overrides.size.map(f32::to_bits).hash(hasher);
self.overrides.weight.map(FontWeight::to_u16).hash(hasher);
self.overrides.italic.hash(hasher);
self.children.len().hash(hasher);
for child in &self.children {
child.measure_hash(hasher);
}
}
fn collect(&self, inherited: TextStyle, out: &mut Vec<(String, ResolvedTextStyle)>) {
let effective = inherited.merge(self.overrides);
if !self.text.is_empty() {
out.push((self.text.clone(), effective.resolved()));
}
for child in &self.children {
child.collect(effective, out);
}
}
}
#[derive(Clone, Debug, PartialEq)]
pub struct TextRun {
pub text: String,
pub size: f32,
pub weight: FontWeight,
pub italic: bool,
pub color: Color,
pub decoration: TextDecoration,
pub decoration_color: Option<Color>,
pub letter_spacing: f32,
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn letter_spacing_cascades_travels_and_stays_written() {
let spaced = TextStyle::new(14.0).letter_spacing(0.5);
assert_eq!(spaced.merge(TextStyle::new(20.0)).letter_spacing, Some(0.5));
assert_eq!(
spaced
.merge(TextStyle::NONE.letter_spacing(-1.0))
.letter_spacing,
Some(-1.0)
);
let half = spaced.lerp(TextStyle::new(14.0).letter_spacing(1.5), 0.5);
assert_eq!(half.letter_spacing, Some(1.0));
assert_eq!(
TextStyle::new(14.0).resolved().letter_spacing,
0.0,
"none by default"
);
let scaled = with_text_scale(2.0, || spaced.resolved());
assert_eq!(scaled.size, 28.0, "the size grows");
assert_eq!(scaled.letter_spacing, 0.5, "the spacing does not");
}
#[test]
fn weights_map_to_opentype() {
assert_eq!(FontWeight::Regular.to_u16(), 400);
assert_eq!(FontWeight::Bold.to_u16(), 700);
}
#[test]
fn builders_compose() {
let s = TextStyle::new(20.0).weight(FontWeight::Bold).italic();
assert_eq!(s.size, Some(20.0));
assert_eq!(s.weight, Some(FontWeight::Bold));
assert_eq!(s.italic, Some(true));
assert_eq!(s.color, None);
}
#[test]
fn a_style_may_name_one_thing_and_leave_the_rest_open() {
let only_size = TextStyle::new(20.0);
assert_eq!(only_size.size, Some(20.0));
assert_eq!(only_size.weight, None, "and says nothing about the weight");
let inherited = TextStyle::NONE.weight(FontWeight::Bold).merge(only_size);
assert_eq!(inherited.size, Some(20.0), "the size it named");
assert_eq!(
inherited.weight,
Some(FontWeight::Bold),
"the weight it did not"
);
}
#[test]
fn a_style_that_says_nothing_resolves_to_the_frameworks_own() {
let r = TextStyle::NONE.resolved();
assert_eq!(r.size, DEFAULT_TEXT_SIZE);
assert_eq!(r.weight, FontWeight::Regular);
assert!(!r.italic);
assert_eq!(r.decoration, TextDecoration::NONE);
assert_eq!(r.color, None);
}
#[test]
fn the_readers_font_size_reaches_a_resolved_style() {
assert_eq!(
TextStyle::new(10.0).resolved().size,
10.0,
"unscaled by default"
);
with_text_scale(1.5, || {
assert_eq!(TextStyle::new(10.0).resolved().size, 15.0);
assert_eq!(
TextStyle::NONE.resolved().size,
DEFAULT_TEXT_SIZE * 1.5,
"the default is a size like any other"
);
});
assert_eq!(
TextStyle::new(10.0).resolved().size,
10.0,
"and the scope put it back"
);
}
#[test]
fn only_the_size_is_scaled() {
with_text_scale(2.0, || {
let r = TextStyle::new(10.0)
.weight(FontWeight::Bold)
.italic()
.resolved();
assert_eq!(r.size, 20.0);
assert_eq!(r.weight, FontWeight::Bold);
assert!(r.italic);
});
}
#[test]
fn a_scale_of_nothing_is_disbelieved() {
with_text_scale(0.0, || {
assert_eq!(TextStyle::new(10.0).resolved().size, 10.0);
});
with_text_scale(-2.0, || {
assert_eq!(TextStyle::new(10.0).resolved().size, 10.0);
});
}
#[test]
fn merge_overrides_type_but_inherits_missing_colour() {
let base = TextStyle::new(16.0).color(Color::WHITE);
let over = TextStyle::new(24.0).weight(FontWeight::Bold);
let merged = base.merge(over);
assert_eq!(merged.size, Some(24.0));
assert_eq!(merged.weight, Some(FontWeight::Bold));
assert_eq!(merged.color, Some(Color::WHITE), "colour inherited");
let over2 = TextStyle::new(24.0).color(Color::BLACK);
assert_eq!(base.merge(over2).color, Some(Color::BLACK));
}
#[test]
fn span_children_inherit_unspecified_attributes() {
let span = TextSpan::new("Hello ")
.child(TextSpan::new("bold").bold())
.child(
TextSpan::new("red italic")
.italic()
.color(Color::rgb(1.0, 0.0, 0.0)),
);
let base = TextStyle::new(20.0).color(Color::WHITE);
let runs = span.flatten(base);
assert_eq!(runs.len(), 3);
let (t0, s0) = &runs[0];
assert_eq!(
(t0.as_str(), s0.size, s0.weight),
("Hello ", 20.0, FontWeight::Regular)
);
let (t1, s1) = &runs[1];
assert_eq!((t1.as_str(), s1.weight), ("bold", FontWeight::Bold));
assert_eq!(s1.size, 20.0, "bold inherits the size");
assert_eq!(s1.color, Some(Color::WHITE), "bold inherits the colour");
let (t2, s2) = &runs[2];
assert_eq!(t2, "red italic");
assert!(s2.italic);
assert_eq!(s2.weight, FontWeight::Regular, "italic inherits the weight");
assert_eq!(s2.color, Some(Color::rgb(1.0, 0.0, 0.0)));
}
#[test]
fn nested_spans_cascade_depth_first() {
let span = TextSpan::new("a").child(
TextSpan::new("b")
.bold()
.child(TextSpan::new("c").size(12.0)),
);
let runs = span.flatten(TextStyle::new(16.0));
assert_eq!(runs.len(), 3);
assert_eq!(runs[2].0, "c");
assert_eq!(runs[2].1.size, 12.0);
assert_eq!(
runs[2].1.weight,
FontWeight::Bold,
"inherits bold from the parent"
);
}
#[test]
fn decorations_combine_and_cascade() {
let s = TextStyle::new(16.0).underline().strikethrough().resolved();
assert!(s.decoration.underline && s.decoration.strikethrough);
assert!(!s.decoration.overline);
assert!(!TextDecoration::UNDERLINE.is_none());
assert!(TextDecoration::NONE.is_none());
let span = TextSpan::new("a")
.underline()
.decoration_color(Color::rgb(1.0, 0.0, 0.0))
.child(TextSpan::new("b"))
.child(TextSpan::new("c").decoration(TextDecoration::NONE));
let runs = span.flatten(TextStyle::new(16.0));
assert!(runs[0].1.decoration.underline);
assert!(
runs[1].1.decoration.underline,
"the child inherits the underline"
);
assert_eq!(
runs[1].1.decoration_color,
Some(Color::rgb(1.0, 0.0, 0.0)),
"the decoration colour cascades"
);
assert!(runs[2].1.decoration.is_none(), "explicitly cancelled");
let base = TextStyle::new(16.0)
.underline()
.decoration_color(Color::BLACK);
let merged = base.merge(TextStyle::new(20.0).strikethrough()).resolved();
assert!(merged.decoration.strikethrough && !merged.decoration.underline);
assert_eq!(merged.decoration_color, Some(Color::BLACK));
let quiet = base.merge(TextStyle::new(20.0)).resolved();
assert!(quiet.decoration.underline, "the underline survived");
}
#[test]
fn empty_nodes_produce_no_run() {
let span = TextSpan::new("").bold().child(TextSpan::new("x"));
let runs = span.flatten(TextStyle::new(16.0));
assert_eq!(runs.len(), 1);
assert_eq!(runs[0].1.weight, FontWeight::Bold);
}
#[test]
fn a_line_height_is_a_ratio_of_the_size_that_asked_for_it() {
assert_eq!(TextStyle::new(20.0).resolved().line_height(), 24.0);
let open = TextStyle {
height: Some(1.5),
..TextStyle::new(20.0)
};
assert_eq!(open.resolved().line_height(), 30.0);
let packed = TextStyle {
height: Some(1.0),
..TextStyle::new(20.0)
};
assert_eq!(packed.resolved().line_height(), 20.0);
}
#[test]
fn the_leading_grows_with_the_reader() {
let style = TextStyle {
height: Some(1.5),
..TextStyle::new(20.0)
};
assert_eq!(style.resolved().line_height(), 30.0);
with_text_scale(2.0, || {
let resolved = style.resolved();
assert_eq!(resolved.size, 40.0);
assert_eq!(resolved.line_height(), 60.0);
});
}
#[test]
fn a_line_height_is_inherited_and_overridable() {
let handed_down = TextStyle {
height: Some(1.8),
..TextStyle::NONE
};
let asks_only_a_size = TextStyle::new(10.0);
assert_eq!(
handed_down.merge(asks_only_a_size).resolved().line_height(),
18.0,
"the size is the near style's and the height the inherited one"
);
let asks_for_both = TextStyle {
height: Some(1.0),
..TextStyle::new(10.0)
};
assert_eq!(
handed_down.merge(asks_for_both).resolved().line_height(),
10.0
);
}
#[test]
fn a_size_travels() {
let a = TextStyle::NONE.size(12.0);
let b = TextStyle::NONE.size(24.0);
assert_eq!(a.lerp(b, 0.0).size, Some(12.0));
assert_eq!(a.lerp(b, 0.5).size, Some(18.0));
assert_eq!(a.lerp(b, 1.0).size, Some(24.0));
}
#[test]
fn a_field_only_one_end_states_holds_still() {
let unset = TextStyle::NONE;
let stated = TextStyle::NONE.size(24.0);
for t in [0.0, 0.25, 0.5, 0.75, 1.0] {
assert_eq!(unset.lerp(stated, t).size, Some(24.0), "arriving, at {t}");
assert_eq!(stated.lerp(unset, t).size, Some(24.0), "leaving, at {t}");
}
assert_eq!(
unset.lerp(unset, 0.5).size,
None,
"and unset at both ends stays so"
);
}
#[test]
fn an_unset_colour_does_not_fade_out() {
let red = Color::rgb(1.0, 0.0, 0.0);
let stated = TextStyle::NONE.color(red);
let mid = TextStyle::NONE.lerp(stated, 0.5).color.expect("a colour");
assert_eq!(mid, red, "held whole, not at half alpha");
assert_eq!(TextStyle::NONE.lerp(TextStyle::NONE, 0.5).color, None);
}
#[test]
fn a_colour_stated_at_both_ends_travels() {
let a = TextStyle::NONE.color(Color::rgb(0.0, 0.0, 0.0));
let b = TextStyle::NONE.color(Color::rgb(1.0, 1.0, 1.0));
let mid = a.lerp(b, 0.5).color.expect("a colour");
assert_eq!(
mid,
Color::rgb(0.0, 0.0, 0.0).lerp(Color::rgb(1.0, 1.0, 1.0), 0.5)
);
}
#[test]
fn faces_and_lines_swap_in_the_middle() {
let upright = TextStyle {
italic: Some(false),
..TextStyle::NONE.decoration(TextDecoration::NONE)
};
let slanted = TextStyle::NONE
.italic()
.decoration(TextDecoration::UNDERLINE);
assert_eq!(upright.lerp(slanted, 0.49).italic, Some(false));
assert_eq!(upright.lerp(slanted, 0.51).italic, Some(true));
assert_eq!(
upright.lerp(slanted, 0.49).decoration,
Some(TextDecoration::NONE)
);
assert_eq!(
upright.lerp(slanted, 0.51).decoration,
Some(TextDecoration::UNDERLINE)
);
}
#[test]
fn a_weight_steps_through_the_faces() {
use FontWeight::{Bold, Medium, Regular, SemiBold};
assert_eq!(Regular.lerp(Bold, 0.0), Regular);
assert_eq!(Regular.lerp(Bold, 0.5), SemiBold, "550 is nearest 600");
assert_eq!(Regular.lerp(Bold, 1.0), Bold);
assert_eq!(Regular.lerp(Bold, 0.2), Medium, "460 is nearest 500");
assert_eq!(Bold.lerp(Regular, 0.5), SemiBold, "and the same going back");
}
}