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();
|
||||
|
||||
@@ -11,6 +11,8 @@ 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, v/Esc cancel
|
||||
Insert: Esc returns to normal
|
||||
Search: Enter finds next literal match, Esc cancels (case-sensitive, wraps)
|
||||
Commands: :w [PATH], :q, :q!, :wq [PATH], :plugins, :PLUGIN [ARGS]
|
||||
|
||||
+2
-1
@@ -28,6 +28,7 @@ module Vamp
|
||||
normal newline backspace submit
|
||||
search search_next search_previous
|
||||
word_next word_previous word_end big_word_next big_word_previous big_word_end
|
||||
select copy cut paste_before paste_after
|
||||
].each do |name|
|
||||
action = Action.new("core.#{name}".freeze).freeze
|
||||
define_singleton_method(name) { action }
|
||||
@@ -35,7 +36,7 @@ module Vamp
|
||||
end
|
||||
|
||||
module Modes
|
||||
%w[normal insert command search].each do |name|
|
||||
%w[normal insert command search visual].each do |name|
|
||||
mode = Mode.new(name.freeze).freeze
|
||||
define_singleton_method(name) { mode }
|
||||
end
|
||||
|
||||
@@ -404,6 +404,55 @@ end
|
||||
assert!(session.editor().should_quit());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn typed_visual_actions_copy_cut_paste_and_cancel_without_splitting_history() {
|
||||
let fixture = Fixture::new();
|
||||
fixture.file("buffer.txt", "one two");
|
||||
let mut session = fixture.open(
|
||||
r#"
|
||||
Vamp.configure do |config|
|
||||
config.bind "v", nil
|
||||
config.bind "ctrl+v", config.core.select
|
||||
config.bind "ctrl+c", config.core.copy, mode: config.modes.visual
|
||||
config.bind "ctrl+x", config.core.cut, mode: config.modes.visual
|
||||
config.bind "ctrl+g", config.core.normal, mode: config.modes.visual
|
||||
config.bind "ctrl+f", config.core.word_end, mode: config.modes.visual
|
||||
config.bind "ctrl+b", config.core.paste_before
|
||||
config.bind "ctrl+p", config.core.paste_after
|
||||
end
|
||||
"#,
|
||||
);
|
||||
session.handle(Input::Char('v'));
|
||||
assert_eq!(session.editor().mode(), crate::editor::Mode::Normal);
|
||||
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);
|
||||
session.handle(Input::Ctrl('c'));
|
||||
assert_eq!(session.editor().mode(), crate::editor::Mode::Normal);
|
||||
assert!(!session.editor().dirty());
|
||||
session.handle(Input::Ctrl('p'));
|
||||
assert_eq!(session.editor().lines(), &["oonene two"]);
|
||||
session.handle(Input::Char('u'));
|
||||
assert_eq!(session.editor().lines(), &["one two"]);
|
||||
session.handle(Input::Ctrl('v'));
|
||||
session.handle(Input::Ctrl('f'));
|
||||
session.handle(Input::Ctrl('x'));
|
||||
assert_eq!(session.editor().lines(), &[" two"]);
|
||||
assert_eq!(session.editor().mode(), crate::editor::Mode::Normal);
|
||||
session.handle(Input::Ctrl('b'));
|
||||
assert_eq!(session.editor().lines(), &["one two"]);
|
||||
session.handle(Input::Char('u'));
|
||||
assert_eq!(session.editor().lines(), &[" two"]);
|
||||
session.handle(Input::Char('u'));
|
||||
assert_eq!(session.editor().lines(), &["one two"]);
|
||||
assert!(!session.editor().dirty());
|
||||
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);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn typed_word_motions_remap_keys_and_keep_insert_typing_in_one_undo_group() {
|
||||
let fixture = Fixture::new();
|
||||
|
||||
+90
@@ -242,6 +242,31 @@ 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() {
|
||||
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);
|
||||
for row in first..=last {
|
||||
let line = &editor.lines()[row];
|
||||
let start = if row == selection.start.row {
|
||||
display_column(line, selection.start.column)
|
||||
} else {
|
||||
0
|
||||
};
|
||||
let end = if row == selection.end.row {
|
||||
display_column(line, selection.end.column)
|
||||
} else {
|
||||
display_column(line, usize::MAX) + 1
|
||||
};
|
||||
let start = start.max(*left);
|
||||
let end = end.min(left.saturating_add(width));
|
||||
let y = area.y + (row - *top) as u16;
|
||||
let buffer = frame.buffer_mut();
|
||||
for column in start..end {
|
||||
buffer[(area.x + (column - *left) as u16, y)].set_style(style);
|
||||
}
|
||||
}
|
||||
}
|
||||
let path = editor
|
||||
.path()
|
||||
.map(|p| p.to_string_lossy().into_owned())
|
||||
@@ -301,6 +326,71 @@ mod tests {
|
||||
assert_eq!(visible_line(text, 5, 2), " e\u{301}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn visual_selection_highlights_graphemes_newlines_and_clipped_ranges_over_search() {
|
||||
let mut editor = Editor::open(None, vec![]).unwrap();
|
||||
editor
|
||||
.apply(vec![crate::editor::Effect::Replace(
|
||||
"a\t界e\u{301}z\n\nxy".into(),
|
||||
)])
|
||||
.unwrap();
|
||||
editor.handle(Input::Char('/'));
|
||||
for ch in "界e\u{301}".chars() {
|
||||
editor.handle(Input::Char(ch));
|
||||
}
|
||||
editor.handle(Input::Enter);
|
||||
editor.handle(Input::Char('v'));
|
||||
editor.handle(Input::Left);
|
||||
let mut terminal = Terminal::new(ratatui::backend::TestBackend::new(10, 6)).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 x in [1, 2, 3, 4] {
|
||||
assert_eq!(buffer[(x, 0)].bg, Color::Blue);
|
||||
}
|
||||
assert_eq!(buffer[(4, 0)].symbol(), "界");
|
||||
assert_eq!(buffer[(6, 0)].bg, Color::Yellow);
|
||||
editor.handle(Input::Down);
|
||||
editor.handle(Input::Down);
|
||||
terminal
|
||||
.draw(|frame| draw(frame, &editor, &mut top, &mut left))
|
||||
.unwrap();
|
||||
let buffer = terminal.backend().buffer();
|
||||
assert_eq!(buffer[(8, 0)].symbol(), " ");
|
||||
assert_eq!(buffer[(8, 0)].bg, Color::Blue);
|
||||
assert_eq!(buffer[(0, 1)].bg, Color::Blue);
|
||||
assert_eq!(buffer[(0, 2)].bg, Color::Blue);
|
||||
assert_ne!(buffer[(1, 2)].bg, Color::Blue);
|
||||
|
||||
editor.handle(Input::Up);
|
||||
editor.handle(Input::Up);
|
||||
editor.handle(Input::End);
|
||||
let mut terminal = Terminal::new(ratatui::backend::TestBackend::new(5, 6)).unwrap();
|
||||
terminal
|
||||
.draw(|frame| draw(frame, &editor, &mut top, &mut left))
|
||||
.unwrap();
|
||||
let buffer = terminal.backend().buffer();
|
||||
assert_eq!(left, 4);
|
||||
assert_eq!(buffer[(0, 0)].symbol(), "界");
|
||||
assert_eq!(buffer[(2, 0)].symbol(), "e\u{301}");
|
||||
assert_eq!(buffer[(4, 0)].symbol(), " ");
|
||||
for x in [0, 2, 3, 4] {
|
||||
assert_eq!(buffer[(x, 0)].bg, Color::Blue);
|
||||
}
|
||||
assert_ne!(buffer[(0, 1)].bg, Color::Blue);
|
||||
editor.handle(Input::Escape);
|
||||
terminal
|
||||
.draw(|frame| draw(frame, &editor, &mut top, &mut left))
|
||||
.unwrap();
|
||||
let buffer = terminal.backend().buffer();
|
||||
assert_eq!(buffer[(0, 0)].bg, Color::Yellow);
|
||||
assert_eq!(buffer[(2, 0)].bg, Color::Yellow);
|
||||
assert_ne!(buffer[(3, 0)].bg, Color::Blue);
|
||||
assert_ne!(buffer[(4, 0)].bg, Color::Blue);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn active_search_highlight_uses_display_cells_and_clips_with_horizontal_scroll() {
|
||||
let mut editor = Editor::open(None, vec![]).unwrap();
|
||||
|
||||
Reference in New Issue
Block a user