From c7870cb93d050840f5fb8ffba0e50f7786a4091c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Tiago=20Lob=C3=A3o?= Date: Wed, 18 Mar 2026 09:55:04 -0300 Subject: [PATCH] editor: Add align selections action (#44769) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Closes #17090 Hello! This is a feature that I really wanted, so I've created an implementation of it. Hope u guys like it. It was alto mentioned at [this discussion](https://github.com/zed-industries/zed/discussions/30473) The use cases were based on the [vscode extension](https://marketplace.visualstudio.com/items?itemName=yo1dog.cursor-align) (only spaces case was implemented) - The algorithm is O(n + m × 2k), where: - n = total cursors (for initial analysis, to understand how many alignments will be performed) - m = number of alignment columns - k = number of alignment rows (first time to get the most-right occurrence, the next one to create the `Vec` of edits) Release Notes: - Added `editor: align selections` command ![ezgif-524b631d71833ba1](https://github.com/user-attachments/assets/28c3435a-2e01-496f-81c4-721ecfb5b22d)
Thank you for creating zed! --------- Co-authored-by: Kirill Bulatov --- crates/editor/src/actions.rs | 2 + crates/editor/src/editor.rs | 92 +++++++++++++++++++ crates/editor/src/editor_tests.rs | 148 ++++++++++++++++++++++++++++++ crates/editor/src/element.rs | 1 + 4 files changed, 243 insertions(+) diff --git a/crates/editor/src/actions.rs b/crates/editor/src/actions.rs index 7451aaced9072d3f60483a3d1091caa38f92294b..d1d2390fd86755c2fdf5fad28552f674b01932ec 100644 --- a/crates/editor/src/actions.rs +++ b/crates/editor/src/actions.rs @@ -883,6 +883,8 @@ actions!( UnwrapSyntaxNode, /// Wraps selections in tag specified by language. WrapSelectionsInTag, + /// Aligns selections from different rows into the same column + AlignSelections, ] ); diff --git a/crates/editor/src/editor.rs b/crates/editor/src/editor.rs index bff3275ae4d550a519aa0eb7c935cfb00201dd81..11b754701ce15586a1f46114e7044fd24158befd 100644 --- a/crates/editor/src/editor.rs +++ b/crates/editor/src/editor.rs @@ -11839,6 +11839,98 @@ impl Editor { } } + pub fn align_selections( + &mut self, + _: &crate::actions::AlignSelections, + window: &mut Window, + cx: &mut Context, + ) { + self.hide_mouse_cursor(HideMouseCursorOrigin::TypingAction, cx); + + let display_snapshot = self.display_snapshot(cx); + + struct CursorData { + anchor: Anchor, + point: Point, + } + let cursor_data: Vec = self + .selections + .disjoint_anchors() + .iter() + .map(|selection| { + let anchor = if selection.reversed { + selection.head() + } else { + selection.tail() + }; + CursorData { + anchor: anchor, + point: anchor.to_point(&display_snapshot.buffer_snapshot()), + } + }) + .collect(); + + let rows_anchors_count: Vec = cursor_data + .iter() + .map(|cursor| cursor.point.row) + .chunk_by(|&row| row) + .into_iter() + .map(|(_, group)| group.count()) + .collect(); + let max_columns = rows_anchors_count.iter().max().copied().unwrap_or(0); + let mut rows_column_offset = vec![0; rows_anchors_count.len()]; + let mut edits = Vec::new(); + + for column_idx in 0..max_columns { + let mut cursor_index = 0; + + // Calculate target_column => position that the selections will go + let mut target_column = 0; + for (row_idx, cursor_count) in rows_anchors_count.iter().enumerate() { + // Skip rows that don't have this column + if column_idx >= *cursor_count { + cursor_index += cursor_count; + continue; + } + + let point = &cursor_data[cursor_index + column_idx].point; + let adjusted_column = point.column + rows_column_offset[row_idx]; + if adjusted_column > target_column { + target_column = adjusted_column; + } + cursor_index += cursor_count; + } + + // Collect edits for this column + cursor_index = 0; + for (row_idx, cursor_count) in rows_anchors_count.iter().enumerate() { + // Skip rows that don't have this column + if column_idx >= *cursor_count { + cursor_index += *cursor_count; + continue; + } + + let point = &cursor_data[cursor_index + column_idx].point; + let spaces_needed = target_column - point.column - rows_column_offset[row_idx]; + if spaces_needed > 0 { + let anchor = cursor_data[cursor_index + column_idx] + .anchor + .bias_left(&display_snapshot); + edits.push((anchor..anchor, " ".repeat(spaces_needed as usize))); + } + rows_column_offset[row_idx] += spaces_needed; + + cursor_index += *cursor_count; + } + } + + if !edits.is_empty() { + self.transact(window, cx, |editor, _window, cx| { + editor.edit(edits, cx); + }); + } + } + pub fn disable_breakpoint( &mut self, _: &crate::actions::DisableBreakpoint, diff --git a/crates/editor/src/editor_tests.rs b/crates/editor/src/editor_tests.rs index f752bb9d21caf6c488c37f3f65ceb7db88471875..05880f9c2a3de2325b8826af0eb3641da0162a4a 100644 --- a/crates/editor/src/editor_tests.rs +++ b/crates/editor/src/editor_tests.rs @@ -34119,3 +34119,151 @@ async fn test_restore_and_next(cx: &mut TestAppContext) { .unindent(), ); } + +#[gpui::test] +async fn test_align_selections(cx: &mut TestAppContext) { + init_test(cx, |_| {}); + let mut cx = EditorTestContext::new(cx).await; + + // 1) one cursor, no action + let before = " abc\n abc\nabc\n ˇabc"; + cx.set_state(before); + cx.update_editor(|e, window, cx| e.align_selections(&AlignSelections, window, cx)); + cx.assert_editor_state(before); + + // 2) multiple cursors at different rows + let before = indoc!( + r#" + let aˇbc = 123; + let xˇyz = 456; + let fˇoo = 789; + let bˇar = 0; + "# + ); + let after = indoc!( + r#" + let a ˇbc = 123; + let x ˇyz = 456; + let f ˇoo = 789; + let bˇar = 0; + "# + ); + cx.set_state(before); + cx.update_editor(|e, window, cx| e.align_selections(&AlignSelections, window, cx)); + cx.assert_editor_state(after); + + // 3) multiple selections at different rows + let before = indoc!( + r#" + let «ˇabc» = 123; + let «ˇxyz» = 456; + let «ˇfoo» = 789; + let «ˇbar» = 0; + "# + ); + let after = indoc!( + r#" + let «ˇabc» = 123; + let «ˇxyz» = 456; + let «ˇfoo» = 789; + let «ˇbar» = 0; + "# + ); + cx.set_state(before); + cx.update_editor(|e, window, cx| e.align_selections(&AlignSelections, window, cx)); + cx.assert_editor_state(after); + + // 4) multiple selections at different rows, inverted head + let before = indoc!( + r#" + let «abcˇ» = 123; + // comment + let «xyzˇ» = 456; + let «fooˇ» = 789; + let «barˇ» = 0; + "# + ); + let after = indoc!( + r#" + let «abcˇ» = 123; + // comment + let «xyzˇ» = 456; + let «fooˇ» = 789; + let «barˇ» = 0; + "# + ); + cx.set_state(before); + cx.update_editor(|e, window, cx| e.align_selections(&AlignSelections, window, cx)); + cx.assert_editor_state(after); +} + +#[gpui::test] +async fn test_align_selections_multicolumn(cx: &mut TestAppContext) { + init_test(cx, |_| {}); + let mut cx = EditorTestContext::new(cx).await; + + // 1) Multicolumn, one non affected editor row + let before = indoc!( + r#" + name «|ˇ» age «|ˇ» height «|ˇ» note + Matthew «|ˇ» 7 «|ˇ» 2333 «|ˇ» smart + Mike «|ˇ» 1234 «|ˇ» 567 «|ˇ» lazy + Anything that is not selected + Miles «|ˇ» 88 «|ˇ» 99 «|ˇ» funny + "# + ); + let after = indoc!( + r#" + name «|ˇ» age «|ˇ» height «|ˇ» note + Matthew «|ˇ» 7 «|ˇ» 2333 «|ˇ» smart + Mike «|ˇ» 1234 «|ˇ» 567 «|ˇ» lazy + Anything that is not selected + Miles «|ˇ» 88 «|ˇ» 99 «|ˇ» funny + "# + ); + cx.set_state(before); + cx.update_editor(|e, window, cx| e.align_selections(&AlignSelections, window, cx)); + cx.assert_editor_state(after); + + // 2) not all alignment rows has the number of alignment columns + let before = indoc!( + r#" + name «|ˇ» age «|ˇ» height + Matthew «|ˇ» 7 «|ˇ» 2333 + Mike «|ˇ» 1234 + Miles «|ˇ» 88 «|ˇ» 99 + "# + ); + let after = indoc!( + r#" + name «|ˇ» age «|ˇ» height + Matthew «|ˇ» 7 «|ˇ» 2333 + Mike «|ˇ» 1234 + Miles «|ˇ» 88 «|ˇ» 99 + "# + ); + cx.set_state(before); + cx.update_editor(|e, window, cx| e.align_selections(&AlignSelections, window, cx)); + cx.assert_editor_state(after); + + // 3) A aligned column shall stay aligned + let before = indoc!( + r#" + $ ˇa ˇa + $ ˇa ˇa + $ ˇa ˇa + $ ˇa ˇa + "# + ); + let after = indoc!( + r#" + $ ˇa ˇa + $ ˇa ˇa + $ ˇa ˇa + $ ˇa ˇa + "# + ); + cx.set_state(before); + cx.update_editor(|e, window, cx| e.align_selections(&AlignSelections, window, cx)); + cx.assert_editor_state(after); +} diff --git a/crates/editor/src/element.rs b/crates/editor/src/element.rs index 7128c60b7f45147f99b6f46d3bd85b9428d358ef..acca539ceac3a549641f6abfb8b1a906114f47fe 100644 --- a/crates/editor/src/element.rs +++ b/crates/editor/src/element.rs @@ -653,6 +653,7 @@ impl EditorElement { register_action(editor, window, Editor::enable_breakpoint); register_action(editor, window, Editor::disable_breakpoint); register_action(editor, window, Editor::toggle_read_only); + register_action(editor, window, Editor::align_selections); if editor.read(cx).enable_wrap_selections_in_tag(cx) { register_action(editor, window, Editor::wrap_selections_in_tag); }