diff --git a/README.md b/README.md index 0e734ba..ca9342d 100644 --- a/README.md +++ b/README.md @@ -17,16 +17,18 @@ Without a file, vamp opens an unnamed buffer. A nonexistent file opens an empty | Mode | Keys | | --- | --- | -| 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 | `i` insert before each caret; `a` insert after each caret; `o`/`O` open lines below/above | +| Normal, Visual | `I` (Shift+i) / `A` (Shift+a) insert at first nonblank / line end; Visual splits each touched row into a caret first | +| Normal | `h`/`j`/`k`/`l` or arrows move every caret; `0`/`$` or Home/End | +| Normal | `w`/`b`/`e` move every caret to next word start, previous/current word start, word end; `W`/`B`/`E` for whitespace-delimited WORDs | +| Normal | `J` (Shift+j) joins each targeted 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 | -| Normal | `v` starts Visual selection; `p`/`P` paste internal clipboard after/before the cursor | -| Visual | Cursor/word motions and `n`/`N` extend selection; `y` copies; `d`/`x`/Delete cuts; `c` changes selection; `v`/Esc cancels | -| Insert | Type, Enter, Tab, Backspace/Delete, arrows, Home/End; Esc returns to normal | +| Normal | `v` starts Visual selections; `p`/`P` paste internal clipboard after/before each caret; Esc keeps only the primary caret | +| Normal, Visual | Ctrl+k clones above; Ctrl+e clones below (Ctrl+j aliases Enter in terminals) | +| Visual | Cursor/word motions and `n`/`N` extend every selection; `y` copies; `d`/`x`/Delete cuts; `c` changes selections; `v`/Esc cancels highlighting | +| Visual | Ctrl+l splits touched rows into Normal carets at first nonblank; Ctrl+n adds the next occurrence; Ctrl+s skips the primary occurrence; Ctrl+a selects all occurrences | +| Insert | Type, Enter, Tab, Backspace/Delete, arrows, Home/End at every caret; Esc returns to Normal, retaining carets | | Command | Enter executes; Backspace edits; Esc cancels | | Search | Type a literal query; Enter finds the next match; Backspace edits; Esc cancels | @@ -36,17 +38,19 @@ Word motions separate runs of Unicode letters/numbers/underscore from runs of pu `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. +These keys type literal text in Insert mode. The corresponding actions can also be bound in Insert mode to move all insertion carets without splitting their undo group. Motions never edit text, add history, or clear an active search highlight. -Visual selection is characterwise and inclusive of both endpoints, normalized when moving backward. Whole combining sequences and ZWJ emoji are selected together. `$`/End can select the newline after a non-final row; empty-line newlines are selectable too. Selected newlines are shown as one colored blank cell after the line. Blue selection highlighting takes precedence over yellow search highlighting. +Editing uses an ordered selection set with one primary target. Cursor and word motions apply to every target; Visual motions keep each target's anchor and move its active endpoint. Whole combining sequences and ZWJ emoji remain intact. Visual selection is characterwise and inclusive of the endpoints; its read-only ranges are end-exclusive. `$`/End can select the newline after a non-final row; empty-line newlines are selectable too. Selected newlines appear as a blue blank cell after the line. Blue highlighting overrides yellow search highlighting; secondary carets are magenta, including on empty lines and at line ends. Only the primary owns the hardware cursor and viewport. The status line counts all targets, including offscreen targets. -Copy and cut return to Normal mode at the selection start; cancellation keeps the focus cursor. Both clamp the cursor to a valid Normal-mode position. `p`/`P` paste after/before the current grapheme, including multiline text. The cursor lands on the last inserted grapheme, or the new row's start when the clipboard ends in a newline. Cut and each paste are separate undo actions. Copy, selection movement, and cancellation do not add history or clear redo. Undo/redo restore text and cursor, not the selection or clipboard. +Ctrl+k/Ctrl+e clone each target to the adjacent row, preserving preferred terminal-cell columns (tabs use four-cell stops); Visual cloning preserves both endpoints. Duplicate carets and truly overlapping selections merge; adjacent ranges remain distinct. Visual Ctrl+l creates one Normal caret at the first nonblank of each touched row. Visual `I`/`A` similarly create one Insert caret per touched row at first nonblank/end. Occurrence actions use the primary selection as a literal, potentially multiline, grapheme-aligned query: Ctrl+n adds the next non-overlapping occurrence and wraps; Ctrl+s moves only the primary to that occurrence; Ctrl+a selects all occurrences. -Visual-mode `c` removes the selection into the internal clipboard and enters Insert mode at its start. Deletion and subsequent replacement typing form one undo action when Insert mode is exited, including through explicit command execution. Esc without typing commits the deletion alone. An identical replacement creates no history entry and preserves redo. Changing an empty final-row selection enters Insert mode without overwriting the clipboard. +Copy and cut return to Normal at each selection start; Visual cancellation keeps each active endpoint and removes highlighting. Normal Esc collapses to the primary. `p`/`P` paste after/before each current grapheme, including multiline text. Each caret lands on its last inserted grapheme, or the new row's start when its fragment ends in a newline. Every multi-target edit is planned against the original buffer and committed as one undo action, not one per target. Copy, movement, and cancellation do not add history or clear redo. Undo/redo restore text, the full caret set, and primary identity in Normal mode, not Visual highlighting or the clipboard. -Bracketed paste retains its existing separate undo group, distinct from typing before/after it. A rejected paste leaves the pending change intact. Normal-mode operator grammar such as `cw` is not supported; `c` types literal text in Insert mode. +Visual `c` removes all selected text into the internal clipboard and enters Insert at each selection start. Deletion and replacement typing form one undo action when Insert mode is exited, including through explicit command execution. Esc without typing commits the deletion alone. An identical replacement creates no history entry and preserves redo. Changing only empty final-row selections enters Insert without overwriting the clipboard. -The clipboard is internal to the editor session and independent of the system clipboard. Empty clipboard paste does not edit text; copying/cutting an empty final-row selection does not overwrite an existing clipboard. Open a new search or enter Ex commands after leaving Visual mode. Successful text effects or explicit command execution also leave Visual mode; rejected effects preserve the selection. +Bracketed paste broadcasts the same literal text to all Insert carets in a separate undo group, distinct from typing before/after it. A rejected paste leaves the pending change intact. Normal-mode operator grammar such as `cw` is not supported; `c` types literal text in Insert mode. + +The clipboard is internal to the editor session and independent of the system clipboard. Copy/cut store fragments in document order: one fragment broadcasts to every caret; matching fragment/caret counts pair in document order; any other count refuses paste without changing state. Empty clipboard paste does not edit text; copying/cutting only empty final-row selections does not overwrite the clipboard. Open a new search or enter Ex commands after leaving Visual mode. Plugin commands execute once, not once per target: successful text effects collapse to the primary and apply once; rejected or message-only effects preserve the selection set. There is no selection-aware plugin API yet. 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. @@ -62,14 +66,14 @@ Paste is backend input: it is ignored in Normal, Command, Search, and Visual mod One crate, four modules, no workspace or frontend-specific types in the editing backend: -- `editor`: owns buffer, cursor, modes, Visual selection and the internal clipboard, search state/navigation and grapheme-coordinate range presentation, key notation and binding resolution, modal paste policy, undo/redo, saving, command ownership, capabilities, and validated edit effects. Its low-level interface returns an `Invocation` when an external command is selected. +- `editor`: owns buffer, the primary cursor and selection set, modes and internal clipboard fragments, search state/navigation and grapheme-coordinate range presentation, key notation and binding resolution, modal paste policy, undo/redo, saving, command ownership, capabilities, and validated edit effects. Its low-level interface returns an `Invocation` when an external command is selected. - `session`: `EditorSession` owns the editor and its Ruby hosts together. It loads configuration, registers eligible plugins before installing bindings, executes selected commands, and retires both host and command ownership on invocation or effect-validation failure. - `tui`: translates terminal events to session input, renders read-only editor state, and advertises `text` and `styled_text` capabilities. It has no plugin lifecycle callback, host storage, or paste acceptance policy. - `ruby`: evaluates configuration and runs isolated plugin hosts. Protocol, deadlines, and process cleanup stay inside this module. - The executable selects paths and frontend capabilities, opens an editor session, and launches the TUI. Frontends drive `EditorSession::handle` or `execute` and inspect `editor()`; there is no mutable editor getter that could independently alter plugin registration. Pasted text uses `Input::Paste(String)` through the same input seam as keys. Plugin effects are applied internally so a rejected result also retires its owner; there is no generic frontend effects entry point. -Frontends inspect `selection()` for an ordered, end-exclusive range of grapheme coordinates. A range ending at the next row's column 0 includes the previous row's newline; an empty final-row range contains no text. Selection normalization, clipboard contents, editing, and history remain inside the editor module. +Frontends inspect `selections()` for document-ordered, end-exclusive Visual ranges of grapheme coordinates (empty outside Visual), `carets()` for all active cursors in document order, `cursor()` for the primary, and `selection_count()` for the total target count. A range ending at the next row's column 0 includes the previous row's newline; an empty final-row range contains no text. Selection normalization, clipboard contents, edit planning, and history remain inside the editor module. The terminal adapter owns raw mode, alternate-screen state, bracketed paste, and its panic hook for one session lifetime. Entry failure, normal return, I/O errors, and unwinding panics restore the caller's hook; active-terminal cleanup runs before caller panic diagnostics. Panics retain their original payload and propagate rather than being swallowed. Terminal state and panic hooks are process-global: run one TUI at a time and do not replace its hook while that session is active. @@ -111,15 +115,16 @@ Terminals share `ctrl+h` with `backspace`, `ctrl+i` with `tab`, and `ctrl+j`/`ct | Available modes | Methods on `config.core` | | --- | --- | -| Normal | `insert`, `insert_first_nonblank`, `append`, `append_line_end`, `open_below`, `open_above` | +| Normal | `insert`, `append`, `open_below`, `open_above` | +| Normal, Visual | `insert_first_nonblank`, `append_line_end`, `clone_above`, `clone_below` | | Normal | `join_lines`, `undo`, `redo`, `command` | | Normal | `search`, `paste_before`, `paste_after` | | Normal, Visual | `select`, `search_next`, `search_previous` | | Normal, Insert, Visual | `left`, `down`, `up`, `right`, `line_start`, `line_end` | | Normal, Insert, Visual | `word_next`, `word_previous`, `word_end`, `big_word_next`, `big_word_previous`, `big_word_end` | | Normal, Insert | `delete` | -| Visual | `copy`, `cut`, `change` | -| Insert, Command, Search, Visual | `normal` | +| Visual | `copy`, `cut`, `change`, `select_lines`, `select_next`, `skip_occurrence`, `select_all` | +| Normal, Insert, Command, Search, Visual | `normal` | | Insert, Command, Search | `backspace` | | Insert | `newline` | | Command, Search | `submit` | @@ -153,7 +158,7 @@ end Run `:greet vamp`, or press `g` with the example configuration. `examples/plugins/greeting.rb` also provides `:upper` to uppercase the buffer. -Callbacks receive an immutable context snapshot: `editor.text`, `editor.row`, `editor.column`, `editor.mode`, and `editor.path`. Row/column are **one-based grapheme positions**; mode is `NORMAL`; path is a string or `nil`. Arguments are the remaining command text, without surrounding whitespace. +Callbacks receive an immutable primary-only context snapshot: `editor.text`, `editor.row`, `editor.column`, `editor.mode`, and `editor.path`. Row/column are **one-based grapheme positions**; mode is `NORMAL`; path is a string or `nil`. Arguments are the remaining command text, without surrounding whitespace. Commands run once regardless of target count; successful text effects collapse to the primary before editing once. The plugin API does not expose selections. `editor.insert(text)`, `editor.replace(text)`, and `editor.message(text)` queue effects. The context remains the pre-command snapshot even after queuing an effect. All effects are validated before the backend changes anything; buffer changes from one successful invocation form one undo action. Exceptions, process exits, timeouts, malformed replies, and invalid effects disable that plugin and discard its effects. Other plugins and built-ins remain available. Plugin `puts`, explicit `STDOUT.write`, and stderr cannot overwrite the TUI; diagnostic output is discarded, while exceptions appear in the status message. diff --git a/src/editor.rs b/src/editor.rs index 5fed22c..33ebc85 100644 --- a/src/editor.rs +++ b/src/editor.rs @@ -9,6 +9,7 @@ use std::os::unix::fs::OpenOptionsExt; use std::path::{Path, PathBuf}; use std::sync::atomic::{AtomicU64, Ordering}; use unicode_segmentation::{GraphemeCursor, UnicodeSegmentation}; +use unicode_width::UnicodeWidthStr; const MAX_EDIT_BYTES: usize = 4 * 1024 * 1024; static TEMP_ID: AtomicU64 = AtomicU64::new(0); @@ -51,7 +52,7 @@ impl fmt::Display for Mode { } } -#[derive(Clone, Copy, Debug, Default, Eq, PartialEq)] +#[derive(Clone, Copy, Debug, Default, Eq, Ord, PartialEq, PartialOrd)] pub struct Cursor { pub row: usize, /// Zero-based extended grapheme index, not a byte or display-cell offset. @@ -106,9 +107,37 @@ pub struct Context { pub path: Option, } +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +struct Target { + anchor: Cursor, + active: Cursor, + // Display columns survive a clone onto a shorter line. + preferred_columns: Option<(usize, usize)>, +} + +impl Target { + fn caret(cursor: Cursor) -> Self { + Self { + anchor: cursor, + active: cursor, + preferred_columns: None, + } + } +} + struct Snapshot { lines: Vec, - cursor: Cursor, + targets: Vec, + primary: usize, +} + +struct Edit<'a> { + target: usize, + start: usize, + end: usize, + text: Cow<'a, str>, + caret: usize, + round_up: bool, } #[derive(Clone, Copy)] @@ -149,6 +178,12 @@ enum Action { Change, PasteBefore, PasteAfter, + SelectLines, + CloneAbove, + CloneBelow, + SelectNext, + SkipOccurrence, + SelectAll, } impl Action { @@ -156,9 +191,9 @@ impl Action { use Action::*; Some(match (mode, name) { (Mode::Normal, "core.insert") => Insert, - (Mode::Normal, "core.insert_first_nonblank") => InsertFirstNonblank, + (Mode::Normal | Mode::Visual, "core.insert_first_nonblank") => InsertFirstNonblank, (Mode::Normal, "core.append") => Append, - (Mode::Normal, "core.append_line_end") => AppendLineEnd, + (Mode::Normal | Mode::Visual, "core.append_line_end") => AppendLineEnd, (Mode::Normal, "core.open_below") => OpenBelow, (Mode::Normal, "core.open_above") => OpenAbove, (Mode::Normal, "core.join_lines") => JoinLines, @@ -169,6 +204,12 @@ impl Action { (Mode::Normal | Mode::Visual, "core.search_next") => SearchNext, (Mode::Normal | Mode::Visual, "core.search_previous") => SearchPrevious, (Mode::Normal | Mode::Visual, "core.select") => Select, + (Mode::Visual, "core.select_lines") => SelectLines, + (Mode::Normal | Mode::Visual, "core.clone_above") => CloneAbove, + (Mode::Normal | Mode::Visual, "core.clone_below") => CloneBelow, + (Mode::Visual, "core.select_next") => SelectNext, + (Mode::Visual, "core.skip_occurrence") => SkipOccurrence, + (Mode::Visual, "core.select_all") => SelectAll, (Mode::Visual, "core.copy") => Copy, (Mode::Visual, "core.cut") => Cut, (Mode::Visual, "core.change") => Change, @@ -189,7 +230,7 @@ impl Action { } (Mode::Normal | Mode::Insert | Mode::Visual, "core.big_word_end") => BigWordEnd, (Mode::Normal | Mode::Insert, "core.delete") => Delete, - (Mode::Insert | Mode::Command | Mode::Search | Mode::Visual, "core.normal") => Normal, + (_, "core.normal") => Normal, (Mode::Insert, "core.newline") => Newline, (Mode::Insert | Mode::Command | Mode::Search, "core.backspace") => Backspace, (Mode::Command | Mode::Search, "core.submit") => Submit, @@ -318,6 +359,16 @@ fn default_bindings() -> HashMap> { .or_default() .insert(Input::Char(key), Binding::Action(action)); } + for (key, action) in [ + (Input::Ctrl('k'), CloneAbove), + (Input::Ctrl('e'), CloneBelow), + (Input::Escape, Normal), + ] { + bindings + .entry(mode) + .or_default() + .insert(key, Binding::Action(action)); + } } for mode in [Mode::Normal, Mode::Insert, Mode::Visual] { for (key, action) in [ @@ -350,6 +401,12 @@ fn default_bindings() -> HashMap> { (Input::Char('c'), Change), (Input::Char('x'), Cut), (Input::Delete, Cut), + (Input::Char('I'), InsertFirstNonblank), + (Input::Char('A'), AppendLineEnd), + (Input::Ctrl('l'), SelectLines), + (Input::Ctrl('n'), SelectNext), + (Input::Ctrl('s'), SkipOccurrence), + (Input::Ctrl('a'), SelectAll), ] { bindings .entry(Mode::Visual) @@ -381,13 +438,13 @@ fn default_bindings() -> HashMap> { pub struct Editor { lines: Vec, saved_lines: Vec, - cursor: Cursor, + targets: Vec, + primary: usize, mode: Mode, command_line: String, search_query: String, search_match: Option, - selection_anchor: Option, - clipboard: String, + clipboard: Vec, message: String, path: Option, quit: bool, @@ -415,13 +472,13 @@ impl Editor { let mut editor = Self { saved_lines: lines.clone(), lines, - cursor: Cursor::default(), + targets: vec![Target::caret(Cursor::default())], + primary: 0, mode: Mode::Normal, command_line: String::new(), search_query: String::new(), search_match: None, - selection_anchor: None, - clipboard: String::new(), + clipboard: Vec::new(), message: String::new(), path, quit: false, @@ -453,7 +510,7 @@ impl Editor { &self.lines } pub fn cursor(&self) -> Cursor { - self.cursor + self.targets[self.primary].active } pub fn mode(&self) -> Mode { self.mode @@ -465,27 +522,23 @@ impl Editor { pub fn search_match(&self) -> Option { self.search_match } - /// Ordered, end-exclusive range in Visual mode; None in other modes. - /// An empty final row produces an empty range, not a synthetic character. - pub fn selection(&self) -> Option { - if self.mode != Mode::Visual { - return None; + /// Ordered, end-exclusive Visual ranges in document order; empty in other modes. + pub fn selections(&self) -> Vec { + if self.mode == Mode::Visual { + self.targets + .iter() + .map(|target| self.target_range(*target)) + .collect() + } else { + Vec::new() } - let anchor = self.selection_anchor?; - let (start, mut end) = - if (anchor.row, anchor.column) <= (self.cursor.row, self.cursor.column) { - (anchor, self.cursor) - } else { - (self.cursor, anchor) - }; - let line = &self.lines[end.row]; - if grapheme_byte(line, end.column) < line.len() { - end.column += 1; - } else if end.row + 1 < self.lines.len() { - end.row += 1; - end.column = 0; - } - Some(Selection { start, end }) + } + /// All active cursors in document order, including the primary cursor. + pub fn carets(&self) -> Vec { + self.targets.iter().map(|target| target.active).collect() + } + pub fn selection_count(&self) -> usize { + self.targets.len() } pub fn message(&self) -> &str { &self.message @@ -503,8 +556,8 @@ impl Editor { pub fn context(&self) -> Context { Context { text: self.lines.join("\n"), - row: self.cursor.row + 1, - column: self.cursor.column + 1, + row: self.cursor().row + 1, + column: self.cursor().column + 1, mode: self.mode.to_string(), path: self .path @@ -657,62 +710,65 @@ impl Editor { fn act(&mut self, action: Action) -> Option { match action { Action::Insert => self.begin_insert(), - Action::InsertFirstNonblank => { - self.begin_insert(); - self.cursor.column = self.lines[self.cursor.row] - .graphemes(true) - .take_while(|grapheme| matches!(*grapheme, " " | "\t")) - .count(); + Action::InsertFirstNonblank | Action::AppendLineEnd => { + if self.mode == Mode::Visual { + let before = self.snapshot(); + self.split_rows(matches!(action, Action::AppendLineEnd), true); + self.begin_insert(); + self.insert_start = Some(before); + } else { + self.begin_insert(); + for target in &mut self.targets { + let line = &self.lines[target.active.row]; + target.active.column = if matches!(action, Action::AppendLineEnd) { + grapheme_count(line) + } else { + first_nonblank(line) + }; + } + self.normalize_targets(); + } } Action::Append => { self.begin_insert(); - self.cursor.column = - (self.cursor.column + 1).min(grapheme_count(&self.lines[self.cursor.row])); - } - Action::AppendLineEnd => { - self.begin_insert(); - self.move_end(); + for target in &mut self.targets { + target.active.column = (target.active.column + 1) + .min(grapheme_count(&self.lines[target.active.row])); + } + self.normalize_targets(); } Action::OpenBelow | Action::OpenAbove => { self.begin_insert(); - if matches!(action, Action::OpenBelow) { - self.cursor.row += 1; - } - self.lines.insert(self.cursor.row, String::new()); - self.search_match = None; - self.cursor.column = 0; + self.open_lines(matches!(action, Action::OpenBelow)); } - Action::Left => self.move_horizontal(false), - Action::Right => self.move_horizontal(true), - Action::Down => self.move_vertical(true), - Action::Up => self.move_vertical(false), - Action::JoinLines => self.join_lines(), - Action::LineStart => self.cursor.column = 0, - Action::LineEnd => self.move_end(), - Action::WordNext + Action::Left + | Action::Right + | Action::Down + | Action::Up + | Action::LineStart + | Action::LineEnd + | Action::WordNext | Action::WordPrevious | Action::WordEnd | Action::BigWordNext | Action::BigWordPrevious - | Action::BigWordEnd => self.move_word(action), + | Action::BigWordEnd => self.move_targets(action), + Action::JoinLines => self.join_lines(), Action::Delete => { - if self.mode == Mode::Insert { - self.delete_forward(); - } else if self.cursor.column < grapheme_count(&self.lines[self.cursor.row]) { - let before = self.snapshot(); - self.delete_forward(); - self.clamp_cursor(); + let before = (self.mode != Mode::Insert).then(|| self.snapshot()); + self.delete_forward(); + if let Some(before) = before { self.record(before); } } Action::Undo => self.undo(), Action::Redo => self.redo(), - Action::Command => { - self.mode = Mode::Command; - self.command_line.clear(); - } - Action::Search => { - self.mode = Mode::Search; + Action::Command | Action::Search => { + self.mode = if matches!(action, Action::Search) { + Mode::Search + } else { + Mode::Command + }; self.command_line.clear(); } Action::SearchNext => self.seek_search(true), @@ -721,25 +777,37 @@ impl Editor { if self.mode == Mode::Visual { self.clear_selection(); } else { - self.selection_anchor = Some(self.cursor); + for target in &mut self.targets { + target.anchor = target.active; + } self.mode = Mode::Visual; + self.normalize_targets(); self.message.clear(); } } + Action::SelectLines => self.split_rows(false, false), + Action::CloneAbove => self.clone_vertical(false), + Action::CloneBelow => self.clone_vertical(true), + Action::SelectNext | Action::SkipOccurrence | Action::SelectAll => { + self.select_occurrences(action); + } Action::Copy | Action::Cut | Action::Change => self.edit_selection(action), Action::PasteBefore => self.paste_clipboard(false), Action::PasteAfter => self.paste_clipboard(true), Action::Normal => { - if self.mode == Mode::Insert { + if self.mode == Mode::Normal { + self.collapse_primary(); + } else if self.mode == Mode::Insert { self.finish_insert(); self.mode = Mode::Normal; - self.cursor.column = self.cursor.column.saturating_sub(1); - self.clamp_cursor(); + for target in &mut self.targets { + target.active.column = target.active.column.saturating_sub(1); + } + self.normalize_targets(); } else { self.command_line.clear(); self.mode = Mode::Normal; - self.selection_anchor = None; - self.clamp_cursor(); + self.normalize_targets(); } } Action::Newline => self.insert_text("\n"), @@ -775,47 +843,59 @@ impl Editor { return; } let query = self.search_query.as_str(); - let origin = ( - self.cursor.row, - grapheme_byte(&self.lines[self.cursor.row], self.cursor.column), - ); // ponytail: O(buffer graphemes × query bytes) scan; index matches if large buffers need it. - let matches = self.lines.iter().enumerate().flat_map(|(row, line)| { - line.grapheme_indices(true).filter_map(move |(byte, _)| { - (line[byte..].starts_with(query) - && GraphemeCursor::new(byte + query.len(), line.len(), true) - .is_boundary(line, 0) - .expect("complete line supplies grapheme context")) - .then_some((row, byte)) + let matches: Vec<_> = self + .lines + .iter() + .enumerate() + .flat_map(|(row, line)| { + line.grapheme_indices(true).filter_map(move |(byte, _)| { + (line[byte..].starts_with(query) + && GraphemeCursor::new(byte + query.len(), line.len(), true) + .is_boundary(line, 0) + .expect("complete line supplies grapheme context")) + .then_some((row, byte)) + }) }) - }); - let found = if forward { - select_search_match(matches, origin, true) - } else { - select_search_match(matches.rev(), origin, false) - }; - if let Some((row, byte)) = found { - self.cursor = Cursor { - row, - column: column_after_byte(&self.lines[row], byte), - }; - self.search_match = Some(SearchMatch { - start: self.cursor, - end_column: self.cursor.column + grapheme_count(query), - }); - self.message.clear(); - } else { + .collect(); + if matches.is_empty() { self.set_message(format!("Not found: {}", self.search_query)); + return; } + for (index, target) in self.targets.iter_mut().enumerate() { + let origin = ( + 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) + } else { + select_search_match(matches.iter().copied().rev(), origin, false) + } + .expect("nonempty matches"); + target.active = Cursor { + row: found.0, + column: column_after_byte(&self.lines[found.0], found.1), + }; + target.preferred_columns = None; + if index == self.primary { + self.search_match = Some(SearchMatch { + start: target.active, + end_column: target.active.column + grapheme_count(query), + }); + } + } + self.normalize_targets(); + self.message.clear(); } pub fn execute(&mut self, command: &str) -> Option { if self.mode == Mode::Insert { self.finish_insert(); } + self.clear_selection(); self.mode = Mode::Normal; - self.selection_anchor = None; - self.clamp_cursor(); + self.normalize_targets(); let command = command.trim().strip_prefix(':').unwrap_or(command.trim()); let (name, args) = command .split_once(char::is_whitespace) @@ -874,6 +954,9 @@ impl Editor { None } + /// Applies one validated plugin response. Successful text effects collapse + /// to the primary caret: Insert runs once there, Replace replaces the buffer. + /// Rejected responses and message-only effects retain the complete set. pub fn apply(&mut self, effects: Vec) -> Result<(), String> { let mut total = 0usize; for effect in &effects { @@ -902,6 +985,9 @@ impl Editor { .then(|| self.snapshot()); if before.is_some() { self.clear_selection(); + self.collapse_primary(); + // Plugin inserts use one primary insertion point, not the selection set. + self.mode = Mode::Insert; } for effect in effects { match effect { @@ -909,12 +995,19 @@ impl Editor { Effect::Replace(text) => { self.lines = split_lines(&text); self.search_match = None; - self.clamp_cursor(); + self.normalize_targets(); } Effect::Message(text) => self.set_message(text), } } - self.clamp_cursor(); + if before.is_some() { + self.mode = if inserting { + Mode::Insert + } else { + Mode::Normal + }; + } + self.normalize_targets(); if let Some(before) = before { self.record(before); } @@ -946,92 +1039,362 @@ impl Editor { fn snapshot(&self) -> Snapshot { Snapshot { lines: self.lines.clone(), - cursor: self.cursor, + targets: self.targets.clone(), + primary: self.primary, } } fn record(&mut self, before: Snapshot) { if before.lines != self.lines { self.search_match = None; - self.clear_selection(); self.undo.push(before); self.redo.clear(); } } - fn edit_selection(&mut self, action: Action) { - let Some(selection) = self.selection() else { - return; + fn target_range(&self, target: Target) -> Selection { + let start = target.anchor.min(target.active); + let end = self.next_boundary(target.anchor.max(target.active)); + Selection { start, end } + } + + fn next_boundary(&self, mut cursor: Cursor) -> Cursor { + if cursor.column < grapheme_count(&self.lines[cursor.row]) { + cursor.column += 1; + } else if cursor.row + 1 < self.lines.len() { + cursor.row += 1; + cursor.column = 0; + } + cursor + } + + fn previous_boundary(&self, mut cursor: Cursor) -> Cursor { + if cursor.column > 0 { + cursor.column -= 1; + } else if cursor.row > 0 { + cursor.row -= 1; + cursor.column = grapheme_count(&self.lines[cursor.row]); + } + cursor + } + + fn range_target(&self, range: Selection, forward: bool) -> Target { + let last = if range.start == range.end { + range.start + } else { + self.previous_boundary(range.end) }; + Target { + anchor: if forward { range.start } else { last }, + active: if forward { last } else { range.start }, + preferred_columns: None, + } + } + + fn max_column(&self, row: usize) -> usize { + let count = grapheme_count(&self.lines[row]); + if self.mode == Mode::Insert || (self.mode == Mode::Visual && row + 1 < self.lines.len()) { + count + } else { + count.saturating_sub(1) + } + } + + fn normalize_targets(&mut self) { + for index in 0..self.targets.len() { + let mut target = self.targets[index]; + target.active.row = target.active.row.min(self.lines.len() - 1); + target.active.column = target.active.column.min(self.max_column(target.active.row)); + if self.mode == Mode::Visual { + target.anchor.row = target.anchor.row.min(self.lines.len() - 1); + target.anchor.column = target.anchor.column.min(self.max_column(target.anchor.row)); + } else { + target.anchor = target.active; + } + self.targets[index] = target; + } + let mut ordered: Vec<_> = self + .targets + .iter() + .copied() + .enumerate() + .map(|(index, target)| { + let range = if self.mode == Mode::Visual { + self.target_range(target) + } else { + Selection { + start: target.active, + end: target.active, + } + }; + (target, index == self.primary, range) + }) + .collect(); + ordered.sort_by_key(|(_, _, range)| (range.start, range.end)); + let mut merged: Vec<(Target, bool, Selection)> = Vec::with_capacity(ordered.len()); + for (target, primary, range) in ordered { + if let Some((previous, was_primary, previous_range)) = merged.last_mut() { + if ranges_intersect(*previous_range, range) { + let representative = if primary { target } else { *previous }; + previous_range.start = previous_range.start.min(range.start); + previous_range.end = previous_range.end.max(range.end); + *previous = if self.mode == Mode::Visual + && self.target_range(representative) != *previous_range + { + self.range_target( + *previous_range, + representative.active >= representative.anchor, + ) + } else { + representative + }; + *was_primary |= primary; + continue; + } + } + merged.push((target, primary, range)); + } + self.primary = merged + .iter() + .position(|(_, primary, _)| *primary) + .unwrap_or(0); + self.targets.clear(); + self.targets + .extend(merged.into_iter().map(|(target, _, _)| target)); + } + + fn collapse_primary(&mut self) { + let target = self.targets[self.primary]; + self.targets.clear(); + self.targets.push(target); + self.primary = 0; + } + + fn clear_selection(&mut self) { + if self.mode == Mode::Visual { + self.mode = Mode::Normal; + self.normalize_targets(); + } + } + + fn split_rows(&mut self, end: bool, insert: bool) { + let ranges = self.selections(); + let primary_row = self.cursor().row; + let mut carets = Vec::new(); + let mut primary = 0; + for (index, range) in ranges.into_iter().enumerate() { + let last = if range.end.column == 0 && range.end.row > range.start.row { + range.end.row - 1 + } else { + range.end.row + }; + for row in range.start.row..=last { + if index == self.primary && row == primary_row { + primary = carets.len(); + } + let column = if end { + grapheme_count(&self.lines[row]) + } else { + first_nonblank(&self.lines[row]) + }; + carets.push(Target::caret(Cursor { row, column })); + } + } + self.targets = carets; + self.primary = primary; + self.mode = if insert { Mode::Insert } else { Mode::Normal }; + self.normalize_targets(); + } + + fn clone_vertical(&mut self, down: bool) { + let count = self.targets.len(); + let primary = self.primary; + for index in 0..count { + let target = self.targets[index]; + let shift = |row: usize| { + if down { + row.checked_add(1).filter(|row| *row < self.lines.len()) + } else { + row.checked_sub(1) + } + }; + let Some(anchor_row) = shift(target.anchor.row) else { + continue; + }; + let Some(active_row) = shift(target.active.row) else { + continue; + }; + let columns = target.preferred_columns.unwrap_or_else(|| { + ( + display_column(&self.lines[target.anchor.row], target.anchor.column), + display_column(&self.lines[target.active.row], target.active.column), + ) + }); + self.targets[index].preferred_columns = Some(columns); + let cloned = Target { + anchor: Cursor { + row: anchor_row, + column: column_at_display( + &self.lines[anchor_row], + columns.0, + self.max_column(anchor_row), + ), + }, + active: Cursor { + row: active_row, + column: column_at_display( + &self.lines[active_row], + columns.1, + self.max_column(active_row), + ), + }, + preferred_columns: Some(columns), + }; + if index == primary { + self.primary = self.targets.len(); + } + self.targets.push(cloned); + } + self.normalize_targets(); + } + + fn select_occurrences(&mut self, action: Action) { + let text = self.lines.join("\n"); + let offsets = line_offsets(&self.lines); + let ranges = self.selections(); + let primary_range = ranges[self.primary]; + let start = cursor_offset(&self.lines, &offsets, primary_range.start); + let end = cursor_offset(&self.lines, &offsets, primary_range.end); + if start == end { + self.set_message("Nothing selected"); + return; + } + let phrase = &text[start..end]; + let mut occupied: Vec<_> = ranges + .iter() + .map(|range| { + ( + cursor_offset(&self.lines, &offsets, range.start), + cursor_offset(&self.lines, &offsets, range.end), + ) + }) + .collect(); + // ponytail: literal scan is O(buffer graphemes × phrase bytes); index only for large buffers. + let candidates: Vec<_> = text + .grapheme_indices(true) + .filter_map(|(byte, _)| { + let end = byte + phrase.len(); + (text[byte..].starts_with(phrase) + && GraphemeCursor::new(end, text.len(), true) + .is_boundary(&text, 0) + .expect("complete buffer supplies grapheme context")) + .then_some((byte, end)) + }) + .collect(); + if matches!(action, Action::SelectAll) { + for (start, end) in candidates { + if occupied + .iter() + .any(|(left, right)| start < *right && *left < end) + { + continue; + } + occupied.push((start, end)); + self.targets.push(self.range_target( + Selection { + start: offset_cursor(&self.lines, &offsets, start, true), + end: offset_cursor(&self.lines, &offsets, end, true), + }, + true, + )); + } + } else { + let available = |(left, right): &(usize, usize)| { + !occupied + .iter() + .any(|(start, end)| *left < *end && *start < *right) + }; + let next = candidates + .iter() + .filter(|range| available(range)) + .find(|(left, _)| *left > start) + .or_else(|| candidates.iter().find(|range| available(range))); + let Some(&(start, end)) = next else { + self.set_message("No more occurrences"); + return; + }; + let target = self.range_target( + Selection { + start: offset_cursor(&self.lines, &offsets, start, true), + end: offset_cursor(&self.lines, &offsets, end, true), + }, + true, + ); + if matches!(action, Action::SkipOccurrence) { + self.targets[self.primary] = target; + } else { + self.primary = self.targets.len(); + self.targets.push(target); + } + } + self.normalize_targets(); + self.message.clear(); + } + + fn edit_selection(&mut self, action: Action) { + let ranges = self.selections(); let cut = matches!(action, Action::Cut | Action::Change); let changing = matches!(action, Action::Change); - if selection.start == selection.end { - self.clear_selection(); - if changing { - self.begin_insert(); - self.message.clear(); - } else { - self.set_message("Nothing selected"); - } - return; - } - let start = selection.start; - let end = selection.end; - let start_byte = grapheme_byte(&self.lines[start.row], start.column); - let end_byte = grapheme_byte(&self.lines[end.row], end.column); - self.clipboard = if start.row == end.row { - self.lines[start.row][start_byte..end_byte].to_owned() - } else { - let capacity = self.lines[start.row..end.row] - .iter() - .map(String::len) - .sum::() - - start_byte - + end_byte - + end.row - - start.row; - let mut text = String::with_capacity(capacity); - text.push_str(&self.lines[start.row][start_byte..]); - for line in &self.lines[start.row + 1..end.row] { - text.push('\n'); - text.push_str(line); - } - text.push('\n'); - text.push_str(&self.lines[end.row][..end_byte]); - text - }; let before = cut.then(|| self.snapshot()); - self.clear_selection(); - self.cursor = start; + let text = self.lines.join("\n"); + let offsets = line_offsets(&self.lines); + let fragments: Vec<_> = ranges + .iter() + .map(|range| { + let start = cursor_offset(&self.lines, &offsets, range.start); + let end = cursor_offset(&self.lines, &offsets, range.end); + text[start..end].to_owned() + }) + .collect(); + let nonempty = fragments.iter().any(|fragment| !fragment.is_empty()); + if nonempty { + self.clipboard = fragments; + } + let edits = ranges + .iter() + .enumerate() + .map(|(target, range)| Edit { + target, + start: cursor_offset(&self.lines, &offsets, range.start), + end: cursor_offset(&self.lines, &offsets, range.end), + text: Cow::Borrowed(""), + caret: 0, + round_up: true, + }) + .collect(); + self.mode = Mode::Insert; if cut { - if start.row == end.row { - self.lines[start.row].replace_range(start_byte..end_byte, ""); - } else { - let mut tail = self - .lines - .drain(start.row + 1..=end.row) - .next_back() - .expect("cross-line selection includes an end row"); - tail.replace_range(..end_byte, &self.lines[start.row][..start_byte]); - self.lines[start.row] = tail; + self.apply_edits(edits); + } else { + for (target, range) in self.targets.iter_mut().zip(&ranges) { + target.active = range.start; } - self.search_match = None; } - if changing { - self.mode = Mode::Insert; - } - self.clamp_cursor(); if changing { self.insert_start = before; self.message.clear(); } else { + self.mode = Mode::Normal; + self.normalize_targets(); if let Some(before) = before { self.record(before); } - self.set_message(if cut { - "Cut selection" + self.set_message(if !nonempty { + "Nothing selected" + } else if cut { + "Cut selections" } else { - "Copied selection" + "Copied selections" }); } } @@ -1041,28 +1404,47 @@ impl Editor { self.set_message("Clipboard is empty"); return; } - let before = self.snapshot(); - if after { - self.cursor.column = - (self.cursor.column + 1).min(grapheme_count(&self.lines[self.cursor.row])); + if self.clipboard.len() != 1 && self.clipboard.len() != self.targets.len() { + self.set_message("Clipboard fragment count does not match caret count"); + return; } - let text = std::mem::take(&mut self.clipboard); - self.insert_text(&text); - self.clipboard = text; - self.cursor.column = self.cursor.column.saturating_sub(1); - self.clamp_cursor(); + let before = self.snapshot(); + let clipboard = std::mem::take(&mut self.clipboard); + let offsets = line_offsets(&self.lines); + let edits = self + .targets + .iter() + .enumerate() + .map(|(target, item)| { + let mut cursor = item.active; + if after { + cursor.column = + (cursor.column + 1).min(grapheme_count(&self.lines[cursor.row])); + } + let start = cursor_offset(&self.lines, &offsets, cursor); + let text = &clipboard[if clipboard.len() == 1 { 0 } else { target }]; + Edit { + target, + start, + end: start, + text: Cow::Borrowed(text), + caret: text.len(), + round_up: true, + } + }) + .collect(); + self.mode = Mode::Insert; + self.apply_edits(edits); + self.clipboard = clipboard; + for target in &mut self.targets { + target.active.column = target.active.column.saturating_sub(1); + } + self.mode = Mode::Normal; + self.normalize_targets(); self.record(before); self.message.clear(); } - fn clear_selection(&mut self) { - self.selection_anchor = None; - if self.mode == Mode::Visual { - self.mode = Mode::Normal; - self.clamp_cursor(); - } - } - fn begin_insert(&mut self) { self.insert_start = Some(self.snapshot()); self.mode = Mode::Insert; @@ -1088,21 +1470,25 @@ impl Editor { self.finish_insert(); let before = (!text.is_empty()).then(|| self.snapshot()); self.insert_text(&normalize_newlines(text)); - self.clamp_cursor(); if let Some(before) = before { self.record(before); } self.insert_start = Some(self.snapshot()); } + fn restore(&mut self, snapshot: Snapshot) { + self.lines = snapshot.lines; + self.targets = snapshot.targets; + self.primary = snapshot.primary; + self.mode = Mode::Normal; + self.search_match = None; + self.normalize_targets(); + } + fn undo(&mut self) { if let Some(before) = self.undo.pop() { - let current = self.snapshot(); - self.search_match = None; - self.lines = before.lines; - self.cursor = before.cursor; - self.redo.push(current); - self.clamp_cursor(); + self.redo.push(self.snapshot()); + self.restore(before); } else { self.set_message("Nothing to undo"); } @@ -1110,57 +1496,41 @@ impl Editor { fn redo(&mut self) { if let Some(after) = self.redo.pop() { - let current = self.snapshot(); - self.search_match = None; - self.lines = after.lines; - self.cursor = after.cursor; - self.undo.push(current); - self.clamp_cursor(); + self.undo.push(self.snapshot()); + self.restore(after); } else { self.set_message("Nothing to redo"); } } - fn max_column(&self) -> usize { - let count = grapheme_count(&self.lines[self.cursor.row]); - if self.mode == Mode::Insert - || (self.mode == Mode::Visual && self.cursor.row + 1 < self.lines.len()) - { - count - } else { - count.saturating_sub(1) + fn move_targets(&mut self, action: Action) { + 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::Right => { + cursor.column = (cursor.column + 1).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::LineStart => cursor.column = 0, + Action::LineEnd => cursor.column = self.max_column(cursor.row), + _ => cursor = self.word_destination(cursor, action), + } + self.targets[index].active = cursor; + self.targets[index].preferred_columns = None; } + self.normalize_targets(); } - - fn clamp_cursor(&mut self) { - self.cursor.row = self.cursor.row.min(self.lines.len() - 1); - self.cursor.column = self.cursor.column.min(self.max_column()); - } - - fn move_horizontal(&mut self, right: bool) { - self.cursor.column = if right { - (self.cursor.column + 1).min(self.max_column()) - } else { - self.cursor.column.saturating_sub(1) - }; - } - - fn move_vertical(&mut self, down: bool) { - self.cursor.row = if down { - (self.cursor.row + 1).min(self.lines.len() - 1) - } else { - self.cursor.row.saturating_sub(1) - }; - self.clamp_cursor(); - } - - fn move_end(&mut self) { - 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); + fn word_destination(&self, cursor: Cursor, action: Action) -> Cursor { + let row = cursor.row; + let byte = grapheme_byte(&self.lines[row], cursor.column); let big = matches!( action, Action::BigWordNext | Action::BigWordPrevious | Action::BigWordEnd @@ -1218,115 +1588,321 @@ impl Editor { }; let line = &self.lines[destination.0]; let column = column_after_byte(line, destination.1); - self.cursor = Cursor { + Cursor { row: destination.0, column: if destination.1 == line.len() && self.mode != Mode::Insert { column.saturating_sub(1) } else { column }, + } + } + + /// Every edit in a batch refers to this one pre-edit buffer. Duplicate row + /// operations share an edit; caret destinations are remapped only afterwards. + // ponytail: rebuilding text costs O(buffer + carets²); use per-line edits for large buffers. + fn apply_edits(&mut self, mut edits: Vec>) { + if edits.is_empty() { + return; + } + edits.sort_by_key(|edit| (edit.start, edit.end)); + let mut unique: Vec = Vec::with_capacity(edits.len()); + for (index, edit) in edits.iter().enumerate() { + if unique.last().is_some_and(|previous| { + let previous = &edits[*previous]; + previous.start == edit.start + && previous.end == edit.end + && previous.text == edit.text + }) { + continue; + } + unique.push(index); + } + let old = self.lines.join("\n"); + let offsets = line_offsets(&self.lines); + let removed: usize = unique + .iter() + .map(|index| edits[*index].end - edits[*index].start) + .sum(); + let added: usize = unique.iter().map(|index| edits[*index].text.len()).sum(); + let mut text = String::with_capacity(old.len() - removed + added); + let mut consumed = 0; + for index in &unique { + let edit = &edits[*index]; + assert!(edit.start >= consumed, "batch edits must not overlap"); + text.push_str(&old[consumed..edit.start]); + text.push_str(&edit.text); + consumed = edit.end; + } + text.push_str(&old[consumed..]); + let remap = |position: usize, after: bool| { + let mut removed = 0; + let mut added = 0; + for index in &unique { + let edit = &edits[*index]; + if position < edit.start { + break; + } + if position == edit.start || position < edit.end { + return edit.start - removed + added + if after { edit.text.len() } else { 0 }; + } + removed += edit.end - edit.start; + added += edit.text.len(); + } + position - removed + added }; + let mut destinations: Vec<_> = self + .targets + .iter() + .map(|target| { + ( + remap(cursor_offset(&self.lines, &offsets, target.active), true), + true, + ) + }) + .collect(); + for edit in &edits { + destinations[edit.target] = (remap(edit.start, false) + edit.caret, edit.round_up); + } + if text != old { + self.search_match = None; + } + self.lines = text.split('\n').map(str::to_owned).collect(); + let offsets = line_offsets(&self.lines); + for (target, (byte, round_up)) in self.targets.iter_mut().zip(destinations) { + target.active = offset_cursor(&self.lines, &offsets, byte, round_up); + target.anchor = target.active; + target.preferred_columns = None; + } + self.normalize_targets(); } fn insert_text(&mut self, text: &str) { if text.is_empty() { return; } - self.search_match = None; - let row = self.cursor.row; - let byte = grapheme_byte(&self.lines[row], self.cursor.column); - let mut parts = text.split('\n'); - let first = parts.next().expect("split always has a first part"); - if !text.contains('\n') { - self.lines[row].insert_str(byte, first); - self.cursor.column = column_after_byte(&self.lines[row], byte + first.len()); - return; - } - let suffix = self.lines[row].split_off(byte); - self.lines[row].push_str(first); - let mut added: Vec = parts.map(str::to_owned).collect(); - let last = added.last_mut().expect("newline supplies another part"); - let inserted_bytes = last.len(); - last.push_str(&suffix); - self.cursor.column = column_after_byte(last, inserted_bytes); - self.cursor.row += added.len(); - self.lines.splice(row + 1..row + 1, added); + let offsets = line_offsets(&self.lines); + let edits = self + .targets + .iter() + .enumerate() + .map(|(target, item)| { + let start = cursor_offset(&self.lines, &offsets, item.active); + Edit { + target, + start, + end: start, + text: Cow::Borrowed(text), + caret: text.len(), + round_up: true, + } + }) + .collect(); + self.apply_edits(edits); + } + + fn open_lines(&mut self, below: bool) { + let offsets = line_offsets(&self.lines); + let edits = self + .targets + .iter() + .enumerate() + .map(|(target, item)| { + let start = offsets[item.active.row] + + if below { + self.lines[item.active.row].len() + } else { + 0 + }; + Edit { + target, + start, + end: start, + text: Cow::Borrowed("\n"), + caret: usize::from(below), + round_up: true, + } + }) + .collect(); + self.apply_edits(edits); } fn delete_forward(&mut self) { - let row = self.cursor.row; - let line = &mut self.lines[row]; - let byte = grapheme_byte(line, self.cursor.column); - if byte < line.len() { - self.search_match = None; - let end = grapheme_byte(line, self.cursor.column + 1); - line.replace_range(byte..end, ""); - self.cursor.column = column_after_byte(line, byte); - } else if self.mode == Mode::Insert && row + 1 < self.lines.len() { - self.search_match = None; - let next = self.lines.remove(row + 1); - self.lines[row].push_str(&next); - self.cursor.column = column_after_byte(&self.lines[row], byte); - } + let offsets = line_offsets(&self.lines); + let edits = self + .targets + .iter() + .enumerate() + .filter_map(|(target, item)| { + let cursor = item.active; + let start = cursor_offset(&self.lines, &offsets, cursor); + let end = if cursor.column < grapheme_count(&self.lines[cursor.row]) { + cursor_offset(&self.lines, &offsets, self.next_boundary(cursor)) + } else if self.mode == Mode::Insert && cursor.row + 1 < self.lines.len() { + start + 1 + } else { + return None; + }; + Some(Edit { + target, + start, + end, + text: Cow::Borrowed(""), + caret: 0, + round_up: true, + }) + }) + .collect(); + self.apply_edits(edits); } fn join_lines(&mut self) { - let row = self.cursor.row; - if row + 1 >= self.lines.len() { - return; - } let before = self.snapshot(); - let next = self.lines.remove(row + 1); - let next = next.trim_start_matches([' ', '\t']); - let line = &mut self.lines[row]; - let join_byte = line.len(); - let separator = if !line.is_empty() - && !next.is_empty() - && !line.ends_with([' ', '\t']) - && !next.starts_with(')') - { - if line.ends_with(['.', '?', '!']) { - " " - } else { - " " - } - } else { - "" - }; - line.reserve(separator.len() + next.len()); - line.push_str(separator); - line.push_str(next); - // A Normal-mode cursor stays on a whole grapheme, even if joining - // a leading combining mark merges it with the preceding character. - self.cursor.column = line - .grapheme_indices(true) - .take_while(|(byte, _)| *byte <= join_byte) - .count() - .saturating_sub(1); + let offsets = line_offsets(&self.lines); + let edits = self + .targets + .iter() + .enumerate() + .filter_map(|(target, item)| { + let row = item.active.row; + let line = &self.lines[row]; + let next = self.lines.get(row + 1)?; + let trimmed = next.trim_start_matches([' ', '\t']); + let separator = if !line.is_empty() + && !trimmed.is_empty() + && !line.ends_with([' ', '\t']) + && !trimmed.starts_with(')') + { + if line.ends_with(['.', '?', '!']) { + " " + } else { + " " + } + } else { + "" + }; + let start = offsets[row] + line.len(); + Some(Edit { + target, + start, + end: start + 1 + next.len() - trimmed.len(), + text: Cow::Borrowed(separator), + caret: 0, + round_up: false, + }) + }) + .collect(); + self.apply_edits(edits); self.record(before); } fn backspace(&mut self) { - let row = self.cursor.row; - if self.cursor.column > 0 { - self.search_match = None; - let line = &mut self.lines[row]; - let start = grapheme_byte(line, self.cursor.column - 1); - let end = grapheme_byte(line, self.cursor.column); - line.replace_range(start..end, ""); - self.cursor.column = column_after_byte(line, start); - } else if row > 0 { - self.search_match = None; - let line = self.lines.remove(row); - self.cursor.row -= 1; - let previous = &mut self.lines[self.cursor.row]; - let byte = previous.len(); - previous.push_str(&line); - self.cursor.column = column_after_byte(previous, byte); - } + let offsets = line_offsets(&self.lines); + let edits = self + .targets + .iter() + .enumerate() + .filter_map(|(target, item)| { + let cursor = item.active; + if cursor.column == 0 && cursor.row == 0 { + return None; + } + let start = cursor_offset(&self.lines, &offsets, self.previous_boundary(cursor)); + let end = cursor_offset(&self.lines, &offsets, cursor); + Some(Edit { + target, + start, + end, + text: Cow::Borrowed(""), + caret: 0, + round_up: true, + }) + }) + .collect(); + self.apply_edits(edits); } } +fn ranges_intersect(left: Selection, right: Selection) -> bool { + if left.start == left.end { + left.start == right.start || (right.start < left.start && left.start < right.end) + } else if right.start == right.end { + left.start <= right.start && right.start < left.end + } else { + left.start < right.end && right.start < left.end + } +} + +fn first_nonblank(line: &str) -> usize { + line.graphemes(true) + .take_while(|grapheme| matches!(*grapheme, " " | "\t")) + .count() +} + +fn display_column(line: &str, column: usize) -> usize { + line.graphemes(true) + .take(column) + .fold(0, |cells, grapheme| { + cells + + if grapheme == "\t" { + 4 - cells % 4 + } else { + grapheme.width() + } + }) +} + +fn column_at_display(line: &str, wanted: usize, maximum: usize) -> usize { + let mut cells = 0; + let mut count = 0; + for (column, grapheme) in line.graphemes(true).enumerate() { + let width = if grapheme == "\t" { + 4 - cells % 4 + } else { + grapheme.width() + }; + if cells + width > wanted { + return column.min(maximum); + } + cells += width; + count = column + 1; + } + count.min(maximum) +} + +fn line_offsets(lines: &[String]) -> Vec { + let mut byte = 0; + lines + .iter() + .map(|line| { + let start = byte; + byte += line.len() + 1; + start + }) + .collect() +} + +fn cursor_offset(lines: &[String], offsets: &[usize], cursor: Cursor) -> usize { + offsets[cursor.row] + grapheme_byte(&lines[cursor.row], cursor.column) +} + +fn offset_cursor(lines: &[String], offsets: &[usize], byte: usize, round_up: bool) -> Cursor { + let row = offsets + .partition_point(|offset| *offset <= byte) + .saturating_sub(1); + let local = (byte - offsets[row]).min(lines[row].len()); + let column = if round_up { + column_after_byte(&lines[row], local) + } else { + lines[row] + .grapheme_indices(true) + .take_while(|(byte, _)| *byte <= local) + .count() + .saturating_sub(1) + }; + Cursor { row, column } +} + fn select_search_match( matches: impl Iterator, origin: (usize, usize), @@ -1545,6 +2121,26 @@ mod tests { } } + fn set_carets(editor: &mut Editor, positions: &[(usize, usize)], primary: usize) { + editor.mode = Mode::Normal; + editor.targets = positions + .iter() + .map(|&(row, column)| Target::caret(Cursor { row, column })) + .collect(); + editor.primary = primary; + editor.normalize_targets(); + } + + fn set_ranges(editor: &mut Editor, ranges: &[(Cursor, Cursor)], primary: usize) { + editor.mode = Mode::Visual; + editor.targets = ranges + .iter() + .map(|&(start, end)| editor.range_target(Selection { start, end }, true)) + .collect(); + editor.primary = primary; + editor.normalize_targets(); + } + #[test] fn visual_change_replaces_reversed_multiline_graphemes_in_one_undo_group() { let mut editor = editor(); @@ -1553,7 +2149,6 @@ mod tests { .apply(vec![Effect::Replace(original.into())]) .unwrap(); submit_search(&mut editor, "👩‍🚀"); - assert_eq!(editor.search_match().unwrap().start.row, 2); editor.handle(Input::Char('v')); editor.handle(Input::Up); editor.handle(Input::Up); @@ -1562,68 +2157,40 @@ mod tests { } editor.handle(Input::Char('c')); assert_eq!(editor.mode(), Mode::Insert); - assert_eq!(editor.selection(), None); + assert!(editor.selections().is_empty()); assert_eq!(editor.search_match(), None); assert_eq!(editor.cursor(), Cursor { row: 0, column: 3 }); assert_eq!(editor.context().text, "pretail\npost"); editor.handle(Input::Paste("\0".into())); - assert_eq!(editor.context().text, "pretail\npost"); type_text(&mut editor, "α\nβ"); editor.handle(Input::Escape); - let replacement = "preα\nβtail\npost"; - assert_eq!(editor.context().text, replacement); + assert_eq!(editor.context().text, "preα\nβtail\npost"); editor.handle(Input::Char('u')); assert_eq!(editor.context().text, original); + assert_eq!(editor.mode(), Mode::Normal); editor.handle(Input::Char('U')); - assert_eq!(editor.context().text, replacement); - editor.handle(Input::Char('u')); - editor.handle(Input::Char('P')); - assert_eq!( - editor.context().text, - "pre界e\u{301}\n\n👩‍🚀界e\u{301}\n\n👩‍🚀tail\npost" - ); + assert_eq!(editor.context().text, "preα\nβtail\npost"); } #[test] - fn visual_change_at_eof_and_empty_ranges_preserves_insertion_and_clipboard_semantics() { + fn visual_change_at_eof_and_empty_ranges_preserves_clipboard() { let mut editor = editor(); editor.apply(vec![Effect::Replace("abX".into())]).unwrap(); editor.handle(Input::End); editor.handle(Input::Char('v')); editor.handle(Input::Char('c')); - assert_eq!(editor.mode(), Mode::Insert); assert_eq!(editor.cursor(), Cursor { row: 0, column: 2 }); type_text(&mut editor, "YZ"); editor.handle(Input::Escape); assert_eq!(editor.context().text, "abYZ"); editor.handle(Input::Char('u')); assert_eq!(editor.context().text, "abX"); - editor.handle(Input::Char('v')); - editor.handle(Input::Char('c')); - editor.handle(Input::Escape); - assert_eq!(editor.context().text, "ab"); - editor.handle(Input::Char('u')); - assert_eq!(editor.context().text, "abX"); - editor.handle(Input::Char('P')); - assert_eq!(editor.context().text, "abXX"); - editor.apply(vec![Effect::Replace("".into())]).unwrap(); - editor.handle(Input::Char('p')); - editor.handle(Input::Char('u')); - assert_eq!(editor.context().text, ""); - editor.handle(Input::Char('v')); - editor.handle(Input::Char('c')); - assert_eq!(editor.mode(), Mode::Insert); - assert_eq!(editor.cursor(), Cursor::default()); - editor.handle(Input::Escape); - editor.handle(Input::Char('U')); - assert_eq!(editor.context().text, "X"); - editor.handle(Input::Char('u')); editor.handle(Input::Char('v')); + assert_eq!(editor.selections()[0].start, editor.selections()[0].end); editor.handle(Input::Char('c')); type_text(&mut editor, "new"); editor.handle(Input::Escape); - assert_eq!(editor.context().text, "new"); editor.handle(Input::Char('u')); assert_eq!(editor.context().text, ""); editor.handle(Input::Char('p')); @@ -1638,13 +2205,11 @@ mod tests { type_text(&mut editor, "X"); editor.handle(Input::Escape); editor.handle(Input::Char('u')); - assert_eq!(editor.context().text, "abc"); editor.handle(Input::Char('v')); editor.handle(Input::Char('e')); editor.handle(Input::Char('c')); type_text(&mut editor, "abc"); editor.handle(Input::Escape); - assert_eq!(editor.context().text, "abc"); editor.handle(Input::Char('U')); assert_eq!(editor.context().text, "Xabc"); } @@ -1658,190 +2223,49 @@ mod tests { editor.handle(Input::Char('v')); editor.handle(Input::Right); assert_eq!( - editor.selection(), - Some(Selection { + editor.selections(), + vec![Selection { start: Cursor { row: 0, column: 1 }, end: Cursor { row: 0, column: 3 }, - }) + }] ); editor.handle(Input::Left); editor.handle(Input::Left); assert_eq!( - editor.selection().unwrap().start, - Cursor { row: 0, column: 0 } - ); - assert_eq!( - editor.selection().unwrap().end, - Cursor { row: 0, column: 2 } + editor.selections()[0], + Selection { + start: Cursor { row: 0, column: 0 }, + end: Cursor { row: 0, column: 2 }, + } ); editor.handle(Input::Down); - assert_eq!( - editor.selection().unwrap().end, - Cursor { row: 2, column: 0 } - ); - let selected = editor.selection(); + assert_eq!(editor.selections()[0].end, Cursor { row: 2, column: 0 }); + let selected = editor.selections(); editor.handle(Input::Paste(":q!\n".into())); - assert_eq!(editor.selection(), selected); - assert_eq!(editor.context().text, text); - assert!(!editor.should_quit()); + assert_eq!(editor.selections(), selected); editor.handle(Input::Down); - assert_eq!( - editor.selection().unwrap().end, - Cursor { row: 2, column: 1 } - ); editor.handle(Input::Char('d')); - assert_eq!(editor.mode(), Mode::Normal); - assert_eq!(editor.selection(), None); + assert!(editor.selections().is_empty()); assert_eq!(editor.context().text, "atail\n"); editor.handle(Input::Char('P')); assert_eq!(editor.context().text, text); editor.handle(Input::Char('u')); - assert_eq!(editor.context().text, "atail\n"); - editor.handle(Input::Char('u')); - assert_eq!(editor.context().text, text); - editor.handle(Input::Char('U')); - assert_eq!(editor.context().text, "atail\n"); - editor.handle(Input::Char('U')); - assert_eq!(editor.context().text, text); - - editor.handle(Input::Char('v')); - editor.handle(Input::Up); - editor.handle(Input::Up); - editor.handle(Input::Char('y')); - assert_eq!(editor.context().text, text); - editor.handle(Input::Char('P')); - assert_eq!( - editor.context().text, - "a界e\u{301}z\n\n👩‍🚀a界e\u{301}z\n\n👩‍🚀tail\n" - ); editor.handle(Input::Char('u')); assert_eq!(editor.context().text, text); + assert_eq!(editor.mode(), Mode::Normal); } #[test] - fn clipboard_copy_preserves_redo_and_pastes_are_independent_of_clipboard_history() { + fn clipboard_copy_preserves_redo_and_survives_history_and_grapheme_merging() { let mut editor = editor(); - editor.apply(vec![Effect::Replace("x界".into())]).unwrap(); - editor.handle(Input::Char('u')); - editor.handle(Input::Char('p')); - editor.handle(Input::Char('P')); - assert_eq!(editor.context().text, ""); - editor.handle(Input::Char('U')); - assert_eq!(editor.context().text, "x界"); - editor.handle(Input::Char('v')); - editor.handle(Input::Right); - editor.handle(Input::Char('y')); - assert_eq!(editor.mode(), Mode::Normal); - assert_eq!(editor.context().text, "x界"); - editor.handle(Input::Char('u')); - assert_eq!(editor.context().text, ""); - editor.handle(Input::Char('P')); - assert_eq!(editor.context().text, "x界"); - editor.handle(Input::Char('u')); - assert_eq!(editor.context().text, ""); - editor.handle(Input::Char('U')); - editor.handle(Input::Char('p')); - assert_eq!(editor.context().text, "x界x界"); - editor.handle(Input::Char('u')); - editor.handle(Input::Char('P')); - assert_eq!(editor.context().text, "xx界界"); - editor.handle(Input::Char('u')); - editor.handle(Input::Char('v')); - editor.handle(Input::Char('y')); - editor.handle(Input::Char('U')); - assert_eq!(editor.context().text, "xx界界"); - editor.handle(Input::Char('u')); - editor.handle(Input::Char('P')); - assert_eq!(editor.context().text, "x界界"); - - editor.apply(vec![Effect::Replace("".into())]).unwrap(); - editor.handle(Input::Char('v')); - let empty = editor.selection().unwrap(); - assert_eq!(empty.start, empty.end); - editor.handle(Input::Char('d')); - editor.handle(Input::Char('p')); - assert_eq!(editor.context().text, "界"); - editor.handle(Input::Char('u')); - editor.handle(Input::Char('v')); - editor.handle(Input::Escape); - editor.handle(Input::Char('U')); - assert_eq!(editor.context().text, "界"); - } - - #[test] - fn visual_empty_lines_and_line_end_include_real_newlines_and_cancel_clamps_cursor() { - let mut editor = editor(); - let text = "ab\n\ncd\n"; - editor.apply(vec![Effect::Replace(text.into())]).unwrap(); - editor.handle(Input::Down); - editor.handle(Input::Char('v')); - assert_eq!( - editor.selection(), - Some(Selection { - start: Cursor { row: 1, column: 0 }, - end: Cursor { row: 2, column: 0 }, - }) - ); - editor.handle(Input::Char('y')); - editor.handle(Input::Down); - editor.handle(Input::Char('p')); - assert_eq!(editor.context().text, "ab\n\nc\nd\n"); - editor.handle(Input::Char('u')); - assert_eq!(editor.context().text, text); - editor.handle(Input::Up); - editor.handle(Input::Char('v')); - editor.handle(Input::Delete); - assert_eq!(editor.context().text, "ab\ncd\n"); - editor.handle(Input::Char('u')); - assert_eq!(editor.context().text, text); - editor.handle(Input::Up); - editor.handle(Input::End); - editor.handle(Input::Char('v')); - editor.handle(Input::End); - assert_eq!(editor.cursor(), Cursor { row: 0, column: 2 }); - assert_eq!( - editor.selection().unwrap().end, - Cursor { row: 1, column: 0 } - ); - editor.handle(Input::Char('y')); - editor.handle(Input::Char('P')); - assert_eq!(editor.context().text, "ab\nb\n\ncd\n"); - editor.handle(Input::Char('u')); - editor.handle(Input::Char('v')); - editor.handle(Input::End); - editor.handle(Input::Escape); - assert_eq!(editor.mode(), Mode::Normal); - assert_eq!(editor.cursor(), Cursor { row: 0, column: 1 }); - assert_eq!(editor.selection(), None); - } - - #[test] - fn selected_effects_validate_before_mode_changes_and_clipboard_graphemes_can_merge() { - let mut editor = editor(); - editor - .apply(vec![Effect::Replace("abcd\n👩‍🚀x".into())]) - .unwrap(); - editor.handle(Input::Char('v')); - editor.handle(Input::Down); - editor.handle(Input::End); - let selected = editor.selection(); - assert!(editor.apply(vec![Effect::Replace("\0".into())]).is_err()); - assert_eq!(editor.mode(), Mode::Visual); - assert_eq!(editor.selection(), selected); - editor.apply(vec![Effect::Replace("z".into())]).unwrap(); - assert_eq!(editor.mode(), Mode::Normal); - assert_eq!(editor.selection(), None); - assert_eq!(editor.cursor(), Cursor::default()); - editor.handle(Input::Char('v')); - editor.execute("plugins"); - assert_eq!(editor.selection(), None); - assert_eq!(editor.mode(), Mode::Normal); - editor .apply(vec![Effect::Replace("\u{301}".into())]) .unwrap(); editor.handle(Input::Char('v')); editor.handle(Input::Char('y')); + editor.handle(Input::Char('u')); + editor.handle(Input::Char('U')); + assert_eq!(editor.context().text, "\u{301}"); editor.apply(vec![Effect::Replace("a".into())]).unwrap(); editor.handle(Input::Char('p')); assert_eq!(editor.context().text, "a\u{301}"); @@ -1850,6 +2274,380 @@ mod tests { assert_eq!(editor.context().text, "a"); } + #[test] + fn visual_empty_lines_include_real_newlines_and_row_split_excludes_exclusive_end() { + let mut editor = editor(); + editor + .apply(vec![Effect::Replace("ab\n\ncd\n".into())]) + .unwrap(); + editor.handle(Input::Down); + editor.handle(Input::Char('v')); + assert_eq!( + editor.selections(), + vec![Selection { + start: Cursor { row: 1, column: 0 }, + end: Cursor { row: 2, column: 0 }, + }] + ); + editor.handle(Input::Ctrl('l')); + assert_eq!(editor.carets(), vec![Cursor { row: 1, column: 0 }]); + editor.handle(Input::Char('v')); + editor.handle(Input::Delete); + assert_eq!(editor.context().text, "ab\ncd\n"); + editor.handle(Input::Char('u')); + assert_eq!(editor.context().text, "ab\n\ncd\n"); + } + + #[test] + fn batch_insert_newline_backspace_and_delete_use_prebuffer_offsets() { + let mut editor = editor(); + editor.apply(vec![Effect::Replace("abcd".into())]).unwrap(); + set_carets(&mut editor, &[(0, 0), (0, 2)], 1); + editor.handle(Input::Char('i')); + type_text(&mut editor, "X"); + assert_eq!(editor.context().text, "XabXcd"); + editor.handle(Input::Enter); + assert_eq!(editor.context().text, "X\nabX\ncd"); + editor.handle(Input::Backspace); + assert_eq!(editor.context().text, "XabXcd"); + editor.handle(Input::Delete); + assert_eq!(editor.context().text, "XbXd"); + editor.handle(Input::Escape); + editor.handle(Input::Char('u')); + assert_eq!(editor.context().text, "abcd"); + assert_eq!( + editor.carets(), + vec![Cursor { row: 0, column: 0 }, Cursor { row: 0, column: 2 }] + ); + assert_eq!(editor.cursor(), Cursor { row: 0, column: 2 }); + } + + #[test] + fn visual_insert_and_append_split_ragged_rows_without_padding() { + for (key, expected) in [('I', " !aa\n!\n !zzz"), ('A', " aa!\n!\n zzz!")] { + let mut editor = editor(); + editor + .apply(vec![Effect::Replace(" aa\n\n zzz".into())]) + .unwrap(); + editor.handle(Input::Char('v')); + editor.handle(Input::Down); + editor.handle(Input::Down); + editor.handle(Input::Char(key)); + assert_eq!(editor.mode(), Mode::Insert); + assert_eq!(editor.selection_count(), 3); + type_text(&mut editor, "!"); + editor.handle(Input::Escape); + assert_eq!(editor.context().text, expected); + editor.handle(Input::Char('u')); + assert_eq!(editor.context().text, " aa\n\n zzz"); + } + } + + #[test] + fn unequal_word_changes_and_typing_share_undo_and_restore_primary_carets() { + let mut editor = editor(); + editor + .apply(vec![Effect::Replace("foo\nlonger".into())]) + .unwrap(); + editor.handle(Input::Ctrl('e')); + editor.handle(Input::Char('v')); + editor.handle(Input::Char('e')); + assert_eq!( + editor + .selections() + .iter() + .map(|range| range.end.column) + .collect::>(), + vec![3, 6] + ); + let before = editor.carets(); + let primary = editor.cursor(); + editor.handle(Input::Char('c')); + type_text(&mut editor, "X"); + editor.handle(Input::Escape); + assert_eq!(editor.context().text, "X\nX"); + assert_eq!(editor.clipboard, vec!["foo", "longer"]); + editor.handle(Input::Char('u')); + assert_eq!(editor.context().text, "foo\nlonger"); + assert_eq!(editor.carets(), before); + assert_eq!(editor.cursor(), primary); + assert_eq!(editor.mode(), Mode::Normal); + assert!(editor.selections().is_empty()); + editor.handle(Input::Char('U')); + assert_eq!(editor.context().text, "X\nX"); + } + + #[test] + fn unicode_overlap_merges_but_adjacency_preserves_distinct_fragments() { + let mut editor = editor(); + editor + .apply(vec![Effect::Replace("a\u{301}👩‍🚀界z".into())]) + .unwrap(); + editor.mode = Mode::Visual; + editor.targets = vec![ + editor.range_target( + Selection { + start: Cursor { row: 0, column: 0 }, + end: Cursor { row: 0, column: 2 }, + }, + true, + ), + editor.range_target( + Selection { + start: Cursor { row: 0, column: 1 }, + end: Cursor { row: 0, column: 3 }, + }, + false, + ), + editor.range_target( + Selection { + start: Cursor { row: 0, column: 3 }, + end: Cursor { row: 0, column: 4 }, + }, + true, + ), + ]; + editor.primary = 1; + editor.normalize_targets(); + assert_eq!(editor.selection_count(), 2); + assert_eq!(editor.selections()[0].end.column, 3); + assert_eq!(editor.cursor().column, 0); + editor.handle(Input::Char('d')); + assert_eq!(editor.context().text, ""); + assert_eq!(editor.clipboard, vec!["a\u{301}👩‍🚀界", "z"]); + assert_eq!(editor.selection_count(), 1); + editor.handle(Input::Char('u')); + assert_eq!( + editor.carets(), + vec![Cursor { row: 0, column: 0 }, Cursor { row: 0, column: 3 }] + ); + } + + #[test] + fn clipboard_pairs_in_document_order_broadcasts_one_and_refuses_mismatch_atomically() { + let mut editor = editor(); + editor + .apply(vec![Effect::Replace("ab\nxyzq".into())]) + .unwrap(); + set_ranges( + &mut editor, + &[ + (Cursor { row: 0, column: 0 }, Cursor { row: 0, column: 1 }), + (Cursor { row: 1, column: 0 }, Cursor { row: 1, column: 3 }), + ], + 1, + ); + editor.handle(Input::Char('d')); + assert_eq!(editor.context().text, "b\nq"); + editor.handle(Input::Char('P')); + assert_eq!(editor.context().text, "ab\nxyzq"); + editor.handle(Input::Char('u')); + editor.handle(Input::Escape); + let before = editor.snapshot(); + let undo = editor.undo.len(); + let redo = editor.redo.len(); + editor.handle(Input::Char('p')); + assert_eq!(editor.lines, before.lines); + assert_eq!(editor.targets, before.targets); + assert_eq!(editor.primary, before.primary); + assert_eq!((editor.undo.len(), editor.redo.len()), (undo, redo)); + assert!(editor.message().contains("fragment count")); + editor.clipboard = vec!["!".into()]; + set_carets(&mut editor, &[(0, 0), (1, 0)], 0); + editor.handle(Input::Char('P')); + assert_eq!(editor.context().text, "!b\n!q"); + } + + #[test] + fn literal_multiline_occurrences_skip_wrap_select_all_and_escape_in_two_stages() { + let mut editor = editor(); + editor + .apply(vec![Effect::Replace("α\nβ α\nβ α\nβ".into())]) + .unwrap(); + set_ranges( + &mut editor, + &[(Cursor { row: 0, column: 0 }, Cursor { row: 1, column: 1 })], + 0, + ); + editor.handle(Input::Ctrl('n')); + assert_eq!(editor.selection_count(), 2); + assert_eq!(editor.cursor(), Cursor { row: 2, column: 0 }); + editor.handle(Input::Ctrl('s')); + assert_eq!(editor.selection_count(), 2); + assert_eq!(editor.cursor(), Cursor { row: 3, column: 0 }); + editor.handle(Input::Ctrl('n')); + assert_eq!(editor.selection_count(), 3); + assert_eq!(editor.cursor(), Cursor { row: 2, column: 0 }); + editor.handle(Input::Ctrl('a')); + assert_eq!(editor.selection_count(), 3); + editor.handle(Input::Escape); + assert_eq!(editor.mode(), Mode::Normal); + assert_eq!(editor.selection_count(), 3); + let primary = editor.cursor(); + editor.handle(Input::Escape); + assert_eq!(editor.carets(), vec![primary]); + } + + #[test] + fn occurrences_reject_grapheme_intersections_and_gather_nonoverlapping_literals() { + let mut editor = editor(); + editor + .apply(vec![Effect::Replace("aa aa aaa a\u{301}a".into())]) + .unwrap(); + editor.handle(Input::Char('v')); + editor.handle(Input::Right); + editor.handle(Input::Ctrl('a')); + assert_eq!( + editor + .selections() + .iter() + .map(|range| range.start.column) + .collect::>(), + vec![0, 3, 6] + ); + assert_eq!(editor.cursor().column, 1); + editor.handle(Input::Ctrl('n')); + assert_eq!(editor.selection_count(), 3); + } + + #[test] + fn vertical_clones_keep_display_columns_through_tabs_wide_and_short_rows() { + let mut editor = editor(); + editor + .apply(vec![Effect::Replace("a\t界z\nx\n123456z".into())]) + .unwrap(); + editor.handle(Input::End); + editor.handle(Input::Ctrl('e')); + assert_eq!(editor.cursor(), Cursor { row: 1, column: 0 }); + editor.handle(Input::Ctrl('e')); + assert_eq!(editor.selection_count(), 3); + assert_eq!(editor.cursor(), Cursor { row: 2, column: 6 }); + editor.handle(Input::Char('i')); + type_text(&mut editor, "!"); + editor.handle(Input::Escape); + assert_eq!(editor.context().text, "a\t界!z\n!x\n123456!z"); + assert_eq!(editor.selection_count(), 3); + editor.handle(Input::Escape); + assert_eq!(editor.selection_count(), 1); + } + + #[test] + fn visual_clones_preserve_directed_ranges_and_motion_targets_every_range() { + let mut editor = editor(); + editor + .apply(vec![Effect::Replace("abc\nabcd\nabcde".into())]) + .unwrap(); + editor.handle(Input::Right); + editor.handle(Input::Char('v')); + editor.handle(Input::Right); + editor.handle(Input::Ctrl('e')); + assert_eq!(editor.selection_count(), 2); + editor.handle(Input::Ctrl('e')); + assert_eq!(editor.selection_count(), 3); + editor.handle(Input::Left); + assert!( + editor + .selections() + .iter() + .all(|range| range.start.column == 1 && range.end.column == 2) + ); + editor.handle(Input::Char('c')); + type_text(&mut editor, "!"); + editor.handle(Input::Escape); + assert_eq!(editor.context().text, "a!c\na!cd\na!cde"); + } + + #[test] + fn batch_open_deduplicates_rows_and_join_uses_original_neighbor_rows() { + for (key, expected) in [('o', "abc\n\nxyz\n"), ('O', "\nabc\n\nxyz")] { + let mut editor = editor(); + editor + .apply(vec![Effect::Replace("abc\nxyz".into())]) + .unwrap(); + set_carets(&mut editor, &[(0, 0), (0, 2), (1, 1)], 2); + editor.handle(Input::Char(key)); + editor.handle(Input::Escape); + assert_eq!(editor.context().text, expected); + assert_eq!(editor.selection_count(), 2); + editor.handle(Input::Char('u')); + assert_eq!(editor.context().text, "abc\nxyz"); + assert_eq!(editor.selection_count(), 3); + } + let mut editor = editor(); + editor + .apply(vec![Effect::Replace("a\n b\n c".into())]) + .unwrap(); + set_carets(&mut editor, &[(0, 0), (1, 1)], 1); + editor.handle(Input::Char('J')); + assert_eq!(editor.context().text, "a b c"); + editor.handle(Input::Char('u')); + assert_eq!(editor.context().text, "a\n b\n c"); + assert_eq!(editor.selection_count(), 2); + } + + #[test] + fn plugin_text_effects_and_invocation_are_single_primary_but_messages_are_not() { + let mut editor = editor(); + editor + .apply(vec![Effect::Replace("aa\nbb\ncc".into())]) + .unwrap(); + editor.register(plugin("sample", &["sample"])).unwrap(); + set_carets(&mut editor, &[(0, 0), (1, 0), (2, 0)], 1); + assert_eq!(editor.execute("sample").unwrap().command, "sample"); + assert_eq!(editor.selection_count(), 3); + editor.handle(Input::Char('v')); + let ranges = editor.selections(); + assert!(editor.apply(vec![Effect::Insert("\0".into())]).is_err()); + editor.apply(vec![Effect::Message("ok".into())]).unwrap(); + assert_eq!(editor.selections(), ranges); + editor.apply(vec![Effect::Insert("!".into())]).unwrap(); + assert_eq!(editor.context().text, "aa\n!bb\ncc"); + assert_eq!(editor.selection_count(), 1); + editor.handle(Input::Char('u')); + assert_eq!(editor.context().text, "aa\nbb\ncc"); + assert_eq!(editor.selection_count(), 3); + assert_eq!(editor.cursor(), Cursor { row: 1, column: 0 }); + assert_eq!(editor.mode(), Mode::Normal); + } + + #[test] + fn literal_paste_broadcasts_and_retains_separate_typing_history_for_all_carets() { + let mut editor = editor(); + editor.apply(vec![Effect::Replace("a\nb".into())]).unwrap(); + editor.handle(Input::Ctrl('e')); + editor.handle(Input::Char('i')); + type_text(&mut editor, "x"); + editor.handle(Input::Paste("界e\u{301}\r\n".into())); + type_text(&mut editor, "z"); + editor.handle(Input::Escape); + assert_eq!(editor.context().text, "x界e\u{301}\nza\nx界e\u{301}\nzb"); + for expected in ["x界e\u{301}\na\nx界e\u{301}\nb", "xa\nxb", "a\nb"] { + editor.handle(Input::Char('u')); + assert_eq!(editor.context().text, expected); + assert_eq!(editor.selection_count(), 2); + } + } + + #[test] + fn caret_motions_and_search_apply_to_all_targets_and_collapse_duplicates() { + let mut editor = editor(); + editor + .apply(vec![Effect::Replace("cat cat\ncat cat".into())]) + .unwrap(); + set_carets(&mut editor, &[(0, 0), (1, 0), (1, 0)], 2); + assert_eq!(editor.selection_count(), 2); + assert_eq!(editor.cursor(), Cursor { row: 1, column: 0 }); + submit_search(&mut editor, "cat"); + assert_eq!( + editor.carets(), + vec![Cursor { row: 0, column: 4 }, Cursor { row: 1, column: 4 }] + ); + editor.handle(Input::Char('b')); + assert!(editor.carets().iter().all(|cursor| cursor.column == 0)); + editor.handle(Input::Up); + assert_eq!(editor.carets(), vec![Cursor::default()]); + } + #[test] fn word_motions_distinguish_punctuation_cross_empty_lines_and_preserve_history() { let mut editor = editor(); diff --git a/src/main.rs b/src/main.rs index 15f3c30..718eb6e 100644 --- a/src/main.rs +++ b/src/main.rs @@ -11,9 +11,13 @@ 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 - v select, p/P paste after/before cursor -Visual: motions extend selection, y copy, d/x/Delete cut, c change, v/Esc cancel -Insert: Esc returns to normal + v select, p/P paste fragments, Ctrl+k/Ctrl+e clone above/below + Esc keeps only the primary caret +Visual: motions extend selections, y copy, d/x/Delete cut, c change + I/A insert at each selected line's first nonblank/end + Ctrl+l split rows, Ctrl+n add occurrence, Ctrl+s skip, Ctrl+a all + v/Esc returns to Normal retaining carets +Insert: Esc returns to Normal retaining carets Search: Enter finds next literal match, Esc cancels (case-sensitive, wraps) Commands: :w [PATH], :q, :q!, :wq [PATH], :plugins, :PLUGIN [ARGS] diff --git a/src/ruby_host.rb b/src/ruby_host.rb index 3943f21..77f19d9 100644 --- a/src/ruby_host.rb +++ b/src/ruby_host.rb @@ -29,6 +29,7 @@ module Vamp search search_next search_previous word_next word_previous word_end big_word_next big_word_previous big_word_end select copy cut change paste_before paste_after + select_lines clone_above clone_below select_next skip_occurrence select_all ].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 9e9e581..012b73c 100644 --- a/src/session.rs +++ b/src/session.rs @@ -155,6 +155,55 @@ Vamp.command("healthy") { |editor, args| editor.replace "healthy #{args}" } } } + #[test] + fn configured_selection_actions_edit_together_and_invoke_plugins_once() { + let fixture = Fixture::new(); + fixture.file("buffer.txt", " alpha\nb\n gamma"); + fixture.file( + "once.rb", + r#" +Vamp.plugin "once", requires: ["text"] +Vamp.command("once") { |editor, _| editor.insert "!" } +"#, + ); + let mut session = fixture.open( + r#" +Vamp.configure do |config| + config.plugin "once.rb" + config.bind "r", config.core.select_lines, mode: config.modes.visual + config.bind "z", config.core.clone_below + config.bind "f", "once" +end +"#, + ); + session.handle(Input::Char('v')); + session.handle(Input::Char('j')); + session.handle(Input::Char('j')); + session.handle(Input::Char('r')); + assert_eq!(session.editor().selection_count(), 3); + session.handle(Input::Char('A')); + session.handle(Input::Char('?')); + session.handle(Input::Escape); + assert_eq!(session.editor().lines(), &[" alpha?", "b?", " gamma?"]); + session.handle(Input::Char('u')); + assert_eq!(session.editor().lines(), &[" alpha", "b", " gamma"]); + assert_eq!(session.editor().selection_count(), 3); + session.handle(Input::Char('f')); + assert_eq!(session.editor().selection_count(), 1); + assert_eq!( + session + .editor() + .lines() + .iter() + .map(|line| line.matches('!').count()) + .sum::(), + 1 + ); + session.handle(Input::Char('u')); + assert_eq!(session.editor().lines(), &[" alpha", "b", " gamma"]); + assert_eq!(session.editor().selection_count(), 3); + } + #[test] fn startup_rejection_installs_no_commands_and_keeps_other_plugins_and_core() { let fixture = Fixture::new(); @@ -468,7 +517,7 @@ end session.handle(Input::Ctrl('v')); assert_eq!(session.editor().mode(), crate::editor::Mode::Visual); session.handle(Input::Ctrl('f')); - assert_eq!(session.editor().selection().unwrap().end.column, 3); + assert_eq!(session.editor().selections()[0].end.column, 3); session.handle(Input::Ctrl('c')); assert_eq!(session.editor().mode(), crate::editor::Mode::Normal); assert!(!session.editor().dirty()); @@ -491,7 +540,7 @@ end session.handle(Input::Ctrl('v')); session.handle(Input::Ctrl('g')); assert_eq!(session.editor().mode(), crate::editor::Mode::Normal); - assert_eq!(session.editor().selection(), None); + assert!(session.editor().selections().is_empty()); } #[test] diff --git a/src/tui.rs b/src/tui.rs index 929d8bf..432fe24 100644 --- a/src/tui.rs +++ b/src/tui.rs @@ -242,7 +242,7 @@ fn draw(frame: &mut Frame, editor: &Editor, top: &mut usize, left: &mut usize) { buffer[(area.x + (column - *left) as u16, y)].set_style(style); } } - if let Some(selection) = editor.selection() { + for selection in editor.selections() { let first = selection.start.row.max(*top); let last = selection.end.row.min(top.saturating_add(height - 1)); let style = Style::default().fg(Color::White).bg(Color::Blue); @@ -267,6 +267,19 @@ fn draw(frame: &mut Frame, editor: &Editor, top: &mut usize, left: &mut usize) { } } } + for caret in editor.carets().into_iter().filter(|caret| *caret != cursor) { + if caret.row < *top || caret.row >= top.saturating_add(height) { + continue; + } + let column = display_column(&editor.lines()[caret.row], caret.column); + if column >= *left && column < left.saturating_add(width) { + frame.buffer_mut()[( + area.x + (column - *left) as u16, + area.y + (caret.row - *top) as u16, + )] + .set_style(Style::default().fg(Color::Black).bg(Color::Magenta)); + } + } let path = editor .path() .map(|p| p.to_string_lossy().into_owned()) @@ -276,12 +289,17 @@ fn draw(frame: &mut Frame, editor: &Editor, top: &mut usize, left: &mut usize) { .map(|c| if c.is_control() { '�' } else { c }) .collect(); let status = format!( - " {} {}{} {}:{}", + " {} {}{} {}:{}{}", editor.mode(), path, if editor.dirty() { " [+]" } else { "" }, cursor.row + 1, - cursor.column + 1 + cursor.column + 1, + if editor.selection_count() > 1 { + format!(" {} selections", editor.selection_count()) + } else { + String::new() + } ); frame.render_widget( Paragraph::new(status).style(Style::default().fg(Color::Black).bg(Color::Cyan)), @@ -326,6 +344,72 @@ mod tests { assert_eq!(visible_line(text, 5, 2), " e\u{301}"); } + #[test] + fn selection_set_renders_all_ranges_and_counts_offscreen_targets() { + let mut editor = Editor::open(None, vec![]).unwrap(); + editor + .apply(vec![crate::editor::Effect::Replace( + "界\n界\n界\n界\n界".into(), + )]) + .unwrap(); + editor.handle(Input::Home); + editor.handle(Input::Char('v')); + editor.handle(Input::Ctrl('a')); + assert_eq!(editor.selection_count(), 5); + assert_eq!(editor.selections().len(), 5); + let mut terminal = Terminal::new(ratatui::backend::TestBackend::new(48, 4)).unwrap(); + let (mut top, mut left) = (0, 0); + terminal + .draw(|frame| draw(frame, &editor, &mut top, &mut left)) + .unwrap(); + let buffer = terminal.backend().buffer(); + for selection in editor.selections() { + if selection.start.row >= top && selection.start.row < top + 2 { + let y = (selection.start.row - top) as u16; + assert!(matches!(buffer[(0, y)].bg, Color::Blue | Color::Magenta)); + } + } + let status: String = (0..48).map(|x| buffer[(x, 2)].symbol()).collect(); + assert!(status.contains("5 selections"), "{status}"); + assert_eq!( + terminal.get_cursor_position().unwrap(), + ratatui::layout::Position::new(0, (editor.cursor().row - top) as u16) + ); + } + + #[test] + fn secondary_carets_render_on_empty_lines_and_at_line_end() { + let mut editor = Editor::open(None, vec![]).unwrap(); + editor + .apply(vec![crate::editor::Effect::Replace("a\n\n界".into())]) + .unwrap(); + editor.handle(Input::Home); + editor.handle(Input::Ctrl('e')); + editor.handle(Input::Ctrl('e')); + assert_eq!(editor.selection_count(), 3); + editor.handle(Input::Char('A')); + let mut terminal = Terminal::new(ratatui::backend::TestBackend::new(48, 5)).unwrap(); + let (mut top, mut left) = (0, 0); + terminal + .draw(|frame| draw(frame, &editor, &mut top, &mut left)) + .unwrap(); + let buffer = terminal.backend().buffer(); + for caret in editor.carets() { + let x = display_column(&editor.lines()[caret.row], caret.column) as u16; + assert_eq!(buffer[(x, caret.row as u16)].symbol(), " "); + if caret != editor.cursor() { + assert_eq!(buffer[(x, caret.row as u16)].bg, Color::Magenta); + } + } + assert_eq!( + terminal.get_cursor_position().unwrap(), + ratatui::layout::Position::new( + display_column(&editor.lines()[editor.cursor().row], editor.cursor().column) as u16, + editor.cursor().row as u16 + ) + ); + } + #[test] fn visual_selection_highlights_graphemes_newlines_and_clipped_ranges_over_search() { let mut editor = Editor::open(None, vec![]).unwrap();