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:
commit
ec3cfc33d6
3 changed files with 260 additions and 106 deletions
|
@ -6471,9 +6471,42 @@ 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 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(
|
let word_range = movement::surrounding_word(
|
||||||
&display_map,
|
&display_map,
|
||||||
selection.start.to_display_point(&display_map),
|
selection.start.to_display_point(&display_map),
|
||||||
|
@ -6482,17 +6515,6 @@ impl Editor {
|
||||||
selection.end = word_range.end.to_offset(&display_map, Bias::Left);
|
selection.end = word_range.end.to_offset(&display_map, Bias::Left);
|
||||||
selection.goal = SelectionGoal::None;
|
selection.goal = SelectionGoal::None;
|
||||||
selection.reversed = false;
|
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(
|
select_next_match_ranges(
|
||||||
self,
|
self,
|
||||||
selection.start..selection.end,
|
selection.start..selection.end,
|
||||||
|
@ -6500,13 +6522,28 @@ impl Editor {
|
||||||
autoscroll,
|
autoscroll,
|
||||||
cx,
|
cx,
|
||||||
);
|
);
|
||||||
self.select_next_state = Some(select_state);
|
}
|
||||||
} else {
|
|
||||||
|
if selections.len() == 1 {
|
||||||
|
let selection = selections
|
||||||
|
.last()
|
||||||
|
.expect("ensured that there's only one selection");
|
||||||
let query = buffer
|
let query = buffer
|
||||||
.text_for_range(selection.start..selection.end)
|
.text_for_range(selection.start..selection.end)
|
||||||
.collect::<String>();
|
.collect::<String>();
|
||||||
self.select_next_state = Some(SelectNextState {
|
let is_empty = query.is_empty();
|
||||||
|
let select_state = SelectNextState {
|
||||||
query: AhoCorasick::new(&[query])?,
|
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(&[selected_text])?,
|
||||||
wordwise: false,
|
wordwise: false,
|
||||||
done: false,
|
done: false,
|
||||||
});
|
});
|
||||||
|
@ -6610,9 +6647,42 @@ 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 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(
|
let word_range = movement::surrounding_word(
|
||||||
&display_map,
|
&display_map,
|
||||||
selection.start.to_display_point(&display_map),
|
selection.start.to_display_point(&display_map),
|
||||||
|
@ -6621,28 +6691,37 @@ impl Editor {
|
||||||
selection.end = word_range.end.to_offset(&display_map, Bias::Left);
|
selection.end = word_range.end.to_offset(&display_map, Bias::Left);
|
||||||
selection.goal = SelectionGoal::None;
|
selection.goal = SelectionGoal::None;
|
||||||
selection.reversed = false;
|
selection.reversed = false;
|
||||||
|
}
|
||||||
|
if selections.len() == 1 {
|
||||||
|
let selection = selections
|
||||||
|
.last()
|
||||||
|
.expect("ensured that there's only one selection");
|
||||||
let query = buffer
|
let query = buffer
|
||||||
.text_for_range(selection.start..selection.end)
|
.text_for_range(selection.start..selection.end)
|
||||||
.collect::<String>();
|
.collect::<String>();
|
||||||
let query = query.chars().rev().collect::<String>();
|
let is_empty = query.is_empty();
|
||||||
let select_state = SelectNextState {
|
let select_state = SelectNextState {
|
||||||
query: AhoCorasick::new(&[query])?,
|
query: AhoCorasick::new(&[query.chars().rev().collect::<String>()])?,
|
||||||
wordwise: true,
|
wordwise: true,
|
||||||
done: false,
|
done: is_empty,
|
||||||
};
|
};
|
||||||
self.unfold_ranges([selection.start..selection.end], false, true, cx);
|
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,
|
||||||
});
|
});
|
||||||
|
|
|
@ -3821,10 +3821,44 @@ 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(
|
||||||
|
r#"let foo = 2;
|
||||||
|
lˇet foo = 2;
|
||||||
|
let fooˇ = 2;
|
||||||
|
let foo = 2;
|
||||||
|
let foo = ˇ2;"#,
|
||||||
|
);
|
||||||
|
|
||||||
|
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ˇ»;"#,
|
||||||
|
);
|
||||||
|
|
||||||
|
// 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ˇ»;"#,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[gpui::test]
|
||||||
|
async fn test_select_previous_with_single_caret(cx: &mut gpui::TestAppContext) {
|
||||||
|
init_test(cx, |_| {});
|
||||||
|
|
||||||
let mut cx = EditorTestContext::new(cx).await;
|
let mut cx = EditorTestContext::new(cx).await;
|
||||||
cx.set_state("abc\nˇabc abc\ndefabc\nabc");
|
cx.set_state("abc\nˇabc abc\ndefabc\nabc");
|
||||||
|
|
||||||
|
@ -3848,10 +3882,52 @@ async fn test_select_previous(cx: &mut gpui::TestAppContext) {
|
||||||
|
|
||||||
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\ndef«abcˇ»\n«abcˇ»");
|
||||||
}
|
|
||||||
{
|
cx.update_editor(|e, cx| e.select_previous(&SelectPrevious::default(), cx))
|
||||||
// `Select previous` with a selection
|
.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;
|
let mut cx = EditorTestContext::new(cx).await;
|
||||||
cx.set_state("abc\n«ˇabc» abc\ndefabc\nabc");
|
cx.set_state("abc\n«ˇabc» abc\ndefabc\nabc");
|
||||||
|
|
||||||
|
@ -3876,7 +3952,6 @@ async fn test_select_previous(cx: &mut gpui::TestAppContext) {
|
||||||
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]
|
#[gpui::test]
|
||||||
|
|
|
@ -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,
|
||||||
|
|
Loading…
Add table
Add a link
Reference in a new issue