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)]
|
||||
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);
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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]
|
||||
async fn test_select_next(cx: &mut TestAppContext) {
|
||||
init_test(cx, |_| {});
|
||||
|
|
Loading…
Add table
Add a link
Reference in a new issue