ltk/theme/text_style.rs
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 205 206 207 208 209 210 211 212 213 214 215 216 217 218 219 220 221 222 223 224 225
// SPDX-License-Identifier: LGPL-2.1-only
// Copyright (C) 2026 Liberux Labs, S. L. <info@liberux.net>
//! Typography tokens: a resolved text style (family + weight + size + …) as
//! the theme knows it.
//!
//! The struct is deliberately named [`TextStyle`] rather than `Typography`
//! to avoid colliding with the [`super::typography`] constants module that
//! ships alongside it.
//!
//! # Resolution
//!
//! A [`TextStyle`] carries a [`FontRef`] (by name) and optionally a slot
//! reference for its default colour. Both are resolved lazily by the theme's
//! slot table: the string IDs live here, the actual font bytes and
//! [`crate::types::Color`] value come from the font registry and the slot
//! store respectively. This keeps the struct copy-cheap and JSON-friendly.
// ─── Font reference ──────────────────────────────────────────────────────────
/// Reference to a font family registered in the theme's `fonts` block.
///
/// A family name like `"sora"` maps to a [`super::FontFamilyDef`] that
/// lists the actual `.ttf` paths for each weight/style. Keeping the
/// reference by name lets multiple text styles share a family without
/// duplicating the source list.
#[ derive( Debug, Clone, PartialEq, Eq, Hash ) ]
pub enum FontRef
{
/// Reference by family id, looked up in the theme's `fonts` block.
Named( String ),
}
// ─── Style axes ──────────────────────────────────────────────────────────────
/// Italic vs upright.
#[ derive( Debug, Clone, Copy, PartialEq, Eq, Hash ) ]
pub enum FontStyle
{
Normal,
Italic,
}
impl Default for FontStyle
{
fn default() -> Self { FontStyle::Normal }
}
/// Case transform applied at render time.
#[ derive( Debug, Clone, Copy, PartialEq, Eq ) ]
pub enum TextTransform
{
/// Render the string unchanged.
None,
/// Uppercase every character.
Uppercase,
/// Lowercase every character.
Lowercase,
/// Uppercase the first letter of each word, leave the rest untouched.
Capitalize,
}
impl Default for TextTransform
{
fn default() -> Self { TextTransform::None }
}
/// Underline / strikethrough decoration.
#[ derive( Debug, Clone, Copy, PartialEq, Eq ) ]
pub enum TextDecoration
{
None,
Underline,
Strikethrough,
}
impl Default for TextDecoration
{
fn default() -> Self { TextDecoration::None }
}
// ─── Line height ─────────────────────────────────────────────────────────────
/// How the vertical advance between lines is specified.
///
/// Both forms are supported because design-tool exports usually emit absolute
/// pixel heights, while hand-authored themes more often prefer relative
/// line-heights that scale with the font size.
#[ derive( Debug, Clone, Copy, PartialEq ) ]
pub enum LineHeight
{
/// Absolute pixel box height for the line.
Px( f32 ),
/// Multiplier of the font size. `1.5` means line-height is 1.5× the size.
Multiplier( f32 ),
}
impl LineHeight
{
/// Resolve to an absolute pixel height given the font size.
pub fn resolve( self, font_size: f32 ) -> f32
{
match self
{
LineHeight::Px( h ) => h,
LineHeight::Multiplier( m ) => font_size * m,
}
}
}
// ─── TextStyle ───────────────────────────────────────────────────────────────
/// A resolved text style: family, weight, size, line-height and the visual
/// modifiers that go with them.
///
/// `color` is a slot id (string) rather than a resolved [`crate::types::Color`]
/// because the slot store is what knows how to resolve names to values — and
/// widgets can override the colour with [`.color()`] at the call-site, which
/// always wins over the style's default.
#[ derive( Debug, Clone, PartialEq ) ]
pub struct TextStyle
{
/// Font family, looked up in the theme's `fonts` block.
pub family: FontRef,
/// Weight as a CSS numeric value (100..=900).
pub weight: u16,
/// Italic vs upright.
pub style: FontStyle,
/// Font size in CSS pixels.
pub size: f32,
/// Line height.
pub line_height: LineHeight,
/// Letter spacing in `em` (fraction of the font size). `0.0` by default.
pub letter_spacing: f32,
/// Case transform applied before shaping.
pub transform: TextTransform,
/// Underline / strikethrough.
pub decoration: TextDecoration,
/// Default colour slot id. `None` means "inherit from the widget's own
/// colour setting". When `Some`, widgets that don't override with
/// [`.color()`] get this.
pub color: Option<String>,
}
impl TextStyle
{
/// Convenience constructor that fills the non-essential fields with
/// sensible defaults (upright, no transform, no decoration, no default
/// colour, no letter spacing).
pub fn new( family: FontRef, weight: u16, size: f32, line_height: LineHeight ) -> Self
{
Self
{
family,
weight,
style: FontStyle::Normal,
size,
line_height,
letter_spacing: 0.0,
transform: TextTransform::None,
decoration: TextDecoration::None,
color: None,
}
}
}
// ─── Tests ───────────────────────────────────────────────────────────────────
#[ cfg( test ) ]
mod tests
{
use super::*;
#[ test ]
fn new_constructor_sets_sensible_defaults()
{
let s = TextStyle::new
(
FontRef::Named( "sora".to_string() ),
400,
16.0,
LineHeight::Px( 24.0 ),
);
assert_eq!( s.weight, 400 );
assert_eq!( s.size, 16.0 );
assert_eq!( s.line_height.resolve( 16.0 ), 24.0 );
assert_eq!( s.transform, TextTransform::None );
assert_eq!( s.letter_spacing, 0.0 );
assert!( s.color.is_none() );
}
#[ test ]
fn caption_l_upcases_at_render_time()
{
let s = TextStyle
{
family: FontRef::Named( "sora".to_string() ),
weight: 400,
style: FontStyle::Normal,
size: 16.0,
line_height: LineHeight::Px( 20.0 ),
letter_spacing: 0.0,
transform: TextTransform::Uppercase,
decoration: TextDecoration::None,
color: None,
};
assert_eq!( s.transform, TextTransform::Uppercase );
}
#[ test ]
fn line_height_multiplier_scales_with_size()
{
let lh = LineHeight::Multiplier( 1.5 );
assert_eq!( lh.resolve( 16.0 ), 24.0 );
assert_eq!( lh.resolve( 12.0 ), 18.0 );
}
#[ test ]
fn defaults_are_normal_and_none()
{
assert_eq!( FontStyle::default(), FontStyle::Normal );
assert_eq!( TextTransform::default(), TextTransform::None );
assert_eq!( TextDecoration::default(), TextDecoration::None );
}
}