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:
Smit Barmase 2025-06-06 06:20:12 +05:30 committed by GitHub
parent 711a9e5753
commit 6a8fdbfd62
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
2 changed files with 383 additions and 43 deletions

View file

@ -1313,6 +1313,11 @@ struct RowHighlight {
#[derive(Clone, Debug)]
struct AddSelectionsState {
groups: Vec<AddSelectionsGroup>,
}
#[derive(Clone, Debug)]
struct AddSelectionsGroup {
above: bool,
stack: Vec<usize>,
}
@ -2717,7 +2722,9 @@ impl Editor {
.display_map
.update(cx, |display_map, cx| display_map.snapshot(cx));
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_prev_state = None;
self.select_syntax_node_history.try_clear();
@ -12699,49 +12706,74 @@ impl Editor {
self.hide_mouse_cursor(&HideMouseCursorOrigin::MovementAction);
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 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 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);
selections.clear();
let mut stack = Vec::new();
for row in range.start.row().0..=range.end.row().0 {
if let Some(selection) = self.selections.build_columnar_selection(
&display_map,
DisplayRow(row),
&positions,
oldest_selection.reversed,
selection.reversed,
&text_layout_details,
) {
stack.push(selection.id);
selections.push(selection);
columnar_selections.push(selection);
}
}
if above {
stack.reverse();
if !stack.is_empty() {
if above {
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 new_selections = Vec::new();
if above == state.above {
let end_row = if above {
DisplayRow(0)
} else {
display_map.max_point().row()
};
let mut last_added_item_per_group = HashMap::default();
for group in state.groups.iter_mut() {
if let Some(last_id) = group.stack.last() {
last_added_item_per_group.insert(*last_id, group);
}
}
'outer: for selection in selections {
if selection.id == last_added_selection {
for selection in columnar_selections {
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();
debug_assert_eq!(range.start.row(), range.end.row());
let mut row = range.start.row();
@ -12756,13 +12788,13 @@ impl Editor {
start_x.min(end_x)..start_x.max(end_x)
};
let mut maybe_new_selection = None;
while row != end_row {
if above {
row.0 -= 1;
} else {
row.0 += 1;
}
if let Some(new_selection) = self.selections.build_columnar_selection(
&display_map,
row,
@ -12770,32 +12802,50 @@ impl Editor {
selection.reversed,
&text_layout_details,
) {
state.stack.push(new_selection.id);
if above {
new_selections.push(new_selection);
new_selections.push(selection);
} else {
new_selections.push(selection);
new_selections.push(new_selection);
}
continue 'outer;
maybe_new_selection = Some(new_selection);
break;
}
}
}
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| {
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);
}
}

View file

@ -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
«»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ˇ»
«»jkl
«»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ˇ»
«»f«ghˇ»
«»jkl
«»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ˇ»
«»jkl
«»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ˇ»
«»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ˇ»
«»jkl
«»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ˇ»
«»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
«»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ˇ»
«»f«ghˇ»
«»jkl
«»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ˇ»
«»f«ghˇ»
«»jk«»
«»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ˇ»
«»jk«»
«»no«»"#
));
}
#[gpui::test]
async fn test_select_next(cx: &mut TestAppContext) {
init_test(cx, |_| {});