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
+224 -7
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;
return self.execute(&command);
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() {