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