diff --git a/docs/editing.md b/docs/editing.md index c81311c..c0e8d5a 100644 --- a/docs/editing.md +++ b/docs/editing.md @@ -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 diff --git a/src/editor.rs b/src/editor.rs index 3ed6993..d5df50b 100644 --- a/src/editor.rs +++ b/src/editor.rs @@ -6490,6 +6490,18 @@ impl Editor { pub fn search_match(&self) -> Option { 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 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 { + 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 + '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() { diff --git a/src/tui.rs b/src/tui.rs index 5679108..9e24f2e 100644 --- a/src/tui.rs +++ b/src/tui.rs @@ -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(); diff --git a/tests/navigation.rs b/tests/navigation.rs index e4339e0..f6bb94c 100644 --- a/tests/navigation.rs +++ b/tests/navigation.rs @@ -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 {