From 901cb8b3d27bbe5a9a34503d026c6f7eb4511917 Mon Sep 17 00:00:00 2001 From: Zachiah Sawyer Date: Thu, 9 May 2024 17:51:19 -0700 Subject: [PATCH] vim: Add basic mark support (#11507) Release Notes: - vim: Added support for buffer-local marks (`'a-'z`) and some builtin marks `'<`,`'>`,`'[`,`']`, `'{`, `'}` and `^`. Global marks (`'A-'Z`), and other builtin marks (`'0-'9`, `'(`, `')`, `''`, `'.`, `'"`) are not yet implemented. (#5122) --------- Co-authored-by: Conrad Irwin --- assets/keymaps/vim.json | 3 + crates/vim/src/motion.rs | 13 +- crates/vim/src/normal.rs | 1 + crates/vim/src/normal/mark.rs | 147 ++++++++++++++++++ crates/vim/src/state.rs | 14 +- crates/vim/src/test.rs | 130 ++++++++++++++++ crates/vim/src/utils.rs | 18 +++ crates/vim/src/vim.rs | 27 +++- crates/vim/test_data/test_builtin_marks.json | 36 +++++ crates/vim/test_data/test_caret_mark.json | 26 ++++ .../vim/test_data/test_lowercase_marks.json | 15 ++ crates/vim/test_data/test_lt_gt_marks.json | 18 +++ crates/vim/test_data/test_marks.json | 15 ++ crates/vim/test_data/test_period_mark.json | 14 ++ 14 files changed, 471 insertions(+), 6 deletions(-) create mode 100644 crates/vim/src/normal/mark.rs create mode 100644 crates/vim/test_data/test_builtin_marks.json create mode 100644 crates/vim/test_data/test_caret_mark.json create mode 100644 crates/vim/test_data/test_lowercase_marks.json create mode 100644 crates/vim/test_data/test_lt_gt_marks.json create mode 100644 crates/vim/test_data/test_marks.json create mode 100644 crates/vim/test_data/test_period_mark.json diff --git a/assets/keymaps/vim.json b/assets/keymaps/vim.json index 1bb362a324..552624c82f 100644 --- a/assets/keymaps/vim.json +++ b/assets/keymaps/vim.json @@ -117,6 +117,9 @@ } } ], + "m": ["vim::PushOperator", "Mark"], + "'": ["vim::PushOperator", { "Jump": { "line": true } }], + "`": ["vim::PushOperator", { "Jump": { "line": false } }], ";": "vim::RepeatFind", ",": "vim::RepeatFindReversed", "ctrl-o": "pane::GoBack", diff --git a/crates/vim/src/motion.rs b/crates/vim/src/motion.rs index 3ae99e9ba4..99cc880a56 100644 --- a/crates/vim/src/motion.rs +++ b/crates/vim/src/motion.rs @@ -13,7 +13,7 @@ use std::ops::Range; use workspace::Workspace; use crate::{ - normal::normal_motion, + normal::{mark, normal_motion}, state::{Mode, Operator}, surrounds::SurroundsType, utils::coerce_punctuation, @@ -105,6 +105,10 @@ pub enum Motion { prior_selections: Vec>, new_selections: Vec>, }, + Jump { + anchor: Anchor, + line: bool, + }, } #[derive(Clone, Deserialize, PartialEq)] @@ -469,6 +473,7 @@ impl Motion { | WindowTop | WindowMiddle | WindowBottom + | Jump { line: true, .. } | EndOfParagraph => true, EndOfLine { .. } | Matching @@ -492,6 +497,7 @@ impl Motion { | FindBackward { .. } | RepeatFind { .. } | RepeatFindReversed { .. } + | Jump { line: false, .. } | ZedSearchResult { .. } => false, } } @@ -531,7 +537,8 @@ impl Motion { | WindowMiddle | WindowBottom | NextLineStart - | ZedSearchResult { .. } => false, + | ZedSearchResult { .. } + | Jump { .. } => false, } } @@ -570,6 +577,7 @@ impl Motion { | PreviousSubwordStart { .. } | FirstNonWhitespace { .. } | FindBackward { .. } + | Jump { .. } | ZedSearchResult { .. } => false, RepeatFind { last_find: motion } | RepeatFindReversed { last_find: motion } => { motion.inclusive() @@ -761,6 +769,7 @@ impl Motion { WindowTop => window_top(map, point, &text_layout_details, times - 1), WindowMiddle => window_middle(map, point, &text_layout_details), WindowBottom => window_bottom(map, point, &text_layout_details, times - 1), + Jump { line, anchor } => mark::jump_motion(map, *anchor, *line), ZedSearchResult { new_selections, .. } => { // There will be only one selection, as // Search::SelectNextMatch selects a single match. diff --git a/crates/vim/src/normal.rs b/crates/vim/src/normal.rs index e0eb1e46d1..9510e4f050 100644 --- a/crates/vim/src/normal.rs +++ b/crates/vim/src/normal.rs @@ -2,6 +2,7 @@ mod case; mod change; mod delete; mod increment; +pub(crate) mod mark; mod paste; pub(crate) mod repeat; mod scroll; diff --git a/crates/vim/src/normal/mark.rs b/crates/vim/src/normal/mark.rs new file mode 100644 index 0000000000..0c6ff2f137 --- /dev/null +++ b/crates/vim/src/normal/mark.rs @@ -0,0 +1,147 @@ +use std::{ops::Range, sync::Arc}; + +use editor::{ + display_map::{DisplaySnapshot, ToDisplayPoint}, + movement, + scroll::Autoscroll, + Anchor, Bias, DisplayPoint, +}; +use gpui::WindowContext; +use language::SelectionGoal; + +use crate::{ + motion::{self, Motion}, + Vim, +}; + +pub fn create_mark(vim: &mut Vim, text: Arc, tail: bool, cx: &mut WindowContext) { + let Some(anchors) = vim.update_active_editor(cx, |_, editor, _| { + editor + .selections + .disjoint_anchors() + .iter() + .map(|s| if tail { s.tail() } else { s.head() }) + .collect::>() + }) else { + return; + }; + vim.update_state(|state| state.marks.insert(text.to_string(), anchors)); + vim.clear_operator(cx); +} + +pub fn create_mark_after(vim: &mut Vim, text: Arc, cx: &mut WindowContext) { + let Some(anchors) = vim.update_active_editor(cx, |_, editor, cx| { + let (map, selections) = editor.selections.all_display(cx); + selections + .into_iter() + .map(|selection| { + let point = movement::saturating_right(&map, selection.tail()); + map.buffer_snapshot + .anchor_before(point.to_offset(&map, Bias::Left)) + }) + .collect::>() + }) else { + return; + }; + + vim.update_state(|state| state.marks.insert(text.to_string(), anchors)); + vim.clear_operator(cx); +} + +pub fn create_mark_before(vim: &mut Vim, text: Arc, cx: &mut WindowContext) { + let Some(anchors) = vim.update_active_editor(cx, |_, editor, cx| { + let (map, selections) = editor.selections.all_display(cx); + selections + .into_iter() + .map(|selection| { + let point = movement::saturating_left(&map, selection.head()); + map.buffer_snapshot + .anchor_before(point.to_offset(&map, Bias::Left)) + }) + .collect::>() + }) else { + return; + }; + + vim.update_state(|state| state.marks.insert(text.to_string(), anchors)); + vim.clear_operator(cx); +} + +pub fn jump(text: Arc, line: bool, cx: &mut WindowContext) { + let anchors = match &*text { + "{" | "}" => Vim::update(cx, |vim, cx| { + vim.update_active_editor(cx, |_, editor, cx| { + let (map, selections) = editor.selections.all_display(cx); + selections + .into_iter() + .map(|selection| { + let point = if &*text == "{" { + movement::start_of_paragraph(&map, selection.head(), 1) + } else { + movement::end_of_paragraph(&map, selection.head(), 1) + }; + map.buffer_snapshot + .anchor_before(point.to_offset(&map, Bias::Left)) + }) + .collect::>() + }) + }), + _ => Vim::read(cx).state().marks.get(&*text).cloned(), + }; + + Vim::update(cx, |vim, cx| { + vim.pop_operator(cx); + }); + + let Some(anchors) = anchors else { return }; + + let is_active_operator = Vim::read(cx).state().active_operator().is_some(); + if is_active_operator { + if let Some(anchor) = anchors.last() { + motion::motion( + Motion::Jump { + anchor: *anchor, + line, + }, + cx, + ) + } + return; + } else { + Vim::update(cx, |vim, cx| { + vim.update_active_editor(cx, |_, editor, cx| { + let map = editor.snapshot(cx); + let mut ranges: Vec> = Vec::new(); + for mut anchor in anchors { + if line { + let mut point = anchor.to_display_point(&map.display_snapshot); + point = motion::first_non_whitespace(&map.display_snapshot, false, point); + anchor = map + .display_snapshot + .buffer_snapshot + .anchor_before(point.to_point(&map.display_snapshot)); + } + if ranges.last() != Some(&(anchor..anchor)) { + ranges.push(anchor..anchor); + } + } + editor.change_selections(Some(Autoscroll::fit()), cx, |s| { + s.select_anchor_ranges(ranges) + }) + }); + }) + } +} + +pub fn jump_motion( + map: &DisplaySnapshot, + anchor: Anchor, + line: bool, +) -> (DisplayPoint, SelectionGoal) { + let mut point = anchor.to_display_point(map); + if line { + point = motion::first_non_whitespace(map, false, point) + } + + (point, SelectionGoal::None) +} diff --git a/crates/vim/src/state.rs b/crates/vim/src/state.rs index 9ece818b16..ed9861a97a 100644 --- a/crates/vim/src/state.rs +++ b/crates/vim/src/state.rs @@ -59,6 +59,8 @@ pub enum Operator { AddSurrounds { target: Option }, ChangeSurrounds { target: Option }, DeleteSurrounds, + Mark, + Jump { line: bool }, } #[derive(Default, Clone)] @@ -74,6 +76,8 @@ pub struct EditorState { pub operator_stack: Vec, pub replacements: Vec<(Range, String)>, + pub marks: HashMap>, + pub current_tx: Option, pub current_anchor: Option>, pub undo_modes: HashMap, @@ -172,7 +176,10 @@ impl EditorState { } matches!( self.operator_stack.last(), - Some(Operator::FindForward { .. }) | Some(Operator::FindBackward { .. }) + Some(Operator::FindForward { .. }) + | Some(Operator::FindBackward { .. }) + | Some(Operator::Mark) + | Some(Operator::Jump { .. }) ) } @@ -254,6 +261,9 @@ impl Operator { Operator::AddSurrounds { .. } => "ys", Operator::ChangeSurrounds { .. } => "cs", Operator::DeleteSurrounds => "ds", + Operator::Mark => "m", + Operator::Jump { line: true } => "'", + Operator::Jump { line: false } => "`", } } @@ -261,6 +271,8 @@ impl Operator { match self { Operator::Object { .. } | Operator::ChangeSurrounds { target: None } => &["VimObject"], Operator::FindForward { .. } + | Operator::Mark + | Operator::Jump { .. } | Operator::FindBackward { .. } | Operator::Replace | Operator::AddSurrounds { target: Some(_) } diff --git a/crates/vim/src/test.rs b/crates/vim/src/test.rs index 29aefbae76..2d3946e9fb 100644 --- a/crates/vim/src/test.rs +++ b/crates/vim/src/test.rs @@ -1073,3 +1073,133 @@ async fn test_mouse_selection(cx: &mut TestAppContext) { cx.assert_state("one «ˇtwo» three", Mode::Visual) } + +#[gpui::test] +async fn test_lowercase_marks(cx: &mut TestAppContext) { + let mut cx = NeovimBackedTestContext::new(cx).await; + + cx.set_shared_state("line one\nline ˇtwo\nline three").await; + cx.simulate_shared_keystrokes(["m", "a", "l", "'", "a"]) + .await; + cx.assert_shared_state("line one\nˇline two\nline three") + .await; + cx.simulate_shared_keystrokes(["`", "a"]).await; + cx.assert_shared_state("line one\nline ˇtwo\nline three") + .await; + + cx.simulate_shared_keystrokes(["^", "d", "`", "a"]).await; + cx.assert_shared_state("line one\nˇtwo\nline three").await; +} + +#[gpui::test] +async fn test_lt_gt_marks(cx: &mut TestAppContext) { + let mut cx = NeovimBackedTestContext::new(cx).await; + + cx.set_shared_state(indoc!( + " + Line one + Line two + Line ˇthree + Line four + Line five + " + )) + .await; + + cx.simulate_shared_keystrokes(["v", "j", "escape", "k", "k"]) + .await; + + cx.simulate_shared_keystrokes(["'", "<"]).await; + cx.assert_shared_state(indoc!( + " + Line one + Line two + ˇLine three + Line four + Line five + " + )) + .await; + + cx.simulate_shared_keystrokes(["`", "<"]).await; + cx.assert_shared_state(indoc!( + " + Line one + Line two + Line ˇthree + Line four + Line five + " + )) + .await; + + cx.simulate_shared_keystrokes(["'", ">"]).await; + cx.assert_shared_state(indoc!( + " + Line one + Line two + Line three + ˇLine four + Line five + " + )) + .await; + + cx.simulate_shared_keystrokes(["`", ">"]).await; + cx.assert_shared_state(indoc!( + " + Line one + Line two + Line three + Line ˇfour + Line five + " + )) + .await; +} + +#[gpui::test] +async fn test_caret_mark(cx: &mut TestAppContext) { + let mut cx = NeovimBackedTestContext::new(cx).await; + + cx.set_shared_state(indoc!( + " + Line one + Line two + Line three + ˇLine four + Line five + " + )) + .await; + + cx.simulate_shared_keystrokes([ + "c", "w", "shift-s", "t", "r", "a", "i", "g", "h", "t", " ", "t", "h", "i", "n", "g", + "escape", "j", "j", + ]) + .await; + + cx.simulate_shared_keystrokes(["'", "^"]).await; + cx.assert_shared_state(indoc!( + " + Line one + Line two + Line three + ˇStraight thing four + Line five + " + )) + .await; + + cx.simulate_shared_keystrokes(["`", "^"]).await; + cx.assert_shared_state(indoc!( + " + Line one + Line two + Line three + Straight thingˇ four + Line five + " + )) + .await; +} diff --git a/crates/vim/src/utils.rs b/crates/vim/src/utils.rs index 3af455a309..1888b303eb 100644 --- a/crates/vim/src/utils.rs +++ b/crates/vim/src/utils.rs @@ -39,6 +39,24 @@ fn copy_selections_content_internal( let mut text = String::new(); let mut clipboard_selections = Vec::with_capacity(selections.len()); let mut ranges_to_highlight = Vec::new(); + + vim.update_state(|state| { + state.marks.insert( + "[".to_string(), + selections + .iter() + .map(|s| buffer.anchor_before(s.start)) + .collect(), + ); + state.marks.insert( + "]".to_string(), + selections + .iter() + .map(|s| buffer.anchor_after(s.end)) + .collect(), + ) + }); + { let mut is_first = true; for selection in selections.iter() { diff --git a/crates/vim/src/vim.rs b/crates/vim/src/vim.rs index 95645f83de..20b07c8046 100644 --- a/crates/vim/src/vim.rs +++ b/crates/vim/src/vim.rs @@ -30,7 +30,10 @@ use gpui::{ use language::{CursorShape, Point, SelectionGoal, TransactionId}; pub use mode_indicator::ModeIndicator; use motion::Motion; -use normal::normal_replace; +use normal::{ + mark::{create_mark, create_mark_after, create_mark_before}, + normal_replace, +}; use replace::multi_replace; use schemars::JsonSchema; use serde::Deserialize; @@ -194,7 +197,9 @@ fn observe_keystrokes(keystroke_event: &KeystrokeEvent, cx: &mut WindowContext) | Operator::Replace | Operator::AddSurrounds { .. } | Operator::ChangeSurrounds { .. } - | Operator::DeleteSurrounds, + | Operator::DeleteSurrounds + | Operator::Mark + | Operator::Jump { .. }, ) => {} Some(_) => { vim.clear_operator(cx); @@ -418,6 +423,10 @@ impl Vim { // Sync editor settings like clip mode self.sync_vim_settings(cx); + if mode != Mode::Insert && last_mode == Mode::Insert { + create_mark_after(self, "^".into(), cx) + } + if leave_selections { return; } @@ -614,6 +623,7 @@ impl Vim { let is_multicursor = editor.read(cx).selections.count() > 1; let state = self.state(); + let mut is_visual = state.mode.is_visual(); if state.mode == Mode::Insert && state.current_tx.is_some() { if state.current_anchor.is_none() { self.update_state(|state| state.current_anchor = Some(newest)); @@ -630,11 +640,18 @@ impl Vim { } else { self.switch_mode(Mode::Visual, false, cx) } + is_visual = true; } else if newest.start == newest.end && !is_multicursor && [Mode::Visual, Mode::VisualLine, Mode::VisualBlock].contains(&state.mode) { - self.switch_mode(Mode::Normal, true, cx) + self.switch_mode(Mode::Normal, true, cx); + is_visual = false; + } + + if is_visual { + create_mark_before(self, ">".into(), cx); + create_mark(self, "<".into(), true, cx) } } @@ -706,6 +723,10 @@ impl Vim { } _ => Vim::update(cx, |vim, cx| vim.clear_operator(cx)), }, + Some(Operator::Mark) => Vim::update(cx, |vim, cx| { + normal::mark::create_mark(vim, text, false, cx) + }), + Some(Operator::Jump { line }) => normal::mark::jump(text, line, cx), _ => match Vim::read(cx).state().mode { Mode::Replace => multi_replace(text, cx), _ => {} diff --git a/crates/vim/test_data/test_builtin_marks.json b/crates/vim/test_data/test_builtin_marks.json new file mode 100644 index 0000000000..0d05385960 --- /dev/null +++ b/crates/vim/test_data/test_builtin_marks.json @@ -0,0 +1,36 @@ +{"Put":{"state":"Line one\nLine two\nLine ˇthree\nLine four\nLine five\n"}} +{"Key":"v"} +{"Key":"j"} +{"Key":"escape"} +{"Key":"k"} +{"Key":"k"} +{"Key":"'"} +{"Key":"<"} +{"Get":{"state":"Line one\nLine two\nˇLine three\nLine four\nLine five\n","mode":"Normal"}} +{"Key":"`"} +{"Key":"<"} +{"Get":{"state":"Line one\nLine two\nLine ˇthree\nLine four\nLine five\n","mode":"Normal"}} +{"Key":"'"} +{"Key":">"} +{"Get":{"state":"Line one\nLine two\nLine three\nˇLine four\nLine five\n","mode":"Normal"}} +{"Key":"`"} +{"Key":">"} +{"Get":{"state":"Line one\nLine two\nLine three\nLine ˇfour\nLine five\n","mode":"Normal"}} +{"Key":"g"} +{"Key":"g"} +{"Key":"^"} +{"Key":"j"} +{"Key":"j"} +{"Key":"l"} +{"Key":"l"} +{"Key":"c"} +{"Key":"e"} +{"Key":"k"} +{"Key":"e"} +{"Key":"escape"} +{"Key":"'"} +{"Key":"."} +{"Get":{"state":"Line one\nLine two\nˇLike three\nLine four\nLine five\n","mode":"Normal"}} +{"Key":"`"} +{"Key":"."} +{"Get":{"state":"Line one\nLine two\nLiˇke three\nLine four\nLine five\n","mode":"Normal"}} diff --git a/crates/vim/test_data/test_caret_mark.json b/crates/vim/test_data/test_caret_mark.json new file mode 100644 index 0000000000..01edb7e836 --- /dev/null +++ b/crates/vim/test_data/test_caret_mark.json @@ -0,0 +1,26 @@ +{"Put":{"state":"Line one\nLine two\nLine three\nˇLine four\nLine five\n"}} +{"Key":"c"} +{"Key":"w"} +{"Key":"shift-s"} +{"Key":"t"} +{"Key":"r"} +{"Key":"a"} +{"Key":"i"} +{"Key":"g"} +{"Key":"h"} +{"Key":"t"} +{"Key":" "} +{"Key":"t"} +{"Key":"h"} +{"Key":"i"} +{"Key":"n"} +{"Key":"g"} +{"Key":"escape"} +{"Key":"j"} +{"Key":"j"} +{"Key":"'"} +{"Key":"^"} +{"Get":{"state":"Line one\nLine two\nLine three\nˇStraight thing four\nLine five\n","mode":"Normal"}} +{"Key":"`"} +{"Key":"^"} +{"Get":{"state":"Line one\nLine two\nLine three\nStraight thingˇ four\nLine five\n","mode":"Normal"}} diff --git a/crates/vim/test_data/test_lowercase_marks.json b/crates/vim/test_data/test_lowercase_marks.json new file mode 100644 index 0000000000..ff02c58728 --- /dev/null +++ b/crates/vim/test_data/test_lowercase_marks.json @@ -0,0 +1,15 @@ +{"Put":{"state":"line one\nline ˇtwo\nline three"}} +{"Key":"m"} +{"Key":"a"} +{"Key":"l"} +{"Key":"'"} +{"Key":"a"} +{"Get":{"state":"line one\nˇline two\nline three","mode":"Normal"}} +{"Key":"`"} +{"Key":"a"} +{"Get":{"state":"line one\nline ˇtwo\nline three","mode":"Normal"}} +{"Key":"^"} +{"Key":"d"} +{"Key":"`"} +{"Key":"a"} +{"Get":{"state":"line one\nˇtwo\nline three","mode":"Normal"}} diff --git a/crates/vim/test_data/test_lt_gt_marks.json b/crates/vim/test_data/test_lt_gt_marks.json new file mode 100644 index 0000000000..acd750dadd --- /dev/null +++ b/crates/vim/test_data/test_lt_gt_marks.json @@ -0,0 +1,18 @@ +{"Put":{"state":"Line one\nLine two\nLine ˇthree\nLine four\nLine five\n"}} +{"Key":"v"} +{"Key":"j"} +{"Key":"escape"} +{"Key":"k"} +{"Key":"k"} +{"Key":"'"} +{"Key":"<"} +{"Get":{"state":"Line one\nLine two\nˇLine three\nLine four\nLine five\n","mode":"Normal"}} +{"Key":"`"} +{"Key":"<"} +{"Get":{"state":"Line one\nLine two\nLine ˇthree\nLine four\nLine five\n","mode":"Normal"}} +{"Key":"'"} +{"Key":">"} +{"Get":{"state":"Line one\nLine two\nLine three\nˇLine four\nLine five\n","mode":"Normal"}} +{"Key":"`"} +{"Key":">"} +{"Get":{"state":"Line one\nLine two\nLine three\nLine ˇfour\nLine five\n","mode":"Normal"}} diff --git a/crates/vim/test_data/test_marks.json b/crates/vim/test_data/test_marks.json new file mode 100644 index 0000000000..ff02c58728 --- /dev/null +++ b/crates/vim/test_data/test_marks.json @@ -0,0 +1,15 @@ +{"Put":{"state":"line one\nline ˇtwo\nline three"}} +{"Key":"m"} +{"Key":"a"} +{"Key":"l"} +{"Key":"'"} +{"Key":"a"} +{"Get":{"state":"line one\nˇline two\nline three","mode":"Normal"}} +{"Key":"`"} +{"Key":"a"} +{"Get":{"state":"line one\nline ˇtwo\nline three","mode":"Normal"}} +{"Key":"^"} +{"Key":"d"} +{"Key":"`"} +{"Key":"a"} +{"Get":{"state":"line one\nˇtwo\nline three","mode":"Normal"}} diff --git a/crates/vim/test_data/test_period_mark.json b/crates/vim/test_data/test_period_mark.json new file mode 100644 index 0000000000..6d3acea83c --- /dev/null +++ b/crates/vim/test_data/test_period_mark.json @@ -0,0 +1,14 @@ +{"Put":{"state":"Line one\nLine two\nLiˇne three\nLine four\nLine five\n"}} +{"Key":"c"} +{"Key":"e"} +{"Key":"k"} +{"Key":"e"} +{"Key":"escape"} +{"Key":"j"} +{"Key":"j"} +{"Key":"'"} +{"Key":"."} +{"Get":{"state":"Line one\nLine two\nˇLike three\nLine four\nLine five\n","mode":"Normal"}} +{"Key":"`"} +{"Key":"."} +{"Get":{"state":"Line one\nLine two\nLiˇke three\nLine four\nLine five\n","mode":"Normal"}}