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
+3 -3
View File
@@ -170,13 +170,13 @@ The last recipe is session-wide across buffers, but pending Insert groups and un
## Search ## 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. `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 ## Commands, saving, and pasted input
+65 -28
View File
@@ -6490,6 +6490,18 @@ impl Editor {
pub fn search_match(&self) -> Option<SearchMatch> { pub fn search_match(&self) -> Option<SearchMatch> {
self.active.view.search_match 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. /// Whole-row shape belongs to the active buffer, not the Visual binding context.
pub fn visual_linewise(&self) -> bool { pub fn visual_linewise(&self) -> bool {
self.active.view.mode == Mode::Visual && self.active.view.linewise self.active.view.mode == Mode::Visual && self.active.view.linewise
@@ -8412,7 +8424,7 @@ impl Editor {
None None
} }
fn set_word_search(&mut self) -> Option<Cursor> { fn set_word_search(&mut self) -> Option<(Cursor, bool)> {
let cursor = self.cursor(); let cursor = self.cursor();
let line = &self.active.lines[cursor.row]; let line = &self.active.lines[cursor.row];
let origin = grapheme_byte(line, cursor.column); let origin = grapheme_byte(line, cursor.column);
@@ -8438,25 +8450,41 @@ impl Editor {
let end = tokens() let end = tokens()
.find(|(position, next)| *position > byte && *next != kind) .find(|(position, next)| *position > byte && *next != kind)
.map_or(line.len(), |(position, _)| position); .map_or(line.len(), |(position, _)| position);
self.active.view.search_query = line[start..end].into(); let query = &line[start..end];
self.active.view.search_whole_word = kind == WordKind::Keyword; let whole_word = kind == WordKind::Keyword;
Some(Cursor { let newly_active = self.active.view.search_query != query
row: cursor.row, || self.active.view.search_whole_word != whole_word;
column: column_after_byte(line, start), 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) { fn seek_search(&mut self, action: Action, count: usize) {
self.reveal_caret(); self.reveal_caret();
let source = self.jump_location();
let primary_origin = if action == Action::SearchWord { let primary_origin = if action == Action::SearchWord {
Some(match self.set_word_search() { let Some((origin, newly_active)) = self.set_word_search() else {
Some(origin) => origin, return;
None => 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 { } else {
None None
}; };
let source = self.jump_location();
let forward = action != Action::SearchPrevious; let forward = action != Action::SearchPrevious;
self.active.view.search_match = None; self.active.view.search_match = None;
if self.active.view.search_query.is_empty() { if self.active.view.search_query.is_empty() {
@@ -8470,22 +8498,7 @@ impl Editor {
.navigable_rows(0, self.active.lines.len()) .navigable_rows(0, self.active.lines.len())
.flat_map(|row| { .flat_map(|row| {
let line = &self.active.lines[row]; let line = &self.active.lines[row];
line.grapheme_indices(true).filter_map(move |(byte, _)| { search_match_bytes(line, query, whole_word).map(move |byte| (row, 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))
})
}) })
.collect(); .collect();
if matches.is_empty() { if matches.is_empty() {
@@ -11927,6 +11940,30 @@ enum WordKind {
EmptyLine, 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 { fn word_kind(grapheme: &str, big: bool) -> WordKind {
let ch = grapheme.chars().next().expect("graphemes are nonempty"); let ch = grapheme.chars().next().expect("graphemes are nonempty");
if ch.is_whitespace() { 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); 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( paint_cells(
frame, frame,
area, area,
@@ -18877,6 +18877,73 @@ end
assert_ne!(buffer[(6, 0)].bg, Color::Blue); 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] #[test]
fn active_search_highlight_uses_display_cells_and_clips_with_horizontal_scroll() { fn active_search_highlight_uses_display_cells_and_clips_with_horizontal_scroll() {
let mut editor = Editor::open(None, vec![]).unwrap(); let mut editor = Editor::open(None, vec![]).unwrap();
+40 -1
View File
@@ -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] #[test]
fn star_search_keeps_whole_word_counts_history_and_literal_search_distinct() { fn star_search_keeps_whole_word_counts_history_and_literal_search_distinct() {
let fixture = Fixture::new(); let fixture = Fixture::new();
let text = "cat\nscatter\ncat_cat\ncat2\n2cat\nCat\ncat\ncat\n\n"; let text = "cat\nscatter\ncat_cat\ncat2\n2cat\nCat\ncat\ncat\n\n";
let mut editor = fixture.editor(text); 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.cursor(), Cursor { row: 6, column: 0 });
assert_eq!(editor.search_match().unwrap().end_column, 3); assert_eq!(editor.search_match().unwrap().end_column, 3);
keys(&mut editor, "nN2*"); 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); let mut editor = fixture.editor(text);
keys(&mut editor, positioning); keys(&mut editor, positioning);
let origin = editor.cursor();
editor.handle(Input::Char('*'));
assert_eq!(editor.cursor(), origin, "{text:?}");
editor.handle(Input::Char('*')); editor.handle(Input::Char('*'));
assert_eq!(editor.cursor(), Cursor { row, column: 0 }, "{text:?}"); assert_eq!(editor.cursor(), Cursor { row, column: 0 }, "{text:?}");
assert_eq!( assert_eq!(
@@ -191,6 +217,11 @@ fn star_search_rebinding_moves_all_carets_and_remains_view_local() {
); );
session.handle(Input::Ctrl('e')); session.handle(Input::Ctrl('e'));
session_keys(&mut session, "2q"); session_keys(&mut session, "2q");
assert_eq!(
session.editor().carets(),
[Cursor::default(), Cursor { row: 1, column: 0 }]
);
session_keys(&mut session, "2q");
assert_eq!( assert_eq!(
session.editor().carets(), session.editor().carets(),
[Cursor { row: 0, column: 16 }, Cursor { row: 1, column: 16 }] [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, "*"); session_keys(&mut session, "*");
assert_eq!(
session.editor().cursor(),
Cursor {
row: 19,
column: second_x
}
);
session_keys(&mut session, "*");
assert_eq!( assert_eq!(
session.editor().cursor(), session.editor().cursor(),
Cursor { Cursor {