feat: add counted forward and backward character seek motions

This commit is contained in:
2026-10-11 12:34:38 +02:00
parent 5186714ef5
commit 0ca0d89b9e
7 changed files with 837 additions and 13 deletions
+427 -11
View File
@@ -262,12 +262,26 @@ enum EditStart {
#[cfg_attr(test, derive(Debug))]
enum SelectionIntent {
Motions(Vec<(Action, usize)>),
Motions {
motions: Vec<Motion>,
linewise: bool,
},
// Whole graphemes, with a newline counting as one boundary.
Spans(Vec<usize>),
Rows(Vec<isize>),
}
#[cfg_attr(test, derive(Debug))]
#[derive(Clone, Copy)]
enum Motion {
Action(Action, usize),
Seek {
forward: bool,
target: char,
count: usize,
},
}
#[cfg_attr(test, derive(Debug))]
enum InsertStep {
Typed(String),
@@ -442,6 +456,16 @@ core_actions![
Action::DocumentEnd,
&[Mode::Normal, Mode::Visual],
),
(
"core.seek_forward",
Action::SeekForward,
&[Mode::Normal, Mode::Visual],
),
(
"core.seek_backward",
Action::SeekBackward,
&[Mode::Normal, Mode::Visual],
),
(
"core.word_next",
Action::WordNext,
@@ -842,6 +866,8 @@ fn default_bindings() -> HashMap<Mode, HashMap<Input, Binding>> {
('W', BigWordNext),
('B', BigWordPrevious),
('E', BigWordEnd),
('f', SeekForward),
('F', SeekBackward),
('n', SearchNext),
('N', SearchPrevious),
('v', Select),
@@ -1007,7 +1033,7 @@ struct Buffer {
redo: Vec<Snapshot>,
insert_start: Option<Snapshot>,
pending_edit: Option<EditRecipe>,
visual_motions: Option<Vec<(Action, usize)>>,
visual_motions: Option<Vec<Motion>>,
}
impl Buffer {
@@ -1125,6 +1151,7 @@ pub struct Editor {
leader_display: String,
g_pending: bool,
line_pending: Option<char>,
seek_pending: Option<(bool, usize)>,
palette: Option<Palette>,
hover: Option<Hover>,
completion: Option<CompletionState>,
@@ -1199,6 +1226,7 @@ impl Editor {
leader_display: String::new(),
g_pending: false,
line_pending: None,
seek_pending: None,
palette: None,
hover: None,
completion: None,
@@ -1732,6 +1760,7 @@ impl Editor {
|| self.count.is_some()
|| self.g_pending
|| self.line_pending.is_some()
|| self.seek_pending.is_some()
{
return self.handle_input(input);
}
@@ -2801,15 +2830,26 @@ impl Editor {
self.line_pending
}
/// Native character seek awaiting a literal target.
pub fn seek_pending(&self) -> Option<char> {
self.seek_pending
.map(|(forward, _)| if forward { 'f' } else { 'F' })
}
fn clear_leader(&mut self) {
self.g_pending = false;
self.line_pending = None;
self.seek_pending = None;
self.leader_suffix.clear();
self.leader_display.clear();
}
fn cancel_leader(&mut self) {
if self.leader_pending().is_some() || self.g_pending || self.line_pending.is_some() {
if self.leader_pending().is_some()
|| self.g_pending
|| self.line_pending.is_some()
|| self.seek_pending.is_some()
{
self.count = None;
self.clear_leader();
}
@@ -3004,6 +3044,9 @@ impl Editor {
Some("core.right") if mode == "tree" => Binding::Action(Action::Right),
Some(target) => return Err(format!("Unknown {mode} action: {target}")),
};
if self.seek_pending.is_some() {
self.cancel_leader();
}
if mode == "tree" {
self.tree_bindings.insert(input, binding);
} else {
@@ -3560,6 +3603,34 @@ impl Editor {
_ => self.finish_completion(),
}
}
if let Some((forward, count)) = self.seek_pending.take() {
if matches!(self.active.mode, Mode::Normal | Mode::Visual)
&& self.startup_diagnostic.is_none()
&& self.hover().is_none()
&& self.palette.is_none()
&& !self
.file_picker
.as_ref()
.is_some_and(|picker| picker.focused)
&& let Input::Char(target) = input
&& (!target.is_control() || target == '\t')
{
let motion = Motion::Seek {
forward,
target,
count,
};
if let Some(motions) = &mut self.active.visual_motions {
motions.push(motion);
}
self.move_motion(motion);
return None;
}
self.count = None;
if input == Input::Escape {
return None;
}
}
if std::mem::take(&mut self.g_pending) {
if matches!(self.active.mode, Mode::Normal | Mode::Visual)
&& self.startup_diagnostic.is_none()
@@ -3861,6 +3932,9 @@ impl Editor {
| Action::LspDiagnostics => unreachable!("catalog Ex actions dispatch above"),
Action::JumpBack => self.traverse_jumps(false, count),
Action::JumpForward => self.traverse_jumps(true, count),
Action::SeekForward | Action::SeekBackward => {
self.seek_pending = Some((action == Action::SeekForward, count));
}
Action::DeleteLine | Action::ChangeLine => {
self.edit_lines(matches!(action, Action::ChangeLine), count);
}
@@ -3902,7 +3976,7 @@ impl Editor {
| Action::BigWordEnd => {
if self.active.mode == Mode::Visual {
if let Some(motions) = &mut self.active.visual_motions {
motions.push((action, count));
motions.push(Motion::Action(action, count));
}
} else if self.active.mode == Mode::Insert {
self.push_insert_step(InsertStep::Move(action, count));
@@ -5693,8 +5767,13 @@ impl Editor {
}
fn selection_intent(&mut self, ranges: &[Selection]) -> SelectionIntent {
if let Some(motions) = self.active.visual_motions.take() {
return SelectionIntent::Motions {
motions,
linewise: self.visual_linewise(),
};
}
if self.visual_linewise() {
self.active.visual_motions = None;
let mut rows: Vec<_> = self
.active
.targets
@@ -5713,9 +5792,6 @@ impl Editor {
}
return SelectionIntent::Rows(rows);
}
if let Some(motions) = self.active.visual_motions.take() {
return SelectionIntent::Motions(motions);
}
let mut spans: Vec<_> = ranges
.iter()
.map(|range| {
@@ -5951,12 +6027,13 @@ impl Editor {
self.normalize_targets();
self.selections()
}
SelectionIntent::Motions(motions) => {
SelectionIntent::Motions { motions, linewise } => {
self.active.linewise = *linewise;
for target in &mut self.active.targets {
target.anchor = target.active;
}
for (action, count) in motions {
self.move_targets(*action, *count);
for motion in motions {
self.move_motion(*motion);
}
self.normalize_targets();
self.selections()
@@ -6068,6 +6145,32 @@ impl Editor {
)
}
fn move_motion(&mut self, motion: Motion) {
match motion {
Motion::Action(action, count) => self.move_targets(action, count),
Motion::Seek {
forward,
target,
count,
} => {
self.reveal_caret();
for caret in &mut self.active.targets {
if let Some(column) = seek_column(
&self.active.lines[caret.active.row],
caret.active.column,
forward,
target,
count,
) {
caret.active.column = column;
caret.preferred_columns = None;
}
}
self.normalize_targets();
}
}
}
fn move_targets(&mut self, action: Action, count: usize) {
self.reveal_caret();
for index in 0..self.active.targets.len() {
@@ -7006,6 +7109,40 @@ fn split_lines(text: &str) -> Vec<String> {
.collect()
}
fn seek_column(
line: &str,
column: usize,
forward: bool,
target: char,
count: usize,
) -> Option<usize> {
let occurrence = count.saturating_sub(1);
if forward {
line.graphemes(true)
.enumerate()
.skip(column.saturating_add(1))
.flat_map(|(column, grapheme)| {
grapheme
.chars()
.filter(move |ch| *ch == target)
.map(move |_| column)
})
.nth(occurrence)
} else {
line[..grapheme_byte(line, column)]
.graphemes(true)
.rev()
.enumerate()
.flat_map(|(offset, grapheme)| {
grapheme
.chars()
.filter(move |ch| *ch == target)
.map(move |_| column - offset - 1)
})
.nth(occurrence)
}
}
fn grapheme_count(line: &str) -> usize {
if line.is_ascii() {
// CRLF is the only multi-byte ASCII grapheme.
@@ -12156,6 +12293,285 @@ mod tests {
}
}
#[test]
fn character_seek_counts_are_strict_line_local_and_atomic_when_missing() {
let mut editor = editor();
editor
.apply(vec![Effect::Replace("xaxaax\nx\n\nx".into())])
.unwrap();
let undo = editor.active.undo.len();
for (column, keys, expected) in [
(0, "fx", 2),
(0, "2fx", 5),
(0, "3fx", 0),
(5, "Fx", 2),
(5, "2Fx", 0),
(5, "3Fx", 5),
(2, "fz", 2),
(0, "999999999999999999999999fx", 0),
] {
set_carets(&mut editor, &[(0, column)], 0);
type_text(&mut editor, keys);
assert_eq!(
editor.cursor(),
Cursor {
row: 0,
column: expected
}
);
assert_eq!(editor.count(), None);
assert_eq!(editor.seek_pending(), None);
}
for row in [1, 2, 3] {
set_carets(&mut editor, &[(row, 0)], 0);
type_text(&mut editor, "fxFx");
assert_eq!(editor.cursor(), Cursor { row, column: 0 });
}
assert_eq!(editor.active.undo.len(), undo);
}
#[test]
fn character_seek_consumes_literal_targets_before_bindings_counts_and_prefixes() {
for target in ['0', '2', 'g', 'f', 'F', '[', ']', ' ', '\t'] {
let mut editor = editor();
editor
.apply(vec![Effect::Replace(format!("a{target}b{target}"))])
.unwrap();
let key = display_key(&Input::Char(target));
editor
.bind("normal", &key, Some("core.right".into()))
.unwrap();
editor
.bind("normal", "z", Some("core.seek_forward".into()))
.unwrap();
type_text(&mut editor, "2z");
editor.handle(Input::Char(target));
assert_eq!(editor.cursor().column, 3);
assert_eq!(editor.count(), None);
assert!(!editor.g_pending());
assert!(editor.leader_pending().is_none());
assert!(editor.seek_pending().is_none());
}
let mut editor = editor();
editor.apply(vec![Effect::Replace("axbx".into())]).unwrap();
editor
.bind("normal", "f", Some("core.right".into()))
.unwrap();
type_text(&mut editor, "f");
assert_eq!(editor.cursor().column, 1);
assert!(editor.seek_pending().is_none());
editor
.bind("normal", "leader f", Some("core.seek_forward".into()))
.unwrap();
set_carets(&mut editor, &[(0, 0)], 0);
type_text(&mut editor, "2 fx");
assert_eq!(editor.cursor().column, 3);
for mode in ["insert", "command", "search", "selection", "tree"] {
assert!(
editor
.bind(mode, "z", Some("core.seek_forward".into()))
.is_err()
);
assert!(
editor
.bind(mode, "z", Some("core.seek_backward".into()))
.is_err()
);
}
editor.apply(vec![Effect::Replace(String::new())]).unwrap();
type_text(&mut editor, "ifFgf");
editor.handle(Input::Escape);
assert_eq!(editor.context().text, "fFgf");
}
#[test]
fn character_seek_unicode_scalars_land_on_graphemes_for_all_carets_and_visual_shapes() {
let mut editor = editor();
editor
.apply(vec![Effect::Replace(
"界e\u{301}👩‍🚀e\u{301}x\n\naxax".into(),
)])
.unwrap();
type_text(&mut editor, "f\u{301}");
assert_eq!(editor.cursor().column, 1);
type_text(&mut editor, "f🚀");
assert_eq!(editor.cursor().column, 2);
type_text(&mut editor, "F\u{301}");
assert_eq!(editor.cursor().column, 1);
type_text(&mut editor, "F\u{301}");
assert_eq!(editor.cursor().column, 1); // The current grapheme is excluded.
for selector in ["", "v", "V"] {
set_carets(&mut editor, &[(0, 0), (1, 0), (2, 0)], 2);
type_text(&mut editor, selector);
type_text(&mut editor, "2fx");
assert_eq!(
editor.carets(),
vec![
Cursor { row: 0, column: 0 },
Cursor { row: 1, column: 0 },
Cursor { row: 2, column: 3 },
]
);
assert_eq!(editor.visual_linewise(), selector == "V");
}
}
#[test]
fn character_seek_escape_and_lifecycle_cancel_pending_without_changing_selection() {
let directory = Directory::new();
let mut editor = editor();
editor
.apply(vec![Effect::Replace("axbx\nnext".into())])
.unwrap();
for selector in ["", "v", "V"] {
set_carets(&mut editor, &[(0, 0)], 0);
type_text(&mut editor, selector);
let mode = editor.mode();
let targets = editor.active.targets.clone();
type_text(&mut editor, "2f");
assert_eq!(editor.seek_pending(), Some('f'));
editor.handle(Input::Escape);
assert_eq!(editor.mode(), mode);
assert_eq!(editor.active.targets, targets);
assert_eq!(editor.count(), None);
assert_eq!(editor.seek_pending(), None);
}
set_carets(&mut editor, &[(0, 0)], 0);
type_text(&mut editor, "2f");
editor.handle(Input::Right);
assert_eq!(editor.cursor().column, 1);
assert_eq!(editor.seek_pending(), None);
type_text(&mut editor, "2f");
editor.handle(Input::Paste("x".into()));
assert_eq!(editor.cursor().column, 1);
assert_eq!(editor.seek_pending(), None);
for command in ["plugins", "bnext", "bprev", "buffer 1"] {
type_text(&mut editor, "f");
editor.execute(command);
assert_eq!(editor.seek_pending(), None);
}
type_text(&mut editor, "f");
editor
.bind("normal", "z", Some("core.right".into()))
.unwrap();
assert_eq!(editor.seek_pending(), None);
type_text(&mut editor, "f");
editor
.bind("selection", "z", Some("core.down".into()))
.unwrap();
assert_eq!(editor.seek_pending(), None);
type_text(&mut editor, "f");
editor.set_leader(",").unwrap();
assert_eq!(editor.seek_pending(), None);
type_text(&mut editor, "f");
editor
.navigate_to(
directory.path("missing"),
TextPosition { row: 0, column: 0 },
)
.unwrap_err();
assert_eq!(editor.seek_pending(), None);
type_text(&mut editor, "f");
editor.execute("palette");
assert_eq!(editor.seek_pending(), None);
editor.handle(Input::Escape);
type_text(&mut editor, "f");
editor
.apply(vec![Effect::Replace("replacement".into())])
.unwrap();
assert_eq!(editor.seek_pending(), None);
}
#[test]
fn character_seek_visual_edit_replay_resolves_target_direction_and_count_fresh() {
for (seek, original, replacement, column) in [
("2fx", "axbxTAIL", "aLONGxbLONGxTAIL", 0),
("2Fx", "xaxbTAIL", "xLONGxaLONGbTAIL", 11),
] {
for change in [false, true] {
let mut editor = editor();
editor
.apply(vec![Effect::Replace(original.into())])
.unwrap();
let start = if seek == "2Fx" { 3 } else { 0 };
set_carets(&mut editor, &[(0, start)], 0);
type_text(
&mut editor,
&format!("v{seek}{}", if change { "c!" } else { "d" }),
);
if change {
editor.handle(Input::Escape);
}
editor
.bind("visual", "f", Some("core.left".into()))
.unwrap();
editor
.bind("visual", "F", Some("core.right".into()))
.unwrap();
editor
.bind("visual", "x", Some("core.line_end".into()))
.unwrap();
editor
.apply(vec![Effect::Replace(replacement.into())])
.unwrap();
set_carets(&mut editor, &[(0, column)], 0);
editor.handle(Input::Char('.'));
assert_eq!(editor.context().text, if change { "!TAIL" } else { "TAIL" });
assert_eq!(editor.mode(), Mode::Normal);
assert_eq!(editor.seek_pending(), None);
}
}
let mut editor = editor();
editor
.apply(vec![Effect::Replace("ax\nkeep".into())])
.unwrap();
type_text(&mut editor, "Vfxd");
editor
.apply(vec![Effect::Replace("alongx\nkeep".into())])
.unwrap();
set_carets(&mut editor, &[(0, 0)], 0);
editor.handle(Input::Char('.'));
assert_eq!(editor.context().text, "keep");
assert!(matches!(
&editor.last_edit.as_ref().unwrap().start,
EditStart::Selection {
selector: SelectionIntent::Motions { linewise: true, .. },
..
}
));
}
#[test]
fn readonly_diff_character_seek_consumes_brackets_and_tab_before_pane_dispatch() {
let directory = Directory::new();
let mut editor = editor();
historical_diff(
&mut editor,
&directory.0,
vec!["a[b]\tc".into()],
vec!["a[b]\td".into()],
);
for focus in [Focus::Before, Focus::After] {
if editor.diff().unwrap().focus != focus {
editor.handle(Input::Char('\t'));
}
let text = editor.context().text;
let rows = editor.diff().unwrap().display_rows.clone();
type_text(&mut editor, "f[");
assert_eq!(editor.cursor().column, 1);
type_text(&mut editor, "f]");
assert_eq!(editor.cursor().column, 3);
type_text(&mut editor, "f\t");
assert_eq!(editor.cursor().column, 4);
type_text(&mut editor, "F[");
assert_eq!(editor.cursor().column, 1);
assert_eq!(editor.diff().unwrap().focus, focus);
assert_eq!(editor.diff().unwrap().display_rows, rows);
assert_eq!(editor.context().text, text);
assert!(editor.active.undo.is_empty());
}
}
fn set_carets(editor: &mut Editor, positions: &[(usize, usize)], primary: usize) {
editor.active.mode = Mode::Normal;
editor.active.visual_motions = None;