diff --git a/README.md b/README.md index 07640b9..30b7e51 100644 --- a/README.md +++ b/README.md @@ -26,6 +26,7 @@ Vamp starts in Normal mode. Press `i`, type text, then Esc to return to Normal. | Edit completion snippet arguments | Type to replace the highlighted argument; Tab / Shift+Tab moves next/previous | | Save in Normal | Space then `w`, or `:w` then Enter | | Undo/redo in Normal | `u` / `U` (Shift+u) | +| Delete/change whole lines in Normal | `dd` / `cc`; leading counts work (`3dd`, `2cc`) | | Search in Normal | `/`, type a literal query, Enter; `n`/`N` for next/previous | | Select/copy/cut | `v` characterwise or `V` whole rows, move to extend, then `y`/`d` in Visual | | Paste in Normal | `p`/`P` after/before the caret or row for linewise fragments; system clipboard by default | @@ -45,7 +46,7 @@ Ruby configuration and plugins run with your account's filesystem/process permis ## Current limits - One visible buffer at a time; no splits/tabs or graphical frontend. -- Modal editing, not full Vim compatibility: characterwise and linewise Visual selections, but no block selection, named registers, or Vim operators. Search is literal, case-sensitive, and single-line. +- Modal editing, not full Vim compatibility: characterwise and linewise Visual selections and counted `dd`/`cc`, but no block selection, named registers, or general operator–motion grammar. Search is literal, case-sensitive, and single-line. - Native opt-in LSP supports diagnostics, go-to-definition, plain-text hover, manual primary-caret completion with snippet arguments, and automatic semantic highlighting—not rename, formatting, or code actions. Completion has no automatic popup or acceptance step; other carets are preserved and translated through the chosen edits. Snippet regex transforms and unresolved variables without defaults are omitted. - System clipboard access supports macOS and Linux. Separate clipboard mode keeps internal editing available on unsupported/headless environments. diff --git a/docs/architecture.md b/docs/architecture.md index 45a7483..24c3d94 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -28,9 +28,10 @@ Frontends inspect `selections()` for document-ordered, end-exclusive Visual rang The TUI also observes the editor's logical caret-reveal generation. Core motion/navigation dispatch advances it even when the resulting position is unchanged, so a detached viewport can resume following without duplicating key-binding policy or changing text revisions and LSP freshness. Frontends inspect `palette()` for the optional read-only query, filtered command entries, selected index, effective bindings, and eligibility. Palette input handling and acceptance stay in the editor; registered command execution stays in the session. Frontends use cached `selection_navigation_hint()` for command lists and `file_navigation_hint()` for the active file view/context, without reconstructing input policy. -Frontends inspect `count()` for the optional pending motion prefix; parsing and counted motion dispatch remain editor-owned. +Frontends inspect `count()` for the optional pending numeric prefix; parsing, counted motion dispatch, and counted Normal line actions remain editor-owned. Frontends inspect `leader_pending()` for the cached display of a pending leader sequence; prefix recognition, cancellation, and dispatch remain editor-owned. Frontends inspect `g_pending()` for the built-in Normal/Visual `gt`/`gb` prefix and Normal-only `gf`. Document-edge motions share multi-target motion dispatch and viewport reveal, retaining Visual anchors and selection shape. Markdown following preflights links and headings before buffer/caret mutation; an unloaded target is validated/prepared once, while loaded targets use their current unsaved text. File identity and activation stay editor-owned. +Frontends inspect `line_operator_pending()` for the optional Normal `d`/`c` prefix. Dedicated `dd`/`cc` actions project counted caret rows through existing linewise selection normalization, clipboard publication, and atomic range editing, without exposing a temporary Visual mode. Clipboard failures restore the original targets before returning. Repeat recipes retain the requested row count rather than the originally clamped range; change recipes share the existing Insert undo group. There is no general operator–motion parser. Editor-owned jump history is a bounded 100-location `VecDeque` of buffer IDs and grapheme cursors plus a current index, with no copied paths/text. Successful high-level navigation records departure/destination once; traversal uses unrecorded buffer activation and position placement, skips closed/clamped-duplicate locations, and truncates forward entries only on a distinct new jump. Session synchronization permanently invalidates pending LSP position requests after their source buffer/revision/caret/mode is left, so returning through history cannot revive an old definition reply. Frontends inspect `buffer_id()`, `buffer_count()`, `has_buffer(id)`, and the ascending-ID `buffers()` iterator of borrowed path/dirty/active metadata. Existing text, cursor, selection, search, and mode methods describe only the active buffer. Frontends inspect `file_picker()` for query, presentation, view, explorer-local INSERT/filter subfocus, selection, and errors, and iterate borrowed `FileRow` metadata (actual path, display label, directory flag, visible depth, expansion). Dock side is a logical preference. They allocate rectangles, add folder suffixes/colors, and render; hierarchy, filtering, parent/child navigation, focus transitions, and selected-file opening remain backend-owned. diff --git a/docs/configuration.md b/docs/configuration.md index 92c59ed..f70efbe 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -59,6 +59,8 @@ Normal mode binds `ctrl+o` to `config.core.jump_back` and `ctrl+i`/`tab` to `con The built-in `gt`/`gb` sequences move to the first/last line in Normal and Visual; `gf` follows Markdown file links in Normal only. This is not a configurable general sequence prefix: bind `config.core.document_start`, `config.core.document_end`, or `config.core.follow_link` to a single key or leader sequence to add another route. A configured or disabled `g` overrides the built-in prefix in that binding mode; choosing `g` as the leader overrides it in both modes. +Normal `dd` and `cc` delete/change complete rows, with leading counts. Bind `config.core.delete_line` or `config.core.change_line` to a single key or leader sequence for another route. A configured/disabled `d` or `c`, or choosing that key as the leader, overrides its built-in doubled sequence. These are dedicated line actions, not general operator–motion grammar. + ## Action reference | Available modes | Methods on `config.core` | @@ -66,6 +68,7 @@ The built-in `gt`/`gb` sequences move to the first/last line in Normal and Visua | 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 | `delete_line`, `change_line` | | Normal | `follow_link` | | Normal | `jump_back`, `jump_forward` | | Normal | `lsp.definition`, `lsp.diagnostics`, `lsp.hover` | diff --git a/docs/editing.md b/docs/editing.md index f317ebe..9d381c3 100644 --- a/docs/editing.md +++ b/docs/editing.md @@ -22,6 +22,7 @@ Default controls and editing behavior. For custom bindings, see [configuration]( | Normal, Visual | Decimal prefixes repeat cursor/word/search motions: `5j`, `3k`, `2w`, `4e`, `2n`; Esc cancels a pending count | | 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 | `dd` deletes whole lines; `cc` replaces them and enters Insert; leading counts work (`3dd`, `2cc`) | | Normal, Visual | `.` repeats the last successful native edit against the current targets; one undo group | | Normal | `/` opens search; `n` finds the next match; `N` (Shift+n) finds the previous match | | Normal | `v` starts characterwise Visual; `V` starts linewise Visual; `p`/`P` paste the configured clipboard after/before each caret or row for linewise fragments (system by default); Esc keeps only the primary caret | @@ -66,7 +67,16 @@ Counts apply to `h`/`j`/`k`/`l`, arrows, `w`/`b`/`e`, `W`/`B`/`E`, and `n`/`N`, Unbound ASCII digits `1`–`9` start a count in Normal/Visual; subsequent digits extend it. `0` retains line-start behavior without a count and extends an existing count when bound to line-start. Explicit digit bindings, including disabled keys, take precedence. Count arithmetic saturates rather than overflowing; large counts stop at motion boundaries or wrap for search, without executing an unbounded repeat loop. -The status bar displays a pending count. Esc cancels it without moving, collapsing carets, or leaving Visual; the next Esc performs its ordinary action. Leader prefixes retain the count until their leaf action resolves. Other commands consume the prefix and execute once, including line-start/end, edits, and plugins. Unknown/disabled keys and bracketed paste clear it; opening the palette clears it, so palette query digits remain literal. Digits also remain literal in Insert and command/search prompts. Counts add no history, and Vim operator grammar such as `3dw` is not supported. +The status bar displays a pending count. Esc cancels it without moving, collapsing carets, or leaving Visual; the next Esc performs its ordinary action. Leader and `dd`/`cc` prefixes retain the count until their action resolves. Line actions use that count; other non-motion commands consume it and execute once, including line-start/end, other edits, and plugins. Unknown/disabled keys and bracketed paste clear it; opening the palette clears it, so palette query digits remain literal. Digits also remain literal in Insert and command/search prompts. Counts add no history, and general Vim operator grammar such as `3dw` is not supported. + +## Normal line actions + +`dd` cuts the current line and its newline into the configured linewise clipboard, staying in Normal. `cc` cuts the same whole-row contents, leaves one empty replacement row, and enters Insert at column 0; indentation is not retained. Leading counts select rows downward from each caret, clamped at EOF: `3dd` cuts up to three rows and `2cc` changes up to two rows. Overlapping ranges merge before editing or clipboard publication; adjacent ranges remain separate targets. Deleting at EOF removes the preceding separator when needed, and the buffer always retains at least one row. + +The first `d` or `c` waits for its matching second key and appears in the status bar. Escape cancels only that pending prefix/count, preserving carets; an unmatched key cancels the prefix and then performs its ordinary action. Only leading counts are supported, not forms such as `d2d` or `d{motion}`. Explicit bindings, disabled first keys, and a leader using that first key take precedence. + +System clipboard publication must succeed before any deletion. Failure preserves text, carets, mode, undo/redo, and repeat intent. `dd` is one undo action; `cc` and subsequent replacement typing form one Insert undo group. Dot repeats the original requested row count at the current carets—even if the original action was clamped at EOF—and replays `cc` replacement text. Rebind the Normal-only `config.core.delete_line` / `config.core.change_line` actions to a single key or leader sequence without relying on the built-in doubled keys. + ## Selections and multi-caret editing @@ -78,7 +88,7 @@ Copy and cut return to Normal at each selection start; Visual cancellation keeps Visual `c` removes all selected text into the configured clipboard and enters Insert at each selection start. In the default System policy, clipboard publication must succeed before any deletion; failures leave text and selections intact. 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 characterwise empty final-row selections enters Insert without overwriting either clipboard; linewise empty final rows are genuine rows and publish a newline. -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`, `dd`, `yy`, or `cc` and blockwise Visual are not supported; `c` types literal text in Insert mode. +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 supports counted `dd` and `cc`, but not general operator grammar such as `cw`, `dw`, or `yy`; blockwise Visual is also unsupported. `c` and `d` type literal text in Insert mode. Clipboard policy is session-wide and defaults to 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 editing state. Empty clipboard paste does not edit text; copying/cutting only characterwise empty final-row selections does not overwrite either register. Linewise copy/cut retain whole-row shape, including a newline fragment for an empty final row. 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. @@ -102,9 +112,9 @@ System clipboard access supports macOS through `osascript`, Linux Wayland throug ## Repeat last edit -`.` repeats the session's last successful native edit in Normal/Visual. Insertion placements (`i`/`I`/`a`/`A`/`o`/`O`), Insert typing and edit-context motions/newline/backspace/delete, Visual change/cut, Normal deletion, clipboard/literal paste, and line joining are recorded as resolved edit operations—not keystrokes, command invocations, or old byte offsets. Remapping keys afterward does not change the recorded operation. Insert `.` and popup query `.` remain literal. A count before dot is consumed and repeats once, consistent with other non-motion commands. +`.` repeats the session's last successful native edit in Normal/Visual. Insertion placements (`i`/`I`/`a`/`A`/`o`/`O`), Insert typing and edit-context motions/newline/backspace/delete, Visual change/cut, counted Normal `dd`/`cc`, Normal character deletion, clipboard/literal paste, and line joining are recorded as resolved edit operations—not keystrokes, command invocations, or old byte offsets. Remapping keys afterward does not change the recorded operation. Insert `.` and popup query `.` remain literal. A count before dot is consumed and repeats once; a stored `dd`/`cc` recipe retains its own requested row count. -In Normal, characterwise Visual change/cut targeting replays its resolved motions and counts at each current caret: `vecitem` followed later by `.` changes a fresh whole word, even if its length differs. If characterwise targeting was rebuilt by cloning, occurrence gathering, search, or overlap merging, repeat uses relative whole-grapheme spans instead. Linewise targeting always uses signed relative row spans, preserving whole-row shape rather than replaying motions. Equal spans broadcast; unequal spans require matching target counts. In Visual, the current explicit ranges and selection shape replace the stored targeting. Target-building commands themselves do not recreate old cursors. +In Normal, characterwise Visual change/cut targeting replays its resolved motions and counts at each current caret: `vecitem` followed later by `.` changes a fresh whole word, even if its length differs. If characterwise targeting was rebuilt by cloning, occurrence gathering, search, or overlap merging, repeat uses relative whole-grapheme spans instead. Visual linewise targeting uses signed relative row spans, preserving whole-row shape rather than replaying motions. Equal spans broadcast; unequal spans require matching target counts. In Visual, current explicit ranges and selection shape replace stored Visual targeting. Native `dd`/`cc` recipes instead always select their requested rows from each current active caret. Target-building commands themselves do not recreate old cursors. Clipboard paste captures the fragments and their characterwise/linewise shape actually pasted, so a later yank or external clipboard change does not change repeat's payload; dot paste never rereads the OS. One fragment broadcasts; equal fragment/target counts pair; mismatches refuse atomically. Other per-target edit data, such as partially effective Insert deletion, obeys the same conservative distribution policy. Dot cut/change follows the current clipboard policy and stages publication until replay validation finishes. A refusal or provider error preserves editor text, caret/primary state, register caches, history, and recipe; already-published OS data cannot be transactionally restored. diff --git a/src/editor.rs b/src/editor.rs index d12ab46..1900240 100644 --- a/src/editor.rs +++ b/src/editor.rs @@ -246,6 +246,10 @@ enum EditStart { change: bool, selector: SelectionIntent, }, + Lines { + change: bool, + count: usize, + }, Delete, Join, Paste { @@ -303,6 +307,8 @@ enum Action { Normal, Newline, Backspace, + DeleteLine, + ChangeLine, Submit, Search, SearchNext, @@ -407,6 +413,8 @@ const CORE_ACTIONS: &[(&str, Action, &[Mode])] = &[ ("core.copy_system", Action::CopySystem, &[Mode::Visual]), ("core.cut", Action::Cut, &[Mode::Visual]), ("core.change", Action::Change, &[Mode::Visual]), + ("core.delete_line", Action::DeleteLine, &[Mode::Normal]), + ("core.change_line", Action::ChangeLine, &[Mode::Normal]), ("core.paste_before", Action::PasteBefore, &[Mode::Normal]), ("core.paste_after", Action::PasteAfter, &[Mode::Normal]), ( @@ -1003,6 +1011,7 @@ pub struct Editor { leader_suffix: Vec, leader_display: String, g_pending: bool, + line_pending: Option, palette: Option, hover: Option, completion: Option, @@ -1069,6 +1078,7 @@ impl Editor { leader_suffix: Vec::new(), leader_display: String::new(), g_pending: false, + line_pending: None, palette: None, hover: None, completion: None, @@ -2132,14 +2142,19 @@ impl Editor { self.g_pending } + pub fn line_operator_pending(&self) -> Option { + self.line_pending + } + fn clear_leader(&mut self) { self.g_pending = false; + self.line_pending = None; self.leader_suffix.clear(); self.leader_display.clear(); } fn cancel_leader(&mut self) { - if self.leader_pending().is_some() || self.g_pending { + if self.leader_pending().is_some() || self.g_pending || self.line_pending.is_some() { self.count = None; self.clear_leader(); } @@ -2546,6 +2561,8 @@ impl Editor { .collect(); let native = match action { Some(Action::FollowLink) if self.active.mode == Mode::Normal => Some("gf"), + Some(Action::DeleteLine) if self.active.mode == Mode::Normal => Some("dd"), + Some(Action::ChangeLine) if self.active.mode == Mode::Normal => Some("cc"), Some(Action::DocumentStart) if matches!(self.active.mode, Mode::Normal | Mode::Visual) => { @@ -2558,14 +2575,16 @@ impl Editor { } _ => None, }; - if let Some(native) = native - && self.leader != Input::Char('g') - && !self - .bindings - .get(&self.active.mode) - .is_some_and(|keys| keys.contains_key(&Input::Char('g'))) - { - bindings.push(native.into()); + if let Some(native) = native { + let prefix = Input::Char(native.as_bytes()[0] as char); + if self.leader != prefix + && !self + .bindings + .get(&self.active.mode) + .is_some_and(|keys| keys.contains_key(&prefix)) + { + bindings.push(native.into()); + } } if !self .bindings @@ -2877,6 +2896,19 @@ impl Editor { if self.palette.is_some() { return self.handle_palette(input); } + if let Some(operator) = self.line_pending.take() { + if self.active.mode == Mode::Normal && input == Input::Char(operator) { + return self.act(if operator == 'd' { + Action::DeleteLine + } else { + Action::ChangeLine + }); + } + self.count = None; + if input == Input::Escape { + return None; + } + } // Paste is literal input, never a configurable key or command. if let Input::Paste(text) = input { self.count = None; @@ -2942,6 +2974,12 @@ impl Editor { self.g_pending = true; return None; } + if self.active.mode == Mode::Normal + && let Input::Char(operator @ ('c' | 'd')) = input + { + self.line_pending = Some(operator); + return None; + } self.count = None; match (self.active.mode, input) { (Mode::Insert, Input::Char(ch)) if !ch.is_control() || ch == '\t' => { @@ -3031,6 +3069,9 @@ impl Editor { } Action::JumpBack => self.traverse_jumps(false, count), Action::JumpForward => self.traverse_jumps(true, count), + Action::DeleteLine | Action::ChangeLine => { + self.edit_lines(matches!(action, Action::ChangeLine), count); + } Action::LspDefinition => return self.execute("lsp.definition"), Action::LspHover => return self.execute("lsp.hover"), Action::LspDiagnostics => return self.execute("lsp.diagnostics"), @@ -4198,6 +4239,72 @@ impl Editor { self.message.clear(); } + fn line_selections(&mut self, count: usize) -> Vec { + self.active.mode = Mode::Visual; + self.active.linewise = true; + self.active.visual_motions = None; + let last = self.active.lines.len() - 1; + for target in &mut self.active.targets { + target.anchor = Cursor { + row: target.active.row, + column: 0, + }; + target.active = Cursor { + row: target + .anchor + .row + .saturating_add(count.saturating_sub(1)) + .min(last), + column: 0, + }; + target.preferred_columns = None; + } + self.normalize_targets(); + self.selections() + } + + fn edit_lines(&mut self, change: bool, count: usize) { + let targets = self.active.targets.clone(); + let primary = self.active.primary; + let ranges = self.line_selections(count); + let fragments = self.selection_fragments(&ranges); + if let Err(error) = self.store_clipboard( + fragments, + self.clipboard_mode == ClipboardMode::System, + true, + ) { + self.active.targets = targets; + self.active.primary = primary; + self.active.mode = Mode::Normal; + self.active.linewise = false; + self.set_message(error); + return; + } + let before = Snapshot { + lines: self.active.lines.clone(), + targets, + primary, + }; + let start = EditStart::Lines { change, count }; + if change { + self.start_insert(before, start); + self.edit_ranges(Action::Change, &ranges); + } else { + self.edit_ranges(Action::Cut, &ranges); + self.active.mode = Mode::Normal; + self.normalize_targets(); + self.record_native( + before, + EditRecipe { + start, + steps: Vec::new(), + }, + ); + } + self.message.clear(); + self.reveal_caret(); + } + fn edit_selection(&mut self, action: Action) { let ranges = self.selections(); let fragments = self.selection_fragments(&ranges); @@ -4803,6 +4910,15 @@ impl Editor { let mut clipboard = None; match &recipe.start { EditStart::Insert(action) => self.place_insert(*action), + EditStart::Lines { change, count } => { + let ranges = self.line_selections(*count); + clipboard = Some((self.selection_fragments(&ranges), true)); + self.edit_ranges(if *change { Action::Change } else { Action::Cut }, &ranges); + if !*change { + self.active.mode = Mode::Normal; + self.normalize_targets(); + } + } EditStart::Selection { change, selector } => { let ranges = self.replay_ranges(selector); clipboard = Some((self.selection_fragments(&ranges), self.visual_linewise())); @@ -6059,6 +6175,137 @@ mod tests { }); } } + + #[test] + fn line_actions_counted_delete_restores_caret_and_pastes_whole_rows() { + let mut editor = editor(); + let source = "zero\n αone\n\tlast\nend"; + editor.apply(vec![Effect::Replace(source.into())]).unwrap(); + type_text(&mut editor, "jll2dd"); + assert_eq!(editor.context().text, "zero\nend"); + assert_eq!(editor.mode(), Mode::Normal); + assert_eq!(editor.cursor(), Cursor { row: 1, column: 0 }); + type_text(&mut editor, "u"); + assert_eq!(editor.context().text, source); + assert_eq!(editor.cursor(), Cursor { row: 1, column: 2 }); + type_text(&mut editor, "UP"); + assert_eq!(editor.context().text, source); + } + + #[test] + fn line_actions_counted_change_groups_typing_and_repeats_resolved_rows() { + let mut editor = editor(); + let source = "keep\n one\ntwo\nend"; + editor.apply(vec![Effect::Replace(source.into())]).unwrap(); + type_text(&mut editor, "j2cc"); + assert_eq!(editor.context().text, "keep\n\nend"); + assert_eq!(editor.mode(), Mode::Insert); + assert_eq!(editor.cursor(), Cursor { row: 1, column: 0 }); + type_text(&mut editor, "new"); + editor.handle(Input::Escape); + type_text(&mut editor, "u"); + assert_eq!(editor.context().text, source); + type_text(&mut editor, "Uj."); + assert_eq!(editor.context().text, "keep\nnew\nnew"); + assert_eq!(editor.mode(), Mode::Normal); + type_text(&mut editor, "u"); + assert_eq!(editor.context().text, "keep\nnew\nend"); + } + + #[test] + fn line_actions_eof_empty_rows_and_repeat_keep_the_requested_count() { + for (source, keys, expected) in [ + ("a\nb", "jdd", "a"), + ("a\nb\n", "jdd", "a\n"), + ("a\nb\n", "gbdd", "a\nb"), + ("a", "dd", ""), + ("", "dd", ""), + ("a\nb\nc", "2dd", "c"), + ("a\nb", "jcc", "a\n"), + ] { + let mut editor = editor(); + editor.apply(vec![Effect::Replace(source.into())]).unwrap(); + type_text(&mut editor, keys); + assert_eq!(editor.context().text, expected, "{keys} on {source:?}"); + if editor.mode() == Mode::Insert { + editor.handle(Input::Escape); + } + if source != expected { + type_text(&mut editor, "u"); + assert_eq!(editor.context().text, source); + } + } + let directory = Directory::new(); + let target = directory.path("repeat"); + fs::write(&target, "1\n2\n3\n4\n5").unwrap(); + let mut editor = editor(); + editor + .apply(vec![Effect::Replace("a\nb\nc".into())]) + .unwrap(); + type_text(&mut editor, "gb3dd"); + assert_eq!(editor.context().text, "a\nb"); + editor.execute(&format!("edit {}", target.display())); + type_text(&mut editor, "."); + assert_eq!(editor.context().text, "4\n5"); + type_text(&mut editor, "u"); + assert_eq!(editor.context().text, "1\n2\n3\n4\n5"); + type_text(&mut editor, &format!("{}dd", usize::MAX)); + assert_eq!(editor.context().text, ""); + } + + #[test] + fn line_actions_merge_overlaps_but_keep_adjacent_change_carets() { + let mut editor = editor(); + let source = "aa\nbb\ncc\ndd\nee"; + editor.apply(vec![Effect::Replace(source.into())]).unwrap(); + editor.handle(Input::Ctrl('e')); + let carets = editor.carets(); + type_text(&mut editor, "2dd"); + assert_eq!(editor.context().text, "dd\nee"); + type_text(&mut editor, "u"); + assert_eq!(editor.context().text, source); + assert_eq!(editor.carets(), carets); + type_text(&mut editor, "UP"); + assert_eq!(editor.context().text, source); + editor.handle(Input::Escape); + editor.handle(Input::Ctrl('e')); + type_text(&mut editor, "ccX"); + editor.handle(Input::Escape); + assert_eq!(editor.context().text, "X\nX\ncc\ndd\nee"); + type_text(&mut editor, "u"); + assert_eq!(editor.context().text, source); + } + + #[test] + fn line_actions_prefix_cancellation_bindings_and_insert_literals_are_independent() { + let mut editor = editor(); + editor + .apply(vec![Effect::Replace("a\nb\nc\nd".into())]) + .unwrap(); + editor.handle(Input::Ctrl('e')); + let carets = editor.carets(); + type_text(&mut editor, "2d"); + editor.handle(Input::Escape); + assert_eq!(editor.carets(), carets); + assert_eq!(editor.context().text, "a\nb\nc\nd"); + editor.handle(Input::Escape); + type_text(&mut editor, "gtdj"); + assert_eq!(editor.cursor().row, 1); + editor.bind("normal", "d", None).unwrap(); + type_text(&mut editor, "dd"); + assert_eq!(editor.context().text, "a\nb\nc\nd"); + editor + .bind("normal", "z", Some("core.delete_line".into())) + .unwrap(); + type_text(&mut editor, "2z"); + assert_eq!(editor.context().text, "a\nd"); + editor + .bind("normal", "q", Some("core.change_line".into())) + .unwrap(); + type_text(&mut editor, "qccdd"); + editor.handle(Input::Escape); + assert_eq!(editor.context().text, "a\nccdd"); + } fn project_editor(directory: &Directory) -> Editor { let mut editor = editor(); editor.project_root = directory.0.clone(); @@ -7237,6 +7484,35 @@ mod tests { } } + #[test] + fn line_actions_clipboard_failure_preserves_carets_history_and_repeat() { + let fixture = Fixture::new(); + fixture.external("keep"); + let mut editor = fixture.editor(); + editor + .apply(vec![Effect::Replace("one\ntwo\nthree".into())]) + .unwrap(); + type_text(&mut editor, "iX"); + editor.handle(Input::Escape); + type_text(&mut editor, "l"); + editor.handle(Input::Ctrl('e')); + let carets = editor.carets(); + let revision = editor.text_revision(); + fixture.fail("fail-write", true); + for keys in ["2dd", "cc"] { + type_text(&mut editor, keys); + assert_eq!(editor.context().text, "Xone\ntwo\nthree"); + assert_eq!(editor.carets(), carets); + assert_eq!(editor.mode(), Mode::Normal); + assert_eq!(editor.text_revision(), revision); + assert_eq!(fixture.bytes("state"), b"keep"); + } + type_text(&mut editor, "u"); + assert_eq!(editor.context().text, "one\ntwo\nthree"); + type_text(&mut editor, "U."); + assert_eq!(editor.context().text, "XXone\ntXwo\nthree"); + } + // Includes every replay-owned state, not the intentionally changed error message. fn state(editor: &Editor) -> String { format!( diff --git a/src/main.rs b/src/main.rs index 1b31368..6f41476 100644 --- a/src/main.rs +++ b/src/main.rs @@ -22,6 +22,7 @@ Normal: i/a insert, I/A insert at first nonblank/end, o/O new line Ctrl+o older jump location; Ctrl+i/Tab newer (counts supported) Normal/Visual: prefix motions with counts (5j, 2w, 3n); Esc cancels count u undo, U redo, : command, / search, n/N next/previous match + dd deletes lines; cc changes lines and enters Insert; counts: 3dd, 2cc; Esc cancels prefix Normal/Visual: . repeats last native edit at current targets (one undo group) v characterwise select, V linewise select, p/P paste fragments, Ctrl+k/Ctrl+e clone above/below y/d/c/p/P use the system clipboard by default; config.clipboard_mode = \"separate\" isolates it diff --git a/src/tui.rs b/src/tui.rs index f113e17..293f010 100644 --- a/src/tui.rs +++ b/src/tui.rs @@ -746,6 +746,9 @@ fn draw( if editor.g_pending() { status.push_str(" g"); } + if let Some(operator) = editor.line_operator_pending() { + write!(status, " {operator}").unwrap(); + } if editor.buffer_count() > 1 { write!( status,