1use arc_swap::ArcSwapOption;
4use std::sync::Arc;
5
6#[cfg(target_os = "linux")]
11const SUBPROCESS_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(2);
12
13#[cfg(target_os = "linux")]
15#[non_exhaustive]
16#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
17pub enum LinuxDesktop {
18 Kde,
20 Gnome,
22 Xfce,
24 Cinnamon,
26 Mate,
28 LxQt,
30 Budgie,
32 Hyprland,
34 Sway,
36 River,
38 Niri,
40 Wayfire,
42 CosmicDe,
44 Unknown,
46}
47
48#[cfg(target_os = "linux")]
51pub(crate) fn xdg_current_desktop() -> String {
52 std::env::var("XDG_CURRENT_DESKTOP").unwrap_or_default()
53}
54
55#[cfg(target_os = "linux")]
77#[must_use]
78pub fn detect_linux_desktop() -> LinuxDesktop {
79 parse_linux_desktop(&xdg_current_desktop())
80}
81
82#[cfg(target_os = "linux")]
88#[must_use]
89pub fn parse_linux_desktop(xdg_current_desktop: &str) -> LinuxDesktop {
90 for component in xdg_current_desktop.split(':') {
91 match component {
92 "KDE" => return LinuxDesktop::Kde,
93 "Budgie" => return LinuxDesktop::Budgie,
94 "GNOME" => return LinuxDesktop::Gnome,
95 "XFCE" => return LinuxDesktop::Xfce,
96 "X-Cinnamon" | "Cinnamon" => return LinuxDesktop::Cinnamon,
97 "MATE" => return LinuxDesktop::Mate,
98 "LXQt" => return LinuxDesktop::LxQt,
99 "Hyprland" => return LinuxDesktop::Hyprland,
100 "sway" => return LinuxDesktop::Sway,
101 "river" => return LinuxDesktop::River,
102 "niri" => return LinuxDesktop::Niri,
103 "Wayfire" => return LinuxDesktop::Wayfire,
104 "COSMIC" => return LinuxDesktop::CosmicDe,
105 _ => {}
106 }
107 }
108 LinuxDesktop::Unknown
109}
110
111#[must_use]
140pub fn system_is_dark() -> bool {
141 system().is_dark()
142}
143
144pub fn invalidate_caches() {
155 system().invalidate_all();
156}
157
158#[must_use]
166pub fn detect_is_dark() -> bool {
167 detect_is_dark_inner()
168}
169
170#[cfg(target_os = "linux")]
179fn run_gsettings_with_timeout(args: &[&str]) -> Option<String> {
180 use std::io::Read;
181 use std::time::{Duration, Instant};
182
183 let start = Instant::now();
184 let timeout = SUBPROCESS_TIMEOUT;
185 let mut child = std::process::Command::new("gsettings")
186 .args(args)
187 .stdout(std::process::Stdio::piped())
188 .stderr(std::process::Stdio::null())
189 .spawn()
190 .ok()?;
191
192 loop {
193 match child.try_wait() {
194 Ok(Some(status)) if status.success() => {
195 let mut buf = String::new();
196 if let Some(mut stdout) = child.stdout.take() {
197 let _ = stdout.read_to_string(&mut buf);
198 }
199 let trimmed = buf.trim().to_string();
200 return if trimmed.is_empty() {
201 None
202 } else {
203 Some(trimmed)
204 };
205 }
206 Ok(Some(_)) => return None,
207 Ok(None) => {
208 if start.elapsed() >= timeout {
209 let _ = child.kill();
210 return None;
211 }
212 std::thread::sleep(Duration::from_millis(50));
213 }
214 Err(_) => return None,
215 }
216 }
217}
218
219#[cfg(all(target_os = "linux", any(feature = "kde", feature = "portal")))]
224fn read_xft_dpi() -> Option<f32> {
225 use std::io::Read;
226 use std::time::{Duration, Instant};
227
228 let start = Instant::now();
229 let timeout = SUBPROCESS_TIMEOUT;
230 let mut child = std::process::Command::new("xrdb")
231 .arg("-query")
232 .stdout(std::process::Stdio::piped())
233 .stderr(std::process::Stdio::null())
234 .spawn()
235 .ok()?;
236
237 loop {
238 match child.try_wait() {
239 Ok(Some(status)) if status.success() => {
240 let mut buf = String::new();
241 if let Some(mut stdout) = child.stdout.take() {
242 let _ = stdout.read_to_string(&mut buf);
243 }
244 for line in buf.lines() {
246 if let Some(rest) = line.strip_prefix("Xft.dpi:")
247 && let Ok(dpi) = rest.trim().parse::<f32>()
248 && dpi > 0.0
249 {
250 return Some(dpi);
251 }
252 }
253 return None;
254 }
255 Ok(Some(_)) => return None,
256 Ok(None) => {
257 if start.elapsed() >= timeout {
258 let _ = child.kill();
259 return None;
260 }
261 std::thread::sleep(Duration::from_millis(50));
262 }
263 Err(_) => return None,
264 }
265 }
266}
267
268#[cfg(all(target_os = "linux", any(feature = "kde", feature = "portal")))]
278fn detect_physical_dpi() -> Option<f32> {
279 use std::io::Read;
280 use std::time::{Duration, Instant};
281
282 let start = Instant::now();
283 let timeout = SUBPROCESS_TIMEOUT;
284 let mut child = std::process::Command::new("xrandr")
285 .stdout(std::process::Stdio::piped())
286 .stderr(std::process::Stdio::null())
287 .spawn()
288 .ok()?;
289
290 loop {
291 match child.try_wait() {
292 Ok(Some(status)) if status.success() => {
293 let mut buf = String::new();
294 if let Some(mut stdout) = child.stdout.take() {
295 let _ = stdout.read_to_string(&mut buf);
296 }
297 return parse_xrandr_dpi(&buf);
298 }
299 Ok(Some(_)) => return None,
300 Ok(None) => {
301 if start.elapsed() >= timeout {
302 let _ = child.kill();
303 return None;
304 }
305 std::thread::sleep(Duration::from_millis(50));
306 }
307 Err(_) => return None,
308 }
309 }
310}
311
312#[cfg(all(target_os = "linux", any(feature = "kde", feature = "portal")))]
321fn parse_xrandr_dpi(output: &str) -> Option<f32> {
322 let line = output
324 .lines()
325 .find(|l| l.contains(" connected") && l.contains("primary"))
326 .or_else(|| {
327 output
328 .lines()
329 .find(|l| l.contains(" connected") && !l.contains("disconnected"))
330 })?;
331
332 let res_token = line
334 .split_whitespace()
335 .find(|s| s.contains('x') && s.contains('+'))?;
336 let (w_str, rest) = res_token.split_once('x')?;
337 let h_str = rest.split('+').next()?;
338 let w_px: f32 = w_str.parse().ok()?;
339 let h_px: f32 = h_str.parse().ok()?;
340
341 let words: Vec<&str> = line.split_whitespace().collect();
343 let mut w_mm = None;
344 let mut h_mm = None;
345 for window in words.windows(3) {
346 let [prev, cur, next] = window else { continue };
347 if *cur == "x" {
348 w_mm = prev.strip_suffix("mm").and_then(|n| n.parse::<f32>().ok());
349 h_mm = next.strip_suffix("mm").and_then(|n| n.parse::<f32>().ok());
350 }
351 }
352 let w_mm = w_mm.filter(|&v| v > 0.0)?;
353 let h_mm = h_mm.filter(|&v| v > 0.0)?;
354
355 let h_dpi = w_px / (w_mm / 25.4);
356 let v_dpi = h_px / (h_mm / 25.4);
357 let avg = (h_dpi + v_dpi) / 2.0;
358
359 if avg > 0.0 { Some(avg) } else { None }
360}
361
362#[cfg(all(test, target_os = "linux", any(feature = "kde", feature = "portal")))]
363#[allow(clippy::unwrap_used)]
364mod xrandr_dpi_tests {
365 use super::parse_xrandr_dpi;
366
367 #[test]
368 fn primary_4k_display() {
369 let output = "Screen 0: minimum 16 x 16, current 3840 x 2160, maximum 32767 x 32767\n\
371 DP-1 connected primary 3840x2160+0+0 (normal left inverted right x axis y axis) 700mm x 390mm\n\
372 3840x2160 60.00*+\n";
373 let dpi = parse_xrandr_dpi(output).unwrap();
374 assert!((dpi - 140.0).abs() < 1.0, "expected ~140 DPI, got {dpi}");
376 }
377
378 #[test]
379 fn standard_1080p_display() {
380 let output = "DP-2 connected primary 1920x1080+0+0 (normal) 530mm x 300mm\n";
381 let dpi = parse_xrandr_dpi(output).unwrap();
382 assert!((dpi - 92.0).abs() < 1.0, "expected ~92 DPI, got {dpi}");
384 }
385
386 #[test]
387 fn no_primary_falls_back_to_first_connected() {
388 let output = "HDMI-1 connected 1920x1080+0+0 (normal) 480mm x 270mm\n\
389 DP-1 disconnected\n";
390 let dpi = parse_xrandr_dpi(output).unwrap();
391 assert!(dpi > 90.0 && dpi < 110.0, "expected ~100 DPI, got {dpi}");
392 }
393
394 #[test]
395 fn disconnected_only_returns_none() {
396 let output = "DP-1 disconnected\nHDMI-1 disconnected\n";
397 assert!(parse_xrandr_dpi(output).is_none());
398 }
399
400 #[test]
401 fn missing_physical_dimensions_returns_none() {
402 let output = "DP-1 connected primary 1920x1080+0+0 (normal)\n";
404 assert!(parse_xrandr_dpi(output).is_none());
405 }
406
407 #[test]
408 fn zero_mm_returns_none() {
409 let output = "DP-1 connected primary 1920x1080+0+0 (normal) 0mm x 0mm\n";
410 assert!(parse_xrandr_dpi(output).is_none());
411 }
412
413 #[test]
414 fn empty_output_returns_none() {
415 assert!(parse_xrandr_dpi("").is_none());
416 }
417}
418
419#[allow(unreachable_code)]
432fn detect_system_font_dpi() -> f32 {
433 #[cfg(target_os = "macos")]
434 {
435 return 72.0;
436 }
437
438 #[cfg(all(target_os = "windows", feature = "windows"))]
439 {
440 return crate::windows::LOGICAL_DPI as f32;
441 }
442
443 #[cfg(all(target_os = "linux", feature = "kde"))]
445 {
446 if let Some(dpi) = read_kde_force_font_dpi() {
447 return dpi;
448 }
449 }
450
451 #[cfg(all(target_os = "linux", any(feature = "kde", feature = "portal")))]
452 {
453 if let Some(dpi) = read_xft_dpi() {
454 return dpi;
455 }
456 if let Some(dpi) = detect_physical_dpi() {
457 return dpi;
458 }
459 }
460
461 96.0
462}
463
464#[cfg(all(target_os = "linux", feature = "kde"))]
470fn read_kde_force_font_dpi() -> Option<f32> {
471 let path = crate::kde::kdeglobals_path();
473 if let Ok(content) = std::fs::read_to_string(&path) {
474 let mut ini = crate::kde::create_kde_parser();
475 if ini.read(content).is_ok()
476 && let Some(dpi_str) = ini.get("General", "forceFontDPI")
477 && let Ok(dpi) = dpi_str.trim().parse::<f32>()
478 && dpi > 0.0
479 {
480 return Some(dpi);
481 }
482 }
483 if let Some(dpi_str) = crate::kde::read_kcmfontsrc_key("General", "forceFontDPI")
485 && let Ok(dpi) = dpi_str.trim().parse::<f32>()
486 && dpi > 0.0
487 {
488 return Some(dpi);
489 }
490 None
491}
492
493#[allow(unreachable_code)]
497fn detect_is_dark_inner() -> bool {
498 #[cfg(target_os = "linux")]
499 {
500 if let Ok(gtk_theme) = std::env::var("GTK_THEME") {
502 let lower = gtk_theme.to_lowercase();
503 if lower.ends_with(":dark") || lower.contains("-dark") {
504 return true;
505 }
506 }
507
508 #[cfg(feature = "kde")]
511 {
512 let de = parse_linux_desktop(&xdg_current_desktop());
513 if matches!(de, LinuxDesktop::Kde) {
514 let path = crate::kde::kdeglobals_path();
515 if let Ok(content) = std::fs::read_to_string(&path) {
516 let mut ini = crate::kde::create_kde_parser();
517 if ini.read(content).is_ok() {
518 return crate::kde::is_dark_theme(&ini);
519 }
520 }
521 }
522 }
523
524 if let Some(val) =
526 run_gsettings_with_timeout(&["get", "org.gnome.desktop.interface", "color-scheme"])
527 {
528 if val.contains("prefer-dark") {
529 return true;
530 }
531 if val.contains("prefer-light") || val.contains("default") {
532 return false;
533 }
534 }
535
536 #[cfg(feature = "kde")]
540 {
541 let path = crate::kde::kdeglobals_path();
542 if let Ok(content) = std::fs::read_to_string(&path) {
543 let mut ini = crate::kde::create_kde_parser();
544 if ini.read(content).is_ok() {
545 return crate::kde::is_dark_theme(&ini);
546 }
547 }
548 }
549
550 let config_home = std::env::var("XDG_CONFIG_HOME").unwrap_or_else(|_| {
552 let home = std::env::var("HOME").unwrap_or_default();
553 format!("{home}/.config")
554 });
555 let ini_path = format!("{config_home}/gtk-3.0/settings.ini");
556 if let Ok(content) = std::fs::read_to_string(&ini_path) {
557 for line in content.lines() {
558 let trimmed = line.trim();
559 if trimmed.starts_with("gtk-application-prefer-dark-theme")
560 && let Some(val) = trimmed.split('=').nth(1)
561 && (val.trim() == "1" || val.trim().eq_ignore_ascii_case("true"))
562 {
563 return true;
564 }
565 }
566 }
567
568 false
569 }
570
571 #[cfg(target_os = "macos")]
572 {
573 #[cfg(feature = "macos")]
576 {
577 use objc2_foundation::NSUserDefaults;
578 let defaults = NSUserDefaults::standardUserDefaults();
579 let key = objc2_foundation::ns_string!("AppleInterfaceStyle");
580 if let Some(value) = defaults.stringForKey(key) {
581 return value.to_string().eq_ignore_ascii_case("dark");
582 }
583 return false;
584 }
585 #[cfg(not(feature = "macos"))]
586 {
587 if let Ok(output) = std::process::Command::new("defaults")
588 .args(["read", "-g", "AppleInterfaceStyle"])
589 .output()
590 && output.status.success()
591 {
592 let val = String::from_utf8_lossy(&output.stdout);
593 return val.trim().eq_ignore_ascii_case("dark");
594 }
595 return false;
596 }
597 }
598
599 #[cfg(target_os = "windows")]
600 {
601 #[cfg(feature = "windows")]
602 {
603 let Ok(settings) = ::windows::UI::ViewManagement::UISettings::new() else {
605 return false;
606 };
607 let Ok(fg) =
608 settings.GetColorValue(::windows::UI::ViewManagement::UIColorType::Foreground)
609 else {
610 return false;
611 };
612 let luma = 0.299 * (fg.R as f32) + 0.587 * (fg.G as f32) + 0.114 * (fg.B as f32);
613 return luma > 128.0;
614 }
615 #[cfg(not(feature = "windows"))]
616 return false;
617 }
618
619 #[cfg(not(any(target_os = "linux", target_os = "macos", target_os = "windows")))]
620 {
621 false
622 }
623}
624
625#[must_use]
656pub fn prefers_reduced_motion() -> bool {
657 system().prefers_reduced_motion()
658}
659
660#[must_use]
668pub fn detect_reduced_motion() -> bool {
669 detect_reduced_motion_inner()
670}
671
672#[allow(unreachable_code)]
676fn detect_reduced_motion_inner() -> bool {
677 #[cfg(target_os = "linux")]
678 {
679 if let Some(val) =
682 run_gsettings_with_timeout(&["get", "org.gnome.desktop.interface", "enable-animations"])
683 {
684 return val.trim() == "false";
685 }
686 false
687 }
688
689 #[cfg(target_os = "macos")]
690 {
691 #[cfg(feature = "macos")]
692 {
693 let workspace = objc2_app_kit::NSWorkspace::sharedWorkspace();
694 return workspace.accessibilityDisplayShouldReduceMotion();
696 }
697 #[cfg(not(feature = "macos"))]
698 return false;
699 }
700
701 #[cfg(target_os = "windows")]
702 {
703 #[cfg(feature = "windows")]
704 {
705 let Ok(settings) = ::windows::UI::ViewManagement::UISettings::new() else {
706 return false;
707 };
708 return match settings.AnimationsEnabled() {
710 Ok(enabled) => !enabled,
711 Err(_) => false,
712 };
713 }
714 #[cfg(not(feature = "windows"))]
715 return false;
716 }
717
718 #[cfg(not(any(target_os = "linux", target_os = "macos", target_os = "windows")))]
719 {
720 false
721 }
722}
723
724pub struct DetectionContext {
739 is_dark: ArcSwapOption<bool>,
740 reduced_motion: ArcSwapOption<bool>,
741 icon_theme: ArcSwapOption<Result<String, crate::model::icons::IconThemeFailure>>,
742 #[cfg(target_os = "linux")]
743 linux_desktop: ArcSwapOption<LinuxDesktop>,
744}
745
746impl DetectionContext {
747 fn new() -> Self {
749 Self {
750 is_dark: ArcSwapOption::empty(),
751 reduced_motion: ArcSwapOption::empty(),
752 icon_theme: ArcSwapOption::empty(),
753 #[cfg(target_os = "linux")]
754 linux_desktop: ArcSwapOption::empty(),
755 }
756 }
757
758 #[must_use]
765 pub fn is_dark(&self) -> bool {
766 if let Some(v) = self.is_dark.load().as_deref() {
767 return *v;
768 }
769 let value = detect_is_dark_inner();
770 self.is_dark.store(Some(Arc::new(value)));
771 value
772 }
773
774 #[must_use]
780 pub fn prefers_reduced_motion(&self) -> bool {
781 if let Some(v) = self.reduced_motion.load().as_deref() {
782 return *v;
783 }
784 let value = detect_reduced_motion_inner();
785 self.reduced_motion.store(Some(Arc::new(value)));
786 value
787 }
788
789 pub fn icon_theme(&self) -> crate::Result<String> {
803 self.icon_theme_with(detect_icon_theme_inner)
804 }
805
806 fn icon_theme_with(
809 &self,
810 detect: impl FnOnce() -> Result<String, crate::model::icons::IconThemeFailure>,
811 ) -> crate::Result<String> {
812 let outcome = match self.icon_theme.load_full() {
813 Some(cached) => cached,
814 None => {
815 let detected = Arc::new(detect());
816 self.icon_theme.store(Some(Arc::clone(&detected)));
817 detected
818 }
819 };
820 match outcome.as_ref() {
821 Ok(theme) => Ok(theme.clone()),
822 Err(failure) => Err(failure.to_error()),
823 }
824 }
825
826 #[cfg(target_os = "linux")]
833 #[must_use]
834 pub fn linux_desktop(&self) -> LinuxDesktop {
835 if let Some(v) = self.linux_desktop.load().as_deref() {
836 return *v;
837 }
838 let value = detect_linux_desktop();
839 self.linux_desktop.store(Some(Arc::new(value)));
840 value
841 }
842
843 pub fn invalidate_is_dark(&self) {
845 self.is_dark.store(None);
846 }
847
848 pub fn invalidate_reduced_motion(&self) {
850 self.reduced_motion.store(None);
851 }
852
853 pub fn invalidate_icon_theme(&self) {
855 self.icon_theme.store(None);
856 }
857
858 #[cfg(target_os = "linux")]
860 pub fn invalidate_linux_desktop(&self) {
861 self.linux_desktop.store(None);
862 }
863
864 pub fn invalidate_all(&self) {
866 self.invalidate_is_dark();
867 self.invalidate_reduced_motion();
868 self.invalidate_icon_theme();
869 #[cfg(target_os = "linux")]
870 self.invalidate_linux_desktop();
871 }
872}
873
874pub fn system() -> &'static DetectionContext {
879 static INSTANCE: std::sync::OnceLock<DetectionContext> = std::sync::OnceLock::new();
880 INSTANCE.get_or_init(DetectionContext::new)
881}
882
883fn detect_icon_theme_inner() -> Result<String, crate::model::icons::IconThemeFailure> {
888 crate::model::icons::detect_icon_theme_outcome()
889}
890
891#[cfg(all(target_os = "linux", feature = "portal"))]
895pub(crate) fn gsettings_get(schema: &str, key: &str) -> Option<String> {
896 run_gsettings_with_timeout(&["get", schema, key])
897}
898
899#[cfg(all(target_os = "linux", any(feature = "kde", feature = "portal")))]
901pub(crate) fn xft_dpi() -> Option<f32> {
902 read_xft_dpi()
903}
904
905#[cfg(all(target_os = "linux", any(feature = "kde", feature = "portal")))]
907pub(crate) fn physical_dpi() -> Option<f32> {
908 detect_physical_dpi()
909}
910
911pub(crate) fn system_font_dpi() -> f32 {
913 detect_system_font_dpi()
914}
915
916#[cfg(test)]
917#[allow(clippy::unwrap_used, clippy::expect_used)]
918mod reduced_motion_tests {
919 use super::*;
920
921 #[test]
922 fn prefers_reduced_motion_smoke_test() {
923 let _result = prefers_reduced_motion();
927 }
928
929 #[cfg(target_os = "linux")]
930 #[test]
931 fn detect_reduced_motion_inner_linux() {
932 let result = detect_reduced_motion_inner();
936 let _ = result;
938 }
939
940 #[cfg(target_os = "macos")]
941 #[test]
942 fn detect_reduced_motion_inner_macos() {
943 let result = detect_reduced_motion_inner();
944 let _ = result;
945 }
946
947 #[cfg(target_os = "windows")]
948 #[test]
949 fn detect_reduced_motion_inner_windows() {
950 let result = detect_reduced_motion_inner();
951 let _ = result;
952 }
953}
954
955#[cfg(test)]
956mod detection_context_tests {
957 use super::*;
958
959 #[test]
960 fn system_returns_same_instance() {
961 let a = system() as *const DetectionContext;
962 let b = system() as *const DetectionContext;
963 assert_eq!(a, b);
964 }
965
966 #[test]
967 fn is_dark_caches_result() {
968 let ctx = DetectionContext::new();
969 let first = ctx.is_dark();
970 let second = ctx.is_dark();
971 assert_eq!(first, second);
972 }
973
974 #[test]
975 fn invalidate_is_dark_clears_cache() {
976 let ctx = DetectionContext::new();
977 let _ = ctx.is_dark(); ctx.invalidate_is_dark();
979 let _ = ctx.is_dark();
981 }
982
983 #[test]
984 fn prefers_reduced_motion_caches_result() {
985 let ctx = DetectionContext::new();
986 let first = ctx.prefers_reduced_motion();
987 let second = ctx.prefers_reduced_motion();
988 assert_eq!(first, second);
989 }
990
991 #[test]
992 fn invalidate_all_clears_all_caches() {
993 let ctx = DetectionContext::new();
994 let _ = ctx.is_dark();
995 let _ = ctx.prefers_reduced_motion();
996 let _ = ctx.icon_theme();
997 ctx.invalidate_all();
998 let _ = ctx.is_dark();
1000 let _ = ctx.prefers_reduced_motion();
1001 let _ = ctx.icon_theme();
1002 }
1003
1004 fn undetected() -> Result<String, crate::model::icons::IconThemeFailure> {
1005 Err(crate::model::icons::IconThemeFailure::for_tests(
1006 "the test's failing detection",
1007 ))
1008 }
1009
1010 #[test]
1011 fn a_failed_icon_theme_detection_is_cached_and_reported() {
1012 let ctx = DetectionContext::new();
1013 let runs = std::cell::Cell::new(0);
1014 let detect = || {
1015 runs.set(runs.get() + 1);
1016 undetected()
1017 };
1018 let first = ctx.icon_theme_with(detect);
1019 let second = ctx.icon_theme_with(detect);
1020 assert_eq!(runs.get(), 1, "a failed detection ran again");
1021 for outcome in [first, second] {
1022 match outcome {
1023 Ok(name) => panic!("a failed detection gave the theme {name}"),
1024 Err(e) => assert!(
1025 e.to_string().contains("the test's failing detection"),
1026 "got: {e}"
1027 ),
1028 }
1029 }
1030 }
1031
1032 #[test]
1033 fn invalidate_icon_theme_clears_a_cached_failure() {
1034 let ctx = DetectionContext::new();
1035 assert!(ctx.icon_theme_with(undetected).is_err());
1036 ctx.invalidate_icon_theme();
1037 let theme = ctx.icon_theme_with(|| Ok("breeze".to_string()));
1038 assert_eq!(theme.ok().as_deref(), Some("breeze"));
1039 }
1040
1041 #[test]
1042 fn invalidate_all_clears_a_cached_failure() {
1043 let ctx = DetectionContext::new();
1044 assert!(ctx.icon_theme_with(undetected).is_err());
1045 ctx.invalidate_all();
1046 let theme = ctx.icon_theme_with(|| Ok("breeze".to_string()));
1047 assert_eq!(theme.ok().as_deref(), Some("breeze"));
1048 }
1049
1050 #[cfg(target_os = "linux")]
1051 #[test]
1052 fn linux_desktop_caches_result() {
1053 let ctx = DetectionContext::new();
1054 let first = ctx.linux_desktop();
1055 let second = ctx.linux_desktop();
1056 assert_eq!(first, second);
1057 }
1058
1059 #[cfg(target_os = "linux")]
1060 #[test]
1061 fn invalidate_linux_desktop_clears_cache() {
1062 let ctx = DetectionContext::new();
1063 let _ = ctx.linux_desktop();
1064 ctx.invalidate_linux_desktop();
1065 let _ = ctx.linux_desktop(); }
1067}