mirror of
https://github.com/openai/codex.git
synced 2026-11-13 14:46:14 +00:00
Add Vim character find and till motions (#40785)
## What changed - Add line-local `f`, `F`, `t`, and `T` motions to Vim normal mode. - Support the motions with `c`, `d`, and `y`, including dot-repeat for changes. - Expose separate configurable normal-mode and operator-motion bindings, while preserving existing custom keymaps that use the new defaults. - Keep character targeting aligned to extended grapheme boundaries and skip embedded composer elements. ## Testing - Cover forward and backward motion and operator ranges, missing and adjacent targets, cancellation, dot-repeat, custom bindings, shifted terminal key events, grapheme clusters, and embedded elements. GitOrigin-RevId: 2610cf9a13538d9ae9b890c01417ca25316c1d60
This commit is contained in:
1 parent
25a6e316c8
commit
98ee29c7cd
16 files changed
+703
-13
No files matched your search
@@ -311,7 +311,7 @@ impl TextArea {
|
||||
}
|
||||
match self.vim_pending {
|
||||
VimPending::None => KeymapContext::VimNormal,
|
||||
VimPending::Replace => KeymapContext::Editor,
|
||||
VimPending::Replace | VimPending::Find { .. } => KeymapContext::Editor,
|
||||
VimPending::Operator(_) => KeymapContext::VimOperator,
|
||||
VimPending::TextObject { .. } => KeymapContext::VimTextObject,
|
||||
}
|
||||
@@ -718,7 +718,7 @@ impl TextArea {
|
||||
self.handle_vim_text_object(operator, scope, event);
|
||||
return;
|
||||
}
|
||||
VimPending::Replace => {
|
||||
VimPending::Replace | VimPending::Find { .. } => {
|
||||
self.handle_vim_pending_command(pending, event);
|
||||
return;
|
||||
}
|
||||
@@ -876,7 +876,7 @@ impl TextArea {
|
||||
self.start_vim_edit(VimAction::Change(VimEditTarget::Line));
|
||||
return true;
|
||||
}
|
||||
false
|
||||
self.handle_vim_operator_command(op, event)
|
||||
}
|
||||
|
||||
fn handle_vim_text_object(
|
||||
|
||||
@@ -28,6 +28,19 @@ pub(super) enum VimPending {
|
||||
scope: VimTextObjectScope,
|
||||
},
|
||||
Replace,
|
||||
Find {
|
||||
motion: VimFindMotion,
|
||||
operator: Option<VimOperator>,
|
||||
},
|
||||
}
|
||||
|
||||
/// Line-local character motions shared by navigation, operators, and dot replay.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub(super) enum VimFindMotion {
|
||||
Forward,
|
||||
Backward,
|
||||
TillForward,
|
||||
TillBackward,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
|
||||
@@ -1,4 +1,8 @@
|
||||
//! Semantic Vim editing transactions and complete-change replay.
|
||||
//! Semantic Vim editing transactions, character find/till motions, and complete-change replay.
|
||||
//!
|
||||
//! `f`/`F` land on a matching grapheme; `t`/`T` stop just before/after it. Forward operator
|
||||
//! motions include the destination, while backward motions exclude the original cursor.
|
||||
//! All four motions share these boundaries for navigation, `c`/`d`/`y`, and semantic `.` replay.
|
||||
|
||||
use super::TextArea;
|
||||
use super::VimMode;
|
||||
@@ -7,10 +11,13 @@ use super::VimOperator;
|
||||
use super::VimPending;
|
||||
use super::VimTextObject;
|
||||
use super::VimTextObjectScope;
|
||||
use super::vim::VimFindMotion;
|
||||
use crate::key_hint::KeyBindingListExt;
|
||||
use crossterm::event::KeyCode;
|
||||
use crossterm::event::KeyEvent;
|
||||
use crossterm::event::KeyModifiers;
|
||||
use unicode_segmentation::GraphemeCursor;
|
||||
use unicode_segmentation::UnicodeSegmentation;
|
||||
|
||||
#[derive(Clone, Debug)]
|
||||
pub(crate) enum VimEdit {
|
||||
@@ -41,6 +48,10 @@ pub(super) enum VimEditTarget {
|
||||
scope: VimTextObjectScope,
|
||||
object: VimTextObject,
|
||||
},
|
||||
Find {
|
||||
motion: VimFindMotion,
|
||||
target: char,
|
||||
},
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, Debug)]
|
||||
@@ -246,6 +257,11 @@ impl TextArea {
|
||||
};
|
||||
self.apply_vim_operator_to_range(operator, range);
|
||||
}
|
||||
VimEditTarget::Find { motion, target } => {
|
||||
if !self.find_vim_character(motion, Some(operator), target) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
}
|
||||
if operator == VimOperator::Change {
|
||||
return self.vim_mode == VimMode::Insert;
|
||||
@@ -306,7 +322,53 @@ impl TextArea {
|
||||
}
|
||||
return true;
|
||||
}
|
||||
false
|
||||
if self.vim_normal_keymap.find_forward.is_pressed(event) {
|
||||
self.start_vim_find(VimFindMotion::Forward, /*operator*/ None);
|
||||
} else if self.vim_normal_keymap.find_backward.is_pressed(event) {
|
||||
self.start_vim_find(VimFindMotion::Backward, /*operator*/ None);
|
||||
} else if self.vim_normal_keymap.till_forward.is_pressed(event) {
|
||||
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 {
|
||||
return false;
|
||||
}
|
||||
true
|
||||
}
|
||||
|
||||
pub(super) fn handle_vim_operator_command(
|
||||
&mut self,
|
||||
operator: VimOperator,
|
||||
event: KeyEvent,
|
||||
) -> bool {
|
||||
if self
|
||||
.vim_operator_keymap
|
||||
.motion_find_forward
|
||||
.is_pressed(event)
|
||||
{
|
||||
self.start_vim_find(VimFindMotion::Forward, Some(operator));
|
||||
} else if self
|
||||
.vim_operator_keymap
|
||||
.motion_find_backward
|
||||
.is_pressed(event)
|
||||
{
|
||||
self.start_vim_find(VimFindMotion::Backward, Some(operator));
|
||||
} else if self
|
||||
.vim_operator_keymap
|
||||
.motion_till_forward
|
||||
.is_pressed(event)
|
||||
{
|
||||
self.start_vim_find(VimFindMotion::TillForward, Some(operator));
|
||||
} else if self
|
||||
.vim_operator_keymap
|
||||
.motion_till_backward
|
||||
.is_pressed(event)
|
||||
{
|
||||
self.start_vim_find(VimFindMotion::TillBackward, Some(operator));
|
||||
} else {
|
||||
return false;
|
||||
}
|
||||
true
|
||||
}
|
||||
|
||||
pub(super) fn handle_vim_pending_command(&mut self, pending: VimPending, event: KeyEvent) {
|
||||
@@ -316,9 +378,112 @@ impl TextArea {
|
||||
self.start_vim_edit(VimAction::Replace(ch));
|
||||
}
|
||||
}
|
||||
VimPending::Find { motion, operator } => {
|
||||
if let Some(ch) = vim_command_char(event) {
|
||||
match operator {
|
||||
Some(VimOperator::Delete) => {
|
||||
self.start_vim_edit(VimAction::Delete(VimEditTarget::Find {
|
||||
motion,
|
||||
target: ch,
|
||||
}));
|
||||
}
|
||||
Some(VimOperator::Change) => {
|
||||
self.start_vim_edit(VimAction::Change(VimEditTarget::Find {
|
||||
motion,
|
||||
target: ch,
|
||||
}));
|
||||
}
|
||||
Some(VimOperator::Yank) | None => {
|
||||
self.find_vim_character(motion, operator, ch);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
VimPending::None | VimPending::Operator(_) | VimPending::TextObject { .. } => {}
|
||||
}
|
||||
}
|
||||
|
||||
fn start_vim_find(&mut self, motion: VimFindMotion, operator: Option<VimOperator>) {
|
||||
self.vim_pending = VimPending::Find { motion, operator };
|
||||
}
|
||||
|
||||
fn find_vim_character(
|
||||
&mut self,
|
||||
motion: VimFindMotion,
|
||||
operator: Option<VimOperator>,
|
||||
target: char,
|
||||
) -> bool {
|
||||
let origin = self.cursor_pos;
|
||||
let found = match motion {
|
||||
VimFindMotion::Forward | VimFindMotion::TillForward => {
|
||||
let line_end = self.end_of_current_line();
|
||||
if origin >= line_end {
|
||||
return false;
|
||||
}
|
||||
let start = self.next_atomic_boundary(origin);
|
||||
self.text[start..line_end]
|
||||
.grapheme_indices(/*is_extended*/ true)
|
||||
.find(|(offset, grapheme)| {
|
||||
grapheme.starts_with(target) && self.is_vim_command_target(start + offset)
|
||||
})
|
||||
.map(|(offset, grapheme)| start + offset..start + offset + grapheme.len())
|
||||
}
|
||||
VimFindMotion::Backward | VimFindMotion::TillBackward => {
|
||||
let line_start = self.beginning_of_current_line();
|
||||
self.text[line_start..origin]
|
||||
.grapheme_indices(/*is_extended*/ true)
|
||||
.rev()
|
||||
.find(|(offset, grapheme)| {
|
||||
grapheme.starts_with(target)
|
||||
&& self.is_vim_command_target(line_start + offset)
|
||||
})
|
||||
.map(|(offset, grapheme)| {
|
||||
let start = line_start + offset;
|
||||
start..start + grapheme.len()
|
||||
})
|
||||
}
|
||||
};
|
||||
let Some(position) = found else {
|
||||
return false;
|
||||
};
|
||||
if let Some(operator) = operator {
|
||||
let range = match motion {
|
||||
VimFindMotion::Forward => origin..position.end,
|
||||
VimFindMotion::Backward => position.start..origin,
|
||||
VimFindMotion::TillForward => origin..position.start,
|
||||
VimFindMotion::TillBackward => position.end..origin,
|
||||
};
|
||||
if operator == VimOperator::Yank {
|
||||
self.set_cursor(range.start);
|
||||
}
|
||||
self.apply_vim_operator_to_range(operator, range);
|
||||
} else {
|
||||
let destination = match motion {
|
||||
VimFindMotion::Forward | VimFindMotion::Backward => position.start,
|
||||
VimFindMotion::TillForward => {
|
||||
let previous = self.text[..position.start]
|
||||
.grapheme_indices(/*is_extended*/ true)
|
||||
.next_back()
|
||||
.map_or(origin, |(offset, _)| offset);
|
||||
self.find_element_containing(previous)
|
||||
.map_or(previous, |idx| self.elements[idx].range.start)
|
||||
}
|
||||
VimFindMotion::TillBackward => position.end,
|
||||
};
|
||||
self.set_cursor(destination);
|
||||
}
|
||||
true
|
||||
}
|
||||
|
||||
fn is_vim_command_target(&self, position: usize) -> bool {
|
||||
!self
|
||||
.elements
|
||||
.iter()
|
||||
.any(|element| element.range.contains(&position))
|
||||
&& GraphemeCursor::new(position, self.text.len(), /*is_extended*/ true)
|
||||
.is_boundary(&self.text, /*chunk_start*/ 0)
|
||||
.unwrap_or(false)
|
||||
}
|
||||
}
|
||||
|
||||
fn vim_command_char(event: KeyEvent) -> Option<char> {
|
||||
|
||||
@@ -300,6 +300,186 @@ fn change_accepts_word_line_and_repeated_operator_motions() {
|
||||
assert_eq!(textarea.text(), " world");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn character_find_is_line_local_and_works_as_operator_motion() {
|
||||
let mut textarea = vim_textarea("alpha beta\nbeta", /*cursor*/ 0);
|
||||
keys(&mut textarea, "fb");
|
||||
assert_eq!(textarea.cursor(), "alpha ".len());
|
||||
keys(&mut textarea, "Fa");
|
||||
assert_eq!(textarea.cursor(), "alpha".len() - 1);
|
||||
keys(&mut textarea, "fz");
|
||||
assert_eq!(textarea.cursor(), "alpha".len() - 1);
|
||||
|
||||
for (motion, cursor, destination, range) in [
|
||||
("fr", 0, 3, 0..4),
|
||||
("tr", 0, 2, 0..3),
|
||||
("Fb", 3, 1, 1..3),
|
||||
("Tb", 3, 2, 2..3),
|
||||
] {
|
||||
let text = "abcrdef";
|
||||
let mut textarea = vim_textarea(text, cursor);
|
||||
keys(&mut textarea, motion);
|
||||
assert_eq!((textarea.text(), textarea.cursor()), (text, destination));
|
||||
for operator in ['c', 'd', 'y'] {
|
||||
let mut textarea = vim_textarea(text, cursor);
|
||||
keys(&mut textarea, &format!("{operator}{motion}"));
|
||||
let expected = if operator == 'y' {
|
||||
text.to_owned()
|
||||
} else {
|
||||
format!("{}{}", &text[..range.start], &text[range.end..])
|
||||
};
|
||||
let mode = if operator == 'c' { "Insert" } else { "Normal" };
|
||||
assert_eq!(
|
||||
(
|
||||
textarea.text(),
|
||||
textarea.cursor(),
|
||||
textarea.vim_mode_label(),
|
||||
textarea.kill_buffer.as_str()
|
||||
),
|
||||
(
|
||||
expected.as_str(),
|
||||
range.start,
|
||||
Some(mode),
|
||||
&text[range.clone()]
|
||||
),
|
||||
"{operator}{motion}",
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
let mut textarea = vim_textarea("\nabc", /*cursor*/ 0);
|
||||
keys(&mut textarea, "fx");
|
||||
assert_eq!(textarea.text(), "\nabc");
|
||||
assert_eq!(textarea.cursor(), 0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn find_and_till_handle_missing_cancelled_and_adjacent_targets() {
|
||||
for motion in ['f', 'F', 't', 'T'] {
|
||||
for operator in ["", "c", "d", "y"] {
|
||||
let mut textarea = vim_textarea("abc\nz", /*cursor*/ 1);
|
||||
keys(&mut textarea, &format!("{operator}{motion}z"));
|
||||
assert_eq!(
|
||||
(
|
||||
textarea.text(),
|
||||
textarea.cursor(),
|
||||
textarea.vim_mode_label()
|
||||
),
|
||||
("abc\nz", 1, Some("Normal"))
|
||||
);
|
||||
keys(&mut textarea, &format!("{operator}{motion}"));
|
||||
escape(&mut textarea);
|
||||
assert!(!textarea.is_vim_operator_pending());
|
||||
}
|
||||
}
|
||||
for (cursor, command, expected) in [(0, "ctb", "bc"), (2, "cTb", "abc")] {
|
||||
let mut textarea = vim_textarea("abc", cursor);
|
||||
keys(&mut textarea, command);
|
||||
assert_eq!(
|
||||
(textarea.text(), textarea.vim_mode_label()),
|
||||
(expected, Some("Insert"))
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn dot_repeat_replays_character_find_operators() {
|
||||
let mut textarea = vim_textarea("one:two:three", /*cursor*/ 0);
|
||||
keys(&mut textarea, "df:.");
|
||||
assert_eq!(textarea.text(), "three");
|
||||
|
||||
for (cursor, command, repeat, expected) in [
|
||||
(0, "cfrX", "w.", "X X"),
|
||||
(0, "ctrX", "w.", "Xr Xr"),
|
||||
(3, "cFbX", concat!("w", "lll."), "aXr aXr"),
|
||||
(3, "cTbX", concat!("w", "lll."), "abXr abXr"),
|
||||
] {
|
||||
let mut textarea = vim_textarea("abcr abcr", cursor);
|
||||
keys(&mut textarea, command);
|
||||
escape(&mut textarea);
|
||||
keys(&mut textarea, repeat);
|
||||
assert_eq!(
|
||||
(textarea.text(), textarea.vim_mode_label()),
|
||||
(expected, Some("Normal"))
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn character_find_never_splits_extended_graphemes() {
|
||||
let mut textarea = vim_textarea("a👩💻z", /*cursor*/ 0);
|
||||
keys(&mut textarea, "f💻");
|
||||
assert_eq!(textarea.cursor(), 0);
|
||||
keys(&mut textarea, "f👩rX");
|
||||
assert_eq!(textarea.text(), "aXz");
|
||||
for (text, command, expected, cursor) in [
|
||||
("a👩💻r", "tr", "a👩💻r", 1),
|
||||
("aहिr", "tr", "aहिr", 1),
|
||||
("aहिr", "dfह", "r", 0),
|
||||
] {
|
||||
let mut textarea = vim_textarea(text, /*cursor*/ 0);
|
||||
keys(&mut textarea, command);
|
||||
assert_eq!((textarea.text(), textarea.cursor()), (expected, cursor));
|
||||
}
|
||||
let mut textarea = vim_textarea("a", /*cursor*/ 0);
|
||||
textarea.set_cursor(/*pos*/ 1);
|
||||
textarea.insert_element("[image]");
|
||||
textarea.insert_str("r");
|
||||
textarea.set_cursor(/*pos*/ 0);
|
||||
keys(&mut textarea, "t]");
|
||||
assert_eq!(textarea.cursor(), 0);
|
||||
keys(&mut textarea, "tr");
|
||||
assert_eq!(textarea.cursor(), 1);
|
||||
textarea.set_cursor(/*pos*/ 0);
|
||||
keys(&mut textarea, "ctr");
|
||||
assert_eq!((textarea.text(), textarea.elements.len()), ("r", 0));
|
||||
|
||||
let prefix = "earlier line\na";
|
||||
for (command, cursor) in [("Fa", prefix.len() - 1), ("Ta", prefix.len())] {
|
||||
let mut textarea = vim_textarea(prefix, /*cursor*/ 0);
|
||||
textarea.set_cursor(prefix.len());
|
||||
textarea.insert_element("[aaaa]");
|
||||
textarea.insert_str("z");
|
||||
textarea.set_cursor(textarea.text().len() - 1);
|
||||
keys(&mut textarea, command);
|
||||
assert_eq!(
|
||||
(textarea.text(), textarea.cursor()),
|
||||
("earlier line\na[aaaa]z", cursor)
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn character_find_and_operator_motion_use_configured_bindings() {
|
||||
let mut keymap = crate::keymap::RuntimeKeymap::defaults();
|
||||
keymap.vim_normal.find_forward = vec![crate::key_hint::plain(KeyCode::Char('z'))];
|
||||
keymap.vim_operator.motion_find_forward = vec![crate::key_hint::plain(KeyCode::Char('z'))];
|
||||
keymap.vim_normal.till_forward = vec![crate::key_hint::plain(KeyCode::Char('q'))];
|
||||
keymap.vim_operator.motion_till_forward = vec![crate::key_hint::plain(KeyCode::Char('q'))];
|
||||
for (motion, cursor, expected) in [("zb", 6, "eta gamma"), ("qb", 5, "beta gamma")] {
|
||||
let mut textarea = vim_textarea("alpha beta gamma", /*cursor*/ 0);
|
||||
textarea.set_keymap_bindings(&keymap);
|
||||
keys(&mut textarea, motion);
|
||||
assert_eq!(textarea.cursor(), cursor);
|
||||
textarea.set_cursor(/*pos*/ 0);
|
||||
keys(&mut textarea, &format!("d{motion}"));
|
||||
assert_eq!(textarea.text(), expected);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn uppercase_commands_accept_shift_only_terminal_events() {
|
||||
let mut textarea = vim_textarea("alpha\nbeta", /*cursor*/ 0);
|
||||
textarea.set_cursor("alpha\nbe".len());
|
||||
textarea.input(KeyEvent::new(KeyCode::Char('f'), KeyModifiers::SHIFT));
|
||||
keys(&mut textarea, "b");
|
||||
assert_eq!(textarea.cursor(), "alpha\n".len());
|
||||
textarea.set_cursor("alpha\nbe".len());
|
||||
textarea.input(KeyEvent::new(KeyCode::Char('t'), KeyModifiers::SHIFT));
|
||||
keys(&mut textarea, "b");
|
||||
assert_eq!(textarea.cursor(), "alpha\nb".len());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn pending_replacement_owns_escape_before_turn_interruption() {
|
||||
let mut textarea = vim_textarea("alpha", /*cursor*/ 0);
|
||||
@@ -354,3 +534,31 @@ fn dot_repeat_has_visual_snapshot_coverage() {
|
||||
^
|
||||
"###);
|
||||
}
|
||||
|
||||
#[test]
|
||||
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"] {
|
||||
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###"
|
||||
tb: alpha beta\ngamma delta
|
||||
^
|
||||
|
||||
fb: alpha beta\ngamma delta
|
||||
^
|
||||
|
||||
Ta: alpha beta\ngamma delta
|
||||
^
|
||||
|
||||
Fa: alpha beta\ngamma delta
|
||||
^
|
||||
|
||||
"###);
|
||||
}
|
||||
Reference in new issue
Block a user