feat: add literal buffer search and match navigation
This commit is contained in:
+224
-7
@@ -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 [
|
||||
|
||||
Reference in New Issue
Block a user