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.
90pub mod detect;
91/// Error types for theme operations.
92pub mod error;
93/// Font matching, and the platform's own typeface for a family (feature
94/// `system-fonts`).
95pub mod fonts;
96/// GNOME portal theme reader.
97///
98/// Requires the `portal` feature, and Linux.
99#[cfg(all(target_os = "linux", feature = "portal"))]
100pub mod gnome;
101/// Icon loading and dispatch.
102pub mod icons;
103/// KDE theme reader.
104///
105/// Requires the `kde` feature, and Linux.
106#[cfg(all(target_os = "linux", feature = "kde"))]
107pub mod kde;
108/// Breeze widget sizing constants. Compiled on every target and feature set,
109/// so the preset gate sees the values the KDE reader states.
110#[path = "kde/metrics.rs"]
111pub(crate) mod kde_metrics;
112/// Theme data model types.
113pub mod model;
114/// Theme pipeline: reader -> preset merge -> resolve -> validate.
115pub mod pipeline;
116/// Bundled theme presets.
117pub mod presets;
118/// Internal `ThemeReader` trait (see module docs for Option A rationale).
119///
120/// Consumed by `pipeline::select_reader` as `Box<dyn ThemeReader>`; the
121/// trait and every impl are `pub(crate)` — not part of the public API.
122mod reader;
123/// Theme resolution engine (inheritance + validation).
124///
125/// Public surface: [`resolve::ResolutionContext`] — the resolution-time
126/// inputs struct consumed by
127/// [`ThemeMode::into_resolved`](crate::theme::ThemeMode::into_resolved).
128/// Inheritance rules, validation machinery, and the range-check helpers
129/// are all crate-internal (no stability guarantee).
130pub mod resolve;
131#[cfg(any(
132    feature = "material-icons",
133    feature = "lucide-icons",
134    feature = "system-icons"
135))]
136mod spinners;
137/// Runtime theme change watching.
138///
139/// Requires the `watch` feature.
140#[cfg(feature = "watch")]
141pub mod watch;
142
143/// Convenience re-exports for common usage.
144///
145/// `use native_theme::prelude::*` imports:
146/// [`Theme`], [`ResolvedTheme`], [`SystemTheme`], [`AccessibilityPreferences`],
147/// [`ResolutionContext`], [`Rgba`], [`Error`], and [`Result`].
148pub mod prelude;
149
150/// Theme data model: types, defaults, fonts, borders, widgets.
151///
152/// Core types: [`Theme`], [`ThemeMode`],
153/// [`ResolvedTheme`], [`ResolvedDefaults`].
154///
155/// Re-exports from the internal model module.
156pub mod theme {
157    pub use crate::model::*;
158    pub use crate::presets::PresetInfo;
159}
160
161/// Freedesktop icon theme lookup (Linux).
162///
163/// Requires the `system-icons` feature, and Linux.
164#[cfg(all(target_os = "linux", feature = "system-icons"))]
165pub mod freedesktop;
166/// macOS platform helpers.
167#[cfg(target_os = "macos")]
168pub mod macos;
169#[cfg(not(target_os = "macos"))]
170pub(crate) mod macos;
171/// SVG-to-RGBA rasterization utilities.
172///
173/// Requires the `svg-rasterize` feature.
174#[cfg(feature = "svg-rasterize")]
175pub mod rasterize;
176/// SF Symbols icon loader (macOS).
177///
178/// Requires the `system-icons` feature, and macOS.
179#[cfg(all(target_os = "macos", feature = "system-icons"))]
180pub mod sficons;
181/// Windows platform theme reader.
182#[cfg(target_os = "windows")]
183#[cfg_attr(not(feature = "windows"), allow(dead_code))]
184pub mod windows;
185#[cfg(not(target_os = "windows"))]
186#[allow(dead_code, unused_variables)]
187pub(crate) mod windows;
188/// Windows Segoe Fluent / stock icon loader.
189///
190/// Requires the `system-icons` feature, and Windows.
191#[cfg(all(target_os = "windows", feature = "system-icons"))]
192pub mod winicons;
193#[cfg(all(not(target_os = "windows"), feature = "system-icons"))]
194#[allow(dead_code, unused_imports)]
195pub(crate) mod winicons;
196
197use std::borrow::Cow;
198
199/// Convenience Result type alias for this crate.
200pub type Result<T> = std::result::Result<T, error::Error>;
201
202// Internal re-exports: keep crate::Type paths working for internal modules
203// without exposing them in the public API. External users access types via
204// native_theme::theme::*, native_theme::icons::*, native_theme::detect::*, etc.
205#[allow(unused_imports)]
206pub(crate) use color::{ParseColorError, Rgba};
207#[cfg(target_os = "linux")]
208#[allow(unused_imports)]
209pub(crate) use detect::LinuxDesktop;
210#[cfg(target_os = "linux")]
211#[allow(unused_imports)]
212pub(crate) use detect::detect_linux_desktop;
213#[allow(unused_imports)]
214pub(crate) use detect::{
215    detect_is_dark, detect_reduced_motion, invalidate_caches, prefers_reduced_motion,
216    system_is_dark,
217};
218#[allow(unused_imports)]
219pub(crate) use error::{Error, ErrorKind, RangeViolation};
220#[allow(unused_imports)]
221pub(crate) use icons::{
222    FreedesktopLoader, IconId, LucideLoader, MaterialLoader, SegoeIconsLoader, SfSymbolsLoader,
223    is_freedesktop_theme_available, load_icon, load_icon_indicator,
224};
225pub use icons::{IconSetChoice, default_icon_choice, list_freedesktop_themes};
226#[allow(unused_imports)]
227pub(crate) use model::icons::{detect_icon_theme, icon_name, system_icon_set, system_icon_theme};
228#[allow(unused_imports)]
229pub(crate) use model::{
230    AnimatedIcon, ButtonTheme, CardTheme, CheckboxTheme, ColorMode, ComboBoxTheme,
231    DefaultsBorderSpec, DialogButtonOrder, DialogTheme, ExpanderTheme, FontSize, FontSpec,
232    FontStyle, IconData, IconProvider, IconRole, IconSet, IconSizes, InputTheme, LayoutTheme,
233    LinkTheme, ListTheme, MenuTheme, PopoverTheme, ProgressBarTheme, ResolvedDefaults,
234    ResolvedDefaultsBorder, ResolvedFontSpec, ResolvedIconSizes, ResolvedPadding,
235    ResolvedTextScale, ResolvedTextScaleEntry, ResolvedTheme, ResolvedWidgetBorder, ScrollbarTheme,
236    SegmentedControlTheme, SeparatorTheme, SidebarTheme, SliderTheme, SpinnerTheme, SplitterTheme,
237    StatusBarTheme, SwitchTheme, TabTheme, TextScale, TextScaleEntry, Theme, ThemeDefaults,
238    ThemeMode, ToolbarTheme, TooltipTheme, TransformAnimation, WidgetBorderSpec, WindowTheme,
239    bundled_icon_by_name, bundled_icon_svg,
240};
241pub use pipeline::{DiagnosticEntry, PlatformPreset};
242#[allow(unused_imports)]
243pub(crate) use pipeline::{diagnose_platform_support, platform_preset_name};
244pub use resolve::ResolutionContext;
245
246/// OS-detected accessibility preferences.
247///
248/// A single copy lives on [`SystemTheme`], shared across light and dark
249/// variants. These are runtime values detected from the OS -- not stored
250/// in TOML presets.
251#[derive(Clone, Debug, PartialEq)]
252pub struct AccessibilityPreferences {
253    /// Text scaling factor (1.0 = no scaling). Multiply font sizes by
254    /// this factor when honoring the user's preference for larger text.
255    pub text_scaling_factor: f32,
256    /// Whether the user has requested reduced motion.
257    pub reduce_motion: bool,
258    /// Whether a high-contrast mode is active.
259    pub high_contrast: bool,
260    /// Whether the user has requested reduced transparency.
261    pub reduce_transparency: bool,
262}
263
264impl Default for AccessibilityPreferences {
265    fn default() -> Self {
266        Self {
267            text_scaling_factor: 1.0,
268            reduce_motion: false,
269            high_contrast: false,
270            reduce_transparency: false,
271        }
272    }
273}
274
275impl AccessibilityPreferences {
276    /// Read the OS accessibility preferences without resolving a theme.
277    ///
278    /// Runs the same platform reader [`SystemTheme::from_system`] runs (KDE
279    /// `kdeglobals`, GNOME portal + gsettings) and takes its accessibility
280    /// block; `reduce_motion` is additionally read through
281    /// [`crate::detect::detect_reduced_motion`] on every platform. Fields no
282    /// reader supplies keep their defaults. Never fails: with no reader or a
283    /// failing reader the defaults are returned.
284    ///
285    /// Use it on the preset path, where accessibility is orthogonal to the
286    /// theme choice (a user with large text wants it under a preset too).
287    #[must_use]
288    #[cfg(target_os = "linux")]
289    pub fn from_system() -> Self {
290        pollster::block_on(pipeline::accessibility_from_system_inner())
291    }
292
293    /// Read the OS accessibility preferences without resolving a theme (non-Linux).
294    ///
295    /// The macOS reader (`NSWorkspace`) and the Windows reader (`UISettings`)
296    /// fill the accessibility block on their platforms; `reduce_motion` is
297    /// additionally read through [`crate::detect::detect_reduced_motion`].
298    /// The inner future has no `.await` points off Linux, so a noop-waker
299    /// single poll suffices, as in [`SystemTheme::from_system`].
300    #[must_use]
301    #[cfg(not(target_os = "linux"))]
302    pub fn from_system() -> Self {
303        let waker = std::task::Waker::noop();
304        let mut cx = std::task::Context::from_waker(&waker);
305        let mut fut = std::pin::pin!(pipeline::accessibility_from_system_inner());
306        match fut.as_mut().poll(&mut cx) {
307            std::task::Poll::Ready(prefs) => prefs,
308            std::task::Poll::Pending => Self::default(),
309        }
310    }
311}
312
313/// Complete reader result for the pipeline.
314///
315/// Bundles the type-safe [`ReaderOutput`] with reader metadata
316/// (name, icon_set, layout, font_dpi, accessibility) so that
317/// `run_pipeline` accepts a single struct instead of many arguments.
318#[derive(Clone, Debug)]
319pub(crate) struct ReaderResult {
320    /// The reader's variant data.
321    pub(crate) output: ReaderOutput,
322    /// Theme name from reader (e.g. "BreezeDark", "GNOME", "macOS").
323    pub(crate) name: Cow<'static, str>,
324    /// Shared icon_set from reader.
325    pub(crate) icon_set: Option<IconSet>,
326    /// Shared layout from reader.
327    pub(crate) layout: LayoutTheme,
328    /// Font DPI captured at detection time (None = auto-detect).
329    pub(crate) font_dpi: Option<f32>,
330    /// OS-detected accessibility preferences.
331    pub(crate) accessibility: AccessibilityPreferences,
332}
333
334/// Output contract for platform readers.
335///
336/// Expresses single-vs-dual variant semantics explicitly:
337/// - `Single`: KDE, GNOME, and Windows readers report only the OS-active mode.
338///   The pipeline fills the inactive variant from the platform preset.
339/// - `Dual`: macOS reads both light and dark appearances in a single call.
340///   The pipeline uses both reader-provided variants directly.
341#[derive(Clone, Debug)]
342pub(crate) enum ReaderOutput {
343    /// Reader provides only the OS-active variant. The pipeline fills the
344    /// inactive variant from the platform preset.
345    #[cfg_attr(all(target_os = "windows", not(feature = "windows")), allow(dead_code))]
346    Single {
347        /// The reader-provided variant (OS-active).
348        mode: Box<ThemeMode>,
349        /// Which color mode this variant represents.
350        is_dark: bool,
351    },
352    /// Reader provides both light and dark variants (macOS).
353    #[allow(dead_code)]
354    Dual {
355        /// The light variant from the reader.
356        light: Box<ThemeMode>,
357        /// The dark variant from the reader.
358        dark: Box<ThemeMode>,
359    },
360}
361
362impl ReaderOutput {
363    /// Reconstruct a [`Theme`] from this reader output (for overlay replay
364    /// and merge compatibility).
365    pub(crate) fn to_theme(
366        &self,
367        name: &str,
368        icon_set: Option<IconSet>,
369        layout: &LayoutTheme,
370    ) -> Theme {
371        let (light, dark) = match self {
372            ReaderOutput::Single { mode, is_dark } => {
373                if *is_dark {
374                    (None, Some(ThemeMode::clone(mode)))
375                } else {
376                    (Some(ThemeMode::clone(mode)), None)
377                }
378            }
379            ReaderOutput::Dual { light, dark } => {
380                (Some(ThemeMode::clone(light)), Some(ThemeMode::clone(dark)))
381            }
382        };
383        Theme {
384            name: std::borrow::Cow::Owned(name.to_string()),
385            light,
386            dark,
387            layout: layout.clone(),
388            icon_set,
389            // Readers keep Theme-level icon_theme = None and rely on either
390            // the preset's value (tier 2) or system detect (tier 3). Readers
391            // that need to override per color mode use ThemeDefaults::icon_theme
392            // on the variant (tier 1, e.g. the KDE reader for breeze/breeze-dark).
393            icon_theme: None,
394        }
395    }
396}
397
398/// Data needed to replay the merge+resolve pipeline for overlay support.
399///
400/// Stores the original reader output and preset name so that
401/// [`SystemTheme::with_overlay()`] can reconstruct pre-resolve variants
402/// on demand instead of storing ~2KB of ThemeMode clones.
403#[derive(Clone, Debug)]
404pub(crate) struct OverlaySource {
405    /// The reader's variant data for replay.
406    pub(crate) reader_output: ReaderOutput,
407    /// Theme name from reader.
408    pub(crate) name: Cow<'static, str>,
409    /// Shared icon_set from reader.
410    pub(crate) icon_set: Option<IconSet>,
411    /// Shared layout from reader.
412    pub(crate) layout: LayoutTheme,
413    /// The live preset name (e.g. "kde-breeze-live").
414    pub(crate) preset_name: String,
415    /// Resolution-time inputs captured at detection time. Replaces the
416    /// old `font_dpi: Option<f32>` field; the context bundles
417    /// `font_dpi` + `button_order` + detected `icon_theme` and is cloned
418    /// into `with_overlay` replays so resolution is deterministic across
419    /// overlay applications.
420    pub(crate) context: crate::resolve::ResolutionContext,
421}
422
423/// Result of the OS-first pipeline. Holds both resolved variants.
424///
425/// Produced by [`SystemTheme::from_system()`] and [`SystemTheme::from_system_async()`].
426/// Both light and dark are always populated: the OS-active variant
427/// comes from the reader + preset + resolve, the inactive variant
428/// comes from the preset + resolve.
429#[derive(Clone, Debug)]
430pub struct SystemTheme {
431    /// Theme name (from reader or preset).
432    ///
433    /// # Ownership type
434    ///
435    /// This field uses `Cow<'static, str>`, not `Arc<str>`. The v0.5.7 API
436    /// review recommended uniform `Arc<str>` across `name`, `icon_theme`,
437    /// `ReaderOutput::name`, and `ResolvedFontSpec::family`. The audit in
438    /// `docs/archive/v0.5.7_gaps.md` §G9 concluded that the
439    /// uniform recommendation should be adopted ONLY for
440    /// [`ResolvedFontSpec::family`](crate::model::font::ResolvedFontSpec)
441    /// (where 26 widgets × connectors genuinely share font families), and
442    /// should be REVERSED for `name` / `icon_theme` because:
443    ///
444    /// - Each resolved theme carries exactly ONE `name` — no dedup benefit.
445    /// - Bundled preset names are `&'static str` literals; `Cow::Borrowed(static_lit)`
446    ///   is zero allocation, zero refcount. `Arc<str>` would require at least one
447    ///   allocation per unique string at construction time, paying allocation cost
448    ///   for a dedup benefit that is structurally absent.
449    ///
450    /// The same reasoning applies symmetrically to
451    /// [`SystemTheme::icon_theme`](Self::icon_theme),
452    /// [`Theme::name`](crate::theme::Theme), and
453    /// [`ThemeDefaults::icon_theme`](crate::model::defaults::ThemeDefaults).
454    ///
455    /// See `docs/archive/v0.5.7_gaps.md` §G9 for the full audit.
456    pub name: Cow<'static, str>,
457    /// The OS color mode preference (light or dark).
458    pub mode: ColorMode,
459    /// Resolved light variant (always populated).
460    pub light: ResolvedTheme,
461    /// Resolved dark variant (always populated).
462    pub dark: ResolvedTheme,
463    /// Data for replaying the pipeline on overlay (replaces light_variant/dark_variant).
464    pub(crate) overlay_source: OverlaySource,
465    /// The platform preset used (e.g., "kde-breeze", "adwaita", "macos-sonoma").
466    pub preset: String,
467    /// The live preset name used internally (e.g., "kde-breeze-live").
468    pub(crate) live_preset: String,
469    /// Which icon loading mechanism to use for this theme.
470    pub icon_set: IconSet,
471    /// The name of the visual icon theme (e.g. `"breeze"`, `"Adwaita"`):
472    /// the active variant's, else the theme's, else the detected system
473    /// icon theme. `None` where the theme states none and detection fails;
474    /// [`system_icon_theme()`](crate::theme::system_icon_theme) gives the
475    /// reason.
476    ///
477    /// # Ownership type
478    ///
479    /// `Cow<'static, str>` is used here per the same principled deviation
480    /// documented on [`SystemTheme::name`](Self::name) — see `docs/archive/v0.5.7_gaps.md`
481    /// §G9. Each resolved theme carries a single icon-theme name (KDE has
482    /// exactly two across light/dark variants — `"breeze"` / `"breeze-dark"`;
483    /// other platforms have one), so the `Arc<str>` dedup benefit does not apply.
484    pub icon_theme: Option<Cow<'static, str>>,
485    /// Each variant's icon theme, resolved by the same three tiers as
486    /// [`icon_theme`](Self::icon_theme), kept by variant rather than relative
487    /// to [`mode`](Self::mode), a public field a caller may change. Read
488    /// through [`icon_theme_for`](Self::icon_theme_for).
489    pub(crate) light_icon_theme: Option<Cow<'static, str>>,
490    /// The dark variant's counterpart of [`light_icon_theme`](Self::light_icon_theme).
491    pub(crate) dark_icon_theme: Option<Cow<'static, str>>,
492    /// Layout spacing shared by both variants: the platform reader's values
493    /// merged field-wise over the preset's, the same precedence the pipeline
494    /// uses for colours. `None` in a field means neither the platform nor the
495    /// preset specifies it (platform-facts §2.20); nothing is invented.
496    pub layout: LayoutTheme,
497    /// OS-detected accessibility preferences (shared across variants).
498    pub accessibility: AccessibilityPreferences,
499}
500
501impl SystemTheme {
502    /// Pick a resolved variant by color mode.
503    ///
504    /// # Examples
505    ///
506    /// ```no_run
507    /// use native_theme::theme::ColorMode;
508    ///
509    /// let sys = native_theme::SystemTheme::from_system()?;
510    /// let dark = sys.pick(ColorMode::Dark);
511    /// let active = sys.pick(sys.mode);
512    /// # Ok::<(), native_theme::error::Error>(())
513    /// ```
514    #[must_use]
515    pub fn pick(&self, mode: ColorMode) -> &ResolvedTheme {
516        match mode {
517            ColorMode::Light => &self.light,
518            ColorMode::Dark => &self.dark,
519        }
520    }
521
522    /// The icon-theme name for one colour mode, resolved for that mode's
523    /// variant by the same three tiers as [`icon_theme`](Self::icon_theme)
524    /// (its `defaults.icon_theme`, then the theme's, then the detected system
525    /// theme); for the mode the theme was built in it equals `icon_theme` as
526    /// built. KDE names one theme per variant (`breeze`, `breeze-dark`); a
527    /// toolkit that keeps a style per colour scheme needs both. `None` where
528    /// that variant states none and detection failed; nothing is invented.
529    #[must_use]
530    pub fn icon_theme_for(&self, mode: ColorMode) -> Option<&str> {
531        match mode {
532            ColorMode::Light => self.light_icon_theme.as_deref(),
533            ColorMode::Dark => self.dark_icon_theme.as_deref(),
534        }
535    }
536
537    /// Apply an app-level TOML overlay and re-resolve.
538    ///
539    /// Merges the overlay onto the pre-resolve [`ThemeMode`] (not the
540    /// already-resolved [`ResolvedTheme`]) so that changed source fields
541    /// propagate correctly through `resolve()`. For example, changing
542    /// `defaults.accent_color` in the overlay will cause `button.primary_background`,
543    /// `checkbox.checked_background`, `slider.fill`, etc. to be re-derived from
544    /// the new accent color.
545    ///
546    /// # Examples
547    ///
548    /// ```no_run
549    /// let system = native_theme::SystemTheme::from_system()?;
550    /// let overlay = native_theme::theme::Theme::from_toml(r##"
551    ///     [light.defaults]
552    ///     accent_color = "#ff6600"
553    ///     [dark.defaults]
554    ///     accent_color = "#ff6600"
555    /// "##)?;
556    /// let customized = system.with_overlay(&overlay)?;
557    /// // customized.pick(customized.mode).defaults.accent_color is now #ff6600
558    /// // and all accent-derived fields are updated
559    /// # Ok::<(), native_theme::error::Error>(())
560    /// ```
561    pub fn with_overlay(&self, overlay: &Theme) -> crate::Result<Self> {
562        // Reconstruct pre-resolve variants from overlay_source
563        let src = &self.overlay_source;
564        let live_preset = Theme::preset(&src.preset_name)?;
565        let full_preset_name = src
566            .preset_name
567            .strip_suffix("-live")
568            .unwrap_or(&src.preset_name);
569        let full_preset = Theme::preset(full_preset_name)?;
570
571        // Reconstruct a Theme from the type-safe ReaderOutput for merge
572        let reader_as_theme = src
573            .reader_output
574            .to_theme(&src.name, src.icon_set, &src.layout);
575
576        let mut merged = full_preset.clone();
577        merged.merge(&live_preset);
578        merged.merge(&reader_as_theme);
579
580        // Shared across variants; read before the variants are moved out of `merged`.
581        let layout = merged.layout.clone();
582
583        // Match on ReaderOutput for type-safe variant selection
584        let (mut light, mut dark) = match &src.reader_output {
585            ReaderOutput::Single { is_dark, .. } => {
586                if *is_dark {
587                    (
588                        full_preset.light.unwrap_or_default(),
589                        merged.dark.unwrap_or_default(),
590                    )
591                } else {
592                    (
593                        merged.light.unwrap_or_default(),
594                        full_preset.dark.unwrap_or_default(),
595                    )
596                }
597            }
598            ReaderOutput::Dual { .. } => (
599                merged.light.unwrap_or_default(),
600                merged.dark.unwrap_or_default(),
601            ),
602        };
603
604        // Apply the user overlay on top
605        if let Some(over) = &overlay.light {
606            light.merge(over);
607        }
608        if let Some(over) = &overlay.dark {
609            dark.merge(over);
610        }
611
612        // Re-resolve both variants using the captured context (avoids
613        // re-detecting DPI / button_order / icon_theme on replay).
614        let resolved_light = light.into_resolved(&src.context)?;
615        let resolved_dark = dark.into_resolved(&src.context)?;
616
617        Ok(SystemTheme {
618            name: self.name.clone(),
619            mode: self.mode,
620            light: resolved_light,
621            dark: resolved_dark,
622            overlay_source: self.overlay_source.clone(),
623            live_preset: self.live_preset.clone(),
624            preset: self.preset.clone(),
625            icon_set: self.icon_set,
626            icon_theme: self.icon_theme.clone(),
627            light_icon_theme: self.light_icon_theme.clone(),
628            dark_icon_theme: self.dark_icon_theme.clone(),
629            layout,
630            accessibility: self.accessibility.clone(),
631        })
632    }
633
634    /// Load the OS theme synchronously.
635    ///
636    /// Detects the platform and desktop environment, reads the current theme
637    /// settings, merges with a platform preset, and returns a fully resolved
638    /// [`SystemTheme`] with both light and dark variants.
639    ///
640    /// The return value goes through the full pipeline: reader output ->
641    /// resolve -> validate -> [`SystemTheme`] with both light and dark
642    /// [`ResolvedTheme`] variants.
643    ///
644    /// # Platform Behavior
645    ///
646    /// - **macOS:** with the `macos` feature, reads both light and dark
647    ///   variants via NSAppearance and merges them with the `macos-sonoma`
648    ///   preset.
649    /// - **Linux:** Uses `pollster::block_on` to drive the async inner
650    ///   implementation, which handles portal D-Bus calls when the `portal`
651    ///   feature is enabled. Without a reader for the detected desktop
652    ///   (feature disabled, or a desktop with no reader), returns the
653    ///   `adwaita` preset resolved in the detected light/dark mode.
654    /// - **Windows:** with the `windows` feature, reads the active variant and
655    ///   merges it with the `windows-11` preset.
656    /// - **Other platforms:** Returns `Error::PlatformUnsupported`.
657    ///
658    /// # Errors
659    ///
660    /// - `Error::FeatureDisabled` on macOS or Windows when the `macos` /
661    ///   `windows` feature is not enabled.
662    /// - `Error::PlatformUnsupported` if the platform has no reader at all.
663    /// - `Error::ReaderFailed` if the platform reader cannot access theme data.
664    ///
665    /// # Examples
666    ///
667    /// ```no_run
668    /// let sys = native_theme::SystemTheme::from_system()?;
669    /// let theme = sys.pick(sys.mode);
670    /// // Icon set and theme are on SystemTheme, shared across variants
671    /// let _icon_set = sys.icon_set;
672    /// let _icon_theme = &sys.icon_theme;
673    /// # Ok::<(), native_theme::error::Error>(())
674    /// ```
675    #[cfg(target_os = "linux")]
676    pub fn from_system() -> crate::Result<Self> {
677        pollster::block_on(pipeline::from_system_inner())
678    }
679
680    /// Load the OS theme synchronously (non-Linux).
681    ///
682    /// On macOS and Windows the async inner has zero `.await` points, so a
683    /// noop-waker single-poll is sufficient -- no async runtime needed.
684    #[cfg(not(target_os = "linux"))]
685    pub fn from_system() -> crate::Result<Self> {
686        let waker = std::task::Waker::noop();
687        let mut cx = std::task::Context::from_waker(&waker);
688        let mut fut = std::pin::pin!(pipeline::from_system_inner());
689        match fut.as_mut().poll(&mut cx) {
690            std::task::Poll::Ready(result) => result,
691            std::task::Poll::Pending => Err(crate::Error::PlatformUnsupported {
692                platform: "unexpected async suspension",
693            }),
694        }
695    }
696
697    /// Async version of [`from_system()`](Self::from_system).
698    ///
699    /// On Linux, this enables portal D-Bus calls (e.g. GNOME settings portal,
700    /// KDE portal backend detection) via `.await`. On macOS and Windows, the
701    /// future completes immediately -- no actual async operations occur.
702    ///
703    /// Returns a [`SystemTheme`] with both resolved light and dark variants,
704    /// same as [`from_system()`](Self::from_system).
705    pub async fn from_system_async() -> crate::Result<Self> {
706        pipeline::from_system_inner().await
707    }
708}
709
710// =============================================================================
711// Tests -- SystemTheme public API (active, pick, platform_preset_name)
712// =============================================================================
713
714#[cfg(test)]
715#[allow(
716    clippy::unwrap_used,
717    clippy::expect_used,
718    clippy::field_reassign_with_default
719)]
720mod system_theme_tests {
721    use super::*;
722
723    // --- SystemTheme::active() / pick() tests ---
724
725    #[test]
726    fn test_system_theme_pick_dark_mode() {
727        let preset = Theme::preset("catppuccin-mocha").unwrap();
728        let mut light_v = preset.light.clone().unwrap();
729        let mut dark_v = preset.dark.clone().unwrap();
730        // Give them distinct accents so we can tell them apart
731        // (test fixture values -- not production hardcoded colors)
732        light_v.defaults.accent_color = Some(Rgba::rgb(0, 0, 255));
733        dark_v.defaults.accent_color = Some(Rgba::rgb(255, 0, 0));
734        light_v.resolve_all();
735        dark_v.resolve_all();
736        let light_resolved = light_v.validate().unwrap();
737        let dark_resolved = dark_v.validate().unwrap();
738
739        let st = SystemTheme {
740            name: "test".into(),
741            mode: ColorMode::Dark,
742            light: light_resolved.clone(),
743            dark: dark_resolved.clone(),
744            overlay_source: OverlaySource {
745                reader_output: ReaderOutput::Dual {
746                    light: Box::new(ThemeMode::default()),
747                    dark: Box::new(ThemeMode::default()),
748                },
749                name: Cow::Borrowed(""),
750                icon_set: None,
751                layout: LayoutTheme::default(),
752                preset_name: "catppuccin-mocha".into(),
753                context: crate::resolve::ResolutionContext::for_tests(),
754            },
755            live_preset: "catppuccin-mocha".into(),
756            preset: "catppuccin-mocha".into(),
757            icon_set: IconSet::Lucide,
758            icon_theme: Some("lucide".into()),
759            light_icon_theme: None,
760            dark_icon_theme: Some("lucide".into()),
761            layout: LayoutTheme::default(),
762            accessibility: AccessibilityPreferences::default(),
763        };
764        assert_eq!(
765            st.pick(st.mode).defaults.accent_color,
766            dark_resolved.defaults.accent_color
767        );
768    }
769
770    #[test]
771    fn test_system_theme_pick_light_mode() {
772        let preset = Theme::preset("catppuccin-mocha").unwrap();
773        let mut light_v = preset.light.clone().unwrap();
774        let mut dark_v = preset.dark.clone().unwrap();
775        light_v.defaults.accent_color = Some(Rgba::rgb(0, 0, 255));
776        dark_v.defaults.accent_color = Some(Rgba::rgb(255, 0, 0));
777        light_v.resolve_all();
778        dark_v.resolve_all();
779        let light_resolved = light_v.validate().unwrap();
780        let dark_resolved = dark_v.validate().unwrap();
781
782        let st = SystemTheme {
783            name: "test".into(),
784            mode: ColorMode::Light,
785            light: light_resolved.clone(),
786            dark: dark_resolved.clone(),
787            overlay_source: OverlaySource {
788                reader_output: ReaderOutput::Dual {
789                    light: Box::new(ThemeMode::default()),
790                    dark: Box::new(ThemeMode::default()),
791                },
792                name: Cow::Borrowed(""),
793                icon_set: None,
794                layout: LayoutTheme::default(),
795                preset_name: "catppuccin-mocha".into(),
796                context: crate::resolve::ResolutionContext::for_tests(),
797            },
798            live_preset: "catppuccin-mocha".into(),
799            preset: "catppuccin-mocha".into(),
800            icon_set: IconSet::Lucide,
801            icon_theme: Some("lucide".into()),
802            light_icon_theme: Some("lucide".into()),
803            dark_icon_theme: None,
804            layout: LayoutTheme::default(),
805            accessibility: AccessibilityPreferences::default(),
806        };
807        assert_eq!(
808            st.pick(st.mode).defaults.accent_color,
809            light_resolved.defaults.accent_color
810        );
811    }
812
813    #[test]
814    fn test_system_theme_pick_explicit() {
815        let preset = Theme::preset("catppuccin-mocha").unwrap();
816        let mut light_v = preset.light.clone().unwrap();
817        let mut dark_v = preset.dark.clone().unwrap();
818        light_v.defaults.accent_color = Some(Rgba::rgb(0, 0, 255));
819        dark_v.defaults.accent_color = Some(Rgba::rgb(255, 0, 0));
820        light_v.resolve_all();
821        dark_v.resolve_all();
822        let light_resolved = light_v.validate().unwrap();
823        let dark_resolved = dark_v.validate().unwrap();
824
825        let st = SystemTheme {
826            name: "test".into(),
827            mode: ColorMode::Light,
828            light: light_resolved.clone(),
829            dark: dark_resolved.clone(),
830            overlay_source: OverlaySource {
831                reader_output: ReaderOutput::Dual {
832                    light: Box::new(ThemeMode::default()),
833                    dark: Box::new(ThemeMode::default()),
834                },
835                name: Cow::Borrowed(""),
836                icon_set: None,
837                layout: LayoutTheme::default(),
838                preset_name: "catppuccin-mocha".into(),
839                context: crate::resolve::ResolutionContext::for_tests(),
840            },
841            live_preset: "catppuccin-mocha".into(),
842            preset: "catppuccin-mocha".into(),
843            icon_set: IconSet::Lucide,
844            icon_theme: Some("lucide".into()),
845            light_icon_theme: Some("lucide".into()),
846            dark_icon_theme: None,
847            layout: LayoutTheme::default(),
848            accessibility: AccessibilityPreferences::default(),
849        };
850        assert_eq!(
851            st.pick(ColorMode::Dark).defaults.accent_color,
852            dark_resolved.defaults.accent_color
853        );
854        assert_eq!(
855            st.pick(ColorMode::Light).defaults.accent_color,
856            light_resolved.defaults.accent_color
857        );
858    }
859
860    // --- platform_preset_name() pure tests ---
861    // Tests the same logic path (parse_linux_desktop -> linux_preset_for_de) without env var mocking.
862
863    /// Prove that the sync `from_system()` API works without any async runtime.
864    /// On Linux with KDE feature: exercises pollster::block_on(from_system_inner()).
865    /// This acts as a compile-time and runtime gate that the sync path works.
866    #[test]
867    #[cfg(target_os = "linux")]
868    #[cfg(feature = "kde")]
869    fn sync_consumer_no_async_runtime() {
870        // Call the actual from_system() entry point.
871        // This exercises the pollster::block_on(pipeline::from_system_inner()) path.
872        // We don't assert Ok because the test environment may lack KDE config files,
873        // but the call must not panic and must return a Result (not hang or deadlock).
874        let _result = SystemTheme::from_system();
875    }
876
877    #[test]
878    #[cfg(target_os = "linux")]
879    fn test_platform_preset_name_kde() {
880        let preset = pipeline::linux_preset_for_de(detect::parse_linux_desktop("KDE"));
881        assert_eq!(preset.name, "kde-breeze");
882        assert!(preset.is_live);
883        assert_eq!(preset.live_name(), "kde-breeze-live");
884    }
885
886    #[test]
887    #[cfg(target_os = "linux")]
888    fn test_platform_preset_name_gnome() {
889        let preset = pipeline::linux_preset_for_de(detect::parse_linux_desktop("GNOME"));
890        assert_eq!(preset.name, "adwaita");
891        assert!(preset.is_live);
892        assert_eq!(preset.live_name(), "adwaita-live");
893    }
894
895    /// §11.2: `from_system()` is an extraction of the reader path, so it must
896    /// agree with `SystemTheme::from_system()` wherever both succeed, and it
897    /// must never return a non-finite or non-positive text scale.
898    #[test]
899    fn accessibility_preferences_from_system_is_consistent_with_system_theme() {
900        let prefs = AccessibilityPreferences::from_system();
901        assert!(prefs.text_scaling_factor.is_finite());
902        assert!(prefs.text_scaling_factor > 0.0);
903
904        // CI has no desktop; only compare when the full pipeline also works.
905        if let Ok(sys) = SystemTheme::from_system() {
906            assert_eq!(
907                prefs.text_scaling_factor,
908                sys.accessibility.text_scaling_factor
909            );
910            assert_eq!(prefs.high_contrast, sys.accessibility.high_contrast);
911            assert_eq!(
912                prefs.reduce_transparency,
913                sys.accessibility.reduce_transparency
914            );
915            // reduce_motion may additionally be true via detect::detect_reduced_motion().
916            assert!(prefs.reduce_motion || !sys.accessibility.reduce_motion);
917        }
918    }
919}
920
921// =============================================================================
922// Tests -- with_overlay
923// =============================================================================
924
925#[cfg(test)]
926#[allow(clippy::unwrap_used, clippy::expect_used)]
927mod overlay_tests {
928    use super::*;
929
930    /// Helper: build a SystemTheme from a preset via pipeline::run_pipeline.
931    /// Uses test-only Result handling (module has #[allow(clippy::unwrap_used)]).
932    fn default_system_theme() -> crate::Result<SystemTheme> {
933        let preset = Theme::preset("catppuccin-mocha")?;
934        let reader = ReaderResult {
935            output: ReaderOutput::Dual {
936                light: Box::new(preset.light.clone().unwrap_or_default()),
937                dark: Box::new(preset.dark.clone().unwrap_or_default()),
938            },
939            name: preset.name,
940            icon_set: preset.icon_set,
941            layout: preset.layout,
942            font_dpi: None,
943            accessibility: AccessibilityPreferences::default(),
944        };
945        pipeline::run_pipeline(reader, "catppuccin-mocha", ColorMode::Light)
946    }
947
948    #[test]
949    fn test_overlay_accent_propagates() -> crate::Result<()> {
950        let st = default_system_theme()?;
951        let new_accent = Rgba::rgb(255, 0, 0);
952
953        // Build overlay with accent on both light and dark
954        let mut overlay = Theme::default();
955        let mut light_v = ThemeMode::default();
956        light_v.defaults.accent_color = Some(new_accent);
957        let mut dark_v = ThemeMode::default();
958        dark_v.defaults.accent_color = Some(new_accent);
959        overlay.light = Some(light_v);
960        overlay.dark = Some(dark_v);
961
962        let result = st.with_overlay(&overlay)?;
963
964        // Accent itself
965        assert_eq!(result.light.defaults.accent_color, new_accent);
966        // Accent-derived widget fields
967        assert_eq!(result.light.button.primary_background, new_accent);
968        assert_eq!(result.light.checkbox.checked_background, new_accent);
969        assert_eq!(result.light.slider.fill_color, new_accent);
970        assert_eq!(result.light.progress_bar.fill_color, new_accent);
971        assert_eq!(result.light.switch.checked_background, new_accent);
972        // Additional accent-derived fields re-resolved via safety nets
973        assert_eq!(
974            result.light.spinner.fill_color, new_accent,
975            "spinner.fill should re-derive from new accent"
976        );
977        Ok(())
978    }
979
980    #[test]
981    fn test_overlay_preserves_unrelated_fields() -> crate::Result<()> {
982        let st = default_system_theme()?;
983        let original_bg = st.light.defaults.background_color;
984
985        // Apply overlay changing only accent
986        let mut overlay = Theme::default();
987        let mut light_v = ThemeMode::default();
988        light_v.defaults.accent_color = Some(Rgba::rgb(255, 0, 0));
989        overlay.light = Some(light_v);
990
991        let result = st.with_overlay(&overlay)?;
992        assert_eq!(
993            result.light.defaults.background_color, original_bg,
994            "background should be unchanged"
995        );
996        Ok(())
997    }
998
999    #[test]
1000    fn test_overlay_empty_noop() -> crate::Result<()> {
1001        let st = default_system_theme()?;
1002        let original_light_accent = st.light.defaults.accent_color;
1003        let original_dark_accent = st.dark.defaults.accent_color;
1004        let original_light_bg = st.light.defaults.background_color;
1005
1006        // Empty overlay
1007        let overlay = Theme::default();
1008        let result = st.with_overlay(&overlay)?;
1009
1010        assert_eq!(result.light.defaults.accent_color, original_light_accent);
1011        assert_eq!(result.dark.defaults.accent_color, original_dark_accent);
1012        assert_eq!(result.light.defaults.background_color, original_light_bg);
1013        Ok(())
1014    }
1015
1016    #[test]
1017    fn test_overlay_both_variants() -> crate::Result<()> {
1018        let st = default_system_theme()?;
1019        let red = Rgba::rgb(255, 0, 0);
1020        let green = Rgba::rgb(0, 255, 0);
1021
1022        let mut overlay = Theme::default();
1023        let mut light_v = ThemeMode::default();
1024        light_v.defaults.accent_color = Some(red);
1025        let mut dark_v = ThemeMode::default();
1026        dark_v.defaults.accent_color = Some(green);
1027        overlay.light = Some(light_v);
1028        overlay.dark = Some(dark_v);
1029
1030        let result = st.with_overlay(&overlay)?;
1031        assert_eq!(
1032            result.light.defaults.accent_color, red,
1033            "light accent = red"
1034        );
1035        assert_eq!(
1036            result.dark.defaults.accent_color, green,
1037            "dark accent = green"
1038        );
1039        Ok(())
1040    }
1041
1042    #[test]
1043    fn test_overlay_font_family() -> crate::Result<()> {
1044        let st = default_system_theme()?;
1045
1046        let mut overlay = Theme::default();
1047        let mut light_v = ThemeMode::default();
1048        light_v.defaults.font.family = Some("Comic Sans".into());
1049        overlay.light = Some(light_v);
1050
1051        let result = st.with_overlay(&overlay)?;
1052        assert_eq!(result.light.defaults.font.family.as_ref(), "Comic Sans");
1053        Ok(())
1054    }
1055
1056    #[test]
1057    fn test_overlay_roundtrip_via_overlay_source() -> crate::Result<()> {
1058        let st = default_system_theme()?;
1059        // Apply overlay and verify accent propagates
1060        let new_accent = Rgba::rgb(255, 0, 0);
1061        let mut overlay = Theme::default();
1062        let mut light_v = ThemeMode::default();
1063        light_v.defaults.accent_color = Some(new_accent);
1064        overlay.light = Some(light_v);
1065
1066        let result = st.with_overlay(&overlay)?;
1067        assert_eq!(result.light.defaults.accent_color, new_accent);
1068        // The dark variant should be unchanged from original
1069        assert_eq!(
1070            result.dark.defaults.accent_color,
1071            st.dark.defaults.accent_color
1072        );
1073        Ok(())
1074    }
1075
1076    #[test]
1077    fn test_overlay_source_no_variant_fields() -> crate::Result<()> {
1078        // Verify overlay_source exists on SystemTheme (compile-time structural check).
1079        // If light_variant or dark_variant fields still existed, this test would
1080        // need updating -- documenting the structural change.
1081        let st = default_system_theme()?;
1082        let _ = &st.overlay_source; // overlay_source exists
1083        Ok(())
1084    }
1085}