Disable all spinners in Zed (#60614)

Closes https://github.com/zed-industries/zed/pull/48577
Part of https://github.com/zed-industries/zed/issues/8043
See
https://zed-industries.slack.com/archives/C07NUKHLVUZ/p1783335475957139
for context.

Release Notes:

- Allow to reduce animations with `"reduce_motion": "on"` settings
This commit is contained in:
Kirill Bulatov authored and GitHub committed 2026-07-14 14:49:06 +00:00
1 parent 928a074dc7
commit 2adb49ea16
13 files changed
+320 -46

No files matched your search

+9
View File
@@ -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,
+17
View File
@@ -749,6 +749,7 @@ pub struct App {
pub(crate) window_update_stack: Vec<WindowId>,
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) {
+103 -19
View File
@@ -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<E: IntoElement + 'static> Element for AnimationElement<E> {
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<RefCell<Vec<f32>>>,
}
impl Render for AnimationTestView {
fn render(&mut self, _window: &mut Window, _cx: &mut Context<Self>) -> 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<RefCell<Vec<f32>>>, WindowHandle<AnimationTestView>) {
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<AnimationTestView>,
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]);
}
}
+2 -1
View File
@@ -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();
}
+20
View File
@@ -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.
+34
View File
@@ -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<T: Default + PartialEq>(value: T) -> Option<T> {
Some(value)
}
}
#[cfg(test)]
mod tests {
use super::*;
fn imported_reduce_motion(content: &str) -> Option<ReduceMotionMode> {
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);
}
}
@@ -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<String>,
/// 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<ReduceMotionMode>,
/// The URL of the Zed server to connect to.
pub server_url: Option<String>,
+15 -1
View File
@@ -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,
}),
]
}
+1
View File
@@ -546,6 +546,7 @@ fn init_renderers(cx: &mut App) {
.add_basic_renderer::<settings::BaseKeymapContent>(render_dropdown)
.add_basic_renderer::<settings::MultiCursorModifier>(render_dropdown)
.add_basic_renderer::<settings::HideMouseMode>(render_dropdown)
.add_basic_renderer::<settings::ReduceMotionMode>(render_dropdown)
.add_basic_renderer::<settings::CurrentLineHighlight>(render_dropdown)
.add_basic_renderer::<settings::ShowWhitespaceSetting>(render_dropdown)
.add_basic_renderer::<settings::SoftWrap>(render_dropdown)
+36 -22
View File
@@ -1264,29 +1264,43 @@ impl<T: ScrollableHandle> Element for ScrollbarElement<T> {
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,
});
+7 -3
View File
@@ -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();
}
+19
View File
@@ -436,6 +436,7 @@ pub fn initialize_workspace(app_state: Arc<AppState>, 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::<SettingsStore>(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::<SettingsStore>(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.
///
+24
View File
@@ -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.