fix: clear search highlighting on Normal escape

This commit is contained in:
Valdemar Lorenzen
2026-10-15 12:21:17 +02:00
parent de62467208
commit adc44d22db
3 changed files with 51 additions and 3 deletions
+2
View File
@@ -176,6 +176,8 @@ Normal `*` highlights the primary caret's word without moving any caret when 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.
Normal-mode Escape hides search highlights without forgetting the query or matching mode. The next `*` re-highlights without moving; `n`/`N` or submitting `/` resumes navigation and restores highlighting. Escape used to cancel a prompt, selection, or pending motion keeps its existing 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 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. 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
+18 -2
View File
@@ -1575,6 +1575,7 @@ struct ViewState {
linewise: bool, linewise: bool,
search_query: String, search_query: String,
search_whole_word: bool, search_whole_word: bool,
search_highlight: bool,
search_match: Option<SearchMatch>, search_match: Option<SearchMatch>,
command_line: String, command_line: String,
caret_reveal_revision: u64, caret_reveal_revision: u64,
@@ -1594,6 +1595,7 @@ impl ViewState {
linewise: false, linewise: false,
search_query: String::new(), search_query: String::new(),
search_whole_word: false, search_whole_word: false,
search_highlight: false,
search_match: None, search_match: None,
command_line: String::new(), command_line: String::new(),
caret_reveal_revision: 0, caret_reveal_revision: 0,
@@ -1613,6 +1615,7 @@ impl ViewState {
linewise: self.linewise, linewise: self.linewise,
search_query: self.search_query.clone(), search_query: self.search_query.clone(),
search_whole_word: self.search_whole_word, search_whole_word: self.search_whole_word,
search_highlight: self.search_highlight,
search_match: self.search_match, search_match: self.search_match,
command_line: self.command_line.clone(), command_line: self.command_line.clone(),
caret_reveal_revision: self.caret_reveal_revision, caret_reveal_revision: self.caret_reveal_revision,
@@ -6491,7 +6494,11 @@ impl Editor {
self.active.view.search_match self.active.view.search_match
} }
pub fn search_matches_on_row(&self, row: usize) -> impl Iterator<Item = SearchMatch> + '_ { pub fn search_matches_on_row(&self, row: usize) -> impl Iterator<Item = SearchMatch> + '_ {
let query = self.active.view.search_query.as_str(); let query = if self.active.view.search_highlight {
self.active.view.search_query.as_str()
} else {
""
};
let line = &self.active.lines[row]; let line = &self.active.lines[row];
let columns = grapheme_count(query); let columns = grapheme_count(query);
search_match_bytes(line, query, self.active.view.search_whole_word).map(move |byte| { search_match_bytes(line, query, self.active.view.search_whole_word).map(move |byte| {
@@ -7934,6 +7941,8 @@ impl Editor {
&& (!self.message.is_empty() || self.diagnostic_at_cursor().is_some()) && (!self.message.is_empty() || self.diagnostic_at_cursor().is_some())
{ {
self.notification_visible = false; self.notification_visible = false;
self.active.view.search_highlight = false;
self.active.view.search_match = None;
return None; return None;
} }
if matches!(self.active.view.mode, Mode::Normal | Mode::Visual) if matches!(self.active.view.mode, Mode::Normal | Mode::Visual)
@@ -8379,6 +8388,8 @@ impl Editor {
} }
Action::Normal => { Action::Normal => {
if self.active.view.mode == Mode::Normal { if self.active.view.mode == Mode::Normal {
self.active.view.search_highlight = false;
self.active.view.search_match = None;
self.collapse_primary(); self.collapse_primary();
} else if self.active.view.mode == Mode::Insert { } else if self.active.view.mode == Mode::Insert {
self.finish_insert(); self.finish_insert();
@@ -8452,11 +8463,15 @@ impl Editor {
.map_or(line.len(), |(position, _)| position); .map_or(line.len(), |(position, _)| position);
let query = &line[start..end]; let query = &line[start..end];
let whole_word = kind == WordKind::Keyword; let whole_word = kind == WordKind::Keyword;
let newly_active = self.active.view.search_query != query let newly_active = !self.active.view.search_highlight
|| self.active.view.search_query != query
|| self.active.view.search_whole_word != whole_word; || self.active.view.search_whole_word != whole_word;
if newly_active { if newly_active {
if self.active.view.search_query != query {
self.active.view.search_query = query.into(); self.active.view.search_query = query.into();
}
self.active.view.search_whole_word = whole_word; self.active.view.search_whole_word = whole_word;
self.active.view.search_highlight = true;
} }
Some(( Some((
Cursor { Cursor {
@@ -8491,6 +8506,7 @@ impl Editor {
self.set_message("No previous search"); self.set_message("No previous search");
return; return;
} }
self.active.view.search_highlight = true;
let query = self.active.view.search_query.as_str(); let query = self.active.view.search_query.as_str();
let whole_word = self.active.view.search_whole_word; let whole_word = self.active.view.search_whole_word;
// ponytail: O(buffer graphemes × query bytes) scan; index matches if large buffers need it. // ponytail: O(buffer graphemes × query bytes) scan; index matches if large buffers need it.
+30
View File
@@ -120,6 +120,36 @@ fn wait_until(session: &mut EditorSession, ready: impl Fn(&EditorSession) -> boo
} }
} }
#[test]
fn normal_escape_hides_highlights_but_retains_search_and_rearms_star() {
let fixture = Fixture::new();
let mut editor = fixture.editor("cat cat");
keys(&mut editor, "*");
assert_eq!(editor.search_matches_on_row(0).count(), 2);
editor.handle(Input::Escape);
assert_eq!(editor.search_matches_on_row(0).count(), 0);
assert_eq!(editor.search_match(), None);
assert_eq!(editor.cursor(), Cursor::default());
keys(&mut editor, "*");
assert_eq!(editor.cursor(), Cursor::default());
assert_eq!(editor.search_matches_on_row(0).count(), 2);
keys(&mut editor, "*");
assert_eq!(editor.cursor(), Cursor { row: 0, column: 4 });
editor.handle(Input::Escape);
keys(&mut editor, "n");
assert_eq!(editor.cursor(), Cursor::default());
assert_eq!(editor.search_matches_on_row(0).count(), 2);
keys(&mut editor, "/missing");
editor.handle(Input::Escape);
assert_eq!(editor.search_matches_on_row(0).count(), 2);
editor.handle(Input::Escape);
editor.handle(Input::Char('/'));
editor.handle(Input::Enter);
assert_eq!(editor.cursor(), Cursor { row: 0, column: 4 });
assert_eq!(editor.search_matches_on_row(0).count(), 2);
assert!(!editor.dirty());
}
#[test] #[test]
fn star_search_arms_without_moving_then_navigates_and_rearms_for_a_new_word_or_mode() { fn star_search_arms_without_moving_then_navigates_and_rearms_for_a_new_word_or_mode() {
let fixture = Fixture::new(); let fixture = Fixture::new();