use ratatui::{
buffer::Buffer,
layout::Rect,
style::{Color, Modifier, Style},
text::{Line, Span},
widgets::Widget,
};
#[derive(Debug, Clone)]
pub struct FileMatchData {
pub path: String,
pub is_dir: bool,
}
pub struct FileAutocompleteWidget<'a> {
matches: &'a [FileMatchData],
selected_index: usize,
query: &'a str,
}
impl<'a> FileAutocompleteWidget<'a> {
pub fn new(matches: &'a [FileMatchData], selected_index: usize, query: &'a str) -> Self {
Self {
matches,
selected_index,
query,
}
}
fn highlight_match(&self, path: &str) -> Vec<Span<'static>> {
let query_lower = self.query.to_lowercase();
let path_lower = path.to_lowercase();
if self.query.is_empty() {
return vec![Span::raw(path.to_string())];
}
let mut spans = Vec::new();
let mut last_end = 0;
let mut pos = 0;
while pos < path.len() {
if let Some(start) = path_lower[pos..].find(&query_lower) {
let abs_start = pos + start;
let abs_end = abs_start + self.query.len();
if abs_start > last_end {
spans.push(Span::raw(path[last_end..abs_start].to_string()));
}
spans.push(Span::styled(
path[abs_start..abs_end.min(path.len())].to_string(),
Style::default()
.fg(Color::Yellow)
.add_modifier(Modifier::BOLD),
));
last_end = abs_end.min(path.len());
pos = abs_end;
} else {
break;
}
}
if last_end < path.len() {
spans.push(Span::raw(path[last_end..].to_string()));
}
spans
}
}
impl<'a> Widget for FileAutocompleteWidget<'a> {
fn render(self, area: Rect, buf: &mut Buffer) {
if self.matches.is_empty() || area.height == 0 {
return;
}
let border_style = Style::default().fg(Color::Cyan);
if area.height > 0 {
let top_line = Line::default().spans(vec![
Span::styled("┌", border_style),
Span::styled(
"─".repeat(area.width.saturating_sub(2) as usize),
border_style,
),
Span::styled("┐", border_style),
]);
top_line.render(area, buf);
}
let max_items = (area.height.saturating_sub(2)) as usize;
let scroll_offset = if self.selected_index >= max_items {
self.selected_index - max_items + 1
} else {
0
};
let items_to_show = self.matches.len().min(max_items);
let end_index = (scroll_offset + items_to_show).min(self.matches.len());
for (display_idx, file_match) in self.matches[scroll_offset..end_index].iter().enumerate() {
let actual_idx = scroll_offset + display_idx;
let y = area.y + 1 + display_idx as u16;
if y >= area.y + area.height - 1 {
break;
}
let is_selected = actual_idx == self.selected_index;
let bg_color = if is_selected {
Color::DarkGray
} else {
Color::Reset
};
let mut spans = vec![Span::styled("│", border_style)];
if is_selected {
spans.push(Span::styled(
"► ",
Style::default().fg(Color::Yellow).bg(bg_color),
));
} else {
spans.push(Span::styled(" ", Style::default().bg(bg_color)));
}
let mut path_spans = self.highlight_match(&file_match.path);
spans.append(&mut path_spans);
if file_match.is_dir {
spans.push(Span::styled(
"/",
Style::default().fg(Color::Blue).bg(bg_color),
));
}
let used_width: usize = spans.iter().map(|s| s.content.chars().count()).sum();
let padding = area
.width
.saturating_sub(used_width as u16)
.saturating_sub(1);
spans.push(Span::styled(
" ".repeat(padding as usize),
Style::default().bg(bg_color),
));
spans.push(Span::styled("│", border_style));
let line = Line::default().spans(spans);
line.render(
Rect {
x: area.x,
y,
width: area.width,
height: 1,
},
buf,
);
}
if area.height > 1 {
let bottom_y = area.y + area.height - 1;
let bottom_line = Line::default().spans(vec![
Span::styled("└", border_style),
Span::styled(
"─".repeat(area.width.saturating_sub(2) as usize),
border_style,
),
Span::styled("┘", border_style),
]);
bottom_line.render(
Rect {
x: area.x,
y: bottom_y,
width: area.width,
height: 1,
},
buf,
);
}
}
}
pub fn calculate_popup_area(input_area: Rect, match_count: usize) -> Rect {
let max_height = 10;
let height = (match_count + 2).min(max_height as usize) as u16;
Rect {
x: input_area.x,
y: input_area.y.saturating_sub(height),
width: input_area.width,
height,
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_highlight_match_basic() {
let matches = vec![FileMatchData {
path: "Cargo.toml".to_string(),
is_dir: false,
}];
let widget = FileAutocompleteWidget::new(&matches, 0, "Cargo");
let spans = widget.highlight_match("Cargo.toml");
assert!(!spans.is_empty());
}
#[test]
fn test_highlight_match_empty_query() {
let matches = vec![FileMatchData {
path: "Cargo.toml".to_string(),
is_dir: false,
}];
let widget = FileAutocompleteWidget::new(&matches, 0, "");
let spans = widget.highlight_match("Cargo.toml");
assert_eq!(spans.len(), 1);
assert_eq!(spans[0].content, "Cargo.toml");
}
#[test]
fn test_calculate_popup_area() {
let input_area = Rect::new(0, 20, 80, 3);
let popup = calculate_popup_area(input_area, 5);
assert_eq!(popup.height, 7); assert_eq!(popup.y, 13); }
}