diff --git a/crates/vim/src/helix.rs b/crates/vim/src/helix.rs index 60d87572eb3151f8e36c06f91501921ea9affb3b..0db3b5a3fe533f9e21503c6904ee0f62764003fb 100644 --- a/crates/vim/src/helix.rs +++ b/crates/vim/src/helix.rs @@ -367,6 +367,56 @@ impl Vim { } } + /// When `reversed` is true (used with `helix_find_range_backward`), the + /// `left` and `right` characters are yielded in reverse text order, so the + /// camelCase transition check must be flipped accordingly. + fn subword_boundary_start( + ignore_punctuation: bool, + reversed: bool, + ) -> impl FnMut(char, char, &CharClassifier) -> bool { + move |left, right, classifier| { + let left_kind = classifier.kind_with(left, ignore_punctuation); + let right_kind = classifier.kind_with(right, ignore_punctuation); + let at_newline = (left == '\n') ^ (right == '\n'); + let is_separator = |c: char| "_$=".contains(c); + + let is_word = left_kind != right_kind && right_kind != CharKind::Whitespace; + let is_subword = (is_separator(left) && !is_separator(right)) + || if reversed { + right.is_lowercase() && left.is_uppercase() + } else { + left.is_lowercase() && right.is_uppercase() + }; + + is_word || (is_subword && !right.is_whitespace()) || at_newline + } + } + + /// When `reversed` is true (used with `helix_find_range_backward`), the + /// `left` and `right` characters are yielded in reverse text order, so the + /// camelCase transition check must be flipped accordingly. + fn subword_boundary_end( + ignore_punctuation: bool, + reversed: bool, + ) -> impl FnMut(char, char, &CharClassifier) -> bool { + move |left, right, classifier| { + let left_kind = classifier.kind_with(left, ignore_punctuation); + let right_kind = classifier.kind_with(right, ignore_punctuation); + let at_newline = (left == '\n') ^ (right == '\n'); + let is_separator = |c: char| "_$=".contains(c); + + let is_word = left_kind != right_kind && left_kind != CharKind::Whitespace; + let is_subword = (!is_separator(left) && is_separator(right)) + || if reversed { + right.is_lowercase() && left.is_uppercase() + } else { + left.is_lowercase() && right.is_uppercase() + }; + + is_word || (is_subword && !left.is_whitespace()) || at_newline + } + } + pub fn helix_move_cursor( &mut self, motion: Motion, @@ -391,6 +441,29 @@ impl Vim { let mut is_boundary = Self::is_boundary_right(ignore_punctuation); self.helix_find_range_backward(times, window, cx, &mut is_boundary) } + // The subword motions implementation is based off of the same + // commands present in Helix itself, namely: + // + // * `move_next_sub_word_start` + // * `move_next_sub_word_end` + // * `move_prev_sub_word_start` + // * `move_prev_sub_word_end` + Motion::NextSubwordStart { ignore_punctuation } => { + let mut is_boundary = Self::subword_boundary_start(ignore_punctuation, false); + self.helix_find_range_forward(times, window, cx, &mut is_boundary) + } + Motion::NextSubwordEnd { ignore_punctuation } => { + let mut is_boundary = Self::subword_boundary_end(ignore_punctuation, false); + self.helix_find_range_forward(times, window, cx, &mut is_boundary) + } + Motion::PreviousSubwordStart { ignore_punctuation } => { + let mut is_boundary = Self::subword_boundary_end(ignore_punctuation, true); + self.helix_find_range_backward(times, window, cx, &mut is_boundary) + } + Motion::PreviousSubwordEnd { ignore_punctuation } => { + let mut is_boundary = Self::subword_boundary_start(ignore_punctuation, true); + self.helix_find_range_backward(times, window, cx, &mut is_boundary) + } Motion::EndOfLine { .. } => { // In Helix mode, EndOfLine should position cursor ON the last character, // not after it. We therefore need special handling for it. @@ -902,7 +975,7 @@ impl Vim { #[cfg(test)] mod test { - use gpui::{UpdateGlobal, VisualTestContext}; + use gpui::{KeyBinding, UpdateGlobal, VisualTestContext}; use indoc::indoc; use project::FakeFs; use search::{ProjectSearchView, project_search}; @@ -975,6 +1048,310 @@ mod test { cx.assert_state("aa\n«ˇ »bb", Mode::HelixNormal); } + #[gpui::test] + async fn test_next_subword_start(cx: &mut gpui::TestAppContext) { + let mut cx = VimTestContext::new(cx, true).await; + cx.enable_helix(); + + // Setup custom keybindings for subword motions so we can use the bindings + // in `simulate_keystroke`. + cx.update(|_window, cx| { + cx.bind_keys([KeyBinding::new( + "w", + crate::motion::NextSubwordStart { + ignore_punctuation: false, + }, + None, + )]); + }); + + cx.set_state("ˇfoo.bar", Mode::HelixNormal); + cx.simulate_keystroke("w"); + cx.assert_state("«fooˇ».bar", Mode::HelixNormal); + cx.simulate_keystroke("w"); + cx.assert_state("foo«.ˇ»bar", Mode::HelixNormal); + cx.simulate_keystroke("w"); + cx.assert_state("foo.«barˇ»", Mode::HelixNormal); + + cx.set_state("ˇfoo(bar)", Mode::HelixNormal); + cx.simulate_keystroke("w"); + cx.assert_state("«fooˇ»(bar)", Mode::HelixNormal); + cx.simulate_keystroke("w"); + cx.assert_state("foo«(ˇ»bar)", Mode::HelixNormal); + cx.simulate_keystroke("w"); + cx.assert_state("foo(«barˇ»)", Mode::HelixNormal); + + cx.set_state("ˇfoo_bar_baz", Mode::HelixNormal); + cx.simulate_keystroke("w"); + cx.assert_state("«foo_ˇ»bar_baz", Mode::HelixNormal); + cx.simulate_keystroke("w"); + cx.assert_state("foo_«bar_ˇ»baz", Mode::HelixNormal); + + cx.set_state("ˇfooBarBaz", Mode::HelixNormal); + cx.simulate_keystroke("w"); + cx.assert_state("«fooˇ»BarBaz", Mode::HelixNormal); + cx.simulate_keystroke("w"); + cx.assert_state("foo«Barˇ»Baz", Mode::HelixNormal); + + cx.set_state("ˇfoo;bar", Mode::HelixNormal); + cx.simulate_keystroke("w"); + cx.assert_state("«fooˇ»;bar", Mode::HelixNormal); + cx.simulate_keystroke("w"); + cx.assert_state("foo«;ˇ»bar", Mode::HelixNormal); + cx.simulate_keystroke("w"); + cx.assert_state("foo;«barˇ»", Mode::HelixNormal); + + cx.set_state("ˇ