Skip to main content

turbo_vision/views/
file_list.rs

1// (C) 2025 - Enzo Lombardi
2
3//! `FileList` view - specialized list viewer for displaying and selecting files.
4// FileList - Specialized list viewer for files
5//
6// Matches Borland: TFileList (views/tfillist.cc)
7//
8// A list viewer that displays files with attributes and optional icons.
9// Designed to be used in file dialogs or as a standalone file browser.
10//
11// Features:
12// - Displays files with visual indicators (directories shown as [dirname])
13// - Supports wildcard filtering (*.rs, *.txt, etc.)
14// - Parent directory (..) navigation
15// - File info display (size, date, attributes)
16// - Integrates with ListViewer trait for consistent navigation
17
18use super::list_viewer::{ListViewer, ListViewerState};
19use super::view::{View, ViewCore};
20use crate::core::event::{Event, EventType};
21use crate::core::geometry::Rect;
22use crate::core::state::State;
23use crate::terminal::Terminal;
24use std::fs;
25use std::path::{Path, PathBuf};
26use std::time::SystemTime;
27
28/// File entry information
29#[derive(Clone, Debug)]
30pub struct FileEntry {
31    pub name: String,
32    pub path: PathBuf,
33    pub is_dir: bool,
34    pub size: u64,
35    pub modified: Option<SystemTime>,
36}
37
38impl FileEntry {
39    /// Create from a directory entry
40    pub fn from_dir_entry(entry: &fs::DirEntry) -> std::io::Result<Self> {
41        let metadata = entry.metadata()?;
42        let name = entry.file_name().to_string_lossy().to_string();
43        let path = entry.path();
44        let is_dir = metadata.is_dir();
45        let size = metadata.len();
46        let modified = metadata.modified().ok();
47
48        Ok(Self {
49            name,
50            path,
51            is_dir,
52            size,
53            modified,
54        })
55    }
56
57    /// Format for display in list
58    pub fn display_name(&self) -> String {
59        if self.is_dir {
60            format!("[{}]", self.name)
61        } else {
62            self.name.clone()
63        }
64    }
65
66    /// Get file size as human-readable string
67    pub fn size_string(&self) -> String {
68        if self.is_dir {
69            "<DIR>".to_string()
70        } else if self.size < 1024 {
71            format!("{} B", self.size)
72        } else if self.size < 1024 * 1024 {
73            format!("{} KB", self.size / 1024)
74        } else if self.size < 1024 * 1024 * 1024 {
75            format!("{} MB", self.size / (1024 * 1024))
76        } else {
77            format!("{} GB", self.size / (1024 * 1024 * 1024))
78        }
79    }
80}
81
82/// `FileList` - Displays files and directories
83///
84/// Matches Borland: `TFileList`
85pub struct FileList {
86    core: ViewCore,
87    list_state: ListViewerState,
88    files: Vec<FileEntry>,
89    current_path: PathBuf,
90    wildcard: String,
91    show_hidden: bool,
92}
93
94impl FileList {
95    /// Create a new file list
96    pub fn new(bounds: Rect, path: &Path) -> Self {
97        Self {
98            core: ViewCore {
99                bounds,
100                state: State::empty(),
101                palette_chain: None,
102                ..ViewCore::default()
103            },
104            list_state: ListViewerState::new(),
105            files: Vec::new(),
106            current_path: path.to_path_buf(),
107            wildcard: "*".to_string(),
108            show_hidden: false,
109        }
110    }
111
112    /// Set the wildcard filter (e.g., "*.rs", "*.txt")
113    pub fn set_wildcard(&mut self, wildcard: &str) {
114        self.wildcard = wildcard.to_string();
115        self.refresh();
116    }
117
118    /// Set whether to show hidden files
119    pub fn set_show_hidden(&mut self, show: bool) {
120        self.show_hidden = show;
121        self.refresh();
122    }
123
124    /// Get current path
125    pub fn current_path(&self) -> &Path {
126        &self.current_path
127    }
128
129    /// Change to a different directory
130    pub fn change_dir(&mut self, path: &Path) -> std::io::Result<()> {
131        let canonical = fs::canonicalize(path)?;
132        self.current_path = canonical;
133        self.refresh();
134        Ok(())
135    }
136
137    /// Refresh the file list
138    pub fn refresh(&mut self) {
139        self.files.clear();
140
141        // Add parent directory entry if not at root
142        if self.current_path.parent().is_some() {
143            self.files.push(FileEntry {
144                name: "..".to_string(),
145                path: self.current_path.parent().unwrap().to_path_buf(),
146                is_dir: true,
147                size: 0,
148                modified: None,
149            });
150        }
151
152        // Read directory entries
153        if let Ok(entries) = fs::read_dir(&self.current_path) {
154            let mut file_entries: Vec<FileEntry> = entries
155                .filter_map(std::result::Result::ok)
156                .filter_map(|e| FileEntry::from_dir_entry(&e).ok())
157                .filter(|entry| {
158                    // Filter hidden files
159                    if !self.show_hidden && entry.name.starts_with('.') {
160                        return false;
161                    }
162                    // Always show directories
163                    if entry.is_dir {
164                        return true;
165                    }
166                    // Filter files by wildcard
167                    self.matches_wildcard(&entry.name)
168                })
169                .collect();
170
171            // Sort: directories first, then files, both alphabetically
172            file_entries.sort_by(|a, b| match (a.is_dir, b.is_dir) {
173                (true, false) => std::cmp::Ordering::Less,
174                (false, true) => std::cmp::Ordering::Greater,
175                _ => a.name.to_lowercase().cmp(&b.name.to_lowercase()),
176            });
177
178            self.files.extend(file_entries);
179        }
180
181        // Update list state
182        self.list_state.set_range(self.files.len());
183        if let Some(focused) = self.list_state.focused {
184            if focused >= self.files.len() {
185                self.list_state.focused = Some(0);
186            }
187        }
188    }
189
190    /// Check if filename matches wildcard pattern
191    fn matches_wildcard(&self, filename: &str) -> bool {
192        if self.wildcard == "*" {
193            return true;
194        }
195
196        // Simple wildcard matching: "*.ext" -> ends with ".ext"
197        if let Some(pattern) = self.wildcard.strip_prefix('*') {
198            return filename.ends_with(pattern);
199        }
200
201        // Exact match
202        filename == self.wildcard
203    }
204
205    /// Get the currently focused file entry
206    pub fn get_focused_entry(&self) -> Option<&FileEntry> {
207        let idx = self.list_state.focused?;
208        self.files.get(idx)
209    }
210
211    /// Get the selected file path (returns None if directory is selected)
212    pub fn get_selected_file(&self) -> Option<PathBuf> {
213        let entry = self.get_focused_entry()?;
214        if entry.is_dir {
215            None
216        } else {
217            Some(entry.path.clone())
218        }
219    }
220
221    /// Navigate into the focused directory
222    pub fn enter_focused_dir(&mut self) -> std::io::Result<bool> {
223        let path = if let Some(entry) = self.get_focused_entry() {
224            entry.is_dir.then(|| entry.path.clone())
225        } else {
226            None
227        };
228
229        if let Some(path) = path {
230            self.change_dir(&path)?;
231            Ok(true)
232        } else {
233            Ok(false)
234        }
235    }
236
237    /// Get file count
238    pub fn file_count(&self) -> usize {
239        self.files.len()
240    }
241}
242
243impl ListViewer for FileList {
244    fn list_state(&self) -> &ListViewerState {
245        &self.list_state
246    }
247
248    fn list_state_mut(&mut self) -> &mut ListViewerState {
249        &mut self.list_state
250    }
251
252    fn get_text(&self, item: usize, _max_len: usize) -> String {
253        self.files
254            .get(item)
255            .map(FileEntry::display_name)
256            .unwrap_or_default()
257    }
258}
259
260impl View for FileList {
261    fn core(&self) -> &ViewCore {
262        &self.core
263    }
264
265    fn core_mut(&mut self) -> &mut ViewCore {
266        &mut self.core
267    }
268
269    fn draw(&mut self, terminal: &mut Terminal) {
270        let width = self.core.bounds.width_clamped() as usize;
271        let height = self.core.bounds.height_clamped() as usize;
272
273        self.list_state.set_range(self.files.len());
274
275        for y in 0..height {
276            let item_idx = self.list_state.top_item + y;
277
278            let (text, color) = if item_idx < self.files.len() {
279                let text = self.get_text(item_idx, width);
280                let is_focused = self.is_focused() && Some(item_idx) == self.list_state.focused;
281                let color = if is_focused {
282                    crate::core::palette::colors::LISTBOX_FOCUSED
283                } else {
284                    crate::core::palette::colors::LISTBOX_NORMAL
285                };
286                (text, color)
287            } else {
288                (String::new(), crate::core::palette::colors::LISTBOX_NORMAL)
289            };
290
291            let padded = format!("{text:width$}");
292
293            for (x, ch) in padded.chars().take(width).enumerate() {
294                terminal.write_cell(x as i16, y as i16, crate::core::draw::Cell::new(ch, color));
295            }
296        }
297    }
298
299    fn handle_event(&mut self, event: &mut Event) {
300        if !self.is_focused() {
301            return;
302        }
303
304        // Use default ListViewer navigation
305        self.handle_list_event(event);
306
307        // Handle Enter to navigate directories
308        if event.what == EventType::Keyboard && event.key_code == crate::core::event::KB_ENTER {
309            let _ = self.enter_focused_dir();
310            event.clear();
311        }
312    }
313
314    fn can_focus(&self) -> bool {
315        true
316    }
317
318    fn get_palette(&self) -> Option<crate::core::palette::Palette> {
319        use crate::core::palette::{Palette, palettes};
320        Some(Palette::from_slice(palettes::CP_LISTBOX))
321    }
322
323    fn as_any(&self) -> &dyn std::any::Any {
324        self
325    }
326
327    fn as_any_mut(&mut self) -> &mut dyn std::any::Any {
328        self
329    }
330}
331
332/// Builder for creating file lists with a fluent API.
333pub struct FileListBuilder {
334    bounds: Option<Rect>,
335    path: Option<PathBuf>,
336}
337
338impl FileListBuilder {
339    pub fn new() -> Self {
340        Self {
341            bounds: None,
342            path: None,
343        }
344    }
345
346    #[must_use]
347    pub fn bounds(mut self, bounds: Rect) -> Self {
348        self.bounds = Some(bounds);
349        self
350    }
351
352    #[must_use]
353    pub fn path(mut self, path: impl Into<PathBuf>) -> Self {
354        self.path = Some(path.into());
355        self
356    }
357
358    pub fn build(self) -> FileList {
359        let bounds = self.bounds.expect("FileList bounds must be set");
360        let path = self.path.expect("FileList path must be set");
361        FileList::new(bounds, &path)
362    }
363
364    pub fn build_boxed(self) -> Box<FileList> {
365        Box::new(self.build())
366    }
367}
368
369impl Default for FileListBuilder {
370    fn default() -> Self {
371        Self::new()
372    }
373}
374
375#[cfg(test)]
376mod tests {
377    use super::*;
378    use std::env;
379
380    #[test]
381    fn test_file_list_creation() {
382        let bounds = Rect::new(0, 0, 40, 10);
383        let path = env::current_dir().unwrap();
384        let mut list = FileList::new(bounds, &path);
385        list.refresh();
386
387        assert!(list.file_count() > 0, "Should find at least some files");
388        assert_eq!(list.current_path(), path.as_path());
389    }
390
391    #[test]
392    fn test_wildcard_filtering() {
393        let bounds = Rect::new(0, 0, 40, 10);
394        let path = env::current_dir().unwrap();
395        let mut list = FileList::new(bounds, &path);
396
397        // Filter for Rust files
398        list.set_wildcard("*.rs");
399
400        // All non-directory entries should be .rs files
401        for entry in &list.files {
402            if !entry.is_dir {
403                assert!(
404                    std::path::Path::new(&entry.name).extension()
405                        == Some(std::ffi::OsStr::new("rs")),
406                    "Should only show .rs files"
407                );
408            }
409        }
410    }
411
412    #[test]
413    fn test_file_entry_display() {
414        let entry = FileEntry {
415            name: "test.txt".to_string(),
416            path: PathBuf::from("test.txt"),
417            is_dir: false,
418            size: 1024,
419            modified: None,
420        };
421        assert_eq!(entry.display_name(), "test.txt");
422
423        let dir_entry = FileEntry {
424            name: "mydir".to_string(),
425            path: PathBuf::from("mydir"),
426            is_dir: true,
427            size: 0,
428            modified: None,
429        };
430        assert_eq!(dir_entry.display_name(), "[mydir]");
431    }
432
433    #[test]
434    fn test_size_formatting() {
435        let small = FileEntry {
436            name: "small".to_string(),
437            path: PathBuf::from("small"),
438            is_dir: false,
439            size: 512,
440            modified: None,
441        };
442        assert_eq!(small.size_string(), "512 B");
443
444        let kb = FileEntry {
445            name: "kb".to_string(),
446            path: PathBuf::from("kb"),
447            is_dir: false,
448            size: 2048,
449            modified: None,
450        };
451        assert_eq!(kb.size_string(), "2 KB");
452
453        let dir = FileEntry {
454            name: "dir".to_string(),
455            path: PathBuf::from("dir"),
456            is_dir: true,
457            size: 0,
458            modified: None,
459        };
460        assert_eq!(dir.size_string(), "<DIR>");
461    }
462}