From 6a8fdbfd62eb33c9c20c8a00b7e9685414cbd758 Mon Sep 17 00:00:00 2001 From: Smit Barmase Date: Fri, 6 Jun 2025 06:20:12 +0530 Subject: [PATCH] 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. --- crates/editor/src/editor.rs | 136 +++++++++----- crates/editor/src/editor_tests.rs | 290 ++++++++++++++++++++++++++++++ 2 files changed, 383 insertions(+), 43 deletions(-) diff --git a/crates/editor/src/editor.rs b/crates/editor/src/editor.rs index cd97ff50b2..4f0b631202 100644 --- a/crates/editor/src/editor.rs +++ b/crates/editor/src/editor.rs @@ -1313,6 +1313,11 @@ struct RowHighlight { #[derive(Clone, Debug)] struct AddSelectionsState { + groups: Vec, +} + +#[derive(Clone, Debug)] +struct AddSelectionsGroup { above: bool, stack: Vec, } @@ -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::(cx); + let all_selections = self.selections.all::(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::(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); } } diff --git a/crates/editor/src/editor_tests.rs b/crates/editor/src/editor_tests.rs index b500a2f3b6..c884ebaffb 100644 --- a/crates/editor/src/editor_tests.rs +++ b/crates/editor/src/editor_tests.rs @@ -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::::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, |_| {});