use std::path::{Path, PathBuf};
use crate::components::{InteractionMode, InteractionOutcome, Text};
use crate::core::{AccessibilityProps, AccessibilityRole, Color, Element};
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum FileType {
File,
Directory,
Symlink,
Hidden,
}
impl FileType {
pub fn icon(&self) -> &'static str {
match self {
FileType::File => "📄",
FileType::Directory => "📁",
FileType::Symlink => "🔗",
FileType::Hidden => "👁",
}
}
pub fn simple_icon(&self) -> &'static str {
match self {
FileType::File => "-",
FileType::Directory => "d",
FileType::Symlink => "l",
FileType::Hidden => ".",
}
}
}
#[derive(Debug, Clone)]
pub struct FileEntry {
pub name: String,
pub path: PathBuf,
pub file_type: FileType,
pub size: Option<u64>,
pub is_hidden: bool,
}
impl FileEntry {
pub fn new(name: impl Into<String>, path: PathBuf, file_type: FileType) -> Self {
let name = name.into();
let is_hidden = name.starts_with('.');
Self {
name,
path,
file_type,
size: None,
is_hidden,
}
}
pub fn directory(name: impl Into<String>, path: PathBuf) -> Self {
Self::new(name, path, FileType::Directory)
}
pub fn file(name: impl Into<String>, path: PathBuf) -> Self {
Self::new(name, path, FileType::File)
}
pub fn with_size(mut self, size: u64) -> Self {
self.size = Some(size);
self
}
pub fn is_directory(&self) -> bool {
self.file_type == FileType::Directory
}
pub fn is_file(&self) -> bool {
self.file_type == FileType::File
}
}
#[derive(Debug, Clone, Default)]
pub enum FileFilter {
#[default]
All,
DirectoriesOnly,
FilesOnly,
Extensions(Vec<String>),
Custom(String),
}
impl FileFilter {
pub fn matches(&self, entry: &FileEntry) -> bool {
match self {
FileFilter::All => true,
FileFilter::DirectoriesOnly => entry.is_directory(),
FileFilter::FilesOnly => entry.is_file(),
FileFilter::Extensions(exts) => {
if entry.is_directory() {
true
} else {
exts.iter().any(|ext| entry.name.ends_with(ext))
}
}
FileFilter::Custom(_) => true, }
}
}
#[derive(Debug, Clone)]
pub struct FilePickerStyle {
pub show_icons: bool,
pub emoji_icons: bool,
pub show_sizes: bool,
pub show_hidden: bool,
pub dir_color: Color,
pub file_color: Color,
pub selected_color: Color,
pub cursor_color: Color,
}
impl Default for FilePickerStyle {
fn default() -> Self {
Self {
show_icons: true,
emoji_icons: false,
show_sizes: false,
show_hidden: false,
dir_color: Color::Blue,
file_color: Color::White,
selected_color: Color::Green,
cursor_color: Color::Cyan,
}
}
}
impl FilePickerStyle {
pub fn new() -> Self {
Self::default()
}
pub fn show_icons(mut self, show: bool) -> Self {
self.show_icons = show;
self
}
pub fn emoji_icons(mut self, emoji: bool) -> Self {
self.emoji_icons = emoji;
self
}
pub fn show_sizes(mut self, show: bool) -> Self {
self.show_sizes = show;
self
}
pub fn show_hidden(mut self, show: bool) -> Self {
self.show_hidden = show;
self
}
pub fn dir_color(mut self, color: Color) -> Self {
self.dir_color = color;
self
}
pub fn file_color(mut self, color: Color) -> Self {
self.file_color = color;
self
}
pub fn minimal() -> Self {
Self::new().show_icons(false).show_sizes(false)
}
pub fn detailed() -> Self {
Self::new()
.show_icons(true)
.show_sizes(true)
.emoji_icons(true)
}
}
#[derive(Debug, Clone)]
pub struct FilePickerState {
current_dir: PathBuf,
entries: Vec<FileEntry>,
cursor: usize,
selected: Vec<PathBuf>,
filter: FileFilter,
style: FilePickerStyle,
search: String,
multi_select: bool,
history: Vec<PathBuf>,
submitted: Option<Vec<PathBuf>>,
cancelled: bool,
}
impl Default for FilePickerState {
fn default() -> Self {
Self::new(PathBuf::from("."))
}
}
impl FilePickerState {
pub fn new(path: PathBuf) -> Self {
Self {
current_dir: path,
entries: Vec::new(),
cursor: 0,
selected: Vec::new(),
filter: FileFilter::All,
style: FilePickerStyle::default(),
search: String::new(),
multi_select: false,
history: Vec::new(),
submitted: None,
cancelled: false,
}
}
pub fn filter(mut self, filter: FileFilter) -> Self {
self.filter = filter;
self
}
pub fn style(mut self, style: FilePickerStyle) -> Self {
self.style = style;
self
}
pub fn multi_select(mut self, enabled: bool) -> Self {
self.multi_select = enabled;
self
}
pub fn current_dir(&self) -> &Path {
&self.current_dir
}
pub fn entries(&self) -> &[FileEntry] {
&self.entries
}
pub fn visible_entries(&self) -> Vec<&FileEntry> {
self.entries
.iter()
.filter(|e| {
if !self.filter.matches(e) {
return false;
}
if !self.style.show_hidden && e.is_hidden {
return false;
}
if !self.search.is_empty() {
return e.name.to_lowercase().contains(&self.search.to_lowercase());
}
true
})
.collect()
}
pub fn cursor(&self) -> usize {
self.cursor
}
pub fn focused(&self) -> Option<&FileEntry> {
let visible = self.visible_entries();
visible.get(self.cursor).copied()
}
pub fn selected(&self) -> &[PathBuf] {
&self.selected
}
pub fn submitted(&self) -> Option<&[PathBuf]> {
self.submitted.as_deref()
}
pub fn is_cancelled(&self) -> bool {
self.cancelled
}
pub fn is_selected(&self, path: &Path) -> bool {
self.selected.iter().any(|p| p == path)
}
pub fn search(&self) -> &str {
&self.search
}
pub fn set_search(&mut self, search: impl Into<String>) {
self.search = search.into();
self.cursor = 0;
}
pub fn clear_search(&mut self) {
self.search.clear();
}
pub fn set_entries(&mut self, entries: Vec<FileEntry>) {
self.entries = entries;
self.cursor = 0;
}
pub fn cursor_up(&mut self) {
let visible_count = self.visible_entries().len();
if visible_count > 0 && self.cursor > 0 {
self.cursor -= 1;
}
}
pub fn cursor_down(&mut self) {
let visible_count = self.visible_entries().len();
if visible_count > 0 && self.cursor < visible_count - 1 {
self.cursor += 1;
}
}
pub fn cursor_first(&mut self) {
self.cursor = 0;
}
pub fn cursor_last(&mut self) {
let visible_count = self.visible_entries().len();
if visible_count > 0 {
self.cursor = visible_count - 1;
}
}
pub fn page_up(&mut self, page_size: usize) {
self.cursor = self.cursor.saturating_sub(page_size);
}
pub fn page_down(&mut self, page_size: usize) {
let visible_count = self.visible_entries().len();
if visible_count > 0 {
self.cursor = (self.cursor + page_size).min(visible_count - 1);
}
}
pub fn toggle_selection(&mut self) {
if let Some(entry) = self.focused() {
let path = entry.path.clone();
if self.is_selected(&path) {
self.selected.retain(|p| p != &path);
} else {
if !self.multi_select {
self.selected.clear();
}
self.selected.push(path);
}
}
}
pub fn select(&mut self) {
if let Some(entry) = self.focused() {
let path = entry.path.clone();
if !self.is_selected(&path) {
if !self.multi_select {
self.selected.clear();
}
self.selected.push(path);
}
}
}
pub fn clear_selection(&mut self) {
self.selected.clear();
}
pub fn enter_directory(&mut self) -> Option<PathBuf> {
let new_dir = {
let entry = self.focused()?;
if !entry.is_directory() {
return None;
}
entry.path.clone()
};
self.history.push(self.current_dir.clone());
self.current_dir = new_dir.clone();
self.cursor = 0;
self.search.clear();
Some(new_dir)
}
pub fn go_parent(&mut self) -> Option<PathBuf> {
if let Some(parent) = self.current_dir.parent() {
self.history.push(self.current_dir.clone());
let parent_path = parent.to_path_buf();
self.current_dir = parent_path.clone();
self.cursor = 0;
self.search.clear();
return Some(parent_path);
}
None
}
pub fn go_back(&mut self) -> Option<PathBuf> {
if let Some(prev) = self.history.pop() {
self.current_dir = prev.clone();
self.cursor = 0;
self.search.clear();
return Some(prev);
}
None
}
pub fn navigate_to(&mut self, path: PathBuf) {
self.history.push(self.current_dir.clone());
self.current_dir = path;
self.cursor = 0;
self.search.clear();
}
pub fn get_style(&self) -> &FilePickerStyle {
&self.style
}
pub fn toggle_hidden(&mut self) {
self.style.show_hidden = !self.style.show_hidden;
self.cursor = 0;
}
}
pub fn handle_file_picker_input(
state: &mut FilePickerState,
input: &str,
key: &crate::hooks::Key,
mode: InteractionMode,
) -> InteractionOutcome<Vec<PathBuf>> {
if mode.is_disabled() {
return InteractionOutcome::Ignored;
}
if key.escape {
state.cancelled = true;
return InteractionOutcome::Cancelled;
}
if key.up_arrow {
state.cursor_up();
return InteractionOutcome::Handled;
}
if key.down_arrow {
state.cursor_down();
return InteractionOutcome::Handled;
}
if key.home {
state.cursor_first();
return InteractionOutcome::Handled;
}
if key.end {
state.cursor_last();
return InteractionOutcome::Handled;
}
if key.page_up {
state.page_up(10);
return InteractionOutcome::Handled;
}
if key.page_down {
state.page_down(10);
return InteractionOutcome::Handled;
}
if key.backspace && state.search.is_empty() {
if state.go_parent().is_some() {
return InteractionOutcome::Handled;
}
return InteractionOutcome::Ignored;
}
if key.return_key && state.focused().is_some_and(FileEntry::is_directory) {
state.enter_directory();
return InteractionOutcome::Handled;
}
if mode.is_read_only() {
return InteractionOutcome::Ignored;
}
if key.backspace {
state.search.pop();
state.cursor = 0;
return InteractionOutcome::Handled;
}
if key.return_key {
state.select();
let selected = state.selected.clone();
state.submitted = Some(selected.clone());
return InteractionOutcome::Submitted(selected);
}
if key.space {
state.toggle_selection();
return InteractionOutcome::Changed(state.selected.clone());
}
if input.chars().count() == 1 && !key.ctrl && !key.alt {
if let Some(ch) = input.chars().next() {
if !ch.is_control() {
let mut search = state.search.clone();
search.push(ch);
state.set_search(search);
return InteractionOutcome::Handled;
}
}
}
InteractionOutcome::Ignored
}
#[derive(Debug)]
pub struct FilePicker<'a> {
state: &'a FilePickerState,
max_visible: usize,
show_path: bool,
show_status: bool,
}
impl<'a> FilePicker<'a> {
pub fn new(state: &'a FilePickerState) -> Self {
Self {
state,
max_visible: 10,
show_path: true,
show_status: true,
}
}
pub fn max_visible(mut self, max: usize) -> Self {
self.max_visible = max;
self
}
pub fn show_path(mut self, show: bool) -> Self {
self.show_path = show;
self
}
pub fn show_status(mut self, show: bool) -> Self {
self.show_status = show;
self
}
pub fn render(&self) -> String {
let mut output = String::new();
let style = self.state.get_style();
let visible = self.state.visible_entries();
if self.show_path {
output.push_str(&format!(
"\x1b[1m{}\x1b[0m\n",
self.state.current_dir.display()
));
output.push_str(&"─".repeat(40));
output.push('\n');
}
if !self.state.search.is_empty() {
output.push_str(&format!("🔍 {}\n", self.state.search));
}
let total = visible.len();
let start = if total <= self.max_visible || self.state.cursor < self.max_visible / 2 {
0
} else if self.state.cursor > total - self.max_visible / 2 {
total - self.max_visible
} else {
self.state.cursor - self.max_visible / 2
};
let end = (start + self.max_visible).min(total);
if start > 0 {
output.push_str(&format!(" \x1b[90m↑ {} more above\x1b[0m\n", start));
}
for (i, entry) in visible.iter().enumerate().skip(start).take(end - start) {
let is_focused = i == self.state.cursor;
let is_selected = self.state.is_selected(&entry.path);
if is_focused {
output.push_str("\x1b[7m"); }
if is_selected {
output.push_str("✓ ");
} else {
output.push_str(" ");
}
if style.show_icons {
let icon = if style.emoji_icons {
entry.file_type.icon()
} else {
entry.file_type.simple_icon()
};
output.push_str(icon);
output.push(' ');
}
let color = if entry.is_directory() {
style.dir_color
} else {
style.file_color
};
output.push_str(&color.to_ansi_fg());
output.push_str(&entry.name);
output.push_str("\x1b[0m");
if style.show_sizes {
if let Some(size) = entry.size {
output.push_str(&format!(" {}", format_size(size)));
}
}
if is_focused {
output.push_str("\x1b[0m");
}
output.push('\n');
}
if end < total {
output.push_str(&format!(" \x1b[90m↓ {} more below\x1b[0m\n", total - end));
}
if self.show_status {
output.push_str(&"─".repeat(40));
output.push('\n');
output.push_str(&format!("{}/{} items", self.state.cursor + 1, total));
if !self.state.selected.is_empty() {
output.push_str(&format!(" | {} selected", self.state.selected.len()));
}
}
output
}
pub fn into_element(self) -> Element {
let visible_entries = self.state.visible_entries();
let mut accessibility = AccessibilityProps::new(AccessibilityRole::FilePicker)
.label(format!(
"File picker {}",
self.state.current_dir().to_string_lossy()
))
.description(format!("{} entries", visible_entries.len()))
.focusable(true);
if let Some(entry) = visible_entries.get(self.state.cursor()).copied() {
accessibility = accessibility.value(entry.name.clone());
}
Text::new(self.render())
.into_element()
.with_accessibility(accessibility)
}
}
fn format_size(bytes: u64) -> String {
const KB: u64 = 1024;
const MB: u64 = KB * 1024;
const GB: u64 = MB * 1024;
if bytes >= GB {
format!("{:.1}G", bytes as f64 / GB as f64)
} else if bytes >= MB {
format!("{:.1}M", bytes as f64 / MB as f64)
} else if bytes >= KB {
format!("{:.1}K", bytes as f64 / KB as f64)
} else {
format!("{}B", bytes)
}
}
#[cfg(test)]
#[path = "file_picker_tests.rs"]
mod file_picker_tests;