Skip to main content

native_theme/
detect.rs

1//! OS detection: dark mode, reduced motion, DPI, desktop environment.
2
3use arc_swap::ArcSwapOption;
4use std::sync::Arc;
5
6/// Timeout for subprocess commands (gsettings, xrdb, xrandr).
7///
8/// Prevents D-Bus-dependent tools from blocking indefinitely when the
9/// session bus is unresponsive or the display server is unavailable.
10#[cfg(target_os = "linux")]
11const SUBPROCESS_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(2);
12
13/// Desktop environments recognized on Linux.
14#[cfg(target_os = "linux")]
15#[non_exhaustive]
16#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
17pub enum LinuxDesktop {
18    /// KDE Plasma desktop.
19    Kde,
20    /// GNOME desktop.
21    Gnome,
22    /// Xfce desktop.
23    Xfce,
24    /// Cinnamon desktop (Linux Mint).
25    Cinnamon,
26    /// MATE desktop.
27    Mate,
28    /// LXQt desktop.
29    LxQt,
30    /// Budgie desktop.
31    Budgie,
32    /// Hyprland Wayland compositor.
33    Hyprland,
34    /// Sway Wayland compositor (i3-compatible).
35    Sway,
36    /// River Wayland compositor.
37    River,
38    /// Niri scrollable Wayland compositor.
39    Niri,
40    /// Wayfire scene-graph Wayland compositor (wlroots-based).
41    Wayfire,
42    /// COSMIC desktop environment (System76).
43    CosmicDe,
44    /// Unrecognized or unset desktop environment.
45    Unknown,
46}
47
48/// Read the `XDG_CURRENT_DESKTOP` environment variable, returning an
49/// empty string if unset or invalid UTF-8.
50#[cfg(target_os = "linux")]
51pub(crate) fn xdg_current_desktop() -> String {
52    std::env::var("XDG_CURRENT_DESKTOP").unwrap_or_default()
53}
54
55/// Detect the current Linux desktop environment.
56///
57/// Reads `XDG_CURRENT_DESKTOP` and returns the recognized desktop.
58/// Returns [`LinuxDesktop::Unknown`] if the variable is unset or
59/// contains no recognized value.
60///
61/// For testable parsing without environment access, use the
62/// `pub` [`parse_linux_desktop()`] function instead.
63///
64/// # Examples
65///
66/// ```no_run
67/// use native_theme::detect::{detect_linux_desktop, LinuxDesktop};
68///
69/// let de = detect_linux_desktop();
70/// match de {
71///     LinuxDesktop::Kde => println!("KDE Plasma"),
72///     LinuxDesktop::Gnome => println!("GNOME"),
73///     _ => println!("Other: {de:?}"),
74/// }
75/// ```
76#[cfg(target_os = "linux")]
77#[must_use]
78pub fn detect_linux_desktop() -> LinuxDesktop {
79    parse_linux_desktop(&xdg_current_desktop())
80}
81
82/// Parse `XDG_CURRENT_DESKTOP` (a colon-separated list) and return
83/// the recognized desktop environment.
84///
85/// Checks components in order; first recognized DE wins. Budgie is checked
86/// before GNOME because Budgie sets `Budgie:GNOME`.
87#[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/// Detect whether the system is using a dark color scheme.
112///
113/// Uses synchronous, platform-specific checks so the result is available
114/// immediately at window creation time (before any async portal response).
115///
116/// # Caching
117///
118/// The result is cached after the first call and reused on subsequent calls.
119/// Call [`invalidate_caches()`] to clear the cached value so the next call
120/// re-queries the OS. For a fresh reading without affecting the cache, use
121/// [`detect_is_dark()`] instead.
122///
123/// For live dark-mode tracking, use `watch::on_theme_change` (feature
124/// `watch`): on each event call [`invalidate_caches()`] and re-read
125/// [`crate::SystemTheme::from_system()`].
126///
127/// # Platform Behavior
128///
129/// - **Linux:** Checks `GTK_THEME` for `:dark` or `-dark`; on KDE (with the
130///   `kde` feature) reads `kdeglobals`' background luminance; then
131///   `gsettings` `color-scheme` (2-second timeout); then `kdeglobals` on
132///   other desktops (with `kde`); finally `gtk-3.0/settings.ini`
133///   `gtk-application-prefer-dark-theme`.
134/// - **macOS:** Reads `AppleInterfaceStyle` via `NSUserDefaults` (with
135///   `macos` feature) or `defaults` subprocess (without).
136/// - **Windows:** Checks foreground color luminance from `UISettings` via
137///   BT.601 coefficients (requires `windows` feature).
138/// - **Other platforms / missing features:** Returns `false` (light).
139#[must_use]
140pub fn system_is_dark() -> bool {
141    system().is_dark()
142}
143
144/// Reset all process-wide caches so the next call to [`system_is_dark()`],
145/// [`prefers_reduced_motion()`], or
146/// [`system_icon_theme()`](crate::theme::system_icon_theme) re-queries the OS; a
147/// cached failure to detect the icon theme is cleared too.
148///
149/// Call this when you detect that the user has changed system settings (e.g.,
150/// dark mode toggle, icon theme switch, accessibility preferences).
151///
152/// The `detect_*()` family of functions are unaffected — they always query
153/// the OS directly.
154pub fn invalidate_caches() {
155    system().invalidate_all();
156}
157
158/// Detect whether the system is using a dark color scheme without caching.
159///
160/// Unlike [`system_is_dark()`], this function queries the OS every time it is
161/// called and never caches the result. Use this when polling for theme changes
162/// or implementing live dark-mode tracking.
163///
164/// See [`system_is_dark()`] for platform behavior details.
165#[must_use]
166pub fn detect_is_dark() -> bool {
167    detect_is_dark_inner()
168}
169
170/// Run a gsettings command with a 2-second timeout.
171///
172/// Spawns `gsettings` with the given arguments, waits up to 2 seconds
173/// for completion, and returns the trimmed stdout on success.  Returns
174/// `None` if the command fails, times out, or produces empty output.
175///
176/// Used by [`detect_is_dark_inner()`] and [`crate::gnome::read_gsetting()`] to
177/// prevent gsettings from blocking indefinitely when D-Bus is unresponsive.
178#[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/// Read `Xft.dpi` from X resources via `xrdb -query`.
220///
221/// Returns `None` if xrdb is not installed, times out (2 seconds),
222/// or the output does not contain a valid positive `Xft.dpi` value.
223#[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                // Parse "Xft.dpi:\t96" from multi-line output
245                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/// Detect physical DPI from display hardware via `xrandr`.
269///
270/// Parses the primary connected output's resolution and physical dimensions
271/// to compute DPI. Falls back to the first connected output if no primary
272/// is found. Returns `None` if `xrandr` is unavailable, times out (2 seconds),
273/// or the output cannot be parsed.
274///
275/// This is a last-resort fallback: prefer `forceFontDPI` (KDE), `Xft.dpi`
276/// (X resources) before calling this.
277#[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/// Parse DPI from xrandr output.
313///
314/// Looks for lines like:
315/// ```text
316/// DP-1 connected primary 3840x2160+0+0 (...) 700mm x 390mm
317/// ```
318/// Extracts the current resolution from the mode string and the physical
319/// dimensions from the trailing `NNNmm x NNNmm`, then computes average DPI.
320#[cfg(all(target_os = "linux", any(feature = "kde", feature = "portal")))]
321fn parse_xrandr_dpi(output: &str) -> Option<f32> {
322    // Prefer the primary output; fall back to the first connected output.
323    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    // Resolution: "3840x2160+0+0" (digits x digits + offset)
333    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    // Physical size: "700mm x 390mm" at the end of the line
342    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        // Real xrandr output: 4K display at 700mm wide
370        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        // 3840/(700/25.4) = 139.3, 2160/(390/25.4) = 140.7, avg ~140
375        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        // 1920/(530/25.4) = 92.0, 1080/(300/25.4) = 91.4, avg ~91.7
383        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        // No "NNNmm x NNNmm" in the line
403        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/// Detect the font DPI for the current system.
420///
421/// Used by [`ThemeMode::into_resolved()`] as a fallback when no OS reader
422/// has provided `font_dpi`. Returns the platform-appropriate DPI for
423/// converting typographic points to logical pixels.
424///
425/// - **Linux, `kde` feature** (any desktop): `forceFontDPI` → `Xft.dpi` → xrandr → 96.0
426/// - **Linux, `portal` feature without `kde`**: `Xft.dpi` → xrandr → 96.0; neither: 96.0
427/// - **macOS**: 72.0 (Apple coordinate system: 1pt = 1px)
428/// - **Windows**: 96.0, the Windows reader's `font_dpi`: a point is 96/72
429///   logical (effective) pixels at any display scale
430/// - **Other**: 96.0
431#[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    // KDE: check forceFontDPI first (same chain as the KDE reader)
444    #[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/// Read KDE's `forceFontDPI` from kdeglobals or kcmfontsrc.
465///
466/// This mirrors the first step of [`crate::kde::detect_font_dpi()`] so that
467/// standalone preset loading (via [`ThemeMode::into_resolved()`]) uses the
468/// same DPI as the full KDE reader pipeline.
469#[cfg(all(target_os = "linux", feature = "kde"))]
470fn read_kde_force_font_dpi() -> Option<f32> {
471    // Try kdeglobals [General] forceFontDPI
472    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    // Try kcmfontsrc [General] forceFontDPI
484    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/// Inner detection logic for [`system_is_dark()`].
494///
495/// Separated from the public function to allow caching.
496#[allow(unreachable_code)]
497fn detect_is_dark_inner() -> bool {
498    #[cfg(target_os = "linux")]
499    {
500        // Check GTK_THEME env var (works across all GTK-based DEs)
501        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        // On KDE, read kdeglobals directly — gsettings color-scheme is
509        // synced by xdg-desktop-portal-kde and can be stale or inverted.
510        #[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        // gsettings color-scheme (reliable on GNOME / GTK-based DEs)
525        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        // Fallback: read KDE's kdeglobals background luminance (non-KDE DE
537        // or when the KDE feature is disabled and the gsettings check above
538        // returned no result).
539        #[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        // Fallback: gtk-3.0/settings.ini for DEs that set the GTK dark preference
551        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        // AppleInterfaceStyle is "Dark" when dark mode is active.
574        // The key is absent in light mode, so any failure means light.
575        #[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            // BT.601 luminance: light foreground indicates dark background.
604            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/// Query whether the user prefers reduced motion.
626///
627/// Returns `true` when the OS accessibility setting indicates animations
628/// should be reduced or disabled. Returns `false` (allow animations) on
629/// unsupported platforms or when the query fails.
630///
631/// # Caching
632///
633/// The result is cached after the first call and reused on subsequent calls.
634/// Call [`invalidate_caches()`] to clear the cached value so the next call
635/// re-queries the OS. For live accessibility-change tracking, subscribe to
636/// OS accessibility events and call `invalidate_caches()` when notified.
637///
638/// # Platform Behavior
639///
640/// - **Linux:** Queries `gsettings get org.gnome.desktop.interface enable-animations`.
641///   Returns `true` when animations are disabled (`enable-animations` is `false`).
642/// - **macOS:** Queries `NSWorkspace.accessibilityDisplayShouldReduceMotion`
643///   (requires `macos` feature).
644/// - **Windows:** Queries `UISettings.AnimationsEnabled()` (requires `windows` feature).
645/// - **Other platforms:** Returns `false`.
646///
647/// # Examples
648///
649/// ```
650/// let reduced = native_theme::detect::prefers_reduced_motion();
651/// // On this platform, the result depends on OS accessibility settings.
652/// // The function always returns a bool (false on unsupported platforms).
653/// assert!(reduced == true || reduced == false);
654/// ```
655#[must_use]
656pub fn prefers_reduced_motion() -> bool {
657    system().prefers_reduced_motion()
658}
659
660/// Detect whether the user prefers reduced motion without caching.
661///
662/// Unlike [`prefers_reduced_motion()`], this function queries the OS every time
663/// it is called and never caches the result. Use this when polling for
664/// accessibility preference changes.
665///
666/// See [`prefers_reduced_motion()`] for platform behavior details.
667#[must_use]
668pub fn detect_reduced_motion() -> bool {
669    detect_reduced_motion_inner()
670}
671
672/// Inner detection logic for [`prefers_reduced_motion()`].
673///
674/// Separated from the public function to allow caching.
675#[allow(unreachable_code)]
676fn detect_reduced_motion_inner() -> bool {
677    #[cfg(target_os = "linux")]
678    {
679        // gsettings boolean output is bare "true\n" or "false\n" (no quotes)
680        // enable-animations has INVERTED semantics: false => reduced motion preferred
681        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            // Direct semantics: true = reduce motion preferred (no inversion needed)
695            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            // AnimationsEnabled has INVERTED semantics: false => reduced motion preferred
709            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
724// === DetectionContext ===
725
726/// Process-wide detection cache.
727///
728/// Provides "cache on first read" semantics for OS detection queries
729/// (`is_dark`, `reduced_motion`, `icon_theme`, `linux_desktop`) and
730/// per-field invalidation for watchers that need fresh data.
731///
732/// Obtain the process-wide instance via [`system()`].
733///
734/// # Thread Safety
735///
736/// All reads and invalidations are lock-free (backed by
737/// [`arc_swap::ArcSwapOption`]).
738pub 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    /// Create an empty context with no cached values.
748    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    /// Whether the system is using a dark color scheme (cached).
759    ///
760    /// The first call queries the OS and caches the result.
761    /// Subsequent calls return the cached value.
762    /// Call [`invalidate_is_dark()`](Self::invalidate_is_dark) to
763    /// force a re-read on the next call.
764    #[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    /// Whether the user prefers reduced motion (cached).
775    ///
776    /// The first call queries the OS and caches the result.
777    /// Call [`invalidate_reduced_motion()`](Self::invalidate_reduced_motion)
778    /// to force a re-read.
779    #[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    /// The current icon theme name (cached), or why none was detected.
790    ///
791    /// The first call detects the theme from the OS and caches the
792    /// outcome, a failure included, so a failed detection is not
793    /// repeated on every call; subsequent calls return the cached
794    /// outcome. Call
795    /// [`invalidate_icon_theme()`](Self::invalidate_icon_theme) to
796    /// force a re-read.
797    ///
798    /// # Errors
799    ///
800    /// As [`system_icon_theme()`](crate::theme::system_icon_theme): no
801    /// theme stands in for one that could not be detected.
802    pub fn icon_theme(&self) -> crate::Result<String> {
803        self.icon_theme_with(detect_icon_theme_inner)
804    }
805
806    /// [`icon_theme()`](Self::icon_theme), with `detect` run where
807    /// nothing is cached.
808    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    /// The current Linux desktop environment (cached).
827    ///
828    /// The first call reads `XDG_CURRENT_DESKTOP` and caches the
829    /// result. Call
830    /// [`invalidate_linux_desktop()`](Self::invalidate_linux_desktop)
831    /// to force a re-read.
832    #[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    /// Clear the cached dark-mode value.
844    pub fn invalidate_is_dark(&self) {
845        self.is_dark.store(None);
846    }
847
848    /// Clear the cached reduced-motion value.
849    pub fn invalidate_reduced_motion(&self) {
850        self.reduced_motion.store(None);
851    }
852
853    /// Clear the cached icon theme, or the cached failure to detect one.
854    pub fn invalidate_icon_theme(&self) {
855        self.icon_theme.store(None);
856    }
857
858    /// Clear the cached Linux desktop environment.
859    #[cfg(target_os = "linux")]
860    pub fn invalidate_linux_desktop(&self) {
861        self.linux_desktop.store(None);
862    }
863
864    /// Clear all cached values.
865    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
874/// Return the process-wide default [`DetectionContext`].
875///
876/// This is a lazily-initialized singleton. The first call creates it;
877/// all subsequent calls return the same instance.
878pub fn system() -> &'static DetectionContext {
879    static INSTANCE: std::sync::OnceLock<DetectionContext> = std::sync::OnceLock::new();
880    INSTANCE.get_or_init(DetectionContext::new)
881}
882
883/// Inner icon theme detection, delegating to platform-specific logic.
884///
885/// On Linux, dispatches by desktop environment. On macOS/iOS/Windows,
886/// returns the compile-time constant; elsewhere, the platform's failure.
887fn detect_icon_theme_inner() -> Result<String, crate::model::icons::IconThemeFailure> {
888    crate::model::icons::detect_icon_theme_outcome()
889}
890
891// === Crate-internal accessors ===
892
893/// Run `gsettings get <schema> <key>` with timeout.
894#[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/// Read Xft.dpi from X resources.
900#[cfg(all(target_os = "linux", any(feature = "kde", feature = "portal")))]
901pub(crate) fn xft_dpi() -> Option<f32> {
902    read_xft_dpi()
903}
904
905/// Detect physical DPI from xrandr.
906#[cfg(all(target_os = "linux", any(feature = "kde", feature = "portal")))]
907pub(crate) fn physical_dpi() -> Option<f32> {
908    detect_physical_dpi()
909}
910
911/// Detect the system font DPI (combining multiple sources).
912pub(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        // Smoke test: function should not panic on any platform.
924        // Cannot assert a specific value because caching preserves the first call
925        // and CI environments have varying accessibility settings.
926        let _result = prefers_reduced_motion();
927    }
928
929    #[cfg(target_os = "linux")]
930    #[test]
931    fn detect_reduced_motion_inner_linux() {
932        // Bypass caching to test actual detection logic.
933        // On CI without gsettings, returns false (animations enabled).
934        // On developer machines, depends on accessibility settings.
935        let result = detect_reduced_motion_inner();
936        // Just verify it returns a bool without panicking.
937        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(); // populate cache
978        ctx.invalidate_is_dark();
979        // After invalidation, next call re-queries (we just verify it doesn't panic)
980        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        // Re-read all without panic
999        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(); // re-reads without panic
1066    }
1067}