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