From 326a5f026a55fb99fb46d1492999e7361d1a3fa0 Mon Sep 17 00:00:00 2001 From: Cain <3140843692@qq.com> Date: Fri, 4 Sep 2026 07:36:22 +0000 Subject: [PATCH] editor: Add optional cursor movement animation (#63195) # Objective Add an optional smooth cursor movement animation to make cursor position changes easier to follow, especially during larger jumps and multi-cursor editing. Related to #4991. ## Solution - Add a new `cursor_animation.enabled` editor setting, disabled by default. - Animate local bar and block cursors using a damped spring while preserving momentum during rapid cursor movement. - Support multiple local cursors independently. - Continue using the cursor color provided by the active theme. - Snap immediately when scrolling, resizing, or other viewport changes would make interpolation misleading. - Respect the `reduce_motion` setting. - Request animation frames only while an enabled cursor animation is active. Enabling the feature causes additional editor redraws while the cursor is moving, so it is opt-in and the performance cost is mentioned in the setting documentation. ### Attribution The cursor spring physics and four-corner deformation algorithm are adapted from [vscode-neovide-cursor](https://github.com/LengineerC/vscode-neovide-cursor), which is available under the MIT License and inspired by Neovide. The implementation adapts the algorithm to Zed's editor and GPUI architecture, including viewport handling, selection state reconciliation, multi-cursor support, theme integration, and reduced-motion behavior. The attribution and license information are also retained in the source file. ## Testing Automated checks run on Windows: - `cargo fmt --all --check` - `cargo test -p editor cursor_animation --lib` - 15 cursor animation tests passed. The tests cover: - Spring convergence. - Short and long cursor movements. - Mid-animation retargeting and momentum preservation. - Multiple independent cursors. - Selection replacement and state cleanup. - Viewport and scroll changes. - Invalid or unusually large timing values. - Supported cursor shapes. To test manually: 1. Add the following to the settings file: ```json "cursor_animation": { "enabled": true } ``` 2. Move the cursor using typing, arrow keys, mouse clicks, and multi-cursor actions. 3. Toggle the setting off and confirm that cursor rendering immediately returns to its normal behavior. Manual testing was performed on Windows and Linux using a release build from this exact PR branch. Verified that: - Cursor animation is disabled by default. - Enabling `cursor_animation.enabled` is hot-reloaded without restarting Zed. - Disabling the setting immediately restores the original cursor behavior. - Cursor movement remains responsive during typing, keyboard navigation, and mouse navigation. This exact PR branch has not yet been manually tested on macOS. ## Self-Review Checklist: - [x] I've reviewed my own diff for quality, security, and reliability - [x] Unsafe blocks (if any) have justifying comments - [x] The content adheres to Zed's UI standards ([UX/UI](https://github.com/zed-industries/zed/blob/main/CONTRIBUTING.md#uiux-checklist) and [icon](https://github.com/zed-industries/zed/blob/main/crates/icons/README.md) guidelines) - [x] Tests cover the new/changed behavior - [x] Performance impact has been considered and is acceptable ## Showcase https://github.com/user-attachments/assets/17450529-491e-4982-b539-c46e3d5df467 --- Release Notes: - Added an optional cursor movement animation setting. --------- Co-authored-by: Christopher Biscardi --- assets/settings/default.json | 6 + crates/editor/src/cursor_animation.rs | 1001 +++++++++++++++++++++++++ crates/editor/src/editor.rs | 6 + crates/editor/src/editor_settings.rs | 10 + crates/editor/src/element.rs | 103 ++- crates/settings/src/vscode_import.rs | 1 + crates/settings_content/src/editor.rs | 11 + crates/settings_ui/src/page_data.rs | 27 +- 8 files changed, 1163 insertions(+), 2 deletions(-) create mode 100644 crates/editor/src/cursor_animation.rs diff --git a/assets/settings/default.json b/assets/settings/default.json index 3e94d4f01c..38ba01b388 100644 --- a/assets/settings/default.json +++ b/assets/settings/default.json @@ -276,6 +276,12 @@ // // Default: "bar" "cursor_shape": "bar", + // Cursor movement animation settings. + // Enabling this may cause additional editor redraws and increase rendering overhead. + "cursor_animation": { + // Whether cursor movement animation is enabled. + "enabled": false, + }, // Determines when the mouse cursor should be hidden in response to keyboard // input. // diff --git a/crates/editor/src/cursor_animation.rs b/crates/editor/src/cursor_animation.rs new file mode 100644 index 0000000000..750f2f1456 --- /dev/null +++ b/crates/editor/src/cursor_animation.rs @@ -0,0 +1,1001 @@ +// Cursor animation physics adapted from vscode-neovide-cursor: +// https://github.com/LengineerC/vscode-neovide-cursor +// +// Copyright (c) 2025 LengineerC +// Licensed under the MIT License: +// https://github.com/LengineerC/vscode-neovide-cursor/blob/main/LICENSE + +use collections::HashMap; +use gpui::{Bounds, Pixels, Point, point, px}; +use std::time::{Duration, Instant}; + +const MAX_FRAME_DURATION: Duration = Duration::from_millis(33); +const SPRING_RESET_EPSILON: f32 = 0.001; +const SPRING_ACTIVE_EPSILON: f32 = 0.01; +const CORNER_ACTIVE_DISTANCE: f32 = 0.5; +const GEOMETRY_EPSILON: f32 = 0.01; +const SHORT_MOVE_THRESHOLD: f32 = 8.0; +const LEADING_SNAP_THRESHOLD: f32 = 0.5; +const SNAP_ANIMATION_LENGTH_SECONDS: f32 = 0.02; +const ANIMATION_RESET_THRESHOLD_SECONDS: f32 = 0.075; +const MAX_TRAIL_DISTANCE_FACTOR: f32 = 100.0; +const RANK_TRAIL_FACTORS: [f32; 4] = [1.0, 0.9, 0.5, 0.3]; +const ANIMATION_LENGTH_SECONDS: f32 = 0.125; +const SHORT_ANIMATION_LENGTH_SECONDS: f32 = 0.05; +const TRAIL_SIZE: f32 = 1.0; + +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub(crate) struct LogicalCursorPosition { + pub row: u32, + pub column: u32, +} + +#[derive(Clone, Copy, Debug, PartialEq)] +pub(crate) struct CursorViewport { + content_origin: Point, + text_bounds: Bounds, + scroll_position: Point, + scroll_pixel_position: Point, + line_height: Pixels, + em_advance: Pixels, +} + +impl CursorViewport { + pub(crate) fn new( + content_origin: Point, + text_bounds: Bounds, + scroll_position: Point, + scroll_pixel_position: Point, + line_height: Pixels, + em_advance: Pixels, + ) -> Self { + Self { + content_origin, + text_bounds, + scroll_position, + scroll_pixel_position, + line_height, + em_advance, + } + } + + fn is_finite(self) -> bool { + f32::from(self.content_origin.x).is_finite() + && f32::from(self.content_origin.y).is_finite() + && f32::from(self.text_bounds.origin.x).is_finite() + && f32::from(self.text_bounds.origin.y).is_finite() + && f32::from(self.text_bounds.size.width).is_finite() + && f32::from(self.text_bounds.size.height).is_finite() + && self.scroll_position.x.is_finite() + && self.scroll_position.y.is_finite() + && self.scroll_pixel_position.x.is_finite() + && self.scroll_pixel_position.y.is_finite() + && f32::from(self.line_height).is_finite() + && f32::from(self.em_advance).is_finite() + } +} + +#[derive(Clone, Copy, Debug, Default, PartialEq)] +struct AnimationPoint { + x: f32, + y: f32, +} + +impl AnimationPoint { + fn length(self) -> f32 { + self.x.hypot(self.y) + } + + fn normalized(self) -> Self { + let length = self.length(); + if length == 0.0 || !length.is_finite() { + return Self::default(); + } + + Self { + x: self.x / length, + y: self.y / length, + } + } + + fn is_finite(self) -> bool { + self.x.is_finite() && self.y.is_finite() + } +} + +impl std::ops::Add for AnimationPoint { + type Output = Self; + + fn add(self, other: Self) -> Self { + Self { + x: self.x + other.x, + y: self.y + other.y, + } + } +} + +impl std::ops::Sub for AnimationPoint { + type Output = Self; + + fn sub(self, other: Self) -> Self { + Self { + x: self.x - other.x, + y: self.y - other.y, + } + } +} + +#[derive(Clone, Copy, Debug, PartialEq)] +struct CursorGeometry { + origin: AnimationPoint, + width: f32, + height: f32, +} + +impl CursorGeometry { + fn from_bounds(bounds: Bounds) -> Self { + Self { + origin: AnimationPoint { + x: bounds.origin.x.into(), + y: bounds.origin.y.into(), + }, + width: bounds.size.width.into(), + height: bounds.size.height.into(), + } + } + + fn center(self) -> AnimationPoint { + AnimationPoint { + x: self.origin.x + self.width / 2.0, + y: self.origin.y + self.height / 2.0, + } + } + + fn is_finite(self) -> bool { + self.origin.is_finite() + && self.width.is_finite() + && self.height.is_finite() + && self.width > 0.0 + && self.height > 0.0 + } + + fn has_same_size(self, other: Self) -> bool { + nearly_equal(self.width, other.width) && nearly_equal(self.height, other.height) + } + + fn has_same_origin(self, other: Self) -> bool { + nearly_equal(self.origin.x, other.origin.x) && nearly_equal(self.origin.y, other.origin.y) + } +} + +#[derive(Clone, Copy, Debug, Default)] +struct DampedSpringAnimation { + position: f32, + velocity: f32, +} + +impl DampedSpringAnimation { + fn update(&mut self, elapsed_seconds: f32, animation_length: f32) -> bool { + if !elapsed_seconds.is_finite() + || elapsed_seconds < 0.0 + || !animation_length.is_finite() + || animation_length <= elapsed_seconds + || self.position.abs() < SPRING_RESET_EPSILON + { + self.reset(); + return false; + } + + if elapsed_seconds == 0.0 { + return self.position.abs() >= SPRING_ACTIVE_EPSILON; + } + + let angular_frequency = 4.0 / animation_length; + let initial_position = self.position; + let combined_velocity = self.position * angular_frequency + self.velocity; + let decay = (-angular_frequency * elapsed_seconds).exp(); + self.position = (initial_position + combined_velocity * elapsed_seconds) * decay; + self.velocity = decay + * (-initial_position * angular_frequency + - combined_velocity * elapsed_seconds * angular_frequency + + combined_velocity); + + if !self.position.is_finite() + || !self.velocity.is_finite() + || self.position.abs() < SPRING_RESET_EPSILON + { + self.reset(); + return false; + } + + self.position.abs() >= SPRING_ACTIVE_EPSILON + } + + fn reset(&mut self) { + self.position = 0.0; + self.velocity = 0.0; + } +} + +#[derive(Clone, Copy, Debug)] +struct Corner { + relative_position: AnimationPoint, + current_position: AnimationPoint, + target_position: AnimationPoint, + horizontal_animation: DampedSpringAnimation, + vertical_animation: DampedSpringAnimation, + animation_length: f32, +} + +impl Corner { + fn new(relative_position: AnimationPoint) -> Self { + Self { + relative_position, + current_position: AnimationPoint::default(), + target_position: AnimationPoint::default(), + horizontal_animation: DampedSpringAnimation::default(), + vertical_animation: DampedSpringAnimation::default(), + animation_length: 0.0, + } + } + + fn destination(self, geometry: CursorGeometry) -> AnimationPoint { + let center = geometry.center(); + AnimationPoint { + x: center.x + self.relative_position.x * geometry.width, + y: center.y + self.relative_position.y * geometry.height, + } + } + + fn direction_alignment(self, geometry: CursorGeometry) -> f32 { + let travel_direction = (self.destination(geometry) - self.current_position).normalized(); + let corner_direction = self.relative_position.normalized(); + travel_direction.x * corner_direction.x + travel_direction.y * corner_direction.y + } + + fn snap(&mut self, geometry: CursorGeometry) { + let destination = self.destination(geometry); + self.current_position = destination; + self.target_position = destination; + self.horizontal_animation.reset(); + self.vertical_animation.reset(); + } + + fn retarget(&mut self, geometry: CursorGeometry, rank: usize) { + let destination = self.destination(geometry); + let horizontal_jump = + (destination.x - self.target_position.x) / geometry.width.max(f32::EPSILON); + let vertical_jump = + (destination.y - self.target_position.y) / geometry.height.max(f32::EPSILON); + let normalized_jump = AnimationPoint { + x: horizontal_jump, + y: vertical_jump, + } + .normalized(); + let corner_direction = self.relative_position.normalized(); + let leading_alignment = + normalized_jump.x * corner_direction.x + normalized_jump.y * corner_direction.y; + let is_short_jump = horizontal_jump.abs() <= SHORT_MOVE_THRESHOLD + && vertical_jump.abs() <= SPRING_RESET_EPSILON; + + let base_animation_length = if is_short_jump { + ANIMATION_LENGTH_SECONDS.min(SHORT_ANIMATION_LENGTH_SECONDS) + } else { + ANIMATION_LENGTH_SECONDS + }; + let reference_animation_length = if leading_alignment > LEADING_SNAP_THRESHOLD { + SNAP_ANIMATION_LENGTH_SECONDS + } else { + base_animation_length * RANK_TRAIL_FACTORS[rank.min(3)] + }; + self.animation_length = base_animation_length + + (reference_animation_length - base_animation_length) * TRAIL_SIZE; + + if self.animation_length > ANIMATION_RESET_THRESHOLD_SECONDS { + self.horizontal_animation.reset(); + self.vertical_animation.reset(); + } + + self.target_position = destination; + self.horizontal_animation.position = destination.x - self.current_position.x; + self.vertical_animation.position = destination.y - self.current_position.y; + } + + fn update(&mut self, elapsed_seconds: f32, max_trail_distance: f32) -> bool { + self.horizontal_animation + .update(elapsed_seconds, self.animation_length); + self.vertical_animation + .update(elapsed_seconds, self.animation_length); + self.horizontal_animation.position = self + .horizontal_animation + .position + .clamp(-max_trail_distance, max_trail_distance); + self.vertical_animation.position = self + .vertical_animation + .position + .clamp(-max_trail_distance, max_trail_distance); + self.current_position = AnimationPoint { + x: self.target_position.x - self.horizontal_animation.position, + y: self.target_position.y - self.vertical_animation.position, + }; + self.horizontal_animation.position.abs() > CORNER_ACTIVE_DISTANCE + || self.vertical_animation.position.abs() > CORNER_ACTIVE_DISTANCE + } +} + +#[derive(Clone)] +pub(crate) struct CursorAnimationState { + corners: [Corner; 4], + target_geometry: Option, + last_logical_position: Option, + last_viewport: Option, + last_frame_at: Option, + active: bool, +} + +#[derive(Default)] +pub(crate) struct CursorAnimationStates { + states: HashMap, + newest_selection_id: Option, + newest_state: Option, +} + +impl CursorAnimationStates { + pub(crate) fn reconcile_newest_selection(&mut self, newest_selection_id: Option) { + // Mouse selection begins with a fresh pending selection id. Seed that id from an + // independent snapshot of the previous newest cursor's geometry and velocity. The + // snapshot survives selection replacement and visibility filtering, so click animation + // does not depend on whether a render-time SelectionLayout happens to compare equal to + // the pending selection. + if let Some(newest_selection_id) = newest_selection_id + && self.newest_selection_id != Some(newest_selection_id) + && !self.states.contains_key(&newest_selection_id) + { + let inherited_state = self + .newest_selection_id + .and_then(|selection_id| self.states.get(&selection_id).cloned()) + .or_else(|| self.newest_state.clone()); + if let Some(state) = inherited_state { + self.states.insert(newest_selection_id, state); + } + } + + self.newest_selection_id = newest_selection_id; + } + + pub(crate) fn capture_newest_state(&mut self) { + if let Some(newest_selection_id) = self.newest_selection_id + && let Some(state) = self.states.get(&newest_selection_id) + { + self.newest_state = Some(state.clone()); + } + } + + pub(crate) fn update( + &mut self, + selection_id: usize, + logical_position: LogicalCursorPosition, + target_bounds: Bounds, + viewport: CursorViewport, + now: Instant, + ) -> Option<[Point; 4]> { + self.states.entry(selection_id).or_default().update( + logical_position, + target_bounds, + viewport, + now, + ) + } + + pub(crate) fn remove(&mut self, selection_id: usize) { + self.states.remove(&selection_id); + } + + pub(crate) fn retain(&mut self, mut keep: impl FnMut(usize) -> bool) { + self.states.retain(|selection_id, _| keep(*selection_id)); + } + + pub(crate) fn clear(&mut self) { + self.states.clear(); + self.newest_selection_id = None; + self.newest_state = None; + } +} + +impl Default for CursorAnimationState { + fn default() -> Self { + const RELATIVE_POSITIONS: [AnimationPoint; 4] = [ + AnimationPoint { x: -0.5, y: -0.5 }, + AnimationPoint { x: 0.5, y: -0.5 }, + AnimationPoint { x: 0.5, y: 0.5 }, + AnimationPoint { x: -0.5, y: 0.5 }, + ]; + + Self { + corners: RELATIVE_POSITIONS.map(Corner::new), + target_geometry: None, + last_logical_position: None, + last_viewport: None, + last_frame_at: None, + active: false, + } + } +} + +impl CursorAnimationState { + pub(crate) fn update( + &mut self, + logical_position: LogicalCursorPosition, + target_bounds: Bounds, + viewport: CursorViewport, + now: Instant, + ) -> Option<[Point; 4]> { + let target_geometry = CursorGeometry::from_bounds(target_bounds); + if !target_geometry.is_finite() || !viewport.is_finite() { + self.reset(); + return None; + } + + let Some(previous_geometry) = self.target_geometry else { + self.snap(logical_position, target_geometry, viewport, now); + return None; + }; + + let logical_position_changed = self.last_logical_position != Some(logical_position); + let target_origin_changed = !previous_geometry.has_same_origin(target_geometry); + let viewport_changed = self.last_viewport != Some(viewport); + let geometry_size_changed = !previous_geometry.has_same_size(target_geometry); + + if viewport_changed + || geometry_size_changed + || (!logical_position_changed && target_origin_changed) + { + self.snap(logical_position, target_geometry, viewport, now); + return None; + } + + self.last_logical_position = Some(logical_position); + self.last_viewport = Some(viewport); + self.target_geometry = Some(target_geometry); + + if target_origin_changed { + let elapsed = if self.active { + self.elapsed_since_last_frame(now) + } else { + Duration::ZERO + }; + self.retarget(target_geometry); + self.advance(elapsed); + self.last_frame_at = Some(now); + } else if self.active { + let elapsed = self.elapsed_since_last_frame(now); + self.advance(elapsed); + self.last_frame_at = Some(now); + } + + self.active.then(|| { + self.corners + .map(|corner| point(px(corner.current_position.x), px(corner.current_position.y))) + }) + } + + pub(crate) fn reset(&mut self) { + self.target_geometry = None; + self.last_logical_position = None; + self.last_viewport = None; + self.last_frame_at = None; + self.active = false; + for corner in &mut self.corners { + corner.horizontal_animation.reset(); + corner.vertical_animation.reset(); + } + } + + fn snap( + &mut self, + logical_position: LogicalCursorPosition, + geometry: CursorGeometry, + viewport: CursorViewport, + now: Instant, + ) { + for corner in &mut self.corners { + corner.snap(geometry); + } + self.target_geometry = Some(geometry); + self.last_logical_position = Some(logical_position); + self.last_viewport = Some(viewport); + self.last_frame_at = Some(now); + self.active = false; + } + + fn retarget(&mut self, geometry: CursorGeometry) { + let mut aligned_corners: [(usize, f32); 4] = + std::array::from_fn(|index| (index, self.corners[index].direction_alignment(geometry))); + aligned_corners.sort_by(|left, right| left.1.total_cmp(&right.1)); + let mut ranks = [0; 4]; + for (rank, (index, _)) in aligned_corners.into_iter().enumerate() { + ranks[index] = rank; + } + + for (index, corner) in self.corners.iter_mut().enumerate() { + corner.retarget(geometry, ranks[index]); + } + self.active = self.corners.iter().any(|corner| { + corner.horizontal_animation.position != 0.0 || corner.vertical_animation.position != 0.0 + }); + } + + fn elapsed_since_last_frame(&self, now: Instant) -> Duration { + self.last_frame_at + .and_then(|last_frame_at| now.checked_duration_since(last_frame_at)) + .unwrap_or_default() + .min(MAX_FRAME_DURATION) + } + + fn advance(&mut self, elapsed: Duration) { + let elapsed_seconds = elapsed.as_secs_f32(); + let max_trail_distance = self + .target_geometry + .map(|geometry| geometry.width.max(geometry.height) * MAX_TRAIL_DISTANCE_FACTOR) + .unwrap_or_default(); + self.active = self.corners.iter_mut().fold(false, |active, corner| { + corner.update(elapsed_seconds, max_trail_distance) || active + }); + + if !self.active + && let Some(target_geometry) = self.target_geometry + { + for corner in &mut self.corners { + corner.snap(target_geometry); + } + } + } +} + +fn nearly_equal(left: f32, right: f32) -> bool { + (left - right).abs() <= GEOMETRY_EPSILON +} + +#[cfg(test)] +mod tests { + use super::*; + use gpui::size; + + fn bounds(x: f32, y: f32) -> Bounds { + Bounds { + origin: point(px(x), px(y)), + size: size(px(2.0), px(20.0)), + } + } + + fn viewport(scroll_y: f64) -> CursorViewport { + CursorViewport::new( + point(px(0.0), px(0.0)), + Bounds { + origin: point(px(0.0), px(0.0)), + size: size(px(800.0), px(600.0)), + }, + point(0.0, scroll_y), + point(0.0, scroll_y * 20.0), + px(20.0), + px(8.0), + ) + } + + fn logical_position(row: u32, column: u32) -> LogicalCursorPosition { + LogicalCursorPosition { row, column } + } + + #[test] + fn spring_converges() { + let mut spring = DampedSpringAnimation { + position: 100.0, + velocity: 0.0, + }; + + for _ in 0..60 { + spring.update(1.0 / 120.0, 0.1); + } + + assert_eq!(spring.position, 0.0); + assert_eq!(spring.velocity, 0.0); + } + + #[test] + fn first_position_snaps_and_reset_forgets_it() { + let now = Instant::now(); + let mut state = CursorAnimationState::default(); + assert!( + state + .update( + logical_position(1, 1), + bounds(10.0, 20.0), + viewport(0.0), + now, + ) + .is_none() + ); + assert!(!state.active); + assert_eq!(state.corners[0].current_position.x, 10.0); + + state.reset(); + assert!(state.target_geometry.is_none()); + assert!(state.last_logical_position.is_none()); + } + + #[test] + fn mid_flight_retarget_starts_from_current_geometry() { + let now = Instant::now(); + let mut state = CursorAnimationState::default(); + state.update(logical_position(0, 0), bounds(0.0, 0.0), viewport(0.0), now); + state.update( + logical_position(0, 10), + bounds(100.0, 0.0), + viewport(0.0), + now, + ); + state.update( + logical_position(0, 10), + bounds(100.0, 0.0), + viewport(0.0), + now + Duration::from_millis(16), + ); + let position_before_retarget = state.corners.map(|corner| corner.current_position); + + state.update( + logical_position(0, 20), + bounds(200.0, 0.0), + viewport(0.0), + now + Duration::from_millis(16), + ); + assert_eq!( + state.corners.map(|corner| corner.current_position), + position_before_retarget + ); + + for frame in 2..30 { + state.update( + logical_position(0, 20), + bounds(200.0, 0.0), + viewport(0.0), + now + Duration::from_millis(frame * 16), + ); + } + assert!(!state.active); + assert_eq!(state.corners[0].current_position.x, 200.0); + } + + #[test] + fn short_horizontal_movement_uses_short_duration() { + let now = Instant::now(); + let mut state = CursorAnimationState::default(); + state.update(logical_position(0, 0), bounds(0.0, 0.0), viewport(0.0), now); + state.update(logical_position(0, 1), bounds(4.0, 0.0), viewport(0.0), now); + + assert!((state.corners[0].animation_length - 0.05).abs() < f32::EPSILON); + assert!((state.corners[3].animation_length - 0.045).abs() < f32::EPSILON); + assert!((state.corners[1].animation_length - 0.02).abs() < f32::EPSILON); + assert!((state.corners[2].animation_length - 0.02).abs() < f32::EPSILON); + } + + #[test] + fn short_mid_flight_retarget_preserves_momentum() { + let now = Instant::now(); + let mut state = CursorAnimationState::default(); + state.update(logical_position(0, 0), bounds(0.0, 0.0), viewport(0.0), now); + state.update(logical_position(0, 1), bounds(4.0, 0.0), viewport(0.0), now); + state.update( + logical_position(0, 1), + bounds(4.0, 0.0), + viewport(0.0), + now + Duration::from_millis(16), + ); + let velocity_before_retarget = state.corners[0].horizontal_animation.velocity; + assert_ne!(velocity_before_retarget, 0.0); + + state.update( + logical_position(0, 2), + bounds(8.0, 0.0), + viewport(0.0), + now + Duration::from_millis(16), + ); + + assert_eq!( + state.corners[0].horizontal_animation.velocity, + velocity_before_retarget + ); + } + + #[test] + fn movement_after_idle_starts_from_previous_geometry() { + let now = Instant::now(); + let mut state = CursorAnimationState::default(); + state.update(logical_position(0, 0), bounds(0.0, 0.0), viewport(0.0), now); + + let corners = state + .update( + logical_position(0, 1), + bounds(4.0, 0.0), + viewport(0.0), + now + Duration::from_secs(5), + ) + .unwrap(); + assert_eq!(corners[0].x, px(0.0)); + + let corners = state + .update( + logical_position(0, 1), + bounds(4.0, 0.0), + viewport(0.0), + now + Duration::from_secs(5) + Duration::from_millis(16), + ) + .unwrap(); + assert!(corners[0].x > px(0.0)); + assert!(corners[0].x < px(4.0)); + } + + #[test] + fn long_movement_ranks_leading_corners_ahead_of_trailing_corners() { + let now = Instant::now(); + let mut state = CursorAnimationState::default(); + state.update(logical_position(0, 0), bounds(0.0, 0.0), viewport(0.0), now); + state.update( + logical_position(0, 20), + bounds(100.0, 0.0), + viewport(0.0), + now, + ); + + assert!(state.corners[1].animation_length < state.corners[0].animation_length); + assert!(state.corners[2].animation_length < state.corners[3].animation_length); + } + + #[test] + fn diagonal_bar_jump_keeps_dimensionally_leading_edge_together() { + let now = Instant::now(); + let mut state = CursorAnimationState::default(); + state.update(logical_position(0, 0), bounds(0.0, 0.0), viewport(0.0), now); + state.update( + logical_position(10, 25), + bounds(200.0, 200.0), + viewport(0.0), + now, + ); + + // Match the reference implementation: hard-snap alignment uses a jump vector + // normalized independently by cursor width and height. For a 2x20 bar moving 200px on + // both axes, horizontal travel dominates in cursor dimensions, so both right corners + // form one leading edge instead of only the diagonal-most corner racing ahead. + assert!( + (state.corners[1].animation_length - SNAP_ANIMATION_LENGTH_SECONDS).abs() + < f32::EPSILON + ); + assert!( + (state.corners[2].animation_length - SNAP_ANIMATION_LENGTH_SECONDS).abs() + < f32::EPSILON + ); + assert!(state.corners[0].animation_length > SNAP_ANIMATION_LENGTH_SECONDS); + assert!(state.corners[3].animation_length > SNAP_ANIMATION_LENGTH_SECONDS); + + state.update( + logical_position(10, 25), + bounds(200.0, 200.0), + viewport(0.0), + now + Duration::from_millis(16), + ); + let leading_edge_height = + state.corners[2].current_position.y - state.corners[1].current_position.y; + assert!((leading_edge_height - 20.0).abs() < GEOMETRY_EPSILON); + } + + #[test] + fn viewport_movement_snaps_even_during_animation() { + let now = Instant::now(); + let mut state = CursorAnimationState::default(); + state.update(logical_position(0, 0), bounds(0.0, 0.0), viewport(0.0), now); + state.update( + logical_position(0, 10), + bounds(100.0, 0.0), + viewport(0.0), + now, + ); + assert!(state.active); + + assert!( + state + .update( + logical_position(0, 10), + bounds(100.0, -20.0), + viewport(1.0), + now + Duration::from_millis(16), + ) + .is_none() + ); + assert!(!state.active); + assert_eq!(state.corners[0].current_position.y, -20.0); + } + + #[test] + fn invalid_or_large_elapsed_time_never_produces_non_finite_values() { + let mut spring = DampedSpringAnimation { + position: 100.0, + velocity: 10.0, + }; + spring.update(f32::NAN, 0.1); + assert!(spring.position.is_finite()); + assert!(spring.velocity.is_finite()); + + spring.position = f32::INFINITY; + spring.velocity = f32::NEG_INFINITY; + spring.update(1.0, 0.1); + assert!(spring.position.is_finite()); + assert!(spring.velocity.is_finite()); + } + + #[test] + fn multiple_cursor_states_animate_independently_and_cleanup() { + let now = Instant::now(); + let mut states = CursorAnimationStates::default(); + + for (selection_id, x) in [(1, 0.0), (2, 40.0)] { + assert!( + states + .update( + selection_id, + logical_position(0, selection_id as u32), + bounds(x, 0.0), + viewport(0.0), + now, + ) + .is_none() + ); + assert!( + states + .update( + selection_id, + logical_position(0, selection_id as u32 + 1), + bounds(x + 4.0, 0.0), + viewport(0.0), + now, + ) + .is_some() + ); + } + + assert_eq!(states.states.len(), 2); + let second_cursor_before = states + .states + .get(&2) + .unwrap() + .corners + .map(|corner| corner.current_position); + + states.update( + 1, + logical_position(0, 3), + bounds(8.0, 0.0), + viewport(0.0), + now + Duration::from_millis(16), + ); + + assert_eq!( + states + .states + .get(&2) + .unwrap() + .corners + .map(|corner| corner.current_position), + second_cursor_before + ); + + states.retain(|selection_id| selection_id == 1); + assert_eq!(states.states.len(), 1); + assert!(states.states.contains_key(&1)); + states.clear(); + assert!(states.states.is_empty()); + } + + #[test] + fn replacing_newest_selection_id_preserves_click_animation() { + let now = Instant::now(); + let mut states = CursorAnimationStates::default(); + + states.reconcile_newest_selection(Some(1)); + assert!( + states + .update( + 1, + logical_position(0, 0), + bounds(0.0, 0.0), + viewport(0.0), + now, + ) + .is_none() + ); + + states.capture_newest_state(); + states.reconcile_newest_selection(Some(2)); + assert!(states.states.contains_key(&1)); + assert!(states.states.contains_key(&2)); + states.retain(|selection_id| selection_id == 2); + assert!(!states.states.contains_key(&1)); + assert!( + states + .update( + 2, + logical_position(4, 8), + bounds(80.0, 80.0), + viewport(0.0), + now, + ) + .is_some() + ); + } + + #[test] + fn adding_cursor_inherits_previous_newest_geometry_without_removing_it() { + let now = Instant::now(); + let mut states = CursorAnimationStates::default(); + + states.reconcile_newest_selection(Some(1)); + states.update( + 1, + logical_position(0, 0), + bounds(0.0, 0.0), + viewport(0.0), + now, + ); + + states.capture_newest_state(); + states.reconcile_newest_selection(Some(2)); + assert!(states.states.contains_key(&1)); + assert!(states.states.contains_key(&2)); + assert!( + states + .update( + 2, + logical_position(4, 8), + bounds(80.0, 80.0), + viewport(0.0), + now, + ) + .is_some() + ); + assert_eq!( + states.states.get(&1).unwrap().last_logical_position, + Some(logical_position(0, 0)) + ); + } + + #[test] + fn newest_geometry_snapshot_survives_state_visibility_cleanup() { + let now = Instant::now(); + let mut states = CursorAnimationStates::default(); + + states.reconcile_newest_selection(Some(1)); + states.update( + 1, + logical_position(0, 0), + bounds(0.0, 0.0), + viewport(0.0), + now, + ); + states.capture_newest_state(); + states.retain(|_| false); + assert!(states.states.is_empty()); + + states.reconcile_newest_selection(Some(2)); + assert!(states.states.contains_key(&2)); + assert!( + states + .update( + 2, + logical_position(4, 8), + bounds(80.0, 80.0), + viewport(0.0), + now, + ) + .is_some() + ); + } +} diff --git a/crates/editor/src/editor.rs b/crates/editor/src/editor.rs index 2e41e84a2c..a3449e415e 100644 --- a/crates/editor/src/editor.rs +++ b/crates/editor/src/editor.rs @@ -65,6 +65,7 @@ mod clipboard; mod code_actions; mod completions; mod config; +mod cursor_animation; mod diagnostics; mod edit_prediction; mod input; @@ -146,6 +147,7 @@ use code_context_menus::{ use code_lens::CodeLensState; use collections::{BTreeMap, HashMap, HashSet, VecDeque}; use convert_case::{Case, Casing}; +use cursor_animation::CursorAnimationStates; use dap::TelemetrySpawnLocation; use display_map::*; use document_colors::LspColorData; @@ -991,6 +993,7 @@ pub struct Editor { completion_provider: Option>, collaboration_hub: Option>, blink_manager: Entity, + cursor_animations: CursorAnimationStates, show_cursor_names: bool, hovered_cursors: HashMap>, pub show_local_selections: bool, @@ -2337,6 +2340,7 @@ impl Editor { collaboration_hub: project.clone().map(|project| Box::new(project) as _), project, blink_manager: blink_manager.clone(), + cursor_animations: CursorAnimationStates::default(), show_local_selections: true, show_scrollbars: ScrollbarAxes { horizontal: full_mode, @@ -10791,6 +10795,7 @@ impl Editor { } fn handle_focus(&mut self, window: &mut Window, cx: &mut Context) { + self.cursor_animations.clear(); cx.emit(EditorEvent::Focused); if let Some(descendant) = self @@ -10855,6 +10860,7 @@ impl Editor { } pub fn handle_blur(&mut self, window: &mut Window, cx: &mut Context) { + self.cursor_animations.clear(); self.blink_manager.update(cx, BlinkManager::disable); self.buffer .update(cx, |buffer, cx| buffer.remove_active_selections(cx)); diff --git a/crates/editor/src/editor_settings.rs b/crates/editor/src/editor_settings.rs index 32b7a024d4..a06564c2ce 100644 --- a/crates/editor/src/editor_settings.rs +++ b/crates/editor/src/editor_settings.rs @@ -19,6 +19,7 @@ use ui::scrollbars::ShowScrollbar; pub struct EditorSettings { pub cursor_blink: bool, pub cursor_shape: Option, + pub cursor_animation: CursorAnimationSettings, pub current_line_highlight: CurrentLineHighlight, pub selection_highlight: bool, pub rounded_selection: bool, @@ -73,6 +74,11 @@ pub struct EditorSettings { pub file_diff: FileDiffSettings, } +#[derive(Copy, Clone, Debug, PartialEq)] +pub struct CursorAnimationSettings { + pub enabled: bool, +} + #[derive(Clone, Copy, Debug)] pub struct FileDiffSettings { /// Whether newly opened file diffs show the full file instead of changes only. @@ -209,6 +215,7 @@ impl EditorSettings { impl Settings for EditorSettings { fn from_settings(content: &settings::SettingsContent) -> Self { let editor = content.editor.clone(); + let cursor_animation = editor.cursor_animation.unwrap(); let scrollbar = editor.scrollbar.unwrap(); let minimap = editor.minimap.unwrap(); let gutter = editor.gutter.unwrap(); @@ -221,6 +228,9 @@ impl Settings for EditorSettings { Self { cursor_blink: editor.cursor_blink.unwrap(), cursor_shape: editor.cursor_shape.map(Into::into), + cursor_animation: CursorAnimationSettings { + enabled: cursor_animation.enabled.unwrap(), + }, current_line_highlight: editor.current_line_highlight.unwrap(), selection_highlight: editor.selection_highlight.unwrap(), rounded_selection: editor.rounded_selection.unwrap(), diff --git a/crates/editor/src/element.rs b/crates/editor/src/element.rs index 75925d5be8..2f7cbb1d24 100644 --- a/crates/editor/src/element.rs +++ b/crates/editor/src/element.rs @@ -17,6 +17,7 @@ use crate::{ SelectionDragState, SizingBehavior, SoftWrap, ToPoint, code_context_menus::{CodeActionsMenu, MENU_ASIDE_MAX_WIDTH, MENU_ASIDE_MIN_WIDTH, MENU_GAP}, column_pixels, + cursor_animation::{CursorViewport, LogicalCursorPosition}, display_map::{ Block, BlockContext, BlockStyle, ChunkRendererId, DisplaySnapshot, EditorMargins, HighlightKey, HighlightedChunk, ToDisplayPoint, @@ -81,7 +82,7 @@ use std::{ ops::{Deref, Range}, rc::Rc, sync::Arc, - time::Duration, + time::{Duration, Instant}, }; use sum_tree::Bias; use text::BufferId; @@ -140,6 +141,7 @@ impl LineNumberStyle { #[derive(Debug)] struct SelectionLayout { + id: usize, head: DisplayPoint, cursor_shape: CursorShape, is_newest: bool, @@ -167,6 +169,7 @@ impl SelectionLayout { is_local: bool, user_name: Option, ) -> Self { + let id = selection.id; let buffer_snapshot = map.buffer_snapshot(); let point_selection = selection.map(|p| p.to_point(buffer_snapshot)); let display_selection = point_selection.map(|p| p.to_display_point(map)); @@ -220,6 +223,7 @@ impl SelectionLayout { } Self { + id, head, cursor_shape, is_newest, @@ -1039,8 +1043,41 @@ impl EditorElement { let mut autoscroll_bounds = None; let cursor_layouts = self.editor.update(cx, |editor, cx| { let mut cursors = Vec::new(); + let mut handled_animation_cursors = HashSet::default(); + let mut request_animation_frame = false; let show_local_cursors = editor.show_local_cursors(window, cx); + let animation_settings = EditorSettings::get_global(cx).cursor_animation; + let animation_enabled = animation_settings.enabled && !cx.reduce_motion(); + let animation_context = animation_enabled.then(|| { + ( + CursorViewport::new( + content_origin, + text_hitbox.bounds, + scroll_position, + scroll_pixel_position, + line_height, + em_advance, + ), + Instant::now(), + ) + }); + + if animation_enabled { + let newest_animation_selection_id = if editor.leader_id.is_none() + && cursor_shape_supports_cursor_animation(editor.cursor_shape) + { + Some(editor.selections.newest_anchor().id) + } else { + None + }; + + editor + .cursor_animations + .reconcile_newest_selection(newest_animation_selection_id); + } else { + editor.cursor_animations.clear(); + } for (player_color, selections) in selections { for selection in selections { @@ -1180,6 +1217,7 @@ impl EditorElement { shape: selection.cursor_shape, block_text, cursor_name: None, + animated_corners: None, }; let cursor_name = selection.user_name.clone().map(|name| CursorName { string: name, @@ -1187,10 +1225,42 @@ impl EditorElement { is_top_row: cursor_position.row().0 == 0, }); cursor.layout(content_origin, cursor_name, window, cx); + if selection.is_local + && cursor_shape_supports_cursor_animation(selection.cursor_shape) + { + if let Some((cursor_viewport, animation_now)) = animation_context { + handled_animation_cursors.insert(selection.id); + let target_bounds = + window.pixel_snap_bounds(cursor.bounds(content_origin)); + cursor.animated_corners = editor.cursor_animations.update( + selection.id, + LogicalCursorPosition { + row: cursor_position.row().0, + column: cursor_position.column(), + }, + target_bounds, + cursor_viewport, + animation_now, + ); + request_animation_frame |= cursor.animated_corners.is_some(); + } + } else if animation_enabled && selection.is_local { + editor.cursor_animations.remove(selection.id); + } cursors.push(cursor); } } + if animation_enabled { + editor.cursor_animations.capture_newest_state(); + editor + .cursor_animations + .retain(|selection_id| handled_animation_cursors.contains(&selection_id)); + if request_animation_frame { + window.request_animation_frame(); + } + } + cursors }); @@ -6673,6 +6743,7 @@ impl EditorElement { let start = range.start.to_display_point(display_snapshot); let end = range.end.to_display_point(display_snapshot); let selection_layout = SelectionLayout { + id: 0, head: start, range: start..end, cursor_shape: CursorShape::Bar, @@ -10417,6 +10488,7 @@ pub struct CursorLayout { shape: CursorShape, block_text: Option, cursor_name: Option, + animated_corners: Option<[gpui::Point; 4]>, } #[derive(Debug)] @@ -10443,6 +10515,7 @@ impl CursorLayout { shape, block_text, cursor_name: None, + animated_corners: None, } } @@ -10513,6 +10586,18 @@ impl CursorLayout { } pub fn paint(&mut self, origin: gpui::Point, window: &mut Window, cx: &mut App) { + if let Some(corners) = self.animated_corners { + let mut builder = gpui::PathBuilder::fill(); + builder.add_polygon(&corners, true); + if let Ok(path) = builder.build() { + if let Some(name) = &mut self.cursor_name { + name.paint(window, cx); + } + window.paint_path(path, self.color); + return; + } + } + let bounds = window.pixel_snap_bounds(self.bounds(origin)); //Draw background or border quad @@ -10547,6 +10632,10 @@ impl CursorLayout { } } +fn cursor_shape_supports_cursor_animation(shape: CursorShape) -> bool { + matches!(shape, CursorShape::Bar | CursorShape::Block) +} + #[derive(Debug)] pub struct HighlightedRange { pub start_y: Pixels, @@ -12302,6 +12391,7 @@ mod tests { }; let spanning_selection = SelectionLayout { + id: 0, head: DisplayPoint::new(DisplayRow(3), 7), cursor_shape: CursorShape::Bar, is_newest: true, @@ -12351,6 +12441,7 @@ mod tests { }; let selection = SelectionLayout { + id: 0, head: DisplayPoint::new(DisplayRow(2), 0), cursor_shape: CursorShape::Bar, is_newest: true, @@ -12551,6 +12642,16 @@ mod tests { assert_eq!(EditorElement::spacer_pattern_period(20.0, 4.8), 5.0); } + #[test] + fn cursor_animation_supports_bar_and_block_shapes() { + assert!(cursor_shape_supports_cursor_animation(CursorShape::Bar)); + assert!(cursor_shape_supports_cursor_animation(CursorShape::Block)); + assert!(!cursor_shape_supports_cursor_animation( + CursorShape::Underline + )); + assert!(!cursor_shape_supports_cursor_animation(CursorShape::Hollow)); + } + #[gpui::test(iterations = 100)] fn test_random_spacer_pattern_period(mut rng: StdRng) { let line_height = rng.next_u32() as f32; diff --git a/crates/settings/src/vscode_import.rs b/crates/settings/src/vscode_import.rs index 297bb8b1be..0b35e9705b 100644 --- a/crates/settings/src/vscode_import.rs +++ b/crates/settings/src/vscode_import.rs @@ -259,6 +259,7 @@ impl VsCodeSettings { "underline" | "underline-thin" => Some(CursorShape::Underline), _ => None, }), + cursor_animation: None, current_line_highlight: self.read_enum("editor.renderLineHighlight", |s| match s { "gutter" => Some(CurrentLineHighlight::Gutter), "line" => Some(CurrentLineHighlight::Line), diff --git a/crates/settings_content/src/editor.rs b/crates/settings_content/src/editor.rs index 3abd295278..23ff824a88 100644 --- a/crates/settings_content/src/editor.rs +++ b/crates/settings_content/src/editor.rs @@ -22,6 +22,8 @@ pub struct EditorSettingsContent { /// /// Default: bar pub cursor_shape: Option, + /// Cursor movement animation settings. + pub cursor_animation: Option, /// Determines how snippets are sorted relative to other completion items. /// /// Default: inline @@ -364,6 +366,15 @@ impl RelativeLineNumbers { } } +#[with_fallible_options] +#[derive(Clone, Default, Debug, Serialize, Deserialize, JsonSchema, MergeFrom, PartialEq)] +pub struct CursorAnimationSettingsContent { + /// Whether cursor movement animation is enabled. + /// + /// Default: false + pub enabled: Option, +} + // Toolbar related settings #[with_fallible_options] #[derive(Debug, Clone, Default, Serialize, Deserialize, JsonSchema, MergeFrom, PartialEq, Eq)] diff --git a/crates/settings_ui/src/page_data.rs b/crates/settings_ui/src/page_data.rs index f8bfd3bc8f..31a556feee 100644 --- a/crates/settings_ui/src/page_data.rs +++ b/crates/settings_ui/src/page_data.rs @@ -1329,7 +1329,7 @@ fn appearance_page() -> SettingsPage { ] } - fn cursor_section() -> [SettingsPageItem; 6] { + fn cursor_section() -> [SettingsPageItem; 7] { [ SettingsPageItem::SectionHeader("Cursor"), SettingsPageItem::SettingItem(SettingItem { @@ -1360,6 +1360,31 @@ fn appearance_page() -> SettingsPage { metadata: None, files: USER, }), + SettingsPageItem::SettingItem(SettingItem { + title: "Cursor Animation", + description: "Whether the cursor smoothly animates when moving around the editor.", + field: Box::new(SettingField { + organization_override: None, + json_path: Some("cursor_animation.enabled"), + pick: |settings_content| { + settings_content + .editor + .cursor_animation + .as_ref()? + .enabled + .as_ref() + }, + write: |settings_content, value, _| { + settings_content + .editor + .cursor_animation + .get_or_insert_default() + .enabled = value; + }, + }), + metadata: None, + files: USER, + }), SettingsPageItem::SettingItem(SettingItem { title: "Cursor Shape", description: "Cursor shape for the editor.",