Show "tab Accept" indicator for single line insertions/deletions (#23411)

Agus Zubiaga and Danilo created

https://github.com/user-attachments/assets/655f20a9-f22f-4f91-870e-d40b20c8c994

Release Notes:

- N/A

Co-authored-by: Danilo <danilo@zed.dev>

Change summary

crates/editor/src/element.rs | 168 +++++++++++++++++++++++--------------
1 file changed, 106 insertions(+), 62 deletions(-)

Detailed changes

crates/editor/src/element.rs 🔗

@@ -1592,6 +1592,7 @@ impl EditorElement {
         &self,
         display_row: DisplayRow,
         display_snapshot: &DisplaySnapshot,
+        buffer_snapshot: &MultiBufferSnapshot,
         line_layout: &LineWithInvisibles,
         crease_trailer: Option<&CreaseTrailerLayout>,
         em_width: Pixels,
@@ -1617,7 +1618,28 @@ impl EditorElement {
         let display_point = DisplayPoint::new(display_row, 0);
         let buffer_row = MultiBufferRow(display_point.to_point(display_snapshot).row);
 
-        let blame = self.editor.read(cx).blame.clone()?;
+        let editor = self.editor.read(cx);
+        let blame = editor.blame.clone()?;
+        let padding = {
+            const INLINE_BLAME_PADDING_EM_WIDTHS: f32 = 6.;
+            const INLINE_ACCEPT_SUGGESTION_EM_WIDTHS: f32 = 14.;
+
+            let mut padding = INLINE_BLAME_PADDING_EM_WIDTHS;
+
+            if let Some(inline_completion) = editor.active_inline_completion.as_ref() {
+                match &inline_completion.completion {
+                    InlineCompletion::Edit(edits)
+                        if single_line_edit(&edits, buffer_snapshot).is_some() =>
+                    {
+                        padding += INLINE_ACCEPT_SUGGESTION_EM_WIDTHS
+                    }
+                    _ => {}
+                }
+            }
+
+            padding * em_width
+        };
+
         let blame_entry = blame
             .update(cx, |blame, cx| {
                 blame.blame_for_rows([Some(buffer_row)], cx).next()
@@ -1631,14 +1653,13 @@ impl EditorElement {
             + line_height * (display_row.as_f32() - scroll_pixel_position.y / line_height);
 
         let start_x = {
-            const INLINE_BLAME_PADDING_EM_WIDTHS: f32 = 6.;
-
             let line_end = if let Some(crease_trailer) = crease_trailer {
                 crease_trailer.bounds.right()
             } else {
                 content_origin.x - scroll_pixel_position.x + line_layout.width
             };
-            let padded_line_end = line_end + em_width * INLINE_BLAME_PADDING_EM_WIDTHS;
+
+            let padded_line_end = line_end + padding;
 
             let min_column_in_pixels = ProjectSettings::get_global(cx)
                 .git
@@ -3326,49 +3347,23 @@ impl EditorElement {
 
         match &active_inline_completion.completion {
             InlineCompletion::Move(target_position) => {
-                let tab_kbd = h_flex()
-                    .px_0p5()
-                    .font(theme::ThemeSettings::get_global(cx).buffer_font.clone())
-                    .text_size(TextSize::XSmall.rems(cx))
-                    .text_color(cx.theme().colors().text)
-                    .child("tab");
-
-                let icon_container = div().mt(px(2.5)); // For optical alignment
-
-                let container_element = h_flex()
-                    .items_center()
-                    .py_0p5()
-                    .px_1()
-                    .gap_1()
-                    .bg(cx.theme().colors().text_accent.opacity(0.15))
-                    .border_1()
-                    .border_color(cx.theme().colors().text_accent.opacity(0.8))
-                    .rounded_md()
-                    .shadow_sm();
-
                 let target_display_point = target_position.to_display_point(editor_snapshot);
                 if target_display_point.row().as_f32() < scroll_top {
-                    let mut element = container_element
-                        .child(tab_kbd)
-                        .child(Label::new("Jump to Edit").size(LabelSize::Small))
-                        .child(
-                            icon_container
-                                .child(Icon::new(IconName::ArrowUp).size(IconSize::Small)),
-                        )
-                        .into_any();
+                    let mut element = inline_completion_tab_indicator(
+                        "Jump to Edit",
+                        Some(IconName::ArrowUp),
+                        cx,
+                    );
                     let size = element.layout_as_root(AvailableSpace::min_size(), cx);
                     let offset = point((text_bounds.size.width - size.width) / 2., PADDING_Y);
                     element.prepaint_at(text_bounds.origin + offset, cx);
                     Some(element)
                 } else if (target_display_point.row().as_f32() + 1.) > scroll_bottom {
-                    let mut element = container_element
-                        .child(tab_kbd)
-                        .child(Label::new("Jump to Edit").size(LabelSize::Small))
-                        .child(
-                            icon_container
-                                .child(Icon::new(IconName::ArrowDown).size(IconSize::Small)),
-                        )
-                        .into_any();
+                    let mut element = inline_completion_tab_indicator(
+                        "Jump to Edit",
+                        Some(IconName::ArrowDown),
+                        cx,
+                    );
                     let size = element.layout_as_root(AvailableSpace::min_size(), cx);
                     let offset = point(
                         (text_bounds.size.width - size.width) / 2.,
@@ -3377,10 +3372,7 @@ impl EditorElement {
                     element.prepaint_at(text_bounds.origin + offset, cx);
                     Some(element)
                 } else {
-                    let mut element = container_element
-                        .child(tab_kbd)
-                        .child(Label::new("Jump to Edit").size(LabelSize::Small))
-                        .into_any();
+                    let mut element = inline_completion_tab_indicator("Jump to Edit", None, cx);
 
                     let target_line_end = DisplayPoint::new(
                         target_display_point.row(),
@@ -3421,9 +3413,28 @@ impl EditorElement {
                     return None;
                 }
 
-                if !hard_to_spot_single_edit(&edits, &editor_snapshot.buffer_snapshot)
-                    && all_edits_insertions_or_deletions(edits, &editor_snapshot.buffer_snapshot)
-                {
+                if let Some(range) = single_line_edit(&edits, &editor_snapshot.buffer_snapshot) {
+                    let mut element = inline_completion_tab_indicator("Accept", None, cx);
+
+                    let target_display_point = range.end.to_display_point(editor_snapshot);
+                    let target_line_end = DisplayPoint::new(
+                        target_display_point.row(),
+                        editor_snapshot.line_len(target_display_point.row()),
+                    );
+                    let origin = self.editor.update(cx, |editor, cx| {
+                        editor.display_to_pixel_point(target_line_end, editor_snapshot, cx)
+                    })?;
+
+                    element.prepaint_as_root(
+                        text_bounds.origin + origin + point(PADDING_X, px(0.)),
+                        AvailableSpace::min_size(),
+                        cx,
+                    );
+
+                    return Some(element);
+                }
+
+                if all_edits_insertions_or_deletions(edits, &editor_snapshot.buffer_snapshot) {
                     return None;
                 }
 
@@ -5203,23 +5214,55 @@ fn header_jump_data(
     }
 }
 
-/// Returns true if there's a single edit, that's either a single character
-/// insertion or a single line deletion.
-fn hard_to_spot_single_edit(
-    edits: &[(Range<Anchor>, String)],
+fn inline_completion_tab_indicator(
+    label: impl Into<SharedString>,
+    icon: Option<IconName>,
+    cx: &WindowContext,
+) -> AnyElement {
+    let tab_kbd = h_flex()
+        .px_0p5()
+        .font(theme::ThemeSettings::get_global(cx).buffer_font.clone())
+        .text_size(TextSize::XSmall.rems(cx))
+        .text_color(cx.theme().colors().text)
+        .child("tab");
+
+    let padding_right = if icon.is_some() { px(4.) } else { px(8.) };
+
+    h_flex()
+        .py_0p5()
+        .pl_1()
+        .pr(padding_right)
+        .gap_1()
+        .bg(cx.theme().colors().text_accent.opacity(0.15))
+        .border_1()
+        .border_color(cx.theme().colors().text_accent.opacity(0.8))
+        .rounded_md()
+        .shadow_sm()
+        .child(tab_kbd)
+        .child(Label::new(label).size(LabelSize::Small))
+        .when_some(icon, |element, icon| {
+            element.child(
+                div()
+                    .mt(px(1.5))
+                    .child(Icon::new(icon).size(IconSize::Small)),
+            )
+        })
+        .into_any()
+}
+
+fn single_line_edit<'a>(
+    edits: &'a [(Range<Anchor>, String)],
     snapshot: &MultiBufferSnapshot,
-) -> bool {
-    if let [(range, new_text)] = edits {
-        match new_text.len() {
-            0 => {
-                let range = range.to_point(&snapshot);
-                range.start.row == range.end.row
-            }
-            1 => true,
-            _ => false,
-        }
+) -> Option<&'a Range<Anchor>> {
+    let [(range, _)] = edits else {
+        return None;
+    };
+
+    let point_range = range.to_point(&snapshot);
+    if point_range.start.row == point_range.end.row {
+        Some(range)
     } else {
-        false
+        None
     }
 }
 
@@ -6507,6 +6550,7 @@ impl Element for EditorElement {
                             inline_blame = self.layout_inline_blame(
                                 display_row,
                                 &snapshot.display_snapshot,
+                                &snapshot.buffer_snapshot,
                                 line_layout,
                                 crease_trailer_layout,
                                 em_width,