Scrollable slash commands (#1830)
Scrollable slash commands. Part 1 of the multi PR.
This commit is contained in:
177
codex-rs/common/src/fuzzy_match.rs
Normal file
177
codex-rs/common/src/fuzzy_match.rs
Normal file
@@ -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<usize>, i32)> {
|
||||||
|
if needle.is_empty() {
|
||||||
|
return Some((Vec::new(), i32::MAX));
|
||||||
|
}
|
||||||
|
|
||||||
|
let mut lowered_chars: Vec<char> = Vec::new();
|
||||||
|
let mut lowered_to_orig_char_idx: Vec<usize> = 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<char> = needle.to_lowercase().chars().collect();
|
||||||
|
|
||||||
|
let mut result_orig_indices: Vec<usize> = Vec::with_capacity(lowered_needle.len());
|
||||||
|
let mut last_lower_pos: Option<usize> = None;
|
||||||
|
let mut cur = 0usize;
|
||||||
|
for &nc in lowered_needle.iter() {
|
||||||
|
let mut found_at: Option<usize> = 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<Vec<usize>> {
|
||||||
|
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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -27,3 +27,5 @@ pub use sandbox_summary::summarize_sandbox_policy;
|
|||||||
mod config_summary;
|
mod config_summary;
|
||||||
|
|
||||||
pub use config_summary::create_config_summary_entries;
|
pub use config_summary::create_config_summary_entries;
|
||||||
|
// Shared fuzzy matcher (used by TUI selection popups and other UI filtering)
|
||||||
|
pub mod fuzzy_match;
|
||||||
|
|||||||
@@ -1,30 +1,19 @@
|
|||||||
use ratatui::buffer::Buffer;
|
use ratatui::buffer::Buffer;
|
||||||
use ratatui::layout::Rect;
|
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 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::SlashCommand;
|
||||||
use crate::slash_command::built_in_slash_commands;
|
use crate::slash_command::built_in_slash_commands;
|
||||||
|
use codex_common::fuzzy_match::fuzzy_match;
|
||||||
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;
|
|
||||||
|
|
||||||
pub(crate) struct CommandPopup {
|
pub(crate) struct CommandPopup {
|
||||||
command_filter: String,
|
command_filter: String,
|
||||||
all_commands: Vec<(&'static str, SlashCommand)>,
|
all_commands: Vec<(&'static str, SlashCommand)>,
|
||||||
selected_idx: Option<usize>,
|
state: ScrollState,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl CommandPopup {
|
impl CommandPopup {
|
||||||
@@ -32,7 +21,7 @@ impl CommandPopup {
|
|||||||
Self {
|
Self {
|
||||||
command_filter: String::new(),
|
command_filter: String::new(),
|
||||||
all_commands: built_in_slash_commands(),
|
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.
|
// Reset or clamp selected index based on new filtered list.
|
||||||
let matches_len = self.filtered_commands().len();
|
let matches_len = self.filtered_commands().len();
|
||||||
self.selected_idx = match matches_len {
|
self.state.clamp_selection(matches_len);
|
||||||
0 => None,
|
self.state
|
||||||
_ => Some(self.selected_idx.unwrap_or(0).min(matches_len - 1)),
|
.ensure_visible(matches_len, MAX_POPUP_ROWS.min(matches_len));
|
||||||
};
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Determine the preferred height of the popup. This is the number of
|
/// Determine the preferred height of the popup. This is the number of
|
||||||
/// rows required to show **at most** `MAX_POPUP_ROWS` commands plus the
|
/// rows required to show at most MAX_POPUP_ROWS commands.
|
||||||
/// table/border overhead (one line at the top and one at the bottom).
|
|
||||||
pub(crate) fn calculate_required_height(&self) -> u16 {
|
pub(crate) fn calculate_required_height(&self) -> u16 {
|
||||||
self.filtered_commands().len().clamp(1, MAX_POPUP_ROWS) as u16
|
self.filtered_commands().len().clamp(1, MAX_POPUP_ROWS) as u16
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Return the list of commands that match the current filter. Matching is
|
/// Compute fuzzy-filtered matches paired with optional highlight indices and score.
|
||||||
/// performed using a *prefix* comparison on the command name.
|
/// Sorted by ascending score, then by command name for stability.
|
||||||
fn filtered_commands(&self) -> Vec<&SlashCommand> {
|
fn filtered(&self) -> Vec<(&SlashCommand, Option<Vec<usize>>, i32)> {
|
||||||
self.all_commands
|
let filter = self.command_filter.trim();
|
||||||
.iter()
|
let mut out: Vec<(&SlashCommand, Option<Vec<usize>>, i32)> = Vec::new();
|
||||||
.filter_map(|(_name, cmd)| {
|
if filter.is_empty() {
|
||||||
if self.command_filter.is_empty()
|
for (_, cmd) in self.all_commands.iter() {
|
||||||
|| cmd
|
out.push((cmd, None, 0));
|
||||||
.command()
|
}
|
||||||
.starts_with(&self.command_filter.to_ascii_lowercase())
|
} else {
|
||||||
{
|
for (_, cmd) in self.all_commands.iter() {
|
||||||
Some(cmd)
|
if let Some((indices, score)) = fuzzy_match(cmd.command(), filter) {
|
||||||
} else {
|
out.push((cmd, Some(indices), score));
|
||||||
None
|
|
||||||
}
|
}
|
||||||
})
|
}
|
||||||
.collect::<Vec<&SlashCommand>>()
|
}
|
||||||
|
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.
|
/// Move the selection cursor one step up.
|
||||||
pub(crate) fn move_up(&mut self) {
|
pub(crate) fn move_up(&mut self) {
|
||||||
if let Some(len) = self.filtered_commands().len().checked_sub(1) {
|
let matches = self.filtered_commands();
|
||||||
if len == usize::MAX {
|
let len = matches.len();
|
||||||
return;
|
self.state.move_up_wrap(len);
|
||||||
}
|
self.state.ensure_visible(len, MAX_POPUP_ROWS.min(len));
|
||||||
}
|
|
||||||
|
|
||||||
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);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Move the selection cursor one step down.
|
/// Move the selection cursor one step down.
|
||||||
pub(crate) fn move_down(&mut self) {
|
pub(crate) fn move_down(&mut self) {
|
||||||
let matches_len = self.filtered_commands().len();
|
let matches = self.filtered_commands();
|
||||||
if matches_len == 0 {
|
let matches_len = matches.len();
|
||||||
self.selected_idx = None;
|
self.state.move_down_wrap(matches_len);
|
||||||
return;
|
self.state
|
||||||
}
|
.ensure_visible(matches_len, MAX_POPUP_ROWS.min(matches_len));
|
||||||
|
|
||||||
match self.selected_idx {
|
|
||||||
Some(idx) if idx + 1 < matches_len => {
|
|
||||||
self.selected_idx = Some(idx + 1);
|
|
||||||
}
|
|
||||||
None => {
|
|
||||||
self.selected_idx = Some(0);
|
|
||||||
}
|
|
||||||
_ => {}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Return currently selected command, if any.
|
/// Return currently selected command, if any.
|
||||||
pub(crate) fn selected_command(&self) -> Option<&SlashCommand> {
|
pub(crate) fn selected_command(&self) -> Option<&SlashCommand> {
|
||||||
let matches = self.filtered_commands();
|
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 {
|
impl WidgetRef for CommandPopup {
|
||||||
fn render_ref(&self, area: Rect, buf: &mut Buffer) {
|
fn render_ref(&self, area: Rect, buf: &mut Buffer) {
|
||||||
let matches = self.filtered_commands();
|
let matches = self.filtered();
|
||||||
|
let rows_all: Vec<GenericDisplayRow> = if matches.is_empty() {
|
||||||
let mut rows: Vec<Row> = Vec::new();
|
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),
|
|
||||||
]));
|
|
||||||
} else {
|
} else {
|
||||||
let default_style = Style::default();
|
matches
|
||||||
let command_style = Style::default().fg(Color::LightBlue);
|
.into_iter()
|
||||||
for (idx, cmd) in visible_matches.iter().enumerate() {
|
.map(|(cmd, indices, _)| GenericDisplayRow {
|
||||||
rows.push(Row::new(vec![
|
name: format!("/{}", cmd.command()),
|
||||||
Cell::from(Line::from(vec![
|
match_indices: indices.map(|v| v.into_iter().map(|i| i + 1).collect()),
|
||||||
if Some(idx) == self.selected_idx {
|
is_current: false,
|
||||||
Span::styled(
|
description: Some(cmd.description().to_string()),
|
||||||
"›",
|
})
|
||||||
Style::default().bg(Color::DarkGray).fg(Color::LightCyan),
|
.collect()
|
||||||
)
|
};
|
||||||
} else {
|
render_rows(area, buf, &rows_all, &self.state, MAX_POPUP_ROWS);
|
||||||
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);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,23 +1,12 @@
|
|||||||
use codex_file_search::FileMatch;
|
use codex_file_search::FileMatch;
|
||||||
use ratatui::buffer::Buffer;
|
use ratatui::buffer::Buffer;
|
||||||
use ratatui::layout::Rect;
|
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;
|
use ratatui::widgets::WidgetRef;
|
||||||
|
|
||||||
/// Maximum number of suggestions shown in the popup.
|
use super::popup_consts::MAX_POPUP_ROWS;
|
||||||
const MAX_RESULTS: usize = 8;
|
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.
|
/// Visual state for the file-search popup.
|
||||||
pub(crate) struct FileSearchPopup {
|
pub(crate) struct FileSearchPopup {
|
||||||
@@ -30,8 +19,8 @@ pub(crate) struct FileSearchPopup {
|
|||||||
waiting: bool,
|
waiting: bool,
|
||||||
/// Cached matches; paths relative to the search dir.
|
/// Cached matches; paths relative to the search dir.
|
||||||
matches: Vec<FileMatch>,
|
matches: Vec<FileMatch>,
|
||||||
/// Currently selected index inside `matches` (if any).
|
/// Shared selection/scroll state.
|
||||||
selected_idx: Option<usize>,
|
state: ScrollState,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl FileSearchPopup {
|
impl FileSearchPopup {
|
||||||
@@ -41,7 +30,7 @@ impl FileSearchPopup {
|
|||||||
pending_query: String::new(),
|
pending_query: String::new(),
|
||||||
waiting: true,
|
waiting: true,
|
||||||
matches: Vec::new(),
|
matches: Vec::new(),
|
||||||
selected_idx: None,
|
state: ScrollState::new(),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -61,7 +50,7 @@ impl FileSearchPopup {
|
|||||||
|
|
||||||
if !keep_existing {
|
if !keep_existing {
|
||||||
self.matches.clear();
|
self.matches.clear();
|
||||||
self.selected_idx = None;
|
self.state.reset();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -75,40 +64,32 @@ impl FileSearchPopup {
|
|||||||
self.display_query = query.to_string();
|
self.display_query = query.to_string();
|
||||||
self.matches = matches;
|
self.matches = matches;
|
||||||
self.waiting = false;
|
self.waiting = false;
|
||||||
self.selected_idx = if self.matches.is_empty() {
|
let len = self.matches.len();
|
||||||
None
|
self.state.clamp_selection(len);
|
||||||
} else {
|
self.state.ensure_visible(len, len.min(MAX_POPUP_ROWS));
|
||||||
Some(0)
|
|
||||||
};
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Move selection cursor up.
|
/// Move selection cursor up.
|
||||||
pub(crate) fn move_up(&mut self) {
|
pub(crate) fn move_up(&mut self) {
|
||||||
if let Some(idx) = self.selected_idx {
|
let len = self.matches.len();
|
||||||
if idx > 0 {
|
self.state.move_up_wrap(len);
|
||||||
self.selected_idx = Some(idx - 1);
|
self.state.ensure_visible(len, len.min(MAX_POPUP_ROWS));
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Move selection cursor down.
|
/// Move selection cursor down.
|
||||||
pub(crate) fn move_down(&mut self) {
|
pub(crate) fn move_down(&mut self) {
|
||||||
if let Some(idx) = self.selected_idx {
|
let len = self.matches.len();
|
||||||
if idx + 1 < self.matches.len() {
|
self.state.move_down_wrap(len);
|
||||||
self.selected_idx = Some(idx + 1);
|
self.state.ensure_visible(len, len.min(MAX_POPUP_ROWS));
|
||||||
}
|
|
||||||
} else if !self.matches.is_empty() {
|
|
||||||
self.selected_idx = Some(0);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
pub(crate) fn selected_match(&self) -> Option<&str> {
|
pub(crate) fn selected_match(&self) -> Option<&str> {
|
||||||
self.selected_idx
|
self.state
|
||||||
|
.selected_idx
|
||||||
.and_then(|idx| self.matches.get(idx))
|
.and_then(|idx| self.matches.get(idx))
|
||||||
.map(|file_match| file_match.path.as_str())
|
.map(|file_match| file_match.path.as_str())
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Preferred height (rows) including border.
|
|
||||||
pub(crate) fn calculate_required_height(&self) -> u16 {
|
pub(crate) fn calculate_required_height(&self) -> u16 {
|
||||||
// Row count depends on whether we already have matches. If no matches
|
// 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
|
// 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
|
// up to MAX_RESULTS regardless of the waiting flag so the list
|
||||||
// remains stable while a newer search is in-flight.
|
// 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 {
|
impl WidgetRef for &FileSearchPopup {
|
||||||
fn render_ref(&self, area: Rect, buf: &mut Buffer) {
|
fn render_ref(&self, area: Rect, buf: &mut Buffer) {
|
||||||
// Prepare rows.
|
// Convert matches to GenericDisplayRow, translating indices to usize at the UI boundary.
|
||||||
let rows: Vec<Row> = if self.matches.is_empty() {
|
let rows_all: Vec<GenericDisplayRow> = if self.matches.is_empty() {
|
||||||
vec![Row::new(vec![
|
Vec::new()
|
||||||
Cell::from(if self.waiting {
|
|
||||||
"(searching …)"
|
|
||||||
} else {
|
|
||||||
"no matches"
|
|
||||||
})
|
|
||||||
.style(Style::new().add_modifier(Modifier::ITALIC | Modifier::DIM)),
|
|
||||||
])]
|
|
||||||
} else {
|
} else {
|
||||||
self.matches
|
self.matches
|
||||||
.iter()
|
.iter()
|
||||||
.take(MAX_RESULTS)
|
.map(|m| GenericDisplayRow {
|
||||||
.enumerate()
|
name: m.path.clone(),
|
||||||
.map(|(i, file_match)| {
|
match_indices: m
|
||||||
let FileMatch { path, indices, .. } = file_match;
|
.indices
|
||||||
let path = path.as_str();
|
.as_ref()
|
||||||
#[allow(clippy::expect_used)]
|
.map(|v| v.iter().map(|&i| i as usize).collect()),
|
||||||
let indices = indices.as_ref().expect("indices should be present");
|
is_current: false,
|
||||||
|
description: None,
|
||||||
// Build spans with bold on matching indices.
|
|
||||||
let mut idx_iter = indices.iter().peekable();
|
|
||||||
let mut spans: Vec<Span> = 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])
|
|
||||||
})
|
})
|
||||||
.collect()
|
.collect()
|
||||||
};
|
};
|
||||||
|
|
||||||
let table = Table::new(rows, vec![Constraint::Percentage(100)])
|
if self.waiting && rows_all.is_empty() {
|
||||||
.block(
|
// Render a minimal waiting stub using the shared renderer (no rows -> "no matches").
|
||||||
Block::default()
|
render_rows(area, buf, &[], &self.state, MAX_POPUP_ROWS);
|
||||||
.borders(Borders::LEFT)
|
} else {
|
||||||
.border_type(BorderType::QuadrantOutside)
|
render_rows(area, buf, &rows_all, &self.state, MAX_POPUP_ROWS);
|
||||||
.border_style(Style::default().fg(Color::DarkGray)),
|
}
|
||||||
)
|
|
||||||
.widths([Constraint::Percentage(100)]);
|
|
||||||
|
|
||||||
table.render(area, buf);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -19,6 +19,9 @@ mod chat_composer_history;
|
|||||||
mod command_popup;
|
mod command_popup;
|
||||||
mod file_search_popup;
|
mod file_search_popup;
|
||||||
mod live_ring_widget;
|
mod live_ring_widget;
|
||||||
|
mod popup_consts;
|
||||||
|
mod scroll_state;
|
||||||
|
mod selection_popup_common;
|
||||||
mod status_indicator_view;
|
mod status_indicator_view;
|
||||||
mod textarea;
|
mod textarea;
|
||||||
|
|
||||||
|
|||||||
5
codex-rs/tui/src/bottom_pane/popup_consts.rs
Normal file
5
codex-rs/tui/src/bottom_pane/popup_consts.rs
Normal file
@@ -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;
|
||||||
115
codex-rs/tui/src/bottom_pane/scroll_state.rs
Normal file
115
codex-rs/tui/src/bottom_pane/scroll_state.rs
Normal file
@@ -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<usize>,
|
||||||
|
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);
|
||||||
|
}
|
||||||
|
}
|
||||||
126
codex-rs/tui/src/bottom_pane/selection_popup_common.rs
Normal file
126
codex-rs/tui/src/bottom_pane/selection_popup_common.rs
Normal file
@@ -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<Vec<usize>>, // indices to bold (char positions)
|
||||||
|
pub is_current: bool,
|
||||||
|
pub description: Option<String>, // 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<Row> = 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<Span> = 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);
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user