From 8f08787cf0ce40521845bbf9a43d0945c8113ca4 Mon Sep 17 00:00:00 2001
From: Waleed Dahshan <58462210+wmstack@users.noreply.github.com>
Date: Wed, 4 Dec 2024 17:19:52 +1100
Subject: [PATCH] Implement Helix Support (WIP) (#19175)
Closes #4642
- Added the ability to switch to helix normal mode, with an additional
helix visual mode.
- ctrlh from Insert mode goes to Helix Normal
mode. i and a to go back.
- Need to find a way to perform the helix normal mode selection with
w , e , b 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.
---
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(-)
create mode 100644 crates/vim/src/helix.rs
diff --git a/assets/keymaps/vim.json b/assets/keymaps/vim.json
index 3c2197afcc..c80a6912cc 100644
--- a/assets/keymaps/vim.json
+++ b/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,
diff --git a/crates/editor/src/movement.rs b/crates/editor/src/movement.rs
index 52bedde2e3..8189dd2947 100644
--- a/crates/editor/src/movement.rs
+++ b/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) {
+ 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) {
+ 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,
diff --git a/crates/language/src/buffer.rs b/crates/language/src/buffer.rs
index 67b33ebd56..c9f5d54299 100644
--- a/crates/language/src/buffer.rs
+++ b/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
diff --git a/crates/text/src/selection.rs b/crates/text/src/selection.rs
index 94c373d630..fffece26b2 100644
--- a/crates/text/src/selection.rs
+++ b/crates/text/src/selection.rs
@@ -84,6 +84,31 @@ impl Selection {
}
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 Selection {
diff --git a/crates/vim/src/helix.rs b/crates/vim/src/helix.rs
new file mode 100644
index 0000000000..21abb5cbaa
--- /dev/null
+++ b/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::action(editor, cx, Vim::helix_normal_after);
+}
+
+impl Vim {
+ pub fn helix_normal_after(&mut self, action: &HelixNormalAfter, cx: &mut ViewContext) {
+ 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,
+ cx: &mut ViewContext,
+ ) {
+ self.helix_move_cursor(motion, times, cx);
+ }
+
+ fn helix_find_range_forward(
+ &mut self,
+ times: Option,
+ cx: &mut ViewContext,
+ 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,
+ cx: &mut ViewContext,
+ 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,
+ cx: &mut ViewContext,
+ ) {
+ 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,
+ cx: &mut ViewContext,
+ ) {
+ 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,
+ );
+ }
+}
diff --git a/crates/vim/src/motion.rs b/crates/vim/src/motion.rs
index eb6e8464a3..08cf219722 100644
--- a/crates/vim/src/motion.rs
+++ b/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 {
diff --git a/crates/vim/src/normal/case.rs b/crates/vim/src/normal/case.rs
index 0aeb4c7e98..405185adf5 100644
--- a/crates/vim/src/normal/case.rs
+++ b/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;
diff --git a/crates/vim/src/object.rs b/crates/vim/src/object.rs
index 380acc896a..b6f164cdb1 100644
--- a/crates/vim/src/object.rs
+++ b/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
}
}
diff --git a/crates/vim/src/state.rs b/crates/vim/src/state.rs
index f43de2cf6f..e93eeef404 100644
--- a/crates/vim/src/state.rs
+++ b/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,
}
}
}
diff --git a/crates/vim/src/test/neovim_connection.rs b/crates/vim/src/test/neovim_connection.rs
index a2ab1f3972..a0a2343bdf 100644
--- a/crates/vim/src/test/neovim_connection.rs
+++ b/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);
diff --git a/crates/vim/src/vim.rs b/crates/vim/src/vim.rs
index db0a765170..c395e9c37e 100644
--- a/crates/vim/src/vim.rs
+++ b/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 => {}
}
}