editor: Add multi cursor support for AddSelectionAbove
/AddSelectionBelow
(#32204)
Closes #31648 This PR adds support for: - Expanding multiple cursors above/below - Expanding multiple selections above/below - Adding new cursors/selections when expansion has already been done. Existing expansions preserve their state and expand/shrink according to the action, while new cursors/selections act like freshly created ones. Tests for both cursor and selections: - below/above cases - undo/redo cases - adding new cursors/selections with existing expansion Before/After: https://github.com/user-attachments/assets/d2fd556b-8972-4719-bd86-e633d42a1aa3 Release Notes: - Improved `AddSelectionAbove` and `AddSelectionBelow` to extend multiple cursors/selections.
This commit is contained in:
parent
711a9e5753
commit
6a8fdbfd62
2 changed files with 383 additions and 43 deletions
|
@ -1313,6 +1313,11 @@ struct RowHighlight {
|
||||||
|
|
||||||
#[derive(Clone, Debug)]
|
#[derive(Clone, Debug)]
|
||||||
struct AddSelectionsState {
|
struct AddSelectionsState {
|
||||||
|
groups: Vec<AddSelectionsGroup>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Clone, Debug)]
|
||||||
|
struct AddSelectionsGroup {
|
||||||
above: bool,
|
above: bool,
|
||||||
stack: Vec<usize>,
|
stack: Vec<usize>,
|
||||||
}
|
}
|
||||||
|
@ -2717,7 +2722,9 @@ impl Editor {
|
||||||
.display_map
|
.display_map
|
||||||
.update(cx, |display_map, cx| display_map.snapshot(cx));
|
.update(cx, |display_map, cx| display_map.snapshot(cx));
|
||||||
let buffer = &display_map.buffer_snapshot;
|
let buffer = &display_map.buffer_snapshot;
|
||||||
self.add_selections_state = None;
|
if self.selections.count() == 1 {
|
||||||
|
self.add_selections_state = None;
|
||||||
|
}
|
||||||
self.select_next_state = None;
|
self.select_next_state = None;
|
||||||
self.select_prev_state = None;
|
self.select_prev_state = None;
|
||||||
self.select_syntax_node_history.try_clear();
|
self.select_syntax_node_history.try_clear();
|
||||||
|
@ -12699,49 +12706,74 @@ impl Editor {
|
||||||
self.hide_mouse_cursor(&HideMouseCursorOrigin::MovementAction);
|
self.hide_mouse_cursor(&HideMouseCursorOrigin::MovementAction);
|
||||||
|
|
||||||
let display_map = self.display_map.update(cx, |map, cx| map.snapshot(cx));
|
let display_map = self.display_map.update(cx, |map, cx| map.snapshot(cx));
|
||||||
let mut selections = self.selections.all::<Point>(cx);
|
let all_selections = self.selections.all::<Point>(cx);
|
||||||
let text_layout_details = self.text_layout_details(window);
|
let text_layout_details = self.text_layout_details(window);
|
||||||
let mut state = self.add_selections_state.take().unwrap_or_else(|| {
|
|
||||||
let oldest_selection = selections.iter().min_by_key(|s| s.id).unwrap().clone();
|
|
||||||
let range = oldest_selection.display_range(&display_map).sorted();
|
|
||||||
|
|
||||||
|
let (mut columnar_selections, new_selections_to_columnarize) = {
|
||||||
|
if let Some(state) = self.add_selections_state.as_ref() {
|
||||||
|
let columnar_selection_ids: HashSet<_> = state
|
||||||
|
.groups
|
||||||
|
.iter()
|
||||||
|
.flat_map(|group| group.stack.iter())
|
||||||
|
.copied()
|
||||||
|
.collect();
|
||||||
|
|
||||||
|
all_selections
|
||||||
|
.into_iter()
|
||||||
|
.partition(|s| columnar_selection_ids.contains(&s.id))
|
||||||
|
} else {
|
||||||
|
(Vec::new(), all_selections)
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
let mut state = self
|
||||||
|
.add_selections_state
|
||||||
|
.take()
|
||||||
|
.unwrap_or_else(|| AddSelectionsState { groups: Vec::new() });
|
||||||
|
|
||||||
|
for selection in new_selections_to_columnarize {
|
||||||
|
let range = selection.display_range(&display_map).sorted();
|
||||||
let start_x = display_map.x_for_display_point(range.start, &text_layout_details);
|
let start_x = display_map.x_for_display_point(range.start, &text_layout_details);
|
||||||
let end_x = display_map.x_for_display_point(range.end, &text_layout_details);
|
let end_x = display_map.x_for_display_point(range.end, &text_layout_details);
|
||||||
let positions = start_x.min(end_x)..start_x.max(end_x);
|
let positions = start_x.min(end_x)..start_x.max(end_x);
|
||||||
|
|
||||||
selections.clear();
|
|
||||||
let mut stack = Vec::new();
|
let mut stack = Vec::new();
|
||||||
for row in range.start.row().0..=range.end.row().0 {
|
for row in range.start.row().0..=range.end.row().0 {
|
||||||
if let Some(selection) = self.selections.build_columnar_selection(
|
if let Some(selection) = self.selections.build_columnar_selection(
|
||||||
&display_map,
|
&display_map,
|
||||||
DisplayRow(row),
|
DisplayRow(row),
|
||||||
&positions,
|
&positions,
|
||||||
oldest_selection.reversed,
|
selection.reversed,
|
||||||
&text_layout_details,
|
&text_layout_details,
|
||||||
) {
|
) {
|
||||||
stack.push(selection.id);
|
stack.push(selection.id);
|
||||||
selections.push(selection);
|
columnar_selections.push(selection);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
if !stack.is_empty() {
|
||||||
if above {
|
if above {
|
||||||
stack.reverse();
|
stack.reverse();
|
||||||
|
}
|
||||||
|
state.groups.push(AddSelectionsGroup { above, stack });
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
AddSelectionsState { above, stack }
|
let mut final_selections = Vec::new();
|
||||||
});
|
let end_row = if above {
|
||||||
|
DisplayRow(0)
|
||||||
|
} else {
|
||||||
|
display_map.max_point().row()
|
||||||
|
};
|
||||||
|
|
||||||
let last_added_selection = *state.stack.last().unwrap();
|
let mut last_added_item_per_group = HashMap::default();
|
||||||
let mut new_selections = Vec::new();
|
for group in state.groups.iter_mut() {
|
||||||
if above == state.above {
|
if let Some(last_id) = group.stack.last() {
|
||||||
let end_row = if above {
|
last_added_item_per_group.insert(*last_id, group);
|
||||||
DisplayRow(0)
|
}
|
||||||
} else {
|
}
|
||||||
display_map.max_point().row()
|
|
||||||
};
|
|
||||||
|
|
||||||
'outer: for selection in selections {
|
for selection in columnar_selections {
|
||||||
if selection.id == last_added_selection {
|
if let Some(group) = last_added_item_per_group.get_mut(&selection.id) {
|
||||||
|
if above == group.above {
|
||||||
let range = selection.display_range(&display_map).sorted();
|
let range = selection.display_range(&display_map).sorted();
|
||||||
debug_assert_eq!(range.start.row(), range.end.row());
|
debug_assert_eq!(range.start.row(), range.end.row());
|
||||||
let mut row = range.start.row();
|
let mut row = range.start.row();
|
||||||
|
@ -12756,13 +12788,13 @@ impl Editor {
|
||||||
start_x.min(end_x)..start_x.max(end_x)
|
start_x.min(end_x)..start_x.max(end_x)
|
||||||
};
|
};
|
||||||
|
|
||||||
|
let mut maybe_new_selection = None;
|
||||||
while row != end_row {
|
while row != end_row {
|
||||||
if above {
|
if above {
|
||||||
row.0 -= 1;
|
row.0 -= 1;
|
||||||
} else {
|
} else {
|
||||||
row.0 += 1;
|
row.0 += 1;
|
||||||
}
|
}
|
||||||
|
|
||||||
if let Some(new_selection) = self.selections.build_columnar_selection(
|
if let Some(new_selection) = self.selections.build_columnar_selection(
|
||||||
&display_map,
|
&display_map,
|
||||||
row,
|
row,
|
||||||
|
@ -12770,32 +12802,50 @@ impl Editor {
|
||||||
selection.reversed,
|
selection.reversed,
|
||||||
&text_layout_details,
|
&text_layout_details,
|
||||||
) {
|
) {
|
||||||
state.stack.push(new_selection.id);
|
maybe_new_selection = Some(new_selection);
|
||||||
if above {
|
break;
|
||||||
new_selections.push(new_selection);
|
|
||||||
new_selections.push(selection);
|
|
||||||
} else {
|
|
||||||
new_selections.push(selection);
|
|
||||||
new_selections.push(new_selection);
|
|
||||||
}
|
|
||||||
|
|
||||||
continue 'outer;
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
new_selections.push(selection);
|
if let Some(new_selection) = maybe_new_selection {
|
||||||
|
group.stack.push(new_selection.id);
|
||||||
|
if above {
|
||||||
|
final_selections.push(new_selection);
|
||||||
|
final_selections.push(selection);
|
||||||
|
} else {
|
||||||
|
final_selections.push(selection);
|
||||||
|
final_selections.push(new_selection);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
final_selections.push(selection);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
group.stack.pop();
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
final_selections.push(selection);
|
||||||
}
|
}
|
||||||
} else {
|
|
||||||
new_selections = selections;
|
|
||||||
new_selections.retain(|s| s.id != last_added_selection);
|
|
||||||
state.stack.pop();
|
|
||||||
}
|
}
|
||||||
|
|
||||||
self.change_selections(Some(Autoscroll::fit()), window, cx, |s| {
|
self.change_selections(Some(Autoscroll::fit()), window, cx, |s| {
|
||||||
s.select(new_selections);
|
s.select(final_selections);
|
||||||
});
|
});
|
||||||
if state.stack.len() > 1 {
|
|
||||||
|
let final_selection_ids: HashSet<_> = self
|
||||||
|
.selections
|
||||||
|
.all::<Point>(cx)
|
||||||
|
.iter()
|
||||||
|
.map(|s| s.id)
|
||||||
|
.collect();
|
||||||
|
state.groups.retain_mut(|group| {
|
||||||
|
// selections might get merged above so we remove invalid items from stacks
|
||||||
|
group.stack.retain(|id| final_selection_ids.contains(id));
|
||||||
|
|
||||||
|
// single selection in stack can be treated as initial state
|
||||||
|
group.stack.len() > 1
|
||||||
|
});
|
||||||
|
|
||||||
|
if !state.groups.is_empty() {
|
||||||
self.add_selections_state = Some(state);
|
self.add_selections_state = Some(state);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -6300,6 +6300,296 @@ async fn test_add_selection_above_below(cx: &mut TestAppContext) {
|
||||||
));
|
));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[gpui::test]
|
||||||
|
async fn test_add_selection_above_below_multi_cursor(cx: &mut TestAppContext) {
|
||||||
|
init_test(cx, |_| {});
|
||||||
|
let mut cx = EditorTestContext::new(cx).await;
|
||||||
|
|
||||||
|
cx.set_state(indoc!(
|
||||||
|
r#"line onˇe
|
||||||
|
liˇne two
|
||||||
|
line three
|
||||||
|
line four"#
|
||||||
|
));
|
||||||
|
|
||||||
|
cx.update_editor(|editor, window, cx| {
|
||||||
|
editor.add_selection_below(&Default::default(), window, cx);
|
||||||
|
});
|
||||||
|
|
||||||
|
// test multiple cursors expand in the same direction
|
||||||
|
cx.assert_editor_state(indoc!(
|
||||||
|
r#"line onˇe
|
||||||
|
liˇne twˇo
|
||||||
|
liˇne three
|
||||||
|
line four"#
|
||||||
|
));
|
||||||
|
|
||||||
|
cx.update_editor(|editor, window, cx| {
|
||||||
|
editor.add_selection_below(&Default::default(), window, cx);
|
||||||
|
});
|
||||||
|
|
||||||
|
cx.update_editor(|editor, window, cx| {
|
||||||
|
editor.add_selection_below(&Default::default(), window, cx);
|
||||||
|
});
|
||||||
|
|
||||||
|
// test multiple cursors expand below overflow
|
||||||
|
cx.assert_editor_state(indoc!(
|
||||||
|
r#"line onˇe
|
||||||
|
liˇne twˇo
|
||||||
|
liˇne thˇree
|
||||||
|
liˇne foˇur"#
|
||||||
|
));
|
||||||
|
|
||||||
|
cx.update_editor(|editor, window, cx| {
|
||||||
|
editor.add_selection_above(&Default::default(), window, cx);
|
||||||
|
});
|
||||||
|
|
||||||
|
// test multiple cursors retrieves back correctly
|
||||||
|
cx.assert_editor_state(indoc!(
|
||||||
|
r#"line onˇe
|
||||||
|
liˇne twˇo
|
||||||
|
liˇne thˇree
|
||||||
|
line four"#
|
||||||
|
));
|
||||||
|
|
||||||
|
cx.update_editor(|editor, window, cx| {
|
||||||
|
editor.add_selection_above(&Default::default(), window, cx);
|
||||||
|
});
|
||||||
|
|
||||||
|
cx.update_editor(|editor, window, cx| {
|
||||||
|
editor.add_selection_above(&Default::default(), window, cx);
|
||||||
|
});
|
||||||
|
|
||||||
|
// test multiple cursor groups maintain independent direction - first expands up, second shrinks above
|
||||||
|
cx.assert_editor_state(indoc!(
|
||||||
|
r#"liˇne onˇe
|
||||||
|
liˇne two
|
||||||
|
line three
|
||||||
|
line four"#
|
||||||
|
));
|
||||||
|
|
||||||
|
cx.update_editor(|editor, window, cx| {
|
||||||
|
editor.undo_selection(&Default::default(), window, cx);
|
||||||
|
});
|
||||||
|
|
||||||
|
// test undo
|
||||||
|
cx.assert_editor_state(indoc!(
|
||||||
|
r#"line onˇe
|
||||||
|
liˇne twˇo
|
||||||
|
line three
|
||||||
|
line four"#
|
||||||
|
));
|
||||||
|
|
||||||
|
cx.update_editor(|editor, window, cx| {
|
||||||
|
editor.redo_selection(&Default::default(), window, cx);
|
||||||
|
});
|
||||||
|
|
||||||
|
// test redo
|
||||||
|
cx.assert_editor_state(indoc!(
|
||||||
|
r#"liˇne onˇe
|
||||||
|
liˇne two
|
||||||
|
line three
|
||||||
|
line four"#
|
||||||
|
));
|
||||||
|
|
||||||
|
cx.set_state(indoc!(
|
||||||
|
r#"abcd
|
||||||
|
ef«ghˇ»
|
||||||
|
ijkl
|
||||||
|
«mˇ»nop"#
|
||||||
|
));
|
||||||
|
|
||||||
|
cx.update_editor(|editor, window, cx| {
|
||||||
|
editor.add_selection_above(&Default::default(), window, cx);
|
||||||
|
});
|
||||||
|
|
||||||
|
// test multiple selections expand in the same direction
|
||||||
|
cx.assert_editor_state(indoc!(
|
||||||
|
r#"ab«cdˇ»
|
||||||
|
ef«ghˇ»
|
||||||
|
«iˇ»jkl
|
||||||
|
«mˇ»nop"#
|
||||||
|
));
|
||||||
|
|
||||||
|
cx.update_editor(|editor, window, cx| {
|
||||||
|
editor.add_selection_above(&Default::default(), window, cx);
|
||||||
|
});
|
||||||
|
|
||||||
|
// test multiple selection upward overflow
|
||||||
|
cx.assert_editor_state(indoc!(
|
||||||
|
r#"ab«cdˇ»
|
||||||
|
«eˇ»f«ghˇ»
|
||||||
|
«iˇ»jkl
|
||||||
|
«mˇ»nop"#
|
||||||
|
));
|
||||||
|
|
||||||
|
cx.update_editor(|editor, window, cx| {
|
||||||
|
editor.add_selection_below(&Default::default(), window, cx);
|
||||||
|
});
|
||||||
|
|
||||||
|
// test multiple selection retrieves back correctly
|
||||||
|
cx.assert_editor_state(indoc!(
|
||||||
|
r#"abcd
|
||||||
|
ef«ghˇ»
|
||||||
|
«iˇ»jkl
|
||||||
|
«mˇ»nop"#
|
||||||
|
));
|
||||||
|
|
||||||
|
cx.update_editor(|editor, window, cx| {
|
||||||
|
editor.add_selection_below(&Default::default(), window, cx);
|
||||||
|
});
|
||||||
|
|
||||||
|
// test multiple cursor groups maintain independent direction - first shrinks down, second expands below
|
||||||
|
cx.assert_editor_state(indoc!(
|
||||||
|
r#"abcd
|
||||||
|
ef«ghˇ»
|
||||||
|
ij«klˇ»
|
||||||
|
«mˇ»nop"#
|
||||||
|
));
|
||||||
|
|
||||||
|
cx.update_editor(|editor, window, cx| {
|
||||||
|
editor.undo_selection(&Default::default(), window, cx);
|
||||||
|
});
|
||||||
|
|
||||||
|
// test undo
|
||||||
|
cx.assert_editor_state(indoc!(
|
||||||
|
r#"abcd
|
||||||
|
ef«ghˇ»
|
||||||
|
«iˇ»jkl
|
||||||
|
«mˇ»nop"#
|
||||||
|
));
|
||||||
|
|
||||||
|
cx.update_editor(|editor, window, cx| {
|
||||||
|
editor.redo_selection(&Default::default(), window, cx);
|
||||||
|
});
|
||||||
|
|
||||||
|
// test redo
|
||||||
|
cx.assert_editor_state(indoc!(
|
||||||
|
r#"abcd
|
||||||
|
ef«ghˇ»
|
||||||
|
ij«klˇ»
|
||||||
|
«mˇ»nop"#
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[gpui::test]
|
||||||
|
async fn test_add_selection_above_below_multi_cursor_existing_state(cx: &mut TestAppContext) {
|
||||||
|
init_test(cx, |_| {});
|
||||||
|
let mut cx = EditorTestContext::new(cx).await;
|
||||||
|
|
||||||
|
cx.set_state(indoc!(
|
||||||
|
r#"line onˇe
|
||||||
|
liˇne two
|
||||||
|
line three
|
||||||
|
line four"#
|
||||||
|
));
|
||||||
|
|
||||||
|
cx.update_editor(|editor, window, cx| {
|
||||||
|
editor.add_selection_below(&Default::default(), window, cx);
|
||||||
|
editor.add_selection_below(&Default::default(), window, cx);
|
||||||
|
editor.add_selection_below(&Default::default(), window, cx);
|
||||||
|
});
|
||||||
|
|
||||||
|
// initial state with two multi cursor groups
|
||||||
|
cx.assert_editor_state(indoc!(
|
||||||
|
r#"line onˇe
|
||||||
|
liˇne twˇo
|
||||||
|
liˇne thˇree
|
||||||
|
liˇne foˇur"#
|
||||||
|
));
|
||||||
|
|
||||||
|
// add single cursor in middle - simulate opt click
|
||||||
|
cx.update_editor(|editor, window, cx| {
|
||||||
|
let new_cursor_point = DisplayPoint::new(DisplayRow(2), 4);
|
||||||
|
editor.begin_selection(new_cursor_point, true, 1, window, cx);
|
||||||
|
editor.end_selection(window, cx);
|
||||||
|
});
|
||||||
|
|
||||||
|
cx.assert_editor_state(indoc!(
|
||||||
|
r#"line onˇe
|
||||||
|
liˇne twˇo
|
||||||
|
liˇneˇ thˇree
|
||||||
|
liˇne foˇur"#
|
||||||
|
));
|
||||||
|
|
||||||
|
cx.update_editor(|editor, window, cx| {
|
||||||
|
editor.add_selection_above(&Default::default(), window, cx);
|
||||||
|
});
|
||||||
|
|
||||||
|
// test new added selection expands above and existing selection shrinks
|
||||||
|
cx.assert_editor_state(indoc!(
|
||||||
|
r#"line onˇe
|
||||||
|
liˇneˇ twˇo
|
||||||
|
liˇneˇ thˇree
|
||||||
|
line four"#
|
||||||
|
));
|
||||||
|
|
||||||
|
cx.update_editor(|editor, window, cx| {
|
||||||
|
editor.add_selection_above(&Default::default(), window, cx);
|
||||||
|
});
|
||||||
|
|
||||||
|
// test new added selection expands above and existing selection shrinks
|
||||||
|
cx.assert_editor_state(indoc!(
|
||||||
|
r#"lineˇ onˇe
|
||||||
|
liˇneˇ twˇo
|
||||||
|
lineˇ three
|
||||||
|
line four"#
|
||||||
|
));
|
||||||
|
|
||||||
|
// intial state with two selection groups
|
||||||
|
cx.set_state(indoc!(
|
||||||
|
r#"abcd
|
||||||
|
ef«ghˇ»
|
||||||
|
ijkl
|
||||||
|
«mˇ»nop"#
|
||||||
|
));
|
||||||
|
|
||||||
|
cx.update_editor(|editor, window, cx| {
|
||||||
|
editor.add_selection_above(&Default::default(), window, cx);
|
||||||
|
editor.add_selection_above(&Default::default(), window, cx);
|
||||||
|
});
|
||||||
|
|
||||||
|
cx.assert_editor_state(indoc!(
|
||||||
|
r#"ab«cdˇ»
|
||||||
|
«eˇ»f«ghˇ»
|
||||||
|
«iˇ»jkl
|
||||||
|
«mˇ»nop"#
|
||||||
|
));
|
||||||
|
|
||||||
|
// add single selection in middle - simulate opt drag
|
||||||
|
cx.update_editor(|editor, window, cx| {
|
||||||
|
let new_cursor_point = DisplayPoint::new(DisplayRow(2), 3);
|
||||||
|
editor.begin_selection(new_cursor_point, true, 1, window, cx);
|
||||||
|
editor.update_selection(
|
||||||
|
DisplayPoint::new(DisplayRow(2), 4),
|
||||||
|
0,
|
||||||
|
gpui::Point::<f32>::default(),
|
||||||
|
window,
|
||||||
|
cx,
|
||||||
|
);
|
||||||
|
editor.end_selection(window, cx);
|
||||||
|
});
|
||||||
|
|
||||||
|
cx.assert_editor_state(indoc!(
|
||||||
|
r#"ab«cdˇ»
|
||||||
|
«eˇ»f«ghˇ»
|
||||||
|
«iˇ»jk«lˇ»
|
||||||
|
«mˇ»nop"#
|
||||||
|
));
|
||||||
|
|
||||||
|
cx.update_editor(|editor, window, cx| {
|
||||||
|
editor.add_selection_below(&Default::default(), window, cx);
|
||||||
|
});
|
||||||
|
|
||||||
|
// test new added selection expands below, others shrinks from above
|
||||||
|
cx.assert_editor_state(indoc!(
|
||||||
|
r#"abcd
|
||||||
|
ef«ghˇ»
|
||||||
|
«iˇ»jk«lˇ»
|
||||||
|
«mˇ»no«pˇ»"#
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
#[gpui::test]
|
#[gpui::test]
|
||||||
async fn test_select_next(cx: &mut TestAppContext) {
|
async fn test_select_next(cx: &mut TestAppContext) {
|
||||||
init_test(cx, |_| {});
|
init_test(cx, |_| {});
|
||||||
|
|
Loading…
Add table
Add a link
Reference in a new issue