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 [