regex-search: TUI regex mode + multi-line wash (Q#RX3/RX4/RX5)
Wires regex matching into the search session and the terminal frontend. SearchSession gains `regex` and `invalid` flags. search_begin takes a `regex` argument; recompute dispatches find_all_regex (regex) vs find_all (literal), recording `invalid` when the pattern won't compile (an invalid pattern clears the matches and shows [invalid] rather than a stale count). search_toggle_regex flips the mode and re-runs the current query. Input: C-M-s / C-M-r start a regex search (search.forward-regex / search.backward-regex commands → ed.search_start(forward, regex)). M-r toggles literal <-> regex mid-search — a new SearchKey decoded in dispatch_search_key, so it works the same in both frontends (the GUI already round-trips every key while searching). The TUI prompt reads "Regex I-search:" in regex mode and "[invalid]" when the pattern won't compile. Multi-line: SearchView now washes each row a match spans, mirroring paint_local_selection's per-row clip (newline excluded so a spanning match doesn't paint a phantom trailing cell). Single-line matches — every literal match — touch exactly one row, unchanged. The GPU already fans multi-line ranges per-line, so it needs no change here. Tests: regex match / smart-case / invalid-flags-and-recovers / toggle-reinterprets-query (core); C-M-s starts regex + M-r toggles mid-search (dispatch); multi-line per-row wash (SearchView render). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
5c34b3c37a
commit
7723f51f12
|
|
@ -122,15 +122,23 @@ cmd { name = "buffer.self-insert",
|
|||
-- C-s / C-r begin a live in-buffer isearch: the match under the cursor
|
||||
-- highlights as you type, the same key steps to the next/previous
|
||||
-- match, RET accepts (keeping the highlights until the next edit), and
|
||||
-- C-g / Esc restore the pre-search cursor. While a search is running
|
||||
-- every keystroke is intercepted in Rust (dispatch_search_key), so
|
||||
-- these commands only run to *start* a search from an idle keymap.
|
||||
-- C-g / Esc restore the pre-search cursor. C-M-s / C-M-r start a regex
|
||||
-- search, and M-r toggles literal <-> regex mid-search (handled in
|
||||
-- Rust). While a search is running every keystroke is intercepted in
|
||||
-- Rust (dispatch_search_key), so these commands only run to *start* a
|
||||
-- search from an idle keymap. ed.search_start(forward, regex).
|
||||
cmd { name = "search.forward",
|
||||
description = "Start an incremental search forward from the cursor.",
|
||||
fn = function() ed.search_start(true) end }
|
||||
fn = function() ed.search_start(true, false) end }
|
||||
cmd { name = "search.backward",
|
||||
description = "Start an incremental search backward from the cursor.",
|
||||
fn = function() ed.search_start(false) end }
|
||||
fn = function() ed.search_start(false, false) end }
|
||||
cmd { name = "search.forward-regex",
|
||||
description = "Start an incremental regex search forward from the cursor.",
|
||||
fn = function() ed.search_start(true, true) end }
|
||||
cmd { name = "search.backward-regex",
|
||||
description = "Start an incremental regex search backward from the cursor.",
|
||||
fn = function() ed.search_start(false, true) end }
|
||||
|
||||
-- History --------------------------------------------------------------------
|
||||
|
||||
|
|
|
|||
|
|
@ -59,8 +59,12 @@ bind("TAB", "buffer.tab")
|
|||
-- adds isearch without colliding with the CUA / Emacs editing keys.
|
||||
-- Once a search is running, C-s / C-r step to the next / previous
|
||||
-- match; that interception happens in Rust, so it needs no binding.
|
||||
-- C-M-s / C-M-r start a regex search (Emacs isearch-forward-regexp);
|
||||
-- M-r toggles literal <-> regex mid-search (intercepted in Rust).
|
||||
bind("C-s", "search.forward")
|
||||
bind("C-r", "search.backward")
|
||||
bind("C-M-s", "search.forward-regex")
|
||||
bind("C-M-r", "search.backward-regex")
|
||||
|
||||
-- CUA-style word-level deletion (the same shortcuts users expect from
|
||||
-- IDEs, browsers, terminals on Linux/Windows). C-BS deletes back to
|
||||
|
|
|
|||
|
|
@ -641,6 +641,7 @@ impl EditorState {
|
|||
/// * `RET` --- accept (keep cursor + highlights).
|
||||
/// * `C-g` / `Esc` --- cancel (restore origin cursor).
|
||||
/// * `BS` --- shorten the query by one char.
|
||||
/// * `M-r` --- toggle literal ↔ regex (Q#RX3).
|
||||
/// * a printable char --- extend the query.
|
||||
///
|
||||
/// Unrecognized chords are swallowed (an active isearch eats every
|
||||
|
|
@ -654,6 +655,7 @@ impl EditorState {
|
|||
SearchKey::Accept => self.core.borrow_mut().search_finish(true),
|
||||
SearchKey::Cancel => self.core.borrow_mut().search_finish(false),
|
||||
SearchKey::Backspace => self.core.borrow_mut().search_backspace(),
|
||||
SearchKey::ToggleRegex => self.core.borrow_mut().search_toggle_regex(),
|
||||
SearchKey::Insert(ch) => self.core.borrow_mut().search_input_char(ch),
|
||||
SearchKey::Ignore => {}
|
||||
}
|
||||
|
|
@ -1221,6 +1223,8 @@ enum SearchKey {
|
|||
Cancel,
|
||||
/// Shorten the query by one character (BS).
|
||||
Backspace,
|
||||
/// Toggle literal ↔ regex matching (M-r; Q#RX3).
|
||||
ToggleRegex,
|
||||
/// Extend the query with a printable character.
|
||||
Insert(char),
|
||||
/// Unhandled --- swallowed without complaint.
|
||||
|
|
@ -1260,6 +1264,14 @@ impl SearchKey {
|
|||
_ => Self::Ignore,
|
||||
};
|
||||
}
|
||||
// M-r toggles regex mode (Q#RX3). Alt-only chord, distinct from
|
||||
// the C-r (previous-match) above.
|
||||
if alt
|
||||
&& !ctrl
|
||||
&& let KeyCode::Char('r') = chord.code
|
||||
{
|
||||
return Self::ToggleRegex;
|
||||
}
|
||||
Self::Ignore
|
||||
}
|
||||
}
|
||||
|
|
@ -1726,24 +1738,28 @@ fn paint_minibuffer(
|
|||
|
||||
/// Paint the incremental-search prompt on the bottom row:
|
||||
/// `I-search: <query> (n/m)`. Backward searches read `I-search
|
||||
/// backward:`; a non-empty query with no matches reads `[no match]`.
|
||||
/// Overwrites the status line painted just before it. The terminal
|
||||
/// cursor is *not* returned here — it stays in the buffer at the
|
||||
/// active match (see [`paint_frame`]).
|
||||
/// backward:`; regex searches prefix `Regex `; a non-empty query with
|
||||
/// no matches reads `[no match]`, and an uncompilable regex reads
|
||||
/// `[invalid]`. Overwrites the status line painted just before it. The
|
||||
/// terminal cursor is *not* returned here — it stays in the buffer at
|
||||
/// the active match (see [`paint_frame`]).
|
||||
fn paint_search_prompt(
|
||||
grid: &mut crate::cell::CellGrid<'_>,
|
||||
core: &EditorCore,
|
||||
term_size: crate::cell::CellSize,
|
||||
) {
|
||||
let prompt = if core.search_forward() {
|
||||
"I-search: "
|
||||
} else {
|
||||
"I-search backward: "
|
||||
let prompt = match (core.search_is_regex(), core.search_forward()) {
|
||||
(false, true) => "I-search: ",
|
||||
(false, false) => "I-search backward: ",
|
||||
(true, true) => "Regex I-search: ",
|
||||
(true, false) => "Regex I-search backward: ",
|
||||
};
|
||||
let query = core.search_query();
|
||||
let (active, total) = core.search_match_summary();
|
||||
let suffix = if query.is_empty() {
|
||||
String::new()
|
||||
} else if core.search_is_invalid() {
|
||||
" [invalid]".to_string()
|
||||
} else if total == 0 {
|
||||
" [no match]".to_string()
|
||||
} else {
|
||||
|
|
@ -2106,6 +2122,40 @@ mod tests {
|
|||
assert_eq!(s.core.borrow().search_query(), "foo");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn regex_isearch_via_dispatch_c_m_s() {
|
||||
let mut s = fresh_with(b"a1 b2 c3");
|
||||
s.core.borrow_mut().active_window_mut().cursor = 0;
|
||||
// C-M-s starts a regex search (search.forward-regex).
|
||||
s.dispatch_key(
|
||||
FrontendId::LOCAL,
|
||||
key(
|
||||
KeyCode::Char('s'),
|
||||
KeyModifiers::CONTROL | KeyModifiers::ALT,
|
||||
),
|
||||
);
|
||||
assert!(s.core.borrow().search_active());
|
||||
assert!(s.core.borrow().search_is_regex());
|
||||
type_chars(&mut s, r"\d");
|
||||
assert_eq!(s.core.borrow().search_match_summary().1, 3);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn m_r_toggles_regex_mid_search() {
|
||||
let mut s = fresh_with(b"a.b axb");
|
||||
s.core.borrow_mut().active_window_mut().cursor = 0;
|
||||
s.dispatch_key(FrontendId::LOCAL, ctrl('s')); // literal
|
||||
type_chars(&mut s, "a.b");
|
||||
assert_eq!(s.core.borrow().search_match_summary().1, 1);
|
||||
// M-r toggles to regex (intercepted in dispatch_search_key).
|
||||
s.dispatch_key(
|
||||
FrontendId::LOCAL,
|
||||
key(KeyCode::Char('r'), KeyModifiers::ALT),
|
||||
);
|
||||
assert!(s.core.borrow().search_is_regex());
|
||||
assert_eq!(s.core.borrow().search_match_summary().1, 2);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn isearch_accumulates_across_renders_like_run_loop() {
|
||||
// Reproduce the real run loop: a render between every keystroke
|
||||
|
|
|
|||
|
|
@ -78,6 +78,14 @@ pub struct SearchSession {
|
|||
/// Drives the prompt label ("I-search" vs "I-search backward")
|
||||
/// and the wrap direction of an empty-query repeat.
|
||||
forward: bool,
|
||||
/// Whether the query is a regex (Q#RX3). `false` = smart-case
|
||||
/// substring (`find_all`); `true` = smart-case regex
|
||||
/// (`find_all_regex`). Toggled live by `M-r`.
|
||||
regex: bool,
|
||||
/// `true` when the last recompute's regex pattern failed to compile
|
||||
/// — the prompt shows `[invalid]` instead of a match count. Always
|
||||
/// `false` in literal mode (substring never "fails to compile").
|
||||
invalid: bool,
|
||||
}
|
||||
|
||||
/// The world state mutated by editor commands.
|
||||
|
|
@ -536,6 +544,21 @@ impl EditorCore {
|
|||
self.search.as_ref().is_none_or(|s| s.forward)
|
||||
}
|
||||
|
||||
/// `true` iff the active search is in regex mode (Q#RX3). `false`
|
||||
/// for literal substring, or when no search is running.
|
||||
#[must_use]
|
||||
pub fn search_is_regex(&self) -> bool {
|
||||
self.search.as_ref().is_some_and(|s| s.regex)
|
||||
}
|
||||
|
||||
/// `true` iff the active regex search's pattern failed to compile —
|
||||
/// the prompt shows `[invalid]` rather than a match count. Always
|
||||
/// `false` in literal mode / when no search is running.
|
||||
#[must_use]
|
||||
pub fn search_is_invalid(&self) -> bool {
|
||||
self.search.as_ref().is_some_and(|s| s.invalid)
|
||||
}
|
||||
|
||||
/// `(active_index, total)` for the active buffer's matches, for the
|
||||
/// prompt's "n/m" readout. `active_index` is 0-based and `None`
|
||||
/// when there are no matches.
|
||||
|
|
@ -552,11 +575,12 @@ impl EditorCore {
|
|||
}
|
||||
|
||||
/// Begin an incremental search anchored at the active buffer +
|
||||
/// cursor. `forward` sets the initial step direction. A no-op if a
|
||||
/// search is already running (the entry chord is intercepted while
|
||||
/// active, so this is only reached from an inactive state — the
|
||||
/// guard is belt-and-suspenders).
|
||||
pub fn search_begin(&mut self, forward: bool) {
|
||||
/// cursor. `forward` sets the initial step direction; `regex`
|
||||
/// selects regex (`true`) vs literal substring (`false`) matching.
|
||||
/// A no-op if a search is already running (the entry chord is
|
||||
/// intercepted while active, so this is only reached from an
|
||||
/// inactive state — the guard is belt-and-suspenders).
|
||||
pub fn search_begin(&mut self, forward: bool, regex: bool) {
|
||||
if self.search.is_some() {
|
||||
return;
|
||||
}
|
||||
|
|
@ -571,9 +595,22 @@ impl EditorCore {
|
|||
query: String::new(),
|
||||
origin,
|
||||
forward,
|
||||
regex,
|
||||
invalid: false,
|
||||
});
|
||||
}
|
||||
|
||||
/// Toggle the active search between literal and regex matching
|
||||
/// (Q#RX3, `M-r`), re-running the current query in the new mode. A
|
||||
/// no-op when no search is running.
|
||||
pub fn search_toggle_regex(&mut self) {
|
||||
let Some(session) = self.search.as_mut() else {
|
||||
return;
|
||||
};
|
||||
session.regex = !session.regex;
|
||||
self.search_recompute();
|
||||
}
|
||||
|
||||
/// Ensure the active window carries a [`crate::search::SearchView`]
|
||||
/// overlay, attaching one if absent (deduped by overlay kind). The
|
||||
/// view reads the per-buffer [`Self::search_store`] keyed on the
|
||||
|
|
@ -617,8 +654,23 @@ impl EditorCore {
|
|||
let bid = session.origin.0;
|
||||
let origin_byte = session.origin.1;
|
||||
let query = session.query.clone();
|
||||
let regex = session.regex;
|
||||
let bytes = self.buffer_bytes(bid);
|
||||
let matches = crate::search::find_all(&bytes, &query);
|
||||
// Regex: `None` ⇒ the pattern won't compile (mark invalid, drop
|
||||
// matches). Literal substring never fails. An invalid pattern
|
||||
// clears the store (no stale matches paint) and shows
|
||||
// `[invalid]` via the prompt.
|
||||
let (matches, invalid) = if regex {
|
||||
match crate::search::find_all_regex(&bytes, &query) {
|
||||
Some(m) => (m, false),
|
||||
None => (Vec::new(), true),
|
||||
}
|
||||
} else {
|
||||
(crate::search::find_all(&bytes, &query), false)
|
||||
};
|
||||
if let Some(session) = self.search.as_mut() {
|
||||
session.invalid = invalid;
|
||||
}
|
||||
let focus = {
|
||||
let mut guard = self
|
||||
.search_store
|
||||
|
|
@ -2422,7 +2474,7 @@ mod tests {
|
|||
let mut s = from_bytes(b"foo bar foo baz foo");
|
||||
let bid = s.active_buffer_id();
|
||||
s.active_window_mut().cursor = 0;
|
||||
s.search_begin(true);
|
||||
s.search_begin(true, false);
|
||||
assert!(s.search_active());
|
||||
type_query(&mut s, "foo");
|
||||
// Three matches: 0..3, 8..11, 16..19; first (at/after origin 0)
|
||||
|
|
@ -2438,7 +2490,7 @@ mod tests {
|
|||
fn search_step_walks_matches_and_wraps() {
|
||||
let mut s = from_bytes(b"foo bar foo baz foo");
|
||||
s.active_window_mut().cursor = 0;
|
||||
s.search_begin(true);
|
||||
s.search_begin(true, false);
|
||||
type_query(&mut s, "foo");
|
||||
assert_eq!(s.cursor(), 0);
|
||||
s.search_step(true);
|
||||
|
|
@ -2455,7 +2507,7 @@ mod tests {
|
|||
fn search_focuses_first_match_at_or_after_origin() {
|
||||
let mut s = from_bytes(b"foo bar foo");
|
||||
s.active_window_mut().cursor = 5; // inside "bar"
|
||||
s.search_begin(true);
|
||||
s.search_begin(true, false);
|
||||
type_query(&mut s, "foo");
|
||||
// First match with start >= 5 is the one at byte 8.
|
||||
assert_eq!(s.cursor(), 8);
|
||||
|
|
@ -2467,7 +2519,7 @@ mod tests {
|
|||
let mut s = from_bytes(b"foo bar foo");
|
||||
let bid = s.active_buffer_id();
|
||||
s.active_window_mut().cursor = 5;
|
||||
s.search_begin(true);
|
||||
s.search_begin(true, false);
|
||||
type_query(&mut s, "foo");
|
||||
assert_eq!(s.cursor(), 8);
|
||||
s.search_finish(false); // cancel
|
||||
|
|
@ -2488,7 +2540,7 @@ mod tests {
|
|||
let mut s = from_bytes(b"foo bar foo");
|
||||
let bid = s.active_buffer_id();
|
||||
s.active_window_mut().cursor = 0;
|
||||
s.search_begin(true);
|
||||
s.search_begin(true, false);
|
||||
type_query(&mut s, "foo");
|
||||
s.search_step(true); // focus the match at byte 8
|
||||
assert_eq!(s.cursor(), 8);
|
||||
|
|
@ -2509,7 +2561,7 @@ mod tests {
|
|||
fn search_backspace_widens_the_match_set() {
|
||||
let mut s = from_bytes(b"fo foo food");
|
||||
s.active_window_mut().cursor = 0;
|
||||
s.search_begin(true);
|
||||
s.search_begin(true, false);
|
||||
type_query(&mut s, "foo"); // matches "foo" at 3..6, 7..10
|
||||
assert_eq!(s.search_match_summary().1, 2);
|
||||
s.search_backspace(); // query "fo"
|
||||
|
|
@ -2521,7 +2573,7 @@ mod tests {
|
|||
fn search_smart_case_is_case_sensitive_with_uppercase() {
|
||||
let mut s = from_bytes(b"Foo foo FOO");
|
||||
s.active_window_mut().cursor = 0;
|
||||
s.search_begin(true);
|
||||
s.search_begin(true, false);
|
||||
type_query(&mut s, "Foo"); // uppercase => case-sensitive
|
||||
assert_eq!(s.search_match_summary().1, 1);
|
||||
s.search_backspace();
|
||||
|
|
@ -2536,7 +2588,7 @@ mod tests {
|
|||
let mut s = from_bytes(b"foo foo");
|
||||
let bid = s.active_buffer_id();
|
||||
s.active_window_mut().cursor = 0;
|
||||
s.search_begin(true);
|
||||
s.search_begin(true, false);
|
||||
type_query(&mut s, "foo");
|
||||
s.search_finish(true); // matches persist after accept
|
||||
assert!(!s.search_store.lock().expect("store").is_stale(bid));
|
||||
|
|
@ -2546,4 +2598,50 @@ mod tests {
|
|||
"an edit marks the buffer's matches stale (linger fix)"
|
||||
);
|
||||
}
|
||||
|
||||
// ---- regex search (Q#RX3) ------------------------------------------
|
||||
|
||||
#[test]
|
||||
fn regex_search_matches_pattern() {
|
||||
let mut s = from_bytes(b"a1 b2 c3");
|
||||
s.active_window_mut().cursor = 0;
|
||||
s.search_begin(true, true);
|
||||
assert!(s.search_is_regex());
|
||||
type_query(&mut s, r"\d");
|
||||
assert_eq!(s.search_match_summary().1, 3, "\\d matches 1, 2, 3");
|
||||
assert!(!s.search_is_invalid());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn regex_invalid_pattern_flags_and_recovers() {
|
||||
let mut s = from_bytes(b"foo");
|
||||
s.active_window_mut().cursor = 0;
|
||||
s.search_begin(true, true);
|
||||
type_query(&mut s, "fo("); // unbalanced group mid-typing
|
||||
assert!(s.search_is_invalid(), "incomplete group is invalid");
|
||||
assert_eq!(s.search_match_summary().1, 0, "invalid ⇒ no matches");
|
||||
type_query(&mut s, "o)"); // completes the group: regex fo(o) → "foo"
|
||||
assert!(!s.search_is_invalid(), "valid pattern recovers");
|
||||
assert_eq!(s.search_match_summary().1, 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn toggle_regex_reinterprets_the_query() {
|
||||
let mut s = from_bytes(b"a.b axb");
|
||||
s.active_window_mut().cursor = 0;
|
||||
s.search_begin(true, false); // literal
|
||||
type_query(&mut s, "a.b");
|
||||
assert!(!s.search_is_regex());
|
||||
assert_eq!(
|
||||
s.search_match_summary().1,
|
||||
1,
|
||||
"literal '.' matches only a.b"
|
||||
);
|
||||
s.search_toggle_regex(); // → regex
|
||||
assert!(s.search_is_regex());
|
||||
assert_eq!(s.search_match_summary().1, 2, "regex '.' also matches axb");
|
||||
s.search_toggle_regex(); // back to literal
|
||||
assert!(!s.search_is_regex());
|
||||
assert_eq!(s.search_match_summary().1, 1);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -11439,13 +11439,14 @@ fn install_history(editor: &Table, lua: &Lua, core: &SharedCore) -> mlua::Result
|
|||
/// post-accept navigation commands can begin / step a search from Lua.
|
||||
fn install_search(editor: &Table, lua: &Lua, core: &SharedCore) -> mlua::Result<()> {
|
||||
{
|
||||
// search_start(forward): begin an isearch in the given
|
||||
// direction, anchored at the active buffer + cursor.
|
||||
// search_start(forward, regex): begin an isearch in the given
|
||||
// direction, anchored at the active buffer + cursor. `regex`
|
||||
// selects regex vs literal substring matching (Q#RX3).
|
||||
let cc = core.clone();
|
||||
editor.set(
|
||||
"search_start",
|
||||
lua.create_function(move |_, forward: bool| {
|
||||
cc.borrow_mut().search_begin(forward);
|
||||
lua.create_function(move |_, (forward, regex): (bool, bool)| {
|
||||
cc.borrow_mut().search_begin(forward, regex);
|
||||
Ok(())
|
||||
})?,
|
||||
)?;
|
||||
|
|
|
|||
148
src/search.rs
148
src/search.rs
|
|
@ -363,45 +363,65 @@ impl View for SearchView {
|
|||
let cell_origin = viewport.cell_origin;
|
||||
|
||||
for m in &matches {
|
||||
let line = crate::diag::line_at_offset(&line_offsets, m.start as u32);
|
||||
if line < start_line_buf {
|
||||
continue;
|
||||
}
|
||||
let row_offset = line - start_line_buf;
|
||||
if row_offset >= max_rows {
|
||||
break;
|
||||
}
|
||||
let line_start = line_offsets[line as usize];
|
||||
let line_end = line_offsets
|
||||
.get(line as usize + 1)
|
||||
.copied()
|
||||
.unwrap_or(source.len() as u32);
|
||||
let line_end_no_nl = if line_end > line_start
|
||||
&& source.get(line_end as usize - 1).copied() == Some(b'\n')
|
||||
{
|
||||
line_end - 1
|
||||
} else {
|
||||
line_end
|
||||
};
|
||||
let line_bytes = &source[line_start as usize..line_end_no_nl as usize];
|
||||
let within_start = (m.start as u32).saturating_sub(line_start) as usize;
|
||||
let within_end = (m.end as u32).saturating_sub(line_start) as usize;
|
||||
let (start_col, end_col) =
|
||||
crate::diag::byte_range_to_display_cols(line_bytes, within_start, within_end);
|
||||
if end_col <= start_col {
|
||||
continue;
|
||||
}
|
||||
let style = if Some(*m) == active {
|
||||
active_match_style()
|
||||
} else {
|
||||
match_style()
|
||||
};
|
||||
let cell_row = cell_origin.row + row_offset;
|
||||
let clamped_start = start_col.min(max_cols);
|
||||
let clamped_end = end_col.min(max_cols);
|
||||
for col in clamped_start..clamped_end {
|
||||
let cell = cells.at(CellCoord::new(cell_row, cell_origin.col + col));
|
||||
cell.style = merge_styles(cell.style, style);
|
||||
// A regex match may span multiple lines (Q#RX4); wash each
|
||||
// row's clipped slice, mirroring the selection renderer.
|
||||
// Single-line matches (every literal match) touch one row.
|
||||
let first_line = crate::diag::line_at_offset(&line_offsets, m.start as u32);
|
||||
// Matches are sorted ascending, so once one starts below the
|
||||
// viewport every later one does too — stop.
|
||||
if first_line >= start_line_buf.saturating_add(max_rows) {
|
||||
break;
|
||||
}
|
||||
let last_byte = m.end.saturating_sub(1).max(m.start) as u32;
|
||||
let last_line = crate::diag::line_at_offset(&line_offsets, last_byte);
|
||||
for line in first_line..=last_line {
|
||||
if line < start_line_buf {
|
||||
continue;
|
||||
}
|
||||
let row_offset = line - start_line_buf;
|
||||
if row_offset >= max_rows {
|
||||
break;
|
||||
}
|
||||
let line_start = line_offsets[line as usize];
|
||||
let line_end = line_offsets
|
||||
.get(line as usize + 1)
|
||||
.copied()
|
||||
.unwrap_or(source.len() as u32);
|
||||
let line_end_no_nl = if line_end > line_start
|
||||
&& source.get(line_end as usize - 1).copied() == Some(b'\n')
|
||||
{
|
||||
line_end - 1
|
||||
} else {
|
||||
line_end
|
||||
};
|
||||
// Clip the match to this line's content (newline excluded
|
||||
// so a multi-line match doesn't wash a phantom trailing
|
||||
// cell).
|
||||
let paint_start = (m.start as u32).max(line_start);
|
||||
let paint_end = (m.end as u32).min(line_end_no_nl);
|
||||
if paint_start >= paint_end {
|
||||
continue;
|
||||
}
|
||||
let line_bytes = &source[line_start as usize..line_end_no_nl as usize];
|
||||
let within_start = (paint_start - line_start) as usize;
|
||||
let within_end = (paint_end - line_start) as usize;
|
||||
let (start_col, end_col) =
|
||||
crate::diag::byte_range_to_display_cols(line_bytes, within_start, within_end);
|
||||
if end_col <= start_col {
|
||||
continue;
|
||||
}
|
||||
let cell_row = cell_origin.row + row_offset;
|
||||
let clamped_start = start_col.min(max_cols);
|
||||
let clamped_end = end_col.min(max_cols);
|
||||
for col in clamped_start..clamped_end {
|
||||
let cell = cells.at(CellCoord::new(cell_row, cell_origin.col + col));
|
||||
cell.style = merge_styles(cell.style, style);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -607,6 +627,66 @@ mod tests {
|
|||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn search_view_washes_a_multiline_match_per_row() {
|
||||
use crate::cell::{Cell, CellGrid, CellSize};
|
||||
use crate::view::Viewport;
|
||||
|
||||
let store = make_shared_store();
|
||||
let bid = BufferId::next();
|
||||
let mut buf = Buffer::new(bid, "t.txt");
|
||||
// "foo\nbar\nbaz": match [0,7) = "foo\nbar" spans lines 0–1.
|
||||
buf.apply_edit(crate::buffer::EditOp::Insert {
|
||||
pos: 0,
|
||||
bytes: b"foo\nbar\nbaz",
|
||||
})
|
||||
.expect("seed");
|
||||
store.lock().unwrap().set(bid, r"foo\nbar", vec![r(0, 7)]);
|
||||
|
||||
let (rows, cols) = (3u32, 10u32);
|
||||
let mut backing = vec![Cell::default(); (rows * cols) as usize];
|
||||
let mut grid = CellGrid {
|
||||
cells: &mut backing,
|
||||
stride: cols,
|
||||
size: CellSize::new(rows, cols),
|
||||
};
|
||||
SearchView::new(store.clone()).render(
|
||||
&buf,
|
||||
Viewport {
|
||||
buffer_start: 0,
|
||||
buffer_end: buf.len(),
|
||||
cell_origin: CellCoord::new(0, 0),
|
||||
cell_size: CellSize::new(rows, cols),
|
||||
},
|
||||
&mut grid,
|
||||
);
|
||||
|
||||
// Row 0 "foo" and row 1 "bar" both wash (the active match's
|
||||
// bright color); the newline cells and row 2 "baz" do not.
|
||||
for col in 0..3 {
|
||||
assert_eq!(
|
||||
grid.get(CellCoord::new(0, col)).style.bg,
|
||||
Color::Indexed(11),
|
||||
"row 0 col {col} should wash"
|
||||
);
|
||||
assert_eq!(
|
||||
grid.get(CellCoord::new(1, col)).style.bg,
|
||||
Color::Indexed(11),
|
||||
"row 1 col {col} should wash"
|
||||
);
|
||||
}
|
||||
assert_eq!(
|
||||
grid.get(CellCoord::new(0, 3)).style.bg,
|
||||
Color::Default,
|
||||
"the newline cell past 'foo' is not washed"
|
||||
);
|
||||
assert_eq!(
|
||||
grid.get(CellCoord::new(2, 0)).style.bg,
|
||||
Color::Default,
|
||||
"row 2 'baz' is outside the match"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn store_staleness_tracks_per_buffer() {
|
||||
let mut s = SearchStore::new();
|
||||
|
|
|
|||
Loading…
Reference in New Issue