Disable all spinners in Zed

# Conflicts:
#	crates/gpui/src/elements/animation.rs
This commit is contained in:
Kirill Bulatov 2026-07-04 02:00:13 +03:00
parent 6979d7281f
commit 94a7a2c71a
13 changed files with 321 additions and 47 deletions

View file

@ -265,6 +265,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,

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) {

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]);
}
}

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();
}

View file

@ -2199,11 +2199,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.

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,
@ -1118,3 +1123,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);
}
}

View file

@ -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>,

View file

@ -1245,7 +1245,7 @@ fn appearance_page() -> SettingsPage {
]
}
fn cursor_section() -> [SettingsPageItem; 5] {
fn cursor_section() -> [SettingsPageItem; 6] {
[
SettingsPageItem::SectionHeader("Cursor"),
SettingsPageItem::SettingItem(SettingItem {
@ -1304,6 +1304,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,
}),
]
}

View file

@ -540,6 +540,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)

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,
});

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();
}

View file

@ -414,6 +414,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 {
@ -1987,6 +1988,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.
///

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.
@ -3279,7 +3303,7 @@ Examples:
- Description:
Preview tabs allow you to open files in preview mode, where they close automatically when you switch to another file unless you explicitly pin them. This is useful for quickly viewing files without cluttering your workspace. Preview tabs display their file names in italics. \
There are several ways to convert a preview tab into a regular tab:
There are several ways to convert a preview tab into a regular tab:
- Double-clicking on the file
- Double-clicking on the tab header