Implement Helix Support (WIP) (#19175)

Waleed Dahshan created

Closes #4642 

- Added the ability to switch to helix normal mode, with an additional
helix visual mode.
- <kbd>ctrl</kbd><kbd>h</kbd> from Insert mode goes to Helix Normal
mode. <kbd> i </kbd> and <kbd> a </kbd> to go back.
- Need to find a way to perform the helix normal mode selection with
<kbd> w </kbd>, <kbd>e </kbd>, <kbd> b </kbd> as a first step. Need to
figure out how the mode will interoperate with the VIM mode as the new
additions are in the same crate.

Change summary

assets/keymaps/vim.json                  |  16 +
crates/editor/src/movement.rs            |  95 +++++++++
crates/language/src/buffer.rs            |  10 
crates/text/src/selection.rs             |  25 ++
crates/vim/src/helix.rs                  | 271 ++++++++++++++++++++++++++
crates/vim/src/motion.rs                 |   4 
crates/vim/src/normal/case.rs            |   2 
crates/vim/src/object.rs                 |   2 
crates/vim/src/state.rs                  |   3 
crates/vim/src/test/neovim_connection.rs |   1 
crates/vim/src/vim.rs                    |  27 +
11 files changed, 444 insertions(+), 12 deletions(-)

Detailed changes

assets/keymaps/vim.json ๐Ÿ”—

@@ -326,6 +326,22 @@
       "ctrl-o": "vim::TemporaryNormal"
     }
   },
+  {
+    "context": "vim_mode == helix_normal",
+    "bindings": {
+      "i": "vim::InsertBefore",
+      "a": "vim::InsertAfter",
+      "w": "vim::NextWordStart",
+      "e": "vim::NextWordEnd",
+      "b": "vim::PreviousWordStart",
+
+      "h": "vim::Left",
+      "j": "vim::Down",
+      "k": "vim::Up",
+      "l": "vim::Right"
+    }
+  },
+
   {
     "context": "vim_mode == insert && !(showing_code_actions || showing_completions)",
     "use_layout_keys": true,

crates/editor/src/movement.rs ๐Ÿ”—

@@ -488,6 +488,101 @@ pub fn find_boundary_point(
     map.clip_point(offset.to_display_point(map), Bias::Right)
 }
 
+pub fn find_preceding_boundary_trail(
+    map: &DisplaySnapshot,
+    head: DisplayPoint,
+    mut is_boundary: impl FnMut(char, char) -> bool,
+) -> (Option<DisplayPoint>, DisplayPoint) {
+    let mut offset = head.to_offset(map, Bias::Left);
+    let mut trail_offset = None;
+
+    let mut prev_ch = map.buffer_snapshot.chars_at(offset).next();
+    let mut forward = map.buffer_snapshot.reversed_chars_at(offset).peekable();
+
+    // Skip newlines
+    while let Some(&ch) = forward.peek() {
+        if ch == '\n' {
+            prev_ch = forward.next();
+            offset -= ch.len_utf8();
+            trail_offset = Some(offset);
+        } else {
+            break;
+        }
+    }
+
+    // Find the boundary
+    let start_offset = offset;
+    for ch in forward {
+        if let Some(prev_ch) = prev_ch {
+            if is_boundary(prev_ch, ch) {
+                if start_offset == offset {
+                    trail_offset = Some(offset);
+                } else {
+                    break;
+                }
+            }
+        }
+        offset -= ch.len_utf8();
+        prev_ch = Some(ch);
+    }
+
+    let trail = trail_offset
+        .map(|trail_offset: usize| map.clip_point(trail_offset.to_display_point(map), Bias::Left));
+
+    (
+        trail,
+        map.clip_point(offset.to_display_point(map), Bias::Left),
+    )
+}
+
+/// Finds the location of a boundary
+pub fn find_boundary_trail(
+    map: &DisplaySnapshot,
+    head: DisplayPoint,
+    mut is_boundary: impl FnMut(char, char) -> bool,
+) -> (Option<DisplayPoint>, DisplayPoint) {
+    let mut offset = head.to_offset(map, Bias::Right);
+    let mut trail_offset = None;
+
+    let mut prev_ch = map.buffer_snapshot.reversed_chars_at(offset).next();
+    let mut forward = map.buffer_snapshot.chars_at(offset).peekable();
+
+    // Skip newlines
+    while let Some(&ch) = forward.peek() {
+        if ch == '\n' {
+            prev_ch = forward.next();
+            offset += ch.len_utf8();
+            trail_offset = Some(offset);
+        } else {
+            break;
+        }
+    }
+
+    // Find the boundary
+    let start_offset = offset;
+    for ch in forward {
+        if let Some(prev_ch) = prev_ch {
+            if is_boundary(prev_ch, ch) {
+                if start_offset == offset {
+                    trail_offset = Some(offset);
+                } else {
+                    break;
+                }
+            }
+        }
+        offset += ch.len_utf8();
+        prev_ch = Some(ch);
+    }
+
+    let trail = trail_offset
+        .map(|trail_offset: usize| map.clip_point(trail_offset.to_display_point(map), Bias::Right));
+
+    (
+        trail,
+        map.clip_point(offset.to_display_point(map), Bias::Right),
+    )
+}
+
 pub fn find_boundary(
     map: &DisplaySnapshot,
     from: DisplayPoint,

crates/language/src/buffer.rs ๐Ÿ”—

@@ -4632,7 +4632,7 @@ impl CharClassifier {
         self.kind(c) == CharKind::Punctuation
     }
 
-    pub fn kind(&self, c: char) -> CharKind {
+    pub fn kind_with(&self, c: char, ignore_punctuation: bool) -> CharKind {
         if c.is_whitespace() {
             return CharKind::Whitespace;
         } else if c.is_alphanumeric() || c == '_' {
@@ -4642,7 +4642,7 @@ impl CharClassifier {
         if let Some(scope) = &self.scope {
             if let Some(characters) = scope.word_characters() {
                 if characters.contains(&c) {
-                    if c == '-' && !self.for_completion && !self.ignore_punctuation {
+                    if c == '-' && !self.for_completion && !ignore_punctuation {
                         return CharKind::Punctuation;
                     }
                     return CharKind::Word;
@@ -4650,12 +4650,16 @@ impl CharClassifier {
             }
         }
 
-        if self.ignore_punctuation {
+        if ignore_punctuation {
             CharKind::Word
         } else {
             CharKind::Punctuation
         }
     }
+
+    pub fn kind(&self, c: char) -> CharKind {
+        self.kind_with(c, self.ignore_punctuation)
+    }
 }
 
 /// Find all of the ranges of whitespace that occur at the ends of lines

crates/text/src/selection.rs ๐Ÿ”—

@@ -84,6 +84,31 @@ impl<T: Copy + Ord> Selection<T> {
         }
         self.goal = new_goal;
     }
+
+    pub fn set_tail(&mut self, tail: T, new_goal: SelectionGoal) {
+        if tail.cmp(&self.head()) <= Ordering::Equal {
+            if self.reversed {
+                self.end = self.start;
+                self.reversed = false;
+            }
+            self.start = tail;
+        } else {
+            if !self.reversed {
+                self.start = self.end;
+                self.reversed = true;
+            }
+            self.end = tail;
+        }
+        self.goal = new_goal;
+    }
+
+    pub fn swap_head_tail(&mut self) {
+        if self.reversed {
+            self.reversed = false;
+        } else {
+            std::mem::swap(&mut self.start, &mut self.end);
+        }
+    }
 }
 
 impl<T: Copy> Selection<T> {

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

@@ -0,0 +1,271 @@
+use editor::{movement, scroll::Autoscroll, DisplayPoint, Editor};
+use gpui::{actions, Action};
+use language::{CharClassifier, CharKind};
+use ui::ViewContext;
+
+use crate::{motion::Motion, state::Mode, Vim};
+
+actions!(vim, [HelixNormalAfter]);
+
+pub fn register(editor: &mut Editor, cx: &mut ViewContext<Vim>) {
+    Vim::action(editor, cx, Vim::helix_normal_after);
+}
+
+impl Vim {
+    pub fn helix_normal_after(&mut self, action: &HelixNormalAfter, cx: &mut ViewContext<Self>) {
+        if self.active_operator().is_some() {
+            self.operator_stack.clear();
+            self.sync_vim_settings(cx);
+            return;
+        }
+        self.stop_recording_immediately(action.boxed_clone(), cx);
+        self.switch_mode(Mode::HelixNormal, false, cx);
+        return;
+    }
+
+    pub fn helix_normal_motion(
+        &mut self,
+        motion: Motion,
+        times: Option<usize>,
+        cx: &mut ViewContext<Self>,
+    ) {
+        self.helix_move_cursor(motion, times, cx);
+    }
+
+    fn helix_find_range_forward(
+        &mut self,
+        times: Option<usize>,
+        cx: &mut ViewContext<Self>,
+        mut is_boundary: impl FnMut(char, char, &CharClassifier) -> bool,
+    ) {
+        self.update_editor(cx, |_, editor, cx| {
+            editor.change_selections(Some(Autoscroll::fit()), cx, |s| {
+                s.move_with(|map, selection| {
+                    let times = times.unwrap_or(1);
+
+                    if selection.head() == map.max_point() {
+                        return;
+                    }
+
+                    // collapse to block cursor
+                    if selection.tail() < selection.head() {
+                        selection.set_tail(movement::left(map, selection.head()), selection.goal);
+                    } else {
+                        selection.set_tail(selection.head(), selection.goal);
+                        selection.set_head(movement::right(map, selection.head()), selection.goal);
+                    }
+
+                    // create a classifier
+                    let classifier = map
+                        .buffer_snapshot
+                        .char_classifier_at(selection.head().to_point(map));
+
+                    let mut last_selection = selection.clone();
+                    for _ in 0..times {
+                        let (new_tail, new_head) =
+                            movement::find_boundary_trail(map, selection.head(), |left, right| {
+                                is_boundary(left, right, &classifier)
+                            });
+
+                        selection.set_head(new_head, selection.goal);
+                        if let Some(new_tail) = new_tail {
+                            selection.set_tail(new_tail, selection.goal);
+                        }
+
+                        if selection.head() == last_selection.head()
+                            && selection.tail() == last_selection.tail()
+                        {
+                            break;
+                        }
+                        last_selection = selection.clone();
+                    }
+                });
+            });
+        });
+    }
+
+    fn helix_find_range_backward(
+        &mut self,
+        times: Option<usize>,
+        cx: &mut ViewContext<Self>,
+        mut is_boundary: impl FnMut(char, char, &CharClassifier) -> bool,
+    ) {
+        self.update_editor(cx, |_, editor, cx| {
+            editor.change_selections(Some(Autoscroll::fit()), cx, |s| {
+                s.move_with(|map, selection| {
+                    let times = times.unwrap_or(1);
+
+                    if selection.head() == DisplayPoint::zero() {
+                        return;
+                    }
+
+                    // collapse to block cursor
+                    if selection.tail() < selection.head() {
+                        selection.set_tail(movement::left(map, selection.head()), selection.goal);
+                    } else {
+                        selection.set_tail(selection.head(), selection.goal);
+                        selection.set_head(movement::right(map, selection.head()), selection.goal);
+                    }
+
+                    // flip the selection
+                    selection.swap_head_tail();
+
+                    // create a classifier
+                    let classifier = map
+                        .buffer_snapshot
+                        .char_classifier_at(selection.head().to_point(map));
+
+                    let mut last_selection = selection.clone();
+                    for _ in 0..times {
+                        let (new_tail, new_head) = movement::find_preceding_boundary_trail(
+                            map,
+                            selection.head(),
+                            |left, right| is_boundary(left, right, &classifier),
+                        );
+
+                        selection.set_head(new_head, selection.goal);
+                        if let Some(new_tail) = new_tail {
+                            selection.set_tail(new_tail, selection.goal);
+                        }
+
+                        if selection.head() == last_selection.head()
+                            && selection.tail() == last_selection.tail()
+                        {
+                            break;
+                        }
+                        last_selection = selection.clone();
+                    }
+                });
+            })
+        });
+    }
+
+    pub fn helix_move_and_collapse(
+        &mut self,
+        motion: Motion,
+        times: Option<usize>,
+        cx: &mut ViewContext<Self>,
+    ) {
+        self.update_editor(cx, |_, editor, cx| {
+            let text_layout_details = editor.text_layout_details(cx);
+            editor.change_selections(Some(Autoscroll::fit()), cx, |s| {
+                s.move_with(|map, selection| {
+                    let goal = selection.goal;
+                    let cursor = if selection.is_empty() || selection.reversed {
+                        selection.head()
+                    } else {
+                        movement::left(map, selection.head())
+                    };
+
+                    let (point, goal) = motion
+                        .move_point(map, cursor, selection.goal, times, &text_layout_details)
+                        .unwrap_or((cursor, goal));
+
+                    selection.collapse_to(point, goal)
+                })
+            });
+        });
+    }
+
+    pub fn helix_move_cursor(
+        &mut self,
+        motion: Motion,
+        times: Option<usize>,
+        cx: &mut ViewContext<Self>,
+    ) {
+        match motion {
+            Motion::NextWordStart { ignore_punctuation } => {
+                self.helix_find_range_forward(times, cx, |left, right, classifier| {
+                    let left_kind = classifier.kind_with(left, ignore_punctuation);
+                    let right_kind = classifier.kind_with(right, ignore_punctuation);
+                    let at_newline = right == '\n';
+
+                    let found =
+                        left_kind != right_kind && right_kind != CharKind::Whitespace || at_newline;
+
+                    found
+                })
+            }
+            Motion::NextWordEnd { ignore_punctuation } => {
+                self.helix_find_range_forward(times, cx, |left, right, classifier| {
+                    let left_kind = classifier.kind_with(left, ignore_punctuation);
+                    let right_kind = classifier.kind_with(right, ignore_punctuation);
+                    let at_newline = right == '\n';
+
+                    let found = left_kind != right_kind
+                        && (left_kind != CharKind::Whitespace || at_newline);
+
+                    found
+                })
+            }
+            Motion::PreviousWordStart { ignore_punctuation } => {
+                self.helix_find_range_backward(times, cx, |left, right, classifier| {
+                    let left_kind = classifier.kind_with(left, ignore_punctuation);
+                    let right_kind = classifier.kind_with(right, ignore_punctuation);
+                    let at_newline = right == '\n';
+
+                    let found = left_kind != right_kind
+                        && (left_kind != CharKind::Whitespace || at_newline);
+
+                    found
+                })
+            }
+            Motion::PreviousWordEnd { ignore_punctuation } => {
+                self.helix_find_range_backward(times, cx, |left, right, classifier| {
+                    let left_kind = classifier.kind_with(left, ignore_punctuation);
+                    let right_kind = classifier.kind_with(right, ignore_punctuation);
+                    let at_newline = right == '\n';
+
+                    let found = left_kind != right_kind
+                        && right_kind != CharKind::Whitespace
+                        && !at_newline;
+
+                    found
+                })
+            }
+            _ => self.helix_move_and_collapse(motion, times, cx),
+        }
+    }
+}
+
+#[cfg(test)]
+mod test {
+    use indoc::indoc;
+
+    use crate::{state::Mode, test::VimTestContext};
+
+    #[gpui::test]
+    async fn test_next_word_start(cx: &mut gpui::TestAppContext) {
+        let mut cx = VimTestContext::new(cx, true).await;
+        // ยซ
+        // ห‡
+        // ยป
+        cx.set_state(
+            indoc! {"
+            The quห‡ick brown
+            fox jumps over
+            the lazy dog."},
+            Mode::HelixNormal,
+        );
+
+        cx.simulate_keystrokes("w");
+
+        cx.assert_state(
+            indoc! {"
+            The quยซick ห‡ยปbrown
+            fox jumps over
+            the lazy dog."},
+            Mode::HelixNormal,
+        );
+
+        cx.simulate_keystrokes("w");
+
+        cx.assert_state(
+            indoc! {"
+            The quick ยซbrownห‡ยป
+            fox jumps over
+            the lazy dog."},
+            Mode::HelixNormal,
+        );
+    }
+}

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

@@ -529,6 +529,8 @@ impl Vim {
                         return;
                     }
                 }
+
+                Mode::HelixNormal => {}
             }
         }
 
@@ -558,6 +560,8 @@ impl Vim {
             Mode::Visual | Mode::VisualLine | Mode::VisualBlock => {
                 self.visual_motion(motion.clone(), count, cx)
             }
+
+            Mode::HelixNormal => self.helix_normal_motion(motion.clone(), count, cx),
         }
         self.clear_operator(cx);
         if let Some(operator) = waiting_operator {

crates/vim/src/normal/case.rs ๐Ÿ”—

@@ -145,6 +145,8 @@ impl Vim {
                             cursor_positions.push(selection.start..selection.start);
                         }
                     }
+
+                    Mode::HelixNormal => {}
                     Mode::Insert | Mode::Normal | Mode::Replace => {
                         let start = selection.start;
                         let mut end = start;

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

@@ -143,7 +143,7 @@ impl Vim {
         match self.mode {
             Mode::Normal => self.normal_object(object, cx),
             Mode::Visual | Mode::VisualLine | Mode::VisualBlock => self.visual_object(object, cx),
-            Mode::Insert | Mode::Replace => {
+            Mode::Insert | Mode::Replace | Mode::HelixNormal => {
                 // Shouldn't execute a text object in insert mode. Ignoring
             }
         }

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

@@ -26,6 +26,7 @@ pub enum Mode {
     Visual,
     VisualLine,
     VisualBlock,
+    HelixNormal,
 }
 
 impl Display for Mode {
@@ -37,6 +38,7 @@ impl Display for Mode {
             Mode::Visual => write!(f, "VISUAL"),
             Mode::VisualLine => write!(f, "VISUAL LINE"),
             Mode::VisualBlock => write!(f, "VISUAL BLOCK"),
+            Mode::HelixNormal => write!(f, "HELIX NORMAL"),
         }
     }
 }
@@ -46,6 +48,7 @@ impl Mode {
         match self {
             Mode::Normal | Mode::Insert | Mode::Replace => false,
             Mode::Visual | Mode::VisualLine | Mode::VisualBlock => true,
+            Mode::HelixNormal => false,
         }
     }
 }

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

@@ -442,6 +442,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!(),
         }
 
         let ranges = encode_ranges(&text, &selections);

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

@@ -6,6 +6,7 @@ mod test;
 mod change_list;
 mod command;
 mod digraph;
+mod helix;
 mod indent;
 mod insert;
 mod mode_indicator;
@@ -337,6 +338,7 @@ impl Vim {
 
             normal::register(editor, cx);
             insert::register(editor, cx);
+            helix::register(editor, cx);
             motion::register(editor, cx);
             command::register(editor, cx);
             replace::register(editor, cx);
@@ -631,7 +633,9 @@ impl Vim {
                 }
             }
             Mode::Replace => CursorShape::Underline,
-            Mode::Visual | Mode::VisualLine | Mode::VisualBlock => CursorShape::Block,
+            Mode::HelixNormal | Mode::Visual | Mode::VisualLine | Mode::VisualBlock => {
+                CursorShape::Block
+            }
             Mode::Insert => CursorShape::Bar,
         }
     }
@@ -645,9 +649,12 @@ impl Vim {
                     true
                 }
             }
-            Mode::Normal | Mode::Replace | Mode::Visual | Mode::VisualLine | Mode::VisualBlock => {
-                false
-            }
+            Mode::Normal
+            | Mode::HelixNormal
+            | Mode::Replace
+            | Mode::Visual
+            | Mode::VisualLine
+            | Mode::VisualBlock => false,
         }
     }
 
@@ -657,9 +664,12 @@ impl Vim {
 
     pub fn clip_at_line_ends(&self) -> bool {
         match self.mode {
-            Mode::Insert | Mode::Visual | Mode::VisualLine | Mode::VisualBlock | Mode::Replace => {
-                false
-            }
+            Mode::Insert
+            | Mode::Visual
+            | Mode::VisualLine
+            | Mode::VisualBlock
+            | Mode::Replace
+            | Mode::HelixNormal => false,
             Mode::Normal => true,
         }
     }
@@ -670,6 +680,7 @@ impl Vim {
             Mode::Visual | Mode::VisualLine | Mode::VisualBlock => "visual",
             Mode::Insert => "insert",
             Mode::Replace => "replace",
+            Mode::HelixNormal => "helix_normal",
         }
         .to_string();
 
@@ -998,7 +1009,7 @@ impl Vim {
                     })
                 });
             }
-            Mode::Insert | Mode::Replace => {}
+            Mode::Insert | Mode::Replace | Mode::HelixNormal => {}
         }
     }