Skip to main content

native_theme/model/
mod.rs

1// Theme model: ThemeVariant and NativeTheme, plus sub-module re-exports
2
3/// Animated icon types (frame sequences and transforms).
4pub mod animated;
5/// Bundled SVG icon lookup tables.
6pub mod bundled;
7/// Global theme defaults shared across widgets.
8pub mod defaults;
9/// Dialog button ordering convention.
10pub mod dialog_order;
11/// Per-widget font specification and text scale.
12pub mod font;
13/// Per-context icon sizes.
14pub mod icon_sizes;
15/// Icon roles, sets, and provider trait.
16pub mod icons;
17/// Resolved (non-optional) theme types produced after resolution.
18pub mod resolved;
19/// Logical spacing scale (xxs through xxl).
20pub mod spacing;
21/// Per-widget struct pairs and macros.
22pub mod widgets;
23
24pub use animated::{AnimatedIcon, Repeat, TransformAnimation};
25pub use bundled::{bundled_icon_by_name, bundled_icon_svg};
26pub use defaults::ThemeDefaults;
27pub use dialog_order::DialogButtonOrder;
28pub use font::{FontSpec, TextScale, TextScaleEntry};
29pub use icon_sizes::IconSizes;
30pub use icons::{
31    IconData, IconProvider, IconRole, IconSet, icon_name, system_icon_set, system_icon_theme,
32};
33pub use resolved::{
34    ResolvedDefaults, ResolvedIconSizes, ResolvedSpacing, ResolvedTextScale,
35    ResolvedTextScaleEntry, ResolvedTheme,
36};
37pub use spacing::ThemeSpacing;
38pub use widgets::*; // All 25 XxxTheme + ResolvedXxx + ResolvedFontSpec
39
40use serde::{Deserialize, Serialize};
41
42/// A single light or dark theme variant containing all visual properties.
43///
44/// Composes defaults, per-widget structs, and optional text scale into one coherent set.
45/// Empty sub-structs are omitted from serialization to keep TOML files clean.
46///
47/// # Examples
48///
49/// ```
50/// use native_theme::{ThemeVariant, Rgba};
51///
52/// let mut variant = ThemeVariant::default();
53/// variant.defaults.accent = Some(Rgba::rgb(0, 120, 215));
54/// variant.defaults.font.family = Some("Inter".into());
55/// assert!(!variant.is_empty());
56/// ```
57#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)]
58#[serde(default)]
59#[non_exhaustive]
60pub struct ThemeVariant {
61    /// Global defaults inherited by all widgets.
62    #[serde(default, skip_serializing_if = "ThemeDefaults::is_empty")]
63    pub defaults: ThemeDefaults,
64
65    /// Per-role text scale overrides.
66    #[serde(default, skip_serializing_if = "TextScale::is_empty")]
67    pub text_scale: TextScale,
68
69    /// Window chrome: background, title bar, radius, shadow.
70    #[serde(default, skip_serializing_if = "WindowTheme::is_empty")]
71    pub window: WindowTheme,
72
73    /// Push button: colors, sizing, spacing, geometry.
74    #[serde(default, skip_serializing_if = "ButtonTheme::is_empty")]
75    pub button: ButtonTheme,
76
77    /// Single-line and multi-line text input fields.
78    #[serde(default, skip_serializing_if = "InputTheme::is_empty")]
79    pub input: InputTheme,
80
81    /// Checkbox and radio button indicator geometry.
82    #[serde(default, skip_serializing_if = "CheckboxTheme::is_empty")]
83    pub checkbox: CheckboxTheme,
84
85    /// Popup and context menu appearance.
86    #[serde(default, skip_serializing_if = "MenuTheme::is_empty")]
87    pub menu: MenuTheme,
88
89    /// Tooltip popup appearance.
90    #[serde(default, skip_serializing_if = "TooltipTheme::is_empty")]
91    pub tooltip: TooltipTheme,
92
93    /// Scrollbar colors and geometry.
94    #[serde(default, skip_serializing_if = "ScrollbarTheme::is_empty")]
95    pub scrollbar: ScrollbarTheme,
96
97    /// Slider control colors and geometry.
98    #[serde(default, skip_serializing_if = "SliderTheme::is_empty")]
99    pub slider: SliderTheme,
100
101    /// Progress bar colors and geometry.
102    #[serde(default, skip_serializing_if = "ProgressBarTheme::is_empty")]
103    pub progress_bar: ProgressBarTheme,
104
105    /// Tab bar colors and sizing.
106    #[serde(default, skip_serializing_if = "TabTheme::is_empty")]
107    pub tab: TabTheme,
108
109    /// Sidebar panel background and foreground colors.
110    #[serde(default, skip_serializing_if = "SidebarTheme::is_empty")]
111    pub sidebar: SidebarTheme,
112
113    /// Toolbar sizing, spacing, and font.
114    #[serde(default, skip_serializing_if = "ToolbarTheme::is_empty")]
115    pub toolbar: ToolbarTheme,
116
117    /// Status bar font.
118    #[serde(default, skip_serializing_if = "StatusBarTheme::is_empty")]
119    pub status_bar: StatusBarTheme,
120
121    /// List and table colors and row geometry.
122    #[serde(default, skip_serializing_if = "ListTheme::is_empty")]
123    pub list: ListTheme,
124
125    /// Popover / dropdown panel appearance.
126    #[serde(default, skip_serializing_if = "PopoverTheme::is_empty")]
127    pub popover: PopoverTheme,
128
129    /// Splitter handle width.
130    #[serde(default, skip_serializing_if = "SplitterTheme::is_empty")]
131    pub splitter: SplitterTheme,
132
133    /// Separator line color.
134    #[serde(default, skip_serializing_if = "SeparatorTheme::is_empty")]
135    pub separator: SeparatorTheme,
136
137    /// Toggle switch track, thumb, and geometry.
138    #[serde(default, skip_serializing_if = "SwitchTheme::is_empty")]
139    pub switch: SwitchTheme,
140
141    /// Dialog sizing, spacing, button order, and title font.
142    #[serde(default, skip_serializing_if = "DialogTheme::is_empty")]
143    pub dialog: DialogTheme,
144
145    /// Spinner / indeterminate progress indicator.
146    #[serde(default, skip_serializing_if = "SpinnerTheme::is_empty")]
147    pub spinner: SpinnerTheme,
148
149    /// ComboBox / dropdown trigger sizing.
150    #[serde(default, skip_serializing_if = "ComboBoxTheme::is_empty")]
151    pub combo_box: ComboBoxTheme,
152
153    /// Segmented control sizing.
154    #[serde(default, skip_serializing_if = "SegmentedControlTheme::is_empty")]
155    pub segmented_control: SegmentedControlTheme,
156
157    /// Card / container colors and geometry.
158    #[serde(default, skip_serializing_if = "CardTheme::is_empty")]
159    pub card: CardTheme,
160
161    /// Expander / disclosure row geometry.
162    #[serde(default, skip_serializing_if = "ExpanderTheme::is_empty")]
163    pub expander: ExpanderTheme,
164
165    /// Hyperlink colors and underline setting.
166    #[serde(default, skip_serializing_if = "LinkTheme::is_empty")]
167    pub link: LinkTheme,
168
169    /// Icon set / naming convention for this variant (e.g., "sf-symbols", "freedesktop").
170    /// When None, resolved at runtime via system_icon_set().
171    #[serde(default, skip_serializing_if = "Option::is_none")]
172    pub icon_set: Option<String>,
173}
174
175impl_merge!(ThemeVariant {
176    option { icon_set }
177    nested {
178        defaults, text_scale, window, button, input, checkbox, menu,
179        tooltip, scrollbar, slider, progress_bar, tab, sidebar,
180        toolbar, status_bar, list, popover, splitter, separator,
181        switch, dialog, spinner, combo_box, segmented_control,
182        card, expander, link
183    }
184});
185
186/// A complete native theme with a name and optional light/dark variants.
187///
188/// This is the top-level type that theme files deserialize into and that
189/// platform readers produce.
190///
191/// # Examples
192///
193/// ```
194/// use native_theme::NativeTheme;
195///
196/// // Load a bundled preset
197/// let theme = NativeTheme::preset("dracula").unwrap();
198/// assert_eq!(theme.name, "Dracula");
199///
200/// // Parse from a TOML string
201/// let toml = r##"
202/// name = "Custom"
203/// [light.defaults]
204/// accent = "#ff6600"
205/// "##;
206/// let custom = NativeTheme::from_toml(toml).unwrap();
207/// assert_eq!(custom.name, "Custom");
208///
209/// // Merge themes (overlay wins for populated fields)
210/// let mut base = NativeTheme::preset("catppuccin-mocha").unwrap();
211/// base.merge(&custom);
212/// assert_eq!(base.name, "Catppuccin Mocha"); // base name is preserved
213/// ```
214#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)]
215#[non_exhaustive]
216#[must_use = "constructing a theme without using it is likely a bug"]
217pub struct NativeTheme {
218    /// Theme name (e.g., "Breeze", "Adwaita", "Windows 11").
219    pub name: String,
220
221    /// Light variant of the theme.
222    #[serde(default, skip_serializing_if = "Option::is_none")]
223    pub light: Option<ThemeVariant>,
224
225    /// Dark variant of the theme.
226    #[serde(default, skip_serializing_if = "Option::is_none")]
227    pub dark: Option<ThemeVariant>,
228}
229
230impl NativeTheme {
231    /// Create a new theme with the given name and no variants.
232    pub fn new(name: impl Into<String>) -> Self {
233        Self {
234            name: name.into(),
235            light: None,
236            dark: None,
237        }
238    }
239
240    /// Merge an overlay theme into this theme.
241    ///
242    /// The base name is kept. For each variant (light/dark):
243    /// - If both base and overlay have a variant, they are merged recursively.
244    /// - If only the overlay has a variant, it is cloned into the base.
245    /// - If only the base has a variant (or neither), no change.
246    pub fn merge(&mut self, overlay: &Self) {
247        // Keep base name (do not overwrite)
248
249        match (&mut self.light, &overlay.light) {
250            (Some(base), Some(over)) => base.merge(over),
251            (None, Some(over)) => self.light = Some(over.clone()),
252            _ => {}
253        }
254
255        match (&mut self.dark, &overlay.dark) {
256            (Some(base), Some(over)) => base.merge(over),
257            (None, Some(over)) => self.dark = Some(over.clone()),
258            _ => {}
259        }
260    }
261
262    /// Pick the appropriate variant for the given mode, with cross-fallback.
263    ///
264    /// When `is_dark` is true, prefers `dark` and falls back to `light`.
265    /// When `is_dark` is false, prefers `light` and falls back to `dark`.
266    /// Returns `None` only if the theme has no variants at all.
267    #[must_use = "this returns the selected variant; it does not apply it"]
268    pub fn pick_variant(&self, is_dark: bool) -> Option<&ThemeVariant> {
269        if is_dark {
270            self.dark.as_ref().or(self.light.as_ref())
271        } else {
272            self.light.as_ref().or(self.dark.as_ref())
273        }
274    }
275
276    /// Returns true if the theme has no variants set.
277    pub fn is_empty(&self) -> bool {
278        self.light.is_none() && self.dark.is_none()
279    }
280
281    /// Load a bundled theme preset by name.
282    ///
283    /// Returns the preset as a fully populated [`NativeTheme`] with both
284    /// light and dark variants.
285    ///
286    /// # Errors
287    /// Returns [`crate::Error::Unavailable`] if the preset name is not recognized.
288    ///
289    /// # Examples
290    /// ```
291    /// let theme = native_theme::NativeTheme::preset("catppuccin-mocha").unwrap();
292    /// assert!(theme.light.is_some());
293    /// ```
294    #[must_use = "this returns a theme preset; it does not apply it"]
295    pub fn preset(name: &str) -> crate::Result<Self> {
296        crate::presets::preset(name)
297    }
298
299    /// Parse a TOML string into a [`NativeTheme`].
300    ///
301    /// # TOML Format
302    ///
303    /// Theme files use the following structure. All fields are `Option<T>` --
304    /// omit any field you don't need. Unknown fields are silently ignored.
305    /// Hex colors accept `#RRGGBB` or `#RRGGBBAA` format.
306    ///
307    /// ```toml
308    /// name = "My Theme"
309    ///
310    /// [light.defaults]
311    /// accent = "#4a90d9"
312    /// background = "#fafafa"
313    /// foreground = "#2e3436"
314    /// surface = "#ffffff"
315    /// border = "#c0c0c0"
316    /// muted = "#929292"
317    /// shadow = "#00000018"
318    /// danger = "#dc3545"
319    /// warning = "#f0ad4e"
320    /// success = "#28a745"
321    /// info = "#4a90d9"
322    /// selection = "#4a90d9"
323    /// selection_foreground = "#ffffff"
324    /// link = "#2a6cb6"
325    /// focus_ring_color = "#4a90d9"
326    /// disabled_foreground = "#c0c0c0"
327    /// radius = 6.0
328    /// radius_lg = 12.0
329    /// frame_width = 1.0
330    /// disabled_opacity = 0.5
331    /// border_opacity = 0.15
332    /// shadow_enabled = true
333    ///
334    /// [light.defaults.font]
335    /// family = "sans-serif"
336    /// size = 10.0
337    ///
338    /// [light.defaults.mono_font]
339    /// family = "monospace"
340    /// size = 10.0
341    ///
342    /// [light.defaults.spacing]
343    /// xxs = 2.0
344    /// xs = 4.0
345    /// s = 6.0
346    /// m = 12.0
347    /// l = 18.0
348    /// xl = 24.0
349    /// xxl = 36.0
350    ///
351    /// [light.button]
352    /// background = "#e8e8e8"
353    /// foreground = "#2e3436"
354    /// min_height = 32.0
355    /// padding_horizontal = 12.0
356    /// padding_vertical = 6.0
357    ///
358    /// [light.tooltip]
359    /// background = "#2e3436"
360    /// foreground = "#f0f0f0"
361    /// padding_horizontal = 6.0
362    /// padding_vertical = 6.0
363    ///
364    /// # [dark.*] mirrors the same structure as [light.*]
365    /// ```
366    ///
367    /// # Errors
368    /// Returns [`crate::Error::Format`] if the TOML is invalid.
369    ///
370    /// # Examples
371    /// ```
372    /// let toml = r##"
373    /// name = "My Theme"
374    /// [light.defaults]
375    /// accent = "#ff0000"
376    /// "##;
377    /// let theme = native_theme::NativeTheme::from_toml(toml).unwrap();
378    /// assert_eq!(theme.name, "My Theme");
379    /// ```
380    #[must_use = "this parses a TOML string into a theme; it does not apply it"]
381    pub fn from_toml(toml_str: &str) -> crate::Result<Self> {
382        crate::presets::from_toml(toml_str)
383    }
384
385    /// Load a [`NativeTheme`] from a TOML file.
386    ///
387    /// # Errors
388    /// Returns [`crate::Error::Unavailable`] if the file cannot be read.
389    ///
390    /// # Examples
391    /// ```no_run
392    /// let theme = native_theme::NativeTheme::from_file("my-theme.toml").unwrap();
393    /// ```
394    #[must_use = "this loads a theme from a file; it does not apply it"]
395    pub fn from_file(path: impl AsRef<std::path::Path>) -> crate::Result<Self> {
396        crate::presets::from_file(path)
397    }
398
399    /// List all available bundled preset names.
400    ///
401    /// # Examples
402    /// ```
403    /// let names = native_theme::NativeTheme::list_presets();
404    /// assert_eq!(names.len(), 16);
405    /// ```
406    #[must_use = "this returns the list of preset names"]
407    pub fn list_presets() -> &'static [&'static str] {
408        crate::presets::list_presets()
409    }
410
411    /// List preset names appropriate for the current platform.
412    ///
413    /// Platform-specific presets (kde-breeze, adwaita, windows-11, macos-sonoma, ios)
414    /// are only included on their native platform. Community themes are always included.
415    ///
416    /// # Examples
417    /// ```
418    /// let names = native_theme::NativeTheme::list_presets_for_platform();
419    /// // On Linux KDE: includes kde-breeze, adwaita, plus all community themes
420    /// // On Windows: includes windows-11 plus all community themes
421    /// assert!(!names.is_empty());
422    /// ```
423    #[must_use = "this returns the filtered list of preset names for this platform"]
424    pub fn list_presets_for_platform() -> Vec<&'static str> {
425        crate::presets::list_presets_for_platform()
426    }
427
428    /// Serialize this theme to a TOML string.
429    ///
430    /// # Errors
431    /// Returns [`crate::Error::Format`] if serialization fails.
432    ///
433    /// # Examples
434    /// ```
435    /// let theme = native_theme::NativeTheme::preset("catppuccin-mocha").unwrap();
436    /// let toml_str = theme.to_toml().unwrap();
437    /// assert!(toml_str.contains("name = \"Catppuccin Mocha\""));
438    /// ```
439    #[must_use = "this serializes the theme to TOML; it does not write to a file"]
440    pub fn to_toml(&self) -> crate::Result<String> {
441        crate::presets::to_toml(self)
442    }
443}
444
445#[cfg(test)]
446#[allow(clippy::unwrap_used, clippy::expect_used)]
447mod tests {
448    use super::*;
449    use crate::Rgba;
450
451    // === ThemeVariant tests ===
452
453    #[test]
454    fn theme_variant_default_is_empty() {
455        assert!(ThemeVariant::default().is_empty());
456    }
457
458    #[test]
459    fn theme_variant_not_empty_when_color_set() {
460        let mut v = ThemeVariant::default();
461        v.defaults.accent = Some(Rgba::rgb(0, 120, 215));
462        assert!(!v.is_empty());
463    }
464
465    #[test]
466    fn theme_variant_not_empty_when_font_set() {
467        let mut v = ThemeVariant::default();
468        v.defaults.font.family = Some("Inter".into());
469        assert!(!v.is_empty());
470    }
471
472    #[test]
473    fn theme_variant_merge_recursively() {
474        let mut base = ThemeVariant::default();
475        base.defaults.background = Some(Rgba::rgb(255, 255, 255));
476        base.defaults.font.family = Some("Noto Sans".into());
477
478        let mut overlay = ThemeVariant::default();
479        overlay.defaults.accent = Some(Rgba::rgb(0, 120, 215));
480        overlay.defaults.spacing.m = Some(12.0);
481
482        base.merge(&overlay);
483
484        // base background preserved
485        assert_eq!(base.defaults.background, Some(Rgba::rgb(255, 255, 255)));
486        // overlay accent applied
487        assert_eq!(base.defaults.accent, Some(Rgba::rgb(0, 120, 215)));
488        // base font preserved
489        assert_eq!(base.defaults.font.family.as_deref(), Some("Noto Sans"));
490        // overlay spacing applied
491        assert_eq!(base.defaults.spacing.m, Some(12.0));
492    }
493
494    #[test]
495    fn theme_variant_has_all_widgets() {
496        let mut v = ThemeVariant::default();
497        // Set a field on each of the 25 widgets
498        v.window.radius = Some(4.0);
499        v.button.min_height = Some(32.0);
500        v.input.min_height = Some(32.0);
501        v.checkbox.indicator_size = Some(18.0);
502        v.menu.item_height = Some(28.0);
503        v.tooltip.padding_horizontal = Some(6.0);
504        v.scrollbar.width = Some(14.0);
505        v.slider.track_height = Some(4.0);
506        v.progress_bar.height = Some(6.0);
507        v.tab.min_height = Some(32.0);
508        v.sidebar.background = Some(Rgba::rgb(240, 240, 240));
509        v.toolbar.height = Some(40.0);
510        v.status_bar.font = Some(crate::model::FontSpec::default());
511        v.list.item_height = Some(28.0);
512        v.popover.radius = Some(6.0);
513        v.splitter.width = Some(4.0);
514        v.separator.color = Some(Rgba::rgb(200, 200, 200));
515        v.switch.track_width = Some(32.0);
516        v.dialog.min_width = Some(320.0);
517        v.spinner.diameter = Some(24.0);
518        v.combo_box.min_height = Some(32.0);
519        v.segmented_control.segment_height = Some(28.0);
520        v.card.radius = Some(8.0);
521        v.expander.header_height = Some(32.0);
522        v.link.underline = Some(true);
523
524        assert!(!v.is_empty());
525        assert!(!v.window.is_empty());
526        assert!(!v.button.is_empty());
527        assert!(!v.input.is_empty());
528        assert!(!v.checkbox.is_empty());
529        assert!(!v.menu.is_empty());
530        assert!(!v.tooltip.is_empty());
531        assert!(!v.scrollbar.is_empty());
532        assert!(!v.slider.is_empty());
533        assert!(!v.progress_bar.is_empty());
534        assert!(!v.tab.is_empty());
535        assert!(!v.sidebar.is_empty());
536        assert!(!v.toolbar.is_empty());
537        assert!(!v.status_bar.is_empty());
538        assert!(!v.list.is_empty());
539        assert!(!v.popover.is_empty());
540        assert!(!v.splitter.is_empty());
541        assert!(!v.separator.is_empty());
542        assert!(!v.switch.is_empty());
543        assert!(!v.dialog.is_empty());
544        assert!(!v.spinner.is_empty());
545        assert!(!v.combo_box.is_empty());
546        assert!(!v.segmented_control.is_empty());
547        assert!(!v.card.is_empty());
548        assert!(!v.expander.is_empty());
549        assert!(!v.link.is_empty());
550    }
551
552    #[test]
553    fn theme_variant_merge_per_widget() {
554        let mut base = ThemeVariant::default();
555        base.button.background = Some(Rgba::rgb(200, 200, 200));
556        base.button.foreground = Some(Rgba::rgb(0, 0, 0));
557        base.tooltip.background = Some(Rgba::rgb(50, 50, 50));
558
559        let mut overlay = ThemeVariant::default();
560        overlay.button.background = Some(Rgba::rgb(255, 255, 255));
561        overlay.button.min_height = Some(32.0);
562
563        base.merge(&overlay);
564
565        // overlay background wins
566        assert_eq!(base.button.background, Some(Rgba::rgb(255, 255, 255)));
567        // overlay min_height added
568        assert_eq!(base.button.min_height, Some(32.0));
569        // base foreground preserved
570        assert_eq!(base.button.foreground, Some(Rgba::rgb(0, 0, 0)));
571        // tooltip from base preserved
572        assert_eq!(base.tooltip.background, Some(Rgba::rgb(50, 50, 50)));
573    }
574
575    // === NativeTheme tests ===
576
577    #[test]
578    fn native_theme_new_constructor() {
579        let theme = NativeTheme::new("Breeze");
580        assert_eq!(theme.name, "Breeze");
581        assert!(theme.light.is_none());
582        assert!(theme.dark.is_none());
583    }
584
585    #[test]
586    fn native_theme_default_is_empty() {
587        let theme = NativeTheme::default();
588        assert!(theme.is_empty());
589        assert_eq!(theme.name, "");
590    }
591
592    #[test]
593    fn native_theme_merge_keeps_base_name() {
594        let mut base = NativeTheme::new("Base Theme");
595        let overlay = NativeTheme::new("Overlay Theme");
596        base.merge(&overlay);
597        assert_eq!(base.name, "Base Theme");
598    }
599
600    #[test]
601    fn native_theme_merge_overlay_light_into_none() {
602        let mut base = NativeTheme::new("Theme");
603
604        let mut overlay = NativeTheme::new("Overlay");
605        let mut light = ThemeVariant::default();
606        light.defaults.accent = Some(Rgba::rgb(0, 120, 215));
607        overlay.light = Some(light);
608
609        base.merge(&overlay);
610
611        assert!(base.light.is_some());
612        assert_eq!(
613            base.light.as_ref().unwrap().defaults.accent,
614            Some(Rgba::rgb(0, 120, 215))
615        );
616    }
617
618    #[test]
619    fn native_theme_merge_both_light_variants() {
620        let mut base = NativeTheme::new("Theme");
621        let mut base_light = ThemeVariant::default();
622        base_light.defaults.background = Some(Rgba::rgb(255, 255, 255));
623        base.light = Some(base_light);
624
625        let mut overlay = NativeTheme::new("Overlay");
626        let mut overlay_light = ThemeVariant::default();
627        overlay_light.defaults.accent = Some(Rgba::rgb(0, 120, 215));
628        overlay.light = Some(overlay_light);
629
630        base.merge(&overlay);
631
632        let light = base.light.as_ref().unwrap();
633        // base background preserved
634        assert_eq!(light.defaults.background, Some(Rgba::rgb(255, 255, 255)));
635        // overlay accent merged in
636        assert_eq!(light.defaults.accent, Some(Rgba::rgb(0, 120, 215)));
637    }
638
639    #[test]
640    fn native_theme_merge_base_light_only_preserved() {
641        let mut base = NativeTheme::new("Theme");
642        let mut base_light = ThemeVariant::default();
643        base_light.defaults.font.family = Some("Inter".into());
644        base.light = Some(base_light);
645
646        let overlay = NativeTheme::new("Overlay"); // no light
647
648        base.merge(&overlay);
649
650        assert!(base.light.is_some());
651        assert_eq!(
652            base.light.as_ref().unwrap().defaults.font.family.as_deref(),
653            Some("Inter")
654        );
655    }
656
657    #[test]
658    fn native_theme_merge_dark_variant() {
659        let mut base = NativeTheme::new("Theme");
660
661        let mut overlay = NativeTheme::new("Overlay");
662        let mut dark = ThemeVariant::default();
663        dark.defaults.background = Some(Rgba::rgb(30, 30, 30));
664        overlay.dark = Some(dark);
665
666        base.merge(&overlay);
667
668        assert!(base.dark.is_some());
669        assert_eq!(
670            base.dark.as_ref().unwrap().defaults.background,
671            Some(Rgba::rgb(30, 30, 30))
672        );
673    }
674
675    #[test]
676    fn native_theme_not_empty_with_light() {
677        let mut theme = NativeTheme::new("Theme");
678        theme.light = Some(ThemeVariant::default());
679        assert!(!theme.is_empty());
680    }
681
682    // === pick_variant tests ===
683
684    #[test]
685    fn pick_variant_dark_with_both_variants_returns_dark() {
686        let mut theme = NativeTheme::new("Test");
687        let mut light = ThemeVariant::default();
688        light.defaults.background = Some(Rgba::rgb(255, 255, 255));
689        theme.light = Some(light);
690        let mut dark = ThemeVariant::default();
691        dark.defaults.background = Some(Rgba::rgb(30, 30, 30));
692        theme.dark = Some(dark);
693
694        let picked = theme.pick_variant(true).unwrap();
695        assert_eq!(picked.defaults.background, Some(Rgba::rgb(30, 30, 30)));
696    }
697
698    #[test]
699    fn pick_variant_light_with_both_variants_returns_light() {
700        let mut theme = NativeTheme::new("Test");
701        let mut light = ThemeVariant::default();
702        light.defaults.background = Some(Rgba::rgb(255, 255, 255));
703        theme.light = Some(light);
704        let mut dark = ThemeVariant::default();
705        dark.defaults.background = Some(Rgba::rgb(30, 30, 30));
706        theme.dark = Some(dark);
707
708        let picked = theme.pick_variant(false).unwrap();
709        assert_eq!(picked.defaults.background, Some(Rgba::rgb(255, 255, 255)));
710    }
711
712    #[test]
713    fn pick_variant_dark_with_only_light_falls_back() {
714        let mut theme = NativeTheme::new("Test");
715        let mut light = ThemeVariant::default();
716        light.defaults.background = Some(Rgba::rgb(255, 255, 255));
717        theme.light = Some(light);
718
719        let picked = theme.pick_variant(true).unwrap();
720        assert_eq!(picked.defaults.background, Some(Rgba::rgb(255, 255, 255)));
721    }
722
723    #[test]
724    fn pick_variant_light_with_only_dark_falls_back() {
725        let mut theme = NativeTheme::new("Test");
726        let mut dark = ThemeVariant::default();
727        dark.defaults.background = Some(Rgba::rgb(30, 30, 30));
728        theme.dark = Some(dark);
729
730        let picked = theme.pick_variant(false).unwrap();
731        assert_eq!(picked.defaults.background, Some(Rgba::rgb(30, 30, 30)));
732    }
733
734    #[test]
735    fn pick_variant_with_no_variants_returns_none() {
736        let theme = NativeTheme::new("Empty");
737        assert!(theme.pick_variant(true).is_none());
738        assert!(theme.pick_variant(false).is_none());
739    }
740
741    // === icon_set tests ===
742
743    #[test]
744    fn icon_set_default_is_none() {
745        assert!(ThemeVariant::default().icon_set.is_none());
746    }
747
748    #[test]
749    fn icon_set_merge_overlay() {
750        let mut base = ThemeVariant::default();
751        let overlay = ThemeVariant {
752            icon_set: Some("material".into()),
753            ..Default::default()
754        };
755        base.merge(&overlay);
756        assert_eq!(base.icon_set.as_deref(), Some("material"));
757    }
758
759    #[test]
760    fn icon_set_merge_none_preserves() {
761        let mut base = ThemeVariant {
762            icon_set: Some("sf-symbols".into()),
763            ..Default::default()
764        };
765        let overlay = ThemeVariant::default();
766        base.merge(&overlay);
767        assert_eq!(base.icon_set.as_deref(), Some("sf-symbols"));
768    }
769
770    #[test]
771    fn icon_set_is_empty_when_set() {
772        assert!(ThemeVariant::default().is_empty());
773        let v = ThemeVariant {
774            icon_set: Some("material".into()),
775            ..Default::default()
776        };
777        assert!(!v.is_empty());
778    }
779
780    #[test]
781    fn icon_set_toml_round_trip() {
782        let variant = ThemeVariant {
783            icon_set: Some("material".into()),
784            ..Default::default()
785        };
786        let toml_str = toml::to_string(&variant).unwrap();
787        assert!(toml_str.contains("icon_set"));
788        let deserialized: ThemeVariant = toml::from_str(&toml_str).unwrap();
789        assert_eq!(deserialized.icon_set.as_deref(), Some("material"));
790    }
791
792    #[test]
793    fn icon_set_toml_absent_deserializes_to_none() {
794        let toml_str = r##"
795[defaults]
796accent = "#ff0000"
797"##;
798        let variant: ThemeVariant = toml::from_str(toml_str).unwrap();
799        assert!(variant.icon_set.is_none());
800    }
801
802    #[test]
803    fn native_theme_serde_toml_round_trip() {
804        let mut theme = NativeTheme::new("Test Theme");
805        let mut light = ThemeVariant::default();
806        light.defaults.accent = Some(Rgba::rgb(0, 120, 215));
807        light.defaults.font.family = Some("Segoe UI".into());
808        light.defaults.radius = Some(4.0);
809        light.defaults.spacing.m = Some(12.0);
810        theme.light = Some(light);
811
812        let toml_str = toml::to_string(&theme).unwrap();
813        let deserialized: NativeTheme = toml::from_str(&toml_str).unwrap();
814
815        assert_eq!(deserialized.name, "Test Theme");
816        let l = deserialized.light.unwrap();
817        assert_eq!(l.defaults.accent, Some(Rgba::rgb(0, 120, 215)));
818        assert_eq!(l.defaults.font.family.as_deref(), Some("Segoe UI"));
819        assert_eq!(l.defaults.radius, Some(4.0));
820        assert_eq!(l.defaults.spacing.m, Some(12.0));
821    }
822}