editor: Add support for `drag_and_drop_selection` (#30671)

CharlesChen0823 and Smit Barmase created

Closes #4958 

Release Notes:

- Added support for drag and drop text selection. It can be disabled by
setting `drag_and_drop_selection` to `false`.

---------

Co-authored-by: Smit Barmase <heysmitbarmase@gmail.com>

Change summary

assets/settings/default.json         |   2 
crates/editor/src/editor.rs          |  68 ++++++++++++++
crates/editor/src/editor_settings.rs |   6 +
crates/editor/src/element.rs         | 141 ++++++++++++++++++++++++++---
crates/vim/src/vim.rs                |   3 
docs/src/configuring-zed.md          |  10 ++
6 files changed, 214 insertions(+), 16 deletions(-)

Detailed changes

assets/settings/default.json 🔗

@@ -217,6 +217,8 @@
   "show_signature_help_after_edits": false,
   // Whether to show code action button at start of buffer line.
   "inline_code_actions": true,
+  // Whether to allow drag and drop text selection in buffer.
+  "drag_and_drop_selection": true,
   // What to do when go to definition yields no results.
   //
   // 1. Do nothing: `none`

crates/editor/src/editor.rs 🔗

@@ -906,6 +906,18 @@ struct InlineBlamePopover {
     popover_state: InlineBlamePopoverState,
 }
 
+enum SelectionDragState {
+    /// State when no drag related activity is detected.
+    None,
+    /// State when the mouse is down on a selection that is about to be dragged.
+    ReadyToDrag { selection: Selection<Anchor> },
+    /// State when the mouse is dragging the selection in the editor.
+    Dragging {
+        selection: Selection<Anchor>,
+        drop_cursor: Selection<Anchor>,
+    },
+}
+
 /// Represents a breakpoint indicator that shows up when hovering over lines in the gutter that don't have
 /// a breakpoint on them.
 #[derive(Clone, Copy, Debug)]
@@ -1091,6 +1103,8 @@ pub struct Editor {
     hide_mouse_mode: HideMouseMode,
     pub change_list: ChangeList,
     inline_value_cache: InlineValueCache,
+    selection_drag_state: SelectionDragState,
+    drag_and_drop_selection_enabled: bool,
 }
 
 #[derive(Copy, Clone, Debug, PartialEq, Eq, Default)]
@@ -1985,6 +1999,8 @@ impl Editor {
                 .unwrap_or_default(),
             change_list: ChangeList::new(),
             mode,
+            selection_drag_state: SelectionDragState::None,
+            drag_and_drop_selection_enabled: EditorSettings::get_global(cx).drag_and_drop_selection,
         };
         if let Some(breakpoints) = editor.breakpoint_store.as_ref() {
             editor
@@ -3530,6 +3546,7 @@ impl Editor {
 
     pub fn cancel(&mut self, _: &Cancel, window: &mut Window, cx: &mut Context<Self>) {
         self.selection_mark_mode = false;
+        self.selection_drag_state = SelectionDragState::None;
 
         if self.clear_expanded_diff_hunks(cx) {
             cx.notify();
@@ -10584,6 +10601,56 @@ impl Editor {
         });
     }
 
+    pub fn drop_selection(
+        &mut self,
+        point_for_position: Option<PointForPosition>,
+        is_cut: bool,
+        window: &mut Window,
+        cx: &mut Context<Self>,
+    ) -> bool {
+        if let Some(point_for_position) = point_for_position {
+            match self.selection_drag_state {
+                SelectionDragState::Dragging { ref selection, .. } => {
+                    let snapshot = self.snapshot(window, cx);
+                    let selection_display =
+                        selection.map(|anchor| anchor.to_display_point(&snapshot));
+                    if !point_for_position.intersects_selection(&selection_display) {
+                        let point = point_for_position.previous_valid;
+                        let display_map = self.display_map.update(cx, |map, cx| map.snapshot(cx));
+                        let buffer = &display_map.buffer_snapshot;
+                        let mut edits = Vec::new();
+                        let insert_point = display_map
+                            .clip_point(point, Bias::Left)
+                            .to_point(&display_map);
+                        let text = buffer
+                            .text_for_range(selection.start..selection.end)
+                            .collect::<String>();
+                        if is_cut {
+                            edits.push(((selection.start..selection.end), String::new()));
+                        }
+                        let insert_anchor = buffer.anchor_before(insert_point);
+                        edits.push(((insert_anchor..insert_anchor), text));
+                        let last_edit_start = insert_anchor.bias_left(buffer);
+                        let last_edit_end = insert_anchor.bias_right(buffer);
+                        self.transact(window, cx, |this, window, cx| {
+                            this.buffer.update(cx, |buffer, cx| {
+                                buffer.edit(edits, None, cx);
+                            });
+                            this.change_selections(Some(Autoscroll::fit()), window, cx, |s| {
+                                s.select_anchor_ranges([last_edit_start..last_edit_end]);
+                            });
+                        });
+                        self.selection_drag_state = SelectionDragState::None;
+                        return true;
+                    }
+                }
+                _ => {}
+            }
+        }
+        self.selection_drag_state = SelectionDragState::None;
+        false
+    }
+
     pub fn duplicate(
         &mut self,
         upwards: bool,
@@ -18987,6 +19054,7 @@ impl Editor {
             self.show_breadcrumbs = editor_settings.toolbar.breadcrumbs;
             self.cursor_shape = editor_settings.cursor_shape.unwrap_or_default();
             self.hide_mouse_mode = editor_settings.hide_mouse.unwrap_or_default();
+            self.drag_and_drop_selection_enabled = editor_settings.drag_and_drop_selection;
         }
 
         if old_cursor_shape != self.cursor_shape {

crates/editor/src/editor_settings.rs 🔗

@@ -49,6 +49,7 @@ pub struct EditorSettings {
     #[serde(default)]
     pub diagnostics_max_severity: Option<DiagnosticSeverity>,
     pub inline_code_actions: bool,
+    pub drag_and_drop_selection: bool,
 }
 
 #[derive(Copy, Clone, Debug, Serialize, Deserialize, PartialEq, Eq, JsonSchema)]
@@ -495,6 +496,11 @@ pub struct EditorSettingsContent {
     ///
     /// Default: true
     pub inline_code_actions: Option<bool>,
+
+    /// Whether to allow drag and drop text selection in buffer.
+    ///
+    /// Default: true
+    pub drag_and_drop_selection: Option<bool>,
 }
 
 // Toolbar related settings

crates/editor/src/element.rs 🔗

@@ -8,8 +8,8 @@ use crate::{
     InlayHintRefreshReason, InlineCompletion, JumpData, LineDown, LineHighlight, LineUp,
     MAX_LINE_LEN, MIN_LINE_NUMBER_DIGITS, MINIMAP_FONT_SIZE, MULTI_BUFFER_EXCERPT_HEADER_HEIGHT,
     OpenExcerpts, PageDown, PageUp, PhantomBreakpointIndicator, Point, RowExt, RowRangeExt,
-    SelectPhase, SelectedTextHighlight, Selection, SoftWrap, StickyHeaderExcerpt, ToPoint,
-    ToggleFold,
+    SelectPhase, SelectedTextHighlight, Selection, SelectionDragState, SoftWrap,
+    StickyHeaderExcerpt, ToPoint, ToggleFold,
     code_context_menus::{CodeActionsMenu, MENU_ASIDE_MAX_WIDTH, MENU_ASIDE_MIN_WIDTH, MENU_GAP},
     display_map::{
         Block, BlockContext, BlockStyle, DisplaySnapshot, EditorMargins, FoldId, HighlightedChunk,
@@ -78,10 +78,11 @@ use std::{
     time::Duration,
 };
 use sum_tree::Bias;
-use text::BufferId;
+use text::{BufferId, SelectionGoal};
 use theme::{ActiveTheme, Appearance, BufferLineHeight, PlayerColor};
 use ui::{ButtonLike, KeyBinding, POPOVER_Y_PADDING, Tooltip, h_flex, prelude::*};
 use unicode_segmentation::UnicodeSegmentation;
+use util::post_inc;
 use util::{RangeExt, ResultExt, debug_panic};
 use workspace::{CollaboratorId, Workspace, item::Item, notifications::NotifyTaskExt};
 
@@ -619,6 +620,7 @@ impl EditorElement {
 
         let text_hitbox = &position_map.text_hitbox;
         let gutter_hitbox = &position_map.gutter_hitbox;
+        let point_for_position = position_map.point_for_position(event.position);
         let mut click_count = event.click_count;
         let mut modifiers = event.modifiers;
 
@@ -632,6 +634,19 @@ impl EditorElement {
             return;
         }
 
+        if editor.drag_and_drop_selection_enabled && click_count == 1 {
+            let newest_anchor = editor.selections.newest_anchor();
+            let snapshot = editor.snapshot(window, cx);
+            let selection = newest_anchor.map(|anchor| anchor.to_display_point(&snapshot));
+            if point_for_position.intersects_selection(&selection) {
+                editor.selection_drag_state = SelectionDragState::ReadyToDrag {
+                    selection: newest_anchor.clone(),
+                };
+                cx.stop_propagation();
+                return;
+            }
+        }
+
         let is_singleton = editor.buffer().read(cx).is_singleton();
 
         if click_count == 2 && !is_singleton {
@@ -675,11 +690,8 @@ impl EditorElement {
             }
         }
 
-        let point_for_position = position_map.point_for_position(event.position);
         let position = point_for_position.previous_valid;
-
         let multi_cursor_modifier = Editor::multi_cursor_modifier(true, &modifiers, cx);
-
         if Editor::columnar_selection_modifiers(multi_cursor_modifier, &modifiers) {
             editor.select(
                 SelectPhase::BeginColumnar {
@@ -818,6 +830,12 @@ impl EditorElement {
         let text_hitbox = &position_map.text_hitbox;
         let end_selection = editor.has_pending_selection();
         let pending_nonempty_selections = editor.has_pending_nonempty_selection();
+        let point_for_position = position_map.point_for_position(event.position);
+
+        let is_cut = !event.modifiers.control;
+        if editor.drop_selection(Some(point_for_position), is_cut, window, cx) {
+            return;
+        }
 
         if end_selection {
             editor.select(SelectPhase::End, window, cx);
@@ -881,12 +899,15 @@ impl EditorElement {
         window: &mut Window,
         cx: &mut Context<Editor>,
     ) {
-        if !editor.has_pending_selection() {
+        if !editor.has_pending_selection()
+            && matches!(editor.selection_drag_state, SelectionDragState::None)
+        {
             return;
         }
 
         let text_bounds = position_map.text_hitbox.bounds;
         let point_for_position = position_map.point_for_position(event.position);
+
         let mut scroll_delta = gpui::Point::<f32>::default();
         let vertical_margin = position_map.line_height.min(text_bounds.size.height / 3.0);
         let top = text_bounds.origin.y + vertical_margin;
@@ -918,15 +939,46 @@ impl EditorElement {
             scroll_delta.x = scale_horizontal_mouse_autoscroll_delta(event.position.x - right);
         }
 
-        editor.select(
-            SelectPhase::Update {
-                position: point_for_position.previous_valid,
-                goal_column: point_for_position.exact_unclipped.column(),
-                scroll_delta,
-            },
-            window,
-            cx,
-        );
+        if !editor.has_pending_selection() {
+            let drop_anchor = position_map
+                .snapshot
+                .display_point_to_anchor(point_for_position.previous_valid, Bias::Left);
+            match editor.selection_drag_state {
+                SelectionDragState::Dragging {
+                    ref mut drop_cursor,
+                    ..
+                } => {
+                    drop_cursor.start = drop_anchor;
+                    drop_cursor.end = drop_anchor;
+                }
+                SelectionDragState::ReadyToDrag { ref selection } => {
+                    let drop_cursor = Selection {
+                        id: post_inc(&mut editor.selections.next_selection_id),
+                        start: drop_anchor,
+                        end: drop_anchor,
+                        reversed: false,
+                        goal: SelectionGoal::None,
+                    };
+                    editor.selection_drag_state = SelectionDragState::Dragging {
+                        selection: selection.clone(),
+                        drop_cursor,
+                    };
+                }
+                _ => {}
+            }
+            editor.apply_scroll_delta(scroll_delta, window, cx);
+            cx.notify();
+        } else {
+            editor.select(
+                SelectPhase::Update {
+                    position: point_for_position.previous_valid,
+                    goal_column: point_for_position.exact_unclipped.column(),
+                    scroll_delta,
+                },
+                window,
+                cx,
+            );
+        }
     }
 
     fn mouse_moved(
@@ -1155,6 +1207,34 @@ impl EditorElement {
 
                 let player = editor.current_user_player_color(cx);
                 selections.push((player, layouts));
+
+                if let SelectionDragState::Dragging {
+                    ref selection,
+                    ref drop_cursor,
+                } = editor.selection_drag_state
+                {
+                    if drop_cursor
+                        .start
+                        .cmp(&selection.start, &snapshot.buffer_snapshot)
+                        .eq(&Ordering::Less)
+                        || drop_cursor
+                            .end
+                            .cmp(&selection.end, &snapshot.buffer_snapshot)
+                            .eq(&Ordering::Greater)
+                    {
+                        let drag_cursor_layout = SelectionLayout::new(
+                            drop_cursor.clone(),
+                            false,
+                            CursorShape::Bar,
+                            &snapshot.display_snapshot,
+                            false,
+                            false,
+                            None,
+                        );
+                        let absent_color = cx.theme().players().absent();
+                        selections.push((absent_color, vec![drag_cursor_layout]));
+                    }
+                }
             }
 
             if let Some(collaboration_hub) = &editor.collaboration_hub {
@@ -9235,6 +9315,35 @@ impl PointForPosition {
             None
         }
     }
+
+    pub fn intersects_selection(&self, selection: &Selection<DisplayPoint>) -> bool {
+        let Some(valid_point) = self.as_valid() else {
+            return false;
+        };
+        let range = selection.range();
+
+        let candidate_row = valid_point.row();
+        let candidate_col = valid_point.column();
+
+        let start_row = range.start.row();
+        let start_col = range.start.column();
+        let end_row = range.end.row();
+        let end_col = range.end.column();
+
+        if candidate_row < start_row || candidate_row > end_row {
+            false
+        } else if start_row == end_row {
+            candidate_col >= start_col && candidate_col < end_col
+        } else {
+            if candidate_row == start_row {
+                candidate_col >= start_col
+            } else if candidate_row == end_row {
+                candidate_col < end_col
+            } else {
+                true
+            }
+        }
+    }
 }
 
 impl PositionMap {

crates/vim/src/vim.rs 🔗

@@ -915,6 +915,9 @@ impl Vim {
         if mode == Mode::Normal || mode != last_mode {
             self.current_tx.take();
             self.current_anchor.take();
+            self.update_editor(window, cx, |_, editor, window, cx| {
+                editor.drop_selection(None, false, window, cx);
+            });
         }
         Vim::take_forced_motion(cx);
         if mode != Mode::Insert && mode != Mode::Replace {

docs/src/configuring-zed.md 🔗

@@ -1216,6 +1216,16 @@ or
 
 `boolean` values
 
+### Drag And Drop Selection
+
+- Description: Whether to allow drag and drop text selection in buffer.
+- Setting: `drag_and_drop_selection`
+- Default: `true`
+
+**Options**
+
+`boolean` values
+
 ## Editor Toolbar
 
 - Description: Whether or not to show various elements in the editor toolbar.