Skip to main content

native_theme/model/
font.rs

1// Font specification and text scale types
2
3use std::collections::HashSet;
4use std::sync::{Arc, Mutex};
5
6use crate::Rgba;
7use native_theme_derive::ThemeFields;
8use serde::{Deserialize, Serialize};
9
10/// Global font family intern cache.
11///
12/// Stores `Arc<str>` values so that repeated calls with the same family name
13/// return clones of the same `Arc`, avoiding redundant allocations.
14static FONT_FAMILY_CACHE: std::sync::LazyLock<Mutex<HashSet<Arc<str>>>> =
15    std::sync::LazyLock::new(|| Mutex::new(HashSet::new()));
16
17/// Intern a font family name, returning a deduplicated `Arc<str>`.
18///
19/// If the same family name has been interned before, returns a clone of the
20/// existing `Arc<str>` (same allocation, bumped reference count). Otherwise,
21/// creates a new `Arc<str>` and caches it for future lookups.
22///
23/// This is useful for connectors that resolve fonts repeatedly -- calling
24/// `intern_font_family("Inter")` 100 times allocates only once.
25///
26/// # Examples
27///
28/// ```
29/// use std::sync::Arc;
30/// use native_theme::theme::intern_font_family;
31///
32/// let a = intern_font_family("Inter");
33/// let b = intern_font_family("Inter");
34/// assert!(Arc::ptr_eq(&a, &b)); // Same allocation
35/// ```
36///
37/// # Panics
38///
39/// This function does not panic. If the internal mutex is poisoned (which
40/// can only happen if a thread panicked while holding it), a fresh `Arc<str>`
41/// is returned without caching.
42pub fn intern_font_family(family: &str) -> Arc<str> {
43    if let Ok(mut cache) = FONT_FAMILY_CACHE.lock() {
44        if let Some(existing) = cache.get(family) {
45            return Arc::clone(existing);
46        }
47        let arc: Arc<str> = Arc::from(family);
48        cache.insert(Arc::clone(&arc));
49        arc
50    } else {
51        // Mutex poisoned -- degrade gracefully without caching
52        Arc::from(family)
53    }
54}
55
56/// Font style: upright, italic, or oblique.
57#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Hash, Serialize, Deserialize)]
58#[serde(rename_all = "lowercase")]
59pub enum FontStyle {
60    /// Normal upright text.
61    #[default]
62    Normal,
63    /// Italic text (true italic glyph).
64    Italic,
65    /// Oblique text (slanted upright glyph).
66    Oblique,
67}
68
69/// A font size with an explicit unit.
70///
71/// In TOML presets, this appears as either `size_pt` (typographic points)
72/// or `size_px` (logical pixels). That mapping is done by the parent structs
73/// (`FontSpec`, `TextScaleEntry`); `FontSize` derives `Serialize` and
74/// `Deserialize` of its own for [`ResolvedFontSpec::defined_size`].
75///
76/// During validation, all `FontSize` values are converted to logical pixels
77/// via `FontSize::to_logical_px(dpi)`, producing a plain `f32` for the resolved model.
78#[derive(Clone, Copy, Debug, PartialEq, serde::Serialize, serde::Deserialize)]
79pub enum FontSize {
80    /// Typographic points (1/72 inch). Stated by the OS readers (KDE, GNOME,
81    /// macOS, Windows) and by the platform presets other than Material
82    /// (Adwaita, KDE Breeze, macOS, Windows 11, iOS).
83    /// Converted to px during validation: `px = pt * dpi / 72`.
84    Pt(f32),
85    /// Logical pixels. Stated by the Material platform preset and the
86    /// community colour-scheme presets (Catppuccin, Dracula, Gruvbox, Nord,
87    /// One Dark, Solarized, Tokyo Night).
88    Px(f32),
89}
90
91impl FontSize {
92    /// Convert to logical pixels.
93    ///
94    /// - `Pt(v)` -> `v * dpi / 72.0` (DPI matters)
95    /// - `Px(v)` -> `v` (already logical pixels, DPI ignored)
96    ///
97    /// The name `to_logical_px` makes the asymmetry explicit: `Px` values
98    /// are already in logical pixels, so the DPI parameter has no effect.
99    ///
100    /// # Examples
101    ///
102    /// ```
103    /// use native_theme::theme::FontSize;
104    ///
105    /// // Pt branch: DPI affects the result
106    /// let pt_size = FontSize::Pt(10.0);
107    /// assert_eq!(pt_size.to_logical_px(96.0), 10.0 * 96.0 / 72.0);
108    /// assert_eq!(pt_size.to_logical_px(72.0), 10.0); // identity at 72 DPI
109    ///
110    /// // Px branch: DPI is ignored, value returned unchanged
111    /// let px_size = FontSize::Px(14.0);
112    /// assert_eq!(px_size.to_logical_px(96.0), 14.0);
113    /// assert_eq!(px_size.to_logical_px(144.0), 14.0); // same regardless of DPI
114    /// ```
115    pub fn to_logical_px(self, dpi: f32) -> f32 {
116        match self {
117            Self::Pt(v) => v * dpi / 72.0,
118            Self::Px(v) => v,
119        }
120    }
121
122    /// Return the raw numeric value regardless of unit.
123    /// Used during inheritance to compute derived values (e.g. line_height)
124    /// before unit conversion.
125    pub fn raw(self) -> f32 {
126        match self {
127            Self::Pt(v) | Self::Px(v) => v,
128        }
129    }
130
131    /// True when the value is in typographic points.
132    pub fn is_pt(self) -> bool {
133        matches!(self, Self::Pt(_))
134    }
135}
136
137impl Default for FontSize {
138    fn default() -> Self {
139        Self::Px(0.0)
140    }
141}
142
143/// Font specification: family name, size, weight, style, and color.
144///
145/// All fields are optional to support partial overlays -- a FontSpec with
146/// only `size` set will only override the size when merged.
147///
148/// **Default behavior asymmetry:** During validation, `family`, `size`,
149/// `weight`, and `color` are required (missing values produce a validation
150/// error). `style` silently defaults to [`FontStyle::Normal`] because
151/// Normal is the universally-safe default -- no theme ever intends to
152/// leave style undefined in a way that would produce incorrect rendering.
153#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize, ThemeFields)]
154#[serde(try_from = "FontSpecRaw", into = "FontSpecRaw")]
155// Phase 93-05 G5: serde serializes via FontSpecRaw which maps `size: FontSize`
156// to two mutually-exclusive keys (`size_pt` / `size_px`). The ThemeFields
157// derive's introspection path cannot see the proxy, so declare the wire-format
158// field names explicitly here. Keep in sync with FontSpecRaw below.
159#[theme_layer(fields = "family, size_pt, size_px, weight, style, color")]
160pub struct FontSpec {
161    /// Font family name (e.g., "Inter", "Noto Sans").
162    pub family: Option<Arc<str>>,
163    /// Font size with explicit unit (points or pixels).
164    ///
165    /// In TOML, set as `size_pt` (typographic points) or `size_px` (logical
166    /// pixels). Converted to `f32` logical pixels during validation via
167    /// `FontSize::to_logical_px(dpi)`.
168    pub size: Option<FontSize>,
169    /// CSS font weight (100–900).
170    pub weight: Option<u16>,
171    /// Font style (normal, italic, oblique).
172    pub style: Option<FontStyle>,
173    /// Font color.
174    pub color: Option<Rgba>,
175}
176
177/// Serde proxy for FontSpec. Maps `FontSize` to two mutually-exclusive keys.
178#[serde_with::skip_serializing_none]
179#[derive(Default, Serialize, Deserialize)]
180#[serde(default)]
181struct FontSpecRaw {
182    family: Option<Arc<str>>,
183    size_pt: Option<f32>,
184    size_px: Option<f32>,
185    weight: Option<u16>,
186    style: Option<FontStyle>,
187    color: Option<Rgba>,
188}
189
190impl TryFrom<FontSpecRaw> for FontSpec {
191    type Error = String;
192    fn try_from(raw: FontSpecRaw) -> Result<Self, Self::Error> {
193        let size = match (raw.size_pt, raw.size_px) {
194            (Some(v), None) => Some(FontSize::Pt(v)),
195            (None, Some(v)) => Some(FontSize::Px(v)),
196            (None, None) => None,
197            (Some(_), Some(_)) => return Err("font: set `size_pt` or `size_px`, not both".into()),
198        };
199        Ok(FontSpec {
200            family: raw.family,
201            size,
202            weight: raw.weight,
203            style: raw.style,
204            color: raw.color,
205        })
206    }
207}
208
209impl From<FontSpec> for FontSpecRaw {
210    fn from(fs: FontSpec) -> Self {
211        let (size_pt, size_px) = match fs.size {
212            Some(FontSize::Pt(v)) => (Some(v), None),
213            Some(FontSize::Px(v)) => (None, Some(v)),
214            None => (None, None),
215        };
216        FontSpecRaw {
217            family: fs.family,
218            size_pt,
219            size_px,
220            weight: fs.weight,
221            style: fs.style,
222            color: fs.color,
223        }
224    }
225}
226
227impl_merge!(FontSpec {
228    option { family, size, weight, style, color }
229});
230
231/// A resolved (non-optional) font specification produced after theme resolution.
232///
233/// Unlike [`FontSpec`], every field but [`defined_size`](Self::defined_size)
234/// is required (non-optional) because resolution has already filled in all
235/// defaults.
236///
237/// No `Default` derive: `ResolvedFontSpec` is always
238/// constructed from a fully populated unresolved source; any "zero"
239/// instance is a placeholder sentinel built manually (see
240/// `resolve::validate_helpers::resolved_font_spec_sentinel`).
241#[derive(Clone, Debug, PartialEq, serde::Serialize, serde::Deserialize)]
242pub struct ResolvedFontSpec {
243    /// Font family name.
244    pub family: Arc<str>,
245    /// Font size in logical pixels. A size stated in points is converted at
246    /// the resolution context's
247    /// [`font_dpi`](crate::resolve::ResolutionContext::font_dpi).
248    pub size: f32,
249    /// The size as its source stated it, before conversion.
250    ///
251    /// [`size`](Self::size) is always logical pixels, which is what a toolkit
252    /// lays out with. This is the unit and number the preset or the platform
253    /// reader actually gave: the OS readers and the platform presets other
254    /// than Material state points, and Material and the community
255    /// colour-scheme presets state pixels.
256    ///
257    /// Anything that shows a size to a person should show **this**. The two
258    /// cannot be told apart after conversion -- a 14px preset and a 10.5pt
259    /// one at 96 DPI both resolve to 14.0 -- so a display that divides
260    /// `size` by the DPI would label the first as points, which no source
261    /// ever said.
262    ///
263    /// `None` only where the source stated no size at all and validation
264    /// recorded the omission.
265    #[serde(default)]
266    pub defined_size: Option<FontSize>,
267    /// CSS font weight (100–900).
268    pub weight: u16,
269    /// Font style (normal, italic, oblique).
270    pub style: FontStyle,
271    /// Font color.
272    pub color: Rgba,
273}
274
275/// A single entry in a text scale: size, weight, and line height.
276///
277/// Used to define typographic roles (caption, heading, etc.) with
278/// consistent sizing and spacing.
279#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize, ThemeFields)]
280#[serde(try_from = "TextScaleEntryRaw", into = "TextScaleEntryRaw")]
281// Phase 93-05 G5: serde proxy through TextScaleEntryRaw maps `size` and
282// `line_height` each to two mutually-exclusive keys. Wire format declared
283// explicitly. Keep in sync with TextScaleEntryRaw below.
284#[theme_layer(fields = "size_pt, size_px, weight, line_height_pt, line_height_px")]
285pub struct TextScaleEntry {
286    /// Font size with explicit unit (points or pixels).
287    ///
288    /// Same semantics as `FontSpec.size` -- in TOML, set as `size_pt` or
289    /// `size_px`. Converted to `f32` logical pixels during validation.
290    pub size: Option<FontSize>,
291    /// CSS font weight (100–900).
292    pub weight: Option<u16>,
293    /// Line height with explicit unit. When `None`, `resolve()` computes it
294    /// as `defaults.line_height * size.raw()`, preserving the unit of `size`.
295    pub line_height: Option<FontSize>,
296}
297
298/// Serde proxy for TextScaleEntry. Maps `FontSize` to two mutually-exclusive keys.
299#[serde_with::skip_serializing_none]
300#[derive(Default, Serialize, Deserialize)]
301#[serde(default)]
302struct TextScaleEntryRaw {
303    size_pt: Option<f32>,
304    size_px: Option<f32>,
305    weight: Option<u16>,
306    line_height_pt: Option<f32>,
307    line_height_px: Option<f32>,
308}
309
310impl TryFrom<TextScaleEntryRaw> for TextScaleEntry {
311    type Error = String;
312    fn try_from(raw: TextScaleEntryRaw) -> Result<Self, Self::Error> {
313        let size = match (raw.size_pt, raw.size_px) {
314            (Some(v), None) => Some(FontSize::Pt(v)),
315            (None, Some(v)) => Some(FontSize::Px(v)),
316            (None, None) => None,
317            (Some(_), Some(_)) => {
318                return Err("text_scale: set `size_pt` or `size_px`, not both".into());
319            }
320        };
321        let line_height = match (raw.line_height_pt, raw.line_height_px) {
322            (Some(v), None) => Some(FontSize::Pt(v)),
323            (None, Some(v)) => Some(FontSize::Px(v)),
324            (None, None) => None,
325            (Some(_), Some(_)) => {
326                return Err(
327                    "text_scale: set `line_height_pt` or `line_height_px`, not both".into(),
328                );
329            }
330        };
331        if let (Some(s), Some(lh)) = (&size, &line_height)
332            && s.is_pt() != lh.is_pt()
333        {
334            return Err(
335                "text_scale: size and line_height must use the same unit suffix (_pt or _px)"
336                    .into(),
337            );
338        }
339        Ok(TextScaleEntry {
340            size,
341            weight: raw.weight,
342            line_height,
343        })
344    }
345}
346
347impl From<TextScaleEntry> for TextScaleEntryRaw {
348    fn from(e: TextScaleEntry) -> Self {
349        let (size_pt, size_px) = match e.size {
350            Some(FontSize::Pt(v)) => (Some(v), None),
351            Some(FontSize::Px(v)) => (None, Some(v)),
352            None => (None, None),
353        };
354        let (line_height_pt, line_height_px) = match e.line_height {
355            Some(FontSize::Pt(v)) => (Some(v), None),
356            Some(FontSize::Px(v)) => (None, Some(v)),
357            None => (None, None),
358        };
359        TextScaleEntryRaw {
360            size_pt,
361            size_px,
362            weight: e.weight,
363            line_height_pt,
364            line_height_px,
365        }
366    }
367}
368
369impl_merge!(TextScaleEntry {
370    option { size, weight, line_height }
371});
372
373/// A named text scale with four typographic roles.
374///
375/// Each field is an optional `TextScaleEntry` so that a partial overlay
376/// can override only specific roles.
377#[serde_with::skip_serializing_none]
378#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize, ThemeFields)]
379#[serde(default)]
380pub struct TextScale {
381    /// Caption / small label text.
382    pub caption: Option<TextScaleEntry>,
383    /// Section heading text.
384    pub section_heading: Option<TextScaleEntry>,
385    /// Dialog title text.
386    pub dialog_title: Option<TextScaleEntry>,
387    /// Large display / hero text.
388    pub display: Option<TextScaleEntry>,
389}
390
391impl_merge!(TextScale {
392    optional_nested { caption, section_heading, dialog_title, display }
393});
394
395#[cfg(test)]
396#[allow(clippy::unwrap_used, clippy::expect_used)]
397mod tests {
398    use super::FontSize;
399    use super::*;
400
401    // === FontSpec tests ===
402
403    #[test]
404    fn font_spec_default_is_empty() {
405        assert!(FontSpec::default().is_empty());
406    }
407
408    #[test]
409    fn font_spec_not_empty_when_family_set() {
410        let fs = FontSpec {
411            family: Some("Inter".into()),
412            ..Default::default()
413        };
414        assert!(!fs.is_empty());
415    }
416
417    #[test]
418    fn font_spec_not_empty_when_size_set() {
419        let fs = FontSpec {
420            size: Some(FontSize::Px(14.0)),
421            ..Default::default()
422        };
423        assert!(!fs.is_empty());
424    }
425
426    #[test]
427    fn font_spec_not_empty_when_weight_set() {
428        let fs = FontSpec {
429            weight: Some(700),
430            ..Default::default()
431        };
432        assert!(!fs.is_empty());
433    }
434
435    #[test]
436    fn font_spec_toml_round_trip() {
437        let fs = FontSpec {
438            family: Some("Inter".into()),
439            size: Some(FontSize::Px(14.0)),
440            weight: Some(400),
441            ..Default::default()
442        };
443        let toml_str = toml::to_string(&fs).unwrap();
444        let deserialized: FontSpec = toml::from_str(&toml_str).unwrap();
445        assert_eq!(deserialized, fs);
446    }
447
448    #[test]
449    fn font_spec_toml_round_trip_partial() {
450        let fs = FontSpec {
451            family: Some("Inter".into()),
452            size: None,
453            weight: None,
454            ..Default::default()
455        };
456        let toml_str = toml::to_string(&fs).unwrap();
457        let deserialized: FontSpec = toml::from_str(&toml_str).unwrap();
458        assert_eq!(deserialized, fs);
459        assert!(deserialized.size.is_none());
460        assert!(deserialized.weight.is_none());
461    }
462
463    #[test]
464    fn font_spec_merge_overlay_family_replaces_base() {
465        let mut base = FontSpec {
466            family: Some("Noto Sans".into()),
467            size: Some(FontSize::Px(12.0)),
468            weight: None,
469            ..Default::default()
470        };
471        let overlay = FontSpec {
472            family: Some("Inter".into()),
473            size: None,
474            weight: None,
475            ..Default::default()
476        };
477        base.merge(&overlay);
478        assert_eq!(base.family.as_deref(), Some("Inter"));
479        // base size preserved since overlay size is None
480        assert_eq!(base.size, Some(FontSize::Px(12.0)));
481    }
482
483    #[test]
484    fn font_spec_merge_none_preserves_base() {
485        let mut base = FontSpec {
486            family: Some("Noto Sans".into()),
487            size: Some(FontSize::Px(12.0)),
488            weight: Some(400),
489            ..Default::default()
490        };
491        let overlay = FontSpec::default();
492        base.merge(&overlay);
493        assert_eq!(base.family.as_deref(), Some("Noto Sans"));
494        assert_eq!(base.size, Some(FontSize::Px(12.0)));
495        assert_eq!(base.weight, Some(400));
496    }
497
498    // === TextScaleEntry tests ===
499
500    #[test]
501    fn text_scale_entry_default_is_empty() {
502        assert!(TextScaleEntry::default().is_empty());
503    }
504
505    #[test]
506    fn text_scale_entry_toml_round_trip() {
507        let entry = TextScaleEntry {
508            size: Some(FontSize::Px(12.0)),
509            weight: Some(400),
510            line_height: Some(FontSize::Px(1.4)),
511        };
512        let toml_str = toml::to_string(&entry).unwrap();
513        let deserialized: TextScaleEntry = toml::from_str(&toml_str).unwrap();
514        assert_eq!(deserialized, entry);
515    }
516
517    #[test]
518    fn text_scale_entry_merge_overlay_wins() {
519        let mut base = TextScaleEntry {
520            size: Some(FontSize::Px(12.0)),
521            weight: Some(400),
522            line_height: None,
523        };
524        let overlay = TextScaleEntry {
525            size: None,
526            weight: Some(700),
527            line_height: Some(FontSize::Px(1.5)),
528        };
529        base.merge(&overlay);
530        assert_eq!(base.size, Some(FontSize::Px(12.0))); // preserved
531        assert_eq!(base.weight, Some(700)); // overlay wins
532        assert_eq!(base.line_height, Some(FontSize::Px(1.5))); // overlay sets
533    }
534
535    // === TextScale tests ===
536
537    #[test]
538    fn text_scale_default_is_empty() {
539        assert!(TextScale::default().is_empty());
540    }
541
542    #[test]
543    fn text_scale_not_empty_when_entry_set() {
544        let ts = TextScale {
545            caption: Some(TextScaleEntry {
546                size: Some(FontSize::Px(11.0)),
547                ..Default::default()
548            }),
549            ..Default::default()
550        };
551        assert!(!ts.is_empty());
552    }
553
554    #[test]
555    fn text_scale_toml_round_trip() {
556        let ts = TextScale {
557            caption: Some(TextScaleEntry {
558                size: Some(FontSize::Px(11.0)),
559                weight: Some(400),
560                line_height: Some(FontSize::Px(1.3)),
561            }),
562            section_heading: Some(TextScaleEntry {
563                size: Some(FontSize::Px(14.0)),
564                weight: Some(600),
565                line_height: Some(FontSize::Px(1.4)),
566            }),
567            dialog_title: Some(TextScaleEntry {
568                size: Some(FontSize::Px(16.0)),
569                weight: Some(700),
570                line_height: Some(FontSize::Px(1.2)),
571            }),
572            display: Some(TextScaleEntry {
573                size: Some(FontSize::Px(24.0)),
574                weight: Some(300),
575                line_height: Some(FontSize::Px(1.1)),
576            }),
577        };
578        let toml_str = toml::to_string(&ts).unwrap();
579        let deserialized: TextScale = toml::from_str(&toml_str).unwrap();
580        assert_eq!(deserialized, ts);
581    }
582
583    #[test]
584    fn text_scale_merge_some_plus_some_merges_inner() {
585        let mut base = TextScale {
586            caption: Some(TextScaleEntry {
587                size: Some(FontSize::Px(11.0)),
588                weight: Some(400),
589                line_height: None,
590            }),
591            ..Default::default()
592        };
593        let overlay = TextScale {
594            caption: Some(TextScaleEntry {
595                size: None,
596                weight: Some(600),
597                line_height: Some(FontSize::Px(1.3)),
598            }),
599            ..Default::default()
600        };
601        base.merge(&overlay);
602        let cap = base.caption.as_ref().unwrap();
603        assert_eq!(cap.size, Some(FontSize::Px(11.0))); // base preserved
604        assert_eq!(cap.weight, Some(600)); // overlay wins
605        assert_eq!(cap.line_height, Some(FontSize::Px(1.3))); // overlay sets
606    }
607
608    #[test]
609    fn text_scale_merge_none_plus_some_clones_overlay() {
610        let mut base = TextScale::default();
611        let overlay = TextScale {
612            section_heading: Some(TextScaleEntry {
613                size: Some(FontSize::Px(14.0)),
614                ..Default::default()
615            }),
616            ..Default::default()
617        };
618        base.merge(&overlay);
619        assert!(base.section_heading.is_some());
620        assert_eq!(base.section_heading.unwrap().size, Some(FontSize::Px(14.0)));
621    }
622
623    #[test]
624    fn text_scale_merge_none_preserves_base_entry() {
625        let mut base = TextScale {
626            display: Some(TextScaleEntry {
627                size: Some(FontSize::Px(24.0)),
628                ..Default::default()
629            }),
630            ..Default::default()
631        };
632        let overlay = TextScale::default();
633        base.merge(&overlay);
634        assert!(base.display.is_some());
635        assert_eq!(base.display.unwrap().size, Some(FontSize::Px(24.0)));
636    }
637
638    // === Arc<str> sharing test ===
639
640    #[test]
641    fn resolved_font_clone_shares_family_arc() {
642        let font = ResolvedFontSpec {
643            family: Arc::from("Inter"),
644            size: 14.0,
645            defined_size: Some(FontSize::Px(14.0)),
646            weight: 400,
647            style: FontStyle::Normal,
648            color: crate::Rgba::rgb(0, 0, 0),
649        };
650        let cloned = font.clone();
651        // str data pointers are identical -- same Arc allocation, not a deep copy
652        assert!(std::ptr::eq(
653            font.family.as_ref() as *const str,
654            cloned.family.as_ref() as *const str,
655        ));
656    }
657
658    // === FontStyle tests ===
659
660    #[test]
661    fn font_style_default_is_normal() {
662        assert_eq!(FontStyle::default(), FontStyle::Normal);
663    }
664
665    #[test]
666    fn font_style_serde_round_trip() {
667        // TOML cannot serialize a bare enum as a top-level value; use a wrapper struct.
668        #[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
669        struct Wrapper {
670            style: FontStyle,
671        }
672
673        for (variant, expected_str) in [
674            (FontStyle::Normal, "normal"),
675            (FontStyle::Italic, "italic"),
676            (FontStyle::Oblique, "oblique"),
677        ] {
678            let original = Wrapper { style: variant };
679            let serialized = toml::to_string(&original).unwrap();
680            assert!(serialized.contains(expected_str), "got: {serialized}");
681            let deserialized: Wrapper = toml::from_str(&serialized).unwrap();
682            assert_eq!(deserialized, original);
683        }
684    }
685
686    #[test]
687    fn font_spec_with_style_and_color_round_trip() {
688        let fs = FontSpec {
689            family: Some("Inter".into()),
690            size: Some(FontSize::Px(14.0)),
691            weight: Some(400),
692            style: Some(FontStyle::Italic),
693            color: Some(crate::Rgba::rgb(255, 0, 0)),
694        };
695        let toml_str = toml::to_string(&fs).unwrap();
696        let deserialized: FontSpec = toml::from_str(&toml_str).unwrap();
697        assert_eq!(deserialized, fs);
698    }
699
700    #[test]
701    fn font_spec_style_none_preserved() {
702        let fs = FontSpec {
703            family: Some("Inter".into()),
704            style: None,
705            ..Default::default()
706        };
707        let toml_str = toml::to_string(&fs).unwrap();
708        let deserialized: FontSpec = toml::from_str(&toml_str).unwrap();
709        assert!(deserialized.style.is_none());
710    }
711
712    #[test]
713    fn font_spec_merge_includes_style_and_color() {
714        let mut base = FontSpec {
715            family: Some("Noto Sans".into()),
716            style: Some(FontStyle::Normal),
717            color: Some(crate::Rgba::rgb(0, 0, 0)),
718            ..Default::default()
719        };
720        let overlay = FontSpec {
721            style: Some(FontStyle::Italic),
722            ..Default::default()
723        };
724        base.merge(&overlay);
725        assert_eq!(base.style, Some(FontStyle::Italic)); // overlay wins
726        assert_eq!(base.color, Some(crate::Rgba::rgb(0, 0, 0))); // base preserved
727        assert_eq!(base.family.as_deref(), Some("Noto Sans")); // base preserved
728    }
729
730    // === FontSize tests ===
731
732    #[test]
733    fn pt_to_logical_px_at_96_dpi() {
734        assert_eq!(FontSize::Pt(10.0).to_logical_px(96.0), 10.0 * 96.0 / 72.0);
735    }
736
737    #[test]
738    fn px_ignores_dpi() {
739        assert_eq!(FontSize::Px(14.0).to_logical_px(96.0), 14.0);
740        assert_eq!(FontSize::Px(14.0).to_logical_px(144.0), 14.0);
741    }
742
743    #[test]
744    fn pt_to_logical_px_at_72_dpi_is_identity() {
745        assert_eq!(FontSize::Pt(10.0).to_logical_px(72.0), 10.0);
746    }
747
748    #[test]
749    fn raw_extracts_value() {
750        assert_eq!(FontSize::Pt(10.0).raw(), 10.0);
751        assert_eq!(FontSize::Px(14.0).raw(), 14.0);
752    }
753
754    #[test]
755    fn font_size_default_is_px_zero() {
756        assert_eq!(FontSize::default(), FontSize::Px(0.0));
757    }
758
759    // === Serde round-trip tests ===
760
761    #[test]
762    fn fontspec_toml_round_trip_size_pt() {
763        let fs = FontSpec {
764            family: Some("Inter".into()),
765            size: Some(FontSize::Pt(10.0)),
766            weight: Some(400),
767            ..Default::default()
768        };
769        let toml_str = toml::to_string(&fs).expect("serialize");
770        assert!(
771            toml_str.contains("size_pt"),
772            "should contain size_pt: {toml_str}"
773        );
774        assert!(
775            !toml_str.contains("size_px"),
776            "should not contain size_px: {toml_str}"
777        );
778        let deserialized: FontSpec = toml::from_str(&toml_str).expect("deserialize");
779        assert_eq!(deserialized, fs);
780    }
781
782    #[test]
783    fn fontspec_toml_round_trip_size_px() {
784        let fs = FontSpec {
785            size: Some(FontSize::Px(14.0)),
786            ..Default::default()
787        };
788        let toml_str = toml::to_string(&fs).expect("serialize");
789        assert!(
790            toml_str.contains("size_px"),
791            "should contain size_px: {toml_str}"
792        );
793        assert!(
794            !toml_str.contains("size_pt"),
795            "should not contain size_pt: {toml_str}"
796        );
797        let deserialized: FontSpec = toml::from_str(&toml_str).expect("deserialize");
798        assert_eq!(deserialized, fs);
799    }
800
801    #[test]
802    fn fontspec_toml_rejects_both_pt_and_px() {
803        let toml_str = "size_pt = 10.0\nsize_px = 14.0\n";
804        assert!(toml::from_str::<FontSpec>(toml_str).is_err());
805    }
806
807    #[test]
808    fn fontspec_toml_rejects_bare_size() {
809        let toml_str = "size = 10.0\n";
810        // With #[serde(default)], the bare `size` key is NOT a recognized field
811        // in FontSpecRaw. It deserializes to FontSpec with size=None.
812        // The TOML linter (lint_toml) catches `size` as unknown separately.
813        let result: FontSpec = toml::from_str(toml_str).expect("deserialize");
814        assert!(
815            result.size.is_none(),
816            "bare 'size' should not set FontSpec.size"
817        );
818    }
819
820    #[test]
821    fn fontspec_toml_no_size_is_valid() {
822        let fs: FontSpec = toml::from_str(r#"family = "Inter""#).expect("deserialize");
823        assert!(fs.size.is_none());
824    }
825
826    #[test]
827    fn text_scale_entry_toml_round_trip_size_pt() {
828        let entry = TextScaleEntry {
829            size: Some(FontSize::Pt(9.0)),
830            weight: Some(400),
831            line_height: Some(FontSize::Pt(12.6)),
832        };
833        let toml_str = toml::to_string(&entry).expect("serialize");
834        assert!(toml_str.contains("size_pt"));
835        let deserialized: TextScaleEntry = toml::from_str(&toml_str).expect("deserialize");
836        assert_eq!(deserialized, entry);
837    }
838
839    #[test]
840    fn text_scale_entry_toml_round_trip_size_px() {
841        let entry = TextScaleEntry {
842            size: Some(FontSize::Px(14.0)),
843            weight: Some(400),
844            line_height: Some(FontSize::Px(18.0)),
845        };
846        let toml_str = toml::to_string(&entry).expect("serialize");
847        assert!(toml_str.contains("size_px"));
848        let deserialized: TextScaleEntry = toml::from_str(&toml_str).expect("deserialize");
849        assert_eq!(deserialized, entry);
850    }
851}