1#![forbid(unsafe_code)]
2
3use 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#[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 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 pub fn go_back(&mut self) -> std::io::Result<bool> {
175 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 if let Some(parent) = self.current_dir.parent().map(|p| p.to_path_buf()) {
193 if let Some(root) = &self.root {
195 if !parent.starts_with(root) && parent != *root {
199 }
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 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
232fn 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#[derive(Debug, Clone)]
270pub struct FilePicker {
271 pub dir_style: Style,
273 pub file_style: Style,
275 pub cursor_style: Style,
277 pub header_style: Style,
279 pub show_header: bool,
281 pub dir_prefix: &'static str,
283 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 pub fn new() -> Self {
304 Self::default()
305 }
306
307 #[must_use]
309 pub fn dir_style(mut self, style: Style) -> Self {
310 self.dir_style = style;
311 self
312 }
313
314 #[must_use]
316 pub fn file_style(mut self, style: Style) -> Self {
317 self.file_style = style;
318 self
319 }
320
321 #[must_use]
323 pub fn cursor_style(mut self, style: Style) -> Self {
324 self.cursor_style = style;
325 self
326 }
327
328 #[must_use]
330 pub fn header_style(mut self, style: Style) -> Self {
331 self.header_style = style;
332 self
333 }
334
335 #[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 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 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 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 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 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(); 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 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 assert!(lines[0].starts_with("> "));
567 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 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 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 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; 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 let lines = buf_to_lines(&frame.buffer);
646 assert!(lines[1].starts_with("> "));
647 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; let result = state.enter();
658 assert!(result.is_ok());
659 assert_eq!(state.selected, Some(PathBuf::from("/tmp/README.md")));
660 }
661
662 #[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 #[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 #[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 #[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 assert_eq!(state.current_dir, PathBuf::from("/"));
783 }
784
785 #[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 #[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 assert_eq!(state.offset, 6);
889 }
890
891 #[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 #[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 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 assert!(lines[0].starts_with("/tmp"));
949 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 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; 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 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 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 assert!(lines[0].trim().is_empty());
999 assert!(lines[1].trim().is_empty());
1000 assert!(lines[2].len() >= 7);
1002 }
1003}