diff --git a/Cargo.lock b/Cargo.lock index c69c844bad05a8..40cd87ac91abe7 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -12252,6 +12252,7 @@ dependencies = [ "schemars", "serde", "serde_json", + "settings", "theme", "ui", "ui_input", diff --git a/assets/settings/default.json b/assets/settings/default.json index bd41f1704f6be5..f231db42b955f0 100644 --- a/assets/settings/default.json +++ b/assets/settings/default.json @@ -195,6 +195,15 @@ // // Default: "client" "window_decorations": "client", + // Whether to reduce motion in UI animations. + // May take 3 values: + // 1. Follow the OS accessibility setting: + // "reduce_motion": "system" + // 2. Always reduce motion (skip animations): + // "reduce_motion": "on" + // 3. Never reduce motion (always animate): + // "reduce_motion": "off" + "reduce_motion": "system", // Whether to use the system provided dialogs for Open and Save As. // When set to false, Zed will use the built-in keyboard-first pickers. "use_system_path_prompts": true, diff --git a/crates/gpui/src/app.rs b/crates/gpui/src/app.rs index 94fdab8927aadd..f8e7850030b161 100644 --- a/crates/gpui/src/app.rs +++ b/crates/gpui/src/app.rs @@ -1236,6 +1236,11 @@ impl App { self.platform.should_auto_hide_scrollbars() } + /// Returns whether the platform's accessibility settings request reduced motion. + pub fn should_reduce_motion(&self) -> bool { + self.platform.should_reduce_motion() + } + /// Restarts the application. pub fn restart(&mut self) { self.restart_observers diff --git a/crates/gpui/src/elements/animation.rs b/crates/gpui/src/elements/animation.rs index e72fb00456d14d..beb2a94493dedd 100644 --- a/crates/gpui/src/elements/animation.rs +++ b/crates/gpui/src/elements/animation.rs @@ -229,6 +229,11 @@ mod easing { } } + /// The cubic ease-out function, which starts quickly and decelerates to a stop + pub fn ease_out_cubic(delta: f32) -> f32 { + 1.0 - (1.0 - delta).powi(3) + } + /// The Quint ease-out function, which starts quickly and decelerates to a stop pub fn ease_out_quint() -> impl Fn(f32) -> f32 { move |delta| 1.0 - (1.0 - delta).powi(5) @@ -261,3 +266,38 @@ mod easing { } } } + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_ease_out_cubic_boundaries() { + assert_eq!(ease_out_cubic(0.0), 0.0); + assert_eq!(ease_out_cubic(1.0), 1.0); + } + + #[test] + fn test_ease_out_cubic_monotonically_increasing() { + let mut previous = 0.0f32; + for i in 1..=100 { + let t = i as f32 / 100.0; + let value = ease_out_cubic(t); + assert!( + value >= previous, + "ease_out_cubic should be monotonically increasing: f({t}) = {value} < f({}) = {previous}", + (i - 1) as f32 / 100.0 + ); + previous = value; + } + } + + #[test] + fn test_ease_out_cubic_midpoint() { + let mid = ease_out_cubic(0.5); + assert!( + mid > 0.5, + "ease-out should be above linear at midpoint, got {mid}" + ); + } +} diff --git a/crates/gpui/src/elements/uniform_list.rs b/crates/gpui/src/elements/uniform_list.rs index a7486f0c00ac4e..468d07254dc37d 100644 --- a/crates/gpui/src/elements/uniform_list.rs +++ b/crates/gpui/src/elements/uniform_list.rs @@ -544,12 +544,13 @@ impl Element for UniformList { window, cx, |_, window, cx| { - for item in &mut request_layout.items { - item.paint(window, cx); - } + // Decorations paint before items so backgrounds (e.g. selection highlights) render behind item content. for decoration in &mut request_layout.decorations { decoration.paint(window, cx); } + for item in &mut request_layout.items { + item.paint(window, cx); + } }, ) } diff --git a/crates/gpui/src/platform.rs b/crates/gpui/src/platform.rs index df72d4dc512861..ae529a63fd99e3 100644 --- a/crates/gpui/src/platform.rs +++ b/crates/gpui/src/platform.rs @@ -270,6 +270,10 @@ pub(crate) trait Platform: 'static { fn set_cursor_style(&self, style: CursorStyle); fn should_auto_hide_scrollbars(&self) -> bool; + fn should_reduce_motion(&self) -> bool { + false + } + fn read_from_clipboard(&self) -> Option; fn write_to_clipboard(&self, item: ClipboardItem); diff --git a/crates/gpui/src/platform/mac/platform.rs b/crates/gpui/src/platform/mac/platform.rs index f66f9ecd1be18d..b3fb615e11ee1a 100644 --- a/crates/gpui/src/platform/mac/platform.rs +++ b/crates/gpui/src/platform/mac/platform.rs @@ -1006,6 +1006,14 @@ impl Platform for MacPlatform { } } + fn should_reduce_motion(&self) -> bool { + unsafe { + let workspace: id = msg_send![class!(NSWorkspace), sharedWorkspace]; + let reduce: BOOL = msg_send![workspace, accessibilityDisplayShouldReduceMotion]; + reduce != NO + } + } + fn read_from_clipboard(&self) -> Option { let state = self.0.lock(); state.general_pasteboard.read() diff --git a/crates/picker/Cargo.toml b/crates/picker/Cargo.toml index f85c55b9f27bcb..330d8df3ccd87d 100644 --- a/crates/picker/Cargo.toml +++ b/crates/picker/Cargo.toml @@ -21,6 +21,7 @@ gpui.workspace = true menu.workspace = true schemars.workspace = true serde.workspace = true +settings.workspace = true theme.workspace = true ui.workspace = true ui_input.workspace = true diff --git a/crates/picker/src/picker.rs b/crates/picker/src/picker.rs index 716653d89642fe..1f6a81c20c8ecd 100644 --- a/crates/picker/src/picker.rs +++ b/crates/picker/src/picker.rs @@ -5,14 +5,15 @@ pub mod popover_menu; use anyhow::Result; use gpui::{ - Action, AnyElement, App, Bounds, ClickEvent, Context, DismissEvent, EventEmitter, FocusHandle, - Focusable, Length, ListSizingBehavior, ListState, MouseButton, MouseUpEvent, Pixels, Render, - ScrollStrategy, Task, UniformListScrollHandle, Window, actions, canvas, div, list, prelude::*, - uniform_list, + Action, Animation, AnimationExt, AnyElement, App, Bounds, ClickEvent, Context, DismissEvent, + EventEmitter, FocusHandle, Focusable, Length, ListSizingBehavior, ListState, MouseButton, + MouseUpEvent, Pixels, Point, Render, ScrollStrategy, Task, UniformListDecoration, + UniformListScrollHandle, Window, actions, canvas, div, list, prelude::*, uniform_list, }; use head::Head; use schemars::JsonSchema; use serde::Deserialize; +use settings::should_reduce_motion; use std::{ cell::Cell, cell::RefCell, collections::HashMap, ops::Range, rc::Rc, sync::Arc, time::Duration, }; @@ -35,6 +36,86 @@ pub enum Direction { Down, } +const MAX_ANIMATED_DISTANCE: usize = 3; + +struct SelectionIndicator { + selected_index: usize, + previous_selected_index: Option, + generation: usize, + reduce_motion: bool, +} + +impl SelectionIndicator { + fn animated_origin(&self, item_height: Pixels, visible_range: &Range) -> Option { + if self.reduce_motion { + return None; + } + let previous_index = self.previous_selected_index?; + if !visible_range.contains(&previous_index) { + return None; + } + let distance = self.selected_index.abs_diff(previous_index); + let clamped_index = if distance > MAX_ANIMATED_DISTANCE { + if self.selected_index > previous_index { + self.selected_index - MAX_ANIMATED_DISTANCE + } else { + self.selected_index + MAX_ANIMATED_DISTANCE + } + } else { + previous_index + }; + Some(item_height * clamped_index) + } +} + +impl UniformListDecoration for SelectionIndicator { + fn compute( + &self, + visible_range: Range, + _bounds: Bounds, + _scroll_offset: Point, + item_height: Pixels, + _item_count: usize, + _window: &mut Window, + cx: &mut App, + ) -> AnyElement { + let selected_top = item_height * self.selected_index; + let background = cx.theme().colors().ghost_element_selected; + let inset = DynamicSpacing::Base04.rems(cx); + + let base = div() + .absolute() + .left(inset) + .right(inset) + .h(item_height) + .bg(background) + .rounded_sm(); + + let indicator = match self.animated_origin(item_height, &visible_range) { + Some(origin_top) => { + let generation = self.generation; + base.with_animation( + ("sel-overlay", generation as u64), + Animation::new(Duration::from_millis(150)) + .with_easing(gpui::ease_in_out), + move |this, delta| { + let offset = origin_top + (selected_top - origin_top) * delta; + this.top(offset) + }, + ) + .into_any_element() + } + None => base.top(selected_top).into_any_element(), + }; + + div() + .relative() + .size_full() + .child(indicator) + .into_any_element() + } +} + actions!( picker, [ @@ -76,6 +157,9 @@ pub struct Picker { picker_bounds: Rc>>>, /// Bounds tracking for items (for aside positioning) - maps item index to bounds item_bounds: Rc>>>, + previous_selected_index: Option, + selection_generation: usize, + last_visible_range: Rc>>, } #[derive(Debug, Default, Clone, Copy, PartialEq)] @@ -342,6 +426,9 @@ impl Picker { is_modal: true, picker_bounds: Rc::new(Cell::new(None)), item_bounds: Rc::new(RefCell::new(HashMap::default())), + previous_selected_index: None, + selection_generation: 0, + last_visible_range: Rc::new(RefCell::new(0..0)), }; this.update_matches("".to_string(), window, cx); // give the delegate 4ms to render the first set of suggestions. @@ -458,6 +545,13 @@ impl Picker { let current_index = self.delegate.selected_index(); if previous_index != current_index { + self.previous_selected_index = + if self.is_fully_visible(current_index, match_count) { + Some(previous_index) + } else { + None + }; + self.selection_generation = self.selection_generation.wrapping_add(1); if let Some(action) = self.delegate.selected_index_changed(ix, window, cx) { action(window, cx); } @@ -467,6 +561,25 @@ impl Picker { } } + /// Returns true if the given index is fully visible (not partially + /// obscured at the edges of the scroll viewport). Items at the very + /// first or last position of the visible range may be only partially + /// shown, so we exclude them unless they sit at the list boundary. + fn is_fully_visible(&self, index: usize, match_count: usize) -> bool { + let visible = self.last_visible_range.borrow().clone(); + let safe_start = if visible.start > 0 { + visible.start + 1 + } else { + visible.start + }; + let safe_end = if visible.end < match_count { + visible.end.saturating_sub(1) + } else { + visible.end + }; + safe_start < safe_end && (safe_start..safe_end).contains(&index) + } + pub fn select_next( &mut self, _: &menu::SelectNext, @@ -714,6 +827,8 @@ impl Picker { state.reset(self.delegate.match_count()); } + self.previous_selected_index = None; + let index = self.delegate.selected_index(); self.scroll_to_item_index(index); self.pending_update_matches = None; @@ -784,12 +899,16 @@ impl Picker { this.handle_click(ix, event.modifiers.platform, window, cx) }), ) - .children(self.delegate.render_match( - ix, - ix == self.delegate.selected_index(), - window, - cx, - )) + .children({ + // When using a uniform list, the SelectionIndicator decoration + // handles the highlight, so individual items should not render + // their own selected background. + let has_selection_overlay = + matches!(self.element_container, ElementContainer::UniformList(_)); + let selected = + ix == self.delegate.selected_index() && !has_selection_overlay; + self.delegate.render_match(ix, selected, window, cx) + }) .when( self.delegate.separators_after_indices().contains(&ix), |picker| { @@ -809,23 +928,36 @@ impl Picker { }; match &self.element_container { - ElementContainer::UniformList(scroll_handle) => uniform_list( - "candidates", - self.delegate.match_count(), - cx.processor(move |picker, visible_range: Range, window, cx| { - visible_range - .map(|ix| picker.render_element(window, cx, ix)) - .collect() - }), - ) - .with_sizing_behavior(sizing_behavior) - .when_some(self.widest_item, |el, widest_item| { - el.with_width_from_item(Some(widest_item)) - }) - .flex_grow() - .py_1() - .track_scroll(&scroll_handle) - .into_any_element(), + ElementContainer::UniformList(scroll_handle) => { + let match_count = self.delegate.match_count(); + let last_visible_range = self.last_visible_range.clone(); + uniform_list( + "candidates", + match_count, + cx.processor(move |picker, visible_range: Range, window, cx| { + *last_visible_range.borrow_mut() = visible_range.clone(); + visible_range + .map(|ix| picker.render_element(window, cx, ix)) + .collect() + }), + ) + .with_sizing_behavior(sizing_behavior) + .when_some(self.widest_item, |el, widest_item| { + el.with_width_from_item(Some(widest_item)) + }) + .when(match_count > 0, |el| { + el.with_decoration(SelectionIndicator { + selected_index: self.delegate.selected_index(), + previous_selected_index: self.previous_selected_index, + generation: self.selection_generation, + reduce_motion: should_reduce_motion(cx), + }) + }) + .flex_grow() + .py_1() + .track_scroll(&scroll_handle) + .into_any_element() + } ElementContainer::List(state) => list( state.clone(), cx.processor(|this, ix, window, cx| { @@ -850,6 +982,69 @@ impl Picker { } } +#[cfg(test)] +mod tests { + use super::*; + use gpui::px; + + fn make_indicator( + selected_index: usize, + previous_selected_index: Option, + reduce_motion: bool, + ) -> SelectionIndicator { + SelectionIndicator { + selected_index, + previous_selected_index, + generation: 0, + reduce_motion, + } + } + + #[test] + fn test_animated_origin_returns_none() { + assert_eq!(make_indicator(5, Some(3), true).animated_origin(px(30.), &(0..10)), None); + assert_eq!(make_indicator(5, None, false).animated_origin(px(30.), &(0..10)), None); + assert_eq!(make_indicator(5, Some(12), false).animated_origin(px(30.), &(3..10)), None); + } + + #[test] + fn test_animated_origin_computes_position() { + assert_eq!(make_indicator(5, Some(3), false).animated_origin(px(30.), &(0..10)), Some(px(90.))); + assert_eq!(make_indicator(5, Some(2), false).animated_origin(px(25.), &(0..10)), Some(px(50.))); + assert_eq!(make_indicator(8, Some(0), false).animated_origin(px(20.), &(0..10)), Some(px(100.))); + assert_eq!(make_indicator(2, Some(9), false).animated_origin(px(20.), &(0..10)), Some(px(100.))); + } + + fn is_fully_visible(visible_range: Range, index: usize, match_count: usize) -> bool { + let safe_start = if visible_range.start > 0 { + visible_range.start + 1 + } else { + visible_range.start + }; + let safe_end = if visible_range.end < match_count { + visible_range.end.saturating_sub(1) + } else { + visible_range.end + }; + safe_start < safe_end && (safe_start..safe_end).contains(&index) + } + + #[test] + fn test_is_fully_visible_true_cases() { + assert!(is_fully_visible(2..8, 5, 20)); // mid-range + assert!(is_fully_visible(0..8, 0, 20)); // list start (no start clip) + assert!(is_fully_visible(12..20, 19, 20)); // list end (no end clip) + } + + #[test] + fn test_is_fully_visible_false_cases() { + assert!(!is_fully_visible(2..8, 2, 20)); // at scroll boundary start + assert!(!is_fully_visible(2..8, 7, 20)); // at scroll boundary end + assert!(!is_fully_visible(5..10, 15, 20)); // outside range entirely + assert!(!is_fully_visible(5..5, 5, 20)); // empty range + } +} + impl EventEmitter for Picker {} impl ModalView for Picker {} diff --git a/crates/settings/src/reduce_motion_setting.rs b/crates/settings/src/reduce_motion_setting.rs new file mode 100644 index 00000000000000..b76712613cada4 --- /dev/null +++ b/crates/settings/src/reduce_motion_setting.rs @@ -0,0 +1,72 @@ +use crate::{self as settings, settings_content::ReduceMotion}; +use settings::{RegisterSetting, Settings}; + +#[derive(Copy, Clone, Debug, PartialEq, Eq, Default, RegisterSetting)] +pub struct ReduceMotionSetting(pub ReduceMotion); + +impl ReduceMotionSetting { + pub fn should_reduce_motion(&self, cx: &gpui::App) -> bool { + match self.0 { + ReduceMotion::System => cx.should_reduce_motion(), + ReduceMotion::On => true, + ReduceMotion::Off => false, + } + } +} + +pub fn should_reduce_motion(cx: &gpui::App) -> bool { + ReduceMotionSetting::get_global(cx).should_reduce_motion(cx) +} + +impl Settings for ReduceMotionSetting { + fn from_settings(settings: &crate::settings_content::SettingsContent) -> Self { + ReduceMotionSetting(settings.workspace.reduce_motion.unwrap_or_default()) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::SettingsStore; + use gpui::{TestAppContext, UpdateGlobal}; + use settings_content::ReduceMotion; + + fn init_test(cx: &mut TestAppContext) { + let store = cx.update(|cx| SettingsStore::test(cx)); + cx.update(|cx| cx.set_global(store)); + } + + fn set_reduce_motion(cx: &mut TestAppContext, value: ReduceMotion) { + cx.update(|cx| { + SettingsStore::update_global(cx, |store, cx| { + store.update_user_settings(cx, |settings| { + settings.workspace.reduce_motion = Some(value); + }); + }); + }); + } + + #[gpui::test] + fn test_should_reduce_motion_variants(cx: &mut TestAppContext) { + init_test(cx); + cx.update(|cx| { + assert_eq!(ReduceMotionSetting::default().0, ReduceMotion::System); + assert!(ReduceMotionSetting(ReduceMotion::On).should_reduce_motion(cx)); + assert!(!ReduceMotionSetting(ReduceMotion::Off).should_reduce_motion(cx)); + assert!(!ReduceMotionSetting(ReduceMotion::System).should_reduce_motion(cx)); + }); + } + + #[gpui::test] + fn test_global_should_reduce_motion(cx: &mut TestAppContext) { + init_test(cx); + + cx.update(|cx| assert!(!should_reduce_motion(cx))); + + set_reduce_motion(cx, ReduceMotion::On); + cx.update(|cx| assert!(should_reduce_motion(cx))); + + set_reduce_motion(cx, ReduceMotion::Off); + cx.update(|cx| assert!(!should_reduce_motion(cx))); + } +} diff --git a/crates/settings/src/settings.rs b/crates/settings/src/settings.rs index af7b0c79ff154d..f1c06abc89f701 100644 --- a/crates/settings/src/settings.rs +++ b/crates/settings/src/settings.rs @@ -3,6 +3,7 @@ mod content_into_gpui; mod editable_setting_control; mod editorconfig_store; mod keymap_file; +mod reduce_motion_setting; mod settings_file; mod settings_store; mod vscode_import; @@ -34,6 +35,7 @@ pub use ::settings_content::*; pub use base_keymap_setting::*; pub use content_into_gpui::IntoGpui; pub use editable_setting_control::*; +pub use reduce_motion_setting::*; pub use editorconfig_store::{ Editorconfig, EditorconfigEvent, EditorconfigProperties, EditorconfigStore, }; diff --git a/crates/settings/src/vscode_import.rs b/crates/settings/src/vscode_import.rs index de8e266dd5581c..840ad5cc622441 100644 --- a/crates/settings/src/vscode_import.rs +++ b/crates/settings/src/vscode_import.rs @@ -873,6 +873,7 @@ impl VsCodeSettings { } }), zoomed_padding: None, + reduce_motion: None, } } diff --git a/crates/settings_content/src/workspace.rs b/crates/settings_content/src/workspace.rs index 178baba2e64c9a..55b1fee6c977dd 100644 --- a/crates/settings_content/src/workspace.rs +++ b/crates/settings_content/src/workspace.rs @@ -116,6 +116,13 @@ pub struct WorkspaceSettingsContent { /// What draws window decorations/titlebar, the client application (Zed) or display server /// Default: client pub window_decorations: Option, + /// Whether to reduce motion in UI animations. + /// When set to "system", follows the OS accessibility setting. + /// When set to "on", animations are always reduced. + /// When set to "off", animations always play. + /// + /// Default: system + pub reduce_motion: Option, } #[with_fallible_options] @@ -336,6 +343,33 @@ pub enum WindowDecorations { Server, } +#[derive( + Copy, + Clone, + Default, + Debug, + Serialize, + Deserialize, + PartialEq, + Eq, + JsonSchema, + MergeFrom, + strum::VariantArray, + strum::VariantNames, +)] +#[serde(rename_all = "snake_case")] +pub enum ReduceMotion { + /// Follow the OS accessibility setting for reduced motion + #[default] + System, + /// Always reduce motion (skip animations) + #[serde(alias = "true")] + On, + /// Never reduce motion (always animate) + #[serde(alias = "false")] + Off, +} + #[derive( Copy, Clone, diff --git a/crates/settings_ui/src/page_data.rs b/crates/settings_ui/src/page_data.rs index 7d60b7e6a88c6d..2eda0db96214fd 100644 --- a/crates/settings_ui/src/page_data.rs +++ b/crates/settings_ui/src/page_data.rs @@ -1213,12 +1213,34 @@ fn appearance_page() -> SettingsPage { ] } + fn reduce_motion_section() -> [SettingsPageItem; 2] { + [ + SettingsPageItem::SectionHeader("Motion"), + SettingsPageItem::SettingItem(SettingItem { + title: "Reduce Motion", + description: "Controls whether animations are reduced. When set to System, follows your OS accessibility preference.", + field: Box::new(SettingField { + json_path: Some("reduce_motion"), + pick: |settings_content| { + settings_content.workspace.reduce_motion.as_ref() + }, + write: |settings_content, value| { + settings_content.workspace.reduce_motion = value; + }, + }), + metadata: None, + files: USER, + }), + ] + } + let items: Box<[SettingsPageItem]> = concat_sections!( theme_section(), buffer_font_section(), ui_font_section(), agent_panel_font_section(), text_rendering_section(), + reduce_motion_section(), cursor_section(), highlighting_section(), guides_section(), diff --git a/crates/settings_ui/src/settings_ui.rs b/crates/settings_ui/src/settings_ui.rs index 14367749879d95..73d60033670e1a 100644 --- a/crates/settings_ui/src/settings_ui.rs +++ b/crates/settings_ui/src/settings_ui.rs @@ -534,6 +534,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_editable_number_field) // please semicolon stay on next line ; diff --git a/crates/ui/src/components/popover_menu.rs b/crates/ui/src/components/popover_menu.rs index cd79e50ce01b1f..1f427ef3a5b684 100644 --- a/crates/ui/src/components/popover_menu.rs +++ b/crates/ui/src/components/popover_menu.rs @@ -1,11 +1,13 @@ use std::{cell::RefCell, rc::Rc}; use gpui::{ - AnyElement, AnyView, App, Bounds, Corner, DismissEvent, DispatchPhase, Element, ElementId, - Entity, Focusable as _, GlobalElementId, HitboxBehavior, HitboxId, InteractiveElement, - IntoElement, LayoutId, Length, ManagedView, MouseDownEvent, ParentElement, Pixels, Point, - Style, Window, anchored, deferred, div, point, prelude::FluentBuilder, px, size, + Animation, AnimationExt, AnyElement, AnyView, App, Bounds, Corner, DismissEvent, + DispatchPhase, Element, ElementId, Entity, Focusable as _, GlobalElementId, HitboxBehavior, + HitboxId, InteractiveElement, IntoElement, LayoutId, Length, ManagedView, MouseDownEvent, + ParentElement, Pixels, Point, Style, Window, anchored, deferred, div, ease_out_quint, point, + prelude::FluentBuilder, px, size, }; +use settings::should_reduce_motion; use crate::prelude::*; @@ -363,6 +365,8 @@ impl Element for PopoverMenu { let element_state = element_state.unwrap_or_default(); let mut menu_layout_id = None; + let reduce_motion = should_reduce_motion(cx); + let menu_element = element_state.menu.borrow_mut().as_mut().map(|menu| { let offset = self.resolved_offset(window); let mut anchored = anchored() @@ -373,7 +377,28 @@ impl Element for PopoverMenu { anchored = anchored.position(child_bounds.corner(self.resolved_attach()) + offset); } - let mut element = deferred(anchored.child(div().occlude().child(menu.clone()))) + let menu_entity_id = menu.entity_id(); + let menu_div = div() + .relative() + .occlude() + .child(menu.clone()); + let animated_menu = if reduce_motion { + menu_div.into_any() + } else { + menu_div + .with_animation( + ("popover-menu-animate", menu_entity_id), + Animation::new(AnimationDuration::Fast.into()) + .with_easing(ease_out_quint()), + move |this, delta| { + const SLIDE_OFFSET: f32 = -6.0; + let slide = SLIDE_OFFSET * (1.0 - delta); + this.opacity(delta).top(px(slide)) + }, + ) + .into_any() + }; + let mut element = deferred(anchored.child(animated_menu)) .with_priority(1) .into_any(); diff --git a/crates/workspace/src/dock.rs b/crates/workspace/src/dock.rs index b3397dd48f5805..a5951ae8cdb776 100644 --- a/crates/workspace/src/dock.rs +++ b/crates/workspace/src/dock.rs @@ -5,18 +5,21 @@ use anyhow::Context as _; use client::proto; use gpui::{ - Action, AnyView, App, Axis, Context, Corner, Entity, EntityId, EventEmitter, FocusHandle, - Focusable, IntoElement, KeyContext, MouseButton, MouseDownEvent, MouseUpEvent, ParentElement, - Render, SharedString, StyleRefinement, Styled, Subscription, WeakEntity, Window, deferred, div, - px, + Action, Animation, AnimationExt, AnyView, App, Axis, Context, Corner, Entity, EntityId, + EventEmitter, FocusHandle, Focusable, IntoElement, KeyContext, MouseButton, MouseDownEvent, + MouseUpEvent, ParentElement, Render, SharedString, StyleRefinement, Styled, Subscription, Task, + WeakEntity, Window, deferred, div, ease_out_cubic, px, }; -use settings::SettingsStore; +use settings::{SettingsStore, should_reduce_motion}; use std::sync::Arc; +use std::time::Duration; use ui::{ContextMenu, Divider, DividerColor, IconButton, Tooltip, h_flex}; use ui::{prelude::*, right_click_menu}; use util::ResultExt as _; pub(crate) const RESIZE_HANDLE_SIZE: Pixels = px(6.); +const DOCK_OPEN_DURATION: Duration = Duration::from_millis(150); +const DOCK_CLOSE_DURATION: Duration = Duration::from_millis(100); pub enum PanelEvent { ZoomIn, @@ -268,11 +271,14 @@ pub struct Dock { panel_entries: Vec, workspace: WeakEntity, is_open: bool, + is_closing: bool, active_panel_index: Option, focus_handle: FocusHandle, pub(crate) serialized_dock: Option, zoom_layer_open: bool, modal_layer: Entity, + animation_generation: usize, + _close_task: Option>, _subscriptions: [Subscription; 2], } @@ -364,11 +370,14 @@ impl Dock { panel_entries: Default::default(), active_panel_index: None, is_open: false, + is_closing: false, focus_handle: focus_handle.clone(), _subscriptions: [focus_subscription, zoom_subscription], serialized_dock: None, zoom_layer_open: false, modal_layer, + animation_generation: 0, + _close_task: None, } }); @@ -481,12 +490,48 @@ impl Dock { } pub fn set_open(&mut self, open: bool, window: &mut Window, cx: &mut Context) { - if open != self.is_open { - self.is_open = open; + if open { + if self.is_closing { + self._close_task = None; + self.is_closing = false; + } + if self.is_open { + return; + } + self.is_open = true; + // Prevents stale close tasks from clearing state after a new open/close cycle has begun. + self.animation_generation = self.animation_generation.wrapping_add(1); if let Some(active_panel) = self.active_panel_entry() { - active_panel.panel.set_active(open, window, cx); + active_panel.panel.set_active(true, window, cx); + } + cx.notify(); + } else { + if !self.is_open { + return; + } + self.is_open = false; + if let Some(active_panel) = self.active_panel_entry() { + active_panel.panel.set_active(false, window, cx); + } + if !should_reduce_motion(cx) { + self.is_closing = true; + self.animation_generation = self.animation_generation.wrapping_add(1); + let close_generation = self.animation_generation; + self._close_task = Some(cx.spawn(async move |this, cx| { + cx.background_executor() + .timer(DOCK_CLOSE_DURATION) + .await; + if let Some(this) = this.upgrade() { + this.update(cx, |dock, cx| { + if dock.animation_generation == close_generation { + dock.is_closing = false; + dock._close_task = None; + cx.notify(); + } + }); + } + })); } - cx.notify(); } } @@ -762,7 +807,8 @@ impl Dock { } fn visible_entry(&self) -> Option<&PanelEntry> { - if self.is_open { + // Panel remains visible during close animation so it can animate out smoothly. + if self.is_open || self.is_closing { self.active_panel_entry() } else { None @@ -912,7 +958,11 @@ impl Render for Dock { } }; - div() + let is_closing = self.is_closing; + let animation_generation = self.animation_generation; + let reduce_motion = should_reduce_motion(cx); + + let dock_div = div() .key_context(dispatch_context) .track_focus(&self.focus_handle(cx)) .flex() @@ -943,11 +993,36 @@ impl Render for Dock { ) .when(self.resizable(cx), |this| { this.child(create_resize_handle()) - }) + }); + + if reduce_motion { + dock_div.into_any_element() + } else { + dock_div + .with_animation( + ("dock-anim", animation_generation as u64), + Animation::new(if is_closing { DOCK_CLOSE_DURATION } else { DOCK_OPEN_DURATION }) + .with_easing(ease_out_cubic), + { + let position = self.position; + let target_size = f32::from(size); + move |this, delta| { + let progress = if is_closing { 1.0 - delta } else { delta }; + let animated_size = px(target_size * progress); + match position.axis() { + Axis::Horizontal => this.w(animated_size), + Axis::Vertical => this.h(animated_size), + } + } + }, + ) + .into_any_element() + } } else { div() .key_context(dispatch_context) .track_focus(&self.focus_handle(cx)) + .into_any_element() } } } @@ -1074,6 +1149,200 @@ impl Render for PanelButtons { } } +#[cfg(test)] +mod tests { + use super::*; + use super::test::TestPanel; + use fs::FakeFs; + use gpui::{TestAppContext, UpdateGlobal, VisualTestContext}; + use project::Project; + use settings::SettingsStore; + + fn init_test(cx: &mut TestAppContext) { + cx.update(|cx| { + let settings_store = SettingsStore::test(cx); + cx.set_global(settings_store); + theme::init(theme::LoadThemes::JustBase, cx); + }); + } + + fn add_dock_with_panel( + workspace: &Entity, + cx: &mut VisualTestContext, + ) -> (Entity, Entity) { + let dock = workspace.update_in(cx, |workspace, _window, _cx| { + workspace.left_dock.clone() + }); + let panel = cx.new(|cx| TestPanel::new(DockPosition::Left, 0, cx)); + dock.update_in(cx, |dock, window, cx| { + dock.add_panel(panel.clone(), workspace.downgrade(), window, cx); + dock.activate_panel(0, window, cx); + }); + (dock, panel) + } + + #[gpui::test] + async fn test_dock_open_close_lifecycle(cx: &mut TestAppContext) { + init_test(cx); + let fs = FakeFs::new(cx.executor()); + let project = Project::test(fs, [], cx).await; + let (workspace, cx) = + cx.add_window_view(|window, cx| crate::Workspace::test_new(project, window, cx)); + let (dock, _panel) = add_dock_with_panel(&workspace, cx); + + dock.update_in(cx, |dock, window, cx| { + dock.set_open(true, window, cx); + assert!(dock.is_open); + assert!(!dock.is_closing); + + dock.set_open(false, window, cx); + assert!(!dock.is_open); + }); + } + + #[gpui::test] + async fn test_dock_set_open_false_with_reduce_motion(cx: &mut TestAppContext) { + init_test(cx); + let fs = FakeFs::new(cx.executor()); + let project = Project::test(fs, [], cx).await; + let (workspace, cx) = + cx.add_window_view(|window, cx| crate::Workspace::test_new(project, window, cx)); + + cx.update(|_window, cx| { + SettingsStore::update_global(cx, |store: &mut SettingsStore, cx| { + store.update_user_settings(cx, |settings| { + settings.workspace.reduce_motion = Some(settings::ReduceMotion::On); + }); + }); + }); + + let (dock, _panel) = add_dock_with_panel(&workspace, cx); + + dock.update_in(cx, |dock, window, cx| { + dock.set_open(true, window, cx); + dock.set_open(false, window, cx); + assert!(!dock.is_open); + assert!(!dock.is_closing); + }); + } + + #[gpui::test] + async fn test_dock_close_animation_lifecycle(cx: &mut TestAppContext) { + init_test(cx); + let fs = FakeFs::new(cx.executor()); + let project = Project::test(fs, [], cx).await; + let (workspace, cx) = + cx.add_window_view(|window, cx| crate::Workspace::test_new(project, window, cx)); + let (dock, _panel) = add_dock_with_panel(&workspace, cx); + + dock.update_in(cx, |dock, window, cx| { + dock.set_open(true, window, cx); + dock.set_open(false, window, cx); + assert!(dock.is_closing); + assert!(dock.visible_entry().is_some()); + }); + + cx.executor().advance_clock(Duration::from_millis(150)); + cx.executor().run_until_parked(); + + dock.update_in(cx, |dock, _window, _cx| { + assert!(!dock.is_closing); + }); + } + + #[gpui::test] + async fn test_dock_reopen_during_close_cancels_animation(cx: &mut TestAppContext) { + init_test(cx); + let fs = FakeFs::new(cx.executor()); + let project = Project::test(fs, [], cx).await; + let (workspace, cx) = + cx.add_window_view(|window, cx| crate::Workspace::test_new(project, window, cx)); + let (dock, _panel) = add_dock_with_panel(&workspace, cx); + + dock.update_in(cx, |dock, window, cx| { + dock.set_open(true, window, cx); + dock.set_open(false, window, cx); + assert!(dock.is_closing); + + dock.set_open(true, window, cx); + assert!(dock.is_open); + assert!(!dock.is_closing); + assert!(dock._close_task.is_none()); + }); + } + + #[gpui::test] + async fn test_dock_noop_operations(cx: &mut TestAppContext) { + init_test(cx); + let fs = FakeFs::new(cx.executor()); + let project = Project::test(fs, [], cx).await; + let (workspace, cx) = + cx.add_window_view(|window, cx| crate::Workspace::test_new(project, window, cx)); + let (dock, _panel) = add_dock_with_panel(&workspace, cx); + + dock.update_in(cx, |dock, window, cx| { + let generation_before = dock.animation_generation; + dock.set_open(false, window, cx); + assert_eq!(dock.animation_generation, generation_before); + + dock.set_open(true, window, cx); + let generation_before = dock.animation_generation; + dock.set_open(true, window, cx); + assert_eq!(dock.animation_generation, generation_before); + }); + } + + #[gpui::test] + async fn test_dock_active_panel_set_active_on_open_close(cx: &mut TestAppContext) { + init_test(cx); + let fs = FakeFs::new(cx.executor()); + let project = Project::test(fs, [], cx).await; + let (workspace, cx) = + cx.add_window_view(|window, cx| crate::Workspace::test_new(project, window, cx)); + let (dock, panel) = add_dock_with_panel(&workspace, cx); + + dock.update_in(cx, |dock, window, cx| { + dock.set_open(true, window, cx); + }); + + panel.read_with(cx, |panel, _| { + assert!(panel.active); + }); + + dock.update_in(cx, |dock, window, cx| { + dock.set_open(false, window, cx); + }); + + panel.read_with(cx, |panel, _| { + assert!(!panel.active); + }); + } + + #[gpui::test] + async fn test_dock_animation_generation_increments(cx: &mut TestAppContext) { + init_test(cx); + let fs = FakeFs::new(cx.executor()); + let project = Project::test(fs, [], cx).await; + let (workspace, cx) = + cx.add_window_view(|window, cx| crate::Workspace::test_new(project, window, cx)); + let (dock, _panel) = add_dock_with_panel(&workspace, cx); + + let generation_0 = dock.read_with(cx, |dock, _| dock.animation_generation); + + dock.update_in(cx, |dock, window, cx| { + dock.set_open(true, window, cx); + }); + let generation_1 = dock.read_with(cx, |dock, _| dock.animation_generation); + assert_eq!(generation_1, generation_0 + 1); + + dock.update_in(cx, |dock, window, cx| { + dock.set_open(false, window, cx); + }); + let generation_2 = dock.read_with(cx, |dock, _| dock.animation_generation); + assert!(generation_2 > generation_1); + } +} + impl StatusItemView for PanelButtons { fn set_active_pane_item( &mut self, diff --git a/crates/workspace/src/modal_layer.rs b/crates/workspace/src/modal_layer.rs index 5949c0b1fffb21..1915213f295dd0 100644 --- a/crates/workspace/src/modal_layer.rs +++ b/crates/workspace/src/modal_layer.rs @@ -1,9 +1,16 @@ +use std::time::Duration; + use gpui::{ - AnyView, DismissEvent, Entity, EventEmitter, FocusHandle, Focusable as _, ManagedView, - MouseButton, Subscription, + Animation, AnimationExt, AnyView, DismissEvent, Entity, EventEmitter, FocusHandle, + Focusable as _, ManagedView, MouseButton, Subscription, Task, ease_out_cubic, }; +use settings::should_reduce_motion; use ui::prelude::*; +const MODAL_OPEN_DURATION: Duration = Duration::from_millis(150); +const MODAL_CLOSE_DURATION: Duration = Duration::from_millis(100); +const MODAL_SLIDE_OFFSET: f32 = -6.0; + #[derive(Debug)] pub enum DismissDecision { Dismiss(bool), @@ -60,9 +67,17 @@ pub struct ActiveModal { focus_handle: FocusHandle, } +struct ClosingModal { + modal_view: AnyView, + fade_out_background: bool, +} + pub struct ModalLayer { active_modal: Option, dismiss_on_focus_lost: bool, + closing_modal: Option, + animation_generation: usize, + _close_task: Option>, } pub(crate) struct ModalOpenedEvent; @@ -80,16 +95,12 @@ impl ModalLayer { Self { active_modal: None, dismiss_on_focus_lost: false, + closing_modal: None, + animation_generation: 0, + _close_task: None, } } - /// Toggles a modal of type `V`. If a modal of the same type is currently active, - /// it will be hidden. If a different modal is active, it will be replaced with the new one. - /// If no modal is active, the new modal will be shown. - /// - /// If closing the current modal fails (e.g., due to `on_before_dismiss` returning - /// `DismissDecision::Dismiss(false)` or `DismissDecision::Pending`), the new modal - /// will not be shown. pub fn toggle_modal(&mut self, window: &mut Window, cx: &mut Context, build_view: B) where V: ModalView, @@ -102,17 +113,25 @@ impl ModalLayer { return; } } + self.cancel_close_animation(); let new_modal = cx.new(|cx| build_view(window, cx)); self.show_modal(new_modal, window, cx); cx.emit(ModalOpenedEvent); } - /// Shows a modal and sets up subscriptions for dismiss events and focus tracking. - /// The modal is automatically focused after being shown. + fn cancel_close_animation(&mut self) { + self.closing_modal = None; + self._close_task = None; + } + fn show_modal(&mut self, new_modal: Entity, window: &mut Window, cx: &mut Context) where V: ModalView, { + self.cancel_close_animation(); + // Prevents stale close tasks from clearing state after a new open/close cycle has begun. + self.animation_generation = self.animation_generation.wrapping_add(1); + let focus_handle = cx.focus_handle(); self.active_modal = Some(ActiveModal { modal: Box::new(new_modal.clone()), @@ -139,13 +158,6 @@ impl ModalLayer { cx.notify(); } - /// Attempts to hide the currently active modal. - /// - /// The modal's `on_before_dismiss` method is called to determine if dismissal should proceed. - /// If dismissal is allowed, the modal is removed and focus is restored to the previously - /// focused element. - /// - /// Returns `true` if the modal was successfully hidden, `false` otherwise. pub fn hide_modal(&mut self, window: &mut Window, cx: &mut Context) -> bool { let Some(active_modal) = self.active_modal.as_mut() else { self.dismiss_on_focus_lost = false; @@ -153,12 +165,11 @@ impl ModalLayer { }; match active_modal.modal.on_before_dismiss(window, cx) { - DismissDecision::Dismiss(should_dismiss) => { - if !should_dismiss { - self.dismiss_on_focus_lost = !should_dismiss; - return false; - } + DismissDecision::Dismiss(false) => { + self.dismiss_on_focus_lost = true; + return false; } + DismissDecision::Dismiss(true) => {} DismissDecision::Pending => { self.dismiss_on_focus_lost = false; return false; @@ -166,18 +177,43 @@ impl ModalLayer { } if let Some(active_modal) = self.active_modal.take() { - if let Some(previous_focus) = active_modal.previous_focus_handle - && active_modal.focus_handle.contains_focused(window, cx) - { - previous_focus.focus(window, cx); + if let Some(previous_focus) = &active_modal.previous_focus_handle { + if active_modal.focus_handle.contains_focused(window, cx) { + previous_focus.focus(window, cx); + } + } + + let fade_out_background = active_modal.modal.fade_out_background(cx); + let render_bare = active_modal.modal.render_bare(cx); + + if !render_bare && !should_reduce_motion(cx) { + self.closing_modal = Some(ClosingModal { + modal_view: active_modal.modal.view(), + fade_out_background, + }); + self.animation_generation = self.animation_generation.wrapping_add(1); + let generation = self.animation_generation; + + self._close_task = Some(cx.spawn(async move |this, cx| { + cx.background_executor().timer(MODAL_CLOSE_DURATION).await; + if let Some(this) = this.upgrade() { + this.update(cx, |this, cx| { + if this.animation_generation == generation { + this.closing_modal = None; + this._close_task = None; + cx.notify(); + } + }); + } + })); } + cx.notify(); } self.dismiss_on_focus_lost = false; true } - /// Returns the currently active modal if it is of type `V`. pub fn active_modal(&self) -> Option> where V: 'static, @@ -191,46 +227,273 @@ impl ModalLayer { } } +#[cfg(test)] +mod tests { + use super::*; + use gpui::{div, Empty, TestAppContext, UpdateGlobal}; + use settings::SettingsStore; + + macro_rules! define_test_modal { + ($name:ident) => { + struct $name { + focus_handle: FocusHandle, + } + + impl $name { + fn new(cx: &mut gpui::Context) -> Self { + Self { + focus_handle: cx.focus_handle(), + } + } + } + + impl Render for $name { + fn render( + &mut self, + _window: &mut Window, + cx: &mut gpui::Context, + ) -> impl IntoElement { + div().track_focus(&self.focus_handle(cx)) + } + } + + impl gpui::Focusable for $name { + fn focus_handle(&self, _cx: &App) -> FocusHandle { + self.focus_handle.clone() + } + } + + impl EventEmitter for $name {} + impl ModalView for $name {} + }; + } + + define_test_modal!(TestModalA); + define_test_modal!(TestModalB); + + fn init_test(cx: &mut TestAppContext) { + cx.update(|cx| { + let settings_store = SettingsStore::test(cx); + cx.set_global(settings_store); + theme::init(theme::LoadThemes::JustBase, cx); + }); + } + + fn new_modal_layer(cx: &mut gpui::VisualTestContext) -> Entity { + cx.new(|_cx| ModalLayer::new()) + } + + #[gpui::test] + async fn test_toggle_modal_open_and_close(cx: &mut TestAppContext) { + init_test(cx); + let (_view, cx) = cx.add_window_view(|_window, _cx| Empty); + let layer = new_modal_layer(cx); + + layer.update_in(cx, |layer, window, cx| { + layer.toggle_modal(window, cx, |_window, cx| TestModalA::new(cx)); + assert!(layer.active_modal.is_some()); + + layer.toggle_modal::(window, cx, |_window, cx| TestModalA::new(cx)); + assert!(layer.active_modal.is_none()); + }); + } + + #[gpui::test] + async fn test_toggle_modal_replaces_different_type(cx: &mut TestAppContext) { + init_test(cx); + let (_view, cx) = cx.add_window_view(|_window, _cx| Empty); + let layer = new_modal_layer(cx); + + layer.update_in(cx, |layer, window, cx| { + layer.toggle_modal(window, cx, |_window, cx| TestModalA::new(cx)); + assert!(layer.active_modal::().is_some()); + + layer.toggle_modal(window, cx, |_window, cx| TestModalB::new(cx)); + assert!(layer.active_modal::().is_some()); + assert!(layer.active_modal::().is_none()); + }); + } + + #[gpui::test] + async fn test_hide_modal_starts_close_animation(cx: &mut TestAppContext) { + init_test(cx); + let (_view, cx) = cx.add_window_view(|_window, _cx| Empty); + let layer = new_modal_layer(cx); + + layer.update_in(cx, |layer, window, cx| { + assert!(!layer.hide_modal(window, cx)); + + layer.toggle_modal(window, cx, |_window, cx| TestModalA::new(cx)); + layer.hide_modal(window, cx); + assert!(layer.active_modal.is_none()); + assert!(layer.closing_modal.is_some()); + }); + } + + #[gpui::test] + async fn test_hide_modal_skips_animation_with_reduce_motion(cx: &mut TestAppContext) { + init_test(cx); + + cx.update(|cx| { + SettingsStore::update_global(cx, |store: &mut SettingsStore, cx| { + store.update_user_settings(cx, |settings| { + settings.workspace.reduce_motion = Some(settings::ReduceMotion::On); + }); + }); + }); + + let (_view, cx) = cx.add_window_view(|_window, _cx| Empty); + let layer = new_modal_layer(cx); + + layer.update_in(cx, |layer, window, cx| { + layer.toggle_modal(window, cx, |_window, cx| TestModalA::new(cx)); + layer.hide_modal(window, cx); + assert!(layer.active_modal.is_none()); + assert!(layer.closing_modal.is_none()); + }); + } + + #[gpui::test] + async fn test_close_animation_completes(cx: &mut TestAppContext) { + init_test(cx); + let (_view, cx) = cx.add_window_view(|_window, _cx| Empty); + let layer = new_modal_layer(cx); + + layer.update_in(cx, |layer, window, cx| { + layer.toggle_modal(window, cx, |_window, cx| TestModalA::new(cx)); + layer.hide_modal(window, cx); + assert!(layer.closing_modal.is_some()); + }); + + cx.executor() + .advance_clock(MODAL_CLOSE_DURATION + std::time::Duration::from_millis(50)); + cx.executor().run_until_parked(); + + layer.update_in(cx, |layer, _window, _cx| { + assert!(layer.closing_modal.is_none()); + }); + } + + #[gpui::test] + async fn test_open_during_close_cancels_animation(cx: &mut TestAppContext) { + init_test(cx); + let (_view, cx) = cx.add_window_view(|_window, _cx| Empty); + let layer = new_modal_layer(cx); + + layer.update_in(cx, |layer, window, cx| { + layer.toggle_modal(window, cx, |_window, cx| TestModalA::new(cx)); + layer.hide_modal(window, cx); + assert!(layer.closing_modal.is_some()); + + layer.toggle_modal(window, cx, |_window, cx| TestModalB::new(cx)); + assert!(layer.closing_modal.is_none()); + assert!(layer.active_modal.is_some()); + }); + } + + #[gpui::test] + async fn test_animation_generation_increments(cx: &mut TestAppContext) { + init_test(cx); + let (_view, cx) = cx.add_window_view(|_window, _cx| Empty); + let layer = new_modal_layer(cx); + + let generation_0 = layer.read_with(cx, |layer, _| layer.animation_generation); + + layer.update_in(cx, |layer, window, cx| { + layer.toggle_modal(window, cx, |_window, cx| TestModalA::new(cx)); + }); + let generation_1 = layer.read_with(cx, |layer, _| layer.animation_generation); + assert!(generation_1 > generation_0); + + layer.update_in(cx, |layer, window, cx| { + layer.hide_modal(window, cx); + }); + let generation_2 = layer.read_with(cx, |layer, _| layer.animation_generation); + assert!(generation_2 > generation_1); + } +} + impl Render for ModalLayer { fn render(&mut self, _: &mut Window, cx: &mut Context) -> impl IntoElement { - let Some(active_modal) = &self.active_modal else { - return div().into_any_element(); - }; + let generation = self.animation_generation; - if active_modal.modal.render_bare(cx) { - return active_modal.modal.view().into_any_element(); - } + let (modal_view, fade_out_background, focus_handle, is_closing) = + if let Some(active_modal) = &self.active_modal { + if active_modal.modal.render_bare(cx) { + return active_modal.modal.view().into_any_element(); + } + ( + active_modal.modal.view(), + active_modal.modal.fade_out_background(cx), + Some(active_modal.focus_handle.clone()), + false, + ) + } else if let Some(closing_modal) = &self.closing_modal { + ( + closing_modal.modal_view.clone(), + closing_modal.fade_out_background, + None, + true, + ) + } else { + return div().into_any_element(); + }; + + let reduce_motion = should_reduce_motion(cx); + + let modal_content = h_flex() + .occlude() + .child(modal_view) + .on_mouse_down(MouseButton::Left, |_, _, cx| { + cx.stop_propagation(); + }); + + let animated_content = if reduce_motion { + modal_content.into_any_element() + } else { + let duration = if is_closing { + MODAL_CLOSE_DURATION + } else { + MODAL_OPEN_DURATION + }; + modal_content + .with_animation( + ("modal-anim", generation as u64), + Animation::new(duration).with_easing(ease_out_cubic), + move |this, delta| { + let progress = if is_closing { 1.0 - delta } else { delta }; + let slide = MODAL_SLIDE_OFFSET * (1.0 - progress); + this.opacity(progress).top(px(slide)) + }, + ) + .into_any_element() + }; div() .absolute() .size_full() .inset_0() .occlude() - .when(active_modal.modal.fade_out_background(cx), |this| { + .when(fade_out_background, |this| { let mut background = cx.theme().colors().elevated_surface_background; background.fade_out(0.2); this.bg(background) }) - .on_mouse_down( - MouseButton::Left, - cx.listener(|this, _, window, cx| { - this.hide_modal(window, cx); - }), - ) + .when(!is_closing, |this| { + this.on_mouse_down( + MouseButton::Left, + cx.listener(|this, _, window, cx| { + this.hide_modal(window, cx); + }), + ) + }) .child( v_flex() .h(px(0.0)) .top_20() .items_center() - .track_focus(&active_modal.focus_handle) - .child( - h_flex() - .occlude() - .child(active_modal.modal.view()) - .on_mouse_down(MouseButton::Left, |_, _, cx| { - cx.stop_propagation(); - }), - ), + .when_some(focus_handle, |this, handle| this.track_focus(&handle)) + .child(animated_content), ) .into_any_element() } diff --git a/crates/workspace/src/utility_pane.rs b/crates/workspace/src/utility_pane.rs index 2760000216d916..35aa926fcbc054 100644 --- a/crates/workspace/src/utility_pane.rs +++ b/crates/workspace/src/utility_pane.rs @@ -1,7 +1,10 @@ +use std::time::Duration; + use gpui::{ - AppContext as _, EntityId, MouseButton, Pixels, Render, StatefulInteractiveElement, - Subscription, WeakEntity, deferred, px, + Animation, AnimationExt as _, AppContext as _, EntityId, MouseButton, Pixels, Render, + StatefulInteractiveElement, Subscription, Task, WeakEntity, deferred, ease_out_cubic, px, }; +use settings::should_reduce_motion; use ui::{ ActiveTheme as _, Context, FluentBuilder as _, InteractiveElement as _, IntoElement, ParentElement as _, RenderOnce, Styled as _, Window, div, @@ -14,6 +17,8 @@ use crate::{ pub(crate) const UTILITY_PANE_RESIZE_HANDLE_SIZE: Pixels = px(6.0); pub(crate) const UTILITY_PANE_MIN_WIDTH: Pixels = px(20.0); +const UTILITY_PANE_OPEN_DURATION: Duration = Duration::from_millis(150); +const UTILITY_PANE_CLOSE_DURATION: Duration = Duration::from_millis(100); #[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] pub enum UtilityPaneSlot { @@ -24,6 +29,9 @@ pub enum UtilityPaneSlot { struct UtilityPaneSlotState { panel_id: EntityId, utility_pane: Box, + animation_generation: usize, + is_closing: bool, + _close_task: Option>, _subscriptions: Vec, } @@ -33,6 +41,22 @@ pub struct UtilityPaneState { right_slot: Option, } +impl UtilityPaneState { + fn slot(&self, slot: UtilityPaneSlot) -> &Option { + match slot { + UtilityPaneSlot::Left => &self.left_slot, + UtilityPaneSlot::Right => &self.right_slot, + } + } + + fn slot_mut(&mut self, slot: UtilityPaneSlot) -> &mut Option { + match slot { + UtilityPaneSlot::Left => &mut self.left_slot, + UtilityPaneSlot::Right => &mut self.right_slot, + } + } +} + #[derive(Clone)] pub struct DraggedUtilityPane(pub UtilityPaneSlot); @@ -52,18 +76,10 @@ pub fn utility_slot_for_dock_position(position: DockPosition) -> UtilityPaneSlot impl Workspace { pub fn utility_pane(&self, slot: UtilityPaneSlot) -> Option<&dyn UtilityPaneHandle> { - match slot { - UtilityPaneSlot::Left => self - .utility_panes - .left_slot - .as_ref() - .map(|s| s.utility_pane.as_ref()), - UtilityPaneSlot::Right => self - .utility_panes - .right_slot - .as_ref() - .map(|s| s.utility_pane.as_ref()), - } + self.utility_panes + .slot(slot) + .as_ref() + .map(|state| state.utility_pane.as_ref()) } pub fn toggle_utility_pane( @@ -72,9 +88,20 @@ impl Workspace { window: &mut Window, cx: &mut Context, ) { - if let Some(handle) = self.utility_pane(slot) { - let current = handle.expanded(cx); - handle.set_expanded(!current, cx); + if let Some(state) = self.utility_panes.slot_mut(slot).as_mut() { + let current = state.utility_pane.expanded(cx); + if current { + state.utility_pane.set_expanded(false, cx); + } else { + // Cancel any pending close animation so the stale close task + // doesn't clear the slot the user just re-expanded. + if state.is_closing { + state.is_closing = false; + state.animation_generation = state.animation_generation.wrapping_add(1); + state._close_task = None; + } + state.utility_pane.set_expanded(true, cx); + } } cx.notify(); self.serialize_workspace(window, cx); @@ -102,34 +129,61 @@ impl Workspace { let subscriptions = vec![minimize_subscription, close_subscription]; let boxed_handle: Box = Box::new(handle); - match slot { - UtilityPaneSlot::Left => { - self.utility_panes.left_slot = Some(UtilityPaneSlotState { - panel_id, - utility_pane: boxed_handle, - _subscriptions: subscriptions, - }); - } - UtilityPaneSlot::Right => { - self.utility_panes.right_slot = Some(UtilityPaneSlotState { - panel_id, - utility_pane: boxed_handle, - _subscriptions: subscriptions, - }); - } - } + let next_generation = self + .utility_panes + .slot(slot) + .as_ref() + .map(|state| state.animation_generation.wrapping_add(1)) + .unwrap_or(0); + + *self.utility_panes.slot_mut(slot) = Some(UtilityPaneSlotState { + panel_id, + utility_pane: boxed_handle, + animation_generation: next_generation, + is_closing: false, + _close_task: None, + _subscriptions: subscriptions, + }); cx.notify(); } pub fn clear_utility_pane(&mut self, slot: UtilityPaneSlot, cx: &mut Context) { - match slot { - UtilityPaneSlot::Left => { - self.utility_panes.left_slot = None; - } - UtilityPaneSlot::Right => { - self.utility_panes.right_slot = None; - } + let Some(state) = self.utility_panes.slot_mut(slot).as_mut() else { + return; + }; + + if state.is_closing { + return; + } + + if should_reduce_motion(cx) { + *self.utility_panes.slot_mut(slot) = None; + cx.notify(); + return; } + + state.is_closing = true; + // Prevents stale close tasks from clearing state after a new open/close cycle has begun. + state.animation_generation = state.animation_generation.wrapping_add(1); + let close_generation = state.animation_generation; + state._close_task = Some(cx.spawn(async move |this, cx| { + cx.background_executor() + .timer(UTILITY_PANE_CLOSE_DURATION) + .await; + if let Some(this) = this.upgrade() { + this.update(cx, |workspace, cx| { + let matches_generation = workspace + .utility_panes + .slot(slot) + .as_ref() + .is_some_and(|state| state.animation_generation == close_generation); + if matches_generation { + *workspace.utility_panes.slot_mut(slot) = None; + cx.notify(); + } + }); + } + })); cx.notify(); } @@ -139,24 +193,37 @@ impl Workspace { provider_panel_id: EntityId, cx: &mut Context, ) { - let should_clear = match slot { - UtilityPaneSlot::Left => self - .utility_panes - .left_slot - .as_ref() - .is_some_and(|slot| slot.panel_id == provider_panel_id), - UtilityPaneSlot::Right => self - .utility_panes - .right_slot - .as_ref() - .is_some_and(|slot| slot.panel_id == provider_panel_id), - }; + let should_clear = self + .utility_panes + .slot(slot) + .as_ref() + .is_some_and(|state| state.panel_id == provider_panel_id && !state.is_closing); if should_clear { self.clear_utility_pane(slot, cx); } } + pub(crate) fn utility_pane_frame( + &self, + slot: UtilityPaneSlot, + cx: &mut Context, + ) -> Option { + let state = self.utility_panes.slot(slot).as_ref()?; + let pane = &state.utility_pane; + let should_show = pane.expanded(cx) || state.is_closing; + if !should_show { + return None; + } + Some(UtilityPaneFrame::new( + slot, + pane.box_clone(), + state.animation_generation, + state.is_closing, + cx, + )) + } + pub fn resize_utility_pane( &mut self, slot: UtilityPaneSlot, @@ -192,12 +259,16 @@ pub struct UtilityPaneFrame { workspace: WeakEntity, slot: UtilityPaneSlot, handle: Box, + animation_generation: usize, + is_closing: bool, } impl UtilityPaneFrame { pub fn new( slot: UtilityPaneSlot, handle: Box, + animation_generation: usize, + is_closing: bool, cx: &mut Context, ) -> Self { let workspace = cx.weak_entity(); @@ -205,15 +276,102 @@ impl UtilityPaneFrame { workspace, slot, handle, + animation_generation, + is_closing, } } } +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_utility_pane_state_slots() { + let state = UtilityPaneState::default(); + assert!(state.slot(UtilityPaneSlot::Left).is_none()); + assert!(std::ptr::eq(state.slot(UtilityPaneSlot::Left), &state.left_slot)); + assert!(state.slot(UtilityPaneSlot::Right).is_none()); + assert!(std::ptr::eq(state.slot(UtilityPaneSlot::Right), &state.right_slot)); + } + + #[test] + fn test_utility_slot_for_dock_position() { + assert_eq!( + utility_slot_for_dock_position(DockPosition::Left), + UtilityPaneSlot::Left + ); + assert_eq!( + utility_slot_for_dock_position(DockPosition::Right), + UtilityPaneSlot::Right + ); + assert_eq!( + utility_slot_for_dock_position(DockPosition::Bottom), + UtilityPaneSlot::Left + ); + } + + #[test] + fn test_utility_pane_slot_state_initial_values() { + let state = UtilityPaneState::default(); + assert!(state.slot(UtilityPaneSlot::Left).is_none()); + assert!(state.slot(UtilityPaneSlot::Right).is_none()); + } + + #[test] + fn test_utility_pane_slot_mut_independence() { + let mut state = UtilityPaneState::default(); + assert!(state.slot(UtilityPaneSlot::Left).is_none()); + assert!(state.slot(UtilityPaneSlot::Right).is_none()); + + let left = state.slot_mut(UtilityPaneSlot::Left); + assert!(left.is_none()); + + let right = state.slot_mut(UtilityPaneSlot::Right); + assert!(right.is_none()); + } + + #[test] + fn test_utility_pane_slot_returns_correct_field() { + let state = UtilityPaneState::default(); + assert!(std::ptr::eq( + state.slot(UtilityPaneSlot::Left), + &state.left_slot + )); + assert!(std::ptr::eq( + state.slot(UtilityPaneSlot::Right), + &state.right_slot + )); + } + + #[test] + fn test_utility_pane_slot_mut_returns_correct_field() { + let mut state = UtilityPaneState::default(); + assert!(std::ptr::eq( + state.slot_mut(UtilityPaneSlot::Left), + &state.left_slot + )); + assert!(std::ptr::eq( + state.slot_mut(UtilityPaneSlot::Right), + &state.right_slot + )); + } + + // Animation lifecycle tests (is_closing, animation_generation, _close_task) + // require a full Workspace test fixture because clear_utility_pane and + // register_utility_pane operate on &mut Workspace with a Context. These + // transitions are best tested via integration tests that can construct a + // Workspace, similar to the patterns in dock.rs tests. +} + impl RenderOnce for UtilityPaneFrame { fn render(self, _window: &mut Window, cx: &mut ui::App) -> impl IntoElement { let workspace = self.workspace.clone(); let slot = self.slot; let width = self.handle.width(cx); + let is_closing = self.is_closing; + let animation_generation = self.animation_generation; + let reduce_motion = should_reduce_motion(cx); let create_resize_handle = || { let workspace_handle = workspace.clone(); @@ -231,8 +389,8 @@ impl RenderOnce for UtilityPaneFrame { }) .on_mouse_up( MouseButton::Left, - move |e: &gpui::MouseUpEvent, window, cx| { - if e.click_count == 2 { + move |event: &gpui::MouseUpEvent, window, cx| { + if event.click_count == 2 { workspace_handle .update(cx, |workspace, cx| { workspace.reset_utility_pane_width(slot, window, cx); @@ -242,41 +400,62 @@ impl RenderOnce for UtilityPaneFrame { } }, ) - .occlude(); - - match slot { - UtilityPaneSlot::Left => deferred( - handle - .absolute() - .right(-UTILITY_PANE_RESIZE_HANDLE_SIZE / 2.) - .top(px(0.)) - .h_full() - .w(UTILITY_PANE_RESIZE_HANDLE_SIZE) - .cursor_col_resize(), - ), - UtilityPaneSlot::Right => deferred( - handle - .absolute() - .left(-UTILITY_PANE_RESIZE_HANDLE_SIZE / 2.) - .top(px(0.)) - .h_full() - .w(UTILITY_PANE_RESIZE_HANDLE_SIZE) - .cursor_col_resize(), - ), - } + .occlude() + .absolute() + .top(px(0.)) + .h_full() + .w(UTILITY_PANE_RESIZE_HANDLE_SIZE) + .cursor_col_resize() + .when(slot == UtilityPaneSlot::Left, |this| { + this.right(-UTILITY_PANE_RESIZE_HANDLE_SIZE / 2.) + }) + .when(slot == UtilityPaneSlot::Right, |this| { + this.left(-UTILITY_PANE_RESIZE_HANDLE_SIZE / 2.) + }); + + deferred(handle) }; - div() + let pane_div = div() .h_full() .bg(cx.theme().colors().tab_bar_background) .w(width) .border_color(cx.theme().colors().border) + .overflow_hidden() .when(self.slot == UtilityPaneSlot::Left, |this| this.border_r_1()) .when(self.slot == UtilityPaneSlot::Right, |this| { this.border_l_1() }) - .child(create_resize_handle()) - .child(self.handle.to_any()) - .into_any_element() + .child( + div() + .min_w(width) + .h_full() + .child(self.handle.to_any()), + ) + .when(!is_closing, |this| this.child(create_resize_handle())); + + if reduce_motion { + pane_div.into_any_element() + } else { + pane_div + .with_animation( + ("utility-pane-anim", animation_generation as u64), + Animation::new(if is_closing { + UTILITY_PANE_CLOSE_DURATION + } else { + UTILITY_PANE_OPEN_DURATION + }) + .with_easing(ease_out_cubic), + { + let target_width = f32::from(width); + move |this, delta| { + let progress = if is_closing { 1.0 - delta } else { delta }; + let animated_width = px(target_width * progress); + this.w(animated_width) + } + }, + ) + .into_any_element() + } } } diff --git a/crates/workspace/src/workspace.rs b/crates/workspace/src/workspace.rs index 4341d61fbe6b6c..9ef78b176aa3e0 100644 --- a/crates/workspace/src/workspace.rs +++ b/crates/workspace/src/workspace.rs @@ -145,7 +145,7 @@ use crate::{ model::{DockData, DockStructure, SerializedItem, SerializedPane, SerializedPaneGroup}, }, security_modal::SecurityModal, - utility_pane::{DraggedUtilityPane, UtilityPaneFrame, UtilityPaneSlot, UtilityPaneState}, + utility_pane::{DraggedUtilityPane, UtilityPaneSlot, UtilityPaneState}, }; pub const SERIALIZATION_THROTTLE_TIME: Duration = Duration::from_millis(200); @@ -7382,12 +7382,8 @@ impl Render for Workspace { cx, )) .when(cx.has_flag::(), |this| { - this.when_some(self.utility_pane(UtilityPaneSlot::Left), |this, pane| { - this.when(pane.expanded(cx), |this| { - this.child( - UtilityPaneFrame::new(UtilityPaneSlot::Left, pane.box_clone(), cx) - ) - }) + this.when_some(self.utility_pane_frame(UtilityPaneSlot::Left, cx), |this, frame| { + this.child(frame) }) }) .child( @@ -7432,12 +7428,8 @@ impl Render for Workspace { ), ) .when(cx.has_flag::(), |this| { - this.when_some(self.utility_pane(UtilityPaneSlot::Right), |this, pane| { - this.when(pane.expanded(cx), |this| { - this.child( - UtilityPaneFrame::new(UtilityPaneSlot::Right, pane.box_clone(), cx) - ) - }) + this.when_some(self.utility_pane_frame(UtilityPaneSlot::Right, cx), |this, frame| { + this.child(frame) }) }) .children(self.render_dock( @@ -7471,12 +7463,8 @@ impl Render for Workspace { .flex_1() .children(self.render_dock(DockPosition::Left, &self.left_dock, window, cx)) .when(cx.has_flag::(), |this| { - this.when_some(self.utility_pane(UtilityPaneSlot::Left), |this, pane| { - this.when(pane.expanded(cx), |this| { - this.child( - UtilityPaneFrame::new(UtilityPaneSlot::Left, pane.box_clone(), cx) - ) - }) + this.when_some(self.utility_pane_frame(UtilityPaneSlot::Left, cx), |this, frame| { + this.child(frame) }) }) .child( @@ -7506,12 +7494,8 @@ impl Render for Workspace { .when_some(paddings.1, |this, p| this.child(p.border_l_1())), ) ) - .when_some(self.utility_pane(UtilityPaneSlot::Right), |this, pane| { - this.when(pane.expanded(cx), |this| { - this.child( - UtilityPaneFrame::new(UtilityPaneSlot::Right, pane.box_clone(), cx) - ) - }) + .when_some(self.utility_pane_frame(UtilityPaneSlot::Right, cx), |this, frame| { + this.child(frame) }) ) .child( @@ -7538,12 +7522,8 @@ impl Render for Workspace { cx, )) .when(cx.has_flag::(), |this| { - this.when_some(self.utility_pane(UtilityPaneSlot::Left), |this, pane| { - this.when(pane.expanded(cx), |this| { - this.child( - UtilityPaneFrame::new(UtilityPaneSlot::Left, pane.box_clone(), cx) - ) - }) + this.when_some(self.utility_pane_frame(UtilityPaneSlot::Left, cx), |this, frame| { + this.child(frame) }) }) .child( @@ -7585,12 +7565,8 @@ impl Render for Workspace { ) ) .when(cx.has_flag::(), |this| { - this.when_some(self.utility_pane(UtilityPaneSlot::Right), |this, pane| { - this.when(pane.expanded(cx), |this| { - this.child( - UtilityPaneFrame::new(UtilityPaneSlot::Right, pane.box_clone(), cx) - ) - }) + this.when_some(self.utility_pane_frame(UtilityPaneSlot::Right, cx), |this, frame| { + this.child(frame) }) }) .children(self.render_dock(DockPosition::Right, &self.right_dock, window, cx)) @@ -7612,12 +7588,8 @@ impl Render for Workspace { window, cx, )) - .when_some(self.utility_pane(UtilityPaneSlot::Left), |this, pane| { - this.when(pane.expanded(cx), |this| { - this.child( - UtilityPaneFrame::new(UtilityPaneSlot::Left, pane.box_clone(), cx) - ) - }) + .when_some(self.utility_pane_frame(UtilityPaneSlot::Left, cx), |this, frame| { + this.child(frame) }) .child( div() @@ -7657,12 +7629,8 @@ impl Render for Workspace { )), ) .when(cx.has_flag::(), |this| { - this.when_some(self.utility_pane(UtilityPaneSlot::Right), |this, pane| { - this.when(pane.expanded(cx), |this| { - this.child( - UtilityPaneFrame::new(UtilityPaneSlot::Right, pane.box_clone(), cx) - ) - }) + this.when_some(self.utility_pane_frame(UtilityPaneSlot::Right, cx), |this, frame| { + this.child(frame) }) }) .children(self.render_dock( diff --git a/crates/workspace/src/workspace_settings.rs b/crates/workspace/src/workspace_settings.rs index 1ef0cd29947782..ed786452b95103 100644 --- a/crates/workspace/src/workspace_settings.rs +++ b/crates/workspace/src/workspace_settings.rs @@ -34,6 +34,7 @@ pub struct WorkspaceSettings { pub use_system_window_tabs: bool, pub zoomed_padding: bool, pub window_decorations: settings::WindowDecorations, + pub reduce_motion: settings::ReduceMotion, } #[derive(Copy, Clone, PartialEq, Debug, Default)] @@ -111,6 +112,7 @@ impl Settings for WorkspaceSettings { use_system_window_tabs: workspace.use_system_window_tabs.unwrap(), zoomed_padding: workspace.zoomed_padding.unwrap(), window_decorations: workspace.window_decorations.unwrap(), + reduce_motion: workspace.reduce_motion.unwrap(), } } }