helix: Change selection cloning (#38090)

fantacell and Jakub Konka created

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 <kubkon@jakubkonka.com>

Change summary

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(-)

Detailed changes

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"
     }
   },
   {

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>) {
     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>,
+    ) {
+        self.update_editor(cx, |_, editor, cx| {
+            let newest = editor.selections.newest::<usize>(cx);
+            editor.change_selections(Default::default(), window, cx, |s| s.select(vec![newest]));
+        });
+    }
 }
 
 #[cfg(test)]

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<usize>,
+        window: &mut Window,
+        cx: &mut Context<Self>,
+    ) {
+        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<usize>,
+        window: &mut Window,
+        cx: &mut Context<Self>,
+    ) {
+        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<usize>,
+        window: &mut Window,
+        cx: &mut Context<Self>,
+        advance_search: impl Fn(&mut DisplayPoint),
+        end_search: impl Fn(&Range<DisplayPoint>, &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<DisplayPoint>,
+    map: &DisplaySnapshot,
+    advance_search: &impl Fn(&mut DisplayPoint),
+    end_search: &impl Fn(&Range<DisplayPoint>, &DisplaySnapshot) -> bool,
+) -> Option<Range<DisplayPoint>> {
+    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<DisplayPoint>,
+    map: &DisplaySnapshot,
+) -> Range<usize> {
+    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,
+        );
+    }
+}