Files
llmx/codex-rs/tui/src/bottom_pane/file_search_popup.rs
Jeremy Rose d86270696e streamline ui (#1733)
Simplify and improve many UI elements.
* Remove all-around borders in most places. These interact badly with
terminal resizing and look heavy. Prefer left-side-only borders.
* Make the viewport adjust to the size of its contents.
* <kbd>/</kbd> and <kbd>@</kbd> autocomplete boxes appear below the
prompt, instead of above it.
* Restyle the keyboard shortcut hints & move them to the left.
* Restyle the approval dialog.
* Use synchronized rendering to avoid flashing during rerenders.


https://github.com/user-attachments/assets/96f044af-283b-411c-b7fc-5e6b8a433c20

<img width="1117" height="858" alt="Screenshot 2025-07-30 at 5 29 20 PM"
src="https://github.com/user-attachments/assets/0cc0af77-8396-429b-b6ee-9feaaccdbee7"
/>
2025-07-31 00:43:21 -07:00

187 lines
6.2 KiB
Rust

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;
/// Visual state for the file-search popup.
pub(crate) struct FileSearchPopup {
/// Query corresponding to the `matches` currently shown.
display_query: String,
/// Latest query typed by the user. May differ from `display_query` when
/// a search is still in-flight.
pending_query: String,
/// When `true` we are still waiting for results for `pending_query`.
waiting: bool,
/// Cached matches; paths relative to the search dir.
matches: Vec<FileMatch>,
/// Currently selected index inside `matches` (if any).
selected_idx: Option<usize>,
}
impl FileSearchPopup {
pub(crate) fn new() -> Self {
Self {
display_query: String::new(),
pending_query: String::new(),
waiting: true,
matches: Vec::new(),
selected_idx: None,
}
}
/// Update the query and reset state to *waiting*.
pub(crate) fn set_query(&mut self, query: &str) {
if query == self.pending_query {
return;
}
// Determine if current matches are still relevant.
let keep_existing = query.starts_with(&self.display_query);
self.pending_query.clear();
self.pending_query.push_str(query);
self.waiting = true; // waiting for new results
if !keep_existing {
self.matches.clear();
self.selected_idx = None;
}
}
/// Replace matches when a `FileSearchResult` arrives.
/// Replace matches. Only applied when `query` matches `pending_query`.
pub(crate) fn set_matches(&mut self, query: &str, matches: Vec<FileMatch>) {
if query != self.pending_query {
return; // stale
}
self.display_query = query.to_string();
self.matches = matches;
self.waiting = false;
self.selected_idx = if self.matches.is_empty() {
None
} else {
Some(0)
};
}
/// 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);
}
}
}
/// 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);
}
}
pub(crate) fn selected_match(&self) -> Option<&str> {
self.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
// row so the popup is still visible. When matches are present we show
// 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
}
}
impl WidgetRef for &FileSearchPopup {
fn render_ref(&self, area: Rect, buf: &mut Buffer) {
// Prepare rows.
let rows: Vec<Row> = 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)),
])]
} 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<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()
};
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);
}
}