Fix select in Helix mode (#38117)

Romans Malinovskis created

Hotfixes issue I have introduced in #37748.

Without this, helix mode select not working at all in `main` branch.

Release Notes:

- N/A

Change summary

assets/keymaps/vim.json  |  2 
crates/vim/src/helix.rs  | 74 ++++++++++++++++++++++++++++++++++++++++++
crates/vim/src/motion.rs |  5 +-
3 files changed, 77 insertions(+), 4 deletions(-)

Detailed changes

assets/keymaps/vim.json 🔗

@@ -328,7 +328,7 @@
   {
     "context": "vim_mode == helix_select",
     "bindings": {
-      "escape": "vim::NormalBefore",
+      "v": "vim::NormalBefore",
       ";": "vim::HelixCollapseSelection",
       "~": "vim::ChangeCase",
       "ctrl-a": "vim::Increment",

crates/vim/src/helix.rs 🔗

@@ -53,6 +53,35 @@ impl Vim {
         self.helix_move_cursor(motion, times, window, cx);
     }
 
+    pub fn helix_select_motion(
+        &mut self,
+        motion: Motion,
+        times: Option<usize>,
+        window: &mut Window,
+        cx: &mut Context<Self>,
+    ) {
+        self.update_editor(cx, |_, editor, cx| {
+            let text_layout_details = editor.text_layout_details(window);
+            editor.change_selections(Default::default(), window, cx, |s| {
+                s.move_with(|map, selection| {
+                    let current_head = selection.head();
+
+                    let Some((new_head, goal)) = motion.move_point(
+                        map,
+                        current_head,
+                        selection.goal,
+                        times,
+                        &text_layout_details,
+                    ) else {
+                        return;
+                    };
+
+                    selection.set_head(new_head, goal);
+                })
+            });
+        });
+    }
+
     /// Updates all selections based on where the cursors are.
     fn helix_new_selections(
         &mut self,
@@ -1033,4 +1062,49 @@ mod test {
             Mode::HelixNormal,
         );
     }
+
+    #[gpui::test]
+    async fn test_helix_select_mode_motion(cx: &mut gpui::TestAppContext) {
+        let mut cx = VimTestContext::new(cx, true).await;
+
+        assert_eq!(cx.mode(), Mode::Normal);
+        cx.enable_helix();
+
+        cx.set_state("ˇhello", Mode::HelixNormal);
+        cx.simulate_keystrokes("l v l l");
+        cx.assert_state("h«ellˇ»o", Mode::HelixSelect);
+    }
+
+    #[gpui::test]
+    async fn test_helix_select_mode_motion_multiple_cursors(cx: &mut gpui::TestAppContext) {
+        let mut cx = VimTestContext::new(cx, true).await;
+
+        assert_eq!(cx.mode(), Mode::Normal);
+        cx.enable_helix();
+
+        // Start with multiple cursors (no selections)
+        cx.set_state("ˇhello\nˇworld", Mode::HelixNormal);
+
+        // Enter select mode and move right twice
+        cx.simulate_keystrokes("v l l");
+
+        // Each cursor should independently create and extend its own selection
+        cx.assert_state("«helˇ»lo\n«worˇ»ld", Mode::HelixSelect);
+    }
+
+    #[gpui::test]
+    async fn test_helix_select_word_motions(cx: &mut gpui::TestAppContext) {
+        let mut cx = VimTestContext::new(cx, true).await;
+
+        cx.set_state("ˇone two", Mode::Normal);
+        cx.simulate_keystrokes("v w");
+        cx.assert_state("«one tˇ»wo", Mode::Visual);
+
+        // In Vim, this selects "t". In helix selections stops just before "t"
+
+        cx.enable_helix();
+        cx.set_state("ˇone two", Mode::HelixNormal);
+        cx.simulate_keystrokes("v w");
+        cx.assert_state("«one ˇ»two", Mode::HelixSelect);
+    }
 }

crates/vim/src/motion.rs 🔗

@@ -726,9 +726,8 @@ impl Vim {
                 self.visual_motion(motion, count, window, cx)
             }
 
-            Mode::HelixNormal | Mode::HelixSelect => {
-                self.helix_normal_motion(motion, count, window, cx)
-            }
+            Mode::HelixNormal => self.helix_normal_motion(motion, count, window, cx),
+            Mode::HelixSelect => self.helix_select_motion(motion, count, window, cx),
         }
         self.clear_operator(window, cx);
         if let Some(operator) = waiting_operator {