mirror of
https://github.com/openai/codex.git
synced 2026-11-03 16:23:51 +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
@@ -231,6 +231,8 @@ pub struct TuiVimNormalKeymap {
|
|||||||
pub move_line_end: Option<KeybindingsSpec>,
|
pub move_line_end: Option<KeybindingsSpec>,
|
||||||
/// Delete character under cursor (`x`).
|
/// Delete character under cursor (`x`).
|
||||||
pub delete_char: Option<KeybindingsSpec>,
|
pub delete_char: Option<KeybindingsSpec>,
|
||||||
|
/// Replace the character under the cursor (`r`).
|
||||||
|
pub replace_char: Option<KeybindingsSpec>,
|
||||||
/// Delete character under cursor and enter insert mode (`s`).
|
/// Delete character under cursor and enter insert mode (`s`).
|
||||||
pub substitute_char: Option<KeybindingsSpec>,
|
pub substitute_char: Option<KeybindingsSpec>,
|
||||||
/// Delete from cursor to end of line (`D`).
|
/// Delete from cursor to end of line (`D`).
|
||||||
|
|||||||
@@ -3627,6 +3627,7 @@
|
|||||||
"open_line_above": null,
|
"open_line_above": null,
|
||||||
"open_line_below": null,
|
"open_line_below": null,
|
||||||
"paste_after": null,
|
"paste_after": null,
|
||||||
|
"replace_char": null,
|
||||||
"start_change_operator": null,
|
"start_change_operator": null,
|
||||||
"start_delete_operator": null,
|
"start_delete_operator": null,
|
||||||
"start_yank_operator": null,
|
"start_yank_operator": null,
|
||||||
@@ -4406,6 +4407,7 @@
|
|||||||
"open_line_above": null,
|
"open_line_above": null,
|
||||||
"open_line_below": null,
|
"open_line_below": null,
|
||||||
"paste_after": null,
|
"paste_after": null,
|
||||||
|
"replace_char": null,
|
||||||
"start_change_operator": null,
|
"start_change_operator": null,
|
||||||
"start_delete_operator": null,
|
"start_delete_operator": null,
|
||||||
"start_yank_operator": null,
|
"start_yank_operator": null,
|
||||||
@@ -4813,6 +4815,14 @@
|
|||||||
],
|
],
|
||||||
"description": "Paste after cursor (`p`)."
|
"description": "Paste after cursor (`p`)."
|
||||||
},
|
},
|
||||||
|
"replace_char": {
|
||||||
|
"allOf": [
|
||||||
|
{
|
||||||
|
"$ref": "#/definitions/KeybindingsSpec"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"description": "Replace the character under the cursor (`r`)."
|
||||||
|
},
|
||||||
"start_change_operator": {
|
"start_change_operator": {
|
||||||
"allOf": [
|
"allOf": [
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -45,6 +45,7 @@ use std::sync::Arc;
|
|||||||
use unicode_segmentation::UnicodeSegmentation;
|
use unicode_segmentation::UnicodeSegmentation;
|
||||||
|
|
||||||
mod vim;
|
mod vim;
|
||||||
|
mod vim_commands;
|
||||||
mod wrapping;
|
mod wrapping;
|
||||||
use self::vim::VimMode;
|
use self::vim::VimMode;
|
||||||
use self::vim::VimMotion;
|
use self::vim::VimMotion;
|
||||||
@@ -295,6 +296,7 @@ impl TextArea {
|
|||||||
}
|
}
|
||||||
match self.vim_pending {
|
match self.vim_pending {
|
||||||
VimPending::None => KeymapContext::VimNormal,
|
VimPending::None => KeymapContext::VimNormal,
|
||||||
|
VimPending::Replace => KeymapContext::Editor,
|
||||||
VimPending::Operator(_) => KeymapContext::VimOperator,
|
VimPending::Operator(_) => KeymapContext::VimOperator,
|
||||||
VimPending::TextObject { .. } => KeymapContext::VimTextObject,
|
VimPending::TextObject { .. } => KeymapContext::VimTextObject,
|
||||||
}
|
}
|
||||||
@@ -342,12 +344,11 @@ impl TextArea {
|
|||||||
|
|
||||||
/// Return whether Escape should be intercepted before composer-level routing.
|
/// Return whether Escape should be intercepted before composer-level routing.
|
||||||
///
|
///
|
||||||
/// In Vim insert mode, Escape is an editing transition rather than a popup
|
/// In Vim insert mode or while a command is pending, Escape is an editing
|
||||||
/// cancel/backtrack shortcut. Letting the composer handle it first would
|
/// transition rather than a popup cancel/backtrack or turn-interrupt shortcut.
|
||||||
/// close UI surfaces while leaving the textarea in insert mode.
|
|
||||||
pub(crate) fn should_handle_vim_insert_escape(&self, event: KeyEvent) -> bool {
|
pub(crate) fn should_handle_vim_insert_escape(&self, event: KeyEvent) -> bool {
|
||||||
self.vim_enabled
|
self.vim_enabled
|
||||||
&& self.vim_mode == VimMode::Insert
|
&& (self.vim_mode == VimMode::Insert || !matches!(self.vim_pending, VimPending::None))
|
||||||
&& event.code == KeyCode::Esc
|
&& event.code == KeyCode::Esc
|
||||||
&& event.modifiers == KeyModifiers::NONE
|
&& event.modifiers == KeyModifiers::NONE
|
||||||
&& matches!(event.kind, KeyEventKind::Press | KeyEventKind::Repeat)
|
&& matches!(event.kind, KeyEventKind::Press | KeyEventKind::Repeat)
|
||||||
@@ -693,6 +694,10 @@ impl TextArea {
|
|||||||
self.handle_vim_text_object(operator, scope, event);
|
self.handle_vim_text_object(operator, scope, event);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
VimPending::Replace => {
|
||||||
|
self.handle_vim_pending_command(pending, event);
|
||||||
|
return;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if self.vim_normal_keymap.enter_insert.is_pressed(event) {
|
if self.vim_normal_keymap.enter_insert.is_pressed(event) {
|
||||||
@@ -822,7 +827,9 @@ impl TextArea {
|
|||||||
}
|
}
|
||||||
if self.vim_normal_keymap.cancel_operator.is_pressed(event) {
|
if self.vim_normal_keymap.cancel_operator.is_pressed(event) {
|
||||||
self.vim_pending = VimPending::None;
|
self.vim_pending = VimPending::None;
|
||||||
|
return;
|
||||||
}
|
}
|
||||||
|
self.handle_vim_extra_command(event);
|
||||||
}
|
}
|
||||||
|
|
||||||
fn handle_vim_operator(&mut self, op: VimOperator, event: KeyEvent) -> bool {
|
fn handle_vim_operator(&mut self, op: VimOperator, event: KeyEvent) -> bool {
|
||||||
@@ -845,12 +852,21 @@ impl TextArea {
|
|||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
if op != VimOperator::Change
|
if let Some(motion) = self.vim_motion_for_event(event) {
|
||||||
&& let Some(motion) = self.vim_motion_for_event(event)
|
|
||||||
{
|
|
||||||
self.apply_vim_operator(op, motion);
|
self.apply_vim_operator(op, motion);
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
if op == VimOperator::Change
|
||||||
|
&& self
|
||||||
|
.vim_normal_keymap
|
||||||
|
.start_change_operator
|
||||||
|
.is_pressed(event)
|
||||||
|
{
|
||||||
|
let range = self.beginning_of_current_line()..self.end_of_current_line();
|
||||||
|
self.kill_line_range(range);
|
||||||
|
self.vim_mode = VimMode::Insert;
|
||||||
|
return true;
|
||||||
|
}
|
||||||
false
|
false
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -912,14 +928,48 @@ impl TextArea {
|
|||||||
}
|
}
|
||||||
|
|
||||||
fn apply_vim_operator(&mut self, op: VimOperator, motion: VimMotion) {
|
fn apply_vim_operator(&mut self, op: VimOperator, motion: VimMotion) {
|
||||||
|
if op == VimOperator::Change && motion == VimMotion::WordForward {
|
||||||
|
let target = if self.text[self.cursor_pos..]
|
||||||
|
.chars()
|
||||||
|
.next()
|
||||||
|
.is_some_and(|ch| !ch.is_whitespace())
|
||||||
|
{
|
||||||
|
self.end_of_next_word()
|
||||||
|
} else {
|
||||||
|
self.beginning_of_next_word()
|
||||||
|
.min(self.end_of_current_line())
|
||||||
|
};
|
||||||
|
if target > self.cursor_pos {
|
||||||
|
self.apply_vim_operator_to_range(op, self.cursor_pos..target);
|
||||||
|
} else {
|
||||||
|
self.vim_mode = VimMode::Insert;
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
let Some(range) = self.range_for_motion(motion) else {
|
let Some(range) = self.range_for_motion(motion) else {
|
||||||
|
if op == VimOperator::Change && motion == VimMotion::LineEnd {
|
||||||
|
self.vim_mode = VimMode::Insert;
|
||||||
|
}
|
||||||
return;
|
return;
|
||||||
};
|
};
|
||||||
match op {
|
if op == VimOperator::Change && matches!(motion, VimMotion::Up | VimMotion::Down) {
|
||||||
VimOperator::Delete => self.kill_range(range),
|
if motion == VimMotion::Up && self.beginning_of_current_line() == 0
|
||||||
VimOperator::Yank => self.yank_range(range),
|
|| motion == VimMotion::Down && self.end_of_current_line() == self.text.len()
|
||||||
VimOperator::Change => {}
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
let retain_newline =
|
||||||
|
range.end < self.text.len() && self.text[range.clone()].ends_with('\n');
|
||||||
|
let start = range.start;
|
||||||
|
self.kill_line_range(range);
|
||||||
|
if retain_newline {
|
||||||
|
self.insert_str_at(start, "\n");
|
||||||
|
self.set_cursor(start);
|
||||||
|
}
|
||||||
|
self.vim_mode = VimMode::Insert;
|
||||||
|
return;
|
||||||
}
|
}
|
||||||
|
self.apply_vim_operator_to_range(op, range);
|
||||||
}
|
}
|
||||||
|
|
||||||
fn apply_vim_operator_to_range(&mut self, op: VimOperator, range: Range<usize>) {
|
fn apply_vim_operator_to_range(&mut self, op: VimOperator, range: Range<usize>) {
|
||||||
@@ -2741,19 +2791,11 @@ mod tests {
|
|||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn vim_text_object_cancellation_and_unsupported_change_motions_do_not_edit() {
|
fn vim_text_object_cancellation_does_not_edit() {
|
||||||
let mut t = ta_with("hello world");
|
let mut t = ta_with("hello world");
|
||||||
t.set_cursor(/*pos*/ 1);
|
t.set_cursor(/*pos*/ 1);
|
||||||
t.set_vim_enabled(/*enabled*/ true);
|
t.set_vim_enabled(/*enabled*/ true);
|
||||||
|
|
||||||
t.input(KeyEvent::new(KeyCode::Char('c'), KeyModifiers::NONE));
|
|
||||||
t.input(KeyEvent::new(KeyCode::Char('$'), KeyModifiers::NONE));
|
|
||||||
|
|
||||||
assert_eq!(t.text(), "hello world");
|
|
||||||
assert_eq!(t.kill_buffer, "");
|
|
||||||
assert_eq!(t.vim_mode_label(), Some("Normal"));
|
|
||||||
assert!(!t.is_vim_operator_pending());
|
|
||||||
|
|
||||||
t.input(KeyEvent::new(KeyCode::Char('d'), KeyModifiers::NONE));
|
t.input(KeyEvent::new(KeyCode::Char('d'), KeyModifiers::NONE));
|
||||||
t.input(KeyEvent::new(KeyCode::Char('i'), KeyModifiers::NONE));
|
t.input(KeyEvent::new(KeyCode::Char('i'), KeyModifiers::NONE));
|
||||||
assert!(t.is_vim_operator_pending());
|
assert!(t.is_vim_operator_pending());
|
||||||
|
|||||||
@@ -27,6 +27,7 @@ pub(super) enum VimPending {
|
|||||||
operator: VimOperator,
|
operator: VimOperator,
|
||||||
scope: VimTextObjectScope,
|
scope: VimTextObjectScope,
|
||||||
},
|
},
|
||||||
|
Replace,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
#[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
|
||||||
|
^
|
||||||
|
"###);
|
||||||
|
}
|
||||||
@@ -161,7 +161,7 @@ pub(crate) struct EditorKeymap {
|
|||||||
///
|
///
|
||||||
/// Normal mode is the resting state when Vim is enabled. Pressing a movement
|
/// 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
|
/// 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
|
/// to insert mode. Default bindings include both `shift(letter)` and
|
||||||
/// `plain(UPPERCASE)` variants for uppercase commands like `A`, `I`, `O` to
|
/// `plain(UPPERCASE)` variants for uppercase commands like `A`, `I`, `O` to
|
||||||
/// handle cross-terminal shift-reporting inconsistencies.
|
/// 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_start: Vec<KeyBinding>,
|
||||||
pub(crate) move_line_end: Vec<KeyBinding>,
|
pub(crate) move_line_end: Vec<KeyBinding>,
|
||||||
pub(crate) delete_char: Vec<KeyBinding>,
|
pub(crate) delete_char: Vec<KeyBinding>,
|
||||||
|
pub(crate) replace_char: Vec<KeyBinding>,
|
||||||
pub(crate) substitute_char: Vec<KeyBinding>,
|
pub(crate) substitute_char: Vec<KeyBinding>,
|
||||||
pub(crate) delete_to_line_end: Vec<KeyBinding>,
|
pub(crate) delete_to_line_end: Vec<KeyBinding>,
|
||||||
pub(crate) change_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>,
|
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
|
/// pressed, the next keypress is matched against this context to determine the
|
||||||
/// motion range. Repeating the operator key (`dd`, `yy`) acts on the whole
|
/// motion range. Repeating the operator key (`dd`, `yy`) acts on the whole
|
||||||
/// line. `Esc` cancels the pending operator and returns to normal mode.
|
/// 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_start: resolve_local!(keymap, defaults, vim_normal, move_line_start),
|
||||||
move_line_end: resolve_local!(keymap, defaults, vim_normal, move_line_end),
|
move_line_end: resolve_local!(keymap, defaults, vim_normal, move_line_end),
|
||||||
delete_char: resolve_local!(keymap, defaults, vim_normal, delete_char),
|
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),
|
substitute_char: resolve_local!(keymap, defaults, vim_normal, substitute_char),
|
||||||
delete_to_line_end: resolve_local!(keymap, defaults, vim_normal, delete_to_line_end),
|
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),
|
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(),
|
keymap.vim_normal.delete_char.as_ref(),
|
||||||
vim_normal.delete_char.as_slice(),
|
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(),
|
keymap.vim_normal.change_to_line_end.as_ref(),
|
||||||
vim_normal.change_to_line_end.as_slice(),
|
vim_normal.change_to_line_end.as_slice(),
|
||||||
@@ -834,6 +844,15 @@ impl RuntimeKeymap {
|
|||||||
.substitute_char
|
.substitute_char
|
||||||
.retain(|binding| !configured_vim_normal_bindings_to_preserve.contains(binding));
|
.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 {
|
let mut vim_operator = VimOperatorKeymap {
|
||||||
delete_line: resolve_local!(keymap, defaults, vim_operator, delete_line),
|
delete_line: resolve_local!(keymap, defaults, vim_operator, delete_line),
|
||||||
@@ -1294,6 +1313,7 @@ impl RuntimeKeymap {
|
|||||||
shift(KeyCode::Char('$'))
|
shift(KeyCode::Char('$'))
|
||||||
],
|
],
|
||||||
delete_char: default_bindings![plain(KeyCode::Char('x'))],
|
delete_char: default_bindings![plain(KeyCode::Char('x'))],
|
||||||
|
replace_char: default_bindings![plain(KeyCode::Char('r'))],
|
||||||
substitute_char: default_bindings![plain(KeyCode::Char('s'))],
|
substitute_char: default_bindings![plain(KeyCode::Char('s'))],
|
||||||
delete_to_line_end: default_bindings![
|
delete_to_line_end: default_bindings![
|
||||||
shift(KeyCode::Char('d')),
|
shift(KeyCode::Char('d')),
|
||||||
@@ -1786,6 +1806,7 @@ impl RuntimeKeymap {
|
|||||||
),
|
),
|
||||||
("move_line_end", self.vim_normal.move_line_end.as_slice()),
|
("move_line_end", self.vim_normal.move_line_end.as_slice()),
|
||||||
("delete_char", self.vim_normal.delete_char.as_slice()),
|
("delete_char", self.vim_normal.delete_char.as_slice()),
|
||||||
|
("replace_char", self.vim_normal.replace_char.as_slice()),
|
||||||
(
|
(
|
||||||
"substitute_char",
|
"substitute_char",
|
||||||
self.vim_normal.substitute_char.as_slice(),
|
self.vim_normal.substitute_char.as_slice(),
|
||||||
@@ -2806,6 +2827,57 @@ mod tests {
|
|||||||
expect_conflict(&keymap, "move_left", "substitute_char");
|
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]
|
#[test]
|
||||||
fn configured_legacy_vim_operator_bindings_prune_new_text_object_defaults() {
|
fn configured_legacy_vim_operator_bindings_prune_new_text_object_defaults() {
|
||||||
let mut keymap = TuiKeymap::default();
|
let mut keymap = TuiKeymap::default();
|
||||||
|
|||||||
@@ -284,6 +284,7 @@ define_runtime_action_bindings! {
|
|||||||
move_line_start,
|
move_line_start,
|
||||||
move_line_end,
|
move_line_end,
|
||||||
delete_char,
|
delete_char,
|
||||||
|
replace_char,
|
||||||
substitute_char,
|
substitute_char,
|
||||||
delete_to_line_end,
|
delete_to_line_end,
|
||||||
change_to_line_end,
|
change_to_line_end,
|
||||||
|
|||||||
@@ -135,6 +135,7 @@ pub(super) const KEYMAP_ACTIONS: &[KeymapActionDescriptor] = &[
|
|||||||
action("vim_normal", "Vim normal", "move_line_start", "Move to the start of the line."),
|
action("vim_normal", "Vim normal", "move_line_start", "Move to the start of the line."),
|
||||||
action("vim_normal", "Vim normal", "move_line_end", "Move to the end of the line."),
|
action("vim_normal", "Vim normal", "move_line_end", "Move to the end of the line."),
|
||||||
action("vim_normal", "Vim normal", "delete_char", "Delete the character under the cursor."),
|
action("vim_normal", "Vim normal", "delete_char", "Delete the character under the cursor."),
|
||||||
|
action("vim_normal", "Vim normal", "replace_char", "Replace the character under the cursor."),
|
||||||
action("vim_normal", "Vim normal", "substitute_char", "Delete the character under the cursor and enter insert mode."),
|
action("vim_normal", "Vim normal", "substitute_char", "Delete the character under the cursor and enter insert mode."),
|
||||||
action("vim_normal", "Vim normal", "delete_to_line_end", "Delete from cursor to end of line."),
|
action("vim_normal", "Vim normal", "delete_to_line_end", "Delete from cursor to end of line."),
|
||||||
action("vim_normal", "Vim normal", "change_to_line_end", "Change from cursor to end of line and enter insert mode."),
|
action("vim_normal", "Vim normal", "change_to_line_end", "Change from cursor to end of line and enter insert mode."),
|
||||||
@@ -142,7 +143,7 @@ pub(super) const KEYMAP_ACTIONS: &[KeymapActionDescriptor] = &[
|
|||||||
action("vim_normal", "Vim normal", "paste_after", "Paste after the cursor."),
|
action("vim_normal", "Vim normal", "paste_after", "Paste after the cursor."),
|
||||||
action("vim_normal", "Vim normal", "start_delete_operator", "Begin a delete operator and wait for a motion."),
|
action("vim_normal", "Vim normal", "start_delete_operator", "Begin a delete operator and wait for a motion."),
|
||||||
action("vim_normal", "Vim normal", "start_yank_operator", "Begin a yank operator and wait for a motion."),
|
action("vim_normal", "Vim normal", "start_yank_operator", "Begin a yank operator and wait for a motion."),
|
||||||
action("vim_normal", "Vim normal", "start_change_operator", "Begin a change operator and wait for a text object."),
|
action("vim_normal", "Vim normal", "start_change_operator", "Begin a change operator and wait for a motion or text object."),
|
||||||
action("vim_normal", "Vim normal", "cancel_operator", "Cancel a pending Vim operator."),
|
action("vim_normal", "Vim normal", "cancel_operator", "Cancel a pending Vim operator."),
|
||||||
action("vim_operator", "Vim operator", "delete_line", "Repeat delete operator to delete the whole line."),
|
action("vim_operator", "Vim operator", "delete_line", "Repeat delete operator to delete the whole line."),
|
||||||
action("vim_operator", "Vim operator", "yank_line", "Repeat yank operator to yank the whole line."),
|
action("vim_operator", "Vim operator", "yank_line", "Repeat yank operator to yank the whole line."),
|
||||||
@@ -285,6 +286,7 @@ pub(super) fn binding_slot<'a>(
|
|||||||
("vim_normal", "move_line_start") => Some(&mut keymap.vim_normal.move_line_start),
|
("vim_normal", "move_line_start") => Some(&mut keymap.vim_normal.move_line_start),
|
||||||
("vim_normal", "move_line_end") => Some(&mut keymap.vim_normal.move_line_end),
|
("vim_normal", "move_line_end") => Some(&mut keymap.vim_normal.move_line_end),
|
||||||
("vim_normal", "delete_char") => Some(&mut keymap.vim_normal.delete_char),
|
("vim_normal", "delete_char") => Some(&mut keymap.vim_normal.delete_char),
|
||||||
|
("vim_normal", "replace_char") => Some(&mut keymap.vim_normal.replace_char),
|
||||||
("vim_normal", "substitute_char") => Some(&mut keymap.vim_normal.substitute_char),
|
("vim_normal", "substitute_char") => Some(&mut keymap.vim_normal.substitute_char),
|
||||||
("vim_normal", "delete_to_line_end") => Some(&mut keymap.vim_normal.delete_to_line_end),
|
("vim_normal", "delete_to_line_end") => Some(&mut keymap.vim_normal.delete_to_line_end),
|
||||||
("vim_normal", "change_to_line_end") => Some(&mut keymap.vim_normal.change_to_line_end),
|
("vim_normal", "change_to_line_end") => Some(&mut keymap.vim_normal.change_to_line_end),
|
||||||
|
|||||||
+1
-1
@@ -5,7 +5,7 @@ expression: "render_picker(params, 120)"
|
|||||||
|
|
||||||
Keymap
|
Keymap
|
||||||
All configurable shortcuts.
|
All configurable shortcuts.
|
||||||
115 actions, 1 customized, 2 unbound.
|
116 actions, 1 customized, 2 unbound.
|
||||||
|
|
||||||
[All] Common Customized (1) Unbound (2) App Composer Editor Vim Navigation Agents Approval Debug
|
[All] Common Customized (1) Unbound (2) App Composer Editor Vim Navigation Agents Approval Debug
|
||||||
|
|
||||||
|
|||||||
+1
-1
@@ -5,7 +5,7 @@ expression: "render_picker(params, 120)"
|
|||||||
|
|
||||||
Keymap
|
Keymap
|
||||||
All configurable shortcuts.
|
All configurable shortcuts.
|
||||||
116 actions, 0 customized, 3 unbound.
|
117 actions, 0 customized, 3 unbound.
|
||||||
|
|
||||||
[All] Common Customized (0) Unbound (3) App Composer Editor Vim Navigation Agents Approval Debug
|
[All] Common Customized (0) Unbound (3) App Composer Editor Vim Navigation Agents Approval Debug
|
||||||
|
|
||||||
|
|||||||
+2
-2
@@ -2,14 +2,14 @@
|
|||||||
source: tui/src/keymap_setup.rs
|
source: tui/src/keymap_setup.rs
|
||||||
expression: snapshot
|
expression: snapshot
|
||||||
---
|
---
|
||||||
tab: All (115 selectable)
|
tab: All (116 selectable)
|
||||||
tab: Common (20 selectable)
|
tab: Common (20 selectable)
|
||||||
tab: Customized (0) (0 selectable)
|
tab: Customized (0) (0 selectable)
|
||||||
tab: Unbound (2) (2 selectable)
|
tab: Unbound (2) (2 selectable)
|
||||||
tab: App (12 selectable)
|
tab: App (12 selectable)
|
||||||
tab: Composer (5 selectable)
|
tab: Composer (5 selectable)
|
||||||
tab: Editor (17 selectable)
|
tab: Editor (17 selectable)
|
||||||
tab: Vim (48 selectable)
|
tab: Vim (49 selectable)
|
||||||
tab: Navigation (20 selectable)
|
tab: Navigation (20 selectable)
|
||||||
tab: Agents (5 selectable)
|
tab: Agents (5 selectable)
|
||||||
tab: Approval (8 selectable)
|
tab: Approval (8 selectable)
|
||||||
|
|||||||
+1
-1
@@ -5,7 +5,7 @@ expression: "render_picker(params, 120)"
|
|||||||
|
|
||||||
Keymap
|
Keymap
|
||||||
All configurable shortcuts.
|
All configurable shortcuts.
|
||||||
115 actions, 1 customized, 2 unbound.
|
116 actions, 1 customized, 2 unbound.
|
||||||
|
|
||||||
[All] Common Customized (1) Unbound (2) App Composer Editor Vim Navigation Agents Approval Debug
|
[All] Common Customized (1) Unbound (2) App Composer Editor Vim Navigation Agents Approval Debug
|
||||||
|
|
||||||
|
|||||||
+1
-1
@@ -5,7 +5,7 @@ expression: "render_picker(params, 78)"
|
|||||||
|
|
||||||
Keymap
|
Keymap
|
||||||
All configurable shortcuts.
|
All configurable shortcuts.
|
||||||
115 actions, 0 customized, 2 unbound.
|
116 actions, 0 customized, 2 unbound.
|
||||||
|
|
||||||
[All] Common Customized (0) Unbound (2) App Composer Editor Vim
|
[All] Common Customized (0) Unbound (2) App Composer Editor Vim
|
||||||
Navigation Agents Approval Debug
|
Navigation Agents Approval Debug
|
||||||
|
|||||||
@@ -5,7 +5,7 @@ expression: "render_picker(params, 120)"
|
|||||||
|
|
||||||
Keymap
|
Keymap
|
||||||
All configurable shortcuts.
|
All configurable shortcuts.
|
||||||
115 actions, 0 customized, 2 unbound.
|
116 actions, 0 customized, 2 unbound.
|
||||||
|
|
||||||
[All] Common Customized (0) Unbound (2) App Composer Editor Vim Navigation Agents Approval Debug
|
[All] Common Customized (0) Unbound (2) App Composer Editor Vim Navigation Agents Approval Debug
|
||||||
|
|
||||||
|
|||||||
Reference in new issue
Block a user