helix: Improve "x" behavior (#35611)

Romans Malinovskis created

Closes #32020 

Release Notes:
- Helix: Improve `x` behaviour. Will respect modifiers (`5 x`). Pressing
`x` on a empty line, will select current+next line, because helix
considers current line to be already selected without the need of
pressing `x`.

Change summary

assets/keymaps/vim.json |   2 
crates/vim/src/helix.rs | 188 ++++++++++++++++++++++++++++++++++++++++++
2 files changed, 187 insertions(+), 3 deletions(-)

Detailed changes

assets/keymaps/vim.json 🔗

@@ -435,7 +435,7 @@
       "g b": "vim::WindowBottom",
 
       "shift-r": "editor::Paste",
-      "x": "editor::SelectLine",
+      "x": "vim::HelixSelectLine",
       "shift-x": "editor::SelectLine",
       "%": "editor::SelectAll",
       // Window mode

crates/vim/src/helix.rs 🔗

@@ -1,8 +1,10 @@
 use editor::display_map::DisplaySnapshot;
-use editor::{DisplayPoint, Editor, SelectionEffects, ToOffset, ToPoint, movement};
+use editor::{
+    DisplayPoint, Editor, HideMouseCursorOrigin, SelectionEffects, ToOffset, ToPoint, movement,
+};
 use gpui::{Action, actions};
 use gpui::{Context, Window};
-use language::{CharClassifier, CharKind};
+use language::{CharClassifier, CharKind, Point};
 use text::{Bias, SelectionGoal};
 
 use crate::motion;
@@ -25,11 +27,14 @@ actions!(
         HelixAppend,
         /// Goes to the location of the last modification.
         HelixGotoLastModification,
+        /// Select entire line or multiple lines, extending downwards.
+        HelixSelectLine,
     ]
 );
 
 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);
     Vim::action(editor, cx, Vim::helix_yank);
@@ -442,6 +447,47 @@ impl Vim {
     ) {
         self.jump(".".into(), false, false, window, cx);
     }
+
+    pub fn helix_select_lines(
+        &mut self,
+        _: &HelixSelectLine,
+        window: &mut Window,
+        cx: &mut Context<Self>,
+    ) {
+        let count = Vim::take_count(cx).unwrap_or(1);
+        self.update_editor(cx, |_, editor, cx| {
+            editor.hide_mouse_cursor(HideMouseCursorOrigin::MovementAction, cx);
+            let display_map = editor.display_map.update(cx, |map, cx| map.snapshot(cx));
+            let mut selections = editor.selections.all::<Point>(cx);
+            let max_point = display_map.buffer_snapshot.max_point();
+            let buffer_snapshot = &display_map.buffer_snapshot;
+
+            for selection in &mut selections {
+                // Start always goes to column 0 of the first selected line
+                let start_row = selection.start.row;
+                let current_end_row = selection.end.row;
+
+                // Check if cursor is on empty line by checking first character
+                let line_start_offset = buffer_snapshot.point_to_offset(Point::new(start_row, 0));
+                let first_char = buffer_snapshot.chars_at(line_start_offset).next();
+                let extra_line = if first_char == Some('\n') { 1 } else { 0 };
+
+                let end_row = current_end_row + count as u32 + extra_line;
+
+                selection.start = Point::new(start_row, 0);
+                selection.end = if end_row > max_point.row {
+                    max_point
+                } else {
+                    Point::new(end_row, 0)
+                };
+                selection.reversed = false;
+            }
+
+            editor.change_selections(Default::default(), window, cx, |s| {
+                s.select(selections);
+            });
+        });
+    }
 }
 
 #[cfg(test)]
@@ -850,4 +896,142 @@ mod test {
             Mode::HelixNormal,
         );
     }
+
+    #[gpui::test]
+    async fn test_helix_select_lines(cx: &mut gpui::TestAppContext) {
+        let mut cx = VimTestContext::new(cx, true).await;
+        cx.set_state(
+            "line one\nline ˇtwo\nline three\nline four",
+            Mode::HelixNormal,
+        );
+        cx.simulate_keystrokes("2 x");
+        cx.assert_state(
+            "line one\n«line two\nline three\nˇ»line four",
+            Mode::HelixNormal,
+        );
+
+        // Test extending existing line selection
+        cx.set_state(
+            indoc! {"
+            li«ˇne one
+            li»ne two
+            line three
+            line four"},
+            Mode::HelixNormal,
+        );
+        cx.simulate_keystrokes("x");
+        cx.assert_state(
+            indoc! {"
+            «line one
+            line two
+            ˇ»line three
+            line four"},
+            Mode::HelixNormal,
+        );
+
+        // Pressing x in empty line, select next line (because helix considers cursor a selection)
+        cx.set_state(
+            indoc! {"
+            line one
+            ˇ
+            line three
+            line four"},
+            Mode::HelixNormal,
+        );
+        cx.simulate_keystrokes("x");
+        cx.assert_state(
+            indoc! {"
+            line one
+            «
+            line three
+            ˇ»line four"},
+            Mode::HelixNormal,
+        );
+
+        // Empty line with count selects extra + count lines
+        cx.set_state(
+            indoc! {"
+            line one
+            ˇ
+            line three
+            line four
+            line five"},
+            Mode::HelixNormal,
+        );
+        cx.simulate_keystrokes("2 x");
+        cx.assert_state(
+            indoc! {"
+            line one
+            «
+            line three
+            line four
+            ˇ»line five"},
+            Mode::HelixNormal,
+        );
+
+        // Compare empty vs non-empty line behavior
+        cx.set_state(
+            indoc! {"
+            ˇnon-empty line
+            line two
+            line three"},
+            Mode::HelixNormal,
+        );
+        cx.simulate_keystrokes("x");
+        cx.assert_state(
+            indoc! {"
+            «non-empty line
+            ˇ»line two
+            line three"},
+            Mode::HelixNormal,
+        );
+
+        // Same test but with empty line - should select one extra
+        cx.set_state(
+            indoc! {"
+            ˇ
+            line two
+            line three"},
+            Mode::HelixNormal,
+        );
+        cx.simulate_keystrokes("x");
+        cx.assert_state(
+            indoc! {"
+            «
+            line two
+            ˇ»line three"},
+            Mode::HelixNormal,
+        );
+
+        // Test selecting multiple lines with count
+        cx.set_state(
+            indoc! {"
+            ˇline one
+            line two
+            line threeˇ
+            line four
+            line five"},
+            Mode::HelixNormal,
+        );
+        cx.simulate_keystrokes("x");
+        cx.assert_state(
+            indoc! {"
+            «line one
+            ˇ»line two
+            «line three
+            ˇ»line four
+            line five"},
+            Mode::HelixNormal,
+        );
+        cx.simulate_keystrokes("x");
+        cx.assert_state(
+            indoc! {"
+            «line one
+            line two
+            line three
+            line four
+            ˇ»line five"},
+            Mode::HelixNormal,
+        );
+    }
 }