feat: add Visual selection and clipboard editing
This commit is contained in:
+424
-32
@@ -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();
|
||||
|
||||
Reference in New Issue
Block a user