From d5a437d22f4c7a981a5bb8baff3166d4b06846b2 Mon Sep 17 00:00:00 2001 From: Pablo Aguiar Date: Tue, 9 Dec 2025 17:15:14 +0100 Subject: [PATCH] editor: Add rotation commands for selections and lines (#41236) Introduces RotateSelectionsForward and RotateSelectionsBackward actions that rotate content in a circular fashion across multiple cursors. Behavior based on context: - With selections: rotates the selected text at each cursor position (e.g., x=1, y=2, z=3 becomes x=3, y=1, z=2) - With just cursors: rotates entire lines at cursor positions (e.g., three lines cycle to line3, line1, line2) Selections are preserved after rotation, allowing repeated cycling. Useful for quickly rearranging values, lines, or arguments. For more examples and use cases, please refer to #5315. I'm eager to read your thoughts and make any adjustments or improvements to any aspect of this change. Closes #5315 Release Notes: - Added `RotateSelectionsForward` and `RotateSelectionsBackward` actions that rotate content in a circular fashion across multiple cursors --- crates/editor/src/actions.rs | 4 + crates/editor/src/editor.rs | 162 ++++++++++++++++++++++++++++++ crates/editor/src/editor_tests.rs | 110 ++++++++++++++++++++ crates/editor/src/element.rs | 2 + 4 files changed, 278 insertions(+) diff --git a/crates/editor/src/actions.rs b/crates/editor/src/actions.rs index 7d6f486974d1ef7e792bd79997aebd332c2336f4..fb058eb8d7c5ad72a2b2656c3ce943871a623163 100644 --- a/crates/editor/src/actions.rs +++ b/crates/editor/src/actions.rs @@ -680,6 +680,10 @@ actions!( ReloadFile, /// Rewraps text to fit within the preferred line length. Rewrap, + /// Rotates selections or lines backward. + RotateSelectionsBackward, + /// Rotates selections or lines forward. + RotateSelectionsForward, /// Runs flycheck diagnostics. RunFlycheck, /// Scrolls the cursor to the bottom of the viewport. diff --git a/crates/editor/src/editor.rs b/crates/editor/src/editor.rs index d173c1cb4aac782283a3832b5e411a0a44cc1f23..023e4931d33be86c70b90a8cd62aa5692c25c9d9 100644 --- a/crates/editor/src/editor.rs +++ b/crates/editor/src/editor.rs @@ -11516,6 +11516,168 @@ impl Editor { self.manipulate_immutable_lines(window, cx, |lines| lines.shuffle(&mut rand::rng())) } + pub fn rotate_selections_forward( + &mut self, + _: &RotateSelectionsForward, + window: &mut Window, + cx: &mut Context, + ) { + self.rotate_selections(window, cx, false) + } + + pub fn rotate_selections_backward( + &mut self, + _: &RotateSelectionsBackward, + window: &mut Window, + cx: &mut Context, + ) { + self.rotate_selections(window, cx, true) + } + + fn rotate_selections(&mut self, window: &mut Window, cx: &mut Context, reverse: bool) { + self.hide_mouse_cursor(HideMouseCursorOrigin::TypingAction, cx); + let display_snapshot = self.display_snapshot(cx); + let selections = self.selections.all::(&display_snapshot); + + if selections.len() < 2 { + return; + } + + let (edits, new_selections) = { + let buffer = self.buffer.read(cx).read(cx); + let has_selections = selections.iter().any(|s| !s.is_empty()); + if has_selections { + let mut selected_texts: Vec = selections + .iter() + .map(|selection| { + buffer + .text_for_range(selection.start..selection.end) + .collect() + }) + .collect(); + + if reverse { + selected_texts.rotate_left(1); + } else { + selected_texts.rotate_right(1); + } + + let mut offset_delta: i64 = 0; + let mut new_selections = Vec::new(); + let edits: Vec<_> = selections + .iter() + .zip(selected_texts.iter()) + .map(|(selection, new_text)| { + let old_len = (selection.end.0 - selection.start.0) as i64; + let new_len = new_text.len() as i64; + let adjusted_start = + MultiBufferOffset((selection.start.0 as i64 + offset_delta) as usize); + let adjusted_end = + MultiBufferOffset((adjusted_start.0 as i64 + new_len) as usize); + + new_selections.push(Selection { + id: selection.id, + start: adjusted_start, + end: adjusted_end, + reversed: selection.reversed, + goal: selection.goal, + }); + + offset_delta += new_len - old_len; + (selection.start..selection.end, new_text.clone()) + }) + .collect(); + (edits, new_selections) + } else { + let mut all_rows: Vec = selections + .iter() + .map(|selection| buffer.offset_to_point(selection.start).row) + .collect(); + all_rows.sort_unstable(); + all_rows.dedup(); + + if all_rows.len() < 2 { + return; + } + + let line_ranges: Vec> = all_rows + .iter() + .map(|&row| { + let start = Point::new(row, 0); + let end = Point::new(row, buffer.line_len(MultiBufferRow(row))); + buffer.point_to_offset(start)..buffer.point_to_offset(end) + }) + .collect(); + + let mut line_texts: Vec = line_ranges + .iter() + .map(|range| buffer.text_for_range(range.clone()).collect()) + .collect(); + + if reverse { + line_texts.rotate_left(1); + } else { + line_texts.rotate_right(1); + } + + let edits = line_ranges + .iter() + .zip(line_texts.iter()) + .map(|(range, new_text)| (range.clone(), new_text.clone())) + .collect(); + + let num_rows = all_rows.len(); + let row_to_index: std::collections::HashMap = all_rows + .iter() + .enumerate() + .map(|(i, &row)| (row, i)) + .collect(); + + // Compute new line start offsets after rotation (handles CRLF) + let newline_len = line_ranges[1].start.0 - line_ranges[0].end.0; + let first_line_start = line_ranges[0].start.0; + let mut new_line_starts: Vec = vec![first_line_start]; + for text in line_texts.iter().take(num_rows - 1) { + let prev_start = *new_line_starts.last().unwrap(); + new_line_starts.push(prev_start + text.len() + newline_len); + } + + let new_selections = selections + .iter() + .map(|selection| { + let point = buffer.offset_to_point(selection.start); + let old_index = row_to_index[&point.row]; + let new_index = if reverse { + (old_index + num_rows - 1) % num_rows + } else { + (old_index + 1) % num_rows + }; + let new_offset = + MultiBufferOffset(new_line_starts[new_index] + point.column as usize); + Selection { + id: selection.id, + start: new_offset, + end: new_offset, + reversed: selection.reversed, + goal: selection.goal, + } + }) + .collect(); + + (edits, new_selections) + } + }; + + self.transact(window, cx, |this, window, cx| { + this.buffer.update(cx, |buffer, cx| { + buffer.edit(edits, None, cx); + }); + this.change_selections(Default::default(), window, cx, |s| { + s.select(new_selections); + }); + }); + } + fn manipulate_lines( &mut self, window: &mut Window, diff --git a/crates/editor/src/editor_tests.rs b/crates/editor/src/editor_tests.rs index 3bd5e6bf8f7947dfc9ac26f8ecbe9b6554151fcb..3c33519370907d3a2f53d63d9e24403c36a5e45a 100644 --- a/crates/editor/src/editor_tests.rs +++ b/crates/editor/src/editor_tests.rs @@ -5777,6 +5777,116 @@ fn test_duplicate_line(cx: &mut TestAppContext) { }); } +#[gpui::test] +async fn test_rotate_selections(cx: &mut TestAppContext) { + init_test(cx, |_| {}); + + let mut cx = EditorTestContext::new(cx).await; + + // Rotate text selections (horizontal) + cx.set_state("x=«1ˇ», y=«2ˇ», z=«3ˇ»"); + cx.update_editor(|e, window, cx| { + e.rotate_selections_forward(&RotateSelectionsForward, window, cx) + }); + cx.assert_editor_state("x=«3ˇ», y=«1ˇ», z=«2ˇ»"); + cx.update_editor(|e, window, cx| { + e.rotate_selections_backward(&RotateSelectionsBackward, window, cx) + }); + cx.assert_editor_state("x=«1ˇ», y=«2ˇ», z=«3ˇ»"); + + // Rotate text selections (vertical) + cx.set_state(indoc! {" + x=«1ˇ» + y=«2ˇ» + z=«3ˇ» + "}); + cx.update_editor(|e, window, cx| { + e.rotate_selections_forward(&RotateSelectionsForward, window, cx) + }); + cx.assert_editor_state(indoc! {" + x=«3ˇ» + y=«1ˇ» + z=«2ˇ» + "}); + cx.update_editor(|e, window, cx| { + e.rotate_selections_backward(&RotateSelectionsBackward, window, cx) + }); + cx.assert_editor_state(indoc! {" + x=«1ˇ» + y=«2ˇ» + z=«3ˇ» + "}); + + // Rotate text selections (vertical, different lengths) + cx.set_state(indoc! {" + x=\"«ˇ»\" + y=\"«aˇ»\" + z=\"«aaˇ»\" + "}); + cx.update_editor(|e, window, cx| { + e.rotate_selections_forward(&RotateSelectionsForward, window, cx) + }); + cx.assert_editor_state(indoc! {" + x=\"«aaˇ»\" + y=\"«ˇ»\" + z=\"«aˇ»\" + "}); + cx.update_editor(|e, window, cx| { + e.rotate_selections_backward(&RotateSelectionsBackward, window, cx) + }); + cx.assert_editor_state(indoc! {" + x=\"«ˇ»\" + y=\"«aˇ»\" + z=\"«aaˇ»\" + "}); + + // Rotate whole lines (cursor positions preserved) + cx.set_state(indoc! {" + ˇline123 + liˇne23 + line3ˇ + "}); + cx.update_editor(|e, window, cx| { + e.rotate_selections_forward(&RotateSelectionsForward, window, cx) + }); + cx.assert_editor_state(indoc! {" + line3ˇ + ˇline123 + liˇne23 + "}); + cx.update_editor(|e, window, cx| { + e.rotate_selections_backward(&RotateSelectionsBackward, window, cx) + }); + cx.assert_editor_state(indoc! {" + ˇline123 + liˇne23 + line3ˇ + "}); + + // Rotate whole lines, multiple cursors per line (positions preserved) + cx.set_state(indoc! {" + ˇliˇne123 + ˇline23 + ˇline3 + "}); + cx.update_editor(|e, window, cx| { + e.rotate_selections_forward(&RotateSelectionsForward, window, cx) + }); + cx.assert_editor_state(indoc! {" + ˇline3 + ˇliˇne123 + ˇline23 + "}); + cx.update_editor(|e, window, cx| { + e.rotate_selections_backward(&RotateSelectionsBackward, window, cx) + }); + cx.assert_editor_state(indoc! {" + ˇliˇne123 + ˇline23 + ˇline3 + "}); +} + #[gpui::test] fn test_move_line_up_down(cx: &mut TestAppContext) { init_test(cx, |_| {}); diff --git a/crates/editor/src/element.rs b/crates/editor/src/element.rs index 5e5749494017479b921a2bbdb2af8fb7d62c9bf4..fab51cbef29de436e447c317849ad15aa318c45d 100644 --- a/crates/editor/src/element.rs +++ b/crates/editor/src/element.rs @@ -253,6 +253,8 @@ impl EditorElement { register_action(editor, window, Editor::sort_lines_case_insensitive); register_action(editor, window, Editor::reverse_lines); register_action(editor, window, Editor::shuffle_lines); + register_action(editor, window, Editor::rotate_selections_forward); + register_action(editor, window, Editor::rotate_selections_backward); register_action(editor, window, Editor::convert_indentation_to_spaces); register_action(editor, window, Editor::convert_indentation_to_tabs); register_action(editor, window, Editor::convert_to_upper_case);