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 => {} } }