fix: highlight all search matches before word navigation

This commit is contained in:
Valdemar Lorenzen
2026-10-15 12:12:05 +02:00
parent 7b3b3866fd
commit de62467208
4 changed files with 176 additions and 33 deletions
+65 -28
View File
@@ -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 {
row: cursor.row,
column: column_after_byte(line, start),
})
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
View File
@@ -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();