Skip to main content

native_theme/model/
mod.rs

1// Theme model: ThemeMode and Theme, plus sub-module re-exports
2
3/// Light or dark color mode preference.
4///
5/// Used by [`SystemTheme`](crate::SystemTheme) to indicate the OS color
6/// mode and by [`SystemTheme::pick()`](crate::SystemTheme::pick) to select
7/// a resolved variant.
8///
9/// # Examples
10///
11/// ```
12/// use native_theme::theme::ColorMode;
13///
14/// let mode = ColorMode::Dark;
15/// assert!(mode.is_dark());
16/// ```
17#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)]
18#[non_exhaustive]
19pub enum ColorMode {
20    /// Light appearance.
21    Light,
22    /// Dark appearance.
23    Dark,
24}
25
26impl ColorMode {
27    /// Returns `true` if this is dark mode.
28    #[must_use]
29    pub fn is_dark(self) -> bool {
30        matches!(self, Self::Dark)
31    }
32}
33
34/// Animated icon types (frame sequences and transforms).
35pub mod animated;
36/// Disclosure arrow placement convention.
37pub mod arrow_side;
38/// Border specification sub-struct for widget border properties.
39pub mod border;
40/// Bundled SVG icon lookup tables.
41pub mod bundled;
42/// Global theme defaults shared across widgets.
43pub mod defaults;
44/// Dialog button ordering convention.
45pub mod dialog_order;
46/// Per-widget font specification and text scale.
47pub mod font;
48/// Per-context icon sizes.
49pub mod icon_sizes;
50/// Icon roles, sets, and provider trait.
51pub mod icons;
52/// Resolved (non-optional) theme types produced after resolution.
53pub mod resolved;
54/// Active-tab indicator placement convention.
55pub mod tab_indicator_side;
56/// Per-widget struct pairs and macros.
57pub mod widgets;
58
59pub use animated::{
60    AnimatedIcon, EmptyFrameListError, FrameList, FramesData, TransformAnimation, TransformData,
61};
62pub use border::{
63    DefaultsBorderSpec, ResolvedDefaultsBorder, ResolvedPadding, ResolvedWidgetBorder,
64    WidgetBorderSpec,
65};
66// G3 (Phase 93-03): demoted to pub(crate). Use the per-set loaders in `crate::icons` externally.
67pub use arrow_side::ArrowSide;
68pub(crate) use bundled::{bundled_icon_by_name, bundled_icon_svg};
69pub use defaults::ThemeDefaults;
70pub use dialog_order::DialogButtonOrder;
71pub use font::{
72    FontSize, FontSpec, FontStyle, ResolvedFontSpec, TextScale, TextScaleEntry, intern_font_family,
73};
74pub use icon_sizes::IconSizes;
75pub use icons::{
76    IconData, IconProvider, IconRole, IconSet, icon_name, system_icon_set, system_icon_theme,
77};
78pub use resolved::{
79    Resolved, ResolvedDefaults, ResolvedIconSizes, ResolvedTextScale, ResolvedTextScaleEntry,
80    ResolvedTheme,
81};
82pub use tab_indicator_side::TabIndicatorSide;
83pub use widgets::*; // All 26 XxxTheme + ResolvedXxxTheme pairs
84
85use std::borrow::Cow;
86
87use serde::{Deserialize, Serialize};
88
89/// A single light or dark theme variant containing all visual properties.
90///
91/// Composes defaults, per-widget structs, and optional text scale into one coherent set.
92/// Empty sub-structs are omitted from serialization to keep TOML files clean.
93///
94/// # Examples
95///
96/// ```
97/// use native_theme::theme::ThemeMode;
98/// use native_theme::color::Rgba;
99///
100/// let mut variant = ThemeMode::default();
101/// variant.defaults.accent_color = Some(Rgba::rgb(0, 120, 215));
102/// variant.defaults.font.family = Some("Inter".into());
103/// assert!(!variant.is_empty());
104/// ```
105#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)]
106#[serde(default)]
107pub struct ThemeMode {
108    /// Global defaults inherited by all widgets.
109    #[serde(default, skip_serializing_if = "ThemeDefaults::is_empty")]
110    pub defaults: ThemeDefaults,
111
112    /// Per-role text scale overrides.
113    #[serde(default, skip_serializing_if = "TextScale::is_empty")]
114    pub text_scale: TextScale,
115
116    /// Window chrome: background, title bar, radius, shadow.
117    #[serde(default, skip_serializing_if = "WindowTheme::is_empty")]
118    pub window: WindowTheme,
119
120    /// Push button: colors, sizing, spacing, geometry.
121    #[serde(default, skip_serializing_if = "ButtonTheme::is_empty")]
122    pub button: ButtonTheme,
123
124    /// Single-line and multi-line text input fields.
125    #[serde(default, skip_serializing_if = "InputTheme::is_empty")]
126    pub input: InputTheme,
127
128    /// Multi-line text field: its own padding; the rest is `input`'s.
129    #[serde(default, skip_serializing_if = "TextAreaTheme::is_empty")]
130    pub text_area: TextAreaTheme,
131
132    /// Checkbox and radio button indicator geometry.
133    #[serde(default, skip_serializing_if = "CheckboxTheme::is_empty")]
134    pub checkbox: CheckboxTheme,
135
136    /// Popup and context menu appearance.
137    #[serde(default, skip_serializing_if = "MenuTheme::is_empty")]
138    pub menu: MenuTheme,
139
140    /// Tooltip popup appearance.
141    #[serde(default, skip_serializing_if = "TooltipTheme::is_empty")]
142    pub tooltip: TooltipTheme,
143
144    /// Scrollbar colors and geometry.
145    #[serde(default, skip_serializing_if = "ScrollbarTheme::is_empty")]
146    pub scrollbar: ScrollbarTheme,
147
148    /// Slider control colors and geometry.
149    #[serde(default, skip_serializing_if = "SliderTheme::is_empty")]
150    pub slider: SliderTheme,
151
152    /// Progress bar colors and geometry.
153    #[serde(default, skip_serializing_if = "ProgressBarTheme::is_empty")]
154    pub progress_bar: ProgressBarTheme,
155
156    /// Tab bar colors and sizing.
157    #[serde(default, skip_serializing_if = "TabTheme::is_empty")]
158    pub tab: TabTheme,
159
160    /// Sidebar panel background and foreground colors.
161    #[serde(default, skip_serializing_if = "SidebarTheme::is_empty")]
162    pub sidebar: SidebarTheme,
163
164    /// Toolbar sizing, spacing, and font.
165    #[serde(default, skip_serializing_if = "ToolbarTheme::is_empty")]
166    pub toolbar: ToolbarTheme,
167
168    /// Status bar font.
169    #[serde(default, skip_serializing_if = "StatusBarTheme::is_empty")]
170    pub status_bar: StatusBarTheme,
171
172    /// List and table colors and row geometry.
173    #[serde(default, skip_serializing_if = "ListTheme::is_empty")]
174    pub list: ListTheme,
175
176    /// Popover / dropdown panel appearance.
177    #[serde(default, skip_serializing_if = "PopoverTheme::is_empty")]
178    pub popover: PopoverTheme,
179
180    /// Splitter handle width.
181    #[serde(default, skip_serializing_if = "SplitterTheme::is_empty")]
182    pub splitter: SplitterTheme,
183
184    /// Separator line color.
185    #[serde(default, skip_serializing_if = "SeparatorTheme::is_empty")]
186    pub separator: SeparatorTheme,
187
188    /// Toggle switch track, thumb, and geometry.
189    #[serde(default, skip_serializing_if = "SwitchTheme::is_empty")]
190    pub switch: SwitchTheme,
191
192    /// Dialog sizing, spacing, button order, and title font.
193    #[serde(default, skip_serializing_if = "DialogTheme::is_empty")]
194    pub dialog: DialogTheme,
195
196    /// Spinner / indeterminate progress indicator.
197    #[serde(default, skip_serializing_if = "SpinnerTheme::is_empty")]
198    pub spinner: SpinnerTheme,
199
200    /// ComboBox / dropdown trigger sizing.
201    #[serde(default, skip_serializing_if = "ComboBoxTheme::is_empty")]
202    pub combo_box: ComboBoxTheme,
203
204    /// Segmented control sizing.
205    #[serde(default, skip_serializing_if = "SegmentedControlTheme::is_empty")]
206    pub segmented_control: SegmentedControlTheme,
207
208    /// Card / container colors and geometry.
209    #[serde(default, skip_serializing_if = "CardTheme::is_empty")]
210    pub card: CardTheme,
211
212    /// Expander / disclosure row geometry.
213    #[serde(default, skip_serializing_if = "ExpanderTheme::is_empty")]
214    pub expander: ExpanderTheme,
215
216    /// Hyperlink colors and underline setting.
217    #[serde(default, skip_serializing_if = "LinkTheme::is_empty")]
218    pub link: LinkTheme,
219}
220
221impl_merge!(ThemeMode {
222    nested {
223        defaults, text_scale, window, button, input, text_area, checkbox, menu,
224        tooltip, scrollbar, slider, progress_bar, tab, sidebar,
225        toolbar, status_bar, list, popover, splitter, separator,
226        switch, dialog, spinner, combo_box, segmented_control,
227        card, expander, link
228    }
229});
230
231/// A complete native theme with a name and optional light/dark variants.
232///
233/// This is the top-level type that theme files deserialize into and that
234/// platform readers produce.
235///
236/// # Examples
237///
238/// ```
239/// use native_theme::theme::Theme;
240///
241/// // Load a bundled preset
242/// let theme = Theme::preset("dracula").unwrap();
243/// assert_eq!(theme.name, "Dracula");
244///
245/// // Parse from a TOML string
246/// let toml = r##"
247/// name = "Custom"
248/// [light.defaults]
249/// accent_color = "#ff6600"
250/// "##;
251/// let custom = Theme::from_toml(toml).unwrap();
252/// assert_eq!(custom.name, "Custom");
253///
254/// // Merge themes (overlay wins for populated fields)
255/// let mut base = Theme::preset("catppuccin-mocha").unwrap();
256/// base.merge(&custom);
257/// assert_eq!(base.name, "Catppuccin Mocha"); // base name is preserved
258/// ```
259#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
260pub struct Theme {
261    /// Theme name (e.g., "KDE Breeze", "Adwaita", "Windows 11").
262    ///
263    /// Uses `Cow<'static, str>` so bundled presets can store borrowed
264    /// `&'static str` values without per-load `String` allocations.
265    /// User-provided names (from TOML files or runtime detection)
266    /// are `Cow::Owned`.
267    pub name: Cow<'static, str>,
268
269    /// Light variant of the theme.
270    #[serde(default, skip_serializing_if = "Option::is_none")]
271    pub light: Option<ThemeMode>,
272
273    /// Dark variant of the theme.
274    #[serde(default, skip_serializing_if = "Option::is_none")]
275    pub dark: Option<ThemeMode>,
276
277    /// Layout spacing constants (shared between light and dark variants).
278    #[serde(default, skip_serializing_if = "LayoutTheme::is_empty")]
279    pub layout: LayoutTheme,
280
281    /// Which icon loading mechanism to use (`Freedesktop`, `Material`, `Lucide`,
282    /// `SfSymbols`, `SegoeIcons`). Shared across light and dark variants.
283    /// When `None`, filled during resolution from
284    /// [`system_icon_set()`](crate::theme::system_icon_set).
285    ///
286    /// # Examples
287    ///
288    /// ```
289    /// use native_theme::theme::{Theme, IconSet};
290    ///
291    /// let theme = Theme::preset("material")?;
292    /// assert_eq!(theme.icon_set, Some(IconSet::Material));
293    /// # Ok::<(), native_theme::error::Error>(())
294    /// ```
295    #[serde(default, skip_serializing_if = "Option::is_none")]
296    pub icon_set: Option<IconSet>,
297
298    /// Visual icon theme name (shared across light and dark variants).
299    ///
300    /// Acts as the default when a variant's
301    /// [`ThemeDefaults::icon_theme`](crate::model::ThemeDefaults::icon_theme)
302    /// is `None`. Variants that need a different icon theme per color mode
303    /// (e.g. KDE Plasma: `"breeze"` light / `"breeze-dark"` dark) set the
304    /// override on [`ThemeDefaults::icon_theme`].
305    ///
306    /// Precedence at resolve time:
307    /// 1. [`ThemeMode::defaults.icon_theme`](crate::model::ThemeDefaults::icon_theme) — per-variant override (if set)
308    /// 2. `Theme::icon_theme` — this field (if set)
309    /// 3. [`system_icon_theme()`](crate::model::icons::system_icon_theme) — runtime
310    ///    detection; none where it fails
311    ///
312    /// See `docs/archive/v0.5.7_gaps.md` §G4 for the design rationale.
313    #[serde(default, skip_serializing_if = "Option::is_none")]
314    pub icon_theme: Option<Cow<'static, str>>,
315}
316
317impl Default for Theme {
318    fn default() -> Self {
319        Self {
320            name: Cow::Borrowed(""),
321            light: None,
322            dark: None,
323            layout: LayoutTheme::default(),
324            icon_set: None,
325            icon_theme: None,
326        }
327    }
328}
329
330impl Theme {
331    /// Merge an overlay theme into this theme.
332    ///
333    /// The base name is kept. For each variant (light/dark):
334    /// - If both base and overlay have a variant, they are merged recursively.
335    /// - If only the overlay has a variant, it is cloned into the base.
336    /// - If only the base has a variant (or neither), no change.
337    pub fn merge(&mut self, overlay: &Self) {
338        // Keep base name (do not overwrite)
339
340        match (&mut self.light, &overlay.light) {
341            (Some(base), Some(over)) => base.merge(over),
342            (None, Some(over)) => self.light = Some(over.clone()),
343            _ => {}
344        }
345
346        match (&mut self.dark, &overlay.dark) {
347            (Some(base), Some(over)) => base.merge(over),
348            (None, Some(over)) => self.dark = Some(over.clone()),
349            _ => {}
350        }
351
352        self.layout.merge(&overlay.layout);
353
354        if overlay.icon_set.is_some() {
355            self.icon_set = overlay.icon_set;
356        }
357
358        if overlay.icon_theme.is_some() {
359            self.icon_theme.clone_from(&overlay.icon_theme);
360        }
361    }
362
363    /// Pick the appropriate variant for the given mode, with cross-fallback.
364    ///
365    /// When `mode` is [`ColorMode::Dark`], prefers `dark` and falls back to `light`.
366    /// When `mode` is [`ColorMode::Light`], prefers `light` and falls back to `dark`.
367    ///
368    /// # Errors
369    ///
370    /// Returns [`Error::NoVariant`](crate::Error::NoVariant) if the theme has
371    /// no variants at all.
372    pub fn pick_variant(&self, mode: ColorMode) -> crate::Result<&ThemeMode> {
373        match mode {
374            ColorMode::Dark => self.dark.as_ref().or(self.light.as_ref()),
375            ColorMode::Light => self.light.as_ref().or(self.dark.as_ref()),
376        }
377        .ok_or(crate::Error::NoVariant { mode })
378    }
379
380    /// Extract a variant by consuming the theme, avoiding a clone.
381    ///
382    /// When `mode` is [`ColorMode::Dark`], returns the `dark` variant (falling back to
383    /// `light`). When [`ColorMode::Light`], returns `light` (falling back to `dark`).
384    ///
385    /// Use this when you own the `Theme` and don't need it afterward.
386    /// For read-only inspection, use [`pick_variant()`](Self::pick_variant).
387    ///
388    /// # Errors
389    ///
390    /// Returns [`Error::NoVariant`](crate::Error::NoVariant) if the theme has
391    /// no variants at all.
392    ///
393    /// # Examples
394    ///
395    /// ```
396    /// use native_theme::theme::ColorMode;
397    ///
398    /// let theme = native_theme::theme::Theme::preset("dracula")?;
399    /// let variant = theme.into_variant(ColorMode::Dark)?;
400    /// let resolved = variant.resolve_system()?;
401    /// # Ok::<(), Box<dyn std::error::Error>>(())
402    /// ```
403    pub fn into_variant(self, mode: ColorMode) -> crate::Result<ThemeMode> {
404        match mode {
405            ColorMode::Dark => self.dark.or(self.light),
406            ColorMode::Light => self.light.or(self.dark),
407        }
408        .ok_or(crate::Error::NoVariant { mode })
409    }
410
411    /// Resolve this theme for a color mode into a ready-to-render bundle.
412    ///
413    /// Picks the variant (with cross-fallback), applies inheritance and
414    /// validation to produce a [`ResolvedTheme`], and resolves the shared
415    /// `icon_set` / `icon_theme` fields. This is the one-call path from a
416    /// parsed [`Theme`] to the values a UI framework needs.
417    ///
418    /// `icon_set` falls back to [`system_icon_set()`] when the TOML omits
419    /// it. `icon_theme` uses three-tier precedence:
420    ///
421    /// 1. The picked variant's `defaults.icon_theme` (per-mode override).
422    /// 2. [`Theme::icon_theme`] (shared across variants).
423    /// 3. [`system_icon_theme()`] (runtime detect).
424    ///
425    /// Where no tier gives a name — the TOML states none and detection
426    /// fails — [`Resolved::icon_theme`] is `None`; the resolution itself
427    /// does not fail over it, and [`system_icon_theme()`] gives the reason.
428    /// [`Resolved::icon_theme_explicit`] reports whether tiers 1 or 2
429    /// fired (value came from TOML) versus tier 3 (runtime detection).
430    ///
431    /// Uses [`ResolutionContext::from_system`](crate::resolve::ResolutionContext::from_system)
432    /// for DPI and button-order. For custom contexts, construct the
433    /// variant manually and call
434    /// [`ThemeMode::into_resolved`](crate::theme::ThemeMode::into_resolved).
435    ///
436    /// # Errors
437    ///
438    /// Returns [`crate::Error::NoVariant`] when the theme has no variants,
439    /// or [`crate::Error::ResolutionIncomplete`] /
440    /// [`crate::Error::ResolutionInvalid`] when the picked variant cannot
441    /// be fully resolved.
442    ///
443    /// # Examples
444    ///
445    /// ```
446    /// use native_theme::theme::{ColorMode, Theme};
447    ///
448    /// // Theme-level `icon_theme` (tier 2) — applies to both modes.
449    /// let material = Theme::preset("material")?;
450    /// let r = material.resolve(ColorMode::Dark)?;
451    /// assert_eq!(r.icon_theme.as_deref(), Some("material"));
452    /// assert!(r.icon_theme_explicit);
453    ///
454    /// // Per-variant `icon_theme` (tier 1) — differs by mode.
455    /// let breeze = Theme::preset("kde-breeze")?;
456    /// assert_eq!(breeze.resolve(ColorMode::Light)?.icon_theme.as_deref(), Some("breeze"));
457    /// assert_eq!(breeze.resolve(ColorMode::Dark)?.icon_theme.as_deref(), Some("breeze-dark"));
458    /// # Ok::<(), native_theme::error::Error>(())
459    /// ```
460    pub fn resolve(&self, mode: ColorMode) -> crate::Result<Resolved> {
461        self.resolve_in(mode, &crate::resolve::ResolutionContext::from_system())
462    }
463
464    /// [`resolve`](Self::resolve) with the resolution-time inputs of `ctx`,
465    /// whose `icon_theme` is tier 3.
466    pub(crate) fn resolve_in(
467        &self,
468        mode: ColorMode,
469        ctx: &crate::resolve::ResolutionContext,
470    ) -> crate::Result<Resolved> {
471        let variant_ref = self.pick_variant(mode)?;
472
473        let tier1 = variant_ref.defaults.icon_theme.clone();
474        let tier2 = self.icon_theme.clone();
475        let icon_theme_explicit = tier1.is_some() || tier2.is_some();
476        let icon_theme = tier1.or(tier2).or_else(|| ctx.icon_theme.clone());
477
478        let icon_set = self
479            .icon_set
480            .unwrap_or_else(crate::model::icons::system_icon_set);
481
482        let variant = variant_ref.clone().into_resolved(ctx)?;
483
484        Ok(Resolved {
485            variant,
486            icon_set,
487            icon_theme,
488            icon_theme_explicit,
489        })
490    }
491
492    /// Returns true if the theme has no variants set.
493    pub fn is_empty(&self) -> bool {
494        self.light.is_none()
495            && self.dark.is_none()
496            && self.layout.is_empty()
497            && self.icon_set.is_none()
498            && self.icon_theme.is_none()
499    }
500
501    /// Load a bundled theme preset by name.
502    ///
503    /// Returns the preset as a fully populated [`Theme`] with both
504    /// light and dark variants.
505    ///
506    /// # Errors
507    /// Returns [`crate::Error::UnknownPreset`] if the preset name is not recognized.
508    ///
509    /// # Examples
510    /// ```
511    /// let theme = native_theme::theme::Theme::preset("catppuccin-mocha")?;
512    /// assert!(theme.light.is_some());
513    /// # Ok::<(), native_theme::error::Error>(())
514    /// ```
515    ///
516    /// Bundled preset names are `Cow::Borrowed` (no allocation):
517    /// ```
518    /// use native_theme::theme::Theme;
519    ///
520    /// let theme = Theme::preset("dracula")?;
521    /// assert!(matches!(theme.name, std::borrow::Cow::Borrowed(_)));
522    /// # Ok::<(), native_theme::error::Error>(())
523    /// ```
524    pub fn preset(name: &str) -> crate::Result<Self> {
525        crate::presets::preset(name)
526    }
527
528    /// Parse a TOML string into a [`Theme`].
529    ///
530    /// # TOML Format
531    ///
532    /// Theme files use the following structure. Every field is optional --
533    /// omit any field you don't need. Unknown fields are silently ignored;
534    /// [`lint_toml`](Self::lint_toml) reports them. Hex colors accept
535    /// `#RGB`, `#RGBA`, `#RRGGBB` or `#RRGGBBAA` (the `#` is optional). A size carries its unit in its key:
536    /// `_px` for logical pixels, and `size_pt` or `size_px` for a font.
537    /// A widget border's padding takes one key per side (`padding_top_px`,
538    /// `padding_right_px`, `padding_bottom_px`, `padding_left_px`) or one per
539    /// axis (`padding_horizontal_px`, `padding_vertical_px`), not both for
540    /// the same axis.
541    ///
542    /// ```
543    /// use native_theme::theme::Theme;
544    ///
545    /// let toml = r##"
546    /// name = "My Theme"
547    ///
548    /// [light.defaults]
549    /// accent_color = "#4a90d9"
550    /// background_color = "#fafafa"
551    /// text_color = "#2e3436"
552    /// surface_color = "#ffffff"
553    /// muted_color = "#929292"
554    /// shadow_color = "#00000018"
555    /// danger_color = "#dc3545"
556    /// warning_color = "#f0ad4e"
557    /// success_color = "#28a745"
558    /// info_color = "#4a90d9"
559    /// selection_background = "#4a90d9"
560    /// selection_text_color = "#ffffff"
561    /// link_color = "#2a6cb6"
562    /// focus_ring_color = "#4a90d9"
563    /// disabled_text_color = "#c0c0c0"
564    /// disabled_opacity = 0.5
565    ///
566    /// [light.defaults.font]
567    /// family = "sans-serif"
568    /// size_pt = 10.0
569    ///
570    /// [light.defaults.mono_font]
571    /// family = "monospace"
572    /// size_pt = 10.0
573    ///
574    /// [light.defaults.border]
575    /// color = "#c0c0c0"
576    /// corner_radius_px = 6.0
577    /// corner_radius_lg_px = 12.0
578    /// line_width_px = 1.0
579    /// opacity = 0.15
580    /// shadow_enabled = true
581    ///
582    /// [light.button]
583    /// background_color = "#e8e8e8"
584    /// min_height_px = 32.0
585    ///
586    /// [light.button.font]
587    /// color = "#2e3436"
588    ///
589    /// [light.button.border]
590    /// padding_horizontal_px = 12.0
591    /// padding_vertical_px = 6.0
592    ///
593    /// [light.tooltip]
594    /// background_color = "#2e3436"
595    /// max_width_px = 300.0
596    ///
597    /// [light.tooltip.font]
598    /// color = "#f0f0f0"
599    ///
600    /// ## [dark.*] mirrors the same structure as [light.*]
601    /// "##;
602    ///
603    /// assert!(Theme::lint_toml(toml)?.is_empty());
604    /// let theme = Theme::from_toml(toml)?;
605    /// let light = theme.light.as_ref();
606    /// assert_eq!(light.and_then(|v| v.button.min_height), Some(32.0));
607    /// assert_eq!(light.and_then(|v| v.tooltip.max_width), Some(300.0));
608    /// assert_eq!(
609    ///     light.and_then(|v| v.defaults.border.corner_radius),
610    ///     Some(6.0)
611    /// );
612    /// let button_border = light.and_then(|v| v.button.border.as_ref());
613    /// assert_eq!(button_border.and_then(|b| b.padding_left), Some(12.0));
614    /// assert_eq!(button_border.and_then(|b| b.padding_bottom), Some(6.0));
615    /// # Ok::<(), native_theme::error::Error>(())
616    /// ```
617    ///
618    /// # Errors
619    /// Returns [`crate::Error::Toml`] if the TOML is invalid.
620    ///
621    /// # Examples
622    /// ```
623    /// let toml = r##"
624    /// name = "My Theme"
625    /// [light.defaults]
626    /// accent_color = "#ff0000"
627    /// "##;
628    /// let theme = native_theme::theme::Theme::from_toml(toml).unwrap();
629    /// assert_eq!(theme.name, "My Theme");
630    /// ```
631    pub fn from_toml(toml_str: &str) -> crate::Result<Self> {
632        crate::presets::from_toml(toml_str)
633    }
634
635    /// Load a [`Theme`] from a TOML file.
636    ///
637    /// # Errors
638    /// Returns [`crate::Error::Io`] if the file cannot be read, or
639    /// [`crate::Error::Toml`] if the TOML content is invalid.
640    ///
641    /// # Examples
642    /// ```no_run
643    /// let theme = native_theme::theme::Theme::from_file("my-theme.toml").unwrap();
644    /// ```
645    pub fn from_file(path: impl AsRef<std::path::Path>) -> crate::Result<Self> {
646        crate::presets::from_file(path)
647    }
648
649    /// List all available bundled presets with structured metadata.
650    ///
651    /// Returns a static slice of [`PresetInfo`](crate::presets::PresetInfo) entries,
652    /// one per bundled preset. Each entry carries the machine-readable key,
653    /// human-readable display name, target platform tags, and a `light_only` flag.
654    ///
655    /// # Examples
656    /// ```
657    /// let presets = native_theme::theme::Theme::list_presets();
658    /// assert_eq!(presets.len(), 16);
659    /// assert_eq!(presets[0].key, "kde-breeze");
660    /// assert_eq!(presets[0].display_name, "KDE Breeze");
661    /// ```
662    #[must_use]
663    pub fn list_presets() -> &'static [crate::presets::PresetInfo] {
664        crate::presets::list_presets()
665    }
666
667    /// List presets appropriate for the current platform, with structured metadata.
668    ///
669    /// Platform presets are included where their platform tag matches:
670    /// kde-breeze on KDE, adwaita on Linux, windows-11 on Windows,
671    /// macos-sonoma on macOS, ios on macOS and iOS. `material` and the
672    /// community themes are always included.
673    ///
674    /// Note: Unlike [`list_presets()`](Self::list_presets) which returns a static slice,
675    /// this method returns `Vec` because it filters the preset list at runtime based
676    /// on the detected platform.
677    ///
678    /// # Examples
679    /// ```
680    /// let presets = native_theme::theme::Theme::list_presets_for_platform();
681    /// // On Linux KDE: kde-breeze, adwaita, material and all community themes
682    /// // On Windows: windows-11, material and all community themes
683    /// assert!(!presets.is_empty());
684    /// ```
685    #[must_use]
686    pub fn list_presets_for_platform() -> Vec<crate::presets::PresetInfo> {
687        crate::presets::list_presets_for_platform()
688    }
689
690    /// Serialize this theme to a TOML string.
691    ///
692    /// # Errors
693    /// Returns [`crate::Error::ReaderFailed`] if serialization fails.
694    ///
695    /// # Examples
696    /// ```
697    /// let theme = native_theme::theme::Theme::preset("catppuccin-mocha").unwrap();
698    /// let toml_str = theme.to_toml().unwrap();
699    /// assert!(toml_str.contains("name = \"Catppuccin Mocha\""));
700    /// ```
701    pub fn to_toml(&self) -> crate::Result<String> {
702        crate::presets::to_toml(self)
703    }
704
705    /// Check a TOML string for unrecognized field names.
706    ///
707    /// Parses the TOML as a generic table and walks all keys, comparing
708    /// against the known fields for each section. Returns a `Vec<String>`
709    /// of warnings for any keys that don't match a known field. An empty
710    /// vec means all keys are recognized.
711    ///
712    /// This is an opt-in linting tool for theme authors. It does NOT affect
713    /// `from_toml()` behavior (which silently ignores unknown fields via serde).
714    ///
715    /// # Errors
716    ///
717    /// Returns `Err` if the TOML string cannot be parsed at all.
718    ///
719    /// # Examples
720    ///
721    /// ```
722    /// let warnings = native_theme::theme::Theme::lint_toml(r##"
723    /// name = "Test"
724    /// [light.defaults]
725    /// backround = "#ffffff"
726    /// "##).unwrap();
727    /// assert_eq!(warnings.len(), 1);
728    /// assert!(warnings[0].contains("backround"));
729    /// ```
730    pub fn lint_toml(toml_str: &str) -> crate::Result<Vec<String>> {
731        let value: toml::Value =
732            toml::from_str(toml_str).map_err(|e: toml::de::Error| crate::Error::Toml(e))?;
733
734        let mut warnings = Vec::new();
735
736        let top_table = match &value {
737            toml::Value::Table(t) => t,
738            _ => return Ok(warnings),
739        };
740
741        // Known top-level keys
742        const TOP_KEYS: &[&str] = &["name", "light", "dark", "layout", "icon_set", "icon_theme"];
743
744        for key in top_table.keys() {
745            if !TOP_KEYS.contains(&key.as_str()) {
746                warnings.push(format!("unknown field: {key}"));
747            }
748        }
749
750        // Structural variant-level keys that are NOT widgets
751        const STRUCTURAL_KEYS: &[&str] = &["defaults", "text_scale"];
752
753        // Phase 93-05 G5: build BOTH field registries from inventory.
754        // - widget_registry: one entry per per-variant widget (ButtonTheme ->
755        //   "button", etc.) populated by #[derive(ThemeWidget)].
756        // - struct_registry: one entry per plain struct (FontSpec, IconSizes,
757        //   ThemeDefaults, LayoutTheme, ...) populated by #[derive(ThemeFields)].
758        let widget_registry: std::collections::HashMap<&str, &[&str]> =
759            inventory::iter::<crate::resolve::WidgetFieldInfo>()
760                .map(|info| (info.widget_name, info.field_names))
761                .collect();
762        let struct_registry: std::collections::HashMap<&str, &[&str]> =
763            inventory::iter::<crate::resolve::FieldInfo>()
764                .map(|info| (info.struct_name, info.field_names))
765                .collect();
766
767        // Helper: fetch a plain-struct field list by type name. Returns None
768        // when the struct did not opt in to ThemeFields -- sub-table linting
769        // is silently skipped in that case, matching the former `continue;`
770        // behaviour when a sub-table's type wasn't recognised.
771        let get_struct_fields =
772            |name: &str| -> Option<&[&str]> { struct_registry.get(name).copied() };
773
774        // Lint a text_scale section
775        let lint_text_scale = |table: &toml::map::Map<String, toml::Value>,
776                               prefix: &str,
777                               warnings: &mut Vec<String>| {
778            let Some(scale_fields) = get_struct_fields("TextScale") else {
779                return;
780            };
781            let entry_fields = get_struct_fields("TextScaleEntry");
782            for key in table.keys() {
783                if !scale_fields.contains(&key.as_str()) {
784                    warnings.push(format!("unknown field: {prefix}.{key}"));
785                } else if let Some(toml::Value::Table(entry_table)) = table.get(key)
786                    && let Some(entry_fields) = entry_fields
787                {
788                    for ekey in entry_table.keys() {
789                        if !entry_fields.contains(&ekey.as_str()) {
790                            warnings.push(format!("unknown field: {prefix}.{key}.{ekey}"));
791                        }
792                    }
793                }
794            }
795        };
796
797        // Lint a defaults section (with nested font, mono_font, border, icon_sizes)
798        let lint_defaults = |table: &toml::map::Map<String, toml::Value>,
799                             prefix: &str,
800                             warnings: &mut Vec<String>| {
801            let Some(defaults_fields) = get_struct_fields("ThemeDefaults") else {
802                return;
803            };
804            for key in table.keys() {
805                if !defaults_fields.contains(&key.as_str()) {
806                    warnings.push(format!("unknown field: {prefix}.{key}"));
807                    continue;
808                }
809                // Check sub-tables for nested struct fields
810                if let Some(toml::Value::Table(sub)) = table.get(key) {
811                    let known = match key.as_str() {
812                        "font" | "mono_font" => get_struct_fields("FontSpec"),
813                        "border" => get_struct_fields("DefaultsBorderSpec"),
814                        "icon_sizes" => get_struct_fields("IconSizes"),
815                        _ => continue,
816                    };
817                    let Some(known) = known else { continue };
818                    for skey in sub.keys() {
819                        if !known.contains(&skey.as_str()) {
820                            warnings.push(format!("unknown field: {prefix}.{key}.{skey}"));
821                        }
822                    }
823                }
824            }
825        };
826
827        // Lint a variant section (light or dark).
828        let lint_variant = |table: &toml::map::Map<String, toml::Value>,
829                            prefix: &str,
830                            warnings: &mut Vec<String>| {
831            for key in table.keys() {
832                let key_str = key.as_str();
833
834                // Check structural keys first, then widget registry
835                let is_structural = STRUCTURAL_KEYS.contains(&key_str);
836                let widget_fields = widget_registry.get(key_str);
837
838                if !is_structural && widget_fields.is_none() {
839                    warnings.push(format!("unknown field: {prefix}.{key}"));
840                    continue;
841                }
842
843                if let Some(toml::Value::Table(sub)) = table.get(key) {
844                    let sub_prefix = format!("{prefix}.{key}");
845                    match key_str {
846                        "defaults" => lint_defaults(sub, &sub_prefix, warnings),
847                        "text_scale" => lint_text_scale(sub, &sub_prefix, warnings),
848                        _ => {
849                            if let Some(fields) = widget_fields {
850                                for skey in sub.keys() {
851                                    if !fields.contains(&skey.as_str()) {
852                                        warnings
853                                            .push(format!("unknown field: {sub_prefix}.{skey}"));
854                                    }
855                                    // Validate sub-tables (font/border nested structs)
856                                    if let Some(toml::Value::Table(nested)) = sub.get(skey) {
857                                        let nested_known = match skey.as_str() {
858                                            s if s == "font" || s.ends_with("_font") => {
859                                                get_struct_fields("FontSpec")
860                                            }
861                                            "border" => get_struct_fields("WidgetBorderSpec"),
862                                            _ => None,
863                                        };
864                                        if let Some(known) = nested_known {
865                                            for nkey in nested.keys() {
866                                                if !known.contains(&nkey.as_str()) {
867                                                    warnings.push(format!(
868                                                        "unknown field: {sub_prefix}.{skey}.{nkey}"
869                                                    ));
870                                                }
871                                            }
872                                        }
873                                    }
874                                }
875                            }
876                        }
877                    }
878                }
879            }
880        };
881
882        // Lint light and dark variant sections
883        for variant_key in &["light", "dark"] {
884            if let Some(toml::Value::Table(variant_table)) = top_table.get(*variant_key) {
885                lint_variant(variant_table, variant_key, &mut warnings);
886            }
887        }
888
889        // Lint top-level [layout] section. LayoutTheme is a "widget" at the
890        // macro level (for its Resolved-pair codegen) but skips the widget
891        // inventory -- its fields are in the struct registry instead.
892        if let Some(toml::Value::Table(layout_table)) = top_table.get("layout")
893            && let Some(layout_fields) = get_struct_fields("LayoutTheme")
894        {
895            for key in layout_table.keys() {
896                if !layout_fields.contains(&key.as_str()) {
897                    warnings.push(format!("unknown field: layout.{key}"));
898                }
899            }
900        }
901
902        Ok(warnings)
903    }
904}
905
906#[cfg(test)]
907#[allow(clippy::unwrap_used, clippy::expect_used)]
908mod tests {
909    use super::*;
910    use crate::Rgba;
911
912    // === ThemeMode tests ===
913
914    #[test]
915    fn theme_variant_default_is_empty() {
916        assert!(ThemeMode::default().is_empty());
917    }
918
919    #[test]
920    fn theme_variant_not_empty_when_color_set() {
921        let mut v = ThemeMode::default();
922        v.defaults.accent_color = Some(Rgba::rgb(0, 120, 215));
923        assert!(!v.is_empty());
924    }
925
926    #[test]
927    fn theme_variant_not_empty_when_font_set() {
928        let mut v = ThemeMode::default();
929        v.defaults.font.family = Some("Inter".into());
930        assert!(!v.is_empty());
931    }
932
933    #[test]
934    fn theme_variant_merge_recursively() {
935        let mut base = ThemeMode::default();
936        base.defaults.background_color = Some(Rgba::rgb(255, 255, 255));
937        base.defaults.font.family = Some("Noto Sans".into());
938
939        let mut overlay = ThemeMode::default();
940        overlay.defaults.accent_color = Some(Rgba::rgb(0, 120, 215));
941        overlay.defaults.border.corner_radius = Some(4.0);
942
943        base.merge(&overlay);
944
945        // base background preserved
946        assert_eq!(
947            base.defaults.background_color,
948            Some(Rgba::rgb(255, 255, 255))
949        );
950        // overlay accent applied
951        assert_eq!(base.defaults.accent_color, Some(Rgba::rgb(0, 120, 215)));
952        // base font preserved
953        assert_eq!(base.defaults.font.family.as_deref(), Some("Noto Sans"));
954        // overlay border applied
955        assert_eq!(base.defaults.border.corner_radius, Some(4.0));
956    }
957
958    #[test]
959    fn theme_variant_has_all_widgets() {
960        let mut v = ThemeMode::default();
961        // Set a field on each of the 25 widgets
962        v.window.background_color = Some(Rgba::rgb(255, 255, 255));
963        v.button.min_height = Some(32.0);
964        v.input.min_height = Some(32.0);
965        v.checkbox.indicator_width = Some(18.0);
966        v.menu.row_height = Some(28.0);
967        v.tooltip.max_width = Some(300.0);
968        v.scrollbar.groove_width = Some(14.0);
969        v.slider.track_height = Some(4.0);
970        v.progress_bar.track_height = Some(6.0);
971        v.tab.min_height = Some(32.0);
972        v.sidebar.background_color = Some(Rgba::rgb(240, 240, 240));
973        v.toolbar.bar_height = Some(40.0);
974        v.status_bar.background_color = Some(Rgba::rgb(240, 240, 240));
975        v.list.row_height = Some(28.0);
976        v.popover.background_color = Some(Rgba::rgb(255, 255, 255));
977        v.splitter.divider_width = Some(4.0);
978        v.separator.line_color = Some(Rgba::rgb(200, 200, 200));
979        v.switch.track_width = Some(32.0);
980        v.dialog.min_width = Some(320.0);
981        v.spinner.diameter = Some(24.0);
982        v.combo_box.min_height = Some(32.0);
983        v.segmented_control.segment_height = Some(28.0);
984        v.card.background_color = Some(Rgba::rgb(255, 255, 255));
985        v.expander.header_height = Some(32.0);
986        v.link.underline_enabled = Some(true);
987
988        assert!(!v.is_empty());
989        assert!(!v.window.is_empty());
990        assert!(!v.button.is_empty());
991        assert!(!v.input.is_empty());
992        assert!(!v.checkbox.is_empty());
993        assert!(!v.menu.is_empty());
994        assert!(!v.tooltip.is_empty());
995        assert!(!v.scrollbar.is_empty());
996        assert!(!v.slider.is_empty());
997        assert!(!v.progress_bar.is_empty());
998        assert!(!v.tab.is_empty());
999        assert!(!v.sidebar.is_empty());
1000        assert!(!v.toolbar.is_empty());
1001        assert!(!v.status_bar.is_empty());
1002        assert!(!v.list.is_empty());
1003        assert!(!v.popover.is_empty());
1004        assert!(!v.splitter.is_empty());
1005        assert!(!v.separator.is_empty());
1006        assert!(!v.switch.is_empty());
1007        assert!(!v.dialog.is_empty());
1008        assert!(!v.spinner.is_empty());
1009        assert!(!v.combo_box.is_empty());
1010        assert!(!v.segmented_control.is_empty());
1011        assert!(!v.card.is_empty());
1012        assert!(!v.expander.is_empty());
1013        assert!(!v.link.is_empty());
1014    }
1015
1016    #[test]
1017    fn theme_variant_merge_per_widget() {
1018        let mut base = ThemeMode::default();
1019        base.button.background_color = Some(Rgba::rgb(200, 200, 200));
1020        base.button.min_height = Some(28.0);
1021        base.tooltip.background_color = Some(Rgba::rgb(50, 50, 50));
1022
1023        let mut overlay = ThemeMode::default();
1024        overlay.button.background_color = Some(Rgba::rgb(255, 255, 255));
1025        overlay.button.min_width = Some(64.0);
1026
1027        base.merge(&overlay);
1028
1029        // overlay background wins
1030        assert_eq!(base.button.background_color, Some(Rgba::rgb(255, 255, 255)));
1031        // overlay min_width added
1032        assert_eq!(base.button.min_width, Some(64.0));
1033        // base min_height preserved
1034        assert_eq!(base.button.min_height, Some(28.0));
1035        // tooltip from base preserved
1036        assert_eq!(base.tooltip.background_color, Some(Rgba::rgb(50, 50, 50)));
1037    }
1038
1039    // === Theme tests ===
1040
1041    #[test]
1042    fn native_theme_default_is_empty() {
1043        let theme = Theme::default();
1044        assert!(theme.is_empty());
1045        assert_eq!(theme.name, "");
1046    }
1047
1048    #[test]
1049    fn native_theme_merge_keeps_base_name() {
1050        let mut base = Theme {
1051            name: "Base Theme".into(),
1052            ..Theme::default()
1053        };
1054        let overlay = Theme {
1055            name: "Overlay Theme".into(),
1056            ..Theme::default()
1057        };
1058        base.merge(&overlay);
1059        assert_eq!(base.name, "Base Theme");
1060    }
1061
1062    #[test]
1063    fn native_theme_merge_overlay_light_into_none() {
1064        let mut base = Theme {
1065            name: "Theme".into(),
1066            ..Theme::default()
1067        };
1068
1069        let mut overlay = Theme {
1070            name: "Overlay".into(),
1071            ..Theme::default()
1072        };
1073        let mut light = ThemeMode::default();
1074        light.defaults.accent_color = Some(Rgba::rgb(0, 120, 215));
1075        overlay.light = Some(light);
1076
1077        base.merge(&overlay);
1078
1079        assert!(base.light.is_some());
1080        assert_eq!(
1081            base.light.as_ref().unwrap().defaults.accent_color,
1082            Some(Rgba::rgb(0, 120, 215))
1083        );
1084    }
1085
1086    #[test]
1087    fn native_theme_merge_both_light_variants() {
1088        let mut base = Theme {
1089            name: "Theme".into(),
1090            ..Theme::default()
1091        };
1092        let mut base_light = ThemeMode::default();
1093        base_light.defaults.background_color = Some(Rgba::rgb(255, 255, 255));
1094        base.light = Some(base_light);
1095
1096        let mut overlay = Theme {
1097            name: "Overlay".into(),
1098            ..Theme::default()
1099        };
1100        let mut overlay_light = ThemeMode::default();
1101        overlay_light.defaults.accent_color = Some(Rgba::rgb(0, 120, 215));
1102        overlay.light = Some(overlay_light);
1103
1104        base.merge(&overlay);
1105
1106        let light = base.light.as_ref().unwrap();
1107        // base background preserved
1108        assert_eq!(
1109            light.defaults.background_color,
1110            Some(Rgba::rgb(255, 255, 255))
1111        );
1112        // overlay accent merged in
1113        assert_eq!(light.defaults.accent_color, Some(Rgba::rgb(0, 120, 215)));
1114    }
1115
1116    #[test]
1117    fn native_theme_merge_base_light_only_preserved() {
1118        let mut base = Theme {
1119            name: "Theme".into(),
1120            ..Theme::default()
1121        };
1122        let mut base_light = ThemeMode::default();
1123        base_light.defaults.font.family = Some("Inter".into());
1124        base.light = Some(base_light);
1125
1126        let overlay = Theme {
1127            name: "Overlay".into(),
1128            ..Theme::default()
1129        }; // no light
1130
1131        base.merge(&overlay);
1132
1133        assert!(base.light.is_some());
1134        assert_eq!(
1135            base.light.as_ref().unwrap().defaults.font.family.as_deref(),
1136            Some("Inter")
1137        );
1138    }
1139
1140    #[test]
1141    fn native_theme_merge_dark_variant() {
1142        let mut base = Theme {
1143            name: "Theme".into(),
1144            ..Theme::default()
1145        };
1146
1147        let mut overlay = Theme {
1148            name: "Overlay".into(),
1149            ..Theme::default()
1150        };
1151        let mut dark = ThemeMode::default();
1152        dark.defaults.background_color = Some(Rgba::rgb(30, 30, 30));
1153        overlay.dark = Some(dark);
1154
1155        base.merge(&overlay);
1156
1157        assert!(base.dark.is_some());
1158        assert_eq!(
1159            base.dark.as_ref().unwrap().defaults.background_color,
1160            Some(Rgba::rgb(30, 30, 30))
1161        );
1162    }
1163
1164    #[test]
1165    fn native_theme_not_empty_with_light() {
1166        let mut theme = Theme {
1167            name: "Theme".into(),
1168            ..Theme::default()
1169        };
1170        theme.light = Some(ThemeMode::default());
1171        assert!(!theme.is_empty());
1172    }
1173
1174    // === pick_variant tests ===
1175
1176    #[test]
1177    fn pick_variant_dark_with_both_variants_returns_dark() {
1178        let mut theme = Theme {
1179            name: "Test".into(),
1180            ..Theme::default()
1181        };
1182        let mut light = ThemeMode::default();
1183        light.defaults.background_color = Some(Rgba::rgb(255, 255, 255));
1184        theme.light = Some(light);
1185        let mut dark = ThemeMode::default();
1186        dark.defaults.background_color = Some(Rgba::rgb(30, 30, 30));
1187        theme.dark = Some(dark);
1188
1189        let picked = theme.pick_variant(ColorMode::Dark).unwrap();
1190        assert_eq!(
1191            picked.defaults.background_color,
1192            Some(Rgba::rgb(30, 30, 30))
1193        );
1194    }
1195
1196    #[test]
1197    fn pick_variant_light_with_both_variants_returns_light() {
1198        let mut theme = Theme {
1199            name: "Test".into(),
1200            ..Theme::default()
1201        };
1202        let mut light = ThemeMode::default();
1203        light.defaults.background_color = Some(Rgba::rgb(255, 255, 255));
1204        theme.light = Some(light);
1205        let mut dark = ThemeMode::default();
1206        dark.defaults.background_color = Some(Rgba::rgb(30, 30, 30));
1207        theme.dark = Some(dark);
1208
1209        let picked = theme.pick_variant(ColorMode::Light).unwrap();
1210        assert_eq!(
1211            picked.defaults.background_color,
1212            Some(Rgba::rgb(255, 255, 255))
1213        );
1214    }
1215
1216    #[test]
1217    fn pick_variant_dark_with_only_light_falls_back() {
1218        let mut theme = Theme {
1219            name: "Test".into(),
1220            ..Theme::default()
1221        };
1222        let mut light = ThemeMode::default();
1223        light.defaults.background_color = Some(Rgba::rgb(255, 255, 255));
1224        theme.light = Some(light);
1225
1226        let picked = theme.pick_variant(ColorMode::Dark).unwrap();
1227        assert_eq!(
1228            picked.defaults.background_color,
1229            Some(Rgba::rgb(255, 255, 255))
1230        );
1231    }
1232
1233    #[test]
1234    fn pick_variant_light_with_only_dark_falls_back() {
1235        let mut theme = Theme {
1236            name: "Test".into(),
1237            ..Theme::default()
1238        };
1239        let mut dark = ThemeMode::default();
1240        dark.defaults.background_color = Some(Rgba::rgb(30, 30, 30));
1241        theme.dark = Some(dark);
1242
1243        let picked = theme.pick_variant(ColorMode::Light).unwrap();
1244        assert_eq!(
1245            picked.defaults.background_color,
1246            Some(Rgba::rgb(30, 30, 30))
1247        );
1248    }
1249
1250    #[test]
1251    fn pick_variant_with_no_variants_returns_err() {
1252        let theme = Theme {
1253            name: "Empty".into(),
1254            ..Theme::default()
1255        };
1256        assert!(theme.pick_variant(ColorMode::Dark).is_err());
1257        assert!(theme.pick_variant(ColorMode::Light).is_err());
1258    }
1259
1260    // === icon_set tests (on Theme, shared across variants) ===
1261
1262    #[test]
1263    fn icon_set_default_is_none() {
1264        assert!(Theme::default().icon_set.is_none());
1265    }
1266
1267    #[test]
1268    fn icon_set_merge_overlay() {
1269        let mut base = Theme {
1270            name: "Base".into(),
1271            ..Theme::default()
1272        };
1273        let mut overlay = Theme {
1274            name: "Overlay".into(),
1275            ..Theme::default()
1276        };
1277        overlay.icon_set = Some(IconSet::Material);
1278        base.merge(&overlay);
1279        assert_eq!(base.icon_set, Some(IconSet::Material));
1280    }
1281
1282    #[test]
1283    fn icon_set_merge_none_preserves() {
1284        let mut base = Theme {
1285            name: "Base".into(),
1286            ..Theme::default()
1287        };
1288        base.icon_set = Some(IconSet::SfSymbols);
1289        let overlay = Theme {
1290            name: "Overlay".into(),
1291            ..Theme::default()
1292        };
1293        base.merge(&overlay);
1294        assert_eq!(base.icon_set, Some(IconSet::SfSymbols));
1295    }
1296
1297    #[test]
1298    fn icon_set_is_empty_when_set() {
1299        assert!(Theme::default().is_empty());
1300        let mut t = Theme {
1301            name: "Test".into(),
1302            ..Theme::default()
1303        };
1304        t.icon_set = Some(IconSet::Material);
1305        assert!(!t.is_empty());
1306    }
1307
1308    #[test]
1309    fn icon_set_toml_round_trip() {
1310        let mut theme = Theme {
1311            name: "Test".into(),
1312            ..Theme::default()
1313        };
1314        theme.icon_set = Some(IconSet::Material);
1315        let mut light = ThemeMode::default();
1316        light.defaults.icon_theme = Some("material".into());
1317        theme.light = Some(light);
1318        let toml_str = theme.to_toml().unwrap();
1319        assert!(toml_str.contains("icon_set"));
1320        let deserialized = Theme::from_toml(&toml_str).unwrap();
1321        assert_eq!(deserialized.icon_set, Some(IconSet::Material));
1322        assert_eq!(
1323            deserialized
1324                .light
1325                .as_ref()
1326                .unwrap()
1327                .defaults
1328                .icon_theme
1329                .as_deref(),
1330            Some("material")
1331        );
1332    }
1333
1334    #[test]
1335    fn icon_set_toml_absent_deserializes_to_none() {
1336        let toml_str = r##"
1337name = "Bare"
1338[light.defaults]
1339accent_color = "#ff0000"
1340"##;
1341        let theme = Theme::from_toml(toml_str).unwrap();
1342        assert!(theme.icon_set.is_none());
1343        assert!(theme.light.as_ref().unwrap().defaults.icon_theme.is_none());
1344    }
1345
1346    #[test]
1347    fn native_theme_serde_toml_round_trip() {
1348        // Load a preset, serialize to TOML, deserialize back, and verify equality
1349        let theme = Theme::preset("material").expect("material preset should load");
1350        let toml_str = theme.to_toml().expect("should serialize");
1351        let theme2 = Theme::from_toml(&toml_str).expect("should deserialize");
1352        assert_eq!(theme, theme2, "round-trip should preserve Theme");
1353    }
1354
1355    // === Theme::resolve() tests ===
1356
1357    #[test]
1358    fn resolve_material_uses_theme_level_icon_theme() {
1359        // material.toml has `icon_theme = "material"` at the root (tier 2).
1360        // The resolver must fill `icon_theme = "material"` for both modes.
1361        let theme = Theme::preset("material").unwrap();
1362        for mode in [ColorMode::Light, ColorMode::Dark] {
1363            let r = theme.resolve(mode).unwrap();
1364            assert_eq!(
1365                r.icon_theme.as_deref(),
1366                Some("material"),
1367                "material (tier 2) for {mode:?}"
1368            );
1369            assert!(
1370                r.icon_theme_explicit,
1371                "explicit flag must be true for {mode:?}"
1372            );
1373            assert_eq!(r.icon_set, IconSet::Material);
1374        }
1375    }
1376
1377    #[test]
1378    fn resolve_kde_breeze_uses_per_variant_icon_theme() {
1379        // kde-breeze.toml has `icon_theme` under each [light.defaults] /
1380        // [dark.defaults] (tier 1). Light → "breeze", Dark → "breeze-dark".
1381        let theme = Theme::preset("kde-breeze").unwrap();
1382
1383        let light = theme.resolve(ColorMode::Light).unwrap();
1384        assert_eq!(light.icon_theme.as_deref(), Some("breeze"));
1385        assert!(light.icon_theme_explicit);
1386
1387        let dark = theme.resolve(ColorMode::Dark).unwrap();
1388        assert_eq!(dark.icon_theme.as_deref(), Some("breeze-dark"));
1389        assert!(dark.icon_theme_explicit);
1390
1391        assert_eq!(light.icon_set, IconSet::Freedesktop);
1392        assert_eq!(dark.icon_set, IconSet::Freedesktop);
1393    }
1394
1395    /// adwaita with no icon theme stated anywhere, so tier 3 decides.
1396    fn adwaita_without_icon_theme() -> Theme {
1397        let mut theme = Theme::preset("adwaita").unwrap();
1398        theme.icon_theme = None;
1399        if let Some(v) = theme.light.as_mut() {
1400            v.defaults.icon_theme = None;
1401        }
1402        if let Some(v) = theme.dark.as_mut() {
1403            v.defaults.icon_theme = None;
1404        }
1405        theme
1406    }
1407
1408    #[test]
1409    fn resolve_minimal_theme_takes_the_detected_icon_theme() {
1410        // No icon_theme anywhere → tier 3, the context's detected theme.
1411        let ctx = crate::resolve::ResolutionContext::with_detected_icon_theme(Ok(
1412            "detected-theme".to_string()
1413        ));
1414        let r = adwaita_without_icon_theme()
1415            .resolve_in(ColorMode::Light, &ctx)
1416            .unwrap();
1417        assert_eq!(r.icon_theme.as_deref(), Some("detected-theme"));
1418        assert!(
1419            !r.icon_theme_explicit,
1420            "tier 3 fallback must report explicit = false"
1421        );
1422    }
1423
1424    #[test]
1425    fn resolve_minimal_theme_without_detection_has_no_icon_theme() {
1426        // No icon_theme anywhere and detection failed → None, and the
1427        // resolution itself still succeeds.
1428        let ctx = crate::resolve::ResolutionContext::with_detected_icon_theme(Err(
1429            crate::Error::PlatformUnsupported {
1430                platform: "the test's failing detection",
1431            },
1432        ));
1433        let r = adwaita_without_icon_theme()
1434            .resolve_in(ColorMode::Light, &ctx)
1435            .expect("a failed icon-theme detection must not fail resolution");
1436        assert_eq!(r.icon_theme, None);
1437        assert!(!r.icon_theme_explicit);
1438    }
1439
1440    #[test]
1441    fn resolve_minimal_theme_resolves_whatever_detection_gives() {
1442        // Through `resolve` itself: detection's outcome is host-dependent,
1443        // but the resolution succeeds and reports tier 3 either way.
1444        let r = adwaita_without_icon_theme()
1445            .resolve(ColorMode::Light)
1446            .unwrap();
1447        assert!(!r.icon_theme_explicit);
1448        assert_eq!(
1449            r.icon_theme.as_deref(),
1450            crate::model::icons::system_icon_theme().ok().as_deref()
1451        );
1452    }
1453
1454    #[test]
1455    fn resolve_tier1_wins_over_tier2() {
1456        // When BOTH Theme::icon_theme (tier 2) and variant.defaults.icon_theme
1457        // (tier 1) are set, tier 1 wins.
1458        let mut theme = Theme::preset("adwaita").unwrap();
1459        theme.icon_theme = Some(Cow::Borrowed("theme-level"));
1460        theme.light.as_mut().unwrap().defaults.icon_theme = Some(Cow::Borrowed("per-variant"));
1461        let r = theme.resolve(ColorMode::Light).unwrap();
1462        assert_eq!(r.icon_theme.as_deref(), Some("per-variant"));
1463        assert!(r.icon_theme_explicit);
1464    }
1465
1466    #[test]
1467    fn resolve_cross_mode_fallback_picks_available_variant() {
1468        // Theme with only a light variant, asked for dark → pick_variant
1469        // cross-falls-back to light and reports light's icon_theme.
1470        let mut theme = Theme::preset("adwaita").unwrap();
1471        theme.dark = None;
1472        theme.icon_theme = None;
1473        theme.light.as_mut().unwrap().defaults.icon_theme = Some(Cow::Borrowed("only-one"));
1474        let r = theme.resolve(ColorMode::Dark).unwrap();
1475        assert_eq!(r.icon_theme.as_deref(), Some("only-one"));
1476        assert!(r.icon_theme_explicit);
1477    }
1478
1479    #[test]
1480    fn resolve_empty_theme_returns_no_variant_error() {
1481        let theme = Theme {
1482            name: "Empty".into(),
1483            ..Theme::default()
1484        };
1485        assert!(matches!(
1486            theme.resolve(ColorMode::Light),
1487            Err(crate::Error::NoVariant {
1488                mode: ColorMode::Light
1489            })
1490        ));
1491    }
1492
1493    #[test]
1494    fn resolve_all_presets_succeed() {
1495        // Every bundled preset must resolve in both modes.
1496        for info in Theme::list_presets() {
1497            let theme = Theme::preset(info.key).unwrap();
1498            for mode in [ColorMode::Light, ColorMode::Dark] {
1499                theme.resolve(mode).unwrap_or_else(|e| {
1500                    panic!("preset '{}' failed to resolve({mode:?}): {e}", info.key)
1501                });
1502            }
1503        }
1504    }
1505
1506    // === lint_toml tests ===
1507
1508    #[test]
1509    fn lint_toml_valid_returns_empty() {
1510        let toml = r##"
1511name = "Valid Theme"
1512[light.defaults]
1513accent_color = "#ff0000"
1514background_color = "#ffffff"
1515[light.defaults.font]
1516family = "Inter"
1517size_px = 14.0
1518[light.button]
1519min_height_px = 32.0
1520"##;
1521        let warnings = Theme::lint_toml(toml).unwrap();
1522        assert!(
1523            warnings.is_empty(),
1524            "Expected no warnings, got: {warnings:?}"
1525        );
1526    }
1527
1528    #[test]
1529    fn lint_toml_detects_unknown_top_level() {
1530        let toml = r##"
1531name = "Test"
1532theme_version = 2
1533"##;
1534        let warnings = Theme::lint_toml(toml).unwrap();
1535        assert_eq!(warnings.len(), 1);
1536        assert!(warnings[0].contains("theme_version"));
1537    }
1538
1539    #[test]
1540    fn lint_toml_detects_misspelled_defaults_field() {
1541        let toml = r##"
1542name = "Test"
1543[light.defaults]
1544backround = "#ffffff"
1545"##;
1546        let warnings = Theme::lint_toml(toml).unwrap();
1547        assert_eq!(warnings.len(), 1);
1548        assert!(warnings[0].contains("backround"));
1549        assert!(warnings[0].contains("light.defaults.backround"));
1550    }
1551
1552    #[test]
1553    fn lint_toml_detects_unknown_widget_field() {
1554        let toml = r##"
1555name = "Test"
1556[dark.button]
1557primary_bg = "#0078d7"
1558"##;
1559        let warnings = Theme::lint_toml(toml).unwrap();
1560        assert_eq!(warnings.len(), 1);
1561        assert!(warnings[0].contains("primary_bg"));
1562    }
1563
1564    #[test]
1565    fn lint_toml_detects_unknown_variant_section() {
1566        let toml = r##"
1567name = "Test"
1568[light.badges]
1569color = "#ff0000"
1570"##;
1571        let warnings = Theme::lint_toml(toml).unwrap();
1572        assert_eq!(warnings.len(), 1);
1573        assert!(warnings[0].contains("badges"));
1574    }
1575
1576    #[test]
1577    fn lint_toml_detects_unknown_font_subfield() {
1578        let toml = r##"
1579name = "Test"
1580[light.defaults.font]
1581famly = "Inter"
1582"##;
1583        let warnings = Theme::lint_toml(toml).unwrap();
1584        assert_eq!(warnings.len(), 1);
1585        assert!(warnings[0].contains("famly"));
1586    }
1587
1588    #[test]
1589    fn lint_toml_detects_unknown_border_subfield() {
1590        let toml = r##"
1591name = "Test"
1592[light.defaults.border]
1593radiusss = 4.0
1594"##;
1595        let warnings = Theme::lint_toml(toml).unwrap();
1596        assert_eq!(warnings.len(), 1);
1597        assert!(warnings[0].contains("radiusss"));
1598    }
1599
1600    #[test]
1601    fn lint_toml_detects_unknown_text_scale_entry() {
1602        let toml = r##"
1603name = "Test"
1604[light.text_scale.headline]
1605size = 24.0
1606"##;
1607        let warnings = Theme::lint_toml(toml).unwrap();
1608        assert_eq!(warnings.len(), 1);
1609        assert!(warnings[0].contains("headline"));
1610    }
1611
1612    #[test]
1613    fn lint_toml_detects_unknown_text_scale_entry_field() {
1614        let toml = r##"
1615name = "Test"
1616[light.text_scale.caption]
1617font_size = 12.0
1618"##;
1619        let warnings = Theme::lint_toml(toml).unwrap();
1620        assert_eq!(warnings.len(), 1);
1621        assert!(warnings[0].contains("font_size"));
1622    }
1623
1624    #[test]
1625    fn lint_toml_multiple_errors() {
1626        let toml = r##"
1627name = "Test"
1628author = "Me"
1629[light.defaults]
1630backround = "#ffffff"
1631[light.button]
1632primay_bg = "#0078d7"
1633"##;
1634        let warnings = Theme::lint_toml(toml).unwrap();
1635        assert_eq!(warnings.len(), 3);
1636    }
1637
1638    #[test]
1639    fn lint_toml_invalid_toml_returns_error() {
1640        let result = Theme::lint_toml("{{{{invalid");
1641        assert!(result.is_err());
1642    }
1643
1644    #[test]
1645    fn lint_toml_preset_has_no_warnings() {
1646        // Spot-check one preset for lint cleanliness via round-trip
1647        let theme = Theme::preset("material").expect("material preset should load");
1648        let toml_str = theme.to_toml().expect("should serialize");
1649        let warnings = Theme::lint_toml(&toml_str).expect("should parse");
1650        assert!(
1651            warnings.is_empty(),
1652            "material preset should have no lint warnings, got: {warnings:?}"
1653        );
1654    }
1655
1656    #[test]
1657    fn lint_toml_all_presets_clean() {
1658        for info in Theme::list_presets() {
1659            let name = info.key;
1660            // Load the raw TOML source for each preset via include_str
1661            // by loading via preset() + to_toml() round-trip
1662            let theme = Theme::preset(name).unwrap_or_else(|e| {
1663                panic!("preset {name} should load: {e}");
1664            });
1665            let toml_str = theme.to_toml().unwrap_or_else(|e| {
1666                panic!("preset {name} should serialize: {e}");
1667            });
1668            let warnings = Theme::lint_toml(&toml_str).unwrap_or_else(|e| {
1669                panic!("preset {name} should lint: {e}");
1670            });
1671            assert!(
1672                warnings.is_empty(),
1673                "preset {name} should have no lint warnings, got: {warnings:?}"
1674            );
1675        }
1676    }
1677
1678    #[test]
1679    fn lint_toml_accepts_padding_sides_and_shorthand() {
1680        let toml = r##"
1681name = "Test"
1682[light.button.border]
1683padding_top_px = 1.0
1684padding_right_px = 2.0
1685padding_bottom_px = 3.0
1686padding_left_px = 4.0
1687[light.input.border]
1688padding_horizontal_px = 6.0
1689padding_vertical_px = 0.0
1690"##;
1691        let warnings = Theme::lint_toml(toml).unwrap();
1692        assert!(warnings.is_empty(), "got: {warnings:?}");
1693        let warnings =
1694            Theme::lint_toml("name = \"T\"\n[light.input.border]\npadding_sideways_px = 1.0\n")
1695                .unwrap();
1696        assert_eq!(warnings.len(), 1, "got: {warnings:?}");
1697    }
1698
1699    #[test]
1700    fn lint_toml_rejects_unknown_field_on_registered_widget() {
1701        // Verify that lint_toml discovers widget field names from inventory.
1702        // If a field name is not in the registered FIELD_NAMES for a widget,
1703        // lint_toml should report it as unknown.
1704        let toml = r##"
1705name = "Test"
1706[light.button]
1707nonexistent_field = "#ff0000"
1708"##;
1709        let warnings = Theme::lint_toml(toml).unwrap();
1710        assert_eq!(warnings.len(), 1);
1711        assert!(warnings[0].contains("nonexistent_field"));
1712        assert!(warnings[0].contains("light.button"));
1713    }
1714
1715    #[test]
1716    fn lint_toml_recognizes_all_registered_widgets() {
1717        // Every widget registered via inventory::submit! should be accepted
1718        // as a valid variant-level section key.
1719        for entry in inventory::iter::<crate::resolve::WidgetFieldInfo> {
1720            let toml_str = format!("name = \"Test\"\n[light.{}]\n", entry.widget_name,);
1721            let warnings = Theme::lint_toml(&toml_str).unwrap();
1722            assert!(
1723                warnings.is_empty(),
1724                "widget '{}' should be recognized, got: {:?}",
1725                entry.widget_name,
1726                warnings,
1727            );
1728        }
1729    }
1730
1731    // === Theme layout integration tests ===
1732
1733    #[test]
1734    fn theme_spec_layout_merge() {
1735        let mut base = Theme {
1736            name: "Base".into(),
1737            ..Theme::default()
1738        };
1739        base.layout.widget_gap = Some(6.0);
1740
1741        let mut overlay = Theme {
1742            name: "Overlay".into(),
1743            ..Theme::default()
1744        };
1745        overlay.layout.container_margin = Some(8.0);
1746
1747        base.merge(&overlay);
1748        assert_eq!(base.layout.widget_gap, Some(6.0));
1749        assert_eq!(base.layout.container_margin, Some(8.0));
1750    }
1751
1752    #[test]
1753    fn theme_spec_layout_toml_round_trip() {
1754        let mut theme = Theme {
1755            name: "Layout Test".into(),
1756            ..Theme::default()
1757        };
1758        theme.layout.widget_gap = Some(8.0);
1759        theme.layout.container_margin = Some(12.0);
1760        theme.layout.window_margin = Some(16.0);
1761        theme.layout.section_gap = Some(24.0);
1762
1763        let toml_str = theme.to_toml().unwrap();
1764        let theme2 = Theme::from_toml(&toml_str).unwrap();
1765        assert_eq!(theme.layout, theme2.layout);
1766    }
1767
1768    #[test]
1769    fn theme_spec_is_empty_with_layout() {
1770        let mut theme = Theme {
1771            name: "Layout Only".into(),
1772            ..Theme::default()
1773        };
1774        assert!(theme.is_empty()); // name doesn't count
1775        theme.layout.widget_gap = Some(8.0);
1776        assert!(!theme.is_empty());
1777    }
1778
1779    #[test]
1780    fn theme_spec_layout_top_level_toml() {
1781        let mut theme = Theme {
1782            name: "Top Level".into(),
1783            ..Theme::default()
1784        };
1785        theme.layout.widget_gap = Some(8.0);
1786
1787        let toml_str = theme.to_toml().unwrap();
1788        // [layout] must be at top level, not under [light.layout] or [dark.layout]
1789        assert!(
1790            toml_str.contains("[layout]"),
1791            "TOML should have [layout] section"
1792        );
1793        assert!(!toml_str.contains("[light.layout]"));
1794        assert!(!toml_str.contains("[dark.layout]"));
1795    }
1796
1797    // === Phase 93-05 G5: ThemeFields inventory baseline-equality tests ===
1798    //
1799    // Each struct that is expected to register via #[derive(ThemeFields)]
1800    // must produce a FieldInfo entry whose `field_names` matches the
1801    // pre-migration hand-authored FIELD_NAMES list bit-for-bit. Failure means
1802    // either the derive emission path is wrong (serde rename drift) or
1803    // the derive was not applied to the struct.
1804
1805    fn field_info_entry(name: &'static str) -> Option<&'static [&'static str]> {
1806        inventory::iter::<crate::resolve::FieldInfo>()
1807            .find(|info| info.struct_name == name)
1808            .map(|info| info.field_names)
1809    }
1810
1811    #[test]
1812    fn font_spec_field_info_matches_baseline() {
1813        let baseline: &[&str] = &["family", "size_pt", "size_px", "weight", "style", "color"];
1814        assert_eq!(field_info_entry("FontSpec"), Some(baseline));
1815    }
1816
1817    #[test]
1818    fn text_scale_entry_field_info_matches_baseline() {
1819        let baseline: &[&str] = &[
1820            "size_pt",
1821            "size_px",
1822            "weight",
1823            "line_height_pt",
1824            "line_height_px",
1825        ];
1826        assert_eq!(field_info_entry("TextScaleEntry"), Some(baseline));
1827    }
1828
1829    #[test]
1830    fn text_scale_field_info_matches_baseline() {
1831        let baseline: &[&str] = &["caption", "section_heading", "dialog_title", "display"];
1832        assert_eq!(field_info_entry("TextScale"), Some(baseline));
1833    }
1834
1835    #[test]
1836    fn defaults_border_spec_field_info_matches_baseline() {
1837        let baseline: &[&str] = &[
1838            "color",
1839            "corner_radius_px",
1840            "corner_radius_lg_px",
1841            "line_width_px",
1842            "opacity",
1843            "shadow_enabled",
1844        ];
1845        assert_eq!(field_info_entry("DefaultsBorderSpec"), Some(baseline));
1846    }
1847
1848    #[test]
1849    fn widget_border_spec_field_info_matches_baseline() {
1850        let baseline: &[&str] = &[
1851            "color",
1852            "corner_radius_px",
1853            "line_width_px",
1854            "shadow_enabled",
1855            "padding_top_px",
1856            "padding_right_px",
1857            "padding_bottom_px",
1858            "padding_left_px",
1859            "padding_horizontal_px",
1860            "padding_vertical_px",
1861        ];
1862        assert_eq!(field_info_entry("WidgetBorderSpec"), Some(baseline));
1863    }
1864
1865    #[test]
1866    fn theme_defaults_field_info_matches_baseline() {
1867        let baseline: &[&str] = &[
1868            "font",
1869            "line_height",
1870            "mono_font",
1871            "background_color",
1872            "text_color",
1873            "accent_color",
1874            "accent_text_color",
1875            "surface_color",
1876            "muted_color",
1877            "shadow_color",
1878            "link_color",
1879            "selection_background",
1880            "selection_text_color",
1881            "selection_inactive_background",
1882            "text_selection_background",
1883            "text_selection_color",
1884            "disabled_text_color",
1885            "danger_color",
1886            "danger_text_color",
1887            "warning_color",
1888            "warning_text_color",
1889            "success_color",
1890            "success_text_color",
1891            "info_color",
1892            "info_text_color",
1893            "border",
1894            "disabled_opacity",
1895            "focus_ring_color",
1896            "focus_ring_width_px",
1897            "focus_ring_offset_px",
1898            "icon_sizes",
1899            "icon_theme",
1900        ];
1901        assert_eq!(field_info_entry("ThemeDefaults"), Some(baseline));
1902    }
1903
1904    #[test]
1905    fn icon_sizes_field_info_matches_baseline() {
1906        let baseline: &[&str] = &[
1907            "toolbar_px",
1908            "small_px",
1909            "large_px",
1910            "dialog_px",
1911            "panel_px",
1912        ];
1913        assert_eq!(field_info_entry("IconSizes"), Some(baseline));
1914    }
1915
1916    #[test]
1917    fn layout_theme_field_info_matches_baseline() {
1918        let baseline: &[&str] = &[
1919            "widget_gap_px",
1920            "container_margin_px",
1921            "window_margin_px",
1922            "section_gap_px",
1923        ];
1924        assert_eq!(field_info_entry("LayoutTheme"), Some(baseline));
1925    }
1926
1927    #[test]
1928    fn widget_registry_still_has_all_widgets() {
1929        // Regression guard: the existing WidgetFieldInfo inventory must remain
1930        // populated after ThemeFields migration. LayoutTheme is NOT a widget
1931        // (it uses `skip_inventory`), so we expect at least the 25 per-variant
1932        // widgets registered through ThemeWidget.
1933        let widget_count = inventory::iter::<crate::resolve::WidgetFieldInfo>().count();
1934        assert!(
1935            widget_count >= 25,
1936            "expected >=25 widgets in WidgetFieldInfo, got {widget_count}"
1937        );
1938    }
1939}