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::{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)]
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")]
199pub fn detect_linux_de(xdg_current_desktop: &str) -> LinuxDesktop {
200 for component in xdg_current_desktop.split(':') {
201 match component {
202 "KDE" => return LinuxDesktop::Kde,
203 "Budgie" => return LinuxDesktop::Budgie,
204 "GNOME" => return LinuxDesktop::Gnome,
205 "XFCE" => return LinuxDesktop::Xfce,
206 "X-Cinnamon" | "Cinnamon" => return LinuxDesktop::Cinnamon,
207 "MATE" => return LinuxDesktop::Mate,
208 "LXQt" => return LinuxDesktop::LxQt,
209 _ => {}
210 }
211 }
212 LinuxDesktop::Unknown
213}
214
215#[must_use = "this returns whether the system uses dark mode"]
242pub fn system_is_dark() -> bool {
243 static CACHED_IS_DARK: std::sync::OnceLock<bool> = std::sync::OnceLock::new();
244 *CACHED_IS_DARK.get_or_init(detect_is_dark_inner)
245}
246
247#[allow(unreachable_code)]
251fn detect_is_dark_inner() -> bool {
252 #[cfg(target_os = "linux")]
253 {
254 if let Ok(output) = std::process::Command::new("gsettings")
256 .args(["get", "org.gnome.desktop.interface", "color-scheme"])
257 .output()
258 && output.status.success()
259 {
260 let val = String::from_utf8_lossy(&output.stdout);
261 if val.contains("prefer-dark") {
262 return true;
263 }
264 if val.contains("prefer-light") || val.contains("default") {
265 return false;
266 }
267 }
268
269 #[cfg(feature = "kde")]
271 {
272 let path = crate::kde::kdeglobals_path();
273 if let Ok(content) = std::fs::read_to_string(&path) {
274 let mut ini = crate::kde::create_kde_parser();
275 if ini.read(content).is_ok() {
276 return crate::kde::is_dark_theme(&ini);
277 }
278 }
279 }
280
281 false
282 }
283
284 #[cfg(target_os = "macos")]
285 {
286 #[cfg(feature = "macos")]
289 {
290 use objc2_foundation::NSUserDefaults;
291 let defaults = NSUserDefaults::standardUserDefaults();
292 let key = objc2_foundation::ns_string!("AppleInterfaceStyle");
293 if let Some(value) = defaults.stringForKey(key) {
294 return value.to_string().eq_ignore_ascii_case("dark");
295 }
296 return false;
297 }
298 #[cfg(not(feature = "macos"))]
299 {
300 if let Ok(output) = std::process::Command::new("defaults")
301 .args(["read", "-g", "AppleInterfaceStyle"])
302 .output()
303 && output.status.success()
304 {
305 let val = String::from_utf8_lossy(&output.stdout);
306 return val.trim().eq_ignore_ascii_case("dark");
307 }
308 return false;
309 }
310 }
311
312 #[cfg(target_os = "windows")]
313 {
314 #[cfg(feature = "windows")]
315 {
316 let Ok(settings) = ::windows::UI::ViewManagement::UISettings::new() else {
318 return false;
319 };
320 let Ok(fg) =
321 settings.GetColorValue(::windows::UI::ViewManagement::UIColorType::Foreground)
322 else {
323 return false;
324 };
325 let luma = 0.299 * (fg.R as f32) + 0.587 * (fg.G as f32) + 0.114 * (fg.B as f32);
326 return luma > 128.0;
327 }
328 #[cfg(not(feature = "windows"))]
329 return false;
330 }
331
332 #[cfg(not(any(target_os = "linux", target_os = "macos", target_os = "windows")))]
333 {
334 false
335 }
336}
337
338#[must_use = "this returns whether reduced motion is preferred"]
368pub fn prefers_reduced_motion() -> bool {
369 static CACHED: std::sync::OnceLock<bool> = std::sync::OnceLock::new();
370 *CACHED.get_or_init(detect_reduced_motion_inner)
371}
372
373#[allow(unreachable_code)]
377fn detect_reduced_motion_inner() -> bool {
378 #[cfg(target_os = "linux")]
379 {
380 if let Ok(output) = std::process::Command::new("gsettings")
383 .args(["get", "org.gnome.desktop.interface", "enable-animations"])
384 .output()
385 && output.status.success()
386 {
387 let val = String::from_utf8_lossy(&output.stdout);
388 return val.trim() == "false";
389 }
390 false
391 }
392
393 #[cfg(target_os = "macos")]
394 {
395 #[cfg(feature = "macos")]
396 {
397 let workspace = objc2_app_kit::NSWorkspace::sharedWorkspace();
398 return workspace.accessibilityDisplayShouldReduceMotion();
400 }
401 #[cfg(not(feature = "macos"))]
402 return false;
403 }
404
405 #[cfg(target_os = "windows")]
406 {
407 #[cfg(feature = "windows")]
408 {
409 let Ok(settings) = ::windows::UI::ViewManagement::UISettings::new() else {
410 return false;
411 };
412 return match settings.AnimationsEnabled() {
414 Ok(enabled) => !enabled,
415 Err(_) => false,
416 };
417 }
418 #[cfg(not(feature = "windows"))]
419 return false;
420 }
421
422 #[cfg(not(any(target_os = "linux", target_os = "macos", target_os = "windows")))]
423 {
424 false
425 }
426}
427
428#[derive(Clone, Debug)]
435pub struct SystemTheme {
436 pub name: String,
438 pub is_dark: bool,
440 pub light: ResolvedThemeVariant,
442 pub dark: ResolvedThemeVariant,
444 pub(crate) light_variant: ThemeVariant,
446 pub(crate) dark_variant: ThemeVariant,
448 pub preset: String,
450 pub(crate) live_preset: String,
452}
453
454impl SystemTheme {
455 pub fn active(&self) -> &ResolvedThemeVariant {
459 if self.is_dark {
460 &self.dark
461 } else {
462 &self.light
463 }
464 }
465
466 pub fn pick(&self, is_dark: bool) -> &ResolvedThemeVariant {
470 if is_dark { &self.dark } else { &self.light }
471 }
472
473 pub fn with_overlay(&self, overlay: &ThemeSpec) -> crate::Result<Self> {
497 let mut light = self.light_variant.clone();
499 let mut dark = self.dark_variant.clone();
500
501 if let Some(over) = &overlay.light {
503 light.merge(over);
504 }
505 if let Some(over) = &overlay.dark {
506 dark.merge(over);
507 }
508
509 let resolved_light = light.clone().into_resolved()?;
511 let resolved_dark = dark.clone().into_resolved()?;
512
513 Ok(SystemTheme {
514 name: self.name.clone(),
515 is_dark: self.is_dark,
516 light: resolved_light,
517 dark: resolved_dark,
518 light_variant: light,
519 dark_variant: dark,
520 live_preset: self.live_preset.clone(),
521 preset: self.preset.clone(),
522 })
523 }
524
525 pub fn with_overlay_toml(&self, toml: &str) -> crate::Result<Self> {
529 let overlay = ThemeSpec::from_toml(toml)?;
530 self.with_overlay(&overlay)
531 }
532
533 #[must_use = "this returns the detected theme; it does not apply it"]
570 pub fn from_system() -> crate::Result<Self> {
571 from_system_inner()
572 }
573
574 #[cfg(target_os = "linux")]
589 #[must_use = "this returns the detected theme; it does not apply it"]
590 pub async fn from_system_async() -> crate::Result<Self> {
591 from_system_async_inner().await
592 }
593
594 #[cfg(not(target_os = "linux"))]
599 #[must_use = "this returns the detected theme; it does not apply it"]
600 pub async fn from_system_async() -> crate::Result<Self> {
601 from_system_inner()
602 }
603}
604
605fn run_pipeline(
612 reader_output: ThemeSpec,
613 preset_name: &str,
614 is_dark: bool,
615) -> crate::Result<SystemTheme> {
616 let live_preset = ThemeSpec::preset(preset_name)?;
617
618 let full_preset_name = preset_name.strip_suffix("-live").unwrap_or(preset_name);
620 let full_preset = ThemeSpec::preset(full_preset_name)?;
621
622 let mut merged = full_preset.clone();
625 merged.merge(&live_preset);
626 merged.merge(&reader_output);
627
628 let name = if reader_output.name.is_empty() {
630 merged.name.clone()
631 } else {
632 reader_output.name.clone()
633 };
634
635 let light_variant = if reader_output.light.is_some() {
638 merged.light.unwrap_or_default()
639 } else {
640 full_preset.light.unwrap_or_default()
641 };
642
643 let dark_variant = if reader_output.dark.is_some() {
644 merged.dark.unwrap_or_default()
645 } else {
646 full_preset.dark.unwrap_or_default()
647 };
648
649 let light_variant_pre = light_variant.clone();
651 let dark_variant_pre = dark_variant.clone();
652
653 let light = light_variant.into_resolved()?;
654 let dark = dark_variant.into_resolved()?;
655
656 Ok(SystemTheme {
657 name,
658 is_dark,
659 light,
660 dark,
661 light_variant: light_variant_pre,
662 dark_variant: dark_variant_pre,
663 preset: full_preset_name.to_string(),
664 live_preset: preset_name.to_string(),
665 })
666}
667
668#[allow(unreachable_code)]
684pub fn platform_preset_name() -> &'static str {
685 #[cfg(target_os = "macos")]
686 {
687 return "macos-sonoma-live";
688 }
689 #[cfg(target_os = "windows")]
690 {
691 return "windows-11-live";
692 }
693 #[cfg(target_os = "linux")]
694 {
695 let desktop = std::env::var("XDG_CURRENT_DESKTOP").unwrap_or_default();
696 match detect_linux_de(&desktop) {
697 LinuxDesktop::Kde => "kde-breeze-live",
698 _ => "adwaita-live",
699 }
700 }
701 #[cfg(not(any(target_os = "linux", target_os = "windows", target_os = "macos")))]
702 {
703 "adwaita-live"
704 }
705}
706
707#[allow(dead_code)]
715fn reader_is_dark(reader: &ThemeSpec) -> bool {
716 reader.dark.is_some() && reader.light.is_none()
717}
718
719#[cfg(target_os = "linux")]
725fn from_linux() -> crate::Result<SystemTheme> {
726 let is_dark = system_is_dark();
727 let desktop = std::env::var("XDG_CURRENT_DESKTOP").unwrap_or_default();
728 match detect_linux_de(&desktop) {
729 #[cfg(feature = "kde")]
730 LinuxDesktop::Kde => {
731 let reader = crate::kde::from_kde()?;
732 run_pipeline(reader, "kde-breeze-live", is_dark)
733 }
734 #[cfg(not(feature = "kde"))]
735 LinuxDesktop::Kde => run_pipeline(ThemeSpec::preset("adwaita")?, "adwaita-live", is_dark),
736 LinuxDesktop::Gnome | LinuxDesktop::Budgie => {
737 run_pipeline(ThemeSpec::preset("adwaita")?, "adwaita-live", is_dark)
739 }
740 LinuxDesktop::Xfce | LinuxDesktop::Cinnamon | LinuxDesktop::Mate | LinuxDesktop::LxQt => {
741 run_pipeline(ThemeSpec::preset("adwaita")?, "adwaita-live", is_dark)
742 }
743 LinuxDesktop::Unknown => {
744 #[cfg(feature = "kde")]
745 {
746 let path = crate::kde::kdeglobals_path();
747 if path.exists() {
748 let reader = crate::kde::from_kde()?;
749 return run_pipeline(reader, "kde-breeze-live", is_dark);
750 }
751 }
752 run_pipeline(ThemeSpec::preset("adwaita")?, "adwaita-live", is_dark)
753 }
754 }
755}
756
757fn from_system_inner() -> crate::Result<SystemTheme> {
758 #[cfg(target_os = "macos")]
759 {
760 #[cfg(feature = "macos")]
761 {
762 let reader = crate::macos::from_macos()?;
763 let is_dark = reader_is_dark(&reader);
764 return run_pipeline(reader, "macos-sonoma-live", is_dark);
765 }
766
767 #[cfg(not(feature = "macos"))]
768 return Err(crate::Error::Unsupported);
769 }
770
771 #[cfg(target_os = "windows")]
772 {
773 #[cfg(feature = "windows")]
774 {
775 let reader = crate::windows::from_windows()?;
776 let is_dark = reader_is_dark(&reader);
777 return run_pipeline(reader, "windows-11-live", is_dark);
778 }
779
780 #[cfg(not(feature = "windows"))]
781 return Err(crate::Error::Unsupported);
782 }
783
784 #[cfg(target_os = "linux")]
785 {
786 from_linux()
787 }
788
789 #[cfg(not(any(target_os = "linux", target_os = "windows", target_os = "macos")))]
790 {
791 Err(crate::Error::Unsupported)
792 }
793}
794
795#[cfg(target_os = "linux")]
796async fn from_system_async_inner() -> crate::Result<SystemTheme> {
797 let is_dark = system_is_dark();
798 let desktop = std::env::var("XDG_CURRENT_DESKTOP").unwrap_or_default();
799 match detect_linux_de(&desktop) {
800 #[cfg(feature = "kde")]
801 LinuxDesktop::Kde => {
802 #[cfg(feature = "portal")]
803 {
804 let reader = crate::gnome::from_kde_with_portal().await?;
805 run_pipeline(reader, "kde-breeze-live", is_dark)
806 }
807 #[cfg(not(feature = "portal"))]
808 {
809 let reader = crate::kde::from_kde()?;
810 run_pipeline(reader, "kde-breeze-live", is_dark)
811 }
812 }
813 #[cfg(not(feature = "kde"))]
814 LinuxDesktop::Kde => run_pipeline(ThemeSpec::preset("adwaita")?, "adwaita-live", is_dark),
815 #[cfg(feature = "portal")]
816 LinuxDesktop::Gnome | LinuxDesktop::Budgie => {
817 let reader = crate::gnome::from_gnome().await?;
818 run_pipeline(reader, "adwaita-live", is_dark)
819 }
820 #[cfg(not(feature = "portal"))]
821 LinuxDesktop::Gnome | LinuxDesktop::Budgie => {
822 run_pipeline(ThemeSpec::preset("adwaita")?, "adwaita-live", is_dark)
823 }
824 LinuxDesktop::Xfce | LinuxDesktop::Cinnamon | LinuxDesktop::Mate | LinuxDesktop::LxQt => {
825 run_pipeline(ThemeSpec::preset("adwaita")?, "adwaita-live", is_dark)
826 }
827 LinuxDesktop::Unknown => {
828 #[cfg(feature = "portal")]
830 {
831 if let Some(detected) = crate::gnome::detect_portal_backend().await {
832 return match detected {
833 #[cfg(feature = "kde")]
834 LinuxDesktop::Kde => {
835 let reader = crate::gnome::from_kde_with_portal().await?;
836 run_pipeline(reader, "kde-breeze-live", is_dark)
837 }
838 #[cfg(not(feature = "kde"))]
839 LinuxDesktop::Kde => {
840 run_pipeline(ThemeSpec::preset("adwaita")?, "adwaita-live", is_dark)
841 }
842 LinuxDesktop::Gnome => {
843 let reader = crate::gnome::from_gnome().await?;
844 run_pipeline(reader, "adwaita-live", is_dark)
845 }
846 _ => {
847 unreachable!("detect_portal_backend only returns Kde or Gnome")
848 }
849 };
850 }
851 }
852 #[cfg(feature = "kde")]
854 {
855 let path = crate::kde::kdeglobals_path();
856 if path.exists() {
857 let reader = crate::kde::from_kde()?;
858 return run_pipeline(reader, "kde-breeze-live", is_dark);
859 }
860 }
861 run_pipeline(ThemeSpec::preset("adwaita")?, "adwaita-live", is_dark)
862 }
863 }
864}
865
866#[must_use = "this returns the loaded icon data; it does not display it"]
895#[allow(unreachable_patterns, clippy::needless_return, unused_variables)]
896pub fn load_icon(role: IconRole, set: IconSet) -> Option<IconData> {
897 match set {
898 #[cfg(all(target_os = "linux", feature = "system-icons"))]
899 IconSet::Freedesktop => freedesktop::load_freedesktop_icon(role, 24),
900
901 #[cfg(all(target_os = "macos", feature = "system-icons"))]
902 IconSet::SfSymbols => sficons::load_sf_icon(role),
903
904 #[cfg(all(target_os = "windows", feature = "system-icons"))]
905 IconSet::SegoeIcons => winicons::load_windows_icon(role),
906
907 #[cfg(feature = "material-icons")]
908 IconSet::Material => {
909 bundled_icon_svg(role, IconSet::Material).map(|b| IconData::Svg(b.to_vec()))
910 }
911
912 #[cfg(feature = "lucide-icons")]
913 IconSet::Lucide => {
914 bundled_icon_svg(role, IconSet::Lucide).map(|b| IconData::Svg(b.to_vec()))
915 }
916
917 _ => None,
919 }
920}
921
922#[must_use = "this returns the loaded icon data; it does not display it"]
945#[allow(unreachable_patterns, unused_variables)]
946pub fn load_system_icon_by_name(name: &str, set: IconSet) -> Option<IconData> {
947 match set {
948 #[cfg(all(target_os = "linux", feature = "system-icons"))]
949 IconSet::Freedesktop => {
950 let theme = system_icon_theme();
951 freedesktop::load_freedesktop_icon_by_name(name, theme, 24)
952 }
953
954 #[cfg(all(target_os = "macos", feature = "system-icons"))]
955 IconSet::SfSymbols => sficons::load_sf_icon_by_name(name),
956
957 #[cfg(all(target_os = "windows", feature = "system-icons"))]
958 IconSet::SegoeIcons => winicons::load_windows_icon_by_name(name),
959
960 #[cfg(feature = "material-icons")]
961 IconSet::Material => {
962 bundled_icon_by_name(name, IconSet::Material).map(|b| IconData::Svg(b.to_vec()))
963 }
964
965 #[cfg(feature = "lucide-icons")]
966 IconSet::Lucide => {
967 bundled_icon_by_name(name, IconSet::Lucide).map(|b| IconData::Svg(b.to_vec()))
968 }
969
970 _ => None,
971 }
972}
973
974#[must_use = "this returns animation data; it does not display anything"]
994pub fn loading_indicator(set: IconSet) -> Option<AnimatedIcon> {
995 match set {
996 #[cfg(all(target_os = "linux", feature = "system-icons"))]
997 IconSet::Freedesktop => freedesktop::load_freedesktop_spinner(),
998
999 #[cfg(feature = "material-icons")]
1000 IconSet::Material => Some(spinners::material_spinner()),
1001
1002 #[cfg(feature = "lucide-icons")]
1003 IconSet::Lucide => Some(spinners::lucide_spinner()),
1004
1005 _ => None,
1006 }
1007}
1008
1009#[must_use = "this returns the loaded icon data; it does not display it"]
1032pub fn load_custom_icon(provider: &(impl IconProvider + ?Sized), set: IconSet) -> Option<IconData> {
1033 if let Some(name) = provider.icon_name(set)
1035 && let Some(data) = load_system_icon_by_name(name, set)
1036 {
1037 return Some(data);
1038 }
1039
1040 if let Some(svg) = provider.icon_svg(set) {
1042 return Some(IconData::Svg(svg.to_vec()));
1043 }
1044
1045 None
1047}
1048
1049#[cfg(test)]
1053pub(crate) static ENV_MUTEX: std::sync::Mutex<()> = std::sync::Mutex::new(());
1054
1055#[cfg(all(test, target_os = "linux"))]
1056#[allow(clippy::unwrap_used, clippy::expect_used)]
1057mod dispatch_tests {
1058 use super::*;
1059
1060 #[test]
1063 fn detect_kde_simple() {
1064 assert_eq!(detect_linux_de("KDE"), LinuxDesktop::Kde);
1065 }
1066
1067 #[test]
1068 fn detect_kde_colon_separated_after() {
1069 assert_eq!(detect_linux_de("ubuntu:KDE"), LinuxDesktop::Kde);
1070 }
1071
1072 #[test]
1073 fn detect_kde_colon_separated_before() {
1074 assert_eq!(detect_linux_de("KDE:plasma"), LinuxDesktop::Kde);
1075 }
1076
1077 #[test]
1078 fn detect_gnome_simple() {
1079 assert_eq!(detect_linux_de("GNOME"), LinuxDesktop::Gnome);
1080 }
1081
1082 #[test]
1083 fn detect_gnome_ubuntu() {
1084 assert_eq!(detect_linux_de("ubuntu:GNOME"), LinuxDesktop::Gnome);
1085 }
1086
1087 #[test]
1088 fn detect_xfce() {
1089 assert_eq!(detect_linux_de("XFCE"), LinuxDesktop::Xfce);
1090 }
1091
1092 #[test]
1093 fn detect_cinnamon() {
1094 assert_eq!(detect_linux_de("X-Cinnamon"), LinuxDesktop::Cinnamon);
1095 }
1096
1097 #[test]
1098 fn detect_cinnamon_short() {
1099 assert_eq!(detect_linux_de("Cinnamon"), LinuxDesktop::Cinnamon);
1100 }
1101
1102 #[test]
1103 fn detect_mate() {
1104 assert_eq!(detect_linux_de("MATE"), LinuxDesktop::Mate);
1105 }
1106
1107 #[test]
1108 fn detect_lxqt() {
1109 assert_eq!(detect_linux_de("LXQt"), LinuxDesktop::LxQt);
1110 }
1111
1112 #[test]
1113 fn detect_budgie() {
1114 assert_eq!(detect_linux_de("Budgie:GNOME"), LinuxDesktop::Budgie);
1115 }
1116
1117 #[test]
1118 fn detect_empty_string() {
1119 assert_eq!(detect_linux_de(""), LinuxDesktop::Unknown);
1120 }
1121
1122 #[test]
1125 #[allow(unsafe_code)]
1126 fn from_linux_non_kde_returns_adwaita() {
1127 let _guard = crate::ENV_MUTEX.lock().unwrap();
1128 unsafe { std::env::set_var("XDG_CURRENT_DESKTOP", "GNOME") };
1132 let result = from_linux();
1133 unsafe { std::env::remove_var("XDG_CURRENT_DESKTOP") };
1134
1135 let theme = result.expect("from_linux() should return Ok for non-KDE desktop");
1136 assert_eq!(theme.name, "Adwaita");
1137 }
1138
1139 #[test]
1142 #[cfg(feature = "kde")]
1143 #[allow(unsafe_code)]
1144 fn from_linux_unknown_de_with_kdeglobals_fallback() {
1145 let _guard = crate::ENV_MUTEX.lock().unwrap();
1146 use std::io::Write;
1147
1148 let tmp_dir = std::env::temp_dir().join("native_theme_test_kde_fallback");
1150 std::fs::create_dir_all(&tmp_dir).unwrap();
1151 let kdeglobals = tmp_dir.join("kdeglobals");
1152 let mut f = std::fs::File::create(&kdeglobals).unwrap();
1153 writeln!(
1154 f,
1155 "[General]\nColorScheme=TestTheme\n\n[Colors:Window]\nBackgroundNormal=239,240,241\n"
1156 )
1157 .unwrap();
1158
1159 let orig_xdg = std::env::var("XDG_CONFIG_HOME").ok();
1161 let orig_desktop = std::env::var("XDG_CURRENT_DESKTOP").ok();
1162
1163 unsafe { std::env::set_var("XDG_CONFIG_HOME", &tmp_dir) };
1164 unsafe { std::env::set_var("XDG_CURRENT_DESKTOP", "SomeUnknownDE") };
1165
1166 let result = from_linux();
1167
1168 match orig_xdg {
1170 Some(val) => unsafe { std::env::set_var("XDG_CONFIG_HOME", val) },
1171 None => unsafe { std::env::remove_var("XDG_CONFIG_HOME") },
1172 }
1173 match orig_desktop {
1174 Some(val) => unsafe { std::env::set_var("XDG_CURRENT_DESKTOP", val) },
1175 None => unsafe { std::env::remove_var("XDG_CURRENT_DESKTOP") },
1176 }
1177
1178 let _ = std::fs::remove_dir_all(&tmp_dir);
1180
1181 let theme = result.expect("from_linux() should return Ok with kdeglobals fallback");
1182 assert_eq!(
1183 theme.name, "TestTheme",
1184 "should use KDE theme name from kdeglobals"
1185 );
1186 }
1187
1188 #[test]
1189 #[allow(unsafe_code)]
1190 fn from_linux_unknown_de_without_kdeglobals_returns_adwaita() {
1191 let _guard = crate::ENV_MUTEX.lock().unwrap();
1192 let orig_xdg = std::env::var("XDG_CONFIG_HOME").ok();
1194 let orig_desktop = std::env::var("XDG_CURRENT_DESKTOP").ok();
1195
1196 unsafe {
1197 std::env::set_var(
1198 "XDG_CONFIG_HOME",
1199 "/tmp/nonexistent_native_theme_test_no_kde",
1200 )
1201 };
1202 unsafe { std::env::set_var("XDG_CURRENT_DESKTOP", "SomeUnknownDE") };
1203
1204 let result = from_linux();
1205
1206 match orig_xdg {
1208 Some(val) => unsafe { std::env::set_var("XDG_CONFIG_HOME", val) },
1209 None => unsafe { std::env::remove_var("XDG_CONFIG_HOME") },
1210 }
1211 match orig_desktop {
1212 Some(val) => unsafe { std::env::set_var("XDG_CURRENT_DESKTOP", val) },
1213 None => unsafe { std::env::remove_var("XDG_CURRENT_DESKTOP") },
1214 }
1215
1216 let theme = result.expect("from_linux() should return Ok (adwaita fallback)");
1217 assert_eq!(
1218 theme.name, "Adwaita",
1219 "should fall back to Adwaita without kdeglobals"
1220 );
1221 }
1222
1223 #[test]
1226 fn detect_hyprland_returns_unknown() {
1227 assert_eq!(detect_linux_de("Hyprland"), LinuxDesktop::Unknown);
1228 }
1229
1230 #[test]
1231 fn detect_sway_returns_unknown() {
1232 assert_eq!(detect_linux_de("sway"), LinuxDesktop::Unknown);
1233 }
1234
1235 #[test]
1236 fn detect_cosmic_returns_unknown() {
1237 assert_eq!(detect_linux_de("COSMIC"), LinuxDesktop::Unknown);
1238 }
1239
1240 #[test]
1243 #[allow(unsafe_code)]
1244 fn from_system_returns_result() {
1245 let _guard = crate::ENV_MUTEX.lock().unwrap();
1246 unsafe { std::env::set_var("XDG_CURRENT_DESKTOP", "GNOME") };
1250 let result = SystemTheme::from_system();
1251 unsafe { std::env::remove_var("XDG_CURRENT_DESKTOP") };
1252
1253 let theme = result.expect("from_system() should return Ok on Linux");
1254 assert_eq!(theme.name, "Adwaita");
1255 }
1256}
1257
1258#[cfg(test)]
1259#[allow(clippy::unwrap_used, clippy::expect_used)]
1260mod load_icon_tests {
1261 use super::*;
1262
1263 #[test]
1264 #[cfg(feature = "material-icons")]
1265 fn load_icon_material_returns_svg() {
1266 let result = load_icon(IconRole::ActionCopy, IconSet::Material);
1267 assert!(result.is_some(), "material ActionCopy should return Some");
1268 match result.unwrap() {
1269 IconData::Svg(bytes) => {
1270 let content = std::str::from_utf8(&bytes).expect("should be valid UTF-8");
1271 assert!(content.contains("<svg"), "should contain SVG data");
1272 }
1273 _ => panic!("expected IconData::Svg for bundled material icon"),
1274 }
1275 }
1276
1277 #[test]
1278 #[cfg(feature = "lucide-icons")]
1279 fn load_icon_lucide_returns_svg() {
1280 let result = load_icon(IconRole::ActionCopy, IconSet::Lucide);
1281 assert!(result.is_some(), "lucide ActionCopy should return Some");
1282 match result.unwrap() {
1283 IconData::Svg(bytes) => {
1284 let content = std::str::from_utf8(&bytes).expect("should be valid UTF-8");
1285 assert!(content.contains("<svg"), "should contain SVG data");
1286 }
1287 _ => panic!("expected IconData::Svg for bundled lucide icon"),
1288 }
1289 }
1290
1291 #[test]
1292 #[cfg(feature = "material-icons")]
1293 fn load_icon_unknown_theme_no_cross_set_fallback() {
1294 let result = load_icon(IconRole::ActionCopy, IconSet::Freedesktop);
1298 let _ = result;
1302 }
1303
1304 #[test]
1305 #[cfg(feature = "material-icons")]
1306 fn load_icon_all_roles_material() {
1307 let mut some_count = 0;
1309 for role in IconRole::ALL {
1310 if load_icon(role, IconSet::Material).is_some() {
1311 some_count += 1;
1312 }
1313 }
1314 assert_eq!(
1316 some_count, 42,
1317 "Material should cover all 42 roles via bundled SVGs"
1318 );
1319 }
1320
1321 #[test]
1322 #[cfg(feature = "lucide-icons")]
1323 fn load_icon_all_roles_lucide() {
1324 let mut some_count = 0;
1325 for role in IconRole::ALL {
1326 if load_icon(role, IconSet::Lucide).is_some() {
1327 some_count += 1;
1328 }
1329 }
1330 assert_eq!(
1332 some_count, 42,
1333 "Lucide should cover all 42 roles via bundled SVGs"
1334 );
1335 }
1336
1337 #[test]
1338 fn load_icon_unrecognized_set_no_features() {
1339 let _result = load_icon(IconRole::ActionCopy, IconSet::SfSymbols);
1341 }
1343}
1344
1345#[cfg(test)]
1346#[allow(clippy::unwrap_used, clippy::expect_used)]
1347mod load_system_icon_by_name_tests {
1348 use super::*;
1349
1350 #[test]
1351 #[cfg(feature = "material-icons")]
1352 fn system_icon_by_name_material() {
1353 let result = load_system_icon_by_name("content_copy", IconSet::Material);
1354 assert!(
1355 result.is_some(),
1356 "content_copy should be found in Material set"
1357 );
1358 assert!(matches!(result.unwrap(), IconData::Svg(_)));
1359 }
1360
1361 #[test]
1362 #[cfg(feature = "lucide-icons")]
1363 fn system_icon_by_name_lucide() {
1364 let result = load_system_icon_by_name("copy", IconSet::Lucide);
1365 assert!(result.is_some(), "copy should be found in Lucide set");
1366 assert!(matches!(result.unwrap(), IconData::Svg(_)));
1367 }
1368
1369 #[test]
1370 #[cfg(feature = "material-icons")]
1371 fn system_icon_by_name_unknown_returns_none() {
1372 let result = load_system_icon_by_name("nonexistent_xyz", IconSet::Material);
1373 assert!(result.is_none(), "nonexistent name should return None");
1374 }
1375
1376 #[test]
1377 fn system_icon_by_name_sf_on_linux_returns_none() {
1378 #[cfg(not(target_os = "macos"))]
1380 {
1381 let result = load_system_icon_by_name("doc.on.doc", IconSet::SfSymbols);
1382 assert!(
1383 result.is_none(),
1384 "SF Symbols should return None on non-macOS"
1385 );
1386 }
1387 }
1388}
1389
1390#[cfg(test)]
1391#[allow(clippy::unwrap_used, clippy::expect_used)]
1392mod load_custom_icon_tests {
1393 use super::*;
1394
1395 #[test]
1396 #[cfg(feature = "material-icons")]
1397 fn custom_icon_with_icon_role_material() {
1398 let result = load_custom_icon(&IconRole::ActionCopy, IconSet::Material);
1399 assert!(
1400 result.is_some(),
1401 "IconRole::ActionCopy should load via material"
1402 );
1403 }
1404
1405 #[test]
1406 #[cfg(feature = "lucide-icons")]
1407 fn custom_icon_with_icon_role_lucide() {
1408 let result = load_custom_icon(&IconRole::ActionCopy, IconSet::Lucide);
1409 assert!(
1410 result.is_some(),
1411 "IconRole::ActionCopy should load via lucide"
1412 );
1413 }
1414
1415 #[test]
1416 fn custom_icon_no_cross_set_fallback() {
1417 #[derive(Debug)]
1419 struct NullProvider;
1420 impl IconProvider for NullProvider {
1421 fn icon_name(&self, _set: IconSet) -> Option<&str> {
1422 None
1423 }
1424 fn icon_svg(&self, _set: IconSet) -> Option<&'static [u8]> {
1425 None
1426 }
1427 }
1428
1429 let result = load_custom_icon(&NullProvider, IconSet::Material);
1430 assert!(
1431 result.is_none(),
1432 "NullProvider should return None (no cross-set fallback)"
1433 );
1434 }
1435
1436 #[test]
1437 fn custom_icon_unknown_set_uses_system() {
1438 #[derive(Debug)]
1440 struct NullProvider;
1441 impl IconProvider for NullProvider {
1442 fn icon_name(&self, _set: IconSet) -> Option<&str> {
1443 None
1444 }
1445 fn icon_svg(&self, _set: IconSet) -> Option<&'static [u8]> {
1446 None
1447 }
1448 }
1449
1450 let _result = load_custom_icon(&NullProvider, IconSet::Freedesktop);
1452 }
1453
1454 #[test]
1455 #[cfg(feature = "material-icons")]
1456 fn custom_icon_via_dyn_dispatch() {
1457 let boxed: Box<dyn IconProvider> = Box::new(IconRole::ActionCopy);
1458 let result = load_custom_icon(&*boxed, IconSet::Material);
1459 assert!(
1460 result.is_some(),
1461 "dyn dispatch through Box<dyn IconProvider> should work"
1462 );
1463 }
1464
1465 #[test]
1466 #[cfg(feature = "material-icons")]
1467 fn custom_icon_bundled_svg_fallback() {
1468 #[derive(Debug)]
1470 struct SvgOnlyProvider;
1471 impl IconProvider for SvgOnlyProvider {
1472 fn icon_name(&self, _set: IconSet) -> Option<&str> {
1473 None
1474 }
1475 fn icon_svg(&self, _set: IconSet) -> Option<&'static [u8]> {
1476 Some(b"<svg>test</svg>")
1477 }
1478 }
1479
1480 let result = load_custom_icon(&SvgOnlyProvider, IconSet::Material);
1481 assert!(
1482 result.is_some(),
1483 "provider with icon_svg should return Some"
1484 );
1485 match result.unwrap() {
1486 IconData::Svg(bytes) => {
1487 assert_eq!(bytes, b"<svg>test</svg>");
1488 }
1489 _ => panic!("expected IconData::Svg"),
1490 }
1491 }
1492}
1493
1494#[cfg(test)]
1495#[allow(clippy::unwrap_used, clippy::expect_used)]
1496mod loading_indicator_tests {
1497 use super::*;
1498
1499 #[test]
1502 #[cfg(feature = "lucide-icons")]
1503 fn loading_indicator_lucide_returns_transform_spin() {
1504 let anim = loading_indicator(IconSet::Lucide);
1505 assert!(anim.is_some(), "lucide should return Some");
1506 let anim = anim.unwrap();
1507 assert!(
1508 matches!(
1509 anim,
1510 AnimatedIcon::Transform {
1511 animation: TransformAnimation::Spin { duration_ms: 1000 },
1512 ..
1513 }
1514 ),
1515 "lucide should be Transform::Spin at 1000ms"
1516 );
1517 }
1518
1519 #[test]
1522 #[cfg(all(target_os = "linux", feature = "system-icons"))]
1523 fn loading_indicator_freedesktop_depends_on_theme() {
1524 let anim = loading_indicator(IconSet::Freedesktop);
1525 if let Some(anim) = anim {
1527 match anim {
1528 AnimatedIcon::Frames { frames, .. } => {
1529 assert!(
1530 !frames.is_empty(),
1531 "Frames variant should have at least one frame"
1532 );
1533 }
1534 AnimatedIcon::Transform { .. } => {
1535 }
1537 }
1538 }
1539 }
1540
1541 #[test]
1543 fn loading_indicator_freedesktop_does_not_panic() {
1544 let _result = loading_indicator(IconSet::Freedesktop);
1545 }
1546
1547 #[test]
1550 #[cfg(feature = "lucide-icons")]
1551 fn lucide_spinner_is_transform() {
1552 let anim = spinners::lucide_spinner();
1553 assert!(matches!(
1554 anim,
1555 AnimatedIcon::Transform {
1556 animation: TransformAnimation::Spin { duration_ms: 1000 },
1557 ..
1558 }
1559 ));
1560 }
1561}
1562
1563#[cfg(all(test, feature = "svg-rasterize"))]
1564#[allow(clippy::unwrap_used, clippy::expect_used)]
1565mod spinner_rasterize_tests {
1566 use super::*;
1567
1568 #[test]
1569 #[cfg(feature = "lucide-icons")]
1570 fn lucide_spinner_icon_rasterizes() {
1571 let anim = spinners::lucide_spinner();
1572 if let AnimatedIcon::Transform { icon, .. } = &anim {
1573 if let IconData::Svg(bytes) = icon {
1574 let result = crate::rasterize::rasterize_svg(bytes, 24);
1575 assert!(result.is_ok(), "lucide loader should rasterize");
1576 if let Ok(IconData::Rgba { data, .. }) = &result {
1577 assert!(
1578 data.iter().any(|&b| b != 0),
1579 "lucide loader rasterized to empty image"
1580 );
1581 }
1582 } else {
1583 panic!("lucide spinner icon should be Svg");
1584 }
1585 } else {
1586 panic!("lucide spinner should be Transform");
1587 }
1588 }
1589}
1590
1591#[cfg(test)]
1592#[allow(
1593 clippy::unwrap_used,
1594 clippy::expect_used,
1595 clippy::field_reassign_with_default
1596)]
1597mod system_theme_tests {
1598 use super::*;
1599
1600 #[test]
1603 fn test_system_theme_active_dark() {
1604 let preset = ThemeSpec::preset("catppuccin-mocha").unwrap();
1605 let mut light_v = preset.light.clone().unwrap();
1606 let mut dark_v = preset.dark.clone().unwrap();
1607 light_v.defaults.accent = Some(Rgba::rgb(0, 0, 255));
1609 dark_v.defaults.accent = Some(Rgba::rgb(255, 0, 0));
1610 light_v.resolve();
1611 dark_v.resolve();
1612 let light_resolved = light_v.validate().unwrap();
1613 let dark_resolved = dark_v.validate().unwrap();
1614
1615 let st = SystemTheme {
1616 name: "test".into(),
1617 is_dark: true,
1618 light: light_resolved.clone(),
1619 dark: dark_resolved.clone(),
1620 light_variant: preset.light.unwrap(),
1621 dark_variant: preset.dark.unwrap(),
1622 live_preset: "catppuccin-mocha".into(),
1623 preset: "catppuccin-mocha".into(),
1624 };
1625 assert_eq!(st.active().defaults.accent, dark_resolved.defaults.accent);
1626 }
1627
1628 #[test]
1629 fn test_system_theme_active_light() {
1630 let preset = ThemeSpec::preset("catppuccin-mocha").unwrap();
1631 let mut light_v = preset.light.clone().unwrap();
1632 let mut dark_v = preset.dark.clone().unwrap();
1633 light_v.defaults.accent = Some(Rgba::rgb(0, 0, 255));
1634 dark_v.defaults.accent = Some(Rgba::rgb(255, 0, 0));
1635 light_v.resolve();
1636 dark_v.resolve();
1637 let light_resolved = light_v.validate().unwrap();
1638 let dark_resolved = dark_v.validate().unwrap();
1639
1640 let st = SystemTheme {
1641 name: "test".into(),
1642 is_dark: false,
1643 light: light_resolved.clone(),
1644 dark: dark_resolved.clone(),
1645 light_variant: preset.light.unwrap(),
1646 dark_variant: preset.dark.unwrap(),
1647 live_preset: "catppuccin-mocha".into(),
1648 preset: "catppuccin-mocha".into(),
1649 };
1650 assert_eq!(st.active().defaults.accent, light_resolved.defaults.accent);
1651 }
1652
1653 #[test]
1654 fn test_system_theme_pick() {
1655 let preset = ThemeSpec::preset("catppuccin-mocha").unwrap();
1656 let mut light_v = preset.light.clone().unwrap();
1657 let mut dark_v = preset.dark.clone().unwrap();
1658 light_v.defaults.accent = Some(Rgba::rgb(0, 0, 255));
1659 dark_v.defaults.accent = Some(Rgba::rgb(255, 0, 0));
1660 light_v.resolve();
1661 dark_v.resolve();
1662 let light_resolved = light_v.validate().unwrap();
1663 let dark_resolved = dark_v.validate().unwrap();
1664
1665 let st = SystemTheme {
1666 name: "test".into(),
1667 is_dark: false,
1668 light: light_resolved.clone(),
1669 dark: dark_resolved.clone(),
1670 light_variant: preset.light.unwrap(),
1671 dark_variant: preset.dark.unwrap(),
1672 live_preset: "catppuccin-mocha".into(),
1673 preset: "catppuccin-mocha".into(),
1674 };
1675 assert_eq!(st.pick(true).defaults.accent, dark_resolved.defaults.accent);
1676 assert_eq!(
1677 st.pick(false).defaults.accent,
1678 light_resolved.defaults.accent
1679 );
1680 }
1681
1682 #[test]
1685 #[cfg(target_os = "linux")]
1686 #[allow(unsafe_code)]
1687 fn test_platform_preset_name_kde() {
1688 let _guard = crate::ENV_MUTEX.lock().unwrap();
1689 unsafe { std::env::set_var("XDG_CURRENT_DESKTOP", "KDE") };
1690 let name = platform_preset_name();
1691 unsafe { std::env::remove_var("XDG_CURRENT_DESKTOP") };
1692 assert_eq!(name, "kde-breeze-live");
1693 }
1694
1695 #[test]
1696 #[cfg(target_os = "linux")]
1697 #[allow(unsafe_code)]
1698 fn test_platform_preset_name_gnome() {
1699 let _guard = crate::ENV_MUTEX.lock().unwrap();
1700 unsafe { std::env::set_var("XDG_CURRENT_DESKTOP", "GNOME") };
1701 let name = platform_preset_name();
1702 unsafe { std::env::remove_var("XDG_CURRENT_DESKTOP") };
1703 assert_eq!(name, "adwaita-live");
1704 }
1705
1706 #[test]
1709 fn test_run_pipeline_produces_both_variants() {
1710 let reader = ThemeSpec::preset("catppuccin-mocha").unwrap();
1711 let result = run_pipeline(reader, "catppuccin-mocha", false);
1712 assert!(result.is_ok(), "run_pipeline should succeed");
1713 let st = result.unwrap();
1714 assert!(!st.name.is_empty(), "name should be populated");
1716 }
1718
1719 #[test]
1720 fn test_run_pipeline_reader_values_win() {
1721 let custom_accent = Rgba::rgb(42, 100, 200);
1723 let mut reader = ThemeSpec::default();
1724 reader.name = "CustomTheme".into();
1725 let mut variant = ThemeVariant::default();
1726 variant.defaults.accent = Some(custom_accent);
1727 reader.light = Some(variant);
1728
1729 let result = run_pipeline(reader, "catppuccin-mocha", false);
1730 assert!(result.is_ok(), "run_pipeline should succeed");
1731 let st = result.unwrap();
1732 assert_eq!(
1734 st.light.defaults.accent, custom_accent,
1735 "reader accent should win over preset accent"
1736 );
1737 assert_eq!(st.name, "CustomTheme", "reader name should win");
1738 }
1739
1740 #[test]
1741 fn test_run_pipeline_single_variant() {
1742 let full = ThemeSpec::preset("kde-breeze").unwrap();
1746 let mut reader = ThemeSpec::default();
1747 let mut dark_v = full.dark.clone().unwrap();
1748 dark_v.defaults.accent = Some(Rgba::rgb(200, 50, 50));
1750 reader.dark = Some(dark_v);
1751 reader.light = None;
1752
1753 let result = run_pipeline(reader, "kde-breeze-live", true);
1754 assert!(
1755 result.is_ok(),
1756 "run_pipeline should succeed with single variant"
1757 );
1758 let st = result.unwrap();
1759 assert_eq!(
1761 st.dark.defaults.accent,
1762 Rgba::rgb(200, 50, 50),
1763 "dark variant should have reader accent"
1764 );
1765 assert_eq!(st.live_preset, "kde-breeze-live");
1768 assert_eq!(st.preset, "kde-breeze");
1769 }
1770
1771 #[test]
1772 fn test_run_pipeline_inactive_variant_from_full_preset() {
1773 let full = ThemeSpec::preset("kde-breeze").unwrap();
1776 let mut reader = ThemeSpec::default();
1777 reader.dark = Some(full.dark.clone().unwrap());
1778 reader.light = None;
1779
1780 let st = run_pipeline(reader, "kde-breeze-live", true).unwrap();
1781
1782 let full_light = full.light.unwrap();
1784 assert_eq!(
1785 st.light.defaults.accent,
1786 full_light.defaults.accent.unwrap(),
1787 "inactive light variant should get accent from full preset"
1788 );
1789 assert_eq!(
1790 st.light.defaults.background,
1791 full_light.defaults.background.unwrap(),
1792 "inactive light variant should get background from full preset"
1793 );
1794 }
1795
1796 #[test]
1799 fn test_run_pipeline_with_preset_as_reader() {
1800 let reader = ThemeSpec::preset("adwaita").unwrap();
1803 let result = run_pipeline(reader, "adwaita", false);
1804 assert!(
1805 result.is_ok(),
1806 "double-merge with same preset should succeed"
1807 );
1808 let st = result.unwrap();
1809 assert_eq!(st.name, "Adwaita");
1810 }
1811
1812 #[test]
1815 fn test_reader_is_dark_only_dark() {
1816 let mut theme = ThemeSpec::default();
1817 theme.dark = Some(ThemeVariant::default());
1818 theme.light = None;
1819 assert!(
1820 reader_is_dark(&theme),
1821 "should be true when only dark is set"
1822 );
1823 }
1824
1825 #[test]
1826 fn test_reader_is_dark_only_light() {
1827 let mut theme = ThemeSpec::default();
1828 theme.light = Some(ThemeVariant::default());
1829 theme.dark = None;
1830 assert!(
1831 !reader_is_dark(&theme),
1832 "should be false when only light is set"
1833 );
1834 }
1835
1836 #[test]
1837 fn test_reader_is_dark_both() {
1838 let mut theme = ThemeSpec::default();
1839 theme.light = Some(ThemeVariant::default());
1840 theme.dark = Some(ThemeVariant::default());
1841 assert!(
1842 !reader_is_dark(&theme),
1843 "should be false when both are set (macOS case)"
1844 );
1845 }
1846
1847 #[test]
1848 fn test_reader_is_dark_neither() {
1849 let theme = ThemeSpec::default();
1850 assert!(
1851 !reader_is_dark(&theme),
1852 "should be false when neither is set"
1853 );
1854 }
1855}
1856
1857#[cfg(test)]
1858#[allow(clippy::unwrap_used, clippy::expect_used)]
1859mod reduced_motion_tests {
1860 use super::*;
1861
1862 #[test]
1863 fn prefers_reduced_motion_smoke_test() {
1864 let _result = prefers_reduced_motion();
1868 }
1869
1870 #[cfg(target_os = "linux")]
1871 #[test]
1872 fn detect_reduced_motion_inner_linux() {
1873 let result = detect_reduced_motion_inner();
1877 let _ = result;
1879 }
1880
1881 #[cfg(target_os = "macos")]
1882 #[test]
1883 fn detect_reduced_motion_inner_macos() {
1884 let result = detect_reduced_motion_inner();
1885 let _ = result;
1886 }
1887
1888 #[cfg(target_os = "windows")]
1889 #[test]
1890 fn detect_reduced_motion_inner_windows() {
1891 let result = detect_reduced_motion_inner();
1892 let _ = result;
1893 }
1894}
1895
1896#[cfg(test)]
1897#[allow(clippy::unwrap_used, clippy::expect_used)]
1898mod overlay_tests {
1899 use super::*;
1900
1901 fn default_system_theme() -> SystemTheme {
1903 let reader = ThemeSpec::preset("catppuccin-mocha").unwrap();
1904 run_pipeline(reader, "catppuccin-mocha", false).unwrap()
1905 }
1906
1907 #[test]
1908 fn test_overlay_accent_propagates() {
1909 let st = default_system_theme();
1910 let new_accent = Rgba::rgb(255, 0, 0);
1911
1912 let mut overlay = ThemeSpec::default();
1914 let mut light_v = ThemeVariant::default();
1915 light_v.defaults.accent = Some(new_accent);
1916 let mut dark_v = ThemeVariant::default();
1917 dark_v.defaults.accent = Some(new_accent);
1918 overlay.light = Some(light_v);
1919 overlay.dark = Some(dark_v);
1920
1921 let result = st.with_overlay(&overlay).unwrap();
1922
1923 assert_eq!(result.light.defaults.accent, new_accent);
1925 assert_eq!(result.light.button.primary_bg, new_accent);
1927 assert_eq!(result.light.checkbox.checked_bg, new_accent);
1928 assert_eq!(result.light.slider.fill, new_accent);
1929 assert_eq!(result.light.progress_bar.fill, new_accent);
1930 assert_eq!(result.light.switch.checked_bg, new_accent);
1931 }
1932
1933 #[test]
1934 fn test_overlay_preserves_unrelated_fields() {
1935 let st = default_system_theme();
1936 let original_bg = st.light.defaults.background;
1937
1938 let mut overlay = ThemeSpec::default();
1940 let mut light_v = ThemeVariant::default();
1941 light_v.defaults.accent = Some(Rgba::rgb(255, 0, 0));
1942 overlay.light = Some(light_v);
1943
1944 let result = st.with_overlay(&overlay).unwrap();
1945 assert_eq!(
1946 result.light.defaults.background, original_bg,
1947 "background should be unchanged"
1948 );
1949 }
1950
1951 #[test]
1952 fn test_overlay_empty_noop() {
1953 let st = default_system_theme();
1954 let original_light_accent = st.light.defaults.accent;
1955 let original_dark_accent = st.dark.defaults.accent;
1956 let original_light_bg = st.light.defaults.background;
1957
1958 let overlay = ThemeSpec::default();
1960 let result = st.with_overlay(&overlay).unwrap();
1961
1962 assert_eq!(result.light.defaults.accent, original_light_accent);
1963 assert_eq!(result.dark.defaults.accent, original_dark_accent);
1964 assert_eq!(result.light.defaults.background, original_light_bg);
1965 }
1966
1967 #[test]
1968 fn test_overlay_both_variants() {
1969 let st = default_system_theme();
1970 let red = Rgba::rgb(255, 0, 0);
1971 let green = Rgba::rgb(0, 255, 0);
1972
1973 let mut overlay = ThemeSpec::default();
1974 let mut light_v = ThemeVariant::default();
1975 light_v.defaults.accent = Some(red);
1976 let mut dark_v = ThemeVariant::default();
1977 dark_v.defaults.accent = Some(green);
1978 overlay.light = Some(light_v);
1979 overlay.dark = Some(dark_v);
1980
1981 let result = st.with_overlay(&overlay).unwrap();
1982 assert_eq!(result.light.defaults.accent, red, "light accent = red");
1983 assert_eq!(result.dark.defaults.accent, green, "dark accent = green");
1984 }
1985
1986 #[test]
1987 fn test_overlay_font_family() {
1988 let st = default_system_theme();
1989
1990 let mut overlay = ThemeSpec::default();
1991 let mut light_v = ThemeVariant::default();
1992 light_v.defaults.font.family = Some("Comic Sans".into());
1993 overlay.light = Some(light_v);
1994
1995 let result = st.with_overlay(&overlay).unwrap();
1996 assert_eq!(result.light.defaults.font.family, "Comic Sans");
1997 }
1998
1999 #[test]
2000 fn test_overlay_toml_convenience() {
2001 let st = default_system_theme();
2002 let result = st
2003 .with_overlay_toml(
2004 r##"
2005 name = "overlay"
2006 [light.defaults]
2007 accent = "#ff0000"
2008 "##,
2009 )
2010 .unwrap();
2011 assert_eq!(result.light.defaults.accent, Rgba::rgb(255, 0, 0));
2012 }
2013}