feat: add word and WORD motions

This commit is contained in:
Valdemar Lorenzen
2026-10-06 12:26:43 +02:00
parent 3ad6ca1fe2
commit 6104b73fdb
6 changed files with 305 additions and 0 deletions
+8
View File
@@ -20,6 +20,7 @@ Without a file, vamp opens an unnamed buffer. A nonexistent file opens an empty
| Normal | `i` insert before cursor; `a` insert after cursor; `o`/`O` open line below/above | | Normal | `i` insert before cursor; `a` insert after cursor; `o`/`O` open line below/above |
| Normal | `I` (Shift+i) insert before the first nonblank, skipping spaces/tabs; `A` (Shift+a) insert at line end | | Normal | `I` (Shift+i) insert before the first nonblank, skipping spaces/tabs; `A` (Shift+a) insert at line end |
| Normal | `h`/`j`/`k`/`l` or arrows; `0`/`$` or Home/End | | Normal | `h`/`j`/`k`/`l` or arrows; `0`/`$` or Home/End |
| Normal | `w`/`b`/`e` next word start, previous/current word start, word end; `W`/`B`/`E` for whitespace-delimited WORDs |
| Normal | `J` (Shift+j) join the current line with the next line | | Normal | `J` (Shift+j) join the current line with the next line |
| Normal | `x`/Delete delete; `u` undo; `U` (Shift+u) redo; `:` enter command mode | | Normal | `x`/Delete delete; `u` undo; `U` (Shift+u) redo; `:` enter command mode |
| Normal | `/` opens search; `n` finds the next match; `N` (Shift+n) finds the previous match | | Normal | `/` opens search; `n` finds the next match; `N` (Shift+n) finds the previous match |
@@ -29,6 +30,12 @@ Without a file, vamp opens an unnamed buffer. A nonexistent file opens an empty
`J` removes the next line's leading spaces/tabs and preserves existing trailing whitespace. It normally adds one space, two after `.`, `?`, or `!` (Vim's default), and none before `)` or when either side is empty. The cursor lands at the join point; the join is one undo action. On the last line, `J` does nothing. `J` removes the next line's leading spaces/tabs and preserves existing trailing whitespace. It normally adds one space, two after `.`, `?`, or `!` (Vim's default), and none before `)` or when either side is empty. The cursor lands at the join point; the join is one undo action. On the last line, `J` does nothing.
Word motions separate runs of Unicode letters/numbers/underscore from runs of punctuation or emoji. Classification uses each grapheme's first character; combining sequences and ZWJ emoji stay intact. WORD motions treat any non-whitespace run as one WORD.
`w`/`W` move to the next start. `b`/`B` move to the current start, or the previous start when already at one. `e`/`E` move to the current end when before it, otherwise the next end. Motions cross lines but never wrap. Truly empty lines are stops for start motions; whitespace-only lines are skipped. End motions skip empty lines while looking for a word. With no further target, motions stop at the buffer edge, including trailing whitespace or an empty final line.
These keys type literal text in Insert mode. The corresponding actions can also be bound in Insert mode to move the insertion cursor without splitting its undo group. Motions never edit text, add history, or clear an active search highlight.
Search is case-sensitive and single-line, with no regular expressions or Unicode normalization. Matches start and end on whole grapheme boundaries, including combining marks and emoji. Next/previous navigation skips the current position and wraps through the buffer; a sole match wraps back to itself. The active match is highlighted yellow. Search is case-sensitive and single-line, with no regular expressions or Unicode normalization. Matches start and end on whole grapheme boundaries, including combining marks and emoji. Next/previous navigation skips the current position and wraps through the buffer; a sole match wraps back to itself. The active match is highlighted yellow.
Submitting an empty search repeats the last query. Esc cancels without moving the cursor or replacing the previous query/match. A missing match reports the query, clears the highlight, and leaves the cursor unchanged; searching without a previous query also leaves the cursor unchanged. Search never edits text or adds undo history. Buffer edits and undo/redo clear the highlight but retain the query, so subsequent navigation searches the current text again. Submitting an empty search repeats the last query. Esc cancels without moving the cursor or replacing the previous query/match. A missing match reports the query, clears the highlight, and leaves the cursor unchanged; searching without a previous query also leaves the cursor unchanged. Search never edits text or adds undo history. Buffer edits and undo/redo clear the highlight but retain the query, so subsequent navigation searches the current text again.
@@ -91,6 +98,7 @@ Terminals share `ctrl+h` with `backspace`, `ctrl+i` with `tab`, and `ctrl+j`/`ct
| Normal | `join_lines`, `undo`, `redo`, `command` | | Normal | `join_lines`, `undo`, `redo`, `command` |
| Normal | `search`, `search_next`, `search_previous` | | Normal | `search`, `search_next`, `search_previous` |
| Normal, Insert | `left`, `down`, `up`, `right`, `line_start`, `line_end`, `delete` | | Normal, Insert | `left`, `down`, `up`, `right`, `line_start`, `line_end`, `delete` |
| Normal, Insert | `word_next`, `word_previous`, `word_end`, `big_word_next`, `big_word_previous`, `big_word_end` |
| Insert, Command, Search | `normal`, `backspace` | | Insert, Command, Search | `normal`, `backspace` |
| Insert | `newline` | | Insert | `newline` |
| Command, Search | `submit` | | Command, Search | `submit` |
+2
View File
@@ -5,4 +5,6 @@ Vamp.configure do |config|
config.bind "ctrl+c", config.core.normal, mode: config.modes.insert config.bind "ctrl+c", config.core.normal, mode: config.modes.insert
config.bind "ctrl+f", config.core.search config.bind "ctrl+f", config.core.search
config.bind "ctrl+c", config.core.normal, mode: config.modes.search config.bind "ctrl+c", config.core.normal, mode: config.modes.search
config.bind "ctrl+w", config.core.word_next
config.bind "ctrl+b", config.core.word_previous, mode: config.modes.insert
end end
+244
View File
@@ -127,6 +127,12 @@ enum Action {
Search, Search,
SearchNext, SearchNext,
SearchPrevious, SearchPrevious,
WordNext,
WordPrevious,
WordEnd,
BigWordNext,
BigWordPrevious,
BigWordEnd,
} }
impl Action { impl Action {
@@ -152,6 +158,12 @@ impl Action {
(Mode::Normal | Mode::Insert, "core.up") => Up, (Mode::Normal | Mode::Insert, "core.up") => Up,
(Mode::Normal | Mode::Insert, "core.line_start") => LineStart, (Mode::Normal | Mode::Insert, "core.line_start") => LineStart,
(Mode::Normal | Mode::Insert, "core.line_end") => LineEnd, (Mode::Normal | Mode::Insert, "core.line_end") => LineEnd,
(Mode::Normal | Mode::Insert, "core.word_next") => WordNext,
(Mode::Normal | Mode::Insert, "core.word_previous") => WordPrevious,
(Mode::Normal | Mode::Insert, "core.word_end") => WordEnd,
(Mode::Normal | Mode::Insert, "core.big_word_next") => BigWordNext,
(Mode::Normal | Mode::Insert, "core.big_word_previous") => BigWordPrevious,
(Mode::Normal | Mode::Insert, "core.big_word_end") => BigWordEnd,
(Mode::Normal | Mode::Insert, "core.delete") => Delete, (Mode::Normal | Mode::Insert, "core.delete") => Delete,
(Mode::Insert | Mode::Command | Mode::Search, "core.normal") => Normal, (Mode::Insert | Mode::Command | Mode::Search, "core.normal") => Normal,
(Mode::Insert, "core.newline") => Newline, (Mode::Insert, "core.newline") => Newline,
@@ -259,6 +271,12 @@ fn default_bindings() -> HashMap<Mode, HashMap<Input, Binding>> {
('/', Search), ('/', Search),
('n', SearchNext), ('n', SearchNext),
('N', SearchPrevious), ('N', SearchPrevious),
('w', WordNext),
('b', WordPrevious),
('e', WordEnd),
('W', BigWordNext),
('B', BigWordPrevious),
('E', BigWordEnd),
] { ] {
bindings bindings
.entry(Mode::Normal) .entry(Mode::Normal)
@@ -587,6 +605,12 @@ impl Editor {
Action::JoinLines => self.join_lines(), Action::JoinLines => self.join_lines(),
Action::LineStart => self.cursor.column = 0, Action::LineStart => self.cursor.column = 0,
Action::LineEnd => self.move_end(), Action::LineEnd => self.move_end(),
Action::WordNext
| Action::WordPrevious
| Action::WordEnd
| Action::BigWordNext
| Action::BigWordPrevious
| Action::BigWordEnd => self.move_word(action),
Action::Delete => { Action::Delete => {
if self.mode == Mode::Insert { if self.mode == Mode::Insert {
self.delete_forward(); self.delete_forward();
@@ -925,6 +949,76 @@ impl Editor {
self.cursor.column = self.max_column(); self.cursor.column = self.max_column();
} }
fn move_word(&mut self, action: Action) {
let row = self.cursor.row;
let byte = grapheme_byte(&self.lines[row], self.cursor.column);
let big = matches!(
action,
Action::BigWordNext | Action::BigWordPrevious | Action::BigWordEnd
);
let destination = match action {
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)
}
})
.map(|(r, b, _)| (r, b))
.unwrap_or((0, 0))
}
_ => {
let mut tokens = word_tokens(&self.lines[row..], big)
.skip_while(|(r, b, _)| (*r, *b) < (0, byte));
let 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 line = &self.lines[destination.0];
let column = column_after_byte(line, destination.1);
self.cursor = Cursor {
row: destination.0,
column: if destination.1 == line.len() && self.mode != Mode::Insert {
column.saturating_sub(1)
} else {
column
},
};
}
fn insert_text(&mut self, text: &str) { fn insert_text(&mut self, text: &str) {
if text.is_empty() { if text.is_empty() {
return; return;
@@ -1039,6 +1133,43 @@ fn select_search_match(
wrapped wrapped
} }
#[derive(Clone, Copy, Eq, PartialEq)]
enum WordKind {
Whitespace,
Keyword,
Punctuation,
EmptyLine,
}
fn word_tokens(
lines: &[String],
big: bool,
) -> impl DoubleEndedIterator<Item = (usize, usize, WordKind)> + '_ {
lines.iter().enumerate().flat_map(move |(row, line)| {
line.grapheme_indices(true)
.map(move |(byte, grapheme)| {
let ch = grapheme.chars().next().expect("graphemes are nonempty");
let kind = if ch.is_whitespace() {
WordKind::Whitespace
} else if big || ch == '_' || ch.is_alphanumeric() {
WordKind::Keyword
} else {
WordKind::Punctuation
};
(row, byte, kind)
})
.chain(std::iter::once((
row,
line.len(),
if line.is_empty() {
WordKind::EmptyLine
} else {
WordKind::Whitespace
},
)))
})
}
fn valid_command(command: &str) -> bool { fn valid_command(command: &str) -> bool {
let command = command.strip_suffix('!').unwrap_or(command); let command = command.strip_suffix('!').unwrap_or(command);
let mut chars = command.chars(); let mut chars = command.chars();
@@ -1205,6 +1336,119 @@ mod tests {
} }
} }
#[test]
fn word_motions_distinguish_punctuation_cross_empty_lines_and_preserve_history() {
let mut editor = editor();
let text = "foo_bar...baz qux\n\n \n界e\u{301}_2 👩‍🚀!! tail";
editor.apply(vec![Effect::Replace(text.into())]).unwrap();
for (key, row, column) in [
('w', 0, 7),
('w', 0, 10),
('w', 0, 15),
('w', 1, 0),
('w', 3, 0),
('w', 3, 5),
('w', 3, 9),
('w', 3, 12),
('w', 3, 12),
('b', 3, 9),
('b', 3, 5),
('b', 3, 0),
('b', 1, 0),
('b', 0, 15),
('b', 0, 10),
('b', 0, 7),
('b', 0, 0),
('b', 0, 0),
('e', 0, 6),
('e', 0, 9),
('e', 0, 12),
('e', 0, 17),
('e', 3, 3),
('e', 3, 7),
('e', 3, 12),
('e', 3, 12),
] {
editor.handle(Input::Char(key));
assert_eq!(editor.cursor(), Cursor { row, column }, "{key}");
}
assert_eq!(editor.context().text, text);
editor.handle(Input::Char('u'));
assert_eq!(editor.context().text, "");
editor.handle(Input::Char('U'));
assert_eq!(editor.context().text, text);
}
#[test]
fn big_word_motions_use_only_whitespace_and_handle_buffer_edges() {
let mut editor = editor();
editor
.apply(vec![Effect::Replace(
"foo_bar...baz qux\n\n \n界e\u{301}_2 👩‍🚀!! tail".into(),
)])
.unwrap();
for (key, row, column) in [
('W', 0, 15),
('W', 1, 0),
('W', 3, 0),
('W', 3, 5),
('W', 3, 9),
('W', 3, 12),
('B', 3, 9),
('B', 3, 5),
('B', 3, 0),
('B', 1, 0),
('B', 0, 15),
('B', 0, 0),
('B', 0, 0),
('E', 0, 12),
('E', 0, 17),
('E', 3, 3),
('E', 3, 7),
('E', 3, 12),
('E', 3, 12),
] {
editor.handle(Input::Char(key));
assert_eq!(editor.cursor(), Cursor { row, column }, "{key}");
}
for (text, next, end) in [("", 0, 0), (" ", 2, 2), ("word ", 6, 3)] {
editor.apply(vec![Effect::Replace(text.into())]).unwrap();
editor.handle(Input::Home);
editor.handle(Input::Char('e'));
assert_eq!(
editor.cursor(),
Cursor {
row: 0,
column: end
}
);
editor.handle(Input::Char('w'));
assert_eq!(
editor.cursor(),
Cursor {
row: 0,
column: next
}
);
editor.handle(Input::Char('b'));
assert_eq!(editor.cursor(), Cursor { row: 0, column: 0 });
}
editor
.apply(vec![Effect::Replace("one\u{2003}two\u{a0}three\n".into())])
.unwrap();
editor.handle(Input::Home);
editor.handle(Input::Char('w'));
assert_eq!(editor.cursor(), Cursor { row: 0, column: 4 });
editor.handle(Input::Char('w'));
assert_eq!(editor.cursor(), Cursor { row: 0, column: 8 });
editor.handle(Input::Char('w'));
assert_eq!(editor.cursor(), Cursor { row: 1, column: 0 });
editor.handle(Input::Char('e'));
assert_eq!(editor.cursor(), Cursor { row: 1, column: 0 });
editor.handle(Input::Char('b'));
assert_eq!(editor.cursor(), Cursor { row: 0, column: 8 });
}
#[test] #[test]
fn search_wraps_both_ways_and_cancel_failure_and_navigation_preserve_edits() { fn search_wraps_both_ways_and_cancel_failure_and_navigation_preserve_edits() {
let mut editor = editor(); let mut editor = editor();
+1
View File
@@ -9,6 +9,7 @@ Usage: vamp [--config PATH | --no-config] [FILE]
Default keys (configurable with config.bind): Default keys (configurable with config.bind):
Normal: i/a insert, I/A insert at first nonblank/end, o/O new line Normal: i/a insert, I/A insert at first nonblank/end, o/O new line
h/j/k/l move, 0/$ line ends, x delete, J join lines h/j/k/l move, 0/$ line ends, x delete, J join lines
w/b/e word next/previous/end, W/B/E whitespace-delimited WORDs
u undo, U redo, : command, / search, n/N next/previous match u undo, U redo, : command, / search, n/N next/previous match
Insert: Esc returns to normal Insert: Esc returns to normal
Search: Enter finds next literal match, Esc cancels (case-sensitive, wraps) Search: Enter finds next literal match, Esc cancels (case-sensitive, wraps)
+1
View File
@@ -27,6 +27,7 @@ module Vamp
left right down up join_lines line_start line_end delete undo redo command left right down up join_lines line_start line_end delete undo redo command
normal newline backspace submit normal newline backspace submit
search search_next search_previous search search_next search_previous
word_next word_previous word_end big_word_next big_word_previous big_word_end
].each do |name| ].each do |name|
action = Action.new("core.#{name}".freeze).freeze action = Action.new("core.#{name}".freeze).freeze
define_singleton_method(name) { action } define_singleton_method(name) { action }
+49
View File
@@ -404,6 +404,55 @@ end
assert!(session.editor().should_quit()); assert!(session.editor().should_quit());
} }
#[test]
fn typed_word_motions_remap_keys_and_keep_insert_typing_in_one_undo_group() {
let fixture = Fixture::new();
fixture.file("buffer.txt", "foo...bar baz");
let mut session = fixture.open(
r#"
Vamp.configure do |config|
config.bind "w", nil
config.bind "ctrl+f", config.core.word_next
config.bind "ctrl+p", config.core.word_previous
config.bind "ctrl+e", config.core.word_end
config.bind "ctrl+a", config.core.big_word_next
config.bind "ctrl+z", config.core.big_word_previous
config.bind "ctrl+t", config.core.big_word_end
config.bind "ctrl+b", config.core.word_previous, mode: config.modes.insert
end
"#,
);
session.handle(Input::Char('w'));
assert_eq!(session.editor().cursor().column, 0);
for (key, column) in [
('f', 3),
('e', 5),
('f', 6),
('p', 3),
('z', 0),
('a', 10),
('t', 12),
('z', 10),
('z', 0),
] {
session.handle(Input::Ctrl(key));
assert_eq!(session.editor().cursor().column, column, "{key}");
}
assert!(!session.editor().dirty());
session.handle(Input::Char('i'));
for ch in "wbeWBE".chars() {
session.handle(Input::Char(ch));
}
session.handle(Input::Ctrl('b'));
assert_eq!(session.editor().cursor().column, 0);
session.handle(Input::Char('X'));
session.handle(Input::Escape);
assert_eq!(session.editor().lines(), &["XwbeWBEfoo...bar baz"]);
session.handle(Input::Char('u'));
assert_eq!(session.editor().lines(), &["foo...bar baz"]);
assert!(!session.editor().dirty());
}
#[test] #[test]
fn typed_search_bindings_drive_navigation_and_the_search_prompt_only() { fn typed_search_bindings_drive_navigation_and_the_search_prompt_only() {
let fixture = Fixture::new(); let fixture = Fixture::new();