helix: Initial support for helix-mode paste (#37963)

jneem and Jakub Konka created

This is a redo of #29776. I went for a separate function -- instead of
adding a bunch of conditions to `vim::Paste` -- because there were quite
a few differences.

Release Notes:

- Added a `vim::HelixPaste` command that imitates Helix's paste behavior

---------

Co-authored-by: Jakub Konka <kubkon@jakubkonka.com>

Change summary

assets/keymaps/vim.json       |   2 
crates/vim/src/helix.rs       |   2 
crates/vim/src/helix/paste.rs | 413 +++++++++++++++++++++++++++++++++++++
3 files changed, 417 insertions(+)

Detailed changes

assets/keymaps/vim.json 🔗

@@ -433,6 +433,8 @@
       "h": "vim::WrappingLeft",
       "l": "vim::WrappingRight",
       "y": "vim::HelixYank",
+      "p": "vim::HelixPaste",
+      "shift-p": ["vim::HelixPaste", { "before": true }],
       "alt-;": "vim::OtherEnd",
       "ctrl-r": "vim::Redo",
       "f": ["vim::PushFindForward", { "before": false, "multiline": true }],

crates/vim/src/helix.rs 🔗

@@ -1,5 +1,6 @@
 mod boundary;
 mod object;
+mod paste;
 mod select;
 
 use editor::display_map::DisplaySnapshot;
@@ -40,6 +41,7 @@ pub fn register(editor: &mut Editor, cx: &mut Context<Vim>) {
     Vim::action(editor, cx, Vim::helix_append);
     Vim::action(editor, cx, Vim::helix_yank);
     Vim::action(editor, cx, Vim::helix_goto_last_modification);
+    Vim::action(editor, cx, Vim::helix_paste);
 }
 
 impl Vim {

crates/vim/src/helix/paste.rs 🔗

@@ -0,0 +1,413 @@
+use editor::{ToOffset, movement};
+use gpui::{Action, Context, Window};
+use schemars::JsonSchema;
+use serde::Deserialize;
+
+use crate::{Vim, state::Mode};
+
+/// Pastes text from the specified register at the cursor position.
+#[derive(Clone, Deserialize, JsonSchema, PartialEq, Action)]
+#[action(namespace = vim)]
+#[serde(deny_unknown_fields)]
+pub struct HelixPaste {
+    #[serde(default)]
+    before: bool,
+}
+
+impl Vim {
+    pub fn helix_paste(
+        &mut self,
+        action: &HelixPaste,
+        window: &mut Window,
+        cx: &mut Context<Self>,
+    ) {
+        self.record_current_action(cx);
+        self.store_visual_marks(window, cx);
+        let count = Vim::take_count(cx).unwrap_or(1);
+        // TODO: vim paste calls take_forced_motion here, but I don't know what that does
+        // (none of the other helix_ methods call it)
+
+        self.update_editor(cx, |vim, editor, cx| {
+            editor.transact(window, cx, |editor, window, cx| {
+                editor.set_clip_at_line_ends(false, cx);
+
+                let selected_register = vim.selected_register.take();
+
+                let Some((text, clipboard_selections)) = Vim::update_globals(cx, |globals, cx| {
+                    globals.read_register(selected_register, Some(editor), cx)
+                })
+                .and_then(|reg| {
+                    (!reg.text.is_empty())
+                        .then_some(reg.text)
+                        .zip(reg.clipboard_selections)
+                }) else {
+                    return;
+                };
+
+                let (display_map, current_selections) = editor.selections.all_adjusted_display(cx);
+
+                // The clipboard can have multiple selections, and there can
+                // be multiple selections. Helix zips them together, so the first
+                // clipboard entry gets pasted at the first selection, the second
+                // entry gets pasted at the second selection, and so on. If there
+                // are more clipboard selections than selections, the extra ones
+                // don't get pasted anywhere. If there are more selections than
+                // clipboard selections, the last clipboard selection gets
+                // pasted at all remaining selections.
+
+                let mut edits = Vec::new();
+                let mut new_selections = Vec::new();
+                let mut start_offset = 0;
+
+                let mut replacement_texts: Vec<String> = Vec::new();
+
+                for ix in 0..current_selections.len() {
+                    let to_insert = if let Some(clip_sel) = clipboard_selections.get(ix) {
+                        let end_offset = start_offset + clip_sel.len;
+                        let text = text[start_offset..end_offset].to_string();
+                        start_offset = end_offset + 1;
+                        text
+                    } else if let Some(last_text) = replacement_texts.last() {
+                        // We have more current selections than clipboard selections: repeat the last one.
+                        last_text.to_owned()
+                    } else {
+                        text.to_string()
+                    };
+                    replacement_texts.push(to_insert);
+                }
+
+                let line_mode = replacement_texts.iter().any(|text| text.ends_with('\n'));
+
+                for (to_insert, sel) in replacement_texts.into_iter().zip(current_selections) {
+                    // Helix doesn't care about the head/tail of the selection.
+                    // Pasting before means pasting before the whole selection.
+                    let display_point = if line_mode {
+                        if action.before {
+                            movement::line_beginning(&display_map, sel.start, false)
+                        } else if sel.end.column() == 0 {
+                            sel.end
+                        } else {
+                            movement::right(
+                                &display_map,
+                                movement::line_end(&display_map, sel.end, false),
+                            )
+                        }
+                    } else if action.before {
+                        sel.start
+                    } else if sel.start == sel.end {
+                        // Helix and Zed differ in how they understand
+                        // single-point cursors. In Helix, a single-point cursor
+                        // is "on top" of some character, and pasting after that
+                        // cursor means that the pasted content should go after
+                        // that character. (If the cursor is at the end of a
+                        // line, the pasted content goes on the next line.)
+                        movement::right(&display_map, sel.end)
+                    } else {
+                        sel.end
+                    };
+                    let point = display_point.to_point(&display_map);
+                    let anchor = if action.before {
+                        display_map.buffer_snapshot.anchor_after(point)
+                    } else {
+                        display_map.buffer_snapshot.anchor_before(point)
+                    };
+                    edits.push((point..point, to_insert.repeat(count)));
+                    new_selections.push((anchor, to_insert.len() * count));
+                }
+
+                editor.edit(edits, cx);
+
+                editor.change_selections(Default::default(), window, cx, |s| {
+                    let snapshot = s.buffer().clone();
+                    s.select_ranges(new_selections.into_iter().map(|(anchor, len)| {
+                        let offset = anchor.to_offset(&snapshot);
+                        if action.before {
+                            offset.saturating_sub(len)..offset
+                        } else {
+                            offset..(offset + len)
+                        }
+                    }));
+                })
+            });
+        });
+
+        self.switch_mode(Mode::HelixNormal, true, window, cx);
+    }
+}
+
+#[cfg(test)]
+mod test {
+    use indoc::indoc;
+
+    use crate::{state::Mode, test::VimTestContext};
+
+    #[gpui::test]
+    async fn test_paste(cx: &mut gpui::TestAppContext) {
+        let mut cx = VimTestContext::new(cx, true).await;
+        cx.enable_helix();
+        cx.set_state(
+            indoc! {"
+            The «quiˇ»ck brown
+            fox jumps over
+            the lazy dog."},
+            Mode::HelixNormal,
+        );
+
+        cx.simulate_keystrokes("y w p");
+
+        cx.assert_state(
+            indoc! {"
+            The quick «quiˇ»brown
+            fox jumps over
+            the lazy dog."},
+            Mode::HelixNormal,
+        );
+
+        // Pasting before the selection:
+        cx.set_state(
+            indoc! {"
+            The quick brown
+            fox «jumpsˇ» over
+            the lazy dog."},
+            Mode::HelixNormal,
+        );
+        cx.simulate_keystrokes("shift-p");
+        cx.assert_state(
+            indoc! {"
+            The quick brown
+            fox «quiˇ»jumps over
+            the lazy dog."},
+            Mode::HelixNormal,
+        );
+    }
+
+    #[gpui::test]
+    async fn test_point_selection_paste(cx: &mut gpui::TestAppContext) {
+        let mut cx = VimTestContext::new(cx, true).await;
+        cx.enable_helix();
+        cx.set_state(
+            indoc! {"
+            The quiˇck brown
+            fox jumps over
+            the lazy dog."},
+            Mode::HelixNormal,
+        );
+
+        cx.simulate_keystrokes("y");
+
+        // Pasting before the selection:
+        cx.set_state(
+            indoc! {"
+            The quick brown
+            fox jumpsˇ over
+            the lazy dog."},
+            Mode::HelixNormal,
+        );
+        cx.simulate_keystrokes("shift-p");
+        cx.assert_state(
+            indoc! {"
+            The quick brown
+            fox jumps«cˇ» over
+            the lazy dog."},
+            Mode::HelixNormal,
+        );
+
+        // Pasting after the selection:
+        cx.set_state(
+            indoc! {"
+            The quick brown
+            fox jumpsˇ over
+            the lazy dog."},
+            Mode::HelixNormal,
+        );
+        cx.simulate_keystrokes("p");
+        cx.assert_state(
+            indoc! {"
+            The quick brown
+            fox jumps «cˇ»over
+            the lazy dog."},
+            Mode::HelixNormal,
+        );
+
+        // Pasting after the selection at the end of a line:
+        cx.set_state(
+            indoc! {"
+            The quick brown
+            fox jumps overˇ
+            the lazy dog."},
+            Mode::HelixNormal,
+        );
+        cx.simulate_keystrokes("p");
+        cx.assert_state(
+            indoc! {"
+            The quick brown
+            fox jumps over
+            «cˇ»the lazy dog."},
+            Mode::HelixNormal,
+        );
+    }
+
+    #[gpui::test]
+    async fn test_multi_cursor_paste(cx: &mut gpui::TestAppContext) {
+        let mut cx = VimTestContext::new(cx, true).await;
+        cx.enable_helix();
+        // Select two blocks of text.
+        cx.set_state(
+            indoc! {"
+            The «quiˇ»ck brown
+            fox ju«mpsˇ» over
+            the lazy dog."},
+            Mode::HelixNormal,
+        );
+        cx.simulate_keystrokes("y");
+
+        // Only one cursor: only the first block gets pasted.
+        cx.set_state(
+            indoc! {"
+            ˇThe quick brown
+            fox jumps over
+            the lazy dog."},
+            Mode::HelixNormal,
+        );
+        cx.simulate_keystrokes("shift-p");
+        cx.assert_state(
+            indoc! {"
+            «quiˇ»The quick brown
+            fox jumps over
+            the lazy dog."},
+            Mode::HelixNormal,
+        );
+
+        // Two cursors: both get pasted.
+        cx.set_state(
+            indoc! {"
+            ˇThe ˇquick brown
+            fox jumps over
+            the lazy dog."},
+            Mode::HelixNormal,
+        );
+        cx.simulate_keystrokes("shift-p");
+        cx.assert_state(
+            indoc! {"
+            «quiˇ»The «mpsˇ»quick brown
+            fox jumps over
+            the lazy dog."},
+            Mode::HelixNormal,
+        );
+
+        // Three cursors: the second yanked block is duplicated.
+        cx.set_state(
+            indoc! {"
+            ˇThe ˇquick brown
+            fox jumpsˇ over
+            the lazy dog."},
+            Mode::HelixNormal,
+        );
+        cx.simulate_keystrokes("shift-p");
+        cx.assert_state(
+            indoc! {"
+            «quiˇ»The «mpsˇ»quick brown
+            fox jumps«mpsˇ» over
+            the lazy dog."},
+            Mode::HelixNormal,
+        );
+
+        // Again with three cursors. All three should be pasted twice.
+        cx.set_state(
+            indoc! {"
+            ˇThe ˇquick brown
+            fox jumpsˇ over
+            the lazy dog."},
+            Mode::HelixNormal,
+        );
+        cx.simulate_keystrokes("2 shift-p");
+        cx.assert_state(
+            indoc! {"
+            «quiquiˇ»The «mpsmpsˇ»quick brown
+            fox jumps«mpsmpsˇ» over
+            the lazy dog."},
+            Mode::HelixNormal,
+        );
+    }
+
+    #[gpui::test]
+    async fn test_line_mode_paste(cx: &mut gpui::TestAppContext) {
+        let mut cx = VimTestContext::new(cx, true).await;
+        cx.enable_helix();
+        cx.set_state(
+            indoc! {"
+            The quick brow«n
+            ˇ»fox jumps over
+            the lazy dog."},
+            Mode::HelixNormal,
+        );
+
+        cx.simulate_keystrokes("y shift-p");
+
+        cx.assert_state(
+            indoc! {"
+            «n
+            ˇ»The quick brown
+            fox jumps over
+            the lazy dog."},
+            Mode::HelixNormal,
+        );
+
+        // In line mode, if we're in the middle of a line then pasting before pastes on
+        // the line before.
+        cx.set_state(
+            indoc! {"
+            The quick brown
+            fox jumpsˇ over
+            the lazy dog."},
+            Mode::HelixNormal,
+        );
+        cx.simulate_keystrokes("shift-p");
+        cx.assert_state(
+            indoc! {"
+            The quick brown
+            «n
+            ˇ»fox jumps over
+            the lazy dog."},
+            Mode::HelixNormal,
+        );
+
+        // In line mode, if we're in the middle of a line then pasting after pastes on
+        // the line after.
+        cx.set_state(
+            indoc! {"
+            The quick brown
+            fox jumpsˇ over
+            the lazy dog."},
+            Mode::HelixNormal,
+        );
+        cx.simulate_keystrokes("p");
+        cx.assert_state(
+            indoc! {"
+            The quick brown
+            fox jumps over
+            «n
+            ˇ»the lazy dog."},
+            Mode::HelixNormal,
+        );
+
+        // If we're currently at the end of a line, "the line after"
+        // means right after the cursor.
+        cx.set_state(
+            indoc! {"
+            The quick brown
+            fox jumps over
+            ˇthe lazy dog."},
+            Mode::HelixNormal,
+        );
+        cx.simulate_keystrokes("p");
+        cx.assert_state(
+            indoc! {"
+            The quick brown
+            fox jumps over
+            «n
+            ˇ»the lazy dog."},
+            Mode::HelixNormal,
+        );
+    }
+}