Add Vim buffer jump motions (#40958)

## What changed

- Add `gg` and `G` motions to jump to the first and last buffer lines.
- Support the motions with delete, change, and yank operators, including dot-repeat for edits.
- Expose normal-mode and operator-motion bindings in the configurable keymap while preserving conflicting user bindings.

## Testing

- Cover buffer jumps, operator combinations, repeat behavior, chord routing, and custom remapping.

GitOrigin-RevId: 24a9178cf7964053aaf9a8cd268c5434dda0778e
This commit is contained in:
Benjamin Carlsson authored and copyberry committed 2026-08-26 19:31:52 +00:00
1 parent 6ac012a0d4
commit d47e5cc0e2
17 files changed
+433 -13

No files matched your search

@@ -52,6 +52,9 @@ pub(super) enum VimEditTarget {
motion: VimFindMotion,
target: char,
},
BufferJump {
last: bool,
},
}
#[derive(Clone, Copy, Debug)]
@@ -262,6 +265,9 @@ impl TextArea {
return false;
}
}
VimEditTarget::BufferJump { last } => {
self.jump_to_vim_buffer_line(last, Some(operator));
}
}
if operator == VimOperator::Change {
return self.vim_mode == VimMode::Insert;
@@ -330,6 +336,10 @@ impl TextArea {
self.start_vim_find(VimFindMotion::TillForward, /*operator*/ None);
} else if self.vim_normal_keymap.till_backward.is_pressed(event) {
self.start_vim_find(VimFindMotion::TillBackward, /*operator*/ None);
} else if self.vim_normal_keymap.jump_top.is_pressed(event) {
self.jump_to_vim_buffer_line(/*last*/ false, /*operator*/ None);
} else if self.vim_normal_keymap.jump_bottom.is_pressed(event) {
self.jump_to_vim_buffer_line(/*last*/ true, /*operator*/ None);
} else {
return false;
}
@@ -365,6 +375,25 @@ impl TextArea {
.is_pressed(event)
{
self.start_vim_find(VimFindMotion::TillBackward, Some(operator));
} else if self.vim_operator_keymap.motion_jump_top.is_pressed(event)
|| self
.vim_operator_keymap
.motion_jump_bottom
.is_pressed(event)
{
let last = self
.vim_operator_keymap
.motion_jump_bottom
.is_pressed(event);
match operator {
VimOperator::Delete => {
self.start_vim_edit(VimAction::Delete(VimEditTarget::BufferJump { last }));
}
VimOperator::Change => {
self.start_vim_edit(VimAction::Change(VimEditTarget::BufferJump { last }));
}
VimOperator::Yank => self.jump_to_vim_buffer_line(last, Some(operator)),
}
} else {
return false;
}
@@ -475,6 +504,33 @@ impl TextArea {
true
}
fn jump_to_vim_buffer_line(&mut self, last: bool, operator: Option<VimOperator>) {
if let Some(operator) = operator {
let current = self.current_line_range_with_newline();
let range = if last {
current.start..self.text.len()
} else {
0..current.end
};
match operator {
VimOperator::Delete => self.kill_line_range(range),
VimOperator::Yank => self.yank_line_range(range),
VimOperator::Change => {
self.kill_line_range(range);
self.vim_mode = VimMode::Insert;
}
}
return;
}
let start = if last {
self.beginning_of_line(self.text.len())
} else {
0
};
self.set_cursor(start);
self.set_cursor(self.first_non_blank_of_current_line());
}
fn is_vim_command_target(&self, position: usize) -> bool {
!self
.elements
@@ -1,9 +1,14 @@
use super::super::TextArea;
use super::VimAction;
use crate::keymap::KeyChordMatch;
use crate::keymap::KeyChordMatcher;
use crate::keymap::KeymapContextSet;
use crate::keymap::RuntimeKeymap;
use crossterm::event::KeyCode;
use crossterm::event::KeyEvent;
use crossterm::event::KeyModifiers;
use pretty_assertions::assert_eq;
use tokio::time::Instant;
fn vim_textarea(text: &str, cursor: usize) -> TextArea {
let mut textarea = TextArea::new();
@@ -14,13 +19,25 @@ fn vim_textarea(text: &str, cursor: usize) -> TextArea {
}
fn keys(textarea: &mut TextArea, keys: &str) {
let keymap = RuntimeKeymap::defaults();
let mut matcher = KeyChordMatcher::default();
for key in keys.chars() {
let code = if key == '\n' {
KeyCode::Enter
} else {
KeyCode::Char(key)
};
textarea.input(KeyEvent::new(code, KeyModifiers::NONE));
let event = KeyEvent::new(code, KeyModifiers::NONE);
match matcher.advance(
event,
&keymap.chords,
KeymapContextSet::new(textarea.keymap_context()),
Instant::now(),
) {
KeyChordMatch::PassThrough => textarea.input(event),
KeyChordMatch::Completed(event) => textarea.input(event),
KeyChordMatch::Pending(_) | KeyChordMatch::Cancelled | KeyChordMatch::Ignored => {}
}
}
}
@@ -383,7 +400,7 @@ fn find_and_till_handle_missing_cancelled_and_adjacent_targets() {
}
#[test]
fn dot_repeat_replays_character_find_operators() {
fn dot_repeat_replays_character_find_and_buffer_jump_operators() {
let mut textarea = vim_textarea("one:two:three", /*cursor*/ 0);
keys(&mut textarea, "df:.");
assert_eq!(textarea.text(), "three");
@@ -403,6 +420,10 @@ fn dot_repeat_replays_character_find_operators() {
(expected, Some("Normal"))
);
}
let mut textarea = vim_textarea("one\ntwo\nthree\nfour\nfive", "one\n".len());
keys(&mut textarea, "dggj.");
assert_eq!(textarea.text(), "five");
}
#[test]
@@ -470,6 +491,9 @@ fn character_find_and_operator_motion_use_configured_bindings() {
#[test]
fn uppercase_commands_accept_shift_only_terminal_events() {
let mut textarea = vim_textarea("alpha\nbeta", /*cursor*/ 0);
textarea.input(KeyEvent::new(KeyCode::Char('g'), KeyModifiers::SHIFT));
assert_eq!(textarea.cursor(), "alpha\n".len());
textarea.set_cursor("alpha\nbe".len());
textarea.input(KeyEvent::new(KeyCode::Char('f'), KeyModifiers::SHIFT));
keys(&mut textarea, "b");
@@ -480,6 +504,35 @@ fn uppercase_commands_accept_shift_only_terminal_events() {
assert_eq!(textarea.cursor(), "alpha\nb".len());
}
#[test]
fn buffer_jumps_target_first_non_blank_and_support_operators() {
let mut textarea = vim_textarea(" first\n second\n third", /*cursor*/ 2);
keys(&mut textarea, "G");
assert_eq!(textarea.cursor(), " first\n second\n ".len());
keys(&mut textarea, "gg");
assert_eq!(textarea.cursor(), 2);
let mut textarea = vim_textarea("first\nsecond\nthird", "first\n".len());
keys(&mut textarea, "dG");
assert_eq!(textarea.text(), "first\n");
let mut textarea = vim_textarea("first\nsecond\nthird", "first\n".len());
keys(&mut textarea, "dgg");
assert_eq!(textarea.text(), "third");
let mut textarea = vim_textarea("one\ntwo\nthree\nfour", "one\n".len());
keys(&mut textarea, "dggp");
assert_eq!(textarea.text(), "three\none\ntwo\nfour");
let mut textarea = vim_textarea("one\ntwo\nthree", "one\n".len());
keys(&mut textarea, "yGp");
assert_eq!(textarea.text(), "one\ntwo\ntwo\nthree\nthree");
let mut textarea = vim_textarea("one\ntwo\nthree", "one\n".len());
keys(&mut textarea, "cG");
assert_eq!(textarea.vim_mode_label(), Some("Insert"));
}
#[test]
fn pending_replacement_owns_escape_before_turn_interruption() {
let mut textarea = vim_textarea("alpha", /*cursor*/ 0);
@@ -539,7 +592,7 @@ fn dot_repeat_has_visual_snapshot_coverage() {
fn find_and_navigation_have_visual_snapshot_coverage() {
let mut textarea = vim_textarea("alpha beta\ngamma delta", /*cursor*/ 0);
let mut states = Vec::new();
for command in ["tb", "fb", "Ta", "Fa"] {
for command in ["tb", "fb", "Ta", "Fa", "G", "gg"] {
keys(&mut textarea, command);
states.push(format!(
"{command}: {}\n{}^",
@@ -560,5 +613,10 @@ fn find_and_navigation_have_visual_snapshot_coverage() {
Fa: alpha beta\ngamma delta
^
G: alpha beta\ngamma delta
^
gg: alpha beta\ngamma delta
^
"###);
}