#![forbid(unsafe_code)]
use ftui_core::geometry::Rect;
use ftui_render::cell::{Cell, CellContent};
use ftui_render::frame::Frame;
use ftui_style::Style;
use ftui_text::{display_width, grapheme_width, graphemes};
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
pub enum FileKind {
File,
Directory,
Symlink,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct FileEntry {
pub name: String,
pub kind: FileKind,
pub size: Option<u64>,
}
impl FileEntry {
#[must_use]
pub fn new(name: impl Into<String>, kind: FileKind) -> Self {
Self {
name: name.into(),
kind,
size: None,
}
}
#[must_use]
pub fn with_size(mut self, size: u64) -> Self {
self.size = Some(size);
self
}
#[must_use]
pub fn is_dir(&self) -> bool {
self.kind == FileKind::Directory
}
#[must_use]
pub fn icon(&self) -> &'static str {
match self.kind {
FileKind::File => " ",
FileKind::Directory => "/",
FileKind::Symlink => "@",
}
}
}
#[derive(Debug, Clone, Default)]
pub struct FilePickerStyle {
pub selected: Style,
pub directory: Style,
pub file: Style,
pub symlink: Style,
pub path: Style,
}
#[derive(Debug, Clone, Default)]
pub struct FilePickerFilter {
pub allowed_extensions: Vec<String>,
pub show_hidden: bool,
}
impl FilePickerFilter {
#[must_use]
pub fn matches(&self, entry: &FileEntry) -> bool {
if entry.is_dir() {
return true;
}
if !self.show_hidden && entry.name.starts_with('.') {
return false;
}
if !self.allowed_extensions.is_empty() {
let ext = entry
.name
.rsplit('.')
.next()
.unwrap_or("")
.to_ascii_lowercase();
return self
.allowed_extensions
.iter()
.any(|a| a.to_ascii_lowercase() == ext);
}
true
}
}
#[derive(Debug, Clone)]
pub struct FilePicker {
entries: Vec<FileEntry>,
selected: usize,
scroll_offset: usize,
current_path: String,
filter: FilePickerFilter,
filtered_indices: Vec<usize>,
style: FilePickerStyle,
visible_height: usize,
}
impl Default for FilePicker {
fn default() -> Self {
Self::new(Vec::new())
}
}
impl FilePicker {
#[must_use]
pub fn new(entries: Vec<FileEntry>) -> Self {
let mut picker = Self {
entries,
selected: 0,
scroll_offset: 0,
current_path: String::from("/"),
filter: FilePickerFilter::default(),
filtered_indices: Vec::new(),
style: FilePickerStyle::default(),
visible_height: 20,
};
picker.rebuild_filter();
picker
}
pub fn set_path(&mut self, path: impl Into<String>) {
self.current_path = path.into();
}
#[must_use]
pub fn path(&self) -> &str {
&self.current_path
}
pub fn set_entries(&mut self, entries: Vec<FileEntry>) {
self.entries = entries;
self.selected = 0;
self.scroll_offset = 0;
self.rebuild_filter();
}
pub fn set_filter(&mut self, filter: FilePickerFilter) {
self.filter = filter;
self.rebuild_filter();
}
pub fn set_style(&mut self, style: FilePickerStyle) {
self.style = style;
}
#[must_use]
pub fn filtered_count(&self) -> usize {
self.filtered_indices.len()
}
#[must_use]
pub fn selected_index(&self) -> usize {
self.selected
}
#[must_use]
pub fn selected_entry(&self) -> Option<&FileEntry> {
let idx = *self.filtered_indices.get(self.selected)?;
self.entries.get(idx)
}
pub fn move_up(&mut self) {
if self.selected > 0 {
self.selected -= 1;
self.ensure_visible();
}
}
pub fn move_down(&mut self) {
if self.selected + 1 < self.filtered_indices.len() {
self.selected += 1;
self.ensure_visible();
}
}
pub fn move_to_first(&mut self) {
self.selected = 0;
self.scroll_offset = 0;
}
pub fn move_to_last(&mut self) {
if !self.filtered_indices.is_empty() {
self.selected = self.filtered_indices.len() - 1;
self.ensure_visible();
}
}
pub fn page_up(&mut self) {
if self.visible_height > 1 {
self.selected = self.selected.saturating_sub(self.visible_height - 1);
self.ensure_visible();
}
}
pub fn page_down(&mut self) {
if self.visible_height > 1 && !self.filtered_indices.is_empty() {
self.selected =
(self.selected + self.visible_height - 1).min(self.filtered_indices.len() - 1);
self.ensure_visible();
}
}
fn rebuild_filter(&mut self) {
self.filtered_indices = self
.entries
.iter()
.enumerate()
.filter(|(_, e)| self.filter.matches(e))
.map(|(i, _)| i)
.collect();
if self.selected >= self.filtered_indices.len() {
self.selected = self.filtered_indices.len().saturating_sub(1);
}
self.ensure_visible();
}
fn ensure_visible(&mut self) {
if self.visible_height == 0 {
return;
}
if self.selected < self.scroll_offset {
self.scroll_offset = self.selected;
} else if self.selected >= self.scroll_offset + self.visible_height {
self.scroll_offset = self.selected - self.visible_height + 1;
}
}
pub fn render(&mut self, area: Rect, frame: &mut Frame) {
if area.height == 0 || area.width == 0 {
return;
}
let width = area.width as usize;
let path_display = truncate_str(&self.current_path, width);
draw_line(frame, area.x, area.y, &path_display, self.style.path, width);
let entry_area_height = (area.height as usize).saturating_sub(1);
self.visible_height = entry_area_height;
self.ensure_visible();
for row in 0..entry_area_height {
let idx = self.scroll_offset + row;
let y = area.y.saturating_add(1).saturating_add(row as u16);
if let Some(&orig_idx) = self.filtered_indices.get(idx) {
let entry = &self.entries[orig_idx];
let is_selected = idx == self.selected;
let prefix = entry.icon();
let name = &entry.name;
let display = format!("{prefix}{name}");
let display = truncate_str(&display, width);
let style = if is_selected {
self.style.selected
} else {
match entry.kind {
FileKind::Directory => self.style.directory,
FileKind::File => self.style.file,
FileKind::Symlink => self.style.symlink,
}
};
draw_line(frame, area.x, y, &display, style, width);
}
}
}
}
fn text_width(text: &str) -> usize {
display_width(text)
}
fn truncate_str(s: &str, max_width: usize) -> String {
if text_width(s) <= max_width {
return s.to_string();
}
if max_width <= 3 {
return ".".repeat(max_width);
}
let target = max_width - 3;
let mut result = String::new();
let mut current_width = 0;
for grapheme in graphemes(s) {
let w = grapheme_width(grapheme);
if w == 0 {
continue;
}
if current_width + w > target {
break;
}
result.push_str(grapheme);
current_width += w;
}
result.push_str("...");
result
}
fn draw_line(frame: &mut Frame, x: u16, y: u16, text: &str, style: Style, width: usize) {
let mut col = 0;
for grapheme in graphemes(text) {
if col >= width {
break;
}
let w = grapheme_width(grapheme);
if w == 0 {
continue;
}
if col.saturating_add(w) > width {
break;
}
let content = if w > 1 || grapheme.chars().count() > 1 {
let id = frame.intern_with_width(grapheme, w.min(u8::MAX as usize) as u8);
CellContent::from_grapheme(id)
} else if let Some(c) = grapheme.chars().next() {
CellContent::from_char(c)
} else {
continue;
};
let cell_x = x.saturating_add(col as u16);
let mut cell = Cell::new(content);
apply_style(&mut cell, style);
frame.buffer.set_fast(cell_x, y, cell);
col += w;
}
while col < width {
let cell_x = x.saturating_add(col as u16);
let mut cell = Cell::from_char(' ');
apply_style(&mut cell, style);
frame.buffer.set_fast(cell_x, y, cell);
col += 1;
}
}
fn apply_style(cell: &mut Cell, style: Style) {
if let Some(fg) = style.fg {
cell.fg = fg;
}
if let Some(bg) = style.bg {
match bg.a() {
0 => {} 255 => cell.bg = bg, _ => cell.bg = bg.over(cell.bg), }
}
if let Some(attrs) = style.attrs {
let cell_flags: ftui_render::cell::StyleFlags = attrs.into();
cell.attrs = cell.attrs.merged_flags(cell_flags);
}
}
#[cfg(test)]
mod tests {
use super::*;
use ftui_render::grapheme_pool::GraphemePool;
use std::fs;
use std::path::{Path, PathBuf};
fn fixture_root() -> PathBuf {
Path::new(env!("CARGO_MANIFEST_DIR"))
.join("target")
.join("filepicker_fixture")
}
fn create_file_if_missing(path: &Path, contents: &str) {
if path.exists() {
return;
}
if let Some(parent) = path.parent() {
fs::create_dir_all(parent).expect("create fixture parent");
}
fs::write(path, contents).expect("write fixture file");
}
fn ensure_fixture_dir() -> PathBuf {
let root = fixture_root();
fs::create_dir_all(&root).expect("create fixture root");
let src_dir = root.join("src");
fs::create_dir_all(&src_dir).expect("create src dir");
create_file_if_missing(&root.join("README.md"), "# readme\n");
create_file_if_missing(&root.join("notes.txt"), "notes\n");
create_file_if_missing(&root.join(".hidden"), "hidden\n");
create_file_if_missing(&src_dir.join("main.rs"), "fn main() {}\n");
root
}
fn load_entries_sorted(dir: &Path) -> Vec<FileEntry> {
let mut entries: Vec<FileEntry> = fs::read_dir(dir)
.expect("read_dir")
.filter_map(|entry| entry.ok())
.map(|entry| {
let name = entry.file_name().to_string_lossy().to_string();
let file_type = entry.file_type().expect("file_type");
let kind = if file_type.is_dir() {
FileKind::Directory
} else if file_type.is_symlink() {
FileKind::Symlink
} else {
FileKind::File
};
let size = if file_type.is_file() {
entry.metadata().ok().map(|m| m.len())
} else {
None
};
FileEntry { name, kind, size }
})
.collect();
entries.sort_by(|a, b| a.name.cmp(&b.name));
entries
}
fn sample_entries() -> Vec<FileEntry> {
vec![
FileEntry::new("..", FileKind::Directory),
FileEntry::new("src", FileKind::Directory),
FileEntry::new("tests", FileKind::Directory),
FileEntry::new("Cargo.toml", FileKind::File),
FileEntry::new("README.md", FileKind::File),
FileEntry::new(".gitignore", FileKind::File),
]
}
#[test]
fn new_picker_selects_first() {
let picker = FilePicker::new(sample_entries());
assert_eq!(picker.selected_index(), 0);
assert_eq!(picker.selected_entry().unwrap().name, "..");
}
#[test]
fn move_down_and_up() {
let mut picker = FilePicker::new(sample_entries());
picker.move_down();
assert_eq!(picker.selected_entry().unwrap().name, "src");
picker.move_down();
assert_eq!(picker.selected_entry().unwrap().name, "tests");
picker.move_up();
assert_eq!(picker.selected_entry().unwrap().name, "src");
}
#[test]
fn move_up_at_top_is_noop() {
let mut picker = FilePicker::new(sample_entries());
picker.move_up();
assert_eq!(picker.selected_index(), 0);
}
#[test]
fn move_down_at_bottom_is_noop() {
let mut picker = FilePicker::new(sample_entries());
for _ in 0..10 {
picker.move_down();
}
assert_eq!(picker.selected_index(), picker.filtered_count() - 1);
}
#[test]
fn move_to_first_and_last() {
let mut picker = FilePicker::new(sample_entries());
picker.move_to_last();
assert_eq!(picker.selected_entry().unwrap().name, "README.md");
picker.move_to_first();
assert_eq!(picker.selected_entry().unwrap().name, "..");
}
#[test]
fn set_entries_resets_selection() {
let mut picker = FilePicker::new(sample_entries());
picker.move_down();
picker.move_down();
assert_eq!(picker.selected_index(), 2);
picker.set_entries(vec![FileEntry::new("new_file.txt", FileKind::File)]);
assert_eq!(picker.selected_index(), 0);
assert_eq!(picker.selected_entry().unwrap().name, "new_file.txt");
}
#[test]
fn empty_picker() {
let picker = FilePicker::new(Vec::new());
assert_eq!(picker.filtered_count(), 0);
assert!(picker.selected_entry().is_none());
}
#[test]
fn filter_by_extension() {
let mut picker = FilePicker::new(sample_entries());
picker.set_filter(FilePickerFilter {
allowed_extensions: vec!["md".into()],
show_hidden: true,
});
let names: Vec<&str> = (0..picker.filtered_count())
.map(|i| {
let idx = picker.filtered_indices[i];
picker.entries[idx].name.as_str()
})
.collect();
assert!(names.contains(&"README.md"));
assert!(!names.contains(&"Cargo.toml"));
assert!(names.contains(&"src"));
}
#[test]
fn filter_hidden_files() {
let mut picker = FilePicker::new(sample_entries());
picker.set_filter(FilePickerFilter {
allowed_extensions: vec![],
show_hidden: false,
});
let names: Vec<&str> = (0..picker.filtered_count())
.map(|i| {
let idx = picker.filtered_indices[i];
picker.entries[idx].name.as_str()
})
.collect();
assert!(!names.contains(&".gitignore"));
assert!(names.contains(&"Cargo.toml"));
}
#[test]
fn filter_show_hidden() {
let mut picker = FilePicker::new(sample_entries());
picker.set_filter(FilePickerFilter {
allowed_extensions: vec![],
show_hidden: true,
});
let has_hidden = (0..picker.filtered_count()).any(|i| {
let idx = picker.filtered_indices[i];
picker.entries[idx].name == ".gitignore"
});
assert!(has_hidden);
}
#[test]
fn filter_real_fs_extensions_and_hidden() {
let root = ensure_fixture_dir();
let entries = load_entries_sorted(&root);
let mut picker = FilePicker::new(entries);
picker.set_filter(FilePickerFilter {
allowed_extensions: vec!["md".into()],
show_hidden: false,
});
let names: Vec<String> = (0..picker.filtered_count())
.map(|i| {
let idx = picker.filtered_indices[i];
picker.entries[idx].name.clone()
})
.collect();
assert!(names.contains(&"README.md".to_string()));
assert!(!names.contains(&"notes.txt".to_string()));
assert!(!names.contains(&".hidden".to_string()));
assert!(names.contains(&"src".to_string()));
}
#[test]
fn filter_real_fs_show_hidden() {
let root = ensure_fixture_dir();
let entries = load_entries_sorted(&root);
let mut picker = FilePicker::new(entries);
picker.set_filter(FilePickerFilter {
allowed_extensions: vec![],
show_hidden: true,
});
let names: Vec<String> = (0..picker.filtered_count())
.map(|i| {
let idx = picker.filtered_indices[i];
picker.entries[idx].name.clone()
})
.collect();
assert!(names.contains(&".hidden".to_string()));
}
#[test]
fn page_up_down() {
let entries: Vec<FileEntry> = (0..50)
.map(|i| FileEntry::new(format!("file_{i:03}.txt"), FileKind::File))
.collect();
let mut picker = FilePicker::new(entries);
picker.visible_height = 10;
picker.page_down();
assert_eq!(picker.selected_index(), 9);
picker.page_down();
assert_eq!(picker.selected_index(), 18);
picker.page_up();
assert_eq!(picker.selected_index(), 9);
}
#[test]
fn scroll_offset_follows_selection() {
let entries: Vec<FileEntry> = (0..30)
.map(|i| FileEntry::new(format!("file_{i:03}.txt"), FileKind::File))
.collect();
let mut picker = FilePicker::new(entries);
picker.visible_height = 5;
for _ in 0..7 {
picker.move_down();
}
assert!(picker.scroll_offset > 0);
assert!(picker.selected >= picker.scroll_offset);
assert!(picker.selected < picker.scroll_offset + picker.visible_height);
}
#[test]
fn file_entry_icons() {
assert_eq!(FileEntry::new("f", FileKind::File).icon(), " ");
assert_eq!(FileEntry::new("d", FileKind::Directory).icon(), "/");
assert_eq!(FileEntry::new("l", FileKind::Symlink).icon(), "@");
}
#[test]
fn truncate_str_short() {
assert_eq!(truncate_str("hello", 10), "hello");
}
#[test]
fn truncate_str_exact() {
assert_eq!(truncate_str("hello", 5), "hello");
}
#[test]
fn truncate_str_truncated() {
let result = truncate_str("hello world", 8);
assert!(result.ends_with("..."));
assert!(text_width(&result) <= 8);
}
#[test]
fn truncate_str_very_narrow() {
assert_eq!(truncate_str("hello", 3), "...");
assert_eq!(truncate_str("hello", 2), "..");
assert_eq!(truncate_str("hello", 1), ".");
}
#[test]
fn render_basic() {
let mut picker = FilePicker::new(sample_entries());
picker.set_path("/home/user/project");
let mut pool = GraphemePool::new();
let mut frame = Frame::new(40, 10, &mut pool);
let area = Rect::from_size(40, 10);
picker.render(area, &mut frame);
}
#[test]
fn render_zero_area() {
let mut picker = FilePicker::new(sample_entries());
let mut pool = GraphemePool::new();
let mut frame = Frame::new(40, 10, &mut pool);
picker.render(Rect::from_size(40, 0), &mut frame);
picker.render(Rect::from_size(0, 10), &mut frame);
}
#[test]
fn render_narrow_width() {
let mut picker = FilePicker::new(sample_entries());
let mut pool = GraphemePool::new();
let mut frame = Frame::new(10, 10, &mut pool);
let area = Rect::from_size(10, 10);
picker.render(area, &mut frame);
}
#[test]
fn set_path_and_get() {
let mut picker = FilePicker::new(Vec::new());
picker.set_path("/tmp/test");
assert_eq!(picker.path(), "/tmp/test");
}
#[test]
fn file_entry_with_size() {
let entry = FileEntry::new("big.dat", FileKind::File).with_size(1024);
assert_eq!(entry.size, Some(1024));
}
#[test]
fn file_kind_debug_clone_copy_eq_hash() {
use std::collections::HashSet;
let kind = FileKind::Directory;
let copied = kind;
assert_eq!(kind, copied);
assert_ne!(FileKind::File, FileKind::Directory);
assert_ne!(FileKind::Symlink, FileKind::File);
assert!(!format!("{:?}", kind).is_empty());
let mut set = HashSet::new();
set.insert(FileKind::File);
set.insert(FileKind::Directory);
set.insert(FileKind::Symlink);
assert_eq!(set.len(), 3);
}
#[test]
fn file_entry_debug_clone_eq() {
let entry = FileEntry::new("test.rs", FileKind::File).with_size(42);
let cloned = entry.clone();
assert_eq!(entry, cloned);
assert!(!format!("{:?}", entry).is_empty());
let different = FileEntry::new("other.rs", FileKind::File);
assert_ne!(entry, different);
}
#[test]
fn file_entry_is_dir() {
assert!(FileEntry::new("d", FileKind::Directory).is_dir());
assert!(!FileEntry::new("f", FileKind::File).is_dir());
assert!(!FileEntry::new("l", FileKind::Symlink).is_dir());
}
#[test]
fn file_entry_new_size_is_none() {
let entry = FileEntry::new("x", FileKind::File);
assert!(entry.size.is_none());
}
#[test]
fn file_picker_style_debug_clone_default() {
let style = FilePickerStyle::default();
let cloned = style.clone();
assert!(!format!("{:?}", cloned).is_empty());
}
#[test]
fn file_picker_filter_debug_clone_default() {
let filter = FilePickerFilter::default();
let cloned = filter.clone();
assert!(cloned.allowed_extensions.is_empty());
assert!(!cloned.show_hidden);
assert!(!format!("{:?}", filter).is_empty());
}
#[test]
fn filter_directories_always_pass() {
let filter = FilePickerFilter {
allowed_extensions: vec!["rs".into()],
show_hidden: false,
};
let dir = FileEntry::new(".hidden_dir", FileKind::Directory);
assert!(filter.matches(&dir), "directories always pass filter");
}
#[test]
fn filter_extension_case_insensitive() {
let filter = FilePickerFilter {
allowed_extensions: vec!["RS".into()],
show_hidden: true,
};
let entry = FileEntry::new("main.rs", FileKind::File);
assert!(
filter.matches(&entry),
"extension matching should be case-insensitive"
);
}
#[test]
fn file_picker_default_empty() {
let picker = FilePicker::default();
assert_eq!(picker.filtered_count(), 0);
assert!(picker.selected_entry().is_none());
assert_eq!(picker.path(), "/");
}
#[test]
fn file_picker_debug_clone() {
let picker = FilePicker::new(sample_entries());
let cloned = picker.clone();
assert_eq!(cloned.filtered_count(), picker.filtered_count());
assert!(!format!("{:?}", picker).is_empty());
}
#[test]
fn set_style_applies() {
let mut picker = FilePicker::new(sample_entries());
let style = FilePickerStyle {
selected: Style::new().fg(ftui_render::cell::PackedRgba::rgb(255, 0, 0)),
..Default::default()
};
picker.set_style(style);
}
#[test]
fn page_up_from_top_stays_at_zero() {
let mut picker = FilePicker::new(sample_entries());
picker.visible_height = 3;
picker.page_up();
assert_eq!(picker.selected_index(), 0);
}
#[test]
fn page_down_clamps_at_end() {
let entries: Vec<FileEntry> = (0..10)
.map(|i| FileEntry::new(format!("f{i}"), FileKind::File))
.collect();
let mut picker = FilePicker::new(entries);
picker.visible_height = 5;
picker.page_down();
picker.page_down();
picker.page_down(); assert_eq!(picker.selected_index(), picker.filtered_count() - 1);
}
#[test]
fn move_to_last_empty_is_noop() {
let mut picker = FilePicker::new(Vec::new());
picker.move_to_last(); assert_eq!(picker.selected_index(), 0);
}
#[test]
fn truncate_str_empty() {
assert_eq!(truncate_str("", 10), "");
assert_eq!(truncate_str("", 0), "");
}
#[test]
fn truncate_str_zero_width() {
assert_eq!(truncate_str("hello", 0), "");
}
#[test]
fn text_width_simple() {
assert_eq!(text_width("hello"), 5);
assert_eq!(text_width(""), 0);
}
#[test]
fn rebuild_filter_clamps_selected() {
let mut picker = FilePicker::new(sample_entries());
picker.move_to_last();
let old_sel = picker.selected_index();
picker.set_filter(FilePickerFilter {
allowed_extensions: vec!["toml".into()],
show_hidden: false,
});
assert!(picker.selected_index() < picker.filtered_count());
assert!(picker.selected_index() <= old_sel);
}
#[test]
fn render_updates_visible_height() {
let mut picker = FilePicker::new(sample_entries());
let mut pool = GraphemePool::new();
let mut frame = Frame::new(40, 15, &mut pool);
let area = Rect::from_size(40, 15);
picker.render(area, &mut frame);
assert_eq!(picker.visible_height, 14);
}
#[test]
fn render_path_displayed() {
let mut picker = FilePicker::new(sample_entries());
picker.set_path("/usr/local");
let mut pool = GraphemePool::new();
let mut frame = Frame::new(40, 10, &mut pool);
picker.render(Rect::from_size(40, 10), &mut frame);
let cell = frame.buffer.get(0, 0).unwrap();
assert_eq!(cell.content.as_char(), Some('/'));
}
}