vim: Fix Helix mode subword motions to select traversed text (#45760)

Update Helix's handling of the following actions in order to ensure that
selections are created when using subword motions:

* `vim::NextSubwordStart`
* `vim::NextSubwordEnd`
* `vim::PreviousSubwordStart`
* `vim::PreviousSubwordEnd`

The handling of these motions was done by
`vim::helix::Vim::helix_move_and_collapse`, which simply moved the
cursor without doing any selection. This commit updates it to correctly
use both `vim::helix::Vim::helix_find_range_forward` and
`vim::helix::Vim::helix_find_range_backward`.

The added tests have been confirmed against Helix's behavior of the following commands:

* `move_next_sub_word_start`
* `move_prev_sub_word_start`
* `move_next_sub_word_end`
* `move_prev_sub_word_end`

Closes #41767

Release Notes:

- Fix subword motions in Helix mode to select traversed text

---------

Co-authored-by: Conrad Irwin <conrad.irwin@gmail.com>
Co-authored-by: dino <dinojoaocosta@gmail.com>
This commit is contained in:
authored and GitHub committed 2026-03-17 10:49:01 +00:00
1 parent b986e15791
commit 293ae8d897
2 files changed
+380 -2

No files matched your search

+378 -1
View File
@@ -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("ˇ<?php\n\n$someVariable = 2;", Mode::HelixNormal);
cx.simulate_keystroke("w");
cx.assert_state("«<?ˇ»php\n\n$someVariable = 2;", Mode::HelixNormal);
cx.simulate_keystroke("w");
cx.assert_state("<?«phpˇ»\n\n$someVariable = 2;", Mode::HelixNormal);
cx.simulate_keystroke("w");
cx.assert_state("<?php\n\n«$ˇ»someVariable = 2;", Mode::HelixNormal);
cx.simulate_keystroke("w");
cx.assert_state("<?php\n\n$«someˇ»Variable = 2;", Mode::HelixNormal);
cx.simulate_keystroke("w");
cx.assert_state("<?php\n\n$some«Variable ˇ»= 2;", Mode::HelixNormal);
cx.simulate_keystroke("w");
cx.assert_state("<?php\n\n$someVariable «= ˇ»2;", Mode::HelixNormal);
cx.simulate_keystroke("w");
cx.assert_state("<?php\n\n$someVariable = «2ˇ»;", Mode::HelixNormal);
cx.simulate_keystroke("w");
cx.assert_state("<?php\n\n$someVariable = 2«;ˇ»", Mode::HelixNormal);
}
#[gpui::test]
async fn test_next_subword_end(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(
"e",
crate::motion::NextSubwordEnd {
ignore_punctuation: false,
},
None,
)]);
});
cx.set_state("ˇfoo.bar", Mode::HelixNormal);
cx.simulate_keystroke("e");
cx.assert_state("«fooˇ».bar", Mode::HelixNormal);
cx.simulate_keystroke("e");
cx.assert_state("foo«.ˇ»bar", Mode::HelixNormal);
cx.simulate_keystroke("e");
cx.assert_state("foo.«barˇ»", Mode::HelixNormal);
cx.set_state("ˇfoo(bar)", Mode::HelixNormal);
cx.simulate_keystroke("e");
cx.assert_state("«fooˇ»(bar)", Mode::HelixNormal);
cx.simulate_keystroke("e");
cx.assert_state("foo«(ˇ»bar)", Mode::HelixNormal);
cx.simulate_keystroke("e");
cx.assert_state("foo(«barˇ»)", Mode::HelixNormal);
cx.set_state("ˇfoo_bar_baz", Mode::HelixNormal);
cx.simulate_keystroke("e");
cx.assert_state("«fooˇ»_bar_baz", Mode::HelixNormal);
cx.simulate_keystroke("e");
cx.assert_state("foo«_barˇ»_baz", Mode::HelixNormal);
cx.simulate_keystroke("e");
cx.assert_state("foo_bar«_bazˇ»", Mode::HelixNormal);
cx.set_state("ˇfooBarBaz", Mode::HelixNormal);
cx.simulate_keystroke("e");
cx.assert_state("«fooˇ»BarBaz", Mode::HelixNormal);
cx.simulate_keystroke("e");
cx.assert_state("foo«Barˇ»Baz", Mode::HelixNormal);
cx.simulate_keystroke("e");
cx.assert_state("fooBar«Bazˇ»", Mode::HelixNormal);
cx.set_state("ˇfoo;bar", Mode::HelixNormal);
cx.simulate_keystroke("e");
cx.assert_state("«fooˇ»;bar", Mode::HelixNormal);
cx.simulate_keystroke("e");
cx.assert_state("foo«;ˇ»bar", Mode::HelixNormal);
cx.simulate_keystroke("e");
cx.assert_state("foo;«barˇ»", Mode::HelixNormal);
cx.set_state("ˇ<?php\n\n$someVariable = 2;", Mode::HelixNormal);
cx.simulate_keystroke("e");
cx.assert_state("«<?ˇ»php\n\n$someVariable = 2;", Mode::HelixNormal);
cx.simulate_keystroke("e");
cx.assert_state("<?«phpˇ»\n\n$someVariable = 2;", Mode::HelixNormal);
cx.simulate_keystroke("e");
cx.assert_state("<?php\n\n«$ˇ»someVariable = 2;", Mode::HelixNormal);
cx.simulate_keystroke("e");
cx.assert_state("<?php\n\n$«someˇ»Variable = 2;", Mode::HelixNormal);
cx.simulate_keystroke("e");
cx.assert_state("<?php\n\n$some«Variableˇ» = 2;", Mode::HelixNormal);
cx.simulate_keystroke("e");
cx.assert_state("<?php\n\n$someVariable« =ˇ» 2;", Mode::HelixNormal);
cx.simulate_keystroke("e");
cx.assert_state("<?php\n\n$someVariable =« 2ˇ»;", Mode::HelixNormal);
cx.simulate_keystroke("e");
cx.assert_state("<?php\n\n$someVariable = 2«;ˇ»", Mode::HelixNormal);
}
#[gpui::test]
async fn test_previous_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(
"b",
crate::motion::PreviousSubwordStart {
ignore_punctuation: false,
},
None,
)]);
});
cx.set_state("foo.barˇ", Mode::HelixNormal);
cx.simulate_keystroke("b");
cx.assert_state("foo.«ˇbar»", Mode::HelixNormal);
cx.simulate_keystroke("b");
cx.assert_state("foo«ˇ.»bar", Mode::HelixNormal);
cx.simulate_keystroke("b");
cx.assert_state("«ˇfoo».bar", Mode::HelixNormal);
cx.set_state("foo(bar)ˇ", Mode::HelixNormal);
cx.simulate_keystroke("b");
cx.assert_state("foo(bar«ˇ)»", Mode::HelixNormal);
cx.simulate_keystroke("b");
cx.assert_state("foo(«ˇbar»)", Mode::HelixNormal);
cx.simulate_keystroke("b");
cx.assert_state("foo«ˇ(»bar)", Mode::HelixNormal);
cx.simulate_keystroke("b");
cx.assert_state("«ˇfoo»(bar)", Mode::HelixNormal);
cx.set_state("foo_bar_bazˇ", Mode::HelixNormal);
cx.simulate_keystroke("b");
cx.assert_state("foo_bar_«ˇbaz»", Mode::HelixNormal);
cx.simulate_keystroke("b");
cx.assert_state("foo_«ˇbar_»baz", Mode::HelixNormal);
cx.simulate_keystroke("b");
cx.assert_state("«ˇfoo_»bar_baz", Mode::HelixNormal);
cx.set_state("foo;barˇ", Mode::HelixNormal);
cx.simulate_keystroke("b");
cx.assert_state("foo;«ˇbar»", Mode::HelixNormal);
cx.simulate_keystroke("b");
cx.assert_state("foo«ˇ;»bar", Mode::HelixNormal);
cx.simulate_keystroke("b");
cx.assert_state("«ˇfoo»;bar", Mode::HelixNormal);
cx.set_state("<?php\n\n$someVariable = 2;ˇ", Mode::HelixNormal);
cx.simulate_keystroke("b");
cx.assert_state("<?php\n\n$someVariable = 2«ˇ;»", Mode::HelixNormal);
cx.simulate_keystroke("b");
cx.assert_state("<?php\n\n$someVariable = «ˇ2»;", Mode::HelixNormal);
cx.simulate_keystroke("b");
cx.assert_state("<?php\n\n$someVariable «ˇ= »2;", Mode::HelixNormal);
cx.simulate_keystroke("b");
cx.assert_state("<?php\n\n$some«ˇVariable »= 2;", Mode::HelixNormal);
cx.simulate_keystroke("b");
cx.assert_state("<?php\n\n$«ˇsome»Variable = 2;", Mode::HelixNormal);
cx.simulate_keystroke("b");
cx.assert_state("<?php\n\n«ˇ$»someVariable = 2;", Mode::HelixNormal);
cx.simulate_keystroke("b");
cx.assert_state("<?«ˇphp»\n\n$someVariable = 2;", Mode::HelixNormal);
cx.simulate_keystroke("b");
cx.assert_state("«ˇ<?»php\n\n$someVariable = 2;", Mode::HelixNormal);
cx.set_state("fooBarBazˇ", Mode::HelixNormal);
cx.simulate_keystroke("b");
cx.assert_state("fooBar«ˇBaz»", Mode::HelixNormal);
cx.simulate_keystroke("b");
cx.assert_state("foo«ˇBar»Baz", Mode::HelixNormal);
cx.simulate_keystroke("b");
cx.assert_state("«ˇfoo»BarBaz", Mode::HelixNormal);
}
#[gpui::test]
async fn test_previous_subword_end(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_keystrokes`.
cx.update(|_window, cx| {
cx.bind_keys([KeyBinding::new(
"g e",
crate::motion::PreviousSubwordEnd {
ignore_punctuation: false,
},
None,
)]);
});
cx.set_state("foo.barˇ", Mode::HelixNormal);
cx.simulate_keystrokes("g e");
cx.assert_state("foo.«ˇbar»", Mode::HelixNormal);
cx.simulate_keystrokes("g e");
cx.assert_state("foo«ˇ.»bar", Mode::HelixNormal);
cx.simulate_keystrokes("g e");
cx.assert_state("«ˇfoo».bar", Mode::HelixNormal);
cx.set_state("foo(bar)ˇ", Mode::HelixNormal);
cx.simulate_keystrokes("g e");
cx.assert_state("foo(bar«ˇ)»", Mode::HelixNormal);
cx.simulate_keystrokes("g e");
cx.assert_state("foo(«ˇbar»)", Mode::HelixNormal);
cx.simulate_keystrokes("g e");
cx.assert_state("foo«ˇ(»bar)", Mode::HelixNormal);
cx.simulate_keystrokes("g e");
cx.assert_state("«ˇfoo»(bar)", Mode::HelixNormal);
cx.set_state("foo_bar_bazˇ", Mode::HelixNormal);
cx.simulate_keystrokes("g e");
cx.assert_state("foo_bar«ˇ_baz»", Mode::HelixNormal);
cx.simulate_keystrokes("g e");
cx.assert_state("foo«ˇ_bar»_baz", Mode::HelixNormal);
cx.simulate_keystrokes("g e");
cx.assert_state("«ˇfoo»_bar_baz", Mode::HelixNormal);
cx.set_state("foo;barˇ", Mode::HelixNormal);
cx.simulate_keystrokes("g e");
cx.assert_state("foo;«ˇbar»", Mode::HelixNormal);
cx.simulate_keystrokes("g e");
cx.assert_state("foo«ˇ;»bar", Mode::HelixNormal);
cx.simulate_keystrokes("g e");
cx.assert_state("«ˇfoo»;bar", Mode::HelixNormal);
cx.set_state("<?php\n\n$someVariable = 2;ˇ", Mode::HelixNormal);
cx.simulate_keystrokes("g e");
cx.assert_state("<?php\n\n$someVariable = 2«ˇ;»", Mode::HelixNormal);
cx.simulate_keystrokes("g e");
cx.assert_state("<?php\n\n$someVariable =«ˇ 2»;", Mode::HelixNormal);
cx.simulate_keystrokes("g e");
cx.assert_state("<?php\n\n$someVariable«ˇ =» 2;", Mode::HelixNormal);
cx.simulate_keystrokes("g e");
cx.assert_state("<?php\n\n$some«ˇVariable» = 2;", Mode::HelixNormal);
cx.simulate_keystrokes("g e");
cx.assert_state("<?php\n\n$«ˇsome»Variable = 2;", Mode::HelixNormal);
cx.simulate_keystrokes("g e");
cx.assert_state("<?php\n\n«ˇ$»someVariable = 2;", Mode::HelixNormal);
cx.simulate_keystrokes("g e");
cx.assert_state("<?«ˇphp»\n\n$someVariable = 2;", Mode::HelixNormal);
cx.simulate_keystrokes("g e");
cx.assert_state("«ˇ<?»php\n\n$someVariable = 2;", Mode::HelixNormal);
cx.set_state("fooBarBazˇ", Mode::HelixNormal);
cx.simulate_keystrokes("g e");
cx.assert_state("fooBar«ˇBaz»", Mode::HelixNormal);
cx.simulate_keystrokes("g e");
cx.assert_state("foo«ˇBar»Baz", Mode::HelixNormal);
cx.simulate_keystrokes("g e");
cx.assert_state("«ˇfoo»BarBaz", Mode::HelixNormal);
}
#[gpui::test]
async fn test_delete(cx: &mut gpui::TestAppContext) {
let mut cx = VimTestContext::new(cx, true).await;
+2 -1
View File
@@ -1924,9 +1924,10 @@ fn next_subword_start(
let found_subword_start = is_subword_start(left, right, ".$_-");
let is_word_start = (left_kind != right_kind)
&& (!right.is_ascii_punctuation() || is_stopping_punct(right));
let found = (!right.is_whitespace() && (is_word_start || found_subword_start))
|| at_newline && crossed_newline
|| at_newline && left == '\n'; // Prevents skipping repeated empty lines
|| right == '\n' && left == '\n'; // Prevents skipping repeated empty lines
crossed_newline |= at_newline;
found