mirror of
https://github.com/zed-industries/zed.git
synced 2026-06-01 03:14:56 +07:00
vim: Add Helix jump-to-word support to Vim mode (#55492)
Closes #55481 Adds Vim-mode access to the existing Helix jump-to-word overlay via `g z`. We use `g z` because it is currently unassigned in Vim mode, while `g w` is already used for rewrap. Most of the implementation lives in `helix.rs` because the existing jump overlay, label generation, and Helix/Vim modal behavior are currently intertwined there. This keeps the change small and reuses the existing navigation overlay logic instead of doing a broader refactor. In Vim normal mode, jump labels behave like a cursor motion: selecting a label moves the cursor to the start of the target word without selecting it. In Vim visual mode, jump labels extend the selection like a Vim word-start motion, preserving Vim’s inclusive visual-selection behavior. Self-Review Checklist: - [x] I've reviewed my own diff for quality, security, and reliability - [x] Unsafe blocks (if any) have justifying comments - [x] The content is consistent with the [UI/UX checklist](https://github.com/zed-industries/zed/blob/main/CONTRIBUTING.md#uiux-checklist) - [x] Tests cover the new/changed behavior - [x] Performance impact has been considered and is acceptable Release Notes: - Added Vim-mode jump-to-word navigation on `g z`.
This commit is contained in:
parent
064a17fd12
commit
249f427f10
4 changed files with 131 additions and 7 deletions
|
|
@ -983,10 +983,17 @@ impl Vim {
|
|||
window: &mut Window,
|
||||
cx: &mut Context<Self>,
|
||||
) {
|
||||
let behaviour = if self.mode.is_visual() {
|
||||
HelixJumpBehaviour::Extend
|
||||
} else {
|
||||
HelixJumpBehaviour::Move
|
||||
let behaviour = match self.mode {
|
||||
// Vim normal mode treats jump-to-word as a cursor motion, while Helix
|
||||
// normal mode treats the cursor as a single-character selection.
|
||||
Mode::Normal => HelixJumpBehaviour::MoveToWordStart,
|
||||
// Vim visual mode extends like a motion, so the cursor stops at the
|
||||
// same word boundary as normal mode instead of selecting the word.
|
||||
Mode::Visual | Mode::VisualLine | Mode::VisualBlock => {
|
||||
HelixJumpBehaviour::ExtendToWordStart
|
||||
}
|
||||
Mode::HelixSelect => HelixJumpBehaviour::Extend,
|
||||
_ => HelixJumpBehaviour::Move,
|
||||
};
|
||||
self.start_helix_jump(behaviour, window, cx);
|
||||
}
|
||||
|
|
@ -1709,7 +1716,7 @@ mod test {
|
|||
use editor::{HighlightKey, MultiBufferOffset};
|
||||
use gpui::{KeyBinding, UpdateGlobal, VisualTestContext};
|
||||
use indoc::indoc;
|
||||
use language::Point;
|
||||
use language::{CursorShape, Point};
|
||||
use project::FakeFs;
|
||||
use search::{ProjectSearchView, project_search};
|
||||
use serde_json::json;
|
||||
|
|
@ -1718,7 +1725,7 @@ mod test {
|
|||
use util::path;
|
||||
use workspace::{DeploySearch, MultiWorkspace};
|
||||
|
||||
use super::HELIX_JUMP_LABEL_LIMIT;
|
||||
use super::{HELIX_JUMP_LABEL_LIMIT, HelixJumpToWord};
|
||||
use crate::{
|
||||
HELIX_JUMP_OVERLAY_KEY, Vim, VimAddon,
|
||||
state::{Mode, Operator},
|
||||
|
|
@ -1772,7 +1779,11 @@ mod test {
|
|||
}
|
||||
|
||||
fn jump_to_word(cx: &mut VimTestContext, target_word: &str) {
|
||||
cx.simulate_keystrokes("g w");
|
||||
jump_to_word_with_keystrokes(cx, "g w", target_word);
|
||||
}
|
||||
|
||||
fn jump_to_word_with_keystrokes(cx: &mut VimTestContext, keystrokes: &str, target_word: &str) {
|
||||
cx.simulate_keystrokes(keystrokes);
|
||||
|
||||
let label = helix_jump_label_for_word(cx, target_word);
|
||||
|
||||
|
|
@ -1782,6 +1793,16 @@ mod test {
|
|||
cx.simulate_keystrokes(&format!("{first} {second}"));
|
||||
}
|
||||
|
||||
fn bind_vim_jump_to_word(cx: &mut VimTestContext, keystrokes: &'static str) {
|
||||
cx.update(|_, cx| {
|
||||
cx.bind_keys([KeyBinding::new(
|
||||
keystrokes,
|
||||
HelixJumpToWord,
|
||||
Some("vim_mode == normal || vim_mode == visual"),
|
||||
)])
|
||||
});
|
||||
}
|
||||
|
||||
fn active_helix_jump_overlay_counts(cx: &mut VimTestContext) -> (usize, usize) {
|
||||
let covered_text_range_count = cx.update_editor(|editor, window, cx| {
|
||||
let snapshot = editor.snapshot(window, cx);
|
||||
|
|
@ -3468,6 +3489,70 @@ mod test {
|
|||
assert_eq!(cx.active_operator(), None);
|
||||
}
|
||||
|
||||
#[gpui::test]
|
||||
async fn test_vim_jump_moves_to_target_word_start(cx: &mut gpui::TestAppContext) {
|
||||
let mut cx = VimTestContext::new(cx, true).await;
|
||||
bind_vim_jump_to_word(&mut cx, "g z");
|
||||
cx.set_state("ˇone two three", Mode::Normal);
|
||||
|
||||
jump_to_word_with_keystrokes(&mut cx, "g z", "two");
|
||||
|
||||
cx.assert_state("one ˇtwo three", Mode::Normal);
|
||||
assert_eq!(cx.active_operator(), None);
|
||||
}
|
||||
|
||||
#[gpui::test]
|
||||
async fn test_vim_jump_keeps_normal_cursor_shape(cx: &mut gpui::TestAppContext) {
|
||||
let mut cx = VimTestContext::new(cx, true).await;
|
||||
bind_vim_jump_to_word(&mut cx, "g z");
|
||||
cx.update(|_, cx| {
|
||||
SettingsStore::update_global(cx, |store, cx| {
|
||||
store.update_user_settings(cx, |settings| {
|
||||
settings.vim.get_or_insert_default().cursor_shape =
|
||||
Some(settings::CursorShapeSettings {
|
||||
normal: Some(settings::CursorShape::Bar),
|
||||
..Default::default()
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
cx.set_state("ˇone two three", Mode::Normal);
|
||||
|
||||
cx.simulate_keystrokes("g z");
|
||||
|
||||
assert!(
|
||||
matches!(cx.active_operator(), Some(Operator::HelixJump { .. })),
|
||||
"expected HelixJump operator to be active"
|
||||
);
|
||||
cx.update_editor(|editor, _, _| {
|
||||
assert_eq!(editor.cursor_shape(), CursorShape::Bar);
|
||||
});
|
||||
}
|
||||
|
||||
#[gpui::test]
|
||||
async fn test_vim_visual_jump_extends_selection(cx: &mut gpui::TestAppContext) {
|
||||
let mut cx = VimTestContext::new(cx, true).await;
|
||||
bind_vim_jump_to_word(&mut cx, "g z");
|
||||
cx.set_state("one «twoˇ» three four", Mode::Visual);
|
||||
|
||||
jump_to_word_with_keystrokes(&mut cx, "g z", "three");
|
||||
|
||||
cx.assert_state("one «two tˇ»hree four", Mode::Visual);
|
||||
assert_eq!(cx.active_operator(), None);
|
||||
}
|
||||
|
||||
#[gpui::test]
|
||||
async fn test_vim_visual_jump_extends_selection_backward(cx: &mut gpui::TestAppContext) {
|
||||
let mut cx = VimTestContext::new(cx, true).await;
|
||||
bind_vim_jump_to_word(&mut cx, "g z");
|
||||
cx.set_state("one two «threeˇ» four", Mode::Visual);
|
||||
|
||||
jump_to_word_with_keystrokes(&mut cx, "g z", "one");
|
||||
|
||||
cx.assert_state("«ˇone two three» four", Mode::Visual);
|
||||
assert_eq!(cx.active_operator(), None);
|
||||
}
|
||||
|
||||
#[gpui::test]
|
||||
async fn test_helix_jump_extends_selection_forward(cx: &mut gpui::TestAppContext) {
|
||||
let mut cx = VimTestContext::new(cx, true).await;
|
||||
|
|
|
|||
|
|
@ -176,7 +176,9 @@ pub struct HelixJumpLabel {
|
|||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
pub enum HelixJumpBehaviour {
|
||||
Move,
|
||||
MoveToWordStart,
|
||||
Extend,
|
||||
ExtendToWordStart,
|
||||
}
|
||||
|
||||
#[derive(Default, Clone, Debug)]
|
||||
|
|
|
|||
|
|
@ -1362,6 +1362,10 @@ impl Vim {
|
|||
Mode::Normal => {
|
||||
if let Some(operator) = self.operator_stack.last() {
|
||||
match operator {
|
||||
// Vim jump labels are transient navigation, so keep the
|
||||
// user's normal cursor shape while waiting for the label.
|
||||
Operator::HelixJump { .. } => cursor_shape.normal,
|
||||
|
||||
// Navigation operators -> Block cursor
|
||||
Operator::FindForward { .. }
|
||||
| Operator::FindBackward { .. }
|
||||
|
|
@ -1828,6 +1832,28 @@ impl Vim {
|
|||
s.select_anchor_ranges([candidate.range.clone()])
|
||||
});
|
||||
}
|
||||
HelixJumpBehaviour::MoveToWordStart => {
|
||||
editor.change_selections(Default::default(), window, cx, |s| {
|
||||
// Vim users expect jump labels to behave like motions, leaving
|
||||
// normal mode at the label instead of selecting the word.
|
||||
s.select_anchor_ranges([candidate.range.start..candidate.range.start])
|
||||
});
|
||||
}
|
||||
HelixJumpBehaviour::ExtendToWordStart => {
|
||||
editor.change_selections(Default::default(), window, cx, |s| {
|
||||
s.move_with(&mut |map, selection| {
|
||||
let word_start = candidate.range.start.to_display_point(map);
|
||||
let tail = selection.tail();
|
||||
|
||||
if word_start >= tail {
|
||||
selection
|
||||
.set_head(motion::right(map, word_start, 1), SelectionGoal::None);
|
||||
} else {
|
||||
selection.set_head_tail(word_start, selection.end, SelectionGoal::None);
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
HelixJumpBehaviour::Extend => {
|
||||
editor.change_selections(Default::default(), window, cx, |s| {
|
||||
s.move_with(&mut |map, selection| {
|
||||
|
|
|
|||
|
|
@ -540,6 +540,17 @@ The [Sneak motion](https://github.com/justinmk/vim-sneak) feature allows for qui
|
|||
}
|
||||
```
|
||||
|
||||
The Helix-style jump-to-word action shows jump labels at visible word starts. It has no default binding in Vim mode, but you can enable it by adding a keybinding to your keymap. This example uses `g w`, which matches the default Helix binding, but overrides Vim mode's default rewrap binding.
|
||||
|
||||
```json [keymap]
|
||||
{
|
||||
"context": "vim_mode == normal || vim_mode == visual",
|
||||
"bindings": {
|
||||
"g w": "vim::HelixJumpToWord"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
The [vim-exchange](https://github.com/tommcdo/vim-exchange) feature does not have a default binding for visual mode, as the `shift-x` binding conflicts with the default `shift-x` binding for visual mode (`vim::VisualDeleteLine`). To assign the default vim-exchange binding, add the following keybinding to your keymap:
|
||||
|
||||
```json [keymap]
|
||||
|
|
|
|||
Loading…
Reference in a new issue