1#![forbid(unsafe_code)]
2
3use 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#[derive(Debug, Clone, PartialEq, Eq)]
26pub struct DirEntry {
27 pub name: String,
29 pub path: PathBuf,
31 pub is_dir: bool,
33}
34
35impl DirEntry {
36 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 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#[derive(Debug, Clone)]
57pub struct FilePickerState {
58 pub current_dir: PathBuf,
60 pub root: Option<PathBuf>,
62 pub entries: Vec<DirEntry>,
64 pub cursor: usize,
66 pub offset: usize,
68 pub selected: Option<PathBuf>,
70 history: Vec<(PathBuf, usize)>,
72}
73
74impl FilePickerState {
75 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 #[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 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 pub fn cursor_up(&mut self) {
109 if self.cursor > 0 {
110 self.cursor -= 1;
111 }
112 }
113
114 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 pub fn cursor_home(&mut self) {
123 self.cursor = 0;
124 }
125
126 pub fn cursor_end(&mut self) {
128 if !self.entries.is_empty() {
129 self.cursor = self.entries.len() - 1;
130 }
131 }
132
133 pub fn page_up(&mut self, page_size: usize) {
135 self.cursor = self.cursor.saturating_sub(page_size);
136 }
137
138 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 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 self.selected = Some(entry.path.clone());
157 return Ok(false);
158 }
159
160 let new_dir = entry.path.clone();
161
162 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 pub fn go_back(&mut self) -> std::io::Result<bool> {
188 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 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); }
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 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
243fn 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 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#[derive(Debug, Clone)]
288pub struct FilePicker {
289 pub dir_style: Style,
291 pub file_style: Style,
293 pub cursor_style: Style,
295 pub header_style: Style,
297 pub show_header: bool,
299 pub dir_prefix: &'static str,
301 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 pub fn new() -> Self {
322 Self::default()
323 }
324
325 #[must_use]
327 pub fn dir_style(mut self, style: Style) -> Self {
328 self.dir_style = style;
329 self
330 }
331
332 #[must_use]
334 pub fn file_style(mut self, style: Style) -> Self {
335 self.file_style = style;
336 self
337 }
338
339 #[must_use]
341 pub fn cursor_style(mut self, style: Style) -> Self {
342 self.cursor_style = style;
343 self
344 }
345
346 #[must_use]
348 pub fn header_style(mut self, style: Style) -> Self {
349 self.header_style = style;
350 self
351 }
352
353 #[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 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 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 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 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 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(); 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 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 assert!(lines[0].starts_with("> "));
613 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 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 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 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; 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 let lines = buf_to_lines(&frame.buffer);
692 assert!(lines[1].starts_with("> "));
693 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; let result = state.enter();
704 assert!(result.is_ok());
705 assert_eq!(state.selected, Some(PathBuf::from("/tmp/README.md")));
706 }
707
708 #[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 #[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 #[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 #[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 assert_eq!(state.current_dir, PathBuf::from("/"));
829 }
830
831 #[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 #[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 assert_eq!(state.offset, 6);
935 }
936
937 #[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 #[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 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 assert!(lines[0].starts_with("/tmp"));
995 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 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; 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 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 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 assert!(lines[0].trim().is_empty());
1045 assert!(lines[1].trim().is_empty());
1046 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}