diff --git a/codex-rs/tui2/docs/transcript_find.md b/codex-rs/tui2/docs/transcript_find.md new file mode 100644 index 0000000000..add3f6f716 --- /dev/null +++ b/codex-rs/tui2/docs/transcript_find.md @@ -0,0 +1,248 @@ +# Transcript Find (Inline Viewport) + +This document describes the design for “find in transcript” in the **TUI2 inline viewport** (the +main transcript region above the composer), not the full-screen transcript overlay. + +The goal is to provide fast, low-friction navigation through the in-memory transcript while keeping +the UI predictable and the implementation easy to review/maintain. + +--- + +## Goals + +- **Search the inline viewport content**, derived from the same flattened transcript lines used for + scrolling/selection, so search results track what the user sees. +- **Ephemeral UI**: no always-on search bar and no scroll bar in this iteration. +- **Fast navigation**: + - highlight all matches + - jump to the next match repeatedly without reopening the prompt +- **Stable anchoring**: jumping should land on stable content anchors (cell + line), not raw screen + rows. +- **Reviewable architecture**: keep `app.rs` changes small by placing feature logic in a dedicated + module and calling it from the render loop and key handler. + +--- + +## Current Implementation (What We Have Today) + +This section documents the current state so it’s easy to compare against the “ideal/perfect” end +state discussed in review. + +For implementation details, see the rustdoc comments and unit tests in `tui2/src/transcript_find.rs`. + +### UI + +- When active, a single prompt row is rendered **above the composer**: + - `"/ query current/total"` +- Matches are highlighted in the transcript: + - all matches: underlined + - current match: reversed + bold + underlined +- The prompt is **not persistent**: it only appears while editing. + +### Keys + +- `Ctrl-F`: open the find prompt and start editing the query. +- While editing: + - type to edit the query (highlights update as you type) + - `Backspace`: delete one character + - `Ctrl-U`: clear the query + - `Enter`: close the prompt and jump to a match (if any) + - `Esc`: close the prompt without clearing the query (highlights remain) +- `Ctrl-G`: jump to next match. + - Works while editing (prompt stays open). + - Works even after the prompt is closed, as long as the query is still active. +- `Esc` (when not editing and a query is active): clears the search/highlights. + +### Footer hints + +- When the find prompt is visible, the footer shows `Ctrl-G next match`: + - in the shortcut summary line + - and in the `?` shortcut overlay + +### Implementation layout + +- Core logic lives in `tui2/src/transcript_find.rs`: + - key handling + - match computation/caching + - jump selection + - per-line rendering helper (`render_line`) and prompt rendering helper (`render_prompt_line`) +- `tui2/src/app.rs` is kept mostly additive by delegating: + - early key handling delegation in `App::handle_key_event` + - per-frame recompute/jump hook after transcript flattening + - per-row render hook for match highlighting + - prompt + cursor positioning while editing +- Footer hint integration is wired via `set_transcript_ui_state(..., find_visible)` through: + - `tui2/src/chatwidget.rs` + - `tui2/src/bottom_pane/mod.rs` + - `tui2/src/bottom_pane/chat_composer.rs` + - `tui2/src/bottom_pane/footer.rs` + +--- + +## UX and Keybindings + +### Entering search + +- `Ctrl-F` opens the find prompt on the line immediately above the composer. +- While the prompt is open, typed characters update the query and immediately update highlights. + +### Navigating results + +- `Ctrl-G` jumps to the next match. + - Works while the prompt is open. + - Also works after the prompt is closed as long as a non-empty query is still active (so users can + “keep stepping” through matches). + +### Exiting / clearing + +- `Esc` closes the prompt without clearing the active query (and therefore keeps highlights). +- `Esc` again (when not editing and a query is active) clears the search/highlights. + +### Footer hints + +When the find prompt is visible, we surface the relevant navigation key (`Ctrl-G`) in: + +- the shortcut summary line (the default footer mode) +- the “?” shortcut overlay + +This keeps the prompt itself visually minimal. + +--- + +## Data Model: Search Over Flattened Lines + +Search operates over the same representation as scrolling and selection: + +1. Cells are flattened into a list of `Line<'static>` plus parallel `TranscriptLineMeta` entries + (see `tui2/src/tui/scrolling.rs` and `tui2/docs/tui_viewport_and_history.md`). +2. The find module searches **plain text** extracted from each flattened line (by concatenating its + spans’ contents). +3. Each match stores: + - `line_index` (index into flattened lines) + - `range` (byte range within the flattened line’s plain text) + - `anchor` derived from `TranscriptLineMeta::CellLine { cell_index, line_in_cell }` + +The anchor is used to update `TranscriptScroll` when jumping so the viewport lands on stable content +even if the transcript grows. + +--- + +## Matching Semantics + +### Smart-case + +The search is “smart-case”: + +- If the query contains any ASCII uppercase, the match is case-sensitive. +- Otherwise, both haystack and needle are matched in ASCII-lowercased form. + +This avoids expensive Unicode case folding and keeps behavior predictable in terminals. + +--- + +## Rendering + +### Highlights + +- All matches are highlighted (currently: underlined). +- The “current match” is emphasized more strongly (currently: reversed + bold + underlined). + +Highlighting is applied at render time for each visible line by splitting spans into segments and +patching styles for the match ranges. + +### Prompt line + +While editing, the line directly above the composer shows: + +`/ query current/total` + +It is rendered inside the transcript viewport area (not as a persistent UI element), and the cursor +is moved into this line while editing. + +--- + +## Performance / Caching + +Recomputing matches happens only when needed. The search module caches based on: + +- transcript width (wrapping changes can change the flattened line list) +- number of flattened lines (transcript growth) + +This keeps the work proportional to actual content changes rather than every frame. + +--- + +## Code Layout (Additive, Review-Friendly) + +The implementation is structured so `app.rs` only delegates: + +- `tui2/src/transcript_find.rs` owns: + - query/edit state + - match computation and caching + - key handling for find-related shortcuts + - rendering helpers for highlighted lines and the prompt line + - producing a scroll anchor when a jump is requested + +`app.rs` integration points are intentionally small: + +- **Key handling**: early delegation to `TranscriptFind::handle_key_event`. +- **Render**: + - call `TranscriptFind::on_render` after building flattened lines to apply pending jumps + - call `TranscriptFind::render_line` per visible row + - render `render_prompt_line` when active and set cursor with `cursor_position` +- **Footer**: + - `set_transcript_ui_state(..., find_visible)` so the footer can show find-related hints only when + the prompt is visible. + +--- + +## Comparison to the “Ideal” End State + +### Ideal UX (what “perfect” looks like) + +- **Ephemeral, minimal UI**: no always-on search bar, and no scroll bar for this feature. +- **Fast entry**: `Ctrl-F` opens a single prompt row above the composer. +- **Live feedback**: highlights update as you type, and the prompt shows `current/total`. +- **Repeat navigation without closing**: `Ctrl-G` jumps to the next match while the prompt stays + open, and continues to work after the prompt closes as long as the query is active. +- **Predictable exit semantics**: + - `Enter`: accept query, close prompt, and jump (if any matches) + - `Esc`: close the prompt but keep the query/highlights + - `Esc` again (with an active query): clear the query/highlights +- **Stable jumping**: navigation targets stable transcript anchors (cell + line-in-cell), so jumping + behaves well as the transcript grows. +- **Discoverability without clutter**: when the prompt is visible, the footer/shortcuts surface the + navigation key (`Ctrl-G`) so the prompt itself stays tight. +- **Future marker integration**: if/when a scroll indicator is introduced, match markers integrate + with it (faint ticks for match lines, stronger marker for the current match). + +### Already aligned with the ideal + +- Ephemeral prompt (no always-on bar). +- Live highlighting while typing. +- `Ctrl-G` repeat navigation without reopening the prompt (including while editing). +- Stable jump anchoring via `(cell_index, line_in_cell)` metadata. +- Footer hints (`Ctrl-G next match`) shown only while the prompt is visible. +- Minimal, review-friendly integration points in `app.rs` via `tui2/src/transcript_find.rs`. + +### Not implemented yet (intentional deferrals) + +- Prev match (e.g. `Ctrl-Shift-G`). +- “Contextual landing” when jumping (e.g. padding/centering so the match isn’t pinned to the top). +- Match markers integrated with a future scroll indicator. + +### Known limitations / trade-offs in the current version + +- Matching is ASCII smart-case (no full Unicode case folding). +- Match ranges are byte ranges in the flattened plain text. This is fine for styling spans by byte + slicing, but any future “column-precise” behaviors should be careful with multi-byte characters. + +--- + +## Future Work (Not Implemented Here) + +- **Prev match**: add `Ctrl-Shift-G` for previous match if desired. +- **Marker integration**: if/when a scroll indicator is added, include match markers derived from + match line indices (faint ticks) and a stronger marker for the current match. +- **Contextual jump placement**: center the current match (or provide padding above) rather than + placing it at the exact top row when jumping. diff --git a/codex-rs/tui2/src/app.rs b/codex-rs/tui2/src/app.rs index 78ccfb0387..afa0a594e9 100644 --- a/codex-rs/tui2/src/app.rs +++ b/codex-rs/tui2/src/app.rs @@ -18,6 +18,7 @@ use crate::render::highlight::highlight_bash_to_lines; use crate::render::renderable::Renderable; use crate::resume_picker::ResumeSelection; use crate::transcript_copy_ui::TranscriptCopyUi; +use crate::transcript_find::TranscriptFind; use crate::transcript_multi_click::TranscriptMultiClick; use crate::transcript_selection::TRANSCRIPT_GUTTER_COLS; use crate::transcript_selection::TranscriptSelection; @@ -334,6 +335,7 @@ pub(crate) struct App { transcript_view_top: usize, transcript_total_lines: usize, transcript_copy_ui: TranscriptCopyUi, + transcript_find: TranscriptFind, // Pager overlay state (Transcript or Static like Diff) pub(crate) overlay: Option, @@ -502,6 +504,7 @@ impl App { transcript_view_top: 0, transcript_total_lines: 0, transcript_copy_ui: TranscriptCopyUi::new_with_shortcut(copy_selection_shortcut), + transcript_find: TranscriptFind::default(), overlay: None, deferred_history_lines: Vec::new(), has_emitted_history_lines: false, @@ -651,7 +654,11 @@ impl App { frame.buffer, ); } - if let Some((x, y)) = self.chat_widget.cursor_pos(chat_area) { + if let Some((x, y)) = + self.transcript_find.cursor_position(frame.area(), chat_top) + { + frame.set_cursor_position((x, y)); + } else if let Some((x, y)) = self.chat_widget.cursor_pos(chat_area) { frame.set_cursor_position((x, y)); } })?; @@ -674,6 +681,7 @@ impl App { selection_active, scroll_position, self.copy_selection_key(), + self.transcript_find.is_visible(), ); } } @@ -722,6 +730,17 @@ impl App { return area.y; } + if let Some((cell_index, line_in_cell)) = self.transcript_find.on_render( + &lines, + &line_meta, + transcript_area.width, + self.transcript_view_top, + ) { + self.transcript_scroll = TranscriptScroll::Scrolled { + cell_index, + line_in_cell, + }; + } let is_user_cell: Vec = cells .iter() .map(|c| c.as_any().is::()) @@ -793,7 +812,10 @@ impl App { } } - lines[line_index].render_ref(row_area, frame.buffer); + let line = self + .transcript_find + .render_line(line_index, &lines[line_index]); + line.render_ref(row_area, frame.buffer); } self.apply_transcript_selection(transcript_area, frame.buffer); @@ -813,6 +835,19 @@ impl App { } else { self.transcript_copy_ui.clear_affordance(); } + + if let Some(prompt_line) = self.transcript_find.render_prompt_line() + && chat_top > area.y + { + let prompt_area = Rect { + x: area.x, + y: chat_top.saturating_sub(1), + width: area.width, + height: 1, + }; + Clear.render_ref(prompt_area, frame.buffer); + Paragraph::new(prompt_line).render_ref(prompt_area, frame.buffer); + } chat_top } @@ -1356,6 +1391,7 @@ impl App { }; self.chat_widget = ChatWidget::new(init, self.server.clone()); self.current_model = model_family.get_model_slug().to_string(); + self.transcript_find.clear(); if let Some(summary) = summary { let mut lines: Vec> = vec![summary.usage_line.clone().into()]; if let Some(command) = summary.resume_command { @@ -1444,6 +1480,7 @@ impl App { tui.frame_requester().schedule_frame(); } self.transcript_cells.push(cell.clone()); + self.transcript_find.note_lines_changed(); let mut display = cell.display_lines(tui.terminal.last_known_screen_size.width); if !display.is_empty() { // Only insert a separating blank line for new cells that are not @@ -1891,6 +1928,11 @@ impl App { } async fn handle_key_event(&mut self, tui: &mut tui::Tui, key_event: KeyEvent) { + if self.transcript_find.handle_key_event(&key_event) { + tui.frame_requester().schedule_frame(); + return; + } + match key_event { KeyEvent { code: KeyCode::Char('t'), @@ -2115,6 +2157,7 @@ mod tests { transcript_copy_ui: TranscriptCopyUi::new_with_shortcut( CopySelectionShortcut::CtrlShiftC, ), + transcript_find: TranscriptFind::default(), overlay: None, deferred_history_lines: Vec::new(), has_emitted_history_lines: false, @@ -2165,6 +2208,7 @@ mod tests { transcript_copy_ui: TranscriptCopyUi::new_with_shortcut( CopySelectionShortcut::CtrlShiftC, ), + transcript_find: TranscriptFind::default(), overlay: None, deferred_history_lines: Vec::new(), has_emitted_history_lines: false, diff --git a/codex-rs/tui2/src/bottom_pane/chat_composer.rs b/codex-rs/tui2/src/bottom_pane/chat_composer.rs index 3d5de81a9f..b6f1cced53 100644 --- a/codex-rs/tui2/src/bottom_pane/chat_composer.rs +++ b/codex-rs/tui2/src/bottom_pane/chat_composer.rs @@ -124,6 +124,7 @@ pub(crate) struct ChatComposer { transcript_selection_active: bool, transcript_scroll_position: Option<(usize, usize)>, transcript_copy_selection_key: KeyBinding, + transcript_find_visible: bool, skills: Option>, dismissed_skill_popup_token: Option, } @@ -176,6 +177,7 @@ impl ChatComposer { transcript_selection_active: false, transcript_scroll_position: None, transcript_copy_selection_key: key_hint::ctrl_shift(KeyCode::Char('c')), + transcript_find_visible: false, skills: None, dismissed_skill_popup_token: None, }; @@ -1545,6 +1547,7 @@ impl ChatComposer { transcript_selection_active: self.transcript_selection_active, transcript_scroll_position: self.transcript_scroll_position, transcript_copy_selection_key: self.transcript_copy_selection_key, + transcript_find_visible: self.transcript_find_visible, } } @@ -1577,11 +1580,13 @@ impl ChatComposer { selection_active: bool, scroll_position: Option<(usize, usize)>, copy_selection_key: KeyBinding, + find_visible: bool, ) { self.transcript_scrolled = scrolled; self.transcript_selection_active = selection_active; self.transcript_scroll_position = scroll_position; self.transcript_copy_selection_key = copy_selection_key; + self.transcript_find_visible = find_visible; } fn sync_popups(&mut self) { diff --git a/codex-rs/tui2/src/bottom_pane/footer.rs b/codex-rs/tui2/src/bottom_pane/footer.rs index 57bffd5639..ce5f69f435 100644 --- a/codex-rs/tui2/src/bottom_pane/footer.rs +++ b/codex-rs/tui2/src/bottom_pane/footer.rs @@ -26,6 +26,7 @@ pub(crate) struct FooterProps { pub(crate) transcript_selection_active: bool, pub(crate) transcript_scroll_position: Option<(usize, usize)>, pub(crate) transcript_copy_selection_key: KeyBinding, + pub(crate) transcript_find_visible: bool, } #[derive(Clone, Copy, Debug, Eq, PartialEq)] @@ -119,6 +120,11 @@ fn footer_lines(props: FooterProps) -> Vec> { line.push_span(props.transcript_copy_selection_key); line.push_span(" copy selection".dim()); } + if props.transcript_find_visible { + line.push_span(" · ".dim()); + line.push_span(key_hint::ctrl(KeyCode::Char('g'))); + line.push_span(" next match".dim()); + } vec![line] } FooterMode::ShortcutOverlay => { @@ -187,6 +193,8 @@ fn shortcut_overlay_lines(state: ShortcutsState) -> Vec> { let mut newline = Line::from(""); let mut file_paths = Line::from(""); let mut paste_image = Line::from(""); + let mut find = Line::from(""); + let mut find_next = Line::from(""); let mut edit_previous = Line::from(""); let mut quit = Line::from(""); let mut show_transcript = Line::from(""); @@ -198,6 +206,8 @@ fn shortcut_overlay_lines(state: ShortcutsState) -> Vec> { ShortcutId::InsertNewline => newline = text, ShortcutId::FilePaths => file_paths = text, ShortcutId::PasteImage => paste_image = text, + ShortcutId::Find => find = text, + ShortcutId::FindNext => find_next = text, ShortcutId::EditPrevious => edit_previous = text, ShortcutId::Quit => quit = text, ShortcutId::ShowTranscript => show_transcript = text, @@ -205,16 +215,8 @@ fn shortcut_overlay_lines(state: ShortcutsState) -> Vec> { } } - let ordered = vec![ - commands, - newline, - file_paths, - paste_image, - edit_previous, - quit, - Line::from(""), - show_transcript, - ]; + let mut ordered = vec![commands, newline, file_paths, paste_image, find, find_next]; + ordered.extend(vec![edit_previous, quit, Line::from(""), show_transcript]); build_columns(ordered) } @@ -286,6 +288,8 @@ enum ShortcutId { InsertNewline, FilePaths, PasteImage, + Find, + FindNext, EditPrevious, Quit, ShowTranscript, @@ -406,6 +410,24 @@ const SHORTCUTS: &[ShortcutDescriptor] = &[ prefix: "", label: " to paste images", }, + ShortcutDescriptor { + id: ShortcutId::Find, + bindings: &[ShortcutBinding { + key: key_hint::ctrl(KeyCode::Char('f')), + condition: DisplayCondition::Always, + }], + prefix: "", + label: " to search transcript", + }, + ShortcutDescriptor { + id: ShortcutId::FindNext, + bindings: &[ShortcutBinding { + key: key_hint::ctrl(KeyCode::Char('g')), + condition: DisplayCondition::Always, + }], + prefix: "", + label: " next match", + }, ShortcutDescriptor { id: ShortcutId::EditPrevious, bindings: &[ShortcutBinding { @@ -469,6 +491,7 @@ mod tests { transcript_selection_active: false, transcript_scroll_position: None, transcript_copy_selection_key: key_hint::ctrl_shift(KeyCode::Char('c')), + transcript_find_visible: false, }, ); @@ -485,6 +508,7 @@ mod tests { transcript_selection_active: true, transcript_scroll_position: Some((3, 42)), transcript_copy_selection_key: key_hint::ctrl_shift(KeyCode::Char('c')), + transcript_find_visible: false, }, ); @@ -501,6 +525,7 @@ mod tests { transcript_selection_active: false, transcript_scroll_position: None, transcript_copy_selection_key: key_hint::ctrl_shift(KeyCode::Char('c')), + transcript_find_visible: false, }, ); @@ -517,6 +542,7 @@ mod tests { transcript_selection_active: false, transcript_scroll_position: None, transcript_copy_selection_key: key_hint::ctrl_shift(KeyCode::Char('c')), + transcript_find_visible: false, }, ); @@ -533,6 +559,7 @@ mod tests { transcript_selection_active: false, transcript_scroll_position: None, transcript_copy_selection_key: key_hint::ctrl_shift(KeyCode::Char('c')), + transcript_find_visible: false, }, ); @@ -549,6 +576,7 @@ mod tests { transcript_selection_active: false, transcript_scroll_position: None, transcript_copy_selection_key: key_hint::ctrl_shift(KeyCode::Char('c')), + transcript_find_visible: false, }, ); @@ -565,6 +593,7 @@ mod tests { transcript_selection_active: false, transcript_scroll_position: None, transcript_copy_selection_key: key_hint::ctrl_shift(KeyCode::Char('c')), + transcript_find_visible: false, }, ); @@ -581,6 +610,7 @@ mod tests { transcript_selection_active: false, transcript_scroll_position: None, transcript_copy_selection_key: key_hint::ctrl_shift(KeyCode::Char('c')), + transcript_find_visible: false, }, ); @@ -597,6 +627,7 @@ mod tests { transcript_selection_active: false, transcript_scroll_position: None, transcript_copy_selection_key: key_hint::ctrl_shift(KeyCode::Char('c')), + transcript_find_visible: false, }, ); } diff --git a/codex-rs/tui2/src/bottom_pane/mod.rs b/codex-rs/tui2/src/bottom_pane/mod.rs index c6ea991c80..03b17d98ae 100644 --- a/codex-rs/tui2/src/bottom_pane/mod.rs +++ b/codex-rs/tui2/src/bottom_pane/mod.rs @@ -387,12 +387,14 @@ impl BottomPane { selection_active: bool, scroll_position: Option<(usize, usize)>, copy_selection_key: crate::key_hint::KeyBinding, + find_visible: bool, ) { self.composer.set_transcript_ui_state( scrolled, selection_active, scroll_position, copy_selection_key, + find_visible, ); self.request_redraw(); } diff --git a/codex-rs/tui2/src/bottom_pane/snapshots/codex_tui2__bottom_pane__chat_composer__tests__footer_mode_shortcut_overlay.snap b/codex-rs/tui2/src/bottom_pane/snapshots/codex_tui2__bottom_pane__chat_composer__tests__footer_mode_shortcut_overlay.snap index 178182bfd7..5802663466 100644 --- a/codex-rs/tui2/src/bottom_pane/snapshots/codex_tui2__bottom_pane__chat_composer__tests__footer_mode_shortcut_overlay.snap +++ b/codex-rs/tui2/src/bottom_pane/snapshots/codex_tui2__bottom_pane__chat_composer__tests__footer_mode_shortcut_overlay.snap @@ -12,5 +12,6 @@ expression: terminal.backend() " " " / for commands shift + enter for newline " " @ for file paths ctrl + v to paste images " +" ctrl + f to search transcript ctrl + g next match " " esc again to edit previous message ctrl + c to exit " " ctrl + t to view transcript " diff --git a/codex-rs/tui2/src/bottom_pane/snapshots/codex_tui2__bottom_pane__footer__tests__footer_shortcuts_shift_and_esc.snap b/codex-rs/tui2/src/bottom_pane/snapshots/codex_tui2__bottom_pane__footer__tests__footer_shortcuts_shift_and_esc.snap index 47508f3240..f602dee4b2 100644 --- a/codex-rs/tui2/src/bottom_pane/snapshots/codex_tui2__bottom_pane__footer__tests__footer_shortcuts_shift_and_esc.snap +++ b/codex-rs/tui2/src/bottom_pane/snapshots/codex_tui2__bottom_pane__footer__tests__footer_shortcuts_shift_and_esc.snap @@ -4,5 +4,6 @@ expression: terminal.backend() --- " / for commands shift + enter for newline " " @ for file paths ctrl + v to paste images " +" ctrl + f to search transcript ctrl + g next match " " esc again to edit previous message ctrl + c to exit " " ctrl + t to view transcript " diff --git a/codex-rs/tui2/src/chatwidget.rs b/codex-rs/tui2/src/chatwidget.rs index ee0dd75ddb..4297ffbbec 100644 --- a/codex-rs/tui2/src/chatwidget.rs +++ b/codex-rs/tui2/src/chatwidget.rs @@ -3097,12 +3097,14 @@ impl ChatWidget { selection_active: bool, scroll_position: Option<(usize, usize)>, copy_selection_key: crate::key_hint::KeyBinding, + find_visible: bool, ) { self.bottom_pane.set_transcript_ui_state( scrolled, selection_active, scroll_position, copy_selection_key, + find_visible, ); } diff --git a/codex-rs/tui2/src/lib.rs b/codex-rs/tui2/src/lib.rs index 4583a5d84a..4fc185b9ba 100644 --- a/codex-rs/tui2/src/lib.rs +++ b/codex-rs/tui2/src/lib.rs @@ -79,6 +79,7 @@ mod text_formatting; mod tooltips; mod transcript_copy; mod transcript_copy_ui; +mod transcript_find; mod transcript_multi_click; mod transcript_render; mod transcript_selection; diff --git a/codex-rs/tui2/src/transcript_find.rs b/codex-rs/tui2/src/transcript_find.rs new file mode 100644 index 0000000000..cebac800b0 --- /dev/null +++ b/codex-rs/tui2/src/transcript_find.rs @@ -0,0 +1,889 @@ +//! Inline "find in transcript" support for the TUI2 inline viewport. +//! +//! This module is intentionally UI-framework-light: it holds the find state and provides helpers +//! that `app.rs` calls from the normal render loop. +//! +//! **Integration points (in `tui2/src/app.rs`):** +//! +//! - Early key handling delegation via [`TranscriptFind::handle_key_event`] +//! - Per-frame update/jump hook via [`TranscriptFind::on_render`] +//! - Per-row highlight hook via [`TranscriptFind::render_line`] +//! - Prompt rendering + cursor positioning via [`TranscriptFind::render_prompt_line`] and +//! [`TranscriptFind::cursor_position`] +//! +//! The search operates on the flattened transcript lines (`Line<'static>`) that are already used +//! for scrolling and selection, and produces stable jump targets via +//! [`TranscriptLineMeta::cell_line`](TranscriptLineMeta::cell_line). + +use crate::render::line_utils::line_to_static; +use crate::tui::scrolling::TranscriptLineMeta; +use crossterm::event::KeyCode; +use crossterm::event::KeyEvent; +use crossterm::event::KeyEventKind; +use crossterm::event::KeyModifiers; +use ratatui::layout::Rect; +use ratatui::style::Style; +use ratatui::style::Stylize; +use ratatui::text::Line; +use ratatui::text::Span; +use unicode_width::UnicodeWidthStr as _; + +use std::ops::Range; + +#[derive(Debug, Default)] +/// Stateful "find in transcript" controller for the inline viewport. +/// +/// The state is designed around the "ephemeral prompt" UX: +/// +/// - `Ctrl-F` enters editing mode (prompt line becomes visible) +/// - `Esc` closes the prompt while keeping highlights (query remains) +/// - `Esc` again clears the query/highlights +/// - `Ctrl-G` advances to the next match while editing or while a query is still active +/// +/// The caller is expected to: +/// +/// - call [`TranscriptFind::on_render`] after flattening the transcript into `lines` + `line_meta` +/// - call [`TranscriptFind::render_line`] for visible rows +/// - use [`TranscriptFind::render_prompt_line`] + [`TranscriptFind::cursor_position`] when editing +pub(crate) struct TranscriptFind { + /// Current query text (plain UTF-8 string). + query: String, + + /// Whether the prompt is currently visible and receiving input. + editing: bool, + + /// Cached width of the flattened transcript viewport. + /// + /// Width changes can change wrapping and therefore the flattened line list. + last_width: Option, + + /// Cached flattened line count. + /// + /// Note: callers should also invoke [`TranscriptFind::note_lines_changed`] when content changes + /// without changing the count (e.g., streaming updates). + last_lines_len: Option, + + /// All matches across the flattened transcript, in display order (line-major, left-to-right). + matches: Vec, + + /// Per-line mapping to indices into `matches`, used by `render_line`. + line_match_indices: Vec>, + + /// Index into `matches` representing the current match (if any). + current_match: Option, + + /// Stable identifier for the current match used to preserve selection across recompute. + /// + /// `(line_index, match_range_start)`. + current_key: Option<(usize, usize)>, + + /// A navigation action to apply on the next [`on_render`](TranscriptFind::on_render) call. + pending: Option, +} + +#[derive(Debug, Clone, Copy)] +/// Deferred navigation requests applied at the next render. +/// +/// This allows key events to be handled without directly mutating the scroll state; instead we +/// produce a stable anchor from [`TranscriptLineMeta`] during [`TranscriptFind::on_render`]. +enum TranscriptFindPendingAction { + /// Jump to the "best" match for the current viewport (used after Enter). + Jump, + /// Advance the current match, wrapping around (used by Ctrl-G). + Next, +} + +#[derive(Debug, Clone)] +/// One match within the flattened transcript. +/// +/// Matches are stored in "scan order" (line-major, then left-to-right), and carry a stable anchor +/// for jumping the viewport. +struct TranscriptFindMatch { + line_index: usize, + range: Range, + /// Stable `(cell_index, line_in_cell)` anchor used to update scroll state. + anchor: Option<(usize, usize)>, +} + +impl TranscriptFind { + /// Whether find is active (either editing or a non-empty query is present). + pub(crate) fn is_active(&self) -> bool { + self.editing || !self.query.is_empty() + } + + /// Whether the find prompt should be visible. + pub(crate) fn is_visible(&self) -> bool { + self.editing + } + + /// Force a recompute on the next render. + /// + /// This is intended for "content changed but line count didn't" scenarios, such as streaming + /// assistant output updating in-place. + pub(crate) fn note_lines_changed(&mut self) { + if self.is_active() { + self.last_lines_len = None; + } + } + + /// Handle find-related key events. + /// + /// Returns `true` when the key was consumed by the find UI/state machine. + /// + /// This method updates internal state only; it does not directly change the transcript scroll + /// position. Navigation keys (e.g. `Ctrl-G`, `Enter`) set a pending action that is applied on + /// the next [`on_render`](Self::on_render) call, where we can map the current match to a stable + /// `(cell_index, line_in_cell)` anchor. + pub(crate) fn handle_key_event(&mut self, key_event: &KeyEvent) -> bool { + match *key_event { + KeyEvent { + code: KeyCode::Char('f'), + modifiers: KeyModifiers::CONTROL, + kind: KeyEventKind::Press, + .. + } => { + self.begin_edit(); + true + } + KeyEvent { + code: KeyCode::Esc, + kind: KeyEventKind::Press | KeyEventKind::Repeat, + .. + } if self.editing => { + self.end_edit(); + true + } + KeyEvent { + code: KeyCode::Esc, + kind: KeyEventKind::Press | KeyEventKind::Repeat, + .. + } if !self.query.is_empty() => { + self.clear(); + true + } + KeyEvent { + code: KeyCode::Char('g'), + modifiers: KeyModifiers::CONTROL, + kind: KeyEventKind::Press | KeyEventKind::Repeat, + .. + } if self.editing || !self.query.is_empty() => { + self.set_pending(TranscriptFindPendingAction::Next); + true + } + _ if self.editing => { + self.handle_edit_key(*key_event); + true + } + _ => false, + } + } + + /// Cursor position for the prompt line (when editing). + /// + /// The caller passes the full frame `area` and the computed top of the chat widget (`chat_top`) + /// so we can position the cursor on the line directly above the composer. + pub(crate) fn cursor_position(&self, area: Rect, chat_top: u16) -> Option<(u16, u16)> { + if !self.editing || chat_top <= area.y { + return None; + } + + let prefix_w = "/ ".width() as u16; + let query_w = self.query.width() as u16; + let x = area + .x + .saturating_add(prefix_w) + .saturating_add(query_w) + .min(area.right().saturating_sub(1)); + let y = chat_top.saturating_sub(1); + Some((x, y)) + } + + /// Update match state and apply any pending navigation request. + /// + /// Returns a stable transcript anchor `(cell_index, line_in_cell)` to be consumed by the + /// transcript scroll state (e.g. to jump the viewport to the current match). + /// + /// Returns `None` when: + /// - find is inactive, or + /// - no navigation action is pending, or + /// - the current match does not map to a stable anchor (e.g. no `CellLine` meta). + pub(crate) fn on_render( + &mut self, + lines: &[Line<'static>], + line_meta: &[TranscriptLineMeta], + width: u16, + preferred_line: usize, + ) -> Option<(usize, usize)> { + if !self.is_active() { + return None; + } + + self.ensure_up_to_date(lines, line_meta, width, preferred_line); + self.apply_pending(preferred_line) + } + + /// Render a transcript line with match highlighting (if active). + /// + /// Highlighting is computed on the flattened plain text of the line (see [`line_plain_text`]) + /// and then applied back onto the styled spans by splitting and patching styles. + /// + /// Styling conventions: + /// - All matches: underlined + /// - Current match: reversed + bold + underlined + pub(crate) fn render_line(&self, line_index: usize, line: &Line<'_>) -> Line<'static> { + if self.query.is_empty() { + return line_to_static(line); + } + + let indices = self.match_indices_for_line(line_index); + if indices.is_empty() { + return line_to_static(line); + } + + let mut ranges: Vec<(Range, Style)> = Vec::with_capacity(indices.len()); + for idx in indices { + let m = &self.matches[*idx]; + let style = if self.current_match == Some(*idx) { + Style::new().reversed().bold().underlined() + } else { + Style::new().underlined() + }; + ranges.push((m.range.clone(), style)); + } + highlight_line(line, &ranges) + } + + /// Render the prompt row shown above the composer while editing. + pub(crate) fn render_prompt_line(&self) -> Option> { + if !self.editing { + return None; + } + + let (current, total) = self.match_summary(); + let mut spans: Vec> = vec!["/ ".dim()]; + spans.push(self.query.clone().into()); + if !self.query.is_empty() { + spans.push(format!(" {current}/{total}").dim()); + } + Some(Line::from(spans)) + } + + /// Handle key events while editing the query. + /// + /// This is only called when [`Self::editing`] is true and the event wasn't handled by the + /// top-level key bindings (e.g., `Ctrl-F`, `Ctrl-G`, `Esc`). + fn handle_edit_key(&mut self, key_event: KeyEvent) { + match key_event { + KeyEvent { + code: KeyCode::Enter, + kind: KeyEventKind::Press, + .. + } => { + self.editing = false; + self.set_pending(TranscriptFindPendingAction::Jump); + } + KeyEvent { + code: KeyCode::Char('u'), + modifiers: KeyModifiers::CONTROL, + kind: KeyEventKind::Press | KeyEventKind::Repeat, + .. + } => { + self.clear_query(); + } + KeyEvent { + code: KeyCode::Backspace, + kind: KeyEventKind::Press | KeyEventKind::Repeat, + .. + } => { + self.backspace(); + } + KeyEvent { + code: KeyCode::Char(c), + modifiers, + kind: KeyEventKind::Press | KeyEventKind::Repeat, + .. + } if !crate::key_hint::has_ctrl_or_alt(modifiers) => { + self.push_char(c); + } + _ => {} + } + } + + /// Enter editing mode (idempotent). + fn begin_edit(&mut self) { + if self.editing { + return; + } + self.editing = true; + } + + /// Exit editing mode without clearing the query/highlights. + fn end_edit(&mut self) { + self.editing = false; + } + + /// Clear all state, including query/highlights and cached match results. + pub(crate) fn clear(&mut self) { + self.query.clear(); + self.editing = false; + self.last_width = None; + self.last_lines_len = None; + self.matches.clear(); + self.line_match_indices.clear(); + self.current_match = None; + self.current_key = None; + self.pending = None; + } + + /// Clear the query text while leaving editing mode unchanged. + /// + /// This resets the cached width so matches will be recomputed when the user types again. + fn clear_query(&mut self) { + self.query.clear(); + self.last_width = None; + } + + /// Remove a single character from the end of the query. + fn backspace(&mut self) { + if self.query.pop().is_some() { + self.last_width = None; + } + } + + /// Append one character to the query. + fn push_char(&mut self, ch: char) { + self.query.push(ch); + self.last_width = None; + } + + /// Record a navigation request to be applied on the next render. + fn set_pending(&mut self, pending: TranscriptFindPendingAction) { + self.pending = Some(pending); + } + + /// Recompute matches when the flattened transcript representation changes. + /// + /// This function is keyed on `width` and `lines.len()` to avoid work on every frame; callers + /// should invoke [`TranscriptFind::note_lines_changed`] if content changes without affecting + /// those keys. + /// + /// When recomputing: + /// - we preserve the selected match using [`Self::current_key`] + /// - we store a stable jump anchor (cell + line-in-cell) when available via + /// [`TranscriptLineMeta::cell_line`] + fn ensure_up_to_date( + &mut self, + lines: &[Line<'static>], + line_meta: &[TranscriptLineMeta], + width: u16, + preferred_line: usize, + ) { + // Fast path: empty query means there are no matches, and we should treat find as inactive + // (even if `editing` is still true). + if self.query.is_empty() { + self.matches.clear(); + self.line_match_indices.clear(); + self.current_match = None; + self.current_key = None; + self.last_width = Some(width); + self.last_lines_len = Some(lines.len()); + return; + } + + // Cache key: if width and line count are unchanged, the flattened transcript representation + // is assumed stable enough to reuse the previous match computation. Callers should use + // `note_lines_changed()` when content changes without affecting these keys. + if self.last_width == Some(width) && self.last_lines_len == Some(lines.len()) { + return; + } + + // Preserve selection across recompute by remembering the currently-selected match's + // identity (line index + start byte offset in the flattened plain text). + let current_key = self.current_key.take(); + self.matches.clear(); + self.line_match_indices = vec![Vec::new(); lines.len()]; + + // Scan each flattened line, extracting plain text and recording match ranges. We keep: + // - a global `matches` list in scan order for navigation (Ctrl-G) + // - a per-line index list for rendering highlights efficiently + for (line_index, line) in lines.iter().enumerate() { + let plain = line_plain_text(line); + let ranges = find_match_ranges(&plain, &self.query); + for range in ranges { + let idx = self.matches.len(); + // Only `CellLine` entries have stable anchors suitable for scroll jumps. + let anchor = line_meta + .get(line_index) + .and_then(TranscriptLineMeta::cell_line); + self.matches.push(TranscriptFindMatch { + line_index, + range: range.clone(), + anchor, + }); + self.line_match_indices[line_index].push(idx); + } + } + + // Choose the current match: + // 1) Prefer to keep the previous selection if it still exists in the new match set. + // 2) Otherwise, pick the first match at/after the preferred line (top of the viewport). + // 3) Otherwise, wrap to the first match. + self.current_match = current_key + .and_then(|(line_index, start)| { + self.matches + .iter() + .position(|m| m.line_index == line_index && m.range.start == start) + }) + .or_else(|| { + self.matches + .iter() + .position(|m| m.line_index >= preferred_line) + .or_else(|| (!self.matches.is_empty()).then_some(0)) + }); + self.current_key = self.current_match.map(|i| { + let m = &self.matches[i]; + (m.line_index, m.range.start) + }); + + self.last_width = Some(width); + self.last_lines_len = Some(lines.len()); + } + + /// Apply a pending navigation action (if any) and return the anchor to scroll to. + /// + /// The returned `(cell_index, line_in_cell)` is derived from [`TranscriptLineMeta`] and is used + /// by the caller to update the transcript scroll state. + /// + /// Note: the internal "current match" is updated even when an anchor is unavailable; in that + /// case this returns `None` and the caller should not change scroll position. + fn apply_pending(&mut self, preferred_line: usize) -> Option<(usize, usize)> { + let pending = self.pending.take()?; + if self.matches.is_empty() { + self.current_match = None; + self.current_key = None; + return None; + } + + match pending { + TranscriptFindPendingAction::Jump => { + if self.current_match.is_none() { + self.current_match = self + .matches + .iter() + .position(|m| m.line_index >= preferred_line) + .or_else(|| (!self.matches.is_empty()).then_some(0)); + } + } + TranscriptFindPendingAction::Next => { + self.current_match = Some(match self.current_match { + Some(i) => (i + 1) % self.matches.len(), + None => 0, + }); + } + } + + self.current_key = self.current_match.map(|i| { + let m = &self.matches[i]; + (m.line_index, m.range.start) + }); + + self.current_match.and_then(|i| self.matches[i].anchor) + } + + /// Return indices into `matches` for the given flattened line index. + /// + /// Out-of-range indices return an empty slice to simplify call sites. + fn match_indices_for_line(&self, line_index: usize) -> &[usize] { + self.line_match_indices + .get(line_index) + .map_or(&[], |v| v.as_slice()) + } + + /// Return `(current, total)` match counts for the prompt display. + /// + /// `current` is 1-based (`0` when no match is selected), and `total` is the number of matches. + fn match_summary(&self) -> (usize, usize) { + let total = self.matches.len(); + let current = self.current_match.map(|i| i + 1).unwrap_or(0); + (current, total) + } +} + +/// Convert a styled [`Line`] into plain text by concatenating its spans. +/// +/// Find operates on plain text for match computation. Styling is applied later during +/// [`highlight_line`]. +fn line_plain_text(line: &Line<'_>) -> String { + let mut out = String::new(); + for span in &line.spans { + out.push_str(span.content.as_ref()); + } + out +} + +/// Find all non-overlapping match ranges of `needle` within `haystack`. +/// +/// Uses "smart-case" semantics: +/// - If `needle` contains any ASCII uppercase letters, matching is case-sensitive. +/// - Otherwise, both strings are compared in ASCII lowercase. +fn find_match_ranges(haystack: &str, needle: &str) -> Vec> { + if needle.is_empty() { + return Vec::new(); + } + let is_case_sensitive = needle.chars().any(|c| c.is_ascii_uppercase()); + if is_case_sensitive { + find_match_ranges_exact(haystack, needle) + } else { + let haystack = haystack.to_ascii_lowercase(); + let needle = needle.to_ascii_lowercase(); + find_match_ranges_exact(&haystack, &needle) + } +} + +/// Find all non-overlapping match ranges of `needle` within `haystack` (case-sensitive). +/// +/// Ranges are byte indices into the provided `haystack` string. +fn find_match_ranges_exact(haystack: &str, needle: &str) -> Vec> { + let mut out = Vec::new(); + let mut start = 0usize; + while start <= haystack.len() { + let Some(rel) = haystack[start..].find(needle) else { + break; + }; + let abs = start + rel; + let end = abs + needle.len(); + out.push(abs..end); + start = end; + } + out +} + +/// Apply highlighting styles to the provided `line`. +/// +/// `ranges` is a list of `(byte_range, style)` pairs referring to byte offsets in the flattened +/// plain text of `line` (see [`line_plain_text`]). The implementation preserves existing span +/// styles by patching the requested highlight style onto each affected segment. +fn highlight_line(line: &Line<'_>, ranges: &[(Range, Style)]) -> Line<'static> { + if ranges.is_empty() { + return line_to_static(line); + } + + // We treat `line` as a single flattened string (see `line_plain_text`) and map highlight byte + // ranges back onto the individual styled spans. As we walk spans left-to-right we keep a + // "global" byte cursor into the flattened text plus an index into `ranges`. + let mut out: Vec> = Vec::new(); + let mut global_pos = 0usize; + let mut range_idx = 0usize; + + for span in &line.spans { + let text = span.content.as_ref(); + let span_start = global_pos; + let span_end = span_start + text.len(); + global_pos = span_end; + + // Skip ranges that end before this span begins. This keeps `range_idx` pointing at the + // first range that could possibly overlap the current span. + while range_idx < ranges.len() && ranges[range_idx].0.end <= span_start { + range_idx += 1; + } + + // `local_pos` tracks how much of the current span we've emitted. We may need to split the + // span into multiple segments as highlight ranges start/end within it. + let mut local_pos = 0usize; + while range_idx < ranges.len() { + let (range, extra_style) = &ranges[range_idx]; + if range.start >= span_end { + break; + } + + // Clamp the global match range to this span's global extent. + let start = range.start.max(span_start); + let end = range.end.min(span_end); + + let start_local = start - span_start; + // Emit any unhighlighted segment that appears before the match starts. + if start_local > local_pos { + out.push(Span::styled( + text[local_pos..start_local].to_string(), + span.style, + )); + } + + let end_local = end - span_start; + // Emit the highlighted segment, preserving existing style by patching it with the + // requested highlight style. + out.push(Span::styled( + text[start_local..end_local].to_string(), + span.style.patch(*extra_style), + )); + local_pos = end_local; + + // Advance to the next range when we've consumed the entire range within this span. + // When the range extends past this span, we keep `range_idx` pinned and continue the + // highlighting into the next span. + if range.end <= span_end { + range_idx += 1; + } else { + break; + } + } + + // Emit any remaining tail of the span that doesn't intersect a highlight range. + if local_pos < text.len() { + out.push(Span::styled(text[local_pos..].to_string(), span.style)); + } + } + + Line::from(out) +} + +#[cfg(test)] +mod tests { + use super::*; + use crossterm::event::KeyEvent; + use crossterm::event::KeyModifiers; + use pretty_assertions::assert_eq; + + /// Build `TranscriptLineMeta` for a single-cell transcript with `line_count` lines. + fn meta_cell_lines(line_count: usize) -> Vec { + (0..line_count) + .map(|line_in_cell| TranscriptLineMeta::CellLine { + cell_index: 0, + line_in_cell, + }) + .collect() + } + + /// Convenience for a key press with no modifiers. + fn key(code: KeyCode) -> KeyEvent { + KeyEvent::new(code, KeyModifiers::NONE) + } + + /// Convenience for a key press with the control modifier. + fn ctrl(code: KeyCode) -> KeyEvent { + KeyEvent::new(code, KeyModifiers::CONTROL) + } + + #[test] + fn smart_case_and_pending_jump() { + let lines: Vec> = vec![Line::from("hello World"), Line::from("second world")]; + let meta = vec![ + TranscriptLineMeta::CellLine { + cell_index: 0, + line_in_cell: 0, + }, + TranscriptLineMeta::CellLine { + cell_index: 0, + line_in_cell: 1, + }, + ]; + + let mut find = TranscriptFind { + query: "world".to_string(), + ..Default::default() + }; + assert_eq!(find.on_render(&lines, &meta, 80, 0), None); + assert_eq!(find.matches.len(), 2); + + find.current_match = None; + find.current_key = None; + find.pending = Some(TranscriptFindPendingAction::Jump); + let anchor = find.on_render(&lines, &meta, 80, 1); + assert_eq!(anchor, Some((0, 1))); + + find.clear(); + find.query = "World".to_string(); + let _ = find.on_render(&lines, &meta, 80, 0); + assert_eq!( + find.matches + .iter() + .map(|m| m.line_index) + .collect::>(), + vec![0] + ); + } + + #[test] + fn ctrl_f_enters_editing_and_renders_prompt() { + let mut find = TranscriptFind::default(); + assert!(!find.is_active()); + + assert!(find.handle_key_event(&ctrl(KeyCode::Char('f')))); + assert!(find.is_active()); + assert!(find.is_visible()); + + let prompt = find.render_prompt_line().expect("prompt line"); + assert_eq!( + prompt + .spans + .iter() + .map(|span| span.content.as_ref()) + .collect::>(), + vec!["/ ", ""] + ); + } + + #[test] + fn edit_keys_modify_query() { + let mut find = TranscriptFind::default(); + let _ = find.handle_key_event(&ctrl(KeyCode::Char('f'))); + + let _ = find.handle_key_event(&key(KeyCode::Char('a'))); + let _ = find.handle_key_event(&key(KeyCode::Char('b'))); + assert_eq!(find.query, "ab"); + + let _ = find.handle_key_event(&key(KeyCode::Backspace)); + assert_eq!(find.query, "a"); + + let _ = find.handle_key_event(&ctrl(KeyCode::Char('u'))); + assert_eq!(find.query, ""); + } + + #[test] + fn enter_closes_prompt_and_requests_jump() { + let lines: Vec> = vec![Line::from("one two"), Line::from("two three")]; + let meta = meta_cell_lines(lines.len()); + + let mut find = TranscriptFind::default(); + let _ = find.handle_key_event(&ctrl(KeyCode::Char('f'))); + for ch in "two".chars() { + let _ = find.handle_key_event(&key(KeyCode::Char(ch))); + } + + let _ = find.handle_key_event(&key(KeyCode::Enter)); + assert!(!find.is_visible()); + assert!(find.is_active()); + assert_eq!(find.render_prompt_line(), None); + + let anchor = find.on_render(&lines, &meta, 80, 0); + assert_eq!(anchor, Some((0, 0))); + } + + #[test] + fn esc_closes_prompt_then_clears_query() { + let mut find = TranscriptFind::default(); + let _ = find.handle_key_event(&ctrl(KeyCode::Char('f'))); + for ch in "two".chars() { + let _ = find.handle_key_event(&key(KeyCode::Char(ch))); + } + + let _ = find.handle_key_event(&key(KeyCode::Esc)); + assert!(!find.is_visible()); + assert!(find.is_active()); + assert_eq!(find.query, "two"); + + let _ = find.handle_key_event(&key(KeyCode::Esc)); + assert!(!find.is_active()); + assert_eq!(find.query, ""); + } + + #[test] + fn ctrl_g_cycles_matches_after_prompt_closed() { + let lines: Vec> = + vec![Line::from("world world"), Line::from("another world")]; + let meta = meta_cell_lines(lines.len()); + + let mut find = TranscriptFind::default(); + let _ = find.handle_key_event(&ctrl(KeyCode::Char('f'))); + for ch in "world".chars() { + let _ = find.handle_key_event(&key(KeyCode::Char(ch))); + } + let _ = find.handle_key_event(&key(KeyCode::Enter)); + + let a0 = find.on_render(&lines, &meta, 80, 0); + assert_eq!(a0, Some((0, 0))); + + assert!(find.handle_key_event(&ctrl(KeyCode::Char('g')))); + let a1 = find.on_render(&lines, &meta, 80, 0); + assert_eq!(a1, Some((0, 0))); + + assert!(find.handle_key_event(&ctrl(KeyCode::Char('g')))); + let a2 = find.on_render(&lines, &meta, 80, 0); + assert_eq!(a2, Some((0, 1))); + + assert!(find.handle_key_event(&ctrl(KeyCode::Char('g')))); + let a3 = find.on_render(&lines, &meta, 80, 0); + assert_eq!(a3, Some((0, 0))); + } + + #[test] + fn note_lines_changed_forces_recompute() { + let mut find = TranscriptFind { + query: "hello".to_string(), + ..Default::default() + }; + + let lines_v1 = vec![Line::from("hello")]; + let meta = meta_cell_lines(lines_v1.len()); + let _ = find.on_render(&lines_v1, &meta, 80, 0); + assert_eq!(find.matches.len(), 1); + + find.note_lines_changed(); + assert_eq!(find.last_lines_len, None); + + let lines_v2 = vec![Line::from("world")]; + let _ = find.on_render(&lines_v2, &meta, 80, 0); + assert_eq!(find.matches.len(), 0); + } + + #[test] + fn render_line_highlights_current_match_more_strongly() { + let lines = vec![Line::from("aa")]; + let meta = meta_cell_lines(lines.len()); + + let mut find = TranscriptFind { + query: "a".to_string(), + ..Default::default() + }; + let _ = find.on_render(&lines, &meta, 80, 0); + + let rendered = find.render_line(0, &lines[0]); + assert_eq!( + rendered, + Line::from(vec![ + Span::styled("a", Style::new().reversed().bold().underlined()), + Span::styled("a", Style::new().underlined()), + ]) + ); + } + + #[test] + fn highlight_line_supports_ranges_across_span_boundaries() { + let line = Line::from(vec!["hello ".into(), "world".into()]); + let query = "o w"; + let ranges = find_match_ranges(&line_plain_text(&line), query); + assert_eq!(ranges, vec![4..7]); + + let rendered = highlight_line(&line, &[(ranges[0].clone(), Style::new().underlined())]); + assert_eq!( + rendered, + Line::from(vec![ + Span::styled("hell".to_string(), Style::default()), + Span::styled("o ".to_string(), Style::new().underlined()), + Span::styled("w".to_string(), Style::new().underlined()), + Span::styled("orld".to_string(), Style::default()), + ]) + ); + } + + #[test] + fn cursor_position_clamps_to_prompt_line_width() { + let mut find = TranscriptFind { + query: "abcdef".to_string(), + editing: true, + ..Default::default() + }; + + let area = Rect::new(10, 5, 5, 5); + let (x, y) = find.cursor_position(area, 8).expect("cursor"); + assert_eq!(y, 7); + assert_eq!(x, area.right().saturating_sub(1)); + find.clear(); + } + + #[test] + fn ctrl_g_is_ignored_when_inactive() { + let mut find = TranscriptFind::default(); + assert!(!find.handle_key_event(&ctrl(KeyCode::Char('g')))); + } +}