feat: add Vim-style word search

This commit is contained in:
Valdemar Lorenzen
2026-10-15 11:59:07 +02:00
parent 2fd240aca8
commit 7b3b3866fd
6 changed files with 206 additions and 19 deletions
+84 -15
View File
@@ -506,6 +506,7 @@ core_actions![
),
("core.command", Action::Command, &[Mode::Normal]),
("core.search", Action::Search, &[Mode::Normal]),
("core.search_word", Action::SearchWord, &[Mode::Normal]),
(
"core.search_next",
Action::SearchNext,
@@ -1263,6 +1264,7 @@ fn default_bindings() -> HashMap<Mode, HashMap<Input, Binding>> {
('U', Redo),
(':', Command),
('/', Search),
('*', SearchWord),
('p', PasteAfter),
('P', PasteBefore),
('_', PaneSplitHorizontal),
@@ -1572,6 +1574,7 @@ struct ViewState {
word_wrap: bool,
linewise: bool,
search_query: String,
search_whole_word: bool,
search_match: Option<SearchMatch>,
command_line: String,
caret_reveal_revision: u64,
@@ -1590,6 +1593,7 @@ impl ViewState {
word_wrap: false,
linewise: false,
search_query: String::new(),
search_whole_word: false,
search_match: None,
command_line: String::new(),
caret_reveal_revision: 0,
@@ -1608,6 +1612,7 @@ impl ViewState {
word_wrap: self.word_wrap,
linewise: self.linewise,
search_query: self.search_query.clone(),
search_whole_word: self.search_whole_word,
search_match: self.search_match,
command_line: self.command_line.clone(),
caret_reveal_revision: self.caret_reveal_revision,
@@ -8323,8 +8328,9 @@ impl Editor {
};
self.active.view.command_line.clear();
}
Action::SearchNext => self.seek_search(true, count),
Action::SearchPrevious => self.seek_search(false, count),
Action::SearchWord | Action::SearchNext | Action::SearchPrevious => {
self.seek_search(action, count);
}
Action::Select | Action::SelectLinewise => {
let linewise = matches!(action, Action::SelectLinewise);
if self.active.view.mode == Mode::Visual && self.active.view.linewise == linewise {
@@ -8395,8 +8401,9 @@ impl Editor {
if searching {
if !command.is_empty() {
self.active.view.search_query = command;
self.active.view.search_whole_word = false;
}
self.seek_search(true, 1);
self.seek_search(Action::SearchNext, 1);
} else {
return self.execute(&command);
}
@@ -8405,15 +8412,59 @@ impl Editor {
None
}
fn seek_search(&mut self, forward: bool, count: usize) {
fn set_word_search(&mut self) -> Option<Cursor> {
let cursor = self.cursor();
let line = &self.active.lines[cursor.row];
let origin = grapheme_byte(line, cursor.column);
let tokens = || {
line.grapheme_indices(true)
.map(|(byte, grapheme)| (byte, word_kind(grapheme, false)))
};
let Some((byte, kind)) = tokens()
.find(|(byte, kind)| *byte >= origin && *kind == WordKind::Keyword)
.or_else(|| {
tokens().find(|(byte, kind)| *byte >= origin && *kind != WordKind::Whitespace)
})
else {
self.set_message("No word under cursor");
return None;
};
let start = tokens()
.rev()
.skip_while(|(position, _)| *position >= byte)
.take_while(|(_, previous)| *previous == kind)
.last()
.map_or(byte, |(position, _)| position);
let end = tokens()
.find(|(position, next)| *position > byte && *next != kind)
.map_or(line.len(), |(position, _)| position);
self.active.view.search_query = line[start..end].into();
self.active.view.search_whole_word = kind == WordKind::Keyword;
Some(Cursor {
row: cursor.row,
column: column_after_byte(line, start),
})
}
fn seek_search(&mut self, action: Action, count: usize) {
self.reveal_caret();
let source = self.jump_location();
let primary_origin = if action == Action::SearchWord {
Some(match self.set_word_search() {
Some(origin) => origin,
None => return,
})
} else {
None
};
let forward = action != Action::SearchPrevious;
self.active.view.search_match = None;
if self.active.view.search_query.is_empty() {
self.set_message("No previous search");
return;
}
let query = self.active.view.search_query.as_str();
let whole_word = self.active.view.search_whole_word;
// ponytail: O(buffer graphemes × query bytes) scan; index matches if large buffers need it.
let matches: Vec<_> = self
.navigable_rows(0, self.active.lines.len())
@@ -8423,7 +8474,16 @@ impl Editor {
(line[byte..].starts_with(query)
&& GraphemeCursor::new(byte + query.len(), line.len(), true)
.is_boundary(line, 0)
.expect("complete line supplies grapheme context"))
.expect("complete line supplies grapheme context")
&& (!whole_word
|| (line[..byte]
.graphemes(true)
.next_back()
.is_none_or(|g| word_kind(g, false) != WordKind::Keyword)
&& line[byte + query.len()..]
.graphemes(true)
.next()
.is_none_or(|g| word_kind(g, false) != WordKind::Keyword))))
.then_some((row, byte))
})
})
@@ -8433,9 +8493,14 @@ impl Editor {
return;
}
for (index, target) in self.active.view.targets.iter_mut().enumerate() {
let cursor = if index == self.active.view.primary {
primary_origin.unwrap_or(target.active)
} else {
target.active
};
let origin = (
target.active.row,
grapheme_byte(&self.active.lines[target.active.row], target.active.column),
cursor.row,
grapheme_byte(&self.active.lines[cursor.row], cursor.column),
);
let first = if forward {
matches.partition_point(|position| *position <= origin) % matches.len()
@@ -11862,6 +11927,17 @@ enum WordKind {
EmptyLine,
}
fn word_kind(grapheme: &str, big: bool) -> WordKind {
let ch = grapheme.chars().next().expect("graphemes are nonempty");
if ch.is_whitespace() {
WordKind::Whitespace
} else if big || ch == '_' || ch.is_alphanumeric() {
WordKind::Keyword
} else {
WordKind::Punctuation
}
}
fn word_tokens(
lines: &[String],
big: bool,
@@ -11869,14 +11945,7 @@ fn word_tokens(
lines.iter().enumerate().flat_map(move |(row, line)| {
line.grapheme_indices(true)
.map(move |(byte, grapheme)| {
let ch = grapheme.chars().next().expect("graphemes are nonempty");
let kind = if ch.is_whitespace() {
WordKind::Whitespace
} else if big || ch == '_' || ch.is_alphanumeric() {
WordKind::Keyword
} else {
WordKind::Punctuation
};
let kind = word_kind(grapheme, big);
(row, byte, kind)
})
.chain(std::iter::once((
+1 -1
View File
@@ -28,7 +28,7 @@ Normal: i/a insert, I/A insert at first nonblank/end, o/O new line
_ splits above/below, | left/right; Option/Alt+h/j/k/l focuses neighboring panes
Ctrl+Option/Alt+h/l previous/next tab; :tab.new opens current buffer in another tab
Normal/Visual: prefix motions with counts (5j, 2w, 3n); Esc cancels count
u undo, U redo, : command, / search, n/N next/previous match
u undo, U redo, : command, / search, * search word, n/N next/previous match
yy copies whole lines; dd deletes them; cc changes them and enters Insert; counts: 3yy, 2dd, 2cc
dw/db cut forward/backward words on the current line; cw/cb cut the same spans and enter Insert
Word counts before/after the operator multiply: d2w, 2cw, 2c3b; newlines are preserved