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:
1 parent
928a074dc7
commit
2adb49ea16
13 files changed
+320
-46
No files matched your search
@@ -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,
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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]);
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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>,
|
||||
|
||||
|
||||
@@ -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,
|
||||
}),
|
||||
]
|
||||
}
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
|
||||
|
||||
@@ -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.
|
||||
///
|
||||
|
||||
@@ -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.
|
||||
|
||||
Reference in new issue
Block a user