From 22e2b8e8323d5bf5e3cba87f2b23a4b397c1ff5e Mon Sep 17 00:00:00 2001 From: Agus Zubiaga Date: Tue, 11 Feb 2025 11:19:51 -0300 Subject: [PATCH] edit predictions: Preview jumps by animating cursor to target (#24604) https://github.com/user-attachments/assets/977d08fb-a2b1-4826-9d95-8f35c6cb9f13 Release Notes: - N/A --------- Co-authored-by: Danilo Co-authored-by: Smit Co-authored-by: Max --- assets/icons/zed_predict_down.svg | 5 + assets/icons/zed_predict_up.svg | 5 + crates/editor/src/editor.rs | 610 ++++++++++++++++++------- crates/editor/src/element.rs | 135 +++++- crates/editor/src/scroll/autoscroll.rs | 24 + crates/ui/src/components/icon.rs | 2 + 6 files changed, 611 insertions(+), 170 deletions(-) create mode 100644 assets/icons/zed_predict_down.svg create mode 100644 assets/icons/zed_predict_up.svg diff --git a/assets/icons/zed_predict_down.svg b/assets/icons/zed_predict_down.svg new file mode 100644 index 00000000000000..4532ad7e26cab7 --- /dev/null +++ b/assets/icons/zed_predict_down.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/assets/icons/zed_predict_up.svg b/assets/icons/zed_predict_up.svg new file mode 100644 index 00000000000000..61ec143022b4f7 --- /dev/null +++ b/assets/icons/zed_predict_up.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/crates/editor/src/editor.rs b/crates/editor/src/editor.rs index f10b893d08088e..957648f4b8aa06 100644 --- a/crates/editor/src/editor.rs +++ b/crates/editor/src/editor.rs @@ -75,14 +75,14 @@ use code_context_menus::{ }; use git::blame::GitBlame; use gpui::{ - div, impl_actions, linear_color_stop, linear_gradient, point, prelude::*, pulsating_between, - px, relative, size, Action, Animation, AnimationExt, AnyElement, App, AsyncWindowContext, - AvailableSpace, Background, Bounds, ClipboardEntry, ClipboardItem, Context, DispatchPhase, - ElementId, Entity, EntityInputHandler, EventEmitter, FocusHandle, FocusOutEvent, Focusable, - FontId, FontWeight, Global, HighlightStyle, Hsla, InteractiveText, KeyContext, Modifiers, - MouseButton, MouseDownEvent, PaintQuad, ParentElement, Pixels, Render, SharedString, Size, - Styled, StyledText, Subscription, Task, TextRun, TextStyle, TextStyleRefinement, - UTF16Selection, UnderlineStyle, UniformListScrollHandle, WeakEntity, WeakFocusHandle, Window, + div, impl_actions, point, prelude::*, pulsating_between, px, relative, size, Action, Animation, + AnimationExt, AnyElement, App, AsyncWindowContext, AvailableSpace, Background, Bounds, + ClipboardEntry, ClipboardItem, Context, DispatchPhase, ElementId, Entity, EntityInputHandler, + EventEmitter, FocusHandle, FocusOutEvent, Focusable, FontId, FontWeight, Global, + HighlightStyle, Hsla, InteractiveText, KeyContext, Modifiers, MouseButton, MouseDownEvent, + PaintQuad, ParentElement, Pixels, Render, SharedString, Size, Styled, StyledText, Subscription, + Task, TextStyle, TextStyleRefinement, UTF16Selection, UnderlineStyle, UniformListScrollHandle, + WeakEntity, WeakFocusHandle, Window, }; use highlight_matching_bracket::refresh_matching_bracket_highlights; use hover_popover::{hide_hover, HoverState}; @@ -485,7 +485,6 @@ enum InlineCompletion { }, Move { target: Anchor, - range_around_target: Range, snapshot: BufferSnapshot, }, } @@ -521,6 +520,296 @@ pub enum MenuInlineCompletionsPolicy { ByProvider, } +// TODO az do we need this? +#[derive(Clone)] +pub enum EditPredictionPreview { + /// Modifier is not pressed + Inactive, + /// Modifier pressed, animating to active + MovingTo { + animation: Range, + scroll_position_at_start: Option>, + target_point: DisplayPoint, + }, + Arrived { + scroll_position_at_start: Option>, + scroll_position_at_arrival: Option>, + target_point: Option, + }, + /// Modifier released, animating from active + MovingFrom { + animation: Range, + target_point: DisplayPoint, + }, +} + +impl EditPredictionPreview { + fn start( + &mut self, + completion: &InlineCompletion, + snapshot: &EditorSnapshot, + cursor: DisplayPoint, + ) -> bool { + if matches!(self, Self::MovingTo { .. } | Self::Arrived { .. }) { + return false; + } + (*self, _) = Self::start_now(completion, snapshot, cursor); + true + } + + fn restart( + &mut self, + completion: &InlineCompletion, + snapshot: &EditorSnapshot, + cursor: DisplayPoint, + ) -> bool { + match self { + Self::Inactive => false, + Self::MovingTo { target_point, .. } + | Self::Arrived { + target_point: Some(target_point), + .. + } => { + let (new_preview, new_target_point) = Self::start_now(completion, snapshot, cursor); + + if new_target_point != Some(*target_point) { + *self = new_preview; + return true; + } + + false + } + Self::Arrived { + target_point: None, .. + } => { + let (new_preview, _) = Self::start_now(completion, snapshot, cursor); + + *self = new_preview; + true + } + Self::MovingFrom { .. } => false, + } + } + + fn start_now( + completion: &InlineCompletion, + snapshot: &EditorSnapshot, + cursor: DisplayPoint, + ) -> (Self, Option) { + let now = Instant::now(); + match completion { + InlineCompletion::Edit { .. } => ( + Self::Arrived { + target_point: None, + scroll_position_at_start: None, + scroll_position_at_arrival: None, + }, + None, + ), + InlineCompletion::Move { target, .. } => { + let target_point = target.to_display_point(&snapshot.display_snapshot); + let duration = Self::animation_duration(cursor, target_point); + + ( + Self::MovingTo { + animation: now..now + duration, + scroll_position_at_start: Some(snapshot.scroll_position()), + target_point, + }, + Some(target_point), + ) + } + } + } + + fn animation_duration(a: DisplayPoint, b: DisplayPoint) -> Duration { + const SPEED: f32 = 8.0; + + let row_diff = b.row().0.abs_diff(a.row().0); + let column_diff = b.column().abs_diff(a.column()); + let distance = ((row_diff.pow(2) + column_diff.pow(2)) as f32).sqrt(); + Duration::from_millis((distance * SPEED) as u64) + } + + fn end( + &mut self, + cursor: DisplayPoint, + scroll_pixel_position: gpui::Point, + window: &mut Window, + cx: &mut Context, + ) -> bool { + let (scroll_position, target_point) = match self { + Self::MovingTo { + scroll_position_at_start, + target_point, + .. + } + | Self::Arrived { + scroll_position_at_start, + scroll_position_at_arrival: None, + target_point: Some(target_point), + .. + } => (*scroll_position_at_start, target_point), + Self::Arrived { + scroll_position_at_start, + scroll_position_at_arrival: Some(scroll_at_arrival), + target_point: Some(target_point), + } => { + const TOLERANCE: f32 = 4.0; + + let diff = *scroll_at_arrival - scroll_pixel_position.map(|p| p.0); + + if diff.x.abs() < TOLERANCE && diff.y.abs() < TOLERANCE { + (*scroll_position_at_start, target_point) + } else { + (None, target_point) + } + } + Self::Arrived { + target_point: None, .. + } => { + *self = Self::Inactive; + return true; + } + Self::MovingFrom { .. } | Self::Inactive => return false, + }; + + let now = Instant::now(); + let duration = Self::animation_duration(cursor, *target_point); + let target_point = *target_point; + + *self = Self::MovingFrom { + animation: now..now + duration, + target_point, + }; + + if let Some(scroll_position) = scroll_position { + cx.spawn_in(window, |editor, mut cx| async move { + smol::Timer::after(duration).await; + editor + .update_in(&mut cx, |editor, window, cx| { + if let Self::MovingFrom { .. } | Self::Inactive = + editor.edit_prediction_preview + { + editor.set_scroll_position(scroll_position, window, cx) + } + }) + .log_err(); + }) + .detach(); + } + + true + } + + /// Whether the preview is active or we are animating to or from it. + fn is_active(&self) -> bool { + matches!( + self, + Self::MovingTo { .. } | Self::Arrived { .. } | Self::MovingFrom { .. } + ) + } + + /// Returns true if the preview is active, not cancelled, and the animation is settled. + fn is_active_settled(&self) -> bool { + matches!(self, Self::Arrived { .. }) + } + + #[allow(clippy::too_many_arguments)] + fn move_state( + &mut self, + snapshot: &EditorSnapshot, + visible_row_range: Range, + line_layouts: &[LineWithInvisibles], + scroll_pixel_position: gpui::Point, + line_height: Pixels, + target: Anchor, + cursor: Option, + ) -> Option { + let delta = match self { + Self::Inactive => return None, + Self::Arrived { .. } => 1., + Self::MovingTo { + animation, + scroll_position_at_start: original_scroll_position, + target_point, + } => { + let now = Instant::now(); + if animation.end < now { + *self = Self::Arrived { + scroll_position_at_start: *original_scroll_position, + scroll_position_at_arrival: Some(scroll_pixel_position.map(|p| p.0)), + target_point: Some(*target_point), + }; + 1.0 + } else { + (now - animation.start).as_secs_f32() + / (animation.end - animation.start).as_secs_f32() + } + } + Self::MovingFrom { animation, .. } => { + let now = Instant::now(); + if animation.end < now { + *self = Self::Inactive; + return None; + } else { + let delta = (now - animation.start).as_secs_f32() + / (animation.end - animation.start).as_secs_f32(); + 1.0 - delta + } + } + }; + + let cursor = cursor?; + + if !visible_row_range.contains(&cursor.row()) { + return None; + } + + let target_position = target.to_display_point(&snapshot.display_snapshot); + + if !visible_row_range.contains(&target_position.row()) { + return None; + } + + let target_row_layout = + &line_layouts[target_position.row().minus(visible_row_range.start) as usize]; + let target_column = target_position.column() as usize; + + let target_character_x = target_row_layout.x_for_index(target_column); + + let target_x = target_character_x - scroll_pixel_position.x; + let target_y = + (target_position.row().as_f32() - scroll_pixel_position.y / line_height) * line_height; + + let origin_x = line_layouts[cursor.row().minus(visible_row_range.start) as usize] + .x_for_index(cursor.column() as usize); + let origin_y = + (cursor.row().as_f32() - scroll_pixel_position.y / line_height) * line_height; + + let delta = 1.0 - (-10.0 * delta).exp2(); + + let x = origin_x + (target_x - origin_x) * delta; + let y = origin_y + (target_y - origin_y) * delta; + + Some(EditPredictionMoveState { + delta, + position: point(x, y), + }) + } +} + +pub(crate) struct EditPredictionMoveState { + delta: f32, + position: gpui::Point, +} + +impl EditPredictionMoveState { + pub fn is_animation_completed(&self) -> bool { + self.delta >= 1. + } +} + #[derive(Copy, Clone, Eq, PartialEq, PartialOrd, Ord, Debug, Default)] struct EditorActionId(usize); @@ -704,7 +993,7 @@ pub struct Editor { inline_completions_hidden_for_vim_mode: bool, show_inline_completions_override: Option, menu_inline_completions_policy: MenuInlineCompletionsPolicy, - previewing_inline_completion: bool, + edit_prediction_preview: EditPredictionPreview, inlay_hint_cache: InlayHintCache, next_inlay_id: usize, _subscriptions: Vec, @@ -1397,7 +1686,7 @@ impl Editor { edit_prediction_provider: None, active_inline_completion: None, stale_inline_completion_in_menu: None, - previewing_inline_completion: false, + edit_prediction_preview: EditPredictionPreview::Inactive, inlay_hint_cache: InlayHintCache::new(inlay_hint_settings), gutter_hovered: false, @@ -5112,11 +5401,11 @@ impl Editor { true } - /// Returns true when we're displaying the inline completion popover below the cursor + /// Returns true when we're displaying the edit prediction popover below the cursor /// like we are not previewing and the LSP autocomplete menu is visible /// or we are in `when_holding_modifier` mode. pub fn edit_prediction_visible_in_cursor_popover(&self, has_completion: bool) -> bool { - if self.previewing_inline_completion + if self.edit_prediction_preview.is_active() || !self.show_edit_predictions_in_menu() || !self.edit_predictions_enabled() { @@ -5138,15 +5427,7 @@ impl Editor { cx: &mut Context, ) { if self.show_edit_predictions_in_menu() { - let accept_binding = self.accept_edit_prediction_keybind(window, cx); - if let Some(accept_keystroke) = accept_binding.keystroke() { - let was_previewing_inline_completion = self.previewing_inline_completion; - self.previewing_inline_completion = modifiers == accept_keystroke.modifiers - && accept_keystroke.modifiers.modified(); - if self.previewing_inline_completion != was_previewing_inline_completion { - self.update_visible_inline_completion(window, cx); - } - } + self.update_edit_prediction_preview(&modifiers, position_map, window, cx); } let mouse_position = window.mouse_position(); @@ -5163,9 +5444,50 @@ impl Editor { ) } + fn update_edit_prediction_preview( + &mut self, + modifiers: &Modifiers, + position_map: &PositionMap, + window: &mut Window, + cx: &mut Context, + ) { + let accept_keybind = self.accept_edit_prediction_keybind(window, cx); + let Some(accept_keystroke) = accept_keybind.keystroke() else { + return; + }; + + if &accept_keystroke.modifiers == modifiers { + if let Some(completion) = self.active_inline_completion.as_ref() { + if self.edit_prediction_preview.start( + &completion.completion, + &position_map.snapshot, + self.selections + .newest_anchor() + .head() + .to_display_point(&position_map.snapshot), + ) { + self.request_autoscroll(Autoscroll::fit(), cx); + self.update_visible_inline_completion(window, cx); + cx.notify(); + } + } + } else if self.edit_prediction_preview.end( + self.selections + .newest_anchor() + .head() + .to_display_point(&position_map.snapshot), + position_map.scroll_pixel_position, + window, + cx, + ) { + self.update_visible_inline_completion(window, cx); + cx.notify(); + } + } + fn update_visible_inline_completion( &mut self, - _window: &mut Window, + window: &mut Window, cx: &mut Context, ) -> Option<()> { let selection = self.selections.newest_anchor(); @@ -5252,25 +5574,11 @@ impl Editor { invalidation_row_range = move_invalidation_row_range.unwrap_or(edit_start_row..edit_end_row); let target = first_edit_start; - let target_point = text::ToPoint::to_point(&target.text_anchor, &snapshot); - // TODO: Base this off of TreeSitter or word boundaries? - let target_excerpt_begin = snapshot.anchor_before(snapshot.clip_point( - Point::new(target_point.row, target_point.column.saturating_sub(20)), - Bias::Left, - )); - let target_excerpt_end = snapshot.anchor_after(snapshot.clip_point( - Point::new(target_point.row, target_point.column + 20), - Bias::Right, - )); - let range_around_target = target_excerpt_begin..target_excerpt_end; - InlineCompletion::Move { - target, - range_around_target, - snapshot, - } + InlineCompletion::Move { target, snapshot } } else { let show_completions_in_buffer = !self.edit_prediction_visible_in_cursor_popover(true) && !self.inline_completions_hidden_for_vim_mode; + if show_completions_in_buffer { if edits .iter() @@ -5329,6 +5637,15 @@ impl Editor { )); self.stale_inline_completion_in_menu = None; + let editor_snapshot = self.snapshot(window, cx); + if self.edit_prediction_preview.restart( + &completion, + &editor_snapshot, + cursor.to_display_point(&editor_snapshot), + ) { + self.request_autoscroll(Autoscroll::fit(), cx); + } + self.active_inline_completion = Some(InlineCompletionState { inlay_ids, completion, @@ -5556,7 +5873,7 @@ impl Editor { } pub fn context_menu_visible(&self) -> bool { - !self.previewing_inline_completion + !self.edit_prediction_preview.is_active() && self .context_menu .borrow() @@ -5591,7 +5908,7 @@ impl Editor { cursor_point: Point, style: &EditorStyle, accept_keystroke: &gpui::Keystroke, - window: &Window, + _window: &Window, cx: &mut Context, ) -> Option { let provider = self.edit_prediction_provider.as_ref()?; @@ -5646,20 +5963,51 @@ impl Editor { } let completion = match &self.active_inline_completion { - Some(completion) => self.render_edit_prediction_cursor_popover_preview( - completion, - cursor_point, - style, - window, - cx, - )?, + Some(completion) => match &completion.completion { + InlineCompletion::Move { + target, snapshot, .. + } if !self.has_visible_completions_menu() => { + use text::ToPoint as _; + + return Some( + h_flex() + .px_2() + .py_1() + .elevation_2(cx) + .border_color(cx.theme().colors().border) + .rounded_tl(px(0.)) + .gap_2() + .child( + if target.text_anchor.to_point(&snapshot).row > cursor_point.row { + Icon::new(IconName::ZedPredictDown) + } else { + Icon::new(IconName::ZedPredictUp) + }, + ) + .child(Label::new("Hold")) + .children(ui::render_modifiers( + &accept_keystroke.modifiers, + PlatformStyle::platform(), + Some(Color::Default), + None, + true, + )) + .into_any(), + ); + } + _ => self.render_edit_prediction_cursor_popover_preview( + completion, + cursor_point, + style, + cx, + )?, + }, None if is_refreshing => match &self.stale_inline_completion_in_menu { Some(stale_completion) => self.render_edit_prediction_cursor_popover_preview( stale_completion, cursor_point, style, - window, cx, )?, @@ -5671,9 +6019,6 @@ impl Editor { None => pending_completion_container().child(Label::new("No Prediction")), }; - let buffer_font = theme::ThemeSettings::get_global(cx).buffer_font.clone(); - let completion = completion.font(buffer_font.clone()); - let completion = if is_refreshing { completion .with_animation( @@ -5698,6 +6043,7 @@ impl Editor { .px_2() .py_1() .elevation_2(cx) + .border_color(cx.theme().colors().border) .child(completion) .child(ui::Divider::vertical()) .child( @@ -5705,19 +6051,22 @@ impl Editor { .h_full() .gap_1() .pl_2() - .child(h_flex().font(buffer_font.clone()).gap_1().children( - ui::render_modifiers( - &accept_keystroke.modifiers, - PlatformStyle::platform(), - Some(if !has_completion { - Color::Muted - } else { - Color::Default - }), - None, - true, - ), - )) + .child( + h_flex() + .font(theme::ThemeSettings::get_global(cx).buffer_font.clone()) + .gap_1() + .children(ui::render_modifiers( + &accept_keystroke.modifiers, + PlatformStyle::platform(), + Some(if !has_completion { + Color::Muted + } else { + Color::Default + }), + None, + true, + )), + ) .child(Label::new("Preview").into_any_element()) .opacity(if has_completion { 1.0 } else { 0.4 }), ) @@ -5730,7 +6079,6 @@ impl Editor { completion: &InlineCompletionState, cursor_point: Point, style: &EditorStyle, - window: &Window, cx: &mut Context, ) -> Option
{ use text::ToPoint as _; @@ -5756,6 +6104,23 @@ impl Editor { } match &completion.completion { + InlineCompletion::Move { + target, snapshot, .. + } => Some( + h_flex() + .px_2() + .gap_2() + .flex_1() + .child( + if target.text_anchor.to_point(&snapshot).row > cursor_point.row { + Icon::new(IconName::ZedPredictDown) + } else { + Icon::new(IconName::ZedPredictUp) + }, + ) + .child(Label::new("Jump to Edit")), + ), + InlineCompletion::Edit { edits, edit_preview, @@ -5825,103 +6190,11 @@ impl Editor { .gap_2() .pr_1() .overflow_x_hidden() + .font(theme::ThemeSettings::get_global(cx).buffer_font.clone()) .child(left) .child(preview), ) } - - InlineCompletion::Move { - target, - range_around_target, - snapshot, - } => { - let highlighted_text = snapshot.highlighted_text_for_range( - range_around_target.clone(), - None, - &style.syntax, - ); - let base = h_flex().gap_3().flex_1().child(render_relative_row_jump( - "Jump ", - cursor_point.row, - target.text_anchor.to_point(&snapshot).row, - )); - - if highlighted_text.text.is_empty() { - return Some(base); - } - - let cursor_color = self.current_user_player_color(cx).cursor; - - let start_point = range_around_target.start.to_point(&snapshot); - let end_point = range_around_target.end.to_point(&snapshot); - let target_point = target.text_anchor.to_point(&snapshot); - - let styled_text = highlighted_text.to_styled_text(&style.text); - let text_len = highlighted_text.text.len(); - - let cursor_relative_position = window - .text_system() - .layout_line( - highlighted_text.text, - style.text.font_size.to_pixels(window.rem_size()), - // We don't need to include highlights - // because we are only using this for the cursor position - &[TextRun { - len: text_len, - font: style.text.font(), - color: style.text.color, - background_color: None, - underline: None, - strikethrough: None, - }], - ) - .log_err() - .map(|line| { - line.x_for_index( - target_point.column.saturating_sub(start_point.column) as usize - ) - }); - - let fade_before = start_point.column > 0; - let fade_after = end_point.column < snapshot.line_len(end_point.row); - - let background = cx.theme().colors().elevated_surface_background; - - let preview = h_flex() - .relative() - .child(styled_text) - .when(fade_before, |parent| { - parent.child(div().absolute().top_0().left_0().w_4().h_full().bg( - linear_gradient( - 90., - linear_color_stop(background, 0.), - linear_color_stop(background.opacity(0.), 1.), - ), - )) - }) - .when(fade_after, |parent| { - parent.child(div().absolute().top_0().right_0().w_4().h_full().bg( - linear_gradient( - -90., - linear_color_stop(background, 0.), - linear_color_stop(background.opacity(0.), 1.), - ), - )) - }) - .when_some(cursor_relative_position, |parent, position| { - parent.child( - div() - .w(px(2.)) - .h_full() - .bg(cursor_color) - .absolute() - .top_0() - .left(position), - ) - }); - - Some(base.child(preview)) - } } } @@ -13740,6 +14013,23 @@ impl Editor { } } + pub fn previewing_edit_prediction_move( + &mut self, + ) -> Option<(Anchor, &mut EditPredictionPreview)> { + if !self.edit_prediction_preview.is_active() { + return None; + }; + + self.active_inline_completion + .as_ref() + .and_then(|completion| match completion.completion { + InlineCompletion::Move { target, .. } => { + Some((target, &mut self.edit_prediction_preview)) + } + _ => None, + }) + } + pub fn show_local_cursors(&self, window: &mut Window, cx: &mut App) -> bool { (self.read_only(cx) || self.blink_manager.read(cx).visible()) && self.focus_handle.is_focused(window) @@ -14572,7 +14862,7 @@ impl Editor { } pub fn has_visible_completions_menu(&self) -> bool { - !self.previewing_inline_completion + !self.edit_prediction_preview.is_active() && self.context_menu.borrow().as_ref().map_or(false, |menu| { menu.visible() && matches!(menu, CodeContextMenu::Completions(_)) }) diff --git a/crates/editor/src/element.rs b/crates/editor/src/element.rs index 646822ab4685ab..377a620594230d 100644 --- a/crates/editor/src/element.rs +++ b/crates/editor/src/element.rs @@ -16,12 +16,12 @@ use crate::{ mouse_context_menu::{self, MenuPosition, MouseContextMenu}, scroll::{axis_pair, scroll_amount::ScrollAmount, AxisPair}, AcceptEditPrediction, BlockId, ChunkReplacement, CursorShape, CustomBlockId, DisplayPoint, - DisplayRow, DocumentHighlightRead, DocumentHighlightWrite, EditDisplayMode, Editor, EditorMode, - EditorSettings, EditorSnapshot, EditorStyle, ExpandExcerpts, FocusedBlock, GoToHunk, - GoToPrevHunk, GutterDimensions, HalfPageDown, HalfPageUp, HandleInput, HoveredCursor, - InlineCompletion, JumpData, LineDown, LineUp, OpenExcerpts, PageDown, PageUp, Point, - RevertSelectedHunks, RowExt, RowRangeExt, SelectPhase, Selection, SoftWrap, - StickyHeaderExcerpt, ToPoint, ToggleFold, CURSORS_VISIBLE_FOR, + DisplayRow, DocumentHighlightRead, DocumentHighlightWrite, EditDisplayMode, + EditPredictionPreview, Editor, EditorMode, EditorSettings, EditorSnapshot, EditorStyle, + ExpandExcerpts, FocusedBlock, GoToHunk, GoToPrevHunk, GutterDimensions, HalfPageDown, + HalfPageUp, HandleInput, HoveredCursor, InlineCompletion, JumpData, LineDown, LineUp, + OpenExcerpts, PageDown, PageUp, Point, RevertSelectedHunks, RowExt, RowRangeExt, SelectPhase, + Selection, SoftWrap, StickyHeaderExcerpt, ToPoint, ToggleFold, CURSORS_VISIBLE_FOR, EDIT_PREDICTION_REQUIRES_MODIFIER_KEY_CONTEXT, FILE_HEADER_HEIGHT, GIT_BLAME_MAX_AUTHOR_CHARS_DISPLAYED, MAX_LINE_LEN, MULTI_BUFFER_EXCERPT_HEADER_HEIGHT, }; @@ -1114,18 +1114,44 @@ impl EditorElement { em_width: Pixels, em_advance: Pixels, autoscroll_containing_element: bool, + newest_selection_head: Option, window: &mut Window, cx: &mut App, ) -> Vec { let mut autoscroll_bounds = None; let cursor_layouts = self.editor.update(cx, |editor, cx| { let mut cursors = Vec::new(); + + let previewing_move = + if let Some((target, preview)) = editor.previewing_edit_prediction_move() { + cursors.extend(self.layout_edit_prediction_preview_cursor( + snapshot, + visible_display_row_range.clone(), + line_layouts, + content_origin, + scroll_pixel_position, + line_height, + em_advance, + preview, + target, + newest_selection_head, + window, + cx, + )); + + true + } else { + false + }; + + let show_local_cursors = !previewing_move && editor.show_local_cursors(window, cx); + for (player_color, selections) in selections { for selection in selections { let cursor_position = selection.head; let in_range = visible_display_row_range.contains(&cursor_position.row()); - if (selection.is_local && !editor.show_local_cursors(window, cx)) + if (selection.is_local && !show_local_cursors) || !in_range || block_start_rows.contains(&cursor_position.row()) { @@ -1249,6 +1275,7 @@ impl EditorElement { cursors.push(cursor); } } + cursors }); @@ -1259,6 +1286,50 @@ impl EditorElement { cursor_layouts } + #[allow(clippy::too_many_arguments)] + fn layout_edit_prediction_preview_cursor( + &self, + snapshot: &EditorSnapshot, + visible_row_range: Range, + line_layouts: &[LineWithInvisibles], + content_origin: gpui::Point, + scroll_pixel_position: gpui::Point, + line_height: Pixels, + em_advance: Pixels, + preview: &mut EditPredictionPreview, + target: Anchor, + cursor: Option, + window: &mut Window, + cx: &mut App, + ) -> Option { + let state = preview.move_state( + snapshot, + visible_row_range, + line_layouts, + scroll_pixel_position, + line_height, + target, + cursor, + )?; + + if !state.is_animation_completed() { + window.request_animation_frame(); + } + + let mut cursor = CursorLayout { + color: self.style.local_player.cursor, + block_width: em_advance, + origin: state.position, + line_height, + shape: CursorShape::Bar, + block_text: None, + cursor_name: None, + }; + + cursor.layout(content_origin, None, window, cx); + Some(cursor) + } + fn layout_scrollbars( &self, snapshot: &EditorSnapshot, @@ -3531,7 +3602,7 @@ impl EditorElement { } #[allow(clippy::too_many_arguments)] - fn layout_inline_completion_popover( + fn layout_edit_prediction_popover( &self, text_bounds: &Bounds, editor_snapshot: &EditorSnapshot, @@ -3559,6 +3630,49 @@ impl EditorElement { match &active_inline_completion.completion { InlineCompletion::Move { target, .. } => { + if editor.edit_prediction_requires_modifier() { + let cursor_position = + target.to_display_point(&editor_snapshot.display_snapshot); + + if !editor.edit_prediction_preview.is_active_settled() + || !visible_row_range.contains(&cursor_position.row()) + { + return None; + } + + let accept_keybind = editor.accept_edit_prediction_keybind(window, cx); + let accept_keystroke = accept_keybind.keystroke()?; + + let mut element = div() + .px_2() + .py_1() + .elevation_2(cx) + .border_color(cx.theme().colors().border) + .rounded_br(px(0.)) + .child(Label::new(accept_keystroke.key.clone()).buffer_font(cx)) + .into_any(); + + let size = element.layout_as_root(AvailableSpace::min_size(), window, cx); + + let cursor_row_layout = &line_layouts + [cursor_position.row().minus(visible_row_range.start) as usize]; + let cursor_column = cursor_position.column() as usize; + + let cursor_character_x = cursor_row_layout.x_for_index(cursor_column); + let target_y = (cursor_position.row().as_f32() + - scroll_pixel_position.y / line_height) + * line_height; + + let offset = point( + cursor_character_x - size.width, + target_y - size.height - PADDING_Y, + ); + + element.prepaint_at(text_bounds.origin + offset, window, cx); + + return Some(element); + } + let target_display_point = target.to_display_point(editor_snapshot); if target_display_point.row().as_f32() < scroll_top { let mut element = inline_completion_accept_indicator( @@ -5688,7 +5802,7 @@ fn inline_completion_accept_indicator( .text_size(TextSize::XSmall.rems(cx)) .text_color(cx.theme().colors().text) .gap_1() - .when(!editor.previewing_inline_completion, |parent| { + .when(!editor.edit_prediction_preview.is_active(), |parent| { parent.children(ui::render_modifiers( &accept_keystroke.modifiers, PlatformStyle::platform(), @@ -7246,6 +7360,7 @@ impl Element for EditorElement { em_width, em_advance, autoscroll_containing_element, + newest_selection_head, window, cx, ); @@ -7397,7 +7512,7 @@ impl Element for EditorElement { ); } - let inline_completion_popover = self.layout_inline_completion_popover( + let inline_completion_popover = self.layout_edit_prediction_popover( &text_hitbox.bounds, &snapshot, start_row..end_row, diff --git a/crates/editor/src/scroll/autoscroll.rs b/crates/editor/src/scroll/autoscroll.rs index 7b6a9fc96234fd..d4c57fd11394b9 100644 --- a/crates/editor/src/scroll/autoscroll.rs +++ b/crates/editor/src/scroll/autoscroll.rs @@ -113,6 +113,7 @@ impl Editor { target_bottom = target_top + 1.; } else { let selections = self.selections.all::(cx); + target_top = selections .first() .unwrap() @@ -144,6 +145,29 @@ impl Editor { target_top = newest_selection_top; target_bottom = newest_selection_top + 1.; } + + if self.edit_prediction_preview.is_active() { + if let Some(completion) = self.active_inline_completion.as_ref() { + match completion.completion { + crate::InlineCompletion::Edit { .. } => {} + crate::InlineCompletion::Move { target, .. } => { + let target_row = target.to_display_point(&display_map).row().as_f32(); + + if target_row < target_top { + target_top = target_row; + } else if target_row >= target_bottom { + target_bottom = target_row + 1.; + } + + let selections_fit = target_bottom - target_top <= visible_lines; + if !selections_fit { + target_top = target_row; + target_bottom = target_row + 1.; + } + } + } + } + } } let margin = if matches!(self.mode, EditorMode::AutoHeight { .. }) { diff --git a/crates/ui/src/components/icon.rs b/crates/ui/src/components/icon.rs index 40d1a422ddb632..d8043f74001a48 100644 --- a/crates/ui/src/components/icon.rs +++ b/crates/ui/src/components/icon.rs @@ -324,6 +324,8 @@ pub enum IconName { ZedAssistant2, ZedAssistantFilled, ZedPredict, + ZedPredictUp, + ZedPredictDown, ZedPredictDisabled, ZedXCopilot, }