Skip to main content

native_theme/
detect.rs

1//! OS detection: dark mode, reduced motion, DPI, desktop environment.
2
3/// Desktop environments recognized on Linux.
4#[cfg(target_os = "linux")]
5#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
6pub enum LinuxDesktop {
7    /// KDE Plasma desktop.
8    Kde,
9    /// GNOME desktop.
10    Gnome,
11    /// Xfce desktop.
12    Xfce,
13    /// Cinnamon desktop (Linux Mint).
14    Cinnamon,
15    /// MATE desktop.
16    Mate,
17    /// LXQt desktop.
18    LxQt,
19    /// Budgie desktop.
20    Budgie,
21    /// Unrecognized or unset desktop environment.
22    Unknown,
23}
24
25/// Read the `XDG_CURRENT_DESKTOP` environment variable, returning an
26/// empty string if unset or invalid UTF-8.
27#[cfg(target_os = "linux")]
28pub(crate) fn xdg_current_desktop() -> String {
29    std::env::var("XDG_CURRENT_DESKTOP").unwrap_or_default()
30}
31
32/// Parse `XDG_CURRENT_DESKTOP` (a colon-separated list) and return
33/// the recognized desktop environment.
34///
35/// Checks components in order; first recognized DE wins. Budgie is checked
36/// before GNOME because Budgie sets `Budgie:GNOME`.
37#[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/// Detect whether the system is using a dark color scheme.
58///
59/// Uses synchronous, platform-specific checks so the result is available
60/// immediately at window creation time (before any async portal response).
61///
62/// # Caching
63///
64/// The result is cached after the first call and reused on subsequent calls.
65/// Call [`invalidate_caches()`] to clear the cached value so the next call
66/// re-queries the OS. For a fresh reading without affecting the cache, use
67/// [`detect_is_dark()`] instead.
68///
69/// For live dark-mode tracking, subscribe to OS appearance-change events
70/// (D-Bus `SettingChanged` on Linux, `NSAppearance` KVO on macOS,
71/// `UISettings.ColorValuesChanged` on Windows) and call [`crate::SystemTheme::from_system()`]
72/// to get a fresh [`crate::SystemTheme`] with updated resolved variants.
73///
74/// # Platform Behavior
75///
76/// - **Linux:** Checks `GTK_THEME` env var for `:dark` suffix or `-dark`
77///   in name; queries `gsettings` for `color-scheme` (with 2-second
78///   timeout); falls back to KDE `kdeglobals` background luminance (with
79///   `kde` feature); reads `gtk-3.0/settings.ini` for
80///   `gtk-application-prefer-dark-theme=1` as final fallback.
81/// - **macOS:** Reads `AppleInterfaceStyle` via `NSUserDefaults` (with
82///   `macos` feature) or `defaults` subprocess (without).
83/// - **Windows:** Checks foreground color luminance from `UISettings` via
84///   BT.601 coefficients (requires `windows` feature).
85/// - **Other platforms / missing features:** Returns `false` (light).
86#[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
100/// Reset all process-wide caches so the next call to [`system_is_dark()`],
101/// [`prefers_reduced_motion()`], or [`crate::system_icon_theme()`] re-queries the OS.
102///
103/// Call this when you detect that the user has changed system settings (e.g.,
104/// dark mode toggle, icon theme switch, accessibility preferences).
105///
106/// The `detect_*()` family of functions are unaffected — they always query
107/// the OS directly.
108pub 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/// Detect whether the system is using a dark color scheme without caching.
119///
120/// Unlike [`system_is_dark()`], this function queries the OS every time it is
121/// called and never caches the result. Use this when polling for theme changes
122/// or implementing live dark-mode tracking.
123///
124/// See [`system_is_dark()`] for platform behavior details.
125#[must_use = "this returns whether the system uses dark mode"]
126pub fn detect_is_dark() -> bool {
127    detect_is_dark_inner()
128}
129
130/// Run a gsettings command with a 2-second timeout.
131///
132/// Spawns `gsettings` with the given arguments, waits up to 2 seconds
133/// for completion, and returns the trimmed stdout on success.  Returns
134/// `None` if the command fails, times out, or produces empty output.
135///
136/// Used by [`detect_is_dark_inner()`] and [`crate::gnome::read_gsetting()`] to
137/// prevent gsettings from blocking indefinitely when D-Bus is unresponsive.
138#[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/// Read `Xft.dpi` from X resources via `xrdb -query`.
179///
180/// Returns `None` if xrdb is not installed, times out (2 seconds),
181/// or the output does not contain a valid positive `Xft.dpi` value.
182#[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                // Parse "Xft.dpi:\t96" from multi-line output
203                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/// Detect physical DPI from display hardware via `xrandr`.
227///
228/// Parses the primary connected output's resolution and physical dimensions
229/// to compute DPI. Falls back to the first connected output if no primary
230/// is found. Returns `None` if `xrandr` is unavailable, times out (2 seconds),
231/// or the output cannot be parsed.
232///
233/// This is a last-resort fallback: prefer `forceFontDPI` (KDE), `Xft.dpi`
234/// (X resources), or `GetDpiForSystem` (Windows) before calling this.
235#[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/// Parse DPI from xrandr output.
270///
271/// Looks for lines like:
272/// ```text
273/// DP-1 connected primary 3840x2160+0+0 (...) 700mm x 390mm
274/// ```
275/// Extracts the current resolution from the mode string and the physical
276/// dimensions from the trailing `NNNmm x NNNmm`, then computes average DPI.
277#[cfg(all(target_os = "linux", any(feature = "kde", feature = "portal")))]
278fn parse_xrandr_dpi(output: &str) -> Option<f32> {
279    // Prefer the primary output; fall back to the first connected output.
280    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    // Resolution: "3840x2160+0+0" (digits x digits + offset)
290    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    // Physical size: "700mm x 390mm" at the end of the line
299    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        // Real xrandr output: 4K display at 700mm wide
330        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        // 3840/(700/25.4) = 139.3, 2160/(390/25.4) = 140.7, avg ~140
335        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        // 1920/(530/25.4) = 92.0, 1080/(300/25.4) = 91.4, avg ~91.7
343        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        // No "NNNmm x NNNmm" in the line
363        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/// Detect the font DPI for the current system.
380///
381/// Used by [`ThemeVariant::into_resolved()`] as a fallback when no OS reader
382/// has provided `font_dpi`. Returns the platform-appropriate DPI for
383/// converting typographic points to logical pixels.
384///
385/// - **Linux (KDE)**: `forceFontDPI` from kdeglobals/kcmfontsrc → `Xft.dpi` → xrandr → 96.0
386/// - **Linux (other)**: `Xft.dpi` → xrandr → 96.0
387/// - **macOS**: 72.0 (Apple coordinate system: 1pt = 1px)
388/// - **Windows**: `GetDpiForSystem()` → 96.0
389/// - **Other**: 96.0
390#[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    // KDE: check forceFontDPI first (same chain as the KDE reader)
403    #[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/// Read KDE's `forceFontDPI` from kdeglobals or kcmfontsrc.
424///
425/// This mirrors the first step of [`crate::kde::detect_font_dpi()`] so that
426/// standalone preset loading (via [`ThemeVariant::into_resolved()`]) uses the
427/// same DPI as the full KDE reader pipeline.
428#[cfg(all(target_os = "linux", feature = "kde"))]
429fn read_kde_force_font_dpi() -> Option<f32> {
430    // Try kdeglobals [General] forceFontDPI
431    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    // Try kcmfontsrc [General] forceFontDPI
443    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/// Inner detection logic for [`system_is_dark()`].
453///
454/// Separated from the public function to allow caching via `OnceLock`.
455#[allow(unreachable_code)]
456fn detect_is_dark_inner() -> bool {
457    #[cfg(target_os = "linux")]
458    {
459        // Check GTK_THEME env var (works across all GTK-based DEs)
460        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        // On KDE, read kdeglobals directly — gsettings color-scheme is
468        // synced by xdg-desktop-portal-kde and can be stale or inverted.
469        #[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        // gsettings color-scheme (reliable on GNOME / GTK-based DEs)
484        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        // Fallback: read KDE's kdeglobals background luminance (non-KDE DE
496        // or when the KDE feature is disabled and the gsettings check above
497        // returned no result).
498        #[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        // Fallback: gtk-3.0/settings.ini for DEs that set the GTK dark preference
510        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        // AppleInterfaceStyle is "Dark" when dark mode is active.
533        // The key is absent in light mode, so any failure means light.
534        #[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            // BT.601 luminance: light foreground indicates dark background.
563            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/// Query whether the user prefers reduced motion.
587///
588/// Returns `true` when the OS accessibility setting indicates animations
589/// should be reduced or disabled. Returns `false` (allow animations) on
590/// unsupported platforms or when the query fails.
591///
592/// # Caching
593///
594/// The result is cached after the first call and reused on subsequent calls.
595/// Call [`invalidate_caches()`] to clear the cached value so the next call
596/// re-queries the OS. For live accessibility-change tracking, subscribe to
597/// OS accessibility events and call `invalidate_caches()` when notified.
598///
599/// # Platform Behavior
600///
601/// - **Linux:** Queries `gsettings get org.gnome.desktop.interface enable-animations`.
602///   Returns `true` when animations are disabled (`enable-animations` is `false`).
603/// - **macOS:** Queries `NSWorkspace.accessibilityDisplayShouldReduceMotion`
604///   (requires `macos` feature).
605/// - **Windows:** Queries `UISettings.AnimationsEnabled()` (requires `windows` feature).
606/// - **Other platforms:** Returns `false`.
607///
608/// # Examples
609///
610/// ```
611/// let reduced = native_theme::prefers_reduced_motion();
612/// // On this platform, the result depends on OS accessibility settings.
613/// // The function always returns a bool (false on unsupported platforms).
614/// assert!(reduced == true || reduced == false);
615/// ```
616#[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/// Detect whether the user prefers reduced motion without caching.
631///
632/// Unlike [`prefers_reduced_motion()`], this function queries the OS every time
633/// it is called and never caches the result. Use this when polling for
634/// accessibility preference changes.
635///
636/// See [`prefers_reduced_motion()`] for platform behavior details.
637#[must_use = "this returns whether reduced motion is preferred"]
638pub fn detect_reduced_motion() -> bool {
639    detect_reduced_motion_inner()
640}
641
642/// Inner detection logic for [`prefers_reduced_motion()`].
643///
644/// Separated from the public function to allow caching via `OnceLock`.
645#[allow(unreachable_code)]
646fn detect_reduced_motion_inner() -> bool {
647    #[cfg(target_os = "linux")]
648    {
649        // gsettings boolean output is bare "true\n" or "false\n" (no quotes)
650        // enable-animations has INVERTED semantics: false => reduced motion preferred
651        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            // Direct semantics: true = reduce motion preferred (no inversion needed)
665            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            // AnimationsEnabled has INVERTED semantics: false => reduced motion preferred
679            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// === Crate-internal accessors ===
695
696/// Run `gsettings get <schema> <key>` with timeout.
697#[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/// Read Xft.dpi from X resources.
703#[cfg(all(target_os = "linux", any(feature = "kde", feature = "portal")))]
704pub(crate) fn xft_dpi() -> Option<f32> {
705    read_xft_dpi()
706}
707
708/// Detect physical DPI from xrandr.
709#[cfg(all(target_os = "linux", any(feature = "kde", feature = "portal")))]
710pub(crate) fn physical_dpi() -> Option<f32> {
711    detect_physical_dpi()
712}
713
714/// Detect the system font DPI (combining multiple sources).
715pub(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        // Smoke test: function should not panic on any platform.
727        // Cannot assert a specific value because OnceLock caches the first call
728        // and CI environments have varying accessibility settings.
729        let _result = prefers_reduced_motion();
730    }
731
732    #[cfg(target_os = "linux")]
733    #[test]
734    fn detect_reduced_motion_inner_linux() {
735        // Bypass OnceLock to test actual detection logic.
736        // On CI without gsettings, returns false (animations enabled).
737        // On developer machines, depends on accessibility settings.
738        let result = detect_reduced_motion_inner();
739        // Just verify it returns a bool without panicking.
740        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}