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