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