Skip to main content

teksilo_platform/
accessibility_prefs.rs

1// SPDX-License-Identifier: MPL-2.0
2// SPDX-FileCopyrightText: 2026 FernTech
3
4//! OS-level accessibility preference detection.
5//!
6//! Queries the desktop environment for user accessibility settings:
7//! high contrast, reduced motion, and text scaling. Each platform uses
8//! native APIs — no polling or runtime dependency beyond what the OS provides.
9//!
10//! # Platform support
11//!
12//! | Preference       | Linux (XDG portal / gsettings) | macOS (NSWorkspace)     | Windows (SystemParametersInfo / UISettings) |
13//! |------------------|-------------------------------|-------------------------|---------------------------------------------|
14//! | High contrast    | portal `contrast` key + GTK theme check | `accessibilityDisplayShouldIncreaseContrast` | `SPI_GETHIGHCONTRAST` |
15//! | Reduced motion   | portal `reduced-motion` key + `enable-animations` | `accessibilityDisplayShouldReduceMotion` | `UISettings.AnimationsEnabled` |
16//! | Text scale       | gsettings `text-scaling-factor` | N/A (uses DPI scaling) | `UISettings.TextScaleFactor` |
17
18/// Accessibility preferences read from the operating system.
19#[derive(Debug, Clone, PartialEq)]
20pub struct AccessibilityPreferences {
21    /// The user has enabled a high-contrast theme or mode.
22    pub high_contrast: bool,
23    /// The user has requested reduced or no animations.
24    pub reduced_motion: bool,
25    /// Text scaling factor (1.0 = normal, 1.25 = GNOME "Large Text", up to 2.25 on Windows).
26    /// On macOS this is always 1.0 — text scaling is handled via display DPI.
27    pub text_scale_factor: f64,
28}
29
30impl Default for AccessibilityPreferences {
31    fn default() -> Self {
32        Self {
33            high_contrast: false,
34            reduced_motion: false,
35            text_scale_factor: 1.0,
36        }
37    }
38}
39
40impl AccessibilityPreferences {
41    /// Query current OS accessibility preferences.
42    ///
43    /// This is a best-effort query. If a particular setting cannot be read
44    /// (missing D-Bus service, unsupported desktop, etc.), the corresponding
45    /// field falls back to its default value. Never panics.
46    pub fn query() -> Self {
47        platform::query()
48    }
49
50    /// Whether the user has requested larger text (text_scale_factor > 1.0).
51    pub fn prefers_large_text(&self) -> bool {
52        self.text_scale_factor > 1.0
53    }
54}
55
56// ── Linux: XDG Desktop Portal via busctl + gsettings subprocess ─────────────
57//
58// Uses subprocess calls (`busctl`, `gsettings`) which are present on all major
59// Linux desktops. This runs once at startup so subprocess overhead is negligible,
60// and it avoids adding zbus as a direct dependency.
61#[cfg(target_os = "linux")]
62mod platform {
63    use super::AccessibilityPreferences;
64    use crate::linux_helpers::{read_gsettings, read_portal_u32};
65
66    pub(super) fn query() -> AccessibilityPreferences {
67        let mut prefs = AccessibilityPreferences::default();
68
69        // Try XDG Desktop Portal first (works across GNOME, KDE 6.6+, Flatpak).
70        // Portal keys live under namespace "org.freedesktop.appearance".
71        if let Some(v) = read_portal_u32("org.freedesktop.appearance", "contrast") {
72            prefs.high_contrast = v == 1;
73        }
74        if let Some(v) = read_portal_u32("org.freedesktop.appearance", "reduced-motion") {
75            prefs.reduced_motion = v == 1;
76        }
77
78        // High contrast fallback: check GTK theme name for "HighContrast"
79        if !prefs.high_contrast
80            && let Some(theme) = read_gsettings("org.gnome.desktop.interface", "gtk-theme")
81        {
82            prefs.high_contrast = theme.contains("HighContrast");
83        }
84
85        // High contrast fallback: GNOME a11y interface flag
86        if !prefs.high_contrast
87            && let Some(val) = read_gsettings("org.gnome.desktop.a11y.interface", "high-contrast")
88        {
89            prefs.high_contrast = val == "true";
90        }
91
92        // Reduced motion fallback: GNOME enable-animations (false → reduced motion)
93        if !prefs.reduced_motion
94            && let Some(val) = read_gsettings("org.gnome.desktop.interface", "enable-animations")
95        {
96            prefs.reduced_motion = val == "false";
97        }
98
99        // Text scaling (not in the portal, must use gsettings)
100        if let Some(val) = read_gsettings("org.gnome.desktop.interface", "text-scaling-factor")
101            && let Ok(scale) = val.parse::<f64>()
102        {
103            prefs.text_scale_factor = scale;
104        }
105
106        prefs
107    }
108}
109
110// ── macOS: NSWorkspace accessibility APIs ───────────────────────────────────
111#[cfg(target_os = "macos")]
112mod platform {
113    use super::AccessibilityPreferences;
114
115    pub(super) fn query() -> AccessibilityPreferences {
116        let mut prefs = AccessibilityPreferences::default();
117
118        let workspace = objc2_app_kit::NSWorkspace::sharedWorkspace();
119
120        // Available since macOS 10.10
121        prefs.high_contrast = workspace.accessibilityDisplayShouldIncreaseContrast();
122
123        // Available since macOS 10.12
124        prefs.reduced_motion = workspace.accessibilityDisplayShouldReduceMotion();
125
126        // macOS has no text-scaling API separate from DPI scaling.
127        // text_scale_factor stays at 1.0 — winit's scale_factor handles DPI.
128
129        prefs
130    }
131}
132
133// ── Windows: SystemParametersInfo + WinRT UISettings ────────────────────────
134#[cfg(target_os = "windows")]
135mod platform {
136    use super::AccessibilityPreferences;
137
138    pub(super) fn query() -> AccessibilityPreferences {
139        let mut prefs = AccessibilityPreferences::default();
140
141        prefs.high_contrast = query_high_contrast();
142        let (reduced_motion, text_scale) = query_ui_settings();
143        prefs.reduced_motion = reduced_motion;
144        prefs.text_scale_factor = text_scale;
145
146        prefs
147    }
148
149    /// Query high-contrast mode via Win32 SystemParametersInfoW.
150    fn query_high_contrast() -> bool {
151        use std::mem;
152        use windows::Win32::UI::Accessibility::HIGHCONTRASTW;
153        use windows::Win32::UI::WindowsAndMessaging::{
154            SPI_GETHIGHCONTRAST, SYSTEM_PARAMETERS_INFO_UPDATE_FLAGS, SystemParametersInfoW,
155        };
156
157        unsafe {
158            let mut hc = HIGHCONTRASTW {
159                cbSize: mem::size_of::<HIGHCONTRASTW>() as u32,
160                ..Default::default()
161            };
162            let ok = SystemParametersInfoW(
163                SPI_GETHIGHCONTRAST,
164                hc.cbSize,
165                Some(&mut hc as *mut _ as *mut _),
166                SYSTEM_PARAMETERS_INFO_UPDATE_FLAGS(0),
167            );
168            if ok.is_ok() {
169                // HCF_HIGHCONTRASTON = 0x00000001
170                (hc.dwFlags.0 & 0x01) != 0
171            } else {
172                false
173            }
174        }
175    }
176
177    /// Query reduced motion and text scale via WinRT UISettings.
178    fn query_ui_settings() -> (bool, f64) {
179        use windows::UI::ViewManagement::UISettings;
180
181        let mut reduced_motion = false;
182        let mut text_scale = 1.0_f64;
183
184        if let Ok(settings) = UISettings::new() {
185            // AnimationsEnabled returns false when user has disabled animations
186            if let Ok(animations_enabled) = settings.AnimationsEnabled() {
187                reduced_motion = !animations_enabled;
188            }
189
190            // TextScaleFactor: 1.0 (100%) to 2.25 (225%)
191            if let Ok(scale) = settings.TextScaleFactor() {
192                text_scale = scale as f64;
193            }
194        }
195
196        (reduced_motion, text_scale)
197    }
198}
199
200// ── Fallback for other platforms (e.g., FreeBSD, Wasm) ──────────────────────
201#[cfg(not(any(target_os = "linux", target_os = "macos", target_os = "windows")))]
202mod platform {
203    use super::AccessibilityPreferences;
204
205    pub(super) fn query() -> AccessibilityPreferences {
206        AccessibilityPreferences::default()
207    }
208}
209
210#[cfg(test)]
211mod tests {
212    use super::*;
213
214    #[test]
215    fn default_preferences() {
216        let prefs = AccessibilityPreferences::default();
217        assert!(!prefs.high_contrast);
218        assert!(!prefs.reduced_motion);
219        assert!((prefs.text_scale_factor - 1.0).abs() < f64::EPSILON);
220        assert!(!prefs.prefers_large_text());
221    }
222
223    #[test]
224    fn large_text_threshold() {
225        let mut prefs = AccessibilityPreferences {
226            text_scale_factor: 1.25,
227            ..AccessibilityPreferences::default()
228        };
229        assert!(prefs.prefers_large_text());
230
231        prefs.text_scale_factor = 1.0;
232        assert!(!prefs.prefers_large_text());
233    }
234
235    #[test]
236    fn query_does_not_panic() {
237        // Should never panic regardless of environment — graceful fallback.
238        let prefs = AccessibilityPreferences::query();
239        assert!(prefs.text_scale_factor > 0.0);
240    }
241}