diff --git a/assets/settings/default.json b/assets/settings/default.json index 9bb7d056bbfc75..7881a62b366436 100644 --- a/assets/settings/default.json +++ b/assets/settings/default.json @@ -700,6 +700,10 @@ // to both the horizontal and vertical delta values while scrolling. Fast scrolling // happens when a user holds the alt or option key while scrolling. "fast_scroll_sensitivity": 4.0, + "smooth_scroll": { + // Whether to animate scrolling with a smooth easing effect. + "enabled": false, + }, "sticky_scroll": { // Whether to stick scopes to the top of the editor. "enabled": false, diff --git a/crates/editor/src/editor.rs b/crates/editor/src/editor.rs index 1be65d476b8f65..d1cfd4637e3412 100644 --- a/crates/editor/src/editor.rs +++ b/crates/editor/src/editor.rs @@ -9683,6 +9683,7 @@ impl Editor { { let editor_settings = EditorSettings::get_global(cx); self.scroll_manager.vertical_scroll_margin = editor_settings.vertical_scroll_margin; + self.scroll_manager.smooth_scroll = editor_settings.smooth_scroll.enabled; if self.breadcrumbs_visibility.settings_visibility() != editor_settings.toolbar.breadcrumbs { diff --git a/crates/editor/src/editor_settings.rs b/crates/editor/src/editor_settings.rs index 05a89a5907ce72..6f842d0c002ccd 100644 --- a/crates/editor/src/editor_settings.rs +++ b/crates/editor/src/editor_settings.rs @@ -38,6 +38,7 @@ pub struct EditorSettings { pub scroll_sensitivity: f32, pub mouse_wheel_zoom: bool, pub fast_scroll_sensitivity: f32, + pub smooth_scroll: SmoothScroll, pub sticky_scroll: StickyScroll, pub relative_line_numbers: RelativeLineNumbers, pub seed_search_query_from_cursor: SeedQuerySetting, @@ -82,6 +83,11 @@ pub struct StickyScroll { pub enabled: bool, } +#[derive(Copy, Clone, Debug, PartialEq)] +pub struct SmoothScroll { + pub enabled: bool, +} + #[derive(Clone, Debug, PartialEq, Eq)] pub struct Toolbar { pub breadcrumbs: bool, @@ -203,6 +209,7 @@ impl Settings for EditorSettings { let search = editor.search.unwrap(); let drag_and_drop_selection = editor.drag_and_drop_selection.unwrap(); let sticky_scroll = editor.sticky_scroll.unwrap(); + let smooth_scroll = editor.smooth_scroll.unwrap(); Self { cursor_blink: editor.cursor_blink.unwrap(), cursor_shape: editor.cursor_shape.map(Into::into), @@ -264,6 +271,9 @@ impl Settings for EditorSettings { scroll_sensitivity: editor.scroll_sensitivity.unwrap(), mouse_wheel_zoom: editor.mouse_wheel_zoom.unwrap(), fast_scroll_sensitivity: editor.fast_scroll_sensitivity.unwrap(), + smooth_scroll: SmoothScroll { + enabled: smooth_scroll.enabled.unwrap(), + }, sticky_scroll: StickyScroll { enabled: sticky_scroll.enabled.unwrap(), }, diff --git a/crates/editor/src/editor_tests.rs b/crates/editor/src/editor_tests.rs index a3a0acd392449f..cef345ab85ab02 100644 --- a/crates/editor/src/editor_tests.rs +++ b/crates/editor/src/editor_tests.rs @@ -6,7 +6,7 @@ use crate::{ element::{StickyHeader, header_jump_data}, linked_editing_ranges::LinkedEditingRanges, runnables::RunnableTasks, - scroll::scroll_amount::ScrollAmount, + scroll::{ScrollBehavior, scroll_amount::ScrollAmount}, test::{ assert_text_with_selections, build_editor, editor_content_with_blocks, editor_lsp_test_context::{EditorLspTestContext, git_commit_lang}, @@ -19,7 +19,7 @@ use collections::HashMap; use futures::{StreamExt, channel::oneshot}; use gpui::{ BackgroundExecutor, DismissEvent, Task, TaskExt, TestAppContext, UpdateGlobal, - VisualTestContext, WindowBounds, WindowOptions, div, + VisualTestContext, WindowBounds, WindowOptions, div, point, }; use indoc::indoc; use language::{ @@ -2864,6 +2864,142 @@ async fn test_move_start_of_paragraph_end_of_paragraph(cx: &mut TestAppContext) cx.assert_editor_state(&"ˇone\ntwo\n \nthree\nfour\nfive\n\nsix"); } +#[gpui::test] +async fn test_instant_scroll_request_during_scroll_animation(cx: &mut TestAppContext) { + init_test(cx, |_| {}); + update_test_editor_settings(cx, &|settings| { + settings.smooth_scroll = Some(settings::SmoothScrollContent { + enabled: Some(true), + }); + }); + let mut cx = EditorTestContext::new(cx).await; + let line_height = cx.update_editor(|editor, window, cx| { + editor.set_vertical_scroll_margin(0, cx); + editor + .style(cx) + .text + .line_height_in_pixels(window.rem_size()) + }); + let window = cx.window; + cx.simulate_window_resize(window, size(px(1000.), 4. * line_height)); + cx.set_state(indoc! {" + ˇone + two + three + four + five + six + seven + eight + nine + ten + eleven + twelve + "}); + + cx.update_editor(|editor, window, cx| { + editor.scroll( + point(0., 8.), + None, + Some(ScrollBehavior::RequestAnimation), + window, + cx, + ); + assert!( + editor + .scroll_manager + .scroll_animation() + .is_some_and(|animation| animation.is_animating()) + ); + + editor.scroll( + point(0., 2.), + None, + Some(ScrollBehavior::Instant), + window, + cx, + ); + assert!( + editor + .scroll_manager + .scroll_animation() + .is_some_and(|animation| animation.is_finished()) + ); + + editor.flush_scroll_animation(window, cx); + assert_eq!(editor.snapshot(window, cx).scroll_position(), point(0., 2.)); + assert!(editor.scroll_manager.scroll_animation().is_none()); + }); +} + +#[gpui::test] +async fn test_smooth_scroll_setting_update_during_animation(cx: &mut TestAppContext) { + init_test(cx, |_| {}); + update_test_editor_settings(cx, &|settings| { + settings.smooth_scroll = Some(settings::SmoothScrollContent { + enabled: Some(true), + }); + }); + let mut cx = EditorTestContext::new(cx).await; + let line_height = cx.update_editor(|editor, window, cx| { + editor.set_vertical_scroll_margin(0, cx); + editor + .style(cx) + .text + .line_height_in_pixels(window.rem_size()) + }); + let window = cx.window; + cx.simulate_window_resize(window, size(px(1000.), 4. * line_height)); + cx.set_state(indoc! {" + ˇone + two + three + four + five + six + seven + eight + nine + ten + eleven + twelve + "}); + + cx.update_editor(|editor, window, cx| { + assert!(editor.scroll_manager.smooth_scroll); + editor.scroll(point(0., 8.), None, None, window, cx); + assert!( + editor + .scroll_manager + .scroll_animation() + .is_some_and(|animation| animation.is_animating()) + ); + }); + + cx.update(|_, cx| { + SettingsStore::update_global(cx, |store, cx| { + store.update_user_settings(cx, |settings| { + settings.editor.smooth_scroll = Some(settings::SmoothScrollContent { + enabled: Some(false), + }); + }); + }); + }); + cx.run_until_parked(); + + cx.update_editor(|editor, window, cx| { + assert!(!editor.scroll_manager.smooth_scroll); + + editor.set_scroll_top_row(DisplayRow(3), window, cx); + assert_eq!(editor.snapshot(window, cx).scroll_position(), point(0., 3.)); + assert!(editor.scroll_manager.scroll_animation().is_none()); + + editor.set_scroll_top_row(DisplayRow(6), window, cx); + assert_eq!(editor.snapshot(window, cx).scroll_position(), point(0., 6.)); + assert!(editor.scroll_manager.scroll_animation().is_none()); + }); +} + #[gpui::test] async fn test_scroll_page_up_page_down(cx: &mut TestAppContext) { init_test(cx, |_| {}); @@ -2896,28 +3032,37 @@ async fn test_scroll_page_up_page_down(cx: &mut TestAppContext) { editor.snapshot(window, cx).scroll_position(), gpui::Point::new(0., 0.) ); + editor.scroll_screen(&ScrollAmount::Page(1.), window, cx); + editor.flush_scroll_animation(window, cx); assert_eq!( editor.snapshot(window, cx).scroll_position(), gpui::Point::new(0., 3.) ); + editor.scroll_screen(&ScrollAmount::Page(1.), window, cx); + editor.flush_scroll_animation(window, cx); assert_eq!( editor.snapshot(window, cx).scroll_position(), gpui::Point::new(0., 6.) ); + editor.scroll_screen(&ScrollAmount::Page(-1.), window, cx); + editor.flush_scroll_animation(window, cx); assert_eq!( editor.snapshot(window, cx).scroll_position(), gpui::Point::new(0., 3.) ); editor.scroll_screen(&ScrollAmount::Page(-0.5), window, cx); + editor.flush_scroll_animation(window, cx); assert_eq!( editor.snapshot(window, cx).scroll_position(), gpui::Point::new(0., 1.) ); + editor.scroll_screen(&ScrollAmount::Page(0.5), window, cx); + editor.flush_scroll_animation(window, cx); assert_eq!( editor.snapshot(window, cx).scroll_position(), gpui::Point::new(0., 3.) @@ -26065,7 +26210,13 @@ async fn test_expand_first_line_diff_hunk_keeps_deleted_lines_visible( cx.set_state("ˇnew\nsecond\nthird\n"); cx.set_head_text("old\nsecond\nthird\n"); cx.update_editor(|editor, window, cx| { - editor.scroll(gpui::Point { x: 0., y: 0. }, None, window, cx); + editor.scroll( + gpui::Point { x: 0., y: 0. }, + None, + Some(ScrollBehavior::Instant), + window, + cx, + ); }); executor.run_until_parked(); assert_eq!(cx.update_editor(|e, _, cx| e.scroll_position(cx)).y, 0.0); @@ -33919,7 +34070,13 @@ async fn test_sticky_scroll(cx: &mut TestAppContext) { let mut sticky_headers = |offset: ScrollOffset| { cx.update_editor(|e, window, cx| { - e.scroll(gpui::Point { x: 0., y: offset }, None, window, cx); + e.scroll( + gpui::Point { x: 0., y: offset }, + None, + Some(ScrollBehavior::Instant), + window, + cx, + ); }); cx.run_until_parked(); cx.update_editor(|e, window, cx| { @@ -34008,7 +34165,13 @@ async fn test_sticky_scroll_with_decoration_prefix_in_item(cx: &mut TestAppConte let mut sticky_headers = |offset: ScrollOffset| { cx.update_editor(|e, window, cx| { - e.scroll(gpui::Point { x: 0., y: offset }, None, window, cx); + e.scroll( + gpui::Point { x: 0., y: offset }, + None, + Some(ScrollBehavior::Instant), + window, + cx, + ); }); cx.run_until_parked(); cx.update_editor(|e, window, cx| { @@ -34071,7 +34234,7 @@ async fn test_sticky_scroll_anchors_multiline_c_signature_on_name_row(cx: &mut T let mut sticky_headers = |offset: ScrollOffset| { cx.update_editor(|editor, window, cx| { - editor.scroll(gpui::Point { x: 0., y: offset }, None, window, cx); + editor.scroll(gpui::Point { x: 0., y: offset }, None, None, window, cx); }); cx.run_until_parked(); cx.update_editor(|editor, window, cx| { @@ -34154,7 +34317,13 @@ async fn test_sticky_scroll_with_expanded_deleted_diff_hunks( let mut sticky_headers = |offset: ScrollOffset| { cx.update_editor(|e, window, cx| { - e.scroll(gpui::Point { x: 0., y: offset }, None, window, cx); + e.scroll( + gpui::Point { x: 0., y: offset }, + None, + Some(ScrollBehavior::Instant), + window, + cx, + ); }); cx.run_until_parked(); cx.update_editor(|e, window, cx| { @@ -34210,7 +34379,13 @@ async fn test_no_duplicated_sticky_headers(cx: &mut TestAppContext) { let mut sticky_headers = |offset: ScrollOffset| { cx.update_editor(|e, window, cx| { - e.scroll(gpui::Point { x: 0., y: offset }, None, window, cx); + e.scroll( + gpui::Point { x: 0., y: offset }, + None, + Some(ScrollBehavior::Instant), + window, + cx, + ); }); cx.run_until_parked(); cx.update_editor(|e, window, cx| { @@ -34507,9 +34682,11 @@ async fn test_scroll_by_clicking_sticky_header(cx: &mut TestAppContext) { y: scroll_offset, }, None, + Some(ScrollBehavior::Instant), window, cx, ); + e.flush_scroll_animation(window, cx); }); cx.run_until_parked(); cx.simulate_click( @@ -34598,7 +34775,13 @@ async fn test_scroll_by_clicking_sticky_header(cx: &mut TestAppContext) { // The text "impl Bar {" starts at column 0, so column 5 = 'B'. let click_x = text_origin_x + em_width * 5.5; cx.update_editor(|e, window, cx| { - e.scroll(gpui::Point { x: 0., y: 4.5 }, None, window, cx); + e.scroll( + gpui::Point { x: 0., y: 4.5 }, + None, + Some(ScrollBehavior::Instant), + window, + cx, + ); }); cx.run_until_parked(); cx.simulate_click( @@ -34672,7 +34855,13 @@ async fn test_clicking_sticky_header_sets_character_select_mode(cx: &mut TestApp editor.end_selection(window, cx); // Scroll down one row to make `fn foo() {` a sticky header - editor.scroll(gpui::Point { x: 0., y: 1. }, None, window, cx); + editor.scroll( + gpui::Point { x: 0., y: 1. }, + None, + Some(ScrollBehavior::Instant), + window, + cx, + ); }); cx.run_until_parked(); diff --git a/crates/editor/src/element.rs b/crates/editor/src/element.rs index 634adcc1892783..c41710acb77dcd 100644 --- a/crates/editor/src/element.rs +++ b/crates/editor/src/element.rs @@ -7975,6 +7975,14 @@ impl Element for EditorElement { ); editor.set_visible_column_count(f64::from(editor_width / em_advance)); + if let Some(animation) = editor.scroll_manager.update_animation() { + editor.set_scroll_position(animation.position(), window, cx); + + if animation.is_animating() { + window.request_animation_frame(); + } + } + if matches!( editor.mode, EditorMode::AutoHeight { .. } | EditorMode::Minimap { .. } diff --git a/crates/editor/src/element/mouse.rs b/crates/editor/src/element/mouse.rs index 5c0709c8d2d1e2..e74363071e7679 100644 --- a/crates/editor/src/element/mouse.rs +++ b/crates/editor/src/element/mouse.rs @@ -489,7 +489,6 @@ impl EditorElement { let position_map = layout.position_map.clone(); let editor = self.editor.clone(); let hitbox = layout.hitbox.clone(); - let mut delta = ScrollDelta::default(); // Set a minimum scroll_sensitivity of 0.01 to make sure the user doesn't // accidentally turn off their scrolling. @@ -503,8 +502,6 @@ impl EditorElement { move |event: &ScrollWheelEvent, phase, window, cx| { if phase == DispatchPhase::Bubble && hitbox.should_handle_scroll(window) { - delta = delta.coalesce(event.delta); - if event.modifiers.secondary() && editor.read(cx).enable_mouse_wheel_zoom && EditorSettings::get_global(cx).mouse_wheel_zoom @@ -533,7 +530,7 @@ impl EditorElement { editor.update(cx, |editor, cx| { let line_height = position_map.line_height; let glyph_width = position_map.em_layout_width; - let (delta, axis) = match delta { + let (delta, axis) = match event.delta { gpui::ScrollDelta::Pixels(mut pixels) => { //Trackpad let axis = @@ -549,7 +546,15 @@ impl EditorElement { } }; - let current_scroll_position = position_map.snapshot.scroll_position(); + let current_scroll_position = match editor + .scroll_manager + .scroll_animation() + .map(|animation| animation.target_position()) + { + Some(target) => target, + None => editor.scroll_position(cx), + }; + let x = (current_scroll_position.x * ScrollPixelOffset::from(glyph_width) - ScrollPixelOffset::from(delta.x * scroll_sensitivity)) @@ -567,7 +572,7 @@ impl EditorElement { } if scroll_position != current_scroll_position { - editor.scroll(scroll_position, axis, window, cx); + editor.scroll(scroll_position, axis, None, window, cx); cx.stop_propagation(); } else if y < 0. && !forbid_vertical_scroll { // Due to clamping, we may fail to detect cases of overscroll to the top; diff --git a/crates/editor/src/scroll.rs b/crates/editor/src/scroll.rs index ec7f9036c4a2d4..ecd01c4c2de684 100644 --- a/crates/editor/src/scroll.rs +++ b/crates/editor/src/scroll.rs @@ -29,6 +29,7 @@ use workspace::{ItemId, WorkspaceId}; pub const SCROLL_EVENT_SEPARATION: Duration = Duration::from_millis(28); const SCROLLBAR_SHOW_INTERVAL: Duration = Duration::from_secs(1); +const SCROLL_ANIMATION_DURATION: Duration = Duration::from_millis(125); pub struct WasScrolled(pub(crate) bool); @@ -201,8 +202,233 @@ impl ActiveScrollbarState { } } +#[derive(Default, Clone, Copy, Debug, PartialEq, Eq)] +pub enum ScrollBehavior { + #[default] + Instant, + RequestAnimation, +} + +#[derive(Clone, Copy, Debug, PartialEq)] +pub(crate) struct ScrollAnimationProgress(f32); + +impl Eq for ScrollAnimationProgress {} + +impl PartialOrd for ScrollAnimationProgress { + fn partial_cmp(&self, other: &Self) -> Option { + Some(self.cmp(other)) + } +} + +impl Ord for ScrollAnimationProgress { + fn cmp(&self, other: &Self) -> std::cmp::Ordering { + self.0.total_cmp(&other.0) + } +} + +impl ScrollAnimationProgress { + pub(crate) const COMPLETE: Self = Self(1.0); + + pub fn value(self) -> f32 { + self.0 + } + + pub fn remaining(self) -> f32 { + Self::COMPLETE.0 - self.0 + } + + pub fn is_finished(&self) -> bool { + *self >= Self::COMPLETE + } +} + +#[derive(Clone, Copy, Debug)] +pub(crate) enum ScrollAnimation { + Completed { + position: gpui::Point, + }, + Animating { + position: gpui::Point, + start_position: gpui::Point, + target_position: gpui::Point, + start_time: Instant, + duration: Duration, + }, +} + +impl ScrollAnimation { + pub fn target_position(&self) -> gpui::Point { + match self { + Self::Completed { position } => *position, + Self::Animating { + target_position, .. + } => *target_position, + } + } + + pub fn position(&self) -> gpui::Point { + match self { + Self::Completed { position } | Self::Animating { position, .. } => *position, + } + } + + pub fn is_animating(&self) -> bool { + matches!(self, Self::Animating { .. }) + } + + pub fn is_finished(&self) -> bool { + !self.is_animating() + } + + fn progress_at(&self, now: Instant) -> ScrollAnimationProgress { + match self { + Self::Completed { .. } => ScrollAnimationProgress::COMPLETE, + Self::Animating { + start_position, + target_position, + start_time, + duration, + .. + } => { + if start_position == target_position { + return ScrollAnimationProgress::COMPLETE; + } + + let elapsed = now.duration_since(*start_time).as_secs_f32(); + let duration = duration.as_secs_f32(); + + ScrollAnimationProgress(elapsed / duration).min(ScrollAnimationProgress::COMPLETE) + } + } + } + + pub fn advance(&mut self) { + self.advance_at(Instant::now()); + } + + fn advance_at(&mut self, now: Instant) { + let Self::Animating { + start_position, + target_position, + .. + } = *self + else { + return; + }; + + let progress = self.progress_at(now); + if progress.is_finished() { + *self = Self::Completed { + position: target_position, + }; + } else { + let current_x = Self::interpolate(start_position.x, target_position.x, progress); + let current_y = Self::interpolate(start_position.y, target_position.y, progress); + + if let Self::Animating { position, .. } = self { + *position = point(current_x, current_y); + } + } + } + + pub fn restart(&mut self, target: gpui::Point) { + self.restart_at(target, Instant::now()); + } + + fn restart_at(&mut self, target: gpui::Point, now: Instant) { + self.advance_at(now); + let current_position = self.position(); + if current_position == target { + *self = Self::Completed { position: target }; + return; + } + + let new_duration = match self { + Self::Animating { .. } => { + let progress = self.progress_at(now); + if progress.is_finished() { + SCROLL_ANIMATION_DURATION + } else { + self.update_duration_at(target, now) + } + } + Self::Completed { .. } => SCROLL_ANIMATION_DURATION, + }; + + *self = Self::Animating { + position: current_position, + start_position: current_position, + target_position: target, + start_time: now, + duration: new_duration, + }; + } + + fn update_duration_at(&self, new_target: gpui::Point, now: Instant) -> Duration { + let Self::Animating { + start_position, + target_position, + duration, + .. + } = *self + else { + return SCROLL_ANIMATION_DURATION; + }; + + let current_position = self.position(); + let remaining = self.progress_at(now).remaining(); + // The derivative of ease_out_cubic f(t) = 1 - (1-t)^3 is f'(t) = 3(1-t)^2 + let derivative = 3.0 * remaining * remaining; + let old_duration_secs = duration.as_secs_f64(); + + let new_displacement_x = new_target.x - current_position.x; + let new_displacement_y = new_target.y - current_position.y; + + let velocity_x = + (target_position.x - start_position.x) * derivative as f64 / old_duration_secs; + let velocity_y = + (target_position.y - start_position.y) * derivative as f64 / old_duration_secs; + + let (dominant_displacement, dominant_velocity) = + if new_displacement_x.abs() >= new_displacement_y.abs() { + (new_displacement_x, velocity_x) + } else { + (new_displacement_y, velocity_y) + }; + + let direction_reversed = dominant_displacement * dominant_velocity < 0.0; + let velocity_near_zero = dominant_velocity.abs() < 1e-6; + + if direction_reversed || velocity_near_zero { + return SCROLL_ANIMATION_DURATION; + } + + // At t=0, ease_out_cubic has initial velocity v0 = displacement * f'(0) / duration + // Solving for duration: new_duration = displacement * 3 / v + let new_duration_secs = dominant_displacement * 3.0 / dominant_velocity; + + let min_duration = SCROLL_ANIMATION_DURATION.as_secs_f64() / 8.0; + let max_duration = SCROLL_ANIMATION_DURATION.as_secs_f64(); + let clamped = new_duration_secs.abs().clamp(min_duration, max_duration); + + Duration::from_secs_f64(clamped) + } + + fn interpolate( + from: ScrollOffset, + to: ScrollOffset, + progress: ScrollAnimationProgress, + ) -> ScrollOffset { + let delta = to - from; + let eased_progress = (gpui::ease_out_cubic())(progress.value()) as ScrollOffset; + + from + delta * eased_progress + } +} + pub struct ScrollManager { pub(crate) vertical_scroll_margin: ScrollOffset, + pub(crate) smooth_scroll: bool, anchor: Entity, /// Value to be used for clamping the x component of the SharedScrollAnchor's offset. /// @@ -229,17 +455,22 @@ pub struct ScrollManager { forbid_vertical_scroll: bool, notified_top_overscroll: bool, minimap_thumb_state: Option, + scroll_animation: Option, _save_scroll_position_task: Task<()>, } impl ScrollManager { pub fn new(cx: &mut Context) -> Self { + let editor_settings = EditorSettings::get_global(cx); + let vertical_scroll_margin = editor_settings.vertical_scroll_margin; + let smooth_scroll = editor_settings.smooth_scroll.enabled; let anchor = cx.new(|_| SharedScrollAnchor { scroll_anchor: ScrollAnchor::new(), display_map_id: None, }); ScrollManager { - vertical_scroll_margin: EditorSettings::get_global(cx).vertical_scroll_margin, + vertical_scroll_margin, + smooth_scroll, anchor, scroll_max_x: None, ongoing: OngoingScroll::new(), @@ -253,6 +484,7 @@ impl ScrollManager { forbid_vertical_scroll: false, notified_top_overscroll: false, minimap_thumb_state: None, + scroll_animation: None, _save_scroll_position_task: Task::ready(()), } } @@ -397,19 +629,14 @@ impl ScrollManager { pos } - fn set_scroll_position( - &mut self, - scroll_position: gpui::Point, - map: &DisplaySnapshot, + fn clamp_scroll_top( + &self, + scroll_top: ScrollOffset, scroll_beyond_last_line: ScrollBeyondLastLine, - local: bool, - autoscroll: bool, - workspace_id: Option, - window: &mut Window, - cx: &mut Context, - ) -> WasScrolled { - let scroll_top = scroll_position.y.max(0.); - let scroll_top = match scroll_beyond_last_line { + map: &DisplaySnapshot, + ) -> ScrollOffset { + let scroll_top = scroll_top.max(0.); + match scroll_beyond_last_line { ScrollBeyondLastLine::OnePage => scroll_top, ScrollBeyondLastLine::Off => { if let Some(height_in_lines) = self.visible_line_count { @@ -429,7 +656,63 @@ impl ScrollManager { scroll_top } } + } + } + + /// Returns the scroll position the editor is settling towards. + /// + /// When a scroll animation is in flight the visible scroll anchor lags + /// behind its destination, so callers that need to reason about the final + /// (logical) scroll position - such as relative scrolls or cursor follow - + /// must use this instead of [`scroll_position`](Self::scroll_position). + pub fn settled_scroll_position( + &self, + snapshot: &DisplaySnapshot, + scroll_beyond_last_line: ScrollBeyondLastLine, + cx: &App, + ) -> gpui::Point { + let Some(animation) = self.scroll_animation else { + return self.scroll_position(snapshot, cx); + }; + let target = animation.target_position(); + let y = self.clamp_scroll_top(target.y, scroll_beyond_last_line, snapshot); + let mut x = target.x.max(0.); + if let Some(max_x) = self.scroll_max_x { + x = x.min(max_x); + } + point(x, y) + } + + /// Like [`scroll_top_display_point`](Self::scroll_top_display_point), but + /// returns the position the editor is settling towards when a scroll + /// animation is in flight. + pub fn settled_scroll_top_display_point( + &self, + snapshot: &DisplaySnapshot, + scroll_beyond_last_line: ScrollBeyondLastLine, + cx: &App, + ) -> DisplayPoint { + let Some(animation) = self.scroll_animation else { + return self.scroll_top_display_point(snapshot, cx); }; + let target = animation.target_position(); + let y = self.clamp_scroll_top(target.y, scroll_beyond_last_line, snapshot); + let point = DisplayPoint::new(DisplayRow(y as u32), target.x.max(0.) as u32); + snapshot.clip_point(point, Bias::Left) + } + + fn set_scroll_position( + &mut self, + scroll_position: gpui::Point, + map: &DisplaySnapshot, + scroll_beyond_last_line: ScrollBeyondLastLine, + local: bool, + autoscroll: bool, + workspace_id: Option, + window: &mut Window, + cx: &mut Context, + ) -> WasScrolled { + let scroll_top = self.clamp_scroll_top(scroll_position.y, scroll_beyond_last_line, map); let scroll_top_row = DisplayRow(scroll_top as u32); let scroll_top_buffer_point = map .clip_point( @@ -439,22 +722,36 @@ impl ScrollManager { .to_point(map); let top_anchor = map.buffer_snapshot().anchor_before(scroll_top_buffer_point); - self.set_anchor( - ScrollAnchor { - anchor: top_anchor, - offset: point( - scroll_position.x.max(0.), - scroll_top - top_anchor.to_display_point(map).row().as_f64(), - ), - }, - map, - scroll_top_buffer_point.row, - local, - autoscroll, - workspace_id, - window, - cx, - ) + let anchor = ScrollAnchor { + anchor: top_anchor, + offset: point( + scroll_position.x.max(0.), + scroll_top - top_anchor.to_display_point(map).row().as_f64(), + ), + }; + + if let Some(animation) = self.scroll_animation + && animation.is_animating() + { + self.anchor.update(cx, |shared, _| { + shared.scroll_anchor = anchor; + shared.display_map_id = Some(map.display_map_id); + }); + cx.notify(); + + WasScrolled(false) + } else { + self.set_anchor( + anchor, + map, + scroll_top_buffer_point.row, + local, + autoscroll, + workspace_id, + window, + cx, + ) + } } fn set_anchor( @@ -480,6 +777,7 @@ impl ScrollManager { self.scroll_max_x.take(); self.autoscroll_request.take(); + self.scroll_animation.take(); let current = self.anchor.read(cx); if current.scroll_anchor == adjusted_anchor { @@ -658,6 +956,56 @@ impl ScrollManager { pub fn forbid_vertical_scroll(&self) -> bool { self.forbid_vertical_scroll } + + pub fn scroll_to( + &mut self, + current_position: gpui::Point, + target_position: gpui::Point, + behavior: Option, + ) { + let behavior = if self.smooth_scroll { + behavior.unwrap_or(ScrollBehavior::RequestAnimation) + } else { + ScrollBehavior::Instant + }; + + if behavior == ScrollBehavior::Instant { + self.scroll_animation = Some(ScrollAnimation::Completed { + position: target_position, + }); + return; + } + + if self + .scroll_animation + .is_some_and(|a| a.target_position() == target_position) + { + return; + } + + if let Some(animation) = &mut self.scroll_animation { + animation.restart(target_position); + } else { + self.scroll_animation = Some(ScrollAnimation::Animating { + position: current_position, + start_position: current_position, + target_position, + start_time: Instant::now(), + duration: SCROLL_ANIMATION_DURATION, + }) + } + } + + pub(crate) fn scroll_animation(&self) -> Option<&ScrollAnimation> { + self.scroll_animation.as_ref() + } + + pub(crate) fn update_animation(&mut self) -> Option { + self.scroll_animation.as_mut()?.advance(); + self.scroll_animation + .take_if(|animation| animation.is_finished()) + .or(self.scroll_animation) + } } impl Editor { @@ -673,6 +1021,19 @@ impl Editor { self.scroll_manager.scroll_top_display_point(snapshot, cx) } + /// Like [`scroll_top_display_point`](Self::scroll_top_display_point), but + /// returns the position the editor is settling towards when a scroll + /// animation is in flight. + pub fn settled_scroll_top_display_point( + &self, + snapshot: &DisplaySnapshot, + cx: &App, + ) -> DisplayPoint { + let scroll_beyond_last_line = self.scroll_beyond_last_line(cx); + self.scroll_manager + .settled_scroll_top_display_point(snapshot, scroll_beyond_last_line, cx) + } + pub fn vertical_scroll_margin(&self) -> usize { self.scroll_manager.vertical_scroll_margin as usize } @@ -762,19 +1123,26 @@ impl Editor { window: &mut Window, cx: &mut Context, ) { - let snapshot = self.snapshot(window, cx).display_snapshot; - let new_screen_top = DisplayPoint::new(row, 0); - let new_screen_top = new_screen_top.to_offset(&snapshot, Bias::Left); - let new_anchor = snapshot.buffer_snapshot().anchor_before(new_screen_top); + if self.scroll_manager.smooth_scroll { + let current_position = self.scroll_position(cx); + let new_position = point(current_position.x, row.0 as f64); - self.set_scroll_anchor( - ScrollAnchor { - anchor: new_anchor, - offset: Default::default(), - }, - window, - cx, - ); + self.scroll(new_position, None, None, window, cx); + } else { + let snapshot = self.snapshot(window, cx).display_snapshot; + let new_screen_top = DisplayPoint::new(row, 0); + let new_screen_top = new_screen_top.to_offset(&snapshot, Bias::Left); + let new_anchor = snapshot.buffer_snapshot().anchor_before(new_screen_top); + + self.set_scroll_anchor( + ScrollAnchor { + anchor: new_anchor, + offset: Default::default(), + }, + window, + cx, + ); + } } pub(crate) fn set_scroll_position_internal( @@ -904,7 +1272,12 @@ impl Editor { return; } - let mut current_position = self.scroll_position(cx); + let mut current_position = { + let map = self.display_map.update(cx, |map, cx| map.snapshot(cx)); + let scroll_beyond_last_line = self.scroll_beyond_last_line(cx); + self.scroll_manager + .settled_scroll_position(&map, scroll_beyond_last_line, cx) + }; let Some(visible_line_count) = self.visible_line_count() else { return; }; @@ -942,7 +1315,8 @@ impl Editor { amount.columns(visible_column_count), amount.lines(visible_line_count), ); - self.set_scroll_position(new_position, window, cx); + + self.scroll(new_position, None, None, window, cx); } /// Returns an ordering. The newest selection is: @@ -994,4 +1368,152 @@ impl Editor { self.set_scroll_anchor(scroll_anchor, window, cx); } } + + #[cfg(test)] + pub fn flush_scroll_animation(&mut self, window: &mut Window, cx: &mut Context) { + if let Some(animation) = self.scroll_manager.update_animation() { + self.set_scroll_position(animation.position(), window, cx); + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + + const EPSILON: ScrollOffset = 0.000_001; + + fn animating_scroll( + start_position: gpui::Point, + target_position: gpui::Point, + start_time: Instant, + ) -> ScrollAnimation { + ScrollAnimation::Animating { + position: start_position, + start_position, + target_position, + start_time, + duration: SCROLL_ANIMATION_DURATION, + } + } + + fn assert_point_near(actual: gpui::Point, expected: gpui::Point) { + assert!( + (actual.x - expected.x).abs() <= EPSILON, + "actual x {} did not match expected x {}", + actual.x, + expected.x + ); + assert!( + (actual.y - expected.y).abs() <= EPSILON, + "actual y {} did not match expected y {}", + actual.y, + expected.y + ); + } + + #[test] + fn scroll_animation_advances_to_expected_positions_per_frame() { + let start_time = Instant::now(); + let start_position = point(0., 0.); + let target_position = point(90., 45.); + let mut animation = animating_scroll(start_position, target_position, start_time); + + for elapsed_millis in [0, 16, 32, 48, 64, 80, 96, 112, 125, 160] { + let elapsed = Duration::from_millis(elapsed_millis); + animation.advance_at(start_time + elapsed); + + let progress = ScrollAnimationProgress( + (elapsed.as_secs_f32() / SCROLL_ANIMATION_DURATION.as_secs_f32()).min(1.0), + ); + let expected_position = if progress.is_finished() { + target_position + } else { + point( + ScrollAnimation::interpolate(start_position.x, target_position.x, progress), + ScrollAnimation::interpolate(start_position.y, target_position.y, progress), + ) + }; + + assert_point_near(animation.position(), expected_position); + assert_eq!(animation.is_finished(), progress.is_finished()); + } + } + + #[test] + fn scroll_animation_restarts_from_current_position_and_can_start_again_after_completion() { + let start_time = Instant::now(); + let mut animation = animating_scroll(point(0., 0.), point(0., 100.), start_time); + + let restart_time = start_time + Duration::from_millis(25); + animation.advance_at(restart_time); + let restart_position = animation.position(); + animation.restart_at(point(0., 200.), restart_time); + + let ScrollAnimation::Animating { + position, + start_position, + target_position, + start_time: animation_start_time, + duration, + } = animation + else { + panic!("expected restarted scroll animation"); + }; + assert_point_near(position, restart_position); + assert_point_near(start_position, restart_position); + assert_eq!(target_position, point(0., 200.)); + assert_eq!(animation_start_time, restart_time); + assert!(duration <= SCROLL_ANIMATION_DURATION); + assert!(duration.as_secs_f64() >= SCROLL_ANIMATION_DURATION.as_secs_f64() / 8.0); + + animation.advance_at(restart_time + SCROLL_ANIMATION_DURATION); + assert!(animation.is_finished()); + assert_point_near(animation.position(), point(0., 200.)); + + let second_start_time = restart_time + SCROLL_ANIMATION_DURATION; + animation.restart_at(point(0., 75.), second_start_time); + let ScrollAnimation::Animating { + start_position, + target_position, + duration, + .. + } = animation + else { + panic!("expected second scroll animation"); + }; + assert_point_near(start_position, point(0., 200.)); + assert_eq!(target_position, point(0., 75.)); + assert_eq!(duration, SCROLL_ANIMATION_DURATION); + + animation.advance_at(second_start_time + SCROLL_ANIMATION_DURATION); + assert!(animation.is_finished()); + assert_point_near(animation.position(), point(0., 75.)); + } + + #[test] + fn scroll_animation_resets_duration_when_direction_reverses() { + let start_time = Instant::now(); + let mut animation = animating_scroll(point(0., 0.), point(0., 120.), start_time); + + let restart_time = start_time + Duration::from_millis(50); + animation.advance_at(restart_time); + let position_before_restart = animation.position(); + animation.restart_at(point(0., -40.), restart_time); + + let ScrollAnimation::Animating { duration, .. } = animation else { + panic!("expected reversed scroll animation"); + }; + assert_eq!(duration, SCROLL_ANIMATION_DURATION); + + animation.advance_at(restart_time + Duration::from_millis(16)); + assert!( + animation.position().y < position_before_restart.y, + "expected animation to move back toward the reversed target" + ); + + animation.advance_at(restart_time + SCROLL_ANIMATION_DURATION); + assert!(animation.is_finished()); + assert_point_near(animation.position(), point(0., -40.)); + } } diff --git a/crates/editor/src/scroll/actions.rs b/crates/editor/src/scroll/actions.rs index 4685b1003ee57d..412054fce016b8 100644 --- a/crates/editor/src/scroll/actions.rs +++ b/crates/editor/src/scroll/actions.rs @@ -2,7 +2,9 @@ use super::Axis; use crate::{ Autoscroll, Editor, EditorMode, NextScreen, NextScrollCursorCenterTopBottom, SCROLL_CENTER_TOP_BOTTOM_DEBOUNCE_TIMEOUT, ScrollCursorBottom, ScrollCursorCenter, - ScrollCursorCenterTopBottom, ScrollCursorTop, display_map::DisplayRow, scroll::ScrollOffset, + ScrollCursorCenterTopBottom, ScrollCursorTop, + display_map::DisplayRow, + scroll::{ScrollBehavior, ScrollOffset}, }; use gpui::{Context, Point, Window}; @@ -27,11 +29,15 @@ impl Editor { &mut self, scroll_position: Point, axis: Option, - window: &mut Window, + behavior: Option, + _: &mut Window, cx: &mut Context, ) { + let current_position = self.scroll_position(cx); self.scroll_manager.update_ongoing_scroll(axis); - self.set_scroll_position(scroll_position, window, cx); + self.scroll_manager + .scroll_to(current_position, scroll_position, behavior); + cx.notify(); } pub fn scroll_cursor_center_top_bottom( diff --git a/crates/gpui/src/elements/animation.rs b/crates/gpui/src/elements/animation.rs index 8a42c8bd492469..249c58590ae1f3 100644 --- a/crates/gpui/src/elements/animation.rs +++ b/crates/gpui/src/elements/animation.rs @@ -232,6 +232,11 @@ mod easing { move |delta| 1.0 - (1.0 - delta).powi(5) } + /// The Cubic ease-out function, which starts quickly and decelerates to a stop + pub fn ease_out_cubic() -> impl Fn(f32) -> f32 { + move |delta| 1.0 - (1.0 - delta).powi(3) + } + /// Apply the given easing function, first in the forward direction and then in the reverse direction pub fn bounce(easing: impl Fn(f32) -> f32) -> impl Fn(f32) -> f32 { move |delta| { diff --git a/crates/search/src/project_search.rs b/crates/search/src/project_search.rs index c014c6b4228392..0c8222eca003b7 100644 --- a/crates/search/src/project_search.rs +++ b/crates/search/src/project_search.rs @@ -18,7 +18,7 @@ use editor::{ actions::{Backtab, FoldAll, SelectAll, Tab, UnfoldAll}, items::active_match_index, multibuffer_context_lines, - scroll::Autoscroll, + scroll::{Autoscroll, ScrollBehavior}, }; use futures::{StreamExt, stream::FuturesOrdered}; use gpui::{ @@ -1747,7 +1747,13 @@ impl ProjectSearchView { editor.change_selections(Default::default(), window, cx, |s| { s.select_ranges(range_to_select) }); - editor.scroll(Point::default(), Some(Axis::Vertical), window, cx); + editor.scroll( + Point::default(), + Some(Axis::Vertical), + Some(ScrollBehavior::Instant), + window, + cx, + ); } }); if is_new_search && self.query_editor.focus_handle(cx).is_focused(window) { @@ -4849,6 +4855,7 @@ pub mod tests { results_editor.scroll( Point::new(0., f64::MAX), Some(Axis::Vertical), + Some(ScrollBehavior::Instant), window, cx, ); diff --git a/crates/settings/src/vscode_import.rs b/crates/settings/src/vscode_import.rs index f9ef797b33093d..f0fccc87d4ef07 100644 --- a/crates/settings/src/vscode_import.rs +++ b/crates/settings/src/vscode_import.rs @@ -297,6 +297,7 @@ impl VsCodeSettings { scroll_beyond_last_line: None, mouse_wheel_zoom: self.read_bool("editor.mouseWheelZoom"), scroll_sensitivity: self.read_f32("editor.mouseWheelScrollSensitivity"), + smooth_scroll: self.smooth_scroll_content(), scrollbar: self.scrollbar_content(), search: self.search_content(), search_wrap: None, @@ -323,6 +324,12 @@ impl VsCodeSettings { } } + fn smooth_scroll_content(&self) -> Option { + skip_default(SmoothScrollContent { + enabled: self.read_bool("editor.smoothScrolling"), + }) + } + fn sticky_scroll_content(&self) -> Option { skip_default(StickyScrollContent { enabled: self.read_bool("editor.stickyScroll.enabled"), diff --git a/crates/settings_content/src/editor.rs b/crates/settings_content/src/editor.rs index 499fb10e3b64e5..061af9d4105983 100644 --- a/crates/settings_content/src/editor.rs +++ b/crates/settings_content/src/editor.rs @@ -108,6 +108,10 @@ pub struct EditorSettingsContent { /// Default: 4.0 #[serde(serialize_with = "crate::serialize_optional_f32_with_two_decimal_places")] pub fast_scroll_sensitivity: Option, + /// Settings for scrolling with a smooth animation + /// + /// Default: smooth scroll is disabled + pub smooth_scroll: Option, /// Settings for sticking scopes to the top of the editor. /// /// Default: sticky scroll is disabled @@ -423,6 +427,16 @@ pub struct StickyScrollContent { pub enabled: Option, } +/// Smooth scroll related settings +#[with_fallible_options] +#[derive(Clone, Default, Debug, Serialize, Deserialize, JsonSchema, MergeFrom, PartialEq)] +pub struct SmoothScrollContent { + /// Whether smooth scroll is enabled. + /// + /// Default: false + pub enabled: Option, +} + /// Minimap related settings #[with_fallible_options] #[derive(Clone, Default, Debug, Serialize, Deserialize, JsonSchema, MergeFrom, PartialEq)] diff --git a/crates/settings_ui/src/page_data.rs b/crates/settings_ui/src/page_data.rs index 15f28020c53374..ddf2ae3dcf8ede 100644 --- a/crates/settings_ui/src/page_data.rs +++ b/crates/settings_ui/src/page_data.rs @@ -1742,7 +1742,7 @@ fn editor_page() -> SettingsPage { ] } - fn scrolling_section() -> [SettingsPageItem; 9] { + fn scrolling_section() -> [SettingsPageItem; 10] { [ SettingsPageItem::SectionHeader("Scrolling"), SettingsPageItem::SettingItem(SettingItem { @@ -1875,6 +1875,30 @@ fn editor_page() -> SettingsPage { metadata: None, files: USER, }), + SettingsPageItem::SettingItem(SettingItem { + title: "Smooth Scroll", + description: "Animate scroll with a smooth effect", + field: Box::new(SettingField { + organization_override: None, + json_path: Some("smooth_scroll.enabled"), + pick: |settings_content| { + settings_content + .editor + .smooth_scroll + .as_ref() + .and_then(|smooth_scroll| smooth_scroll.enabled.as_ref()) + }, + write: |settings_content, value, _| { + settings_content + .editor + .smooth_scroll + .get_or_insert_default() + .enabled = value; + }, + }), + metadata: None, + files: USER, + }), ] } diff --git a/crates/vim/src/normal/scroll.rs b/crates/vim/src/normal/scroll.rs index befaacf31c7dac..89946c3db2187c 100644 --- a/crates/vim/src/normal/scroll.rs +++ b/crates/vim/src/normal/scroll.rs @@ -109,7 +109,7 @@ impl Vim { self.update_editor(cx, |vim, editor, cx| { let should_move_cursor = editor.newest_selection_on_screen(cx).is_eq(); let display_snapshot = editor.display_map.update(cx, |map, cx| map.snapshot(cx)); - let old_top = editor.scroll_top_display_point(&display_snapshot, cx); + let old_top = editor.settled_scroll_top_display_point(&display_snapshot, cx); if editor.scroll_hover(amount, window, cx) { return; @@ -141,7 +141,7 @@ impl Vim { }; let display_snapshot = editor.display_map.update(cx, |map, cx| map.snapshot(cx)); - let top = editor.scroll_top_display_point(&display_snapshot, cx); + let top = editor.settled_scroll_top_display_point(&display_snapshot, cx); let vertical_scroll_margin = EditorSettings::get_global(cx).vertical_scroll_margin; let mut move_cursor = |map: &editor::display_map::DisplaySnapshot, diff --git a/docs/src/reference/all-settings.md b/docs/src/reference/all-settings.md index 1045c09996a6f8..f1a93563a8db40 100644 --- a/docs/src/reference/all-settings.md +++ b/docs/src/reference/all-settings.md @@ -3661,6 +3661,30 @@ Non-negative `integer` values Non-negative `integer` values +## Smooth Scroll + +- Description: Whether to animate scrolling with a smooth easing effect. +- Setting: `smooth_scroll` +- Default: + +```json [settings] +"smooth_scroll": { + "enabled": false, +} +``` + +> **Tip:** For a better smooth scrolling experience, consider increasing [`scroll_sensitivity`](#scroll-sensitivity) to make each scroll gesture cover more distance, allowing the animation to be more noticeable. + +### Enabled + +- Description: Whether smooth scrolling is enabled. +- Setting: `enabled` +- Default: `false` + +**Options** + +`boolean` values + ## Search - Description: Search options to enable by default when opening new project and buffer searches.