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