Changes: - the composer and user messages now have a colored background that stretches the entire width of the terminal. - the prompt character was changed from a cyan `▌` to a bold `›`. - the "working" shimmer now follows the "dark gray" color of the terminal, better matching the terminal's color scheme | Terminal + Background | Screenshot | |------------------------------|------------| | iTerm with dark bg | <img width="810" height="641" alt="Screenshot 2025-09-25 at 11 44 52 AM" src="https://github.com/user-attachments/assets/1317e579-64a9-4785-93e6-98b0258f5d92" /> | | iTerm with light bg | <img width="845" height="540" alt="Screenshot 2025-09-25 at 11 46 29 AM" src="https://github.com/user-attachments/assets/e671d490-c747-4460-af0b-3f8d7f7a6b8e" /> | | iTerm with color bg | <img width="825" height="564" alt="Screenshot 2025-09-25 at 11 47 12 AM" src="https://github.com/user-attachments/assets/141cda1b-1164-41d5-87da-3be11e6a3063" /> | | Terminal.app with dark bg | <img width="577" height="367" alt="Screenshot 2025-09-25 at 11 45 22 AM" src="https://github.com/user-attachments/assets/93fc4781-99f7-4ee7-9c8e-3db3cd854fe5" /> | | Terminal.app with light bg | <img width="577" height="367" alt="Screenshot 2025-09-25 at 11 46 04 AM" src="https://github.com/user-attachments/assets/19bf6a3c-91e0-447b-9667-b8033f512219" /> | | Terminal.app with color bg | <img width="577" height="367" alt="Screenshot 2025-09-25 at 11 45 50 AM" src="https://github.com/user-attachments/assets/dd7c4b5b-342e-4028-8140-f4e65752bd0b" /> |
152 lines
4.8 KiB
Rust
152 lines
4.8 KiB
Rust
use codex_file_search::FileMatch;
|
|
use ratatui::buffer::Buffer;
|
|
use ratatui::layout::Rect;
|
|
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;
|
|
|
|
/// 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>,
|
|
/// Shared selection/scroll state.
|
|
state: ScrollState,
|
|
}
|
|
|
|
impl FileSearchPopup {
|
|
pub(crate) fn new() -> Self {
|
|
Self {
|
|
display_query: String::new(),
|
|
pending_query: String::new(),
|
|
waiting: true,
|
|
matches: Vec::new(),
|
|
state: ScrollState::new(),
|
|
}
|
|
}
|
|
|
|
/// 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.state.reset();
|
|
}
|
|
}
|
|
|
|
/// Put the popup into an "idle" state used for an empty query (just "@").
|
|
/// Shows a hint instead of matches until the user types more characters.
|
|
pub(crate) fn set_empty_prompt(&mut self) {
|
|
self.display_query.clear();
|
|
self.pending_query.clear();
|
|
self.waiting = false;
|
|
self.matches.clear();
|
|
// Reset selection/scroll state when showing the empty prompt.
|
|
self.state.reset();
|
|
}
|
|
|
|
/// 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;
|
|
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) {
|
|
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) {
|
|
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.state
|
|
.selected_idx
|
|
.and_then(|idx| self.matches.get(idx))
|
|
.map(|file_match| file_match.path.as_str())
|
|
}
|
|
|
|
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_POPUP_ROWS) as u16
|
|
}
|
|
}
|
|
|
|
impl WidgetRef for &FileSearchPopup {
|
|
fn render_ref(&self, area: Rect, buf: &mut Buffer) {
|
|
// Convert matches to GenericDisplayRow, translating indices to usize at the UI boundary.
|
|
let rows_all: Vec<GenericDisplayRow> = if self.matches.is_empty() {
|
|
Vec::new()
|
|
} else {
|
|
self.matches
|
|
.iter()
|
|
.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 empty_message = if self.waiting {
|
|
"loading..."
|
|
} else {
|
|
"no matches"
|
|
};
|
|
|
|
render_rows(
|
|
area,
|
|
buf,
|
|
&rows_all,
|
|
&self.state,
|
|
MAX_POPUP_ROWS,
|
|
empty_message,
|
|
false,
|
|
);
|
|
}
|
|
}
|