diff --git a/README.md b/README.md index ba40df5..3be9aaf 100644 --- a/README.md +++ b/README.md @@ -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` (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 | `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 | `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 | @@ -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. +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. 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 | `search`, `search_next`, `search_previous` | | 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 | `newline` | | Command, Search | `submit` | diff --git a/examples/config.rb b/examples/config.rb index 732e6af..bf52bbb 100644 --- a/examples/config.rb +++ b/examples/config.rb @@ -5,4 +5,6 @@ Vamp.configure do |config| config.bind "ctrl+c", config.core.normal, mode: config.modes.insert config.bind "ctrl+f", config.core.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 diff --git a/src/editor.rs b/src/editor.rs index fa31908..97943e7 100644 --- a/src/editor.rs +++ b/src/editor.rs @@ -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> { ('/', 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 + '_ { + 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(); diff --git a/src/main.rs b/src/main.rs index 098cc18..71f9461 100644 --- a/src/main.rs +++ b/src/main.rs @@ -9,6 +9,7 @@ Usage: vamp [--config PATH | --no-config] [FILE] Default keys (configurable with config.bind): 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 + w/b/e word next/previous/end, W/B/E whitespace-delimited WORDs u undo, U redo, : command, / search, n/N next/previous match Insert: Esc returns to normal Search: Enter finds next literal match, Esc cancels (case-sensitive, wraps) diff --git a/src/ruby_host.rb b/src/ruby_host.rb index d6fc72f..37aeee7 100644 --- a/src/ruby_host.rb +++ b/src/ruby_host.rb @@ -27,6 +27,7 @@ module Vamp left right down up join_lines line_start line_end delete undo redo command normal newline backspace submit search search_next search_previous + word_next word_previous word_end big_word_next big_word_previous big_word_end ].each do |name| action = Action.new("core.#{name}".freeze).freeze define_singleton_method(name) { action } diff --git a/src/session.rs b/src/session.rs index f0e1739..bef2d19 100644 --- a/src/session.rs +++ b/src/session.rs @@ -404,6 +404,55 @@ end 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] fn typed_search_bindings_drive_navigation_and_the_search_prompt_only() { let fixture = Fixture::new();