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
17#[macro_export]
53macro_rules! impl_merge {
54 (
55 $struct_name:ident {
56 $(option { $($opt_field:ident),* $(,)? })?
57 $(nested { $($nest_field:ident),* $(,)? })?
58 $(optional_nested { $($on_field:ident),* $(,)? })?
59 }
60 ) => {
61 impl $struct_name {
62 pub fn merge(&mut self, overlay: &Self) {
66 $($(
67 if overlay.$opt_field.is_some() {
68 self.$opt_field = overlay.$opt_field.clone();
69 }
70 )*)?
71 $($(
72 self.$nest_field.merge(&overlay.$nest_field);
73 )*)?
74 $($(
75 match (&mut self.$on_field, &overlay.$on_field) {
76 (Some(base), Some(over)) => base.merge(over),
77 (None, Some(over)) => self.$on_field = Some(over.clone()),
78 _ => {}
79 }
80 )*)?
81 }
82
83 pub fn is_empty(&self) -> bool {
85 true
86 $($(&& self.$opt_field.is_none())*)?
87 $($(&& self.$nest_field.is_empty())*)?
88 $($(&& self.$on_field.is_none())*)?
89 }
90 }
91 };
92}
93
94pub mod color;
96pub mod error;
98#[cfg(all(target_os = "linux", feature = "portal"))]
100pub mod gnome;
101#[cfg(all(target_os = "linux", feature = "kde"))]
103pub mod kde;
104pub mod model;
106pub mod presets;
108mod resolve;
110#[cfg(any(
111 feature = "material-icons",
112 feature = "lucide-icons",
113 feature = "system-icons"
114))]
115mod spinners;
116
117pub use color::Rgba;
118pub use error::{Error, ThemeResolutionError};
119pub use model::{
120 AnimatedIcon, ButtonTheme, CardTheme, CheckboxTheme, ComboBoxTheme, DialogButtonOrder,
121 DialogTheme, ExpanderTheme, FontSpec, IconData, IconProvider, IconRole, IconSet, IconSizes,
122 InputTheme, LinkTheme, ListTheme, MenuTheme, NativeTheme, PopoverTheme, ProgressBarTheme,
123 Repeat, ResolvedDefaults, ResolvedFontSpec, ResolvedIconSizes, ResolvedSpacing,
124 ResolvedTextScale, ResolvedTextScaleEntry, ResolvedTheme, ScrollbarTheme,
125 SegmentedControlTheme, SeparatorTheme, SidebarTheme, SliderTheme, SpinnerTheme, SplitterTheme,
126 StatusBarTheme, SwitchTheme, TabTheme, TextScale, TextScaleEntry, ThemeDefaults, ThemeSpacing,
127 ThemeVariant, ToolbarTheme, TooltipTheme, TransformAnimation, WindowTheme,
128 bundled_icon_by_name, bundled_icon_svg,
129};
130pub use model::icons::{icon_name, system_icon_set, system_icon_theme};
132
133#[cfg(all(target_os = "linux", feature = "system-icons"))]
135pub mod freedesktop;
136pub mod macos;
138#[cfg(feature = "svg-rasterize")]
140pub mod rasterize;
141#[cfg(all(target_os = "macos", feature = "system-icons"))]
143pub mod sficons;
144#[cfg_attr(not(target_os = "windows"), allow(dead_code, unused_variables))]
149pub mod windows;
150#[cfg(feature = "system-icons")]
152#[cfg_attr(not(target_os = "windows"), allow(dead_code, unused_imports))]
153pub mod winicons;
154
155#[cfg(all(target_os = "linux", feature = "system-icons"))]
156pub use freedesktop::{load_freedesktop_icon, load_freedesktop_icon_by_name};
157#[cfg(all(target_os = "linux", feature = "portal"))]
158pub use gnome::from_gnome;
159#[cfg(all(target_os = "linux", feature = "portal", feature = "kde"))]
160pub use gnome::from_kde_with_portal;
161#[cfg(all(target_os = "linux", feature = "kde"))]
162pub use kde::from_kde;
163#[cfg(all(target_os = "macos", feature = "macos"))]
164pub use macos::from_macos;
165#[cfg(feature = "svg-rasterize")]
166pub use rasterize::rasterize_svg;
167#[cfg(all(target_os = "macos", feature = "system-icons"))]
168pub use sficons::load_sf_icon;
169#[cfg(all(target_os = "macos", feature = "system-icons"))]
170pub use sficons::load_sf_icon_by_name;
171#[cfg(all(target_os = "windows", feature = "windows"))]
172pub use windows::from_windows;
173#[cfg(all(target_os = "windows", feature = "system-icons"))]
174pub use winicons::load_windows_icon;
175#[cfg(all(target_os = "windows", feature = "system-icons"))]
176pub use winicons::load_windows_icon_by_name;
177
178pub type Result<T> = std::result::Result<T, Error>;
180
181#[cfg(target_os = "linux")]
183#[derive(Debug, Clone, Copy, PartialEq)]
184pub enum LinuxDesktop {
185 Kde,
187 Gnome,
189 Xfce,
191 Cinnamon,
193 Mate,
195 LxQt,
197 Budgie,
199 Unknown,
201}
202
203#[cfg(target_os = "linux")]
209pub fn detect_linux_de(xdg_current_desktop: &str) -> LinuxDesktop {
210 for component in xdg_current_desktop.split(':') {
211 match component {
212 "KDE" => return LinuxDesktop::Kde,
213 "Budgie" => return LinuxDesktop::Budgie,
214 "GNOME" => return LinuxDesktop::Gnome,
215 "XFCE" => return LinuxDesktop::Xfce,
216 "X-Cinnamon" | "Cinnamon" => return LinuxDesktop::Cinnamon,
217 "MATE" => return LinuxDesktop::Mate,
218 "LXQt" => return LinuxDesktop::LxQt,
219 _ => {}
220 }
221 }
222 LinuxDesktop::Unknown
223}
224
225#[must_use = "this returns whether the system uses dark mode"]
241pub fn system_is_dark() -> bool {
242 static CACHED_IS_DARK: std::sync::OnceLock<bool> = std::sync::OnceLock::new();
243 *CACHED_IS_DARK.get_or_init(detect_is_dark_inner)
244}
245
246#[allow(unreachable_code)]
250fn detect_is_dark_inner() -> bool {
251 #[cfg(target_os = "linux")]
252 {
253 if let Ok(output) = std::process::Command::new("gsettings")
255 .args(["get", "org.gnome.desktop.interface", "color-scheme"])
256 .output()
257 && output.status.success()
258 {
259 let val = String::from_utf8_lossy(&output.stdout);
260 if val.contains("prefer-dark") {
261 return true;
262 }
263 if val.contains("prefer-light") || val.contains("default") {
264 return false;
265 }
266 }
267
268 #[cfg(feature = "kde")]
270 {
271 let path = crate::kde::kdeglobals_path();
272 if let Ok(content) = std::fs::read_to_string(&path) {
273 let mut ini = crate::kde::create_kde_parser();
274 if ini.read(content).is_ok() {
275 return crate::kde::is_dark_theme(&ini);
276 }
277 }
278 }
279
280 false
281 }
282
283 #[cfg(target_os = "macos")]
284 {
285 if let Ok(output) = std::process::Command::new("defaults")
288 .args(["read", "-g", "AppleInterfaceStyle"])
289 .output()
290 && output.status.success()
291 {
292 let val = String::from_utf8_lossy(&output.stdout);
293 return val.trim().eq_ignore_ascii_case("dark");
294 }
295 return false;
296 }
297
298 #[cfg(target_os = "windows")]
299 {
300 #[cfg(feature = "windows")]
301 {
302 let Ok(settings) = ::windows::UI::ViewManagement::UISettings::new() else {
304 return false;
305 };
306 let Ok(fg) =
307 settings.GetColorValue(::windows::UI::ViewManagement::UIColorType::Foreground)
308 else {
309 return false;
310 };
311 let luma = 0.299 * (fg.R as f32) + 0.587 * (fg.G as f32) + 0.114 * (fg.B as f32);
312 return luma > 128.0;
313 }
314 #[cfg(not(feature = "windows"))]
315 return false;
316 }
317
318 #[cfg(not(any(target_os = "linux", target_os = "macos", target_os = "windows")))]
319 {
320 false
321 }
322}
323
324#[must_use = "this returns whether reduced motion is preferred"]
350pub fn prefers_reduced_motion() -> bool {
351 static CACHED: std::sync::OnceLock<bool> = std::sync::OnceLock::new();
352 *CACHED.get_or_init(detect_reduced_motion_inner)
353}
354
355#[allow(unreachable_code)]
359fn detect_reduced_motion_inner() -> bool {
360 #[cfg(target_os = "linux")]
361 {
362 if let Ok(output) = std::process::Command::new("gsettings")
365 .args(["get", "org.gnome.desktop.interface", "enable-animations"])
366 .output()
367 && output.status.success()
368 {
369 let val = String::from_utf8_lossy(&output.stdout);
370 return val.trim() == "false";
371 }
372 false
373 }
374
375 #[cfg(target_os = "macos")]
376 {
377 #[cfg(feature = "macos")]
378 {
379 let workspace = objc2_app_kit::NSWorkspace::sharedWorkspace();
380 return workspace.accessibilityDisplayShouldReduceMotion();
382 }
383 #[cfg(not(feature = "macos"))]
384 return false;
385 }
386
387 #[cfg(target_os = "windows")]
388 {
389 #[cfg(feature = "windows")]
390 {
391 let Ok(settings) = ::windows::UI::ViewManagement::UISettings::new() else {
392 return false;
393 };
394 return match settings.AnimationsEnabled() {
396 Ok(enabled) => !enabled,
397 Err(_) => false,
398 };
399 }
400 #[cfg(not(feature = "windows"))]
401 return false;
402 }
403
404 #[cfg(not(any(target_os = "linux", target_os = "macos", target_os = "windows")))]
405 {
406 false
407 }
408}
409
410pub struct SystemTheme {
417 pub name: String,
419 pub is_dark: bool,
421 pub light: ResolvedTheme,
423 pub dark: ResolvedTheme,
425 pub(crate) light_variant: ThemeVariant,
427 pub(crate) dark_variant: ThemeVariant,
429 pub live_preset: String,
431 pub full_preset: String,
433}
434
435impl SystemTheme {
436 pub fn active(&self) -> &ResolvedTheme {
440 if self.is_dark {
441 &self.dark
442 } else {
443 &self.light
444 }
445 }
446
447 pub fn pick(&self, is_dark: bool) -> &ResolvedTheme {
451 if is_dark { &self.dark } else { &self.light }
452 }
453
454 pub fn with_overlay(self, overlay: &NativeTheme) -> crate::Result<Self> {
478 let mut light = self.light_variant.clone();
480 let mut dark = self.dark_variant.clone();
481
482 if let Some(over) = &overlay.light {
484 light.merge(over);
485 }
486 if let Some(over) = &overlay.dark {
487 dark.merge(over);
488 }
489
490 let resolved_light = resolve_variant(light.clone())?;
492 let resolved_dark = resolve_variant(dark.clone())?;
493
494 Ok(SystemTheme {
495 name: self.name,
496 is_dark: self.is_dark,
497 light: resolved_light,
498 dark: resolved_dark,
499 light_variant: light,
500 dark_variant: dark,
501 live_preset: self.live_preset,
502 full_preset: self.full_preset,
503 })
504 }
505
506 pub fn with_overlay_toml(self, toml: &str) -> crate::Result<Self> {
510 let overlay = NativeTheme::from_toml(toml)?;
511 self.with_overlay(&overlay)
512 }
513}
514
515fn resolve_variant(mut variant: ThemeVariant) -> crate::Result<ResolvedTheme> {
520 variant.resolve();
521 variant.validate()
522}
523
524fn run_pipeline(
531 reader_output: NativeTheme,
532 preset_name: &str,
533 is_dark: bool,
534) -> crate::Result<SystemTheme> {
535 let live_preset = NativeTheme::preset(preset_name)?;
536
537 let full_preset_name = preset_name.strip_suffix("-live").unwrap_or(preset_name);
539 let full_preset = NativeTheme::preset(full_preset_name)?;
540
541 let mut merged = full_preset.clone();
544 merged.merge(&live_preset);
545 merged.merge(&reader_output);
546
547 let name = if reader_output.name.is_empty() {
549 merged.name.clone()
550 } else {
551 reader_output.name.clone()
552 };
553
554 let light_variant = if reader_output.light.is_some() {
557 merged.light.unwrap_or_default()
558 } else {
559 full_preset.light.unwrap_or_default()
560 };
561
562 let dark_variant = if reader_output.dark.is_some() {
563 merged.dark.unwrap_or_default()
564 } else {
565 full_preset.dark.unwrap_or_default()
566 };
567
568 let light_variant_pre = light_variant.clone();
570 let dark_variant_pre = dark_variant.clone();
571
572 let light = resolve_variant(light_variant)?;
573 let dark = resolve_variant(dark_variant)?;
574
575 Ok(SystemTheme {
576 name,
577 is_dark,
578 light,
579 dark,
580 light_variant: light_variant_pre,
581 dark_variant: dark_variant_pre,
582 live_preset: preset_name.to_string(),
583 full_preset: full_preset_name.to_string(),
584 })
585}
586
587#[allow(unreachable_code)]
603pub fn platform_preset_name() -> &'static str {
604 #[cfg(target_os = "macos")]
605 {
606 return "macos-sonoma-live";
607 }
608 #[cfg(target_os = "windows")]
609 {
610 return "windows-11-live";
611 }
612 #[cfg(target_os = "linux")]
613 {
614 let desktop = std::env::var("XDG_CURRENT_DESKTOP").unwrap_or_default();
615 match detect_linux_de(&desktop) {
616 LinuxDesktop::Kde => "kde-breeze-live",
617 _ => "adwaita-live",
618 }
619 }
620 #[cfg(not(any(target_os = "linux", target_os = "windows", target_os = "macos")))]
621 {
622 "adwaita-live"
623 }
624}
625
626#[allow(dead_code)]
634fn reader_is_dark(reader: &NativeTheme) -> bool {
635 reader.dark.is_some() && reader.light.is_none()
636}
637
638#[cfg(target_os = "linux")]
644fn from_linux() -> crate::Result<SystemTheme> {
645 let is_dark = system_is_dark();
646 let desktop = std::env::var("XDG_CURRENT_DESKTOP").unwrap_or_default();
647 match detect_linux_de(&desktop) {
648 #[cfg(feature = "kde")]
649 LinuxDesktop::Kde => {
650 let reader = crate::kde::from_kde()?;
651 run_pipeline(reader, "kde-breeze-live", is_dark)
652 }
653 #[cfg(not(feature = "kde"))]
654 LinuxDesktop::Kde => run_pipeline(NativeTheme::preset("adwaita")?, "adwaita-live", is_dark),
655 LinuxDesktop::Gnome | LinuxDesktop::Budgie => {
656 run_pipeline(NativeTheme::preset("adwaita")?, "adwaita-live", is_dark)
658 }
659 LinuxDesktop::Xfce | LinuxDesktop::Cinnamon | LinuxDesktop::Mate | LinuxDesktop::LxQt => {
660 run_pipeline(NativeTheme::preset("adwaita")?, "adwaita-live", is_dark)
661 }
662 LinuxDesktop::Unknown => {
663 #[cfg(feature = "kde")]
664 {
665 let path = crate::kde::kdeglobals_path();
666 if path.exists() {
667 let reader = crate::kde::from_kde()?;
668 return run_pipeline(reader, "kde-breeze-live", is_dark);
669 }
670 }
671 run_pipeline(NativeTheme::preset("adwaita")?, "adwaita-live", is_dark)
672 }
673 }
674}
675
676#[must_use = "this returns the detected theme; it does not apply it"]
703pub fn from_system() -> crate::Result<SystemTheme> {
704 #[cfg(target_os = "macos")]
705 {
706 #[cfg(feature = "macos")]
707 {
708 let reader = crate::macos::from_macos()?;
709 let is_dark = reader_is_dark(&reader);
710 return run_pipeline(reader, "macos-sonoma-live", is_dark);
711 }
712
713 #[cfg(not(feature = "macos"))]
714 return Err(crate::Error::Unsupported);
715 }
716
717 #[cfg(target_os = "windows")]
718 {
719 #[cfg(feature = "windows")]
720 {
721 let reader = crate::windows::from_windows()?;
722 let is_dark = reader_is_dark(&reader);
723 return run_pipeline(reader, "windows-11-live", is_dark);
724 }
725
726 #[cfg(not(feature = "windows"))]
727 return Err(crate::Error::Unsupported);
728 }
729
730 #[cfg(target_os = "linux")]
731 {
732 from_linux()
733 }
734
735 #[cfg(not(any(target_os = "linux", target_os = "windows", target_os = "macos")))]
736 {
737 Err(crate::Error::Unsupported)
738 }
739}
740
741#[cfg(target_os = "linux")]
754#[must_use = "this returns the detected theme; it does not apply it"]
755pub async fn from_system_async() -> crate::Result<SystemTheme> {
756 let is_dark = system_is_dark();
757 let desktop = std::env::var("XDG_CURRENT_DESKTOP").unwrap_or_default();
758 match detect_linux_de(&desktop) {
759 #[cfg(feature = "kde")]
760 LinuxDesktop::Kde => {
761 #[cfg(feature = "portal")]
762 {
763 let reader = crate::gnome::from_kde_with_portal().await?;
764 return run_pipeline(reader, "kde-breeze-live", is_dark);
765 }
766 #[cfg(not(feature = "portal"))]
767 {
768 let reader = crate::kde::from_kde()?;
769 return run_pipeline(reader, "kde-breeze-live", is_dark);
770 }
771 }
772 #[cfg(not(feature = "kde"))]
773 LinuxDesktop::Kde => run_pipeline(NativeTheme::preset("adwaita")?, "adwaita-live", is_dark),
774 #[cfg(feature = "portal")]
775 LinuxDesktop::Gnome | LinuxDesktop::Budgie => {
776 let reader = crate::gnome::from_gnome().await?;
777 run_pipeline(reader, "adwaita-live", is_dark)
778 }
779 #[cfg(not(feature = "portal"))]
780 LinuxDesktop::Gnome | LinuxDesktop::Budgie => {
781 run_pipeline(NativeTheme::preset("adwaita")?, "adwaita-live", is_dark)
782 }
783 LinuxDesktop::Xfce | LinuxDesktop::Cinnamon | LinuxDesktop::Mate | LinuxDesktop::LxQt => {
784 run_pipeline(NativeTheme::preset("adwaita")?, "adwaita-live", is_dark)
785 }
786 LinuxDesktop::Unknown => {
787 #[cfg(feature = "portal")]
789 {
790 if let Some(detected) = crate::gnome::detect_portal_backend().await {
791 return match detected {
792 #[cfg(feature = "kde")]
793 LinuxDesktop::Kde => {
794 let reader = crate::gnome::from_kde_with_portal().await?;
795 run_pipeline(reader, "kde-breeze-live", is_dark)
796 }
797 #[cfg(not(feature = "kde"))]
798 LinuxDesktop::Kde => {
799 run_pipeline(NativeTheme::preset("adwaita")?, "adwaita-live", is_dark)
800 }
801 LinuxDesktop::Gnome => {
802 let reader = crate::gnome::from_gnome().await?;
803 run_pipeline(reader, "adwaita-live", is_dark)
804 }
805 _ => {
806 unreachable!("detect_portal_backend only returns Kde or Gnome")
807 }
808 };
809 }
810 }
811 #[cfg(feature = "kde")]
813 {
814 let path = crate::kde::kdeglobals_path();
815 if path.exists() {
816 let reader = crate::kde::from_kde()?;
817 return run_pipeline(reader, "kde-breeze-live", is_dark);
818 }
819 }
820 run_pipeline(NativeTheme::preset("adwaita")?, "adwaita-live", is_dark)
821 }
822 }
823}
824
825#[cfg(not(target_os = "linux"))]
829#[must_use = "this returns the detected theme; it does not apply it"]
830pub async fn from_system_async() -> crate::Result<SystemTheme> {
831 from_system()
832}
833
834#[must_use = "this returns the loaded icon data; it does not display it"]
861#[allow(unreachable_patterns, clippy::needless_return, unused_variables)]
862pub fn load_icon(role: IconRole, icon_set: &str) -> Option<IconData> {
863 let set = IconSet::from_name(icon_set).unwrap_or_else(system_icon_set);
864
865 match set {
866 #[cfg(all(target_os = "linux", feature = "system-icons"))]
867 IconSet::Freedesktop => freedesktop::load_freedesktop_icon(role),
868
869 #[cfg(all(target_os = "macos", feature = "system-icons"))]
870 IconSet::SfSymbols => sficons::load_sf_icon(role),
871
872 #[cfg(all(target_os = "windows", feature = "system-icons"))]
873 IconSet::SegoeIcons => winicons::load_windows_icon(role),
874
875 #[cfg(feature = "material-icons")]
876 IconSet::Material => {
877 bundled_icon_svg(IconSet::Material, role).map(|b| IconData::Svg(b.to_vec()))
878 }
879
880 #[cfg(feature = "lucide-icons")]
881 IconSet::Lucide => {
882 bundled_icon_svg(IconSet::Lucide, role).map(|b| IconData::Svg(b.to_vec()))
883 }
884
885 _ => None,
887 }
888}
889
890#[must_use = "this returns the loaded icon data; it does not display it"]
913#[allow(unreachable_patterns, unused_variables)]
914pub fn load_system_icon_by_name(name: &str, set: IconSet) -> Option<IconData> {
915 match set {
916 #[cfg(all(target_os = "linux", feature = "system-icons"))]
917 IconSet::Freedesktop => {
918 let theme = system_icon_theme();
919 freedesktop::load_freedesktop_icon_by_name(name, &theme)
920 }
921
922 #[cfg(all(target_os = "macos", feature = "system-icons"))]
923 IconSet::SfSymbols => sficons::load_sf_icon_by_name(name),
924
925 #[cfg(all(target_os = "windows", feature = "system-icons"))]
926 IconSet::SegoeIcons => winicons::load_windows_icon_by_name(name),
927
928 #[cfg(feature = "material-icons")]
929 IconSet::Material => {
930 bundled_icon_by_name(IconSet::Material, name).map(|b| IconData::Svg(b.to_vec()))
931 }
932
933 #[cfg(feature = "lucide-icons")]
934 IconSet::Lucide => {
935 bundled_icon_by_name(IconSet::Lucide, name).map(|b| IconData::Svg(b.to_vec()))
936 }
937
938 _ => None,
939 }
940}
941
942#[must_use = "this returns animation data; it does not display anything"]
965pub fn loading_indicator(icon_set: &str) -> Option<AnimatedIcon> {
966 let set = IconSet::from_name(icon_set).unwrap_or_else(system_icon_set);
967 match set {
968 #[cfg(all(target_os = "linux", feature = "system-icons"))]
969 IconSet::Freedesktop => freedesktop::load_freedesktop_spinner(),
970
971 #[cfg(feature = "material-icons")]
972 IconSet::Material => Some(spinners::material_spinner()),
973
974 #[cfg(feature = "lucide-icons")]
975 IconSet::Lucide => Some(spinners::lucide_spinner()),
976
977 _ => None,
978 }
979}
980
981#[must_use = "this returns the loaded icon data; it does not display it"]
1007pub fn load_custom_icon(
1008 provider: &(impl IconProvider + ?Sized),
1009 icon_set: &str,
1010) -> Option<IconData> {
1011 let set = IconSet::from_name(icon_set).unwrap_or_else(system_icon_set);
1012
1013 if let Some(name) = provider.icon_name(set)
1015 && let Some(data) = load_system_icon_by_name(name, set)
1016 {
1017 return Some(data);
1018 }
1019
1020 if let Some(svg) = provider.icon_svg(set) {
1022 return Some(IconData::Svg(svg.to_vec()));
1023 }
1024
1025 None
1027}
1028
1029#[cfg(test)]
1033pub(crate) static ENV_MUTEX: std::sync::Mutex<()> = std::sync::Mutex::new(());
1034
1035#[cfg(all(test, target_os = "linux"))]
1036#[allow(clippy::unwrap_used, clippy::expect_used)]
1037mod dispatch_tests {
1038 use super::*;
1039
1040 #[test]
1043 fn detect_kde_simple() {
1044 assert_eq!(detect_linux_de("KDE"), LinuxDesktop::Kde);
1045 }
1046
1047 #[test]
1048 fn detect_kde_colon_separated_after() {
1049 assert_eq!(detect_linux_de("ubuntu:KDE"), LinuxDesktop::Kde);
1050 }
1051
1052 #[test]
1053 fn detect_kde_colon_separated_before() {
1054 assert_eq!(detect_linux_de("KDE:plasma"), LinuxDesktop::Kde);
1055 }
1056
1057 #[test]
1058 fn detect_gnome_simple() {
1059 assert_eq!(detect_linux_de("GNOME"), LinuxDesktop::Gnome);
1060 }
1061
1062 #[test]
1063 fn detect_gnome_ubuntu() {
1064 assert_eq!(detect_linux_de("ubuntu:GNOME"), LinuxDesktop::Gnome);
1065 }
1066
1067 #[test]
1068 fn detect_xfce() {
1069 assert_eq!(detect_linux_de("XFCE"), LinuxDesktop::Xfce);
1070 }
1071
1072 #[test]
1073 fn detect_cinnamon() {
1074 assert_eq!(detect_linux_de("X-Cinnamon"), LinuxDesktop::Cinnamon);
1075 }
1076
1077 #[test]
1078 fn detect_cinnamon_short() {
1079 assert_eq!(detect_linux_de("Cinnamon"), LinuxDesktop::Cinnamon);
1080 }
1081
1082 #[test]
1083 fn detect_mate() {
1084 assert_eq!(detect_linux_de("MATE"), LinuxDesktop::Mate);
1085 }
1086
1087 #[test]
1088 fn detect_lxqt() {
1089 assert_eq!(detect_linux_de("LXQt"), LinuxDesktop::LxQt);
1090 }
1091
1092 #[test]
1093 fn detect_budgie() {
1094 assert_eq!(detect_linux_de("Budgie:GNOME"), LinuxDesktop::Budgie);
1095 }
1096
1097 #[test]
1098 fn detect_empty_string() {
1099 assert_eq!(detect_linux_de(""), LinuxDesktop::Unknown);
1100 }
1101
1102 #[test]
1105 #[allow(unsafe_code)]
1106 fn from_linux_non_kde_returns_adwaita() {
1107 let _guard = crate::ENV_MUTEX.lock().unwrap();
1108 unsafe { std::env::set_var("XDG_CURRENT_DESKTOP", "GNOME") };
1112 let result = from_linux();
1113 unsafe { std::env::remove_var("XDG_CURRENT_DESKTOP") };
1114
1115 let theme = result.expect("from_linux() should return Ok for non-KDE desktop");
1116 assert_eq!(theme.name, "Adwaita");
1117 }
1118
1119 #[test]
1122 #[cfg(feature = "kde")]
1123 #[allow(unsafe_code)]
1124 fn from_linux_unknown_de_with_kdeglobals_fallback() {
1125 let _guard = crate::ENV_MUTEX.lock().unwrap();
1126 use std::io::Write;
1127
1128 let tmp_dir = std::env::temp_dir().join("native_theme_test_kde_fallback");
1130 std::fs::create_dir_all(&tmp_dir).unwrap();
1131 let kdeglobals = tmp_dir.join("kdeglobals");
1132 let mut f = std::fs::File::create(&kdeglobals).unwrap();
1133 writeln!(
1134 f,
1135 "[General]\nColorScheme=TestTheme\n\n[Colors:Window]\nBackgroundNormal=239,240,241\n"
1136 )
1137 .unwrap();
1138
1139 let orig_xdg = std::env::var("XDG_CONFIG_HOME").ok();
1141 let orig_desktop = std::env::var("XDG_CURRENT_DESKTOP").ok();
1142
1143 unsafe { std::env::set_var("XDG_CONFIG_HOME", &tmp_dir) };
1144 unsafe { std::env::set_var("XDG_CURRENT_DESKTOP", "SomeUnknownDE") };
1145
1146 let result = from_linux();
1147
1148 match orig_xdg {
1150 Some(val) => unsafe { std::env::set_var("XDG_CONFIG_HOME", val) },
1151 None => unsafe { std::env::remove_var("XDG_CONFIG_HOME") },
1152 }
1153 match orig_desktop {
1154 Some(val) => unsafe { std::env::set_var("XDG_CURRENT_DESKTOP", val) },
1155 None => unsafe { std::env::remove_var("XDG_CURRENT_DESKTOP") },
1156 }
1157
1158 let _ = std::fs::remove_dir_all(&tmp_dir);
1160
1161 let theme = result.expect("from_linux() should return Ok with kdeglobals fallback");
1162 assert_eq!(
1163 theme.name, "TestTheme",
1164 "should use KDE theme name from kdeglobals"
1165 );
1166 }
1167
1168 #[test]
1169 #[allow(unsafe_code)]
1170 fn from_linux_unknown_de_without_kdeglobals_returns_adwaita() {
1171 let _guard = crate::ENV_MUTEX.lock().unwrap();
1172 let orig_xdg = std::env::var("XDG_CONFIG_HOME").ok();
1174 let orig_desktop = std::env::var("XDG_CURRENT_DESKTOP").ok();
1175
1176 unsafe {
1177 std::env::set_var(
1178 "XDG_CONFIG_HOME",
1179 "/tmp/nonexistent_native_theme_test_no_kde",
1180 )
1181 };
1182 unsafe { std::env::set_var("XDG_CURRENT_DESKTOP", "SomeUnknownDE") };
1183
1184 let result = from_linux();
1185
1186 match orig_xdg {
1188 Some(val) => unsafe { std::env::set_var("XDG_CONFIG_HOME", val) },
1189 None => unsafe { std::env::remove_var("XDG_CONFIG_HOME") },
1190 }
1191 match orig_desktop {
1192 Some(val) => unsafe { std::env::set_var("XDG_CURRENT_DESKTOP", val) },
1193 None => unsafe { std::env::remove_var("XDG_CURRENT_DESKTOP") },
1194 }
1195
1196 let theme = result.expect("from_linux() should return Ok (adwaita fallback)");
1197 assert_eq!(
1198 theme.name, "Adwaita",
1199 "should fall back to Adwaita without kdeglobals"
1200 );
1201 }
1202
1203 #[test]
1206 fn detect_hyprland_returns_unknown() {
1207 assert_eq!(detect_linux_de("Hyprland"), LinuxDesktop::Unknown);
1208 }
1209
1210 #[test]
1211 fn detect_sway_returns_unknown() {
1212 assert_eq!(detect_linux_de("sway"), LinuxDesktop::Unknown);
1213 }
1214
1215 #[test]
1216 fn detect_cosmic_returns_unknown() {
1217 assert_eq!(detect_linux_de("COSMIC"), LinuxDesktop::Unknown);
1218 }
1219
1220 #[test]
1223 #[allow(unsafe_code)]
1224 fn from_system_returns_result() {
1225 let _guard = crate::ENV_MUTEX.lock().unwrap();
1226 unsafe { std::env::set_var("XDG_CURRENT_DESKTOP", "GNOME") };
1230 let result = from_system();
1231 unsafe { std::env::remove_var("XDG_CURRENT_DESKTOP") };
1232
1233 let theme = result.expect("from_system() should return Ok on Linux");
1234 assert_eq!(theme.name, "Adwaita");
1235 }
1236}
1237
1238#[cfg(test)]
1239#[allow(clippy::unwrap_used, clippy::expect_used)]
1240mod load_icon_tests {
1241 use super::*;
1242
1243 #[test]
1244 #[cfg(feature = "material-icons")]
1245 fn load_icon_material_returns_svg() {
1246 let result = load_icon(IconRole::ActionCopy, "material");
1247 assert!(result.is_some(), "material ActionCopy should return Some");
1248 match result.unwrap() {
1249 IconData::Svg(bytes) => {
1250 let content = std::str::from_utf8(&bytes).expect("should be valid UTF-8");
1251 assert!(content.contains("<svg"), "should contain SVG data");
1252 }
1253 _ => panic!("expected IconData::Svg for bundled material icon"),
1254 }
1255 }
1256
1257 #[test]
1258 #[cfg(feature = "lucide-icons")]
1259 fn load_icon_lucide_returns_svg() {
1260 let result = load_icon(IconRole::ActionCopy, "lucide");
1261 assert!(result.is_some(), "lucide ActionCopy should return Some");
1262 match result.unwrap() {
1263 IconData::Svg(bytes) => {
1264 let content = std::str::from_utf8(&bytes).expect("should be valid UTF-8");
1265 assert!(content.contains("<svg"), "should contain SVG data");
1266 }
1267 _ => panic!("expected IconData::Svg for bundled lucide icon"),
1268 }
1269 }
1270
1271 #[test]
1272 #[cfg(feature = "material-icons")]
1273 fn load_icon_unknown_theme_no_cross_set_fallback() {
1274 let result = load_icon(IconRole::ActionCopy, "unknown-theme");
1278 let _ = result;
1282 }
1283
1284 #[test]
1285 #[cfg(feature = "material-icons")]
1286 fn load_icon_all_roles_material() {
1287 let mut some_count = 0;
1289 for role in IconRole::ALL {
1290 if load_icon(role, "material").is_some() {
1291 some_count += 1;
1292 }
1293 }
1294 assert_eq!(
1296 some_count, 42,
1297 "Material should cover all 42 roles via bundled SVGs"
1298 );
1299 }
1300
1301 #[test]
1302 #[cfg(feature = "lucide-icons")]
1303 fn load_icon_all_roles_lucide() {
1304 let mut some_count = 0;
1305 for role in IconRole::ALL {
1306 if load_icon(role, "lucide").is_some() {
1307 some_count += 1;
1308 }
1309 }
1310 assert_eq!(
1312 some_count, 42,
1313 "Lucide should cover all 42 roles via bundled SVGs"
1314 );
1315 }
1316
1317 #[test]
1318 fn load_icon_unrecognized_set_no_features() {
1319 let _result = load_icon(IconRole::ActionCopy, "sf-symbols");
1321 }
1323}
1324
1325#[cfg(test)]
1326#[allow(clippy::unwrap_used, clippy::expect_used)]
1327mod load_system_icon_by_name_tests {
1328 use super::*;
1329
1330 #[test]
1331 #[cfg(feature = "material-icons")]
1332 fn system_icon_by_name_material() {
1333 let result = load_system_icon_by_name("content_copy", IconSet::Material);
1334 assert!(
1335 result.is_some(),
1336 "content_copy should be found in Material set"
1337 );
1338 assert!(matches!(result.unwrap(), IconData::Svg(_)));
1339 }
1340
1341 #[test]
1342 #[cfg(feature = "lucide-icons")]
1343 fn system_icon_by_name_lucide() {
1344 let result = load_system_icon_by_name("copy", IconSet::Lucide);
1345 assert!(result.is_some(), "copy should be found in Lucide set");
1346 assert!(matches!(result.unwrap(), IconData::Svg(_)));
1347 }
1348
1349 #[test]
1350 #[cfg(feature = "material-icons")]
1351 fn system_icon_by_name_unknown_returns_none() {
1352 let result = load_system_icon_by_name("nonexistent_xyz", IconSet::Material);
1353 assert!(result.is_none(), "nonexistent name should return None");
1354 }
1355
1356 #[test]
1357 fn system_icon_by_name_sf_on_linux_returns_none() {
1358 #[cfg(not(target_os = "macos"))]
1360 {
1361 let result = load_system_icon_by_name("doc.on.doc", IconSet::SfSymbols);
1362 assert!(
1363 result.is_none(),
1364 "SF Symbols should return None on non-macOS"
1365 );
1366 }
1367 }
1368}
1369
1370#[cfg(test)]
1371#[allow(clippy::unwrap_used, clippy::expect_used)]
1372mod load_custom_icon_tests {
1373 use super::*;
1374
1375 #[test]
1376 #[cfg(feature = "material-icons")]
1377 fn custom_icon_with_icon_role_material() {
1378 let result = load_custom_icon(&IconRole::ActionCopy, "material");
1379 assert!(
1380 result.is_some(),
1381 "IconRole::ActionCopy should load via material"
1382 );
1383 }
1384
1385 #[test]
1386 #[cfg(feature = "lucide-icons")]
1387 fn custom_icon_with_icon_role_lucide() {
1388 let result = load_custom_icon(&IconRole::ActionCopy, "lucide");
1389 assert!(
1390 result.is_some(),
1391 "IconRole::ActionCopy should load via lucide"
1392 );
1393 }
1394
1395 #[test]
1396 fn custom_icon_no_cross_set_fallback() {
1397 #[derive(Debug)]
1399 struct NullProvider;
1400 impl IconProvider for NullProvider {
1401 fn icon_name(&self, _set: IconSet) -> Option<&str> {
1402 None
1403 }
1404 fn icon_svg(&self, _set: IconSet) -> Option<&'static [u8]> {
1405 None
1406 }
1407 }
1408
1409 let result = load_custom_icon(&NullProvider, "material");
1410 assert!(
1411 result.is_none(),
1412 "NullProvider should return None (no cross-set fallback)"
1413 );
1414 }
1415
1416 #[test]
1417 fn custom_icon_unknown_set_uses_system() {
1418 #[derive(Debug)]
1420 struct NullProvider;
1421 impl IconProvider for NullProvider {
1422 fn icon_name(&self, _set: IconSet) -> Option<&str> {
1423 None
1424 }
1425 fn icon_svg(&self, _set: IconSet) -> Option<&'static [u8]> {
1426 None
1427 }
1428 }
1429
1430 let _result = load_custom_icon(&NullProvider, "unknown-set");
1432 }
1433
1434 #[test]
1435 #[cfg(feature = "material-icons")]
1436 fn custom_icon_via_dyn_dispatch() {
1437 let boxed: Box<dyn IconProvider> = Box::new(IconRole::ActionCopy);
1438 let result = load_custom_icon(&*boxed, "material");
1439 assert!(
1440 result.is_some(),
1441 "dyn dispatch through Box<dyn IconProvider> should work"
1442 );
1443 }
1444
1445 #[test]
1446 #[cfg(feature = "material-icons")]
1447 fn custom_icon_bundled_svg_fallback() {
1448 #[derive(Debug)]
1450 struct SvgOnlyProvider;
1451 impl IconProvider for SvgOnlyProvider {
1452 fn icon_name(&self, _set: IconSet) -> Option<&str> {
1453 None
1454 }
1455 fn icon_svg(&self, _set: IconSet) -> Option<&'static [u8]> {
1456 Some(b"<svg>test</svg>")
1457 }
1458 }
1459
1460 let result = load_custom_icon(&SvgOnlyProvider, "material");
1461 assert!(
1462 result.is_some(),
1463 "provider with icon_svg should return Some"
1464 );
1465 match result.unwrap() {
1466 IconData::Svg(bytes) => {
1467 assert_eq!(bytes, b"<svg>test</svg>");
1468 }
1469 _ => panic!("expected IconData::Svg"),
1470 }
1471 }
1472}
1473
1474#[cfg(test)]
1475#[allow(clippy::unwrap_used, clippy::expect_used)]
1476mod loading_indicator_tests {
1477 use super::*;
1478
1479 #[test]
1482 #[cfg(feature = "lucide-icons")]
1483 fn loading_indicator_lucide_returns_transform_spin() {
1484 let anim = loading_indicator("lucide");
1485 assert!(anim.is_some(), "lucide should return Some");
1486 let anim = anim.unwrap();
1487 assert!(
1488 matches!(
1489 anim,
1490 AnimatedIcon::Transform {
1491 animation: TransformAnimation::Spin { duration_ms: 1000 },
1492 ..
1493 }
1494 ),
1495 "lucide should be Transform::Spin at 1000ms"
1496 );
1497 }
1498
1499 #[test]
1502 #[cfg(all(target_os = "linux", feature = "system-icons"))]
1503 fn loading_indicator_freedesktop_depends_on_theme() {
1504 let anim = loading_indicator("freedesktop");
1505 if let Some(anim) = anim {
1507 match anim {
1508 AnimatedIcon::Frames { frames, .. } => {
1509 assert!(
1510 !frames.is_empty(),
1511 "Frames variant should have at least one frame"
1512 );
1513 }
1514 AnimatedIcon::Transform { .. } => {
1515 }
1517 }
1518 }
1519 }
1520
1521 #[test]
1524 fn loading_indicator_unknown_falls_back_to_system() {
1525 let _result = loading_indicator("unknown");
1526 }
1527
1528 #[test]
1529 fn loading_indicator_empty_string_falls_back_to_system() {
1530 let _result = loading_indicator("");
1531 }
1532
1533 #[test]
1536 #[cfg(feature = "lucide-icons")]
1537 fn lucide_spinner_is_transform() {
1538 let anim = spinners::lucide_spinner();
1539 assert!(matches!(
1540 anim,
1541 AnimatedIcon::Transform {
1542 animation: TransformAnimation::Spin { duration_ms: 1000 },
1543 ..
1544 }
1545 ));
1546 }
1547}
1548
1549#[cfg(all(test, feature = "svg-rasterize"))]
1550#[allow(clippy::unwrap_used, clippy::expect_used)]
1551mod spinner_rasterize_tests {
1552 use super::*;
1553
1554 #[test]
1555 #[cfg(feature = "lucide-icons")]
1556 fn lucide_spinner_icon_rasterizes() {
1557 let anim = spinners::lucide_spinner();
1558 if let AnimatedIcon::Transform { icon, .. } = &anim {
1559 if let IconData::Svg(bytes) = icon {
1560 let result = crate::rasterize::rasterize_svg(bytes, 24);
1561 assert!(result.is_some(), "lucide loader should rasterize");
1562 if let Some(IconData::Rgba { data, .. }) = &result {
1563 assert!(
1564 data.iter().any(|&b| b != 0),
1565 "lucide loader rasterized to empty image"
1566 );
1567 }
1568 } else {
1569 panic!("lucide spinner icon should be Svg");
1570 }
1571 } else {
1572 panic!("lucide spinner should be Transform");
1573 }
1574 }
1575}
1576
1577#[cfg(test)]
1578#[allow(
1579 clippy::unwrap_used,
1580 clippy::expect_used,
1581 clippy::field_reassign_with_default
1582)]
1583mod system_theme_tests {
1584 use super::*;
1585
1586 #[test]
1589 fn test_system_theme_active_dark() {
1590 let preset = NativeTheme::preset("catppuccin-mocha").unwrap();
1591 let mut light_v = preset.light.clone().unwrap();
1592 let mut dark_v = preset.dark.clone().unwrap();
1593 light_v.defaults.accent = Some(Rgba::rgb(0, 0, 255));
1595 dark_v.defaults.accent = Some(Rgba::rgb(255, 0, 0));
1596 light_v.resolve();
1597 dark_v.resolve();
1598 let light_resolved = light_v.validate().unwrap();
1599 let dark_resolved = dark_v.validate().unwrap();
1600
1601 let st = SystemTheme {
1602 name: "test".into(),
1603 is_dark: true,
1604 light: light_resolved.clone(),
1605 dark: dark_resolved.clone(),
1606 light_variant: preset.light.unwrap(),
1607 dark_variant: preset.dark.unwrap(),
1608 live_preset: "catppuccin-mocha".into(),
1609 full_preset: "catppuccin-mocha".into(),
1610 };
1611 assert_eq!(st.active().defaults.accent, dark_resolved.defaults.accent);
1612 }
1613
1614 #[test]
1615 fn test_system_theme_active_light() {
1616 let preset = NativeTheme::preset("catppuccin-mocha").unwrap();
1617 let mut light_v = preset.light.clone().unwrap();
1618 let mut dark_v = preset.dark.clone().unwrap();
1619 light_v.defaults.accent = Some(Rgba::rgb(0, 0, 255));
1620 dark_v.defaults.accent = Some(Rgba::rgb(255, 0, 0));
1621 light_v.resolve();
1622 dark_v.resolve();
1623 let light_resolved = light_v.validate().unwrap();
1624 let dark_resolved = dark_v.validate().unwrap();
1625
1626 let st = SystemTheme {
1627 name: "test".into(),
1628 is_dark: false,
1629 light: light_resolved.clone(),
1630 dark: dark_resolved.clone(),
1631 light_variant: preset.light.unwrap(),
1632 dark_variant: preset.dark.unwrap(),
1633 live_preset: "catppuccin-mocha".into(),
1634 full_preset: "catppuccin-mocha".into(),
1635 };
1636 assert_eq!(st.active().defaults.accent, light_resolved.defaults.accent);
1637 }
1638
1639 #[test]
1640 fn test_system_theme_pick() {
1641 let preset = NativeTheme::preset("catppuccin-mocha").unwrap();
1642 let mut light_v = preset.light.clone().unwrap();
1643 let mut dark_v = preset.dark.clone().unwrap();
1644 light_v.defaults.accent = Some(Rgba::rgb(0, 0, 255));
1645 dark_v.defaults.accent = Some(Rgba::rgb(255, 0, 0));
1646 light_v.resolve();
1647 dark_v.resolve();
1648 let light_resolved = light_v.validate().unwrap();
1649 let dark_resolved = dark_v.validate().unwrap();
1650
1651 let st = SystemTheme {
1652 name: "test".into(),
1653 is_dark: false,
1654 light: light_resolved.clone(),
1655 dark: dark_resolved.clone(),
1656 light_variant: preset.light.unwrap(),
1657 dark_variant: preset.dark.unwrap(),
1658 live_preset: "catppuccin-mocha".into(),
1659 full_preset: "catppuccin-mocha".into(),
1660 };
1661 assert_eq!(st.pick(true).defaults.accent, dark_resolved.defaults.accent);
1662 assert_eq!(
1663 st.pick(false).defaults.accent,
1664 light_resolved.defaults.accent
1665 );
1666 }
1667
1668 #[test]
1671 #[cfg(target_os = "linux")]
1672 #[allow(unsafe_code)]
1673 fn test_platform_preset_name_kde() {
1674 let _guard = crate::ENV_MUTEX.lock().unwrap();
1675 unsafe { std::env::set_var("XDG_CURRENT_DESKTOP", "KDE") };
1676 let name = platform_preset_name();
1677 unsafe { std::env::remove_var("XDG_CURRENT_DESKTOP") };
1678 assert_eq!(name, "kde-breeze-live");
1679 }
1680
1681 #[test]
1682 #[cfg(target_os = "linux")]
1683 #[allow(unsafe_code)]
1684 fn test_platform_preset_name_gnome() {
1685 let _guard = crate::ENV_MUTEX.lock().unwrap();
1686 unsafe { std::env::set_var("XDG_CURRENT_DESKTOP", "GNOME") };
1687 let name = platform_preset_name();
1688 unsafe { std::env::remove_var("XDG_CURRENT_DESKTOP") };
1689 assert_eq!(name, "adwaita-live");
1690 }
1691
1692 #[test]
1695 fn test_run_pipeline_produces_both_variants() {
1696 let reader = NativeTheme::preset("catppuccin-mocha").unwrap();
1697 let result = run_pipeline(reader, "catppuccin-mocha", false);
1698 assert!(result.is_ok(), "run_pipeline should succeed");
1699 let st = result.unwrap();
1700 assert!(!st.name.is_empty(), "name should be populated");
1702 }
1704
1705 #[test]
1706 fn test_run_pipeline_reader_values_win() {
1707 let custom_accent = Rgba::rgb(42, 100, 200);
1709 let mut reader = NativeTheme::default();
1710 reader.name = "CustomTheme".into();
1711 let mut variant = ThemeVariant::default();
1712 variant.defaults.accent = Some(custom_accent);
1713 reader.light = Some(variant);
1714
1715 let result = run_pipeline(reader, "catppuccin-mocha", false);
1716 assert!(result.is_ok(), "run_pipeline should succeed");
1717 let st = result.unwrap();
1718 assert_eq!(
1720 st.light.defaults.accent, custom_accent,
1721 "reader accent should win over preset accent"
1722 );
1723 assert_eq!(st.name, "CustomTheme", "reader name should win");
1724 }
1725
1726 #[test]
1727 fn test_run_pipeline_single_variant() {
1728 let full = NativeTheme::preset("kde-breeze").unwrap();
1732 let mut reader = NativeTheme::default();
1733 let mut dark_v = full.dark.clone().unwrap();
1734 dark_v.defaults.accent = Some(Rgba::rgb(200, 50, 50));
1736 reader.dark = Some(dark_v);
1737 reader.light = None;
1738
1739 let result = run_pipeline(reader, "kde-breeze-live", true);
1740 assert!(
1741 result.is_ok(),
1742 "run_pipeline should succeed with single variant"
1743 );
1744 let st = result.unwrap();
1745 assert_eq!(
1747 st.dark.defaults.accent,
1748 Rgba::rgb(200, 50, 50),
1749 "dark variant should have reader accent"
1750 );
1751 assert_eq!(st.live_preset, "kde-breeze-live");
1754 assert_eq!(st.full_preset, "kde-breeze");
1755 }
1756
1757 #[test]
1758 fn test_run_pipeline_inactive_variant_from_full_preset() {
1759 let full = NativeTheme::preset("kde-breeze").unwrap();
1762 let mut reader = NativeTheme::default();
1763 reader.dark = Some(full.dark.clone().unwrap());
1764 reader.light = None;
1765
1766 let st = run_pipeline(reader, "kde-breeze-live", true).unwrap();
1767
1768 let full_light = full.light.unwrap();
1770 assert_eq!(
1771 st.light.defaults.accent,
1772 full_light.defaults.accent.unwrap(),
1773 "inactive light variant should get accent from full preset"
1774 );
1775 assert_eq!(
1776 st.light.defaults.background,
1777 full_light.defaults.background.unwrap(),
1778 "inactive light variant should get background from full preset"
1779 );
1780 }
1781
1782 #[test]
1785 fn test_run_pipeline_with_preset_as_reader() {
1786 let reader = NativeTheme::preset("adwaita").unwrap();
1789 let result = run_pipeline(reader, "adwaita", false);
1790 assert!(
1791 result.is_ok(),
1792 "double-merge with same preset should succeed"
1793 );
1794 let st = result.unwrap();
1795 assert_eq!(st.name, "Adwaita");
1796 }
1797
1798 #[test]
1801 fn test_reader_is_dark_only_dark() {
1802 let mut theme = NativeTheme::default();
1803 theme.dark = Some(ThemeVariant::default());
1804 theme.light = None;
1805 assert!(
1806 reader_is_dark(&theme),
1807 "should be true when only dark is set"
1808 );
1809 }
1810
1811 #[test]
1812 fn test_reader_is_dark_only_light() {
1813 let mut theme = NativeTheme::default();
1814 theme.light = Some(ThemeVariant::default());
1815 theme.dark = None;
1816 assert!(
1817 !reader_is_dark(&theme),
1818 "should be false when only light is set"
1819 );
1820 }
1821
1822 #[test]
1823 fn test_reader_is_dark_both() {
1824 let mut theme = NativeTheme::default();
1825 theme.light = Some(ThemeVariant::default());
1826 theme.dark = Some(ThemeVariant::default());
1827 assert!(
1828 !reader_is_dark(&theme),
1829 "should be false when both are set (macOS case)"
1830 );
1831 }
1832
1833 #[test]
1834 fn test_reader_is_dark_neither() {
1835 let theme = NativeTheme::default();
1836 assert!(
1837 !reader_is_dark(&theme),
1838 "should be false when neither is set"
1839 );
1840 }
1841}
1842
1843#[cfg(test)]
1844#[allow(clippy::unwrap_used, clippy::expect_used)]
1845mod reduced_motion_tests {
1846 use super::*;
1847
1848 #[test]
1849 fn prefers_reduced_motion_smoke_test() {
1850 let _result = prefers_reduced_motion();
1854 }
1855
1856 #[cfg(target_os = "linux")]
1857 #[test]
1858 fn detect_reduced_motion_inner_linux() {
1859 let result = detect_reduced_motion_inner();
1863 let _ = result;
1865 }
1866
1867 #[cfg(target_os = "macos")]
1868 #[test]
1869 fn detect_reduced_motion_inner_macos() {
1870 let result = detect_reduced_motion_inner();
1871 let _ = result;
1872 }
1873
1874 #[cfg(target_os = "windows")]
1875 #[test]
1876 fn detect_reduced_motion_inner_windows() {
1877 let result = detect_reduced_motion_inner();
1878 let _ = result;
1879 }
1880}
1881
1882#[cfg(test)]
1883#[allow(clippy::unwrap_used, clippy::expect_used)]
1884mod overlay_tests {
1885 use super::*;
1886
1887 fn default_system_theme() -> SystemTheme {
1889 let reader = NativeTheme::preset("catppuccin-mocha").unwrap();
1890 run_pipeline(reader, "catppuccin-mocha", false).unwrap()
1891 }
1892
1893 #[test]
1894 fn test_overlay_accent_propagates() {
1895 let st = default_system_theme();
1896 let new_accent = Rgba::rgb(255, 0, 0);
1897
1898 let mut overlay = NativeTheme::default();
1900 let mut light_v = ThemeVariant::default();
1901 light_v.defaults.accent = Some(new_accent);
1902 let mut dark_v = ThemeVariant::default();
1903 dark_v.defaults.accent = Some(new_accent);
1904 overlay.light = Some(light_v);
1905 overlay.dark = Some(dark_v);
1906
1907 let result = st.with_overlay(&overlay).unwrap();
1908
1909 assert_eq!(result.light.defaults.accent, new_accent);
1911 assert_eq!(result.light.button.primary_bg, new_accent);
1913 assert_eq!(result.light.checkbox.checked_bg, new_accent);
1914 assert_eq!(result.light.slider.fill, new_accent);
1915 assert_eq!(result.light.progress_bar.fill, new_accent);
1916 assert_eq!(result.light.switch.checked_bg, new_accent);
1917 }
1918
1919 #[test]
1920 fn test_overlay_preserves_unrelated_fields() {
1921 let st = default_system_theme();
1922 let original_bg = st.light.defaults.background;
1923
1924 let mut overlay = NativeTheme::default();
1926 let mut light_v = ThemeVariant::default();
1927 light_v.defaults.accent = Some(Rgba::rgb(255, 0, 0));
1928 overlay.light = Some(light_v);
1929
1930 let result = st.with_overlay(&overlay).unwrap();
1931 assert_eq!(
1932 result.light.defaults.background, original_bg,
1933 "background should be unchanged"
1934 );
1935 }
1936
1937 #[test]
1938 fn test_overlay_empty_noop() {
1939 let st = default_system_theme();
1940 let original_light_accent = st.light.defaults.accent;
1941 let original_dark_accent = st.dark.defaults.accent;
1942 let original_light_bg = st.light.defaults.background;
1943
1944 let overlay = NativeTheme::default();
1946 let result = st.with_overlay(&overlay).unwrap();
1947
1948 assert_eq!(result.light.defaults.accent, original_light_accent);
1949 assert_eq!(result.dark.defaults.accent, original_dark_accent);
1950 assert_eq!(result.light.defaults.background, original_light_bg);
1951 }
1952
1953 #[test]
1954 fn test_overlay_both_variants() {
1955 let st = default_system_theme();
1956 let red = Rgba::rgb(255, 0, 0);
1957 let green = Rgba::rgb(0, 255, 0);
1958
1959 let mut overlay = NativeTheme::default();
1960 let mut light_v = ThemeVariant::default();
1961 light_v.defaults.accent = Some(red);
1962 let mut dark_v = ThemeVariant::default();
1963 dark_v.defaults.accent = Some(green);
1964 overlay.light = Some(light_v);
1965 overlay.dark = Some(dark_v);
1966
1967 let result = st.with_overlay(&overlay).unwrap();
1968 assert_eq!(result.light.defaults.accent, red, "light accent = red");
1969 assert_eq!(result.dark.defaults.accent, green, "dark accent = green");
1970 }
1971
1972 #[test]
1973 fn test_overlay_font_family() {
1974 let st = default_system_theme();
1975
1976 let mut overlay = NativeTheme::default();
1977 let mut light_v = ThemeVariant::default();
1978 light_v.defaults.font.family = Some("Comic Sans".into());
1979 overlay.light = Some(light_v);
1980
1981 let result = st.with_overlay(&overlay).unwrap();
1982 assert_eq!(result.light.defaults.font.family, "Comic Sans");
1983 }
1984
1985 #[test]
1986 fn test_overlay_toml_convenience() {
1987 let st = default_system_theme();
1988 let result = st
1989 .with_overlay_toml(
1990 r##"
1991 name = "overlay"
1992 [light.defaults]
1993 accent = "#ff0000"
1994 "##,
1995 )
1996 .unwrap();
1997 assert_eq!(result.light.defaults.accent, Rgba::rgb(255, 0, 0));
1998 }
1999}