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, clear_text_area, clear_text_row, 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
162        // Confinement check: prevent symlinks from escaping the root
163        if let Some(root) = &self.root
164            && let (Ok(resolved_new), Ok(resolved_root)) =
165                (std::fs::canonicalize(&new_dir), std::fs::canonicalize(root))
166            && !resolved_new.starts_with(&resolved_root)
167        {
168            return Err(std::io::Error::new(
169                std::io::ErrorKind::PermissionDenied,
170                "Cannot traverse outside root directory via symlink",
171            ));
172        }
173
174        let new_entries = read_directory(&new_dir)?;
175
176        self.history.push((self.current_dir.clone(), self.cursor));
177        self.current_dir = new_dir;
178        self.entries = new_entries;
179        self.cursor = 0;
180        self.offset = 0;
181        Ok(true)
182    }
183
184    /// Go back to the parent directory.
185    ///
186    /// Returns `Ok(true)` if navigation succeeded.
187    pub fn go_back(&mut self) -> std::io::Result<bool> {
188        // If root is set, prevent going above it using canonicalized paths
189        if let Some(root) = &self.root {
190            let resolved_curr = std::fs::canonicalize(&self.current_dir)
191                .unwrap_or_else(|_| self.current_dir.clone());
192            let resolved_root = std::fs::canonicalize(root).unwrap_or_else(|_| root.clone());
193            if resolved_curr == resolved_root || !resolved_curr.starts_with(&resolved_root) {
194                return Ok(false);
195            }
196        }
197
198        if let Some((prev_dir, prev_cursor)) = self.history.pop() {
199            let entries = read_directory(&prev_dir)?;
200            self.current_dir = prev_dir;
201            self.entries = entries;
202            self.cursor = prev_cursor.min(self.entries.len().saturating_sub(1));
203            self.offset = 0;
204            return Ok(true);
205        }
206
207        // No history — try parent directory
208        if let Some(parent) = self.current_dir.parent().map(|p| p.to_path_buf()) {
209            if let Some(root) = &self.root {
210                let resolved_parent =
211                    std::fs::canonicalize(&parent).unwrap_or_else(|_| parent.clone());
212                let resolved_root = std::fs::canonicalize(root).unwrap_or_else(|_| root.clone());
213                if !resolved_parent.starts_with(&resolved_root) {
214                    return Ok(false); // Block parent traversal outside root
215                }
216            }
217
218            let entries = read_directory(&parent)?;
219            self.current_dir = parent;
220            self.entries = entries;
221            self.cursor = 0;
222            self.offset = 0;
223            return Ok(true);
224        }
225
226        Ok(false)
227    }
228
229    /// Ensure scroll offset keeps cursor visible for the given viewport height.
230    fn adjust_scroll(&mut self, visible_rows: usize) {
231        if visible_rows == 0 {
232            return;
233        }
234        if self.cursor < self.offset {
235            self.offset = self.cursor;
236        }
237        if self.cursor >= self.offset + visible_rows {
238            self.offset = self.cursor + 1 - visible_rows;
239        }
240    }
241}
242
243/// Read a directory and return sorted entries (dirs first, then files).
244fn read_directory(path: &Path) -> std::io::Result<Vec<DirEntry>> {
245    let mut dirs = Vec::new();
246    let mut files = Vec::new();
247
248    for entry in std::fs::read_dir(path)? {
249        let entry = entry?;
250        let name = entry.file_name().to_string_lossy().to_string();
251        let mut file_type = entry.file_type()?;
252        let full_path = entry.path();
253
254        // If it's a symlink, check what it points to
255        if file_type.is_symlink()
256            && let Ok(metadata) = std::fs::metadata(&full_path)
257        {
258            file_type = metadata.file_type();
259        }
260
261        if file_type.is_dir() {
262            dirs.push(DirEntry::dir(name, full_path));
263        } else {
264            files.push(DirEntry::file(name, full_path));
265        }
266    }
267
268    dirs.sort_by_key(|a| a.name.to_lowercase());
269    files.sort_by_key(|a| a.name.to_lowercase());
270
271    dirs.extend(files);
272    Ok(dirs)
273}
274
275/// Configuration and rendering for the file picker widget.
276///
277/// # Example
278///
279/// ```ignore
280/// let picker = FilePicker::new()
281///     .dir_style(Style::new().fg(PackedRgba::rgb(100, 100, 255)))
282///     .cursor_style(Style::new().bold());
283///
284/// let mut state = FilePickerState::from_path(".").unwrap();
285/// picker.render(area, &mut frame, &mut state);
286/// ```
287#[derive(Debug, Clone)]
288pub struct FilePicker {
289    /// Style for directory entries.
290    pub dir_style: Style,
291    /// Style for file entries.
292    pub file_style: Style,
293    /// Style for the cursor row.
294    pub cursor_style: Style,
295    /// Style for the header (current directory).
296    pub header_style: Style,
297    /// Whether to show the current directory path as a header.
298    pub show_header: bool,
299    /// Prefix for directory entries.
300    pub dir_prefix: &'static str,
301    /// Prefix for file entries.
302    pub file_prefix: &'static str,
303}
304
305impl Default for FilePicker {
306    fn default() -> Self {
307        Self {
308            dir_style: Style::default(),
309            file_style: Style::default(),
310            cursor_style: Style::default(),
311            header_style: Style::default(),
312            show_header: true,
313            dir_prefix: "📁 ",
314            file_prefix: "  ",
315        }
316    }
317}
318
319impl FilePicker {
320    /// Create a new file picker with default styles.
321    pub fn new() -> Self {
322        Self::default()
323    }
324
325    /// Set the directory entry style.
326    #[must_use]
327    pub fn dir_style(mut self, style: Style) -> Self {
328        self.dir_style = style;
329        self
330    }
331
332    /// Set the file entry style.
333    #[must_use]
334    pub fn file_style(mut self, style: Style) -> Self {
335        self.file_style = style;
336        self
337    }
338
339    /// Set the cursor (highlight) style.
340    #[must_use]
341    pub fn cursor_style(mut self, style: Style) -> Self {
342        self.cursor_style = style;
343        self
344    }
345
346    /// Set the header style.
347    #[must_use]
348    pub fn header_style(mut self, style: Style) -> Self {
349        self.header_style = style;
350        self
351    }
352
353    /// Toggle header display.
354    #[must_use]
355    pub fn show_header(mut self, show: bool) -> Self {
356        self.show_header = show;
357        self
358    }
359}
360
361impl StatefulWidget for FilePicker {
362    type State = FilePickerState;
363
364    fn render(&self, area: Rect, frame: &mut Frame, state: &mut Self::State) {
365        if area.is_empty() {
366            return;
367        }
368
369        let deg = frame.buffer.degradation;
370        if !deg.render_content() {
371            return;
372        }
373
374        clear_text_area(frame, area, Style::default());
375
376        let header_style = if deg.apply_styling() {
377            self.header_style
378        } else {
379            Style::default()
380        };
381        let dir_style = if deg.apply_styling() {
382            self.dir_style
383        } else {
384            Style::default()
385        };
386        let file_style = if deg.apply_styling() {
387            self.file_style
388        } else {
389            Style::default()
390        };
391        let cursor_style = if deg.apply_styling() {
392            self.cursor_style
393        } else {
394            Style::default()
395        };
396
397        let mut y = area.y;
398        let max_y = area.bottom();
399
400        // Header: current directory path
401        if self.show_header && y < max_y {
402            clear_text_row(frame, Rect::new(area.x, y, area.width, 1), header_style);
403            let header = state.current_dir.to_string_lossy();
404            draw_text_span(frame, area.x, y, &header, header_style, area.right());
405            y += 1;
406        }
407
408        if y >= max_y {
409            return;
410        }
411
412        let visible_rows = (max_y - y) as usize;
413        state.adjust_scroll(visible_rows);
414
415        if state.entries.is_empty() {
416            clear_text_row(frame, Rect::new(area.x, y, area.width, 1), file_style);
417            draw_text_span(
418                frame,
419                area.x,
420                y,
421                "(empty directory)",
422                file_style,
423                area.right(),
424            );
425            return;
426        }
427
428        let end_idx = (state.offset + visible_rows).min(state.entries.len());
429        for (i, entry) in state.entries[state.offset..end_idx].iter().enumerate() {
430            if y >= max_y {
431                break;
432            }
433
434            let actual_idx = state.offset + i;
435            let is_cursor = actual_idx == state.cursor;
436
437            let prefix = if entry.is_dir {
438                self.dir_prefix
439            } else {
440                self.file_prefix
441            };
442
443            let base_style = if entry.is_dir { dir_style } else { file_style };
444
445            let style = if is_cursor {
446                cursor_style.merge(&base_style)
447            } else {
448                base_style
449            };
450
451            clear_text_row(frame, Rect::new(area.x, y, area.width, 1), style);
452
453            // Draw cursor indicator
454            let mut x = area.x;
455            if is_cursor {
456                draw_text_span(frame, x, y, "> ", cursor_style, area.right());
457                x = x.saturating_add(2);
458            } else {
459                x = x.saturating_add(2);
460            }
461
462            // Draw prefix + name
463            x = draw_text_span(frame, x, y, prefix, style, area.right());
464            draw_text_span(frame, x, y, &entry.name, style, area.right());
465
466            y += 1;
467        }
468    }
469}
470
471#[cfg(test)]
472mod tests {
473    use super::*;
474    use ftui_render::grapheme_pool::GraphemePool;
475
476    fn buf_to_lines(buf: &ftui_render::buffer::Buffer) -> Vec<String> {
477        let mut lines = Vec::new();
478        for y in 0..buf.height() {
479            let mut row = String::with_capacity(buf.width() as usize);
480            for x in 0..buf.width() {
481                let ch = buf
482                    .get(x, y)
483                    .and_then(|c| c.content.as_char())
484                    .unwrap_or(' ');
485                row.push(ch);
486            }
487            lines.push(row);
488        }
489        lines
490    }
491
492    fn make_entries() -> Vec<DirEntry> {
493        vec![
494            DirEntry::dir("docs", "/tmp/docs"),
495            DirEntry::dir("src", "/tmp/src"),
496            DirEntry::file("README.md", "/tmp/README.md"),
497            DirEntry::file("main.rs", "/tmp/main.rs"),
498        ]
499    }
500
501    fn make_state() -> FilePickerState {
502        FilePickerState::new(PathBuf::from("/tmp"), make_entries())
503    }
504
505    #[test]
506    fn dir_entry_constructors() {
507        let d = DirEntry::dir("src", "/src");
508        assert!(d.is_dir);
509        assert_eq!(d.name, "src");
510
511        let f = DirEntry::file("main.rs", "/main.rs");
512        assert!(!f.is_dir);
513        assert_eq!(f.name, "main.rs");
514    }
515
516    #[test]
517    fn state_cursor_movement() {
518        let mut state = make_state();
519        assert_eq!(state.cursor, 0);
520
521        state.cursor_down();
522        assert_eq!(state.cursor, 1);
523
524        state.cursor_down();
525        state.cursor_down();
526        assert_eq!(state.cursor, 3);
527
528        // Can't go past end
529        state.cursor_down();
530        assert_eq!(state.cursor, 3);
531
532        state.cursor_up();
533        assert_eq!(state.cursor, 2);
534
535        state.cursor_home();
536        assert_eq!(state.cursor, 0);
537
538        // Can't go before start
539        state.cursor_up();
540        assert_eq!(state.cursor, 0);
541
542        state.cursor_end();
543        assert_eq!(state.cursor, 3);
544    }
545
546    #[test]
547    fn state_page_navigation() {
548        let entries: Vec<DirEntry> = (0..20)
549            .map(|i| DirEntry::file(format!("file{i}.txt"), format!("/tmp/file{i}.txt")))
550            .collect();
551        let mut state = FilePickerState::new(PathBuf::from("/tmp"), entries);
552
553        state.page_down(5);
554        assert_eq!(state.cursor, 5);
555
556        state.page_down(5);
557        assert_eq!(state.cursor, 10);
558
559        state.page_up(3);
560        assert_eq!(state.cursor, 7);
561
562        state.page_up(100);
563        assert_eq!(state.cursor, 0);
564
565        state.page_down(100);
566        assert_eq!(state.cursor, 19);
567    }
568
569    #[test]
570    fn state_empty_entries() {
571        let mut state = FilePickerState::new(PathBuf::from("/tmp"), vec![]);
572        state.cursor_down(); // should not panic
573        state.cursor_up();
574        state.cursor_end();
575        state.cursor_home();
576        state.page_down(10);
577        state.page_up(10);
578        assert_eq!(state.cursor, 0);
579    }
580
581    #[test]
582    fn adjust_scroll_keeps_cursor_visible() {
583        let entries: Vec<DirEntry> = (0..20)
584            .map(|i| DirEntry::file(format!("f{i}"), format!("/f{i}")))
585            .collect();
586        let mut state = FilePickerState::new(PathBuf::from("/"), entries);
587
588        state.cursor = 15;
589        state.adjust_scroll(5);
590        // cursor=15 should be visible in a 5-row window
591        assert!(state.offset <= 15);
592        assert!(state.offset + 5 > 15);
593
594        state.cursor = 0;
595        state.adjust_scroll(5);
596        assert_eq!(state.offset, 0);
597    }
598
599    #[test]
600    fn render_basic() {
601        let picker = FilePicker::new().show_header(false);
602        let mut state = make_state();
603
604        let area = Rect::new(0, 0, 30, 5);
605        let mut pool = GraphemePool::new();
606        let mut frame = Frame::new(30, 5, &mut pool);
607
608        picker.render(area, &mut frame, &mut state);
609        let lines = buf_to_lines(&frame.buffer);
610
611        // First entry should have cursor indicator "> "
612        assert!(lines[0].starts_with("> "));
613        // Should contain directory and file names
614        let all_text = lines.join("\n");
615        assert!(all_text.contains("docs"));
616        assert!(all_text.contains("src"));
617        assert!(all_text.contains("README.md"));
618        assert!(all_text.contains("main.rs"));
619    }
620
621    #[test]
622    fn render_with_header() {
623        let picker = FilePicker::new().show_header(true);
624        let mut state = make_state();
625
626        let area = Rect::new(0, 0, 30, 6);
627        let mut pool = GraphemePool::new();
628        let mut frame = Frame::new(30, 6, &mut pool);
629
630        picker.render(area, &mut frame, &mut state);
631        let lines = buf_to_lines(&frame.buffer);
632
633        // First line should be the directory path
634        assert!(lines[0].starts_with("/tmp"));
635    }
636
637    #[test]
638    fn render_empty_directory() {
639        let picker = FilePicker::new().show_header(false);
640        let mut state = FilePickerState::new(PathBuf::from("/empty"), vec![]);
641
642        let area = Rect::new(0, 0, 30, 3);
643        let mut pool = GraphemePool::new();
644        let mut frame = Frame::new(30, 3, &mut pool);
645
646        picker.render(area, &mut frame, &mut state);
647        let lines = buf_to_lines(&frame.buffer);
648
649        assert!(lines[0].contains("empty directory"));
650    }
651
652    #[test]
653    fn render_scrolling() {
654        let entries: Vec<DirEntry> = (0..20)
655            .map(|i| DirEntry::file(format!("file{i:02}.txt"), format!("/tmp/file{i:02}.txt")))
656            .collect();
657        let mut state = FilePickerState::new(PathBuf::from("/tmp"), entries);
658        let picker = FilePicker::new().show_header(false);
659
660        // Move cursor to item 15, viewport is 5 rows
661        state.cursor = 15;
662        let area = Rect::new(0, 0, 30, 5);
663        let mut pool = GraphemePool::new();
664        let mut frame = Frame::new(30, 5, &mut pool);
665
666        picker.render(area, &mut frame, &mut state);
667        let lines = buf_to_lines(&frame.buffer);
668
669        // file15 should be visible (with cursor)
670        let all_text = lines.join("\n");
671        assert!(all_text.contains("file15"));
672    }
673
674    #[test]
675    fn cursor_style_applied_to_selected_row() {
676        use ftui_render::cell::PackedRgba;
677
678        let picker = FilePicker::new()
679            .show_header(false)
680            .cursor_style(Style::new().fg(PackedRgba::rgb(255, 0, 0)));
681        let mut state = make_state();
682        state.cursor = 1; // "src"
683
684        let area = Rect::new(0, 0, 30, 4);
685        let mut pool = GraphemePool::new();
686        let mut frame = Frame::new(30, 4, &mut pool);
687
688        picker.render(area, &mut frame, &mut state);
689
690        // The cursor row (y=1) should have the cursor indicator
691        let lines = buf_to_lines(&frame.buffer);
692        assert!(lines[1].starts_with("> "));
693        // Non-cursor rows should not
694        assert!(!lines[0].starts_with("> "));
695    }
696
697    #[test]
698    fn selected_set_on_file_entry() {
699        let mut state = make_state();
700        state.cursor = 2; // README.md (a file)
701
702        // enter() on a file should set selected
703        let result = state.enter();
704        assert!(result.is_ok());
705        assert_eq!(state.selected, Some(PathBuf::from("/tmp/README.md")));
706    }
707
708    // ── DirEntry edge cases ───────────────────────────────────────
709
710    #[test]
711    fn dir_entry_equality() {
712        let a = DirEntry::dir("src", "/src");
713        let b = DirEntry::dir("src", "/src");
714        assert_eq!(a, b);
715
716        let c = DirEntry::file("src", "/src");
717        assert_ne!(a, c, "dir vs file should differ");
718    }
719
720    #[test]
721    fn dir_entry_clone() {
722        let orig = DirEntry::file("main.rs", "/main.rs");
723        let cloned = orig.clone();
724        assert_eq!(orig, cloned);
725    }
726
727    #[test]
728    fn dir_entry_debug_format() {
729        let e = DirEntry::dir("test", "/test");
730        let dbg = format!("{e:?}");
731        assert!(dbg.contains("test"));
732        assert!(dbg.contains("is_dir: true"));
733    }
734
735    // ── FilePickerState construction ──────────────────────────────
736
737    #[test]
738    fn state_new_defaults() {
739        let state = FilePickerState::new(PathBuf::from("/home"), vec![]);
740        assert_eq!(state.current_dir, PathBuf::from("/home"));
741        assert_eq!(state.cursor, 0);
742        assert_eq!(state.offset, 0);
743        assert!(state.selected.is_none());
744        assert!(state.root.is_none());
745        assert!(state.entries.is_empty());
746    }
747
748    #[test]
749    fn state_with_root_sets_root() {
750        let state = FilePickerState::new(PathBuf::from("/home/user"), vec![]).with_root("/home");
751        assert_eq!(state.root, Some(PathBuf::from("/home")));
752    }
753
754    // ── Cursor on single entry ────────────────────────────────────
755
756    #[test]
757    fn cursor_movement_single_entry() {
758        let entries = vec![DirEntry::file("only.txt", "/only.txt")];
759        let mut state = FilePickerState::new(PathBuf::from("/"), entries);
760
761        assert_eq!(state.cursor, 0);
762        state.cursor_down();
763        assert_eq!(state.cursor, 0, "can't go past single entry");
764        state.cursor_up();
765        assert_eq!(state.cursor, 0);
766        state.cursor_end();
767        assert_eq!(state.cursor, 0);
768        state.cursor_home();
769        assert_eq!(state.cursor, 0);
770    }
771
772    #[test]
773    fn page_down_clamps_to_last() {
774        let entries: Vec<DirEntry> = (0..5)
775            .map(|i| DirEntry::file(format!("f{i}"), format!("/f{i}")))
776            .collect();
777        let mut state = FilePickerState::new(PathBuf::from("/"), entries);
778
779        state.page_down(100);
780        assert_eq!(state.cursor, 4);
781    }
782
783    #[test]
784    fn page_up_clamps_to_zero() {
785        let entries: Vec<DirEntry> = (0..5)
786            .map(|i| DirEntry::file(format!("f{i}"), format!("/f{i}")))
787            .collect();
788        let mut state = FilePickerState::new(PathBuf::from("/"), entries);
789        state.cursor = 3;
790
791        state.page_up(100);
792        assert_eq!(state.cursor, 0);
793    }
794
795    #[test]
796    fn page_operations_on_empty_entries() {
797        let mut state = FilePickerState::new(PathBuf::from("/"), vec![]);
798        state.page_down(10);
799        assert_eq!(state.cursor, 0);
800        state.page_up(10);
801        assert_eq!(state.cursor, 0);
802    }
803
804    // ── enter() edge cases ────────────────────────────────────────
805
806    #[test]
807    fn enter_on_empty_entries_returns_false() {
808        let mut state = FilePickerState::new(PathBuf::from("/"), vec![]);
809        let result = state.enter();
810        assert!(result.is_ok());
811        assert!(!result.unwrap());
812        assert!(state.selected.is_none());
813    }
814
815    #[test]
816    fn enter_on_file_sets_selected_without_navigation() {
817        let entries = vec![
818            DirEntry::dir("sub", "/sub"),
819            DirEntry::file("readme.txt", "/readme.txt"),
820        ];
821        let mut state = FilePickerState::new(PathBuf::from("/"), entries);
822        state.cursor = 1;
823
824        let result = state.enter().unwrap();
825        assert!(!result, "enter on file returns false (no navigation)");
826        assert_eq!(state.selected, Some(PathBuf::from("/readme.txt")));
827        // Current directory unchanged.
828        assert_eq!(state.current_dir, PathBuf::from("/"));
829    }
830
831    // ── go_back() edge cases ──────────────────────────────────────
832
833    #[test]
834    fn go_back_blocked_at_root() {
835        let root = std::env::temp_dir();
836        let mut state = FilePickerState::new(root.clone(), vec![]).with_root(root);
837
838        let changed = state.go_back().unwrap();
839        assert!(!changed, "go_back should be blocked when already at root");
840    }
841
842    #[test]
843    fn go_back_without_history_uses_parent_directory() {
844        let current = std::env::temp_dir();
845        let parent = current
846            .parent()
847            .expect("temp_dir should have a parent")
848            .to_path_buf();
849
850        let mut state = FilePickerState::new(current.clone(), vec![]);
851        let changed = state.go_back().unwrap();
852
853        assert!(
854            changed,
855            "go_back should navigate to parent when history is empty"
856        );
857        assert_eq!(state.current_dir, parent);
858        assert_eq!(state.cursor, 0, "parent navigation resets cursor to home");
859    }
860
861    #[test]
862    fn go_back_restores_history_cursor_with_clamp() {
863        let child = std::env::temp_dir();
864        let parent = child
865            .parent()
866            .expect("temp_dir should have a parent")
867            .to_path_buf();
868
869        let mut state = FilePickerState::new(
870            parent.clone(),
871            vec![
872                DirEntry::file("placeholder.txt", parent.join("placeholder.txt")),
873                DirEntry::dir("child", child.clone()),
874            ],
875        );
876        state.cursor = 1;
877
878        let entered = state.enter().unwrap();
879        assert!(entered, "enter should navigate into selected directory");
880
881        let went_back = state.go_back().unwrap();
882        assert!(
883            went_back,
884            "go_back should restore previous directory from history"
885        );
886        assert_eq!(state.current_dir, parent);
887
888        let expected_cursor = 1.min(state.entries.len().saturating_sub(1));
889        assert_eq!(state.cursor, expected_cursor);
890    }
891
892    // ── adjust_scroll edge cases ──────────────────────────────────
893
894    #[test]
895    fn adjust_scroll_zero_visible_rows_is_noop() {
896        let entries: Vec<DirEntry> = (0..10)
897            .map(|i| DirEntry::file(format!("f{i}"), format!("/f{i}")))
898            .collect();
899        let mut state = FilePickerState::new(PathBuf::from("/"), entries);
900        state.cursor = 5;
901        state.offset = 0;
902
903        state.adjust_scroll(0);
904        assert_eq!(
905            state.offset, 0,
906            "zero visible rows should not change offset"
907        );
908    }
909
910    #[test]
911    fn adjust_scroll_cursor_above_viewport() {
912        let entries: Vec<DirEntry> = (0..20)
913            .map(|i| DirEntry::file(format!("f{i}"), format!("/f{i}")))
914            .collect();
915        let mut state = FilePickerState::new(PathBuf::from("/"), entries);
916        state.offset = 10;
917        state.cursor = 5;
918
919        state.adjust_scroll(5);
920        assert_eq!(state.offset, 5, "offset should snap to cursor");
921    }
922
923    #[test]
924    fn adjust_scroll_cursor_below_viewport() {
925        let entries: Vec<DirEntry> = (0..20)
926            .map(|i| DirEntry::file(format!("f{i}"), format!("/f{i}")))
927            .collect();
928        let mut state = FilePickerState::new(PathBuf::from("/"), entries);
929        state.offset = 0;
930        state.cursor = 10;
931
932        state.adjust_scroll(5);
933        // cursor=10 should be the last visible row: offset + 5 > 10 → offset = 6
934        assert_eq!(state.offset, 6);
935    }
936
937    // ── FilePicker builder ────────────────────────────────────────
938
939    #[test]
940    fn file_picker_default_values() {
941        let picker = FilePicker::default();
942        assert!(picker.show_header);
943        assert_eq!(picker.dir_prefix, "📁 ");
944        assert_eq!(picker.file_prefix, "  ");
945    }
946
947    #[test]
948    fn file_picker_builder_chain() {
949        let picker = FilePicker::new()
950            .dir_style(Style::default())
951            .file_style(Style::default())
952            .cursor_style(Style::default())
953            .header_style(Style::default())
954            .show_header(false);
955        assert!(!picker.show_header);
956    }
957
958    #[test]
959    fn file_picker_debug_format() {
960        let picker = FilePicker::new();
961        let dbg = format!("{picker:?}");
962        assert!(dbg.contains("FilePicker"));
963    }
964
965    // ── Render edge cases ─────────────────────────────────────────
966
967    #[test]
968    fn render_zero_area_is_noop() {
969        let picker = FilePicker::new();
970        let mut state = make_state();
971
972        let area = Rect::new(0, 0, 0, 0);
973        let mut pool = GraphemePool::new();
974        let mut frame = Frame::new(30, 5, &mut pool);
975
976        picker.render(area, &mut frame, &mut state);
977        // No crash, buffer untouched.
978        let lines = buf_to_lines(&frame.buffer);
979        assert!(lines[0].trim().is_empty());
980    }
981
982    #[test]
983    fn render_height_one_shows_only_header() {
984        let picker = FilePicker::new().show_header(true);
985        let mut state = make_state();
986
987        let area = Rect::new(0, 0, 30, 1);
988        let mut pool = GraphemePool::new();
989        let mut frame = Frame::new(30, 5, &mut pool);
990
991        picker.render(area, &mut frame, &mut state);
992        let lines = buf_to_lines(&frame.buffer);
993        // Only the header row should have content.
994        assert!(lines[0].starts_with("/tmp"));
995        // Row 1 should be empty (no room for entries).
996        assert!(lines[1].trim().is_empty());
997    }
998
999    #[test]
1000    fn render_no_header_uses_full_area_for_entries() {
1001        let picker = FilePicker::new().show_header(false);
1002        let mut state = make_state();
1003
1004        let area = Rect::new(0, 0, 30, 4);
1005        let mut pool = GraphemePool::new();
1006        let mut frame = Frame::new(30, 4, &mut pool);
1007
1008        picker.render(area, &mut frame, &mut state);
1009        let lines = buf_to_lines(&frame.buffer);
1010        // First line should be an entry (cursor on first entry), not a header.
1011        assert!(lines[0].starts_with("> "));
1012    }
1013
1014    #[test]
1015    fn render_cursor_on_last_entry() {
1016        let picker = FilePicker::new().show_header(false);
1017        let mut state = make_state();
1018        state.cursor = 3; // last entry: main.rs
1019
1020        let area = Rect::new(0, 0, 30, 5);
1021        let mut pool = GraphemePool::new();
1022        let mut frame = Frame::new(30, 5, &mut pool);
1023
1024        picker.render(area, &mut frame, &mut state);
1025        let lines = buf_to_lines(&frame.buffer);
1026        // The cursor row should contain "main.rs".
1027        let cursor_line = lines.iter().find(|l| l.starts_with("> ")).unwrap();
1028        assert!(cursor_line.contains("main.rs"));
1029    }
1030
1031    #[test]
1032    fn render_area_offset() {
1033        // Render into a sub-area of a larger buffer.
1034        let picker = FilePicker::new().show_header(false);
1035        let mut state = make_state();
1036
1037        let area = Rect::new(5, 2, 20, 3);
1038        let mut pool = GraphemePool::new();
1039        let mut frame = Frame::new(30, 10, &mut pool);
1040
1041        picker.render(area, &mut frame, &mut state);
1042        let lines = buf_to_lines(&frame.buffer);
1043        // Rows 0 and 1 should be empty (area starts at y=2).
1044        assert!(lines[0].trim().is_empty());
1045        assert!(lines[1].trim().is_empty());
1046        // Row 2 should have content starting at x=5.
1047        assert!(lines[2].len() >= 7);
1048    }
1049
1050    #[test]
1051    fn render_shorter_header_and_fewer_entries_clear_stale_content() {
1052        let picker = FilePicker::new().show_header(true);
1053        let mut state = FilePickerState::new(PathBuf::from("/tmp/very/long/path"), make_entries());
1054
1055        let area = Rect::new(0, 0, 24, 4);
1056        let mut pool = GraphemePool::new();
1057        let mut frame = Frame::new(24, 4, &mut pool);
1058
1059        picker.render(area, &mut frame, &mut state);
1060
1061        state.current_dir = PathBuf::from("/x");
1062        state.entries = vec![DirEntry::file("a", "/x/a")];
1063        state.cursor = 0;
1064        state.offset = 0;
1065
1066        picker.render(area, &mut frame, &mut state);
1067        let lines = buf_to_lines(&frame.buffer);
1068
1069        assert_eq!(lines[0], format!("{:<24}", "/x"));
1070        assert!(lines[1].starts_with(">   a"));
1071        assert_eq!(lines[2], " ".repeat(24));
1072        assert_eq!(lines[3], " ".repeat(24));
1073    }
1074}