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

+75 -3
View File
@@ -161,7 +161,7 @@ pub(crate) struct EditorKeymap {
///
/// Normal mode is the resting state when Vim is enabled. Pressing a movement
/// or editing key here either moves the cursor, triggers an operator-pending
/// state (via `start_delete_operator` / `start_yank_operator`), or transitions
/// state (via `start_delete_operator`, `start_yank_operator`, or `start_change_operator`), or transitions
/// to insert mode. Default bindings include both `shift(letter)` and
/// `plain(UPPERCASE)` variants for uppercase commands like `A`, `I`, `O` to
/// handle cross-terminal shift-reporting inconsistencies.
@@ -183,6 +183,7 @@ pub(crate) struct VimNormalKeymap {
pub(crate) move_line_start: Vec<KeyBinding>,
pub(crate) move_line_end: Vec<KeyBinding>,
pub(crate) delete_char: Vec<KeyBinding>,
pub(crate) replace_char: Vec<KeyBinding>,
pub(crate) substitute_char: Vec<KeyBinding>,
pub(crate) delete_to_line_end: Vec<KeyBinding>,
pub(crate) change_to_line_end: Vec<KeyBinding>,
@@ -194,9 +195,9 @@ pub(crate) struct VimNormalKeymap {
pub(crate) cancel_operator: Vec<KeyBinding>,
}
/// Vim operator-pending keybindings active after `d` or `y` in normal mode.
/// Vim operator-pending keybindings active after `d`, `y`, or `c` in normal mode.
///
/// When an operator (`start_delete_operator` or `start_yank_operator`) is
/// When a delete, yank, or change operator is
/// pressed, the next keypress is matched against this context to determine the
/// motion range. Repeating the operator key (`dd`, `yy`) acts on the whole
/// line. `Esc` cancels the pending operator and returns to normal mode.
@@ -704,6 +705,7 @@ impl RuntimeKeymap {
move_line_start: resolve_local!(keymap, defaults, vim_normal, move_line_start),
move_line_end: resolve_local!(keymap, defaults, vim_normal, move_line_end),
delete_char: resolve_local!(keymap, defaults, vim_normal, delete_char),
replace_char: resolve_local!(keymap, defaults, vim_normal, replace_char),
substitute_char: resolve_local!(keymap, defaults, vim_normal, substitute_char),
delete_to_line_end: resolve_local!(keymap, defaults, vim_normal, delete_to_line_end),
change_to_line_end: resolve_local!(keymap, defaults, vim_normal, change_to_line_end),
@@ -790,6 +792,14 @@ impl RuntimeKeymap {
keymap.vim_normal.delete_char.as_ref(),
vim_normal.delete_char.as_slice(),
),
(
keymap.vim_normal.replace_char.as_ref(),
vim_normal.replace_char.as_slice(),
),
(
keymap.vim_normal.substitute_char.as_ref(),
vim_normal.substitute_char.as_slice(),
),
(
keymap.vim_normal.change_to_line_end.as_ref(),
vim_normal.change_to_line_end.as_slice(),
@@ -834,6 +844,15 @@ impl RuntimeKeymap {
.substitute_char
.retain(|binding| !configured_vim_normal_bindings_to_preserve.contains(binding));
}
if keymap.vim_normal.replace_char.is_none() {
vim_normal.replace_char.retain(|binding| {
!configured_vim_normal_bindings_to_preserve.contains(binding)
&& !chords.bindings.iter().any(|chord| {
chord.action.context == KeymapContext::VimNormal
&& chord.chord.prefix.parts() == binding.parts()
})
});
}
let mut vim_operator = VimOperatorKeymap {
delete_line: resolve_local!(keymap, defaults, vim_operator, delete_line),
@@ -1294,6 +1313,7 @@ impl RuntimeKeymap {
shift(KeyCode::Char('$'))
],
delete_char: default_bindings![plain(KeyCode::Char('x'))],
replace_char: default_bindings![plain(KeyCode::Char('r'))],
substitute_char: default_bindings![plain(KeyCode::Char('s'))],
delete_to_line_end: default_bindings![
shift(KeyCode::Char('d')),
@@ -1786,6 +1806,7 @@ impl RuntimeKeymap {
),
("move_line_end", self.vim_normal.move_line_end.as_slice()),
("delete_char", self.vim_normal.delete_char.as_slice()),
("replace_char", self.vim_normal.replace_char.as_slice()),
(
"substitute_char",
self.vim_normal.substitute_char.as_slice(),
@@ -2806,6 +2827,57 @@ mod tests {
expect_conflict(&keymap, "move_left", "substitute_char");
}
#[test]
fn configured_legacy_vim_normal_bindings_prune_new_replace_default() {
let mut keymap = TuiKeymap::default();
keymap.vim_normal.move_left = Some(one("r"));
let runtime = RuntimeKeymap::from_config(&keymap).expect("config should parse");
assert_eq!(
runtime.vim_normal.move_left,
vec![key_hint::plain(KeyCode::Char('r'))]
);
assert_eq!(runtime.vim_normal.replace_char, Vec::new());
}
#[test]
fn configured_substitute_binding_prunes_new_replace_default() {
let mut keymap = TuiKeymap::default();
keymap.vim_normal.substitute_char = Some(one("r"));
let runtime = RuntimeKeymap::from_config(&keymap).expect("config should parse");
assert_eq!(
runtime.vim_normal.substitute_char,
vec![key_hint::plain(KeyCode::Char('r'))]
);
assert!(runtime.vim_normal.replace_char.is_empty());
}
#[test]
fn configured_vim_normal_chord_prefix_prunes_new_replace_default() {
let mut keymap = TuiKeymap::default();
keymap.vim_normal.move_line_start = Some(one("r g"));
let runtime = RuntimeKeymap::from_config(&keymap).expect("config should parse");
assert!(runtime.vim_normal.replace_char.is_empty());
assert!(runtime.chords.bindings.iter().any(|binding| {
binding.action.context == KeymapContext::VimNormal
&& binding.chord.prefix == key_hint::plain(KeyCode::Char('r'))
}));
}
#[test]
fn explicit_new_vim_normal_replace_binding_still_conflicts_with_legacy_binding() {
let mut keymap = TuiKeymap::default();
keymap.vim_normal.move_left = Some(one("r"));
keymap.vim_normal.replace_char = Some(one("r"));
expect_conflict(&keymap, "move_left", "replace_char");
}
#[test]
fn configured_legacy_vim_operator_bindings_prune_new_text_object_defaults() {
let mut keymap = TuiKeymap::default();