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}