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