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/// Border specification sub-struct for widget border properties.
6pub mod border;
7/// Bundled SVG icon lookup tables.
8pub mod bundled;
9/// Global theme defaults shared across widgets.
10pub mod defaults;
11/// Dialog button ordering convention.
12pub mod dialog_order;
13/// Per-widget font specification and text scale.
14pub mod font;
15/// Per-context icon sizes.
16pub mod icon_sizes;
17/// Icon roles, sets, and provider trait.
18pub mod icons;
19/// Resolved (non-optional) theme types produced after resolution.
20pub mod resolved;
21/// Per-widget struct pairs and macros.
22pub mod widgets;
23
24pub use animated::{AnimatedIcon, TransformAnimation};
25pub use border::{BorderSpec, ResolvedBorderSpec};
26pub use bundled::{bundled_icon_by_name, bundled_icon_svg};
27pub use defaults::ThemeDefaults;
28pub use dialog_order::DialogButtonOrder;
29pub use font::{FontSize, FontSpec, FontStyle, ResolvedFontSpec, TextScale, TextScaleEntry};
30pub use icon_sizes::IconSizes;
31pub use icons::{
32    IconData, IconProvider, IconRole, IconSet, icon_name, system_icon_set, system_icon_theme,
33};
34pub use resolved::{
35    ResolvedIconSizes, ResolvedTextScale, ResolvedTextScaleEntry, ResolvedThemeDefaults,
36    ResolvedThemeVariant,
37};
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_color = 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    /// Which icon loading mechanism to use (`Freedesktop`, `Material`, `Lucide`,
169    /// `SfSymbols`, `SegoeIcons`).  Determines *how* icons are looked up — e.g.
170    /// freedesktop theme directories vs. bundled SVG tables.
171    /// When `None`, filled by [`resolve()`](ThemeVariant::resolve) from
172    /// [`system_icon_set()`](crate::system_icon_set).
173    #[serde(default, skip_serializing_if = "Option::is_none")]
174    pub icon_set: Option<IconSet>,
175
176    /// The name of the visual icon theme that provides the actual icon files
177    /// (e.g. `"breeze"`, `"Adwaita"`, `"Lucide"`).  For `Freedesktop` this
178    /// selects the theme directory; for bundled sets it is a display label.
179    /// When `None`, filled by [`resolve_platform_defaults()`](ThemeVariant::resolve_platform_defaults)
180    /// from [`system_icon_theme()`](crate::system_icon_theme).
181    #[serde(default, skip_serializing_if = "Option::is_none")]
182    pub icon_theme: Option<String>,
183}
184
185impl_merge!(ThemeVariant {
186    option { icon_set, icon_theme }
187    nested {
188        defaults, text_scale, window, button, input, checkbox, menu,
189        tooltip, scrollbar, slider, progress_bar, tab, sidebar,
190        toolbar, status_bar, list, popover, splitter, separator,
191        switch, dialog, spinner, combo_box, segmented_control,
192        card, expander, link
193    }
194});
195
196/// A complete native theme with a name and optional light/dark variants.
197///
198/// This is the top-level type that theme files deserialize into and that
199/// platform readers produce.
200///
201/// # Examples
202///
203/// ```
204/// use native_theme::ThemeSpec;
205///
206/// // Load a bundled preset
207/// let theme = ThemeSpec::preset("dracula").unwrap();
208/// assert_eq!(theme.name, "Dracula");
209///
210/// // Parse from a TOML string
211/// let toml = r##"
212/// name = "Custom"
213/// [light.defaults]
214/// accent_color = "#ff6600"
215/// "##;
216/// let custom = ThemeSpec::from_toml(toml).unwrap();
217/// assert_eq!(custom.name, "Custom");
218///
219/// // Merge themes (overlay wins for populated fields)
220/// let mut base = ThemeSpec::preset("catppuccin-mocha").unwrap();
221/// base.merge(&custom);
222/// assert_eq!(base.name, "Catppuccin Mocha"); // base name is preserved
223/// ```
224#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)]
225#[must_use = "constructing a theme without using it is likely a bug"]
226pub struct ThemeSpec {
227    /// Theme name (e.g., "Breeze", "Adwaita", "Windows 11").
228    pub name: String,
229
230    /// Light variant of the theme.
231    #[serde(default, skip_serializing_if = "Option::is_none")]
232    pub light: Option<ThemeVariant>,
233
234    /// Dark variant of the theme.
235    #[serde(default, skip_serializing_if = "Option::is_none")]
236    pub dark: Option<ThemeVariant>,
237
238    /// Layout spacing constants (shared between light and dark variants).
239    #[serde(default, skip_serializing_if = "LayoutTheme::is_empty")]
240    pub layout: LayoutTheme,
241}
242
243impl ThemeSpec {
244    /// Create a new theme with the given name and no variants.
245    pub fn new(name: impl Into<String>) -> Self {
246        Self {
247            name: name.into(),
248            light: None,
249            dark: None,
250            layout: LayoutTheme::default(),
251        }
252    }
253
254    /// Merge an overlay theme into this theme.
255    ///
256    /// The base name is kept. For each variant (light/dark):
257    /// - If both base and overlay have a variant, they are merged recursively.
258    /// - If only the overlay has a variant, it is cloned into the base.
259    /// - If only the base has a variant (or neither), no change.
260    pub fn merge(&mut self, overlay: &Self) {
261        // Keep base name (do not overwrite)
262
263        match (&mut self.light, &overlay.light) {
264            (Some(base), Some(over)) => base.merge(over),
265            (None, Some(over)) => self.light = Some(over.clone()),
266            _ => {}
267        }
268
269        match (&mut self.dark, &overlay.dark) {
270            (Some(base), Some(over)) => base.merge(over),
271            (None, Some(over)) => self.dark = Some(over.clone()),
272            _ => {}
273        }
274
275        self.layout.merge(&overlay.layout);
276    }
277
278    /// Pick the appropriate variant for the given mode, with cross-fallback.
279    ///
280    /// When `is_dark` is true, prefers `dark` and falls back to `light`.
281    /// When `is_dark` is false, prefers `light` and falls back to `dark`.
282    /// Returns `None` only if the theme has no variants at all.
283    #[must_use = "this returns the selected variant; it does not apply it"]
284    pub fn pick_variant(&self, is_dark: bool) -> Option<&ThemeVariant> {
285        if is_dark {
286            self.dark.as_ref().or(self.light.as_ref())
287        } else {
288            self.light.as_ref().or(self.dark.as_ref())
289        }
290    }
291
292    /// Extract a variant by consuming the theme, avoiding a clone.
293    ///
294    /// When `is_dark` is true, returns the `dark` variant (falling back to
295    /// `light`). When false, returns `light` (falling back to `dark`).
296    /// Returns `None` only if the theme has no variants at all.
297    ///
298    /// Use this when you own the `ThemeSpec` and don't need it afterward.
299    /// For read-only inspection, use [`pick_variant()`](Self::pick_variant).
300    ///
301    /// # Examples
302    ///
303    /// ```
304    /// let theme = native_theme::ThemeSpec::preset("dracula").unwrap();
305    /// let variant = theme.into_variant(true).unwrap();
306    /// let resolved = variant.into_resolved().unwrap();
307    /// ```
308    #[must_use = "this returns the extracted variant; it does not apply it"]
309    pub fn into_variant(self, is_dark: bool) -> Option<ThemeVariant> {
310        if is_dark {
311            self.dark.or(self.light)
312        } else {
313            self.light.or(self.dark)
314        }
315    }
316
317    /// Returns true if the theme has no variants set.
318    pub fn is_empty(&self) -> bool {
319        self.light.is_none() && self.dark.is_none() && self.layout.is_empty()
320    }
321
322    /// Load a bundled theme preset by name.
323    ///
324    /// Returns the preset as a fully populated [`ThemeSpec`] with both
325    /// light and dark variants.
326    ///
327    /// # Errors
328    /// Returns [`crate::Error::Unavailable`] if the preset name is not recognized.
329    ///
330    /// # Examples
331    /// ```
332    /// let theme = native_theme::ThemeSpec::preset("catppuccin-mocha").unwrap();
333    /// assert!(theme.light.is_some());
334    /// ```
335    #[must_use = "this returns a theme preset; it does not apply it"]
336    pub fn preset(name: &str) -> crate::Result<Self> {
337        crate::presets::preset(name)
338    }
339
340    /// Parse a TOML string into a [`ThemeSpec`].
341    ///
342    /// # TOML Format
343    ///
344    /// Theme files use the following structure. All fields are `Option<T>` --
345    /// omit any field you don't need. Unknown fields are silently ignored.
346    /// Hex colors accept `#RRGGBB` or `#RRGGBBAA` format.
347    ///
348    /// ```toml
349    /// name = "My Theme"
350    ///
351    /// [light.defaults]
352    /// accent_color = "#4a90d9"
353    /// background_color = "#fafafa"
354    /// text_color = "#2e3436"
355    /// surface_color = "#ffffff"
356    /// muted_color = "#929292"
357    /// shadow_color = "#00000018"
358    /// danger_color = "#dc3545"
359    /// warning_color = "#f0ad4e"
360    /// success_color = "#28a745"
361    /// info_color = "#4a90d9"
362    /// selection_background = "#4a90d9"
363    /// selection_text_color = "#ffffff"
364    /// link_color = "#2a6cb6"
365    /// focus_ring_color = "#4a90d9"
366    /// disabled_text_color = "#c0c0c0"
367    /// disabled_opacity = 0.5
368    ///
369    /// [light.defaults.font]
370    /// family = "sans-serif"
371    /// size = 10.0
372    ///
373    /// [light.defaults.mono_font]
374    /// family = "monospace"
375    /// size = 10.0
376    ///
377    /// [light.defaults.border]
378    /// color = "#c0c0c0"
379    /// corner_radius = 6.0
380    /// corner_radius_lg = 12.0
381    /// line_width = 1.0
382    /// opacity = 0.15
383    /// shadow_enabled = true
384    ///
385    /// [light.button]
386    /// background_color = "#e8e8e8"
387    /// min_height = 32.0
388    ///
389    /// [light.button.font]
390    /// color = "#2e3436"
391    ///
392    /// [light.button.border]
393    /// padding_horizontal = 12.0
394    /// padding_vertical = 6.0
395    ///
396    /// [light.tooltip]
397    /// background_color = "#2e3436"
398    /// max_width = 300.0
399    ///
400    /// [light.tooltip.font]
401    /// color = "#f0f0f0"
402    ///
403    /// # [dark.*] mirrors the same structure as [light.*]
404    /// ```
405    ///
406    /// # Errors
407    /// Returns [`crate::Error::Format`] if the TOML is invalid.
408    ///
409    /// # Examples
410    /// ```
411    /// let toml = r##"
412    /// name = "My Theme"
413    /// [light.defaults]
414    /// accent_color = "#ff0000"
415    /// "##;
416    /// let theme = native_theme::ThemeSpec::from_toml(toml).unwrap();
417    /// assert_eq!(theme.name, "My Theme");
418    /// ```
419    #[must_use = "this parses a TOML string into a theme; it does not apply it"]
420    pub fn from_toml(toml_str: &str) -> crate::Result<Self> {
421        crate::presets::from_toml(toml_str)
422    }
423
424    /// Parse custom TOML and merge onto a base preset.
425    ///
426    /// This is the recommended way to create custom themes. The base preset
427    /// provides geometry, spacing, and widget defaults. The custom TOML
428    /// overrides colors, fonts, and any other fields.
429    ///
430    /// # Errors
431    ///
432    /// Returns [`crate::Error::Unavailable`] if the base preset name is not
433    /// recognized, or [`crate::Error::Format`] if the custom TOML is invalid.
434    ///
435    /// # Examples
436    ///
437    /// ```
438    /// let theme = native_theme::ThemeSpec::from_toml_with_base(
439    ///     r##"name = "My Theme"
440    /// [dark.defaults]
441    /// accent_color = "#ff6600"
442    /// background_color = "#1e1e1e"
443    /// text_color = "#e0e0e0""##,
444    ///     "material",
445    /// ).unwrap();
446    /// assert!(theme.dark.is_some());
447    /// ```
448    pub fn from_toml_with_base(toml_str: &str, base: &str) -> crate::Result<Self> {
449        let mut theme = Self::preset(base)?;
450        let overlay = Self::from_toml(toml_str)?;
451        theme.merge(&overlay);
452        Ok(theme)
453    }
454
455    /// Load a [`ThemeSpec`] from a TOML file.
456    ///
457    /// # Errors
458    /// Returns [`crate::Error::Io`] if the file cannot be read, or
459    /// [`crate::Error::Format`] if the TOML content is invalid.
460    ///
461    /// # Examples
462    /// ```no_run
463    /// let theme = native_theme::ThemeSpec::from_file("my-theme.toml").unwrap();
464    /// ```
465    #[must_use = "this loads a theme from a file; it does not apply it"]
466    pub fn from_file(path: impl AsRef<std::path::Path>) -> crate::Result<Self> {
467        crate::presets::from_file(path)
468    }
469
470    /// List all available bundled preset names.
471    ///
472    /// # Examples
473    /// ```
474    /// let names = native_theme::ThemeSpec::list_presets();
475    /// assert_eq!(names.len(), 16);
476    /// ```
477    #[must_use = "this returns the list of preset names"]
478    pub fn list_presets() -> &'static [&'static str] {
479        crate::presets::list_presets()
480    }
481
482    /// List preset names appropriate for the current platform.
483    ///
484    /// Platform-specific presets (kde-breeze, adwaita, windows-11, macos-sonoma, ios)
485    /// are only included on their native platform. Community themes are always included.
486    ///
487    /// # Examples
488    /// ```
489    /// let names = native_theme::ThemeSpec::list_presets_for_platform();
490    /// // On Linux KDE: includes kde-breeze, adwaita, plus all community themes
491    /// // On Windows: includes windows-11 plus all community themes
492    /// assert!(!names.is_empty());
493    /// ```
494    #[must_use = "this returns the filtered list of preset names for this platform"]
495    pub fn list_presets_for_platform() -> Vec<&'static str> {
496        crate::presets::list_presets_for_platform()
497    }
498
499    /// Serialize this theme to a TOML string.
500    ///
501    /// # Errors
502    /// Returns [`crate::Error::Format`] if serialization fails.
503    ///
504    /// # Examples
505    /// ```
506    /// let theme = native_theme::ThemeSpec::preset("catppuccin-mocha").unwrap();
507    /// let toml_str = theme.to_toml().unwrap();
508    /// assert!(toml_str.contains("name = \"Catppuccin Mocha\""));
509    /// ```
510    #[must_use = "this serializes the theme to TOML; it does not write to a file"]
511    pub fn to_toml(&self) -> crate::Result<String> {
512        crate::presets::to_toml(self)
513    }
514
515    /// Check a TOML string for unrecognized field names.
516    ///
517    /// Parses the TOML as a generic table and walks all keys, comparing
518    /// against the known fields for each section. Returns a `Vec<String>`
519    /// of warnings for any keys that don't match a known field. An empty
520    /// vec means all keys are recognized.
521    ///
522    /// This is an opt-in linting tool for theme authors. It does NOT affect
523    /// `from_toml()` behavior (which silently ignores unknown fields via serde).
524    ///
525    /// # Errors
526    ///
527    /// Returns `Err` if the TOML string cannot be parsed at all.
528    ///
529    /// # Examples
530    ///
531    /// ```
532    /// let warnings = native_theme::ThemeSpec::lint_toml(r##"
533    /// name = "Test"
534    /// [light.defaults]
535    /// backround = "#ffffff"
536    /// "##).unwrap();
537    /// assert_eq!(warnings.len(), 1);
538    /// assert!(warnings[0].contains("backround"));
539    /// ```
540    pub fn lint_toml(toml_str: &str) -> crate::Result<Vec<String>> {
541        use crate::model::defaults::ThemeDefaults;
542
543        let value: toml::Value = toml::from_str(toml_str)
544            .map_err(|e: toml::de::Error| crate::Error::Format(e.to_string()))?;
545
546        let mut warnings = Vec::new();
547
548        let top_table = match &value {
549            toml::Value::Table(t) => t,
550            _ => return Ok(warnings),
551        };
552
553        // Known top-level keys
554        const TOP_KEYS: &[&str] = &["name", "light", "dark", "layout"];
555
556        for key in top_table.keys() {
557            if !TOP_KEYS.contains(&key.as_str()) {
558                warnings.push(format!("unknown field: {key}"));
559            }
560        }
561
562        // Variant-level known keys: widget names + special fields
563        const VARIANT_KEYS: &[&str] = &[
564            "defaults",
565            "text_scale",
566            "window",
567            "button",
568            "input",
569            "checkbox",
570            "menu",
571            "tooltip",
572            "scrollbar",
573            "slider",
574            "progress_bar",
575            "tab",
576            "sidebar",
577            "toolbar",
578            "status_bar",
579            "list",
580            "popover",
581            "splitter",
582            "separator",
583            "switch",
584            "dialog",
585            "spinner",
586            "combo_box",
587            "segmented_control",
588            "card",
589            "expander",
590            "link",
591            "icon_set",
592            "icon_theme",
593        ];
594
595        // FontSpec, BorderSpec, TextScaleEntry, TextScale, and IconSizes
596        // all use their own FIELD_NAMES constants (issue 3b).
597
598        /// Look up the known field names for a given widget section key.
599        fn widget_fields(section: &str) -> Option<&'static [&'static str]> {
600            match section {
601                "window" => Some(WindowTheme::FIELD_NAMES),
602                "button" => Some(ButtonTheme::FIELD_NAMES),
603                "input" => Some(InputTheme::FIELD_NAMES),
604                "checkbox" => Some(CheckboxTheme::FIELD_NAMES),
605                "menu" => Some(MenuTheme::FIELD_NAMES),
606                "tooltip" => Some(TooltipTheme::FIELD_NAMES),
607                "scrollbar" => Some(ScrollbarTheme::FIELD_NAMES),
608                "slider" => Some(SliderTheme::FIELD_NAMES),
609                "progress_bar" => Some(ProgressBarTheme::FIELD_NAMES),
610                "tab" => Some(TabTheme::FIELD_NAMES),
611                "sidebar" => Some(SidebarTheme::FIELD_NAMES),
612                "toolbar" => Some(ToolbarTheme::FIELD_NAMES),
613                "status_bar" => Some(StatusBarTheme::FIELD_NAMES),
614                "list" => Some(ListTheme::FIELD_NAMES),
615                "popover" => Some(PopoverTheme::FIELD_NAMES),
616                "splitter" => Some(SplitterTheme::FIELD_NAMES),
617                "separator" => Some(SeparatorTheme::FIELD_NAMES),
618                "switch" => Some(SwitchTheme::FIELD_NAMES),
619                "dialog" => Some(DialogTheme::FIELD_NAMES),
620                "spinner" => Some(SpinnerTheme::FIELD_NAMES),
621                "combo_box" => Some(ComboBoxTheme::FIELD_NAMES),
622                "segmented_control" => Some(SegmentedControlTheme::FIELD_NAMES),
623                "card" => Some(CardTheme::FIELD_NAMES),
624                "expander" => Some(ExpanderTheme::FIELD_NAMES),
625                "link" => Some(LinkTheme::FIELD_NAMES),
626                _ => None,
627            }
628        }
629
630        // Lint a text_scale section
631        fn lint_text_scale(
632            table: &toml::map::Map<String, toml::Value>,
633            prefix: &str,
634            warnings: &mut Vec<String>,
635        ) {
636            for key in table.keys() {
637                if !TextScale::FIELD_NAMES.contains(&key.as_str()) {
638                    warnings.push(format!("unknown field: {prefix}.{key}"));
639                } else if let Some(toml::Value::Table(entry_table)) = table.get(key) {
640                    for ekey in entry_table.keys() {
641                        if !TextScaleEntry::FIELD_NAMES.contains(&ekey.as_str()) {
642                            warnings.push(format!("unknown field: {prefix}.{key}.{ekey}"));
643                        }
644                    }
645                }
646            }
647        }
648
649        // Lint a defaults section (with nested font, mono_font, border, icon_sizes)
650        fn lint_defaults(
651            table: &toml::map::Map<String, toml::Value>,
652            prefix: &str,
653            warnings: &mut Vec<String>,
654        ) {
655            for key in table.keys() {
656                if !ThemeDefaults::FIELD_NAMES.contains(&key.as_str()) {
657                    warnings.push(format!("unknown field: {prefix}.{key}"));
658                    continue;
659                }
660                // Check sub-tables for nested struct fields
661                if let Some(toml::Value::Table(sub)) = table.get(key) {
662                    let known = match key.as_str() {
663                        "font" | "mono_font" => FontSpec::FIELD_NAMES,
664                        "border" => BorderSpec::FIELD_NAMES,
665                        "icon_sizes" => IconSizes::FIELD_NAMES,
666                        _ => continue,
667                    };
668                    for skey in sub.keys() {
669                        if !known.contains(&skey.as_str()) {
670                            warnings.push(format!("unknown field: {prefix}.{key}.{skey}"));
671                        }
672                    }
673                }
674            }
675        }
676
677        // Lint a variant section (light or dark)
678        fn lint_variant(
679            table: &toml::map::Map<String, toml::Value>,
680            prefix: &str,
681            warnings: &mut Vec<String>,
682        ) {
683            for key in table.keys() {
684                if !VARIANT_KEYS.contains(&key.as_str()) {
685                    warnings.push(format!("unknown field: {prefix}.{key}"));
686                    continue;
687                }
688
689                if let Some(toml::Value::Table(sub)) = table.get(key) {
690                    let sub_prefix = format!("{prefix}.{key}");
691                    match key.as_str() {
692                        "defaults" => lint_defaults(sub, &sub_prefix, warnings),
693                        "text_scale" => lint_text_scale(sub, &sub_prefix, warnings),
694                        _ => {
695                            if let Some(fields) = widget_fields(key) {
696                                for skey in sub.keys() {
697                                    if !fields.contains(&skey.as_str()) {
698                                        warnings
699                                            .push(format!("unknown field: {sub_prefix}.{skey}"));
700                                    }
701                                    // Validate sub-tables (font/border nested structs)
702                                    if let Some(toml::Value::Table(nested)) = sub.get(skey) {
703                                        let nested_known = match skey.as_str() {
704                                            s if s == "font" || s.ends_with("_font") => {
705                                                Some(FontSpec::FIELD_NAMES)
706                                            }
707                                            "border" => Some(BorderSpec::FIELD_NAMES),
708                                            _ => None,
709                                        };
710                                        if let Some(known) = nested_known {
711                                            for nkey in nested.keys() {
712                                                if !known.contains(&nkey.as_str()) {
713                                                    warnings.push(format!(
714                                                        "unknown field: {sub_prefix}.{skey}.{nkey}"
715                                                    ));
716                                                }
717                                            }
718                                        }
719                                    }
720                                }
721                            }
722                        }
723                    }
724                }
725            }
726        }
727
728        // Lint light and dark variant sections
729        for variant_key in &["light", "dark"] {
730            if let Some(toml::Value::Table(variant_table)) = top_table.get(*variant_key) {
731                lint_variant(variant_table, variant_key, &mut warnings);
732            }
733        }
734
735        // Lint top-level [layout] section
736        if let Some(toml::Value::Table(layout_table)) = top_table.get("layout") {
737            for key in layout_table.keys() {
738                if !LayoutTheme::FIELD_NAMES.contains(&key.as_str()) {
739                    warnings.push(format!("unknown field: layout.{key}"));
740                }
741            }
742        }
743
744        Ok(warnings)
745    }
746}
747
748#[cfg(test)]
749#[allow(clippy::unwrap_used, clippy::expect_used)]
750mod tests {
751    use super::*;
752    use crate::Rgba;
753
754    // === ThemeVariant tests ===
755
756    #[test]
757    fn theme_variant_default_is_empty() {
758        assert!(ThemeVariant::default().is_empty());
759    }
760
761    #[test]
762    fn theme_variant_not_empty_when_color_set() {
763        let mut v = ThemeVariant::default();
764        v.defaults.accent_color = Some(Rgba::rgb(0, 120, 215));
765        assert!(!v.is_empty());
766    }
767
768    #[test]
769    fn theme_variant_not_empty_when_font_set() {
770        let mut v = ThemeVariant::default();
771        v.defaults.font.family = Some("Inter".into());
772        assert!(!v.is_empty());
773    }
774
775    #[test]
776    fn theme_variant_merge_recursively() {
777        let mut base = ThemeVariant::default();
778        base.defaults.background_color = Some(Rgba::rgb(255, 255, 255));
779        base.defaults.font.family = Some("Noto Sans".into());
780
781        let mut overlay = ThemeVariant::default();
782        overlay.defaults.accent_color = Some(Rgba::rgb(0, 120, 215));
783        overlay.defaults.border.corner_radius = Some(4.0);
784
785        base.merge(&overlay);
786
787        // base background preserved
788        assert_eq!(
789            base.defaults.background_color,
790            Some(Rgba::rgb(255, 255, 255))
791        );
792        // overlay accent applied
793        assert_eq!(base.defaults.accent_color, Some(Rgba::rgb(0, 120, 215)));
794        // base font preserved
795        assert_eq!(base.defaults.font.family.as_deref(), Some("Noto Sans"));
796        // overlay border applied
797        assert_eq!(base.defaults.border.corner_radius, Some(4.0));
798    }
799
800    #[test]
801    fn theme_variant_has_all_widgets() {
802        let mut v = ThemeVariant::default();
803        // Set a field on each of the 25 widgets
804        v.window.background_color = Some(Rgba::rgb(255, 255, 255));
805        v.button.min_height = Some(32.0);
806        v.input.min_height = Some(32.0);
807        v.checkbox.indicator_width = Some(18.0);
808        v.menu.row_height = Some(28.0);
809        v.tooltip.max_width = Some(300.0);
810        v.scrollbar.groove_width = Some(14.0);
811        v.slider.track_height = Some(4.0);
812        v.progress_bar.track_height = Some(6.0);
813        v.tab.min_height = Some(32.0);
814        v.sidebar.background_color = Some(Rgba::rgb(240, 240, 240));
815        v.toolbar.bar_height = Some(40.0);
816        v.status_bar.background_color = Some(Rgba::rgb(240, 240, 240));
817        v.list.row_height = Some(28.0);
818        v.popover.background_color = Some(Rgba::rgb(255, 255, 255));
819        v.splitter.divider_width = Some(4.0);
820        v.separator.line_color = Some(Rgba::rgb(200, 200, 200));
821        v.switch.track_width = Some(32.0);
822        v.dialog.min_width = Some(320.0);
823        v.spinner.diameter = Some(24.0);
824        v.combo_box.min_height = Some(32.0);
825        v.segmented_control.segment_height = Some(28.0);
826        v.card.background_color = Some(Rgba::rgb(255, 255, 255));
827        v.expander.header_height = Some(32.0);
828        v.link.underline_enabled = Some(true);
829
830        assert!(!v.is_empty());
831        assert!(!v.window.is_empty());
832        assert!(!v.button.is_empty());
833        assert!(!v.input.is_empty());
834        assert!(!v.checkbox.is_empty());
835        assert!(!v.menu.is_empty());
836        assert!(!v.tooltip.is_empty());
837        assert!(!v.scrollbar.is_empty());
838        assert!(!v.slider.is_empty());
839        assert!(!v.progress_bar.is_empty());
840        assert!(!v.tab.is_empty());
841        assert!(!v.sidebar.is_empty());
842        assert!(!v.toolbar.is_empty());
843        assert!(!v.status_bar.is_empty());
844        assert!(!v.list.is_empty());
845        assert!(!v.popover.is_empty());
846        assert!(!v.splitter.is_empty());
847        assert!(!v.separator.is_empty());
848        assert!(!v.switch.is_empty());
849        assert!(!v.dialog.is_empty());
850        assert!(!v.spinner.is_empty());
851        assert!(!v.combo_box.is_empty());
852        assert!(!v.segmented_control.is_empty());
853        assert!(!v.card.is_empty());
854        assert!(!v.expander.is_empty());
855        assert!(!v.link.is_empty());
856    }
857
858    #[test]
859    fn theme_variant_merge_per_widget() {
860        let mut base = ThemeVariant::default();
861        base.button.background_color = Some(Rgba::rgb(200, 200, 200));
862        base.button.min_height = Some(28.0);
863        base.tooltip.background_color = Some(Rgba::rgb(50, 50, 50));
864
865        let mut overlay = ThemeVariant::default();
866        overlay.button.background_color = Some(Rgba::rgb(255, 255, 255));
867        overlay.button.min_width = Some(64.0);
868
869        base.merge(&overlay);
870
871        // overlay background wins
872        assert_eq!(base.button.background_color, Some(Rgba::rgb(255, 255, 255)));
873        // overlay min_width added
874        assert_eq!(base.button.min_width, Some(64.0));
875        // base min_height preserved
876        assert_eq!(base.button.min_height, Some(28.0));
877        // tooltip from base preserved
878        assert_eq!(base.tooltip.background_color, Some(Rgba::rgb(50, 50, 50)));
879    }
880
881    // === ThemeSpec tests ===
882
883    #[test]
884    fn native_theme_new_constructor() {
885        let theme = ThemeSpec::new("Breeze");
886        assert_eq!(theme.name, "Breeze");
887        assert!(theme.light.is_none());
888        assert!(theme.dark.is_none());
889    }
890
891    #[test]
892    fn native_theme_default_is_empty() {
893        let theme = ThemeSpec::default();
894        assert!(theme.is_empty());
895        assert_eq!(theme.name, "");
896    }
897
898    #[test]
899    fn native_theme_merge_keeps_base_name() {
900        let mut base = ThemeSpec::new("Base Theme");
901        let overlay = ThemeSpec::new("Overlay Theme");
902        base.merge(&overlay);
903        assert_eq!(base.name, "Base Theme");
904    }
905
906    #[test]
907    fn native_theme_merge_overlay_light_into_none() {
908        let mut base = ThemeSpec::new("Theme");
909
910        let mut overlay = ThemeSpec::new("Overlay");
911        let mut light = ThemeVariant::default();
912        light.defaults.accent_color = Some(Rgba::rgb(0, 120, 215));
913        overlay.light = Some(light);
914
915        base.merge(&overlay);
916
917        assert!(base.light.is_some());
918        assert_eq!(
919            base.light.as_ref().unwrap().defaults.accent_color,
920            Some(Rgba::rgb(0, 120, 215))
921        );
922    }
923
924    #[test]
925    fn native_theme_merge_both_light_variants() {
926        let mut base = ThemeSpec::new("Theme");
927        let mut base_light = ThemeVariant::default();
928        base_light.defaults.background_color = Some(Rgba::rgb(255, 255, 255));
929        base.light = Some(base_light);
930
931        let mut overlay = ThemeSpec::new("Overlay");
932        let mut overlay_light = ThemeVariant::default();
933        overlay_light.defaults.accent_color = Some(Rgba::rgb(0, 120, 215));
934        overlay.light = Some(overlay_light);
935
936        base.merge(&overlay);
937
938        let light = base.light.as_ref().unwrap();
939        // base background preserved
940        assert_eq!(
941            light.defaults.background_color,
942            Some(Rgba::rgb(255, 255, 255))
943        );
944        // overlay accent merged in
945        assert_eq!(light.defaults.accent_color, Some(Rgba::rgb(0, 120, 215)));
946    }
947
948    #[test]
949    fn native_theme_merge_base_light_only_preserved() {
950        let mut base = ThemeSpec::new("Theme");
951        let mut base_light = ThemeVariant::default();
952        base_light.defaults.font.family = Some("Inter".into());
953        base.light = Some(base_light);
954
955        let overlay = ThemeSpec::new("Overlay"); // no light
956
957        base.merge(&overlay);
958
959        assert!(base.light.is_some());
960        assert_eq!(
961            base.light.as_ref().unwrap().defaults.font.family.as_deref(),
962            Some("Inter")
963        );
964    }
965
966    #[test]
967    fn native_theme_merge_dark_variant() {
968        let mut base = ThemeSpec::new("Theme");
969
970        let mut overlay = ThemeSpec::new("Overlay");
971        let mut dark = ThemeVariant::default();
972        dark.defaults.background_color = Some(Rgba::rgb(30, 30, 30));
973        overlay.dark = Some(dark);
974
975        base.merge(&overlay);
976
977        assert!(base.dark.is_some());
978        assert_eq!(
979            base.dark.as_ref().unwrap().defaults.background_color,
980            Some(Rgba::rgb(30, 30, 30))
981        );
982    }
983
984    #[test]
985    fn native_theme_not_empty_with_light() {
986        let mut theme = ThemeSpec::new("Theme");
987        theme.light = Some(ThemeVariant::default());
988        assert!(!theme.is_empty());
989    }
990
991    // === pick_variant tests ===
992
993    #[test]
994    fn pick_variant_dark_with_both_variants_returns_dark() {
995        let mut theme = ThemeSpec::new("Test");
996        let mut light = ThemeVariant::default();
997        light.defaults.background_color = Some(Rgba::rgb(255, 255, 255));
998        theme.light = Some(light);
999        let mut dark = ThemeVariant::default();
1000        dark.defaults.background_color = Some(Rgba::rgb(30, 30, 30));
1001        theme.dark = Some(dark);
1002
1003        let picked = theme.pick_variant(true).unwrap();
1004        assert_eq!(
1005            picked.defaults.background_color,
1006            Some(Rgba::rgb(30, 30, 30))
1007        );
1008    }
1009
1010    #[test]
1011    fn pick_variant_light_with_both_variants_returns_light() {
1012        let mut theme = ThemeSpec::new("Test");
1013        let mut light = ThemeVariant::default();
1014        light.defaults.background_color = Some(Rgba::rgb(255, 255, 255));
1015        theme.light = Some(light);
1016        let mut dark = ThemeVariant::default();
1017        dark.defaults.background_color = Some(Rgba::rgb(30, 30, 30));
1018        theme.dark = Some(dark);
1019
1020        let picked = theme.pick_variant(false).unwrap();
1021        assert_eq!(
1022            picked.defaults.background_color,
1023            Some(Rgba::rgb(255, 255, 255))
1024        );
1025    }
1026
1027    #[test]
1028    fn pick_variant_dark_with_only_light_falls_back() {
1029        let mut theme = ThemeSpec::new("Test");
1030        let mut light = ThemeVariant::default();
1031        light.defaults.background_color = Some(Rgba::rgb(255, 255, 255));
1032        theme.light = Some(light);
1033
1034        let picked = theme.pick_variant(true).unwrap();
1035        assert_eq!(
1036            picked.defaults.background_color,
1037            Some(Rgba::rgb(255, 255, 255))
1038        );
1039    }
1040
1041    #[test]
1042    fn pick_variant_light_with_only_dark_falls_back() {
1043        let mut theme = ThemeSpec::new("Test");
1044        let mut dark = ThemeVariant::default();
1045        dark.defaults.background_color = Some(Rgba::rgb(30, 30, 30));
1046        theme.dark = Some(dark);
1047
1048        let picked = theme.pick_variant(false).unwrap();
1049        assert_eq!(
1050            picked.defaults.background_color,
1051            Some(Rgba::rgb(30, 30, 30))
1052        );
1053    }
1054
1055    #[test]
1056    fn pick_variant_with_no_variants_returns_none() {
1057        let theme = ThemeSpec::new("Empty");
1058        assert!(theme.pick_variant(true).is_none());
1059        assert!(theme.pick_variant(false).is_none());
1060    }
1061
1062    // === icon_set tests ===
1063
1064    #[test]
1065    fn icon_set_default_is_none() {
1066        assert!(ThemeVariant::default().icon_set.is_none());
1067    }
1068
1069    #[test]
1070    fn icon_set_merge_overlay() {
1071        let mut base = ThemeVariant::default();
1072        let overlay = ThemeVariant {
1073            icon_set: Some(IconSet::Material),
1074            ..Default::default()
1075        };
1076        base.merge(&overlay);
1077        assert_eq!(base.icon_set, Some(IconSet::Material));
1078    }
1079
1080    #[test]
1081    fn icon_set_merge_none_preserves() {
1082        let mut base = ThemeVariant {
1083            icon_set: Some(IconSet::SfSymbols),
1084            ..Default::default()
1085        };
1086        let overlay = ThemeVariant::default();
1087        base.merge(&overlay);
1088        assert_eq!(base.icon_set, Some(IconSet::SfSymbols));
1089    }
1090
1091    #[test]
1092    fn icon_set_is_empty_when_set() {
1093        assert!(ThemeVariant::default().is_empty());
1094        let v = ThemeVariant {
1095            icon_set: Some(IconSet::Material),
1096            ..Default::default()
1097        };
1098        assert!(!v.is_empty());
1099    }
1100
1101    #[test]
1102    fn icon_set_toml_round_trip() {
1103        let variant = ThemeVariant {
1104            icon_set: Some(IconSet::Material),
1105            ..Default::default()
1106        };
1107        let toml_str = toml::to_string(&variant).unwrap();
1108        assert!(toml_str.contains("icon_set"));
1109        let deserialized: ThemeVariant = toml::from_str(&toml_str).unwrap();
1110        assert_eq!(deserialized.icon_set, Some(IconSet::Material));
1111    }
1112
1113    #[test]
1114    fn icon_set_toml_absent_deserializes_to_none() {
1115        let toml_str = r##"
1116[defaults]
1117accent_color = "#ff0000"
1118"##;
1119        let variant: ThemeVariant = toml::from_str(toml_str).unwrap();
1120        assert!(variant.icon_set.is_none());
1121    }
1122
1123    #[test]
1124    fn native_theme_serde_toml_round_trip() {
1125        // Load a preset, serialize to TOML, deserialize back, and verify equality
1126        let theme = ThemeSpec::preset("material").expect("material preset should load");
1127        let toml_str = theme.to_toml().expect("should serialize");
1128        let theme2 = ThemeSpec::from_toml(&toml_str).expect("should deserialize");
1129        assert_eq!(theme, theme2, "round-trip should preserve ThemeSpec");
1130    }
1131
1132    // === from_toml_with_base tests ===
1133
1134    #[test]
1135    fn from_toml_with_base_merges_colors_onto_preset() {
1136        let overlay_toml = r##"
1137name = "Custom"
1138[light.defaults]
1139accent_color = "#ff00ff"
1140"##;
1141        let theme = ThemeSpec::from_toml_with_base(overlay_toml, "material").expect("should merge");
1142        // The overlay accent_color should replace the preset's
1143        let light = theme.light.as_ref().expect("light variant should exist");
1144        assert_eq!(
1145            light.defaults.accent_color,
1146            Some(crate::Rgba::rgb(255, 0, 255)),
1147            "overlay accent_color should replace preset"
1148        );
1149        // Other preset fields should be preserved (font is set in material)
1150        assert!(
1151            light.defaults.font.family.is_some(),
1152            "preset font family should be preserved after merge"
1153        );
1154    }
1155
1156    #[test]
1157    fn from_toml_with_base_unknown_preset_returns_error() {
1158        let err = ThemeSpec::from_toml_with_base("name = \"X\"", "nonexistent").unwrap_err();
1159        match err {
1160            crate::Error::Unavailable(msg) => assert!(msg.contains("nonexistent")),
1161            other => panic!("expected Unavailable, got: {other:?}"),
1162        }
1163    }
1164
1165    #[test]
1166    fn from_toml_with_base_invalid_toml_returns_error() {
1167        let err = ThemeSpec::from_toml_with_base("{{{{invalid", "material").unwrap_err();
1168        match err {
1169            crate::Error::Format(_) => {}
1170            other => panic!("expected Format, got: {other:?}"),
1171        }
1172    }
1173
1174    // === lint_toml tests ===
1175
1176    #[test]
1177    fn lint_toml_valid_returns_empty() {
1178        let toml = r##"
1179name = "Valid Theme"
1180[light.defaults]
1181accent_color = "#ff0000"
1182background_color = "#ffffff"
1183[light.defaults.font]
1184family = "Inter"
1185size_px = 14.0
1186[light.button]
1187min_height_px = 32.0
1188"##;
1189        let warnings = ThemeSpec::lint_toml(toml).unwrap();
1190        assert!(
1191            warnings.is_empty(),
1192            "Expected no warnings, got: {warnings:?}"
1193        );
1194    }
1195
1196    #[test]
1197    fn lint_toml_detects_unknown_top_level() {
1198        let toml = r##"
1199name = "Test"
1200theme_version = 2
1201"##;
1202        let warnings = ThemeSpec::lint_toml(toml).unwrap();
1203        assert_eq!(warnings.len(), 1);
1204        assert!(warnings[0].contains("theme_version"));
1205    }
1206
1207    #[test]
1208    fn lint_toml_detects_misspelled_defaults_field() {
1209        let toml = r##"
1210name = "Test"
1211[light.defaults]
1212backround = "#ffffff"
1213"##;
1214        let warnings = ThemeSpec::lint_toml(toml).unwrap();
1215        assert_eq!(warnings.len(), 1);
1216        assert!(warnings[0].contains("backround"));
1217        assert!(warnings[0].contains("light.defaults.backround"));
1218    }
1219
1220    #[test]
1221    fn lint_toml_detects_unknown_widget_field() {
1222        let toml = r##"
1223name = "Test"
1224[dark.button]
1225primary_bg = "#0078d7"
1226"##;
1227        let warnings = ThemeSpec::lint_toml(toml).unwrap();
1228        assert_eq!(warnings.len(), 1);
1229        assert!(warnings[0].contains("primary_bg"));
1230    }
1231
1232    #[test]
1233    fn lint_toml_detects_unknown_variant_section() {
1234        let toml = r##"
1235name = "Test"
1236[light.badges]
1237color = "#ff0000"
1238"##;
1239        let warnings = ThemeSpec::lint_toml(toml).unwrap();
1240        assert_eq!(warnings.len(), 1);
1241        assert!(warnings[0].contains("badges"));
1242    }
1243
1244    #[test]
1245    fn lint_toml_detects_unknown_font_subfield() {
1246        let toml = r##"
1247name = "Test"
1248[light.defaults.font]
1249famly = "Inter"
1250"##;
1251        let warnings = ThemeSpec::lint_toml(toml).unwrap();
1252        assert_eq!(warnings.len(), 1);
1253        assert!(warnings[0].contains("famly"));
1254    }
1255
1256    #[test]
1257    fn lint_toml_detects_unknown_border_subfield() {
1258        let toml = r##"
1259name = "Test"
1260[light.defaults.border]
1261radiusss = 4.0
1262"##;
1263        let warnings = ThemeSpec::lint_toml(toml).unwrap();
1264        assert_eq!(warnings.len(), 1);
1265        assert!(warnings[0].contains("radiusss"));
1266    }
1267
1268    #[test]
1269    fn lint_toml_detects_unknown_text_scale_entry() {
1270        let toml = r##"
1271name = "Test"
1272[light.text_scale.headline]
1273size = 24.0
1274"##;
1275        let warnings = ThemeSpec::lint_toml(toml).unwrap();
1276        assert_eq!(warnings.len(), 1);
1277        assert!(warnings[0].contains("headline"));
1278    }
1279
1280    #[test]
1281    fn lint_toml_detects_unknown_text_scale_entry_field() {
1282        let toml = r##"
1283name = "Test"
1284[light.text_scale.caption]
1285font_size = 12.0
1286"##;
1287        let warnings = ThemeSpec::lint_toml(toml).unwrap();
1288        assert_eq!(warnings.len(), 1);
1289        assert!(warnings[0].contains("font_size"));
1290    }
1291
1292    #[test]
1293    fn lint_toml_multiple_errors() {
1294        let toml = r##"
1295name = "Test"
1296author = "Me"
1297[light.defaults]
1298backround = "#ffffff"
1299[light.button]
1300primay_bg = "#0078d7"
1301"##;
1302        let warnings = ThemeSpec::lint_toml(toml).unwrap();
1303        assert_eq!(warnings.len(), 3);
1304    }
1305
1306    #[test]
1307    fn lint_toml_invalid_toml_returns_error() {
1308        let result = ThemeSpec::lint_toml("{{{{invalid");
1309        assert!(result.is_err());
1310    }
1311
1312    #[test]
1313    fn lint_toml_preset_has_no_warnings() {
1314        // Spot-check one preset for lint cleanliness via round-trip
1315        let theme = ThemeSpec::preset("material").expect("material preset should load");
1316        let toml_str = theme.to_toml().expect("should serialize");
1317        let warnings = ThemeSpec::lint_toml(&toml_str).expect("should parse");
1318        assert!(
1319            warnings.is_empty(),
1320            "material preset should have no lint warnings, got: {warnings:?}"
1321        );
1322    }
1323
1324    #[test]
1325    fn lint_toml_all_presets_clean() {
1326        for name in ThemeSpec::list_presets() {
1327            // Load the raw TOML source for each preset via include_str
1328            // by loading via preset() + to_toml() round-trip
1329            let theme = ThemeSpec::preset(name).unwrap_or_else(|e| {
1330                panic!("preset {name} should load: {e}");
1331            });
1332            let toml_str = theme.to_toml().unwrap_or_else(|e| {
1333                panic!("preset {name} should serialize: {e}");
1334            });
1335            let warnings = ThemeSpec::lint_toml(&toml_str).unwrap_or_else(|e| {
1336                panic!("preset {name} should lint: {e}");
1337            });
1338            assert!(
1339                warnings.is_empty(),
1340                "preset {name} should have no lint warnings, got: {warnings:?}"
1341            );
1342        }
1343    }
1344
1345    // === ThemeSpec layout integration tests ===
1346
1347    #[test]
1348    fn theme_spec_layout_merge() {
1349        let mut base = ThemeSpec::new("Base");
1350        base.layout.widget_gap = Some(6.0);
1351
1352        let mut overlay = ThemeSpec::new("Overlay");
1353        overlay.layout.container_margin = Some(8.0);
1354
1355        base.merge(&overlay);
1356        assert_eq!(base.layout.widget_gap, Some(6.0));
1357        assert_eq!(base.layout.container_margin, Some(8.0));
1358    }
1359
1360    #[test]
1361    fn theme_spec_layout_toml_round_trip() {
1362        let mut theme = ThemeSpec::new("Layout Test");
1363        theme.layout.widget_gap = Some(8.0);
1364        theme.layout.container_margin = Some(12.0);
1365        theme.layout.window_margin = Some(16.0);
1366        theme.layout.section_gap = Some(24.0);
1367
1368        let toml_str = theme.to_toml().unwrap();
1369        let theme2 = ThemeSpec::from_toml(&toml_str).unwrap();
1370        assert_eq!(theme.layout, theme2.layout);
1371    }
1372
1373    #[test]
1374    fn theme_spec_is_empty_with_layout() {
1375        let mut theme = ThemeSpec::new("Layout Only");
1376        assert!(theme.is_empty()); // name doesn't count
1377        theme.layout.widget_gap = Some(8.0);
1378        assert!(!theme.is_empty());
1379    }
1380
1381    #[test]
1382    fn theme_spec_layout_top_level_toml() {
1383        let mut theme = ThemeSpec::new("Top Level");
1384        theme.layout.widget_gap = Some(8.0);
1385
1386        let toml_str = theme.to_toml().unwrap();
1387        // [layout] must be at top level, not under [light.layout] or [dark.layout]
1388        assert!(
1389            toml_str.contains("[layout]"),
1390            "TOML should have [layout] section"
1391        );
1392        assert!(!toml_str.contains("[light.layout]"));
1393        assert!(!toml_str.contains("[dark.layout]"));
1394    }
1395}