use std::fs;
use std::path::PathBuf;
use ratatui::{
layout::Rect,
style::{Color, Modifier, Style},
text::{Line, Span},
widgets::{Block, Borders, Clear, List, ListItem},
Frame,
};
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum FileExplorerMode {
Load,
Save,
}
#[derive(Debug)]
pub struct FileExplorer {
visible: bool,
mode: FileExplorerMode,
files: Vec<PathBuf>,
selected_index: usize,
current_dir: PathBuf,
save_filename: String,
editing_filename: bool,
}
impl Default for FileExplorer {
fn default() -> Self {
Self::new()
}
}
impl FileExplorer {
pub fn new() -> Self {
Self {
visible: false,
mode: FileExplorerMode::Load,
files: Vec::new(),
selected_index: 0,
current_dir: PathBuf::from("."),
save_filename: String::from("level.json"),
editing_filename: false,
}
}
pub fn show_load(&mut self) {
self.mode = FileExplorerMode::Load;
self.visible = true;
self.selected_index = 0;
self.editing_filename = false;
self.refresh_files();
}
pub fn show_save(&mut self) {
self.mode = FileExplorerMode::Save;
self.visible = true;
self.selected_index = 0;
self.save_filename = String::from("level.json");
self.editing_filename = true;
self.refresh_files();
}
pub fn hide(&mut self) {
self.visible = false;
self.editing_filename = false;
}
pub fn is_visible(&self) -> bool {
self.visible
}
pub fn mode(&self) -> FileExplorerMode {
self.mode
}
pub fn is_editing_filename(&self) -> bool {
self.editing_filename
}
pub fn refresh_files(&mut self) {
self.files.clear();
if let Ok(entries) = fs::read_dir(&self.current_dir) {
for entry in entries.flatten() {
let path = entry.path();
if path.is_file() {
if let Some(ext) = path.extension() {
if ext.eq_ignore_ascii_case("json") {
self.files.push(path);
}
}
}
}
}
self.files.sort();
if self.selected_index >= self.files.len() && !self.files.is_empty() {
self.selected_index = self.files.len() - 1;
}
}
pub fn select_previous(&mut self) {
if self.mode == FileExplorerMode::Save && self.editing_filename {
if !self.files.is_empty() {
self.editing_filename = false;
self.selected_index = self.files.len().saturating_sub(1);
}
} else if self.selected_index > 0 {
self.selected_index -= 1;
} else if self.mode == FileExplorerMode::Save {
self.editing_filename = true;
}
}
pub fn select_next(&mut self) {
if self.mode == FileExplorerMode::Save && self.editing_filename {
if !self.files.is_empty() {
self.editing_filename = false;
self.selected_index = 0;
}
} else if self.selected_index < self.files.len().saturating_sub(1) {
self.selected_index += 1;
} else if self.mode == FileExplorerMode::Save {
self.editing_filename = true;
}
}
pub fn selected_file(&self) -> Option<&PathBuf> {
if self.editing_filename {
None
} else {
self.files.get(self.selected_index)
}
}
pub fn save_filename(&self) -> &str {
&self.save_filename
}
pub fn get_target_path(&self) -> Option<String> {
match self.mode {
FileExplorerMode::Save => {
if self.editing_filename {
let filename = if self.save_filename.ends_with(".json") {
self.save_filename.clone()
} else {
format!("{}.json", self.save_filename)
};
Some(filename)
} else {
self.selected_file()
.map(|p| p.to_string_lossy().to_string())
}
}
FileExplorerMode::Load => self
.selected_file()
.map(|p| p.to_string_lossy().to_string()),
}
}
pub fn handle_char(&mut self, c: char) {
if self.editing_filename
&& self.mode == FileExplorerMode::Save
&& (c.is_alphanumeric() || c == '_' || c == '-' || c == '.')
{
self.save_filename.push(c);
}
}
pub fn handle_backspace(&mut self) {
if self.editing_filename && self.mode == FileExplorerMode::Save {
self.save_filename.pop();
}
}
pub fn render(&self, frame: &mut Frame, area: Rect) {
if !self.visible {
return;
}
let popup_width = (area.width * 60 / 100).max(30).min(area.width);
let popup_height = (area.height * 50 / 100).max(10).min(area.height);
let popup_x = (area.width.saturating_sub(popup_width)) / 2;
let popup_y = (area.height.saturating_sub(popup_height)) / 2;
let popup_area = Rect::new(popup_x, popup_y, popup_width, popup_height);
frame.render_widget(Clear, popup_area);
let title = match self.mode {
FileExplorerMode::Load => " Load Level ",
FileExplorerMode::Save => " Save Level ",
};
let mut items: Vec<ListItem> = Vec::new();
if self.mode == FileExplorerMode::Save {
let input_style = if self.editing_filename {
Style::default()
.fg(Color::Yellow)
.add_modifier(Modifier::BOLD)
} else {
Style::default().fg(Color::White)
};
let cursor = if self.editing_filename { "_" } else { "" };
let input_line = Line::from(vec![
Span::styled("New file: ", Style::default().fg(Color::Gray)),
Span::styled(format!("{}{}", self.save_filename, cursor), input_style),
]);
items.push(ListItem::new(input_line));
if !self.files.is_empty() {
items.push(ListItem::new(Line::from(Span::styled(
"--- Existing Files ---",
Style::default().fg(Color::DarkGray),
))));
}
}
for (i, path) in self.files.iter().enumerate() {
let filename = path
.file_name()
.map(|n| n.to_string_lossy().to_string())
.unwrap_or_else(|| path.to_string_lossy().to_string());
let is_selected = !self.editing_filename && i == self.selected_index;
let style = if is_selected {
Style::default()
.fg(Color::Yellow)
.add_modifier(Modifier::BOLD)
} else {
Style::default().fg(Color::White)
};
let prefix = if is_selected { "> " } else { " " };
items.push(ListItem::new(Span::styled(
format!("{}{}", prefix, filename),
style,
)));
}
if self.files.is_empty() && self.mode == FileExplorerMode::Load {
items.push(ListItem::new(Span::styled(
" No JSON files found in current directory",
Style::default().fg(Color::DarkGray),
)));
}
let help_text = match self.mode {
FileExplorerMode::Load => "[Enter] Load [Esc] Cancel",
FileExplorerMode::Save => "[Enter] Save [Esc] Cancel",
};
let block = Block::default()
.title(title)
.borders(Borders::ALL)
.border_style(Style::default().fg(Color::Cyan))
.style(Style::default().bg(Color::Black));
let list = List::new(items).block(block);
frame.render_widget(list, popup_area);
let help_y = popup_area.y + popup_area.height.saturating_sub(1);
if help_y < area.height {
let help_area = Rect::new(popup_area.x + 2, help_y, popup_area.width - 4, 1);
let help_widget = ratatui::widgets::Paragraph::new(Span::styled(
help_text,
Style::default().fg(Color::DarkGray),
));
frame.render_widget(help_widget, help_area);
}
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_file_explorer_modes() {
let mut explorer = FileExplorer::new();
explorer.show_load();
assert!(explorer.is_visible());
assert_eq!(explorer.mode(), FileExplorerMode::Load);
explorer.hide();
assert!(!explorer.is_visible());
explorer.show_save();
assert!(explorer.is_visible());
assert_eq!(explorer.mode(), FileExplorerMode::Save);
assert!(explorer.is_editing_filename());
}
#[test]
fn test_filename_editing() {
let mut explorer = FileExplorer::new();
explorer.show_save();
while !explorer.save_filename.is_empty() {
explorer.handle_backspace();
}
explorer.handle_char('t');
explorer.handle_char('e');
explorer.handle_char('s');
explorer.handle_char('t');
assert_eq!(explorer.save_filename(), "test");
explorer.handle_backspace();
assert_eq!(explorer.save_filename(), "tes");
}
#[test]
fn test_get_target_path_save() {
let mut explorer = FileExplorer::new();
explorer.show_save();
explorer.save_filename = String::from("custom");
let path = explorer.get_target_path();
assert_eq!(path, Some("custom.json".to_string()));
explorer.save_filename = String::from("custom.json");
let path = explorer.get_target_path();
assert_eq!(path, Some("custom.json".to_string()));
}
}