audium 1.2.1

A terminal music app
use crossterm::event::KeyCode;
use ratatui::{
    Frame,
    layout::{Alignment, Rect},
    style::{Modifier, Style},
    text::{Line, Span},
    widgets::{Block, BorderType, Borders, Clear, List, ListItem, ListState},
};
use std::{
    fs,
    path::{Path, PathBuf},
};

use crate::ui::layout::{Theme, truncate};

/// A file extension is considered audio if it is one of these.
const AUDIO_EXTS: &[&str] = &[
    "mp3", "flac", "ogg", "wav", "aac", "m4a", "opus", "wma", "aiff",
];

fn is_audio(path: &Path) -> bool {
    path.extension()
        .and_then(|e| e.to_str())
        .map(|e| AUDIO_EXTS.contains(&e.to_lowercase().as_str()))
        .unwrap_or(false)
}

// ── Entry ──────────────────────────────────────────────────────────────────

#[derive(Debug, Clone)]
pub struct DirEntry {
    pub name: String,
    pub path: PathBuf,
    pub is_dir: bool,
}

// ── State ──────────────────────────────────────────────────────────────────

pub struct FilePicker {
    pub current_dir: PathBuf,
    pub entries: Vec<DirEntry>,
    pub cursor: usize,
}

impl FilePicker {
    pub fn new(start_dir: impl Into<PathBuf>) -> Self {
        let dir = start_dir.into();
        let mut picker = Self {
            current_dir: dir,
            entries: Vec::new(),
            cursor: 0,
        };
        picker.refresh();
        picker
    }

    pub fn refresh(&mut self) {
        self.entries.clear();
        self.cursor = 0;

        if let Some(parent) = self.current_dir.parent() {
            self.entries.push(DirEntry {
                name: "..".into(),
                path: parent.to_path_buf(),
                is_dir: true,
            });
        }

        let read = match fs::read_dir(&self.current_dir) {
            Ok(r) => r,
            Err(_) => return,
        };

        let mut dirs: Vec<DirEntry> = Vec::new();
        let mut files: Vec<DirEntry> = Vec::new();

        for entry in read.flatten() {
            let path = entry.path();
            let name = entry.file_name().to_string_lossy().into_owned();
            let is_dir = path.is_dir();

            if !is_dir && !is_audio(&path) {
                continue;
            }

            let bucket = if is_dir { &mut dirs } else { &mut files };
            bucket.push(DirEntry { name, path, is_dir });
        }

        dirs.sort_by_key(|a| a.name.to_lowercase());
        files.sort_by_key(|a| a.name.to_lowercase());

        self.entries.extend(dirs);
        self.entries.extend(files);
    }

    pub fn move_down(&mut self) {
        if !self.entries.is_empty() {
            self.cursor = (self.cursor + 1).min(self.entries.len() - 1);
        }
    }

    pub fn move_up(&mut self) {
        self.cursor = self.cursor.saturating_sub(1);
    }

    pub fn selected(&self) -> Option<&DirEntry> {
        self.entries.get(self.cursor)
    }

    pub fn enter_dir(&mut self) -> bool {
        if let Some(entry) = self.selected()
            && entry.is_dir
        {
            self.current_dir = entry.path.clone();
            self.refresh();
            return true;
        }
        false
    }

    pub fn handle_key(&mut self, code: KeyCode) -> FilePickerOutcome {
        match code {
            KeyCode::Char('j') | KeyCode::Down => {
                self.move_down();
                FilePickerOutcome::Continue
            }
            KeyCode::Char('k') | KeyCode::Up => {
                self.move_up();
                FilePickerOutcome::Continue
            }
            KeyCode::Enter => {
                if let Some(entry) = self.selected().cloned() {
                    if entry.is_dir {
                        self.enter_dir();
                        FilePickerOutcome::Continue
                    } else {
                        FilePickerOutcome::Selected(entry.path)
                    }
                } else {
                    FilePickerOutcome::Continue
                }
            }
            KeyCode::Esc | KeyCode::Char('q') => FilePickerOutcome::Dismissed,
            _ => FilePickerOutcome::Continue,
        }
    }
}

pub enum FilePickerOutcome {
    Continue,
    Selected(PathBuf),
    Dismissed,
}

// ── Rendering ──────────────────────────────────────────────────────────────

pub fn render_filepicker(frame: &mut Frame, picker: &FilePicker, theme: &Theme) {
    let area = frame.area();
    let width = area.width.min(70);
    let height = area.height.saturating_sub(4).min(30);
    let rect = Rect {
        x: area.x + area.width.saturating_sub(width) / 2,
        y: area.y + area.height.saturating_sub(height) / 2,
        width,
        height,
    };

    frame.render_widget(Clear, rect);

    // "  📁  " prefix (5 cols) + trailing " " (1) + corners (2) = 8 overhead
    let path_max = width.saturating_sub(8) as usize;
    let path_str = picker.current_dir.to_string_lossy();
    let title = format!(" 📁 {} ", truncate(&path_str, path_max));

    let block = Block::default()
        .title(title)
        .title_alignment(Alignment::Left)
        .borders(Borders::ALL)
        .border_type(BorderType::Rounded)
        .border_style(Style::default().fg(theme.accent))
        .style(theme.apply_bg(Style::default()));

    let inner = block.inner(rect);
    frame.render_widget(block, rect);

    let list_height = inner.height.saturating_sub(1);
    let list_rect = Rect {
        height: list_height,
        ..inner
    };
    let hint_rect = Rect {
        y: inner.y + list_height,
        height: 1,
        ..inner
    };

    let name_max = inner.width.saturating_sub(2) as usize; // 2 cols for the icon
    let items: Vec<ListItem> = picker
        .entries
        .iter()
        .map(|e| {
            let (icon, style) = if e.is_dir {
                (
                    "",
                    Style::default()
                        .fg(theme.dir_col)
                        .add_modifier(Modifier::BOLD),
                )
            } else {
                ("", Style::default().fg(theme.text_dim))
            };
            ListItem::new(Line::from(vec![
                Span::styled(icon, style),
                Span::styled(truncate(&e.name, name_max), style),
            ]))
        })
        .collect();

    let mut list_state = ListState::default();
    list_state.select(Some(picker.cursor));

    frame.render_stateful_widget(
        List::new(items)
            .highlight_style(
                Style::default()
                    .fg(theme.text)
                    .bg(theme.panel_bg)
                    .add_modifier(Modifier::BOLD),
            )
            .highlight_symbol(""),
        list_rect,
        &mut list_state,
    );

    frame.render_widget(
        ratatui::widgets::Paragraph::new(Span::styled(
            "[Enter] open/select  [j/k] navigate  [Esc] cancel",
            Style::default().fg(theme.subtle),
        )),
        hint_rect,
    );
}