feat: add linewise Visual selection and paste

This commit is contained in:
2026-10-10 17:20:22 +02:00
parent dc0bdd60d0
commit 27b94744f0
8 changed files with 1001 additions and 62 deletions
+856 -43
View File
File diff suppressed because it is too large Load Diff
+2 -2
View File
@@ -22,13 +22,13 @@ Normal: i/a insert, I/A insert at first nonblank/end, o/O new line
Normal/Visual: prefix motions with counts (5j, 2w, 3n); Esc cancels count
u undo, U redo, : command, / search, n/N next/previous match
Normal/Visual: . repeats last native edit at current targets (one undo group)
v select, p/P paste fragments, Ctrl+k/Ctrl+e clone above/below
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
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
v/V switches selection shape; same selector or Esc cancels 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]
+33
View File
@@ -1527,6 +1527,39 @@ end
assert_eq!(session.editor().lines(), &["base"]);
}
#[test]
fn configured_linewise_change_and_paste_preserve_rows_and_one_replacement_history() {
let fixture = Fixture::new();
fixture.file("buffer.txt", " alpha\n界beta\ntail");
let mut session = fixture.open(
r#"
Vamp.configure do |config|
config.clipboard_mode = "separate"
config.bind "V", nil
config.bind "s", config.core.select_linewise
config.bind "s", config.core.select_linewise, mode: config.modes.visual
config.bind "r", config.core.change, mode: config.modes.visual
end
"#,
);
for key in "llsjrnew".chars() {
session.handle(Input::Char(key));
}
session.handle(Input::Escape);
assert_eq!(session.editor().lines(), &["new", "tail"]);
session.handle(Input::Char('p'));
assert_eq!(
session.editor().lines(),
&["new", " alpha", "界beta", "tail"]
);
session.handle(Input::Char('u'));
assert_eq!(session.editor().lines(), &["new", "tail"]);
session.handle(Input::Char('u'));
assert_eq!(session.editor().lines(), &[" alpha", "界beta", "tail"]);
session.handle(Input::Char('U'));
assert_eq!(session.editor().lines(), &["new", "tail"]);
}
#[test]
fn configured_selection_actions_edit_together_and_invoke_plugins_once() {
let fixture = Fixture::new();
+93 -2
View File
@@ -665,7 +665,18 @@ fn draw(
buffer[(viewport.x + (column - *left) as u16, y)].set_style(style);
}
}
for selection in &selections {
let selection_style = Style::default().fg(Color::White).bg(Color::Blue);
for rows in editor.linewise_rows() {
let first = (*rows.start()).max(*top);
let last = (*rows.end()).min(top.saturating_add(height - 1));
for row in first..=last {
let y = viewport.y + (row - *top) as u16;
for x in viewport.x..viewport.x + viewport.width {
frame.buffer_mut()[(x, y)].set_style(selection_style);
}
}
}
for selection in selections.iter().filter(|_| !editor.visual_linewise()) {
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);
@@ -712,7 +723,11 @@ fn draw(
.chars()
.map(|c| if c.is_control() { '�' } else { c })
.collect();
let mut status = format!(" {}", editor.mode());
let mut status = if editor.visual_linewise() {
" VISUAL LINE".into()
} else {
format!(" {}", editor.mode())
};
if let Some(sequence) = editor.leader_pending() {
write!(status, " leader: {sequence}").unwrap();
}
@@ -5552,6 +5567,82 @@ end
);
}
#[test]
fn linewise_selection_fills_blank_and_final_rows_without_coloring_gutter() {
let mut editor = Editor::open(None, vec![]).unwrap();
editor
.apply(vec![crate::editor::Effect::Replace("long text\n\n".into())])
.unwrap();
editor.handle(Input::Char('V'));
editor.handle(Input::Down);
editor.handle(Input::Down);
let before = (editor.lines().to_vec(), editor.cursor(), editor.carets());
for width in [12, 3, 1] {
let mut terminal = Terminal::new(ratatui::backend::TestBackend::new(width, 6)).unwrap();
let (mut top, mut left) = (0, 0);
terminal
.draw(|frame| {
draw(
frame,
&editor,
&mut top,
&mut left,
&mut SyntaxCache::new(),
&mut DocumentViewport::default(),
&mut WheelPanels::default(),
)
})
.unwrap();
let buffer = terminal.backend().buffer();
if width == 12 {
let status: String = (0..width).map(|x| buffer[(x, 4)].symbol()).collect();
assert!(status.contains("VISUAL LINE"), "{status}");
}
let gutter = if width > 2 { 2 } else { 0 };
for row in 0..3 {
for x in 0..gutter {
assert_ne!(buffer[(x, row)].bg, Color::Blue);
}
for x in gutter..width {
assert_eq!(buffer[(x, row)].bg, Color::Blue);
}
}
for x in 0..width {
assert_ne!(buffer[(x, 3)].bg, Color::Blue);
}
}
assert_eq!(
before,
(editor.lines().to_vec(), editor.cursor(), editor.carets())
);
editor.handle(Input::Up);
editor.handle(Input::Up);
editor.handle(Input::End);
let mut terminal = Terminal::new(ratatui::backend::TestBackend::new(5, 3)).unwrap();
let (mut top, mut left) = (0, 0);
terminal
.draw(|frame| {
draw(
frame,
&editor,
&mut top,
&mut left,
&mut SyntaxCache::new(),
&mut DocumentViewport::default(),
&mut WheelPanels::default(),
)
})
.unwrap();
assert!(left > 0);
let buffer = terminal.backend().buffer();
for x in 0..2 {
assert_ne!(buffer[(x, 0)].bg, Color::Blue);
}
for x in 2..5 {
assert_eq!(buffer[(x, 0)].bg, Color::Blue);
}
}
#[test]
fn visual_selection_highlights_graphemes_newlines_and_clipped_ranges_over_search() {
let mut editor = Editor::open(None, vec![]).unwrap();