1#[cfg(target_os = "linux")]
5#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
6pub enum LinuxDesktop {
7 Kde,
9 Gnome,
11 Xfce,
13 Cinnamon,
15 Mate,
17 LxQt,
19 Budgie,
21 Unknown,
23}
24
25#[cfg(target_os = "linux")]
28pub(crate) fn xdg_current_desktop() -> String {
29 std::env::var("XDG_CURRENT_DESKTOP").unwrap_or_default()
30}
31
32#[cfg(target_os = "linux")]
38#[must_use]
39pub fn detect_linux_de(xdg_current_desktop: &str) -> LinuxDesktop {
40 for component in xdg_current_desktop.split(':') {
41 match component {
42 "KDE" => return LinuxDesktop::Kde,
43 "Budgie" => return LinuxDesktop::Budgie,
44 "GNOME" => return LinuxDesktop::Gnome,
45 "XFCE" => return LinuxDesktop::Xfce,
46 "X-Cinnamon" | "Cinnamon" => return LinuxDesktop::Cinnamon,
47 "MATE" => return LinuxDesktop::Mate,
48 "LXQt" => return LinuxDesktop::LxQt,
49 _ => {}
50 }
51 }
52 LinuxDesktop::Unknown
53}
54
55static CACHED_IS_DARK: std::sync::RwLock<Option<bool>> = std::sync::RwLock::new(None);
56
57#[must_use = "this returns whether the system uses dark mode"]
87pub fn system_is_dark() -> bool {
88 if let Ok(guard) = CACHED_IS_DARK.read()
89 && let Some(v) = *guard
90 {
91 return v;
92 }
93 let value = detect_is_dark_inner();
94 if let Ok(mut guard) = CACHED_IS_DARK.write() {
95 *guard = Some(value);
96 }
97 value
98}
99
100pub fn invalidate_caches() {
109 if let Ok(mut g) = CACHED_IS_DARK.write() {
110 *g = None;
111 }
112 if let Ok(mut g) = CACHED_REDUCED_MOTION.write() {
113 *g = None;
114 }
115 crate::model::icons::invalidate_icon_theme_cache();
116}
117
118#[must_use = "this returns whether the system uses dark mode"]
126pub fn detect_is_dark() -> bool {
127 detect_is_dark_inner()
128}
129
130#[cfg(target_os = "linux")]
139fn run_gsettings_with_timeout(args: &[&str]) -> Option<String> {
140 use std::io::Read;
141 use std::time::{Duration, Instant};
142
143 let deadline = Instant::now() + Duration::from_secs(2);
144 let mut child = std::process::Command::new("gsettings")
145 .args(args)
146 .stdout(std::process::Stdio::piped())
147 .stderr(std::process::Stdio::null())
148 .spawn()
149 .ok()?;
150
151 loop {
152 match child.try_wait() {
153 Ok(Some(status)) if status.success() => {
154 let mut buf = String::new();
155 if let Some(mut stdout) = child.stdout.take() {
156 let _ = stdout.read_to_string(&mut buf);
157 }
158 let trimmed = buf.trim().to_string();
159 return if trimmed.is_empty() {
160 None
161 } else {
162 Some(trimmed)
163 };
164 }
165 Ok(Some(_)) => return None,
166 Ok(None) => {
167 if Instant::now() >= deadline {
168 let _ = child.kill();
169 return None;
170 }
171 std::thread::sleep(Duration::from_millis(50));
172 }
173 Err(_) => return None,
174 }
175 }
176}
177
178#[cfg(all(target_os = "linux", any(feature = "kde", feature = "portal")))]
183fn read_xft_dpi() -> Option<f32> {
184 use std::io::Read;
185 use std::time::{Duration, Instant};
186
187 let deadline = Instant::now() + Duration::from_secs(2);
188 let mut child = std::process::Command::new("xrdb")
189 .arg("-query")
190 .stdout(std::process::Stdio::piped())
191 .stderr(std::process::Stdio::null())
192 .spawn()
193 .ok()?;
194
195 loop {
196 match child.try_wait() {
197 Ok(Some(status)) if status.success() => {
198 let mut buf = String::new();
199 if let Some(mut stdout) = child.stdout.take() {
200 let _ = stdout.read_to_string(&mut buf);
201 }
202 for line in buf.lines() {
204 if let Some(rest) = line.strip_prefix("Xft.dpi:")
205 && let Ok(dpi) = rest.trim().parse::<f32>()
206 && dpi > 0.0
207 {
208 return Some(dpi);
209 }
210 }
211 return None;
212 }
213 Ok(Some(_)) => return None,
214 Ok(None) => {
215 if Instant::now() >= deadline {
216 let _ = child.kill();
217 return None;
218 }
219 std::thread::sleep(Duration::from_millis(50));
220 }
221 Err(_) => return None,
222 }
223 }
224}
225
226#[cfg(all(target_os = "linux", any(feature = "kde", feature = "portal")))]
236fn detect_physical_dpi() -> Option<f32> {
237 use std::io::Read;
238 use std::time::{Duration, Instant};
239
240 let deadline = Instant::now() + Duration::from_secs(2);
241 let mut child = std::process::Command::new("xrandr")
242 .stdout(std::process::Stdio::piped())
243 .stderr(std::process::Stdio::null())
244 .spawn()
245 .ok()?;
246
247 loop {
248 match child.try_wait() {
249 Ok(Some(status)) if status.success() => {
250 let mut buf = String::new();
251 if let Some(mut stdout) = child.stdout.take() {
252 let _ = stdout.read_to_string(&mut buf);
253 }
254 return parse_xrandr_dpi(&buf);
255 }
256 Ok(Some(_)) => return None,
257 Ok(None) => {
258 if Instant::now() >= deadline {
259 let _ = child.kill();
260 return None;
261 }
262 std::thread::sleep(Duration::from_millis(50));
263 }
264 Err(_) => return None,
265 }
266 }
267}
268
269#[cfg(all(target_os = "linux", any(feature = "kde", feature = "portal")))]
278fn parse_xrandr_dpi(output: &str) -> Option<f32> {
279 let line = output
281 .lines()
282 .find(|l| l.contains(" connected") && l.contains("primary"))
283 .or_else(|| {
284 output
285 .lines()
286 .find(|l| l.contains(" connected") && !l.contains("disconnected"))
287 })?;
288
289 let res_token = line
291 .split_whitespace()
292 .find(|s| s.contains('x') && s.contains('+'))?;
293 let (w_str, rest) = res_token.split_once('x')?;
294 let h_str = rest.split('+').next()?;
295 let w_px: f32 = w_str.parse().ok()?;
296 let h_px: f32 = h_str.parse().ok()?;
297
298 let words: Vec<&str> = line.split_whitespace().collect();
300 let mut w_mm = None;
301 let mut h_mm = None;
302 for i in 1..words.len().saturating_sub(1) {
303 if words[i] == "x" {
304 w_mm = words[i - 1]
305 .strip_suffix("mm")
306 .and_then(|n| n.parse::<f32>().ok());
307 h_mm = words[i + 1]
308 .strip_suffix("mm")
309 .and_then(|n| n.parse::<f32>().ok());
310 }
311 }
312 let w_mm = w_mm.filter(|&v| v > 0.0)?;
313 let h_mm = h_mm.filter(|&v| v > 0.0)?;
314
315 let h_dpi = w_px / (w_mm / 25.4);
316 let v_dpi = h_px / (h_mm / 25.4);
317 let avg = (h_dpi + v_dpi) / 2.0;
318
319 if avg > 0.0 { Some(avg) } else { None }
320}
321
322#[cfg(all(test, target_os = "linux", any(feature = "kde", feature = "portal")))]
323#[allow(clippy::unwrap_used)]
324mod xrandr_dpi_tests {
325 use super::parse_xrandr_dpi;
326
327 #[test]
328 fn primary_4k_display() {
329 let output = "Screen 0: minimum 16 x 16, current 3840 x 2160, maximum 32767 x 32767\n\
331 DP-1 connected primary 3840x2160+0+0 (normal left inverted right x axis y axis) 700mm x 390mm\n\
332 3840x2160 60.00*+\n";
333 let dpi = parse_xrandr_dpi(output).unwrap();
334 assert!((dpi - 140.0).abs() < 1.0, "expected ~140 DPI, got {dpi}");
336 }
337
338 #[test]
339 fn standard_1080p_display() {
340 let output = "DP-2 connected primary 1920x1080+0+0 (normal) 530mm x 300mm\n";
341 let dpi = parse_xrandr_dpi(output).unwrap();
342 assert!((dpi - 92.0).abs() < 1.0, "expected ~92 DPI, got {dpi}");
344 }
345
346 #[test]
347 fn no_primary_falls_back_to_first_connected() {
348 let output = "HDMI-1 connected 1920x1080+0+0 (normal) 480mm x 270mm\n\
349 DP-1 disconnected\n";
350 let dpi = parse_xrandr_dpi(output).unwrap();
351 assert!(dpi > 90.0 && dpi < 110.0, "expected ~100 DPI, got {dpi}");
352 }
353
354 #[test]
355 fn disconnected_only_returns_none() {
356 let output = "DP-1 disconnected\nHDMI-1 disconnected\n";
357 assert!(parse_xrandr_dpi(output).is_none());
358 }
359
360 #[test]
361 fn missing_physical_dimensions_returns_none() {
362 let output = "DP-1 connected primary 1920x1080+0+0 (normal)\n";
364 assert!(parse_xrandr_dpi(output).is_none());
365 }
366
367 #[test]
368 fn zero_mm_returns_none() {
369 let output = "DP-1 connected primary 1920x1080+0+0 (normal) 0mm x 0mm\n";
370 assert!(parse_xrandr_dpi(output).is_none());
371 }
372
373 #[test]
374 fn empty_output_returns_none() {
375 assert!(parse_xrandr_dpi("").is_none());
376 }
377}
378
379#[allow(unreachable_code)]
391fn detect_system_font_dpi() -> f32 {
392 #[cfg(target_os = "macos")]
393 {
394 return 72.0;
395 }
396
397 #[cfg(all(target_os = "windows", feature = "windows"))]
398 {
399 return crate::windows::read_dpi() as f32;
400 }
401
402 #[cfg(all(target_os = "linux", feature = "kde"))]
404 {
405 if let Some(dpi) = read_kde_force_font_dpi() {
406 return dpi;
407 }
408 }
409
410 #[cfg(all(target_os = "linux", any(feature = "kde", feature = "portal")))]
411 {
412 if let Some(dpi) = read_xft_dpi() {
413 return dpi;
414 }
415 if let Some(dpi) = detect_physical_dpi() {
416 return dpi;
417 }
418 }
419
420 96.0
421}
422
423#[cfg(all(target_os = "linux", feature = "kde"))]
429fn read_kde_force_font_dpi() -> Option<f32> {
430 let path = crate::kde::kdeglobals_path();
432 if let Ok(content) = std::fs::read_to_string(&path) {
433 let mut ini = crate::kde::create_kde_parser();
434 if ini.read(content).is_ok()
435 && let Some(dpi_str) = ini.get("General", "forceFontDPI")
436 && let Ok(dpi) = dpi_str.trim().parse::<f32>()
437 && dpi > 0.0
438 {
439 return Some(dpi);
440 }
441 }
442 if let Some(dpi_str) = crate::kde::read_kcmfontsrc_key("General", "forceFontDPI")
444 && let Ok(dpi) = dpi_str.trim().parse::<f32>()
445 && dpi > 0.0
446 {
447 return Some(dpi);
448 }
449 None
450}
451
452#[allow(unreachable_code)]
456fn detect_is_dark_inner() -> bool {
457 #[cfg(target_os = "linux")]
458 {
459 if let Ok(gtk_theme) = std::env::var("GTK_THEME") {
461 let lower = gtk_theme.to_lowercase();
462 if lower.ends_with(":dark") || lower.contains("-dark") {
463 return true;
464 }
465 }
466
467 #[cfg(feature = "kde")]
470 {
471 let de = detect_linux_de(&xdg_current_desktop());
472 if matches!(de, LinuxDesktop::Kde) {
473 let path = crate::kde::kdeglobals_path();
474 if let Ok(content) = std::fs::read_to_string(&path) {
475 let mut ini = crate::kde::create_kde_parser();
476 if ini.read(content).is_ok() {
477 return crate::kde::is_dark_theme(&ini);
478 }
479 }
480 }
481 }
482
483 if let Some(val) =
485 run_gsettings_with_timeout(&["get", "org.gnome.desktop.interface", "color-scheme"])
486 {
487 if val.contains("prefer-dark") {
488 return true;
489 }
490 if val.contains("prefer-light") || val.contains("default") {
491 return false;
492 }
493 }
494
495 #[cfg(feature = "kde")]
499 {
500 let path = crate::kde::kdeglobals_path();
501 if let Ok(content) = std::fs::read_to_string(&path) {
502 let mut ini = crate::kde::create_kde_parser();
503 if ini.read(content).is_ok() {
504 return crate::kde::is_dark_theme(&ini);
505 }
506 }
507 }
508
509 let config_home = std::env::var("XDG_CONFIG_HOME").unwrap_or_else(|_| {
511 let home = std::env::var("HOME").unwrap_or_default();
512 format!("{home}/.config")
513 });
514 let ini_path = format!("{config_home}/gtk-3.0/settings.ini");
515 if let Ok(content) = std::fs::read_to_string(&ini_path) {
516 for line in content.lines() {
517 let trimmed = line.trim();
518 if trimmed.starts_with("gtk-application-prefer-dark-theme")
519 && let Some(val) = trimmed.split('=').nth(1)
520 && (val.trim() == "1" || val.trim().eq_ignore_ascii_case("true"))
521 {
522 return true;
523 }
524 }
525 }
526
527 false
528 }
529
530 #[cfg(target_os = "macos")]
531 {
532 #[cfg(feature = "macos")]
535 {
536 use objc2_foundation::NSUserDefaults;
537 let defaults = NSUserDefaults::standardUserDefaults();
538 let key = objc2_foundation::ns_string!("AppleInterfaceStyle");
539 if let Some(value) = defaults.stringForKey(key) {
540 return value.to_string().eq_ignore_ascii_case("dark");
541 }
542 return false;
543 }
544 #[cfg(not(feature = "macos"))]
545 {
546 if let Ok(output) = std::process::Command::new("defaults")
547 .args(["read", "-g", "AppleInterfaceStyle"])
548 .output()
549 && output.status.success()
550 {
551 let val = String::from_utf8_lossy(&output.stdout);
552 return val.trim().eq_ignore_ascii_case("dark");
553 }
554 return false;
555 }
556 }
557
558 #[cfg(target_os = "windows")]
559 {
560 #[cfg(feature = "windows")]
561 {
562 let Ok(settings) = ::windows::UI::ViewManagement::UISettings::new() else {
564 return false;
565 };
566 let Ok(fg) =
567 settings.GetColorValue(::windows::UI::ViewManagement::UIColorType::Foreground)
568 else {
569 return false;
570 };
571 let luma = 0.299 * (fg.R as f32) + 0.587 * (fg.G as f32) + 0.114 * (fg.B as f32);
572 return luma > 128.0;
573 }
574 #[cfg(not(feature = "windows"))]
575 return false;
576 }
577
578 #[cfg(not(any(target_os = "linux", target_os = "macos", target_os = "windows")))]
579 {
580 false
581 }
582}
583
584static CACHED_REDUCED_MOTION: std::sync::RwLock<Option<bool>> = std::sync::RwLock::new(None);
585
586#[must_use = "this returns whether reduced motion is preferred"]
617pub fn prefers_reduced_motion() -> bool {
618 if let Ok(guard) = CACHED_REDUCED_MOTION.read()
619 && let Some(v) = *guard
620 {
621 return v;
622 }
623 let value = detect_reduced_motion_inner();
624 if let Ok(mut guard) = CACHED_REDUCED_MOTION.write() {
625 *guard = Some(value);
626 }
627 value
628}
629
630#[must_use = "this returns whether reduced motion is preferred"]
638pub fn detect_reduced_motion() -> bool {
639 detect_reduced_motion_inner()
640}
641
642#[allow(unreachable_code)]
646fn detect_reduced_motion_inner() -> bool {
647 #[cfg(target_os = "linux")]
648 {
649 if let Some(val) =
652 run_gsettings_with_timeout(&["get", "org.gnome.desktop.interface", "enable-animations"])
653 {
654 return val.trim() == "false";
655 }
656 false
657 }
658
659 #[cfg(target_os = "macos")]
660 {
661 #[cfg(feature = "macos")]
662 {
663 let workspace = objc2_app_kit::NSWorkspace::sharedWorkspace();
664 return workspace.accessibilityDisplayShouldReduceMotion();
666 }
667 #[cfg(not(feature = "macos"))]
668 return false;
669 }
670
671 #[cfg(target_os = "windows")]
672 {
673 #[cfg(feature = "windows")]
674 {
675 let Ok(settings) = ::windows::UI::ViewManagement::UISettings::new() else {
676 return false;
677 };
678 return match settings.AnimationsEnabled() {
680 Ok(enabled) => !enabled,
681 Err(_) => false,
682 };
683 }
684 #[cfg(not(feature = "windows"))]
685 return false;
686 }
687
688 #[cfg(not(any(target_os = "linux", target_os = "macos", target_os = "windows")))]
689 {
690 false
691 }
692}
693
694#[cfg(all(target_os = "linux", feature = "portal"))]
698pub(crate) fn gsettings_get(schema: &str, key: &str) -> Option<String> {
699 run_gsettings_with_timeout(&["get", schema, key])
700}
701
702#[cfg(all(target_os = "linux", any(feature = "kde", feature = "portal")))]
704pub(crate) fn xft_dpi() -> Option<f32> {
705 read_xft_dpi()
706}
707
708#[cfg(all(target_os = "linux", any(feature = "kde", feature = "portal")))]
710pub(crate) fn physical_dpi() -> Option<f32> {
711 detect_physical_dpi()
712}
713
714pub(crate) fn system_font_dpi() -> f32 {
716 detect_system_font_dpi()
717}
718
719#[cfg(test)]
720#[allow(clippy::unwrap_used, clippy::expect_used)]
721mod reduced_motion_tests {
722 use super::*;
723
724 #[test]
725 fn prefers_reduced_motion_smoke_test() {
726 let _result = prefers_reduced_motion();
730 }
731
732 #[cfg(target_os = "linux")]
733 #[test]
734 fn detect_reduced_motion_inner_linux() {
735 let result = detect_reduced_motion_inner();
739 let _ = result;
741 }
742
743 #[cfg(target_os = "macos")]
744 #[test]
745 fn detect_reduced_motion_inner_macos() {
746 let result = detect_reduced_motion_inner();
747 let _ = result;
748 }
749
750 #[cfg(target_os = "windows")]
751 #[test]
752 fn detect_reduced_motion_inner_windows() {
753 let result = detect_reduced_motion_inner();
754 let _ = result;
755 }
756}