feat: add word and WORD motions
This commit is contained in:
+244
@@ -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();
|
||||
|
||||
Reference in New Issue
Block a user