vim: Surround in visual mode (#13347)

Benjamin Davies created

Adds support for surrounding text in visual/visual-line/visual-block
mode by re-using the `AddSurrounds` operator. There is no default
binding though so the user must follow the instructions to enable it.

Note that the behaviour varies slightly for the visual-line and
visual-block modes. In visual-line mode the surrounds are placed on
separate lines (the vim-surround extension also indents the contents but
I opted not to as that behaviour is less important with the use of code
formatters). In visual-block mode each of the selected regions is
surrounded and the cursor returns to the beginning of the selection
after the action is complete.

Release Notes:

- Added action to surround text in visual mode (no default binding).

Fixes #13122

Change summary

crates/vim/src/state.rs     |   5 +
crates/vim/src/surrounds.rs | 149 +++++++++++++++++++++++++++++++++++++-
crates/vim/src/vim.rs       |   6 +
docs/src/vim.md             |  16 ++++
4 files changed, 167 insertions(+), 9 deletions(-)

Detailed changes

crates/vim/src/state.rs 🔗

@@ -287,7 +287,10 @@ impl EditorState {
                 .unwrap_or_else(|| "none"),
         );
 
-        if self.mode == Mode::Replace {
+        if self.mode == Mode::Replace
+            || (matches!(active_operator, Some(Operator::AddSurrounds { .. }))
+                && self.mode.is_visual())
+        {
             context.add("VimWaiting");
         }
         context

crates/vim/src/surrounds.rs 🔗

@@ -9,10 +9,12 @@ use gpui::WindowContext;
 use language::BracketPair;
 use serde::Deserialize;
 use std::sync::Arc;
+
 #[derive(Clone, Debug, PartialEq, Eq)]
 pub enum SurroundsType {
     Motion(Motion),
     Object(Object),
+    Selection,
 }
 
 // This exists so that we can have Deserialize on Operators, but not on Motions.
@@ -29,6 +31,7 @@ pub fn add_surrounds(text: Arc<str>, target: SurroundsType, cx: &mut WindowConte
     Vim::update(cx, |vim, cx| {
         vim.stop_recording();
         let count = vim.take_count(cx);
+        let mode = vim.state().mode;
         vim.update_active_editor(cx, |_, editor, cx| {
             let text_layout_details = editor.text_layout_details(cx);
             editor.transact(cx, |editor, cx| {
@@ -84,19 +87,25 @@ pub fn add_surrounds(text: Arc<str>, target: SurroundsType, cx: &mut WindowConte
                                 });
                             range
                         }
+                        SurroundsType::Selection => Some(selection.range()),
                     };
 
                     if let Some(range) = range {
                         let start = range.start.to_offset(&display_map, Bias::Right);
                         let end = range.end.to_offset(&display_map, Bias::Left);
-                        let start_cursor_str =
-                            format!("{}{}", pair.start, if surround { " " } else { "" });
-                        let close_cursor_str =
-                            format!("{}{}", if surround { " " } else { "" }, pair.end);
+                        let (start_cursor_str, end_cursor_str) = if mode == Mode::VisualLine {
+                            (format!("{}\n", pair.start), format!("{}\n", pair.end))
+                        } else {
+                            let maybe_space = if surround { " " } else { "" };
+                            (
+                                format!("{}{}", pair.start, maybe_space),
+                                format!("{}{}", maybe_space, pair.end),
+                            )
+                        };
                         let start_anchor = display_map.buffer_snapshot.anchor_before(start);
 
                         edits.push((start..start, start_cursor_str));
-                        edits.push((end..end, close_cursor_str));
+                        edits.push((end..end, end_cursor_str));
                         anchors.push(start_anchor..start_anchor);
                     } else {
                         let start_anchor = display_map
@@ -111,7 +120,11 @@ pub fn add_surrounds(text: Arc<str>, target: SurroundsType, cx: &mut WindowConte
                 });
                 editor.set_clip_at_line_ends(true, cx);
                 editor.change_selections(Some(Autoscroll::fit()), cx, |s| {
-                    s.select_anchor_ranges(anchors)
+                    if mode == Mode::VisualBlock {
+                        s.select_anchor_ranges(anchors.into_iter().take(1))
+                    } else {
+                        s.select_anchor_ranges(anchors)
+                    }
                 });
             });
         });
@@ -530,9 +543,14 @@ fn object_to_bracket_pair(object: Object) -> Option<BracketPair> {
 
 #[cfg(test)]
 mod test {
+    use gpui::KeyBinding;
     use indoc::indoc;
 
-    use crate::{state::Mode, test::VimTestContext};
+    use crate::{
+        state::{Mode, Operator},
+        test::VimTestContext,
+        PushOperator,
+    };
 
     #[gpui::test]
     async fn test_add_surrounds(cx: &mut gpui::TestAppContext) {
@@ -682,6 +700,123 @@ mod test {
         );
     }
 
+    #[gpui::test]
+    async fn test_add_surrounds_visual(cx: &mut gpui::TestAppContext) {
+        let mut cx = VimTestContext::new(cx, true).await;
+
+        cx.update(|cx| {
+            cx.bind_keys([KeyBinding::new(
+                "shift-s",
+                PushOperator(Operator::AddSurrounds { target: None }),
+                Some("vim_mode == visual"),
+            )])
+        });
+
+        // test add surrounds with arround
+        cx.set_state(
+            indoc! {"
+            The quˇick brown
+            fox jumps over
+            the lazy dog."},
+            Mode::Normal,
+        );
+        cx.simulate_keystrokes("v i w shift-s {");
+        cx.assert_state(
+            indoc! {"
+            The ˇ{ quick } brown
+            fox jumps over
+            the lazy dog."},
+            Mode::Normal,
+        );
+
+        // test add surrounds not with arround
+        cx.set_state(
+            indoc! {"
+            The quˇick brown
+            fox jumps over
+            the lazy dog."},
+            Mode::Normal,
+        );
+        cx.simulate_keystrokes("v i w shift-s }");
+        cx.assert_state(
+            indoc! {"
+            The ˇ{quick} brown
+            fox jumps over
+            the lazy dog."},
+            Mode::Normal,
+        );
+
+        // test add surrounds with motion
+        cx.set_state(
+            indoc! {"
+            The quˇick brown
+            fox jumps over
+            the lazy dog."},
+            Mode::Normal,
+        );
+        cx.simulate_keystrokes("v e shift-s }");
+        cx.assert_state(
+            indoc! {"
+            The quˇ{ick} brown
+            fox jumps over
+            the lazy dog."},
+            Mode::Normal,
+        );
+
+        // test add surrounds with multi cursor
+        cx.set_state(
+            indoc! {"
+            The quˇick brown
+            fox jumps over
+            the laˇzy dog."},
+            Mode::Normal,
+        );
+        cx.simulate_keystrokes("v i w shift-s '");
+        cx.assert_state(
+            indoc! {"
+            The ˇ'quick' brown
+            fox jumps over
+            the ˇ'lazy' dog."},
+            Mode::Normal,
+        );
+
+        // test add surrounds with visual block
+        cx.set_state(
+            indoc! {"
+            The quˇick brown
+            fox jumps over
+            the lazy dog."},
+            Mode::Normal,
+        );
+        cx.simulate_keystrokes("ctrl-v i w j j shift-s '");
+        cx.assert_state(
+            indoc! {"
+            The ˇ'quick' brown
+            fox 'jumps' over
+            the 'lazy 'dog."},
+            Mode::Normal,
+        );
+
+        // test add surrounds with visual line
+        cx.set_state(
+            indoc! {"
+            The quˇick brown
+            fox jumps over
+            the lazy dog."},
+            Mode::Normal,
+        );
+        cx.simulate_keystrokes("j shift-v shift-s '");
+        cx.assert_state(
+            indoc! {"
+            The quick brown
+            ˇ'
+            fox jumps over
+            '
+            the lazy dog."},
+            Mode::Normal,
+        );
+    }
+
     #[gpui::test]
     async fn test_delete_surrounds(cx: &mut gpui::TestAppContext) {
         let mut cx = VimTestContext::new(cx, true).await;

crates/vim/src/vim.rs 🔗

@@ -39,7 +39,7 @@ use serde_derive::Serialize;
 use settings::{update_settings_file, Settings, SettingsSources, SettingsStore};
 use state::{EditorState, Mode, Operator, RecordedSelection, Register, WorkspaceState};
 use std::{ops::Range, sync::Arc};
-use surrounds::{add_surrounds, change_surrounds, delete_surrounds};
+use surrounds::{add_surrounds, change_surrounds, delete_surrounds, SurroundsType};
 use ui::BorrowAppContext;
 use visual::{visual_block_motion, visual_replace};
 use workspace::{self, Workspace};
@@ -861,6 +861,10 @@ impl Vim {
                         Vim::update(cx, |vim, cx| vim.clear_operator(cx));
                     }
                 }
+                Mode::Visual | Mode::VisualLine | Mode::VisualBlock => {
+                    add_surrounds(text, SurroundsType::Selection, cx);
+                    Vim::update(cx, |vim, cx| vim.clear_operator(cx));
+                }
                 _ => Vim::update(cx, |vim, cx| vim.clear_operator(cx)),
             },
             Some(Operator::ChangeSurrounds { target }) => match Vim::read(cx).state().mode {

docs/src/vim.md 🔗

@@ -293,6 +293,22 @@ Subword motion is not enabled by default. To enable it, add these bindings to yo
   },
 ```
 
+Surrounding the selection in visual mode is also not enabled by default (`shift-s` normally behaves like `c`). To enable it, add the following to your keymap.
+
+```json
+  {
+    "context": "Editor && vim_mode == visual && !VimWaiting && !VimObject",
+    "bindings": {
+      "shift-s": [
+        "vim::PushOperator",
+        {
+          "AddSurrounds": {}
+        }
+      ]
+    }
+  }
+```
+
 ## Supported plugins
 
 Zed has nascent support for some Vim plugins: