From 25172f990b098d1706455377f2a88c0afb7f3217 Mon Sep 17 00:00:00 2001 From: fantacell Date: Thu, 16 Oct 2025 18:36:31 +0200 Subject: [PATCH] helix: Change selection cloning (#38090) Closes #33637 Closes #37332 and solves part of https://github.com/zed-industries/zed/discussions/33580#discussioncomment-14195506 This improves the "C" and "alt-C" actions to work like helix. It also adds "," which removes all but the newest cursors. In helix the one that's left would be the primary selection, but I don't think that has an equivalent yet, so this simulates what would be the primary selection if it was never cycled with "(" ")". Release Notes: - Improved multicursor creation and deletion in helix mode --------- Co-authored-by: Jakub Konka --- assets/keymaps/vim.json | 5 +- crates/vim/src/helix.rs | 29 ++++ crates/vim/src/helix/duplicate.rs | 233 ++++++++++++++++++++++++++++++ 3 files changed, 265 insertions(+), 2 deletions(-) create mode 100644 crates/vim/src/helix/duplicate.rs diff --git a/assets/keymaps/vim.json b/assets/keymaps/vim.json index adfdd4883640eb8a3ee6c193e1bd26ea479f55b6..d9e13e36b4eb5b5d04c8e51adadc516054dc0775 100644 --- a/assets/keymaps/vim.json +++ b/assets/keymaps/vim.json @@ -498,8 +498,9 @@ "ctrl-c": "editor::ToggleComments", "d": "vim::HelixDelete", "c": "vim::Substitute", - "shift-c": ["editor::AddSelectionBelow", { "skip_soft_wrap": true }], - "alt-shift-c": ["editor::AddSelectionAbove", { "skip_soft_wrap": true }] + "shift-c": "vim::HelixDuplicateBelow", + "alt-shift-c": "vim::HelixDuplicateAbove", + ",": "vim::HelixKeepNewestSelection" } }, { diff --git a/crates/vim/src/helix.rs b/crates/vim/src/helix.rs index d2c270e5239204b169c55483fdd6ac3185dd4529..764a00d3487dd6f6c83112e586ed1f1e8c97307a 100644 --- a/crates/vim/src/helix.rs +++ b/crates/vim/src/helix.rs @@ -1,4 +1,5 @@ mod boundary; +mod duplicate; mod object; mod paste; mod select; @@ -40,6 +41,13 @@ actions!( HelixSelectLine, /// Select all matches of a given pattern within the current selection. HelixSelectRegex, + /// Removes all but the one selection that was created last. + /// `Newest` can eventually be `Primary`. + HelixKeepNewestSelection, + /// Copies all selections below. + HelixDuplicateBelow, + /// Copies all selections above. + HelixDuplicateAbove, ] ); @@ -51,6 +59,15 @@ pub fn register(editor: &mut Editor, cx: &mut Context) { Vim::action(editor, cx, Vim::helix_goto_last_modification); Vim::action(editor, cx, Vim::helix_paste); Vim::action(editor, cx, Vim::helix_select_regex); + Vim::action(editor, cx, Vim::helix_keep_newest_selection); + Vim::action(editor, cx, |vim, _: &HelixDuplicateBelow, window, cx| { + let times = Vim::take_count(cx); + vim.helix_duplicate_selections_below(times, window, cx); + }); + Vim::action(editor, cx, |vim, _: &HelixDuplicateAbove, window, cx| { + let times = Vim::take_count(cx); + vim.helix_duplicate_selections_above(times, window, cx); + }); } impl Vim { @@ -575,6 +592,18 @@ impl Vim { }); }); } + + fn helix_keep_newest_selection( + &mut self, + _: &HelixKeepNewestSelection, + window: &mut Window, + cx: &mut Context, + ) { + self.update_editor(cx, |_, editor, cx| { + let newest = editor.selections.newest::(cx); + editor.change_selections(Default::default(), window, cx, |s| s.select(vec![newest])); + }); + } } #[cfg(test)] diff --git a/crates/vim/src/helix/duplicate.rs b/crates/vim/src/helix/duplicate.rs new file mode 100644 index 0000000000000000000000000000000000000000..91e53f13e9962db30ad34b91a3c29e2a530626d8 --- /dev/null +++ b/crates/vim/src/helix/duplicate.rs @@ -0,0 +1,233 @@ +use std::ops::Range; + +use editor::{DisplayPoint, display_map::DisplaySnapshot}; +use gpui::Context; +use text::Bias; +use ui::Window; + +use crate::Vim; + +impl Vim { + /// Creates a duplicate of every selection below it in the first place that has both its start + /// and end + pub(super) fn helix_duplicate_selections_below( + &mut self, + times: Option, + window: &mut Window, + cx: &mut Context, + ) { + self.duplicate_selections( + times, + window, + cx, + |prev_point| *prev_point.row_mut() += 1, + |prev_range, map| prev_range.end.row() >= map.max_point().row(), + false, + ); + } + + /// Creates a duplicate of every selection above it in the first place that has both its start + /// and end + pub(super) fn helix_duplicate_selections_above( + &mut self, + times: Option, + window: &mut Window, + cx: &mut Context, + ) { + self.duplicate_selections( + times, + window, + cx, + |prev_point| *prev_point.row_mut() = prev_point.row().0.saturating_sub(1), + |prev_range, _| prev_range.start.row() == DisplayPoint::zero().row(), + true, + ); + } + + fn duplicate_selections( + &mut self, + times: Option, + window: &mut Window, + cx: &mut Context, + advance_search: impl Fn(&mut DisplayPoint), + end_search: impl Fn(&Range, &DisplaySnapshot) -> bool, + above: bool, + ) { + let times = times.unwrap_or(1); + self.update_editor(cx, |_, editor, cx| { + let mut selections = Vec::new(); + let (map, mut original_selections) = editor.selections.all_display(cx); + // The order matters, because it is recorded when the selections are added. + if above { + original_selections.reverse(); + } + + for origin in original_selections { + let origin = origin.tail()..origin.head(); + selections.push(display_point_range_to_offset_range(&origin, &map)); + let mut last_origin = origin; + for _ in 1..=times { + if let Some(duplicate) = find_next_valid_duplicate_space( + last_origin.clone(), + &map, + &advance_search, + &end_search, + ) { + selections.push(display_point_range_to_offset_range(&duplicate, &map)); + last_origin = duplicate; + } else { + break; + } + } + } + + editor.change_selections(Default::default(), window, cx, |s| { + s.select_ranges(selections); + }); + }); + } +} + +fn find_next_valid_duplicate_space( + mut origin: Range, + map: &DisplaySnapshot, + advance_search: &impl Fn(&mut DisplayPoint), + end_search: &impl Fn(&Range, &DisplaySnapshot) -> bool, +) -> Option> { + while !end_search(&origin, map) { + advance_search(&mut origin.start); + advance_search(&mut origin.end); + + if map.clip_point(origin.start, Bias::Left) == origin.start + && map.clip_point(origin.end, Bias::Right) == origin.end + { + return Some(origin); + } + } + None +} + +fn display_point_range_to_offset_range( + range: &Range, + map: &DisplaySnapshot, +) -> Range { + range.start.to_offset(map, Bias::Left)..range.end.to_offset(map, Bias::Right) +} + +#[cfg(test)] +mod tests { + use db::indoc; + + use crate::{state::Mode, test::VimTestContext}; + + #[gpui::test] + async fn test_selection_duplication(cx: &mut gpui::TestAppContext) { + let mut cx = VimTestContext::new(cx, true).await; + cx.enable_helix(); + + cx.set_state( + indoc! {" + The quick brown + fox «jumpsˇ» + over the + lazy dog."}, + Mode::HelixNormal, + ); + + cx.simulate_keystrokes("C"); + + cx.assert_state( + indoc! {" + The quick brown + fox «jumpsˇ» + over the + lazy« dog.ˇ»"}, + Mode::HelixNormal, + ); + + cx.simulate_keystrokes("C"); + + cx.assert_state( + indoc! {" + The quick brown + fox «jumpsˇ» + over the + lazy« dog.ˇ»"}, + Mode::HelixNormal, + ); + + cx.simulate_keystrokes("alt-C"); + + cx.assert_state( + indoc! {" + The «quickˇ» brown + fox «jumpsˇ» + over the + lazy« dog.ˇ»"}, + Mode::HelixNormal, + ); + + cx.simulate_keystrokes(","); + + cx.assert_state( + indoc! {" + The «quickˇ» brown + fox jumps + over the + lazy dog."}, + Mode::HelixNormal, + ); + } + + #[gpui::test] + async fn test_selection_duplication_backwards(cx: &mut gpui::TestAppContext) { + let mut cx = VimTestContext::new(cx, true).await; + cx.enable_helix(); + + cx.set_state( + indoc! {" + The quick brown + «ˇfox» jumps + over the + lazy dog."}, + Mode::HelixNormal, + ); + + cx.simulate_keystrokes("C C alt-C"); + + cx.assert_state( + indoc! {" + «ˇThe» quick brown + «ˇfox» jumps + «ˇove»r the + «ˇlaz»y dog."}, + Mode::HelixNormal, + ); + } + + #[gpui::test] + async fn test_selection_duplication_count(cx: &mut gpui::TestAppContext) { + let mut cx = VimTestContext::new(cx, true).await; + cx.enable_helix(); + + cx.set_state( + indoc! {" + The «qˇ»uick brown + fox jumps + over the + lazy dog."}, + Mode::HelixNormal, + ); + + cx.simulate_keystrokes("9 C"); + + cx.assert_state( + indoc! {" + The «qˇ»uick brown + fox «jˇ»umps + over« ˇ»the + lazy« ˇ»dog."}, + Mode::HelixNormal, + ); + } +}