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 mut file_type = entry.file_type()?;
241 let full_path = entry.path();
242
243 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#[derive(Debug, Clone)]
277pub struct FilePicker {
278 pub dir_style: Style,
280 pub file_style: Style,
282 pub cursor_style: Style,
284 pub header_style: Style,
286 pub show_header: bool,
288 pub dir_prefix: &'static str,
290 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 pub fn new() -> Self {
311 Self::default()
312 }
313
314 #[must_use]
316 pub fn dir_style(mut self, style: Style) -> Self {
317 self.dir_style = style;
318 self
319 }
320
321 #[must_use]
323 pub fn file_style(mut self, style: Style) -> Self {
324 self.file_style = style;
325 self
326 }
327
328 #[must_use]
330 pub fn cursor_style(mut self, style: Style) -> Self {
331 self.cursor_style = style;
332 self
333 }
334
335 #[must_use]
337 pub fn header_style(mut self, style: Style) -> Self {
338 self.header_style = style;
339 self
340 }
341
342 #[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 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 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 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 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 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(); 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 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 assert!(lines[0].starts_with("> "));
574 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 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 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 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; 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 let lines = buf_to_lines(&frame.buffer);
653 assert!(lines[1].starts_with("> "));
654 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; let result = state.enter();
665 assert!(result.is_ok());
666 assert_eq!(state.selected, Some(PathBuf::from("/tmp/README.md")));
667 }
668
669 #[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 #[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 #[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 #[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 assert_eq!(state.current_dir, PathBuf::from("/"));
790 }
791
792 #[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 #[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 assert_eq!(state.offset, 6);
896 }
897
898 #[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 #[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 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 assert!(lines[0].starts_with("/tmp"));
956 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 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; 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 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 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 assert!(lines[0].trim().is_empty());
1006 assert!(lines[1].trim().is_empty());
1007 assert!(lines[2].len() >= 7);
1009 }
1010}