Skip to main content

native_theme/model/
font.rs

1// Font specification and text scale types
2
3use serde::{Deserialize, Serialize};
4
5/// Font specification: family name, size, and weight.
6///
7/// All fields are optional to support partial overlays — a FontSpec with
8/// only `size` set will only override the size when merged.
9#[serde_with::skip_serializing_none]
10#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)]
11#[serde(default)]
12pub struct FontSpec {
13    /// Font family name (e.g., "Inter", "Noto Sans").
14    pub family: Option<String>,
15    /// Font size in logical pixels.
16    pub size: Option<f32>,
17    /// CSS font weight (100–900).
18    pub weight: Option<u16>,
19}
20
21impl_merge!(FontSpec {
22    option { family, size, weight }
23});
24
25/// A resolved (non-optional) font specification produced after theme resolution.
26///
27/// Unlike [`FontSpec`], all fields are required (non-optional)
28/// because resolution has already filled in all defaults.
29#[derive(Clone, Debug, Default, PartialEq, serde::Serialize)]
30pub struct ResolvedFontSpec {
31    /// Font family name.
32    pub family: String,
33    /// Font size in logical pixels.
34    pub size: f32,
35    /// CSS font weight (100–900).
36    pub weight: u16,
37}
38
39/// A single entry in a text scale: size, weight, and line height.
40///
41/// Used to define typographic roles (caption, heading, etc.) with
42/// consistent sizing and spacing.
43#[serde_with::skip_serializing_none]
44#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)]
45#[serde(default)]
46pub struct TextScaleEntry {
47    /// Font size in logical pixels.
48    pub size: Option<f32>,
49    /// CSS font weight (100–900).
50    pub weight: Option<u16>,
51    /// Line height in logical pixels. When `None`, `resolve()` computes it
52    /// as `defaults.line_height × size`.
53    pub line_height: Option<f32>,
54}
55
56impl_merge!(TextScaleEntry {
57    option { size, weight, line_height }
58});
59
60/// A named text scale with four typographic roles.
61///
62/// Each field is an optional `TextScaleEntry` so that a partial overlay
63/// can override only specific roles.
64#[serde_with::skip_serializing_none]
65#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)]
66#[serde(default)]
67pub struct TextScale {
68    /// Caption / small label text.
69    pub caption: Option<TextScaleEntry>,
70    /// Section heading text.
71    pub section_heading: Option<TextScaleEntry>,
72    /// Dialog title text.
73    pub dialog_title: Option<TextScaleEntry>,
74    /// Large display / hero text.
75    pub display: Option<TextScaleEntry>,
76}
77
78impl_merge!(TextScale {
79    optional_nested { caption, section_heading, dialog_title, display }
80});
81
82#[cfg(test)]
83#[allow(clippy::unwrap_used, clippy::expect_used)]
84mod tests {
85    use super::*;
86
87    // === FontSpec tests ===
88
89    #[test]
90    fn font_spec_default_is_empty() {
91        assert!(FontSpec::default().is_empty());
92    }
93
94    #[test]
95    fn font_spec_not_empty_when_family_set() {
96        let fs = FontSpec {
97            family: Some("Inter".into()),
98            ..Default::default()
99        };
100        assert!(!fs.is_empty());
101    }
102
103    #[test]
104    fn font_spec_not_empty_when_size_set() {
105        let fs = FontSpec {
106            size: Some(14.0),
107            ..Default::default()
108        };
109        assert!(!fs.is_empty());
110    }
111
112    #[test]
113    fn font_spec_not_empty_when_weight_set() {
114        let fs = FontSpec {
115            weight: Some(700),
116            ..Default::default()
117        };
118        assert!(!fs.is_empty());
119    }
120
121    #[test]
122    fn font_spec_toml_round_trip() {
123        let fs = FontSpec {
124            family: Some("Inter".into()),
125            size: Some(14.0),
126            weight: Some(400),
127        };
128        let toml_str = toml::to_string(&fs).unwrap();
129        let deserialized: FontSpec = toml::from_str(&toml_str).unwrap();
130        assert_eq!(deserialized, fs);
131    }
132
133    #[test]
134    fn font_spec_toml_round_trip_partial() {
135        let fs = FontSpec {
136            family: Some("Inter".into()),
137            size: None,
138            weight: None,
139        };
140        let toml_str = toml::to_string(&fs).unwrap();
141        let deserialized: FontSpec = toml::from_str(&toml_str).unwrap();
142        assert_eq!(deserialized, fs);
143        assert!(deserialized.size.is_none());
144        assert!(deserialized.weight.is_none());
145    }
146
147    #[test]
148    fn font_spec_merge_overlay_family_replaces_base() {
149        let mut base = FontSpec {
150            family: Some("Noto Sans".into()),
151            size: Some(12.0),
152            weight: None,
153        };
154        let overlay = FontSpec {
155            family: Some("Inter".into()),
156            size: None,
157            weight: None,
158        };
159        base.merge(&overlay);
160        assert_eq!(base.family.as_deref(), Some("Inter"));
161        // base size preserved since overlay size is None
162        assert_eq!(base.size, Some(12.0));
163    }
164
165    #[test]
166    fn font_spec_merge_none_preserves_base() {
167        let mut base = FontSpec {
168            family: Some("Noto Sans".into()),
169            size: Some(12.0),
170            weight: Some(400),
171        };
172        let overlay = FontSpec::default();
173        base.merge(&overlay);
174        assert_eq!(base.family.as_deref(), Some("Noto Sans"));
175        assert_eq!(base.size, Some(12.0));
176        assert_eq!(base.weight, Some(400));
177    }
178
179    // === TextScaleEntry tests ===
180
181    #[test]
182    fn text_scale_entry_default_is_empty() {
183        assert!(TextScaleEntry::default().is_empty());
184    }
185
186    #[test]
187    fn text_scale_entry_toml_round_trip() {
188        let entry = TextScaleEntry {
189            size: Some(12.0),
190            weight: Some(400),
191            line_height: Some(1.4),
192        };
193        let toml_str = toml::to_string(&entry).unwrap();
194        let deserialized: TextScaleEntry = toml::from_str(&toml_str).unwrap();
195        assert_eq!(deserialized, entry);
196    }
197
198    #[test]
199    fn text_scale_entry_merge_overlay_wins() {
200        let mut base = TextScaleEntry {
201            size: Some(12.0),
202            weight: Some(400),
203            line_height: None,
204        };
205        let overlay = TextScaleEntry {
206            size: None,
207            weight: Some(700),
208            line_height: Some(1.5),
209        };
210        base.merge(&overlay);
211        assert_eq!(base.size, Some(12.0)); // preserved
212        assert_eq!(base.weight, Some(700)); // overlay wins
213        assert_eq!(base.line_height, Some(1.5)); // overlay sets
214    }
215
216    // === TextScale tests ===
217
218    #[test]
219    fn text_scale_default_is_empty() {
220        assert!(TextScale::default().is_empty());
221    }
222
223    #[test]
224    fn text_scale_not_empty_when_entry_set() {
225        let ts = TextScale {
226            caption: Some(TextScaleEntry {
227                size: Some(11.0),
228                ..Default::default()
229            }),
230            ..Default::default()
231        };
232        assert!(!ts.is_empty());
233    }
234
235    #[test]
236    fn text_scale_toml_round_trip() {
237        let ts = TextScale {
238            caption: Some(TextScaleEntry {
239                size: Some(11.0),
240                weight: Some(400),
241                line_height: Some(1.3),
242            }),
243            section_heading: Some(TextScaleEntry {
244                size: Some(14.0),
245                weight: Some(600),
246                line_height: Some(1.4),
247            }),
248            dialog_title: Some(TextScaleEntry {
249                size: Some(16.0),
250                weight: Some(700),
251                line_height: Some(1.2),
252            }),
253            display: Some(TextScaleEntry {
254                size: Some(24.0),
255                weight: Some(300),
256                line_height: Some(1.1),
257            }),
258        };
259        let toml_str = toml::to_string(&ts).unwrap();
260        let deserialized: TextScale = toml::from_str(&toml_str).unwrap();
261        assert_eq!(deserialized, ts);
262    }
263
264    #[test]
265    fn text_scale_merge_some_plus_some_merges_inner() {
266        let mut base = TextScale {
267            caption: Some(TextScaleEntry {
268                size: Some(11.0),
269                weight: Some(400),
270                line_height: None,
271            }),
272            ..Default::default()
273        };
274        let overlay = TextScale {
275            caption: Some(TextScaleEntry {
276                size: None,
277                weight: Some(600),
278                line_height: Some(1.3),
279            }),
280            ..Default::default()
281        };
282        base.merge(&overlay);
283        let cap = base.caption.as_ref().unwrap();
284        assert_eq!(cap.size, Some(11.0)); // base preserved
285        assert_eq!(cap.weight, Some(600)); // overlay wins
286        assert_eq!(cap.line_height, Some(1.3)); // overlay sets
287    }
288
289    #[test]
290    fn text_scale_merge_none_plus_some_clones_overlay() {
291        let mut base = TextScale::default();
292        let overlay = TextScale {
293            section_heading: Some(TextScaleEntry {
294                size: Some(14.0),
295                ..Default::default()
296            }),
297            ..Default::default()
298        };
299        base.merge(&overlay);
300        assert!(base.section_heading.is_some());
301        assert_eq!(base.section_heading.unwrap().size, Some(14.0));
302    }
303
304    #[test]
305    fn text_scale_merge_none_preserves_base_entry() {
306        let mut base = TextScale {
307            display: Some(TextScaleEntry {
308                size: Some(24.0),
309                ..Default::default()
310            }),
311            ..Default::default()
312        };
313        let overlay = TextScale::default();
314        base.merge(&overlay);
315        assert!(base.display.is_some());
316        assert_eq!(base.display.unwrap().size, Some(24.0));
317    }
318}