From 2098b4036994878fffb221459bebd96648375285 Mon Sep 17 00:00:00 2001 From: easong-openai Date: Wed, 6 Aug 2025 21:23:09 -0700 Subject: [PATCH] Scrollable slash commands (#1830) Scrollable slash commands. Part 1 of the multi PR. --- codex-rs/common/src/fuzzy_match.rs | 177 ++++++++++++++++++ codex-rs/common/src/lib.rs | 2 + codex-rs/tui/src/bottom_pane/command_popup.rs | 173 ++++++----------- .../tui/src/bottom_pane/file_search_popup.rs | 129 ++++--------- codex-rs/tui/src/bottom_pane/mod.rs | 3 + codex-rs/tui/src/bottom_pane/popup_consts.rs | 5 + codex-rs/tui/src/bottom_pane/scroll_state.rs | 115 ++++++++++++ .../src/bottom_pane/selection_popup_common.rs | 126 +++++++++++++ 8 files changed, 523 insertions(+), 207 deletions(-) create mode 100644 codex-rs/common/src/fuzzy_match.rs create mode 100644 codex-rs/tui/src/bottom_pane/popup_consts.rs create mode 100644 codex-rs/tui/src/bottom_pane/scroll_state.rs create mode 100644 codex-rs/tui/src/bottom_pane/selection_popup_common.rs diff --git a/codex-rs/common/src/fuzzy_match.rs b/codex-rs/common/src/fuzzy_match.rs new file mode 100644 index 00000000..836848d6 --- /dev/null +++ b/codex-rs/common/src/fuzzy_match.rs @@ -0,0 +1,177 @@ +/// Simple case-insensitive subsequence matcher used for fuzzy filtering. +/// +/// Returns the indices (character positions) of the matched characters in the +/// ORIGINAL `haystack` string and a score where smaller is better. +/// +/// Unicode correctness: we perform the match on a lowercased copy of the +/// haystack and needle but maintain a mapping from each character in the +/// lowercased haystack back to the original character index in `haystack`. +/// This ensures the returned indices can be safely used with +/// `str::chars().enumerate()` consumers for highlighting, even when +/// lowercasing expands certain characters (e.g., ß → ss, İ → i̇). +pub fn fuzzy_match(haystack: &str, needle: &str) -> Option<(Vec, i32)> { + if needle.is_empty() { + return Some((Vec::new(), i32::MAX)); + } + + let mut lowered_chars: Vec = Vec::new(); + let mut lowered_to_orig_char_idx: Vec = Vec::new(); + for (orig_idx, ch) in haystack.chars().enumerate() { + for lc in ch.to_lowercase() { + lowered_chars.push(lc); + lowered_to_orig_char_idx.push(orig_idx); + } + } + + let lowered_needle: Vec = needle.to_lowercase().chars().collect(); + + let mut result_orig_indices: Vec = Vec::with_capacity(lowered_needle.len()); + let mut last_lower_pos: Option = None; + let mut cur = 0usize; + for &nc in lowered_needle.iter() { + let mut found_at: Option = None; + while cur < lowered_chars.len() { + if lowered_chars[cur] == nc { + found_at = Some(cur); + cur += 1; + break; + } + cur += 1; + } + let pos = found_at?; + result_orig_indices.push(lowered_to_orig_char_idx[pos]); + last_lower_pos = Some(pos); + } + + let first_lower_pos = if result_orig_indices.is_empty() { + 0usize + } else { + let target_orig = result_orig_indices[0]; + lowered_to_orig_char_idx + .iter() + .position(|&oi| oi == target_orig) + .unwrap_or(0) + }; + // last defaults to first for single-hit; score = extra span between first/last hit + // minus needle len (≥0). + // Strongly reward prefix matches by subtracting 100 when the first hit is at index 0. + let last_lower_pos = last_lower_pos.unwrap_or(first_lower_pos); + let window = + (last_lower_pos as i32 - first_lower_pos as i32 + 1) - (lowered_needle.len() as i32); + let mut score = window.max(0); + if first_lower_pos == 0 { + score -= 100; + } + + result_orig_indices.sort_unstable(); + result_orig_indices.dedup(); + Some((result_orig_indices, score)) +} + +/// Convenience wrapper to get only the indices for a fuzzy match. +pub fn fuzzy_indices(haystack: &str, needle: &str) -> Option> { + fuzzy_match(haystack, needle).map(|(mut idx, _)| { + idx.sort_unstable(); + idx.dedup(); + idx + }) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn ascii_basic_indices() { + let (idx, score) = match fuzzy_match("hello", "hl") { + Some(v) => v, + None => panic!("expected a match"), + }; + assert_eq!(idx, vec![0, 2]); + // 'h' at 0, 'l' at 2 -> window 1; start-of-string bonus applies (-100) + assert_eq!(score, -99); + } + + #[test] + fn unicode_dotted_i_istanbul_highlighting() { + let (idx, score) = match fuzzy_match("İstanbul", "is") { + Some(v) => v, + None => panic!("expected a match"), + }; + assert_eq!(idx, vec![0, 1]); + // Matches at lowered positions 0 and 2 -> window 1; start-of-string bonus applies + assert_eq!(score, -99); + } + + #[test] + fn unicode_german_sharp_s_casefold() { + assert!(fuzzy_match("straße", "strasse").is_none()); + } + + #[test] + fn prefer_contiguous_match_over_spread() { + let (_idx_a, score_a) = match fuzzy_match("abc", "abc") { + Some(v) => v, + None => panic!("expected a match"), + }; + let (_idx_b, score_b) = match fuzzy_match("a-b-c", "abc") { + Some(v) => v, + None => panic!("expected a match"), + }; + // Contiguous window -> 0; start-of-string bonus -> -100 + assert_eq!(score_a, -100); + // Spread over 5 chars for 3-letter needle -> window 2; with bonus -> -98 + assert_eq!(score_b, -98); + assert!(score_a < score_b); + } + + #[test] + fn start_of_string_bonus_applies() { + let (_idx_a, score_a) = match fuzzy_match("file_name", "file") { + Some(v) => v, + None => panic!("expected a match"), + }; + let (_idx_b, score_b) = match fuzzy_match("my_file_name", "file") { + Some(v) => v, + None => panic!("expected a match"), + }; + // Start-of-string contiguous -> window 0; bonus -> -100 + assert_eq!(score_a, -100); + // Non-prefix contiguous -> window 0; no bonus -> 0 + assert_eq!(score_b, 0); + assert!(score_a < score_b); + } + + #[test] + fn empty_needle_matches_with_max_score_and_no_indices() { + let (idx, score) = match fuzzy_match("anything", "") { + Some(v) => v, + None => panic!("empty needle should match"), + }; + assert!(idx.is_empty()); + assert_eq!(score, i32::MAX); + } + + #[test] + fn case_insensitive_matching_basic() { + let (idx, score) = match fuzzy_match("FooBar", "foO") { + Some(v) => v, + None => panic!("expected a match"), + }; + assert_eq!(idx, vec![0, 1, 2]); + // Contiguous prefix match (case-insensitive) -> window 0 with bonus + assert_eq!(score, -100); + } + + #[test] + fn indices_are_deduped_for_multichar_lowercase_expansion() { + let needle = "\u{0069}\u{0307}"; // "i" + combining dot above + let (idx, score) = match fuzzy_match("İ", needle) { + Some(v) => v, + None => panic!("expected a match"), + }; + assert_eq!(idx, vec![0]); + // Lowercasing 'İ' expands to two chars; contiguous prefix -> window 0 with bonus + assert_eq!(score, -100); + } +} diff --git a/codex-rs/common/src/lib.rs b/codex-rs/common/src/lib.rs index 38f3832b..8595262c 100644 --- a/codex-rs/common/src/lib.rs +++ b/codex-rs/common/src/lib.rs @@ -27,3 +27,5 @@ pub use sandbox_summary::summarize_sandbox_policy; mod config_summary; pub use config_summary::create_config_summary_entries; +// Shared fuzzy matcher (used by TUI selection popups and other UI filtering) +pub mod fuzzy_match; diff --git a/codex-rs/tui/src/bottom_pane/command_popup.rs b/codex-rs/tui/src/bottom_pane/command_popup.rs index 1027df1a..b7a203e9 100644 --- a/codex-rs/tui/src/bottom_pane/command_popup.rs +++ b/codex-rs/tui/src/bottom_pane/command_popup.rs @@ -1,30 +1,19 @@ use ratatui::buffer::Buffer; use ratatui::layout::Rect; -use ratatui::style::Color; -use ratatui::style::Style; -use ratatui::style::Stylize; -use ratatui::symbols::border::QUADRANT_LEFT_HALF; -use ratatui::text::Line; -use ratatui::text::Span; -use ratatui::widgets::Cell; -use ratatui::widgets::Row; -use ratatui::widgets::Table; -use ratatui::widgets::Widget; use ratatui::widgets::WidgetRef; +use super::popup_consts::MAX_POPUP_ROWS; +use super::scroll_state::ScrollState; +use super::selection_popup_common::GenericDisplayRow; +use super::selection_popup_common::render_rows; use crate::slash_command::SlashCommand; use crate::slash_command::built_in_slash_commands; - -const MAX_POPUP_ROWS: usize = 5; -/// Ideally this is enough to show the longest command name. -const FIRST_COLUMN_WIDTH: u16 = 20; - -use ratatui::style::Modifier; +use codex_common::fuzzy_match::fuzzy_match; pub(crate) struct CommandPopup { command_filter: String, all_commands: Vec<(&'static str, SlashCommand)>, - selected_idx: Option, + state: ScrollState, } impl CommandPopup { @@ -32,7 +21,7 @@ impl CommandPopup { Self { command_filter: String::new(), all_commands: built_in_slash_commands(), - selected_idx: None, + state: ScrollState::new(), } } @@ -62,130 +51,84 @@ impl CommandPopup { // Reset or clamp selected index based on new filtered list. let matches_len = self.filtered_commands().len(); - self.selected_idx = match matches_len { - 0 => None, - _ => Some(self.selected_idx.unwrap_or(0).min(matches_len - 1)), - }; + self.state.clamp_selection(matches_len); + self.state + .ensure_visible(matches_len, MAX_POPUP_ROWS.min(matches_len)); } /// Determine the preferred height of the popup. This is the number of - /// rows required to show **at most** `MAX_POPUP_ROWS` commands plus the - /// table/border overhead (one line at the top and one at the bottom). + /// rows required to show at most MAX_POPUP_ROWS commands. pub(crate) fn calculate_required_height(&self) -> u16 { self.filtered_commands().len().clamp(1, MAX_POPUP_ROWS) as u16 } - /// Return the list of commands that match the current filter. Matching is - /// performed using a *prefix* comparison on the command name. - fn filtered_commands(&self) -> Vec<&SlashCommand> { - self.all_commands - .iter() - .filter_map(|(_name, cmd)| { - if self.command_filter.is_empty() - || cmd - .command() - .starts_with(&self.command_filter.to_ascii_lowercase()) - { - Some(cmd) - } else { - None + /// Compute fuzzy-filtered matches paired with optional highlight indices and score. + /// Sorted by ascending score, then by command name for stability. + fn filtered(&self) -> Vec<(&SlashCommand, Option>, i32)> { + let filter = self.command_filter.trim(); + let mut out: Vec<(&SlashCommand, Option>, i32)> = Vec::new(); + if filter.is_empty() { + for (_, cmd) in self.all_commands.iter() { + out.push((cmd, None, 0)); + } + } else { + for (_, cmd) in self.all_commands.iter() { + if let Some((indices, score)) = fuzzy_match(cmd.command(), filter) { + out.push((cmd, Some(indices), score)); } - }) - .collect::>() + } + } + out.sort_by(|a, b| a.2.cmp(&b.2).then_with(|| a.0.command().cmp(b.0.command()))); + out + } + + fn filtered_commands(&self) -> Vec<&SlashCommand> { + self.filtered().into_iter().map(|(c, _, _)| c).collect() } /// Move the selection cursor one step up. pub(crate) fn move_up(&mut self) { - if let Some(len) = self.filtered_commands().len().checked_sub(1) { - if len == usize::MAX { - return; - } - } - - if let Some(idx) = self.selected_idx { - if idx > 0 { - self.selected_idx = Some(idx - 1); - } - } else if !self.filtered_commands().is_empty() { - self.selected_idx = Some(0); - } + let matches = self.filtered_commands(); + let len = matches.len(); + self.state.move_up_wrap(len); + self.state.ensure_visible(len, MAX_POPUP_ROWS.min(len)); } /// Move the selection cursor one step down. pub(crate) fn move_down(&mut self) { - let matches_len = self.filtered_commands().len(); - if matches_len == 0 { - self.selected_idx = None; - return; - } - - match self.selected_idx { - Some(idx) if idx + 1 < matches_len => { - self.selected_idx = Some(idx + 1); - } - None => { - self.selected_idx = Some(0); - } - _ => {} - } + let matches = self.filtered_commands(); + let matches_len = matches.len(); + self.state.move_down_wrap(matches_len); + self.state + .ensure_visible(matches_len, MAX_POPUP_ROWS.min(matches_len)); } /// Return currently selected command, if any. pub(crate) fn selected_command(&self) -> Option<&SlashCommand> { let matches = self.filtered_commands(); - self.selected_idx.and_then(|idx| matches.get(idx).copied()) + self.state + .selected_idx + .and_then(|idx| matches.get(idx).copied()) } } impl WidgetRef for CommandPopup { fn render_ref(&self, area: Rect, buf: &mut Buffer) { - let matches = self.filtered_commands(); - - let mut rows: Vec = Vec::new(); - let visible_matches: Vec<&SlashCommand> = - matches.into_iter().take(MAX_POPUP_ROWS).collect(); - - if visible_matches.is_empty() { - rows.push(Row::new(vec![ - Cell::from(""), - Cell::from("No matching commands").add_modifier(Modifier::ITALIC), - ])); + let matches = self.filtered(); + let rows_all: Vec = if matches.is_empty() { + Vec::new() } else { - let default_style = Style::default(); - let command_style = Style::default().fg(Color::LightBlue); - for (idx, cmd) in visible_matches.iter().enumerate() { - rows.push(Row::new(vec![ - Cell::from(Line::from(vec![ - if Some(idx) == self.selected_idx { - Span::styled( - "›", - Style::default().bg(Color::DarkGray).fg(Color::LightCyan), - ) - } else { - Span::styled(QUADRANT_LEFT_HALF, Style::default().fg(Color::DarkGray)) - }, - Span::styled(format!("/{}", cmd.command()), command_style), - ])), - Cell::from(cmd.description().to_string()).style(default_style), - ])); - } - } - - use ratatui::layout::Constraint; - - let table = Table::new( - rows, - [Constraint::Length(FIRST_COLUMN_WIDTH), Constraint::Min(10)], - ) - .column_spacing(0); - // .block( - // Block::default() - // .borders(Borders::LEFT) - // .border_type(BorderType::QuadrantOutside) - // .border_style(Style::default().fg(Color::DarkGray)), - // ); - - table.render(area, buf); + matches + .into_iter() + .map(|(cmd, indices, _)| GenericDisplayRow { + name: format!("/{}", cmd.command()), + match_indices: indices.map(|v| v.into_iter().map(|i| i + 1).collect()), + is_current: false, + description: Some(cmd.description().to_string()), + }) + .collect() + }; + render_rows(area, buf, &rows_all, &self.state, MAX_POPUP_ROWS); } } diff --git a/codex-rs/tui/src/bottom_pane/file_search_popup.rs b/codex-rs/tui/src/bottom_pane/file_search_popup.rs index ac6c91cf..c30a24f9 100644 --- a/codex-rs/tui/src/bottom_pane/file_search_popup.rs +++ b/codex-rs/tui/src/bottom_pane/file_search_popup.rs @@ -1,23 +1,12 @@ use codex_file_search::FileMatch; use ratatui::buffer::Buffer; use ratatui::layout::Rect; -use ratatui::prelude::Constraint; -use ratatui::style::Color; -use ratatui::style::Modifier; -use ratatui::style::Style; -use ratatui::text::Line; -use ratatui::text::Span; -use ratatui::widgets::Block; -use ratatui::widgets::BorderType; -use ratatui::widgets::Borders; -use ratatui::widgets::Cell; -use ratatui::widgets::Row; -use ratatui::widgets::Table; -use ratatui::widgets::Widget; use ratatui::widgets::WidgetRef; -/// Maximum number of suggestions shown in the popup. -const MAX_RESULTS: usize = 8; +use super::popup_consts::MAX_POPUP_ROWS; +use super::scroll_state::ScrollState; +use super::selection_popup_common::GenericDisplayRow; +use super::selection_popup_common::render_rows; /// Visual state for the file-search popup. pub(crate) struct FileSearchPopup { @@ -30,8 +19,8 @@ pub(crate) struct FileSearchPopup { waiting: bool, /// Cached matches; paths relative to the search dir. matches: Vec, - /// Currently selected index inside `matches` (if any). - selected_idx: Option, + /// Shared selection/scroll state. + state: ScrollState, } impl FileSearchPopup { @@ -41,7 +30,7 @@ impl FileSearchPopup { pending_query: String::new(), waiting: true, matches: Vec::new(), - selected_idx: None, + state: ScrollState::new(), } } @@ -61,7 +50,7 @@ impl FileSearchPopup { if !keep_existing { self.matches.clear(); - self.selected_idx = None; + self.state.reset(); } } @@ -75,40 +64,32 @@ impl FileSearchPopup { self.display_query = query.to_string(); self.matches = matches; self.waiting = false; - self.selected_idx = if self.matches.is_empty() { - None - } else { - Some(0) - }; + let len = self.matches.len(); + self.state.clamp_selection(len); + self.state.ensure_visible(len, len.min(MAX_POPUP_ROWS)); } /// Move selection cursor up. pub(crate) fn move_up(&mut self) { - if let Some(idx) = self.selected_idx { - if idx > 0 { - self.selected_idx = Some(idx - 1); - } - } + let len = self.matches.len(); + self.state.move_up_wrap(len); + self.state.ensure_visible(len, len.min(MAX_POPUP_ROWS)); } /// Move selection cursor down. pub(crate) fn move_down(&mut self) { - if let Some(idx) = self.selected_idx { - if idx + 1 < self.matches.len() { - self.selected_idx = Some(idx + 1); - } - } else if !self.matches.is_empty() { - self.selected_idx = Some(0); - } + let len = self.matches.len(); + self.state.move_down_wrap(len); + self.state.ensure_visible(len, len.min(MAX_POPUP_ROWS)); } pub(crate) fn selected_match(&self) -> Option<&str> { - self.selected_idx + self.state + .selected_idx .and_then(|idx| self.matches.get(idx)) .map(|file_match| file_match.path.as_str()) } - /// Preferred height (rows) including border. pub(crate) fn calculate_required_height(&self) -> u16 { // Row count depends on whether we already have matches. If no matches // yet (e.g. initial search or query with no results) reserve a single @@ -116,71 +97,35 @@ impl FileSearchPopup { // up to MAX_RESULTS regardless of the waiting flag so the list // remains stable while a newer search is in-flight. - self.matches.len().clamp(1, MAX_RESULTS) as u16 + self.matches.len().clamp(1, MAX_POPUP_ROWS) as u16 } } impl WidgetRef for &FileSearchPopup { fn render_ref(&self, area: Rect, buf: &mut Buffer) { - // Prepare rows. - let rows: Vec = if self.matches.is_empty() { - vec![Row::new(vec![ - Cell::from(if self.waiting { - "(searching …)" - } else { - "no matches" - }) - .style(Style::new().add_modifier(Modifier::ITALIC | Modifier::DIM)), - ])] + // Convert matches to GenericDisplayRow, translating indices to usize at the UI boundary. + let rows_all: Vec = if self.matches.is_empty() { + Vec::new() } else { self.matches .iter() - .take(MAX_RESULTS) - .enumerate() - .map(|(i, file_match)| { - let FileMatch { path, indices, .. } = file_match; - let path = path.as_str(); - #[allow(clippy::expect_used)] - let indices = indices.as_ref().expect("indices should be present"); - - // Build spans with bold on matching indices. - let mut idx_iter = indices.iter().peekable(); - let mut spans: Vec = Vec::with_capacity(path.len()); - - for (char_idx, ch) in path.chars().enumerate() { - let mut style = Style::default(); - if idx_iter - .peek() - .is_some_and(|next| **next == char_idx as u32) - { - idx_iter.next(); - style = style.add_modifier(Modifier::BOLD); - } - spans.push(Span::styled(ch.to_string(), style)); - } - - // Create cell from the spans. - let mut cell = Cell::from(Line::from(spans)); - - // If selected, also paint yellow. - if Some(i) == self.selected_idx { - cell = cell.style(Style::default().fg(Color::Yellow)); - } - - Row::new(vec![cell]) + .map(|m| GenericDisplayRow { + name: m.path.clone(), + match_indices: m + .indices + .as_ref() + .map(|v| v.iter().map(|&i| i as usize).collect()), + is_current: false, + description: None, }) .collect() }; - let table = Table::new(rows, vec![Constraint::Percentage(100)]) - .block( - Block::default() - .borders(Borders::LEFT) - .border_type(BorderType::QuadrantOutside) - .border_style(Style::default().fg(Color::DarkGray)), - ) - .widths([Constraint::Percentage(100)]); - - table.render(area, buf); + if self.waiting && rows_all.is_empty() { + // Render a minimal waiting stub using the shared renderer (no rows -> "no matches"). + render_rows(area, buf, &[], &self.state, MAX_POPUP_ROWS); + } else { + render_rows(area, buf, &rows_all, &self.state, MAX_POPUP_ROWS); + } } } diff --git a/codex-rs/tui/src/bottom_pane/mod.rs b/codex-rs/tui/src/bottom_pane/mod.rs index cdb01ba0..ff3cf2f2 100644 --- a/codex-rs/tui/src/bottom_pane/mod.rs +++ b/codex-rs/tui/src/bottom_pane/mod.rs @@ -19,6 +19,9 @@ mod chat_composer_history; mod command_popup; mod file_search_popup; mod live_ring_widget; +mod popup_consts; +mod scroll_state; +mod selection_popup_common; mod status_indicator_view; mod textarea; diff --git a/codex-rs/tui/src/bottom_pane/popup_consts.rs b/codex-rs/tui/src/bottom_pane/popup_consts.rs new file mode 100644 index 00000000..5f447d73 --- /dev/null +++ b/codex-rs/tui/src/bottom_pane/popup_consts.rs @@ -0,0 +1,5 @@ +//! Shared popup-related constants for bottom pane widgets. + +/// Maximum number of rows any popup should attempt to display. +/// Keep this consistent across all popups for a uniform feel. +pub(crate) const MAX_POPUP_ROWS: usize = 8; diff --git a/codex-rs/tui/src/bottom_pane/scroll_state.rs b/codex-rs/tui/src/bottom_pane/scroll_state.rs new file mode 100644 index 00000000..a9728d1a --- /dev/null +++ b/codex-rs/tui/src/bottom_pane/scroll_state.rs @@ -0,0 +1,115 @@ +/// Generic scroll/selection state for a vertical list menu. +/// +/// Encapsulates the common behavior of a selectable list that supports: +/// - Optional selection (None when list is empty) +/// - Wrap-around navigation on Up/Down +/// - Maintaining a scroll window (`scroll_top`) so the selected row stays visible +#[derive(Debug, Default, Clone, Copy)] +pub(crate) struct ScrollState { + pub selected_idx: Option, + pub scroll_top: usize, +} + +impl ScrollState { + pub fn new() -> Self { + Self { + selected_idx: None, + scroll_top: 0, + } + } + + /// Reset selection and scroll. + pub fn reset(&mut self) { + self.selected_idx = None; + self.scroll_top = 0; + } + + /// Clamp selection to be within the [0, len-1] range, or None when empty. + pub fn clamp_selection(&mut self, len: usize) { + self.selected_idx = match len { + 0 => None, + _ => Some(self.selected_idx.unwrap_or(0).min(len - 1)), + }; + if len == 0 { + self.scroll_top = 0; + } + } + + /// Move selection up by one, wrapping to the bottom when necessary. + pub fn move_up_wrap(&mut self, len: usize) { + if len == 0 { + self.selected_idx = None; + self.scroll_top = 0; + return; + } + self.selected_idx = Some(match self.selected_idx { + Some(idx) if idx > 0 => idx - 1, + Some(_) => len - 1, + None => 0, + }); + } + + /// Move selection down by one, wrapping to the top when necessary. + pub fn move_down_wrap(&mut self, len: usize) { + if len == 0 { + self.selected_idx = None; + self.scroll_top = 0; + return; + } + self.selected_idx = Some(match self.selected_idx { + Some(idx) if idx + 1 < len => idx + 1, + _ => 0, + }); + } + + /// Adjust `scroll_top` so that the current `selected_idx` is visible within + /// the window of `visible_rows`. + pub fn ensure_visible(&mut self, len: usize, visible_rows: usize) { + if len == 0 || visible_rows == 0 { + self.scroll_top = 0; + return; + } + if let Some(sel) = self.selected_idx { + if sel < self.scroll_top { + self.scroll_top = sel; + } else { + let bottom = self.scroll_top + visible_rows - 1; + if sel > bottom { + self.scroll_top = sel + 1 - visible_rows; + } + } + } else { + self.scroll_top = 0; + } + } +} + +#[cfg(test)] +mod tests { + use super::ScrollState; + + #[test] + fn wrap_navigation_and_visibility() { + let mut s = ScrollState::new(); + let len = 10; + let vis = 5; + + s.clamp_selection(len); + assert_eq!(s.selected_idx, Some(0)); + s.ensure_visible(len, vis); + assert_eq!(s.scroll_top, 0); + + s.move_up_wrap(len); + s.ensure_visible(len, vis); + assert_eq!(s.selected_idx, Some(len - 1)); + match s.selected_idx { + Some(sel) => assert!(s.scroll_top <= sel), + None => panic!("expected Some(selected_idx) after wrap"), + } + + s.move_down_wrap(len); + s.ensure_visible(len, vis); + assert_eq!(s.selected_idx, Some(0)); + assert_eq!(s.scroll_top, 0); + } +} diff --git a/codex-rs/tui/src/bottom_pane/selection_popup_common.rs b/codex-rs/tui/src/bottom_pane/selection_popup_common.rs new file mode 100644 index 00000000..1a31115d --- /dev/null +++ b/codex-rs/tui/src/bottom_pane/selection_popup_common.rs @@ -0,0 +1,126 @@ +use ratatui::buffer::Buffer; +use ratatui::layout::Rect; +use ratatui::prelude::Constraint; +use ratatui::style::Color; +use ratatui::style::Modifier; +use ratatui::style::Style; +use ratatui::text::Line; +use ratatui::text::Span; +use ratatui::widgets::Block; +use ratatui::widgets::BorderType; +use ratatui::widgets::Borders; +use ratatui::widgets::Cell; +use ratatui::widgets::Row; +use ratatui::widgets::Table; +use ratatui::widgets::Widget; + +use super::scroll_state::ScrollState; + +/// A generic representation of a display row for selection popups. +pub(crate) struct GenericDisplayRow { + pub name: String, + pub match_indices: Option>, // indices to bold (char positions) + pub is_current: bool, + pub description: Option, // optional grey text after the name +} + +impl GenericDisplayRow {} + +/// Render a list of rows using the provided ScrollState, with shared styling +/// and behavior for selection popups. +pub(crate) fn render_rows( + area: Rect, + buf: &mut Buffer, + rows_all: &[GenericDisplayRow], + state: &ScrollState, + max_results: usize, +) { + let mut rows: Vec = Vec::new(); + if rows_all.is_empty() { + rows.push(Row::new(vec![Cell::from(Line::from(Span::styled( + "no matches", + Style::default().add_modifier(Modifier::ITALIC | Modifier::DIM), + )))])); + } else { + let max_rows_from_area = area.height as usize; + let visible_rows = max_results + .min(rows_all.len()) + .min(max_rows_from_area.max(1)); + + // Compute starting index based on scroll state and selection. + let mut start_idx = state.scroll_top.min(rows_all.len().saturating_sub(1)); + if let Some(sel) = state.selected_idx { + if sel < start_idx { + start_idx = sel; + } else if visible_rows > 0 { + let bottom = start_idx + visible_rows - 1; + if sel > bottom { + start_idx = sel + 1 - visible_rows; + } + } + } + + for (i, row) in rows_all + .iter() + .enumerate() + .skip(start_idx) + .take(visible_rows) + { + let GenericDisplayRow { + name, + match_indices, + is_current, + description, + } = row; + + // Highlight fuzzy indices when present. + let mut spans: Vec = Vec::with_capacity(name.len()); + if let Some(idxs) = match_indices.as_ref() { + let mut idx_iter = idxs.iter().peekable(); + for (char_idx, ch) in name.chars().enumerate() { + let mut style = Style::default(); + if idx_iter.peek().is_some_and(|next| **next == char_idx) { + idx_iter.next(); + style = style.add_modifier(Modifier::BOLD); + } + spans.push(Span::styled(ch.to_string(), style)); + } + } else { + spans.push(Span::raw(name.clone())); + } + + if let Some(desc) = description.as_ref() { + spans.push(Span::raw(" ")); + spans.push(Span::styled( + desc.clone(), + Style::default() + .fg(Color::DarkGray) + .add_modifier(Modifier::DIM), + )); + } + + let mut cell = Cell::from(Line::from(spans)); + if Some(i) == state.selected_idx { + cell = cell.style( + Style::default() + .fg(Color::Yellow) + .add_modifier(Modifier::BOLD), + ); + } else if *is_current { + cell = cell.style(Style::default().fg(Color::Cyan)); + } + rows.push(Row::new(vec![cell])); + } + } + + let table = Table::new(rows, vec![Constraint::Percentage(100)]) + .block( + Block::default() + .borders(Borders::LEFT) + .border_type(BorderType::QuadrantOutside) + .border_style(Style::default().fg(Color::DarkGray)), + ) + .widths([Constraint::Percentage(100)]); + + table.render(area, buf); +}