From dfb86b432b650279bd63f577b5c58132eca07087 Mon Sep 17 00:00:00 2001 From: "Matheus T. dos Santos" Date: Sun, 19 Jul 2026 12:53:55 -0300 Subject: [PATCH 1/2] feat: add regex mode to search (#209) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Search mode gains a regex toggle bound to `Ctrl+E` (mirroring the `Ctrl+W` case-sensitivity toggle). A new box in the search bar shows the state next to the case box: `[.*]` when on, `[ ]` when off. When on, each line is matched with a regular expression instead of a literal substring; case sensitivity is folded into the compiled regex, so the two toggles compose. The two match computations — the navigation entries built in `update_search_state` and the highlighted spans built in `search_line` — now share a single `SearchMatcher` that is compiled once per search change (not per rendered line) and returns character-offset match positions. Matching happens on the whole decoded line so regex anchors (`^`, `$`, `\b`) behave against the real line boundaries and the two paths stay column-aligned. An invalid / in-progress pattern matches nothing, which the existing red "0 matches" bar already signals. The issue asked for `Ctrl+Shift+F`, but control combos collapse Shift on most terminals (indistinguishable from `Ctrl+F` without the Kitty keyboard protocol), so `Ctrl+E` is used instead for a reliable, cross-platform toggle. Co-Authored-By: Claude Opus 4.8 (1M context) --- src/graphics/graphics_task.rs | 83 ++++++----- src/graphics/screen.rs | 267 +++++++++++++++++++++++++++------- src/inputs/inputs_task.rs | 15 ++ tests/tui_e2e.rs | 39 +++++ 4 files changed, 316 insertions(+), 88 deletions(-) diff --git a/src/graphics/graphics_task.rs b/src/graphics/graphics_task.rs index 6153dae..bbd78d4 100644 --- a/src/graphics/graphics_task.rs +++ b/src/graphics/graphics_task.rs @@ -293,6 +293,7 @@ impl GraphicsTask { rect: Rect, frame: &mut Frame, is_case_sensitive: bool, + is_regex: bool, ) { let (text, cursor) = { let inputs_shared = inputs_shared @@ -319,8 +320,9 @@ impl GraphicsTask { let block = Block::default() .title(format!( - "[{}][{}/{}] Search Mode", + "[{}][{}][{}/{}] Search Mode", if is_case_sensitive { "Aa" } else { "--" }, + if is_regex { ".*" } else { " " }, current, total )) @@ -349,11 +351,15 @@ impl GraphicsTask { search_indexes: Option<(usize, usize)>, filter_label: &str, ) { - let (input_mode, is_case_sensitive) = { + let (input_mode, is_case_sensitive, is_regex) = { let inputs_shared = inputs_shared .read() .expect("Cannot get inputs lock for read"); - (inputs_shared.mode, inputs_shared.is_case_sensitive) + ( + inputs_shared.mode, + inputs_shared.is_case_sensitive, + inputs_shared.is_regex, + ) }; match input_mode { @@ -371,6 +377,7 @@ impl GraphicsTask { rect, frame, is_case_sensitive, + is_regex, ), } } @@ -684,17 +691,22 @@ impl GraphicsTask { if changed { Self::rebuild_displayed_buffer(&mut private); - let (search_buffer, is_case_sensitive) = { + let (search_buffer, is_case_sensitive, is_regex) = { let input_sr = private .inputs_shared .read() .expect("Cannot get input lock for read"); - (input_sr.search_buffer.clone(), input_sr.is_case_sensitive) + ( + input_sr.search_buffer.clone(), + input_sr.is_case_sensitive, + input_sr.is_regex, + ) }; Self::update_search_state( &mut private, search_buffer, is_case_sensitive, + is_regex, ); } } @@ -798,15 +810,24 @@ impl GraphicsTask { .jump_to_previous_search(max_main_axis as usize); } GraphicsCommand::SearchChange => { - let (search_buffer, is_case_sensitive) = { + let (search_buffer, is_case_sensitive, is_regex) = { let input_sr = private .inputs_shared .read() .expect("Cannot get input lock for read"); - (input_sr.search_buffer.clone(), input_sr.is_case_sensitive) + ( + input_sr.search_buffer.clone(), + input_sr.is_case_sensitive, + input_sr.is_regex, + ) }; - Self::update_search_state(&mut private, search_buffer, is_case_sensitive); + Self::update_search_state( + &mut private, + search_buffer, + is_case_sensitive, + is_regex, + ); } GraphicsCommand::ChangeToNormalMode => { let max_main_axis = Self::max_main_axis(&private); @@ -820,10 +841,11 @@ impl GraphicsTask { .expect("Cannot get input lock for read"); let query = shared.search_buffer.clone(); let is_case_sensitive = shared.is_case_sensitive; + let is_regex = shared.is_regex; private .screen - .change_mode_to_search(query, is_case_sensitive); + .change_mode_to_search(query, is_case_sensitive, is_regex); } GraphicsCommand::Exit => break 'draw_loop, } @@ -904,15 +926,19 @@ impl GraphicsTask { save_stats.file_size = private.typewriter.get_size(); new_messages = vec![]; - let (search_buffer, is_case_sensitive) = { + let (search_buffer, is_case_sensitive, is_regex) = { let input_sr = private .inputs_shared .read() .expect("Cannot get input lock for read"); - (input_sr.search_buffer.clone(), input_sr.is_case_sensitive) + ( + input_sr.search_buffer.clone(), + input_sr.is_case_sensitive, + input_sr.is_regex, + ) }; - Self::update_search_state(&mut private, search_buffer, is_case_sensitive); + Self::update_search_state(&mut private, search_buffer, is_case_sensitive, is_regex); } if need_redraw { @@ -1002,19 +1028,15 @@ impl GraphicsTask { private: &mut GraphicsConnections, pattern: String, is_case_sensitive: bool, + is_regex: bool, ) { let decoder = private.screen.decoder(); let mode = private.screen.mode_mut(); - let pattern = if !is_case_sensitive { - pattern.to_ascii_lowercase() - } else { - pattern - }; - - mode.set_query(pattern.clone(), is_case_sensitive); + let is_empty = pattern.is_empty(); + mode.set_query(pattern, is_case_sensitive, is_regex); - if pattern.is_empty() { + if is_empty { return; } @@ -1023,23 +1045,10 @@ impl GraphicsTask { let message = message.decode(decoder).message; let message = ANSI::remove_encoding(message); - let message = if !is_case_sensitive { - message.to_ascii_lowercase() - } else { - message - }; - - let mut search_start_byte = 0; - while let Some(rel_byte) = message[search_start_byte..].find(&pattern) { - let abs_byte = search_start_byte + rel_byte; - - let column_chars = message[..abs_byte].chars().count(); - mode.add_entry(BufferPosition { - line, - column: column_chars, - }); - - search_start_byte = abs_byte + pattern.len(); + // Same matcher and same `message` as `search_line`, so the columns + // recorded here match the highlighted spans exactly (regex or not). + for (column, _len) in mode.search_matches(&message) { + mode.add_entry(BufferPosition { line, column }); } } diff --git a/src/graphics/screen.rs b/src/graphics/screen.rs index 7f39a66..13e4c38 100644 --- a/src/graphics/screen.rs +++ b/src/graphics/screen.rs @@ -20,6 +20,7 @@ use ratatui::{ block::Title, }, }; +use regex::{Regex, RegexBuilder}; pub struct Screen { position: BufferPosition, @@ -73,12 +74,16 @@ impl Screen { self.clamp_position(max_main_axis); } - pub fn change_mode_to_search(&mut self, query: String, is_case_sensitive: bool) { + pub fn change_mode_to_search( + &mut self, + query: String, + is_case_sensitive: bool, + is_regex: bool, + ) { self.mode = ScreenMode::Search { - query, current: 0, entries: vec![], - is_case_sensitive, + matcher: SearchMatcher::build(&query, is_case_sensitive, is_regex), }; } @@ -447,28 +452,114 @@ impl Screen { } } +/// Turns a search query into the positions it matches on a line. Built once +/// per search change (not per rendered line) so a regex compiles only once. +pub enum SearchMatcher { + /// Empty query, or a regex that failed to compile: matches nothing. + Empty, + /// Literal substring search, with case folding when not case-sensitive. + Plain { + needle: String, + is_case_sensitive: bool, + }, + /// The query compiled as a regular expression (case folding is baked into + /// the compiled regex). + Regex(Regex), +} + +impl SearchMatcher { + fn build(query: &str, is_case_sensitive: bool, is_regex: bool) -> Self { + if query.is_empty() { + return Self::Empty; + } + + if is_regex { + match RegexBuilder::new(query) + .case_insensitive(!is_case_sensitive) + .build() + { + Ok(regex) => Self::Regex(regex), + // An in-progress or otherwise invalid pattern matches nothing; + // the search bar turning red (0 matches) signals it to the user. + Err(_) => Self::Empty, + } + } else { + Self::Plain { + needle: query.to_string(), + is_case_sensitive, + } + } + } + + fn is_empty(&self) -> bool { + matches!(self, Self::Empty) + } + + /// Non-overlapping matches within `line`, each as `(char_start, char_len)`. + /// The offsets are in characters (not bytes) so they line up with the + /// screen columns used for highlighting and navigation. + fn matches(&self, line: &str) -> Vec<(usize, usize)> { + match self { + Self::Empty => vec![], + Self::Plain { + needle, + is_case_sensitive, + } => { + let (haystack, needle) = if *is_case_sensitive { + (line.to_string(), needle.clone()) + } else { + (line.to_ascii_lowercase(), needle.to_ascii_lowercase()) + }; + let needle_chars = needle.chars().count(); + + let mut result = vec![]; + let mut start_byte = 0; + while let Some(rel_byte) = haystack[start_byte..].find(&needle) { + let abs_byte = start_byte + rel_byte; + let column = haystack[..abs_byte].chars().count(); + result.push((column, needle_chars)); + start_byte = abs_byte + needle.len(); + } + result + } + Self::Regex(regex) => { + let mut result = vec![]; + for m in regex.find_iter(line) { + // Skip zero-width matches (e.g. a trailing `.*` or `a*`): + // there is nothing to highlight and they only inflate the + // match count. + if m.start() == m.end() { + continue; + } + let column = line[..m.start()].chars().count(); + let len = line[m.start()..m.end()].chars().count(); + result.push((column, len)); + } + result + } + } + } +} + pub enum ScreenMode { Normal, Search { - query: String, current: usize, entries: Vec, - is_case_sensitive: bool, + matcher: SearchMatcher, }, } impl ScreenMode { - pub fn set_query(&mut self, query: String, is_case_sensitive: bool) { + pub fn set_query(&mut self, query: String, is_case_sensitive: bool, is_regex: bool) { if let Self::Search { - query: current_query, entries, - is_case_sensitive: current_is_case_sensitive, + matcher, current, .. } = self { - *current_query = query; - *current_is_case_sensitive = is_case_sensitive; + *matcher = SearchMatcher::build(&query, is_case_sensitive, is_regex); *current = 0; entries.clear(); } @@ -480,6 +571,15 @@ impl ScreenMode { } } + /// Match positions of the active search query within `line`, as + /// `(char_start, char_len)`. Empty outside Search mode. + pub fn search_matches(&self, line: &str) -> Vec<(usize, usize)> { + match self { + Self::Search { matcher, .. } => matcher.matches(line), + Self::Normal => vec![], + } + } + pub fn update_current(&mut self) { if let Self::Search { entries, current, .. @@ -700,10 +800,9 @@ impl ScreenMode { fn search_line(&self, line: BufferLine) -> Vec> { let Self::Search { - query, current, entries, - is_case_sensitive, + matcher, .. } = self else { @@ -715,55 +814,51 @@ impl ScreenMode { let disable_style = Style::default().bg(Color::Reset).fg(Color::DarkGray); let message = ANSI::remove_encoding(line.message); - if query.is_empty() { + if matcher.is_empty() { + return vec![Span::styled(message, disable_style)]; + } + + // Match on the whole (decoded, ANSI-stripped) line so regex anchors like + // `^`, `$` and `\b` behave against the real line boundaries. The same + // `message` and matcher feed `update_search_state`, so the char columns + // computed here line up with the navigation entries below. + let matches = matcher.matches(&message); + if matches.is_empty() { return vec![Span::styled(message, disable_style)]; } let highlighted_style = Style::default().bg(Color::Reset).fg(Color::Yellow); let chosen_style = Style::default().bg(Color::Yellow).fg(Color::Black); - let query = if *is_case_sensitive { - query.to_string() - } else { - query.to_ascii_lowercase() - }; - - let message_splitted = message.to_special_char(|string| { - let string = if *is_case_sensitive { - string.to_string() - } else { - string.to_ascii_lowercase() - }; - - string.find(&query).map(|start| { - let start = string[..start].chars().count(); - (start, query.chars().count()).into() - }) - }); + let chars = message.chars().collect::>(); let mut output = vec![]; + let mut cursor = 0; - for submsg in message_splitted { - let vec_span = match submsg { - SpecialCharItem::Plain(submsg) => { - let submsg = ANSI::remove_encoding(submsg); - vec![Span::styled(submsg, disable_style)] - } - SpecialCharItem::Special(query, column) => { - let query_pos = BufferPosition { - line: line.line, - column, - }; - - if entries.get(*current) == Some(&query_pos) { - let chosen = Span::styled(query.to_string(), chosen_style); - Self::highlight_special_characters(chosen) - } else { - vec![Span::styled(query.to_string(), highlighted_style)] - } - } + for (start, len) in matches { + if start > cursor { + let plain = chars[cursor..start].iter().collect::(); + output.push(Span::styled(plain, disable_style)); + } + + let matched = chars[start..start + len].iter().collect::(); + let query_pos = BufferPosition { + line: line.line, + column: start, }; - output.extend(vec_span); + if entries.get(*current) == Some(&query_pos) { + let chosen = Span::styled(matched, chosen_style); + output.extend(Self::highlight_special_characters(chosen)); + } else { + output.push(Span::styled(matched, highlighted_style)); + } + + cursor = start + len; + } + + if cursor < chars.len() { + let plain = chars[cursor..].iter().collect::(); + output.push(Span::styled(plain, disable_style)); } output @@ -935,3 +1030,73 @@ impl ScreenDecoder { } } } + +#[cfg(test)] +mod tests { + use super::SearchMatcher; + + // Regex search on each line (issue #209). + + #[test] + fn plain_case_sensitive_finds_all_occurrences() { + let matcher = SearchMatcher::build("ab", true, false); + assert_eq!(matcher.matches("ab_ab_AB"), vec![(0, 2), (3, 2)]); + } + + #[test] + fn plain_case_insensitive_matches_regardless_of_case() { + let matcher = SearchMatcher::build("ab", false, false); + assert_eq!(matcher.matches("ab_ab_AB"), vec![(0, 2), (3, 2), (6, 2)]); + } + + #[test] + fn columns_are_character_offsets_not_bytes() { + // "á" is two bytes but one column; the two "X" matches must land on + // char columns 1 and 3, not byte offsets 2 and 5. + let matcher = SearchMatcher::build("X", true, false); + assert_eq!(matcher.matches("áXbX"), vec![(1, 1), (3, 1)]); + } + + #[test] + fn regex_matches_pattern_with_char_columns_and_lengths() { + let matcher = SearchMatcher::build(r"\d+", true, true); + assert_eq!(matcher.matches("ab12cde345"), vec![(2, 2), (7, 3)]); + } + + #[test] + fn regex_case_insensitive_flag_is_honored() { + let sensitive = SearchMatcher::build("ERR", true, true); + assert!(sensitive.matches("an err happened").is_empty()); + + let insensitive = SearchMatcher::build("ERR", false, true); + assert_eq!(insensitive.matches("an err happened"), vec![(3, 3)]); + } + + #[test] + fn regex_anchor_matches_only_at_line_start() { + let matcher = SearchMatcher::build("^ab", true, true); + assert_eq!(matcher.matches("abcab"), vec![(0, 2)]); + assert!(matcher.matches("xabcab").is_empty()); + } + + #[test] + fn regex_zero_width_matches_are_skipped() { + // A trailing `.*` and empty `a*` runs would otherwise inflate the match + // count with nothing to highlight. + let matcher = SearchMatcher::build("a*", true, true); + assert_eq!(matcher.matches("baa"), vec![(1, 2)]); + } + + #[test] + fn invalid_regex_matches_nothing() { + let matcher = SearchMatcher::build("(unclosed", true, true); + assert!(matcher.is_empty()); + assert!(matcher.matches("(unclosed group here").is_empty()); + } + + #[test] + fn empty_query_matches_nothing() { + assert!(SearchMatcher::build("", false, false).is_empty()); + assert!(SearchMatcher::build("", false, true).is_empty()); + } +} diff --git a/src/inputs/inputs_task.rs b/src/inputs/inputs_task.rs index 8975643..83baa89 100644 --- a/src/inputs/inputs_task.rs +++ b/src/inputs/inputs_task.rs @@ -46,6 +46,10 @@ pub struct InputsShared { pub current_hint: Option, pub mode: InputMode, pub is_case_sensitive: bool, + /// Search mode only: when `true`, the search buffer is treated as a regular + /// expression matched against each line instead of a literal substring. + /// Toggled with `Ctrl+E`. + pub is_regex: bool, pub tag_list: TagList, /// Headless raw passthrough: when `true`, keystrokes are encoded and sent /// straight to the wire and the display prints received bytes verbatim. @@ -270,6 +274,17 @@ impl InputsTask { .send(GraphicsCommand::SearchChange); } } + KeyCode::Char('e') | KeyCode::Char('E') if key.modifiers == KeyModifiers::CONTROL => { + let mut sw = shared.write().expect("Cannot get input lock for write"); + + if matches!(sw.mode, InputMode::Search) { + sw.is_regex = !sw.is_regex; + + let _ = private + .graphics_cmd_sender + .send(GraphicsCommand::SearchChange); + } + } KeyCode::Char(c) => { let mut sw = shared.write().expect("Cannot get input lock for write"); diff --git a/tests/tui_e2e.rs b/tests/tui_e2e.rs index 6a518cb..ff23e75 100644 --- a/tests/tui_e2e.rs +++ b/tests/tui_e2e.rs @@ -265,6 +265,45 @@ fn tag_autocomplete_lists_only_matching_tags() { ); } +#[test] +fn regex_search_toggles_with_ctrl_e_and_matches_each_line() { + // Issue #209: Ctrl+E toggles regex mode in search. As a literal string + // `\d+` matches nothing; as a regex it matches the digit run on each line. + let mut tui = Tui::start(&[]); + tui.wait_until_ready(); + + // Two TX lines with digit runs give the regex something to match. + tui.type_text("err 12"); + tui.press_enter(); + tui.wait_for("err 12\\r\\n", SETTLE); + tui.type_text("err 345"); + tui.press_enter(); + tui.wait_for("err 345\\r\\n", SETTLE); + + // Enter search mode (Ctrl+F = 0x06) and type the pattern. + tui.type_text("\x06"); + tui.type_text("\\d+"); + // Wait until the pattern shows in the search bar (keystrokes landed). Regex + // is off by default, so the literal `\d+` matches nothing. + let plain = tui.wait_for("\\d+", SETTLE); + assert!( + plain.contains("[ ]"), + "regex should start disabled ([ ]).\n{plain}" + ); + assert!( + plain.contains("[--/--]"), + "literal \\d+ should match nothing.\n{plain}" + ); + + // Toggle regex on (Ctrl+E = 0x05): the two digit runs now match. + tui.type_text("\x05"); + let regex = tui.wait_for("[1/2]", SETTLE); + assert!( + regex.contains("[.*]"), + "regex box should read [.*] once enabled.\n{regex}" + ); +} + #[test] fn tag_autocomplete_down_arrow_then_tab_completes_selected_tag() { // Issue #177: the arrows move the highlight inside the pop-up and Tab From c2e0ac94fc71aa5d713b32755deb6391563b573e Mon Sep 17 00:00:00 2001 From: "Matheus T. dos Santos" Date: Sun, 19 Jul 2026 13:54:57 -0300 Subject: [PATCH 2/2] test: make regex-search e2e independent of serial connection The e2e test searched for a bare \d+, but on Linux the PTY serial port connects and adds a "Connected at .../dev/pts/N with 115200bps" log line whose digits also matched, giving 4 hits instead of the 2 asserted (macOS never connects, so it saw 2 and passed). Anchor the pattern to `err ` so the connection line is excluded and the count is a deterministic 2 on both. Co-Authored-By: Claude Opus 4.8 (1M context) --- tests/tui_e2e.rs | 19 +++++++++++++------ 1 file changed, 13 insertions(+), 6 deletions(-) diff --git a/tests/tui_e2e.rs b/tests/tui_e2e.rs index ff23e75..5089870 100644 --- a/tests/tui_e2e.rs +++ b/tests/tui_e2e.rs @@ -268,7 +268,14 @@ fn tag_autocomplete_lists_only_matching_tags() { #[test] fn regex_search_toggles_with_ctrl_e_and_matches_each_line() { // Issue #209: Ctrl+E toggles regex mode in search. As a literal string - // `\d+` matches nothing; as a regex it matches the digit run on each line. + // `err \d+` matches nothing; as a regex it matches the digit run on each + // injected line. + // + // The pattern is anchored to `err ` on purpose: on Linux the PTY serial + // port connects and adds a "Connected at .../dev/pts/N with 115200bps" log + // line whose digits a bare `\d+` would also match (4 hits, not 2), while on + // macOS the port never connects (2 hits). Requiring `err ` excludes that + // connection line, so the match count is a deterministic 2 on both. let mut tui = Tui::start(&[]); tui.wait_until_ready(); @@ -282,20 +289,20 @@ fn regex_search_toggles_with_ctrl_e_and_matches_each_line() { // Enter search mode (Ctrl+F = 0x06) and type the pattern. tui.type_text("\x06"); - tui.type_text("\\d+"); + tui.type_text("err \\d+"); // Wait until the pattern shows in the search bar (keystrokes landed). Regex - // is off by default, so the literal `\d+` matches nothing. - let plain = tui.wait_for("\\d+", SETTLE); + // is off by default, so the literal `err \d+` matches nothing. + let plain = tui.wait_for("err \\d+", SETTLE); assert!( plain.contains("[ ]"), "regex should start disabled ([ ]).\n{plain}" ); assert!( plain.contains("[--/--]"), - "literal \\d+ should match nothing.\n{plain}" + "literal `err \\d+` should match nothing.\n{plain}" ); - // Toggle regex on (Ctrl+E = 0x05): the two digit runs now match. + // Toggle regex on (Ctrl+E = 0x05): the two `err ` lines now match. tui.type_text("\x05"); let regex = tui.wait_for("[1/2]", SETTLE); assert!(