From adc44d22db928e3a7c0724402cad051c580d6fac Mon Sep 17 00:00:00 2001 From: Valdemar Lorenzen Date: Thu, 15 Oct 2026 12:21:17 +0200 Subject: [PATCH] fix: clear search highlighting on Normal escape --- docs/editing.md | 2 ++ src/editor.rs | 22 +++++++++++++++++++--- tests/navigation.rs | 30 ++++++++++++++++++++++++++++++ 3 files changed, 51 insertions(+), 3 deletions(-) diff --git a/docs/editing.md b/docs/editing.md index c0e8d5a..d7163eb 100644 --- a/docs/editing.md +++ b/docs/editing.md @@ -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. +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. ## Commands, saving, and pasted input diff --git a/src/editor.rs b/src/editor.rs index d5df50b..a8ce008 100644 --- a/src/editor.rs +++ b/src/editor.rs @@ -1575,6 +1575,7 @@ struct ViewState { linewise: bool, search_query: String, search_whole_word: bool, + search_highlight: bool, search_match: Option, command_line: String, caret_reveal_revision: u64, @@ -1594,6 +1595,7 @@ impl ViewState { linewise: false, search_query: String::new(), search_whole_word: false, + search_highlight: false, search_match: None, command_line: String::new(), caret_reveal_revision: 0, @@ -1613,6 +1615,7 @@ impl ViewState { linewise: self.linewise, search_query: self.search_query.clone(), search_whole_word: self.search_whole_word, + search_highlight: self.search_highlight, search_match: self.search_match, command_line: self.command_line.clone(), caret_reveal_revision: self.caret_reveal_revision, @@ -6491,7 +6494,11 @@ impl Editor { self.active.view.search_match } pub fn search_matches_on_row(&self, row: usize) -> impl Iterator + '_ { - 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 columns = grapheme_count(query); 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.notification_visible = false; + self.active.view.search_highlight = false; + self.active.view.search_match = None; return None; } if matches!(self.active.view.mode, Mode::Normal | Mode::Visual) @@ -8379,6 +8388,8 @@ impl Editor { } Action::Normal => { if self.active.view.mode == Mode::Normal { + self.active.view.search_highlight = false; + self.active.view.search_match = None; self.collapse_primary(); } else if self.active.view.mode == Mode::Insert { self.finish_insert(); @@ -8452,11 +8463,15 @@ impl Editor { .map_or(line.len(), |(position, _)| position); let query = &line[start..end]; 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; if newly_active { - self.active.view.search_query = query.into(); + if self.active.view.search_query != query { + self.active.view.search_query = query.into(); + } self.active.view.search_whole_word = whole_word; + self.active.view.search_highlight = true; } Some(( Cursor { @@ -8491,6 +8506,7 @@ impl Editor { self.set_message("No previous search"); return; } + self.active.view.search_highlight = true; 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. diff --git a/tests/navigation.rs b/tests/navigation.rs index f6bb94c..307bc92 100644 --- a/tests/navigation.rs +++ b/tests/navigation.rs @@ -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] fn star_search_arms_without_moving_then_navigates_and_rearms_for_a_new_word_or_mode() { let fixture = Fixture::new();