feat: add literal buffer search and match navigation

This commit is contained in:
Valdemar Lorenzen
2026-10-06 12:02:14 +02:00
parent 714f1ff4de
commit 3ad6ca1fe2
7 changed files with 362 additions and 19 deletions
+16 -7
View File
@@ -22,22 +22,28 @@ Without a file, vamp opens an unnamed buffer. A nonexistent file opens an empty
| Normal | `h`/`j`/`k`/`l` or arrows; `0`/`$` or Home/End |
| 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 |
| 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 |
`J` removes the next line's leading spaces/tabs and preserves existing trailing whitespace. It normally adds one space, two after `.`, `?`, or `!` (Vim's default), and none before `)` or when either side is empty. The cursor lands at the join point; the join is one undo action. On the last line, `J` does nothing.
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.
Commands: `:w [path]`, `:q`, `:q!`, `:wq [path]`, `:plugins`, and `:plugin-command [arguments]`.
`: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 and Command 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, 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.
## Module seams
One crate, four modules, no workspace or frontend-specific types in the editing backend:
- `editor`: owns buffer, cursor, modes, 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, 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.
- `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.
@@ -62,10 +68,12 @@ Vamp.configure do |config|
config.bind "ctrl+r", config.core.redo
config.bind "shift+j", config.core.join_lines
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
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`, and `config.modes.command`. 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`, and `config.modes.search`. 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.
@@ -81,10 +89,11 @@ 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` |
| Insert, Command | `normal`, `backspace` |
| Insert, Command, Search | `normal`, `backspace` |
| Insert | `newline` |
| Command | `submit` |
| Command, Search | `submit` |
Adding a binding keeps the original key. Pass `nil` to disable a key, including literal typing in Insert/Command mode:
@@ -99,7 +108,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 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 mode or entering commands, restart with `--no-config` to restore defaults.
## Ruby plugins
@@ -129,7 +138,7 @@ JSON uses Ruby's standard library. Protocol Buffers is not installed or required
## Current limits
- One text buffer; Normal, Insert, and Command modes. No Visual mode, Vim counts/operators, splits, search, LSP, or graphical frontend.
- One text buffer; Normal, Insert, Command, and Search modes. No Visual mode, 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.
+2
View File
@@ -3,4 +3,6 @@ Vamp.configure do |config|
config.bind "g", "greet"
config.bind "ctrl+r", config.core.redo
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
end
+223 -6
View File
@@ -8,7 +8,7 @@ use std::io::{self, Write};
use std::os::unix::fs::OpenOptionsExt;
use std::path::{Path, PathBuf};
use std::sync::atomic::{AtomicU64, Ordering};
use unicode_segmentation::UnicodeSegmentation;
use unicode_segmentation::{GraphemeCursor, UnicodeSegmentation};
const MAX_EDIT_BYTES: usize = 4 * 1024 * 1024;
static TEMP_ID: AtomicU64 = AtomicU64::new(0);
@@ -35,6 +35,7 @@ pub enum Mode {
Normal,
Insert,
Command,
Search,
}
impl fmt::Display for Mode {
@@ -43,6 +44,7 @@ impl fmt::Display for Mode {
Self::Normal => "NORMAL",
Self::Insert => "INSERT",
Self::Command => "COMMAND",
Self::Search => "SEARCH",
})
}
}
@@ -54,6 +56,13 @@ pub struct Cursor {
pub column: usize,
}
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
pub struct SearchMatch {
pub start: Cursor,
/// Exclusive end, in grapheme columns on the same row as `start`.
pub end_column: usize,
}
#[derive(Clone, Debug, Eq, PartialEq)]
pub struct Feature {
pub name: String,
@@ -115,6 +124,9 @@ enum Action {
Newline,
Backspace,
Submit,
Search,
SearchNext,
SearchPrevious,
}
impl Action {
@@ -131,6 +143,9 @@ impl Action {
(Mode::Normal, "core.undo") => Undo,
(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,
@@ -138,10 +153,10 @@ impl Action {
(Mode::Normal | Mode::Insert, "core.line_start") => LineStart,
(Mode::Normal | Mode::Insert, "core.line_end") => LineEnd,
(Mode::Normal | Mode::Insert, "core.delete") => Delete,
(Mode::Insert | Mode::Command, "core.normal") => Normal,
(Mode::Insert | Mode::Command | Mode::Search, "core.normal") => Normal,
(Mode::Insert, "core.newline") => Newline,
(Mode::Insert | Mode::Command, "core.backspace") => Backspace,
(Mode::Command, "core.submit") => Submit,
(Mode::Insert | Mode::Command | Mode::Search, "core.backspace") => Backspace,
(Mode::Command | Mode::Search, "core.submit") => Submit,
_ => return None,
})
}
@@ -241,6 +256,9 @@ fn default_bindings() -> HashMap<Mode, HashMap<Input, Binding>> {
('u', Undo),
('U', Redo),
(':', Command),
('/', Search),
('n', SearchNext),
('N', SearchPrevious),
] {
bindings
.entry(Mode::Normal)
@@ -263,7 +281,7 @@ fn default_bindings() -> HashMap<Mode, HashMap<Input, Binding>> {
.insert(key, Binding::Action(action));
}
}
for mode in [Mode::Insert, Mode::Command] {
for mode in [Mode::Insert, Mode::Command, Mode::Search] {
for (key, action) in [
(Input::Escape, Normal),
(Input::Backspace, Backspace),
@@ -291,6 +309,8 @@ pub struct Editor {
cursor: Cursor,
mode: Mode,
command_line: String,
search_query: String,
search_match: Option<SearchMatch>,
message: String,
path: Option<PathBuf>,
quit: bool,
@@ -321,6 +341,8 @@ impl Editor {
cursor: Cursor::default(),
mode: Mode::Normal,
command_line: String::new(),
search_query: String::new(),
search_match: None,
message: String::new(),
path,
quit: false,
@@ -357,9 +379,13 @@ impl Editor {
pub fn mode(&self) -> Mode {
self.mode
}
/// Editable Ex/search prompt, without its `:` or `/` prefix.
pub fn command_line(&self) -> &str {
&self.command_line
}
pub fn search_match(&self) -> Option<SearchMatch> {
self.search_match
}
pub fn message(&self) -> &str {
&self.message
}
@@ -461,6 +487,7 @@ impl Editor {
"normal" => Mode::Normal,
"insert" => Mode::Insert,
"command" => Mode::Command,
"search" => Mode::Search,
_ => return Err(format!("Unknown binding mode: {mode}")),
};
let key = parse_key(key)?;
@@ -515,7 +542,11 @@ impl Editor {
let mut bytes = [0; 4];
self.insert_text(ch.encode_utf8(&mut bytes));
}
(Mode::Command, Input::Char(ch)) if !ch.is_control() => self.command_line.push(ch),
(Mode::Command | Mode::Search, Input::Char(ch))
if !ch.is_control() || (self.mode == Mode::Search && ch == '\t') =>
{
self.command_line.push(ch);
}
_ => {}
}
None
@@ -546,6 +577,7 @@ impl Editor {
self.cursor.row += 1;
}
self.lines.insert(self.cursor.row, String::new());
self.search_match = None;
self.cursor.column = 0;
}
Action::Left => self.move_horizontal(false),
@@ -571,6 +603,12 @@ impl Editor {
self.mode = Mode::Command;
self.command_line.clear();
}
Action::Search => {
self.mode = Mode::Search;
self.command_line.clear();
}
Action::SearchNext => self.seek_search(true),
Action::SearchPrevious => self.seek_search(false),
Action::Normal => {
if self.mode == Mode::Insert {
self.finish_insert();
@@ -593,13 +631,62 @@ impl Editor {
}
Action::Submit => {
let command = std::mem::take(&mut self.command_line);
let searching = self.mode == Mode::Search;
self.mode = Mode::Normal;
if searching {
if !command.is_empty() {
self.search_query = command;
}
self.seek_search(true);
} else {
return self.execute(&command);
}
}
}
None
}
fn seek_search(&mut self, forward: bool) {
self.search_match = None;
if self.search_query.is_empty() {
self.set_message("No previous search");
return;
}
let query = self.search_query.as_str();
let origin = (
self.cursor.row,
grapheme_byte(&self.lines[self.cursor.row], self.cursor.column),
);
// ponytail: O(buffer graphemes × query bytes) scan; index matches if large buffers need it.
let matches = self.lines.iter().enumerate().flat_map(|(row, line)| {
line.grapheme_indices(true).filter_map(move |(byte, _)| {
(line[byte..].starts_with(query)
&& GraphemeCursor::new(byte + query.len(), line.len(), true)
.is_boundary(line, 0)
.expect("complete line supplies grapheme context"))
.then_some((row, byte))
})
});
let found = if forward {
select_search_match(matches, origin, true)
} else {
select_search_match(matches.rev(), origin, false)
};
if let Some((row, byte)) = found {
self.cursor = Cursor {
row,
column: column_after_byte(&self.lines[row], byte),
};
self.search_match = Some(SearchMatch {
start: self.cursor,
end_column: self.cursor.column + grapheme_count(query),
});
self.message.clear();
} else {
self.set_message(format!("Not found: {}", self.search_query));
}
}
pub fn execute(&mut self, command: &str) -> Option<Invocation> {
if self.mode == Mode::Insert {
self.finish_insert();
@@ -695,6 +782,7 @@ impl Editor {
Effect::Insert(text) => self.insert_text(&normalize_newlines(&text)),
Effect::Replace(text) => {
self.lines = split_lines(&text);
self.search_match = None;
self.clamp_cursor();
}
Effect::Message(text) => self.set_message(text),
@@ -738,6 +826,7 @@ impl Editor {
fn record(&mut self, before: Snapshot) {
if before.lines != self.lines {
self.search_match = None;
self.undo.push(before);
self.redo.clear();
}
@@ -778,6 +867,7 @@ impl Editor {
fn undo(&mut self) {
if let Some(before) = self.undo.pop() {
let current = self.snapshot();
self.search_match = None;
self.lines = before.lines;
self.cursor = before.cursor;
self.redo.push(current);
@@ -790,6 +880,7 @@ impl Editor {
fn redo(&mut self) {
if let Some(after) = self.redo.pop() {
let current = self.snapshot();
self.search_match = None;
self.lines = after.lines;
self.cursor = after.cursor;
self.undo.push(current);
@@ -838,6 +929,7 @@ impl Editor {
if text.is_empty() {
return;
}
self.search_match = None;
let row = self.cursor.row;
let byte = grapheme_byte(&self.lines[row], self.cursor.column);
let mut parts = text.split('\n');
@@ -863,10 +955,12 @@ impl Editor {
let line = &mut self.lines[row];
let byte = grapheme_byte(line, self.cursor.column);
if byte < line.len() {
self.search_match = None;
let end = grapheme_byte(line, self.cursor.column + 1);
line.replace_range(byte..end, "");
self.cursor.column = column_after_byte(line, byte);
} else if self.mode == Mode::Insert && row + 1 < self.lines.len() {
self.search_match = None;
let next = self.lines.remove(row + 1);
self.lines[row].push_str(&next);
self.cursor.column = column_after_byte(&self.lines[row], byte);
@@ -912,12 +1006,14 @@ impl Editor {
fn backspace(&mut self) {
let row = self.cursor.row;
if self.cursor.column > 0 {
self.search_match = None;
let line = &mut self.lines[row];
let start = grapheme_byte(line, self.cursor.column - 1);
let end = grapheme_byte(line, self.cursor.column);
line.replace_range(start..end, "");
self.cursor.column = column_after_byte(line, start);
} else if row > 0 {
self.search_match = None;
let line = self.lines.remove(row);
self.cursor.row -= 1;
let previous = &mut self.lines[self.cursor.row];
@@ -928,6 +1024,21 @@ impl Editor {
}
}
fn select_search_match(
matches: impl Iterator<Item = (usize, usize)>,
origin: (usize, usize),
forward: bool,
) -> Option<(usize, usize)> {
let mut wrapped = None;
for position in matches {
wrapped.get_or_insert(position);
if (forward && position > origin) || (!forward && position < origin) {
return Some(position);
}
}
wrapped
}
fn valid_command(command: &str) -> bool {
let command = command.strip_suffix('!').unwrap_or(command);
let mut chars = command.chars();
@@ -1080,6 +1191,11 @@ mod tests {
});
}
}
fn submit_search(editor: &mut Editor, query: &str) {
editor.handle(Input::Char('/'));
type_text(editor, query);
editor.handle(Input::Enter);
}
fn plugin(name: &str, commands: &[&str]) -> Feature {
Feature {
name: name.into(),
@@ -1089,6 +1205,107 @@ mod tests {
}
}
#[test]
fn search_wraps_both_ways_and_cancel_failure_and_navigation_preserve_edits() {
let mut editor = editor();
let text = "zero cat\ncat cat\nCAT\n";
editor.apply(vec![Effect::Replace(text.into())]).unwrap();
submit_search(&mut editor, "cat");
assert_eq!(editor.cursor(), Cursor { row: 0, column: 5 });
assert_eq!(editor.search_match().unwrap().end_column, 8);
editor.handle(Input::Char('n'));
assert_eq!(editor.cursor(), Cursor { row: 1, column: 0 });
editor.handle(Input::Char('n'));
assert_eq!(editor.cursor(), Cursor { row: 1, column: 4 });
editor.handle(Input::Char('n'));
assert_eq!(editor.cursor(), Cursor { row: 0, column: 5 });
editor.handle(Input::Char('N'));
assert_eq!(editor.cursor(), Cursor { row: 1, column: 4 });
editor.handle(Input::Char('N'));
assert_eq!(editor.cursor(), Cursor { row: 1, column: 0 });
let cursor = editor.cursor();
let active = editor.search_match();
editor.handle(Input::Char('/'));
type_text(&mut editor, "other界e\u{301}");
editor.handle(Input::Backspace);
assert_eq!(editor.command_line(), "other界");
editor.handle(Input::Paste(":q!\n".into()));
assert_eq!(editor.command_line(), "other界");
editor.handle(Input::Escape);
assert_eq!(editor.mode(), Mode::Normal);
assert_eq!(editor.cursor(), cursor);
assert_eq!(editor.search_match(), active);
assert!(editor.command_line().is_empty());
submit_search(&mut editor, "");
assert_eq!(editor.cursor(), Cursor { row: 1, column: 4 });
let cursor = editor.cursor();
submit_search(&mut editor, "missing");
assert_eq!(editor.mode(), Mode::Normal);
assert_eq!(editor.cursor(), cursor);
assert_eq!(editor.search_match(), None);
editor.handle(Input::Char('N'));
assert_eq!(editor.cursor(), cursor);
assert!(!editor.should_quit());
assert_eq!(editor.context().text, text);
editor.handle(Input::Char('u'));
assert_eq!(editor.context().text, "");
editor.handle(Input::Char('U'));
assert_eq!(editor.context().text, text);
}
#[test]
fn search_is_literal_overlapping_and_grapheme_safe_and_rechecks_after_edits() {
let mut editor = editor();
editor
.apply(vec![Effect::Replace("e\u{301} e 👩‍🚀 👩 aa... aaa".into())])
.unwrap();
submit_search(&mut editor, "e");
assert_eq!(editor.cursor().column, 2);
submit_search(&mut editor, "\u{301}");
assert_eq!(editor.cursor().column, 2);
assert_eq!(editor.search_match(), None);
submit_search(&mut editor, "👩");
assert_eq!(editor.cursor().column, 6);
submit_search(&mut editor, "👩‍🚀");
assert_eq!(editor.cursor().column, 4);
assert_eq!(editor.search_match().unwrap().end_column, 5);
submit_search(&mut editor, ".");
assert_eq!(editor.cursor().column, 10);
editor.handle(Input::Char('n'));
assert_eq!(editor.cursor().column, 11);
submit_search(&mut editor, "aa");
assert_eq!(editor.cursor().column, 14);
editor.handle(Input::Char('n'));
assert_eq!(editor.cursor().column, 15);
editor.handle(Input::Char('n'));
assert_eq!(editor.cursor().column, 8);
submit_search(&mut editor, "e\u{301}");
assert_eq!(editor.cursor().column, 0);
assert_eq!(editor.search_match().unwrap().end_column, 1);
let active = editor.search_match();
assert!(editor.apply(vec![Effect::Replace("\0".into())]).is_err());
assert_eq!(editor.search_match(), active);
editor.handle(Input::Char('x'));
assert_eq!(editor.search_match(), None);
editor.handle(Input::Char('n'));
assert_eq!(editor.search_match(), None);
editor.handle(Input::Char('u'));
assert_eq!(editor.search_match(), None);
editor.handle(Input::Char('n'));
assert_eq!(editor.cursor().column, 0);
assert_eq!(editor.search_match().unwrap().end_column, 1);
editor
.apply(vec![Effect::Replace("e\u{301}".into())])
.unwrap();
assert_eq!(editor.search_match(), None);
editor.handle(Input::Char('n'));
assert_eq!(editor.cursor().column, 0);
assert_eq!(editor.search_match().unwrap().end_column, 1);
}
#[test]
fn join_lines_matches_vim_spacing_and_grapheme_cursor_positions() {
for (original, expected, column) in [
+2 -1
View File
@@ -9,8 +9,9 @@ Usage: vamp [--config PATH | --no-config] [FILE]
Default keys (configurable with config.bind):
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
u undo, U redo, : command
u undo, U redo, : command, / search, n/N next/previous match
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]
Ruby config: $XDG_CONFIG_HOME/vamp/config.rb or ~/.config/vamp/config.rb
+2 -1
View File
@@ -26,6 +26,7 @@ module Vamp
insert insert_first_nonblank append append_line_end open_below open_above
left right down up join_lines line_start line_end delete undo redo command
normal newline backspace submit
search search_next search_previous
].each do |name|
action = Action.new("core.#{name}".freeze).freeze
define_singleton_method(name) { action }
@@ -33,7 +34,7 @@ module Vamp
end
module Modes
%w[normal insert command].each do |name|
%w[normal insert command search].each do |name|
mode = Mode.new(name.freeze).freeze
define_singleton_method(name) { mode }
end
+43
View File
@@ -404,6 +404,49 @@ end
assert!(session.editor().should_quit());
}
#[test]
fn typed_search_bindings_drive_navigation_and_the_search_prompt_only() {
let fixture = Fixture::new();
fixture.file("buffer.txt", "cat\ncat\ncat");
let mut session = fixture.open(
r#"
Vamp.configure do |config|
config.bind "ctrl+f", config.core.search
config.bind "ctrl+n", config.core.search_next
config.bind "ctrl+p", config.core.search_previous
config.bind "ctrl+x", config.core.submit, mode: config.modes.search
config.bind "ctrl+c", config.core.normal, mode: config.modes.search
config.bind "ctrl+h", config.core.backspace, mode: config.modes.search
config.bind "enter", nil, mode: config.modes.search
config.bind "/", nil
end
"#,
);
session.handle(Input::Char('/'));
assert_eq!(session.editor().mode(), crate::editor::Mode::Normal);
session.handle(Input::Ctrl('f'));
assert_eq!(session.editor().mode(), crate::editor::Mode::Search);
for ch in "cat界".chars() {
session.handle(Input::Char(ch));
}
session.handle(Input::Backspace);
session.handle(Input::Enter);
assert_eq!(session.editor().mode(), crate::editor::Mode::Search);
session.handle(Input::Ctrl('x'));
assert_eq!(session.editor().cursor().row, 1);
session.handle(Input::Ctrl('n'));
assert_eq!(session.editor().cursor().row, 2);
session.handle(Input::Ctrl('p'));
assert_eq!(session.editor().cursor().row, 1);
session.handle(Input::Ctrl('f'));
session.handle(Input::Char('q'));
session.handle(Input::Ctrl('c'));
session.handle(Input::Ctrl('n'));
assert_eq!(session.editor().cursor().row, 2);
assert!(!session.editor().dirty());
assert!(!session.editor().should_quit());
}
#[test]
fn typed_config_errors_discard_partial_bindings_and_leave_default_editing_available() {
let fixture = Fixture::new();
+73 -3
View File
@@ -228,6 +228,20 @@ fn draw(frame: &mut Frame, editor: &Editor, top: &mut usize, left: &mut usize) {
Paragraph::new(lines),
Rect::new(area.x, area.y, area.width, area.height - 2),
);
if let Some(found) = editor.search_match()
&& found.start.row >= *top
&& found.start.row < top.saturating_add(height)
{
let line = &editor.lines()[found.start.row];
let start = display_column(line, found.start.column).max(*left);
let end = display_column(line, found.end_column).min(left.saturating_add(width));
let y = area.y + (found.start.row - *top) as u16;
let style = Style::default().fg(Color::Black).bg(Color::Yellow);
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())
@@ -249,9 +263,14 @@ fn draw(frame: &mut Frame, editor: &Editor, top: &mut usize, left: &mut usize) {
Rect::new(area.x, area.y + area.height - 2, area.width, 1),
);
let bottom = area.y + area.height - 1;
if editor.mode() == Mode::Command {
let command = format!(":{}", editor.command_line());
let command_width = UnicodeWidthStr::width(command.as_str());
if matches!(editor.mode(), Mode::Command | Mode::Search) {
let prefix = if editor.mode() == Mode::Search {
'/'
} else {
':'
};
let command = format!("{prefix}{}", editor.command_line());
let command_width = display_column(&command, usize::MAX);
let offset = command_width.saturating_sub(width - 1);
frame.render_widget(
Paragraph::new(visible_line(&command, offset, width)),
@@ -282,6 +301,57 @@ mod tests {
assert_eq!(visible_line(text, 5, 2), " e\u{301}");
}
#[test]
fn active_search_highlight_uses_display_cells_and_clips_with_horizontal_scroll() {
let mut editor = Editor::open(None, vec![]).unwrap();
editor
.apply(vec![crate::editor::Effect::Replace(
"a\t界e\u{301}zz".into(),
)])
.unwrap();
editor.handle(Input::Char('/'));
for ch in "\t界e\u{301}".chars() {
editor.handle(Input::Char(ch));
}
editor.handle(Input::Enter);
editor.handle(Input::End);
let mut terminal = Terminal::new(ratatui::backend::TestBackend::new(5, 5)).unwrap();
let (mut top, mut left) = (0, 0);
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}");
for x in [0, 2] {
assert_eq!(buffer[(x, 0)].bg, Color::Yellow);
}
for x in 3..5 {
assert_ne!(buffer[(x, 0)].bg, Color::Yellow);
}
editor.handle(Input::Char('/'));
for ch in "界界".chars() {
editor.handle(Input::Char(ch));
}
terminal
.draw(|frame| draw(frame, &editor, &mut top, &mut left))
.unwrap();
let buffer = terminal.backend().buffer();
assert_eq!(buffer[(0, 4)].symbol(), "界");
assert_eq!(buffer[(2, 4)].symbol(), "界");
editor.handle(Input::Backspace);
editor.handle(Input::Backspace);
editor.handle(Input::Char('\t'));
terminal
.draw(|frame| draw(frame, &editor, &mut top, &mut left))
.unwrap();
assert_eq!(
terminal.get_cursor_position().unwrap(),
ratatui::layout::Position::new(4, 4)
);
}
#[cfg(unix)]
#[test]
fn terminal_session_scopes_the_callers_panic_hook() {