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