1#![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
17macro_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 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 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
87pub mod color;
89mod detect;
91pub mod error;
93#[cfg(all(target_os = "linux", feature = "portal"))]
95pub mod gnome;
96mod icons;
98#[cfg(all(target_os = "linux", feature = "kde"))]
100pub mod kde;
101pub mod model;
103mod pipeline;
105pub mod presets;
107mod resolve;
109#[cfg(any(
110 feature = "material-icons",
111 feature = "lucide-icons",
112 feature = "system-icons"
113))]
114mod spinners;
115#[cfg(test)]
117mod test_util;
118#[cfg(feature = "watch")]
120pub mod watch;
121
122pub use color::{ParseColorError, Rgba};
123pub use error::{Error, ThemeResolutionError};
124pub use model::{
125 AnimatedIcon, BorderSpec, ButtonTheme, CardTheme, CheckboxTheme, ComboBoxTheme,
126 DialogButtonOrder, DialogTheme, ExpanderTheme, FontSize, FontSpec, FontStyle, IconData,
127 IconProvider, IconRole, IconSet, IconSizes, InputTheme, LayoutTheme, LinkTheme, ListTheme,
128 MenuTheme, PopoverTheme, ProgressBarTheme, ResolvedBorderSpec, ResolvedFontSpec,
129 ResolvedIconSizes, ResolvedTextScale, ResolvedTextScaleEntry, ResolvedThemeDefaults,
130 ResolvedThemeVariant, ScrollbarTheme, SegmentedControlTheme, SeparatorTheme, SidebarTheme,
131 SliderTheme, SpinnerTheme, SplitterTheme, StatusBarTheme, SwitchTheme, TabTheme, TextScale,
132 TextScaleEntry, ThemeDefaults, ThemeSpec, ThemeVariant, ToolbarTheme, TooltipTheme,
133 TransformAnimation, WindowTheme, bundled_icon_by_name, bundled_icon_svg,
134};
135pub use model::icons::{detect_icon_theme, icon_name, system_icon_set, system_icon_theme};
137
138#[cfg(all(target_os = "linux", feature = "system-icons"))]
140pub mod freedesktop;
141#[cfg(target_os = "macos")]
143pub mod macos;
144#[cfg(not(target_os = "macos"))]
145pub(crate) mod macos;
146#[cfg(feature = "svg-rasterize")]
148pub mod rasterize;
149#[cfg(all(target_os = "macos", feature = "system-icons"))]
151pub mod sficons;
152#[cfg(target_os = "windows")]
154pub mod windows;
155#[cfg(not(target_os = "windows"))]
156#[allow(dead_code, unused_variables)]
157pub(crate) mod windows;
158#[cfg(all(target_os = "windows", feature = "system-icons"))]
160pub mod winicons;
161#[cfg(all(not(target_os = "windows"), feature = "system-icons"))]
162#[allow(dead_code, unused_imports)]
163pub(crate) mod winicons;
164
165#[cfg(all(target_os = "linux", feature = "system-icons"))]
166pub use freedesktop::{load_freedesktop_icon, load_freedesktop_icon_by_name};
167#[cfg(all(target_os = "linux", feature = "portal"))]
168pub use gnome::from_gnome;
169#[cfg(all(target_os = "linux", feature = "portal", feature = "kde"))]
170pub use gnome::from_kde_with_portal;
171#[cfg(all(target_os = "linux", feature = "kde"))]
172pub use kde::from_kde;
173#[cfg(all(target_os = "macos", feature = "macos"))]
174pub use macos::from_macos;
175#[cfg(feature = "svg-rasterize")]
176pub use rasterize::rasterize_svg;
177#[cfg(all(target_os = "macos", feature = "system-icons"))]
178pub use sficons::load_sf_icon;
179#[cfg(all(target_os = "macos", feature = "system-icons"))]
180pub use sficons::load_sf_icon_by_name;
181#[cfg(all(target_os = "windows", feature = "windows"))]
182pub use windows::from_windows;
183#[cfg(all(target_os = "windows", feature = "system-icons"))]
184pub use winicons::load_windows_icon;
185#[cfg(all(target_os = "windows", feature = "system-icons"))]
186pub use winicons::load_windows_icon_by_name;
187
188#[cfg(feature = "watch")]
189pub use watch::{ThemeChangeEvent, ThemeWatcher, on_theme_change};
190
191#[cfg(target_os = "linux")]
192pub use detect::LinuxDesktop;
193#[cfg(target_os = "linux")]
194pub use detect::detect_linux_de;
195pub use detect::{
196 detect_is_dark, detect_reduced_motion, invalidate_caches, prefers_reduced_motion,
197 system_is_dark,
198};
199pub use icons::{
200 is_freedesktop_theme_available, load_custom_icon, load_icon, load_icon_from_theme,
201 load_system_icon_by_name, loading_indicator,
202};
203pub use pipeline::{diagnose_platform_support, platform_preset_name};
204
205pub type Result<T> = std::result::Result<T, Error>;
207
208#[derive(Clone, Debug)]
215pub struct SystemTheme {
216 pub name: String,
218 pub is_dark: bool,
220 pub light: ResolvedThemeVariant,
222 pub dark: ResolvedThemeVariant,
224 pub(crate) light_variant: ThemeVariant,
226 pub(crate) dark_variant: ThemeVariant,
228 pub preset: String,
230 pub(crate) live_preset: String,
232}
233
234impl SystemTheme {
235 #[must_use]
239 pub fn active(&self) -> &ResolvedThemeVariant {
240 if self.is_dark {
241 &self.dark
242 } else {
243 &self.light
244 }
245 }
246
247 #[must_use]
251 pub fn pick(&self, is_dark: bool) -> &ResolvedThemeVariant {
252 if is_dark { &self.dark } else { &self.light }
253 }
254
255 #[must_use = "this returns a new theme with the overlay applied; it does not modify self"]
279 pub fn with_overlay(&self, overlay: &ThemeSpec) -> crate::Result<Self> {
280 let mut light = self.light_variant.clone();
282 let mut dark = self.dark_variant.clone();
283
284 if let Some(over) = &overlay.light {
286 light.merge(over);
287 }
288 if let Some(over) = &overlay.dark {
289 dark.merge(over);
290 }
291
292 let resolved_light = light.clone().into_resolved()?;
294 let resolved_dark = dark.clone().into_resolved()?;
295
296 Ok(SystemTheme {
297 name: self.name.clone(),
298 is_dark: self.is_dark,
299 light: resolved_light,
300 dark: resolved_dark,
301 light_variant: light,
302 dark_variant: dark,
303 live_preset: self.live_preset.clone(),
304 preset: self.preset.clone(),
305 })
306 }
307
308 #[must_use = "this returns a new theme with the overlay applied; it does not modify self"]
312 pub fn with_overlay_toml(&self, toml: &str) -> crate::Result<Self> {
313 let overlay = ThemeSpec::from_toml(toml)?;
314 self.with_overlay(&overlay)
315 }
316
317 #[must_use = "this returns the detected theme; it does not apply it"]
354 pub fn from_system() -> crate::Result<Self> {
355 pipeline::from_system_inner()
356 }
357
358 #[cfg(target_os = "linux")]
373 #[must_use = "this returns the detected theme; it does not apply it"]
374 pub async fn from_system_async() -> crate::Result<Self> {
375 pipeline::from_system_async_inner().await
376 }
377
378 #[cfg(not(target_os = "linux"))]
383 #[must_use = "this returns the detected theme; it does not apply it"]
384 pub async fn from_system_async() -> crate::Result<Self> {
385 pipeline::from_system_inner()
386 }
387}
388
389#[cfg(test)]
394#[allow(
395 clippy::unwrap_used,
396 clippy::expect_used,
397 clippy::field_reassign_with_default
398)]
399mod system_theme_tests {
400 use super::*;
401
402 #[test]
405 fn test_system_theme_active_dark() {
406 let preset = ThemeSpec::preset("catppuccin-mocha").unwrap();
407 let mut light_v = preset.light.clone().unwrap();
408 let mut dark_v = preset.dark.clone().unwrap();
409 light_v.defaults.accent_color = Some(Rgba::rgb(0, 0, 255));
412 dark_v.defaults.accent_color = Some(Rgba::rgb(255, 0, 0));
413 light_v.resolve_all();
414 dark_v.resolve_all();
415 let light_resolved = light_v.validate().unwrap();
416 let dark_resolved = dark_v.validate().unwrap();
417
418 let st = SystemTheme {
419 name: "test".into(),
420 is_dark: true,
421 light: light_resolved.clone(),
422 dark: dark_resolved.clone(),
423 light_variant: preset.light.unwrap(),
424 dark_variant: preset.dark.unwrap(),
425 live_preset: "catppuccin-mocha".into(),
426 preset: "catppuccin-mocha".into(),
427 };
428 assert_eq!(
429 st.active().defaults.accent_color,
430 dark_resolved.defaults.accent_color
431 );
432 }
433
434 #[test]
435 fn test_system_theme_active_light() {
436 let preset = ThemeSpec::preset("catppuccin-mocha").unwrap();
437 let mut light_v = preset.light.clone().unwrap();
438 let mut dark_v = preset.dark.clone().unwrap();
439 light_v.defaults.accent_color = Some(Rgba::rgb(0, 0, 255));
440 dark_v.defaults.accent_color = Some(Rgba::rgb(255, 0, 0));
441 light_v.resolve_all();
442 dark_v.resolve_all();
443 let light_resolved = light_v.validate().unwrap();
444 let dark_resolved = dark_v.validate().unwrap();
445
446 let st = SystemTheme {
447 name: "test".into(),
448 is_dark: false,
449 light: light_resolved.clone(),
450 dark: dark_resolved.clone(),
451 light_variant: preset.light.unwrap(),
452 dark_variant: preset.dark.unwrap(),
453 live_preset: "catppuccin-mocha".into(),
454 preset: "catppuccin-mocha".into(),
455 };
456 assert_eq!(
457 st.active().defaults.accent_color,
458 light_resolved.defaults.accent_color
459 );
460 }
461
462 #[test]
463 fn test_system_theme_pick() {
464 let preset = ThemeSpec::preset("catppuccin-mocha").unwrap();
465 let mut light_v = preset.light.clone().unwrap();
466 let mut dark_v = preset.dark.clone().unwrap();
467 light_v.defaults.accent_color = Some(Rgba::rgb(0, 0, 255));
468 dark_v.defaults.accent_color = Some(Rgba::rgb(255, 0, 0));
469 light_v.resolve_all();
470 dark_v.resolve_all();
471 let light_resolved = light_v.validate().unwrap();
472 let dark_resolved = dark_v.validate().unwrap();
473
474 let st = SystemTheme {
475 name: "test".into(),
476 is_dark: false,
477 light: light_resolved.clone(),
478 dark: dark_resolved.clone(),
479 light_variant: preset.light.unwrap(),
480 dark_variant: preset.dark.unwrap(),
481 live_preset: "catppuccin-mocha".into(),
482 preset: "catppuccin-mocha".into(),
483 };
484 assert_eq!(
485 st.pick(true).defaults.accent_color,
486 dark_resolved.defaults.accent_color
487 );
488 assert_eq!(
489 st.pick(false).defaults.accent_color,
490 light_resolved.defaults.accent_color
491 );
492 }
493
494 #[test]
497 #[cfg(target_os = "linux")]
498 #[allow(unsafe_code)]
499 fn test_platform_preset_name_kde() {
500 let _guard = crate::test_util::ENV_MUTEX.lock().unwrap();
501 unsafe { std::env::set_var("XDG_CURRENT_DESKTOP", "KDE") };
502 let name = platform_preset_name();
503 unsafe { std::env::remove_var("XDG_CURRENT_DESKTOP") };
504 assert_eq!(name, "kde-breeze-live");
505 }
506
507 #[test]
508 #[cfg(target_os = "linux")]
509 #[allow(unsafe_code)]
510 fn test_platform_preset_name_gnome() {
511 let _guard = crate::test_util::ENV_MUTEX.lock().unwrap();
512 unsafe { std::env::set_var("XDG_CURRENT_DESKTOP", "GNOME") };
513 let name = platform_preset_name();
514 unsafe { std::env::remove_var("XDG_CURRENT_DESKTOP") };
515 assert_eq!(name, "adwaita-live");
516 }
517}
518
519#[cfg(test)]
524#[allow(clippy::unwrap_used, clippy::expect_used)]
525mod overlay_tests {
526 use super::*;
527
528 fn default_system_theme() -> SystemTheme {
530 let reader = ThemeSpec::preset("catppuccin-mocha").unwrap();
531 pipeline::run_pipeline(reader, "catppuccin-mocha", false).unwrap()
532 }
533
534 #[test]
535 fn test_overlay_accent_propagates() {
536 let st = default_system_theme();
537 let new_accent = Rgba::rgb(255, 0, 0);
538
539 let mut overlay = ThemeSpec::default();
541 let mut light_v = ThemeVariant::default();
542 light_v.defaults.accent_color = Some(new_accent);
543 let mut dark_v = ThemeVariant::default();
544 dark_v.defaults.accent_color = Some(new_accent);
545 overlay.light = Some(light_v);
546 overlay.dark = Some(dark_v);
547
548 let result = st.with_overlay(&overlay).unwrap();
549
550 assert_eq!(result.light.defaults.accent_color, new_accent);
552 assert_eq!(result.light.button.primary_background, new_accent);
554 assert_eq!(result.light.checkbox.checked_background, new_accent);
555 assert_eq!(result.light.slider.fill_color, new_accent);
556 assert_eq!(result.light.progress_bar.fill_color, new_accent);
557 assert_eq!(result.light.switch.checked_background, new_accent);
558 assert_eq!(
560 result.light.spinner.fill_color, new_accent,
561 "spinner.fill should re-derive from new accent"
562 );
563 }
564
565 #[test]
566 fn test_overlay_preserves_unrelated_fields() {
567 let st = default_system_theme();
568 let original_bg = st.light.defaults.background_color;
569
570 let mut overlay = ThemeSpec::default();
572 let mut light_v = ThemeVariant::default();
573 light_v.defaults.accent_color = Some(Rgba::rgb(255, 0, 0));
574 overlay.light = Some(light_v);
575
576 let result = st.with_overlay(&overlay).unwrap();
577 assert_eq!(
578 result.light.defaults.background_color, original_bg,
579 "background should be unchanged"
580 );
581 }
582
583 #[test]
584 fn test_overlay_empty_noop() {
585 let st = default_system_theme();
586 let original_light_accent = st.light.defaults.accent_color;
587 let original_dark_accent = st.dark.defaults.accent_color;
588 let original_light_bg = st.light.defaults.background_color;
589
590 let overlay = ThemeSpec::default();
592 let result = st.with_overlay(&overlay).unwrap();
593
594 assert_eq!(result.light.defaults.accent_color, original_light_accent);
595 assert_eq!(result.dark.defaults.accent_color, original_dark_accent);
596 assert_eq!(result.light.defaults.background_color, original_light_bg);
597 }
598
599 #[test]
600 fn test_overlay_both_variants() {
601 let st = default_system_theme();
602 let red = Rgba::rgb(255, 0, 0);
603 let green = Rgba::rgb(0, 255, 0);
604
605 let mut overlay = ThemeSpec::default();
606 let mut light_v = ThemeVariant::default();
607 light_v.defaults.accent_color = Some(red);
608 let mut dark_v = ThemeVariant::default();
609 dark_v.defaults.accent_color = Some(green);
610 overlay.light = Some(light_v);
611 overlay.dark = Some(dark_v);
612
613 let result = st.with_overlay(&overlay).unwrap();
614 assert_eq!(
615 result.light.defaults.accent_color, red,
616 "light accent = red"
617 );
618 assert_eq!(
619 result.dark.defaults.accent_color, green,
620 "dark accent = green"
621 );
622 }
623
624 #[test]
625 fn test_overlay_font_family() {
626 let st = default_system_theme();
627
628 let mut overlay = ThemeSpec::default();
629 let mut light_v = ThemeVariant::default();
630 light_v.defaults.font.family = Some("Comic Sans".into());
631 overlay.light = Some(light_v);
632
633 let result = st.with_overlay(&overlay).unwrap();
634 assert_eq!(result.light.defaults.font.family, "Comic Sans");
635 }
636
637 #[test]
638 fn test_overlay_toml_convenience() {
639 let st = default_system_theme();
640 let result = st
641 .with_overlay_toml(
642 r##"
643 name = "overlay"
644 [light.defaults]
645 accent_color = "#ff0000"
646 "##,
647 )
648 .unwrap();
649 assert_eq!(result.light.defaults.accent_color, Rgba::rgb(255, 0, 0));
650 }
651}