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 pub fn dir_style(mut self, style: Style) -> Self {
309 self.dir_style = style;
310 self
311 }
312
313 pub fn file_style(mut self, style: Style) -> Self {
315 self.file_style = style;
316 self
317 }
318
319 pub fn cursor_style(mut self, style: Style) -> Self {
321 self.cursor_style = style;
322 self
323 }
324
325 pub fn header_style(mut self, style: Style) -> Self {
327 self.header_style = style;
328 self
329 }
330
331 pub fn show_header(mut self, show: bool) -> Self {
333 self.show_header = show;
334 self
335 }
336}
337
338impl StatefulWidget for FilePicker {
339 type State = FilePickerState;
340
341 fn render(&self, area: Rect, frame: &mut Frame, state: &mut Self::State) {
342 if area.is_empty() {
343 return;
344 }
345
346 let mut y = area.y;
347 let max_y = area.bottom();
348
349 if self.show_header && y < max_y {
351 let header = state.current_dir.to_string_lossy();
352 draw_text_span(frame, area.x, y, &header, self.header_style, area.right());
353 y += 1;
354 }
355
356 if y >= max_y {
357 return;
358 }
359
360 let visible_rows = (max_y - y) as usize;
361 state.adjust_scroll(visible_rows);
362
363 if state.entries.is_empty() {
364 draw_text_span(
365 frame,
366 area.x,
367 y,
368 "(empty directory)",
369 self.file_style,
370 area.right(),
371 );
372 return;
373 }
374
375 let end_idx = (state.offset + visible_rows).min(state.entries.len());
376 for (i, entry) in state.entries[state.offset..end_idx].iter().enumerate() {
377 if y >= max_y {
378 break;
379 }
380
381 let actual_idx = state.offset + i;
382 let is_cursor = actual_idx == state.cursor;
383
384 let prefix = if entry.is_dir {
385 self.dir_prefix
386 } else {
387 self.file_prefix
388 };
389
390 let base_style = if entry.is_dir {
391 self.dir_style
392 } else {
393 self.file_style
394 };
395
396 let style = if is_cursor {
397 self.cursor_style.merge(&base_style)
398 } else {
399 base_style
400 };
401
402 let mut x = area.x;
404 if is_cursor {
405 draw_text_span(frame, x, y, "> ", self.cursor_style, area.right());
406 x = x.saturating_add(2);
407 } else {
408 x = x.saturating_add(2);
409 }
410
411 x = draw_text_span(frame, x, y, prefix, style, area.right());
413 draw_text_span(frame, x, y, &entry.name, style, area.right());
414
415 y += 1;
416 }
417 }
418}
419
420#[cfg(test)]
421mod tests {
422 use super::*;
423 use ftui_render::grapheme_pool::GraphemePool;
424
425 fn buf_to_lines(buf: &ftui_render::buffer::Buffer) -> Vec<String> {
426 let mut lines = Vec::new();
427 for y in 0..buf.height() {
428 let mut row = String::with_capacity(buf.width() as usize);
429 for x in 0..buf.width() {
430 let ch = buf
431 .get(x, y)
432 .and_then(|c| c.content.as_char())
433 .unwrap_or(' ');
434 row.push(ch);
435 }
436 lines.push(row);
437 }
438 lines
439 }
440
441 fn make_entries() -> Vec<DirEntry> {
442 vec![
443 DirEntry::dir("docs", "/tmp/docs"),
444 DirEntry::dir("src", "/tmp/src"),
445 DirEntry::file("README.md", "/tmp/README.md"),
446 DirEntry::file("main.rs", "/tmp/main.rs"),
447 ]
448 }
449
450 fn make_state() -> FilePickerState {
451 FilePickerState::new(PathBuf::from("/tmp"), make_entries())
452 }
453
454 #[test]
455 fn dir_entry_constructors() {
456 let d = DirEntry::dir("src", "/src");
457 assert!(d.is_dir);
458 assert_eq!(d.name, "src");
459
460 let f = DirEntry::file("main.rs", "/main.rs");
461 assert!(!f.is_dir);
462 assert_eq!(f.name, "main.rs");
463 }
464
465 #[test]
466 fn state_cursor_movement() {
467 let mut state = make_state();
468 assert_eq!(state.cursor, 0);
469
470 state.cursor_down();
471 assert_eq!(state.cursor, 1);
472
473 state.cursor_down();
474 state.cursor_down();
475 assert_eq!(state.cursor, 3);
476
477 state.cursor_down();
479 assert_eq!(state.cursor, 3);
480
481 state.cursor_up();
482 assert_eq!(state.cursor, 2);
483
484 state.cursor_home();
485 assert_eq!(state.cursor, 0);
486
487 state.cursor_up();
489 assert_eq!(state.cursor, 0);
490
491 state.cursor_end();
492 assert_eq!(state.cursor, 3);
493 }
494
495 #[test]
496 fn state_page_navigation() {
497 let entries: Vec<DirEntry> = (0..20)
498 .map(|i| DirEntry::file(format!("file{i}.txt"), format!("/tmp/file{i}.txt")))
499 .collect();
500 let mut state = FilePickerState::new(PathBuf::from("/tmp"), entries);
501
502 state.page_down(5);
503 assert_eq!(state.cursor, 5);
504
505 state.page_down(5);
506 assert_eq!(state.cursor, 10);
507
508 state.page_up(3);
509 assert_eq!(state.cursor, 7);
510
511 state.page_up(100);
512 assert_eq!(state.cursor, 0);
513
514 state.page_down(100);
515 assert_eq!(state.cursor, 19);
516 }
517
518 #[test]
519 fn state_empty_entries() {
520 let mut state = FilePickerState::new(PathBuf::from("/tmp"), vec![]);
521 state.cursor_down(); state.cursor_up();
523 state.cursor_end();
524 state.cursor_home();
525 state.page_down(10);
526 state.page_up(10);
527 assert_eq!(state.cursor, 0);
528 }
529
530 #[test]
531 fn adjust_scroll_keeps_cursor_visible() {
532 let entries: Vec<DirEntry> = (0..20)
533 .map(|i| DirEntry::file(format!("f{i}"), format!("/f{i}")))
534 .collect();
535 let mut state = FilePickerState::new(PathBuf::from("/"), entries);
536
537 state.cursor = 15;
538 state.adjust_scroll(5);
539 assert!(state.offset <= 15);
541 assert!(state.offset + 5 > 15);
542
543 state.cursor = 0;
544 state.adjust_scroll(5);
545 assert_eq!(state.offset, 0);
546 }
547
548 #[test]
549 fn render_basic() {
550 let picker = FilePicker::new().show_header(false);
551 let mut state = make_state();
552
553 let area = Rect::new(0, 0, 30, 5);
554 let mut pool = GraphemePool::new();
555 let mut frame = Frame::new(30, 5, &mut pool);
556
557 picker.render(area, &mut frame, &mut state);
558 let lines = buf_to_lines(&frame.buffer);
559
560 assert!(lines[0].starts_with("> "));
562 let all_text = lines.join("\n");
564 assert!(all_text.contains("docs"));
565 assert!(all_text.contains("src"));
566 assert!(all_text.contains("README.md"));
567 assert!(all_text.contains("main.rs"));
568 }
569
570 #[test]
571 fn render_with_header() {
572 let picker = FilePicker::new().show_header(true);
573 let mut state = make_state();
574
575 let area = Rect::new(0, 0, 30, 6);
576 let mut pool = GraphemePool::new();
577 let mut frame = Frame::new(30, 6, &mut pool);
578
579 picker.render(area, &mut frame, &mut state);
580 let lines = buf_to_lines(&frame.buffer);
581
582 assert!(lines[0].starts_with("/tmp"));
584 }
585
586 #[test]
587 fn render_empty_directory() {
588 let picker = FilePicker::new().show_header(false);
589 let mut state = FilePickerState::new(PathBuf::from("/empty"), vec![]);
590
591 let area = Rect::new(0, 0, 30, 3);
592 let mut pool = GraphemePool::new();
593 let mut frame = Frame::new(30, 3, &mut pool);
594
595 picker.render(area, &mut frame, &mut state);
596 let lines = buf_to_lines(&frame.buffer);
597
598 assert!(lines[0].contains("empty directory"));
599 }
600
601 #[test]
602 fn render_scrolling() {
603 let entries: Vec<DirEntry> = (0..20)
604 .map(|i| DirEntry::file(format!("file{i:02}.txt"), format!("/tmp/file{i:02}.txt")))
605 .collect();
606 let mut state = FilePickerState::new(PathBuf::from("/tmp"), entries);
607 let picker = FilePicker::new().show_header(false);
608
609 state.cursor = 15;
611 let area = Rect::new(0, 0, 30, 5);
612 let mut pool = GraphemePool::new();
613 let mut frame = Frame::new(30, 5, &mut pool);
614
615 picker.render(area, &mut frame, &mut state);
616 let lines = buf_to_lines(&frame.buffer);
617
618 let all_text = lines.join("\n");
620 assert!(all_text.contains("file15"));
621 }
622
623 #[test]
624 fn cursor_style_applied_to_selected_row() {
625 use ftui_render::cell::PackedRgba;
626
627 let picker = FilePicker::new()
628 .show_header(false)
629 .cursor_style(Style::new().fg(PackedRgba::rgb(255, 0, 0)));
630 let mut state = make_state();
631 state.cursor = 1; let area = Rect::new(0, 0, 30, 4);
634 let mut pool = GraphemePool::new();
635 let mut frame = Frame::new(30, 4, &mut pool);
636
637 picker.render(area, &mut frame, &mut state);
638
639 let lines = buf_to_lines(&frame.buffer);
641 assert!(lines[1].starts_with("> "));
642 assert!(!lines[0].starts_with("> "));
644 }
645
646 #[test]
647 fn selected_set_on_file_entry() {
648 let mut state = make_state();
649 state.cursor = 2; let result = state.enter();
653 assert!(result.is_ok());
654 assert_eq!(state.selected, Some(PathBuf::from("/tmp/README.md")));
655 }
656}