Adds a way to select items under multiple carets (#4067)

Kirill Bulatov created

Deals with https://github.com/zed-industries/community/issues/2374

Release Notes:

- Added a way to select items under multiple carets with
`editor::SelectNext` and `editor::SelectPrevious` commands

Change summary

crates/editor/src/editor.rs            | 205 +++++++++++++++++++--------
crates/editor/src/editor_tests.rs      | 161 ++++++++++++++++-----
crates/editor/src/scroll/autoscroll.rs |   4 
3 files changed, 262 insertions(+), 108 deletions(-)

Detailed changes

crates/editor/src/editor.rs 🔗

@@ -6471,42 +6471,79 @@ impl Editor {
             }
 
             self.select_next_state = Some(select_next_state);
-        } else if selections.len() == 1 {
-            let selection = selections.last_mut().unwrap();
-            if selection.start == selection.end {
-                let word_range = movement::surrounding_word(
-                    &display_map,
-                    selection.start.to_display_point(&display_map),
-                );
-                selection.start = word_range.start.to_offset(&display_map, Bias::Left);
-                selection.end = word_range.end.to_offset(&display_map, Bias::Left);
-                selection.goal = SelectionGoal::None;
-                selection.reversed = false;
-
-                let query = buffer
-                    .text_for_range(selection.start..selection.end)
-                    .collect::<String>();
-
-                let is_empty = query.is_empty();
-                let select_state = SelectNextState {
-                    query: AhoCorasick::new(&[query])?,
-                    wordwise: true,
-                    done: is_empty,
-                };
-                select_next_match_ranges(
-                    self,
-                    selection.start..selection.end,
-                    replace_newest,
-                    autoscroll,
-                    cx,
-                );
-                self.select_next_state = Some(select_state);
-            } else {
-                let query = buffer
-                    .text_for_range(selection.start..selection.end)
-                    .collect::<String>();
+        } else {
+            let mut only_carets = true;
+            let mut same_text_selected = true;
+            let mut selected_text = None;
+
+            let mut selections_iter = selections.iter().peekable();
+            while let Some(selection) = selections_iter.next() {
+                if selection.start != selection.end {
+                    only_carets = false;
+                }
+
+                if same_text_selected {
+                    if selected_text.is_none() {
+                        selected_text =
+                            Some(buffer.text_for_range(selection.range()).collect::<String>());
+                    }
+
+                    if let Some(next_selection) = selections_iter.peek() {
+                        if next_selection.range().len() == selection.range().len() {
+                            let next_selected_text = buffer
+                                .text_for_range(next_selection.range())
+                                .collect::<String>();
+                            if Some(next_selected_text) != selected_text {
+                                same_text_selected = false;
+                                selected_text = None;
+                            }
+                        } else {
+                            same_text_selected = false;
+                            selected_text = None;
+                        }
+                    }
+                }
+            }
+
+            if only_carets {
+                for selection in &mut selections {
+                    let word_range = movement::surrounding_word(
+                        &display_map,
+                        selection.start.to_display_point(&display_map),
+                    );
+                    selection.start = word_range.start.to_offset(&display_map, Bias::Left);
+                    selection.end = word_range.end.to_offset(&display_map, Bias::Left);
+                    selection.goal = SelectionGoal::None;
+                    selection.reversed = false;
+                    select_next_match_ranges(
+                        self,
+                        selection.start..selection.end,
+                        replace_newest,
+                        autoscroll,
+                        cx,
+                    );
+                }
+
+                if selections.len() == 1 {
+                    let selection = selections
+                        .last()
+                        .expect("ensured that there's only one selection");
+                    let query = buffer
+                        .text_for_range(selection.start..selection.end)
+                        .collect::<String>();
+                    let is_empty = query.is_empty();
+                    let select_state = SelectNextState {
+                        query: AhoCorasick::new(&[query])?,
+                        wordwise: true,
+                        done: is_empty,
+                    };
+                    self.select_next_state = Some(select_state);
+                } else {
+                    self.select_next_state = None;
+                }
+            } else if let Some(selected_text) = selected_text {
                 self.select_next_state = Some(SelectNextState {
-                    query: AhoCorasick::new(&[query])?,
+                    query: AhoCorasick::new(&[selected_text])?,
                     wordwise: false,
                     done: false,
                 });
@@ -6610,39 +6647,81 @@ impl Editor {
             }
 
             self.select_prev_state = Some(select_prev_state);
-        } else if selections.len() == 1 {
-            let selection = selections.last_mut().unwrap();
-            if selection.start == selection.end {
-                let word_range = movement::surrounding_word(
-                    &display_map,
-                    selection.start.to_display_point(&display_map),
+        } else {
+            let mut only_carets = true;
+            let mut same_text_selected = true;
+            let mut selected_text = None;
+
+            let mut selections_iter = selections.iter().peekable();
+            while let Some(selection) = selections_iter.next() {
+                if selection.start != selection.end {
+                    only_carets = false;
+                }
+
+                if same_text_selected {
+                    if selected_text.is_none() {
+                        selected_text =
+                            Some(buffer.text_for_range(selection.range()).collect::<String>());
+                    }
+
+                    if let Some(next_selection) = selections_iter.peek() {
+                        if next_selection.range().len() == selection.range().len() {
+                            let next_selected_text = buffer
+                                .text_for_range(next_selection.range())
+                                .collect::<String>();
+                            if Some(next_selected_text) != selected_text {
+                                same_text_selected = false;
+                                selected_text = None;
+                            }
+                        } else {
+                            same_text_selected = false;
+                            selected_text = None;
+                        }
+                    }
+                }
+            }
+
+            if only_carets {
+                for selection in &mut selections {
+                    let word_range = movement::surrounding_word(
+                        &display_map,
+                        selection.start.to_display_point(&display_map),
+                    );
+                    selection.start = word_range.start.to_offset(&display_map, Bias::Left);
+                    selection.end = word_range.end.to_offset(&display_map, Bias::Left);
+                    selection.goal = SelectionGoal::None;
+                    selection.reversed = false;
+                }
+                if selections.len() == 1 {
+                    let selection = selections
+                        .last()
+                        .expect("ensured that there's only one selection");
+                    let query = buffer
+                        .text_for_range(selection.start..selection.end)
+                        .collect::<String>();
+                    let is_empty = query.is_empty();
+                    let select_state = SelectNextState {
+                        query: AhoCorasick::new(&[query.chars().rev().collect::<String>()])?,
+                        wordwise: true,
+                        done: is_empty,
+                    };
+                    self.select_prev_state = Some(select_state);
+                } else {
+                    self.select_prev_state = None;
+                }
+
+                self.unfold_ranges(
+                    selections.iter().map(|s| s.range()).collect::<Vec<_>>(),
+                    false,
+                    true,
+                    cx,
                 );
-                selection.start = word_range.start.to_offset(&display_map, Bias::Left);
-                selection.end = word_range.end.to_offset(&display_map, Bias::Left);
-                selection.goal = SelectionGoal::None;
-                selection.reversed = false;
-
-                let query = buffer
-                    .text_for_range(selection.start..selection.end)
-                    .collect::<String>();
-                let query = query.chars().rev().collect::<String>();
-                let select_state = SelectNextState {
-                    query: AhoCorasick::new(&[query])?,
-                    wordwise: true,
-                    done: false,
-                };
-                self.unfold_ranges([selection.start..selection.end], false, true, cx);
                 self.change_selections(Some(Autoscroll::newest()), cx, |s| {
                     s.select(selections);
                 });
-                self.select_prev_state = Some(select_state);
-            } else {
-                let query = buffer
-                    .text_for_range(selection.start..selection.end)
-                    .collect::<String>();
-                let query = query.chars().rev().collect::<String>();
+            } else if let Some(selected_text) = selected_text {
                 self.select_prev_state = Some(SelectNextState {
-                    query: AhoCorasick::new(&[query])?,
+                    query: AhoCorasick::new(&[selected_text.chars().rev().collect::<String>()])?,
                     wordwise: false,
                     done: false,
                 });

crates/editor/src/editor_tests.rs 🔗

@@ -3821,62 +3821,137 @@ async fn test_select_next(cx: &mut gpui::TestAppContext) {
 }
 
 #[gpui::test]
-async fn test_select_previous(cx: &mut gpui::TestAppContext) {
+async fn test_select_next_with_multiple_carets(cx: &mut gpui::TestAppContext) {
     init_test(cx, |_| {});
-    {
-        // `Select previous` without a selection (selects wordwise)
-        let mut cx = EditorTestContext::new(cx).await;
-        cx.set_state("abc\nˇabc abc\ndefabc\nabc");
 
-        cx.update_editor(|e, cx| e.select_previous(&SelectPrevious::default(), cx))
-            .unwrap();
-        cx.assert_editor_state("abc\n«abcˇ» abc\ndefabc\nabc");
+    let mut cx = EditorTestContext::new(cx).await;
+    cx.set_state(
+        r#"let foo = 2;
+lˇet foo = 2;
+let fooˇ = 2;
+let foo = 2;
+let foo = ˇ2;"#,
+    );
 
-        cx.update_editor(|e, cx| e.select_previous(&SelectPrevious::default(), cx))
-            .unwrap();
-        cx.assert_editor_state("«abcˇ»\n«abcˇ» abc\ndefabc\nabc");
+    cx.update_editor(|e, cx| e.select_next(&SelectNext::default(), cx))
+        .unwrap();
+    cx.assert_editor_state(
+        r#"let foo = 2;
+«letˇ» foo = 2;
+let «fooˇ» = 2;
+let foo = 2;
+let foo = «2ˇ»;"#,
+    );
 
-        cx.update_editor(|view, cx| view.undo_selection(&UndoSelection, cx));
-        cx.assert_editor_state("abc\n«abcˇ» abc\ndefabc\nabc");
+    // noop for multiple selections with different contents
+    cx.update_editor(|e, cx| e.select_next(&SelectNext::default(), cx))
+        .unwrap();
+    cx.assert_editor_state(
+        r#"let foo = 2;
+«letˇ» foo = 2;
+let «fooˇ» = 2;
+let foo = 2;
+let foo = «2ˇ»;"#,
+    );
+}
 
-        cx.update_editor(|view, cx| view.redo_selection(&RedoSelection, cx));
-        cx.assert_editor_state("«abcˇ»\n«abcˇ» abc\ndefabc\nabc");
+#[gpui::test]
+async fn test_select_previous_with_single_caret(cx: &mut gpui::TestAppContext) {
+    init_test(cx, |_| {});
 
-        cx.update_editor(|e, cx| e.select_previous(&SelectPrevious::default(), cx))
-            .unwrap();
-        cx.assert_editor_state("«abcˇ»\n«abcˇ» abc\ndefabc\n«abcˇ»");
+    let mut cx = EditorTestContext::new(cx).await;
+    cx.set_state("abc\nˇabc abc\ndefabc\nabc");
 
-        cx.update_editor(|e, cx| e.select_previous(&SelectPrevious::default(), cx))
-            .unwrap();
-        cx.assert_editor_state("«abcˇ»\n«abcˇ» «abcˇ»\ndefabc\n«abcˇ»");
-    }
-    {
-        // `Select previous` with a selection
-        let mut cx = EditorTestContext::new(cx).await;
-        cx.set_state("abc\n«ˇabc» abc\ndefabc\nabc");
+    cx.update_editor(|e, cx| e.select_previous(&SelectPrevious::default(), cx))
+        .unwrap();
+    cx.assert_editor_state("abc\n«abcˇ» abc\ndefabc\nabc");
 
-        cx.update_editor(|e, cx| e.select_previous(&SelectPrevious::default(), cx))
-            .unwrap();
-        cx.assert_editor_state("«abcˇ»\n«ˇabc» abc\ndefabc\nabc");
+    cx.update_editor(|e, cx| e.select_previous(&SelectPrevious::default(), cx))
+        .unwrap();
+    cx.assert_editor_state("«abcˇ»\n«abcˇ» abc\ndefabc\nabc");
 
-        cx.update_editor(|e, cx| e.select_previous(&SelectPrevious::default(), cx))
-            .unwrap();
-        cx.assert_editor_state("«abcˇ»\n«ˇabc» abc\ndefabc\n«abcˇ»");
+    cx.update_editor(|view, cx| view.undo_selection(&UndoSelection, cx));
+    cx.assert_editor_state("abc\n«abcˇ» abc\ndefabc\nabc");
 
-        cx.update_editor(|view, cx| view.undo_selection(&UndoSelection, cx));
-        cx.assert_editor_state("«abcˇ»\n«ˇabc» abc\ndefabc\nabc");
+    cx.update_editor(|view, cx| view.redo_selection(&RedoSelection, cx));
+    cx.assert_editor_state("«abcˇ»\n«abcˇ» abc\ndefabc\nabc");
 
-        cx.update_editor(|view, cx| view.redo_selection(&RedoSelection, cx));
-        cx.assert_editor_state("«abcˇ»\n«ˇabc» abc\ndefabc\n«abcˇ»");
+    cx.update_editor(|e, cx| e.select_previous(&SelectPrevious::default(), cx))
+        .unwrap();
+    cx.assert_editor_state("«abcˇ»\n«abcˇ» abc\ndefabc\n«abcˇ»");
 
-        cx.update_editor(|e, cx| e.select_previous(&SelectPrevious::default(), cx))
-            .unwrap();
-        cx.assert_editor_state("«abcˇ»\n«ˇabc» abc\ndef«abcˇ»\n«abcˇ»");
+    cx.update_editor(|e, cx| e.select_previous(&SelectPrevious::default(), cx))
+        .unwrap();
+    cx.assert_editor_state("«abcˇ»\n«abcˇ» abc\ndef«abcˇ»\n«abcˇ»");
 
-        cx.update_editor(|e, cx| e.select_previous(&SelectPrevious::default(), cx))
-            .unwrap();
-        cx.assert_editor_state("«abcˇ»\n«ˇabc» «abcˇ»\ndef«abcˇ»\n«abcˇ»");
-    }
+    cx.update_editor(|e, cx| e.select_previous(&SelectPrevious::default(), cx))
+        .unwrap();
+    cx.assert_editor_state("«abcˇ»\n«abcˇ» «abcˇ»\ndef«abcˇ»\n«abcˇ»");
+}
+
+#[gpui::test]
+async fn test_select_previous_with_multiple_carets(cx: &mut gpui::TestAppContext) {
+    init_test(cx, |_| {});
+
+    let mut cx = EditorTestContext::new(cx).await;
+    cx.set_state(
+        r#"let foo = 2;
+lˇet foo = 2;
+let fooˇ = 2;
+let foo = 2;
+let foo = ˇ2;"#,
+    );
+
+    cx.update_editor(|e, cx| e.select_previous(&SelectPrevious::default(), cx))
+        .unwrap();
+    cx.assert_editor_state(
+        r#"let foo = 2;
+«letˇ» foo = 2;
+let «fooˇ» = 2;
+let foo = 2;
+let foo = «2ˇ»;"#,
+    );
+
+    // noop for multiple selections with different contents
+    cx.update_editor(|e, cx| e.select_previous(&SelectPrevious::default(), cx))
+        .unwrap();
+    cx.assert_editor_state(
+        r#"let foo = 2;
+«letˇ» foo = 2;
+let «fooˇ» = 2;
+let foo = 2;
+let foo = «2ˇ»;"#,
+    );
+}
+
+#[gpui::test]
+async fn test_select_previous_with_single_selection(cx: &mut gpui::TestAppContext) {
+    init_test(cx, |_| {});
+
+    let mut cx = EditorTestContext::new(cx).await;
+    cx.set_state("abc\n«ˇabc» abc\ndefabc\nabc");
+
+    cx.update_editor(|e, cx| e.select_previous(&SelectPrevious::default(), cx))
+        .unwrap();
+    cx.assert_editor_state("«abcˇ»\n«ˇabc» abc\ndefabc\nabc");
+
+    cx.update_editor(|e, cx| e.select_previous(&SelectPrevious::default(), cx))
+        .unwrap();
+    cx.assert_editor_state("«abcˇ»\n«ˇabc» abc\ndefabc\n«abcˇ»");
+
+    cx.update_editor(|view, cx| view.undo_selection(&UndoSelection, cx));
+    cx.assert_editor_state("«abcˇ»\n«ˇabc» abc\ndefabc\nabc");
+
+    cx.update_editor(|view, cx| view.redo_selection(&RedoSelection, cx));
+    cx.assert_editor_state("«abcˇ»\n«ˇabc» abc\ndefabc\n«abcˇ»");
+
+    cx.update_editor(|e, cx| e.select_previous(&SelectPrevious::default(), cx))
+        .unwrap();
+    cx.assert_editor_state("«abcˇ»\n«ˇabc» abc\ndef«abcˇ»\n«abcˇ»");
+
+    cx.update_editor(|e, cx| e.select_previous(&SelectPrevious::default(), cx))
+        .unwrap();
+    cx.assert_editor_state("«abcˇ»\n«ˇabc» «abcˇ»\ndef«abcˇ»\n«abcˇ»");
 }
 
 #[gpui::test]

crates/editor/src/scroll/autoscroll.rs 🔗

@@ -5,7 +5,7 @@ use language::Point;
 
 use crate::{display_map::ToDisplayPoint, Editor, EditorMode, LineWithInvisibles};
 
-#[derive(PartialEq, Eq)]
+#[derive(PartialEq, Eq, Clone, Copy)]
 pub enum Autoscroll {
     Next,
     Strategy(AutoscrollStrategy),
@@ -25,7 +25,7 @@ impl Autoscroll {
     }
 }
 
-#[derive(PartialEq, Eq, Default)]
+#[derive(PartialEq, Eq, Default, Clone, Copy)]
 pub enum AutoscrollStrategy {
     Fit,
     Newest,