toon_format/tui/components/
file_browser.rs

1//! File browser for opening JSON/TOON files.
2
3use std::fs;
4
5use ratatui::{
6    layout::{
7        Alignment,
8        Constraint,
9        Direction,
10        Layout,
11        Rect,
12    },
13    text::{
14        Line,
15        Span,
16    },
17    widgets::{
18        Block,
19        Borders,
20        List,
21        ListItem,
22        Paragraph,
23    },
24    Frame,
25};
26
27use crate::tui::{
28    state::AppState,
29    theme::Theme,
30};
31
32/// File browser state and rendering.
33pub struct FileBrowser {
34    pub selected_index: usize,
35    pub scroll_offset: usize,
36}
37
38impl FileBrowser {
39    pub fn new() -> Self {
40        Self {
41            selected_index: 0,
42            scroll_offset: 0,
43        }
44    }
45
46    pub fn move_up(&mut self) {
47        if self.selected_index > 0 {
48            self.selected_index -= 1;
49            if self.selected_index < self.scroll_offset {
50                self.scroll_offset = self.selected_index;
51            }
52        }
53    }
54
55    pub fn move_down(&mut self, max: usize) {
56        if self.selected_index < max.saturating_sub(1) {
57            self.selected_index += 1;
58        }
59    }
60
61    pub fn get_selected_entry(&self, dir: &std::path::Path) -> Option<std::path::PathBuf> {
62        let entries = self.get_directory_entries(dir);
63        if self.selected_index < entries.len() {
64            let (name, _is_dir, _, _) = &entries[self.selected_index];
65            if name == ".." {
66                dir.parent().map(|p| p.to_path_buf())
67            } else {
68                Some(dir.join(name))
69            }
70        } else {
71            None
72        }
73    }
74
75    pub fn get_entry_count(&self, dir: &std::path::Path) -> usize {
76        self.get_directory_entries(dir).len()
77    }
78
79    pub fn render(&mut self, f: &mut Frame, area: Rect, app: &AppState, theme: &Theme) {
80        let block = Block::default()
81            .borders(Borders::ALL)
82            .border_style(theme.border_style(true))
83            .title(" File Browser - Press Esc to close ")
84            .title_alignment(Alignment::Center);
85
86        let inner = block.inner(area);
87        f.render_widget(block, area);
88
89        let chunks = Layout::default()
90            .direction(Direction::Vertical)
91            .constraints([
92                Constraint::Length(2),
93                Constraint::Min(10),
94                Constraint::Length(3),
95            ])
96            .split(inner);
97
98        let current_dir = Paragraph::new(Line::from(vec![
99            Span::styled("Current: ", theme.line_number_style()),
100            Span::styled(
101                app.file_state.current_dir.display().to_string(),
102                theme.info_style(),
103            ),
104        ]));
105        f.render_widget(current_dir, chunks[0]);
106
107        let entries = self.get_directory_entries(&app.file_state.current_dir);
108        let items: Vec<ListItem> = entries
109            .iter()
110            .enumerate()
111            .map(|(idx, (name, is_dir, is_json, is_toon))| {
112                let icon = if *is_dir {
113                    "📁"
114                } else if *is_json {
115                    "📄"
116                } else if *is_toon {
117                    "📋"
118                } else {
119                    "📃"
120                };
121
122                let style = if idx == self.selected_index {
123                    theme.selection_style()
124                } else if *is_json || *is_toon {
125                    theme.highlight_style()
126                } else {
127                    theme.normal_style()
128                };
129
130                ListItem::new(Line::from(vec![
131                    Span::styled(format!("  {icon} "), style),
132                    Span::styled(name, style),
133                ]))
134            })
135            .collect();
136
137        let list = List::new(items);
138        f.render_widget(list, chunks[1]);
139
140        let instructions = Paragraph::new(Line::from(vec![
141            Span::styled("↑↓", theme.info_style()),
142            Span::styled(" Navigate | ", theme.line_number_style()),
143            Span::styled("Enter", theme.info_style()),
144            Span::styled(" Open | ", theme.line_number_style()),
145            Span::styled("Space", theme.info_style()),
146            Span::styled(" Select | ", theme.line_number_style()),
147            Span::styled("Esc", theme.info_style()),
148            Span::styled(" Close", theme.line_number_style()),
149        ]))
150        .alignment(Alignment::Center);
151        f.render_widget(instructions, chunks[2]);
152    }
153
154    fn get_directory_entries(&self, dir: &std::path::Path) -> Vec<(String, bool, bool, bool)> {
155        let mut entries = vec![("..".to_string(), true, false, false)];
156
157        if let Ok(read_dir) = fs::read_dir(dir) {
158            let mut files: Vec<_> = read_dir
159                .filter_map(|entry| entry.ok())
160                .filter_map(|entry| {
161                    let path = entry.path();
162                    let name = path.file_name()?.to_str()?.to_string();
163                    let is_dir = path.is_dir();
164                    let is_json =
165                        !is_dir && path.extension().and_then(|e| e.to_str()) == Some("json");
166                    let is_toon =
167                        !is_dir && path.extension().and_then(|e| e.to_str()) == Some("toon");
168                    Some((name, is_dir, is_json, is_toon))
169                })
170                .collect();
171
172            files.sort_by(|a, b| {
173                if a.1 == b.1 {
174                    a.0.cmp(&b.0)
175                } else {
176                    b.1.cmp(&a.1)
177                }
178            });
179
180            entries.extend(files);
181        }
182
183        entries
184    }
185}
186
187impl Default for FileBrowser {
188    fn default() -> Self {
189        Self::new()
190    }
191}