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, 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"]
243pub fn system_is_dark() -> bool {
244 static CACHED_IS_DARK: std::sync::OnceLock<bool> = std::sync::OnceLock::new();
245 *CACHED_IS_DARK.get_or_init(detect_is_dark_inner)
246}
247
248#[allow(unreachable_code)]
252fn detect_is_dark_inner() -> bool {
253 #[cfg(target_os = "linux")]
254 {
255 if let Ok(output) = std::process::Command::new("gsettings")
257 .args(["get", "org.gnome.desktop.interface", "color-scheme"])
258 .output()
259 && output.status.success()
260 {
261 let val = String::from_utf8_lossy(&output.stdout);
262 if val.contains("prefer-dark") {
263 return true;
264 }
265 if val.contains("prefer-light") || val.contains("default") {
266 return false;
267 }
268 }
269
270 #[cfg(feature = "kde")]
272 {
273 let path = crate::kde::kdeglobals_path();
274 if let Ok(content) = std::fs::read_to_string(&path) {
275 let mut ini = crate::kde::create_kde_parser();
276 if ini.read(content).is_ok() {
277 return crate::kde::is_dark_theme(&ini);
278 }
279 }
280 }
281
282 false
283 }
284
285 #[cfg(target_os = "macos")]
286 {
287 #[cfg(feature = "macos")]
290 {
291 use objc2_foundation::NSUserDefaults;
292 let defaults = NSUserDefaults::standardUserDefaults();
293 let key = objc2_foundation::ns_string!("AppleInterfaceStyle");
294 if let Some(value) = defaults.stringForKey(key) {
295 return value.to_string().eq_ignore_ascii_case("dark");
296 }
297 return false;
298 }
299 #[cfg(not(feature = "macos"))]
300 {
301 if let Ok(output) = std::process::Command::new("defaults")
302 .args(["read", "-g", "AppleInterfaceStyle"])
303 .output()
304 && output.status.success()
305 {
306 let val = String::from_utf8_lossy(&output.stdout);
307 return val.trim().eq_ignore_ascii_case("dark");
308 }
309 return false;
310 }
311 }
312
313 #[cfg(target_os = "windows")]
314 {
315 #[cfg(feature = "windows")]
316 {
317 let Ok(settings) = ::windows::UI::ViewManagement::UISettings::new() else {
319 return false;
320 };
321 let Ok(fg) =
322 settings.GetColorValue(::windows::UI::ViewManagement::UIColorType::Foreground)
323 else {
324 return false;
325 };
326 let luma = 0.299 * (fg.R as f32) + 0.587 * (fg.G as f32) + 0.114 * (fg.B as f32);
327 return luma > 128.0;
328 }
329 #[cfg(not(feature = "windows"))]
330 return false;
331 }
332
333 #[cfg(not(any(target_os = "linux", target_os = "macos", target_os = "windows")))]
334 {
335 false
336 }
337}
338
339#[must_use = "this returns whether reduced motion is preferred"]
369pub fn prefers_reduced_motion() -> bool {
370 static CACHED: std::sync::OnceLock<bool> = std::sync::OnceLock::new();
371 *CACHED.get_or_init(detect_reduced_motion_inner)
372}
373
374#[allow(unreachable_code)]
378fn detect_reduced_motion_inner() -> bool {
379 #[cfg(target_os = "linux")]
380 {
381 if let Ok(output) = std::process::Command::new("gsettings")
384 .args(["get", "org.gnome.desktop.interface", "enable-animations"])
385 .output()
386 && output.status.success()
387 {
388 let val = String::from_utf8_lossy(&output.stdout);
389 return val.trim() == "false";
390 }
391 false
392 }
393
394 #[cfg(target_os = "macos")]
395 {
396 #[cfg(feature = "macos")]
397 {
398 let workspace = objc2_app_kit::NSWorkspace::sharedWorkspace();
399 return workspace.accessibilityDisplayShouldReduceMotion();
401 }
402 #[cfg(not(feature = "macos"))]
403 return false;
404 }
405
406 #[cfg(target_os = "windows")]
407 {
408 #[cfg(feature = "windows")]
409 {
410 let Ok(settings) = ::windows::UI::ViewManagement::UISettings::new() else {
411 return false;
412 };
413 return match settings.AnimationsEnabled() {
415 Ok(enabled) => !enabled,
416 Err(_) => false,
417 };
418 }
419 #[cfg(not(feature = "windows"))]
420 return false;
421 }
422
423 #[cfg(not(any(target_os = "linux", target_os = "macos", target_os = "windows")))]
424 {
425 false
426 }
427}
428
429#[derive(Clone, Debug)]
436pub struct SystemTheme {
437 pub name: String,
439 pub is_dark: bool,
441 pub light: ResolvedThemeVariant,
443 pub dark: ResolvedThemeVariant,
445 pub(crate) light_variant: ThemeVariant,
447 pub(crate) dark_variant: ThemeVariant,
449 pub preset: String,
451 pub(crate) live_preset: String,
453}
454
455impl SystemTheme {
456 #[must_use]
460 pub fn active(&self) -> &ResolvedThemeVariant {
461 if self.is_dark {
462 &self.dark
463 } else {
464 &self.light
465 }
466 }
467
468 #[must_use]
472 pub fn pick(&self, is_dark: bool) -> &ResolvedThemeVariant {
473 if is_dark { &self.dark } else { &self.light }
474 }
475
476 #[must_use = "this returns a new theme with the overlay applied; it does not modify self"]
500 pub fn with_overlay(&self, overlay: &ThemeSpec) -> crate::Result<Self> {
501 let mut light = self.light_variant.clone();
503 let mut dark = self.dark_variant.clone();
504
505 if let Some(over) = &overlay.light {
507 light.merge(over);
508 }
509 if let Some(over) = &overlay.dark {
510 dark.merge(over);
511 }
512
513 let resolved_light = light.clone().into_resolved()?;
515 let resolved_dark = dark.clone().into_resolved()?;
516
517 Ok(SystemTheme {
518 name: self.name.clone(),
519 is_dark: self.is_dark,
520 light: resolved_light,
521 dark: resolved_dark,
522 light_variant: light,
523 dark_variant: dark,
524 live_preset: self.live_preset.clone(),
525 preset: self.preset.clone(),
526 })
527 }
528
529 #[must_use = "this returns a new theme with the overlay applied; it does not modify self"]
533 pub fn with_overlay_toml(&self, toml: &str) -> crate::Result<Self> {
534 let overlay = ThemeSpec::from_toml(toml)?;
535 self.with_overlay(&overlay)
536 }
537
538 #[must_use = "this returns the detected theme; it does not apply it"]
575 pub fn from_system() -> crate::Result<Self> {
576 from_system_inner()
577 }
578
579 #[cfg(target_os = "linux")]
594 #[must_use = "this returns the detected theme; it does not apply it"]
595 pub async fn from_system_async() -> crate::Result<Self> {
596 from_system_async_inner().await
597 }
598
599 #[cfg(not(target_os = "linux"))]
604 #[must_use = "this returns the detected theme; it does not apply it"]
605 pub async fn from_system_async() -> crate::Result<Self> {
606 from_system_inner()
607 }
608}
609
610fn run_pipeline(
617 reader_output: ThemeSpec,
618 preset_name: &str,
619 is_dark: bool,
620) -> crate::Result<SystemTheme> {
621 let live_preset = ThemeSpec::preset(preset_name)?;
622
623 let full_preset_name = preset_name.strip_suffix("-live").unwrap_or(preset_name);
625 let full_preset = ThemeSpec::preset(full_preset_name)?;
626
627 let mut merged = full_preset.clone();
630 merged.merge(&live_preset);
631 merged.merge(&reader_output);
632
633 let name = if reader_output.name.is_empty() {
635 merged.name.clone()
636 } else {
637 reader_output.name.clone()
638 };
639
640 let light_variant = if reader_output.light.is_some() {
643 merged.light.unwrap_or_default()
644 } else {
645 full_preset.light.unwrap_or_default()
646 };
647
648 let dark_variant = if reader_output.dark.is_some() {
649 merged.dark.unwrap_or_default()
650 } else {
651 full_preset.dark.unwrap_or_default()
652 };
653
654 let light_variant_pre = light_variant.clone();
656 let dark_variant_pre = dark_variant.clone();
657
658 let light = light_variant.into_resolved()?;
659 let dark = dark_variant.into_resolved()?;
660
661 Ok(SystemTheme {
662 name,
663 is_dark,
664 light,
665 dark,
666 light_variant: light_variant_pre,
667 dark_variant: dark_variant_pre,
668 preset: full_preset_name.to_string(),
669 live_preset: preset_name.to_string(),
670 })
671}
672
673#[allow(unreachable_code)]
689#[must_use]
690pub fn platform_preset_name() -> &'static str {
691 #[cfg(target_os = "macos")]
692 {
693 return "macos-sonoma-live";
694 }
695 #[cfg(target_os = "windows")]
696 {
697 return "windows-11-live";
698 }
699 #[cfg(target_os = "linux")]
700 {
701 let desktop = std::env::var("XDG_CURRENT_DESKTOP").unwrap_or_default();
702 match detect_linux_de(&desktop) {
703 LinuxDesktop::Kde => "kde-breeze-live",
704 _ => "adwaita-live",
705 }
706 }
707 #[cfg(not(any(target_os = "linux", target_os = "windows", target_os = "macos")))]
708 {
709 "adwaita-live"
710 }
711}
712
713#[allow(dead_code)]
721fn reader_is_dark(reader: &ThemeSpec) -> bool {
722 reader.dark.is_some() && reader.light.is_none()
723}
724
725#[cfg(target_os = "linux")]
731fn from_linux() -> crate::Result<SystemTheme> {
732 let is_dark = system_is_dark();
733 let desktop = std::env::var("XDG_CURRENT_DESKTOP").unwrap_or_default();
734 match detect_linux_de(&desktop) {
735 #[cfg(feature = "kde")]
736 LinuxDesktop::Kde => {
737 let reader = crate::kde::from_kde()?;
738 run_pipeline(reader, "kde-breeze-live", is_dark)
739 }
740 #[cfg(not(feature = "kde"))]
741 LinuxDesktop::Kde => run_pipeline(ThemeSpec::preset("adwaita")?, "adwaita-live", is_dark),
742 LinuxDesktop::Gnome | LinuxDesktop::Budgie => {
743 run_pipeline(ThemeSpec::preset("adwaita")?, "adwaita-live", is_dark)
745 }
746 LinuxDesktop::Xfce | LinuxDesktop::Cinnamon | LinuxDesktop::Mate | LinuxDesktop::LxQt => {
747 run_pipeline(ThemeSpec::preset("adwaita")?, "adwaita-live", is_dark)
748 }
749 LinuxDesktop::Unknown => {
750 #[cfg(feature = "kde")]
751 {
752 let path = crate::kde::kdeglobals_path();
753 if path.exists() {
754 let reader = crate::kde::from_kde()?;
755 return run_pipeline(reader, "kde-breeze-live", is_dark);
756 }
757 }
758 run_pipeline(ThemeSpec::preset("adwaita")?, "adwaita-live", is_dark)
759 }
760 }
761}
762
763fn from_system_inner() -> crate::Result<SystemTheme> {
764 #[cfg(target_os = "macos")]
765 {
766 #[cfg(feature = "macos")]
767 {
768 let reader = crate::macos::from_macos()?;
769 let is_dark = reader_is_dark(&reader);
770 return run_pipeline(reader, "macos-sonoma-live", is_dark);
771 }
772
773 #[cfg(not(feature = "macos"))]
774 return Err(crate::Error::Unsupported(
775 "macOS theme detection requires the `macos` feature",
776 ));
777 }
778
779 #[cfg(target_os = "windows")]
780 {
781 #[cfg(feature = "windows")]
782 {
783 let reader = crate::windows::from_windows()?;
784 let is_dark = reader_is_dark(&reader);
785 return run_pipeline(reader, "windows-11-live", is_dark);
786 }
787
788 #[cfg(not(feature = "windows"))]
789 return Err(crate::Error::Unsupported(
790 "Windows theme detection requires the `windows` feature",
791 ));
792 }
793
794 #[cfg(target_os = "linux")]
795 {
796 from_linux()
797 }
798
799 #[cfg(not(any(target_os = "linux", target_os = "windows", target_os = "macos")))]
800 {
801 Err(crate::Error::Unsupported(
802 "no theme reader available for this platform",
803 ))
804 }
805}
806
807#[cfg(target_os = "linux")]
808async fn from_system_async_inner() -> crate::Result<SystemTheme> {
809 let is_dark = system_is_dark();
810 let desktop = std::env::var("XDG_CURRENT_DESKTOP").unwrap_or_default();
811 match detect_linux_de(&desktop) {
812 #[cfg(feature = "kde")]
813 LinuxDesktop::Kde => {
814 #[cfg(feature = "portal")]
815 {
816 let reader = crate::gnome::from_kde_with_portal().await?;
817 run_pipeline(reader, "kde-breeze-live", is_dark)
818 }
819 #[cfg(not(feature = "portal"))]
820 {
821 let reader = crate::kde::from_kde()?;
822 run_pipeline(reader, "kde-breeze-live", is_dark)
823 }
824 }
825 #[cfg(not(feature = "kde"))]
826 LinuxDesktop::Kde => run_pipeline(ThemeSpec::preset("adwaita")?, "adwaita-live", is_dark),
827 #[cfg(feature = "portal")]
828 LinuxDesktop::Gnome | LinuxDesktop::Budgie => {
829 let reader = crate::gnome::from_gnome().await?;
830 run_pipeline(reader, "adwaita-live", is_dark)
831 }
832 #[cfg(not(feature = "portal"))]
833 LinuxDesktop::Gnome | LinuxDesktop::Budgie => {
834 run_pipeline(ThemeSpec::preset("adwaita")?, "adwaita-live", is_dark)
835 }
836 LinuxDesktop::Xfce | LinuxDesktop::Cinnamon | LinuxDesktop::Mate | LinuxDesktop::LxQt => {
837 run_pipeline(ThemeSpec::preset("adwaita")?, "adwaita-live", is_dark)
838 }
839 LinuxDesktop::Unknown => {
840 #[cfg(feature = "portal")]
842 {
843 if let Some(detected) = crate::gnome::detect_portal_backend().await {
844 return match detected {
845 #[cfg(feature = "kde")]
846 LinuxDesktop::Kde => {
847 let reader = crate::gnome::from_kde_with_portal().await?;
848 run_pipeline(reader, "kde-breeze-live", is_dark)
849 }
850 #[cfg(not(feature = "kde"))]
851 LinuxDesktop::Kde => {
852 run_pipeline(ThemeSpec::preset("adwaita")?, "adwaita-live", is_dark)
853 }
854 LinuxDesktop::Gnome => {
855 let reader = crate::gnome::from_gnome().await?;
856 run_pipeline(reader, "adwaita-live", is_dark)
857 }
858 _ => {
859 run_pipeline(ThemeSpec::preset("adwaita")?, "adwaita-live", is_dark)
862 }
863 };
864 }
865 }
866 #[cfg(feature = "kde")]
868 {
869 let path = crate::kde::kdeglobals_path();
870 if path.exists() {
871 let reader = crate::kde::from_kde()?;
872 return run_pipeline(reader, "kde-breeze-live", is_dark);
873 }
874 }
875 run_pipeline(ThemeSpec::preset("adwaita")?, "adwaita-live", is_dark)
876 }
877 }
878}
879
880#[must_use = "this returns the loaded icon data; it does not display it"]
909#[allow(unreachable_patterns, clippy::needless_return, unused_variables)]
910pub fn load_icon(role: IconRole, set: IconSet) -> Option<IconData> {
911 match set {
912 #[cfg(all(target_os = "linux", feature = "system-icons"))]
913 IconSet::Freedesktop => freedesktop::load_freedesktop_icon(role, 24),
914
915 #[cfg(all(target_os = "macos", feature = "system-icons"))]
916 IconSet::SfSymbols => sficons::load_sf_icon(role),
917
918 #[cfg(all(target_os = "windows", feature = "system-icons"))]
919 IconSet::SegoeIcons => winicons::load_windows_icon(role),
920
921 #[cfg(feature = "material-icons")]
922 IconSet::Material => {
923 bundled_icon_svg(role, IconSet::Material).map(|b| IconData::Svg(b.to_vec()))
924 }
925
926 #[cfg(feature = "lucide-icons")]
927 IconSet::Lucide => {
928 bundled_icon_svg(role, IconSet::Lucide).map(|b| IconData::Svg(b.to_vec()))
929 }
930
931 _ => None,
933 }
934}
935
936#[must_use = "this returns the loaded icon data; it does not display it"]
959#[allow(unreachable_patterns, unused_variables)]
960pub fn load_system_icon_by_name(name: &str, set: IconSet) -> Option<IconData> {
961 match set {
962 #[cfg(all(target_os = "linux", feature = "system-icons"))]
963 IconSet::Freedesktop => {
964 let theme = system_icon_theme();
965 freedesktop::load_freedesktop_icon_by_name(name, theme, 24)
966 }
967
968 #[cfg(all(target_os = "macos", feature = "system-icons"))]
969 IconSet::SfSymbols => sficons::load_sf_icon_by_name(name),
970
971 #[cfg(all(target_os = "windows", feature = "system-icons"))]
972 IconSet::SegoeIcons => winicons::load_windows_icon_by_name(name),
973
974 #[cfg(feature = "material-icons")]
975 IconSet::Material => {
976 bundled_icon_by_name(name, IconSet::Material).map(|b| IconData::Svg(b.to_vec()))
977 }
978
979 #[cfg(feature = "lucide-icons")]
980 IconSet::Lucide => {
981 bundled_icon_by_name(name, IconSet::Lucide).map(|b| IconData::Svg(b.to_vec()))
982 }
983
984 _ => None,
985 }
986}
987
988#[must_use = "this returns animation data; it does not display anything"]
1008pub fn loading_indicator(set: IconSet) -> Option<AnimatedIcon> {
1009 match set {
1010 #[cfg(all(target_os = "linux", feature = "system-icons"))]
1011 IconSet::Freedesktop => freedesktop::load_freedesktop_spinner(),
1012
1013 #[cfg(feature = "material-icons")]
1014 IconSet::Material => Some(spinners::material_spinner()),
1015
1016 #[cfg(feature = "lucide-icons")]
1017 IconSet::Lucide => Some(spinners::lucide_spinner()),
1018
1019 _ => None,
1020 }
1021}
1022
1023#[must_use = "this returns the loaded icon data; it does not display it"]
1046pub fn load_custom_icon(provider: &(impl IconProvider + ?Sized), set: IconSet) -> Option<IconData> {
1047 if let Some(name) = provider.icon_name(set)
1049 && let Some(data) = load_system_icon_by_name(name, set)
1050 {
1051 return Some(data);
1052 }
1053
1054 if let Some(svg) = provider.icon_svg(set) {
1056 return Some(IconData::Svg(svg.to_vec()));
1057 }
1058
1059 None
1061}
1062
1063#[cfg(test)]
1067pub(crate) static ENV_MUTEX: std::sync::Mutex<()> = std::sync::Mutex::new(());
1068
1069#[cfg(all(test, target_os = "linux"))]
1070#[allow(clippy::unwrap_used, clippy::expect_used)]
1071mod dispatch_tests {
1072 use super::*;
1073
1074 #[test]
1077 fn detect_kde_simple() {
1078 assert_eq!(detect_linux_de("KDE"), LinuxDesktop::Kde);
1079 }
1080
1081 #[test]
1082 fn detect_kde_colon_separated_after() {
1083 assert_eq!(detect_linux_de("ubuntu:KDE"), LinuxDesktop::Kde);
1084 }
1085
1086 #[test]
1087 fn detect_kde_colon_separated_before() {
1088 assert_eq!(detect_linux_de("KDE:plasma"), LinuxDesktop::Kde);
1089 }
1090
1091 #[test]
1092 fn detect_gnome_simple() {
1093 assert_eq!(detect_linux_de("GNOME"), LinuxDesktop::Gnome);
1094 }
1095
1096 #[test]
1097 fn detect_gnome_ubuntu() {
1098 assert_eq!(detect_linux_de("ubuntu:GNOME"), LinuxDesktop::Gnome);
1099 }
1100
1101 #[test]
1102 fn detect_xfce() {
1103 assert_eq!(detect_linux_de("XFCE"), LinuxDesktop::Xfce);
1104 }
1105
1106 #[test]
1107 fn detect_cinnamon() {
1108 assert_eq!(detect_linux_de("X-Cinnamon"), LinuxDesktop::Cinnamon);
1109 }
1110
1111 #[test]
1112 fn detect_cinnamon_short() {
1113 assert_eq!(detect_linux_de("Cinnamon"), LinuxDesktop::Cinnamon);
1114 }
1115
1116 #[test]
1117 fn detect_mate() {
1118 assert_eq!(detect_linux_de("MATE"), LinuxDesktop::Mate);
1119 }
1120
1121 #[test]
1122 fn detect_lxqt() {
1123 assert_eq!(detect_linux_de("LXQt"), LinuxDesktop::LxQt);
1124 }
1125
1126 #[test]
1127 fn detect_budgie() {
1128 assert_eq!(detect_linux_de("Budgie:GNOME"), LinuxDesktop::Budgie);
1129 }
1130
1131 #[test]
1132 fn detect_empty_string() {
1133 assert_eq!(detect_linux_de(""), LinuxDesktop::Unknown);
1134 }
1135
1136 #[test]
1139 #[allow(unsafe_code)]
1140 fn from_linux_non_kde_returns_adwaita() {
1141 let _guard = crate::ENV_MUTEX.lock().unwrap();
1142 unsafe { std::env::set_var("XDG_CURRENT_DESKTOP", "GNOME") };
1146 let result = from_linux();
1147 unsafe { std::env::remove_var("XDG_CURRENT_DESKTOP") };
1148
1149 let theme = result.expect("from_linux() should return Ok for non-KDE desktop");
1150 assert_eq!(theme.name, "Adwaita");
1151 }
1152
1153 #[test]
1156 #[cfg(feature = "kde")]
1157 #[allow(unsafe_code)]
1158 fn from_linux_unknown_de_with_kdeglobals_fallback() {
1159 let _guard = crate::ENV_MUTEX.lock().unwrap();
1160 use std::io::Write;
1161
1162 let tmp_dir = std::env::temp_dir().join("native_theme_test_kde_fallback");
1164 std::fs::create_dir_all(&tmp_dir).unwrap();
1165 let kdeglobals = tmp_dir.join("kdeglobals");
1166 let mut f = std::fs::File::create(&kdeglobals).unwrap();
1167 writeln!(
1168 f,
1169 "[General]\nColorScheme=TestTheme\n\n[Colors:Window]\nBackgroundNormal=239,240,241\n"
1170 )
1171 .unwrap();
1172
1173 let orig_xdg = std::env::var("XDG_CONFIG_HOME").ok();
1175 let orig_desktop = std::env::var("XDG_CURRENT_DESKTOP").ok();
1176
1177 unsafe { std::env::set_var("XDG_CONFIG_HOME", &tmp_dir) };
1178 unsafe { std::env::set_var("XDG_CURRENT_DESKTOP", "SomeUnknownDE") };
1179
1180 let result = from_linux();
1181
1182 match orig_xdg {
1184 Some(val) => unsafe { std::env::set_var("XDG_CONFIG_HOME", val) },
1185 None => unsafe { std::env::remove_var("XDG_CONFIG_HOME") },
1186 }
1187 match orig_desktop {
1188 Some(val) => unsafe { std::env::set_var("XDG_CURRENT_DESKTOP", val) },
1189 None => unsafe { std::env::remove_var("XDG_CURRENT_DESKTOP") },
1190 }
1191
1192 let _ = std::fs::remove_dir_all(&tmp_dir);
1194
1195 let theme = result.expect("from_linux() should return Ok with kdeglobals fallback");
1196 assert_eq!(
1197 theme.name, "TestTheme",
1198 "should use KDE theme name from kdeglobals"
1199 );
1200 }
1201
1202 #[test]
1203 #[allow(unsafe_code)]
1204 fn from_linux_unknown_de_without_kdeglobals_returns_adwaita() {
1205 let _guard = crate::ENV_MUTEX.lock().unwrap();
1206 let orig_xdg = std::env::var("XDG_CONFIG_HOME").ok();
1208 let orig_desktop = std::env::var("XDG_CURRENT_DESKTOP").ok();
1209
1210 unsafe {
1211 std::env::set_var(
1212 "XDG_CONFIG_HOME",
1213 "/tmp/nonexistent_native_theme_test_no_kde",
1214 )
1215 };
1216 unsafe { std::env::set_var("XDG_CURRENT_DESKTOP", "SomeUnknownDE") };
1217
1218 let result = from_linux();
1219
1220 match orig_xdg {
1222 Some(val) => unsafe { std::env::set_var("XDG_CONFIG_HOME", val) },
1223 None => unsafe { std::env::remove_var("XDG_CONFIG_HOME") },
1224 }
1225 match orig_desktop {
1226 Some(val) => unsafe { std::env::set_var("XDG_CURRENT_DESKTOP", val) },
1227 None => unsafe { std::env::remove_var("XDG_CURRENT_DESKTOP") },
1228 }
1229
1230 let theme = result.expect("from_linux() should return Ok (adwaita fallback)");
1231 assert_eq!(
1232 theme.name, "Adwaita",
1233 "should fall back to Adwaita without kdeglobals"
1234 );
1235 }
1236
1237 #[test]
1240 fn detect_hyprland_returns_unknown() {
1241 assert_eq!(detect_linux_de("Hyprland"), LinuxDesktop::Unknown);
1242 }
1243
1244 #[test]
1245 fn detect_sway_returns_unknown() {
1246 assert_eq!(detect_linux_de("sway"), LinuxDesktop::Unknown);
1247 }
1248
1249 #[test]
1250 fn detect_cosmic_returns_unknown() {
1251 assert_eq!(detect_linux_de("COSMIC"), LinuxDesktop::Unknown);
1252 }
1253
1254 #[test]
1257 #[allow(unsafe_code)]
1258 fn from_system_returns_result() {
1259 let _guard = crate::ENV_MUTEX.lock().unwrap();
1260 unsafe { std::env::set_var("XDG_CURRENT_DESKTOP", "GNOME") };
1264 let result = SystemTheme::from_system();
1265 unsafe { std::env::remove_var("XDG_CURRENT_DESKTOP") };
1266
1267 let theme = result.expect("from_system() should return Ok on Linux");
1268 assert_eq!(theme.name, "Adwaita");
1269 }
1270}
1271
1272#[cfg(test)]
1273#[allow(clippy::unwrap_used, clippy::expect_used)]
1274mod load_icon_tests {
1275 use super::*;
1276
1277 #[test]
1278 #[cfg(feature = "material-icons")]
1279 fn load_icon_material_returns_svg() {
1280 let result = load_icon(IconRole::ActionCopy, IconSet::Material);
1281 assert!(result.is_some(), "material 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 material icon"),
1288 }
1289 }
1290
1291 #[test]
1292 #[cfg(feature = "lucide-icons")]
1293 fn load_icon_lucide_returns_svg() {
1294 let result = load_icon(IconRole::ActionCopy, IconSet::Lucide);
1295 assert!(result.is_some(), "lucide ActionCopy should return Some");
1296 match result.unwrap() {
1297 IconData::Svg(bytes) => {
1298 let content = std::str::from_utf8(&bytes).expect("should be valid UTF-8");
1299 assert!(content.contains("<svg"), "should contain SVG data");
1300 }
1301 _ => panic!("expected IconData::Svg for bundled lucide icon"),
1302 }
1303 }
1304
1305 #[test]
1306 #[cfg(feature = "material-icons")]
1307 fn load_icon_unknown_theme_no_cross_set_fallback() {
1308 let result = load_icon(IconRole::ActionCopy, IconSet::Freedesktop);
1312 let _ = result;
1316 }
1317
1318 #[test]
1319 #[cfg(feature = "material-icons")]
1320 fn load_icon_all_roles_material() {
1321 let mut some_count = 0;
1323 for role in IconRole::ALL {
1324 if load_icon(role, IconSet::Material).is_some() {
1325 some_count += 1;
1326 }
1327 }
1328 assert_eq!(
1330 some_count, 42,
1331 "Material should cover all 42 roles via bundled SVGs"
1332 );
1333 }
1334
1335 #[test]
1336 #[cfg(feature = "lucide-icons")]
1337 fn load_icon_all_roles_lucide() {
1338 let mut some_count = 0;
1339 for role in IconRole::ALL {
1340 if load_icon(role, IconSet::Lucide).is_some() {
1341 some_count += 1;
1342 }
1343 }
1344 assert_eq!(
1346 some_count, 42,
1347 "Lucide should cover all 42 roles via bundled SVGs"
1348 );
1349 }
1350
1351 #[test]
1352 fn load_icon_unrecognized_set_no_features() {
1353 let _result = load_icon(IconRole::ActionCopy, IconSet::SfSymbols);
1355 }
1357}
1358
1359#[cfg(test)]
1360#[allow(clippy::unwrap_used, clippy::expect_used)]
1361mod load_system_icon_by_name_tests {
1362 use super::*;
1363
1364 #[test]
1365 #[cfg(feature = "material-icons")]
1366 fn system_icon_by_name_material() {
1367 let result = load_system_icon_by_name("content_copy", IconSet::Material);
1368 assert!(
1369 result.is_some(),
1370 "content_copy should be found in Material set"
1371 );
1372 assert!(matches!(result.unwrap(), IconData::Svg(_)));
1373 }
1374
1375 #[test]
1376 #[cfg(feature = "lucide-icons")]
1377 fn system_icon_by_name_lucide() {
1378 let result = load_system_icon_by_name("copy", IconSet::Lucide);
1379 assert!(result.is_some(), "copy should be found in Lucide set");
1380 assert!(matches!(result.unwrap(), IconData::Svg(_)));
1381 }
1382
1383 #[test]
1384 #[cfg(feature = "material-icons")]
1385 fn system_icon_by_name_unknown_returns_none() {
1386 let result = load_system_icon_by_name("nonexistent_xyz", IconSet::Material);
1387 assert!(result.is_none(), "nonexistent name should return None");
1388 }
1389
1390 #[test]
1391 fn system_icon_by_name_sf_on_linux_returns_none() {
1392 #[cfg(not(target_os = "macos"))]
1394 {
1395 let result = load_system_icon_by_name("doc.on.doc", IconSet::SfSymbols);
1396 assert!(
1397 result.is_none(),
1398 "SF Symbols should return None on non-macOS"
1399 );
1400 }
1401 }
1402}
1403
1404#[cfg(test)]
1405#[allow(clippy::unwrap_used, clippy::expect_used)]
1406mod load_custom_icon_tests {
1407 use super::*;
1408
1409 #[test]
1410 #[cfg(feature = "material-icons")]
1411 fn custom_icon_with_icon_role_material() {
1412 let result = load_custom_icon(&IconRole::ActionCopy, IconSet::Material);
1413 assert!(
1414 result.is_some(),
1415 "IconRole::ActionCopy should load via material"
1416 );
1417 }
1418
1419 #[test]
1420 #[cfg(feature = "lucide-icons")]
1421 fn custom_icon_with_icon_role_lucide() {
1422 let result = load_custom_icon(&IconRole::ActionCopy, IconSet::Lucide);
1423 assert!(
1424 result.is_some(),
1425 "IconRole::ActionCopy should load via lucide"
1426 );
1427 }
1428
1429 #[test]
1430 fn custom_icon_no_cross_set_fallback() {
1431 #[derive(Debug)]
1433 struct NullProvider;
1434 impl IconProvider for NullProvider {
1435 fn icon_name(&self, _set: IconSet) -> Option<&str> {
1436 None
1437 }
1438 fn icon_svg(&self, _set: IconSet) -> Option<&'static [u8]> {
1439 None
1440 }
1441 }
1442
1443 let result = load_custom_icon(&NullProvider, IconSet::Material);
1444 assert!(
1445 result.is_none(),
1446 "NullProvider should return None (no cross-set fallback)"
1447 );
1448 }
1449
1450 #[test]
1451 fn custom_icon_unknown_set_uses_system() {
1452 #[derive(Debug)]
1454 struct NullProvider;
1455 impl IconProvider for NullProvider {
1456 fn icon_name(&self, _set: IconSet) -> Option<&str> {
1457 None
1458 }
1459 fn icon_svg(&self, _set: IconSet) -> Option<&'static [u8]> {
1460 None
1461 }
1462 }
1463
1464 let _result = load_custom_icon(&NullProvider, IconSet::Freedesktop);
1466 }
1467
1468 #[test]
1469 #[cfg(feature = "material-icons")]
1470 fn custom_icon_via_dyn_dispatch() {
1471 let boxed: Box<dyn IconProvider> = Box::new(IconRole::ActionCopy);
1472 let result = load_custom_icon(&*boxed, IconSet::Material);
1473 assert!(
1474 result.is_some(),
1475 "dyn dispatch through Box<dyn IconProvider> should work"
1476 );
1477 }
1478
1479 #[test]
1480 #[cfg(feature = "material-icons")]
1481 fn custom_icon_bundled_svg_fallback() {
1482 #[derive(Debug)]
1484 struct SvgOnlyProvider;
1485 impl IconProvider for SvgOnlyProvider {
1486 fn icon_name(&self, _set: IconSet) -> Option<&str> {
1487 None
1488 }
1489 fn icon_svg(&self, _set: IconSet) -> Option<&'static [u8]> {
1490 Some(b"<svg>test</svg>")
1491 }
1492 }
1493
1494 let result = load_custom_icon(&SvgOnlyProvider, IconSet::Material);
1495 assert!(
1496 result.is_some(),
1497 "provider with icon_svg should return Some"
1498 );
1499 match result.unwrap() {
1500 IconData::Svg(bytes) => {
1501 assert_eq!(bytes, b"<svg>test</svg>");
1502 }
1503 _ => panic!("expected IconData::Svg"),
1504 }
1505 }
1506}
1507
1508#[cfg(test)]
1509#[allow(clippy::unwrap_used, clippy::expect_used)]
1510mod loading_indicator_tests {
1511 use super::*;
1512
1513 #[test]
1516 #[cfg(feature = "lucide-icons")]
1517 fn loading_indicator_lucide_returns_transform_spin() {
1518 let anim = loading_indicator(IconSet::Lucide);
1519 assert!(anim.is_some(), "lucide should return Some");
1520 let anim = anim.unwrap();
1521 assert!(
1522 matches!(
1523 anim,
1524 AnimatedIcon::Transform {
1525 animation: TransformAnimation::Spin { duration_ms: 1000 },
1526 ..
1527 }
1528 ),
1529 "lucide should be Transform::Spin at 1000ms"
1530 );
1531 }
1532
1533 #[test]
1536 #[cfg(all(target_os = "linux", feature = "system-icons"))]
1537 fn loading_indicator_freedesktop_depends_on_theme() {
1538 let anim = loading_indicator(IconSet::Freedesktop);
1539 if let Some(anim) = anim {
1541 match anim {
1542 AnimatedIcon::Frames { frames, .. } => {
1543 assert!(
1544 !frames.is_empty(),
1545 "Frames variant should have at least one frame"
1546 );
1547 }
1548 AnimatedIcon::Transform { .. } => {
1549 }
1551 }
1552 }
1553 }
1554
1555 #[test]
1557 fn loading_indicator_freedesktop_does_not_panic() {
1558 let _result = loading_indicator(IconSet::Freedesktop);
1559 }
1560
1561 #[test]
1564 #[cfg(feature = "lucide-icons")]
1565 fn lucide_spinner_is_transform() {
1566 let anim = spinners::lucide_spinner();
1567 assert!(matches!(
1568 anim,
1569 AnimatedIcon::Transform {
1570 animation: TransformAnimation::Spin { duration_ms: 1000 },
1571 ..
1572 }
1573 ));
1574 }
1575}
1576
1577#[cfg(all(test, feature = "svg-rasterize"))]
1578#[allow(clippy::unwrap_used, clippy::expect_used)]
1579mod spinner_rasterize_tests {
1580 use super::*;
1581
1582 #[test]
1583 #[cfg(feature = "lucide-icons")]
1584 fn lucide_spinner_icon_rasterizes() {
1585 let anim = spinners::lucide_spinner();
1586 if let AnimatedIcon::Transform { icon, .. } = &anim {
1587 if let IconData::Svg(bytes) = icon {
1588 let result = crate::rasterize::rasterize_svg(bytes, 24);
1589 assert!(result.is_ok(), "lucide loader should rasterize");
1590 if let Ok(IconData::Rgba { data, .. }) = &result {
1591 assert!(
1592 data.iter().any(|&b| b != 0),
1593 "lucide loader rasterized to empty image"
1594 );
1595 }
1596 } else {
1597 panic!("lucide spinner icon should be Svg");
1598 }
1599 } else {
1600 panic!("lucide spinner should be Transform");
1601 }
1602 }
1603}
1604
1605#[cfg(test)]
1606#[allow(
1607 clippy::unwrap_used,
1608 clippy::expect_used,
1609 clippy::field_reassign_with_default
1610)]
1611mod system_theme_tests {
1612 use super::*;
1613
1614 #[test]
1617 fn test_system_theme_active_dark() {
1618 let preset = ThemeSpec::preset("catppuccin-mocha").unwrap();
1619 let mut light_v = preset.light.clone().unwrap();
1620 let mut dark_v = preset.dark.clone().unwrap();
1621 light_v.defaults.accent = Some(Rgba::rgb(0, 0, 255));
1623 dark_v.defaults.accent = Some(Rgba::rgb(255, 0, 0));
1624 light_v.resolve();
1625 dark_v.resolve();
1626 let light_resolved = light_v.validate().unwrap();
1627 let dark_resolved = dark_v.validate().unwrap();
1628
1629 let st = SystemTheme {
1630 name: "test".into(),
1631 is_dark: true,
1632 light: light_resolved.clone(),
1633 dark: dark_resolved.clone(),
1634 light_variant: preset.light.unwrap(),
1635 dark_variant: preset.dark.unwrap(),
1636 live_preset: "catppuccin-mocha".into(),
1637 preset: "catppuccin-mocha".into(),
1638 };
1639 assert_eq!(st.active().defaults.accent, dark_resolved.defaults.accent);
1640 }
1641
1642 #[test]
1643 fn test_system_theme_active_light() {
1644 let preset = ThemeSpec::preset("catppuccin-mocha").unwrap();
1645 let mut light_v = preset.light.clone().unwrap();
1646 let mut dark_v = preset.dark.clone().unwrap();
1647 light_v.defaults.accent = Some(Rgba::rgb(0, 0, 255));
1648 dark_v.defaults.accent = Some(Rgba::rgb(255, 0, 0));
1649 light_v.resolve();
1650 dark_v.resolve();
1651 let light_resolved = light_v.validate().unwrap();
1652 let dark_resolved = dark_v.validate().unwrap();
1653
1654 let st = SystemTheme {
1655 name: "test".into(),
1656 is_dark: false,
1657 light: light_resolved.clone(),
1658 dark: dark_resolved.clone(),
1659 light_variant: preset.light.unwrap(),
1660 dark_variant: preset.dark.unwrap(),
1661 live_preset: "catppuccin-mocha".into(),
1662 preset: "catppuccin-mocha".into(),
1663 };
1664 assert_eq!(st.active().defaults.accent, light_resolved.defaults.accent);
1665 }
1666
1667 #[test]
1668 fn test_system_theme_pick() {
1669 let preset = ThemeSpec::preset("catppuccin-mocha").unwrap();
1670 let mut light_v = preset.light.clone().unwrap();
1671 let mut dark_v = preset.dark.clone().unwrap();
1672 light_v.defaults.accent = Some(Rgba::rgb(0, 0, 255));
1673 dark_v.defaults.accent = Some(Rgba::rgb(255, 0, 0));
1674 light_v.resolve();
1675 dark_v.resolve();
1676 let light_resolved = light_v.validate().unwrap();
1677 let dark_resolved = dark_v.validate().unwrap();
1678
1679 let st = SystemTheme {
1680 name: "test".into(),
1681 is_dark: false,
1682 light: light_resolved.clone(),
1683 dark: dark_resolved.clone(),
1684 light_variant: preset.light.unwrap(),
1685 dark_variant: preset.dark.unwrap(),
1686 live_preset: "catppuccin-mocha".into(),
1687 preset: "catppuccin-mocha".into(),
1688 };
1689 assert_eq!(st.pick(true).defaults.accent, dark_resolved.defaults.accent);
1690 assert_eq!(
1691 st.pick(false).defaults.accent,
1692 light_resolved.defaults.accent
1693 );
1694 }
1695
1696 #[test]
1699 #[cfg(target_os = "linux")]
1700 #[allow(unsafe_code)]
1701 fn test_platform_preset_name_kde() {
1702 let _guard = crate::ENV_MUTEX.lock().unwrap();
1703 unsafe { std::env::set_var("XDG_CURRENT_DESKTOP", "KDE") };
1704 let name = platform_preset_name();
1705 unsafe { std::env::remove_var("XDG_CURRENT_DESKTOP") };
1706 assert_eq!(name, "kde-breeze-live");
1707 }
1708
1709 #[test]
1710 #[cfg(target_os = "linux")]
1711 #[allow(unsafe_code)]
1712 fn test_platform_preset_name_gnome() {
1713 let _guard = crate::ENV_MUTEX.lock().unwrap();
1714 unsafe { std::env::set_var("XDG_CURRENT_DESKTOP", "GNOME") };
1715 let name = platform_preset_name();
1716 unsafe { std::env::remove_var("XDG_CURRENT_DESKTOP") };
1717 assert_eq!(name, "adwaita-live");
1718 }
1719
1720 #[test]
1723 fn test_run_pipeline_produces_both_variants() {
1724 let reader = ThemeSpec::preset("catppuccin-mocha").unwrap();
1725 let result = run_pipeline(reader, "catppuccin-mocha", false);
1726 assert!(result.is_ok(), "run_pipeline should succeed");
1727 let st = result.unwrap();
1728 assert!(!st.name.is_empty(), "name should be populated");
1730 }
1732
1733 #[test]
1734 fn test_run_pipeline_reader_values_win() {
1735 let custom_accent = Rgba::rgb(42, 100, 200);
1737 let mut reader = ThemeSpec::default();
1738 reader.name = "CustomTheme".into();
1739 let mut variant = ThemeVariant::default();
1740 variant.defaults.accent = Some(custom_accent);
1741 reader.light = Some(variant);
1742
1743 let result = run_pipeline(reader, "catppuccin-mocha", false);
1744 assert!(result.is_ok(), "run_pipeline should succeed");
1745 let st = result.unwrap();
1746 assert_eq!(
1748 st.light.defaults.accent, custom_accent,
1749 "reader accent should win over preset accent"
1750 );
1751 assert_eq!(st.name, "CustomTheme", "reader name should win");
1752 }
1753
1754 #[test]
1755 fn test_run_pipeline_single_variant() {
1756 let full = ThemeSpec::preset("kde-breeze").unwrap();
1760 let mut reader = ThemeSpec::default();
1761 let mut dark_v = full.dark.clone().unwrap();
1762 dark_v.defaults.accent = Some(Rgba::rgb(200, 50, 50));
1764 reader.dark = Some(dark_v);
1765 reader.light = None;
1766
1767 let result = run_pipeline(reader, "kde-breeze-live", true);
1768 assert!(
1769 result.is_ok(),
1770 "run_pipeline should succeed with single variant"
1771 );
1772 let st = result.unwrap();
1773 assert_eq!(
1775 st.dark.defaults.accent,
1776 Rgba::rgb(200, 50, 50),
1777 "dark variant should have reader accent"
1778 );
1779 assert_eq!(st.live_preset, "kde-breeze-live");
1782 assert_eq!(st.preset, "kde-breeze");
1783 }
1784
1785 #[test]
1786 fn test_run_pipeline_inactive_variant_from_full_preset() {
1787 let full = ThemeSpec::preset("kde-breeze").unwrap();
1790 let mut reader = ThemeSpec::default();
1791 reader.dark = Some(full.dark.clone().unwrap());
1792 reader.light = None;
1793
1794 let st = run_pipeline(reader, "kde-breeze-live", true).unwrap();
1795
1796 let full_light = full.light.unwrap();
1798 assert_eq!(
1799 st.light.defaults.accent,
1800 full_light.defaults.accent.unwrap(),
1801 "inactive light variant should get accent from full preset"
1802 );
1803 assert_eq!(
1804 st.light.defaults.background,
1805 full_light.defaults.background.unwrap(),
1806 "inactive light variant should get background from full preset"
1807 );
1808 }
1809
1810 #[test]
1813 fn test_run_pipeline_with_preset_as_reader() {
1814 let reader = ThemeSpec::preset("adwaita").unwrap();
1817 let result = run_pipeline(reader, "adwaita", false);
1818 assert!(
1819 result.is_ok(),
1820 "double-merge with same preset should succeed"
1821 );
1822 let st = result.unwrap();
1823 assert_eq!(st.name, "Adwaita");
1824 }
1825
1826 #[test]
1829 fn test_reader_is_dark_only_dark() {
1830 let mut theme = ThemeSpec::default();
1831 theme.dark = Some(ThemeVariant::default());
1832 theme.light = None;
1833 assert!(
1834 reader_is_dark(&theme),
1835 "should be true when only dark is set"
1836 );
1837 }
1838
1839 #[test]
1840 fn test_reader_is_dark_only_light() {
1841 let mut theme = ThemeSpec::default();
1842 theme.light = Some(ThemeVariant::default());
1843 theme.dark = None;
1844 assert!(
1845 !reader_is_dark(&theme),
1846 "should be false when only light is set"
1847 );
1848 }
1849
1850 #[test]
1851 fn test_reader_is_dark_both() {
1852 let mut theme = ThemeSpec::default();
1853 theme.light = Some(ThemeVariant::default());
1854 theme.dark = Some(ThemeVariant::default());
1855 assert!(
1856 !reader_is_dark(&theme),
1857 "should be false when both are set (macOS case)"
1858 );
1859 }
1860
1861 #[test]
1862 fn test_reader_is_dark_neither() {
1863 let theme = ThemeSpec::default();
1864 assert!(
1865 !reader_is_dark(&theme),
1866 "should be false when neither is set"
1867 );
1868 }
1869}
1870
1871#[cfg(test)]
1872#[allow(clippy::unwrap_used, clippy::expect_used)]
1873mod reduced_motion_tests {
1874 use super::*;
1875
1876 #[test]
1877 fn prefers_reduced_motion_smoke_test() {
1878 let _result = prefers_reduced_motion();
1882 }
1883
1884 #[cfg(target_os = "linux")]
1885 #[test]
1886 fn detect_reduced_motion_inner_linux() {
1887 let result = detect_reduced_motion_inner();
1891 let _ = result;
1893 }
1894
1895 #[cfg(target_os = "macos")]
1896 #[test]
1897 fn detect_reduced_motion_inner_macos() {
1898 let result = detect_reduced_motion_inner();
1899 let _ = result;
1900 }
1901
1902 #[cfg(target_os = "windows")]
1903 #[test]
1904 fn detect_reduced_motion_inner_windows() {
1905 let result = detect_reduced_motion_inner();
1906 let _ = result;
1907 }
1908}
1909
1910#[cfg(test)]
1911#[allow(clippy::unwrap_used, clippy::expect_used)]
1912mod overlay_tests {
1913 use super::*;
1914
1915 fn default_system_theme() -> SystemTheme {
1917 let reader = ThemeSpec::preset("catppuccin-mocha").unwrap();
1918 run_pipeline(reader, "catppuccin-mocha", false).unwrap()
1919 }
1920
1921 #[test]
1922 fn test_overlay_accent_propagates() {
1923 let st = default_system_theme();
1924 let new_accent = Rgba::rgb(255, 0, 0);
1925
1926 let mut overlay = ThemeSpec::default();
1928 let mut light_v = ThemeVariant::default();
1929 light_v.defaults.accent = Some(new_accent);
1930 let mut dark_v = ThemeVariant::default();
1931 dark_v.defaults.accent = Some(new_accent);
1932 overlay.light = Some(light_v);
1933 overlay.dark = Some(dark_v);
1934
1935 let result = st.with_overlay(&overlay).unwrap();
1936
1937 assert_eq!(result.light.defaults.accent, new_accent);
1939 assert_eq!(result.light.button.primary_bg, new_accent);
1941 assert_eq!(result.light.checkbox.checked_bg, new_accent);
1942 assert_eq!(result.light.slider.fill, new_accent);
1943 assert_eq!(result.light.progress_bar.fill, new_accent);
1944 assert_eq!(result.light.switch.checked_bg, new_accent);
1945 }
1946
1947 #[test]
1948 fn test_overlay_preserves_unrelated_fields() {
1949 let st = default_system_theme();
1950 let original_bg = st.light.defaults.background;
1951
1952 let mut overlay = ThemeSpec::default();
1954 let mut light_v = ThemeVariant::default();
1955 light_v.defaults.accent = Some(Rgba::rgb(255, 0, 0));
1956 overlay.light = Some(light_v);
1957
1958 let result = st.with_overlay(&overlay).unwrap();
1959 assert_eq!(
1960 result.light.defaults.background, original_bg,
1961 "background should be unchanged"
1962 );
1963 }
1964
1965 #[test]
1966 fn test_overlay_empty_noop() {
1967 let st = default_system_theme();
1968 let original_light_accent = st.light.defaults.accent;
1969 let original_dark_accent = st.dark.defaults.accent;
1970 let original_light_bg = st.light.defaults.background;
1971
1972 let overlay = ThemeSpec::default();
1974 let result = st.with_overlay(&overlay).unwrap();
1975
1976 assert_eq!(result.light.defaults.accent, original_light_accent);
1977 assert_eq!(result.dark.defaults.accent, original_dark_accent);
1978 assert_eq!(result.light.defaults.background, original_light_bg);
1979 }
1980
1981 #[test]
1982 fn test_overlay_both_variants() {
1983 let st = default_system_theme();
1984 let red = Rgba::rgb(255, 0, 0);
1985 let green = Rgba::rgb(0, 255, 0);
1986
1987 let mut overlay = ThemeSpec::default();
1988 let mut light_v = ThemeVariant::default();
1989 light_v.defaults.accent = Some(red);
1990 let mut dark_v = ThemeVariant::default();
1991 dark_v.defaults.accent = Some(green);
1992 overlay.light = Some(light_v);
1993 overlay.dark = Some(dark_v);
1994
1995 let result = st.with_overlay(&overlay).unwrap();
1996 assert_eq!(result.light.defaults.accent, red, "light accent = red");
1997 assert_eq!(result.dark.defaults.accent, green, "dark accent = green");
1998 }
1999
2000 #[test]
2001 fn test_overlay_font_family() {
2002 let st = default_system_theme();
2003
2004 let mut overlay = ThemeSpec::default();
2005 let mut light_v = ThemeVariant::default();
2006 light_v.defaults.font.family = Some("Comic Sans".into());
2007 overlay.light = Some(light_v);
2008
2009 let result = st.with_overlay(&overlay).unwrap();
2010 assert_eq!(result.light.defaults.font.family, "Comic Sans");
2011 }
2012
2013 #[test]
2014 fn test_overlay_toml_convenience() {
2015 let st = default_system_theme();
2016 let result = st
2017 .with_overlay_toml(
2018 r##"
2019 name = "overlay"
2020 [light.defaults]
2021 accent = "#ff0000"
2022 "##,
2023 )
2024 .unwrap();
2025 assert_eq!(result.light.defaults.accent, Rgba::rgb(255, 0, 0));
2026 }
2027}