From f81405a5ed4c6e97450453c71138c05a87c4669e Mon Sep 17 00:00:00 2001 From: Valdemar Lorenzen Date: Mon, 5 Oct 2026 15:42:19 +0200 Subject: [PATCH] feat: add Vim-style I and A and move redo to U --- README.md | 3 ++- src/editor.rs | 73 ++++++++++++++++++++++++++++++++++++++++++++------- src/main.rs | 5 ++-- 3 files changed, 68 insertions(+), 13 deletions(-) diff --git a/README.md b/README.md index 654e329..e5c8c79 100644 --- a/README.md +++ b/README.md @@ -18,8 +18,9 @@ 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 | `x`/Delete delete; `u` undo; Ctrl-r redo; `:` enter command mode | +| Normal | `x`/Delete delete; `u` undo; `U` (Shift+u) redo; `:` enter command mode | | Insert | Type, Enter, Tab, Backspace/Delete, arrows, Home/End; Esc returns to normal | | Command | Enter executes; Backspace edits; Esc cancels | diff --git a/src/editor.rs b/src/editor.rs index 0265f61..64618cf 100644 --- a/src/editor.rs +++ b/src/editor.rs @@ -259,7 +259,7 @@ impl Editor { } pub fn bind(&mut self, key: char, command: String) -> Result<(), String> { - if key.is_control() || "iaoOhjklx0$u:".contains(key) { + if key.is_control() || "iIaAoOhjklx0$uU:".contains(key) { return Err(format!("Key {key:?} is reserved for editing")); } if self.owner(&command).is_none() { @@ -273,11 +273,22 @@ impl Editor { match self.mode { Mode::Normal => match input { Input::Char('i') => self.begin_insert(), + Input::Char('I') => { + self.begin_insert(); + self.cursor.column = self.lines[self.cursor.row] + .graphemes(true) + .take_while(|grapheme| matches!(*grapheme, " " | "\t")) + .count(); + } Input::Char('a') => { self.begin_insert(); self.cursor.column = (self.cursor.column + 1).min(grapheme_count(&self.lines[self.cursor.row])); } + Input::Char('A') => { + self.begin_insert(); + self.move_end(); + } Input::Char('o') | Input::Char('O') => { self.begin_insert(); if input == Input::Char('o') { @@ -301,7 +312,7 @@ impl Editor { } } Input::Char('u') => self.undo(), - Input::Ctrl('r') => self.redo(), + Input::Char('U') => self.redo(), Input::Char(':') => { self.mode = Mode::Command; self.command_line.clear(); @@ -811,6 +822,48 @@ mod tests { } } + #[test] + fn uppercase_u_redoes_and_control_r_no_longer_does() { + let mut editor = editor(); + editor.handle(Input::Char('i')); + type_text(&mut editor, "changed"); + editor.handle(Input::Escape); + editor.handle(Input::Char('u')); + assert_eq!(editor.context().text, ""); + editor.handle(Input::Ctrl('r')); + assert_eq!(editor.context().text, ""); + editor.handle(Input::Char('U')); + assert_eq!(editor.context().text, "changed"); + } + + #[test] + fn uppercase_insert_keys_target_line_positions_and_preserve_undo_cursor() { + for (original, key, expected) in [ + (" \t界e\u{301} tail", 'I', " \tX界e\u{301} tail"), + (" \t ", 'I', " \t X"), + ("", 'I', "X"), + (" \t界e\u{301} tail", 'A', " \t界e\u{301} tailX"), + ("", 'A', "X"), + ] { + let mut editor = editor(); + editor + .apply(vec![Effect::Replace(original.into())]) + .unwrap(); + editor.handle(if key == 'I' { Input::End } else { Input::Right }); + let before = editor.cursor(); + editor.handle(Input::Char(key)); + assert_eq!(editor.mode(), Mode::Insert); + type_text(&mut editor, "X"); + editor.handle(Input::Escape); + assert_eq!(editor.context().text, expected); + editor.handle(Input::Char('u')); + assert_eq!(editor.context().text, original); + assert_eq!(editor.cursor(), before); + editor.handle(Input::Char('U')); + assert_eq!(editor.context().text, expected); + } + } + #[test] fn paste_is_ignored_in_normal_and_command_modes_without_dispatching() { let mut editor = editor(); @@ -852,7 +905,7 @@ mod tests { assert_eq!(editor.context().text, expected); } for expected in ["a", "a界e\u{301}\t\n:q!\n", "a界e\u{301}\t\n:q!\nz"] { - editor.handle(Input::Ctrl('r')); + editor.handle(Input::Char('U')); assert_eq!(editor.context().text, expected); } } @@ -880,7 +933,7 @@ mod tests { editor.handle(Input::Escape); editor.handle(Input::Char('u')); assert_eq!(editor.context().text, ""); - editor.handle(Input::Ctrl('r')); + editor.handle(Input::Char('U')); assert_eq!(editor.context().text, "beforeafter"); } } @@ -896,7 +949,7 @@ mod tests { editor.handle(Input::Char('u')); assert_eq!(editor.context().text, ""); assert!(!editor.dirty()); - editor.handle(Input::Ctrl('r')); + editor.handle(Input::Char('U')); assert_eq!(editor.context().text, "hello\n世界"); editor.handle(Input::Char('O')); type_text(&mut editor, "above"); @@ -1027,7 +1080,7 @@ mod tests { fn bindings_dispatch_only_normal_mode_and_essential_keys_stay_protected() { let mut editor = editor(); editor.register(plugin("greeting", &["greet"])).unwrap(); - for key in "iaoOhjklx0$u:\u{1b}\r".chars() { + for key in "iIaAoOhjklx0$uU:\u{1b}\r".chars() { assert!(editor.bind(key, "greet".into()).is_err()); } editor.bind('g', "greet".into()).unwrap(); @@ -1110,7 +1163,7 @@ mod tests { assert_eq!(editor.message(), "ok"); editor.handle(Input::Char('u')); assert_eq!(editor.context().text, ""); - editor.handle(Input::Ctrl('r')); + editor.handle(Input::Char('U')); assert_eq!(editor.context().text, "one\ntwo"); editor .apply(vec![Effect::Replace("replacement".into())]) @@ -1165,11 +1218,11 @@ mod tests { editor .apply(vec![Effect::Message("notice".into())]) .unwrap(); - editor.handle(Input::Ctrl('r')); + editor.handle(Input::Char('U')); assert_eq!(editor.context().text, "a"); editor.handle(Input::Char('u')); editor.apply(vec![Effect::Insert("b".into())]).unwrap(); - editor.handle(Input::Ctrl('r')); + editor.handle(Input::Char('U')); assert_eq!(editor.context().text, "b"); } @@ -1201,7 +1254,7 @@ mod tests { assert!(!editor.dirty()); editor.handle(Input::Char('u')); assert!(editor.dirty()); - editor.handle(Input::Ctrl('r')); + editor.handle(Input::Char('U')); assert!(!editor.dirty()); editor.execute("q"); assert!(editor.should_quit()); diff --git a/src/main.rs b/src/main.rs index efafb07..8d17fe4 100644 --- a/src/main.rs +++ b/src/main.rs @@ -6,8 +6,9 @@ const HELP: &str = "vamp — modal editor with isolated Ruby plugins Usage: vamp [--config PATH | --no-config] [FILE] -Normal: i/a insert, o/O new line, h/j/k/l move, 0/$ line ends, - x delete, u undo, Ctrl-r redo, : command +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 + u undo, U redo, : command Insert: Esc returns to normal Commands: :w [PATH], :q, :q!, :wq [PATH], :plugins, :PLUGIN [ARGS]