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:
Benjamin Carlsson authored and copyberry committed 2026-08-26 04:18:49 +00:00
1 parent 25a6e316c8
commit 98ee29c7cd
16 files changed
+703 -13

No files matched your search

+3 -3
View File
@@ -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
^
"###);
}