Implement word-wise mouse selection

Antonio Scandurra and Nathan Sobo created

Co-Authored-By: Nathan Sobo <nathan@zed.dev>

Change summary

crates/editor/src/element.rs          | 16 +---
crates/editor/src/lib.rs              | 99 +++++++++++++++++-----------
crates/editor/src/movement.rs         |  8 ++
crates/gpui/src/app.rs                |  1 
crates/gpui/src/platform/event.rs     |  2 
crates/gpui/src/platform/mac/event.rs |  2 
6 files changed, 77 insertions(+), 51 deletions(-)

Detailed changes

crates/editor/src/element.rs 🔗

@@ -1,6 +1,6 @@
 use super::{
     DisplayPoint, DisplayRow, Editor, EditorMode, EditorSettings, EditorStyle, Input, Scroll,
-    Select, SelectMode, SelectPhase, Snapshot, MAX_LINE_LEN,
+    Select, SelectPhase, Snapshot, MAX_LINE_LEN,
 };
 use clock::ReplicaId;
 use gpui::{
@@ -56,7 +56,7 @@ impl EditorElement {
         &self,
         position: Vector2F,
         cmd: bool,
-        count: usize,
+        click_count: usize,
         layout: &mut LayoutState,
         paint: &mut PaintState,
         cx: &mut EventContext,
@@ -64,16 +64,10 @@ impl EditorElement {
         if paint.text_bounds.contains_point(position) {
             let snapshot = self.snapshot(cx.app);
             let position = paint.point_for_position(&snapshot, layout, position);
-            let mode = match count {
-                1 => SelectMode::Character,
-                2 => SelectMode::Word,
-                3 => SelectMode::Line,
-                _ => SelectMode::All,
-            };
             cx.dispatch_action(Select(SelectPhase::Begin {
                 position,
                 add: cmd,
-                mode,
+                click_count,
             }));
             true
         } else {
@@ -855,8 +849,8 @@ impl Element for EditorElement {
                 Event::LeftMouseDown {
                     position,
                     cmd,
-                    count,
-                } => self.mouse_down(*position, *cmd, *count, layout, paint, cx),
+                    click_count,
+                } => self.mouse_down(*position, *cmd, *click_count, layout, paint, cx),
                 Event::LeftMouseUp { position } => self.mouse_up(*position, cx),
                 Event::LeftMouseDragged { position } => {
                     self.mouse_dragged(*position, layout, paint, cx)

crates/editor/src/lib.rs 🔗

@@ -274,7 +274,7 @@ pub enum SelectPhase {
     Begin {
         position: DisplayPoint,
         add: bool,
-        mode: SelectMode,
+        click_count: usize,
     },
     Update {
         position: DisplayPoint,
@@ -283,11 +283,11 @@ pub enum SelectPhase {
     End,
 }
 
-#[derive(Copy, Clone, Debug)]
-pub enum SelectMode {
+#[derive(Clone, Debug)]
+enum SelectMode {
     Character,
-    Word,
-    Line,
+    Word(Range<Anchor>),
+    Line(Range<Anchor>),
     All,
 }
 
@@ -644,8 +644,8 @@ impl Editor {
             SelectPhase::Begin {
                 position,
                 add,
-                mode,
-            } => self.begin_selection(*position, *add, *mode, cx),
+                click_count,
+            } => self.begin_selection(*position, *add, *click_count, cx),
             SelectPhase::Update {
                 position,
                 scroll_position,
@@ -658,7 +658,7 @@ impl Editor {
         &mut self,
         position: DisplayPoint,
         add: bool,
-        mode: SelectMode,
+        click_count: usize,
         cx: &mut ViewContext<Self>,
     ) {
         if !self.focused {
@@ -670,20 +670,24 @@ impl Editor {
         let buffer = self.buffer.read(cx);
         let start;
         let end;
-        match mode {
-            SelectMode::Character => {
+        let mode;
+        match click_count {
+            1 => {
                 start = buffer.anchor_before(position.to_point(&display_map));
                 end = start.clone();
+                mode = SelectMode::Character;
             }
-            SelectMode::Word => {
+            2 => {
                 let range = movement::surrounding_word(&display_map, position);
                 start = buffer.anchor_before(range.start.to_point(&display_map));
                 end = buffer.anchor_before(range.end.to_point(&display_map));
+                mode = SelectMode::Word(start.clone()..end.clone());
             }
-            SelectMode::Line => todo!(),
-            SelectMode::All => {
+            3 => todo!(),
+            _ => {
                 start = buffer.anchor_before(0);
                 end = buffer.anchor_before(buffer.len());
+                mode = SelectMode::All;
             }
         }
         let selection = Selection {
@@ -711,32 +715,51 @@ impl Editor {
         let display_map = self.display_map.update(cx, |map, cx| map.snapshot(cx));
         if let Some(PendingSelection { selection, mode }) = self.pending_selection.as_mut() {
             let buffer = self.buffer.read(cx);
-            let cursor = match mode {
-                SelectMode::Character => buffer.anchor_before(position.to_point(&display_map)),
-                SelectMode::Word => {
-                    let word_range = movement::surrounding_word(&display_map, position);
-                    if word_range.start < selection.start.to_display_point(&display_map) {
-                        buffer.anchor_before(word_range.start.to_point(&display_map))
+            let head;
+            let tail;
+            match mode {
+                SelectMode::Character => {
+                    head = position.to_point(&display_map);
+                    tail = selection.tail().to_point(buffer);
+                }
+                SelectMode::Word(original_range) => {
+                    let original_display_range = original_range.start.to_display_point(&display_map)
+                        ..original_range.end.to_display_point(&display_map);
+                    let original_buffer_range = original_display_range.start.to_point(&display_map)
+                        ..original_display_range.end.to_point(&display_map);
+                    if movement::is_inside_word(&display_map, position)
+                        || original_display_range.contains(&position)
+                    {
+                        let word_range = movement::surrounding_word(&display_map, position);
+                        if word_range.start < original_display_range.start {
+                            head = word_range.start.to_point(&display_map);
+                        } else {
+                            head = word_range.end.to_point(&display_map);
+                        }
+                    } else {
+                        head = position.to_point(&display_map);
+                    }
+
+                    if head <= original_buffer_range.start {
+                        tail = original_buffer_range.end;
                     } else {
-                        buffer.anchor_before(word_range.end.to_point(&display_map))
+                        tail = original_buffer_range.start;
                     }
                 }
-                SelectMode::Line => todo!(),
-                SelectMode::All => selection.head(),
+                SelectMode::Line(_) => todo!(),
+                SelectMode::All => {
+                    return;
+                }
             };
 
-            if cursor.cmp(&selection.tail(), buffer).unwrap() < Ordering::Equal {
-                if !selection.reversed {
-                    selection.end = selection.start.clone();
-                    selection.reversed = true;
-                }
-                selection.start = cursor;
+            if head < tail {
+                selection.start = buffer.anchor_before(head);
+                selection.end = buffer.anchor_before(tail);
+                selection.reversed = true;
             } else {
-                if selection.reversed {
-                    selection.start = selection.end.clone();
-                    selection.reversed = false;
-                }
-                selection.end = cursor;
+                selection.start = buffer.anchor_before(tail);
+                selection.end = buffer.anchor_before(head);
+                selection.reversed = false;
             }
         } else {
             log::error!("update_selection dispatched with no pending selection");
@@ -3198,7 +3221,7 @@ mod tests {
             cx.add_window(Default::default(), |cx| build_editor(buffer, settings, cx));
 
         editor.update(cx, |view, cx| {
-            view.begin_selection(DisplayPoint::new(2, 2), false, SelectMode::Character, cx);
+            view.begin_selection(DisplayPoint::new(2, 2), false, 1, cx);
         });
 
         assert_eq!(
@@ -3235,7 +3258,7 @@ mod tests {
         );
 
         editor.update(cx, |view, cx| {
-            view.begin_selection(DisplayPoint::new(3, 3), true, SelectMode::Character, cx);
+            view.begin_selection(DisplayPoint::new(3, 3), true, 1, cx);
             view.update_selection(DisplayPoint::new(0, 0), Vector2F::zero(), cx);
         });
 
@@ -3264,7 +3287,7 @@ mod tests {
         let (_, view) = cx.add_window(Default::default(), |cx| build_editor(buffer, settings, cx));
 
         view.update(cx, |view, cx| {
-            view.begin_selection(DisplayPoint::new(2, 2), false, SelectMode::Character, cx);
+            view.begin_selection(DisplayPoint::new(2, 2), false, 1, cx);
             assert_eq!(
                 view.selection_ranges(cx),
                 [DisplayPoint::new(2, 2)..DisplayPoint::new(2, 2)]
@@ -3296,11 +3319,11 @@ mod tests {
         let (_, view) = cx.add_window(Default::default(), |cx| build_editor(buffer, settings, cx));
 
         view.update(cx, |view, cx| {
-            view.begin_selection(DisplayPoint::new(3, 4), false, SelectMode::Character, cx);
+            view.begin_selection(DisplayPoint::new(3, 4), false, 1, cx);
             view.update_selection(DisplayPoint::new(1, 1), Vector2F::zero(), cx);
             view.end_selection(cx);
 
-            view.begin_selection(DisplayPoint::new(0, 1), true, SelectMode::Character, cx);
+            view.begin_selection(DisplayPoint::new(0, 1), true, 1, cx);
             view.update_selection(DisplayPoint::new(0, 3), Vector2F::zero(), cx);
             view.end_selection(cx);
             assert_eq!(

crates/editor/src/movement.rs 🔗

@@ -181,6 +181,14 @@ pub fn next_word_boundary(map: &DisplayMapSnapshot, mut point: DisplayPoint) ->
     point
 }
 
+pub fn is_inside_word(map: &DisplayMapSnapshot, point: DisplayPoint) -> bool {
+    let ix = map.clip_point(point, Bias::Left).to_offset(map, Bias::Left);
+    let text = map.buffer_snapshot.text();
+    let next_char_kind = text.chars_at(ix).next().map(char_kind);
+    let prev_char_kind = text.reversed_chars_at(ix).next().map(char_kind);
+    prev_char_kind.zip(next_char_kind) == Some((CharKind::Word, CharKind::Word))
+}
+
 pub fn surrounding_word(map: &DisplayMapSnapshot, point: DisplayPoint) -> Range<DisplayPoint> {
     let mut start = map.clip_point(point, Bias::Left).to_offset(map, Bias::Left);
     let mut end = start;

crates/gpui/src/app.rs 🔗

@@ -3651,6 +3651,7 @@ mod tests {
             Event::LeftMouseDown {
                 position: Default::default(),
                 cmd: false,
+                click_count: 1,
             },
             cx,
         );

crates/gpui/src/platform/event.rs 🔗

@@ -15,7 +15,7 @@ pub enum Event {
     LeftMouseDown {
         position: Vector2F,
         cmd: bool,
-        count: usize,
+        click_count: usize,
     },
     LeftMouseUp {
         position: Vector2F,

crates/gpui/src/platform/mac/event.rs 🔗

@@ -94,7 +94,7 @@ impl Event {
                     cmd: native_event
                         .modifierFlags()
                         .contains(NSEventModifierFlags::NSCommandKeyMask),
-                    count: native_event.clickCount() as usize,
+                    click_count: native_event.clickCount() as usize,
                 })
             }
             NSEventType::NSLeftMouseUp => window_height.map(|window_height| Self::LeftMouseUp {