Skip to main content

native_theme/model/
font.rs

1// Font specification and text scale types
2
3use crate::Rgba;
4use serde::{Deserialize, Serialize};
5
6/// Font style: upright, italic, or oblique.
7#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Hash, Serialize, Deserialize)]
8#[serde(rename_all = "lowercase")]
9pub enum FontStyle {
10    /// Normal upright text.
11    #[default]
12    Normal,
13    /// Italic text (true italic glyph).
14    Italic,
15    /// Oblique text (slanted upright glyph).
16    Oblique,
17}
18
19/// A font size with an explicit unit.
20///
21/// In TOML presets, this appears as either `size_pt` (typographic points)
22/// or `size_px` (logical pixels). Serde mapping is handled by the parent
23/// struct (`FontSpec`, `TextScaleEntry`) — `FontSize` itself has no
24/// `Serialize`/`Deserialize` impl.
25///
26/// During validation, all `FontSize` values are converted to logical pixels
27/// via `FontSize::to_px(dpi)`, producing a plain `f32` for the resolved model.
28#[derive(Clone, Copy, Debug, PartialEq)]
29pub enum FontSize {
30    /// Typographic points (1/72 inch). Used by platform presets where the OS
31    /// reports font sizes in points (KDE, GNOME, Windows).
32    /// Converted to px during validation: `px = pt * dpi / 72`.
33    Pt(f32),
34    /// Logical pixels. Used by community/non-platform presets where font sizes
35    /// are hand-authored in pixels.
36    Px(f32),
37}
38
39impl FontSize {
40    /// Convert to logical pixels.
41    ///
42    /// - `Pt(v)` -> `v * dpi / 72.0`
43    /// - `Px(v)` -> `v` (dpi ignored)
44    pub fn to_px(self, dpi: f32) -> f32 {
45        match self {
46            Self::Pt(v) => v * dpi / 72.0,
47            Self::Px(v) => v,
48        }
49    }
50
51    /// Return the raw numeric value regardless of unit.
52    /// Used during inheritance to compute derived values (e.g. line_height)
53    /// before unit conversion.
54    pub fn raw(self) -> f32 {
55        match self {
56            Self::Pt(v) | Self::Px(v) => v,
57        }
58    }
59
60    /// True when the value is in typographic points.
61    pub fn is_pt(self) -> bool {
62        matches!(self, Self::Pt(_))
63    }
64}
65
66impl Default for FontSize {
67    fn default() -> Self {
68        Self::Px(0.0)
69    }
70}
71
72/// Font specification: family name, size, and weight.
73///
74/// All fields are optional to support partial overlays — a FontSpec with
75/// only `size` set will only override the size when merged.
76#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)]
77#[serde(try_from = "FontSpecRaw", into = "FontSpecRaw")]
78pub struct FontSpec {
79    /// Font family name (e.g., "Inter", "Noto Sans").
80    pub family: Option<String>,
81    /// Font size with explicit unit (points or pixels).
82    ///
83    /// In TOML, set as `size_pt` (typographic points) or `size_px` (logical
84    /// pixels). Converted to `f32` logical pixels during validation via
85    /// `FontSize::to_px(dpi)`.
86    pub size: Option<FontSize>,
87    /// CSS font weight (100–900).
88    pub weight: Option<u16>,
89    /// Font style (normal, italic, oblique).
90    pub style: Option<FontStyle>,
91    /// Font color.
92    pub color: Option<Rgba>,
93}
94
95impl FontSpec {
96    /// All serialized field names for FontSpec, for TOML linting.
97    pub const FIELD_NAMES: &[&str] = &["family", "size_pt", "size_px", "weight", "style", "color"];
98}
99
100/// Serde proxy for FontSpec. Maps `FontSize` to two mutually-exclusive keys.
101#[serde_with::skip_serializing_none]
102#[derive(Default, Serialize, Deserialize)]
103#[serde(default)]
104struct FontSpecRaw {
105    family: Option<String>,
106    size_pt: Option<f32>,
107    size_px: Option<f32>,
108    weight: Option<u16>,
109    style: Option<FontStyle>,
110    color: Option<Rgba>,
111}
112
113impl TryFrom<FontSpecRaw> for FontSpec {
114    type Error = String;
115    fn try_from(raw: FontSpecRaw) -> Result<Self, Self::Error> {
116        let size = match (raw.size_pt, raw.size_px) {
117            (Some(v), None) => Some(FontSize::Pt(v)),
118            (None, Some(v)) => Some(FontSize::Px(v)),
119            (None, None) => None,
120            (Some(_), Some(_)) => return Err("font: set `size_pt` or `size_px`, not both".into()),
121        };
122        Ok(FontSpec {
123            family: raw.family,
124            size,
125            weight: raw.weight,
126            style: raw.style,
127            color: raw.color,
128        })
129    }
130}
131
132impl From<FontSpec> for FontSpecRaw {
133    fn from(fs: FontSpec) -> Self {
134        let (size_pt, size_px) = match fs.size {
135            Some(FontSize::Pt(v)) => (Some(v), None),
136            Some(FontSize::Px(v)) => (None, Some(v)),
137            None => (None, None),
138        };
139        FontSpecRaw {
140            family: fs.family,
141            size_pt,
142            size_px,
143            weight: fs.weight,
144            style: fs.style,
145            color: fs.color,
146        }
147    }
148}
149
150impl_merge!(FontSpec {
151    option { family, size, weight, style, color }
152});
153
154/// A resolved (non-optional) font specification produced after theme resolution.
155///
156/// Unlike [`FontSpec`], all fields are required (non-optional)
157/// because resolution has already filled in all defaults.
158#[derive(Clone, Debug, Default, PartialEq, serde::Serialize, serde::Deserialize)]
159pub struct ResolvedFontSpec {
160    /// Font family name.
161    pub family: String,
162    /// Font size in logical pixels. Converted from platform points during
163    /// resolution if `font_dpi` was set on the source `ThemeDefaults`.
164    pub size: f32,
165    /// CSS font weight (100–900).
166    pub weight: u16,
167    /// Font style (normal, italic, oblique).
168    pub style: FontStyle,
169    /// Font color.
170    pub color: Rgba,
171}
172
173/// A single entry in a text scale: size, weight, and line height.
174///
175/// Used to define typographic roles (caption, heading, etc.) with
176/// consistent sizing and spacing.
177#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)]
178#[serde(try_from = "TextScaleEntryRaw", into = "TextScaleEntryRaw")]
179pub struct TextScaleEntry {
180    /// Font size with explicit unit (points or pixels).
181    ///
182    /// Same semantics as `FontSpec.size` -- in TOML, set as `size_pt` or
183    /// `size_px`. Converted to `f32` logical pixels during validation.
184    pub size: Option<FontSize>,
185    /// CSS font weight (100–900).
186    pub weight: Option<u16>,
187    /// Line height with explicit unit. When `None`, `resolve()` computes it
188    /// as `defaults.line_height * size.raw()`, preserving the unit of `size`.
189    pub line_height: Option<FontSize>,
190}
191
192impl TextScaleEntry {
193    /// All serialized field names for TOML linting.
194    pub const FIELD_NAMES: &[&str] = &[
195        "size_pt",
196        "size_px",
197        "weight",
198        "line_height_pt",
199        "line_height_px",
200    ];
201}
202
203/// Serde proxy for TextScaleEntry. Maps `FontSize` to two mutually-exclusive keys.
204#[serde_with::skip_serializing_none]
205#[derive(Default, Serialize, Deserialize)]
206#[serde(default)]
207struct TextScaleEntryRaw {
208    size_pt: Option<f32>,
209    size_px: Option<f32>,
210    weight: Option<u16>,
211    line_height_pt: Option<f32>,
212    line_height_px: Option<f32>,
213}
214
215impl TryFrom<TextScaleEntryRaw> for TextScaleEntry {
216    type Error = String;
217    fn try_from(raw: TextScaleEntryRaw) -> Result<Self, Self::Error> {
218        let size = match (raw.size_pt, raw.size_px) {
219            (Some(v), None) => Some(FontSize::Pt(v)),
220            (None, Some(v)) => Some(FontSize::Px(v)),
221            (None, None) => None,
222            (Some(_), Some(_)) => {
223                return Err("text_scale: set `size_pt` or `size_px`, not both".into());
224            }
225        };
226        let line_height = match (raw.line_height_pt, raw.line_height_px) {
227            (Some(v), None) => Some(FontSize::Pt(v)),
228            (None, Some(v)) => Some(FontSize::Px(v)),
229            (None, None) => None,
230            (Some(_), Some(_)) => {
231                return Err(
232                    "text_scale: set `line_height_pt` or `line_height_px`, not both".into(),
233                );
234            }
235        };
236        if let (Some(s), Some(lh)) = (&size, &line_height)
237            && s.is_pt() != lh.is_pt()
238        {
239            return Err(
240                "text_scale: size and line_height must use the same unit suffix (_pt or _px)"
241                    .into(),
242            );
243        }
244        Ok(TextScaleEntry {
245            size,
246            weight: raw.weight,
247            line_height,
248        })
249    }
250}
251
252impl From<TextScaleEntry> for TextScaleEntryRaw {
253    fn from(e: TextScaleEntry) -> Self {
254        let (size_pt, size_px) = match e.size {
255            Some(FontSize::Pt(v)) => (Some(v), None),
256            Some(FontSize::Px(v)) => (None, Some(v)),
257            None => (None, None),
258        };
259        let (line_height_pt, line_height_px) = match e.line_height {
260            Some(FontSize::Pt(v)) => (Some(v), None),
261            Some(FontSize::Px(v)) => (None, Some(v)),
262            None => (None, None),
263        };
264        TextScaleEntryRaw {
265            size_pt,
266            size_px,
267            weight: e.weight,
268            line_height_pt,
269            line_height_px,
270        }
271    }
272}
273
274impl_merge!(TextScaleEntry {
275    option { size, weight, line_height }
276});
277
278/// A named text scale with four typographic roles.
279///
280/// Each field is an optional `TextScaleEntry` so that a partial overlay
281/// can override only specific roles.
282#[serde_with::skip_serializing_none]
283#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)]
284#[serde(default)]
285pub struct TextScale {
286    /// Caption / small label text.
287    pub caption: Option<TextScaleEntry>,
288    /// Section heading text.
289    pub section_heading: Option<TextScaleEntry>,
290    /// Dialog title text.
291    pub dialog_title: Option<TextScaleEntry>,
292    /// Large display / hero text.
293    pub display: Option<TextScaleEntry>,
294}
295
296impl TextScale {
297    /// All serialized field names for TOML linting (issue 3b).
298    pub const FIELD_NAMES: &[&str] = &["caption", "section_heading", "dialog_title", "display"];
299}
300
301impl_merge!(TextScale {
302    optional_nested { caption, section_heading, dialog_title, display }
303});
304
305#[cfg(test)]
306#[allow(clippy::unwrap_used, clippy::expect_used)]
307mod tests {
308    use super::FontSize;
309    use super::*;
310
311    // === FontSpec tests ===
312
313    #[test]
314    fn font_spec_default_is_empty() {
315        assert!(FontSpec::default().is_empty());
316    }
317
318    #[test]
319    fn font_spec_not_empty_when_family_set() {
320        let fs = FontSpec {
321            family: Some("Inter".into()),
322            ..Default::default()
323        };
324        assert!(!fs.is_empty());
325    }
326
327    #[test]
328    fn font_spec_not_empty_when_size_set() {
329        let fs = FontSpec {
330            size: Some(FontSize::Px(14.0)),
331            ..Default::default()
332        };
333        assert!(!fs.is_empty());
334    }
335
336    #[test]
337    fn font_spec_not_empty_when_weight_set() {
338        let fs = FontSpec {
339            weight: Some(700),
340            ..Default::default()
341        };
342        assert!(!fs.is_empty());
343    }
344
345    #[test]
346    fn font_spec_toml_round_trip() {
347        let fs = FontSpec {
348            family: Some("Inter".into()),
349            size: Some(FontSize::Px(14.0)),
350            weight: Some(400),
351            ..Default::default()
352        };
353        let toml_str = toml::to_string(&fs).unwrap();
354        let deserialized: FontSpec = toml::from_str(&toml_str).unwrap();
355        assert_eq!(deserialized, fs);
356    }
357
358    #[test]
359    fn font_spec_toml_round_trip_partial() {
360        let fs = FontSpec {
361            family: Some("Inter".into()),
362            size: None,
363            weight: None,
364            ..Default::default()
365        };
366        let toml_str = toml::to_string(&fs).unwrap();
367        let deserialized: FontSpec = toml::from_str(&toml_str).unwrap();
368        assert_eq!(deserialized, fs);
369        assert!(deserialized.size.is_none());
370        assert!(deserialized.weight.is_none());
371    }
372
373    #[test]
374    fn font_spec_merge_overlay_family_replaces_base() {
375        let mut base = FontSpec {
376            family: Some("Noto Sans".into()),
377            size: Some(FontSize::Px(12.0)),
378            weight: None,
379            ..Default::default()
380        };
381        let overlay = FontSpec {
382            family: Some("Inter".into()),
383            size: None,
384            weight: None,
385            ..Default::default()
386        };
387        base.merge(&overlay);
388        assert_eq!(base.family.as_deref(), Some("Inter"));
389        // base size preserved since overlay size is None
390        assert_eq!(base.size, Some(FontSize::Px(12.0)));
391    }
392
393    #[test]
394    fn font_spec_merge_none_preserves_base() {
395        let mut base = FontSpec {
396            family: Some("Noto Sans".into()),
397            size: Some(FontSize::Px(12.0)),
398            weight: Some(400),
399            ..Default::default()
400        };
401        let overlay = FontSpec::default();
402        base.merge(&overlay);
403        assert_eq!(base.family.as_deref(), Some("Noto Sans"));
404        assert_eq!(base.size, Some(FontSize::Px(12.0)));
405        assert_eq!(base.weight, Some(400));
406    }
407
408    // === TextScaleEntry tests ===
409
410    #[test]
411    fn text_scale_entry_default_is_empty() {
412        assert!(TextScaleEntry::default().is_empty());
413    }
414
415    #[test]
416    fn text_scale_entry_toml_round_trip() {
417        let entry = TextScaleEntry {
418            size: Some(FontSize::Px(12.0)),
419            weight: Some(400),
420            line_height: Some(FontSize::Px(1.4)),
421        };
422        let toml_str = toml::to_string(&entry).unwrap();
423        let deserialized: TextScaleEntry = toml::from_str(&toml_str).unwrap();
424        assert_eq!(deserialized, entry);
425    }
426
427    #[test]
428    fn text_scale_entry_merge_overlay_wins() {
429        let mut base = TextScaleEntry {
430            size: Some(FontSize::Px(12.0)),
431            weight: Some(400),
432            line_height: None,
433        };
434        let overlay = TextScaleEntry {
435            size: None,
436            weight: Some(700),
437            line_height: Some(FontSize::Px(1.5)),
438        };
439        base.merge(&overlay);
440        assert_eq!(base.size, Some(FontSize::Px(12.0))); // preserved
441        assert_eq!(base.weight, Some(700)); // overlay wins
442        assert_eq!(base.line_height, Some(FontSize::Px(1.5))); // overlay sets
443    }
444
445    // === TextScale tests ===
446
447    #[test]
448    fn text_scale_default_is_empty() {
449        assert!(TextScale::default().is_empty());
450    }
451
452    #[test]
453    fn text_scale_not_empty_when_entry_set() {
454        let ts = TextScale {
455            caption: Some(TextScaleEntry {
456                size: Some(FontSize::Px(11.0)),
457                ..Default::default()
458            }),
459            ..Default::default()
460        };
461        assert!(!ts.is_empty());
462    }
463
464    #[test]
465    fn text_scale_toml_round_trip() {
466        let ts = TextScale {
467            caption: Some(TextScaleEntry {
468                size: Some(FontSize::Px(11.0)),
469                weight: Some(400),
470                line_height: Some(FontSize::Px(1.3)),
471            }),
472            section_heading: Some(TextScaleEntry {
473                size: Some(FontSize::Px(14.0)),
474                weight: Some(600),
475                line_height: Some(FontSize::Px(1.4)),
476            }),
477            dialog_title: Some(TextScaleEntry {
478                size: Some(FontSize::Px(16.0)),
479                weight: Some(700),
480                line_height: Some(FontSize::Px(1.2)),
481            }),
482            display: Some(TextScaleEntry {
483                size: Some(FontSize::Px(24.0)),
484                weight: Some(300),
485                line_height: Some(FontSize::Px(1.1)),
486            }),
487        };
488        let toml_str = toml::to_string(&ts).unwrap();
489        let deserialized: TextScale = toml::from_str(&toml_str).unwrap();
490        assert_eq!(deserialized, ts);
491    }
492
493    #[test]
494    fn text_scale_merge_some_plus_some_merges_inner() {
495        let mut base = TextScale {
496            caption: Some(TextScaleEntry {
497                size: Some(FontSize::Px(11.0)),
498                weight: Some(400),
499                line_height: None,
500            }),
501            ..Default::default()
502        };
503        let overlay = TextScale {
504            caption: Some(TextScaleEntry {
505                size: None,
506                weight: Some(600),
507                line_height: Some(FontSize::Px(1.3)),
508            }),
509            ..Default::default()
510        };
511        base.merge(&overlay);
512        let cap = base.caption.as_ref().unwrap();
513        assert_eq!(cap.size, Some(FontSize::Px(11.0))); // base preserved
514        assert_eq!(cap.weight, Some(600)); // overlay wins
515        assert_eq!(cap.line_height, Some(FontSize::Px(1.3))); // overlay sets
516    }
517
518    #[test]
519    fn text_scale_merge_none_plus_some_clones_overlay() {
520        let mut base = TextScale::default();
521        let overlay = TextScale {
522            section_heading: Some(TextScaleEntry {
523                size: Some(FontSize::Px(14.0)),
524                ..Default::default()
525            }),
526            ..Default::default()
527        };
528        base.merge(&overlay);
529        assert!(base.section_heading.is_some());
530        assert_eq!(base.section_heading.unwrap().size, Some(FontSize::Px(14.0)));
531    }
532
533    #[test]
534    fn text_scale_merge_none_preserves_base_entry() {
535        let mut base = TextScale {
536            display: Some(TextScaleEntry {
537                size: Some(FontSize::Px(24.0)),
538                ..Default::default()
539            }),
540            ..Default::default()
541        };
542        let overlay = TextScale::default();
543        base.merge(&overlay);
544        assert!(base.display.is_some());
545        assert_eq!(base.display.unwrap().size, Some(FontSize::Px(24.0)));
546    }
547
548    // === FontStyle tests ===
549
550    #[test]
551    fn font_style_default_is_normal() {
552        assert_eq!(FontStyle::default(), FontStyle::Normal);
553    }
554
555    #[test]
556    fn font_style_serde_round_trip() {
557        // TOML cannot serialize a bare enum as a top-level value; use a wrapper struct.
558        #[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
559        struct Wrapper {
560            style: FontStyle,
561        }
562
563        for (variant, expected_str) in [
564            (FontStyle::Normal, "normal"),
565            (FontStyle::Italic, "italic"),
566            (FontStyle::Oblique, "oblique"),
567        ] {
568            let original = Wrapper { style: variant };
569            let serialized = toml::to_string(&original).unwrap();
570            assert!(serialized.contains(expected_str), "got: {serialized}");
571            let deserialized: Wrapper = toml::from_str(&serialized).unwrap();
572            assert_eq!(deserialized, original);
573        }
574    }
575
576    #[test]
577    fn font_spec_with_style_and_color_round_trip() {
578        let fs = FontSpec {
579            family: Some("Inter".into()),
580            size: Some(FontSize::Px(14.0)),
581            weight: Some(400),
582            style: Some(FontStyle::Italic),
583            color: Some(crate::Rgba::rgb(255, 0, 0)),
584        };
585        let toml_str = toml::to_string(&fs).unwrap();
586        let deserialized: FontSpec = toml::from_str(&toml_str).unwrap();
587        assert_eq!(deserialized, fs);
588    }
589
590    #[test]
591    fn font_spec_style_none_preserved() {
592        let fs = FontSpec {
593            family: Some("Inter".into()),
594            style: None,
595            ..Default::default()
596        };
597        let toml_str = toml::to_string(&fs).unwrap();
598        let deserialized: FontSpec = toml::from_str(&toml_str).unwrap();
599        assert!(deserialized.style.is_none());
600    }
601
602    #[test]
603    fn font_spec_merge_includes_style_and_color() {
604        let mut base = FontSpec {
605            family: Some("Noto Sans".into()),
606            style: Some(FontStyle::Normal),
607            color: Some(crate::Rgba::rgb(0, 0, 0)),
608            ..Default::default()
609        };
610        let overlay = FontSpec {
611            style: Some(FontStyle::Italic),
612            ..Default::default()
613        };
614        base.merge(&overlay);
615        assert_eq!(base.style, Some(FontStyle::Italic)); // overlay wins
616        assert_eq!(base.color, Some(crate::Rgba::rgb(0, 0, 0))); // base preserved
617        assert_eq!(base.family.as_deref(), Some("Noto Sans")); // base preserved
618    }
619
620    // === FontSize tests ===
621
622    #[test]
623    fn pt_to_px_at_96_dpi() {
624        assert_eq!(FontSize::Pt(10.0).to_px(96.0), 10.0 * 96.0 / 72.0);
625    }
626
627    #[test]
628    fn px_ignores_dpi() {
629        assert_eq!(FontSize::Px(14.0).to_px(96.0), 14.0);
630        assert_eq!(FontSize::Px(14.0).to_px(144.0), 14.0);
631    }
632
633    #[test]
634    fn pt_to_px_at_72_dpi_is_identity() {
635        assert_eq!(FontSize::Pt(10.0).to_px(72.0), 10.0);
636    }
637
638    #[test]
639    fn raw_extracts_value() {
640        assert_eq!(FontSize::Pt(10.0).raw(), 10.0);
641        assert_eq!(FontSize::Px(14.0).raw(), 14.0);
642    }
643
644    #[test]
645    fn font_size_default_is_px_zero() {
646        assert_eq!(FontSize::default(), FontSize::Px(0.0));
647    }
648
649    // === Serde round-trip tests ===
650
651    #[test]
652    fn fontspec_toml_round_trip_size_pt() {
653        let fs = FontSpec {
654            family: Some("Inter".into()),
655            size: Some(FontSize::Pt(10.0)),
656            weight: Some(400),
657            ..Default::default()
658        };
659        let toml_str = toml::to_string(&fs).expect("serialize");
660        assert!(
661            toml_str.contains("size_pt"),
662            "should contain size_pt: {toml_str}"
663        );
664        assert!(
665            !toml_str.contains("size_px"),
666            "should not contain size_px: {toml_str}"
667        );
668        let deserialized: FontSpec = toml::from_str(&toml_str).expect("deserialize");
669        assert_eq!(deserialized, fs);
670    }
671
672    #[test]
673    fn fontspec_toml_round_trip_size_px() {
674        let fs = FontSpec {
675            size: Some(FontSize::Px(14.0)),
676            ..Default::default()
677        };
678        let toml_str = toml::to_string(&fs).expect("serialize");
679        assert!(
680            toml_str.contains("size_px"),
681            "should contain size_px: {toml_str}"
682        );
683        assert!(
684            !toml_str.contains("size_pt"),
685            "should not contain size_pt: {toml_str}"
686        );
687        let deserialized: FontSpec = toml::from_str(&toml_str).expect("deserialize");
688        assert_eq!(deserialized, fs);
689    }
690
691    #[test]
692    fn fontspec_toml_rejects_both_pt_and_px() {
693        let toml_str = "size_pt = 10.0\nsize_px = 14.0\n";
694        assert!(toml::from_str::<FontSpec>(toml_str).is_err());
695    }
696
697    #[test]
698    fn fontspec_toml_rejects_bare_size() {
699        let toml_str = "size = 10.0\n";
700        // With #[serde(default)], the bare `size` key is NOT a recognized field
701        // in FontSpecRaw. It deserializes to FontSpec with size=None.
702        // The TOML linter (lint_toml) catches `size` as unknown separately.
703        let result: FontSpec = toml::from_str(toml_str).expect("deserialize");
704        assert!(
705            result.size.is_none(),
706            "bare 'size' should not set FontSpec.size"
707        );
708    }
709
710    #[test]
711    fn fontspec_toml_no_size_is_valid() {
712        let fs: FontSpec = toml::from_str(r#"family = "Inter""#).expect("deserialize");
713        assert!(fs.size.is_none());
714    }
715
716    #[test]
717    fn text_scale_entry_toml_round_trip_size_pt() {
718        let entry = TextScaleEntry {
719            size: Some(FontSize::Pt(9.0)),
720            weight: Some(400),
721            line_height: Some(FontSize::Pt(12.6)),
722        };
723        let toml_str = toml::to_string(&entry).expect("serialize");
724        assert!(toml_str.contains("size_pt"));
725        let deserialized: TextScaleEntry = toml::from_str(&toml_str).expect("deserialize");
726        assert_eq!(deserialized, entry);
727    }
728
729    #[test]
730    fn text_scale_entry_toml_round_trip_size_px() {
731        let entry = TextScaleEntry {
732            size: Some(FontSize::Px(14.0)),
733            weight: Some(400),
734            line_height: Some(FontSize::Px(18.0)),
735        };
736        let toml_str = toml::to_string(&entry).expect("serialize");
737        assert!(toml_str.contains("size_px"));
738        let deserialized: TextScaleEntry = toml::from_str(&toml_str).expect("deserialize");
739        assert_eq!(deserialized, entry);
740    }
741}