fix: highlight all search matches before word navigation
This commit is contained in:
+3
-3
@@ -170,13 +170,13 @@ The last recipe is session-wide across buffers, but pending Insert groups and un
|
||||
|
||||
## Search
|
||||
|
||||
Search is case-sensitive and single-line, with no regular expressions or Unicode normalization. Matches start and end on whole grapheme boundaries, including combining marks and emoji. Next/previous navigation skips the current position and wraps through the buffer; a sole match wraps back to itself. The active match is highlighted yellow.
|
||||
Search is case-sensitive and single-line, with no regular expressions or Unicode normalization. Matches start and end on whole grapheme boundaries, including combining marks and emoji. Next/previous navigation skips the current position and wraps through the buffer; a sole match wraps back to itself. All occurrences of the active query are highlighted yellow.
|
||||
|
||||
Normal `*` searches forward for the primary caret's word and highlights the destination in one action. It uses the same Unicode letters/digits/underscore boundaries as `w`, so `cat` skips `scatter`, `cat_cat`, and `cat2`. If the caret is not on a keyword, it takes the next keyword on that line; with none, it uses the punctuation run under or after the caret as a literal substring. A blank remainder reports no word and preserves the previous search. Counts such as `2*` skip to the requested occurrence; every caret searches the primary word from its own position. This also works in read-only diff text. Bind `config.core.search_word` to remap it.
|
||||
Normal `*` highlights the primary caret's word without moving any caret when it selects a new query or changes the matching mode. Pressing `*` again for the same active word navigates forward; counts such as `2*` then skip to the requested occurrence, with every caret searching the primary word from its own position. Selecting a different word starts a new highlight-only search. It uses the same Unicode letters/digits/underscore boundaries as `w`, so `cat` skips `scatter`, `cat_cat`, and `cat2`. If the caret is not on a keyword, it takes the next keyword on that line; with none, it uses the punctuation run under or after the caret as a literal substring. A blank remainder reports no word and preserves the previous search. This also works in read-only diff text. Bind `config.core.search_word` to remap it.
|
||||
|
||||
`n`/`N` and an empty `/` submission retain the whole-word setting; a nonempty `/` query returns to literal substring search. Cancelling `/` preserves both the previous query and its matching behavior.
|
||||
|
||||
Submitting an empty search repeats the last query. Esc cancels without moving the cursor or replacing the previous query/match. A missing match reports the query, clears the highlight, and leaves the cursor unchanged; searching without a previous query also leaves the cursor unchanged. Search never edits text or adds undo history. Buffer edits and undo/redo clear the highlight but retain the query, so subsequent navigation searches the current text again.
|
||||
Submitting an empty search repeats the last query. Esc cancels without moving the cursor or replacing the previous query/match. A missing match reports the query and leaves the cursor unchanged; searching without a previous query also leaves the cursor unchanged. Search never edits text or adds undo history. Buffer edits and undo/redo retain the query and recompute its visible highlights, so subsequent navigation searches the current text again.
|
||||
|
||||
## Commands, saving, and pasted input
|
||||
|
||||
|
||||
+63
-26
@@ -6490,6 +6490,18 @@ impl Editor {
|
||||
pub fn search_match(&self) -> Option<SearchMatch> {
|
||||
self.active.view.search_match
|
||||
}
|
||||
pub fn search_matches_on_row(&self, row: usize) -> impl Iterator<Item = SearchMatch> + '_ {
|
||||
let query = self.active.view.search_query.as_str();
|
||||
let line = &self.active.lines[row];
|
||||
let columns = grapheme_count(query);
|
||||
search_match_bytes(line, query, self.active.view.search_whole_word).map(move |byte| {
|
||||
let column = column_after_byte(line, byte);
|
||||
SearchMatch {
|
||||
start: Cursor { row, column },
|
||||
end_column: column + columns,
|
||||
}
|
||||
})
|
||||
}
|
||||
/// Whole-row shape belongs to the active buffer, not the Visual binding context.
|
||||
pub fn visual_linewise(&self) -> bool {
|
||||
self.active.view.mode == Mode::Visual && self.active.view.linewise
|
||||
@@ -8412,7 +8424,7 @@ impl Editor {
|
||||
None
|
||||
}
|
||||
|
||||
fn set_word_search(&mut self) -> Option<Cursor> {
|
||||
fn set_word_search(&mut self) -> Option<(Cursor, bool)> {
|
||||
let cursor = self.cursor();
|
||||
let line = &self.active.lines[cursor.row];
|
||||
let origin = grapheme_byte(line, cursor.column);
|
||||
@@ -8438,25 +8450,41 @@ impl Editor {
|
||||
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 {
|
||||
let query = &line[start..end];
|
||||
let whole_word = kind == WordKind::Keyword;
|
||||
let newly_active = self.active.view.search_query != query
|
||||
|| self.active.view.search_whole_word != whole_word;
|
||||
if newly_active {
|
||||
self.active.view.search_query = query.into();
|
||||
self.active.view.search_whole_word = whole_word;
|
||||
}
|
||||
Some((
|
||||
Cursor {
|
||||
row: cursor.row,
|
||||
column: column_after_byte(line, start),
|
||||
})
|
||||
},
|
||||
newly_active,
|
||||
))
|
||||
}
|
||||
|
||||
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,
|
||||
})
|
||||
let Some((origin, newly_active)) = self.set_word_search() else {
|
||||
return;
|
||||
};
|
||||
if newly_active {
|
||||
self.active.view.search_match = Some(SearchMatch {
|
||||
start: origin,
|
||||
end_column: origin.column + grapheme_count(&self.active.view.search_query),
|
||||
});
|
||||
return;
|
||||
}
|
||||
Some(origin)
|
||||
} else {
|
||||
None
|
||||
};
|
||||
let source = self.jump_location();
|
||||
let forward = action != Action::SearchPrevious;
|
||||
self.active.view.search_match = None;
|
||||
if self.active.view.search_query.is_empty() {
|
||||
@@ -8470,22 +8498,7 @@ impl Editor {
|
||||
.navigable_rows(0, self.active.lines.len())
|
||||
.flat_map(|row| {
|
||||
let line = &self.active.lines[row];
|
||||
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")
|
||||
&& (!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))
|
||||
})
|
||||
search_match_bytes(line, query, whole_word).map(move |byte| (row, byte))
|
||||
})
|
||||
.collect();
|
||||
if matches.is_empty() {
|
||||
@@ -11927,6 +11940,30 @@ enum WordKind {
|
||||
EmptyLine,
|
||||
}
|
||||
|
||||
fn search_match_bytes<'a>(
|
||||
line: &'a str,
|
||||
query: &'a str,
|
||||
whole_word: bool,
|
||||
) -> impl Iterator<Item = usize> + 'a {
|
||||
let line = if query.is_empty() { "" } else { 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")
|
||||
&& (!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(byte)
|
||||
})
|
||||
}
|
||||
|
||||
fn word_kind(grapheme: &str, big: bool) -> WordKind {
|
||||
let ch = grapheme.chars().next().expect("graphemes are nonempty");
|
||||
if ch.is_whitespace() {
|
||||
|
||||
+68
-1
@@ -3122,7 +3122,7 @@ fn paint_editor_line(
|
||||
paint_grapheme_range(frame, area, left, line, start..end, style);
|
||||
}
|
||||
}
|
||||
if let Some(found) = editor.search_match().filter(|found| found.start.row == row) {
|
||||
for found in editor.search_matches_on_row(row) {
|
||||
paint_cells(
|
||||
frame,
|
||||
area,
|
||||
@@ -18877,6 +18877,73 @@ end
|
||||
assert_ne!(buffer[(6, 0)].bg, Color::Blue);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn search_highlights_all_occurrences_without_moving_on_first_star() {
|
||||
let mut editor = Editor::open(None, vec![]).unwrap();
|
||||
editor.set_gutter_style(crate::gutter::GutterStyle::None);
|
||||
configure_chrome(&mut editor, |top, bottom| {
|
||||
for bar in [top, bottom] {
|
||||
bar.left.clear();
|
||||
bar.center.clear();
|
||||
bar.right.clear();
|
||||
}
|
||||
});
|
||||
editor
|
||||
.apply(vec![crate::editor::Effect::Replace(
|
||||
"cat cat_cat cat\ncat".into(),
|
||||
)])
|
||||
.unwrap();
|
||||
editor.handle(Input::Char('*'));
|
||||
assert_eq!(editor.cursor(), Cursor::default());
|
||||
let mut terminal = Terminal::new(ratatui::backend::TestBackend::new(30, 6)).unwrap();
|
||||
let (mut top, mut left) = (0, 0);
|
||||
let mut syntax = SyntaxCache::new();
|
||||
terminal
|
||||
.draw(|frame| {
|
||||
draw(
|
||||
frame,
|
||||
&editor,
|
||||
&mut top,
|
||||
&mut left,
|
||||
&mut syntax,
|
||||
&mut DocumentViewport::default(),
|
||||
&mut WheelPanels::default(),
|
||||
);
|
||||
})
|
||||
.unwrap();
|
||||
let buffer = terminal.backend().buffer();
|
||||
for (row, start, end) in [(0, 0, 3), (0, 12, 15), (1, 0, 3)] {
|
||||
for column in start..end {
|
||||
assert_eq!(buffer[(column + 2, row)].bg, Color::Yellow);
|
||||
}
|
||||
}
|
||||
assert_ne!(buffer[(6, 0)].bg, Color::Yellow);
|
||||
editor.handle(Input::Char('/'));
|
||||
for ch in "cat".chars() {
|
||||
editor.handle(Input::Char(ch));
|
||||
}
|
||||
editor.handle(Input::Enter);
|
||||
terminal
|
||||
.draw(|frame| {
|
||||
draw(
|
||||
frame,
|
||||
&editor,
|
||||
&mut top,
|
||||
&mut left,
|
||||
&mut syntax,
|
||||
&mut DocumentViewport::default(),
|
||||
&mut WheelPanels::default(),
|
||||
);
|
||||
})
|
||||
.unwrap();
|
||||
for column in [0, 4, 8, 12] {
|
||||
assert_eq!(
|
||||
terminal.backend().buffer()[(column + 2, 0)].bg,
|
||||
Color::Yellow
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn active_search_highlight_uses_display_cells_and_clips_with_horizontal_scroll() {
|
||||
let mut editor = Editor::open(None, vec![]).unwrap();
|
||||
|
||||
+40
-1
@@ -120,12 +120,35 @@ fn wait_until(session: &mut EditorSession, ready: impl Fn(&EditorSession) -> boo
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn star_search_arms_without_moving_then_navigates_and_rearms_for_a_new_word_or_mode() {
|
||||
let fixture = Fixture::new();
|
||||
let mut editor = fixture.editor("cat dog cat dog");
|
||||
keys(&mut editor, "l2*");
|
||||
assert_eq!(editor.cursor(), Cursor { row: 0, column: 1 });
|
||||
assert_eq!(editor.search_match().unwrap().start, Cursor::default());
|
||||
keys(&mut editor, "*");
|
||||
assert_eq!(editor.cursor(), Cursor { row: 0, column: 8 });
|
||||
keys(&mut editor, "w*");
|
||||
assert_eq!(editor.cursor(), Cursor { row: 0, column: 12 });
|
||||
keys(&mut editor, "*");
|
||||
assert_eq!(editor.cursor(), Cursor { row: 0, column: 4 });
|
||||
|
||||
keys(&mut editor, "/cat");
|
||||
editor.handle(Input::Enter);
|
||||
assert_eq!(editor.cursor(), Cursor { row: 0, column: 8 });
|
||||
keys(&mut editor, "*");
|
||||
assert_eq!(editor.cursor(), Cursor { row: 0, column: 8 });
|
||||
keys(&mut editor, "*");
|
||||
assert_eq!(editor.cursor(), Cursor::default());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn star_search_keeps_whole_word_counts_history_and_literal_search_distinct() {
|
||||
let fixture = Fixture::new();
|
||||
let text = "cat\nscatter\ncat_cat\ncat2\n2cat\nCat\ncat\ncat\n\n";
|
||||
let mut editor = fixture.editor(text);
|
||||
keys(&mut editor, "l*");
|
||||
keys(&mut editor, "l**");
|
||||
assert_eq!(editor.cursor(), Cursor { row: 6, column: 0 });
|
||||
assert_eq!(editor.search_match().unwrap().end_column, 3);
|
||||
keys(&mut editor, "nN2*");
|
||||
@@ -169,6 +192,9 @@ fn star_search_uses_unicode_words_and_vim_same_line_fallbacks() {
|
||||
] {
|
||||
let mut editor = fixture.editor(text);
|
||||
keys(&mut editor, positioning);
|
||||
let origin = editor.cursor();
|
||||
editor.handle(Input::Char('*'));
|
||||
assert_eq!(editor.cursor(), origin, "{text:?}");
|
||||
editor.handle(Input::Char('*'));
|
||||
assert_eq!(editor.cursor(), Cursor { row, column: 0 }, "{text:?}");
|
||||
assert_eq!(
|
||||
@@ -191,6 +217,11 @@ fn star_search_rebinding_moves_all_carets_and_remains_view_local() {
|
||||
);
|
||||
session.handle(Input::Ctrl('e'));
|
||||
session_keys(&mut session, "2q");
|
||||
assert_eq!(
|
||||
session.editor().carets(),
|
||||
[Cursor::default(), Cursor { row: 1, column: 0 }]
|
||||
);
|
||||
session_keys(&mut session, "2q");
|
||||
assert_eq!(
|
||||
session.editor().carets(),
|
||||
[Cursor { row: 0, column: 16 }, Cursor { row: 1, column: 16 }]
|
||||
@@ -439,6 +470,14 @@ fn readonly_before_and_historical_after_seeks_preserve_snapshots_and_folded_exce
|
||||
}
|
||||
);
|
||||
session_keys(&mut session, "*");
|
||||
assert_eq!(
|
||||
session.editor().cursor(),
|
||||
Cursor {
|
||||
row: 19,
|
||||
column: second_x
|
||||
}
|
||||
);
|
||||
session_keys(&mut session, "*");
|
||||
assert_eq!(
|
||||
session.editor().cursor(),
|
||||
Cursor {
|
||||
|
||||
Reference in New Issue
Block a user