Skip to main content

native_theme/model/
mod.rs

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