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

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
This commit is contained in:
Kirill Bulatov 2024-01-16 11:08:48 +02:00 committed by GitHub
commit ec3cfc33d6
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
3 changed files with 260 additions and 106 deletions

View file

@ -6471,42 +6471,79 @@ impl Editor {
} }
self.select_next_state = Some(select_next_state); self.select_next_state = Some(select_next_state);
} else if selections.len() == 1 { } else {
let selection = selections.last_mut().unwrap(); let mut only_carets = true;
if selection.start == selection.end { let mut same_text_selected = true;
let word_range = movement::surrounding_word( let mut selected_text = None;
&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 let mut selections_iter = selections.iter().peekable();
.text_for_range(selection.start..selection.end) while let Some(selection) = selections_iter.next() {
.collect::<String>(); if selection.start != selection.end {
only_carets = false;
}
let is_empty = query.is_empty(); if same_text_selected {
let select_state = SelectNextState { if selected_text.is_none() {
query: AhoCorasick::new(&[query])?, selected_text =
wordwise: true, Some(buffer.text_for_range(selection.range()).collect::<String>());
done: is_empty, }
};
select_next_match_ranges( if let Some(next_selection) = selections_iter.peek() {
self, if next_selection.range().len() == selection.range().len() {
selection.start..selection.end, let next_selected_text = buffer
replace_newest, .text_for_range(next_selection.range())
autoscroll, .collect::<String>();
cx, if Some(next_selected_text) != selected_text {
); same_text_selected = false;
self.select_next_state = Some(select_state); selected_text = None;
} else { }
let query = buffer } else {
.text_for_range(selection.start..selection.end) same_text_selected = false;
.collect::<String>(); 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 { self.select_next_state = Some(SelectNextState {
query: AhoCorasick::new(&[query])?, query: AhoCorasick::new(&[selected_text])?,
wordwise: false, wordwise: false,
done: false, done: false,
}); });
@ -6610,39 +6647,81 @@ impl Editor {
} }
self.select_prev_state = Some(select_prev_state); self.select_prev_state = Some(select_prev_state);
} else if selections.len() == 1 { } else {
let selection = selections.last_mut().unwrap(); let mut only_carets = true;
if selection.start == selection.end { let mut same_text_selected = true;
let word_range = movement::surrounding_word( let mut selected_text = None;
&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 let mut selections_iter = selections.iter().peekable();
.text_for_range(selection.start..selection.end) while let Some(selection) = selections_iter.next() {
.collect::<String>(); if selection.start != selection.end {
let query = query.chars().rev().collect::<String>(); only_carets = false;
let select_state = SelectNextState { }
query: AhoCorasick::new(&[query])?,
wordwise: true, if same_text_selected {
done: false, if selected_text.is_none() {
}; selected_text =
self.unfold_ranges([selection.start..selection.end], false, true, cx); 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,
);
self.change_selections(Some(Autoscroll::newest()), cx, |s| { self.change_selections(Some(Autoscroll::newest()), cx, |s| {
s.select(selections); s.select(selections);
}); });
self.select_prev_state = Some(select_state); } else if let Some(selected_text) = selected_text {
} else {
let query = buffer
.text_for_range(selection.start..selection.end)
.collect::<String>();
let query = query.chars().rev().collect::<String>();
self.select_prev_state = Some(SelectNextState { self.select_prev_state = Some(SelectNextState {
query: AhoCorasick::new(&[query])?, query: AhoCorasick::new(&[selected_text.chars().rev().collect::<String>()])?,
wordwise: false, wordwise: false,
done: false, done: false,
}); });

View file

@ -3821,62 +3821,137 @@ async fn test_select_next(cx: &mut gpui::TestAppContext) {
} }
#[gpui::test] #[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, |_| {}); 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)) let mut cx = EditorTestContext::new(cx).await;
.unwrap(); cx.set_state(
cx.assert_editor_state("abc\n«abcˇ» abc\ndefabc\nabc"); 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)) cx.update_editor(|e, cx| e.select_next(&SelectNext::default(), cx))
.unwrap(); .unwrap();
cx.assert_editor_state("«abcˇ»\n«abcˇ» abc\ndefabc\nabc"); 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)); // noop for multiple selections with different contents
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.redo_selection(&RedoSelection, cx)); #[gpui::test]
cx.assert_editor_state("«abcˇ»\n«abcˇ» abc\ndefabc\nabc"); 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)) let mut cx = EditorTestContext::new(cx).await;
.unwrap(); cx.set_state("abc\nˇabc abc\ndefabc\nabc");
cx.assert_editor_state("«abcˇ»\n«abcˇ» abc\ndefabc\n«abcˇ»");
cx.update_editor(|e, cx| e.select_previous(&SelectPrevious::default(), cx)) cx.update_editor(|e, cx| e.select_previous(&SelectPrevious::default(), cx))
.unwrap(); .unwrap();
cx.assert_editor_state("«abcˇ»\n«abcˇ» «abcˇ»\ndefabc\n«abcˇ»"); cx.assert_editor_state("abc\n«abcˇ» abc\ndefabc\nabc");
}
{
// `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)) cx.update_editor(|e, cx| e.select_previous(&SelectPrevious::default(), cx))
.unwrap(); .unwrap();
cx.assert_editor_state("«abcˇ»\n«ˇabc» abc\ndefabc\nabc"); cx.assert_editor_state("«abcˇ»\n«abcˇ» abc\ndefabc\nabc");
cx.update_editor(|e, cx| e.select_previous(&SelectPrevious::default(), cx)) cx.update_editor(|view, cx| view.undo_selection(&UndoSelection, cx));
.unwrap(); cx.assert_editor_state("abc\n«abcˇ» abc\ndefabc\nabc");
cx.assert_editor_state("«abcˇ»\n«ˇabc» abc\ndefabc\n«abcˇ»");
cx.update_editor(|view, cx| view.undo_selection(&UndoSelection, cx)); cx.update_editor(|view, cx| view.redo_selection(&RedoSelection, cx));
cx.assert_editor_state("«abcˇ»\n«ˇabc» abc\ndefabc\nabc"); cx.assert_editor_state("«abcˇ»\n«abcˇ» abc\ndefabc\nabc");
cx.update_editor(|view, cx| view.redo_selection(&RedoSelection, cx)); cx.update_editor(|e, cx| e.select_previous(&SelectPrevious::default(), cx))
cx.assert_editor_state("«abcˇ»\n«ˇabc» abc\ndefabc\n«abcˇ»"); .unwrap();
cx.assert_editor_state("«abcˇ»\n«abcˇ» abc\ndefabc\n«abcˇ»");
cx.update_editor(|e, cx| e.select_previous(&SelectPrevious::default(), cx)) cx.update_editor(|e, cx| e.select_previous(&SelectPrevious::default(), cx))
.unwrap(); .unwrap();
cx.assert_editor_state("«abcˇ»\n«ˇabc» abc\ndef«abcˇ»\n«abcˇ»"); cx.assert_editor_state("«abcˇ»\n«abcˇ» abc\ndef«abcˇ»\n«abcˇ»");
cx.update_editor(|e, cx| e.select_previous(&SelectPrevious::default(), cx)) cx.update_editor(|e, cx| e.select_previous(&SelectPrevious::default(), cx))
.unwrap(); .unwrap();
cx.assert_editor_state("«abcˇ»\n«ˇabc» «abcˇ»\ndef«abcˇ»\n«abcˇ»"); 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] #[gpui::test]

View file

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