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:
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
@@ -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));
|
||||
|
||||
@@ -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(),
|
||||
|
||||
@@ -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;
|
||||
|
||||
Reference in new issue
Block a user