diff --git a/assets/settings/default.json b/assets/settings/default.json index 1d4ea7a1f5..b2fdb36f7e 100644 --- a/assets/settings/default.json +++ b/assets/settings/default.json @@ -268,6 +268,15 @@ // 3. Hide on typing and on key bindings that resolve to an action: // "on_typing_and_action" "hide_mouse": "on_typing_and_action", + // Whether to reduce non-essential motion in the UI, such as loading + // spinners and pulsating labels, by rendering them in a static state. + // + // May take 2 values: + // 1. Always reduce motion: + // "on" + // 2. Never reduce motion: + // "off" + "reduce_motion": "off", // Determines whether the focused panel follows the mouse location. "focus_follows_mouse": { "enabled": false, diff --git a/crates/gpui/src/app.rs b/crates/gpui/src/app.rs index 0467c3e4fd..1a3e28fff9 100644 --- a/crates/gpui/src/app.rs +++ b/crates/gpui/src/app.rs @@ -749,6 +749,7 @@ pub struct App { pub(crate) window_update_stack: Vec, pub(crate) mode: GpuiMode, pub(crate) cursor_hide_mode: CursorHideMode, + pub(crate) reduce_motion: bool, /// Whether the app was created by [`Application::new_inaccessible`]. No /// accesskit APIs will be called when this flag is set. pub(crate) accessibility_force_disabled: bool, @@ -841,6 +842,7 @@ impl App { quit_mode: QuitMode::default(), quitting: false, cursor_hide_mode: CursorHideMode::default(), + reduce_motion: false, accessibility_force_disabled: false, #[cfg(any(test, feature = "test-support", debug_assertions))] @@ -1003,6 +1005,21 @@ impl App { self.platform.is_cursor_visible() } + /// Returns whether non-essential animations (e.g. loading spinners) should + /// be rendered in a static state instead of animating. + pub fn reduce_motion(&self) -> bool { + self.reduce_motion + } + + /// Sets whether non-essential animations (e.g. loading spinners) should be + /// rendered in a static state instead of animating. + pub fn set_reduce_motion(&mut self, reduce_motion: bool) { + if self.reduce_motion != reduce_motion { + self.reduce_motion = reduce_motion; + self.refresh_windows(); + } + } + /// Schedules all windows in the application to be redrawn. This can be called /// multiple times in an update cycle and still result in a single redraw. pub fn refresh_windows(&mut self) { diff --git a/crates/gpui/src/elements/animation.rs b/crates/gpui/src/elements/animation.rs index b816f7dd93..0a3c0c300b 100644 --- a/crates/gpui/src/elements/animation.rs +++ b/crates/gpui/src/elements/animation.rs @@ -48,6 +48,12 @@ impl Animation { } /// An extension trait for adding the animation wrapper to both Elements and Components +/// +/// Animations rendered through this trait automatically respect +/// [`App::reduce_motion`](crate::App::reduce_motion): when it is set, +/// the element is rendered in a static state (the end state for oneshot +/// animations, the start state for repeating ones) and no animation frames are +/// scheduled. pub trait AnimationExt { /// Render this component or element with an animation fn with_animation( @@ -152,25 +158,36 @@ impl Element for AnimationElement { start: Instant::now(), animation_ix: 0, }); - let animation_ix = state.animation_ix; - - let mut delta = state.start.elapsed().as_secs_f32() - / self.animations[animation_ix].duration.as_secs_f32(); - - let mut done = false; - if delta > 1.0 { - if self.animations[animation_ix].oneshot { - if animation_ix >= self.animations.len() - 1 { - done = true; - } else { - state.start = Instant::now(); - state.animation_ix += 1; - } - delta = 1.0; + let (animation_ix, delta, done) = if cx.reduce_motion() { + let animation_ix = self.animations.len() - 1; + let delta = if self.animations[animation_ix].oneshot { + 1.0 } else { - delta %= 1.0; + 0.0 + }; + (animation_ix, delta, true) + } else { + let animation_ix = state.animation_ix; + + let mut delta = state.start.elapsed().as_secs_f32() + / self.animations[animation_ix].duration.as_secs_f32(); + + let mut done = false; + if delta > 1.0 { + if self.animations[animation_ix].oneshot { + if animation_ix >= self.animations.len() - 1 { + done = true; + } else { + state.start = Instant::now(); + state.animation_ix += 1; + } + delta = 1.0; + } else { + delta %= 1.0; + } } - } + (animation_ix, delta, done) + }; let delta = (self.animations[animation_ix].easing)(delta); debug_assert!( @@ -273,11 +290,55 @@ mod easing { #[cfg(test)] mod tests { - use crate::InteractiveElement; - use crate::div; + use std::{cell::RefCell, rc::Rc, time::Duration}; + + use crate::{ + Animation, Context, InteractiveElement, Render, TestAppContext, WindowHandle, div, + prelude::*, px, size, + }; use super::*; + struct AnimationTestView { + rendered_deltas: Rc>>, + } + + impl Render for AnimationTestView { + fn render(&mut self, _window: &mut Window, _cx: &mut Context) -> impl IntoElement { + let rendered_deltas = self.rendered_deltas.clone(); + div().size_full().child(div().with_animation( + "repeating-animation", + Animation::new(Duration::from_secs(1)).repeat(), + move |this, delta| { + rendered_deltas.borrow_mut().push(delta); + this + }, + )) + } + } + + fn open_test_window( + cx: &mut TestAppContext, + ) -> (Rc>>, WindowHandle) { + let rendered_deltas = Rc::new(RefCell::new(Vec::new())); + let window = cx.open_window(size(px(100.), px(100.)), { + let rendered_deltas = rendered_deltas.clone(); + move |_, _| AnimationTestView { rendered_deltas } + }); + cx.run_until_parked(); + (rendered_deltas, window) + } + + fn simulate_next_frame( + window: &WindowHandle, + cx: &mut TestAppContext, + ) -> usize { + let callback_count = window + .update(cx, |_, window, cx| window.simulate_next_frame(cx)) + .unwrap(); + cx.run_until_parked(); + callback_count + } // Before parent-animation-element, using .with_animation // would not allow chaining .parent after. This is just a // build check that we can call div().id().with_animation().child() @@ -299,4 +360,27 @@ mod tests { div(), ); } + + #[gpui::test] + fn test_repeating_animation_schedules_animation_frames(cx: &mut TestAppContext) { + let (rendered_deltas, window) = open_test_window(cx); + + assert_eq!(rendered_deltas.borrow().len(), 1); + + for expected_frames in 2..=3 { + assert_eq!(simulate_next_frame(&window, cx), 1); + assert_eq!(rendered_deltas.borrow().len(), expected_frames); + } + } + + #[gpui::test] + fn test_reduce_motion_renders_single_static_frame(cx: &mut TestAppContext) { + cx.update(|cx| cx.set_reduce_motion(true)); + let (rendered_deltas, window) = open_test_window(cx); + + assert_eq!(*rendered_deltas.borrow(), vec![0.0]); + + assert_eq!(simulate_next_frame(&window, cx), 0); + assert_eq!(*rendered_deltas.borrow(), vec![0.0]); + } } diff --git a/crates/gpui/src/elements/img.rs b/crates/gpui/src/elements/img.rs index 6f01182f9e..4850d895b6 100644 --- a/crates/gpui/src/elements/img.rs +++ b/crates/gpui/src/elements/img.rs @@ -317,7 +317,7 @@ impl Element for Img { if let Some(state) = &mut state { state.frame_index = state.frame_index.min(max_frame_index); - if frame_count > 1 { + if frame_count > 1 && !cx.reduce_motion() { if window.is_window_active() { let current_time = Instant::now(); if let Some(last_frame_time) = state.last_frame_time { @@ -378,6 +378,7 @@ impl Element for Img { if global_id.is_some() && data.frame_count() > 1 && window.is_window_active() + && !cx.reduce_motion() { window.request_animation_frame(); } diff --git a/crates/gpui/src/window.rs b/crates/gpui/src/window.rs index 4c4d039ace..f08d347581 100644 --- a/crates/gpui/src/window.rs +++ b/crates/gpui/src/window.rs @@ -2202,11 +2202,31 @@ impl Window { /// It will cause the window to redraw on the next frame, even if no other changes have occurred. /// /// If called from within a view, it will notify that view on the next frame. Otherwise, it will refresh the entire window. + /// + /// Callers driving purely decorative animations (spinners, pulses, and the + /// like) should prefer [`AnimationExt::with_animation`](crate::AnimationExt::with_animation), + /// which automatically respects [`App::reduce_motion`]. When using this + /// method directly for decorative motion, check [`App::reduce_motion`] + /// and skip the frame request when it is set. pub fn request_animation_frame(&self) { let entity = self.current_view(); self.on_next_frame(move |_, cx| cx.notify(entity)); } + /// Runs all callbacks scheduled via [`Self::on_next_frame`], returning how many ran. + /// + /// Tests have no platform frame loop, so this simulates the delivery of the + /// next frame. + #[cfg(any(test, feature = "test-support"))] + pub fn simulate_next_frame(&mut self, cx: &mut App) -> usize { + let callbacks = self.next_frame_callbacks.take(); + let count = callbacks.len(); + for callback in callbacks { + callback(self, cx); + } + count + } + /// Spawn the future returned by the given closure on the application thread pool. /// The closure is provided a handle to the current window and an `AsyncWindowContext` for /// use within your future. diff --git a/crates/settings/src/vscode_import.rs b/crates/settings/src/vscode_import.rs index 5829109797..5d1a628578 100644 --- a/crates/settings/src/vscode_import.rs +++ b/crates/settings/src/vscode_import.rs @@ -206,6 +206,11 @@ impl VsCodeSettings { project: self.project_settings_content(), project_panel: self.project_panel_settings_content(), proxy: self.read_string("http.proxy"), + reduce_motion: self.read_enum("workbench.reduceMotion", |s| match s { + "on" => Some(ReduceMotionMode::On), + "off" => Some(ReduceMotionMode::Off), + _ => None, + }), remote: RemoteSettingsContent::default(), repl: None, server_url: None, @@ -1125,3 +1130,32 @@ fn skip_default(value: T) -> Option { Some(value) } } + +#[cfg(test)] +mod tests { + use super::*; + + fn imported_reduce_motion(content: &str) -> Option { + VsCodeSettings::from_str(content, VsCodeSettingsSource::VsCode) + .unwrap() + .settings_content() + .reduce_motion + } + + #[test] + fn test_import_reduce_motion() { + assert_eq!( + imported_reduce_motion(r#"{ "workbench.reduceMotion": "on" }"#), + Some(ReduceMotionMode::On) + ); + assert_eq!( + imported_reduce_motion(r#"{ "workbench.reduceMotion": "off" }"#), + Some(ReduceMotionMode::Off) + ); + assert_eq!( + imported_reduce_motion(r#"{ "workbench.reduceMotion": "auto" }"#), + None + ); + assert_eq!(imported_reduce_motion("{}"), None); + } +} diff --git a/crates/settings_content/src/settings_content.rs b/crates/settings_content/src/settings_content.rs index 4628bcf34e..0edd11fdea 100644 --- a/crates/settings_content/src/settings_content.rs +++ b/crates/settings_content/src/settings_content.rs @@ -111,6 +111,33 @@ pub enum HideMouseMode { OnTypingAndAction, } +/// Determines whether to reduce non-essential motion in the UI, such as +/// loading spinners and pulsating labels, by rendering them in a static state. +/// +/// Default: off +#[derive( + Copy, + Clone, + Debug, + Default, + Serialize, + Deserialize, + PartialEq, + Eq, + JsonSchema, + MergeFrom, + strum::VariantArray, + strum::VariantNames, +)] +#[serde(rename_all = "snake_case")] +pub enum ReduceMotionMode { + /// Always reduce motion + On, + /// Never reduce motion + #[default] + Off, +} + #[with_fallible_options] #[derive(Debug, PartialEq, Default, Clone, Serialize, Deserialize, JsonSchema, MergeFrom)] pub struct SettingsContent { @@ -216,6 +243,12 @@ pub struct SettingsContent { pub proxy: Option, + /// Whether to reduce non-essential motion in the UI, such as loading + /// spinners and pulsating labels, by rendering them in a static state. + /// + /// Default: off + pub reduce_motion: Option, + /// The URL of the Zed server to connect to. pub server_url: Option, diff --git a/crates/settings_ui/src/page_data.rs b/crates/settings_ui/src/page_data.rs index 0cb49694cb..ce7b51f2e9 100644 --- a/crates/settings_ui/src/page_data.rs +++ b/crates/settings_ui/src/page_data.rs @@ -1259,7 +1259,7 @@ fn appearance_page() -> SettingsPage { ] } - fn cursor_section() -> [SettingsPageItem; 5] { + fn cursor_section() -> [SettingsPageItem; 6] { [ SettingsPageItem::SectionHeader("Cursor"), SettingsPageItem::SettingItem(SettingItem { @@ -1318,6 +1318,20 @@ fn appearance_page() -> SettingsPage { metadata: None, files: USER, }), + SettingsPageItem::SettingItem(SettingItem { + title: "Reduce Motion", + description: "Whether to reduce non-essential motion, such as loading spinners, by rendering them in a static state.", + field: Box::new(SettingField { + organization_override: None, + json_path: Some("reduce_motion"), + pick: |settings_content| settings_content.reduce_motion.as_ref(), + write: |settings_content, value, _| { + settings_content.reduce_motion = value; + }, + }), + metadata: None, + files: USER, + }), ] } diff --git a/crates/settings_ui/src/settings_ui.rs b/crates/settings_ui/src/settings_ui.rs index 45901486a1..4f5937db1b 100644 --- a/crates/settings_ui/src/settings_ui.rs +++ b/crates/settings_ui/src/settings_ui.rs @@ -546,6 +546,7 @@ fn init_renderers(cx: &mut App) { .add_basic_renderer::(render_dropdown) .add_basic_renderer::(render_dropdown) .add_basic_renderer::(render_dropdown) + .add_basic_renderer::(render_dropdown) .add_basic_renderer::(render_dropdown) .add_basic_renderer::(render_dropdown) .add_basic_renderer::(render_dropdown) diff --git a/crates/ui/src/components/scrollbar.rs b/crates/ui/src/components/scrollbar.rs index 3e2c8c8b9a..dde9312334 100644 --- a/crates/ui/src/components/scrollbar.rs +++ b/crates/ui/src/components/scrollbar.rs @@ -1264,29 +1264,43 @@ impl Element for ScrollbarElement { current_delta, animation_duration: delta_duration, showing: should_invert, - } => window.with_element_state(id.unwrap(), |state, window| { - let state = state.unwrap_or_else(|| Instant::now()); - let current = Instant::now(); - - let new_delta = DELTA_MAX.min( - current_delta + (current - state).div_duration_f32(delta_duration), - ); - self.state.update(cx, |state, _| { - let has_border = state - .track_color - .as_ref() - .is_some_and(|track_colors| track_colors.has_border); - state.show_state.set_delta(new_delta, has_border) - }); - - window.request_animation_frame(); - let delta = if should_invert { - DELTA_MAX - current_delta + } => { + if cx.reduce_motion() { + self.state.update(cx, |state, _| { + let has_border = state + .track_color + .as_ref() + .is_some_and(|track_colors| track_colors.has_border); + state.show_state.set_delta(DELTA_MAX, has_border) + }); + if should_invert { 0.0 } else { DELTA_MAX } } else { - current_delta - }; - (ease_in_out(delta), current) - }), + window.with_element_state(id.unwrap(), |state, window| { + let state = state.unwrap_or_else(|| Instant::now()); + let current = Instant::now(); + + let new_delta = DELTA_MAX.min( + current_delta + + (current - state).div_duration_f32(delta_duration), + ); + self.state.update(cx, |state, _| { + let has_border = state + .track_color + .as_ref() + .is_some_and(|track_colors| track_colors.has_border); + state.show_state.set_delta(new_delta, has_border) + }); + + window.request_animation_frame(); + let delta = if should_invert { + DELTA_MAX - current_delta + } else { + current_delta + }; + (ease_in_out(delta), current) + }) + } + } AnimationState::Stale => 1.0, }); diff --git a/crates/workspace/src/notifications.rs b/crates/workspace/src/notifications.rs index 7441cf391d..59246eaba2 100644 --- a/crates/workspace/src/notifications.rs +++ b/crates/workspace/src/notifications.rs @@ -528,8 +528,12 @@ pub mod simple_message_notification { if let Some(auto_hide) = this.auto_hide.as_mut() { auto_hide.finish_timer(); if !auto_hide.hovered { - auto_hide.start_fading_out(); - cx.notify(); + if cx.reduce_motion() { + cx.emit(DismissEvent); + } else { + auto_hide.start_fading_out(); + cx.notify(); + } } } }) { @@ -956,7 +960,7 @@ pub mod simple_message_notification { cx.emit(DismissEvent); } - if self.needs_animation_frame() { + if self.needs_animation_frame() && !cx.reduce_motion() { window.request_animation_frame(); } diff --git a/crates/zed/src/zed.rs b/crates/zed/src/zed.rs index 55cf315227..d4b394429a 100644 --- a/crates/zed/src/zed.rs +++ b/crates/zed/src/zed.rs @@ -436,6 +436,7 @@ pub fn initialize_workspace(app_state: Arc, cx: &mut App) { .detach(); init_cursor_hide_mode(cx); + init_reduce_motion(cx); cx.observe_new(|_multi_workspace: &mut MultiWorkspace, window, cx| { let Some(window) = window else { @@ -2018,6 +2019,24 @@ fn init_cursor_hide_mode(cx: &mut App) { cx.observe_global::(apply).detach(); } +#[derive(Copy, Clone, Debug, settings::RegisterSetting)] +struct ReduceMotionSetting(settings::ReduceMotionMode); + +impl Settings for ReduceMotionSetting { + fn from_settings(content: &settings::SettingsContent) -> Self { + Self(content.reduce_motion.unwrap_or_default()) + } +} + +fn init_reduce_motion(cx: &mut App) { + let apply = |cx: &mut App| { + let reduce_motion = ReduceMotionSetting::get_global(cx).0 == settings::ReduceMotionMode::On; + cx.set_reduce_motion(reduce_motion); + }; + apply(cx); + cx.observe_global::(apply).detach(); +} + /// Starts watching `~/.config/zed/AGENTS.md` (or the platform equivalent) and /// surfaces any read errors using the same notification UI as settings errors. /// diff --git a/docs/src/reference/all-settings.md b/docs/src/reference/all-settings.md index 2ded300ce5..2569008716 100644 --- a/docs/src/reference/all-settings.md +++ b/docs/src/reference/all-settings.md @@ -858,6 +858,30 @@ List of `string` values } ``` +## Reduce Motion + +- Description: Whether to reduce non-essential motion in the UI, such as loading spinners and pulsating labels, by rendering them in a static state. +- Setting: `reduce_motion` +- Default: `off` + +**Options** + +1. Always reduce motion: + +```json [settings] +{ + "reduce_motion": "on" +} +``` + +2. Never reduce motion: + +```json [settings] +{ + "reduce_motion": "off" +} +``` + ## Snippet Sort Order - Description: Determines how snippets are sorted relative to other completion items.