diff --git a/assets/keymaps/vim.json b/assets/keymaps/vim.json index 817198659657814dcc597926d689063ae2182c78..590e84cf7fc10f7af5dd317bc114b75390414e4f 100644 --- a/assets/keymaps/vim.json +++ b/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 }], diff --git a/crates/vim/src/helix.rs b/crates/vim/src/helix.rs index cc235d67ae6efcae2fb5a5c5d899b9f7776cbda4..ec1618311f8b8e16b71a39fc1d29b5c60eb49c96 100644 --- a/crates/vim/src/helix.rs +++ b/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::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 { diff --git a/crates/vim/src/helix/paste.rs b/crates/vim/src/helix/paste.rs new file mode 100644 index 0000000000000000000000000000000000000000..ecfdaa499257ad91d8518f488be9a4d4dbb51f1c --- /dev/null +++ b/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.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 = 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, + ); + } +}