Skip to main content

cranpose_ui/text/
font.rs

1#[derive(Clone, Debug, PartialEq, Eq, Hash)]
2pub struct FontFile {
3    pub path: String,
4    pub weight: FontWeight,
5    pub style: FontStyle,
6}
7
8impl FontFile {
9    pub fn new(path: impl Into<String>) -> Self {
10        Self {
11            path: path.into(),
12            weight: FontWeight::NORMAL,
13            style: FontStyle::Normal,
14        }
15    }
16
17    pub fn with_weight(mut self, weight: FontWeight) -> Self {
18        self.weight = weight;
19        self
20    }
21
22    pub fn with_style(mut self, style: FontStyle) -> Self {
23        self.style = style;
24        self
25    }
26}
27
28#[derive(Clone, Debug, PartialEq, Eq, Hash)]
29pub struct FileBackedFontFamily {
30    pub fonts: Vec<FontFile>,
31}
32
33impl FileBackedFontFamily {
34    pub fn new(fonts: Vec<FontFile>) -> Self {
35        assert!(
36            !fonts.is_empty(),
37            "FileBackedFontFamily requires at least one font file"
38        );
39        Self { fonts }
40    }
41}
42
43#[derive(Clone, Debug, PartialEq, Eq, Hash)]
44pub struct LoadedTypefacePath {
45    pub path: String,
46}
47
48impl LoadedTypefacePath {
49    pub fn new(path: impl Into<String>) -> Self {
50        Self { path: path.into() }
51    }
52}
53
54#[derive(Clone, Debug, PartialEq, Eq, Hash, Default)]
55pub enum FontFamily {
56    #[default]
57    Default,
58    SansSerif,
59    Serif,
60    Monospace,
61    Cursive,
62    Fantasy,
63    Named(String),
64    FileBacked(FileBackedFontFamily),
65    LoadedTypeface(LoadedTypefacePath),
66}
67
68impl FontFamily {
69    pub fn named(name: impl Into<String>) -> Self {
70        Self::from_name(&name.into())
71    }
72
73    pub fn from_name(name: &str) -> Self {
74        match name {
75            "" | "Default" | "default" => Self::Default,
76            "SansSerif" | "sans-serif" => Self::SansSerif,
77            "Serif" | "serif" => Self::Serif,
78            "Monospace" | "monospace" => Self::Monospace,
79            "Cursive" | "cursive" => Self::Cursive,
80            "Fantasy" | "fantasy" => Self::Fantasy,
81            value => Self::Named(value.to_string()),
82        }
83    }
84
85    pub fn file_backed(fonts: Vec<FontFile>) -> Self {
86        Self::FileBacked(FileBackedFontFamily::new(fonts))
87    }
88
89    pub fn loaded_typeface_path(path: impl Into<String>) -> Self {
90        Self::LoadedTypeface(LoadedTypefacePath::new(path))
91    }
92
93    pub fn family_name(&self) -> Option<&str> {
94        match self {
95            Self::Named(name) => Some(name.as_str()),
96            _ => None,
97        }
98    }
99}
100
101impl From<&str> for FontFamily {
102    fn from(value: &str) -> Self {
103        Self::from_name(value)
104    }
105}
106
107impl From<String> for FontFamily {
108    fn from(value: String) -> Self {
109        Self::from_name(value.as_str())
110    }
111}
112
113#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, PartialOrd, Ord)]
114pub struct FontWeight(pub u16);
115
116impl FontWeight {
117    pub fn new(weight: u16) -> Self {
118        match Self::try_new(weight) {
119            Some(value) => value,
120            None => panic!("Font weight must be in range [1, 1000], got {weight}"),
121        }
122    }
123
124    pub const fn try_new(weight: u16) -> Option<Self> {
125        if weight >= 1 && weight <= 1000 {
126            Some(Self(weight))
127        } else {
128            None
129        }
130    }
131
132    pub const fn value(self) -> u16 {
133        self.0
134    }
135
136    pub const THIN: Self = Self(100);
137    pub const EXTRA_LIGHT: Self = Self(200);
138    pub const LIGHT: Self = Self(300);
139    pub const NORMAL: Self = Self(400);
140    pub const MEDIUM: Self = Self(500);
141    pub const SEMI_BOLD: Self = Self(600);
142    pub const BOLD: Self = Self(700);
143    pub const EXTRA_BOLD: Self = Self(800);
144    pub const BLACK: Self = Self(900);
145    pub const W100: Self = Self(100);
146    pub const W200: Self = Self(200);
147    pub const W300: Self = Self(300);
148    pub const W400: Self = Self(400);
149    pub const W500: Self = Self(500);
150    pub const W600: Self = Self(600);
151    pub const W700: Self = Self(700);
152    pub const W800: Self = Self(800);
153    pub const W900: Self = Self(900);
154}
155
156impl Default for FontWeight {
157    fn default() -> Self {
158        Self::NORMAL
159    }
160}
161
162#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, Default)]
163pub enum FontStyle {
164    #[default]
165    Normal,
166    Italic,
167}
168
169#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, Default)]
170pub enum FontSynthesis {
171    #[default]
172    None,
173    All,
174    Weight,
175    Style,
176}
177
178#[cfg(test)]
179mod tests {
180    use super::*;
181
182    #[test]
183    fn font_family_maps_compose_generic_names() {
184        assert_eq!(FontFamily::from_name("Default"), FontFamily::Default);
185        assert_eq!(FontFamily::from_name("sans-serif"), FontFamily::SansSerif);
186        assert_eq!(FontFamily::from_name("serif"), FontFamily::Serif);
187        assert_eq!(FontFamily::from_name("monospace"), FontFamily::Monospace);
188        assert_eq!(FontFamily::from_name("cursive"), FontFamily::Cursive);
189        assert_eq!(FontFamily::from_name("fantasy"), FontFamily::Fantasy);
190    }
191
192    #[test]
193    fn font_family_preserves_custom_names() {
194        let family = FontFamily::named("Fira Sans");
195        assert_eq!(family, FontFamily::Named("Fira Sans".to_string()));
196        assert_eq!(family.family_name(), Some("Fira Sans"));
197    }
198
199    #[test]
200    fn font_family_file_backed_preserves_font_entries() {
201        let family = FontFamily::file_backed(vec![
202            FontFile::new("/tmp/Roboto-Regular.ttf"),
203            FontFile::new("/tmp/Roboto-Bold.ttf").with_weight(FontWeight::BOLD),
204        ]);
205        let FontFamily::FileBacked(file_backed) = family else {
206            panic!("expected file-backed family");
207        };
208        assert_eq!(file_backed.fonts.len(), 2);
209        assert_eq!(file_backed.fonts[0].path, "/tmp/Roboto-Regular.ttf");
210        assert_eq!(file_backed.fonts[0].weight, FontWeight::NORMAL);
211        assert_eq!(file_backed.fonts[1].path, "/tmp/Roboto-Bold.ttf");
212        assert_eq!(file_backed.fonts[1].weight, FontWeight::BOLD);
213    }
214
215    #[test]
216    fn font_file_builder_applies_style_and_weight() {
217        let file = FontFile::new("/tmp/Roboto-Italic.ttf")
218            .with_weight(FontWeight::MEDIUM)
219            .with_style(FontStyle::Italic);
220        assert_eq!(file.path, "/tmp/Roboto-Italic.ttf");
221        assert_eq!(file.weight, FontWeight::MEDIUM);
222        assert_eq!(file.style, FontStyle::Italic);
223    }
224
225    #[test]
226    #[should_panic(expected = "requires at least one font file")]
227    fn file_backed_font_family_rejects_empty_font_list() {
228        let _ = FileBackedFontFamily::new(Vec::new());
229    }
230
231    #[test]
232    fn font_family_loaded_typeface_path_preserves_path() {
233        let family = FontFamily::loaded_typeface_path("/tmp/FiraSans-Regular.ttf");
234        let FontFamily::LoadedTypeface(typeface) = family else {
235            panic!("expected loaded typeface family");
236        };
237        assert_eq!(typeface.path, "/tmp/FiraSans-Regular.ttf");
238    }
239
240    #[test]
241    fn font_weight_default_is_normal() {
242        assert_eq!(FontWeight::default(), FontWeight::NORMAL);
243    }
244
245    #[test]
246    fn font_weight_try_new_validates_range() {
247        assert_eq!(FontWeight::try_new(0), None);
248        assert_eq!(FontWeight::try_new(1), Some(FontWeight(1)));
249        assert_eq!(FontWeight::try_new(1000), Some(FontWeight(1000)));
250        assert_eq!(FontWeight::try_new(1001), None);
251    }
252}