mirror of
https://github.com/openai/codex.git
synced 2026-11-13 14:46:14 +00:00
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:
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
|
||||
^
|
||||
"###);
|
||||
}
|
||||
Reference in new issue
Block a user