Expand Vim change commands and add character replacement (#39661)

## What changed

- Add the configurable `vim_normal.replace_char` action, bound to `r` by default, to replace the grapheme under the cursor while remaining in normal mode.
- Support change-operator motions such as `cw`, `c$`, `cj`, and `ck`, plus the repeated `cc` command for changing the current line.
- Let `Esc` cancel a pending replacement before it reaches composer-level handling.
- Preserve existing custom Vim bindings and chord prefixes when introducing the new default.

## Testing

- Cover replacement, grapheme boundaries, remapping and unbinding, change motions, cancellation, keymap conflicts, and keymap picker snapshots.

GitOrigin-RevId: 98feb4eeb57142c37adb73abfe58d1f703b9270d
This commit is contained in:
Benjamin Carlsson authored and copyberry committed 2026-08-20 08:13:56 +00:00
1 parent e3e5ad2847
commit 631d5a8b02
15 files changed
+377 -31

No files matched your search

@@ -27,6 +27,7 @@ pub(super) enum VimPending {
operator: VimOperator,
scope: VimTextObjectScope,
},
Replace,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
@@ -0,0 +1,57 @@
//! Pending Vim editing commands.
use super::TextArea;
use super::VimPending;
use crate::key_hint::KeyBindingListExt;
use crossterm::event::KeyCode;
use crossterm::event::KeyEvent;
use crossterm::event::KeyModifiers;
impl TextArea {
pub(super) fn handle_vim_extra_command(&mut self, event: KeyEvent) -> bool {
if self.vim_normal_keymap.replace_char.is_pressed(event)
&& self.cursor_pos < self.end_of_current_line()
{
self.vim_pending = VimPending::Replace;
return true;
}
false
}
pub(super) fn handle_vim_pending_command(&mut self, pending: VimPending, event: KeyEvent) {
match pending {
VimPending::Replace => {
if let Some(ch) = vim_command_char(event) {
let start = self.cursor_pos;
let end = self.next_atomic_boundary(start);
self.replace_range(start..end, &ch.to_string());
self.set_cursor(start + usize::from(ch == '\n'));
}
}
VimPending::None | VimPending::Operator(_) | VimPending::TextObject { .. } => {}
}
}
}
fn vim_command_char(event: KeyEvent) -> Option<char> {
if event.code == KeyCode::Enter {
return Some('\n');
}
let KeyCode::Char(ch) = event.code else {
return None;
};
match event.modifiers {
KeyModifiers::NONE => Some(ch),
KeyModifiers::SHIFT => Some(if ch.is_ascii_lowercase() {
ch.to_ascii_uppercase()
} else {
ch
}),
modifiers if crate::key_hint::is_altgr(modifiers) => Some(ch),
_ => None,
}
}
#[cfg(test)]
#[path = "vim_commands_tests.rs"]
mod tests;
@@ -0,0 +1,159 @@
use super::super::TextArea;
use crossterm::event::KeyCode;
use crossterm::event::KeyEvent;
use crossterm::event::KeyModifiers;
use pretty_assertions::assert_eq;
fn vim_textarea(text: &str, cursor: usize) -> TextArea {
let mut textarea = TextArea::new();
textarea.insert_str(text);
textarea.set_cursor(cursor);
textarea.set_vim_enabled(/*enabled*/ true);
textarea
}
fn keys(textarea: &mut TextArea, keys: &str) {
for key in keys.chars() {
let code = if key == '\n' {
KeyCode::Enter
} else {
KeyCode::Char(key)
};
textarea.input(KeyEvent::new(code, KeyModifiers::NONE));
}
}
fn escape(textarea: &mut TextArea) {
textarea.input(KeyEvent::new(KeyCode::Esc, KeyModifiers::NONE));
}
#[test]
fn replace_character_preserves_normal_mode_and_grapheme_boundaries() {
let mut textarea = vim_textarea("a👩‍💻c", /*cursor*/ 1);
keys(&mut textarea, "rZ");
assert_eq!(textarea.text(), "aZc");
assert_eq!(textarea.cursor(), 1);
assert_eq!(textarea.vim_mode_label(), Some("Normal"));
keys(&mut textarea, "r");
assert_eq!(
textarea.keymap_context(),
crate::keymap::KeymapContext::Editor
);
escape(&mut textarea);
assert_eq!(textarea.text(), "aZc");
keys(&mut textarea, "r\n");
assert_eq!(textarea.text(), "a\nc");
assert_eq!(textarea.cursor(), 2);
#[cfg(windows)]
{
keys(&mut textarea, "r");
textarea.input(KeyEvent::new(
KeyCode::Char('@'),
KeyModifiers::CONTROL | KeyModifiers::ALT,
));
assert_eq!(textarea.text(), "a\n@");
}
}
#[test]
fn replace_character_uses_configured_binding_and_supports_unbinding() {
let mut textarea = vim_textarea("abc", /*cursor*/ 0);
let mut keymap = crate::keymap::RuntimeKeymap::defaults();
keymap.vim_normal.replace_char = vec![crate::key_hint::plain(KeyCode::Char('z'))];
textarea.set_keymap_bindings(&keymap);
keys(&mut textarea, "zQ");
assert_eq!(textarea.text(), "Qbc");
keymap.vim_normal.replace_char.clear();
textarea.set_keymap_bindings(&keymap);
keys(&mut textarea, "zR");
assert_eq!(textarea.text(), "Qbc");
}
#[test]
fn change_accepts_word_line_and_repeated_operator_motions() {
let mut textarea = vim_textarea("hello world\nnext", /*cursor*/ 0);
keys(&mut textarea, "cw");
assert_eq!(textarea.text(), " world\nnext");
assert_eq!(textarea.vim_mode_label(), Some("Insert"));
let mut textarea = vim_textarea("hello world\nnext", /*cursor*/ 1);
keys(&mut textarea, "c$");
assert_eq!(textarea.text(), "h\nnext");
assert_eq!(textarea.vim_mode_label(), Some("Insert"));
let mut textarea = vim_textarea("first\nsecond\nthird", /*cursor*/ 8);
keys(&mut textarea, "cc");
assert_eq!(textarea.text(), "first\n\nthird");
assert_eq!(textarea.cursor(), "first\n".len());
keys(&mut textarea, "X");
escape(&mut textarea);
keys(&mut textarea, "p");
assert_eq!(textarea.text(), "first\nX\nsecond\nthird");
for (text, cursor, command, expected) in [
("a b", 0, "cw", " b"),
("\nnext", 0, "cw", "\nnext"),
("one\ntwo\nthree", 0, "cj", "\nthree"),
("one\ntwo\nthree", 8, "ck", "one\n"),
("one\ntwo\n", 8, "ck", "one\n"),
("\nnext", 0, "c$", "\nnext"),
] {
let mut textarea = vim_textarea(text, cursor);
keys(&mut textarea, command);
assert_eq!(textarea.text(), expected);
assert_eq!(textarea.vim_mode_label(), Some("Insert"));
}
for (cursor, command) in [(0, "ck"), (4, "cj")] {
let mut textarea = vim_textarea("one\ntwo", cursor);
keys(&mut textarea, command);
assert_eq!(
(textarea.text(), textarea.vim_mode_label()),
("one\ntwo", Some("Normal"))
);
}
let mut textarea = vim_textarea("hello world", /*cursor*/ 0);
textarea.vim_operator_keymap.motion_word_forward =
vec![crate::key_hint::plain(KeyCode::Char('c'))];
keys(&mut textarea, "cc");
assert_eq!(textarea.text(), " world");
}
#[test]
fn pending_replacement_owns_escape_before_turn_interruption() {
let mut textarea = vim_textarea("alpha", /*cursor*/ 0);
let escape = KeyEvent::new(KeyCode::Esc, KeyModifiers::NONE);
assert!(!textarea.should_handle_vim_insert_escape(escape));
keys(&mut textarea, "r");
assert!(textarea.should_handle_vim_insert_escape(escape));
textarea.input(escape);
assert!(!textarea.should_handle_vim_insert_escape(escape));
assert_eq!(textarea.text(), "alpha");
}
#[test]
fn editor_commands_have_visual_snapshot_coverage() {
let mut textarea = vim_textarea("alpha beta\ngamma delta", /*cursor*/ 0);
let mut states = Vec::new();
for command in ["rZ", "cw"] {
keys(&mut textarea, command);
states.push(format!(
"{command}: {}\n{}^",
textarea.text().replace('\n', "\\n"),
" ".repeat(textarea.cursor())
));
}
insta::assert_snapshot!(states.join("\n\n"), @r###"
rZ: Zlpha beta\ngamma delta
^
cw: beta\ngamma delta
^
"###);
}