1#![warn(missing_docs)]
9#![deny(unsafe_code)]
10#![deny(clippy::unwrap_used)]
11#![deny(clippy::expect_used)]
12
13#[doc = include_str!("../README.md")]
14#[cfg(doctest)]
15pub struct ReadmeDoctests;
16
17macro_rules! impl_merge {
40 (
41 $struct_name:ident {
42 $(option { $($opt_field:ident),* $(,)? })?
43 $(soft_option { $($so_field:ident),* $(,)? })?
44 $(nested { $($nest_field:ident),* $(,)? })?
45 $(optional_nested { $($on_field:ident),* $(,)? })?
46 }
47 ) => {
48 impl $struct_name {
49 pub fn merge(&mut self, overlay: &Self) {
53 $($(
54 if overlay.$opt_field.is_some() {
55 self.$opt_field = overlay.$opt_field.clone();
56 }
57 )*)?
58 $($(
59 if overlay.$so_field.is_some() {
60 self.$so_field = overlay.$so_field.clone();
61 }
62 )*)?
63 $($(
64 self.$nest_field.merge(&overlay.$nest_field);
65 )*)?
66 $($(
67 match (&mut self.$on_field, &overlay.$on_field) {
68 (Some(base), Some(over)) => base.merge(over),
69 (None, Some(over)) => self.$on_field = Some(over.clone()),
70 _ => {}
71 }
72 )*)?
73 }
74
75 pub fn is_empty(&self) -> bool {
77 true
78 $($(&& self.$opt_field.is_none())*)?
79 $($(&& self.$so_field.is_none())*)?
80 $($(&& self.$nest_field.is_empty())*)?
81 $($(&& self.$on_field.as_ref().map_or(true, |v| v.is_empty()))*)?
82 }
83 }
84 };
85}
86
87pub mod color;
89pub mod error;
91#[cfg(all(target_os = "linux", feature = "portal"))]
93pub mod gnome;
94#[cfg(all(target_os = "linux", feature = "kde"))]
96pub mod kde;
97pub mod model;
99pub mod presets;
101mod resolve;
103#[cfg(any(
104 feature = "material-icons",
105 feature = "lucide-icons",
106 feature = "system-icons"
107))]
108mod spinners;
109
110pub use color::{ParseColorError, Rgba};
111pub use error::{Error, ThemeResolutionError};
112pub use model::{
113 AnimatedIcon, BorderSpec, ButtonTheme, CardTheme, CheckboxTheme, ComboBoxTheme,
114 DialogButtonOrder, DialogTheme, ExpanderTheme, FontSize, FontSpec, FontStyle, IconData,
115 IconProvider, IconRole, IconSet, IconSizes, InputTheme, LayoutTheme, LinkTheme, ListTheme,
116 MenuTheme, PopoverTheme, ProgressBarTheme, ResolvedBorderSpec, ResolvedFontSpec,
117 ResolvedIconSizes, ResolvedTextScale, ResolvedTextScaleEntry, ResolvedThemeDefaults,
118 ResolvedThemeVariant, ScrollbarTheme, SegmentedControlTheme, SeparatorTheme, SidebarTheme,
119 SliderTheme, SpinnerTheme, SplitterTheme, StatusBarTheme, SwitchTheme, TabTheme, TextScale,
120 TextScaleEntry, ThemeDefaults, ThemeSpec, ThemeVariant, ToolbarTheme, TooltipTheme,
121 TransformAnimation, WindowTheme, bundled_icon_by_name, bundled_icon_svg,
122};
123pub use model::icons::{detect_icon_theme, icon_name, system_icon_set, system_icon_theme};
125
126#[cfg(all(target_os = "linux", feature = "system-icons"))]
128pub mod freedesktop;
129#[cfg(target_os = "macos")]
131pub mod macos;
132#[cfg(not(target_os = "macos"))]
133pub(crate) mod macos;
134#[cfg(feature = "svg-rasterize")]
136pub mod rasterize;
137#[cfg(all(target_os = "macos", feature = "system-icons"))]
139pub mod sficons;
140#[cfg(target_os = "windows")]
142pub mod windows;
143#[cfg(not(target_os = "windows"))]
144#[allow(dead_code, unused_variables)]
145pub(crate) mod windows;
146#[cfg(all(target_os = "windows", feature = "system-icons"))]
148pub mod winicons;
149#[cfg(all(not(target_os = "windows"), feature = "system-icons"))]
150#[allow(dead_code, unused_imports)]
151pub(crate) mod winicons;
152
153#[cfg(all(target_os = "linux", feature = "system-icons"))]
154pub use freedesktop::{load_freedesktop_icon, load_freedesktop_icon_by_name};
155#[cfg(all(target_os = "linux", feature = "portal"))]
156pub use gnome::from_gnome;
157#[cfg(all(target_os = "linux", feature = "portal", feature = "kde"))]
158pub use gnome::from_kde_with_portal;
159#[cfg(all(target_os = "linux", feature = "kde"))]
160pub use kde::from_kde;
161#[cfg(all(target_os = "macos", feature = "macos"))]
162pub use macos::from_macos;
163#[cfg(feature = "svg-rasterize")]
164pub use rasterize::rasterize_svg;
165#[cfg(all(target_os = "macos", feature = "system-icons"))]
166pub use sficons::load_sf_icon;
167#[cfg(all(target_os = "macos", feature = "system-icons"))]
168pub use sficons::load_sf_icon_by_name;
169#[cfg(all(target_os = "windows", feature = "windows"))]
170pub use windows::from_windows;
171#[cfg(all(target_os = "windows", feature = "system-icons"))]
172pub use winicons::load_windows_icon;
173#[cfg(all(target_os = "windows", feature = "system-icons"))]
174pub use winicons::load_windows_icon_by_name;
175
176pub type Result<T> = std::result::Result<T, Error>;
178
179#[cfg(target_os = "linux")]
181#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
182pub enum LinuxDesktop {
183 Kde,
185 Gnome,
187 Xfce,
189 Cinnamon,
191 Mate,
193 LxQt,
195 Budgie,
197 Unknown,
199}
200
201#[cfg(target_os = "linux")]
204pub(crate) fn xdg_current_desktop() -> String {
205 std::env::var("XDG_CURRENT_DESKTOP").unwrap_or_default()
206}
207
208#[cfg(target_os = "linux")]
214#[must_use]
215pub fn detect_linux_de(xdg_current_desktop: &str) -> LinuxDesktop {
216 for component in xdg_current_desktop.split(':') {
217 match component {
218 "KDE" => return LinuxDesktop::Kde,
219 "Budgie" => return LinuxDesktop::Budgie,
220 "GNOME" => return LinuxDesktop::Gnome,
221 "XFCE" => return LinuxDesktop::Xfce,
222 "X-Cinnamon" | "Cinnamon" => return LinuxDesktop::Cinnamon,
223 "MATE" => return LinuxDesktop::Mate,
224 "LXQt" => return LinuxDesktop::LxQt,
225 _ => {}
226 }
227 }
228 LinuxDesktop::Unknown
229}
230
231static CACHED_IS_DARK: std::sync::RwLock<Option<bool>> = std::sync::RwLock::new(None);
232
233#[must_use = "this returns whether the system uses dark mode"]
263pub fn system_is_dark() -> bool {
264 if let Ok(guard) = CACHED_IS_DARK.read()
265 && let Some(v) = *guard
266 {
267 return v;
268 }
269 let value = detect_is_dark_inner();
270 if let Ok(mut guard) = CACHED_IS_DARK.write() {
271 *guard = Some(value);
272 }
273 value
274}
275
276pub fn invalidate_caches() {
285 if let Ok(mut g) = CACHED_IS_DARK.write() {
286 *g = None;
287 }
288 if let Ok(mut g) = CACHED_REDUCED_MOTION.write() {
289 *g = None;
290 }
291 crate::model::icons::invalidate_icon_theme_cache();
292}
293
294#[must_use = "this returns whether the system uses dark mode"]
302pub fn detect_is_dark() -> bool {
303 detect_is_dark_inner()
304}
305
306#[cfg(target_os = "linux")]
315pub(crate) fn run_gsettings_with_timeout(args: &[&str]) -> Option<String> {
316 use std::io::Read;
317 use std::time::{Duration, Instant};
318
319 let deadline = Instant::now() + Duration::from_secs(2);
320 let mut child = std::process::Command::new("gsettings")
321 .args(args)
322 .stdout(std::process::Stdio::piped())
323 .stderr(std::process::Stdio::null())
324 .spawn()
325 .ok()?;
326
327 loop {
328 match child.try_wait() {
329 Ok(Some(status)) if status.success() => {
330 let mut buf = String::new();
331 if let Some(mut stdout) = child.stdout.take() {
332 let _ = stdout.read_to_string(&mut buf);
333 }
334 let trimmed = buf.trim().to_string();
335 return if trimmed.is_empty() {
336 None
337 } else {
338 Some(trimmed)
339 };
340 }
341 Ok(Some(_)) => return None,
342 Ok(None) => {
343 if Instant::now() >= deadline {
344 let _ = child.kill();
345 return None;
346 }
347 std::thread::sleep(Duration::from_millis(50));
348 }
349 Err(_) => return None,
350 }
351 }
352}
353
354#[cfg(all(target_os = "linux", any(feature = "kde", feature = "portal")))]
359pub(crate) fn read_xft_dpi() -> Option<f32> {
360 use std::io::Read;
361 use std::time::{Duration, Instant};
362
363 let deadline = Instant::now() + Duration::from_secs(2);
364 let mut child = std::process::Command::new("xrdb")
365 .arg("-query")
366 .stdout(std::process::Stdio::piped())
367 .stderr(std::process::Stdio::null())
368 .spawn()
369 .ok()?;
370
371 loop {
372 match child.try_wait() {
373 Ok(Some(status)) if status.success() => {
374 let mut buf = String::new();
375 if let Some(mut stdout) = child.stdout.take() {
376 let _ = stdout.read_to_string(&mut buf);
377 }
378 for line in buf.lines() {
380 if let Some(rest) = line.strip_prefix("Xft.dpi:")
381 && let Ok(dpi) = rest.trim().parse::<f32>()
382 && dpi > 0.0
383 {
384 return Some(dpi);
385 }
386 }
387 return None;
388 }
389 Ok(Some(_)) => return None,
390 Ok(None) => {
391 if Instant::now() >= deadline {
392 let _ = child.kill();
393 return None;
394 }
395 std::thread::sleep(Duration::from_millis(50));
396 }
397 Err(_) => return None,
398 }
399 }
400}
401
402#[cfg(all(target_os = "linux", any(feature = "kde", feature = "portal")))]
412pub(crate) fn detect_physical_dpi() -> Option<f32> {
413 use std::io::Read;
414 use std::time::{Duration, Instant};
415
416 let deadline = Instant::now() + Duration::from_secs(2);
417 let mut child = std::process::Command::new("xrandr")
418 .stdout(std::process::Stdio::piped())
419 .stderr(std::process::Stdio::null())
420 .spawn()
421 .ok()?;
422
423 loop {
424 match child.try_wait() {
425 Ok(Some(status)) if status.success() => {
426 let mut buf = String::new();
427 if let Some(mut stdout) = child.stdout.take() {
428 let _ = stdout.read_to_string(&mut buf);
429 }
430 return parse_xrandr_dpi(&buf);
431 }
432 Ok(Some(_)) => return None,
433 Ok(None) => {
434 if Instant::now() >= deadline {
435 let _ = child.kill();
436 return None;
437 }
438 std::thread::sleep(Duration::from_millis(50));
439 }
440 Err(_) => return None,
441 }
442 }
443}
444
445#[cfg(all(target_os = "linux", any(feature = "kde", feature = "portal")))]
454fn parse_xrandr_dpi(output: &str) -> Option<f32> {
455 let line = output
457 .lines()
458 .find(|l| l.contains(" connected") && l.contains("primary"))
459 .or_else(|| {
460 output
461 .lines()
462 .find(|l| l.contains(" connected") && !l.contains("disconnected"))
463 })?;
464
465 let res_token = line
467 .split_whitespace()
468 .find(|s| s.contains('x') && s.contains('+'))?;
469 let (w_str, rest) = res_token.split_once('x')?;
470 let h_str = rest.split('+').next()?;
471 let w_px: f32 = w_str.parse().ok()?;
472 let h_px: f32 = h_str.parse().ok()?;
473
474 let words: Vec<&str> = line.split_whitespace().collect();
476 let mut w_mm = None;
477 let mut h_mm = None;
478 for i in 1..words.len().saturating_sub(1) {
479 if words[i] == "x" {
480 w_mm = words[i - 1]
481 .strip_suffix("mm")
482 .and_then(|n| n.parse::<f32>().ok());
483 h_mm = words[i + 1]
484 .strip_suffix("mm")
485 .and_then(|n| n.parse::<f32>().ok());
486 }
487 }
488 let w_mm = w_mm.filter(|&v| v > 0.0)?;
489 let h_mm = h_mm.filter(|&v| v > 0.0)?;
490
491 let h_dpi = w_px / (w_mm / 25.4);
492 let v_dpi = h_px / (h_mm / 25.4);
493 let avg = (h_dpi + v_dpi) / 2.0;
494
495 if avg > 0.0 { Some(avg) } else { None }
496}
497
498#[cfg(all(test, target_os = "linux", any(feature = "kde", feature = "portal")))]
499#[allow(clippy::unwrap_used)]
500mod xrandr_dpi_tests {
501 use super::parse_xrandr_dpi;
502
503 #[test]
504 fn primary_4k_display() {
505 let output = "Screen 0: minimum 16 x 16, current 3840 x 2160, maximum 32767 x 32767\n\
507 DP-1 connected primary 3840x2160+0+0 (normal left inverted right x axis y axis) 700mm x 390mm\n\
508 3840x2160 60.00*+\n";
509 let dpi = parse_xrandr_dpi(output).unwrap();
510 assert!((dpi - 140.0).abs() < 1.0, "expected ~140 DPI, got {dpi}");
512 }
513
514 #[test]
515 fn standard_1080p_display() {
516 let output = "DP-2 connected primary 1920x1080+0+0 (normal) 530mm x 300mm\n";
517 let dpi = parse_xrandr_dpi(output).unwrap();
518 assert!((dpi - 92.0).abs() < 1.0, "expected ~92 DPI, got {dpi}");
520 }
521
522 #[test]
523 fn no_primary_falls_back_to_first_connected() {
524 let output = "HDMI-1 connected 1920x1080+0+0 (normal) 480mm x 270mm\n\
525 DP-1 disconnected\n";
526 let dpi = parse_xrandr_dpi(output).unwrap();
527 assert!(dpi > 90.0 && dpi < 110.0, "expected ~100 DPI, got {dpi}");
528 }
529
530 #[test]
531 fn disconnected_only_returns_none() {
532 let output = "DP-1 disconnected\nHDMI-1 disconnected\n";
533 assert!(parse_xrandr_dpi(output).is_none());
534 }
535
536 #[test]
537 fn missing_physical_dimensions_returns_none() {
538 let output = "DP-1 connected primary 1920x1080+0+0 (normal)\n";
540 assert!(parse_xrandr_dpi(output).is_none());
541 }
542
543 #[test]
544 fn zero_mm_returns_none() {
545 let output = "DP-1 connected primary 1920x1080+0+0 (normal) 0mm x 0mm\n";
546 assert!(parse_xrandr_dpi(output).is_none());
547 }
548
549 #[test]
550 fn empty_output_returns_none() {
551 assert!(parse_xrandr_dpi("").is_none());
552 }
553}
554
555#[allow(unreachable_code)]
567pub(crate) fn detect_system_font_dpi() -> f32 {
568 #[cfg(target_os = "macos")]
569 {
570 return 72.0;
571 }
572
573 #[cfg(all(target_os = "windows", feature = "windows"))]
574 {
575 return crate::windows::read_dpi() as f32;
576 }
577
578 #[cfg(all(target_os = "linux", feature = "kde"))]
580 {
581 if let Some(dpi) = read_kde_force_font_dpi() {
582 return dpi;
583 }
584 }
585
586 #[cfg(all(target_os = "linux", any(feature = "kde", feature = "portal")))]
587 {
588 if let Some(dpi) = read_xft_dpi() {
589 return dpi;
590 }
591 if let Some(dpi) = detect_physical_dpi() {
592 return dpi;
593 }
594 }
595
596 96.0
597}
598
599#[cfg(all(target_os = "linux", feature = "kde"))]
605fn read_kde_force_font_dpi() -> Option<f32> {
606 let path = crate::kde::kdeglobals_path();
608 if let Ok(content) = std::fs::read_to_string(&path) {
609 let mut ini = crate::kde::create_kde_parser();
610 if ini.read(content).is_ok()
611 && let Some(dpi_str) = ini.get("General", "forceFontDPI")
612 && let Ok(dpi) = dpi_str.trim().parse::<f32>()
613 && dpi > 0.0
614 {
615 return Some(dpi);
616 }
617 }
618 if let Some(dpi_str) = crate::kde::read_kcmfontsrc_key("General", "forceFontDPI")
620 && let Ok(dpi) = dpi_str.trim().parse::<f32>()
621 && dpi > 0.0
622 {
623 return Some(dpi);
624 }
625 None
626}
627
628#[allow(unreachable_code)]
632fn detect_is_dark_inner() -> bool {
633 #[cfg(target_os = "linux")]
634 {
635 if let Ok(gtk_theme) = std::env::var("GTK_THEME") {
637 let lower = gtk_theme.to_lowercase();
638 if lower.ends_with(":dark") || lower.contains("-dark") {
639 return true;
640 }
641 }
642
643 if let Some(val) =
645 run_gsettings_with_timeout(&["get", "org.gnome.desktop.interface", "color-scheme"])
646 {
647 if val.contains("prefer-dark") {
648 return true;
649 }
650 if val.contains("prefer-light") || val.contains("default") {
651 return false;
652 }
653 }
654
655 #[cfg(feature = "kde")]
657 {
658 let path = crate::kde::kdeglobals_path();
659 if let Ok(content) = std::fs::read_to_string(&path) {
660 let mut ini = crate::kde::create_kde_parser();
661 if ini.read(content).is_ok() {
662 return crate::kde::is_dark_theme(&ini);
663 }
664 }
665 }
666
667 let config_home = std::env::var("XDG_CONFIG_HOME").unwrap_or_else(|_| {
669 let home = std::env::var("HOME").unwrap_or_default();
670 format!("{home}/.config")
671 });
672 let ini_path = format!("{config_home}/gtk-3.0/settings.ini");
673 if let Ok(content) = std::fs::read_to_string(&ini_path) {
674 for line in content.lines() {
675 let trimmed = line.trim();
676 if trimmed.starts_with("gtk-application-prefer-dark-theme")
677 && let Some(val) = trimmed.split('=').nth(1)
678 && (val.trim() == "1" || val.trim().eq_ignore_ascii_case("true"))
679 {
680 return true;
681 }
682 }
683 }
684
685 false
686 }
687
688 #[cfg(target_os = "macos")]
689 {
690 #[cfg(feature = "macos")]
693 {
694 use objc2_foundation::NSUserDefaults;
695 let defaults = NSUserDefaults::standardUserDefaults();
696 let key = objc2_foundation::ns_string!("AppleInterfaceStyle");
697 if let Some(value) = defaults.stringForKey(key) {
698 return value.to_string().eq_ignore_ascii_case("dark");
699 }
700 return false;
701 }
702 #[cfg(not(feature = "macos"))]
703 {
704 if let Ok(output) = std::process::Command::new("defaults")
705 .args(["read", "-g", "AppleInterfaceStyle"])
706 .output()
707 && output.status.success()
708 {
709 let val = String::from_utf8_lossy(&output.stdout);
710 return val.trim().eq_ignore_ascii_case("dark");
711 }
712 return false;
713 }
714 }
715
716 #[cfg(target_os = "windows")]
717 {
718 #[cfg(feature = "windows")]
719 {
720 let Ok(settings) = ::windows::UI::ViewManagement::UISettings::new() else {
722 return false;
723 };
724 let Ok(fg) =
725 settings.GetColorValue(::windows::UI::ViewManagement::UIColorType::Foreground)
726 else {
727 return false;
728 };
729 let luma = 0.299 * (fg.R as f32) + 0.587 * (fg.G as f32) + 0.114 * (fg.B as f32);
730 return luma > 128.0;
731 }
732 #[cfg(not(feature = "windows"))]
733 return false;
734 }
735
736 #[cfg(not(any(target_os = "linux", target_os = "macos", target_os = "windows")))]
737 {
738 false
739 }
740}
741
742static CACHED_REDUCED_MOTION: std::sync::RwLock<Option<bool>> = std::sync::RwLock::new(None);
743
744#[must_use = "this returns whether reduced motion is preferred"]
775pub fn prefers_reduced_motion() -> bool {
776 if let Ok(guard) = CACHED_REDUCED_MOTION.read()
777 && let Some(v) = *guard
778 {
779 return v;
780 }
781 let value = detect_reduced_motion_inner();
782 if let Ok(mut guard) = CACHED_REDUCED_MOTION.write() {
783 *guard = Some(value);
784 }
785 value
786}
787
788#[must_use = "this returns whether reduced motion is preferred"]
796pub fn detect_reduced_motion() -> bool {
797 detect_reduced_motion_inner()
798}
799
800#[allow(unreachable_code)]
804fn detect_reduced_motion_inner() -> bool {
805 #[cfg(target_os = "linux")]
806 {
807 if let Some(val) =
810 run_gsettings_with_timeout(&["get", "org.gnome.desktop.interface", "enable-animations"])
811 {
812 return val.trim() == "false";
813 }
814 false
815 }
816
817 #[cfg(target_os = "macos")]
818 {
819 #[cfg(feature = "macos")]
820 {
821 let workspace = objc2_app_kit::NSWorkspace::sharedWorkspace();
822 return workspace.accessibilityDisplayShouldReduceMotion();
824 }
825 #[cfg(not(feature = "macos"))]
826 return false;
827 }
828
829 #[cfg(target_os = "windows")]
830 {
831 #[cfg(feature = "windows")]
832 {
833 let Ok(settings) = ::windows::UI::ViewManagement::UISettings::new() else {
834 return false;
835 };
836 return match settings.AnimationsEnabled() {
838 Ok(enabled) => !enabled,
839 Err(_) => false,
840 };
841 }
842 #[cfg(not(feature = "windows"))]
843 return false;
844 }
845
846 #[cfg(not(any(target_os = "linux", target_os = "macos", target_os = "windows")))]
847 {
848 false
849 }
850}
851
852#[derive(Clone, Debug)]
859pub struct SystemTheme {
860 pub name: String,
862 pub is_dark: bool,
864 pub light: ResolvedThemeVariant,
866 pub dark: ResolvedThemeVariant,
868 pub(crate) light_variant: ThemeVariant,
870 pub(crate) dark_variant: ThemeVariant,
872 pub preset: String,
874 pub(crate) live_preset: String,
876}
877
878impl SystemTheme {
879 #[must_use]
883 pub fn active(&self) -> &ResolvedThemeVariant {
884 if self.is_dark {
885 &self.dark
886 } else {
887 &self.light
888 }
889 }
890
891 #[must_use]
895 pub fn pick(&self, is_dark: bool) -> &ResolvedThemeVariant {
896 if is_dark { &self.dark } else { &self.light }
897 }
898
899 #[must_use = "this returns a new theme with the overlay applied; it does not modify self"]
923 pub fn with_overlay(&self, overlay: &ThemeSpec) -> crate::Result<Self> {
924 let mut light = self.light_variant.clone();
926 let mut dark = self.dark_variant.clone();
927
928 if let Some(over) = &overlay.light {
930 light.merge(over);
931 }
932 if let Some(over) = &overlay.dark {
933 dark.merge(over);
934 }
935
936 let resolved_light = light.clone().into_resolved()?;
938 let resolved_dark = dark.clone().into_resolved()?;
939
940 Ok(SystemTheme {
941 name: self.name.clone(),
942 is_dark: self.is_dark,
943 light: resolved_light,
944 dark: resolved_dark,
945 light_variant: light,
946 dark_variant: dark,
947 live_preset: self.live_preset.clone(),
948 preset: self.preset.clone(),
949 })
950 }
951
952 #[must_use = "this returns a new theme with the overlay applied; it does not modify self"]
956 pub fn with_overlay_toml(&self, toml: &str) -> crate::Result<Self> {
957 let overlay = ThemeSpec::from_toml(toml)?;
958 self.with_overlay(&overlay)
959 }
960
961 #[must_use = "this returns the detected theme; it does not apply it"]
998 pub fn from_system() -> crate::Result<Self> {
999 from_system_inner()
1000 }
1001
1002 #[cfg(target_os = "linux")]
1017 #[must_use = "this returns the detected theme; it does not apply it"]
1018 pub async fn from_system_async() -> crate::Result<Self> {
1019 from_system_async_inner().await
1020 }
1021
1022 #[cfg(not(target_os = "linux"))]
1027 #[must_use = "this returns the detected theme; it does not apply it"]
1028 pub async fn from_system_async() -> crate::Result<Self> {
1029 from_system_inner()
1030 }
1031}
1032
1033fn run_pipeline(
1040 reader_output: ThemeSpec,
1041 preset_name: &str,
1042 is_dark: bool,
1043) -> crate::Result<SystemTheme> {
1044 let live_preset = ThemeSpec::preset(preset_name)?;
1045
1046 let full_preset_name = preset_name.strip_suffix("-live").unwrap_or(preset_name);
1049 debug_assert!(
1050 full_preset_name != preset_name || !preset_name.ends_with("-live"),
1051 "live preset '{preset_name}' should have -live suffix stripped"
1052 );
1053 let full_preset = ThemeSpec::preset(full_preset_name)?;
1054
1055 let mut merged = full_preset.clone();
1058 merged.merge(&live_preset);
1059 merged.merge(&reader_output);
1060
1061 let name = if reader_output.name.is_empty() {
1063 merged.name.clone()
1064 } else {
1065 reader_output.name.clone()
1066 };
1067
1068 let mut light_variant = if reader_output.light.is_some() {
1072 merged.light.unwrap_or_default()
1073 } else {
1074 full_preset.light.unwrap_or_default()
1075 };
1076
1077 let mut dark_variant = if reader_output.dark.is_some() {
1078 merged.dark.unwrap_or_default()
1079 } else {
1080 full_preset.dark.unwrap_or_default()
1081 };
1082
1083 if let Some(reader_dpi) = reader_output
1088 .light
1089 .as_ref()
1090 .and_then(|v| v.defaults.font_dpi)
1091 .or_else(|| {
1092 reader_output
1093 .dark
1094 .as_ref()
1095 .and_then(|v| v.defaults.font_dpi)
1096 })
1097 {
1098 if light_variant.defaults.font_dpi.is_none() {
1099 light_variant.defaults.font_dpi = Some(reader_dpi);
1100 }
1101 if dark_variant.defaults.font_dpi.is_none() {
1102 dark_variant.defaults.font_dpi = Some(reader_dpi);
1103 }
1104 }
1105
1106 let light_variant_pre = light_variant.clone();
1108 let dark_variant_pre = dark_variant.clone();
1109
1110 let light = light_variant.into_resolved()?;
1111 let dark = dark_variant.into_resolved()?;
1112
1113 Ok(SystemTheme {
1114 name,
1115 is_dark,
1116 light,
1117 dark,
1118 light_variant: light_variant_pre,
1119 dark_variant: dark_variant_pre,
1120 preset: full_preset_name.to_string(),
1121 live_preset: preset_name.to_string(),
1122 })
1123}
1124
1125#[cfg(target_os = "linux")]
1135fn linux_preset_for_de(de: LinuxDesktop) -> &'static str {
1136 match de {
1137 LinuxDesktop::Kde => "kde-breeze-live",
1138 _ => "adwaita-live",
1139 }
1140}
1141
1142#[allow(unreachable_code)]
1158#[must_use]
1159pub fn platform_preset_name() -> &'static str {
1160 #[cfg(target_os = "macos")]
1161 {
1162 return "macos-sonoma-live";
1163 }
1164 #[cfg(target_os = "windows")]
1165 {
1166 return "windows-11-live";
1167 }
1168 #[cfg(target_os = "linux")]
1169 {
1170 linux_preset_for_de(detect_linux_de(&xdg_current_desktop()))
1171 }
1172 #[cfg(not(any(target_os = "linux", target_os = "windows", target_os = "macos")))]
1173 {
1174 "adwaita-live"
1175 }
1176}
1177
1178#[must_use]
1202pub fn diagnose_platform_support() -> Vec<String> {
1203 let mut diagnostics = Vec::new();
1204
1205 #[cfg(target_os = "linux")]
1206 {
1207 diagnostics.push("Platform: Linux".to_string());
1208
1209 match std::env::var("XDG_CURRENT_DESKTOP") {
1211 Ok(val) if !val.is_empty() => {
1212 let de = detect_linux_de(&val);
1213 diagnostics.push(format!("XDG_CURRENT_DESKTOP: {val}"));
1214 diagnostics.push(format!("Detected DE: {de:?}"));
1215 }
1216 _ => {
1217 diagnostics.push("XDG_CURRENT_DESKTOP: not set".to_string());
1218 diagnostics.push("Detected DE: Unknown (env var missing)".to_string());
1219 }
1220 }
1221
1222 match std::process::Command::new("gsettings")
1224 .arg("--version")
1225 .output()
1226 {
1227 Ok(output) if output.status.success() => {
1228 let version = String::from_utf8_lossy(&output.stdout);
1229 diagnostics.push(format!("gsettings: available ({})", version.trim()));
1230 }
1231 Ok(_) => {
1232 diagnostics.push("gsettings: found but returned error".to_string());
1233 }
1234 Err(_) => {
1235 diagnostics.push(
1236 "gsettings: not found (dark mode and icon theme detection may be limited)"
1237 .to_string(),
1238 );
1239 }
1240 }
1241
1242 #[cfg(feature = "kde")]
1244 {
1245 let path = crate::kde::kdeglobals_path();
1246 if path.exists() {
1247 diagnostics.push(format!("KDE kdeglobals: found at {}", path.display()));
1248 } else {
1249 diagnostics.push(format!("KDE kdeglobals: not found at {}", path.display()));
1250 }
1251 }
1252
1253 #[cfg(not(feature = "kde"))]
1254 {
1255 diagnostics.push("KDE support: disabled (kde feature not enabled)".to_string());
1256 }
1257
1258 #[cfg(feature = "portal")]
1260 diagnostics.push("Portal support: enabled".to_string());
1261
1262 #[cfg(not(feature = "portal"))]
1263 diagnostics.push("Portal support: disabled (portal feature not enabled)".to_string());
1264 }
1265
1266 #[cfg(target_os = "macos")]
1267 {
1268 diagnostics.push("Platform: macOS".to_string());
1269
1270 #[cfg(feature = "macos")]
1271 diagnostics.push("macOS theme detection: enabled (macos feature active)".to_string());
1272
1273 #[cfg(not(feature = "macos"))]
1274 diagnostics.push(
1275 "macOS theme detection: limited (macos feature not enabled, using subprocess fallback)"
1276 .to_string(),
1277 );
1278 }
1279
1280 #[cfg(target_os = "windows")]
1281 {
1282 diagnostics.push("Platform: Windows".to_string());
1283
1284 #[cfg(feature = "windows")]
1285 diagnostics.push("Windows theme detection: enabled (windows feature active)".to_string());
1286
1287 #[cfg(not(feature = "windows"))]
1288 diagnostics
1289 .push("Windows theme detection: disabled (windows feature not enabled)".to_string());
1290 }
1291
1292 #[cfg(not(any(target_os = "linux", target_os = "macos", target_os = "windows")))]
1293 {
1294 diagnostics.push("Platform: unsupported (no native theme detection available)".to_string());
1295 }
1296
1297 diagnostics
1298}
1299
1300#[allow(dead_code)]
1308fn reader_is_dark(reader: &ThemeSpec) -> bool {
1309 reader.dark.is_some() && reader.light.is_none()
1310}
1311
1312#[cfg(target_os = "linux")]
1318fn from_linux() -> crate::Result<SystemTheme> {
1319 let is_dark = system_is_dark();
1320 let de = detect_linux_de(&xdg_current_desktop());
1321 let preset = linux_preset_for_de(de);
1322 match de {
1323 #[cfg(feature = "kde")]
1324 LinuxDesktop::Kde => {
1325 let reader = crate::kde::from_kde()?;
1326 run_pipeline(reader, preset, is_dark)
1327 }
1328 #[cfg(not(feature = "kde"))]
1329 LinuxDesktop::Kde => run_pipeline(ThemeSpec::preset("adwaita")?, "adwaita-live", is_dark),
1330 LinuxDesktop::Gnome | LinuxDesktop::Budgie => {
1331 run_pipeline(ThemeSpec::preset("adwaita")?, preset, is_dark)
1333 }
1334 LinuxDesktop::Xfce | LinuxDesktop::Cinnamon | LinuxDesktop::Mate | LinuxDesktop::LxQt => {
1335 run_pipeline(ThemeSpec::preset("adwaita")?, preset, is_dark)
1336 }
1337 LinuxDesktop::Unknown => {
1338 #[cfg(feature = "kde")]
1339 {
1340 let path = crate::kde::kdeglobals_path();
1341 if path.exists() {
1342 let reader = crate::kde::from_kde()?;
1343 return run_pipeline(reader, linux_preset_for_de(LinuxDesktop::Kde), is_dark);
1344 }
1345 }
1346 run_pipeline(ThemeSpec::preset("adwaita")?, preset, is_dark)
1347 }
1348 }
1349}
1350
1351fn from_system_inner() -> crate::Result<SystemTheme> {
1352 #[cfg(target_os = "macos")]
1353 {
1354 #[cfg(feature = "macos")]
1355 {
1356 let reader = crate::macos::from_macos()?;
1357 let is_dark = reader_is_dark(&reader);
1358 return run_pipeline(reader, "macos-sonoma-live", is_dark);
1359 }
1360
1361 #[cfg(not(feature = "macos"))]
1362 return Err(crate::Error::Unsupported(
1363 "macOS theme detection requires the `macos` feature",
1364 ));
1365 }
1366
1367 #[cfg(target_os = "windows")]
1368 {
1369 #[cfg(feature = "windows")]
1370 {
1371 let reader = crate::windows::from_windows()?;
1372 let is_dark = reader_is_dark(&reader);
1373 return run_pipeline(reader, "windows-11-live", is_dark);
1374 }
1375
1376 #[cfg(not(feature = "windows"))]
1377 return Err(crate::Error::Unsupported(
1378 "Windows theme detection requires the `windows` feature",
1379 ));
1380 }
1381
1382 #[cfg(target_os = "linux")]
1383 {
1384 from_linux()
1385 }
1386
1387 #[cfg(not(any(target_os = "linux", target_os = "windows", target_os = "macos")))]
1388 {
1389 Err(crate::Error::Unsupported(
1390 "no theme reader available for this platform",
1391 ))
1392 }
1393}
1394
1395#[cfg(target_os = "linux")]
1396async fn from_system_async_inner() -> crate::Result<SystemTheme> {
1397 let is_dark = system_is_dark();
1398 let de = detect_linux_de(&xdg_current_desktop());
1399 let preset = linux_preset_for_de(de);
1400 match de {
1401 #[cfg(feature = "kde")]
1402 LinuxDesktop::Kde => {
1403 #[cfg(feature = "portal")]
1404 {
1405 let reader = crate::gnome::from_kde_with_portal().await?;
1406 run_pipeline(reader, preset, is_dark)
1407 }
1408 #[cfg(not(feature = "portal"))]
1409 {
1410 let reader = crate::kde::from_kde()?;
1411 run_pipeline(reader, preset, is_dark)
1412 }
1413 }
1414 #[cfg(not(feature = "kde"))]
1415 LinuxDesktop::Kde => run_pipeline(ThemeSpec::preset("adwaita")?, "adwaita-live", is_dark),
1416 #[cfg(feature = "portal")]
1417 LinuxDesktop::Gnome | LinuxDesktop::Budgie => {
1418 let reader = crate::gnome::from_gnome().await?;
1419 run_pipeline(reader, preset, is_dark)
1420 }
1421 #[cfg(not(feature = "portal"))]
1422 LinuxDesktop::Gnome | LinuxDesktop::Budgie => {
1423 run_pipeline(ThemeSpec::preset("adwaita")?, preset, is_dark)
1424 }
1425 LinuxDesktop::Xfce | LinuxDesktop::Cinnamon | LinuxDesktop::Mate | LinuxDesktop::LxQt => {
1426 run_pipeline(ThemeSpec::preset("adwaita")?, preset, is_dark)
1427 }
1428 LinuxDesktop::Unknown => {
1429 #[cfg(feature = "portal")]
1431 {
1432 if let Some(detected) = crate::gnome::detect_portal_backend().await {
1433 let detected_preset = linux_preset_for_de(detected);
1434 return match detected {
1435 #[cfg(feature = "kde")]
1436 LinuxDesktop::Kde => {
1437 let reader = crate::gnome::from_kde_with_portal().await?;
1438 run_pipeline(reader, detected_preset, is_dark)
1439 }
1440 #[cfg(not(feature = "kde"))]
1441 LinuxDesktop::Kde => {
1442 run_pipeline(ThemeSpec::preset("adwaita")?, "adwaita-live", is_dark)
1443 }
1444 LinuxDesktop::Gnome => {
1445 let reader = crate::gnome::from_gnome().await?;
1446 run_pipeline(reader, detected_preset, is_dark)
1447 }
1448 _ => {
1449 run_pipeline(ThemeSpec::preset("adwaita")?, detected_preset, is_dark)
1452 }
1453 };
1454 }
1455 }
1456 #[cfg(feature = "kde")]
1458 {
1459 let path = crate::kde::kdeglobals_path();
1460 if path.exists() {
1461 let reader = crate::kde::from_kde()?;
1462 return run_pipeline(reader, linux_preset_for_de(LinuxDesktop::Kde), is_dark);
1463 }
1464 }
1465 run_pipeline(ThemeSpec::preset("adwaita")?, preset, is_dark)
1466 }
1467 }
1468}
1469
1470#[must_use = "this returns the loaded icon data; it does not display it"]
1503#[allow(unreachable_patterns, clippy::needless_return, unused_variables)]
1504pub fn load_icon(role: IconRole, set: IconSet) -> Option<IconData> {
1505 match set {
1506 #[cfg(all(target_os = "linux", feature = "system-icons"))]
1507 IconSet::Freedesktop => freedesktop::load_freedesktop_icon(role, 24),
1508
1509 #[cfg(all(target_os = "macos", feature = "system-icons"))]
1510 IconSet::SfSymbols => sficons::load_sf_icon(role),
1511
1512 #[cfg(all(target_os = "windows", feature = "system-icons"))]
1513 IconSet::SegoeIcons => winicons::load_windows_icon(role),
1514
1515 #[cfg(feature = "material-icons")]
1516 IconSet::Material => {
1517 bundled_icon_svg(role, IconSet::Material).map(|b| IconData::Svg(b.to_vec()))
1518 }
1519
1520 #[cfg(feature = "lucide-icons")]
1521 IconSet::Lucide => {
1522 bundled_icon_svg(role, IconSet::Lucide).map(|b| IconData::Svg(b.to_vec()))
1523 }
1524
1525 _ => None,
1527 }
1528}
1529
1530#[must_use = "this returns the loaded icon data; it does not display it"]
1555#[allow(unreachable_patterns, clippy::needless_return, unused_variables)]
1556pub fn load_icon_from_theme(
1557 role: IconRole,
1558 set: IconSet,
1559 preferred_theme: &str,
1560) -> Option<IconData> {
1561 match set {
1562 #[cfg(all(target_os = "linux", feature = "system-icons"))]
1563 IconSet::Freedesktop => {
1564 let name = icon_name(role, IconSet::Freedesktop)?;
1565 freedesktop::load_freedesktop_icon_by_name(name, preferred_theme, 24)
1566 }
1567
1568 _ => load_icon(role, set),
1570 }
1571}
1572
1573#[must_use]
1581pub fn is_freedesktop_theme_available(theme: &str) -> bool {
1582 #[cfg(target_os = "linux")]
1583 {
1584 let data_dirs = std::env::var("XDG_DATA_DIRS")
1585 .unwrap_or_else(|_| "/usr/share:/usr/local/share".to_string());
1586 for dir in data_dirs.split(':') {
1587 if std::path::Path::new(dir)
1588 .join("icons")
1589 .join(theme)
1590 .join("index.theme")
1591 .exists()
1592 {
1593 return true;
1594 }
1595 }
1596 let data_home = std::env::var("XDG_DATA_HOME").unwrap_or_else(|_| {
1597 std::env::var("HOME")
1598 .map(|h| format!("{h}/.local/share"))
1599 .unwrap_or_default()
1600 });
1601 if !data_home.is_empty() {
1602 return std::path::Path::new(&data_home)
1603 .join("icons")
1604 .join(theme)
1605 .join("index.theme")
1606 .exists();
1607 }
1608 false
1609 }
1610 #[cfg(not(target_os = "linux"))]
1611 {
1612 false
1613 }
1614}
1615
1616#[must_use = "this returns the loaded icon data; it does not display it"]
1639#[allow(unreachable_patterns, unused_variables)]
1640pub fn load_system_icon_by_name(name: &str, set: IconSet) -> Option<IconData> {
1641 match set {
1642 #[cfg(all(target_os = "linux", feature = "system-icons"))]
1643 IconSet::Freedesktop => {
1644 let theme = system_icon_theme();
1645 freedesktop::load_freedesktop_icon_by_name(name, theme, 24)
1646 }
1647
1648 #[cfg(all(target_os = "macos", feature = "system-icons"))]
1649 IconSet::SfSymbols => sficons::load_sf_icon_by_name(name),
1650
1651 #[cfg(all(target_os = "windows", feature = "system-icons"))]
1652 IconSet::SegoeIcons => winicons::load_windows_icon_by_name(name),
1653
1654 #[cfg(feature = "material-icons")]
1655 IconSet::Material => {
1656 bundled_icon_by_name(name, IconSet::Material).map(|b| IconData::Svg(b.to_vec()))
1657 }
1658
1659 #[cfg(feature = "lucide-icons")]
1660 IconSet::Lucide => {
1661 bundled_icon_by_name(name, IconSet::Lucide).map(|b| IconData::Svg(b.to_vec()))
1662 }
1663
1664 _ => None,
1665 }
1666}
1667
1668#[must_use = "this returns animation data; it does not display anything"]
1688pub fn loading_indicator(set: IconSet) -> Option<AnimatedIcon> {
1689 match set {
1690 #[cfg(all(target_os = "linux", feature = "system-icons"))]
1691 IconSet::Freedesktop => freedesktop::load_freedesktop_spinner(),
1692
1693 #[cfg(feature = "material-icons")]
1694 IconSet::Material => Some(spinners::material_spinner()),
1695
1696 #[cfg(feature = "lucide-icons")]
1697 IconSet::Lucide => Some(spinners::lucide_spinner()),
1698
1699 _ => None,
1700 }
1701}
1702
1703#[must_use = "this returns the loaded icon data; it does not display it"]
1726pub fn load_custom_icon(provider: &(impl IconProvider + ?Sized), set: IconSet) -> Option<IconData> {
1727 if let Some(name) = provider.icon_name(set)
1729 && let Some(data) = load_system_icon_by_name(name, set)
1730 {
1731 return Some(data);
1732 }
1733
1734 if let Some(svg) = provider.icon_svg(set) {
1736 return Some(IconData::Svg(svg.to_vec()));
1737 }
1738
1739 None
1741}
1742
1743#[cfg(test)]
1747pub(crate) static ENV_MUTEX: std::sync::Mutex<()> = std::sync::Mutex::new(());
1748
1749#[cfg(all(test, target_os = "linux"))]
1750#[allow(clippy::unwrap_used, clippy::expect_used)]
1751mod dispatch_tests {
1752 use super::*;
1753
1754 #[test]
1757 fn detect_kde_simple() {
1758 assert_eq!(detect_linux_de("KDE"), LinuxDesktop::Kde);
1759 }
1760
1761 #[test]
1762 fn detect_kde_colon_separated_after() {
1763 assert_eq!(detect_linux_de("ubuntu:KDE"), LinuxDesktop::Kde);
1764 }
1765
1766 #[test]
1767 fn detect_kde_colon_separated_before() {
1768 assert_eq!(detect_linux_de("KDE:plasma"), LinuxDesktop::Kde);
1769 }
1770
1771 #[test]
1772 fn detect_gnome_simple() {
1773 assert_eq!(detect_linux_de("GNOME"), LinuxDesktop::Gnome);
1774 }
1775
1776 #[test]
1777 fn detect_gnome_ubuntu() {
1778 assert_eq!(detect_linux_de("ubuntu:GNOME"), LinuxDesktop::Gnome);
1779 }
1780
1781 #[test]
1782 fn detect_xfce() {
1783 assert_eq!(detect_linux_de("XFCE"), LinuxDesktop::Xfce);
1784 }
1785
1786 #[test]
1787 fn detect_cinnamon() {
1788 assert_eq!(detect_linux_de("X-Cinnamon"), LinuxDesktop::Cinnamon);
1789 }
1790
1791 #[test]
1792 fn detect_cinnamon_short() {
1793 assert_eq!(detect_linux_de("Cinnamon"), LinuxDesktop::Cinnamon);
1794 }
1795
1796 #[test]
1797 fn detect_mate() {
1798 assert_eq!(detect_linux_de("MATE"), LinuxDesktop::Mate);
1799 }
1800
1801 #[test]
1802 fn detect_lxqt() {
1803 assert_eq!(detect_linux_de("LXQt"), LinuxDesktop::LxQt);
1804 }
1805
1806 #[test]
1807 fn detect_budgie() {
1808 assert_eq!(detect_linux_de("Budgie:GNOME"), LinuxDesktop::Budgie);
1809 }
1810
1811 #[test]
1812 fn detect_empty_string() {
1813 assert_eq!(detect_linux_de(""), LinuxDesktop::Unknown);
1814 }
1815
1816 #[test]
1819 #[allow(unsafe_code)]
1820 fn from_linux_non_kde_returns_adwaita() {
1821 let _guard = crate::ENV_MUTEX.lock().unwrap();
1822 unsafe { std::env::set_var("XDG_CURRENT_DESKTOP", "GNOME") };
1826 let result = from_linux();
1827 unsafe { std::env::remove_var("XDG_CURRENT_DESKTOP") };
1828
1829 let theme = result.expect("from_linux() should return Ok for non-KDE desktop");
1830 assert_eq!(theme.name, "Adwaita");
1831 }
1832
1833 #[test]
1836 #[cfg(feature = "kde")]
1837 #[allow(unsafe_code)]
1838 fn from_linux_unknown_de_with_kdeglobals_fallback() {
1839 let _guard = crate::ENV_MUTEX.lock().unwrap();
1840 use std::io::Write;
1841
1842 let tmp_dir = std::env::temp_dir().join("native_theme_test_kde_fallback");
1844 std::fs::create_dir_all(&tmp_dir).unwrap();
1845 let kdeglobals = tmp_dir.join("kdeglobals");
1846 let mut f = std::fs::File::create(&kdeglobals).unwrap();
1847 writeln!(
1848 f,
1849 "[General]\nColorScheme=TestTheme\n\n[Colors:Window]\nBackgroundNormal=239,240,241\n"
1850 )
1851 .unwrap();
1852
1853 let orig_xdg = std::env::var("XDG_CONFIG_HOME").ok();
1855 let orig_desktop = std::env::var("XDG_CURRENT_DESKTOP").ok();
1856
1857 unsafe { std::env::set_var("XDG_CONFIG_HOME", &tmp_dir) };
1858 unsafe { std::env::set_var("XDG_CURRENT_DESKTOP", "SomeUnknownDE") };
1859
1860 let result = from_linux();
1861
1862 match orig_xdg {
1864 Some(val) => unsafe { std::env::set_var("XDG_CONFIG_HOME", val) },
1865 None => unsafe { std::env::remove_var("XDG_CONFIG_HOME") },
1866 }
1867 match orig_desktop {
1868 Some(val) => unsafe { std::env::set_var("XDG_CURRENT_DESKTOP", val) },
1869 None => unsafe { std::env::remove_var("XDG_CURRENT_DESKTOP") },
1870 }
1871
1872 let _ = std::fs::remove_dir_all(&tmp_dir);
1874
1875 let theme = result.expect("from_linux() should return Ok with kdeglobals fallback");
1876 assert_eq!(
1877 theme.name, "TestTheme",
1878 "should use KDE theme name from kdeglobals"
1879 );
1880 }
1881
1882 #[test]
1883 #[allow(unsafe_code)]
1884 fn from_linux_unknown_de_without_kdeglobals_returns_adwaita() {
1885 let _guard = crate::ENV_MUTEX.lock().unwrap();
1886 let orig_xdg = std::env::var("XDG_CONFIG_HOME").ok();
1888 let orig_desktop = std::env::var("XDG_CURRENT_DESKTOP").ok();
1889
1890 unsafe {
1891 std::env::set_var(
1892 "XDG_CONFIG_HOME",
1893 "/tmp/nonexistent_native_theme_test_no_kde",
1894 )
1895 };
1896 unsafe { std::env::set_var("XDG_CURRENT_DESKTOP", "SomeUnknownDE") };
1897
1898 let result = from_linux();
1899
1900 match orig_xdg {
1902 Some(val) => unsafe { std::env::set_var("XDG_CONFIG_HOME", val) },
1903 None => unsafe { std::env::remove_var("XDG_CONFIG_HOME") },
1904 }
1905 match orig_desktop {
1906 Some(val) => unsafe { std::env::set_var("XDG_CURRENT_DESKTOP", val) },
1907 None => unsafe { std::env::remove_var("XDG_CURRENT_DESKTOP") },
1908 }
1909
1910 let theme = result.expect("from_linux() should return Ok (adwaita fallback)");
1911 assert_eq!(
1912 theme.name, "Adwaita",
1913 "should fall back to Adwaita without kdeglobals"
1914 );
1915 }
1916
1917 #[test]
1920 fn detect_hyprland_returns_unknown() {
1921 assert_eq!(detect_linux_de("Hyprland"), LinuxDesktop::Unknown);
1922 }
1923
1924 #[test]
1925 fn detect_sway_returns_unknown() {
1926 assert_eq!(detect_linux_de("sway"), LinuxDesktop::Unknown);
1927 }
1928
1929 #[test]
1930 fn detect_cosmic_returns_unknown() {
1931 assert_eq!(detect_linux_de("COSMIC"), LinuxDesktop::Unknown);
1932 }
1933
1934 #[test]
1937 #[allow(unsafe_code)]
1938 fn from_system_returns_result() {
1939 let _guard = crate::ENV_MUTEX.lock().unwrap();
1940 unsafe { std::env::set_var("XDG_CURRENT_DESKTOP", "GNOME") };
1944 let result = SystemTheme::from_system();
1945 unsafe { std::env::remove_var("XDG_CURRENT_DESKTOP") };
1946
1947 let theme = result.expect("from_system() should return Ok on Linux");
1948 assert_eq!(theme.name, "Adwaita");
1949 }
1950}
1951
1952#[cfg(test)]
1953#[allow(clippy::unwrap_used, clippy::expect_used)]
1954mod load_icon_tests {
1955 use super::*;
1956
1957 #[test]
1958 #[cfg(feature = "material-icons")]
1959 fn load_icon_material_returns_svg() {
1960 let result = load_icon(IconRole::ActionCopy, IconSet::Material);
1961 assert!(result.is_some(), "material ActionCopy should return Some");
1962 match result.unwrap() {
1963 IconData::Svg(bytes) => {
1964 let content = std::str::from_utf8(&bytes).expect("should be valid UTF-8");
1965 assert!(content.contains("<svg"), "should contain SVG data");
1966 }
1967 _ => panic!("expected IconData::Svg for bundled material icon"),
1968 }
1969 }
1970
1971 #[test]
1972 #[cfg(feature = "lucide-icons")]
1973 fn load_icon_lucide_returns_svg() {
1974 let result = load_icon(IconRole::ActionCopy, IconSet::Lucide);
1975 assert!(result.is_some(), "lucide ActionCopy should return Some");
1976 match result.unwrap() {
1977 IconData::Svg(bytes) => {
1978 let content = std::str::from_utf8(&bytes).expect("should be valid UTF-8");
1979 assert!(content.contains("<svg"), "should contain SVG data");
1980 }
1981 _ => panic!("expected IconData::Svg for bundled lucide icon"),
1982 }
1983 }
1984
1985 #[test]
1986 #[cfg(feature = "material-icons")]
1987 fn load_icon_unknown_theme_no_cross_set_fallback() {
1988 let result = load_icon(IconRole::ActionCopy, IconSet::Freedesktop);
1992 let _ = result;
1996 }
1997
1998 #[test]
1999 #[cfg(feature = "material-icons")]
2000 fn load_icon_all_roles_material() {
2001 let mut some_count = 0;
2003 for role in IconRole::ALL {
2004 if load_icon(role, IconSet::Material).is_some() {
2005 some_count += 1;
2006 }
2007 }
2008 assert_eq!(
2010 some_count, 42,
2011 "Material should cover all 42 roles via bundled SVGs"
2012 );
2013 }
2014
2015 #[test]
2016 #[cfg(feature = "lucide-icons")]
2017 fn load_icon_all_roles_lucide() {
2018 let mut some_count = 0;
2019 for role in IconRole::ALL {
2020 if load_icon(role, IconSet::Lucide).is_some() {
2021 some_count += 1;
2022 }
2023 }
2024 assert_eq!(
2026 some_count, 42,
2027 "Lucide should cover all 42 roles via bundled SVGs"
2028 );
2029 }
2030
2031 #[test]
2032 fn load_icon_unrecognized_set_no_features() {
2033 let _result = load_icon(IconRole::ActionCopy, IconSet::SfSymbols);
2035 }
2037}
2038
2039#[cfg(test)]
2040#[allow(clippy::unwrap_used, clippy::expect_used)]
2041mod load_system_icon_by_name_tests {
2042 use super::*;
2043
2044 #[test]
2045 #[cfg(feature = "material-icons")]
2046 fn system_icon_by_name_material() {
2047 let result = load_system_icon_by_name("content_copy", IconSet::Material);
2048 assert!(
2049 result.is_some(),
2050 "content_copy should be found in Material set"
2051 );
2052 assert!(matches!(result.unwrap(), IconData::Svg(_)));
2053 }
2054
2055 #[test]
2056 #[cfg(feature = "lucide-icons")]
2057 fn system_icon_by_name_lucide() {
2058 let result = load_system_icon_by_name("copy", IconSet::Lucide);
2059 assert!(result.is_some(), "copy should be found in Lucide set");
2060 assert!(matches!(result.unwrap(), IconData::Svg(_)));
2061 }
2062
2063 #[test]
2064 #[cfg(feature = "material-icons")]
2065 fn system_icon_by_name_unknown_returns_none() {
2066 let result = load_system_icon_by_name("nonexistent_xyz", IconSet::Material);
2067 assert!(result.is_none(), "nonexistent name should return None");
2068 }
2069
2070 #[test]
2071 fn system_icon_by_name_sf_on_linux_returns_none() {
2072 #[cfg(not(target_os = "macos"))]
2074 {
2075 let result = load_system_icon_by_name("doc.on.doc", IconSet::SfSymbols);
2076 assert!(
2077 result.is_none(),
2078 "SF Symbols should return None on non-macOS"
2079 );
2080 }
2081 }
2082}
2083
2084#[cfg(test)]
2085#[allow(clippy::unwrap_used, clippy::expect_used)]
2086mod load_custom_icon_tests {
2087 use super::*;
2088
2089 #[test]
2090 #[cfg(feature = "material-icons")]
2091 fn custom_icon_with_icon_role_material() {
2092 let result = load_custom_icon(&IconRole::ActionCopy, IconSet::Material);
2093 assert!(
2094 result.is_some(),
2095 "IconRole::ActionCopy should load via material"
2096 );
2097 }
2098
2099 #[test]
2100 #[cfg(feature = "lucide-icons")]
2101 fn custom_icon_with_icon_role_lucide() {
2102 let result = load_custom_icon(&IconRole::ActionCopy, IconSet::Lucide);
2103 assert!(
2104 result.is_some(),
2105 "IconRole::ActionCopy should load via lucide"
2106 );
2107 }
2108
2109 #[test]
2110 fn custom_icon_no_cross_set_fallback() {
2111 #[derive(Debug)]
2113 struct NullProvider;
2114 impl IconProvider for NullProvider {
2115 fn icon_name(&self, _set: IconSet) -> Option<&str> {
2116 None
2117 }
2118 fn icon_svg(&self, _set: IconSet) -> Option<&'static [u8]> {
2119 None
2120 }
2121 }
2122
2123 let result = load_custom_icon(&NullProvider, IconSet::Material);
2124 assert!(
2125 result.is_none(),
2126 "NullProvider should return None (no cross-set fallback)"
2127 );
2128 }
2129
2130 #[test]
2131 fn custom_icon_unknown_set_uses_system() {
2132 #[derive(Debug)]
2134 struct NullProvider;
2135 impl IconProvider for NullProvider {
2136 fn icon_name(&self, _set: IconSet) -> Option<&str> {
2137 None
2138 }
2139 fn icon_svg(&self, _set: IconSet) -> Option<&'static [u8]> {
2140 None
2141 }
2142 }
2143
2144 let _result = load_custom_icon(&NullProvider, IconSet::Freedesktop);
2146 }
2147
2148 #[test]
2149 #[cfg(feature = "material-icons")]
2150 fn custom_icon_via_dyn_dispatch() {
2151 let boxed: Box<dyn IconProvider> = Box::new(IconRole::ActionCopy);
2152 let result = load_custom_icon(&*boxed, IconSet::Material);
2153 assert!(
2154 result.is_some(),
2155 "dyn dispatch through Box<dyn IconProvider> should work"
2156 );
2157 }
2158
2159 #[test]
2160 #[cfg(feature = "material-icons")]
2161 fn custom_icon_bundled_svg_fallback() {
2162 #[derive(Debug)]
2164 struct SvgOnlyProvider;
2165 impl IconProvider for SvgOnlyProvider {
2166 fn icon_name(&self, _set: IconSet) -> Option<&str> {
2167 None
2168 }
2169 fn icon_svg(&self, _set: IconSet) -> Option<&'static [u8]> {
2170 Some(b"<svg>test</svg>")
2171 }
2172 }
2173
2174 let result = load_custom_icon(&SvgOnlyProvider, IconSet::Material);
2175 assert!(
2176 result.is_some(),
2177 "provider with icon_svg should return Some"
2178 );
2179 match result.unwrap() {
2180 IconData::Svg(bytes) => {
2181 assert_eq!(bytes, b"<svg>test</svg>");
2182 }
2183 _ => panic!("expected IconData::Svg"),
2184 }
2185 }
2186}
2187
2188#[cfg(test)]
2189#[allow(clippy::unwrap_used, clippy::expect_used)]
2190mod loading_indicator_tests {
2191 use super::*;
2192
2193 #[test]
2196 #[cfg(feature = "lucide-icons")]
2197 fn loading_indicator_lucide_returns_frames() {
2198 let anim = loading_indicator(IconSet::Lucide);
2199 assert!(anim.is_some(), "lucide should return Some");
2200 let anim = anim.unwrap();
2201 assert!(
2202 matches!(anim, AnimatedIcon::Frames { .. }),
2203 "lucide should be pre-rotated Frames"
2204 );
2205 if let AnimatedIcon::Frames {
2206 frames,
2207 frame_duration_ms,
2208 } = &anim
2209 {
2210 assert_eq!(frames.len(), 24);
2211 assert_eq!(*frame_duration_ms, 42);
2212 }
2213 }
2214
2215 #[test]
2218 #[cfg(all(target_os = "linux", feature = "system-icons"))]
2219 fn loading_indicator_freedesktop_depends_on_theme() {
2220 let anim = loading_indicator(IconSet::Freedesktop);
2221 if let Some(anim) = anim {
2223 match anim {
2224 AnimatedIcon::Frames { frames, .. } => {
2225 assert!(
2226 !frames.is_empty(),
2227 "Frames variant should have at least one frame"
2228 );
2229 }
2230 AnimatedIcon::Transform { .. } => {
2231 }
2233 }
2234 }
2235 }
2236
2237 #[test]
2239 fn loading_indicator_freedesktop_does_not_panic() {
2240 let _result = loading_indicator(IconSet::Freedesktop);
2241 }
2242
2243 #[test]
2246 #[cfg(feature = "lucide-icons")]
2247 fn lucide_spinner_is_frames() {
2248 let anim = spinners::lucide_spinner();
2249 assert!(
2250 matches!(anim, AnimatedIcon::Frames { .. }),
2251 "lucide should be pre-rotated Frames"
2252 );
2253 }
2254}
2255
2256#[cfg(all(test, feature = "svg-rasterize"))]
2257#[allow(clippy::unwrap_used, clippy::expect_used)]
2258mod spinner_rasterize_tests {
2259 use super::*;
2260
2261 #[test]
2262 #[cfg(feature = "lucide-icons")]
2263 fn lucide_spinner_icon_rasterizes() {
2264 let anim = spinners::lucide_spinner();
2265 if let AnimatedIcon::Frames { frames, .. } = &anim {
2266 let first = frames.first().expect("should have at least one frame");
2267 if let IconData::Svg(bytes) = first {
2268 let result = crate::rasterize::rasterize_svg(bytes, 24);
2269 assert!(result.is_ok(), "lucide loader should rasterize");
2270 if let Ok(IconData::Rgba { data, .. }) = &result {
2271 assert!(
2272 data.iter().any(|&b| b != 0),
2273 "lucide loader rasterized to empty image"
2274 );
2275 }
2276 } else {
2277 panic!("lucide spinner frame should be Svg");
2278 }
2279 } else {
2280 panic!("lucide spinner should be Frames");
2281 }
2282 }
2283}
2284
2285#[cfg(test)]
2286#[allow(
2287 clippy::unwrap_used,
2288 clippy::expect_used,
2289 clippy::field_reassign_with_default
2290)]
2291mod system_theme_tests {
2292 use super::*;
2293
2294 #[test]
2297 fn test_system_theme_active_dark() {
2298 let preset = ThemeSpec::preset("catppuccin-mocha").unwrap();
2299 let mut light_v = preset.light.clone().unwrap();
2300 let mut dark_v = preset.dark.clone().unwrap();
2301 light_v.defaults.accent_color = Some(Rgba::rgb(0, 0, 255));
2303 dark_v.defaults.accent_color = Some(Rgba::rgb(255, 0, 0));
2304 light_v.resolve_all();
2305 dark_v.resolve_all();
2306 let light_resolved = light_v.validate().unwrap();
2307 let dark_resolved = dark_v.validate().unwrap();
2308
2309 let st = SystemTheme {
2310 name: "test".into(),
2311 is_dark: true,
2312 light: light_resolved.clone(),
2313 dark: dark_resolved.clone(),
2314 light_variant: preset.light.unwrap(),
2315 dark_variant: preset.dark.unwrap(),
2316 live_preset: "catppuccin-mocha".into(),
2317 preset: "catppuccin-mocha".into(),
2318 };
2319 assert_eq!(
2320 st.active().defaults.accent_color,
2321 dark_resolved.defaults.accent_color
2322 );
2323 }
2324
2325 #[test]
2326 fn test_system_theme_active_light() {
2327 let preset = ThemeSpec::preset("catppuccin-mocha").unwrap();
2328 let mut light_v = preset.light.clone().unwrap();
2329 let mut dark_v = preset.dark.clone().unwrap();
2330 light_v.defaults.accent_color = Some(Rgba::rgb(0, 0, 255));
2331 dark_v.defaults.accent_color = Some(Rgba::rgb(255, 0, 0));
2332 light_v.resolve_all();
2333 dark_v.resolve_all();
2334 let light_resolved = light_v.validate().unwrap();
2335 let dark_resolved = dark_v.validate().unwrap();
2336
2337 let st = SystemTheme {
2338 name: "test".into(),
2339 is_dark: false,
2340 light: light_resolved.clone(),
2341 dark: dark_resolved.clone(),
2342 light_variant: preset.light.unwrap(),
2343 dark_variant: preset.dark.unwrap(),
2344 live_preset: "catppuccin-mocha".into(),
2345 preset: "catppuccin-mocha".into(),
2346 };
2347 assert_eq!(
2348 st.active().defaults.accent_color,
2349 light_resolved.defaults.accent_color
2350 );
2351 }
2352
2353 #[test]
2354 fn test_system_theme_pick() {
2355 let preset = ThemeSpec::preset("catppuccin-mocha").unwrap();
2356 let mut light_v = preset.light.clone().unwrap();
2357 let mut dark_v = preset.dark.clone().unwrap();
2358 light_v.defaults.accent_color = Some(Rgba::rgb(0, 0, 255));
2359 dark_v.defaults.accent_color = Some(Rgba::rgb(255, 0, 0));
2360 light_v.resolve_all();
2361 dark_v.resolve_all();
2362 let light_resolved = light_v.validate().unwrap();
2363 let dark_resolved = dark_v.validate().unwrap();
2364
2365 let st = SystemTheme {
2366 name: "test".into(),
2367 is_dark: false,
2368 light: light_resolved.clone(),
2369 dark: dark_resolved.clone(),
2370 light_variant: preset.light.unwrap(),
2371 dark_variant: preset.dark.unwrap(),
2372 live_preset: "catppuccin-mocha".into(),
2373 preset: "catppuccin-mocha".into(),
2374 };
2375 assert_eq!(
2376 st.pick(true).defaults.accent_color,
2377 dark_resolved.defaults.accent_color
2378 );
2379 assert_eq!(
2380 st.pick(false).defaults.accent_color,
2381 light_resolved.defaults.accent_color
2382 );
2383 }
2384
2385 #[test]
2388 #[cfg(target_os = "linux")]
2389 #[allow(unsafe_code)]
2390 fn test_platform_preset_name_kde() {
2391 let _guard = crate::ENV_MUTEX.lock().unwrap();
2392 unsafe { std::env::set_var("XDG_CURRENT_DESKTOP", "KDE") };
2393 let name = platform_preset_name();
2394 unsafe { std::env::remove_var("XDG_CURRENT_DESKTOP") };
2395 assert_eq!(name, "kde-breeze-live");
2396 }
2397
2398 #[test]
2399 #[cfg(target_os = "linux")]
2400 #[allow(unsafe_code)]
2401 fn test_platform_preset_name_gnome() {
2402 let _guard = crate::ENV_MUTEX.lock().unwrap();
2403 unsafe { std::env::set_var("XDG_CURRENT_DESKTOP", "GNOME") };
2404 let name = platform_preset_name();
2405 unsafe { std::env::remove_var("XDG_CURRENT_DESKTOP") };
2406 assert_eq!(name, "adwaita-live");
2407 }
2408
2409 #[test]
2412 fn test_run_pipeline_produces_both_variants() {
2413 let reader = ThemeSpec::preset("catppuccin-mocha").unwrap();
2414 let result = run_pipeline(reader, "catppuccin-mocha", false);
2415 assert!(result.is_ok(), "run_pipeline should succeed");
2416 let st = result.unwrap();
2417 assert!(!st.name.is_empty(), "name should be populated");
2419 }
2421
2422 #[test]
2423 fn test_run_pipeline_reader_values_win() {
2424 let custom_accent = Rgba::rgb(42, 100, 200);
2426 let mut reader = ThemeSpec::default();
2427 reader.name = "CustomTheme".into();
2428 let mut variant = ThemeVariant::default();
2429 variant.defaults.accent_color = Some(custom_accent);
2430 reader.light = Some(variant);
2431
2432 let result = run_pipeline(reader, "catppuccin-mocha", false);
2433 assert!(result.is_ok(), "run_pipeline should succeed");
2434 let st = result.unwrap();
2435 assert_eq!(
2437 st.light.defaults.accent_color, custom_accent,
2438 "reader accent should win over preset accent"
2439 );
2440 assert_eq!(st.name, "CustomTheme", "reader name should win");
2441 }
2442
2443 #[test]
2444 fn test_run_pipeline_single_variant() {
2445 let full = ThemeSpec::preset("kde-breeze").unwrap();
2449 let mut reader = ThemeSpec::default();
2450 let mut dark_v = full.dark.clone().unwrap();
2451 dark_v.defaults.accent_color = Some(Rgba::rgb(200, 50, 50));
2453 reader.dark = Some(dark_v);
2454 reader.light = None;
2455
2456 let result = run_pipeline(reader, "kde-breeze-live", true);
2457 assert!(
2458 result.is_ok(),
2459 "run_pipeline should succeed with single variant"
2460 );
2461 let st = result.unwrap();
2462 assert_eq!(
2464 st.dark.defaults.accent_color,
2465 Rgba::rgb(200, 50, 50),
2466 "dark variant should have reader accent"
2467 );
2468 assert_eq!(st.live_preset, "kde-breeze-live");
2471 assert_eq!(st.preset, "kde-breeze");
2472 }
2473
2474 #[test]
2475 fn test_run_pipeline_inactive_variant_from_full_preset() {
2476 let full = ThemeSpec::preset("kde-breeze").unwrap();
2479 let mut reader = ThemeSpec::default();
2480 reader.dark = Some(full.dark.clone().unwrap());
2481 reader.light = None;
2482
2483 let st = run_pipeline(reader, "kde-breeze-live", true).unwrap();
2484
2485 let full_light = full.light.unwrap();
2487 assert_eq!(
2488 st.light.defaults.accent_color,
2489 full_light.defaults.accent_color.unwrap(),
2490 "inactive light variant should get accent from full preset"
2491 );
2492 assert_eq!(
2493 st.light.defaults.background_color,
2494 full_light.defaults.background_color.unwrap(),
2495 "inactive light variant should get background from full preset"
2496 );
2497 }
2498
2499 #[test]
2502 fn test_run_pipeline_with_preset_as_reader() {
2503 let reader = ThemeSpec::preset("adwaita").unwrap();
2506 let result = run_pipeline(reader, "adwaita", false);
2507 assert!(
2508 result.is_ok(),
2509 "double-merge with same preset should succeed"
2510 );
2511 let st = result.unwrap();
2512 assert_eq!(st.name, "Adwaita");
2513 }
2514
2515 #[test]
2518 fn test_run_pipeline_propagates_font_dpi_to_inactive_variant() {
2519 let mut reader = ThemeSpec::default();
2521 reader.dark = Some(ThemeVariant {
2522 defaults: ThemeDefaults {
2523 font_dpi: Some(120.0),
2524 ..Default::default()
2525 },
2526 ..Default::default()
2527 });
2528
2529 let st = run_pipeline(reader, "kde-breeze-live", true).unwrap();
2530 let resolved_size = st.light.defaults.font.size;
2543 assert!(
2544 resolved_size > 10.0,
2545 "inactive variant font size should be DPI-converted (got {resolved_size}, expected > 10.0)"
2546 );
2547 let expected = 10.0 * 120.0 / 72.0; assert!(
2550 (resolved_size - expected).abs() < 0.1,
2551 "font size should be 10pt * 120/72 = {expected:.1}px, got {resolved_size}"
2552 );
2553 }
2554
2555 #[test]
2558 fn test_reader_is_dark_only_dark() {
2559 let mut theme = ThemeSpec::default();
2560 theme.dark = Some(ThemeVariant::default());
2561 theme.light = None;
2562 assert!(
2563 reader_is_dark(&theme),
2564 "should be true when only dark is set"
2565 );
2566 }
2567
2568 #[test]
2569 fn test_reader_is_dark_only_light() {
2570 let mut theme = ThemeSpec::default();
2571 theme.light = Some(ThemeVariant::default());
2572 theme.dark = None;
2573 assert!(
2574 !reader_is_dark(&theme),
2575 "should be false when only light is set"
2576 );
2577 }
2578
2579 #[test]
2580 fn test_reader_is_dark_both() {
2581 let mut theme = ThemeSpec::default();
2582 theme.light = Some(ThemeVariant::default());
2583 theme.dark = Some(ThemeVariant::default());
2584 assert!(
2585 !reader_is_dark(&theme),
2586 "should be false when both are set (macOS case)"
2587 );
2588 }
2589
2590 #[test]
2591 fn test_reader_is_dark_neither() {
2592 let theme = ThemeSpec::default();
2593 assert!(
2594 !reader_is_dark(&theme),
2595 "should be false when neither is set"
2596 );
2597 }
2598}
2599
2600#[cfg(test)]
2601#[allow(clippy::unwrap_used, clippy::expect_used)]
2602mod reduced_motion_tests {
2603 use super::*;
2604
2605 #[test]
2606 fn prefers_reduced_motion_smoke_test() {
2607 let _result = prefers_reduced_motion();
2611 }
2612
2613 #[cfg(target_os = "linux")]
2614 #[test]
2615 fn detect_reduced_motion_inner_linux() {
2616 let result = detect_reduced_motion_inner();
2620 let _ = result;
2622 }
2623
2624 #[cfg(target_os = "macos")]
2625 #[test]
2626 fn detect_reduced_motion_inner_macos() {
2627 let result = detect_reduced_motion_inner();
2628 let _ = result;
2629 }
2630
2631 #[cfg(target_os = "windows")]
2632 #[test]
2633 fn detect_reduced_motion_inner_windows() {
2634 let result = detect_reduced_motion_inner();
2635 let _ = result;
2636 }
2637}
2638
2639#[cfg(test)]
2640#[allow(clippy::unwrap_used, clippy::expect_used)]
2641mod overlay_tests {
2642 use super::*;
2643
2644 fn default_system_theme() -> SystemTheme {
2646 let reader = ThemeSpec::preset("catppuccin-mocha").unwrap();
2647 run_pipeline(reader, "catppuccin-mocha", false).unwrap()
2648 }
2649
2650 #[test]
2651 fn test_overlay_accent_propagates() {
2652 let st = default_system_theme();
2653 let new_accent = Rgba::rgb(255, 0, 0);
2654
2655 let mut overlay = ThemeSpec::default();
2657 let mut light_v = ThemeVariant::default();
2658 light_v.defaults.accent_color = Some(new_accent);
2659 let mut dark_v = ThemeVariant::default();
2660 dark_v.defaults.accent_color = Some(new_accent);
2661 overlay.light = Some(light_v);
2662 overlay.dark = Some(dark_v);
2663
2664 let result = st.with_overlay(&overlay).unwrap();
2665
2666 assert_eq!(result.light.defaults.accent_color, new_accent);
2668 assert_eq!(result.light.button.primary_background, new_accent);
2670 assert_eq!(result.light.checkbox.checked_background, new_accent);
2671 assert_eq!(result.light.slider.fill_color, new_accent);
2672 assert_eq!(result.light.progress_bar.fill_color, new_accent);
2673 assert_eq!(result.light.switch.checked_background, new_accent);
2674 assert_eq!(
2676 result.light.spinner.fill_color, new_accent,
2677 "spinner.fill should re-derive from new accent"
2678 );
2679 }
2680
2681 #[test]
2682 fn test_overlay_preserves_unrelated_fields() {
2683 let st = default_system_theme();
2684 let original_bg = st.light.defaults.background_color;
2685
2686 let mut overlay = ThemeSpec::default();
2688 let mut light_v = ThemeVariant::default();
2689 light_v.defaults.accent_color = Some(Rgba::rgb(255, 0, 0));
2690 overlay.light = Some(light_v);
2691
2692 let result = st.with_overlay(&overlay).unwrap();
2693 assert_eq!(
2694 result.light.defaults.background_color, original_bg,
2695 "background should be unchanged"
2696 );
2697 }
2698
2699 #[test]
2700 fn test_overlay_empty_noop() {
2701 let st = default_system_theme();
2702 let original_light_accent = st.light.defaults.accent_color;
2703 let original_dark_accent = st.dark.defaults.accent_color;
2704 let original_light_bg = st.light.defaults.background_color;
2705
2706 let overlay = ThemeSpec::default();
2708 let result = st.with_overlay(&overlay).unwrap();
2709
2710 assert_eq!(result.light.defaults.accent_color, original_light_accent);
2711 assert_eq!(result.dark.defaults.accent_color, original_dark_accent);
2712 assert_eq!(result.light.defaults.background_color, original_light_bg);
2713 }
2714
2715 #[test]
2716 fn test_overlay_both_variants() {
2717 let st = default_system_theme();
2718 let red = Rgba::rgb(255, 0, 0);
2719 let green = Rgba::rgb(0, 255, 0);
2720
2721 let mut overlay = ThemeSpec::default();
2722 let mut light_v = ThemeVariant::default();
2723 light_v.defaults.accent_color = Some(red);
2724 let mut dark_v = ThemeVariant::default();
2725 dark_v.defaults.accent_color = Some(green);
2726 overlay.light = Some(light_v);
2727 overlay.dark = Some(dark_v);
2728
2729 let result = st.with_overlay(&overlay).unwrap();
2730 assert_eq!(
2731 result.light.defaults.accent_color, red,
2732 "light accent = red"
2733 );
2734 assert_eq!(
2735 result.dark.defaults.accent_color, green,
2736 "dark accent = green"
2737 );
2738 }
2739
2740 #[test]
2741 fn test_overlay_font_family() {
2742 let st = default_system_theme();
2743
2744 let mut overlay = ThemeSpec::default();
2745 let mut light_v = ThemeVariant::default();
2746 light_v.defaults.font.family = Some("Comic Sans".into());
2747 overlay.light = Some(light_v);
2748
2749 let result = st.with_overlay(&overlay).unwrap();
2750 assert_eq!(result.light.defaults.font.family, "Comic Sans");
2751 }
2752
2753 #[test]
2754 fn test_overlay_toml_convenience() {
2755 let st = default_system_theme();
2756 let result = st
2757 .with_overlay_toml(
2758 r##"
2759 name = "overlay"
2760 [light.defaults]
2761 accent_color = "#ff0000"
2762 "##,
2763 )
2764 .unwrap();
2765 assert_eq!(result.light.defaults.accent_color, Rgba::rgb(255, 0, 0));
2766 }
2767}