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
33#[derive(Clone, Copy, Debug, PartialEq, Eq)]
34pub enum FontFamilyError {
35    EmptyFileList,
36}
37
38impl std::fmt::Display for FontFamilyError {
39    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
40        match self {
41            FontFamilyError::EmptyFileList => {
42                write!(f, "file-backed font family requires at least one font file")
43            }
44        }
45    }
46}
47
48impl std::error::Error for FontFamilyError {}
49
50impl FileBackedFontFamily {
51    pub fn new(fonts: Vec<FontFile>) -> Result<Self, FontFamilyError> {
52        if fonts.is_empty() {
53            return Err(FontFamilyError::EmptyFileList);
54        }
55        Ok(Self { fonts })
56    }
57}
58
59#[derive(Clone, Debug, PartialEq, Eq, Hash)]
60pub struct LoadedTypefacePath {
61    pub path: String,
62}
63
64impl LoadedTypefacePath {
65    pub fn new(path: impl Into<String>) -> Self {
66        Self { path: path.into() }
67    }
68}
69
70#[derive(Clone, Debug, PartialEq, Eq, Hash, Default)]
71pub enum FontFamily {
72    #[default]
73    Default,
74    SansSerif,
75    Serif,
76    Monospace,
77    Cursive,
78    Fantasy,
79    Named(String),
80    FileBacked(FileBackedFontFamily),
81    LoadedTypeface(LoadedTypefacePath),
82}
83
84impl FontFamily {
85    pub fn named(name: impl Into<String>) -> Self {
86        Self::from_name(&name.into())
87    }
88
89    pub fn from_name(name: &str) -> Self {
90        match name {
91            "" | "Default" | "default" => Self::Default,
92            "SansSerif" | "sans-serif" => Self::SansSerif,
93            "Serif" | "serif" => Self::Serif,
94            "Monospace" | "monospace" => Self::Monospace,
95            "Cursive" | "cursive" => Self::Cursive,
96            "Fantasy" | "fantasy" => Self::Fantasy,
97            value => Self::Named(value.to_string()),
98        }
99    }
100
101    pub fn file_backed(fonts: Vec<FontFile>) -> Result<Self, FontFamilyError> {
102        FileBackedFontFamily::new(fonts).map(Self::FileBacked)
103    }
104
105    pub fn loaded_typeface_path(path: impl Into<String>) -> Self {
106        Self::LoadedTypeface(LoadedTypefacePath::new(path))
107    }
108
109    pub fn family_name(&self) -> Option<&str> {
110        match self {
111            Self::Named(name) => Some(name.as_str()),
112            _ => None,
113        }
114    }
115}
116
117impl From<&str> for FontFamily {
118    fn from(value: &str) -> Self {
119        Self::from_name(value)
120    }
121}
122
123impl From<String> for FontFamily {
124    fn from(value: String) -> Self {
125        Self::from_name(value.as_str())
126    }
127}
128
129#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, PartialOrd, Ord)]
130pub struct FontWeight(pub u16);
131
132impl FontWeight {
133    pub const fn new(weight: u16) -> Self {
134        Self::clamped(weight)
135    }
136
137    pub const fn try_new(weight: u16) -> Option<Self> {
138        if weight >= 1 && weight <= 1000 {
139            Some(Self(weight))
140        } else {
141            None
142        }
143    }
144
145    pub const fn clamped(weight: u16) -> Self {
146        if weight < 1 {
147            Self(1)
148        } else if weight > 1000 {
149            Self(1000)
150        } else {
151            Self(weight)
152        }
153    }
154
155    pub const fn value(self) -> u16 {
156        self.0
157    }
158
159    pub const THIN: Self = Self(100);
160    pub const EXTRA_LIGHT: Self = Self(200);
161    pub const LIGHT: Self = Self(300);
162    pub const NORMAL: Self = Self(400);
163    pub const MEDIUM: Self = Self(500);
164    pub const SEMI_BOLD: Self = Self(600);
165    pub const BOLD: Self = Self(700);
166    pub const EXTRA_BOLD: Self = Self(800);
167    pub const BLACK: Self = Self(900);
168    pub const W100: Self = Self(100);
169    pub const W200: Self = Self(200);
170    pub const W300: Self = Self(300);
171    pub const W400: Self = Self(400);
172    pub const W500: Self = Self(500);
173    pub const W600: Self = Self(600);
174    pub const W700: Self = Self(700);
175    pub const W800: Self = Self(800);
176    pub const W900: Self = Self(900);
177}
178
179impl Default for FontWeight {
180    fn default() -> Self {
181        Self::NORMAL
182    }
183}
184
185#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, Default)]
186pub enum FontStyle {
187    #[default]
188    Normal,
189    Italic,
190}
191
192#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, Default)]
193pub enum FontSynthesis {
194    #[default]
195    None,
196    All,
197    Weight,
198    Style,
199}
200
201#[cfg(test)]
202mod tests {
203    use super::*;
204
205    #[test]
206    fn font_family_maps_compose_generic_names() {
207        assert_eq!(FontFamily::from_name("Default"), FontFamily::Default);
208        assert_eq!(FontFamily::from_name("sans-serif"), FontFamily::SansSerif);
209        assert_eq!(FontFamily::from_name("serif"), FontFamily::Serif);
210        assert_eq!(FontFamily::from_name("monospace"), FontFamily::Monospace);
211        assert_eq!(FontFamily::from_name("cursive"), FontFamily::Cursive);
212        assert_eq!(FontFamily::from_name("fantasy"), FontFamily::Fantasy);
213    }
214
215    #[test]
216    fn font_family_preserves_custom_names() {
217        let family = FontFamily::named("Fira Sans");
218        assert_eq!(family, FontFamily::Named("Fira Sans".to_string()));
219        assert_eq!(family.family_name(), Some("Fira Sans"));
220    }
221
222    #[test]
223    fn font_family_file_backed_preserves_font_entries() {
224        let family = FontFamily::file_backed(vec![
225            FontFile::new("fixtures/fonts/Roboto-Regular.ttf"),
226            FontFile::new("fixtures/fonts/Roboto-Bold.ttf").with_weight(FontWeight::BOLD),
227        ])
228        .expect("valid file-backed font family");
229        let FontFamily::FileBacked(file_backed) = family else {
230            panic!("expected file-backed family");
231        };
232        assert_eq!(file_backed.fonts.len(), 2);
233        assert_eq!(
234            file_backed.fonts[0].path,
235            "fixtures/fonts/Roboto-Regular.ttf"
236        );
237        assert_eq!(file_backed.fonts[0].weight, FontWeight::NORMAL);
238        assert_eq!(file_backed.fonts[1].path, "fixtures/fonts/Roboto-Bold.ttf");
239        assert_eq!(file_backed.fonts[1].weight, FontWeight::BOLD);
240    }
241
242    #[test]
243    fn font_file_builder_applies_style_and_weight() {
244        let file = FontFile::new("fixtures/fonts/Roboto-Italic.ttf")
245            .with_weight(FontWeight::MEDIUM)
246            .with_style(FontStyle::Italic);
247        assert_eq!(file.path, "fixtures/fonts/Roboto-Italic.ttf");
248        assert_eq!(file.weight, FontWeight::MEDIUM);
249        assert_eq!(file.style, FontStyle::Italic);
250    }
251
252    #[test]
253    fn file_backed_font_family_rejects_empty_font_list() {
254        assert_eq!(
255            FileBackedFontFamily::new(Vec::new()),
256            Err(FontFamilyError::EmptyFileList)
257        );
258    }
259
260    #[test]
261    fn font_family_loaded_typeface_path_preserves_path() {
262        let family = FontFamily::loaded_typeface_path("fixtures/fonts/FiraSans-Regular.ttf");
263        let FontFamily::LoadedTypeface(typeface) = family else {
264            panic!("expected loaded typeface family");
265        };
266        assert_eq!(typeface.path, "fixtures/fonts/FiraSans-Regular.ttf");
267    }
268
269    #[test]
270    fn font_weight_default_is_normal() {
271        assert_eq!(FontWeight::default(), FontWeight::NORMAL);
272    }
273
274    #[test]
275    fn font_weight_try_new_validates_range() {
276        assert_eq!(FontWeight::try_new(0), None);
277        assert_eq!(FontWeight::try_new(1), Some(FontWeight(1)));
278        assert_eq!(FontWeight::try_new(1000), Some(FontWeight(1000)));
279        assert_eq!(FontWeight::try_new(1001), None);
280    }
281
282    #[test]
283    fn font_weight_new_clamps_invalid_input() {
284        assert_eq!(FontWeight::new(0), FontWeight(1));
285        assert_eq!(FontWeight::new(500), FontWeight(500));
286        assert_eq!(FontWeight::new(1001), FontWeight(1000));
287    }
288}