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