Skip to main content

native_theme/
lib.rs

1//! # native-theme
2//!
3//! Cross-platform native theme detection and loading for Rust GUI applications.
4//!
5//! Any Rust GUI app can look native on any platform by loading a single theme
6//! file or reading live OS settings, without coupling to any specific toolkit.
7
8#![warn(missing_docs)]
9#![deny(unsafe_code)]
10#![deny(clippy::unwrap_used)]
11#![deny(clippy::expect_used)]
12
13#[doc = include_str!("../README.md")]
14#[cfg(doctest)]
15pub struct ReadmeDoctests;
16
17/// Generates `merge()` and `is_empty()` methods for theme structs.
18///
19/// Four field categories:
20/// - `option { field1, field2, ... }` -- `Option<T>` leaf fields
21/// - `soft_option { field1, field2, ... }` -- `Option<T>` leaf fields (same merge/is_empty as option)
22/// - `nested { field1, field2, ... }` -- nested struct fields with their own `merge()`
23/// - `optional_nested { field1, field2, ... }` -- `Option<T>` where T has its own `merge()`
24///
25/// For `option` and `soft_option` fields, `Some` values in the overlay replace the
26/// corresponding fields in self; `None` fields are left unchanged.
27/// For `nested` fields, merge is called recursively.
28/// For `optional_nested` fields: if both base and overlay are `Some`, the inner values
29/// are merged recursively. If base is `None` and overlay is `Some`, overlay is cloned.
30/// If overlay is `None`, the base field is preserved unchanged.
31///
32/// # Examples
33///
34/// ```ignore
35/// impl_merge!(MyColors {
36///     option { accent, background }
37/// });
38/// ```
39macro_rules! impl_merge {
40    (
41        $struct_name:ident {
42            $(option { $($opt_field:ident),* $(,)? })?
43            $(soft_option { $($so_field:ident),* $(,)? })?
44            $(nested { $($nest_field:ident),* $(,)? })?
45            $(optional_nested { $($on_field:ident),* $(,)? })*
46        }
47    ) => {
48        impl $struct_name {
49            /// Merge an overlay into this value. `Some` fields in the overlay
50            /// replace the corresponding fields in self; `None` fields are
51            /// left unchanged. Nested structs are merged recursively.
52            pub fn merge(&mut self, overlay: &Self) {
53                $($(
54                    if overlay.$opt_field.is_some() {
55                        self.$opt_field = overlay.$opt_field.clone();
56                    }
57                )*)?
58                $($(
59                    if overlay.$so_field.is_some() {
60                        self.$so_field = overlay.$so_field.clone();
61                    }
62                )*)?
63                $($(
64                    self.$nest_field.merge(&overlay.$nest_field);
65                )*)?
66                $($(
67                    match (&mut self.$on_field, &overlay.$on_field) {
68                        (Some(base), Some(over)) => base.merge(over),
69                        (None, Some(over)) => self.$on_field = Some(over.clone()),
70                        _ => {}
71                    }
72                )*)*
73            }
74
75            /// Returns true if all fields are at their default (None/empty) state.
76            pub fn is_empty(&self) -> bool {
77                true
78                $($(&& self.$opt_field.is_none())*)?
79                $($(&& self.$so_field.is_none())*)?
80                $($(&& self.$nest_field.is_empty())*)?
81                $($(&& self.$on_field.as_ref().map_or(true, |v| v.is_empty()))*)*
82            }
83        }
84    };
85}
86
87/// Color types and sRGB utilities.
88pub mod color;
89/// OS detection: dark mode, reduced motion, DPI, desktop environment.
90mod detect;
91/// Error types for theme operations.
92pub mod error;
93/// GNOME portal theme reader.
94#[cfg(all(target_os = "linux", feature = "portal"))]
95pub mod gnome;
96/// Icon loading and dispatch.
97mod icons;
98/// KDE theme reader.
99#[cfg(all(target_os = "linux", feature = "kde"))]
100pub mod kde;
101/// Theme data model types.
102pub mod model;
103/// Theme pipeline: reader -> preset merge -> resolve -> validate.
104mod pipeline;
105/// Bundled theme presets.
106pub mod presets;
107/// Theme resolution engine (inheritance + validation).
108mod resolve;
109#[cfg(any(
110    feature = "material-icons",
111    feature = "lucide-icons",
112    feature = "system-icons"
113))]
114mod spinners;
115/// Shared test infrastructure (ENV_MUTEX for env var serialization).
116#[cfg(test)]
117mod test_util;
118/// Runtime theme change watching.
119#[cfg(feature = "watch")]
120pub mod watch;
121
122pub use color::{ParseColorError, Rgba};
123pub use error::{Error, ThemeResolutionError};
124pub use model::{
125    AnimatedIcon, BorderSpec, ButtonTheme, CardTheme, CheckboxTheme, ComboBoxTheme,
126    DialogButtonOrder, DialogTheme, ExpanderTheme, FontSize, FontSpec, FontStyle, IconData,
127    IconProvider, IconRole, IconSet, IconSizes, InputTheme, LayoutTheme, LinkTheme, ListTheme,
128    MenuTheme, PopoverTheme, ProgressBarTheme, ResolvedBorderSpec, ResolvedFontSpec,
129    ResolvedIconSizes, ResolvedTextScale, ResolvedTextScaleEntry, ResolvedThemeDefaults,
130    ResolvedThemeVariant, ScrollbarTheme, SegmentedControlTheme, SeparatorTheme, SidebarTheme,
131    SliderTheme, SpinnerTheme, SplitterTheme, StatusBarTheme, SwitchTheme, TabTheme, TextScale,
132    TextScaleEntry, ThemeDefaults, ThemeSpec, ThemeVariant, ToolbarTheme, TooltipTheme,
133    TransformAnimation, WindowTheme, bundled_icon_by_name, bundled_icon_svg,
134};
135// icon helper functions re-exported from this module
136pub use model::icons::{detect_icon_theme, icon_name, system_icon_set, system_icon_theme};
137
138/// Freedesktop icon theme lookup (Linux).
139#[cfg(all(target_os = "linux", feature = "system-icons"))]
140pub mod freedesktop;
141/// macOS platform helpers.
142#[cfg(target_os = "macos")]
143pub mod macos;
144#[cfg(not(target_os = "macos"))]
145pub(crate) mod macos;
146/// SVG-to-RGBA rasterization utilities.
147#[cfg(feature = "svg-rasterize")]
148pub mod rasterize;
149/// SF Symbols icon loader (macOS).
150#[cfg(all(target_os = "macos", feature = "system-icons"))]
151pub mod sficons;
152/// Windows platform theme reader.
153#[cfg(target_os = "windows")]
154pub mod windows;
155#[cfg(not(target_os = "windows"))]
156#[allow(dead_code, unused_variables)]
157pub(crate) mod windows;
158/// Windows Segoe Fluent / stock icon loader.
159#[cfg(all(target_os = "windows", feature = "system-icons"))]
160pub mod winicons;
161#[cfg(all(not(target_os = "windows"), feature = "system-icons"))]
162#[allow(dead_code, unused_imports)]
163pub(crate) mod winicons;
164
165#[cfg(all(target_os = "linux", feature = "system-icons"))]
166pub use freedesktop::{load_freedesktop_icon, load_freedesktop_icon_by_name};
167#[cfg(all(target_os = "linux", feature = "portal"))]
168pub use gnome::from_gnome;
169#[cfg(all(target_os = "linux", feature = "portal", feature = "kde"))]
170pub use gnome::from_kde_with_portal;
171#[cfg(all(target_os = "linux", feature = "kde"))]
172pub use kde::from_kde;
173#[cfg(all(target_os = "macos", feature = "macos"))]
174pub use macos::from_macos;
175#[cfg(feature = "svg-rasterize")]
176pub use rasterize::rasterize_svg;
177#[cfg(all(target_os = "macos", feature = "system-icons"))]
178pub use sficons::load_sf_icon;
179#[cfg(all(target_os = "macos", feature = "system-icons"))]
180pub use sficons::load_sf_icon_by_name;
181#[cfg(all(target_os = "windows", feature = "windows"))]
182pub use windows::from_windows;
183#[cfg(all(target_os = "windows", feature = "system-icons"))]
184pub use winicons::load_windows_icon;
185#[cfg(all(target_os = "windows", feature = "system-icons"))]
186pub use winicons::load_windows_icon_by_name;
187
188#[cfg(feature = "watch")]
189pub use watch::{ThemeChangeEvent, ThemeWatcher, on_theme_change};
190
191#[cfg(target_os = "linux")]
192pub use detect::LinuxDesktop;
193#[cfg(target_os = "linux")]
194pub use detect::detect_linux_de;
195pub use detect::{
196    detect_is_dark, detect_reduced_motion, invalidate_caches, prefers_reduced_motion,
197    system_is_dark,
198};
199pub use icons::{
200    is_freedesktop_theme_available, load_custom_icon, load_icon, load_icon_from_theme,
201    load_system_icon_by_name, loading_indicator,
202};
203pub use pipeline::{diagnose_platform_support, platform_preset_name};
204
205/// Convenience Result type alias for this crate.
206pub type Result<T> = std::result::Result<T, Error>;
207
208/// Result of the OS-first pipeline. Holds both resolved variants.
209///
210/// Produced by [`SystemTheme::from_system()`] and [`SystemTheme::from_system_async()`].
211/// Both light and dark are always populated: the OS-active variant
212/// comes from the reader + preset + resolve, the inactive variant
213/// comes from the preset + resolve.
214#[derive(Clone, Debug)]
215pub struct SystemTheme {
216    /// Theme name (from reader or preset).
217    pub name: String,
218    /// Whether the OS is currently in dark mode.
219    pub is_dark: bool,
220    /// Resolved light variant (always populated).
221    pub light: ResolvedThemeVariant,
222    /// Resolved dark variant (always populated).
223    pub dark: ResolvedThemeVariant,
224    /// Pre-resolve light variant (retained for overlay support).
225    pub(crate) light_variant: ThemeVariant,
226    /// Pre-resolve dark variant (retained for overlay support).
227    pub(crate) dark_variant: ThemeVariant,
228    /// The platform preset used (e.g., "kde-breeze", "adwaita", "macos-sonoma").
229    pub preset: String,
230    /// The live preset name used internally (e.g., "kde-breeze-live").
231    pub(crate) live_preset: String,
232}
233
234impl SystemTheme {
235    /// Returns the OS-active resolved variant.
236    ///
237    /// If `is_dark` is true, returns `&self.dark`; otherwise `&self.light`.
238    #[must_use]
239    pub fn active(&self) -> &ResolvedThemeVariant {
240        if self.is_dark {
241            &self.dark
242        } else {
243            &self.light
244        }
245    }
246
247    /// Pick a resolved variant by explicit preference.
248    ///
249    /// `pick(true)` returns `&self.dark`, `pick(false)` returns `&self.light`.
250    #[must_use]
251    pub fn pick(&self, is_dark: bool) -> &ResolvedThemeVariant {
252        if is_dark { &self.dark } else { &self.light }
253    }
254
255    /// Apply an app-level TOML overlay and re-resolve.
256    ///
257    /// Merges the overlay onto the pre-resolve [`ThemeVariant`] (not the
258    /// already-resolved [`ResolvedThemeVariant`]) so that changed source fields
259    /// propagate correctly through `resolve()`. For example, changing
260    /// `defaults.accent_color` in the overlay will cause `button.primary_background`,
261    /// `checkbox.checked_background`, `slider.fill`, etc. to be re-derived from
262    /// the new accent color.
263    ///
264    /// # Examples
265    ///
266    /// ```no_run
267    /// let system = native_theme::SystemTheme::from_system().unwrap();
268    /// let overlay = native_theme::ThemeSpec::from_toml(r##"
269    ///     [light.defaults]
270    ///     accent_color = "#ff6600"
271    ///     [dark.defaults]
272    ///     accent_color = "#ff6600"
273    /// "##).unwrap();
274    /// let customized = system.with_overlay(&overlay).unwrap();
275    /// // customized.active().defaults.accent_color is now #ff6600
276    /// // and all accent-derived fields are updated
277    /// ```
278    #[must_use = "this returns a new theme with the overlay applied; it does not modify self"]
279    pub fn with_overlay(&self, overlay: &ThemeSpec) -> crate::Result<Self> {
280        // Start from pre-resolve variants (avoids double-resolve idempotency issue)
281        let mut light = self.light_variant.clone();
282        let mut dark = self.dark_variant.clone();
283
284        // Merge overlay onto pre-resolve variants (overlay values win)
285        if let Some(over) = &overlay.light {
286            light.merge(over);
287        }
288        if let Some(over) = &overlay.dark {
289            dark.merge(over);
290        }
291
292        // Resolve and validate both
293        let resolved_light = light.clone().into_resolved()?;
294        let resolved_dark = dark.clone().into_resolved()?;
295
296        Ok(SystemTheme {
297            name: self.name.clone(),
298            is_dark: self.is_dark,
299            light: resolved_light,
300            dark: resolved_dark,
301            light_variant: light,
302            dark_variant: dark,
303            live_preset: self.live_preset.clone(),
304            preset: self.preset.clone(),
305        })
306    }
307
308    /// Apply an app overlay from a TOML string.
309    ///
310    /// Parses the TOML as a [`ThemeSpec`] and calls [`with_overlay`](Self::with_overlay).
311    #[must_use = "this returns a new theme with the overlay applied; it does not modify self"]
312    pub fn with_overlay_toml(&self, toml: &str) -> crate::Result<Self> {
313        let overlay = ThemeSpec::from_toml(toml)?;
314        self.with_overlay(&overlay)
315    }
316
317    /// Load the OS theme synchronously.
318    ///
319    /// Detects the platform and desktop environment, reads the current theme
320    /// settings, merges with a platform preset, and returns a fully resolved
321    /// [`SystemTheme`] with both light and dark variants.
322    ///
323    /// The return value goes through the full pipeline: reader output ->
324    /// resolve -> validate -> [`SystemTheme`] with both light and dark
325    /// [`ResolvedThemeVariant`] variants.
326    ///
327    /// # Platform Behavior
328    ///
329    /// - **macOS:** Calls `from_macos()` when the `macos` feature is enabled.
330    ///   Reads both light and dark variants via NSAppearance, merges with
331    ///   `macos-sonoma` preset.
332    /// - **Linux (KDE):** Calls `from_kde()` when `XDG_CURRENT_DESKTOP` contains
333    ///   "KDE" and the `kde` feature is enabled, merges with `kde-breeze` preset.
334    /// - **Linux (other):** Uses the `adwaita` preset. For live GNOME portal
335    ///   data, use [`from_system_async()`](Self::from_system_async) (requires
336    ///   `portal-tokio` or `portal-async-io` feature).
337    /// - **Windows:** Calls `from_windows()` when the `windows` feature is enabled,
338    ///   merges with `windows-11` preset.
339    /// - **Other platforms:** Returns `Error::Unsupported`.
340    ///
341    /// # Errors
342    ///
343    /// - `Error::Unsupported` if the platform has no reader or the required feature
344    ///   is not enabled.
345    /// - `Error::Unavailable` if the platform reader cannot access theme data.
346    ///
347    /// # Examples
348    ///
349    /// ```no_run
350    /// let system = native_theme::SystemTheme::from_system().unwrap();
351    /// let active = system.active();
352    /// ```
353    #[must_use = "this returns the detected theme; it does not apply it"]
354    pub fn from_system() -> crate::Result<Self> {
355        pipeline::from_system_inner()
356    }
357
358    /// Async version of [`from_system()`](Self::from_system) that uses D-Bus
359    /// portal backend detection to improve desktop environment heuristics on
360    /// Linux.
361    ///
362    /// When `XDG_CURRENT_DESKTOP` is unset or unrecognized, queries the
363    /// D-Bus session bus for portal backend activatable names to determine
364    /// whether KDE or GNOME portal is running, then dispatches to the
365    /// appropriate reader.
366    ///
367    /// Returns a [`SystemTheme`] with both resolved light and dark variants,
368    /// same as [`from_system()`](Self::from_system).
369    ///
370    /// On non-Linux platforms, behaves identically to
371    /// [`from_system()`](Self::from_system).
372    #[cfg(target_os = "linux")]
373    #[must_use = "this returns the detected theme; it does not apply it"]
374    pub async fn from_system_async() -> crate::Result<Self> {
375        pipeline::from_system_async_inner().await
376    }
377
378    /// Async version of [`from_system()`](Self::from_system).
379    ///
380    /// On non-Linux platforms, this is equivalent to calling
381    /// [`from_system()`](Self::from_system).
382    #[cfg(not(target_os = "linux"))]
383    #[must_use = "this returns the detected theme; it does not apply it"]
384    pub async fn from_system_async() -> crate::Result<Self> {
385        pipeline::from_system_inner()
386    }
387}
388
389// =============================================================================
390// Tests -- SystemTheme public API (active, pick, platform_preset_name)
391// =============================================================================
392
393#[cfg(test)]
394#[allow(
395    clippy::unwrap_used,
396    clippy::expect_used,
397    clippy::field_reassign_with_default
398)]
399mod system_theme_tests {
400    use super::*;
401
402    // --- SystemTheme::active() / pick() tests ---
403
404    #[test]
405    fn test_system_theme_active_dark() {
406        let preset = ThemeSpec::preset("catppuccin-mocha").unwrap();
407        let mut light_v = preset.light.clone().unwrap();
408        let mut dark_v = preset.dark.clone().unwrap();
409        // Give them distinct accents so we can tell them apart
410        // (test fixture values -- not production hardcoded colors)
411        light_v.defaults.accent_color = Some(Rgba::rgb(0, 0, 255));
412        dark_v.defaults.accent_color = Some(Rgba::rgb(255, 0, 0));
413        light_v.resolve_all();
414        dark_v.resolve_all();
415        let light_resolved = light_v.validate().unwrap();
416        let dark_resolved = dark_v.validate().unwrap();
417
418        let st = SystemTheme {
419            name: "test".into(),
420            is_dark: true,
421            light: light_resolved.clone(),
422            dark: dark_resolved.clone(),
423            light_variant: preset.light.unwrap(),
424            dark_variant: preset.dark.unwrap(),
425            live_preset: "catppuccin-mocha".into(),
426            preset: "catppuccin-mocha".into(),
427        };
428        assert_eq!(
429            st.active().defaults.accent_color,
430            dark_resolved.defaults.accent_color
431        );
432    }
433
434    #[test]
435    fn test_system_theme_active_light() {
436        let preset = ThemeSpec::preset("catppuccin-mocha").unwrap();
437        let mut light_v = preset.light.clone().unwrap();
438        let mut dark_v = preset.dark.clone().unwrap();
439        light_v.defaults.accent_color = Some(Rgba::rgb(0, 0, 255));
440        dark_v.defaults.accent_color = Some(Rgba::rgb(255, 0, 0));
441        light_v.resolve_all();
442        dark_v.resolve_all();
443        let light_resolved = light_v.validate().unwrap();
444        let dark_resolved = dark_v.validate().unwrap();
445
446        let st = SystemTheme {
447            name: "test".into(),
448            is_dark: false,
449            light: light_resolved.clone(),
450            dark: dark_resolved.clone(),
451            light_variant: preset.light.unwrap(),
452            dark_variant: preset.dark.unwrap(),
453            live_preset: "catppuccin-mocha".into(),
454            preset: "catppuccin-mocha".into(),
455        };
456        assert_eq!(
457            st.active().defaults.accent_color,
458            light_resolved.defaults.accent_color
459        );
460    }
461
462    #[test]
463    fn test_system_theme_pick() {
464        let preset = ThemeSpec::preset("catppuccin-mocha").unwrap();
465        let mut light_v = preset.light.clone().unwrap();
466        let mut dark_v = preset.dark.clone().unwrap();
467        light_v.defaults.accent_color = Some(Rgba::rgb(0, 0, 255));
468        dark_v.defaults.accent_color = Some(Rgba::rgb(255, 0, 0));
469        light_v.resolve_all();
470        dark_v.resolve_all();
471        let light_resolved = light_v.validate().unwrap();
472        let dark_resolved = dark_v.validate().unwrap();
473
474        let st = SystemTheme {
475            name: "test".into(),
476            is_dark: false,
477            light: light_resolved.clone(),
478            dark: dark_resolved.clone(),
479            light_variant: preset.light.unwrap(),
480            dark_variant: preset.dark.unwrap(),
481            live_preset: "catppuccin-mocha".into(),
482            preset: "catppuccin-mocha".into(),
483        };
484        assert_eq!(
485            st.pick(true).defaults.accent_color,
486            dark_resolved.defaults.accent_color
487        );
488        assert_eq!(
489            st.pick(false).defaults.accent_color,
490            light_resolved.defaults.accent_color
491        );
492    }
493
494    // --- platform_preset_name() tests ---
495
496    #[test]
497    #[cfg(target_os = "linux")]
498    #[allow(unsafe_code)]
499    fn test_platform_preset_name_kde() {
500        let _guard = crate::test_util::ENV_MUTEX.lock().unwrap();
501        unsafe { std::env::set_var("XDG_CURRENT_DESKTOP", "KDE") };
502        let name = platform_preset_name();
503        unsafe { std::env::remove_var("XDG_CURRENT_DESKTOP") };
504        assert_eq!(name, "kde-breeze-live");
505    }
506
507    #[test]
508    #[cfg(target_os = "linux")]
509    #[allow(unsafe_code)]
510    fn test_platform_preset_name_gnome() {
511        let _guard = crate::test_util::ENV_MUTEX.lock().unwrap();
512        unsafe { std::env::set_var("XDG_CURRENT_DESKTOP", "GNOME") };
513        let name = platform_preset_name();
514        unsafe { std::env::remove_var("XDG_CURRENT_DESKTOP") };
515        assert_eq!(name, "adwaita-live");
516    }
517}
518
519// =============================================================================
520// Tests -- with_overlay / with_overlay_toml
521// =============================================================================
522
523#[cfg(test)]
524#[allow(clippy::unwrap_used, clippy::expect_used)]
525mod overlay_tests {
526    use super::*;
527
528    /// Helper: build a SystemTheme from a preset via pipeline::run_pipeline.
529    fn default_system_theme() -> SystemTheme {
530        let reader = ThemeSpec::preset("catppuccin-mocha").unwrap();
531        pipeline::run_pipeline(reader, "catppuccin-mocha", false).unwrap()
532    }
533
534    #[test]
535    fn test_overlay_accent_propagates() {
536        let st = default_system_theme();
537        let new_accent = Rgba::rgb(255, 0, 0);
538
539        // Build overlay with accent on both light and dark
540        let mut overlay = ThemeSpec::default();
541        let mut light_v = ThemeVariant::default();
542        light_v.defaults.accent_color = Some(new_accent);
543        let mut dark_v = ThemeVariant::default();
544        dark_v.defaults.accent_color = Some(new_accent);
545        overlay.light = Some(light_v);
546        overlay.dark = Some(dark_v);
547
548        let result = st.with_overlay(&overlay).unwrap();
549
550        // Accent itself
551        assert_eq!(result.light.defaults.accent_color, new_accent);
552        // Accent-derived widget fields
553        assert_eq!(result.light.button.primary_background, new_accent);
554        assert_eq!(result.light.checkbox.checked_background, new_accent);
555        assert_eq!(result.light.slider.fill_color, new_accent);
556        assert_eq!(result.light.progress_bar.fill_color, new_accent);
557        assert_eq!(result.light.switch.checked_background, new_accent);
558        // Additional accent-derived fields re-resolved via safety nets
559        assert_eq!(
560            result.light.spinner.fill_color, new_accent,
561            "spinner.fill should re-derive from new accent"
562        );
563    }
564
565    #[test]
566    fn test_overlay_preserves_unrelated_fields() {
567        let st = default_system_theme();
568        let original_bg = st.light.defaults.background_color;
569
570        // Apply overlay changing only accent
571        let mut overlay = ThemeSpec::default();
572        let mut light_v = ThemeVariant::default();
573        light_v.defaults.accent_color = Some(Rgba::rgb(255, 0, 0));
574        overlay.light = Some(light_v);
575
576        let result = st.with_overlay(&overlay).unwrap();
577        assert_eq!(
578            result.light.defaults.background_color, original_bg,
579            "background should be unchanged"
580        );
581    }
582
583    #[test]
584    fn test_overlay_empty_noop() {
585        let st = default_system_theme();
586        let original_light_accent = st.light.defaults.accent_color;
587        let original_dark_accent = st.dark.defaults.accent_color;
588        let original_light_bg = st.light.defaults.background_color;
589
590        // Empty overlay
591        let overlay = ThemeSpec::default();
592        let result = st.with_overlay(&overlay).unwrap();
593
594        assert_eq!(result.light.defaults.accent_color, original_light_accent);
595        assert_eq!(result.dark.defaults.accent_color, original_dark_accent);
596        assert_eq!(result.light.defaults.background_color, original_light_bg);
597    }
598
599    #[test]
600    fn test_overlay_both_variants() {
601        let st = default_system_theme();
602        let red = Rgba::rgb(255, 0, 0);
603        let green = Rgba::rgb(0, 255, 0);
604
605        let mut overlay = ThemeSpec::default();
606        let mut light_v = ThemeVariant::default();
607        light_v.defaults.accent_color = Some(red);
608        let mut dark_v = ThemeVariant::default();
609        dark_v.defaults.accent_color = Some(green);
610        overlay.light = Some(light_v);
611        overlay.dark = Some(dark_v);
612
613        let result = st.with_overlay(&overlay).unwrap();
614        assert_eq!(
615            result.light.defaults.accent_color, red,
616            "light accent = red"
617        );
618        assert_eq!(
619            result.dark.defaults.accent_color, green,
620            "dark accent = green"
621        );
622    }
623
624    #[test]
625    fn test_overlay_font_family() {
626        let st = default_system_theme();
627
628        let mut overlay = ThemeSpec::default();
629        let mut light_v = ThemeVariant::default();
630        light_v.defaults.font.family = Some("Comic Sans".into());
631        overlay.light = Some(light_v);
632
633        let result = st.with_overlay(&overlay).unwrap();
634        assert_eq!(result.light.defaults.font.family, "Comic Sans");
635    }
636
637    #[test]
638    fn test_overlay_toml_convenience() {
639        let st = default_system_theme();
640        let result = st
641            .with_overlay_toml(
642                r##"
643            name = "overlay"
644            [light.defaults]
645            accent_color = "#ff0000"
646        "##,
647            )
648            .unwrap();
649        assert_eq!(result.light.defaults.accent_color, Rgba::rgb(255, 0, 0));
650    }
651}