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 {
39 (
40 $struct_name:ident {
41 $(option { $($opt_field:ident),* $(,)? })?
42 $(nested { $($nest_field:ident),* $(,)? })?
43 $(optional_nested { $($on_field:ident),* $(,)? })?
44 }
45 ) => {
46 impl $struct_name {
47 pub fn merge(&mut self, overlay: &Self) {
51 $($(
52 if overlay.$opt_field.is_some() {
53 self.$opt_field = overlay.$opt_field.clone();
54 }
55 )*)?
56 $($(
57 self.$nest_field.merge(&overlay.$nest_field);
58 )*)?
59 $($(
60 match (&mut self.$on_field, &overlay.$on_field) {
61 (Some(base), Some(over)) => base.merge(over),
62 (None, Some(over)) => self.$on_field = Some(over.clone()),
63 _ => {}
64 }
65 )*)?
66 }
67
68 pub fn is_empty(&self) -> bool {
70 true
71 $($(&& self.$opt_field.is_none())*)?
72 $($(&& self.$nest_field.is_empty())*)?
73 $($(&& self.$on_field.is_none())*)?
74 }
75 }
76 };
77}
78
79pub mod color;
81pub mod error;
83#[cfg(all(target_os = "linux", feature = "portal"))]
85pub mod gnome;
86#[cfg(all(target_os = "linux", feature = "kde"))]
88pub mod kde;
89pub mod model;
91pub mod presets;
93mod resolve;
95#[cfg(any(
96 feature = "material-icons",
97 feature = "lucide-icons",
98 feature = "system-icons"
99))]
100mod spinners;
101
102pub use color::{ParseColorError, Rgba};
103pub use error::{Error, ThemeResolutionError};
104pub use model::{
105 AnimatedIcon, ButtonTheme, CardTheme, CheckboxTheme, ComboBoxTheme, DialogButtonOrder,
106 DialogTheme, ExpanderTheme, FontSpec, IconData, IconProvider, IconRole, IconSet, IconSizes,
107 InputTheme, LinkTheme, ListTheme, MenuTheme, PopoverTheme, ProgressBarTheme, ResolvedFontSpec,
108 ResolvedIconSizes, ResolvedTextScale, ResolvedTextScaleEntry, ResolvedThemeDefaults,
109 ResolvedThemeSpacing, ResolvedThemeVariant, ScrollbarTheme, SegmentedControlTheme,
110 SeparatorTheme, SidebarTheme, SliderTheme, SpinnerTheme, SplitterTheme, StatusBarTheme,
111 SwitchTheme, TabTheme, TextScale, TextScaleEntry, ThemeDefaults, ThemeSpacing, ThemeSpec,
112 ThemeVariant, ToolbarTheme, TooltipTheme, TransformAnimation, WindowTheme,
113 bundled_icon_by_name, bundled_icon_svg,
114};
115pub use model::icons::{detect_icon_theme, icon_name, system_icon_set, system_icon_theme};
117
118#[cfg(all(target_os = "linux", feature = "system-icons"))]
120pub mod freedesktop;
121#[cfg(target_os = "macos")]
123pub mod macos;
124#[cfg(not(target_os = "macos"))]
125pub(crate) mod macos;
126#[cfg(feature = "svg-rasterize")]
128pub mod rasterize;
129#[cfg(all(target_os = "macos", feature = "system-icons"))]
131pub mod sficons;
132#[cfg(target_os = "windows")]
134pub mod windows;
135#[cfg(not(target_os = "windows"))]
136#[allow(dead_code, unused_variables)]
137pub(crate) mod windows;
138#[cfg(all(target_os = "windows", feature = "system-icons"))]
140pub mod winicons;
141#[cfg(all(not(target_os = "windows"), feature = "system-icons"))]
142#[allow(dead_code, unused_imports)]
143pub(crate) mod winicons;
144
145#[cfg(all(target_os = "linux", feature = "system-icons"))]
146pub use freedesktop::{load_freedesktop_icon, load_freedesktop_icon_by_name};
147#[cfg(all(target_os = "linux", feature = "portal"))]
148pub use gnome::from_gnome;
149#[cfg(all(target_os = "linux", feature = "portal", feature = "kde"))]
150pub use gnome::from_kde_with_portal;
151#[cfg(all(target_os = "linux", feature = "kde"))]
152pub use kde::from_kde;
153#[cfg(all(target_os = "macos", feature = "macos"))]
154pub use macos::from_macos;
155#[cfg(feature = "svg-rasterize")]
156pub use rasterize::rasterize_svg;
157#[cfg(all(target_os = "macos", feature = "system-icons"))]
158pub use sficons::load_sf_icon;
159#[cfg(all(target_os = "macos", feature = "system-icons"))]
160pub use sficons::load_sf_icon_by_name;
161#[cfg(all(target_os = "windows", feature = "windows"))]
162pub use windows::from_windows;
163#[cfg(all(target_os = "windows", feature = "system-icons"))]
164pub use winicons::load_windows_icon;
165#[cfg(all(target_os = "windows", feature = "system-icons"))]
166pub use winicons::load_windows_icon_by_name;
167
168pub type Result<T> = std::result::Result<T, Error>;
170
171#[cfg(target_os = "linux")]
173#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
174pub enum LinuxDesktop {
175 Kde,
177 Gnome,
179 Xfce,
181 Cinnamon,
183 Mate,
185 LxQt,
187 Budgie,
189 Unknown,
191}
192
193#[cfg(target_os = "linux")]
199#[must_use]
200pub fn detect_linux_de(xdg_current_desktop: &str) -> LinuxDesktop {
201 for component in xdg_current_desktop.split(':') {
202 match component {
203 "KDE" => return LinuxDesktop::Kde,
204 "Budgie" => return LinuxDesktop::Budgie,
205 "GNOME" => return LinuxDesktop::Gnome,
206 "XFCE" => return LinuxDesktop::Xfce,
207 "X-Cinnamon" | "Cinnamon" => return LinuxDesktop::Cinnamon,
208 "MATE" => return LinuxDesktop::Mate,
209 "LXQt" => return LinuxDesktop::LxQt,
210 _ => {}
211 }
212 }
213 LinuxDesktop::Unknown
214}
215
216#[must_use = "this returns whether the system uses dark mode"]
244pub fn system_is_dark() -> bool {
245 static CACHED_IS_DARK: std::sync::OnceLock<bool> = std::sync::OnceLock::new();
246 *CACHED_IS_DARK.get_or_init(detect_is_dark_inner)
247}
248
249#[must_use = "this returns whether the system uses dark mode"]
257pub fn detect_is_dark() -> bool {
258 detect_is_dark_inner()
259}
260
261#[allow(unreachable_code)]
265fn detect_is_dark_inner() -> bool {
266 #[cfg(target_os = "linux")]
267 {
268 if let Ok(output) = std::process::Command::new("gsettings")
270 .args(["get", "org.gnome.desktop.interface", "color-scheme"])
271 .output()
272 && output.status.success()
273 {
274 let val = String::from_utf8_lossy(&output.stdout);
275 if val.contains("prefer-dark") {
276 return true;
277 }
278 if val.contains("prefer-light") || val.contains("default") {
279 return false;
280 }
281 }
282
283 #[cfg(feature = "kde")]
285 {
286 let path = crate::kde::kdeglobals_path();
287 if let Ok(content) = std::fs::read_to_string(&path) {
288 let mut ini = crate::kde::create_kde_parser();
289 if ini.read(content).is_ok() {
290 return crate::kde::is_dark_theme(&ini);
291 }
292 }
293 }
294
295 false
296 }
297
298 #[cfg(target_os = "macos")]
299 {
300 #[cfg(feature = "macos")]
303 {
304 use objc2_foundation::NSUserDefaults;
305 let defaults = NSUserDefaults::standardUserDefaults();
306 let key = objc2_foundation::ns_string!("AppleInterfaceStyle");
307 if let Some(value) = defaults.stringForKey(key) {
308 return value.to_string().eq_ignore_ascii_case("dark");
309 }
310 return false;
311 }
312 #[cfg(not(feature = "macos"))]
313 {
314 if let Ok(output) = std::process::Command::new("defaults")
315 .args(["read", "-g", "AppleInterfaceStyle"])
316 .output()
317 && output.status.success()
318 {
319 let val = String::from_utf8_lossy(&output.stdout);
320 return val.trim().eq_ignore_ascii_case("dark");
321 }
322 return false;
323 }
324 }
325
326 #[cfg(target_os = "windows")]
327 {
328 #[cfg(feature = "windows")]
329 {
330 let Ok(settings) = ::windows::UI::ViewManagement::UISettings::new() else {
332 return false;
333 };
334 let Ok(fg) =
335 settings.GetColorValue(::windows::UI::ViewManagement::UIColorType::Foreground)
336 else {
337 return false;
338 };
339 let luma = 0.299 * (fg.R as f32) + 0.587 * (fg.G as f32) + 0.114 * (fg.B as f32);
340 return luma > 128.0;
341 }
342 #[cfg(not(feature = "windows"))]
343 return false;
344 }
345
346 #[cfg(not(any(target_os = "linux", target_os = "macos", target_os = "windows")))]
347 {
348 false
349 }
350}
351
352#[must_use = "this returns whether reduced motion is preferred"]
382pub fn prefers_reduced_motion() -> bool {
383 static CACHED: std::sync::OnceLock<bool> = std::sync::OnceLock::new();
384 *CACHED.get_or_init(detect_reduced_motion_inner)
385}
386
387#[must_use = "this returns whether reduced motion is preferred"]
395pub fn detect_reduced_motion() -> bool {
396 detect_reduced_motion_inner()
397}
398
399#[allow(unreachable_code)]
403fn detect_reduced_motion_inner() -> bool {
404 #[cfg(target_os = "linux")]
405 {
406 if let Ok(output) = std::process::Command::new("gsettings")
409 .args(["get", "org.gnome.desktop.interface", "enable-animations"])
410 .output()
411 && output.status.success()
412 {
413 let val = String::from_utf8_lossy(&output.stdout);
414 return val.trim() == "false";
415 }
416 false
417 }
418
419 #[cfg(target_os = "macos")]
420 {
421 #[cfg(feature = "macos")]
422 {
423 let workspace = objc2_app_kit::NSWorkspace::sharedWorkspace();
424 return workspace.accessibilityDisplayShouldReduceMotion();
426 }
427 #[cfg(not(feature = "macos"))]
428 return false;
429 }
430
431 #[cfg(target_os = "windows")]
432 {
433 #[cfg(feature = "windows")]
434 {
435 let Ok(settings) = ::windows::UI::ViewManagement::UISettings::new() else {
436 return false;
437 };
438 return match settings.AnimationsEnabled() {
440 Ok(enabled) => !enabled,
441 Err(_) => false,
442 };
443 }
444 #[cfg(not(feature = "windows"))]
445 return false;
446 }
447
448 #[cfg(not(any(target_os = "linux", target_os = "macos", target_os = "windows")))]
449 {
450 false
451 }
452}
453
454#[derive(Clone, Debug)]
461pub struct SystemTheme {
462 pub name: String,
464 pub is_dark: bool,
466 pub light: ResolvedThemeVariant,
468 pub dark: ResolvedThemeVariant,
470 pub(crate) light_variant: ThemeVariant,
472 pub(crate) dark_variant: ThemeVariant,
474 pub preset: String,
476 pub(crate) live_preset: String,
478}
479
480impl SystemTheme {
481 #[must_use]
485 pub fn active(&self) -> &ResolvedThemeVariant {
486 if self.is_dark {
487 &self.dark
488 } else {
489 &self.light
490 }
491 }
492
493 #[must_use]
497 pub fn pick(&self, is_dark: bool) -> &ResolvedThemeVariant {
498 if is_dark { &self.dark } else { &self.light }
499 }
500
501 #[must_use = "this returns a new theme with the overlay applied; it does not modify self"]
525 pub fn with_overlay(&self, overlay: &ThemeSpec) -> crate::Result<Self> {
526 let mut light = self.light_variant.clone();
528 let mut dark = self.dark_variant.clone();
529
530 if let Some(over) = &overlay.light {
532 light.merge(over);
533 }
534 if let Some(over) = &overlay.dark {
535 dark.merge(over);
536 }
537
538 let resolved_light = light.clone().into_resolved()?;
540 let resolved_dark = dark.clone().into_resolved()?;
541
542 Ok(SystemTheme {
543 name: self.name.clone(),
544 is_dark: self.is_dark,
545 light: resolved_light,
546 dark: resolved_dark,
547 light_variant: light,
548 dark_variant: dark,
549 live_preset: self.live_preset.clone(),
550 preset: self.preset.clone(),
551 })
552 }
553
554 #[must_use = "this returns a new theme with the overlay applied; it does not modify self"]
558 pub fn with_overlay_toml(&self, toml: &str) -> crate::Result<Self> {
559 let overlay = ThemeSpec::from_toml(toml)?;
560 self.with_overlay(&overlay)
561 }
562
563 #[must_use = "this returns the detected theme; it does not apply it"]
600 pub fn from_system() -> crate::Result<Self> {
601 from_system_inner()
602 }
603
604 #[cfg(target_os = "linux")]
619 #[must_use = "this returns the detected theme; it does not apply it"]
620 pub async fn from_system_async() -> crate::Result<Self> {
621 from_system_async_inner().await
622 }
623
624 #[cfg(not(target_os = "linux"))]
629 #[must_use = "this returns the detected theme; it does not apply it"]
630 pub async fn from_system_async() -> crate::Result<Self> {
631 from_system_inner()
632 }
633}
634
635fn run_pipeline(
642 reader_output: ThemeSpec,
643 preset_name: &str,
644 is_dark: bool,
645) -> crate::Result<SystemTheme> {
646 let live_preset = ThemeSpec::preset(preset_name)?;
647
648 let full_preset_name = preset_name.strip_suffix("-live").unwrap_or(preset_name);
650 let full_preset = ThemeSpec::preset(full_preset_name)?;
651
652 let mut merged = full_preset.clone();
655 merged.merge(&live_preset);
656 merged.merge(&reader_output);
657
658 let name = if reader_output.name.is_empty() {
660 merged.name.clone()
661 } else {
662 reader_output.name.clone()
663 };
664
665 let light_variant = if reader_output.light.is_some() {
668 merged.light.unwrap_or_default()
669 } else {
670 full_preset.light.unwrap_or_default()
671 };
672
673 let dark_variant = if reader_output.dark.is_some() {
674 merged.dark.unwrap_or_default()
675 } else {
676 full_preset.dark.unwrap_or_default()
677 };
678
679 let light_variant_pre = light_variant.clone();
681 let dark_variant_pre = dark_variant.clone();
682
683 let light = light_variant.into_resolved()?;
684 let dark = dark_variant.into_resolved()?;
685
686 Ok(SystemTheme {
687 name,
688 is_dark,
689 light,
690 dark,
691 light_variant: light_variant_pre,
692 dark_variant: dark_variant_pre,
693 preset: full_preset_name.to_string(),
694 live_preset: preset_name.to_string(),
695 })
696}
697
698#[cfg(target_os = "linux")]
708fn linux_preset_for_de(de: LinuxDesktop) -> &'static str {
709 match de {
710 LinuxDesktop::Kde => "kde-breeze-live",
711 _ => "adwaita-live",
712 }
713}
714
715#[allow(unreachable_code)]
731#[must_use]
732pub fn platform_preset_name() -> &'static str {
733 #[cfg(target_os = "macos")]
734 {
735 return "macos-sonoma-live";
736 }
737 #[cfg(target_os = "windows")]
738 {
739 return "windows-11-live";
740 }
741 #[cfg(target_os = "linux")]
742 {
743 let desktop = std::env::var("XDG_CURRENT_DESKTOP").unwrap_or_default();
744 linux_preset_for_de(detect_linux_de(&desktop))
745 }
746 #[cfg(not(any(target_os = "linux", target_os = "windows", target_os = "macos")))]
747 {
748 "adwaita-live"
749 }
750}
751
752#[must_use]
776pub fn diagnose_platform_support() -> Vec<String> {
777 let mut diagnostics = Vec::new();
778
779 #[cfg(target_os = "linux")]
780 {
781 diagnostics.push("Platform: Linux".to_string());
782
783 match std::env::var("XDG_CURRENT_DESKTOP") {
785 Ok(val) if !val.is_empty() => {
786 let de = detect_linux_de(&val);
787 diagnostics.push(format!("XDG_CURRENT_DESKTOP: {val}"));
788 diagnostics.push(format!("Detected DE: {de:?}"));
789 }
790 _ => {
791 diagnostics.push("XDG_CURRENT_DESKTOP: not set".to_string());
792 diagnostics.push("Detected DE: Unknown (env var missing)".to_string());
793 }
794 }
795
796 match std::process::Command::new("gsettings")
798 .arg("--version")
799 .output()
800 {
801 Ok(output) if output.status.success() => {
802 let version = String::from_utf8_lossy(&output.stdout);
803 diagnostics.push(format!("gsettings: available ({})", version.trim()));
804 }
805 Ok(_) => {
806 diagnostics.push("gsettings: found but returned error".to_string());
807 }
808 Err(_) => {
809 diagnostics.push(
810 "gsettings: not found (dark mode and icon theme detection may be limited)"
811 .to_string(),
812 );
813 }
814 }
815
816 #[cfg(feature = "kde")]
818 {
819 let path = crate::kde::kdeglobals_path();
820 if path.exists() {
821 diagnostics.push(format!("KDE kdeglobals: found at {}", path.display()));
822 } else {
823 diagnostics.push(format!("KDE kdeglobals: not found at {}", path.display()));
824 }
825 }
826
827 #[cfg(not(feature = "kde"))]
828 {
829 diagnostics.push("KDE support: disabled (kde feature not enabled)".to_string());
830 }
831
832 #[cfg(feature = "portal")]
834 diagnostics.push("Portal support: enabled".to_string());
835
836 #[cfg(not(feature = "portal"))]
837 diagnostics.push("Portal support: disabled (portal feature not enabled)".to_string());
838 }
839
840 #[cfg(target_os = "macos")]
841 {
842 diagnostics.push("Platform: macOS".to_string());
843
844 #[cfg(feature = "macos")]
845 diagnostics.push("macOS theme detection: enabled (macos feature active)".to_string());
846
847 #[cfg(not(feature = "macos"))]
848 diagnostics.push(
849 "macOS theme detection: limited (macos feature not enabled, using subprocess fallback)"
850 .to_string(),
851 );
852 }
853
854 #[cfg(target_os = "windows")]
855 {
856 diagnostics.push("Platform: Windows".to_string());
857
858 #[cfg(feature = "windows")]
859 diagnostics.push("Windows theme detection: enabled (windows feature active)".to_string());
860
861 #[cfg(not(feature = "windows"))]
862 diagnostics
863 .push("Windows theme detection: disabled (windows feature not enabled)".to_string());
864 }
865
866 #[cfg(not(any(target_os = "linux", target_os = "macos", target_os = "windows")))]
867 {
868 diagnostics.push("Platform: unsupported (no native theme detection available)".to_string());
869 }
870
871 diagnostics
872}
873
874#[allow(dead_code)]
882fn reader_is_dark(reader: &ThemeSpec) -> bool {
883 reader.dark.is_some() && reader.light.is_none()
884}
885
886#[cfg(target_os = "linux")]
892fn from_linux() -> crate::Result<SystemTheme> {
893 let is_dark = system_is_dark();
894 let desktop = std::env::var("XDG_CURRENT_DESKTOP").unwrap_or_default();
895 let de = detect_linux_de(&desktop);
896 let preset = linux_preset_for_de(de);
897 match de {
898 #[cfg(feature = "kde")]
899 LinuxDesktop::Kde => {
900 let reader = crate::kde::from_kde()?;
901 run_pipeline(reader, preset, is_dark)
902 }
903 #[cfg(not(feature = "kde"))]
904 LinuxDesktop::Kde => run_pipeline(ThemeSpec::preset("adwaita")?, "adwaita-live", is_dark),
905 LinuxDesktop::Gnome | LinuxDesktop::Budgie => {
906 run_pipeline(ThemeSpec::preset("adwaita")?, preset, is_dark)
908 }
909 LinuxDesktop::Xfce | LinuxDesktop::Cinnamon | LinuxDesktop::Mate | LinuxDesktop::LxQt => {
910 run_pipeline(ThemeSpec::preset("adwaita")?, preset, is_dark)
911 }
912 LinuxDesktop::Unknown => {
913 #[cfg(feature = "kde")]
914 {
915 let path = crate::kde::kdeglobals_path();
916 if path.exists() {
917 let reader = crate::kde::from_kde()?;
918 return run_pipeline(reader, linux_preset_for_de(LinuxDesktop::Kde), is_dark);
919 }
920 }
921 run_pipeline(ThemeSpec::preset("adwaita")?, preset, is_dark)
922 }
923 }
924}
925
926fn from_system_inner() -> crate::Result<SystemTheme> {
927 #[cfg(target_os = "macos")]
928 {
929 #[cfg(feature = "macos")]
930 {
931 let reader = crate::macos::from_macos()?;
932 let is_dark = reader_is_dark(&reader);
933 return run_pipeline(reader, "macos-sonoma-live", is_dark);
934 }
935
936 #[cfg(not(feature = "macos"))]
937 return Err(crate::Error::Unsupported(
938 "macOS theme detection requires the `macos` feature",
939 ));
940 }
941
942 #[cfg(target_os = "windows")]
943 {
944 #[cfg(feature = "windows")]
945 {
946 let reader = crate::windows::from_windows()?;
947 let is_dark = reader_is_dark(&reader);
948 return run_pipeline(reader, "windows-11-live", is_dark);
949 }
950
951 #[cfg(not(feature = "windows"))]
952 return Err(crate::Error::Unsupported(
953 "Windows theme detection requires the `windows` feature",
954 ));
955 }
956
957 #[cfg(target_os = "linux")]
958 {
959 from_linux()
960 }
961
962 #[cfg(not(any(target_os = "linux", target_os = "windows", target_os = "macos")))]
963 {
964 Err(crate::Error::Unsupported(
965 "no theme reader available for this platform",
966 ))
967 }
968}
969
970#[cfg(target_os = "linux")]
971async fn from_system_async_inner() -> crate::Result<SystemTheme> {
972 let is_dark = system_is_dark();
973 let desktop = std::env::var("XDG_CURRENT_DESKTOP").unwrap_or_default();
974 let de = detect_linux_de(&desktop);
975 let preset = linux_preset_for_de(de);
976 match de {
977 #[cfg(feature = "kde")]
978 LinuxDesktop::Kde => {
979 #[cfg(feature = "portal")]
980 {
981 let reader = crate::gnome::from_kde_with_portal().await?;
982 run_pipeline(reader, preset, is_dark)
983 }
984 #[cfg(not(feature = "portal"))]
985 {
986 let reader = crate::kde::from_kde()?;
987 run_pipeline(reader, preset, is_dark)
988 }
989 }
990 #[cfg(not(feature = "kde"))]
991 LinuxDesktop::Kde => run_pipeline(ThemeSpec::preset("adwaita")?, "adwaita-live", is_dark),
992 #[cfg(feature = "portal")]
993 LinuxDesktop::Gnome | LinuxDesktop::Budgie => {
994 let reader = crate::gnome::from_gnome().await?;
995 run_pipeline(reader, preset, is_dark)
996 }
997 #[cfg(not(feature = "portal"))]
998 LinuxDesktop::Gnome | LinuxDesktop::Budgie => {
999 run_pipeline(ThemeSpec::preset("adwaita")?, preset, is_dark)
1000 }
1001 LinuxDesktop::Xfce | LinuxDesktop::Cinnamon | LinuxDesktop::Mate | LinuxDesktop::LxQt => {
1002 run_pipeline(ThemeSpec::preset("adwaita")?, preset, is_dark)
1003 }
1004 LinuxDesktop::Unknown => {
1005 #[cfg(feature = "portal")]
1007 {
1008 if let Some(detected) = crate::gnome::detect_portal_backend().await {
1009 let detected_preset = linux_preset_for_de(detected);
1010 return match detected {
1011 #[cfg(feature = "kde")]
1012 LinuxDesktop::Kde => {
1013 let reader = crate::gnome::from_kde_with_portal().await?;
1014 run_pipeline(reader, detected_preset, is_dark)
1015 }
1016 #[cfg(not(feature = "kde"))]
1017 LinuxDesktop::Kde => {
1018 run_pipeline(ThemeSpec::preset("adwaita")?, "adwaita-live", is_dark)
1019 }
1020 LinuxDesktop::Gnome => {
1021 let reader = crate::gnome::from_gnome().await?;
1022 run_pipeline(reader, detected_preset, is_dark)
1023 }
1024 _ => {
1025 run_pipeline(ThemeSpec::preset("adwaita")?, detected_preset, is_dark)
1028 }
1029 };
1030 }
1031 }
1032 #[cfg(feature = "kde")]
1034 {
1035 let path = crate::kde::kdeglobals_path();
1036 if path.exists() {
1037 let reader = crate::kde::from_kde()?;
1038 return run_pipeline(reader, linux_preset_for_de(LinuxDesktop::Kde), is_dark);
1039 }
1040 }
1041 run_pipeline(ThemeSpec::preset("adwaita")?, preset, is_dark)
1042 }
1043 }
1044}
1045
1046#[must_use = "this returns the loaded icon data; it does not display it"]
1079#[allow(unreachable_patterns, clippy::needless_return, unused_variables)]
1080pub fn load_icon(role: IconRole, set: IconSet) -> Option<IconData> {
1081 match set {
1082 #[cfg(all(target_os = "linux", feature = "system-icons"))]
1083 IconSet::Freedesktop => freedesktop::load_freedesktop_icon(role, 24),
1084
1085 #[cfg(all(target_os = "macos", feature = "system-icons"))]
1086 IconSet::SfSymbols => sficons::load_sf_icon(role),
1087
1088 #[cfg(all(target_os = "windows", feature = "system-icons"))]
1089 IconSet::SegoeIcons => winicons::load_windows_icon(role),
1090
1091 #[cfg(feature = "material-icons")]
1092 IconSet::Material => {
1093 bundled_icon_svg(role, IconSet::Material).map(|b| IconData::Svg(b.to_vec()))
1094 }
1095
1096 #[cfg(feature = "lucide-icons")]
1097 IconSet::Lucide => {
1098 bundled_icon_svg(role, IconSet::Lucide).map(|b| IconData::Svg(b.to_vec()))
1099 }
1100
1101 _ => None,
1103 }
1104}
1105
1106#[must_use = "this returns the loaded icon data; it does not display it"]
1131#[allow(unreachable_patterns, clippy::needless_return, unused_variables)]
1132pub fn load_icon_from_theme(
1133 role: IconRole,
1134 set: IconSet,
1135 preferred_theme: &str,
1136) -> Option<IconData> {
1137 match set {
1138 #[cfg(all(target_os = "linux", feature = "system-icons"))]
1139 IconSet::Freedesktop => {
1140 let name = icon_name(role, IconSet::Freedesktop)?;
1141 freedesktop::load_freedesktop_icon_by_name(name, preferred_theme, 24)
1142 }
1143
1144 _ => load_icon(role, set),
1146 }
1147}
1148
1149#[must_use]
1157pub fn is_freedesktop_theme_available(theme: &str) -> bool {
1158 #[cfg(target_os = "linux")]
1159 {
1160 let data_dirs = std::env::var("XDG_DATA_DIRS")
1161 .unwrap_or_else(|_| "/usr/share:/usr/local/share".to_string());
1162 for dir in data_dirs.split(':') {
1163 if std::path::Path::new(dir)
1164 .join("icons")
1165 .join(theme)
1166 .join("index.theme")
1167 .exists()
1168 {
1169 return true;
1170 }
1171 }
1172 let data_home = std::env::var("XDG_DATA_HOME").unwrap_or_else(|_| {
1173 std::env::var("HOME")
1174 .map(|h| format!("{h}/.local/share"))
1175 .unwrap_or_default()
1176 });
1177 if !data_home.is_empty() {
1178 return std::path::Path::new(&data_home)
1179 .join("icons")
1180 .join(theme)
1181 .join("index.theme")
1182 .exists();
1183 }
1184 false
1185 }
1186 #[cfg(not(target_os = "linux"))]
1187 {
1188 false
1189 }
1190}
1191
1192#[must_use = "this returns the loaded icon data; it does not display it"]
1215#[allow(unreachable_patterns, unused_variables)]
1216pub fn load_system_icon_by_name(name: &str, set: IconSet) -> Option<IconData> {
1217 match set {
1218 #[cfg(all(target_os = "linux", feature = "system-icons"))]
1219 IconSet::Freedesktop => {
1220 let theme = system_icon_theme();
1221 freedesktop::load_freedesktop_icon_by_name(name, theme, 24)
1222 }
1223
1224 #[cfg(all(target_os = "macos", feature = "system-icons"))]
1225 IconSet::SfSymbols => sficons::load_sf_icon_by_name(name),
1226
1227 #[cfg(all(target_os = "windows", feature = "system-icons"))]
1228 IconSet::SegoeIcons => winicons::load_windows_icon_by_name(name),
1229
1230 #[cfg(feature = "material-icons")]
1231 IconSet::Material => {
1232 bundled_icon_by_name(name, IconSet::Material).map(|b| IconData::Svg(b.to_vec()))
1233 }
1234
1235 #[cfg(feature = "lucide-icons")]
1236 IconSet::Lucide => {
1237 bundled_icon_by_name(name, IconSet::Lucide).map(|b| IconData::Svg(b.to_vec()))
1238 }
1239
1240 _ => None,
1241 }
1242}
1243
1244#[must_use = "this returns animation data; it does not display anything"]
1264pub fn loading_indicator(set: IconSet) -> Option<AnimatedIcon> {
1265 match set {
1266 #[cfg(all(target_os = "linux", feature = "system-icons"))]
1267 IconSet::Freedesktop => freedesktop::load_freedesktop_spinner(),
1268
1269 #[cfg(feature = "material-icons")]
1270 IconSet::Material => Some(spinners::material_spinner()),
1271
1272 #[cfg(feature = "lucide-icons")]
1273 IconSet::Lucide => Some(spinners::lucide_spinner()),
1274
1275 _ => None,
1276 }
1277}
1278
1279#[must_use = "this returns the loaded icon data; it does not display it"]
1302pub fn load_custom_icon(provider: &(impl IconProvider + ?Sized), set: IconSet) -> Option<IconData> {
1303 if let Some(name) = provider.icon_name(set)
1305 && let Some(data) = load_system_icon_by_name(name, set)
1306 {
1307 return Some(data);
1308 }
1309
1310 if let Some(svg) = provider.icon_svg(set) {
1312 return Some(IconData::Svg(svg.to_vec()));
1313 }
1314
1315 None
1317}
1318
1319#[cfg(test)]
1323pub(crate) static ENV_MUTEX: std::sync::Mutex<()> = std::sync::Mutex::new(());
1324
1325#[cfg(all(test, target_os = "linux"))]
1326#[allow(clippy::unwrap_used, clippy::expect_used)]
1327mod dispatch_tests {
1328 use super::*;
1329
1330 #[test]
1333 fn detect_kde_simple() {
1334 assert_eq!(detect_linux_de("KDE"), LinuxDesktop::Kde);
1335 }
1336
1337 #[test]
1338 fn detect_kde_colon_separated_after() {
1339 assert_eq!(detect_linux_de("ubuntu:KDE"), LinuxDesktop::Kde);
1340 }
1341
1342 #[test]
1343 fn detect_kde_colon_separated_before() {
1344 assert_eq!(detect_linux_de("KDE:plasma"), LinuxDesktop::Kde);
1345 }
1346
1347 #[test]
1348 fn detect_gnome_simple() {
1349 assert_eq!(detect_linux_de("GNOME"), LinuxDesktop::Gnome);
1350 }
1351
1352 #[test]
1353 fn detect_gnome_ubuntu() {
1354 assert_eq!(detect_linux_de("ubuntu:GNOME"), LinuxDesktop::Gnome);
1355 }
1356
1357 #[test]
1358 fn detect_xfce() {
1359 assert_eq!(detect_linux_de("XFCE"), LinuxDesktop::Xfce);
1360 }
1361
1362 #[test]
1363 fn detect_cinnamon() {
1364 assert_eq!(detect_linux_de("X-Cinnamon"), LinuxDesktop::Cinnamon);
1365 }
1366
1367 #[test]
1368 fn detect_cinnamon_short() {
1369 assert_eq!(detect_linux_de("Cinnamon"), LinuxDesktop::Cinnamon);
1370 }
1371
1372 #[test]
1373 fn detect_mate() {
1374 assert_eq!(detect_linux_de("MATE"), LinuxDesktop::Mate);
1375 }
1376
1377 #[test]
1378 fn detect_lxqt() {
1379 assert_eq!(detect_linux_de("LXQt"), LinuxDesktop::LxQt);
1380 }
1381
1382 #[test]
1383 fn detect_budgie() {
1384 assert_eq!(detect_linux_de("Budgie:GNOME"), LinuxDesktop::Budgie);
1385 }
1386
1387 #[test]
1388 fn detect_empty_string() {
1389 assert_eq!(detect_linux_de(""), LinuxDesktop::Unknown);
1390 }
1391
1392 #[test]
1395 #[allow(unsafe_code)]
1396 fn from_linux_non_kde_returns_adwaita() {
1397 let _guard = crate::ENV_MUTEX.lock().unwrap();
1398 unsafe { std::env::set_var("XDG_CURRENT_DESKTOP", "GNOME") };
1402 let result = from_linux();
1403 unsafe { std::env::remove_var("XDG_CURRENT_DESKTOP") };
1404
1405 let theme = result.expect("from_linux() should return Ok for non-KDE desktop");
1406 assert_eq!(theme.name, "Adwaita");
1407 }
1408
1409 #[test]
1412 #[cfg(feature = "kde")]
1413 #[allow(unsafe_code)]
1414 fn from_linux_unknown_de_with_kdeglobals_fallback() {
1415 let _guard = crate::ENV_MUTEX.lock().unwrap();
1416 use std::io::Write;
1417
1418 let tmp_dir = std::env::temp_dir().join("native_theme_test_kde_fallback");
1420 std::fs::create_dir_all(&tmp_dir).unwrap();
1421 let kdeglobals = tmp_dir.join("kdeglobals");
1422 let mut f = std::fs::File::create(&kdeglobals).unwrap();
1423 writeln!(
1424 f,
1425 "[General]\nColorScheme=TestTheme\n\n[Colors:Window]\nBackgroundNormal=239,240,241\n"
1426 )
1427 .unwrap();
1428
1429 let orig_xdg = std::env::var("XDG_CONFIG_HOME").ok();
1431 let orig_desktop = std::env::var("XDG_CURRENT_DESKTOP").ok();
1432
1433 unsafe { std::env::set_var("XDG_CONFIG_HOME", &tmp_dir) };
1434 unsafe { std::env::set_var("XDG_CURRENT_DESKTOP", "SomeUnknownDE") };
1435
1436 let result = from_linux();
1437
1438 match orig_xdg {
1440 Some(val) => unsafe { std::env::set_var("XDG_CONFIG_HOME", val) },
1441 None => unsafe { std::env::remove_var("XDG_CONFIG_HOME") },
1442 }
1443 match orig_desktop {
1444 Some(val) => unsafe { std::env::set_var("XDG_CURRENT_DESKTOP", val) },
1445 None => unsafe { std::env::remove_var("XDG_CURRENT_DESKTOP") },
1446 }
1447
1448 let _ = std::fs::remove_dir_all(&tmp_dir);
1450
1451 let theme = result.expect("from_linux() should return Ok with kdeglobals fallback");
1452 assert_eq!(
1453 theme.name, "TestTheme",
1454 "should use KDE theme name from kdeglobals"
1455 );
1456 }
1457
1458 #[test]
1459 #[allow(unsafe_code)]
1460 fn from_linux_unknown_de_without_kdeglobals_returns_adwaita() {
1461 let _guard = crate::ENV_MUTEX.lock().unwrap();
1462 let orig_xdg = std::env::var("XDG_CONFIG_HOME").ok();
1464 let orig_desktop = std::env::var("XDG_CURRENT_DESKTOP").ok();
1465
1466 unsafe {
1467 std::env::set_var(
1468 "XDG_CONFIG_HOME",
1469 "/tmp/nonexistent_native_theme_test_no_kde",
1470 )
1471 };
1472 unsafe { std::env::set_var("XDG_CURRENT_DESKTOP", "SomeUnknownDE") };
1473
1474 let result = from_linux();
1475
1476 match orig_xdg {
1478 Some(val) => unsafe { std::env::set_var("XDG_CONFIG_HOME", val) },
1479 None => unsafe { std::env::remove_var("XDG_CONFIG_HOME") },
1480 }
1481 match orig_desktop {
1482 Some(val) => unsafe { std::env::set_var("XDG_CURRENT_DESKTOP", val) },
1483 None => unsafe { std::env::remove_var("XDG_CURRENT_DESKTOP") },
1484 }
1485
1486 let theme = result.expect("from_linux() should return Ok (adwaita fallback)");
1487 assert_eq!(
1488 theme.name, "Adwaita",
1489 "should fall back to Adwaita without kdeglobals"
1490 );
1491 }
1492
1493 #[test]
1496 fn detect_hyprland_returns_unknown() {
1497 assert_eq!(detect_linux_de("Hyprland"), LinuxDesktop::Unknown);
1498 }
1499
1500 #[test]
1501 fn detect_sway_returns_unknown() {
1502 assert_eq!(detect_linux_de("sway"), LinuxDesktop::Unknown);
1503 }
1504
1505 #[test]
1506 fn detect_cosmic_returns_unknown() {
1507 assert_eq!(detect_linux_de("COSMIC"), LinuxDesktop::Unknown);
1508 }
1509
1510 #[test]
1513 #[allow(unsafe_code)]
1514 fn from_system_returns_result() {
1515 let _guard = crate::ENV_MUTEX.lock().unwrap();
1516 unsafe { std::env::set_var("XDG_CURRENT_DESKTOP", "GNOME") };
1520 let result = SystemTheme::from_system();
1521 unsafe { std::env::remove_var("XDG_CURRENT_DESKTOP") };
1522
1523 let theme = result.expect("from_system() should return Ok on Linux");
1524 assert_eq!(theme.name, "Adwaita");
1525 }
1526}
1527
1528#[cfg(test)]
1529#[allow(clippy::unwrap_used, clippy::expect_used)]
1530mod load_icon_tests {
1531 use super::*;
1532
1533 #[test]
1534 #[cfg(feature = "material-icons")]
1535 fn load_icon_material_returns_svg() {
1536 let result = load_icon(IconRole::ActionCopy, IconSet::Material);
1537 assert!(result.is_some(), "material ActionCopy should return Some");
1538 match result.unwrap() {
1539 IconData::Svg(bytes) => {
1540 let content = std::str::from_utf8(&bytes).expect("should be valid UTF-8");
1541 assert!(content.contains("<svg"), "should contain SVG data");
1542 }
1543 _ => panic!("expected IconData::Svg for bundled material icon"),
1544 }
1545 }
1546
1547 #[test]
1548 #[cfg(feature = "lucide-icons")]
1549 fn load_icon_lucide_returns_svg() {
1550 let result = load_icon(IconRole::ActionCopy, IconSet::Lucide);
1551 assert!(result.is_some(), "lucide ActionCopy should return Some");
1552 match result.unwrap() {
1553 IconData::Svg(bytes) => {
1554 let content = std::str::from_utf8(&bytes).expect("should be valid UTF-8");
1555 assert!(content.contains("<svg"), "should contain SVG data");
1556 }
1557 _ => panic!("expected IconData::Svg for bundled lucide icon"),
1558 }
1559 }
1560
1561 #[test]
1562 #[cfg(feature = "material-icons")]
1563 fn load_icon_unknown_theme_no_cross_set_fallback() {
1564 let result = load_icon(IconRole::ActionCopy, IconSet::Freedesktop);
1568 let _ = result;
1572 }
1573
1574 #[test]
1575 #[cfg(feature = "material-icons")]
1576 fn load_icon_all_roles_material() {
1577 let mut some_count = 0;
1579 for role in IconRole::ALL {
1580 if load_icon(role, IconSet::Material).is_some() {
1581 some_count += 1;
1582 }
1583 }
1584 assert_eq!(
1586 some_count, 42,
1587 "Material should cover all 42 roles via bundled SVGs"
1588 );
1589 }
1590
1591 #[test]
1592 #[cfg(feature = "lucide-icons")]
1593 fn load_icon_all_roles_lucide() {
1594 let mut some_count = 0;
1595 for role in IconRole::ALL {
1596 if load_icon(role, IconSet::Lucide).is_some() {
1597 some_count += 1;
1598 }
1599 }
1600 assert_eq!(
1602 some_count, 42,
1603 "Lucide should cover all 42 roles via bundled SVGs"
1604 );
1605 }
1606
1607 #[test]
1608 fn load_icon_unrecognized_set_no_features() {
1609 let _result = load_icon(IconRole::ActionCopy, IconSet::SfSymbols);
1611 }
1613}
1614
1615#[cfg(test)]
1616#[allow(clippy::unwrap_used, clippy::expect_used)]
1617mod load_system_icon_by_name_tests {
1618 use super::*;
1619
1620 #[test]
1621 #[cfg(feature = "material-icons")]
1622 fn system_icon_by_name_material() {
1623 let result = load_system_icon_by_name("content_copy", IconSet::Material);
1624 assert!(
1625 result.is_some(),
1626 "content_copy should be found in Material set"
1627 );
1628 assert!(matches!(result.unwrap(), IconData::Svg(_)));
1629 }
1630
1631 #[test]
1632 #[cfg(feature = "lucide-icons")]
1633 fn system_icon_by_name_lucide() {
1634 let result = load_system_icon_by_name("copy", IconSet::Lucide);
1635 assert!(result.is_some(), "copy should be found in Lucide set");
1636 assert!(matches!(result.unwrap(), IconData::Svg(_)));
1637 }
1638
1639 #[test]
1640 #[cfg(feature = "material-icons")]
1641 fn system_icon_by_name_unknown_returns_none() {
1642 let result = load_system_icon_by_name("nonexistent_xyz", IconSet::Material);
1643 assert!(result.is_none(), "nonexistent name should return None");
1644 }
1645
1646 #[test]
1647 fn system_icon_by_name_sf_on_linux_returns_none() {
1648 #[cfg(not(target_os = "macos"))]
1650 {
1651 let result = load_system_icon_by_name("doc.on.doc", IconSet::SfSymbols);
1652 assert!(
1653 result.is_none(),
1654 "SF Symbols should return None on non-macOS"
1655 );
1656 }
1657 }
1658}
1659
1660#[cfg(test)]
1661#[allow(clippy::unwrap_used, clippy::expect_used)]
1662mod load_custom_icon_tests {
1663 use super::*;
1664
1665 #[test]
1666 #[cfg(feature = "material-icons")]
1667 fn custom_icon_with_icon_role_material() {
1668 let result = load_custom_icon(&IconRole::ActionCopy, IconSet::Material);
1669 assert!(
1670 result.is_some(),
1671 "IconRole::ActionCopy should load via material"
1672 );
1673 }
1674
1675 #[test]
1676 #[cfg(feature = "lucide-icons")]
1677 fn custom_icon_with_icon_role_lucide() {
1678 let result = load_custom_icon(&IconRole::ActionCopy, IconSet::Lucide);
1679 assert!(
1680 result.is_some(),
1681 "IconRole::ActionCopy should load via lucide"
1682 );
1683 }
1684
1685 #[test]
1686 fn custom_icon_no_cross_set_fallback() {
1687 #[derive(Debug)]
1689 struct NullProvider;
1690 impl IconProvider for NullProvider {
1691 fn icon_name(&self, _set: IconSet) -> Option<&str> {
1692 None
1693 }
1694 fn icon_svg(&self, _set: IconSet) -> Option<&'static [u8]> {
1695 None
1696 }
1697 }
1698
1699 let result = load_custom_icon(&NullProvider, IconSet::Material);
1700 assert!(
1701 result.is_none(),
1702 "NullProvider should return None (no cross-set fallback)"
1703 );
1704 }
1705
1706 #[test]
1707 fn custom_icon_unknown_set_uses_system() {
1708 #[derive(Debug)]
1710 struct NullProvider;
1711 impl IconProvider for NullProvider {
1712 fn icon_name(&self, _set: IconSet) -> Option<&str> {
1713 None
1714 }
1715 fn icon_svg(&self, _set: IconSet) -> Option<&'static [u8]> {
1716 None
1717 }
1718 }
1719
1720 let _result = load_custom_icon(&NullProvider, IconSet::Freedesktop);
1722 }
1723
1724 #[test]
1725 #[cfg(feature = "material-icons")]
1726 fn custom_icon_via_dyn_dispatch() {
1727 let boxed: Box<dyn IconProvider> = Box::new(IconRole::ActionCopy);
1728 let result = load_custom_icon(&*boxed, IconSet::Material);
1729 assert!(
1730 result.is_some(),
1731 "dyn dispatch through Box<dyn IconProvider> should work"
1732 );
1733 }
1734
1735 #[test]
1736 #[cfg(feature = "material-icons")]
1737 fn custom_icon_bundled_svg_fallback() {
1738 #[derive(Debug)]
1740 struct SvgOnlyProvider;
1741 impl IconProvider for SvgOnlyProvider {
1742 fn icon_name(&self, _set: IconSet) -> Option<&str> {
1743 None
1744 }
1745 fn icon_svg(&self, _set: IconSet) -> Option<&'static [u8]> {
1746 Some(b"<svg>test</svg>")
1747 }
1748 }
1749
1750 let result = load_custom_icon(&SvgOnlyProvider, IconSet::Material);
1751 assert!(
1752 result.is_some(),
1753 "provider with icon_svg should return Some"
1754 );
1755 match result.unwrap() {
1756 IconData::Svg(bytes) => {
1757 assert_eq!(bytes, b"<svg>test</svg>");
1758 }
1759 _ => panic!("expected IconData::Svg"),
1760 }
1761 }
1762}
1763
1764#[cfg(test)]
1765#[allow(clippy::unwrap_used, clippy::expect_used)]
1766mod loading_indicator_tests {
1767 use super::*;
1768
1769 #[test]
1772 #[cfg(feature = "lucide-icons")]
1773 fn loading_indicator_lucide_returns_transform_spin() {
1774 let anim = loading_indicator(IconSet::Lucide);
1775 assert!(anim.is_some(), "lucide should return Some");
1776 let anim = anim.unwrap();
1777 assert!(
1778 matches!(
1779 anim,
1780 AnimatedIcon::Transform {
1781 animation: TransformAnimation::Spin { duration_ms: 1000 },
1782 ..
1783 }
1784 ),
1785 "lucide should be Transform::Spin at 1000ms"
1786 );
1787 }
1788
1789 #[test]
1792 #[cfg(all(target_os = "linux", feature = "system-icons"))]
1793 fn loading_indicator_freedesktop_depends_on_theme() {
1794 let anim = loading_indicator(IconSet::Freedesktop);
1795 if let Some(anim) = anim {
1797 match anim {
1798 AnimatedIcon::Frames { frames, .. } => {
1799 assert!(
1800 !frames.is_empty(),
1801 "Frames variant should have at least one frame"
1802 );
1803 }
1804 AnimatedIcon::Transform { .. } => {
1805 }
1807 }
1808 }
1809 }
1810
1811 #[test]
1813 fn loading_indicator_freedesktop_does_not_panic() {
1814 let _result = loading_indicator(IconSet::Freedesktop);
1815 }
1816
1817 #[test]
1820 #[cfg(feature = "lucide-icons")]
1821 fn lucide_spinner_is_transform() {
1822 let anim = spinners::lucide_spinner();
1823 assert!(matches!(
1824 anim,
1825 AnimatedIcon::Transform {
1826 animation: TransformAnimation::Spin { duration_ms: 1000 },
1827 ..
1828 }
1829 ));
1830 }
1831}
1832
1833#[cfg(all(test, feature = "svg-rasterize"))]
1834#[allow(clippy::unwrap_used, clippy::expect_used)]
1835mod spinner_rasterize_tests {
1836 use super::*;
1837
1838 #[test]
1839 #[cfg(feature = "lucide-icons")]
1840 fn lucide_spinner_icon_rasterizes() {
1841 let anim = spinners::lucide_spinner();
1842 if let AnimatedIcon::Transform { icon, .. } = &anim {
1843 if let IconData::Svg(bytes) = icon {
1844 let result = crate::rasterize::rasterize_svg(bytes, 24);
1845 assert!(result.is_ok(), "lucide loader should rasterize");
1846 if let Ok(IconData::Rgba { data, .. }) = &result {
1847 assert!(
1848 data.iter().any(|&b| b != 0),
1849 "lucide loader rasterized to empty image"
1850 );
1851 }
1852 } else {
1853 panic!("lucide spinner icon should be Svg");
1854 }
1855 } else {
1856 panic!("lucide spinner should be Transform");
1857 }
1858 }
1859}
1860
1861#[cfg(test)]
1862#[allow(
1863 clippy::unwrap_used,
1864 clippy::expect_used,
1865 clippy::field_reassign_with_default
1866)]
1867mod system_theme_tests {
1868 use super::*;
1869
1870 #[test]
1873 fn test_system_theme_active_dark() {
1874 let preset = ThemeSpec::preset("catppuccin-mocha").unwrap();
1875 let mut light_v = preset.light.clone().unwrap();
1876 let mut dark_v = preset.dark.clone().unwrap();
1877 light_v.defaults.accent = Some(Rgba::rgb(0, 0, 255));
1879 dark_v.defaults.accent = Some(Rgba::rgb(255, 0, 0));
1880 light_v.resolve_all();
1881 dark_v.resolve_all();
1882 let light_resolved = light_v.validate().unwrap();
1883 let dark_resolved = dark_v.validate().unwrap();
1884
1885 let st = SystemTheme {
1886 name: "test".into(),
1887 is_dark: true,
1888 light: light_resolved.clone(),
1889 dark: dark_resolved.clone(),
1890 light_variant: preset.light.unwrap(),
1891 dark_variant: preset.dark.unwrap(),
1892 live_preset: "catppuccin-mocha".into(),
1893 preset: "catppuccin-mocha".into(),
1894 };
1895 assert_eq!(st.active().defaults.accent, dark_resolved.defaults.accent);
1896 }
1897
1898 #[test]
1899 fn test_system_theme_active_light() {
1900 let preset = ThemeSpec::preset("catppuccin-mocha").unwrap();
1901 let mut light_v = preset.light.clone().unwrap();
1902 let mut dark_v = preset.dark.clone().unwrap();
1903 light_v.defaults.accent = Some(Rgba::rgb(0, 0, 255));
1904 dark_v.defaults.accent = Some(Rgba::rgb(255, 0, 0));
1905 light_v.resolve_all();
1906 dark_v.resolve_all();
1907 let light_resolved = light_v.validate().unwrap();
1908 let dark_resolved = dark_v.validate().unwrap();
1909
1910 let st = SystemTheme {
1911 name: "test".into(),
1912 is_dark: false,
1913 light: light_resolved.clone(),
1914 dark: dark_resolved.clone(),
1915 light_variant: preset.light.unwrap(),
1916 dark_variant: preset.dark.unwrap(),
1917 live_preset: "catppuccin-mocha".into(),
1918 preset: "catppuccin-mocha".into(),
1919 };
1920 assert_eq!(st.active().defaults.accent, light_resolved.defaults.accent);
1921 }
1922
1923 #[test]
1924 fn test_system_theme_pick() {
1925 let preset = ThemeSpec::preset("catppuccin-mocha").unwrap();
1926 let mut light_v = preset.light.clone().unwrap();
1927 let mut dark_v = preset.dark.clone().unwrap();
1928 light_v.defaults.accent = Some(Rgba::rgb(0, 0, 255));
1929 dark_v.defaults.accent = Some(Rgba::rgb(255, 0, 0));
1930 light_v.resolve_all();
1931 dark_v.resolve_all();
1932 let light_resolved = light_v.validate().unwrap();
1933 let dark_resolved = dark_v.validate().unwrap();
1934
1935 let st = SystemTheme {
1936 name: "test".into(),
1937 is_dark: false,
1938 light: light_resolved.clone(),
1939 dark: dark_resolved.clone(),
1940 light_variant: preset.light.unwrap(),
1941 dark_variant: preset.dark.unwrap(),
1942 live_preset: "catppuccin-mocha".into(),
1943 preset: "catppuccin-mocha".into(),
1944 };
1945 assert_eq!(st.pick(true).defaults.accent, dark_resolved.defaults.accent);
1946 assert_eq!(
1947 st.pick(false).defaults.accent,
1948 light_resolved.defaults.accent
1949 );
1950 }
1951
1952 #[test]
1955 #[cfg(target_os = "linux")]
1956 #[allow(unsafe_code)]
1957 fn test_platform_preset_name_kde() {
1958 let _guard = crate::ENV_MUTEX.lock().unwrap();
1959 unsafe { std::env::set_var("XDG_CURRENT_DESKTOP", "KDE") };
1960 let name = platform_preset_name();
1961 unsafe { std::env::remove_var("XDG_CURRENT_DESKTOP") };
1962 assert_eq!(name, "kde-breeze-live");
1963 }
1964
1965 #[test]
1966 #[cfg(target_os = "linux")]
1967 #[allow(unsafe_code)]
1968 fn test_platform_preset_name_gnome() {
1969 let _guard = crate::ENV_MUTEX.lock().unwrap();
1970 unsafe { std::env::set_var("XDG_CURRENT_DESKTOP", "GNOME") };
1971 let name = platform_preset_name();
1972 unsafe { std::env::remove_var("XDG_CURRENT_DESKTOP") };
1973 assert_eq!(name, "adwaita-live");
1974 }
1975
1976 #[test]
1979 fn test_run_pipeline_produces_both_variants() {
1980 let reader = ThemeSpec::preset("catppuccin-mocha").unwrap();
1981 let result = run_pipeline(reader, "catppuccin-mocha", false);
1982 assert!(result.is_ok(), "run_pipeline should succeed");
1983 let st = result.unwrap();
1984 assert!(!st.name.is_empty(), "name should be populated");
1986 }
1988
1989 #[test]
1990 fn test_run_pipeline_reader_values_win() {
1991 let custom_accent = Rgba::rgb(42, 100, 200);
1993 let mut reader = ThemeSpec::default();
1994 reader.name = "CustomTheme".into();
1995 let mut variant = ThemeVariant::default();
1996 variant.defaults.accent = Some(custom_accent);
1997 reader.light = Some(variant);
1998
1999 let result = run_pipeline(reader, "catppuccin-mocha", false);
2000 assert!(result.is_ok(), "run_pipeline should succeed");
2001 let st = result.unwrap();
2002 assert_eq!(
2004 st.light.defaults.accent, custom_accent,
2005 "reader accent should win over preset accent"
2006 );
2007 assert_eq!(st.name, "CustomTheme", "reader name should win");
2008 }
2009
2010 #[test]
2011 fn test_run_pipeline_single_variant() {
2012 let full = ThemeSpec::preset("kde-breeze").unwrap();
2016 let mut reader = ThemeSpec::default();
2017 let mut dark_v = full.dark.clone().unwrap();
2018 dark_v.defaults.accent = Some(Rgba::rgb(200, 50, 50));
2020 reader.dark = Some(dark_v);
2021 reader.light = None;
2022
2023 let result = run_pipeline(reader, "kde-breeze-live", true);
2024 assert!(
2025 result.is_ok(),
2026 "run_pipeline should succeed with single variant"
2027 );
2028 let st = result.unwrap();
2029 assert_eq!(
2031 st.dark.defaults.accent,
2032 Rgba::rgb(200, 50, 50),
2033 "dark variant should have reader accent"
2034 );
2035 assert_eq!(st.live_preset, "kde-breeze-live");
2038 assert_eq!(st.preset, "kde-breeze");
2039 }
2040
2041 #[test]
2042 fn test_run_pipeline_inactive_variant_from_full_preset() {
2043 let full = ThemeSpec::preset("kde-breeze").unwrap();
2046 let mut reader = ThemeSpec::default();
2047 reader.dark = Some(full.dark.clone().unwrap());
2048 reader.light = None;
2049
2050 let st = run_pipeline(reader, "kde-breeze-live", true).unwrap();
2051
2052 let full_light = full.light.unwrap();
2054 assert_eq!(
2055 st.light.defaults.accent,
2056 full_light.defaults.accent.unwrap(),
2057 "inactive light variant should get accent from full preset"
2058 );
2059 assert_eq!(
2060 st.light.defaults.background,
2061 full_light.defaults.background.unwrap(),
2062 "inactive light variant should get background from full preset"
2063 );
2064 }
2065
2066 #[test]
2069 fn test_run_pipeline_with_preset_as_reader() {
2070 let reader = ThemeSpec::preset("adwaita").unwrap();
2073 let result = run_pipeline(reader, "adwaita", false);
2074 assert!(
2075 result.is_ok(),
2076 "double-merge with same preset should succeed"
2077 );
2078 let st = result.unwrap();
2079 assert_eq!(st.name, "Adwaita");
2080 }
2081
2082 #[test]
2085 fn test_reader_is_dark_only_dark() {
2086 let mut theme = ThemeSpec::default();
2087 theme.dark = Some(ThemeVariant::default());
2088 theme.light = None;
2089 assert!(
2090 reader_is_dark(&theme),
2091 "should be true when only dark is set"
2092 );
2093 }
2094
2095 #[test]
2096 fn test_reader_is_dark_only_light() {
2097 let mut theme = ThemeSpec::default();
2098 theme.light = Some(ThemeVariant::default());
2099 theme.dark = None;
2100 assert!(
2101 !reader_is_dark(&theme),
2102 "should be false when only light is set"
2103 );
2104 }
2105
2106 #[test]
2107 fn test_reader_is_dark_both() {
2108 let mut theme = ThemeSpec::default();
2109 theme.light = Some(ThemeVariant::default());
2110 theme.dark = Some(ThemeVariant::default());
2111 assert!(
2112 !reader_is_dark(&theme),
2113 "should be false when both are set (macOS case)"
2114 );
2115 }
2116
2117 #[test]
2118 fn test_reader_is_dark_neither() {
2119 let theme = ThemeSpec::default();
2120 assert!(
2121 !reader_is_dark(&theme),
2122 "should be false when neither is set"
2123 );
2124 }
2125}
2126
2127#[cfg(test)]
2128#[allow(clippy::unwrap_used, clippy::expect_used)]
2129mod reduced_motion_tests {
2130 use super::*;
2131
2132 #[test]
2133 fn prefers_reduced_motion_smoke_test() {
2134 let _result = prefers_reduced_motion();
2138 }
2139
2140 #[cfg(target_os = "linux")]
2141 #[test]
2142 fn detect_reduced_motion_inner_linux() {
2143 let result = detect_reduced_motion_inner();
2147 let _ = result;
2149 }
2150
2151 #[cfg(target_os = "macos")]
2152 #[test]
2153 fn detect_reduced_motion_inner_macos() {
2154 let result = detect_reduced_motion_inner();
2155 let _ = result;
2156 }
2157
2158 #[cfg(target_os = "windows")]
2159 #[test]
2160 fn detect_reduced_motion_inner_windows() {
2161 let result = detect_reduced_motion_inner();
2162 let _ = result;
2163 }
2164}
2165
2166#[cfg(test)]
2167#[allow(clippy::unwrap_used, clippy::expect_used)]
2168mod overlay_tests {
2169 use super::*;
2170
2171 fn default_system_theme() -> SystemTheme {
2173 let reader = ThemeSpec::preset("catppuccin-mocha").unwrap();
2174 run_pipeline(reader, "catppuccin-mocha", false).unwrap()
2175 }
2176
2177 #[test]
2178 fn test_overlay_accent_propagates() {
2179 let st = default_system_theme();
2180 let new_accent = Rgba::rgb(255, 0, 0);
2181
2182 let mut overlay = ThemeSpec::default();
2184 let mut light_v = ThemeVariant::default();
2185 light_v.defaults.accent = Some(new_accent);
2186 let mut dark_v = ThemeVariant::default();
2187 dark_v.defaults.accent = Some(new_accent);
2188 overlay.light = Some(light_v);
2189 overlay.dark = Some(dark_v);
2190
2191 let result = st.with_overlay(&overlay).unwrap();
2192
2193 assert_eq!(result.light.defaults.accent, new_accent);
2195 assert_eq!(result.light.button.primary_background, new_accent);
2197 assert_eq!(result.light.checkbox.checked_background, new_accent);
2198 assert_eq!(result.light.slider.fill, new_accent);
2199 assert_eq!(result.light.progress_bar.fill, new_accent);
2200 assert_eq!(result.light.switch.checked_background, new_accent);
2201 }
2202
2203 #[test]
2204 fn test_overlay_preserves_unrelated_fields() {
2205 let st = default_system_theme();
2206 let original_bg = st.light.defaults.background;
2207
2208 let mut overlay = ThemeSpec::default();
2210 let mut light_v = ThemeVariant::default();
2211 light_v.defaults.accent = Some(Rgba::rgb(255, 0, 0));
2212 overlay.light = Some(light_v);
2213
2214 let result = st.with_overlay(&overlay).unwrap();
2215 assert_eq!(
2216 result.light.defaults.background, original_bg,
2217 "background should be unchanged"
2218 );
2219 }
2220
2221 #[test]
2222 fn test_overlay_empty_noop() {
2223 let st = default_system_theme();
2224 let original_light_accent = st.light.defaults.accent;
2225 let original_dark_accent = st.dark.defaults.accent;
2226 let original_light_bg = st.light.defaults.background;
2227
2228 let overlay = ThemeSpec::default();
2230 let result = st.with_overlay(&overlay).unwrap();
2231
2232 assert_eq!(result.light.defaults.accent, original_light_accent);
2233 assert_eq!(result.dark.defaults.accent, original_dark_accent);
2234 assert_eq!(result.light.defaults.background, original_light_bg);
2235 }
2236
2237 #[test]
2238 fn test_overlay_both_variants() {
2239 let st = default_system_theme();
2240 let red = Rgba::rgb(255, 0, 0);
2241 let green = Rgba::rgb(0, 255, 0);
2242
2243 let mut overlay = ThemeSpec::default();
2244 let mut light_v = ThemeVariant::default();
2245 light_v.defaults.accent = Some(red);
2246 let mut dark_v = ThemeVariant::default();
2247 dark_v.defaults.accent = Some(green);
2248 overlay.light = Some(light_v);
2249 overlay.dark = Some(dark_v);
2250
2251 let result = st.with_overlay(&overlay).unwrap();
2252 assert_eq!(result.light.defaults.accent, red, "light accent = red");
2253 assert_eq!(result.dark.defaults.accent, green, "dark accent = green");
2254 }
2255
2256 #[test]
2257 fn test_overlay_font_family() {
2258 let st = default_system_theme();
2259
2260 let mut overlay = ThemeSpec::default();
2261 let mut light_v = ThemeVariant::default();
2262 light_v.defaults.font.family = Some("Comic Sans".into());
2263 overlay.light = Some(light_v);
2264
2265 let result = st.with_overlay(&overlay).unwrap();
2266 assert_eq!(result.light.defaults.font.family, "Comic Sans");
2267 }
2268
2269 #[test]
2270 fn test_overlay_toml_convenience() {
2271 let st = default_system_theme();
2272 let result = st
2273 .with_overlay_toml(
2274 r##"
2275 name = "overlay"
2276 [light.defaults]
2277 accent = "#ff0000"
2278 "##,
2279 )
2280 .unwrap();
2281 assert_eq!(result.light.defaults.accent, Rgba::rgb(255, 0, 0));
2282 }
2283}