diff --git a/codex-rs/tui/src/pager_overlay.rs b/codex-rs/tui/src/pager_overlay.rs index 867cdbdb93..d8f54291c4 100644 --- a/codex-rs/tui/src/pager_overlay.rs +++ b/codex-rs/tui/src/pager_overlay.rs @@ -1862,9 +1862,7 @@ impl TranscriptOverlay { self.view.keymap.next_search_match.is_pressed(key_event); let previous_match_pressed = self.view.keymap.previous_search_match.is_pressed(key_event); - if self.view.keymap.start_search.is_pressed(key_event) { - self.activate_search(); - } else if matches!(key_event.code, KeyCode::Esc) { + if matches!(key_event.code, KeyCode::Esc) { self.cancel_search_mode(); } else if matches!(key_event.code, KeyCode::Backspace) { self.search_backspace(); @@ -1888,6 +1886,8 @@ impl TranscriptOverlay { text.push(ch); self.append_search_text(&text); } + } else if self.view.keymap.start_search.is_pressed(key_event) { + self.activate_search(); } else { return self.handle_viewport_key_event(tui, key_event); } diff --git a/codex-rs/tui/src/pager_overlay_search.rs b/codex-rs/tui/src/pager_overlay_search.rs index 468b719b77..8e13084cfc 100644 --- a/codex-rs/tui/src/pager_overlay_search.rs +++ b/codex-rs/tui/src/pager_overlay_search.rs @@ -1,5 +1,6 @@ use crate::history_cell::HistoryCell; use crate::history_cell::HistoryRenderMode; +use crate::wrapping::wrap_ranges_trim; use ratatui::text::Line; use unicode_width::UnicodeWidthChar; @@ -48,7 +49,9 @@ pub(crate) struct SearchLine { scroll_line_index: usize, width: u16, folded_text: String, + plain_text: String, display_cols_by_byte: Vec, + source_bytes_by_folded_byte: Vec, owning_user_prompt_cell: Option, } @@ -71,13 +74,18 @@ impl SearchLine { line_index, scroll_line_index, width, - folded_text: plain_text.to_lowercase(), - display_cols_by_byte: display_cols_by_byte(&plain_text), + folded_text: folded_text(&plain_text), + display_cols_by_byte: display_cols_by_folded_byte(&plain_text), + source_bytes_by_folded_byte: source_bytes_by_folded_byte(&plain_text), + plain_text, owning_user_prompt_cell, } } fn find_matches(&self, query: &str) -> Vec { + if query.is_empty() { + return Vec::new(); + } let mut matches = Vec::new(); let mut start = 0usize; while let Some(found) = self.folded_text[start..].find(query) { @@ -88,12 +96,15 @@ impl SearchLine { line_index: self.line_index, scroll_line_index: self .scroll_line_index - .saturating_add(usize::from(self.display_col(match_start) / self.width)), + .saturating_add(self.wrapped_row(match_start)), start_col: self.display_col(match_start), end_col: self.display_col(match_end), owning_user_prompt_cell: self.owning_user_prompt_cell, }); - start = match_start.saturating_add(1); + let Some(ch) = self.folded_text[match_start..].chars().next() else { + break; + }; + start = match_start.saturating_add(ch.len_utf8()); } matches } @@ -104,6 +115,18 @@ impl SearchLine { .copied() .unwrap_or_default() } + + fn wrapped_row(&self, folded_byte_index: usize) -> usize { + let source_byte_index = self + .source_bytes_by_folded_byte + .get(folded_byte_index) + .copied() + .unwrap_or(self.plain_text.len()); + wrap_ranges_trim(&self.plain_text, usize::from(self.width.max(/*other*/ 1))) + .iter() + .position(|range| range.contains(&source_byte_index)) + .unwrap_or_default() + } } pub(crate) fn transcript_search_lines( @@ -171,13 +194,67 @@ fn push_search_lines( } } -fn display_cols_by_byte(text: &str) -> Vec { - let mut cols = vec![0; text.len().saturating_add(1)]; +fn folded_text(text: &str) -> String { + text.chars().flat_map(char::to_lowercase).collect() +} + +fn display_cols_by_folded_byte(text: &str) -> Vec { + let mut cols = vec![0]; let mut col = 0u16; - for (byte_index, ch) in text.char_indices() { - cols[byte_index] = col; - col = col.saturating_add(u16::try_from(ch.width().unwrap_or(0)).unwrap_or(u16::MAX)); + for ch in text.chars() { + let next_col = + col.saturating_add(u16::try_from(ch.width().unwrap_or(0)).unwrap_or(u16::MAX)); + cols.extend(std::iter::repeat_n( + next_col, + ch.to_lowercase().map(char::len_utf8).sum(), + )); + col = next_col; } - cols[text.len()] = col; cols } + +fn source_bytes_by_folded_byte(text: &str) -> Vec { + let mut source_bytes = vec![0]; + for (source_byte_index, ch) in text.char_indices() { + let next_source_byte = source_byte_index.saturating_add(ch.len_utf8()); + source_bytes.extend(std::iter::repeat_n( + next_source_byte, + ch.to_lowercase().map(char::len_utf8).sum(), + )); + } + source_bytes +} + +#[cfg(test)] +mod tests { + use super::*; + use pretty_assertions::assert_eq; + + #[test] + fn search_match_iteration_advances_on_utf8_boundaries() { + let line = SearchLine::from_line( + /*renderable_index*/ 0, + /*line_index*/ 0, + /*scroll_line_index*/ 0, + /*width*/ 80, + &Line::from("éé"), + /*owning_user_prompt_cell*/ None, + ); + + assert_eq!(line.find_matches("é").len(), 2); + } + + #[test] + fn search_match_scroll_offset_uses_word_wrapping() { + let line = SearchLine::from_line( + /*renderable_index*/ 0, + /*line_index*/ 0, + /*scroll_line_index*/ 0, + /*width*/ 10, + &Line::from("aaaaa aaaaa needle"), + /*owning_user_prompt_cell*/ None, + ); + + assert_eq!(line.find_matches("needle")[0].scroll_line_index, 2); + } +}