Skip to main content

native_theme/model/
mod.rs

1// Theme model: ThemeVariant and NativeTheme, plus sub-module re-exports
2
3pub mod bundled;
4pub mod colors;
5pub mod fonts;
6pub mod geometry;
7pub mod icons;
8pub mod spacing;
9pub mod widget_metrics;
10
11pub use bundled::{bundled_icon_by_name, bundled_icon_svg};
12pub use colors::ThemeColors;
13pub use fonts::ThemeFonts;
14pub use geometry::ThemeGeometry;
15pub use icons::{
16    IconData, IconProvider, IconRole, IconSet, icon_name, system_icon_set, system_icon_theme,
17};
18pub use spacing::ThemeSpacing;
19pub use widget_metrics::{
20    ButtonMetrics, CheckboxMetrics, InputMetrics, ListItemMetrics, MenuItemMetrics,
21    ProgressBarMetrics, ScrollbarMetrics, SliderMetrics, SplitterMetrics, TabMetrics,
22    ToolbarMetrics, TooltipMetrics, WidgetMetrics,
23};
24
25use serde::{Deserialize, Serialize};
26
27/// A single light or dark theme variant containing all visual properties.
28///
29/// Composes colors, fonts, geometry, and spacing into one coherent set.
30/// Empty sub-structs are omitted from serialization to keep TOML files clean.
31///
32/// # Examples
33///
34/// ```
35/// use native_theme::{ThemeVariant, Rgba};
36///
37/// let mut variant = ThemeVariant::default();
38/// variant.colors.accent = Some(Rgba::rgb(0, 120, 215));
39/// variant.fonts.family = Some("Inter".into());
40/// assert!(!variant.is_empty());
41/// ```
42#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)]
43#[serde(default)]
44#[non_exhaustive]
45pub struct ThemeVariant {
46    #[serde(default, skip_serializing_if = "ThemeColors::is_empty")]
47    pub colors: ThemeColors,
48
49    #[serde(default, skip_serializing_if = "ThemeFonts::is_empty")]
50    pub fonts: ThemeFonts,
51
52    #[serde(default, skip_serializing_if = "ThemeGeometry::is_empty")]
53    pub geometry: ThemeGeometry,
54
55    #[serde(default, skip_serializing_if = "ThemeSpacing::is_empty")]
56    pub spacing: ThemeSpacing,
57
58    /// Per-widget sizing and spacing metrics.
59    ///
60    /// Optional because not all themes or presets provide widget metrics.
61    /// When merging, if both base and overlay have widget metrics they are
62    /// merged recursively; if only the overlay has them they are cloned.
63    #[serde(default, skip_serializing_if = "Option::is_none")]
64    pub widget_metrics: Option<WidgetMetrics>,
65
66    /// Icon set / naming convention for this variant (e.g., "sf-symbols", "freedesktop").
67    /// When None, resolved at runtime via system_icon_set().
68    #[serde(default, skip_serializing_if = "Option::is_none", alias = "icon_theme")]
69    pub icon_set: Option<String>,
70}
71
72impl ThemeVariant {
73    /// Merge an overlay into this value. `Some` fields in the overlay
74    /// replace the corresponding fields in self; `None` fields are
75    /// left unchanged. Nested structs are merged recursively.
76    pub fn merge(&mut self, overlay: &Self) {
77        self.colors.merge(&overlay.colors);
78        self.fonts.merge(&overlay.fonts);
79        self.geometry.merge(&overlay.geometry);
80        self.spacing.merge(&overlay.spacing);
81
82        match (&mut self.widget_metrics, &overlay.widget_metrics) {
83            (Some(base), Some(over)) => base.merge(over),
84            (None, Some(over)) => self.widget_metrics = Some(over.clone()),
85            _ => {}
86        }
87
88        if overlay.icon_set.is_some() {
89            self.icon_set.clone_from(&overlay.icon_set);
90        }
91    }
92
93    /// Returns true if all fields are at their default (None/empty) state.
94    pub fn is_empty(&self) -> bool {
95        self.colors.is_empty()
96            && self.fonts.is_empty()
97            && self.geometry.is_empty()
98            && self.spacing.is_empty()
99            && self.widget_metrics.as_ref().is_none_or(|wm| wm.is_empty())
100            && self.icon_set.is_none()
101    }
102}
103
104/// A complete native theme with a name and optional light/dark variants.
105///
106/// This is the top-level type that theme files deserialize into and that
107/// platform readers produce.
108///
109/// # Examples
110///
111/// ```
112/// use native_theme::NativeTheme;
113///
114/// // Load a bundled preset
115/// let theme = NativeTheme::preset("dracula").unwrap();
116/// assert_eq!(theme.name, "Dracula");
117///
118/// // Parse from a TOML string
119/// let toml = r##"
120/// name = "Custom"
121/// [light.colors]
122/// accent = "#ff6600"
123/// "##;
124/// let custom = NativeTheme::from_toml(toml).unwrap();
125/// assert_eq!(custom.name, "Custom");
126///
127/// // Merge themes (overlay wins for populated fields)
128/// let mut base = NativeTheme::preset("default").unwrap();
129/// base.merge(&custom);
130/// assert_eq!(base.name, "Default"); // base name is preserved
131/// ```
132#[derive(Clone, Debug, Default, Serialize, Deserialize)]
133#[non_exhaustive]
134#[must_use = "constructing a theme without using it is likely a bug"]
135pub struct NativeTheme {
136    /// Theme name (e.g., "Breeze", "Adwaita", "Windows 11").
137    pub name: String,
138
139    /// Light variant of the theme.
140    #[serde(default, skip_serializing_if = "Option::is_none")]
141    pub light: Option<ThemeVariant>,
142
143    /// Dark variant of the theme.
144    #[serde(default, skip_serializing_if = "Option::is_none")]
145    pub dark: Option<ThemeVariant>,
146}
147
148impl NativeTheme {
149    /// Create a new theme with the given name and no variants.
150    pub fn new(name: impl Into<String>) -> Self {
151        Self {
152            name: name.into(),
153            light: None,
154            dark: None,
155        }
156    }
157
158    /// Merge an overlay theme into this theme.
159    ///
160    /// The base name is kept. For each variant (light/dark):
161    /// - If both base and overlay have a variant, they are merged recursively.
162    /// - If only the overlay has a variant, it is cloned into the base.
163    /// - If only the base has a variant (or neither), no change.
164    pub fn merge(&mut self, overlay: &Self) {
165        // Keep base name (do not overwrite)
166
167        match (&mut self.light, &overlay.light) {
168            (Some(base), Some(over)) => base.merge(over),
169            (None, Some(over)) => self.light = Some(over.clone()),
170            _ => {}
171        }
172
173        match (&mut self.dark, &overlay.dark) {
174            (Some(base), Some(over)) => base.merge(over),
175            (None, Some(over)) => self.dark = Some(over.clone()),
176            _ => {}
177        }
178    }
179
180    /// Pick the appropriate variant for the given mode, with cross-fallback.
181    ///
182    /// When `is_dark` is true, prefers `dark` and falls back to `light`.
183    /// When `is_dark` is false, prefers `light` and falls back to `dark`.
184    /// Returns `None` only if the theme has no variants at all.
185    #[must_use = "this returns the selected variant; it does not apply it"]
186    pub fn pick_variant(&self, is_dark: bool) -> Option<&ThemeVariant> {
187        if is_dark {
188            self.dark.as_ref().or(self.light.as_ref())
189        } else {
190            self.light.as_ref().or(self.dark.as_ref())
191        }
192    }
193
194    /// Returns true if the theme has no variants set.
195    pub fn is_empty(&self) -> bool {
196        self.light.is_none() && self.dark.is_none()
197    }
198
199    /// Load a bundled theme preset by name.
200    ///
201    /// Returns the preset as a fully populated [`NativeTheme`] with both
202    /// light and dark variants.
203    ///
204    /// # Errors
205    /// Returns [`crate::Error::Unavailable`] if the preset name is not recognized.
206    ///
207    /// # Examples
208    /// ```
209    /// let theme = native_theme::NativeTheme::preset("default").unwrap();
210    /// assert!(theme.light.is_some());
211    /// ```
212    #[must_use = "this returns a theme preset; it does not apply it"]
213    pub fn preset(name: &str) -> crate::Result<Self> {
214        crate::presets::preset(name)
215    }
216
217    /// Parse a TOML string into a [`NativeTheme`].
218    ///
219    /// # TOML Format
220    ///
221    /// Theme files use the following structure. All fields are `Option<T>` --
222    /// omit any field you don't need. Unknown fields are silently ignored.
223    /// Hex colors accept `#RRGGBB` or `#RRGGBBAA` format.
224    ///
225    /// ```toml
226    /// name = "My Theme"
227    ///
228    /// [light.colors]
229    /// # Core (7)
230    /// accent = "#4a90d9"
231    /// background = "#fafafa"
232    /// foreground = "#2e3436"
233    /// surface = "#ffffff"
234    /// border = "#c0c0c0"
235    /// muted = "#929292"
236    /// shadow = "#00000018"
237    /// # Primary (2)
238    /// primary_background = "#4a90d9"
239    /// primary_foreground = "#ffffff"
240    /// # Secondary (2)
241    /// secondary_background = "#6c757d"
242    /// secondary_foreground = "#ffffff"
243    /// # Status (8) -- each has an optional _foreground variant
244    /// danger = "#dc3545"
245    /// danger_foreground = "#ffffff"
246    /// warning = "#f0ad4e"
247    /// warning_foreground = "#ffffff"
248    /// success = "#28a745"
249    /// success_foreground = "#ffffff"
250    /// info = "#4a90d9"
251    /// info_foreground = "#ffffff"
252    /// # Interactive (4)
253    /// selection = "#4a90d9"
254    /// selection_foreground = "#ffffff"
255    /// link = "#2a6cb6"
256    /// focus_ring = "#4a90d9"
257    /// # Panel (6) -- each has an optional _foreground variant
258    /// sidebar = "#f0f0f0"
259    /// sidebar_foreground = "#2e3436"
260    /// tooltip = "#2e3436"
261    /// tooltip_foreground = "#ffffff"
262    /// popover = "#ffffff"
263    /// popover_foreground = "#2e3436"
264    /// # Component (7) -- button and input have _foreground variants
265    /// button = "#e8e8e8"
266    /// button_foreground = "#2e3436"
267    /// input = "#ffffff"
268    /// input_foreground = "#2e3436"
269    /// disabled = "#c0c0c0"
270    /// separator = "#d0d0d0"
271    /// alternate_row = "#f5f5f5"
272    ///
273    /// [light.fonts]
274    /// family = "sans-serif"
275    /// size = 10.0
276    /// mono_family = "monospace"
277    /// mono_size = 10.0
278    ///
279    /// [light.geometry]
280    /// radius = 6.0
281    /// radius_lg = 12.0
282    /// frame_width = 1.0
283    /// disabled_opacity = 0.5
284    /// border_opacity = 0.15
285    /// scroll_width = 8.0
286    ///
287    /// [light.spacing]
288    /// xxs = 2.0
289    /// xs = 4.0
290    /// s = 6.0
291    /// m = 12.0
292    /// l = 18.0
293    /// xl = 24.0
294    /// xxl = 36.0
295    ///
296    /// # [dark.*] mirrors the same structure as [light.*]
297    /// ```
298    ///
299    /// # Errors
300    /// Returns [`crate::Error::Format`] if the TOML is invalid.
301    ///
302    /// # Examples
303    /// ```
304    /// let toml = r##"
305    /// name = "My Theme"
306    /// [light.colors]
307    /// accent = "#ff0000"
308    /// "##;
309    /// let theme = native_theme::NativeTheme::from_toml(toml).unwrap();
310    /// assert_eq!(theme.name, "My Theme");
311    /// ```
312    #[must_use = "this parses a TOML string into a theme; it does not apply it"]
313    pub fn from_toml(toml_str: &str) -> crate::Result<Self> {
314        crate::presets::from_toml(toml_str)
315    }
316
317    /// Load a [`NativeTheme`] from a TOML file.
318    ///
319    /// # Errors
320    /// Returns [`crate::Error::Unavailable`] if the file cannot be read.
321    ///
322    /// # Examples
323    /// ```no_run
324    /// let theme = native_theme::NativeTheme::from_file("my-theme.toml").unwrap();
325    /// ```
326    #[must_use = "this loads a theme from a file; it does not apply it"]
327    pub fn from_file(path: impl AsRef<std::path::Path>) -> crate::Result<Self> {
328        crate::presets::from_file(path)
329    }
330
331    /// List all available bundled preset names.
332    ///
333    /// # Examples
334    /// ```
335    /// let names = native_theme::NativeTheme::list_presets();
336    /// assert_eq!(names.len(), 17);
337    /// ```
338    #[must_use = "this returns the list of preset names"]
339    pub fn list_presets() -> &'static [&'static str] {
340        crate::presets::list_presets()
341    }
342
343    /// Serialize this theme to a TOML string.
344    ///
345    /// # Errors
346    /// Returns [`crate::Error::Format`] if serialization fails.
347    ///
348    /// # Examples
349    /// ```
350    /// let theme = native_theme::NativeTheme::preset("default").unwrap();
351    /// let toml_str = theme.to_toml().unwrap();
352    /// assert!(toml_str.contains("name = \"Default\""));
353    /// ```
354    #[must_use = "this serializes the theme to TOML; it does not write to a file"]
355    pub fn to_toml(&self) -> crate::Result<String> {
356        crate::presets::to_toml(self)
357    }
358}
359
360#[cfg(test)]
361mod tests {
362    use super::*;
363    use crate::Rgba;
364
365    // === ThemeVariant tests ===
366
367    #[test]
368    fn theme_variant_default_is_empty() {
369        assert!(ThemeVariant::default().is_empty());
370    }
371
372    #[test]
373    fn theme_variant_not_empty_when_color_set() {
374        let mut v = ThemeVariant::default();
375        v.colors.accent = Some(Rgba::rgb(0, 120, 215));
376        assert!(!v.is_empty());
377    }
378
379    #[test]
380    fn theme_variant_not_empty_when_font_set() {
381        let mut v = ThemeVariant::default();
382        v.fonts.family = Some("Inter".into());
383        assert!(!v.is_empty());
384    }
385
386    #[test]
387    fn theme_variant_merge_recursively() {
388        let mut base = ThemeVariant::default();
389        base.colors.background = Some(Rgba::rgb(255, 255, 255));
390        base.fonts.family = Some("Noto Sans".into());
391
392        let mut overlay = ThemeVariant::default();
393        overlay.colors.accent = Some(Rgba::rgb(0, 120, 215));
394        overlay.spacing.m = Some(12.0);
395
396        base.merge(&overlay);
397
398        // base background preserved
399        assert_eq!(base.colors.background, Some(Rgba::rgb(255, 255, 255)));
400        // overlay accent applied
401        assert_eq!(base.colors.accent, Some(Rgba::rgb(0, 120, 215)));
402        // base font preserved
403        assert_eq!(base.fonts.family.as_deref(), Some("Noto Sans"));
404        // overlay spacing applied
405        assert_eq!(base.spacing.m, Some(12.0));
406    }
407
408    // === NativeTheme tests ===
409
410    #[test]
411    fn native_theme_new_constructor() {
412        let theme = NativeTheme::new("Breeze");
413        assert_eq!(theme.name, "Breeze");
414        assert!(theme.light.is_none());
415        assert!(theme.dark.is_none());
416    }
417
418    #[test]
419    fn native_theme_default_is_empty() {
420        let theme = NativeTheme::default();
421        assert!(theme.is_empty());
422        assert_eq!(theme.name, "");
423    }
424
425    #[test]
426    fn native_theme_merge_keeps_base_name() {
427        let mut base = NativeTheme::new("Base Theme");
428        let overlay = NativeTheme::new("Overlay Theme");
429        base.merge(&overlay);
430        assert_eq!(base.name, "Base Theme");
431    }
432
433    #[test]
434    fn native_theme_merge_overlay_light_into_none() {
435        let mut base = NativeTheme::new("Theme");
436
437        let mut overlay = NativeTheme::new("Overlay");
438        let mut light = ThemeVariant::default();
439        light.colors.accent = Some(Rgba::rgb(0, 120, 215));
440        overlay.light = Some(light);
441
442        base.merge(&overlay);
443
444        assert!(base.light.is_some());
445        assert_eq!(
446            base.light.as_ref().unwrap().colors.accent,
447            Some(Rgba::rgb(0, 120, 215))
448        );
449    }
450
451    #[test]
452    fn native_theme_merge_both_light_variants() {
453        let mut base = NativeTheme::new("Theme");
454        let mut base_light = ThemeVariant::default();
455        base_light.colors.background = Some(Rgba::rgb(255, 255, 255));
456        base.light = Some(base_light);
457
458        let mut overlay = NativeTheme::new("Overlay");
459        let mut overlay_light = ThemeVariant::default();
460        overlay_light.colors.accent = Some(Rgba::rgb(0, 120, 215));
461        overlay.light = Some(overlay_light);
462
463        base.merge(&overlay);
464
465        let light = base.light.as_ref().unwrap();
466        // base background preserved
467        assert_eq!(light.colors.background, Some(Rgba::rgb(255, 255, 255)));
468        // overlay accent merged in
469        assert_eq!(light.colors.accent, Some(Rgba::rgb(0, 120, 215)));
470    }
471
472    #[test]
473    fn native_theme_merge_base_light_only_preserved() {
474        let mut base = NativeTheme::new("Theme");
475        let mut base_light = ThemeVariant::default();
476        base_light.fonts.family = Some("Inter".into());
477        base.light = Some(base_light);
478
479        let overlay = NativeTheme::new("Overlay"); // no light
480
481        base.merge(&overlay);
482
483        assert!(base.light.is_some());
484        assert_eq!(
485            base.light.as_ref().unwrap().fonts.family.as_deref(),
486            Some("Inter")
487        );
488    }
489
490    #[test]
491    fn native_theme_merge_dark_variant() {
492        let mut base = NativeTheme::new("Theme");
493
494        let mut overlay = NativeTheme::new("Overlay");
495        let mut dark = ThemeVariant::default();
496        dark.colors.background = Some(Rgba::rgb(30, 30, 30));
497        overlay.dark = Some(dark);
498
499        base.merge(&overlay);
500
501        assert!(base.dark.is_some());
502        assert_eq!(
503            base.dark.as_ref().unwrap().colors.background,
504            Some(Rgba::rgb(30, 30, 30))
505        );
506    }
507
508    #[test]
509    fn native_theme_not_empty_with_light() {
510        let mut theme = NativeTheme::new("Theme");
511        theme.light = Some(ThemeVariant::default());
512        assert!(!theme.is_empty());
513    }
514
515    // === pick_variant tests ===
516
517    #[test]
518    fn pick_variant_dark_with_both_variants_returns_dark() {
519        let mut theme = NativeTheme::new("Test");
520        let mut light = ThemeVariant::default();
521        light.colors.background = Some(Rgba::rgb(255, 255, 255));
522        theme.light = Some(light);
523        let mut dark = ThemeVariant::default();
524        dark.colors.background = Some(Rgba::rgb(30, 30, 30));
525        theme.dark = Some(dark);
526
527        let picked = theme.pick_variant(true).unwrap();
528        assert_eq!(picked.colors.background, Some(Rgba::rgb(30, 30, 30)));
529    }
530
531    #[test]
532    fn pick_variant_light_with_both_variants_returns_light() {
533        let mut theme = NativeTheme::new("Test");
534        let mut light = ThemeVariant::default();
535        light.colors.background = Some(Rgba::rgb(255, 255, 255));
536        theme.light = Some(light);
537        let mut dark = ThemeVariant::default();
538        dark.colors.background = Some(Rgba::rgb(30, 30, 30));
539        theme.dark = Some(dark);
540
541        let picked = theme.pick_variant(false).unwrap();
542        assert_eq!(picked.colors.background, Some(Rgba::rgb(255, 255, 255)));
543    }
544
545    #[test]
546    fn pick_variant_dark_with_only_light_falls_back() {
547        let mut theme = NativeTheme::new("Test");
548        let mut light = ThemeVariant::default();
549        light.colors.background = Some(Rgba::rgb(255, 255, 255));
550        theme.light = Some(light);
551
552        let picked = theme.pick_variant(true).unwrap();
553        assert_eq!(picked.colors.background, Some(Rgba::rgb(255, 255, 255)));
554    }
555
556    #[test]
557    fn pick_variant_light_with_only_dark_falls_back() {
558        let mut theme = NativeTheme::new("Test");
559        let mut dark = ThemeVariant::default();
560        dark.colors.background = Some(Rgba::rgb(30, 30, 30));
561        theme.dark = Some(dark);
562
563        let picked = theme.pick_variant(false).unwrap();
564        assert_eq!(picked.colors.background, Some(Rgba::rgb(30, 30, 30)));
565    }
566
567    #[test]
568    fn pick_variant_with_no_variants_returns_none() {
569        let theme = NativeTheme::new("Empty");
570        assert!(theme.pick_variant(true).is_none());
571        assert!(theme.pick_variant(false).is_none());
572    }
573
574    // === icon_set tests ===
575
576    #[test]
577    fn icon_set_default_is_none() {
578        assert!(ThemeVariant::default().icon_set.is_none());
579    }
580
581    #[test]
582    fn icon_set_merge_overlay() {
583        let mut base = ThemeVariant::default();
584        let overlay = ThemeVariant {
585            icon_set: Some("material".into()),
586            ..Default::default()
587        };
588        base.merge(&overlay);
589        assert_eq!(base.icon_set.as_deref(), Some("material"));
590    }
591
592    #[test]
593    fn icon_set_merge_none_preserves() {
594        let mut base = ThemeVariant {
595            icon_set: Some("sf-symbols".into()),
596            ..Default::default()
597        };
598        let overlay = ThemeVariant::default();
599        base.merge(&overlay);
600        assert_eq!(base.icon_set.as_deref(), Some("sf-symbols"));
601    }
602
603    #[test]
604    fn icon_set_is_empty_when_set() {
605        assert!(ThemeVariant::default().is_empty());
606        let v = ThemeVariant {
607            icon_set: Some("material".into()),
608            ..Default::default()
609        };
610        assert!(!v.is_empty());
611    }
612
613    #[test]
614    fn icon_set_toml_round_trip() {
615        let variant = ThemeVariant {
616            icon_set: Some("material".into()),
617            ..Default::default()
618        };
619        let toml_str = toml::to_string(&variant).unwrap();
620        assert!(toml_str.contains("icon_set"));
621        let deserialized: ThemeVariant = toml::from_str(&toml_str).unwrap();
622        assert_eq!(deserialized.icon_set.as_deref(), Some("material"));
623    }
624
625    #[test]
626    fn icon_set_toml_alias_backward_compat() {
627        // Old TOML files use "icon_theme" — verify the serde alias works
628        let toml_str = r#"icon_theme = "freedesktop""#;
629        let variant: ThemeVariant = toml::from_str(toml_str).unwrap();
630        assert_eq!(variant.icon_set.as_deref(), Some("freedesktop"));
631    }
632
633    #[test]
634    fn icon_set_toml_absent_deserializes_to_none() {
635        let toml_str = r##"
636[colors]
637accent = "#ff0000"
638"##;
639        let variant: ThemeVariant = toml::from_str(toml_str).unwrap();
640        assert!(variant.icon_set.is_none());
641    }
642
643    #[test]
644    fn native_theme_serde_toml_round_trip() {
645        let mut theme = NativeTheme::new("Test Theme");
646        let mut light = ThemeVariant::default();
647        light.colors.accent = Some(Rgba::rgb(0, 120, 215));
648        light.fonts.family = Some("Segoe UI".into());
649        light.geometry.radius = Some(4.0);
650        light.spacing.m = Some(12.0);
651        theme.light = Some(light);
652
653        let toml_str = toml::to_string(&theme).unwrap();
654        let deserialized: NativeTheme = toml::from_str(&toml_str).unwrap();
655
656        assert_eq!(deserialized.name, "Test Theme");
657        let l = deserialized.light.unwrap();
658        assert_eq!(l.colors.accent, Some(Rgba::rgb(0, 120, 215)));
659        assert_eq!(l.fonts.family.as_deref(), Some("Segoe UI"));
660        assert_eq!(l.geometry.radius, Some(4.0));
661        assert_eq!(l.spacing.m, Some(12.0));
662    }
663}