Skip to content

Commit f560730

Browse files
committed
editor: Add rotation commands for selections and lines
Introduces RotateForward and RotateBackward 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. Fixes #5315
1 parent 2284131 commit f560730

File tree

4 files changed

+243
-0
lines changed

4 files changed

+243
-0
lines changed

crates/editor/src/actions.rs

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -666,6 +666,10 @@ actions!(
666666
ReloadFile,
667667
/// Rewraps text to fit within the preferred line length.
668668
Rewrap,
669+
/// Rotates selections or lines backward.
670+
RotateBackward,
671+
/// Rotates selections or lines forward.
672+
RotateForward,
669673
/// Runs flycheck diagnostics.
670674
RunFlycheck,
671675
/// Scrolls the cursor to the bottom of the viewport.

crates/editor/src/editor.rs

Lines changed: 123 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11015,6 +11015,129 @@ impl Editor {
1101511015
self.manipulate_immutable_lines(window, cx, |lines| lines.shuffle(&mut rand::rng()))
1101611016
}
1101711017

11018+
pub fn rotate_forward(
11019+
&mut self,
11020+
_: &RotateForward,
11021+
window: &mut Window,
11022+
cx: &mut Context<Self>,
11023+
) {
11024+
self.rotate(window, cx, false)
11025+
}
11026+
11027+
pub fn rotate_backward(
11028+
&mut self,
11029+
_: &RotateBackward,
11030+
window: &mut Window,
11031+
cx: &mut Context<Self>,
11032+
) {
11033+
self.rotate(window, cx, true)
11034+
}
11035+
11036+
fn rotate(&mut self, window: &mut Window, cx: &mut Context<Self>, reverse: bool) {
11037+
self.hide_mouse_cursor(HideMouseCursorOrigin::TypingAction, cx);
11038+
let display_snapshot = self.display_snapshot(cx);
11039+
let selections = self.selections.all::<usize>(&display_snapshot);
11040+
11041+
if selections.len() < 2 {
11042+
return;
11043+
}
11044+
11045+
let (edits, new_selections) = {
11046+
let buffer = self.buffer.read(cx).read(cx);
11047+
let has_selections = selections.iter().any(|s| !s.is_empty());
11048+
if has_selections {
11049+
let mut selected_texts: Vec<String> = selections
11050+
.iter()
11051+
.map(|selection| {
11052+
buffer
11053+
.text_for_range(selection.start..selection.end)
11054+
.collect()
11055+
})
11056+
.collect();
11057+
11058+
if reverse {
11059+
selected_texts.rotate_left(1);
11060+
} else {
11061+
selected_texts.rotate_right(1);
11062+
}
11063+
11064+
let mut offset_delta = 0i64;
11065+
let mut new_sels = Vec::new();
11066+
let edits: Vec<_> = selections
11067+
.iter()
11068+
.zip(selected_texts.iter())
11069+
.map(|(selection, new_text)| {
11070+
let old_len = (selection.end - selection.start) as i64;
11071+
let new_len = new_text.len() as i64;
11072+
let adjusted_start = (selection.start as i64 + offset_delta) as usize;
11073+
let adjusted_end = (adjusted_start as i64 + new_len) as usize;
11074+
11075+
new_sels.push(Selection {
11076+
id: selection.id,
11077+
start: adjusted_start,
11078+
end: adjusted_end,
11079+
reversed: selection.reversed,
11080+
goal: selection.goal,
11081+
});
11082+
11083+
offset_delta += new_len - old_len;
11084+
(selection.start..selection.end, new_text.clone())
11085+
})
11086+
.collect();
11087+
(edits, Some(new_sels))
11088+
} else {
11089+
let mut all_rows: Vec<u32> = selections
11090+
.iter()
11091+
.map(|selection| buffer.offset_to_point(selection.start).row)
11092+
.collect();
11093+
all_rows.sort_unstable();
11094+
all_rows.dedup();
11095+
11096+
if all_rows.len() < 2 {
11097+
return;
11098+
}
11099+
11100+
let line_ranges: Vec<Range<usize>> = all_rows
11101+
.iter()
11102+
.map(|&row| {
11103+
let start = Point::new(row, 0);
11104+
let end = Point::new(row, buffer.line_len(MultiBufferRow(row)));
11105+
buffer.point_to_offset(start)..buffer.point_to_offset(end)
11106+
})
11107+
.collect();
11108+
11109+
let mut line_texts: Vec<String> = line_ranges
11110+
.iter()
11111+
.map(|range| buffer.text_for_range(range.clone()).collect())
11112+
.collect();
11113+
11114+
if reverse {
11115+
line_texts.rotate_left(1);
11116+
} else {
11117+
line_texts.rotate_right(1);
11118+
}
11119+
11120+
let edits = line_ranges
11121+
.iter()
11122+
.zip(line_texts.iter())
11123+
.map(|(range, new_text)| (range.clone(), new_text.clone()))
11124+
.collect();
11125+
(edits, None)
11126+
}
11127+
};
11128+
11129+
self.transact(window, cx, |this, window, cx| {
11130+
this.buffer.update(cx, |buffer, cx| {
11131+
buffer.edit(edits, None, cx);
11132+
});
11133+
if let Some(new_sels) = new_selections {
11134+
this.change_selections(Default::default(), window, cx, |s| {
11135+
s.select(new_sels);
11136+
});
11137+
}
11138+
});
11139+
}
11140+
1101811141
fn manipulate_lines<M>(
1101911142
&mut self,
1102011143
window: &mut Window,

crates/editor/src/editor_tests.rs

Lines changed: 114 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5719,6 +5719,120 @@ fn test_duplicate_line(cx: &mut TestAppContext) {
57195719
});
57205720
}
57215721

5722+
#[gpui::test]
5723+
fn test_rotate(cx: &mut TestAppContext) {
5724+
init_test(cx, |_| {});
5725+
5726+
// Rotate text selections (horizontal)
5727+
let (text, selection_ranges) = marked_text_ranges("x=«1ˇ», y=«2ˇ», z=«3ˇ»", true);
5728+
_ = cx.add_window(|window, cx| {
5729+
let buffer = MultiBuffer::build_simple(&text, cx);
5730+
let mut editor = build_editor(buffer, window, cx);
5731+
editor.change_selections(SelectionEffects::no_scroll(), window, cx, |s| {
5732+
s.select_ranges(selection_ranges)
5733+
});
5734+
editor.rotate_forward(&RotateForward, window, cx);
5735+
assert_eq!(editor.display_text(cx), "x=3, y=1, z=2");
5736+
assert_eq!(
5737+
editor.selections.display_ranges(cx),
5738+
vec![
5739+
DisplayPoint::new(DisplayRow(0), 2)..DisplayPoint::new(DisplayRow(0), 3),
5740+
DisplayPoint::new(DisplayRow(0), 7)..DisplayPoint::new(DisplayRow(0), 8),
5741+
DisplayPoint::new(DisplayRow(0), 12)..DisplayPoint::new(DisplayRow(0), 13),
5742+
]
5743+
);
5744+
5745+
editor.rotate_backward(&RotateBackward, window, cx);
5746+
assert_eq!(editor.display_text(cx), "x=1, y=2, z=3");
5747+
5748+
editor
5749+
});
5750+
5751+
// Rotate text selections (vertical)
5752+
let (text, selection_ranges) = marked_text_ranges("x=«1ˇ»\ny=«2ˇ»\nz=«3ˇ»\n", true);
5753+
_ = cx.add_window(|window, cx| {
5754+
let buffer = MultiBuffer::build_simple(&text, cx);
5755+
let mut editor = build_editor(buffer, window, cx);
5756+
editor.change_selections(SelectionEffects::no_scroll(), window, cx, |s| {
5757+
s.select_ranges(selection_ranges)
5758+
});
5759+
editor.rotate_forward(&RotateForward, window, cx);
5760+
assert_eq!(editor.display_text(cx), "x=3\ny=1\nz=2\n");
5761+
assert_eq!(
5762+
editor.selections.display_ranges(cx),
5763+
vec![
5764+
DisplayPoint::new(DisplayRow(0), 2)..DisplayPoint::new(DisplayRow(0), 3),
5765+
DisplayPoint::new(DisplayRow(1), 2)..DisplayPoint::new(DisplayRow(1), 3),
5766+
DisplayPoint::new(DisplayRow(2), 2)..DisplayPoint::new(DisplayRow(2), 3),
5767+
]
5768+
);
5769+
5770+
editor.rotate_backward(&RotateBackward, window, cx);
5771+
assert_eq!(editor.display_text(cx), "x=1\ny=2\nz=3\n");
5772+
5773+
editor
5774+
});
5775+
5776+
// Rotate text selections (vertical, different lengths)
5777+
let (text, selection_ranges) = marked_text_ranges("x=\"«ˇ»\"\ny=\"«aˇ»\"\nz=\"«aaˇ»\"\n", true);
5778+
_ = cx.add_window(|window, cx| {
5779+
let buffer = MultiBuffer::build_simple(&text, cx);
5780+
let mut editor = build_editor(buffer, window, cx);
5781+
editor.change_selections(SelectionEffects::no_scroll(), window, cx, |s| {
5782+
s.select_ranges(selection_ranges)
5783+
});
5784+
editor.rotate_forward(&RotateForward, window, cx);
5785+
assert_eq!(editor.display_text(cx), "x=\"aa\"\ny=\"\"\nz=\"a\"\n");
5786+
assert_eq!(
5787+
editor.selections.display_ranges(cx),
5788+
vec![
5789+
DisplayPoint::new(DisplayRow(0), 3)..DisplayPoint::new(DisplayRow(0), 5),
5790+
DisplayPoint::new(DisplayRow(1), 3)..DisplayPoint::new(DisplayRow(1), 3),
5791+
DisplayPoint::new(DisplayRow(2), 3)..DisplayPoint::new(DisplayRow(2), 4),
5792+
]
5793+
);
5794+
5795+
editor.rotate_backward(&RotateBackward, window, cx);
5796+
assert_eq!(editor.display_text(cx), "x=\"\"\ny=\"a\"\nz=\"aa\"\n");
5797+
5798+
editor
5799+
});
5800+
5801+
// Rotate whole lines
5802+
let (text, selection_ranges) = marked_text_ranges("ˇline1\nˇline2\nˇline3\n", true);
5803+
_ = cx.add_window(|window, cx| {
5804+
let buffer = MultiBuffer::build_simple(&text, cx);
5805+
let mut editor = build_editor(buffer, window, cx);
5806+
editor.change_selections(SelectionEffects::no_scroll(), window, cx, |s| {
5807+
s.select_ranges(selection_ranges)
5808+
});
5809+
editor.rotate_forward(&RotateForward, window, cx);
5810+
assert_eq!(editor.display_text(cx), "line3\nline1\nline2\n");
5811+
5812+
editor.rotate_backward(&RotateBackward, window, cx);
5813+
assert_eq!(editor.display_text(cx), "line1\nline2\nline3\n");
5814+
5815+
editor
5816+
});
5817+
5818+
// Rotate whole lines, multiple cursors per line
5819+
let (text, selection_ranges) = marked_text_ranges("ˇlinˇe1\nˇline2\nˇline3\n", true);
5820+
_ = cx.add_window(|window, cx| {
5821+
let buffer = MultiBuffer::build_simple(&text, cx);
5822+
let mut editor = build_editor(buffer, window, cx);
5823+
editor.change_selections(SelectionEffects::no_scroll(), window, cx, |s| {
5824+
s.select_ranges(selection_ranges)
5825+
});
5826+
editor.rotate_forward(&RotateForward, window, cx);
5827+
assert_eq!(editor.display_text(cx), "line3\nline1\nline2\n");
5828+
5829+
editor.rotate_backward(&RotateBackward, window, cx);
5830+
assert_eq!(editor.display_text(cx), "line1\nline2\nline3\n");
5831+
5832+
editor
5833+
});
5834+
}
5835+
57225836
#[gpui::test]
57235837
fn test_move_line_up_down(cx: &mut TestAppContext) {
57245838
init_test(cx, |_| {});

crates/editor/src/element.rs

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -243,6 +243,8 @@ impl EditorElement {
243243
register_action(editor, window, Editor::sort_lines_case_insensitive);
244244
register_action(editor, window, Editor::reverse_lines);
245245
register_action(editor, window, Editor::shuffle_lines);
246+
register_action(editor, window, Editor::rotate_forward);
247+
register_action(editor, window, Editor::rotate_backward);
246248
register_action(editor, window, Editor::convert_indentation_to_spaces);
247249
register_action(editor, window, Editor::convert_indentation_to_tabs);
248250
register_action(editor, window, Editor::convert_to_upper_case);

0 commit comments

Comments
 (0)