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
+244
View File
@@ -127,6 +127,12 @@ enum Action {
Search,
SearchNext,
SearchPrevious,
WordNext,
WordPrevious,
WordEnd,
BigWordNext,
BigWordPrevious,
BigWordEnd,
}
impl Action {
@@ -152,6 +158,12 @@ impl Action {
(Mode::Normal | Mode::Insert, "core.up") => Up,
(Mode::Normal | Mode::Insert, "core.line_start") => LineStart,
(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::Insert | Mode::Command | Mode::Search, "core.normal") => Normal,
(Mode::Insert, "core.newline") => Newline,
@@ -259,6 +271,12 @@ fn default_bindings() -> HashMap<Mode, HashMap<Input, Binding>> {
('/', Search),
('n', SearchNext),
('N', SearchPrevious),
('w', WordNext),
('b', WordPrevious),
('e', WordEnd),
('W', BigWordNext),
('B', BigWordPrevious),
('E', BigWordEnd),
] {
bindings
.entry(Mode::Normal)
@@ -587,6 +605,12 @@ impl Editor {
Action::JoinLines => self.join_lines(),
Action::LineStart => self.cursor.column = 0,
Action::LineEnd => self.move_end(),
Action::WordNext
| Action::WordPrevious
| Action::WordEnd
| Action::BigWordNext
| Action::BigWordPrevious
| Action::BigWordEnd => self.move_word(action),
Action::Delete => {
if self.mode == Mode::Insert {
self.delete_forward();
@@ -925,6 +949,76 @@ impl Editor {
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) {
if text.is_empty() {
return;
@@ -1039,6 +1133,43 @@ fn select_search_match(
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 {
let command = command.strip_suffix('!').unwrap_or(command);
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]
fn search_wraps_both_ways_and_cancel_failure_and_navigation_preserve_edits() {
let mut editor = editor();