feat: add counted motions in Normal and Visual modes

This commit is contained in:
Valdemar Lorenzen
2026-10-08 12:19:38 +02:00
parent bd78656691
commit 38006c1eca
4 changed files with 335 additions and 78 deletions
+293 -76
View File
@@ -609,6 +609,7 @@ pub struct Editor {
features: HashMap<String, Feature>,
bindings: HashMap<Mode, HashMap<Input, Binding>>,
palette: Option<Palette>,
count: Option<usize>,
// ponytail: full-buffer history costs O(buffer size) per action; use edit deltas for large files.
undo: Vec<Snapshot>,
redo: Vec<Snapshot>,
@@ -644,6 +645,7 @@ impl Editor {
features: HashMap::new(),
bindings: default_bindings(),
palette: None,
count: None,
undo: Vec::new(),
redo: Vec::new(),
insert_start: None,
@@ -678,6 +680,10 @@ impl Editor {
pub fn palette(&self) -> Option<&Palette> {
self.palette.as_ref()
}
/// Pending decimal prefix for the next Normal/Visual motion.
pub fn count(&self) -> Option<usize> {
self.count
}
/// Editable Ex/search prompt, without its `:` or `/` prefix.
pub fn command_line(&self) -> &str {
&self.command_line
@@ -958,6 +964,7 @@ impl Editor {
}
// Paste is literal input, never a configurable key or command.
if let Input::Paste(text) = input {
self.count = None;
if self.mode == Mode::Insert {
self.paste(&text);
}
@@ -968,6 +975,30 @@ impl Editor {
Input::Ctrl(ch) => control_key(ch),
input => input,
};
if input == Input::Escape && self.count.take().is_some() {
return None;
}
if matches!(self.mode, Mode::Normal | Mode::Visual)
&& let Input::Char(ch @ '0'..='9') = input
{
let binding = self
.bindings
.get(&self.mode)
.and_then(|keys| keys.get(&input));
let extends_count = ch == '0'
&& self.count.is_some()
&& matches!(binding, Some(Binding::Action(Action::LineStart)));
if (binding.is_none() || extends_count) && (ch != '0' || self.count.is_some()) {
let digit = usize::from(ch as u8 - b'0');
self.count = Some(
self.count
.unwrap_or(0)
.saturating_mul(10)
.saturating_add(digit),
);
return None;
}
}
match self
.bindings
.get(&self.mode)
@@ -978,9 +1009,13 @@ impl Editor {
let command = command.clone();
return self.execute(&command);
}
Some(Binding::Disabled) => return None,
Some(Binding::Disabled) => {
self.count = None;
return None;
}
None => {}
}
self.count = None;
match (self.mode, input) {
(Mode::Insert, Input::Char(ch)) if !ch.is_control() || ch == '\t' => {
let mut bytes = [0; 4];
@@ -997,6 +1032,7 @@ impl Editor {
}
fn act(&mut self, action: Action) -> Option<Invocation> {
let count = self.count.take().unwrap_or(1);
match action {
Action::Palette => {
self.palette = Some(Palette {
@@ -1049,7 +1085,7 @@ impl Editor {
| Action::WordEnd
| Action::BigWordNext
| Action::BigWordPrevious
| Action::BigWordEnd => self.move_targets(action),
| Action::BigWordEnd => self.move_targets(action, count),
Action::JoinLines => self.join_lines(),
Action::Delete => {
let before = (self.mode != Mode::Insert).then(|| self.snapshot());
@@ -1068,8 +1104,8 @@ impl Editor {
};
self.command_line.clear();
}
Action::SearchNext => self.seek_search(true),
Action::SearchPrevious => self.seek_search(false),
Action::SearchNext => self.seek_search(true, count),
Action::SearchPrevious => self.seek_search(false, count),
Action::Select => {
if self.mode == Mode::Visual {
self.clear_selection();
@@ -1124,7 +1160,7 @@ impl Editor {
if !command.is_empty() {
self.search_query = command;
}
self.seek_search(true);
self.seek_search(true, 1);
} else {
return self.execute(&command);
}
@@ -1133,7 +1169,7 @@ impl Editor {
None
}
fn seek_search(&mut self, forward: bool) {
fn seek_search(&mut self, forward: bool, count: usize) {
self.search_match = None;
if self.search_query.is_empty() {
self.set_message("No previous search");
@@ -1164,12 +1200,21 @@ impl Editor {
target.active.row,
grapheme_byte(&self.lines[target.active.row], target.active.column),
);
let found = if forward {
select_search_match(matches.iter().copied(), origin, true)
let first = if forward {
matches.partition_point(|position| *position <= origin) % matches.len()
} else {
select_search_match(matches.iter().copied().rev(), origin, false)
}
.expect("nonempty matches");
matches
.partition_point(|position| *position < origin)
.checked_sub(1)
.unwrap_or(matches.len() - 1)
};
let distance = (count - 1) % matches.len();
let destination = if forward {
(first + distance) % matches.len()
} else {
(first + matches.len() - distance) % matches.len()
};
let found = matches[destination];
target.active = Cursor {
row: found.0,
column: column_after_byte(&self.lines[found.0], found.1),
@@ -1187,6 +1232,7 @@ impl Editor {
}
pub fn execute(&mut self, command: &str) -> Option<Invocation> {
self.count = None;
self.palette = None;
if self.mode == Mode::Insert {
self.finish_insert();
@@ -1283,6 +1329,7 @@ impl Editor {
})
.then(|| self.snapshot());
if before.is_some() {
self.count = None;
self.clear_selection();
self.collapse_primary();
// Plugin inserts use one primary insertion point, not the selection set.
@@ -1805,32 +1852,42 @@ impl Editor {
}
}
fn move_targets(&mut self, action: Action) {
fn move_targets(&mut self, action: Action, count: usize) {
for index in 0..self.targets.len() {
let mut cursor = self.targets[index].active;
match action {
Action::Left => cursor.column = cursor.column.saturating_sub(1),
Action::Left => cursor.column = cursor.column.saturating_sub(count),
Action::Right => {
cursor.column = (cursor.column + 1).min(self.max_column(cursor.row))
cursor.column = cursor
.column
.saturating_add(count)
.min(self.max_column(cursor.row))
}
Action::Down => {
cursor.row = (cursor.row + 1).min(self.lines.len() - 1);
cursor.column = cursor.column.min(self.max_column(cursor.row));
}
Action::Up => {
cursor.row = cursor.row.saturating_sub(1);
cursor.column = cursor.column.min(self.max_column(cursor.row));
Action::Down | Action::Up => {
let row = if matches!(action, Action::Down) {
cursor.row.saturating_add(count).min(self.lines.len() - 1)
} else {
cursor.row.saturating_sub(count)
};
let first = cursor.row.min(row);
let last = cursor.row.max(row);
cursor.column = (first..=last)
.map(|row| self.max_column(row))
.min()
.unwrap_or(0)
.min(cursor.column);
cursor.row = row;
}
Action::LineStart => cursor.column = 0,
Action::LineEnd => cursor.column = self.max_column(cursor.row),
_ => cursor = self.word_destination(cursor, action),
_ => cursor = self.word_destination(cursor, action, count),
}
self.targets[index].active = cursor;
self.targets[index].preferred_columns = None;
}
self.normalize_targets();
}
fn word_destination(&self, cursor: Cursor, action: Action) -> Cursor {
fn word_destination(&self, cursor: Cursor, action: Action, count: usize) -> Cursor {
let row = cursor.row;
let byte = grapheme_byte(&self.lines[row], cursor.column);
let big = matches!(
@@ -1841,51 +1898,57 @@ impl Editor {
Action::WordPrevious | Action::BigWordPrevious => {
let mut tokens = word_tokens(&self.lines[..=row], big)
.rev()
.skip_while(|(r, b, _)| (*r, *b) >= (row, byte));
tokens
.find(|(_, _, kind)| *kind != WordKind::Whitespace)
.map(|first| {
if first.2 == WordKind::EmptyLine {
first
} else {
tokens
.take_while(|(_, _, kind)| *kind == first.2)
.last()
.unwrap_or(first)
.skip_while(|(r, b, _)| (*r, *b) >= (row, byte))
.peekable();
let mut destination = (0, 0);
for _ in 0..count {
let Some(mut first) = tokens.find(|(_, _, kind)| *kind != WordKind::Whitespace)
else {
destination = (0, 0);
break;
};
if first.2 != WordKind::EmptyLine {
while tokens.peek().is_some_and(|(_, _, kind)| *kind == first.2) {
first = tokens.next().expect("peeked token");
}
})
.map(|(r, b, _)| (r, b))
.unwrap_or((0, 0))
}
destination = (first.0, first.1);
}
destination
}
_ => {
let mut tokens = word_tokens(&self.lines[row..], big)
.skip_while(|(r, b, _)| (*r, *b) < (0, byte));
let current = tokens
.skip_while(|(r, b, _)| (*r, *b) < (0, byte))
.peekable();
let mut current = tokens
.next()
.expect("cursor has a grapheme or line-end token")
.2;
let found = match action {
Action::WordNext | Action::BigWordNext => tokens
.skip_while(|(_, _, kind)| {
*kind == current && current != WordKind::EmptyLine
})
.find(|(_, _, kind)| *kind != WordKind::Whitespace),
Action::WordEnd | Action::BigWordEnd => tokens
.find(|(_, _, kind)| {
!matches!(kind, WordKind::Whitespace | WordKind::EmptyLine)
})
.map(|first| {
tokens
.take_while(|(_, _, kind)| *kind == first.2)
.last()
.unwrap_or(first)
}),
_ => unreachable!("word motion actions only"),
};
found.map(|(r, b, _)| (row + r, b)).unwrap_or_else(|| {
let last = self.lines.len() - 1;
(last, self.lines[last].len())
})
let last = self.lines.len() - 1;
let mut destination = (last, self.lines[last].len());
for _ in 0..count {
let next_start = matches!(action, Action::WordNext | Action::BigWordNext);
if next_start && current != WordKind::EmptyLine {
while tokens.peek().is_some_and(|(_, _, kind)| *kind == current) {
tokens.next();
}
}
let Some(mut first) = tokens.find(|(_, _, kind)| {
*kind != WordKind::Whitespace
&& (next_start || *kind != WordKind::EmptyLine)
}) else {
destination = (last, self.lines[last].len());
break;
};
current = first.2;
if !next_start {
while tokens.peek().is_some_and(|(_, _, kind)| *kind == current) {
first = tokens.next().expect("peeked token");
}
}
destination = (row + first.0, first.1);
}
destination
}
};
let line = &self.lines[destination.0];
@@ -2205,21 +2268,6 @@ fn offset_cursor(lines: &[String], offsets: &[usize], byte: usize, round_up: boo
Cursor { row, column }
}
fn select_search_match(
matches: impl Iterator<Item = (usize, usize)>,
origin: (usize, usize),
forward: bool,
) -> Option<(usize, usize)> {
let mut wrapped = None;
for position in matches {
wrapped.get_or_insert(position);
if (forward && position > origin) || (!forward && position < origin) {
return Some(position);
}
}
wrapped
}
#[derive(Clone, Copy, Eq, PartialEq)]
enum WordKind {
Whitespace,
@@ -2409,6 +2457,175 @@ mod tests {
});
}
}
#[test]
fn counted_cursor_motions_apply_to_all_carets_and_clamp_through_short_rows() {
let mut editor = editor();
editor
.apply(vec![Effect::Replace(
"e\u{301}界👩‍🚀z\n\nlong\nfour\naa\nbbbb\ncccc".into(),
)])
.unwrap();
set_carets(&mut editor, &[(0, 3), (4, 1)], 1);
let undo = editor.undo.len();
type_text(&mut editor, "2j");
assert_eq!(
editor.carets(),
vec![Cursor { row: 2, column: 0 }, Cursor { row: 6, column: 1 }]
);
assert_eq!(editor.cursor(), Cursor { row: 6, column: 1 });
type_text(&mut editor, "100k");
assert_eq!(editor.carets(), vec![Cursor::default()]);
type_text(&mut editor, "2l");
assert_eq!(editor.cursor(), Cursor { row: 0, column: 2 });
type_text(&mut editor, "9h");
assert_eq!(editor.cursor(), Cursor::default());
type_text(&mut editor, "999999999999999999999999999999999999");
assert_eq!(editor.count(), Some(usize::MAX));
editor.handle(Input::Down);
assert_eq!(editor.cursor(), Cursor { row: 6, column: 0 });
assert_eq!(editor.count(), None);
assert_eq!(editor.undo.len(), undo);
assert_eq!(
editor.lines(),
&["e\u{301}界👩‍🚀z", "", "long", "four", "aa", "bbbb", "cccc"]
);
}
#[test]
fn counted_words_match_repeated_unicode_motions_without_intermediate_selection_merging() {
let text = "foo_bar...baz qux\n\n \n界e\u{301}_2 👩‍🚀!! tail\n";
for key in ['w', 'b', 'e', 'W', 'B', 'E'] {
for positions in [&[(0, 0), (3, 0)][..], &[(0, 8), (3, 8)][..]] {
let mut counted = editor();
counted.apply(vec![Effect::Replace(text.into())]).unwrap();
set_carets(&mut counted, positions, 1);
let mut repeated = editor();
repeated.apply(vec![Effect::Replace(text.into())]).unwrap();
set_carets(&mut repeated, positions, 1);
type_text(&mut counted, &format!("3{key}"));
for _ in 0..3 {
repeated.handle(Input::Char(key));
}
assert_eq!(counted.carets(), repeated.carets(), "{key} {positions:?}");
assert_eq!(counted.cursor(), repeated.cursor(), "{key} {positions:?}");
}
}
let mut editor = editor();
editor
.apply(vec![Effect::Replace(
"one two three\none longer third".into(),
)])
.unwrap();
editor.handle(Input::Ctrl('e'));
editor.handle(Input::Char('v'));
type_text(&mut editor, "2e");
assert_eq!(
editor.selections(),
vec![
Selection {
start: Cursor { row: 0, column: 0 },
end: Cursor { row: 0, column: 7 }
},
Selection {
start: Cursor { row: 1, column: 0 },
end: Cursor { row: 1, column: 10 }
},
]
);
editor.handle(Input::Char('c'));
type_text(&mut editor, "item");
editor.handle(Input::Escape);
assert_eq!(editor.lines(), &["item three", "item third"]);
editor.handle(Input::Char('u'));
assert_eq!(editor.lines(), &["one two three", "one longer third"]);
assert_eq!(editor.selection_count(), 2);
type_text(&mut editor, &format!("{}w", usize::MAX));
assert_eq!(editor.cursor(), Cursor { row: 1, column: 15 });
}
#[test]
fn count_digits_zero_and_escape_preserve_modal_editing_and_custom_bindings() {
let mut editor = editor();
editor
.apply(vec![Effect::Replace(["abc"; 12].join("\n"))])
.unwrap();
type_text(&mut editor, "10j");
assert_eq!(editor.cursor(), Cursor { row: 10, column: 0 });
editor.handle(Input::End);
type_text(&mut editor, "0");
assert_eq!(editor.cursor().column, 0);
editor.handle(Input::Ctrl('k'));
editor.handle(Input::Char('v'));
let selected = editor.selections();
type_text(&mut editor, "4");
editor.handle(Input::Escape);
assert_eq!(editor.mode(), Mode::Visual);
assert_eq!(editor.selections(), selected);
assert_eq!(editor.count(), None);
editor.handle(Input::Escape);
assert_eq!(editor.mode(), Mode::Normal);
assert_eq!(editor.selection_count(), 2);
type_text(&mut editor, "3g");
assert_eq!(editor.count(), None);
editor.bind("normal", "2", None).unwrap();
type_text(&mut editor, "42");
assert_eq!(editor.count(), None);
editor
.bind("normal", "3", Some("core.down".into()))
.unwrap();
type_text(&mut editor, "3");
assert_eq!(editor.cursor().row, 10);
assert_eq!(editor.count(), None);
type_text(&mut editor, "5i12");
editor.handle(Input::Escape);
assert_eq!(editor.lines()[10], "12abc");
editor.handle(Input::Char('u'));
assert_eq!(editor.lines()[10], "abc");
editor.handle(Input::Char('/'));
type_text(&mut editor, "123");
assert_eq!(editor.command_line(), "123");
assert_eq!(editor.count(), None);
}
#[test]
fn counted_search_wraps_per_target_and_unrelated_actions_execute_once() {
let mut editor = editor();
editor
.apply(vec![Effect::Replace("cat cat cat\ncat cat cat".into())])
.unwrap();
set_carets(&mut editor, &[(0, 0), (1, 0)], 1);
submit_search(&mut editor, "cat");
type_text(&mut editor, "2n");
assert_eq!(
editor.carets(),
vec![Cursor { row: 0, column: 0 }, Cursor { row: 1, column: 0 }]
);
type_text(&mut editor, "2N");
assert_eq!(
editor.carets(),
vec![Cursor { row: 0, column: 4 }, Cursor { row: 1, column: 4 }]
);
editor.register(plugin("once", &["once"])).unwrap();
editor.bind("normal", "g", Some("once".into())).unwrap();
type_text(&mut editor, "8");
let invocation = editor.handle(Input::Char('g')).unwrap();
assert_eq!(invocation.command, "once");
assert_eq!(editor.count(), None);
type_text(&mut editor, "7");
editor.handle(Input::Ctrl('p'));
type_text(&mut editor, "core.word_next");
editor.handle(Input::Enter);
assert_eq!(
editor.carets(),
vec![Cursor { row: 0, column: 8 }, Cursor { row: 1, column: 8 }]
);
assert_eq!(editor.count(), None);
type_text(&mut editor, "9");
editor.handle(Input::Paste("ignored".into()));
editor.handle(Input::Char('h'));
assert_eq!(editor.cursor(), Cursor { row: 1, column: 7 });
}
fn submit_search(editor: &mut Editor, query: &str) {
editor.handle(Input::Char('/'));
type_text(editor, query);