feat: add Visual selection and clipboard editing
This commit is contained in:
@@ -24,6 +24,8 @@ Without a file, vamp opens an unnamed buffer. A nonexistent file opens an empty
|
||||
| Normal | `J` (Shift+j) join the current line with the next line |
|
||||
| Normal | `x`/Delete delete; `u` undo; `U` (Shift+u) redo; `:` enter command mode |
|
||||
| Normal | `/` opens search; `n` finds the next match; `N` (Shift+n) finds the previous match |
|
||||
| Normal | `v` starts Visual selection; `p`/`P` paste internal clipboard after/before the cursor |
|
||||
| Visual | Cursor/word motions and `n`/`N` extend selection; `y` copies; `d`/`x`/Delete cuts; `v`/Esc cancels |
|
||||
| Insert | Type, Enter, Tab, Backspace/Delete, arrows, Home/End; Esc returns to normal |
|
||||
| Command | Enter executes; Backspace edits; Esc cancels |
|
||||
| Search | Type a literal query; Enter finds the next match; Backspace edits; Esc cancels |
|
||||
@@ -36,6 +38,12 @@ Word motions separate runs of Unicode letters/numbers/underscore from runs of pu
|
||||
|
||||
These keys type literal text in Insert mode. The corresponding actions can also be bound in Insert mode to move the insertion cursor without splitting its undo group. Motions never edit text, add history, or clear an active search highlight.
|
||||
|
||||
Visual selection is characterwise and inclusive of both endpoints, normalized when moving backward. Whole combining sequences and ZWJ emoji are selected together. `$`/End can select the newline after a non-final row; empty-line newlines are selectable too. Selected newlines are shown as one colored blank cell after the line. Blue selection highlighting takes precedence over yellow search highlighting.
|
||||
|
||||
Copy and cut return to Normal mode at the selection start; cancellation keeps the focus cursor. Both clamp the cursor to a valid Normal-mode position. `p`/`P` paste after/before the current grapheme, including multiline text. The cursor lands on the last inserted grapheme, or the new row's start when the clipboard ends in a newline. Cut and each paste are separate undo actions. Copy, selection movement, and cancellation do not add history or clear redo. Undo/redo restore text and cursor, not the selection or clipboard.
|
||||
|
||||
The clipboard is internal to the editor session and independent of the system clipboard. Empty clipboard paste does not edit text; copying/cutting an empty final-row selection does not overwrite an existing clipboard. Open a new search or enter Ex commands after leaving Visual mode. Successful text effects or explicit command execution also leave Visual mode; rejected effects preserve the selection.
|
||||
|
||||
Search is case-sensitive and single-line, with no regular expressions or Unicode normalization. Matches start and end on whole grapheme boundaries, including combining marks and emoji. Next/previous navigation skips the current position and wraps through the buffer; a sole match wraps back to itself. The active match is highlighted yellow.
|
||||
|
||||
Submitting an empty search repeats the last query. Esc cancels without moving the cursor or replacing the previous query/match. A missing match reports the query, clears the highlight, and leaves the cursor unchanged; searching without a previous query also leaves the cursor unchanged. Search never edits text or adds undo history. Buffer edits and undo/redo clear the highlight but retain the query, so subsequent navigation searches the current text again.
|
||||
@@ -44,19 +52,20 @@ Commands: `:w [path]`, `:q`, `:q!`, `:wq [path]`, `:plugins`, and `:plugin-comma
|
||||
|
||||
`:q` refuses unsaved changes. `:wq` quits only after a successful save. Saves use a same-directory temporary file and rename, preserve existing permissions, and reject target symlinks. UTF-8 text is edited by extended grapheme; tabs render at four-cell stops. LF and trailing-newline structure are preserved; CRLF and CR are normalized to LF. Binary/terminal-control-containing text is rejected. Bracketed paste inserts only in Insert mode and never executes pasted commands in Normal mode.
|
||||
|
||||
Paste is backend input: it is ignored in Normal, Command, and Search modes, and inserts literal text in Insert mode, with CRLF/CR normalization and a 4 MiB limit. Pasted text is one undo action, separate from typing before and after it. Rejected paste reports an error without changing text, cursor, mode, or pending insert history.
|
||||
Paste is backend input: it is ignored in Normal, Command, Search, and Visual modes, and inserts literal text in Insert mode, with CRLF/CR normalization and a 4 MiB limit. Pasted text is one undo action, separate from typing before and after it. Rejected paste reports an error without changing text, cursor, mode, or pending insert history. Internal clipboard actions are separate from `Input::Paste` and use already-validated buffer text.
|
||||
|
||||
## Module seams
|
||||
|
||||
One crate, four modules, no workspace or frontend-specific types in the editing backend:
|
||||
|
||||
- `editor`: owns buffer, cursor, modes, search state/navigation and grapheme-coordinate match presentation, key notation and binding resolution, modal paste policy, undo/redo, saving, command ownership, capabilities, and validated edit effects. Its low-level interface returns an `Invocation` when an external command is selected.
|
||||
- `editor`: owns buffer, cursor, modes, Visual selection and the internal clipboard, search state/navigation and grapheme-coordinate range presentation, key notation and binding resolution, modal paste policy, undo/redo, saving, command ownership, capabilities, and validated edit effects. Its low-level interface returns an `Invocation` when an external command is selected.
|
||||
- `session`: `EditorSession` owns the editor and its Ruby hosts together. It loads configuration, registers eligible plugins before installing bindings, executes selected commands, and retires both host and command ownership on invocation or effect-validation failure.
|
||||
- `tui`: translates terminal events to session input, renders read-only editor state, and advertises `text` and `styled_text` capabilities. It has no plugin lifecycle callback, host storage, or paste acceptance policy.
|
||||
- `ruby`: evaluates configuration and runs isolated plugin hosts. Protocol, deadlines, and process cleanup stay inside this module.
|
||||
- The executable selects paths and frontend capabilities, opens an editor session, and launches the TUI.
|
||||
|
||||
Frontends drive `EditorSession::handle` or `execute` and inspect `editor()`; there is no mutable editor getter that could independently alter plugin registration. Pasted text uses `Input::Paste(String)` through the same input seam as keys. Plugin effects are applied internally so a rejected result also retires its owner; there is no generic frontend effects entry point.
|
||||
Frontends inspect `selection()` for an ordered, end-exclusive range of grapheme coordinates. A range ending at the next row's column 0 includes the previous row's newline; an empty final-row range contains no text. Selection normalization, clipboard contents, editing, and history remain inside the editor module.
|
||||
|
||||
The terminal adapter owns raw mode, alternate-screen state, bracketed paste, and its panic hook for one session lifetime. Entry failure, normal return, I/O errors, and unwinding panics restore the caller's hook; active-terminal cleanup runs before caller panic diagnostics. Panics retain their original payload and propagate rather than being swallowed. Terminal state and panic hooks are process-global: run one TUI at a time and do not replace its hook while that session is active.
|
||||
|
||||
@@ -77,10 +86,13 @@ Vamp.configure do |config|
|
||||
config.bind "ctrl+c", config.core.normal, mode: config.modes.insert
|
||||
config.bind "ctrl+f", config.core.search
|
||||
config.bind "ctrl+c", config.core.normal, mode: config.modes.search
|
||||
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
|
||||
end
|
||||
```
|
||||
|
||||
Plugin paths resolve relative to the config file, not the shell's working directory. `config.bind key, target, mode: config.modes.normal` overrides one key; available modes are `config.modes.normal`, `config.modes.insert`, `config.modes.command`, and `config.modes.search`. Omitting `mode` selects Normal.
|
||||
Plugin paths resolve relative to the config file, not the shell's working directory. `config.bind key, target, mode: config.modes.normal` overrides one key; available modes are `config.modes.normal`, `config.modes.insert`, `config.modes.command`, `config.modes.search`, and `config.modes.visual`. Omitting `mode` selects Normal.
|
||||
|
||||
`config.core` exposes the built-in actions below as immutable `Action` values; `config.modes` exposes immutable `Mode` values. Misspelled methods fail during configuration evaluation, and `bind` rejects the wrong value type. Built-in action strings and mode strings are not accepted. Registered Ex/plugin commands still use exact string names (`"w"`, `"greet"`, etc., without `:` or arguments) and can only be bound in Normal mode. Ruby provides runtime checks, not compile-time checking; the editor still validates action/mode compatibility.
|
||||
|
||||
@@ -96,10 +108,14 @@ Terminals share `ctrl+h` with `backspace`, `ctrl+i` with `tab`, and `ctrl+j`/`ct
|
||||
| --- | --- |
|
||||
| Normal | `insert`, `insert_first_nonblank`, `append`, `append_line_end`, `open_below`, `open_above` |
|
||||
| Normal | `join_lines`, `undo`, `redo`, `command` |
|
||||
| Normal | `search`, `search_next`, `search_previous` |
|
||||
| Normal, Insert | `left`, `down`, `up`, `right`, `line_start`, `line_end`, `delete` |
|
||||
| Normal, Insert | `word_next`, `word_previous`, `word_end`, `big_word_next`, `big_word_previous`, `big_word_end` |
|
||||
| Insert, Command, Search | `normal`, `backspace` |
|
||||
| Normal | `search`, `paste_before`, `paste_after` |
|
||||
| Normal, Visual | `select`, `search_next`, `search_previous` |
|
||||
| Normal, Insert, Visual | `left`, `down`, `up`, `right`, `line_start`, `line_end` |
|
||||
| Normal, Insert, Visual | `word_next`, `word_previous`, `word_end`, `big_word_next`, `big_word_previous`, `big_word_end` |
|
||||
| Normal, Insert | `delete` |
|
||||
| Visual | `copy`, `cut` |
|
||||
| Insert, Command, Search, Visual | `normal` |
|
||||
| Insert, Command, Search | `backspace` |
|
||||
| Insert | `newline` |
|
||||
| Command, Search | `submit` |
|
||||
|
||||
@@ -116,7 +132,7 @@ end
|
||||
|
||||
The last valid declaration for a key/mode wins. Invalid keys, incompatible action/mode pairs, or unknown command names leave that binding unchanged and appear in the status message; other valid bindings still load. Ruby syntax errors, unknown methods, and type errors disable the entire configuration and fall back to defaults. One plugin's load failure does not prevent other plugins loading. Disabling a plugin removes its bindings, without restoring any default keys they replaced.
|
||||
|
||||
Bindings are resolved inside the editor module, not the terminal adapter. Bracketed paste remains literal and bypasses keybindings. If configuration disables your keys for leaving Insert/Command/Search mode or entering commands, restart with `--no-config` to restore defaults.
|
||||
Bindings are resolved inside the editor module, not the terminal adapter. Bracketed paste remains literal and bypasses keybindings. If configuration disables your keys for leaving Insert/Command/Search/Visual mode or entering commands, restart with `--no-config` to restore defaults.
|
||||
|
||||
## Ruby plugins
|
||||
|
||||
@@ -146,7 +162,7 @@ JSON uses Ruby's standard library. Protocol Buffers is not installed or required
|
||||
|
||||
## Current limits
|
||||
|
||||
- One text buffer; Normal, Insert, Command, and Search modes. No Visual mode, Vim counts/operators, splits, regex/multiline search, LSP, or graphical frontend.
|
||||
- One text buffer; Normal, Insert, Command, Search, and characterwise Visual modes. No system clipboard integration, named registers, line/block selection, Vim counts/operators, splits, regex/multiline search, LSP, or graphical frontend.
|
||||
- Undo uses full-buffer snapshots and movement scans the current line. This favors straightforward correctness over large-file performance.
|
||||
- Plugin invocation is synchronous and may pause input for up to 500 ms before a host timeout. Config/plugin startup has a 2-second deadline per host. Buffer context must fit within a 4 MiB JSON request; a too-large request disables that plugin, not editing.
|
||||
- Disabling a plugin removes its commands and bindings until the next launch. There is no hot reload.
|
||||
|
||||
@@ -7,4 +7,7 @@ Vamp.configure do |config|
|
||||
config.bind "ctrl+c", config.core.normal, mode: config.modes.search
|
||||
config.bind "ctrl+w", config.core.word_next
|
||||
config.bind "ctrl+b", config.core.word_previous, mode: config.modes.insert
|
||||
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
|
||||
end
|
||||
|
||||
@@ -2,6 +2,5 @@ sjdalkjdas dsasda sdklam dalkmsd adadkajsdlakj
|
||||
o
|
||||
aaklsjdad
|
||||
|
||||
ads
|
||||
dasdkaljdalkjsda
|
||||
ads dasdkaljdalkjsda
|
||||
sdalksdjaldskjadkjsla
|
||||
+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