feat: add Vim-style word search
This commit is contained in:
+84
-15
@@ -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
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user