Skip to main content

ftui_widgets/
file_picker.rs

1#![forbid(unsafe_code)]
2
3//! File picker widget for browsing and selecting files.
4//!
5//! Provides a TUI file browser with keyboard navigation. The widget
6//! renders a directory listing with cursor selection and supports
7//! entering subdirectories and navigating back to parents.
8//!
9//! # Architecture
10//!
11//! - [`FilePicker`] — stateless configuration and rendering
12//! - [`FilePickerState`] — mutable navigation state (cursor, directory, entries)
13//! - [`DirEntry`] — a single file/directory entry
14//!
15//! The widget uses [`StatefulWidget`] so the application owns the state
16//! and can read the selected path.
17
18use crate::{StatefulWidget, draw_text_span};
19use ftui_core::geometry::Rect;
20use ftui_render::frame::Frame;
21use ftui_style::Style;
22use std::path::{Path, PathBuf};
23
24/// A single entry in a directory listing.
25#[derive(Debug, Clone, PartialEq, Eq)]
26pub struct DirEntry {
27    /// Display name.
28    pub name: String,
29    /// Full path.
30    pub path: PathBuf,
31    /// Whether this is a directory.
32    pub is_dir: bool,
33}
34
35impl DirEntry {
36    /// Create a directory entry.
37    pub fn dir(name: impl Into<String>, path: impl Into<PathBuf>) -> Self {
38        Self {
39            name: name.into(),
40            path: path.into(),
41            is_dir: true,
42        }
43    }
44
45    /// Create a file entry.
46    pub fn file(name: impl Into<String>, path: impl Into<PathBuf>) -> Self {
47        Self {
48            name: name.into(),
49            path: path.into(),
50            is_dir: false,
51        }
52    }
53}
54
55/// Mutable state for the file picker.
56#[derive(Debug, Clone)]
57pub struct FilePickerState {
58    /// Current directory being displayed.
59    pub current_dir: PathBuf,
60    /// Root directory for confinement (if set, cannot navigate above this).
61    pub root: Option<PathBuf>,
62    /// Directory entries (sorted: dirs first, then files).
63    pub entries: Vec<DirEntry>,
64    /// Currently highlighted index.
65    pub cursor: usize,
66    /// Scroll offset (first visible row).
67    pub offset: usize,
68    /// The selected/confirmed path (set when user presses enter on a file).
69    pub selected: Option<PathBuf>,
70    /// Navigation history for going back.
71    history: Vec<(PathBuf, usize)>,
72}
73
74impl FilePickerState {
75    /// Create a new state with the given directory and entries.
76    pub fn new(current_dir: PathBuf, entries: Vec<DirEntry>) -> Self {
77        Self {
78            current_dir,
79            root: None,
80            entries,
81            cursor: 0,
82            offset: 0,
83            selected: None,
84            history: Vec::new(),
85        }
86    }
87
88    /// Set a root directory to confine navigation.
89    ///
90    /// When set, the user cannot navigate to a parent directory above this root.
91    #[must_use]
92    pub fn with_root(mut self, root: impl Into<PathBuf>) -> Self {
93        self.root = Some(root.into());
94        self
95    }
96
97    /// Create state from a directory path by reading the filesystem.
98    ///
99    /// Sorts entries: directories first (alphabetical), then files (alphabetical).
100    /// Returns an error if the directory cannot be read.
101    pub fn from_path(path: impl AsRef<Path>) -> std::io::Result<Self> {
102        let path = path.as_ref().to_path_buf();
103        let entries = read_directory(&path)?;
104        Ok(Self::new(path, entries))
105    }
106
107    /// Move cursor up.
108    pub fn cursor_up(&mut self) {
109        if self.cursor > 0 {
110            self.cursor -= 1;
111        }
112    }
113
114    /// Move cursor down.
115    pub fn cursor_down(&mut self) {
116        if !self.entries.is_empty() && self.cursor < self.entries.len() - 1 {
117            self.cursor += 1;
118        }
119    }
120
121    /// Move cursor to the first entry.
122    pub fn cursor_home(&mut self) {
123        self.cursor = 0;
124    }
125
126    /// Move cursor to the last entry.
127    pub fn cursor_end(&mut self) {
128        if !self.entries.is_empty() {
129            self.cursor = self.entries.len() - 1;
130        }
131    }
132
133    /// Page up by `page_size` rows.
134    pub fn page_up(&mut self, page_size: usize) {
135        self.cursor = self.cursor.saturating_sub(page_size);
136    }
137
138    /// Page down by `page_size` rows.
139    pub fn page_down(&mut self, page_size: usize) {
140        if !self.entries.is_empty() {
141            self.cursor = (self.cursor + page_size).min(self.entries.len() - 1);
142        }
143    }
144
145    /// Enter the selected directory (if cursor is on a directory).
146    ///
147    /// Returns `Ok(true)` if navigation succeeded, `Ok(false)` if cursor is on a file,
148    /// or an error if the directory cannot be read.
149    pub fn enter(&mut self) -> std::io::Result<bool> {
150        let Some(entry) = self.entries.get(self.cursor) else {
151            return Ok(false);
152        };
153
154        if !entry.is_dir {
155            // Select the file
156            self.selected = Some(entry.path.clone());
157            return Ok(false);
158        }
159
160        let new_dir = entry.path.clone();
161        let new_entries = read_directory(&new_dir)?;
162
163        self.history.push((self.current_dir.clone(), self.cursor));
164        self.current_dir = new_dir;
165        self.entries = new_entries;
166        self.cursor = 0;
167        self.offset = 0;
168        Ok(true)
169    }
170
171    /// Go back to the parent directory.
172    ///
173    /// Returns `Ok(true)` if navigation succeeded.
174    pub fn go_back(&mut self) -> std::io::Result<bool> {
175        // If root is set, prevent going above it
176        if let Some(root) = &self.root
177            && self.current_dir == *root
178        {
179            return Ok(false);
180        }
181
182        if let Some((prev_dir, prev_cursor)) = self.history.pop() {
183            let entries = read_directory(&prev_dir)?;
184            self.current_dir = prev_dir;
185            self.entries = entries;
186            self.cursor = prev_cursor.min(self.entries.len().saturating_sub(1));
187            self.offset = 0;
188            return Ok(true);
189        }
190
191        // No history — try parent directory
192        if let Some(parent) = self.current_dir.parent().map(|p| p.to_path_buf()) {
193            // Additional check for root in case history was empty but we are at root
194            if let Some(root) = &self.root {
195                // If parent is outside root (e.g. root is /a/b, parent is /a), stop.
196                // Or simply: if current_dir IS root, we shouldn't be here (checked above).
197                // But just in case parent logic is tricky:
198                if !parent.starts_with(root) && parent != *root {
199                    // Allow going TO root, but not above.
200                    // If parent == root, it's allowed.
201                    // If parent is above root, blocked.
202                    // But we already checked self.current_dir == *root.
203                    // So we are inside root. Parent should be safe unless we are AT root.
204                }
205            }
206
207            let entries = read_directory(&parent)?;
208            self.current_dir = parent;
209            self.entries = entries;
210            self.cursor = 0;
211            self.offset = 0;
212            return Ok(true);
213        }
214
215        Ok(false)
216    }
217
218    /// Ensure scroll offset keeps cursor visible for the given viewport height.
219    fn adjust_scroll(&mut self, visible_rows: usize) {
220        if visible_rows == 0 {
221            return;
222        }
223        if self.cursor < self.offset {
224            self.offset = self.cursor;
225        }
226        if self.cursor >= self.offset + visible_rows {
227            self.offset = self.cursor + 1 - visible_rows;
228        }
229    }
230}
231
232/// Read a directory and return sorted entries (dirs first, then files).
233fn read_directory(path: &Path) -> std::io::Result<Vec<DirEntry>> {
234    let mut dirs = Vec::new();
235    let mut files = Vec::new();
236
237    for entry in std::fs::read_dir(path)? {
238        let entry = entry?;
239        let name = entry.file_name().to_string_lossy().to_string();
240        let file_type = entry.file_type()?;
241        let full_path = entry.path();
242
243        if file_type.is_dir() {
244            dirs.push(DirEntry::dir(name, full_path));
245        } else {
246            files.push(DirEntry::file(name, full_path));
247        }
248    }
249
250    dirs.sort_by_key(|a| a.name.to_lowercase());
251    files.sort_by_key(|a| a.name.to_lowercase());
252
253    dirs.extend(files);
254    Ok(dirs)
255}
256
257/// Configuration and rendering for the file picker widget.
258///
259/// # Example
260///
261/// ```ignore
262/// let picker = FilePicker::new()
263///     .dir_style(Style::new().fg(PackedRgba::rgb(100, 100, 255)))
264///     .cursor_style(Style::new().bold());
265///
266/// let mut state = FilePickerState::from_path(".").unwrap();
267/// picker.render(area, &mut frame, &mut state);
268/// ```
269#[derive(Debug, Clone)]
270pub struct FilePicker {
271    /// Style for directory entries.
272    pub dir_style: Style,
273    /// Style for file entries.
274    pub file_style: Style,
275    /// Style for the cursor row.
276    pub cursor_style: Style,
277    /// Style for the header (current directory).
278    pub header_style: Style,
279    /// Whether to show the current directory path as a header.
280    pub show_header: bool,
281    /// Prefix for directory entries.
282    pub dir_prefix: &'static str,
283    /// Prefix for file entries.
284    pub file_prefix: &'static str,
285}
286
287impl Default for FilePicker {
288    fn default() -> Self {
289        Self {
290            dir_style: Style::default(),
291            file_style: Style::default(),
292            cursor_style: Style::default(),
293            header_style: Style::default(),
294            show_header: true,
295            dir_prefix: "📁 ",
296            file_prefix: "  ",
297        }
298    }
299}
300
301impl FilePicker {
302    /// Create a new file picker with default styles.
303    pub fn new() -> Self {
304        Self::default()
305    }
306
307    /// Set the directory entry style.
308    pub fn dir_style(mut self, style: Style) -> Self {
309        self.dir_style = style;
310        self
311    }
312
313    /// Set the file entry style.
314    pub fn file_style(mut self, style: Style) -> Self {
315        self.file_style = style;
316        self
317    }
318
319    /// Set the cursor (highlight) style.
320    pub fn cursor_style(mut self, style: Style) -> Self {
321        self.cursor_style = style;
322        self
323    }
324
325    /// Set the header style.
326    pub fn header_style(mut self, style: Style) -> Self {
327        self.header_style = style;
328        self
329    }
330
331    /// Toggle header display.
332    pub fn show_header(mut self, show: bool) -> Self {
333        self.show_header = show;
334        self
335    }
336}
337
338impl StatefulWidget for FilePicker {
339    type State = FilePickerState;
340
341    fn render(&self, area: Rect, frame: &mut Frame, state: &mut Self::State) {
342        if area.is_empty() {
343            return;
344        }
345
346        let mut y = area.y;
347        let max_y = area.bottom();
348
349        // Header: current directory path
350        if self.show_header && y < max_y {
351            let header = state.current_dir.to_string_lossy();
352            draw_text_span(frame, area.x, y, &header, self.header_style, area.right());
353            y += 1;
354        }
355
356        if y >= max_y {
357            return;
358        }
359
360        let visible_rows = (max_y - y) as usize;
361        state.adjust_scroll(visible_rows);
362
363        if state.entries.is_empty() {
364            draw_text_span(
365                frame,
366                area.x,
367                y,
368                "(empty directory)",
369                self.file_style,
370                area.right(),
371            );
372            return;
373        }
374
375        let end_idx = (state.offset + visible_rows).min(state.entries.len());
376        for (i, entry) in state.entries[state.offset..end_idx].iter().enumerate() {
377            if y >= max_y {
378                break;
379            }
380
381            let actual_idx = state.offset + i;
382            let is_cursor = actual_idx == state.cursor;
383
384            let prefix = if entry.is_dir {
385                self.dir_prefix
386            } else {
387                self.file_prefix
388            };
389
390            let base_style = if entry.is_dir {
391                self.dir_style
392            } else {
393                self.file_style
394            };
395
396            let style = if is_cursor {
397                self.cursor_style.merge(&base_style)
398            } else {
399                base_style
400            };
401
402            // Draw cursor indicator
403            let mut x = area.x;
404            if is_cursor {
405                draw_text_span(frame, x, y, "> ", self.cursor_style, area.right());
406                x = x.saturating_add(2);
407            } else {
408                x = x.saturating_add(2);
409            }
410
411            // Draw prefix + name
412            x = draw_text_span(frame, x, y, prefix, style, area.right());
413            draw_text_span(frame, x, y, &entry.name, style, area.right());
414
415            y += 1;
416        }
417    }
418}
419
420#[cfg(test)]
421mod tests {
422    use super::*;
423    use ftui_render::grapheme_pool::GraphemePool;
424
425    fn buf_to_lines(buf: &ftui_render::buffer::Buffer) -> Vec<String> {
426        let mut lines = Vec::new();
427        for y in 0..buf.height() {
428            let mut row = String::with_capacity(buf.width() as usize);
429            for x in 0..buf.width() {
430                let ch = buf
431                    .get(x, y)
432                    .and_then(|c| c.content.as_char())
433                    .unwrap_or(' ');
434                row.push(ch);
435            }
436            lines.push(row);
437        }
438        lines
439    }
440
441    fn make_entries() -> Vec<DirEntry> {
442        vec![
443            DirEntry::dir("docs", "/tmp/docs"),
444            DirEntry::dir("src", "/tmp/src"),
445            DirEntry::file("README.md", "/tmp/README.md"),
446            DirEntry::file("main.rs", "/tmp/main.rs"),
447        ]
448    }
449
450    fn make_state() -> FilePickerState {
451        FilePickerState::new(PathBuf::from("/tmp"), make_entries())
452    }
453
454    #[test]
455    fn dir_entry_constructors() {
456        let d = DirEntry::dir("src", "/src");
457        assert!(d.is_dir);
458        assert_eq!(d.name, "src");
459
460        let f = DirEntry::file("main.rs", "/main.rs");
461        assert!(!f.is_dir);
462        assert_eq!(f.name, "main.rs");
463    }
464
465    #[test]
466    fn state_cursor_movement() {
467        let mut state = make_state();
468        assert_eq!(state.cursor, 0);
469
470        state.cursor_down();
471        assert_eq!(state.cursor, 1);
472
473        state.cursor_down();
474        state.cursor_down();
475        assert_eq!(state.cursor, 3);
476
477        // Can't go past end
478        state.cursor_down();
479        assert_eq!(state.cursor, 3);
480
481        state.cursor_up();
482        assert_eq!(state.cursor, 2);
483
484        state.cursor_home();
485        assert_eq!(state.cursor, 0);
486
487        // Can't go before start
488        state.cursor_up();
489        assert_eq!(state.cursor, 0);
490
491        state.cursor_end();
492        assert_eq!(state.cursor, 3);
493    }
494
495    #[test]
496    fn state_page_navigation() {
497        let entries: Vec<DirEntry> = (0..20)
498            .map(|i| DirEntry::file(format!("file{i}.txt"), format!("/tmp/file{i}.txt")))
499            .collect();
500        let mut state = FilePickerState::new(PathBuf::from("/tmp"), entries);
501
502        state.page_down(5);
503        assert_eq!(state.cursor, 5);
504
505        state.page_down(5);
506        assert_eq!(state.cursor, 10);
507
508        state.page_up(3);
509        assert_eq!(state.cursor, 7);
510
511        state.page_up(100);
512        assert_eq!(state.cursor, 0);
513
514        state.page_down(100);
515        assert_eq!(state.cursor, 19);
516    }
517
518    #[test]
519    fn state_empty_entries() {
520        let mut state = FilePickerState::new(PathBuf::from("/tmp"), vec![]);
521        state.cursor_down(); // should not panic
522        state.cursor_up();
523        state.cursor_end();
524        state.cursor_home();
525        state.page_down(10);
526        state.page_up(10);
527        assert_eq!(state.cursor, 0);
528    }
529
530    #[test]
531    fn adjust_scroll_keeps_cursor_visible() {
532        let entries: Vec<DirEntry> = (0..20)
533            .map(|i| DirEntry::file(format!("f{i}"), format!("/f{i}")))
534            .collect();
535        let mut state = FilePickerState::new(PathBuf::from("/"), entries);
536
537        state.cursor = 15;
538        state.adjust_scroll(5);
539        // cursor=15 should be visible in a 5-row window
540        assert!(state.offset <= 15);
541        assert!(state.offset + 5 > 15);
542
543        state.cursor = 0;
544        state.adjust_scroll(5);
545        assert_eq!(state.offset, 0);
546    }
547
548    #[test]
549    fn render_basic() {
550        let picker = FilePicker::new().show_header(false);
551        let mut state = make_state();
552
553        let area = Rect::new(0, 0, 30, 5);
554        let mut pool = GraphemePool::new();
555        let mut frame = Frame::new(30, 5, &mut pool);
556
557        picker.render(area, &mut frame, &mut state);
558        let lines = buf_to_lines(&frame.buffer);
559
560        // First entry should have cursor indicator "> "
561        assert!(lines[0].starts_with("> "));
562        // Should contain directory and file names
563        let all_text = lines.join("\n");
564        assert!(all_text.contains("docs"));
565        assert!(all_text.contains("src"));
566        assert!(all_text.contains("README.md"));
567        assert!(all_text.contains("main.rs"));
568    }
569
570    #[test]
571    fn render_with_header() {
572        let picker = FilePicker::new().show_header(true);
573        let mut state = make_state();
574
575        let area = Rect::new(0, 0, 30, 6);
576        let mut pool = GraphemePool::new();
577        let mut frame = Frame::new(30, 6, &mut pool);
578
579        picker.render(area, &mut frame, &mut state);
580        let lines = buf_to_lines(&frame.buffer);
581
582        // First line should be the directory path
583        assert!(lines[0].starts_with("/tmp"));
584    }
585
586    #[test]
587    fn render_empty_directory() {
588        let picker = FilePicker::new().show_header(false);
589        let mut state = FilePickerState::new(PathBuf::from("/empty"), vec![]);
590
591        let area = Rect::new(0, 0, 30, 3);
592        let mut pool = GraphemePool::new();
593        let mut frame = Frame::new(30, 3, &mut pool);
594
595        picker.render(area, &mut frame, &mut state);
596        let lines = buf_to_lines(&frame.buffer);
597
598        assert!(lines[0].contains("empty directory"));
599    }
600
601    #[test]
602    fn render_scrolling() {
603        let entries: Vec<DirEntry> = (0..20)
604            .map(|i| DirEntry::file(format!("file{i:02}.txt"), format!("/tmp/file{i:02}.txt")))
605            .collect();
606        let mut state = FilePickerState::new(PathBuf::from("/tmp"), entries);
607        let picker = FilePicker::new().show_header(false);
608
609        // Move cursor to item 15, viewport is 5 rows
610        state.cursor = 15;
611        let area = Rect::new(0, 0, 30, 5);
612        let mut pool = GraphemePool::new();
613        let mut frame = Frame::new(30, 5, &mut pool);
614
615        picker.render(area, &mut frame, &mut state);
616        let lines = buf_to_lines(&frame.buffer);
617
618        // file15 should be visible (with cursor)
619        let all_text = lines.join("\n");
620        assert!(all_text.contains("file15"));
621    }
622
623    #[test]
624    fn cursor_style_applied_to_selected_row() {
625        use ftui_render::cell::PackedRgba;
626
627        let picker = FilePicker::new()
628            .show_header(false)
629            .cursor_style(Style::new().fg(PackedRgba::rgb(255, 0, 0)));
630        let mut state = make_state();
631        state.cursor = 1; // "src"
632
633        let area = Rect::new(0, 0, 30, 4);
634        let mut pool = GraphemePool::new();
635        let mut frame = Frame::new(30, 4, &mut pool);
636
637        picker.render(area, &mut frame, &mut state);
638
639        // The cursor row (y=1) should have the cursor indicator
640        let lines = buf_to_lines(&frame.buffer);
641        assert!(lines[1].starts_with("> "));
642        // Non-cursor rows should not
643        assert!(!lines[0].starts_with("> "));
644    }
645
646    #[test]
647    fn selected_set_on_file_entry() {
648        let mut state = make_state();
649        state.cursor = 2; // README.md (a file)
650
651        // enter() on a file should set selected
652        let result = state.enter();
653        assert!(result.is_ok());
654        assert_eq!(state.selected, Some(PathBuf::from("/tmp/README.md")));
655    }
656}