ballin 0.1.2

A colorful interactive physics simulator with thousands of balls, but in your terminal.
Documentation
//! File explorer UI for loading level configuration files.
//!
//! Provides a simple popup file browser that lists JSON files in the
//! current directory and allows the user to select one for loading.

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);
                        }
                    }
                }
            }
        }

        // Sort files alphabetically
        self.files.sort();

        // Reset selection if out of bounds
        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
    }

    /// Returns typed filename for save mode, selected file for load mode.
    pub fn get_target_path(&self) -> Option<String> {
        match self.mode {
            FileExplorerMode::Save => {
                if self.editing_filename {
                    // Use the typed filename
                    let filename = if self.save_filename.ends_with(".json") {
                        self.save_filename.clone()
                    } else {
                        format!("{}.json", self.save_filename)
                    };
                    Some(filename)
                } else {
                    // Use selected file (overwrite)
                    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;
        }

        // Calculate centered popup area (60% width, 50% height)
        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);

        // Clear the area behind the popup
        frame.render_widget(Clear, popup_area);

        // Build title based on mode
        let title = match self.mode {
            FileExplorerMode::Load => " Load Level ",
            FileExplorerMode::Save => " Save Level ",
        };

        // Build list items
        let mut items: Vec<ListItem> = Vec::new();

        // In save mode, add filename input at the top
        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));

            // Add separator
            if !self.files.is_empty() {
                items.push(ListItem::new(Line::from(Span::styled(
                    "--- Existing Files ---",
                    Style::default().fg(Color::DarkGray),
                ))));
            }
        }

        // Add file entries
        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 no files found
        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),
            )));
        }

        // Add help text at bottom
        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);

        // Render help text at the bottom of the popup
        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();

        // Clear default filename
        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();

        // Clear and set custom filename
        explorer.save_filename = String::from("custom");

        let path = explorer.get_target_path();
        assert_eq!(path, Some("custom.json".to_string()));

        // With .json extension already
        explorer.save_filename = String::from("custom.json");
        let path = explorer.get_target_path();
        assert_eq!(path, Some("custom.json".to_string()));
    }
}