160 lines
4.9 KiB
Rust
160 lines
4.9 KiB
Rust
|
|
use ratatui::buffer::Buffer;
|
||
|
|
use ratatui::layout::Rect;
|
||
|
|
use ratatui::prelude::Constraint;
|
||
|
|
use ratatui::style::Color;
|
||
|
|
use ratatui::style::Style;
|
||
|
|
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<String>,
|
||
|
|
/// 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<String>) {
|
||
|
|
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(String::as_str)
|
||
|
|
}
|
||
|
|
|
||
|
|
/// Preferred height (rows) including border.
|
||
|
|
pub(crate) fn calculate_required_height(&self, _area: &Rect) -> 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.
|
||
|
|
let rows = if self.matches.is_empty() {
|
||
|
|
1
|
||
|
|
} else {
|
||
|
|
self.matches.len().clamp(1, MAX_RESULTS)
|
||
|
|
} as u16;
|
||
|
|
rows + 2 // border
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
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(" no matches ")])]
|
||
|
|
} else {
|
||
|
|
self.matches
|
||
|
|
.iter()
|
||
|
|
.take(MAX_RESULTS)
|
||
|
|
.enumerate()
|
||
|
|
.map(|(i, p)| {
|
||
|
|
let mut cell = Cell::from(p.as_str());
|
||
|
|
if Some(i) == self.selected_idx {
|
||
|
|
cell = cell.style(Style::default().fg(Color::Yellow));
|
||
|
|
}
|
||
|
|
Row::new(vec![cell])
|
||
|
|
})
|
||
|
|
.collect()
|
||
|
|
};
|
||
|
|
|
||
|
|
let mut title = format!(" @{} ", self.pending_query);
|
||
|
|
if self.waiting {
|
||
|
|
title.push_str(" (searching …)");
|
||
|
|
}
|
||
|
|
|
||
|
|
let table = Table::new(rows, vec![Constraint::Percentage(100)])
|
||
|
|
.block(
|
||
|
|
Block::default()
|
||
|
|
.borders(Borders::ALL)
|
||
|
|
.border_type(BorderType::Rounded)
|
||
|
|
.title(title),
|
||
|
|
)
|
||
|
|
.widths([Constraint::Percentage(100)]);
|
||
|
|
|
||
|
|
table.render(area, buf);
|
||
|
|
}
|
||
|
|
}
|