use ratatui::{
buffer::Buffer,
layout::Rect,
style::{Modifier, Style},
text::{Line, Span},
widgets::{Block, Borders, Paragraph, Widget},
};
use crate::render::theme::Theme;
const MAX_VISIBLE_ROWS: usize = 8;
pub struct FilePickerWidget<'a> {
pub theme: &'a Theme,
pub matches: &'a [String],
pub selected_index: usize,
pub loading: bool,
}
impl<'a> Widget for FilePickerWidget<'a> {
fn render(self, area: Rect, buf: &mut Buffer) {
let total = self.matches.len();
let selected = self.selected_index.min(total.saturating_sub(1));
let scroll_offset = if selected >= MAX_VISIBLE_ROWS {
selected + 1 - MAX_VISIBLE_ROWS
} else {
0
};
let visible_end = (scroll_offset + MAX_VISIBLE_ROWS).min(total);
let title = if total > MAX_VISIBLE_ROWS {
format!(
" Files ({}-{} of {}) ↑↓ navigate · Tab/Enter insert · Esc dismiss ",
scroll_offset + 1,
visible_end,
total
)
} else {
format!(" Files ({total}) ↑↓ navigate · Tab/Enter insert · Esc dismiss ")
};
let block = Block::default()
.borders(Borders::ALL)
.border_style(Style::new().fg(self.theme.colors.border.to_color()))
.title(title);
if self.matches.is_empty() {
let text = if self.loading {
" Scanning project files..."
} else {
" No matching files"
};
let line = Line::from(vec![Span::styled(
text,
Style::new().fg(self.theme.colors.text_disabled.to_color()),
)]);
Paragraph::new(vec![line]).block(block).render(area, buf);
return;
}
let mut lines: Vec<Line> = Vec::with_capacity(MAX_VISIBLE_ROWS);
for (offset, path) in self.matches[scroll_offset..visible_end].iter().enumerate() {
let absolute_index = scroll_offset + offset;
let is_selected = absolute_index == selected;
let (dir, name) = match path.rfind('/') {
Some(_) if path.ends_with('/') => ("", path.as_str()),
Some(idx) => path.split_at(idx + 1),
None => ("", path.as_str()),
};
let dir_style = if is_selected {
Style::new()
.fg(self.theme.colors.text_secondary.to_color())
.add_modifier(Modifier::REVERSED)
} else {
Style::new().fg(self.theme.colors.text_secondary.to_color())
};
let name_style = if is_selected {
Style::new()
.fg(self.theme.colors.text_highlight.to_color())
.add_modifier(Modifier::BOLD | Modifier::REVERSED)
} else {
Style::new().fg(self.theme.colors.text_primary.to_color())
};
lines.push(Line::from(vec![
Span::styled(" ", dir_style),
Span::styled(dir.to_string(), dir_style),
Span::styled(name.to_string(), name_style),
]));
}
Paragraph::new(lines).block(block).render(area, buf);
}
}
#[cfg(test)]
mod tests {
use super::*;
fn render_lines(widget: FilePickerWidget<'_>, width: u16, height: u16) -> Vec<String> {
let area = Rect::new(0, 0, width, height);
let mut buf = Buffer::empty(area);
widget.render(area, &mut buf);
(0..height)
.map(|y| {
(0..width)
.map(|x| buf[(x, y)].symbol().to_string())
.collect()
})
.collect()
}
#[test]
fn renders_matches_with_count_in_title() {
let theme = Theme::dark();
let matches = vec!["src/main.rs".to_string(), "docs/".to_string()];
let lines = render_lines(
FilePickerWidget {
theme: &theme,
matches: &matches,
selected_index: 0,
loading: false,
},
60,
5,
);
let all = lines.join("\n");
assert!(all.contains("Files (2)"), "{all}");
assert!(all.contains("main.rs"), "{all}");
assert!(all.contains("docs/"), "{all}");
}
#[test]
fn out_of_range_selection_does_not_panic() {
let theme = Theme::dark();
let matches = vec!["a.rs".to_string()];
let lines = render_lines(
FilePickerWidget {
theme: &theme,
matches: &matches,
selected_index: 99,
loading: false,
},
40,
4,
);
assert!(lines.join("\n").contains("a.rs"));
}
#[test]
fn empty_states_distinguish_loading_from_no_match() {
let theme = Theme::dark();
let loading = render_lines(
FilePickerWidget {
theme: &theme,
matches: &[],
selected_index: 0,
loading: true,
},
50,
3,
)
.join("\n");
assert!(loading.contains("Scanning project files"), "{loading}");
let empty = render_lines(
FilePickerWidget {
theme: &theme,
matches: &[],
selected_index: 0,
loading: false,
},
50,
3,
)
.join("\n");
assert!(empty.contains("No matching files"), "{empty}");
}
}