Skip to main content

gpui_base/
reduce_motion.rs

1//! Honors the operating system's reduced-motion preference.
2//!
3//! Every Base transition, spring, presence and reveal consults
4//! [`App::reduce_motion`], but GPUI never reads the platform's setting into
5//! that flag: it stays `false` until something sets it. [`init`] reads the
6//! setting when Base initializes and writes it into the flag, so an
7//! application inherits the user's choice by calling `gpui_base::init` or
8//! `gpui_component::init`.
9//!
10//! The setting is read on the platforms below; everywhere else the flag is
11//! left alone.
12//!
13//! - macOS: `NSWorkspace.accessibilityDisplayShouldReduceMotion`, the
14//!   "Reduce motion" switch under Accessibility → Display.
15//! - Windows: `SystemParametersInfoW(SPI_GETCLIENTAREAANIMATION)`, the
16//!   "Animation effects" switch under Accessibility → Visual effects, whose
17//!   off state asks for reduced motion.
18//! - Linux: the `reduced-motion` key of the `org.freedesktop.appearance`
19//!   namespace of the XDG desktop portal's Settings interface, which GNOME
20//!   and KDE back with their own animation switches. The portal answers over
21//!   D-Bus, so the reading lands a moment after `init` returns, and Base keeps
22//!   following the portal's change signal for the life of the application.
23//!
24//! An application owns the flag once it sets it. Base writes the
25//! flag only while it still holds what Base last wrote (or GPUI's initial
26//! `false`), so an application that calls [`App::set_reduce_motion`] after
27//! `init` is never overridden by a later reading, and one that wants to follow
28//! the system again calls [`apply_system_reduce_motion`].
29//!
30//! Under GPUI's test scheduler the platform is never consulted, whichever
31//! crate's tests are running: a probe that answers from another thread would
32//! break the scheduler's determinism, and a test wanting reduced motion sets
33//! the flag itself.
34
35use gpui::{App, Global};
36
37/// What Base last wrote into [`App::set_reduce_motion`], and whether it is
38/// already listening for the platform to change its mind.
39#[derive(Default)]
40struct SystemReduceMotion {
41    applied: Option<bool>,
42    following: bool,
43}
44
45impl Global for SystemReduceMotion {}
46
47/// Applies the system's reduced-motion preference during `gpui_base::init`.
48pub(crate) fn init(cx: &mut App) {
49    apply_system_reduce_motion(cx);
50}
51
52/// Reads the operating system's reduced-motion preference into
53/// [`App::set_reduce_motion`].
54///
55/// `gpui_base::init` calls this once. Call it again to re-read the preference
56/// on a platform Base does not follow live — macOS and Windows post no
57/// notification Base can subscribe to without a window, so a change made
58/// while the application runs reaches it only through this call.
59///
60/// The application wins over the system: when the flag no longer holds what
61/// Base last wrote, the application set it, and this call leaves it alone.
62/// Where the platform cannot say (wasm, an unsupported desktop, a Linux
63/// session without the portal) the flag is left as it is, and under GPUI's
64/// test scheduler the platform is not asked at all.
65pub fn apply_system_reduce_motion(cx: &mut App) {
66    if is_test_scheduler(cx) {
67        return;
68    }
69    apply_preference(platform::read(), cx);
70    let state = cx.default_global::<SystemReduceMotion>();
71    if !state.following {
72        state.following = true;
73        platform::follow(cx);
74    }
75}
76
77/// Whether the application runs on GPUI's deterministic test scheduler,
78/// which must not be woken by a platform answering from its own thread.
79fn is_test_scheduler(cx: &App) -> bool {
80    cx.background_executor()
81        .scheduler_executor()
82        .scheduler()
83        .as_test()
84        .is_some()
85}
86
87/// Writes one reading of the system preference into the flag, unless the
88/// application has taken the flag over since the reading Base last applied.
89fn apply_preference(preference: Option<bool>, cx: &mut App) {
90    let Some(reduce) = preference else {
91        return;
92    };
93    let applied = cx
94        .try_global::<SystemReduceMotion>()
95        .and_then(|state| state.applied);
96    if cx.reduce_motion() != applied.unwrap_or(false) {
97        return;
98    }
99    cx.set_reduce_motion(reduce);
100    cx.default_global::<SystemReduceMotion>().applied = Some(reduce);
101}
102
103#[cfg(target_os = "macos")]
104mod platform {
105    use gpui::App;
106    use objc2_app_kit::NSWorkspace;
107
108    pub(super) fn read() -> Option<bool> {
109        Some(NSWorkspace::sharedWorkspace().accessibilityDisplayShouldReduceMotion())
110    }
111
112    pub(super) fn follow(_cx: &mut App) {}
113}
114
115#[cfg(target_os = "windows")]
116mod platform {
117    use gpui::App;
118    use windows::Win32::{
119        Foundation::BOOL,
120        UI::WindowsAndMessaging::{
121            SPI_GETCLIENTAREAANIMATION, SYSTEM_PARAMETERS_INFO_UPDATE_FLAGS, SystemParametersInfoW,
122        },
123    };
124
125    pub(super) fn read() -> Option<bool> {
126        let mut animations_enabled = BOOL(0);
127        // SAFETY: SPI_GETCLIENTAREAANIMATION writes one BOOL through `pvParam`
128        // and ignores `uiParam`; the BOOL outlives the call.
129        unsafe {
130            SystemParametersInfoW(
131                SPI_GETCLIENTAREAANIMATION,
132                0,
133                Some((&mut animations_enabled as *mut BOOL).cast()),
134                SYSTEM_PARAMETERS_INFO_UPDATE_FLAGS(0),
135            )
136        }
137        .ok()?;
138        Some(!animations_enabled.as_bool())
139    }
140
141    pub(super) fn follow(_cx: &mut App) {}
142}
143
144#[cfg(target_os = "linux")]
145mod platform {
146    use ashpd::desktop::settings::{ReducedMotion, Settings};
147    use futures::StreamExt as _;
148    use gpui::App;
149
150    /// The portal answers asynchronously; [`follow`] applies the first reading.
151    pub(super) fn read() -> Option<bool> {
152        None
153    }
154
155    pub(super) fn follow(cx: &mut App) {
156        cx.spawn(async move |cx| {
157            let settings = Settings::new().await.ok()?;
158            let current = settings.reduced_motion().await.ok()?;
159            cx.update(|cx| super::apply_preference(Some(reduces(current)), cx));
160            let mut changes = settings.receive_reduced_motion_changed().await.ok()?;
161            while let Some(preference) = changes.next().await {
162                cx.update(|cx| super::apply_preference(Some(reduces(preference)), cx));
163            }
164            Some(())
165        })
166        .detach();
167    }
168
169    fn reduces(preference: ReducedMotion) -> bool {
170        matches!(preference, ReducedMotion::ReducedMotion)
171    }
172}
173
174#[cfg(not(any(target_os = "macos", target_os = "windows", target_os = "linux")))]
175mod platform {
176    use gpui::App;
177
178    pub(super) fn read() -> Option<bool> {
179        None
180    }
181
182    pub(super) fn follow(_cx: &mut App) {}
183}
184
185#[cfg(test)]
186mod tests {
187    use gpui::TestAppContext;
188
189    use super::{apply_preference, apply_system_reduce_motion};
190
191    #[gpui::test]
192    fn a_system_preference_for_reduced_motion_sets_the_flag(cx: &mut TestAppContext) {
193        cx.update(|cx| {
194            apply_preference(Some(true), cx);
195            assert!(cx.reduce_motion());
196        });
197    }
198
199    #[gpui::test]
200    fn an_unknown_system_preference_leaves_the_flag_alone(cx: &mut TestAppContext) {
201        cx.update(|cx| {
202            apply_preference(None, cx);
203            assert!(!cx.reduce_motion());
204
205            cx.set_reduce_motion(true);
206            apply_preference(None, cx);
207            assert!(cx.reduce_motion());
208        });
209    }
210
211    #[gpui::test]
212    fn the_system_drives_the_flag_until_the_application_sets_it(cx: &mut TestAppContext) {
213        cx.update(|cx| {
214            apply_preference(Some(true), cx);
215            apply_preference(Some(false), cx);
216            assert!(!cx.reduce_motion());
217            apply_preference(Some(true), cx);
218            assert!(cx.reduce_motion());
219
220            cx.set_reduce_motion(false);
221            apply_preference(Some(true), cx);
222            assert!(!cx.reduce_motion());
223        });
224    }
225
226    #[gpui::test]
227    fn the_test_scheduler_is_never_asked_for_the_platform_preference(cx: &mut TestAppContext) {
228        cx.update(|cx| {
229            apply_system_reduce_motion(cx);
230            assert!(!cx.reduce_motion());
231        });
232        cx.run_until_parked();
233        cx.update(|cx| assert!(!cx.reduce_motion()));
234    }
235
236    #[gpui::test]
237    fn a_flag_the_application_set_before_the_first_reading_is_kept(cx: &mut TestAppContext) {
238        cx.update(|cx| {
239            cx.set_reduce_motion(true);
240            apply_preference(Some(false), cx);
241            assert!(cx.reduce_motion());
242        });
243    }
244}