Helix Select Mode (#37748)

Romans Malinovskis , fantacell , and Conrad Irwin created

Please credit @eliaperantoni, for the original PR (#34136).
Merge after (#34060) to avoid conflicts.

Closes https://github.com/zed-industries/zed/issues/33838
Closes https://github.com/zed-industries/zed/issues/33906

Release Notes:
- Helix will no longer sometimes fall out into "normal" mode, will
remain in "helix normal" (example: vv)
- Added dedicated "helix select" mode that can be targeted by
keybindings

Known issues:
- [ ] Helix motion, especially surround-add will not properly work in
visual mode, as it won't call `helix_move_cursor`. It is possible
however to respect self.mode in change_selection now.
- [ ] Some operations, such as `Ctrl+A` (increment) or `>` (indent) will
collapse selection also. I haven't found a way to avoid it.

---------

Co-authored-by: fantacell <ghub@giggo.de>
Co-authored-by: Conrad Irwin <conrad.irwin@gmail.com>

Change summary

assets/keymaps/vim.json                  | 28 +++++++++++++++++++++
crates/vim/src/helix.rs                  | 33 +++++++++++--------------
crates/vim/src/insert.rs                 |  6 ---
crates/vim/src/motion.rs                 |  6 +++-
crates/vim/src/normal/convert.rs         |  2 
crates/vim/src/object.rs                 |  2 
crates/vim/src/state.rs                  |  4 ++
crates/vim/src/test/neovim_connection.rs |  2 
crates/vim/src/vim.rs                    | 31 ++++++++++++++++-------
9 files changed, 73 insertions(+), 41 deletions(-)

Detailed changes

assets/keymaps/vim.json πŸ”—

@@ -325,6 +325,27 @@
       "\"": "vim::PushRegister"
     }
   },
+  {
+    "context": "vim_mode == helix_select",
+    "bindings": {
+      "escape": "vim::NormalBefore",
+      ";": "vim::HelixCollapseSelection",
+      "~": "vim::ChangeCase",
+      "ctrl-a": "vim::Increment",
+      "ctrl-x": "vim::Decrement",
+      "shift-j": "vim::JoinLines",
+      "i": "vim::InsertBefore",
+      "a": "vim::InsertAfter",
+      "p": "vim::Paste",
+      "u": "vim::Undo",
+      "r": "vim::PushReplace",
+      "s": "vim::Substitute",
+      "ctrl-pageup": "pane::ActivatePreviousItem",
+      "ctrl-pagedown": "pane::ActivateNextItem",
+      ".": "vim::Repeat",
+      "alt-.": "vim::RepeatFind"
+    }
+  },
   {
     "context": "vim_mode == insert",
     "bindings": {
@@ -396,7 +417,12 @@
     "bindings": {
       "i": "vim::HelixInsert",
       "a": "vim::HelixAppend",
-      "ctrl-[": "editor::Cancel",
+      "ctrl-[": "editor::Cancel"
+    }
+  },
+  {
+    "context": "(vim_mode == helix_normal || vim_mode == helix_select) && !menu",
+    "bindings": {
       ";": "vim::HelixCollapseSelection",
       ":": "command_palette::Toggle",
       "m": "vim::PushHelixMatch",

crates/vim/src/helix.rs πŸ”—

@@ -6,7 +6,7 @@ use editor::display_map::DisplaySnapshot;
 use editor::{
     DisplayPoint, Editor, HideMouseCursorOrigin, SelectionEffects, ToOffset, ToPoint, movement,
 };
-use gpui::{Action, actions};
+use gpui::actions;
 use gpui::{Context, Window};
 use language::{CharClassifier, CharKind, Point};
 use text::{Bias, SelectionGoal};
@@ -21,8 +21,6 @@ use crate::{
 actions!(
     vim,
     [
-        /// Switches to normal mode after the cursor (Helix-style).
-        HelixNormalAfter,
         /// Yanks the current selection or character if no selection.
         HelixYank,
         /// Inserts at the beginning of the selection.
@@ -37,7 +35,6 @@ actions!(
 );
 
 pub fn register(editor: &mut Editor, cx: &mut Context<Vim>) {
-    Vim::action(editor, cx, Vim::helix_normal_after);
     Vim::action(editor, cx, Vim::helix_select_lines);
     Vim::action(editor, cx, Vim::helix_insert);
     Vim::action(editor, cx, Vim::helix_append);
@@ -46,21 +43,6 @@ pub fn register(editor: &mut Editor, cx: &mut Context<Vim>) {
 }
 
 impl Vim {
-    pub fn helix_normal_after(
-        &mut self,
-        action: &HelixNormalAfter,
-        window: &mut Window,
-        cx: &mut Context<Self>,
-    ) {
-        if self.active_operator().is_some() {
-            self.operator_stack.clear();
-            self.sync_vim_settings(window, cx);
-            return;
-        }
-        self.stop_recording_immediately(action.boxed_clone(), cx);
-        self.switch_mode(Mode::HelixNormal, false, window, cx);
-    }
-
     pub fn helix_normal_motion(
         &mut self,
         motion: Motion,
@@ -854,6 +836,19 @@ mod test {
         cx.assert_state("foo hello worldˇ baz", Mode::HelixNormal);
     }
 
+    #[gpui::test]
+    async fn test_helix_select_mode(cx: &mut gpui::TestAppContext) {
+        let mut cx = VimTestContext::new(cx, true).await;
+
+        assert_eq!(cx.mode(), Mode::Normal);
+        cx.enable_helix();
+
+        cx.simulate_keystrokes("v");
+        assert_eq!(cx.mode(), Mode::HelixSelect);
+        cx.simulate_keystrokes("escape");
+        assert_eq!(cx.mode(), Mode::HelixNormal);
+    }
+
     #[gpui::test]
     async fn test_insert_mode_stickiness(cx: &mut gpui::TestAppContext) {
         let mut cx = VimTestContext::new(cx, true).await;

crates/vim/src/insert.rs πŸ”—

@@ -63,11 +63,7 @@ impl Vim {
                 }
             });
 
-            if HelixModeSetting::get_global(cx).0 {
-                self.switch_mode(Mode::HelixNormal, false, window, cx);
-            } else {
-                self.switch_mode(Mode::Normal, false, window, cx);
-            }
+            self.switch_mode(Mode::Normal, false, window, cx);
             return;
         }
 

crates/vim/src/motion.rs πŸ”—

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

crates/vim/src/normal/convert.rs πŸ”—

@@ -212,7 +212,7 @@ impl Vim {
                         }
                     }
 
-                    Mode::HelixNormal => {
+                    Mode::HelixNormal | Mode::HelixSelect => {
                         if selection.is_empty() {
                             // Handle empty selection by operating on the whole word
                             let (word_range, _) = snapshot.surrounding_word(selection.start, false);

crates/vim/src/object.rs πŸ”—

@@ -398,7 +398,7 @@ impl Vim {
 
         match self.mode {
             Mode::Normal | Mode::HelixNormal => self.normal_object(object, count, window, cx),
-            Mode::Visual | Mode::VisualLine | Mode::VisualBlock => {
+            Mode::Visual | Mode::VisualLine | Mode::VisualBlock | Mode::HelixSelect => {
                 self.visual_object(object, count, window, cx)
             }
             Mode::Insert | Mode::Replace => {

crates/vim/src/state.rs πŸ”—

@@ -46,6 +46,7 @@ pub enum Mode {
     VisualLine,
     VisualBlock,
     HelixNormal,
+    HelixSelect,
 }
 
 impl Display for Mode {
@@ -58,6 +59,7 @@ impl Display for Mode {
             Mode::VisualLine => write!(f, "VISUAL LINE"),
             Mode::VisualBlock => write!(f, "VISUAL BLOCK"),
             Mode::HelixNormal => write!(f, "HELIX NORMAL"),
+            Mode::HelixSelect => write!(f, "HELIX SELECT"),
         }
     }
 }
@@ -65,7 +67,7 @@ impl Display for Mode {
 impl Mode {
     pub fn is_visual(&self) -> bool {
         match self {
-            Self::Visual | Self::VisualLine | Self::VisualBlock => true,
+            Self::Visual | Self::VisualLine | Self::VisualBlock | Self::HelixSelect => true,
             Self::Normal | Self::Insert | Self::Replace | Self::HelixNormal => false,
         }
     }

crates/vim/src/test/neovim_connection.rs πŸ”—

@@ -443,7 +443,7 @@ impl NeovimConnection {
             }
             Mode::Insert | Mode::Normal | Mode::Replace => selections
                 .push(Point::new(selection_row, selection_col)..Point::new(cursor_row, cursor_col)),
-            Mode::HelixNormal => unreachable!(),
+            Mode::HelixNormal | Mode::HelixSelect => unreachable!(),
         }
 
         let ranges = encode_ranges(&text, &selections);

crates/vim/src/vim.rs πŸ”—

@@ -516,11 +516,7 @@ impl Vim {
 
         vim.update(cx, |_, cx| {
             Vim::action(editor, cx, |vim, _: &SwitchToNormalMode, window, cx| {
-                if HelixModeSetting::get_global(cx).0 {
-                    vim.switch_mode(Mode::HelixNormal, false, window, cx)
-                } else {
-                    vim.switch_mode(Mode::Normal, false, window, cx)
-                }
+                vim.switch_mode(Mode::Normal, false, window, cx)
             });
 
             Vim::action(editor, cx, |vim, _: &SwitchToInsertMode, window, cx| {
@@ -1030,6 +1026,13 @@ impl Vim {
                 editor.set_relative_line_number(Some(is_relative), cx)
             });
         }
+        if HelixModeSetting::get_global(cx).0 {
+            if self.mode == Mode::Normal {
+                self.mode = Mode::HelixNormal
+            } else if self.mode == Mode::Visual {
+                self.mode = Mode::HelixSelect
+            }
+        }
 
         if leave_selections {
             return;
@@ -1151,7 +1154,7 @@ impl Vim {
             }
             Mode::HelixNormal => cursor_shape.normal.unwrap_or(CursorShape::Block),
             Mode::Replace => cursor_shape.replace.unwrap_or(CursorShape::Underline),
-            Mode::Visual | Mode::VisualLine | Mode::VisualBlock => {
+            Mode::Visual | Mode::VisualLine | Mode::VisualBlock | Mode::HelixSelect => {
                 cursor_shape.visual.unwrap_or(CursorShape::Block)
             }
             Mode::Insert => cursor_shape.insert.unwrap_or({
@@ -1175,7 +1178,8 @@ impl Vim {
             | Mode::Replace
             | Mode::Visual
             | Mode::VisualLine
-            | Mode::VisualBlock => false,
+            | Mode::VisualBlock
+            | Mode::HelixSelect => false,
         }
     }
 
@@ -1190,7 +1194,8 @@ impl Vim {
             | Mode::VisualLine
             | Mode::VisualBlock
             | Mode::Replace
-            | Mode::HelixNormal => false,
+            | Mode::HelixNormal
+            | Mode::HelixSelect => false,
             Mode::Normal => true,
         }
     }
@@ -1202,6 +1207,7 @@ impl Vim {
             Mode::Insert => "insert",
             Mode::Replace => "replace",
             Mode::HelixNormal => "helix_normal",
+            Mode::HelixSelect => "helix_select",
         }
         .to_string();
 
@@ -1227,7 +1233,12 @@ impl Vim {
             }
         }
 
-        if mode == "normal" || mode == "visual" || mode == "operator" || mode == "helix_normal" {
+        if mode == "normal"
+            || mode == "visual"
+            || mode == "operator"
+            || mode == "helix_normal"
+            || mode == "helix_select"
+        {
             context.add("VimControl");
         }
         context.set("vim_mode", mode);
@@ -1522,7 +1533,7 @@ impl Vim {
         cx: &mut Context<Self>,
     ) {
         match self.mode {
-            Mode::VisualLine | Mode::VisualBlock | Mode::Visual => {
+            Mode::VisualLine | Mode::VisualBlock | Mode::Visual | Mode::HelixSelect => {
                 self.update_editor(cx, |vim, editor, cx| {
                     let original_mode = vim.undo_modes.get(transaction_id);
                     editor.change_selections(SelectionEffects::no_scroll(), window, cx, |s| {