feat: add Visual selection and clipboard editing

This commit is contained in:
Valdemar Lorenzen
2026-10-06 12:45:08 +02:00
parent 6104b73fdb
commit f775ff3b59
8 changed files with 596 additions and 44 deletions
+424 -32
View File
@@ -36,6 +36,7 @@ pub enum Mode {
Insert,
Command,
Search,
Visual,
}
impl fmt::Display for Mode {
@@ -45,6 +46,7 @@ impl fmt::Display for Mode {
Self::Insert => "INSERT",
Self::Command => "COMMAND",
Self::Search => "SEARCH",
Self::Visual => "VISUAL",
})
}
}
@@ -63,6 +65,14 @@ pub struct SearchMatch {
pub end_column: usize,
}
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
pub struct Selection {
/// Inclusive start of the ordered range, in grapheme coordinates.
pub start: Cursor,
/// Exclusive end in grapheme coordinates; may be the next row's column 0.
pub end: Cursor,
}
#[derive(Clone, Debug, Eq, PartialEq)]
pub struct Feature {
pub name: String,
@@ -133,6 +143,11 @@ enum Action {
BigWordNext,
BigWordPrevious,
BigWordEnd,
Select,
Copy,
Cut,
PasteBefore,
PasteAfter,
}
impl Action {
@@ -150,22 +165,29 @@ impl Action {
(Mode::Normal, "core.redo") => Redo,
(Mode::Normal, "core.command") => Command,
(Mode::Normal, "core.search") => Search,
(Mode::Normal, "core.search_next") => SearchNext,
(Mode::Normal, "core.search_previous") => SearchPrevious,
(Mode::Normal | Mode::Insert, "core.left") => Left,
(Mode::Normal | Mode::Insert, "core.right") => Right,
(Mode::Normal | Mode::Insert, "core.down") => Down,
(Mode::Normal | Mode::Insert, "core.up") => Up,
(Mode::Normal | Mode::Insert, "core.line_start") => LineStart,
(Mode::Normal | Mode::Insert, "core.line_end") => LineEnd,
(Mode::Normal | Mode::Insert, "core.word_next") => WordNext,
(Mode::Normal | Mode::Insert, "core.word_previous") => WordPrevious,
(Mode::Normal | Mode::Insert, "core.word_end") => WordEnd,
(Mode::Normal | Mode::Insert, "core.big_word_next") => BigWordNext,
(Mode::Normal | Mode::Insert, "core.big_word_previous") => BigWordPrevious,
(Mode::Normal | Mode::Insert, "core.big_word_end") => BigWordEnd,
(Mode::Normal | Mode::Visual, "core.search_next") => SearchNext,
(Mode::Normal | Mode::Visual, "core.search_previous") => SearchPrevious,
(Mode::Normal | Mode::Visual, "core.select") => Select,
(Mode::Visual, "core.copy") => Copy,
(Mode::Visual, "core.cut") => Cut,
(Mode::Normal, "core.paste_before") => PasteBefore,
(Mode::Normal, "core.paste_after") => PasteAfter,
(Mode::Normal | Mode::Insert | Mode::Visual, "core.left") => Left,
(Mode::Normal | Mode::Insert | Mode::Visual, "core.right") => Right,
(Mode::Normal | Mode::Insert | Mode::Visual, "core.down") => Down,
(Mode::Normal | Mode::Insert | Mode::Visual, "core.up") => Up,
(Mode::Normal | Mode::Insert | Mode::Visual, "core.line_start") => LineStart,
(Mode::Normal | Mode::Insert | Mode::Visual, "core.line_end") => LineEnd,
(Mode::Normal | Mode::Insert | Mode::Visual, "core.word_next") => WordNext,
(Mode::Normal | Mode::Insert | Mode::Visual, "core.word_previous") => WordPrevious,
(Mode::Normal | Mode::Insert | Mode::Visual, "core.word_end") => WordEnd,
(Mode::Normal | Mode::Insert | Mode::Visual, "core.big_word_next") => BigWordNext,
(Mode::Normal | Mode::Insert | Mode::Visual, "core.big_word_previous") => {
BigWordPrevious
}
(Mode::Normal | Mode::Insert | Mode::Visual, "core.big_word_end") => BigWordEnd,
(Mode::Normal | Mode::Insert, "core.delete") => Delete,
(Mode::Insert | Mode::Command | Mode::Search, "core.normal") => Normal,
(Mode::Insert | Mode::Command | Mode::Search | Mode::Visual, "core.normal") => Normal,
(Mode::Insert, "core.newline") => Newline,
(Mode::Insert | Mode::Command | Mode::Search, "core.backspace") => Backspace,
(Mode::Command | Mode::Search, "core.submit") => Submit,
@@ -257,33 +279,45 @@ fn default_bindings() -> HashMap<Mode, HashMap<Input, Binding>> {
('A', AppendLineEnd),
('o', OpenBelow),
('O', OpenAbove),
('h', Left),
('j', Down),
('k', Up),
('l', Right),
('J', JoinLines),
('0', LineStart),
('$', LineEnd),
('x', Delete),
('u', Undo),
('U', Redo),
(':', Command),
('/', Search),
('n', SearchNext),
('N', SearchPrevious),
('w', WordNext),
('b', WordPrevious),
('e', WordEnd),
('W', BigWordNext),
('B', BigWordPrevious),
('E', BigWordEnd),
('p', PasteAfter),
('P', PasteBefore),
] {
bindings
.entry(Mode::Normal)
.or_default()
.insert(Input::Char(key), Binding::Action(action));
}
for mode in [Mode::Normal, Mode::Insert] {
for mode in [Mode::Normal, Mode::Visual] {
for (key, action) in [
('h', Left),
('j', Down),
('k', Up),
('l', Right),
('0', LineStart),
('$', LineEnd),
('w', WordNext),
('b', WordPrevious),
('e', WordEnd),
('W', BigWordNext),
('B', BigWordPrevious),
('E', BigWordEnd),
('n', SearchNext),
('N', SearchPrevious),
('v', Select),
] {
bindings
.entry(mode)
.or_default()
.insert(Input::Char(key), Binding::Action(action));
}
}
for mode in [Mode::Normal, Mode::Insert, Mode::Visual] {
for (key, action) in [
(Input::Left, Left),
(Input::Right, Right),
@@ -291,7 +325,6 @@ fn default_bindings() -> HashMap<Mode, HashMap<Input, Binding>> {
(Input::Up, Up),
(Input::Home, LineStart),
(Input::End, LineEnd),
(Input::Delete, Delete),
] {
bindings
.entry(mode)
@@ -299,6 +332,27 @@ fn default_bindings() -> HashMap<Mode, HashMap<Input, Binding>> {
.insert(key, Binding::Action(action));
}
}
for mode in [Mode::Normal, Mode::Insert] {
bindings
.entry(mode)
.or_default()
.insert(Input::Delete, Binding::Action(Delete));
}
bindings
.entry(Mode::Visual)
.or_default()
.insert(Input::Escape, Binding::Action(Normal));
for (key, action) in [
(Input::Char('y'), Copy),
(Input::Char('d'), Cut),
(Input::Char('x'), Cut),
(Input::Delete, Cut),
] {
bindings
.entry(Mode::Visual)
.or_default()
.insert(key, Binding::Action(action));
}
for mode in [Mode::Insert, Mode::Command, Mode::Search] {
for (key, action) in [
(Input::Escape, Normal),
@@ -329,6 +383,8 @@ pub struct Editor {
command_line: String,
search_query: String,
search_match: Option<SearchMatch>,
selection_anchor: Option<Cursor>,
clipboard: String,
message: String,
path: Option<PathBuf>,
quit: bool,
@@ -361,6 +417,8 @@ impl Editor {
command_line: String::new(),
search_query: String::new(),
search_match: None,
selection_anchor: None,
clipboard: String::new(),
message: String::new(),
path,
quit: false,
@@ -404,6 +462,28 @@ impl Editor {
pub fn search_match(&self) -> Option<SearchMatch> {
self.search_match
}
/// Ordered, end-exclusive range in Visual mode; None in other modes.
/// An empty final row produces an empty range, not a synthetic character.
pub fn selection(&self) -> Option<Selection> {
if self.mode != Mode::Visual {
return None;
}
let anchor = self.selection_anchor?;
let (start, mut end) =
if (anchor.row, anchor.column) <= (self.cursor.row, self.cursor.column) {
(anchor, self.cursor)
} else {
(self.cursor, anchor)
};
let line = &self.lines[end.row];
if grapheme_byte(line, end.column) < line.len() {
end.column += 1;
} else if end.row + 1 < self.lines.len() {
end.row += 1;
end.column = 0;
}
Some(Selection { start, end })
}
pub fn message(&self) -> &str {
&self.message
}
@@ -506,6 +586,7 @@ impl Editor {
"insert" => Mode::Insert,
"command" => Mode::Command,
"search" => Mode::Search,
"visual" => Mode::Visual,
_ => return Err(format!("Unknown binding mode: {mode}")),
};
let key = parse_key(key)?;
@@ -633,6 +714,19 @@ impl Editor {
}
Action::SearchNext => self.seek_search(true),
Action::SearchPrevious => self.seek_search(false),
Action::Select => {
if self.mode == Mode::Visual {
self.clear_selection();
} else {
self.selection_anchor = Some(self.cursor);
self.mode = Mode::Visual;
self.message.clear();
}
}
Action::Copy => self.copy_selection(false),
Action::Cut => self.copy_selection(true),
Action::PasteBefore => self.paste_clipboard(false),
Action::PasteAfter => self.paste_clipboard(true),
Action::Normal => {
if self.mode == Mode::Insert {
self.finish_insert();
@@ -642,6 +736,8 @@ impl Editor {
} else {
self.command_line.clear();
self.mode = Mode::Normal;
self.selection_anchor = None;
self.clamp_cursor();
}
}
Action::Newline => self.insert_text("\n"),
@@ -716,6 +812,7 @@ impl Editor {
self.finish_insert();
}
self.mode = Mode::Normal;
self.selection_anchor = None;
self.clamp_cursor();
let command = command.trim().strip_prefix(':').unwrap_or(command.trim());
let (name, args) = command
@@ -801,6 +898,9 @@ impl Editor {
|| matches!(effect, Effect::Replace(_))
})
.then(|| self.snapshot());
if before.is_some() {
self.clear_selection();
}
for effect in effects {
match effect {
Effect::Insert(text) => self.insert_text(&normalize_newlines(&text)),
@@ -851,11 +951,100 @@ impl Editor {
fn record(&mut self, before: Snapshot) {
if before.lines != self.lines {
self.search_match = None;
self.clear_selection();
self.undo.push(before);
self.redo.clear();
}
}
fn copy_selection(&mut self, cut: bool) {
let Some(selection) = self.selection() else {
return;
};
if selection.start == selection.end {
self.clear_selection();
self.set_message("Nothing selected");
return;
}
let start = selection.start;
let end = selection.end;
let start_byte = grapheme_byte(&self.lines[start.row], start.column);
let end_byte = grapheme_byte(&self.lines[end.row], end.column);
self.clipboard = if start.row == end.row {
self.lines[start.row][start_byte..end_byte].to_owned()
} else {
let capacity = self.lines[start.row..end.row]
.iter()
.map(String::len)
.sum::<usize>()
- start_byte
+ end_byte
+ end.row
- start.row;
let mut text = String::with_capacity(capacity);
text.push_str(&self.lines[start.row][start_byte..]);
for line in &self.lines[start.row + 1..end.row] {
text.push('\n');
text.push_str(line);
}
text.push('\n');
text.push_str(&self.lines[end.row][..end_byte]);
text
};
let before = cut.then(|| self.snapshot());
self.clear_selection();
self.cursor = start;
if cut {
if start.row == end.row {
self.lines[start.row].replace_range(start_byte..end_byte, "");
} else {
let mut tail = self
.lines
.drain(start.row + 1..=end.row)
.next_back()
.expect("cross-line selection includes an end row");
tail.replace_range(..end_byte, &self.lines[start.row][..start_byte]);
self.lines[start.row] = tail;
}
}
self.clamp_cursor();
if let Some(before) = before {
self.record(before);
}
self.set_message(if cut {
"Cut selection"
} else {
"Copied selection"
});
}
fn paste_clipboard(&mut self, after: bool) {
if self.clipboard.is_empty() {
self.set_message("Clipboard is empty");
return;
}
let before = self.snapshot();
if after {
self.cursor.column =
(self.cursor.column + 1).min(grapheme_count(&self.lines[self.cursor.row]));
}
let text = std::mem::take(&mut self.clipboard);
self.insert_text(&text);
self.clipboard = text;
self.cursor.column = self.cursor.column.saturating_sub(1);
self.clamp_cursor();
self.record(before);
self.message.clear();
}
fn clear_selection(&mut self) {
self.selection_anchor = None;
if self.mode == Mode::Visual {
self.mode = Mode::Normal;
self.clamp_cursor();
}
}
fn begin_insert(&mut self) {
self.insert_start = Some(self.snapshot());
self.mode = Mode::Insert;
@@ -916,7 +1105,9 @@ impl Editor {
fn max_column(&self) -> usize {
let count = grapheme_count(&self.lines[self.cursor.row]);
if self.mode == Mode::Insert {
if self.mode == Mode::Insert
|| (self.mode == Mode::Visual && self.cursor.row + 1 < self.lines.len())
{
count
} else {
count.saturating_sub(1)
@@ -1336,6 +1527,207 @@ mod tests {
}
}
#[test]
fn visual_ranges_copy_both_directions_and_cut_and_paste_multiline_graphemes() {
let mut editor = editor();
let text = "a界e\u{301}z\n\n👩‍🚀tail\n";
editor.apply(vec![Effect::Replace(text.into())]).unwrap();
editor.handle(Input::Right);
editor.handle(Input::Char('v'));
editor.handle(Input::Right);
assert_eq!(
editor.selection(),
Some(Selection {
start: Cursor { row: 0, column: 1 },
end: Cursor { row: 0, column: 3 },
})
);
editor.handle(Input::Left);
editor.handle(Input::Left);
assert_eq!(
editor.selection().unwrap().start,
Cursor { row: 0, column: 0 }
);
assert_eq!(
editor.selection().unwrap().end,
Cursor { row: 0, column: 2 }
);
editor.handle(Input::Down);
assert_eq!(
editor.selection().unwrap().end,
Cursor { row: 2, column: 0 }
);
let selected = editor.selection();
editor.handle(Input::Paste(":q!\n".into()));
assert_eq!(editor.selection(), selected);
assert_eq!(editor.context().text, text);
assert!(!editor.should_quit());
editor.handle(Input::Down);
assert_eq!(
editor.selection().unwrap().end,
Cursor { row: 2, column: 1 }
);
editor.handle(Input::Char('d'));
assert_eq!(editor.mode(), Mode::Normal);
assert_eq!(editor.selection(), None);
assert_eq!(editor.context().text, "atail\n");
editor.handle(Input::Char('P'));
assert_eq!(editor.context().text, text);
editor.handle(Input::Char('u'));
assert_eq!(editor.context().text, "atail\n");
editor.handle(Input::Char('u'));
assert_eq!(editor.context().text, text);
editor.handle(Input::Char('U'));
assert_eq!(editor.context().text, "atail\n");
editor.handle(Input::Char('U'));
assert_eq!(editor.context().text, text);
editor.handle(Input::Char('v'));
editor.handle(Input::Up);
editor.handle(Input::Up);
editor.handle(Input::Char('y'));
assert_eq!(editor.context().text, text);
editor.handle(Input::Char('P'));
assert_eq!(
editor.context().text,
"a界e\u{301}z\n\n👩‍🚀a界e\u{301}z\n\n👩‍🚀tail\n"
);
editor.handle(Input::Char('u'));
assert_eq!(editor.context().text, text);
}
#[test]
fn clipboard_copy_preserves_redo_and_pastes_are_independent_of_clipboard_history() {
let mut editor = editor();
editor.apply(vec![Effect::Replace("x界".into())]).unwrap();
editor.handle(Input::Char('u'));
editor.handle(Input::Char('p'));
editor.handle(Input::Char('P'));
assert_eq!(editor.context().text, "");
editor.handle(Input::Char('U'));
assert_eq!(editor.context().text, "x界");
editor.handle(Input::Char('v'));
editor.handle(Input::Right);
editor.handle(Input::Char('y'));
assert_eq!(editor.mode(), Mode::Normal);
assert_eq!(editor.context().text, "x界");
editor.handle(Input::Char('u'));
assert_eq!(editor.context().text, "");
editor.handle(Input::Char('P'));
assert_eq!(editor.context().text, "x界");
editor.handle(Input::Char('u'));
assert_eq!(editor.context().text, "");
editor.handle(Input::Char('U'));
editor.handle(Input::Char('p'));
assert_eq!(editor.context().text, "x界x界");
editor.handle(Input::Char('u'));
editor.handle(Input::Char('P'));
assert_eq!(editor.context().text, "xx界界");
editor.handle(Input::Char('u'));
editor.handle(Input::Char('v'));
editor.handle(Input::Char('y'));
editor.handle(Input::Char('U'));
assert_eq!(editor.context().text, "xx界界");
editor.handle(Input::Char('u'));
editor.handle(Input::Char('P'));
assert_eq!(editor.context().text, "x界界");
editor.apply(vec![Effect::Replace("".into())]).unwrap();
editor.handle(Input::Char('v'));
let empty = editor.selection().unwrap();
assert_eq!(empty.start, empty.end);
editor.handle(Input::Char('d'));
editor.handle(Input::Char('p'));
assert_eq!(editor.context().text, "界");
editor.handle(Input::Char('u'));
editor.handle(Input::Char('v'));
editor.handle(Input::Escape);
editor.handle(Input::Char('U'));
assert_eq!(editor.context().text, "界");
}
#[test]
fn visual_empty_lines_and_line_end_include_real_newlines_and_cancel_clamps_cursor() {
let mut editor = editor();
let text = "ab\n\ncd\n";
editor.apply(vec![Effect::Replace(text.into())]).unwrap();
editor.handle(Input::Down);
editor.handle(Input::Char('v'));
assert_eq!(
editor.selection(),
Some(Selection {
start: Cursor { row: 1, column: 0 },
end: Cursor { row: 2, column: 0 },
})
);
editor.handle(Input::Char('y'));
editor.handle(Input::Down);
editor.handle(Input::Char('p'));
assert_eq!(editor.context().text, "ab\n\nc\nd\n");
editor.handle(Input::Char('u'));
assert_eq!(editor.context().text, text);
editor.handle(Input::Up);
editor.handle(Input::Char('v'));
editor.handle(Input::Delete);
assert_eq!(editor.context().text, "ab\ncd\n");
editor.handle(Input::Char('u'));
assert_eq!(editor.context().text, text);
editor.handle(Input::Up);
editor.handle(Input::End);
editor.handle(Input::Char('v'));
editor.handle(Input::End);
assert_eq!(editor.cursor(), Cursor { row: 0, column: 2 });
assert_eq!(
editor.selection().unwrap().end,
Cursor { row: 1, column: 0 }
);
editor.handle(Input::Char('y'));
editor.handle(Input::Char('P'));
assert_eq!(editor.context().text, "ab\nb\n\ncd\n");
editor.handle(Input::Char('u'));
editor.handle(Input::Char('v'));
editor.handle(Input::End);
editor.handle(Input::Escape);
assert_eq!(editor.mode(), Mode::Normal);
assert_eq!(editor.cursor(), Cursor { row: 0, column: 1 });
assert_eq!(editor.selection(), None);
}
#[test]
fn selected_effects_validate_before_mode_changes_and_clipboard_graphemes_can_merge() {
let mut editor = editor();
editor
.apply(vec![Effect::Replace("abcd\n👩‍🚀x".into())])
.unwrap();
editor.handle(Input::Char('v'));
editor.handle(Input::Down);
editor.handle(Input::End);
let selected = editor.selection();
assert!(editor.apply(vec![Effect::Replace("\0".into())]).is_err());
assert_eq!(editor.mode(), Mode::Visual);
assert_eq!(editor.selection(), selected);
editor.apply(vec![Effect::Replace("z".into())]).unwrap();
assert_eq!(editor.mode(), Mode::Normal);
assert_eq!(editor.selection(), None);
assert_eq!(editor.cursor(), Cursor::default());
editor.handle(Input::Char('v'));
editor.execute("plugins");
assert_eq!(editor.selection(), None);
assert_eq!(editor.mode(), Mode::Normal);
editor
.apply(vec![Effect::Replace("\u{301}".into())])
.unwrap();
editor.handle(Input::Char('v'));
editor.handle(Input::Char('y'));
editor.apply(vec![Effect::Replace("a".into())]).unwrap();
editor.handle(Input::Char('p'));
assert_eq!(editor.context().text, "a\u{301}");
assert_eq!(editor.cursor(), Cursor::default());
editor.handle(Input::Char('u'));
assert_eq!(editor.context().text, "a");
}
#[test]
fn word_motions_distinguish_punctuation_cross_empty_lines_and_preserve_history() {
let mut editor = editor();