//! File/directory browser modal widget. use crate::theme; use ratatui::layout::{Constraint, Layout, Rect}; use ratatui::style::{Color, Style}; use ratatui::text::{Line, Span}; use ratatui::widgets::Paragraph; use ratatui::Frame; use super::ModalFrame; /// Modal listing files and directories with a filter input line. pub struct FileBrowserModal<'a> { title: &'a str, input: &'a str, entries: &'a [(String, bool, bool)], audio_counts: &'a [Option], selected: usize, scroll_offset: usize, border_color: Option, width: u16, height: u16, hints: Option>, color_path: bool, } impl<'a> FileBrowserModal<'a> { pub fn new(title: &'a str, input: &'a str, entries: &'a [(String, bool, bool)]) -> Self { Self { title, input, entries, audio_counts: &[], selected: 0, scroll_offset: 0, border_color: None, width: 60, height: 16, hints: None, color_path: false, } } pub fn selected(mut self, idx: usize) -> Self { self.selected = idx; self } pub fn scroll_offset(mut self, offset: usize) -> Self { self.scroll_offset = offset; self } pub fn border_color(mut self, c: Color) -> Self { self.border_color = Some(c); self } pub fn width(mut self, w: u16) -> Self { self.width = w; self } pub fn height(mut self, h: u16) -> Self { self.height = h; self } pub fn hints(mut self, hints: Line<'a>) -> Self { self.hints = Some(hints); self } pub fn audio_counts(mut self, counts: &'a [Option]) -> Self { self.audio_counts = counts; self } pub fn color_path(mut self) -> Self { self.color_path = true; self } pub fn render_centered(self, frame: &mut Frame, term: Rect) -> Rect { let colors = theme::get(); let border_color = self.border_color.unwrap_or(colors.ui.text_primary); let inner = ModalFrame::new(self.title) .width(self.width) .height(self.height) .border_color(border_color) .render_centered(frame, term); let has_hints = self.hints.is_some(); let constraints = if has_hints { vec![ Constraint::Length(1), Constraint::Min(1), Constraint::Length(1), ] } else { vec![Constraint::Length(1), Constraint::Min(1)] }; let rows = Layout::vertical(constraints).split(inner); // Input line let input_spans = if self.color_path { let (path_part, filter_part) = match self.input.rfind('/') { Some(pos) => (&self.input[..=pos], &self.input[pos + 1..]), None => ("", self.input), }; vec![ Span::raw("> "), Span::styled(path_part.to_string(), Style::new().fg(colors.browser.directory)), Span::styled(filter_part.to_string(), Style::new().fg(colors.input.text)), Span::styled("█", Style::new().fg(colors.input.cursor)), ] } else { vec![ Span::raw("> "), Span::styled(self.input, Style::new().fg(colors.input.text)), Span::styled("█", Style::new().fg(colors.input.cursor)), ] }; frame.render_widget(Paragraph::new(Line::from(input_spans)), rows[0]); // Hints bar if let Some(hints) = self.hints { let hint_row = rows[2]; frame.render_widget( Paragraph::new(hints).alignment(ratatui::layout::Alignment::Right), hint_row, ); } // Entries list let visible_height = rows[1].height as usize; let visible_entries = self .entries .iter() .enumerate() .skip(self.scroll_offset) .take(visible_height); let lines: Vec = visible_entries .map(|(abs_idx, (name, is_dir, is_cagire))| { let is_selected = abs_idx == self.selected; let prefix = if is_selected { "> " } else { " " }; let color = if is_selected { colors.browser.selected } else if *is_dir { colors.browser.directory } else if *is_cagire { colors.browser.project_file } else { colors.browser.file }; let display = if *is_dir { format!("{prefix}{name}/") } else { format!("{prefix}{name}") }; let mut spans = vec![Span::styled(display, Style::new().fg(color))]; if *is_dir && name != ".." { if let Some(Some(count)) = self.audio_counts.get(abs_idx) { spans.push(Span::styled( format!(" ({count})"), Style::new().fg(colors.browser.file), )); } } Line::from(spans) }) .collect(); frame.render_widget(Paragraph::new(lines), rows[1]); inner } }