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}