Fix visual objects

Conrad Irwin created

Adds 'a'/'i' in visual mode

Change summary

assets/keymaps/vim.json                      | 20 +++++++
crates/vim/src/object.rs                     | 29 +++++++++++
crates/vim/src/visual.rs                     | 54 ++++++++++++++++-----
crates/vim/test_data/test_visual_object.json | 19 +++++++
4 files changed, 107 insertions(+), 15 deletions(-)

Detailed changes

assets/keymaps/vim.json 🔗

@@ -366,7 +366,7 @@
     }
   },
   {
-    "context": "Editor && vim_mode == visual && !VimWaiting",
+    "context": "Editor && vim_mode == visual && !VimWaiting && !VimObject",
     "bindings": {
       "u": "editor::Undo",
       "o": "vim::OtherEnd",
@@ -400,7 +400,23 @@
         "Normal"
       ],
       ">": "editor::Indent",
-      "<": "editor::Outdent"
+      "<": "editor::Outdent",
+      "i": [
+        "vim::PushOperator",
+        {
+          "Object": {
+            "around": false
+          }
+        }
+      ],
+      "a": [
+        "vim::PushOperator",
+        {
+          "Object": {
+            "around": true
+          }
+        }
+      ],
     }
   },
   {

crates/vim/src/object.rs 🔗

@@ -84,6 +84,35 @@ impl Object {
             | Object::SquareBrackets => true,
         }
     }
+
+    pub fn always_expands_both_ways(self) -> bool {
+        match self {
+            Object::Word { .. } | Object::Sentence => false,
+            Object::Quotes
+            | Object::BackQuotes
+            | Object::DoubleQuotes
+            | Object::Parentheses
+            | Object::SquareBrackets
+            | Object::CurlyBrackets
+            | Object::AngleBrackets => true,
+        }
+    }
+
+    pub fn target_visual_mode(self, current_mode: Mode) -> Mode {
+        match self {
+            Object::Word { .. } if current_mode == Mode::VisualLine => Mode::Visual,
+            Object::Word { .. } => current_mode,
+            Object::Sentence
+            | Object::Quotes
+            | Object::BackQuotes
+            | Object::DoubleQuotes
+            | Object::Parentheses
+            | Object::SquareBrackets
+            | Object::CurlyBrackets
+            | Object::AngleBrackets => Mode::Visual,
+        }
+    }
+
     pub fn range(
         self,
         map: &DisplaySnapshot,

crates/vim/src/visual.rs 🔗

@@ -1,4 +1,4 @@
-use std::{borrow::Cow, sync::Arc};
+use std::{borrow::Cow, cmp, sync::Arc};
 
 use collections::HashMap;
 use editor::{
@@ -198,6 +198,11 @@ pub fn visual_object(object: Object, cx: &mut WindowContext) {
     Vim::update(cx, |vim, cx| {
         if let Some(Operator::Object { around }) = vim.active_operator() {
             vim.pop_operator(cx);
+            let current_mode = vim.state().mode;
+            let target_mode = object.target_visual_mode(current_mode);
+            if target_mode != current_mode {
+                vim.switch_mode(target_mode, true, cx);
+            }
 
             vim.update_active_editor(cx, |editor, cx| {
                 editor.change_selections(Some(Autoscroll::fit()), cx, |s| {
@@ -213,20 +218,21 @@ pub fn visual_object(object: Object, cx: &mut WindowContext) {
 
                         if let Some(range) = object.range(map, head, around) {
                             if !range.is_empty() {
-                                let expand_both_ways = if selection.is_empty() {
-                                    true
-                                // contains only one character
-                                } else if let Some((_, start)) =
-                                    map.reverse_chars_at(selection.end).next()
-                                {
-                                    selection.start == start
-                                } else {
-                                    false
-                                };
+                                let expand_both_ways =
+                                    if object.always_expands_both_ways() || selection.is_empty() {
+                                        true
+                                        // contains only one character
+                                    } else if let Some((_, start)) =
+                                        map.reverse_chars_at(selection.end).next()
+                                    {
+                                        selection.start == start
+                                    } else {
+                                        false
+                                    };
 
                                 if expand_both_ways {
-                                    selection.start = range.start;
-                                    selection.end = range.end;
+                                    selection.start = cmp::min(selection.start, range.start);
+                                    selection.end = cmp::max(selection.end, range.end);
                                 } else if selection.reversed {
                                     selection.start = range.start;
                                 } else {
@@ -1030,6 +1036,28 @@ mod test {
         .await;
     }
 
+    #[gpui::test]
+    async fn test_visual_object(cx: &mut gpui::TestAppContext) {
+        let mut cx = NeovimBackedTestContext::new(cx).await;
+
+        cx.set_shared_state("hello (in [parˇens] o)").await;
+        cx.simulate_shared_keystrokes(["ctrl-v", "l"]).await;
+        cx.simulate_shared_keystrokes(["a", "]"]).await;
+        cx.assert_shared_state("hello (in «[parens]ˇ» o)").await;
+        assert_eq!(cx.mode(), Mode::Visual);
+        cx.simulate_shared_keystrokes(["i", "("]).await;
+        cx.assert_shared_state("hello («in [parens] oˇ»)").await;
+
+        cx.set_shared_state("hello in a wˇord again.").await;
+        cx.simulate_shared_keystrokes(["ctrl-v", "l", "i", "w"])
+            .await;
+        cx.assert_shared_state("hello in a w«ordˇ» again.").await;
+        assert_eq!(cx.mode(), Mode::VisualBlock);
+        cx.simulate_shared_keystrokes(["o", "a", "s"]).await;
+        cx.assert_shared_state("«ˇhello in a word» again.").await;
+        assert_eq!(cx.mode(), Mode::Visual);
+    }
+
     #[gpui::test]
     async fn test_mode_across_command(cx: &mut gpui::TestAppContext) {
         let mut cx = VimTestContext::new(cx, true).await;

crates/vim/test_data/test_visual_object.json 🔗

@@ -0,0 +1,19 @@
+{"Put":{"state":"hello (in [parˇens] o)"}}
+{"Key":"ctrl-v"}
+{"Key":"l"}
+{"Key":"a"}
+{"Key":"]"}
+{"Get":{"state":"hello (in «[parens]ˇ» o)","mode":"Visual"}}
+{"Key":"i"}
+{"Key":"("}
+{"Get":{"state":"hello («in [parens] oˇ»)","mode":"Visual"}}
+{"Put":{"state":"hello in a wˇord again."}}
+{"Key":"ctrl-v"}
+{"Key":"l"}
+{"Key":"i"}
+{"Key":"w"}
+{"Get":{"state":"hello in a w«ordˇ» again.","mode":"VisualBlock"}}
+{"Key":"o"}
+{"Key":"a"}
+{"Key":"s"}
+{"Get":{"state":"«ˇhello in a word» again.","mode":"VisualBlock"}}