feat: implement selection-first multi-target editing
This commit is contained in:
+1314
-516
File diff suppressed because it is too large
Load Diff
+7
-3
@@ -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]
|
||||
|
||||
|
||||
@@ -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
@@ -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
@@ -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();
|
||||
|
||||
Reference in New Issue
Block a user