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
This commit is contained in:
Pablo Aguiar
2025-12-09 11:15:14 -05:00
committed by GitHub
parent a524071dd9
commit d5a437d22f
4 changed files with 278 additions and 0 deletions
+4
View File
@@ -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.
+162
View File
@@ -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>,
) {
self.rotate_selections(window, cx, false)
}
pub fn rotate_selections_backward(
&mut self,
_: &RotateSelectionsBackward,
window: &mut Window,
cx: &mut Context<Self>,
) {
self.rotate_selections(window, cx, true)
}
fn rotate_selections(&mut self, window: &mut Window, cx: &mut Context<Self>, reverse: bool) {
self.hide_mouse_cursor(HideMouseCursorOrigin::TypingAction, cx);
let display_snapshot = self.display_snapshot(cx);
let selections = self.selections.all::<MultiBufferOffset>(&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<String> = 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<u32> = 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<Range<MultiBufferOffset>> = 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<String> = 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<u32, usize> = 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<usize> = 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<M>(
&mut self,
window: &mut Window,
+110
View File
@@ -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, |_| {});
+2
View File
@@ -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);