Skip to main content

hefesto_widgets/popups/file_browser_popup/
mod.rs

1use std::path::{Path, PathBuf};
2
3use ratatui::{
4    buffer::Buffer,
5    layout::Rect,
6    style::{Color, Style},
7    widgets::StatefulWidget,
8};
9
10use crate::badge::BadgeStack;
11use crate::popup::{Popup, PopupSize};
12use crate::popups::auto_sized::AutoSized;
13use crate::popups::choose_popup::ChoosePopupState;
14use crate::popups::file_browser::FileBrowser;
15use crate::{BorderType, DotGridConfig, DotPattern};
16
17#[derive(Clone)]
18pub struct FileEntry {
19    pub name: String,
20    pub is_dir: bool,
21    pub path: PathBuf,
22}
23
24const DEFAULT_DIR_ICON: &str = "📁 ";
25const DEFAULT_FILE_ICON: &str = "📄 ";
26
27impl Default for FileBrowserState {
28    fn default() -> Self {
29        Self {
30            entries: Vec::new(),
31            items: Vec::new(),
32            cwd: PathBuf::new(),
33            choose_popup_state: ChoosePopupState::default(),
34            show_hidden: false,
35        }
36    }
37}
38
39#[derive(Clone)]
40pub struct FileBrowserState {
41    pub entries: Vec<FileEntry>,
42    pub items: Vec<(String, Style)>,
43    pub cwd: PathBuf,
44    pub choose_popup_state: ChoosePopupState,
45    pub show_hidden: bool,
46}
47
48impl FileBrowserState {
49    pub fn navigate_to(&mut self, path: &Path) {
50        if let Ok(read_dir) = std::fs::read_dir(path) {
51            let mut entries: Vec<FileEntry> = read_dir
52                .filter_map(|e| e.ok())
53                .filter(|e| {
54                    if self.show_hidden {
55                        true
56                    } else {
57                        let name = e.file_name();
58                        let name = name.to_string_lossy();
59                        !name.starts_with('.')
60                    }
61                })
62                .map(|e| {
63                    let path = e.path();
64                    let is_dir = e.file_type().map(|t| t.is_dir()).unwrap_or(false);
65                    FileEntry {
66                        name: e.file_name().to_string_lossy().to_string(),
67                        is_dir,
68                        path,
69                    }
70                })
71                .collect();
72
73            entries.sort_by(|a, b| {
74                if a.is_dir != b.is_dir {
75                    b.is_dir.cmp(&a.is_dir)
76                } else {
77                    a.name.to_lowercase().cmp(&b.name.to_lowercase())
78                }
79            });
80
81            if let Some(parent) = path.parent() {
82                entries.insert(
83                    0,
84                    FileEntry {
85                        name: "..".to_string(),
86                        is_dir: true,
87                        path: parent.to_path_buf(),
88                    },
89                );
90            }
91
92            self.items = entries
93                .iter()
94                .map(|e| {
95                    let text = if e.is_dir {
96                        if e.name == ".." {
97                            "↑ ..".to_string()
98                        } else {
99                            format!("{}{}", DEFAULT_DIR_ICON, e.name)
100                        }
101                    } else {
102                        format!("{}{}", DEFAULT_FILE_ICON, e.name)
103                    };
104                    let style = if e.is_dir {
105                        Style::new().fg(Color::Cyan)
106                    } else {
107                        Style::new().fg(Color::White)
108                    };
109                    (text, style)
110                })
111                .collect();
112
113            self.entries = entries;
114            self.cwd = path.to_path_buf();
115            self.choose_popup_state = ChoosePopupState::default();
116            self.choose_popup_state.scroll_list_state.follow = false;
117            self.choose_popup_state.scroll_list_state.select(Some(0));
118        }
119    }
120
121    pub fn go_up(&mut self) {
122        let parent = self.cwd.parent().map(|p| p.to_path_buf());
123        if let Some(p) = parent {
124            self.navigate_to(&p);
125        }
126    }
127
128    pub fn enter_directory(&mut self) {
129        let target = self.selected_entry().map(|e| (e.is_dir, e.path.clone()));
130        if let Some((true, path)) = target {
131            self.navigate_to(&path);
132        }
133    }
134
135    pub fn selected_entry(&self) -> Option<&FileEntry> {
136        if self.choose_popup_state.show_filter && !self.choose_popup_state.text_input.content.is_empty() {
137            self.filtered_indices().get(self.choose_popup_state.cursor).and_then(|&i| self.entries.get(i))
138        } else {
139            self.entries.get(self.choose_popup_state.cursor)
140        }
141    }
142
143    pub fn current_path(&self) -> Option<PathBuf> {
144        self.selected_entry().map(|e| e.path.clone())
145    }
146
147    pub fn select(&mut self, index: usize) {
148        let count = self.visible_count();
149        self.choose_popup_state.cursor = index.min(count.saturating_sub(1));
150        self.choose_popup_state.scroll_list_state.select(Some(self.choose_popup_state.cursor));
151    }
152
153    pub fn next(&mut self) {
154        let count = self.visible_count();
155        self.choose_popup_state.next(count);
156    }
157
158    pub fn previous(&mut self) {
159        self.choose_popup_state.previous();
160    }
161
162    pub fn first(&mut self) {
163        self.choose_popup_state.first();
164    }
165
166    pub fn last(&mut self) {
167        let count = self.visible_count();
168        self.choose_popup_state.last(count);
169    }
170
171    pub fn toggle(&mut self, idx: usize) {
172        self.choose_popup_state.toggle(idx);
173    }
174
175    pub fn toggle_cursor(&mut self) {
176        self.choose_popup_state.toggle_cursor();
177    }
178
179    pub fn chosen_indices(&self) -> &std::collections::HashSet<usize> {
180        &self.choose_popup_state.chosen_indices
181    }
182
183    pub fn chosen_paths(&self) -> Vec<PathBuf> {
184        self.choose_popup_state
185            .chosen_indices
186            .iter()
187            .filter_map(|&i| self.entries.get(i).map(|e| e.path.clone()))
188            .collect()
189    }
190
191    // Filter API
192
193    pub fn show_filter(&self) -> bool {
194        self.choose_popup_state.show_filter
195    }
196
197    pub fn set_show_filter(&mut self, show: bool) {
198        self.choose_popup_state.show_filter = show;
199    }
200
201    pub fn insert_filter_char(&mut self, c: char) {
202        self.choose_popup_state.insert_filter_char(c);
203    }
204
205    pub fn delete_before_filter(&mut self) {
206        self.choose_popup_state.delete_before_filter();
207    }
208
209    pub fn delete_at_filter(&mut self) {
210        self.choose_popup_state.delete_at_filter();
211    }
212
213    pub fn filter_cursor_left(&mut self) {
214        self.choose_popup_state.filter_cursor_left();
215    }
216
217    pub fn filter_cursor_right(&mut self) {
218        self.choose_popup_state.filter_cursor_right();
219    }
220
221    pub fn filter_cursor_home(&mut self) {
222        self.choose_popup_state.filter_cursor_home();
223    }
224
225    pub fn filter_cursor_end(&mut self) {
226        self.choose_popup_state.filter_cursor_end();
227    }
228
229    pub fn filtered_indices(&self) -> Vec<usize> {
230        self.choose_popup_state.filtered_indices(&self.items)
231    }
232
233    pub fn visible_count(&self) -> usize {
234        self.choose_popup_state.visible_count(&self.items)
235    }
236
237    pub fn original_index(&self) -> Option<usize> {
238        self.choose_popup_state.original_index(&self.items)
239    }
240
241    pub fn filter_content(&self) -> &str {
242        &self.choose_popup_state.text_input.content
243    }
244
245    pub fn max_selected(&self) -> Option<usize> {
246        self.choose_popup_state.max_selected
247    }
248
249    pub fn set_max_selected(&mut self, max: Option<usize>) {
250        self.choose_popup_state.max_selected = max;
251    }
252
253    pub fn show_hidden(&self) -> bool {
254        self.show_hidden
255    }
256
257    pub fn set_show_hidden(&mut self, show: bool) {
258        self.show_hidden = show;
259    }
260}
261
262#[derive(Clone)]
263pub struct FileBrowserPopup {
264    file_browser: FileBrowser,
265    width: PopupSize,
266    height: PopupSize,
267    border_color: Option<Color>,
268    border_type: BorderType,
269    padding: u16,
270    header: bool,
271    position: Option<(u16, u16)>,
272    origin: Option<(u16, u16)>,
273    bg_color: Option<Color>,
274    no_dot_grid: bool,
275    dot_grid: Option<DotGridConfig>,
276    badges: Option<BadgeStack<'static>>,
277}
278
279impl FileBrowserPopup {
280    pub fn new() -> Self {
281        Self {
282            file_browser: FileBrowser::new(),
283            width: PopupSize::Auto,
284            height: PopupSize::Auto,
285            border_color: None,
286            border_type: BorderType::Rounded,
287            padding: 0,
288            header: false,
289            position: None,
290            origin: None,
291            bg_color: None,
292            no_dot_grid: false,
293            dot_grid: None,
294            badges: None,
295        }
296    }
297
298    // Widget config (delegates to FileBrowser)
299
300    pub fn dir_style(mut self, style: Style) -> Self {
301        self.file_browser = self.file_browser.dir_style(style);
302        self
303    }
304
305    pub fn file_style(mut self, style: Style) -> Self {
306        self.file_browser = self.file_browser.file_style(style);
307        self
308    }
309
310    pub fn highlight_style(mut self, style: Style) -> Self {
311        self.file_browser = self.file_browser.highlight_style(style);
312        self
313    }
314
315    pub fn dir_icon(mut self, icon: &str) -> Self {
316        self.file_browser = self.file_browser.dir_icon(icon);
317        self
318    }
319
320    pub fn file_icon(mut self, icon: &str) -> Self {
321        self.file_browser = self.file_browser.file_icon(icon);
322        self
323    }
324
325    pub fn max_selected(mut self, max: usize) -> Self {
326        self.file_browser = self.file_browser.max_selected(max);
327        self
328    }
329
330    pub fn no_max_selected(mut self) -> Self {
331        self.file_browser = self.file_browser.no_max_selected();
332        self
333    }
334
335    // Popup chrome config
336
337    pub fn width(mut self, w: PopupSize) -> Self {
338        self.width = w;
339        self
340    }
341
342    pub fn border_color(mut self, color: Color) -> Self {
343        self.border_color = Some(color);
344        self
345    }
346
347    pub fn border_type(mut self, bt: BorderType) -> Self {
348        self.border_type = bt;
349        self
350    }
351
352    /// Background color for the entire popup area (including border).
353    pub fn bg_color(mut self, color: Color) -> Self {
354        self.bg_color = Some(color);
355        self
356    }
357
358    pub fn padding(mut self, p: u16) -> Self {
359        self.padding = p;
360        self
361    }
362
363    pub fn header(mut self) -> Self {
364        self.header = true;
365        self
366    }
367
368    pub fn height(mut self, h: PopupSize) -> Self {
369        self.height = h;
370        self
371    }
372
373    pub fn position(mut self, x: u16, y: u16) -> Self {
374        self.position = Some((x, y));
375        self
376    }
377
378    pub fn origin(mut self, x: u16, y: u16) -> Self {
379        self.origin = Some((x, y));
380        self
381    }
382
383    pub fn no_background(mut self) -> Self {
384        self.no_dot_grid = true;
385        self
386    }
387
388    pub fn background_dots(mut self, color: Color, symbol: &str, density: u16) -> Self {
389        self.dot_grid = Some(DotGridConfig { color, symbol: symbol.to_string(), density_x: density, density_y: density, pattern: DotPattern::default() });
390        self
391    }
392
393    pub fn background_pattern(mut self, pattern: DotPattern) -> Self {
394        if let Some(ref mut dg) = self.dot_grid {
395            dg.pattern = pattern;
396        } else {
397            self.no_dot_grid = false;
398            self.dot_grid = Some(DotGridConfig { pattern, ..DotGridConfig::default() });
399        }
400        self
401    }
402
403    pub fn background_spacing(mut self, density_x: u16, density_y: u16) -> Self {
404        if let Some(ref mut dg) = self.dot_grid {
405            dg.density_x = density_x;
406            dg.density_y = density_y;
407        } else {
408            self.no_dot_grid = false;
409            self.dot_grid = Some(DotGridConfig {
410                density_x,
411                density_y,
412                ..DotGridConfig::default()
413            });
414        }
415        self
416    }
417
418    pub fn badges(mut self, badges: BadgeStack<'static>) -> Self {
419        self.badges = Some(badges);
420        self
421    }
422}
423
424impl Default for FileBrowserPopup {
425    fn default() -> Self {
426        Self::new()
427    }
428}
429
430impl AutoSized for FileBrowserPopup {
431    type State = FileBrowserState;
432
433    fn auto_height(&self, state: &Self::State, area: Rect) -> u16 {
434        let max_visible = area.height.saturating_sub(7).min(20).max(3) as usize;
435        let visible = state.entries.len().min(max_visible);
436        visible as u16 + 5
437    }
438}
439
440impl FileBrowserPopup {
441    /// Resolves the final `Rect` for this popup within the given `area`,
442    /// using the file browser state for Auto-height resolution.
443    ///
444    /// See [`AutoSized::auto_height`].
445    pub fn resolve_rect(&self, area: Rect, state: &FileBrowserState) -> Rect {
446        let border_color = self.border_color.unwrap_or(Color::White);
447        let mut popup = Popup::new(border_color)
448            .padding(self.padding)
449            .width(self.width)
450            .border_type(self.border_type);
451
452        if self.height == PopupSize::Auto {
453            popup = popup.height(PopupSize::Fixed(self.auto_height(state, area)));
454        } else {
455            popup = popup.height(self.height);
456        }
457        if let Some((x, y)) = self.position {
458            popup = popup.position(x, y);
459        }
460        if let Some((x, y)) = self.origin {
461            popup = popup.origin(x, y);
462        }
463        popup.resolve_rect(area)
464    }
465}
466
467impl StatefulWidget for FileBrowserPopup {
468    type State = FileBrowserState;
469
470    fn render(self, area: Rect, buf: &mut Buffer, state: &mut Self::State) {
471        let auto_h = if self.height == PopupSize::Auto {
472            Some(self.auto_height(state, area))
473        } else {
474            None
475        };
476
477        let path_str = state.cwd.to_string_lossy().to_string();
478        let border_color = self.border_color.unwrap_or(Color::White);
479        let mut popup = Popup::new(border_color)
480            .padding(self.padding)
481            .width(self.width)
482            .border_type(self.border_type);
483
484        if let Some(h) = auto_h {
485            popup = popup.height(PopupSize::Fixed(h));
486        }
487        if !path_str.is_empty() {
488            popup = popup.title(&path_str);
489        }
490        if let Some(bg) = self.bg_color {
491            popup = popup.bg_color(bg);
492        }
493        if self.header {
494            popup = popup.header();
495        }
496        if let Some((x, y)) = self.position {
497            popup = popup.position(x, y);
498        }
499        if let Some((x, y)) = self.origin {
500            popup = popup.origin(x, y);
501        }
502        if self.no_dot_grid {
503            popup = popup.no_background();
504        }
505        if let Some(ref dg) = self.dot_grid {
506            popup = popup
507                .background_dots(dg.color, &dg.symbol, dg.density_x)
508                .background_spacing(dg.density_x, dg.density_y);
509        }
510        if self.height != PopupSize::Auto {
511            popup = popup.height(self.height);
512        }
513
514        if let Some(ref badges) = self.badges {
515            badges.render_all(area, buf);
516        }
517        let inner = popup.render_inner(area, buf);
518        self.file_browser.render(inner, buf, state);
519    }
520}
521
522#[cfg(test)]
523mod tests;