feat: implement selection-first multi-target editing

This commit is contained in:
Valdemar Lorenzen
2026-10-08 10:42:16 +02:00
parent 53fcd7a1f2
commit 8ef92a7b15
6 changed files with 1485 additions and 544 deletions
+1314 -516
View File
File diff suppressed because it is too large Load Diff
+7 -3
View File
@@ -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]
+1
View File
@@ -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 }
+51 -2
View File
@@ -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::<usize>(),
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]
+87 -3
View File
@@ -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();