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 <chris@christopherbiscardi.com>
This commit is contained in:
CainandChristopher Biscardi authored and GitHub committed 2026-09-04 07:36:22 +00:00
1 parent b536d5369d
commit 326a5f026a
8 files changed
+1163 -2

No files matched your search

File diff suppressed because it is too large. Load diff
+6
View File
@@ -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<Rc<dyn CompletionProvider>>,
collaboration_hub: Option<Box<dyn CollaborationHub>>,
blink_manager: Entity<BlinkManager>,
cursor_animations: CursorAnimationStates,
show_cursor_names: bool,
hovered_cursors: HashMap<HoveredCursor, Task<()>>,
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>) {
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>) {
self.cursor_animations.clear();
self.blink_manager.update(cx, BlinkManager::disable);
self.buffer
.update(cx, |buffer, cx| buffer.remove_active_selections(cx));
+10
View File
@@ -19,6 +19,7 @@ use ui::scrollbars::ShowScrollbar;
pub struct EditorSettings {
pub cursor_blink: bool,
pub cursor_shape: Option<CursorShape>,
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(),
+102 -1
View File
@@ -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<SharedString>,
) -> 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<ShapedLine>,
cursor_name: Option<AnyElement>,
animated_corners: Option<[gpui::Point<Pixels>; 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<Pixels>, 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;