fix: highlight all search matches before word navigation
This commit is contained in:
+3
-3
@@ -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
|
||||||
|
|
||||||
|
|||||||
+63
-26
@@ -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
|
||||||
|
|| 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,
|
row: cursor.row,
|
||||||
column: column_after_byte(line, start),
|
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
@@ -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
@@ -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 {
|
||||||
|
|||||||
Reference in New Issue
Block a user