1use std::fmt;
4
5#[cfg(target_os = "linux")]
6use crate::detect::{LinuxDesktop, detect_linux_desktop, parse_linux_desktop, system_is_dark};
7
8use crate::model::Theme;
9use crate::{OverlaySource, ReaderOutput, ReaderResult, SystemTheme};
10
11pub(crate) fn run_pipeline(
21 reader: ReaderResult,
22 preset_name: &str,
23 mode: crate::ColorMode,
24) -> crate::Result<SystemTheme> {
25 let ReaderResult {
26 output: reader_output,
27 name: reader_name,
28 icon_set: reader_icon_set,
29 layout: reader_layout,
30 font_dpi,
31 accessibility,
32 } = reader;
33
34 let live_preset = Theme::preset(preset_name)?;
35
36 let full_preset_name = preset_name.strip_suffix("-live").unwrap_or(preset_name);
39 debug_assert!(
40 full_preset_name != preset_name || !preset_name.ends_with("-live"),
41 "live preset '{preset_name}' should have -live suffix stripped"
42 );
43 let full_preset = Theme::preset(full_preset_name)?;
44
45 let reader_as_theme = reader_output.to_theme(&reader_name, reader_icon_set, &reader_layout);
47
48 let mut merged = full_preset.clone();
51 merged.merge(&live_preset);
52 merged.merge(&reader_as_theme);
53
54 let name = if reader_name.is_empty() {
56 merged.name.clone()
57 } else {
58 reader_name.clone()
59 };
60
61 let icon_set = merged
63 .icon_set
64 .unwrap_or_else(crate::model::icons::system_icon_set);
65
66 let mut ctx = crate::resolve::ResolutionContext::from_system();
71 if let Some(dpi) = font_dpi {
72 ctx.font_dpi = dpi;
73 }
74
75 let icon_theme_of =
85 |variant: &Option<crate::model::ThemeMode>| -> Option<std::borrow::Cow<'static, str>> {
86 variant
87 .as_ref()
88 .and_then(|v| v.defaults.icon_theme.clone()) .or_else(|| merged.icon_theme.clone()) .or_else(|| ctx.icon_theme.clone()) };
92 let light_icon_theme = icon_theme_of(&merged.light);
93 let dark_icon_theme = icon_theme_of(&merged.dark);
94 let icon_theme = match mode {
95 crate::ColorMode::Light => light_icon_theme.clone(),
96 crate::ColorMode::Dark => dark_icon_theme.clone(),
97 };
98
99 let layout = merged.layout.clone();
101
102 let (light_variant, dark_variant) = match &reader_output {
106 ReaderOutput::Single { is_dark, .. } => {
107 if *is_dark {
108 (
109 full_preset.light.unwrap_or_default(),
110 merged.dark.unwrap_or_default(),
111 )
112 } else {
113 (
114 merged.light.unwrap_or_default(),
115 full_preset.dark.unwrap_or_default(),
116 )
117 }
118 }
119 ReaderOutput::Dual { .. } => (
120 merged.light.unwrap_or_default(),
121 merged.dark.unwrap_or_default(),
122 ),
123 };
124
125 let light = light_variant.into_resolved(&ctx)?;
126 let dark = dark_variant.into_resolved(&ctx)?;
127
128 let overlay_source = OverlaySource {
130 reader_output,
131 name: reader_name,
132 icon_set: reader_icon_set,
133 layout: reader_layout,
134 preset_name: preset_name.to_string(),
135 context: ctx,
136 };
137
138 Ok(SystemTheme {
139 name,
140 mode,
141 light,
142 dark,
143 overlay_source,
144 preset: full_preset_name.to_string(),
145 live_preset: preset_name.to_string(),
146 icon_set,
147 icon_theme,
148 light_icon_theme,
149 dark_icon_theme,
150 layout,
151 accessibility,
152 })
153}
154
155#[derive(Clone, Debug, PartialEq, Eq)]
179#[non_exhaustive]
180pub enum DiagnosticEntry {
181 Platform(&'static str),
183 #[cfg(target_os = "linux")]
185 DesktopEnv(crate::detect::LinuxDesktop),
186 EnvVar {
188 name: &'static str,
190 value: String,
192 },
193 EnvVarMissing(&'static str),
195 ToolAvailable {
197 name: &'static str,
199 version: String,
201 },
202 ToolError(&'static str),
204 ToolMissing {
206 name: &'static str,
208 impact: &'static str,
210 },
211 ConfigFound {
213 name: &'static str,
215 path: std::path::PathBuf,
217 },
218 ConfigMissing {
220 name: &'static str,
222 path: std::path::PathBuf,
224 },
225 FeatureEnabled(&'static str),
227 FeatureDisabled {
229 feature: &'static str,
231 impact: &'static str,
233 },
234}
235
236impl DiagnosticEntry {
237 #[must_use]
242 pub fn name(&self) -> &str {
243 match self {
244 Self::Platform(_) => "Platform",
245 #[cfg(target_os = "linux")]
246 Self::DesktopEnv(_) => "Detected DE",
247 Self::EnvVar { name, .. } | Self::EnvVarMissing(name) => name,
248 Self::ToolAvailable { name, .. }
249 | Self::ToolError(name)
250 | Self::ToolMissing { name, .. } => name,
251 Self::ConfigFound { name, .. } | Self::ConfigMissing { name, .. } => name,
252 Self::FeatureEnabled(feature) | Self::FeatureDisabled { feature, .. } => feature,
253 }
254 }
255
256 #[must_use]
259 pub fn status(&self) -> &str {
260 match self {
261 Self::Platform(p) => p,
262 #[cfg(target_os = "linux")]
263 Self::DesktopEnv(_) => "detected",
264 Self::EnvVar { value, .. } => value.as_str(),
265 Self::EnvVarMissing(_) => "not set",
266 Self::ToolAvailable { .. } => "available",
267 Self::ToolError(_) => "found but returned error",
268 Self::ToolMissing { .. } => "not found",
269 Self::ConfigFound { .. } => "found",
270 Self::ConfigMissing { .. } => "not found",
271 Self::FeatureEnabled(_) => "enabled",
272 Self::FeatureDisabled { .. } => "disabled",
273 }
274 }
275
276 #[must_use]
278 pub fn detail(&self) -> Option<String> {
279 match self {
280 #[cfg(target_os = "linux")]
281 Self::DesktopEnv(de) => Some(format!("{de:?}")),
282 Self::ToolAvailable { version, .. } => Some(version.clone()),
283 Self::ToolMissing { impact, .. } => Some((*impact).to_string()),
284 Self::FeatureDisabled { impact, .. } => Some((*impact).to_string()),
285 Self::ConfigFound { path, .. } | Self::ConfigMissing { path, .. } => {
286 Some(path.display().to_string())
287 }
288 _ => None,
289 }
290 }
291}
292
293impl fmt::Display for DiagnosticEntry {
294 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
295 match self {
296 Self::Platform(p) => write!(f, "Platform: {p}"),
297 #[cfg(target_os = "linux")]
298 Self::DesktopEnv(de) => write!(f, "Detected DE: {de:?}"),
299 Self::EnvVar { name, value } => write!(f, "{name}: {value}"),
300 Self::EnvVarMissing(name) => write!(f, "{name}: not set"),
301 Self::ToolAvailable { name, version } => {
302 write!(f, "{name}: available ({version})")
303 }
304 Self::ToolError(name) => write!(f, "{name}: found but returned error"),
305 Self::ToolMissing { name, impact } => write!(f, "{name}: not found ({impact})"),
306 Self::ConfigFound { name, path } => {
307 write!(f, "{name}: found at {}", path.display())
308 }
309 Self::ConfigMissing { name, path } => {
310 write!(f, "{name}: not found at {}", path.display())
311 }
312 Self::FeatureEnabled(feature) => write!(f, "{feature} support: enabled"),
313 Self::FeatureDisabled { feature, impact } => {
314 write!(f, "{feature} support: disabled ({impact})")
315 }
316 }
317 }
318}
319
320#[derive(Clone, Debug, PartialEq, Eq)]
330pub struct PlatformPreset {
331 pub name: &'static str,
333 pub is_live: bool,
336}
337
338impl PlatformPreset {
339 #[must_use]
345 pub fn live_name(&self) -> String {
346 if self.is_live {
347 format!("{}-live", self.name)
348 } else {
349 self.name.to_string()
350 }
351 }
352}
353
354impl fmt::Display for PlatformPreset {
355 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
356 f.write_str(self.name)
357 }
358}
359
360#[cfg(target_os = "linux")]
369pub(crate) fn linux_preset_for_de(de: LinuxDesktop) -> PlatformPreset {
370 match de {
371 LinuxDesktop::Kde => PlatformPreset {
372 name: "kde-breeze",
373 is_live: true,
374 },
375 _ => PlatformPreset {
376 name: "adwaita",
377 is_live: true,
378 },
379 }
380}
381
382#[allow(unreachable_code)]
407#[must_use]
408pub fn platform_preset_name() -> PlatformPreset {
409 #[cfg(target_os = "macos")]
410 {
411 return PlatformPreset {
412 name: "macos-sonoma",
413 is_live: true,
414 };
415 }
416 #[cfg(target_os = "windows")]
417 {
418 return PlatformPreset {
419 name: "windows-11",
420 is_live: true,
421 };
422 }
423 #[cfg(target_os = "linux")]
424 {
425 linux_preset_for_de(detect_linux_desktop())
426 }
427 #[cfg(not(any(target_os = "linux", target_os = "windows", target_os = "macos")))]
428 {
429 PlatformPreset {
430 name: "adwaita",
431 is_live: true,
432 }
433 }
434}
435
436#[must_use]
464pub fn diagnose_platform_support() -> Vec<DiagnosticEntry> {
465 let mut diagnostics = Vec::new();
466
467 #[cfg(target_os = "linux")]
468 {
469 diagnostics.push(DiagnosticEntry::Platform("Linux"));
470
471 match std::env::var("XDG_CURRENT_DESKTOP") {
473 Ok(val) if !val.is_empty() => {
474 let de = parse_linux_desktop(&val);
475 diagnostics.push(DiagnosticEntry::EnvVar {
476 name: "XDG_CURRENT_DESKTOP",
477 value: val,
478 });
479 diagnostics.push(DiagnosticEntry::DesktopEnv(de));
480 }
481 _ => {
482 diagnostics.push(DiagnosticEntry::EnvVarMissing("XDG_CURRENT_DESKTOP"));
483 diagnostics.push(DiagnosticEntry::DesktopEnv(LinuxDesktop::Unknown));
484 }
485 }
486
487 match std::process::Command::new("gsettings")
489 .arg("--version")
490 .output()
491 {
492 Ok(output) if output.status.success() => {
493 let version = String::from_utf8_lossy(&output.stdout).trim().to_string();
494 diagnostics.push(DiagnosticEntry::ToolAvailable {
495 name: "gsettings",
496 version,
497 });
498 }
499 Ok(_) => {
500 diagnostics.push(DiagnosticEntry::ToolError("gsettings"));
501 }
502 Err(_) => {
503 diagnostics.push(DiagnosticEntry::ToolMissing {
504 name: "gsettings",
505 impact: "dark mode and icon theme detection may be limited",
506 });
507 }
508 }
509
510 #[cfg(feature = "kde")]
512 {
513 let path = crate::kde::kdeglobals_path();
514 if path.exists() {
515 diagnostics.push(DiagnosticEntry::ConfigFound {
516 name: "KDE kdeglobals",
517 path,
518 });
519 } else {
520 diagnostics.push(DiagnosticEntry::ConfigMissing {
521 name: "KDE kdeglobals",
522 path,
523 });
524 }
525 }
526
527 #[cfg(not(feature = "kde"))]
528 {
529 diagnostics.push(DiagnosticEntry::FeatureDisabled {
530 feature: "KDE",
531 impact: "kde feature not enabled",
532 });
533 }
534
535 #[cfg(feature = "portal")]
537 diagnostics.push(DiagnosticEntry::FeatureEnabled("Portal"));
538
539 #[cfg(not(feature = "portal"))]
540 diagnostics.push(DiagnosticEntry::FeatureDisabled {
541 feature: "Portal",
542 impact: "portal feature not enabled",
543 });
544 }
545
546 #[cfg(target_os = "macos")]
547 {
548 diagnostics.push(DiagnosticEntry::Platform("macOS"));
549
550 #[cfg(feature = "macos")]
551 diagnostics.push(DiagnosticEntry::FeatureEnabled("macOS theme detection"));
552
553 #[cfg(not(feature = "macos"))]
554 diagnostics.push(DiagnosticEntry::FeatureDisabled {
555 feature: "macOS theme detection",
556 impact: "macos feature not enabled, using subprocess fallback",
557 });
558 }
559
560 #[cfg(target_os = "windows")]
561 {
562 diagnostics.push(DiagnosticEntry::Platform("Windows"));
563
564 #[cfg(feature = "windows")]
565 diagnostics.push(DiagnosticEntry::FeatureEnabled("Windows theme detection"));
566
567 #[cfg(not(feature = "windows"))]
568 diagnostics.push(DiagnosticEntry::FeatureDisabled {
569 feature: "Windows theme detection",
570 impact: "windows feature not enabled",
571 });
572 }
573
574 #[cfg(not(any(target_os = "linux", target_os = "macos", target_os = "windows")))]
575 {
576 diagnostics.push(DiagnosticEntry::Platform(
577 "unsupported (no native theme detection available)",
578 ));
579 }
580
581 diagnostics
582}
583
584#[cfg(any(target_os = "linux", test))]
587fn preset_as_reader(preset_name: &str, mode: crate::ColorMode) -> crate::Result<ReaderResult> {
588 let theme = Theme::preset(preset_name)?;
589 let is_dark = mode == crate::ColorMode::Dark;
590 let output = if is_dark {
591 ReaderOutput::Single {
592 mode: Box::new(theme.dark.unwrap_or_default()),
593 is_dark: true,
594 }
595 } else {
596 ReaderOutput::Single {
597 mode: Box::new(theme.light.unwrap_or_default()),
598 is_dark: false,
599 }
600 };
601 Ok(ReaderResult {
602 output,
603 name: theme.name,
604 icon_set: theme.icon_set,
605 layout: theme.layout,
606 font_dpi: None,
607 accessibility: crate::AccessibilityPreferences::default(),
608 })
609}
610
611#[allow(unreachable_code)]
625#[allow(clippy::unnecessary_wraps)] #[allow(clippy::needless_return)] async fn select_reader() -> Option<(Box<dyn crate::reader::ThemeReader>, &'static str)> {
628 #[cfg(target_os = "macos")]
629 {
630 #[cfg(feature = "macos")]
631 {
632 return Some((Box::new(crate::macos::MacosReader), "macos-sonoma-live"));
633 }
634 #[cfg(not(feature = "macos"))]
635 return None;
636 }
637
638 #[cfg(target_os = "windows")]
639 {
640 #[cfg(feature = "windows")]
641 {
642 return Some((Box::new(crate::windows::WindowsReader), "windows-11-live"));
643 }
644 #[cfg(not(feature = "windows"))]
645 return None;
646 }
647
648 #[cfg(target_os = "linux")]
649 {
650 let de = detect_linux_desktop();
651 match de {
652 #[cfg(all(feature = "kde", feature = "portal"))]
653 LinuxDesktop::Kde => {
654 return Some((
655 Box::new(crate::gnome::GnomePortalKdeReader),
656 "kde-breeze-live",
657 ));
658 }
659 #[cfg(all(feature = "kde", not(feature = "portal")))]
660 LinuxDesktop::Kde => {
661 return Some((Box::new(crate::kde::KdeReader), "kde-breeze-live"));
662 }
663 #[cfg(not(feature = "kde"))]
664 LinuxDesktop::Kde => return None,
665 #[cfg(feature = "portal")]
666 LinuxDesktop::Gnome | LinuxDesktop::Budgie => {
667 return Some((Box::new(crate::gnome::GnomeReader), "adwaita-live"));
668 }
669 #[cfg(not(feature = "portal"))]
670 LinuxDesktop::Gnome | LinuxDesktop::Budgie => return None,
671 LinuxDesktop::Xfce
672 | LinuxDesktop::Cinnamon
673 | LinuxDesktop::Mate
674 | LinuxDesktop::LxQt
675 | LinuxDesktop::Hyprland
676 | LinuxDesktop::Sway
677 | LinuxDesktop::River
678 | LinuxDesktop::Niri
679 | LinuxDesktop::Wayfire
680 | LinuxDesktop::CosmicDe => return None,
681 LinuxDesktop::Unknown => {
682 #[cfg(feature = "portal")]
684 {
685 if let Some(detected) = crate::gnome::detect_portal_backend().await {
686 return match detected {
687 #[cfg(feature = "kde")]
688 LinuxDesktop::Kde => Some((
689 Box::new(crate::gnome::GnomePortalKdeReader),
690 "kde-breeze-live",
691 )),
692 #[cfg(not(feature = "kde"))]
693 LinuxDesktop::Kde => None,
694 LinuxDesktop::Gnome => {
695 Some((Box::new(crate::gnome::GnomeReader), "adwaita-live"))
696 }
697 _ => None,
700 };
701 }
702 }
703 #[cfg(feature = "kde")]
705 {
706 if crate::kde::kdeglobals_path().exists() {
707 return Some((Box::new(crate::kde::KdeReader), "kde-breeze-live"));
708 }
709 }
710 return None;
711 }
712 }
713 }
714
715 #[cfg(not(any(target_os = "linux", target_os = "windows", target_os = "macos")))]
716 {
717 None
718 }
719}
720
721#[allow(unreachable_code)]
734#[allow(clippy::needless_return)] pub(crate) async fn from_system_inner() -> crate::Result<SystemTheme> {
736 if let Some((reader, preset_live)) = select_reader().await {
737 let result = reader.read().await?;
738 let mode = match &result.output {
739 ReaderOutput::Single { is_dark, .. } => {
740 if *is_dark {
741 crate::ColorMode::Dark
742 } else {
743 crate::ColorMode::Light
744 }
745 }
746 ReaderOutput::Dual { .. } => crate::ColorMode::Light,
747 };
748 return run_pipeline(result, preset_live, mode);
749 }
750
751 #[cfg(target_os = "macos")]
754 {
755 return Err(crate::Error::FeatureDisabled {
756 name: "macos",
757 needed_for: "macOS theme detection",
758 });
759 }
760 #[cfg(target_os = "windows")]
761 {
762 return Err(crate::Error::FeatureDisabled {
763 name: "windows",
764 needed_for: "Windows theme detection",
765 });
766 }
767 #[cfg(target_os = "linux")]
768 {
769 let mode = if system_is_dark() {
770 crate::ColorMode::Dark
771 } else {
772 crate::ColorMode::Light
773 };
774 let preset_live = linux_preset_for_de(detect_linux_desktop()).live_name();
775 return run_pipeline(preset_as_reader("adwaita", mode)?, &preset_live, mode);
776 }
777 #[cfg(not(any(target_os = "linux", target_os = "windows", target_os = "macos")))]
778 {
779 Err(crate::Error::PlatformUnsupported {
780 platform: "unsupported",
781 })
782 }
783}
784
785pub(crate) async fn accessibility_from_system_inner() -> crate::AccessibilityPreferences {
794 let mut prefs = match select_reader().await {
795 Some((reader, _preset)) => match reader.read().await {
796 Ok(result) => result.accessibility,
797 Err(_) => crate::AccessibilityPreferences::default(),
798 },
799 None => crate::AccessibilityPreferences::default(),
800 };
801 if !prefs.reduce_motion {
802 prefs.reduce_motion = crate::detect::detect_reduced_motion();
803 }
804 prefs
805}
806
807#[cfg(all(test, target_os = "linux"))]
812#[allow(clippy::unwrap_used, clippy::expect_used)]
813mod dispatch_tests {
814 use super::*;
815
816 #[test]
819 fn detect_kde_simple() {
820 assert_eq!(parse_linux_desktop("KDE"), LinuxDesktop::Kde);
821 }
822
823 #[test]
824 fn detect_kde_colon_separated_after() {
825 assert_eq!(parse_linux_desktop("ubuntu:KDE"), LinuxDesktop::Kde);
826 }
827
828 #[test]
829 fn detect_kde_colon_separated_before() {
830 assert_eq!(parse_linux_desktop("KDE:plasma"), LinuxDesktop::Kde);
831 }
832
833 #[test]
834 fn detect_gnome_simple() {
835 assert_eq!(parse_linux_desktop("GNOME"), LinuxDesktop::Gnome);
836 }
837
838 #[test]
839 fn detect_gnome_ubuntu() {
840 assert_eq!(parse_linux_desktop("ubuntu:GNOME"), LinuxDesktop::Gnome);
841 }
842
843 #[test]
844 fn detect_xfce() {
845 assert_eq!(parse_linux_desktop("XFCE"), LinuxDesktop::Xfce);
846 }
847
848 #[test]
849 fn detect_cinnamon() {
850 assert_eq!(parse_linux_desktop("X-Cinnamon"), LinuxDesktop::Cinnamon);
851 }
852
853 #[test]
854 fn detect_cinnamon_short() {
855 assert_eq!(parse_linux_desktop("Cinnamon"), LinuxDesktop::Cinnamon);
856 }
857
858 #[test]
859 fn detect_mate() {
860 assert_eq!(parse_linux_desktop("MATE"), LinuxDesktop::Mate);
861 }
862
863 #[test]
864 fn detect_lxqt() {
865 assert_eq!(parse_linux_desktop("LXQt"), LinuxDesktop::LxQt);
866 }
867
868 #[test]
869 fn detect_budgie() {
870 assert_eq!(parse_linux_desktop("Budgie:GNOME"), LinuxDesktop::Budgie);
871 }
872
873 #[test]
874 fn detect_empty_string() {
875 assert_eq!(parse_linux_desktop(""), LinuxDesktop::Unknown);
876 }
877
878 #[test]
881 fn from_linux_non_kde_returns_adwaita() -> crate::Result<()> {
882 let preset = linux_preset_for_de(LinuxDesktop::Gnome);
884 let result = preset_as_reader("adwaita", crate::ColorMode::Light)?;
885 let theme = run_pipeline(result, &preset.live_name(), crate::ColorMode::Light)?;
886 assert_eq!(theme.name, "Adwaita");
887 Ok(())
888 }
889
890 #[test]
891 #[cfg(feature = "kde")]
892 fn from_linux_unknown_de_with_kdeglobals_fallback() -> crate::Result<()> {
893 const MINIMAL_KDE_FIXTURE: &str = "\
896[General]
897ColorScheme=TestTheme
898
899[Colors:Window]
900BackgroundNormal=239,240,241
901
902[Colors:View]
903BackgroundNormal=252,252,252
904ForegroundNormal=35,38,41
905DecorationFocus=61,174,233
906BackgroundAlternate=239,240,241
907ForegroundLink=41,128,185";
908
909 let (reader_theme, dpi, acc) =
910 crate::kde::from_kde_content_pure(MINIMAL_KDE_FIXTURE, None)?;
911 let is_dark = reader_theme.dark.is_some() && reader_theme.light.is_none();
912 let output = if is_dark {
913 ReaderOutput::Single {
914 mode: Box::new(reader_theme.dark.unwrap_or_default()),
915 is_dark: true,
916 }
917 } else {
918 ReaderOutput::Single {
919 mode: Box::new(reader_theme.light.unwrap_or_default()),
920 is_dark: false,
921 }
922 };
923 let result = ReaderResult {
924 output,
925 name: reader_theme.name,
926 icon_set: reader_theme.icon_set,
927 layout: reader_theme.layout,
928 font_dpi: dpi,
929 accessibility: acc,
930 };
931 let preset = linux_preset_for_de(LinuxDesktop::Kde);
932 let theme = run_pipeline(result, &preset.live_name(), crate::ColorMode::Light)?;
933 assert_eq!(
934 theme.name, "TestTheme",
935 "should use KDE theme name from reader output"
936 );
937 Ok(())
938 }
939
940 #[test]
941 fn from_linux_unknown_de_without_kdeglobals_returns_adwaita() -> crate::Result<()> {
942 let preset = linux_preset_for_de(LinuxDesktop::Unknown);
944 let result = preset_as_reader("adwaita", crate::ColorMode::Light)?;
945 let theme = run_pipeline(result, &preset.live_name(), crate::ColorMode::Light)?;
946 assert_eq!(
947 theme.name, "Adwaita",
948 "should fall back to Adwaita without kdeglobals"
949 );
950 Ok(())
951 }
952
953 #[test]
956 fn detect_hyprland() {
957 assert_eq!(parse_linux_desktop("Hyprland"), LinuxDesktop::Hyprland);
958 }
959
960 #[test]
961 fn detect_sway() {
962 assert_eq!(parse_linux_desktop("sway"), LinuxDesktop::Sway);
963 }
964
965 #[test]
966 fn detect_cosmic() {
967 assert_eq!(parse_linux_desktop("COSMIC"), LinuxDesktop::CosmicDe);
968 }
969
970 #[test]
971 fn detect_river() {
972 assert_eq!(parse_linux_desktop("river"), LinuxDesktop::River);
973 }
974
975 #[test]
976 fn detect_niri() {
977 assert_eq!(parse_linux_desktop("niri"), LinuxDesktop::Niri);
978 }
979
980 #[test]
981 fn parses_xdg_wayfire() {
982 assert_eq!(parse_linux_desktop("Wayfire"), LinuxDesktop::Wayfire);
983 assert_eq!(parse_linux_desktop("Wayfire:GNOME"), LinuxDesktop::Wayfire);
984 }
985
986 #[test]
987 fn parses_xdg_empty_is_unknown() {
988 assert_eq!(parse_linux_desktop(""), LinuxDesktop::Unknown);
991 }
992
993 #[test]
994 fn detect_cosmic_full_desktop() {
995 assert_eq!(
996 parse_linux_desktop("COSMIC:Freedesktop"),
997 LinuxDesktop::CosmicDe
998 );
999 }
1000
1001 #[test]
1004 fn from_system_returns_result() {
1005 let result = preset_as_reader("adwaita", crate::ColorMode::Light).unwrap();
1007 let theme = run_pipeline(result, "adwaita-live", crate::ColorMode::Light)
1008 .expect("run_pipeline should succeed with adwaita preset");
1009 assert_eq!(theme.name, "Adwaita");
1010 }
1011}
1012
1013#[cfg(test)]
1017#[allow(
1018 clippy::unwrap_used,
1019 clippy::expect_used,
1020 clippy::field_reassign_with_default
1021)]
1022mod pipeline_tests {
1023 use crate::color::Rgba;
1024 use crate::model::{LayoutTheme, Theme, ThemeMode};
1025 use crate::{ReaderOutput, ReaderResult};
1026
1027 use super::{preset_as_reader, run_pipeline};
1028
1029 fn reader_from_preset(preset_name: &str) -> ReaderResult {
1031 let preset = Theme::preset(preset_name).unwrap();
1032 ReaderResult {
1033 output: ReaderOutput::Dual {
1034 light: Box::new(preset.light.clone().unwrap_or_default()),
1035 dark: Box::new(preset.dark.clone().unwrap_or_default()),
1036 },
1037 name: preset.name,
1038 icon_set: preset.icon_set,
1039 layout: preset.layout,
1040 font_dpi: None,
1041 accessibility: crate::AccessibilityPreferences::default(),
1042 }
1043 }
1044
1045 #[test]
1048 fn test_run_pipeline_produces_both_variants() {
1049 let reader = reader_from_preset("catppuccin-mocha");
1050 let result = run_pipeline(reader, "catppuccin-mocha", crate::ColorMode::Light);
1051 assert!(result.is_ok(), "run_pipeline should succeed");
1052 let st = result.unwrap();
1053 assert!(!st.name.is_empty(), "name should be populated");
1055 }
1057
1058 #[test]
1059 fn test_run_pipeline_reader_values_win() {
1060 let custom_accent = Rgba::rgb(42, 100, 200);
1063 let mut variant = ThemeMode::default();
1064 variant.defaults.accent_color = Some(custom_accent);
1065
1066 let reader = ReaderResult {
1067 output: ReaderOutput::Single {
1068 mode: Box::new(variant),
1069 is_dark: false,
1070 },
1071 name: "CustomTheme".into(),
1072 icon_set: None,
1073 layout: LayoutTheme::default(),
1074 font_dpi: None,
1075 accessibility: crate::AccessibilityPreferences::default(),
1076 };
1077
1078 let result = run_pipeline(reader, "catppuccin-mocha", crate::ColorMode::Light);
1079 assert!(result.is_ok(), "run_pipeline should succeed");
1080 let st = result.unwrap();
1081 assert_eq!(
1083 st.light.defaults.accent_color, custom_accent,
1084 "reader accent should win over preset accent"
1085 );
1086 assert_eq!(st.name, "CustomTheme", "reader name should win");
1087 }
1088
1089 #[test]
1090 fn test_run_pipeline_single_variant() {
1091 let full = Theme::preset("kde-breeze").unwrap();
1095 let mut dark_v = full.dark.clone().unwrap();
1096 dark_v.defaults.accent_color = Some(Rgba::rgb(200, 50, 50));
1098
1099 let reader = ReaderResult {
1100 output: ReaderOutput::Single {
1101 mode: Box::new(dark_v),
1102 is_dark: true,
1103 },
1104 name: std::borrow::Cow::Borrowed(""),
1105 icon_set: None,
1106 layout: LayoutTheme::default(),
1107 font_dpi: None,
1108 accessibility: crate::AccessibilityPreferences::default(),
1109 };
1110
1111 let result = run_pipeline(reader, "kde-breeze-live", crate::ColorMode::Dark);
1112 assert!(
1113 result.is_ok(),
1114 "run_pipeline should succeed with single variant"
1115 );
1116 let st = result.unwrap();
1117 assert_eq!(
1119 st.dark.defaults.accent_color,
1120 Rgba::rgb(200, 50, 50),
1121 "dark variant should have reader accent"
1122 );
1123 assert_eq!(st.live_preset, "kde-breeze-live");
1126 assert_eq!(st.preset, "kde-breeze");
1127 }
1128
1129 #[test]
1130 fn test_run_pipeline_inactive_variant_from_full_preset() {
1131 let full = Theme::preset("kde-breeze").unwrap();
1134
1135 let reader = ReaderResult {
1136 output: ReaderOutput::Single {
1137 mode: Box::new(full.dark.clone().unwrap_or_default()),
1138 is_dark: true,
1139 },
1140 name: std::borrow::Cow::Borrowed(""),
1141 icon_set: None,
1142 layout: LayoutTheme::default(),
1143 font_dpi: None,
1144 accessibility: crate::AccessibilityPreferences::default(),
1145 };
1146
1147 let st = run_pipeline(reader, "kde-breeze-live", crate::ColorMode::Dark).unwrap();
1148
1149 let full_light = full.light.unwrap();
1151 assert_eq!(
1152 st.light.defaults.accent_color,
1153 full_light.defaults.accent_color.unwrap(),
1154 "inactive light variant should get accent from full preset"
1155 );
1156 assert_eq!(
1157 st.light.defaults.background_color,
1158 full_light.defaults.background_color.unwrap(),
1159 "inactive light variant should get background from full preset"
1160 );
1161 }
1162
1163 #[test]
1166 fn test_run_pipeline_with_preset_as_reader() {
1167 let reader = reader_from_preset("adwaita");
1170 let result = run_pipeline(reader, "adwaita", crate::ColorMode::Light);
1171 assert!(
1172 result.is_ok(),
1173 "double-merge with same preset should succeed"
1174 );
1175 let st = result.unwrap();
1176 assert_eq!(st.name, "Adwaita");
1177 }
1178
1179 #[test]
1182 fn test_single_variant_fills_inactive_from_preset() {
1183 let full = Theme::preset("kde-breeze").unwrap();
1186 let dark_v = full.dark.clone().unwrap();
1187 let reader = ReaderResult {
1188 output: ReaderOutput::Single {
1189 mode: Box::new(dark_v),
1190 is_dark: true,
1191 },
1192 name: "Breeze Dark".into(),
1193 icon_set: full.icon_set,
1194 layout: full.layout.clone(),
1195 font_dpi: None,
1196 accessibility: crate::AccessibilityPreferences::default(),
1197 };
1198
1199 let st = run_pipeline(reader, "kde-breeze-live", crate::ColorMode::Dark).unwrap();
1200
1201 let full_light = Theme::preset("kde-breeze").unwrap().light.unwrap();
1204 assert_eq!(
1205 st.light.defaults.accent_color,
1206 full_light.defaults.accent_color.unwrap(),
1207 "Single: inactive light variant should come from full preset"
1208 );
1209 assert_eq!(st.mode, crate::ColorMode::Dark);
1210 }
1211
1212 #[test]
1213 fn test_dual_variant_uses_both_from_reader() {
1214 let full = Theme::preset("macos-sonoma").unwrap();
1217 let custom_light_accent = Rgba::rgb(42, 100, 200);
1218 let custom_dark_accent = Rgba::rgb(200, 50, 50);
1219
1220 let mut light_v = full.light.clone().unwrap();
1221 light_v.defaults.accent_color = Some(custom_light_accent);
1222 let mut dark_v = full.dark.clone().unwrap();
1223 dark_v.defaults.accent_color = Some(custom_dark_accent);
1224
1225 let reader = ReaderResult {
1226 output: ReaderOutput::Dual {
1227 light: Box::new(light_v),
1228 dark: Box::new(dark_v),
1229 },
1230 name: "macOS Sonoma".into(),
1231 icon_set: full.icon_set,
1232 layout: full.layout.clone(),
1233 font_dpi: Some(72.0),
1234 accessibility: crate::AccessibilityPreferences::default(),
1235 };
1236
1237 let st = run_pipeline(reader, "macos-sonoma-live", crate::ColorMode::Light).unwrap();
1238
1239 assert_eq!(
1241 st.light.defaults.accent_color, custom_light_accent,
1242 "Dual: light variant should have reader's light accent"
1243 );
1244 assert_eq!(
1245 st.dark.defaults.accent_color, custom_dark_accent,
1246 "Dual: dark variant should have reader's dark accent"
1247 );
1248 }
1249
1250 fn reader_with_empty_variant(is_dark: bool) -> ReaderResult {
1261 ReaderResult {
1262 output: ReaderOutput::Single {
1263 mode: Box::new(ThemeMode::default()),
1264 is_dark,
1265 },
1266 name: std::borrow::Cow::Borrowed(""),
1267 icon_set: None,
1268 layout: LayoutTheme::default(),
1269 font_dpi: None,
1270 accessibility: crate::AccessibilityPreferences::default(),
1271 }
1272 }
1273
1274 #[test]
1275 fn icon_theme_tier2_theme_level_used_when_per_variant_none() {
1276 let reader = reader_with_empty_variant(false);
1281 let st = run_pipeline(reader, "adwaita", crate::ColorMode::Light).unwrap();
1282 assert_eq!(
1283 st.icon_theme.as_deref(),
1284 Some("Adwaita"),
1285 "tier 2: Theme.icon_theme should be used when per-variant is None"
1286 );
1287 }
1288
1289 #[test]
1290 fn icon_theme_tier1_per_variant_wins_over_theme_level() {
1291 let mut variant = ThemeMode::default();
1295 variant.defaults.icon_theme = Some(std::borrow::Cow::Borrowed("custom-override"));
1296 let reader = ReaderResult {
1297 output: ReaderOutput::Single {
1298 mode: Box::new(variant),
1299 is_dark: false,
1300 },
1301 name: std::borrow::Cow::Borrowed(""),
1302 icon_set: None,
1303 layout: LayoutTheme::default(),
1304 font_dpi: None,
1305 accessibility: crate::AccessibilityPreferences::default(),
1306 };
1307 let st = run_pipeline(reader, "adwaita", crate::ColorMode::Light).unwrap();
1309 assert_eq!(
1310 st.icon_theme.as_deref(),
1311 Some("custom-override"),
1312 "tier 1: per-variant icon_theme override must win over Theme-level"
1313 );
1314 }
1315
1316 #[test]
1317 fn icon_theme_kde_per_variant_values_still_win() {
1318 let kde_full = Theme::preset("kde-breeze").unwrap();
1322 let dark_v = kde_full.dark.clone().unwrap();
1324 assert_eq!(
1325 dark_v.defaults.icon_theme.as_deref(),
1326 Some("breeze-dark"),
1327 "precondition: kde-breeze dark variant must carry breeze-dark"
1328 );
1329 let reader = ReaderResult {
1330 output: ReaderOutput::Single {
1331 mode: Box::new(dark_v),
1332 is_dark: true,
1333 },
1334 name: std::borrow::Cow::Borrowed(""),
1335 icon_set: None,
1336 layout: LayoutTheme::default(),
1337 font_dpi: None,
1338 accessibility: crate::AccessibilityPreferences::default(),
1339 };
1340 let st = run_pipeline(reader, "kde-breeze-live", crate::ColorMode::Dark).unwrap();
1341 assert_eq!(
1342 st.icon_theme.as_deref(),
1343 Some("breeze-dark"),
1344 "KDE per-variant icon_theme must still win (Phase 80-fix invariant)"
1345 );
1346 }
1347
1348 #[test]
1349 fn theme_icon_theme_round_trips_when_some() {
1350 let theme = Theme {
1353 name: std::borrow::Cow::Borrowed("RoundTrip"),
1354 light: Some(ThemeMode::default()),
1355 dark: None,
1356 layout: LayoutTheme::default(),
1357 icon_set: None,
1358 icon_theme: Some(std::borrow::Cow::Borrowed("lucide")),
1359 };
1360 let toml_str = theme.to_toml().expect("serialize");
1361 let reparsed = Theme::from_toml(&toml_str).expect("deserialize");
1362 assert_eq!(
1363 reparsed.icon_theme.as_deref(),
1364 Some("lucide"),
1365 "Theme.icon_theme must round-trip through TOML"
1366 );
1367 assert!(
1369 toml_str.contains("icon_theme = \"lucide\""),
1370 "serialized TOML should contain top-level icon_theme, got:\n{toml_str}"
1371 );
1372 }
1373
1374 #[test]
1375 fn theme_icon_theme_skipped_when_none() {
1376 let theme = Theme {
1379 name: std::borrow::Cow::Borrowed("NoIcon"),
1380 light: Some(ThemeMode::default()),
1381 dark: None,
1382 layout: LayoutTheme::default(),
1383 icon_set: None,
1384 icon_theme: None,
1385 };
1386 let toml_str = theme.to_toml().expect("serialize");
1387 assert!(
1388 !toml_str.contains("icon_theme"),
1389 "serialized TOML should NOT contain icon_theme when None, got:\n{toml_str}"
1390 );
1391 }
1392
1393 #[test]
1394 fn lint_toml_accepts_top_level_icon_theme() {
1395 let toml_str = r##"
1398name = "Test"
1399icon_theme = "lucide"
1400
1401[light.defaults]
1402accent_color = "#0066cc"
1403"##;
1404 let warnings = Theme::lint_toml(toml_str).expect("lint");
1405 assert!(
1406 !warnings.iter().any(|w| w.contains("icon_theme")),
1407 "lint_toml must not flag top-level icon_theme as unknown; warnings: {warnings:?}"
1408 );
1409 }
1410
1411 #[test]
1415 fn system_theme_layout_is_the_merged_preset_layout() -> crate::Result<()> {
1416 let reader = preset_as_reader("adwaita", crate::ColorMode::Light)?;
1417 let sys = run_pipeline(reader, "adwaita-live", crate::ColorMode::Light)?;
1418
1419 let mut expected = Theme::preset("adwaita")?.layout;
1420 expected.merge(&Theme::preset("adwaita-live")?.layout);
1421 expected.merge(&Theme::preset("adwaita")?.layout);
1422
1423 assert_eq!(sys.layout, expected);
1424 assert!(
1425 sys.layout.widget_gap.is_some(),
1426 "adwaita defines all four layout keys (spec §1.3)"
1427 );
1428 Ok(())
1429 }
1430
1431 #[test]
1435 fn icon_theme_for_gives_each_variants_name() -> crate::Result<()> {
1436 for mode in [crate::ColorMode::Light, crate::ColorMode::Dark] {
1437 let reader = ReaderResult {
1440 output: ReaderOutput::Single {
1441 mode: Box::new(crate::model::ThemeMode::default()),
1442 is_dark: mode == crate::ColorMode::Dark,
1443 },
1444 name: std::borrow::Cow::Borrowed(""),
1445 icon_set: None,
1446 layout: crate::theme::LayoutTheme::default(),
1447 font_dpi: None,
1448 accessibility: crate::AccessibilityPreferences::default(),
1449 };
1450 let theme = run_pipeline(reader, "kde-breeze-live", mode)?;
1451 assert_eq!(
1452 theme.icon_theme_for(crate::ColorMode::Light),
1453 Some("breeze"),
1454 "kde-breeze.toml:9 names breeze for light (active mode {mode:?})"
1455 );
1456 assert_eq!(
1457 theme.icon_theme_for(crate::ColorMode::Dark),
1458 Some("breeze-dark"),
1459 "kde-breeze.toml:317 names breeze-dark for dark (active mode {mode:?})"
1460 );
1461 assert_eq!(
1462 theme.icon_theme_for(mode),
1463 theme.icon_theme.as_deref(),
1464 "the active mode's name is `icon_theme` itself"
1465 );
1466 let mut flipped = theme;
1468 flipped.mode = match mode {
1469 crate::ColorMode::Light => crate::ColorMode::Dark,
1470 crate::ColorMode::Dark => crate::ColorMode::Light,
1471 };
1472 assert_eq!(
1473 flipped.icon_theme_for(crate::ColorMode::Light),
1474 Some("breeze"),
1475 "built in {mode:?}, mode flipped"
1476 );
1477 assert_eq!(
1478 flipped.icon_theme_for(crate::ColorMode::Dark),
1479 Some("breeze-dark"),
1480 "built in {mode:?}, mode flipped"
1481 );
1482 }
1483 Ok(())
1484 }
1485}
1486
1487#[cfg(test)]
1505#[allow(clippy::unwrap_used, clippy::expect_used)]
1506mod theme_reader_trait_tests {
1507 #[test]
1512 fn theme_reader_trait_exists_and_is_object_safe() {
1513 #[allow(dead_code)]
1520 fn assert_object_safe<T: ?Sized>(_: &T) {}
1521
1522 #[cfg(all(target_os = "linux", feature = "kde"))]
1525 {
1526 let r: Box<dyn crate::reader::ThemeReader> = Box::new(crate::kde::KdeReader);
1527 assert_object_safe(&r);
1528 }
1529 #[cfg(all(target_os = "macos", feature = "macos"))]
1530 {
1531 let r: Box<dyn crate::reader::ThemeReader> = Box::new(crate::macos::MacosReader);
1532 assert_object_safe(&r);
1533 }
1534 #[cfg(all(target_os = "windows", feature = "windows"))]
1535 {
1536 let r: Box<dyn crate::reader::ThemeReader> = Box::new(crate::windows::WindowsReader);
1537 assert_object_safe(&r);
1538 }
1539 let _: Option<&dyn crate::reader::ThemeReader> = None;
1543 }
1544
1545 #[test]
1557 fn select_reader_returns_platform_specific_impl() {
1558 let picked: Option<(Box<dyn crate::reader::ThemeReader>, &'static str)> =
1562 pollster::block_on(super::select_reader());
1563
1564 #[cfg(all(target_os = "macos", feature = "macos"))]
1568 {
1569 assert!(
1570 picked.is_some(),
1571 "select_reader must return Some on macOS+macos"
1572 );
1573 if let Some((_reader, preset)) = &picked {
1574 assert_eq!(*preset, "macos-sonoma-live");
1575 }
1576 }
1577 #[cfg(all(target_os = "windows", feature = "windows"))]
1578 {
1579 assert!(
1580 picked.is_some(),
1581 "select_reader must return Some on windows+windows"
1582 );
1583 if let Some((_reader, preset)) = &picked {
1584 assert_eq!(*preset, "windows-11-live");
1585 }
1586 }
1587 #[cfg(all(target_os = "linux", feature = "kde", feature = "portal"))]
1588 {
1589 if let Some((_reader, preset)) = &picked {
1593 assert!(
1594 *preset == "kde-breeze-live" || *preset == "adwaita-live",
1595 "select_reader on linux must return a known -live preset, got: {preset}"
1596 );
1597 }
1598 }
1599 #[cfg(not(any(target_os = "linux", target_os = "macos", target_os = "windows")))]
1600 {
1601 assert!(
1602 picked.is_none(),
1603 "select_reader() must return None on unsupported platforms"
1604 );
1605 }
1606 drop(picked);
1607 }
1608}