Skip to main content

presentar_terminal/widgets/
files_panel.rs

1//! `FilesPanel` widget for filesystem usage visualization.
2//!
3//! Displays a treemap of disk usage by directory.
4//! Wraps the Treemap widget with filesystem-specific display.
5
6use presentar_core::{
7    Brick, BrickAssertion, BrickBudget, BrickVerification, Canvas, Color, Constraints, Event,
8    LayoutResult, Point, Rect, Size, TextStyle, TypeId, Widget,
9};
10use std::any::Any;
11use std::time::Duration;
12
13/// A filesystem entry for display.
14#[derive(Debug, Clone)]
15pub struct FileEntry {
16    /// Directory/file name.
17    pub name: String,
18    /// Size in bytes.
19    pub size: u64,
20    /// Whether this is a directory.
21    pub is_dir: bool,
22    /// Color for display.
23    pub color: Color,
24}
25
26impl FileEntry {
27    /// Create a new file entry.
28    #[must_use]
29    pub fn new(name: impl Into<String>, size: u64, is_dir: bool) -> Self {
30        // Assign color based on common directory types
31        let name_str = name.into();
32        let color = Self::color_for_name(&name_str);
33        Self {
34            name: name_str,
35            size,
36            is_dir,
37            color,
38        }
39    }
40
41    /// Create a directory entry.
42    #[must_use]
43    pub fn directory(name: impl Into<String>, size: u64) -> Self {
44        Self::new(name, size, true)
45    }
46
47    /// Create a file entry.
48    #[must_use]
49    pub fn file(name: impl Into<String>, size: u64) -> Self {
50        Self::new(name, size, false)
51    }
52
53    /// Set color explicitly.
54    #[must_use]
55    pub fn with_color(mut self, color: Color) -> Self {
56        self.color = color;
57        self
58    }
59
60    /// Get color based on directory name.
61    fn color_for_name(name: &str) -> Color {
62        match name.to_lowercase().as_str() {
63            "home" | "users" => Color::new(0.4, 0.7, 0.9, 1.0), // Blue
64            "var" | "log" => Color::new(0.9, 0.6, 0.3, 1.0),    // Orange
65            "usr" | "bin" | "lib" => Color::new(0.5, 0.8, 0.5, 1.0), // Green
66            "tmp" | "cache" => Color::new(0.7, 0.7, 0.7, 1.0),  // Gray
67            "etc" | "config" => Color::new(0.8, 0.5, 0.8, 1.0), // Purple
68            "opt" | "local" => Color::new(0.6, 0.8, 0.6, 1.0),  // Light green
69            _ => Color::new(0.6, 0.6, 0.8, 1.0),                // Default blue-gray
70        }
71    }
72
73    /// Format size for display.
74    pub fn size_display(&self) -> String {
75        const KB: u64 = 1024;
76        const MB: u64 = KB * 1024;
77        const GB: u64 = MB * 1024;
78        const TB: u64 = GB * 1024;
79
80        if self.size >= TB {
81            format!("{:.1}T", self.size as f64 / TB as f64)
82        } else if self.size >= GB {
83            format!("{:.1}G", self.size as f64 / GB as f64)
84        } else if self.size >= MB {
85            format!("{:.0}M", self.size as f64 / MB as f64)
86        } else if self.size >= KB {
87            format!("{:.0}K", self.size as f64 / KB as f64)
88        } else {
89            format!("{}B", self.size)
90        }
91    }
92
93    /// Get percentage of total.
94    pub fn percent_of(&self, total: u64) -> f32 {
95        if total > 0 {
96            (self.size as f64 / total as f64 * 100.0) as f32
97        } else {
98            0.0
99        }
100    }
101}
102
103/// Files panel displaying filesystem usage as a treemap.
104#[derive(Debug, Clone)]
105pub struct FilesPanel {
106    /// File/directory entries.
107    entries: Vec<FileEntry>,
108    /// Total size for percentage calculation.
109    total_size: u64,
110    /// Show size labels.
111    show_sizes: bool,
112    /// Show percentage bars.
113    show_bars: bool,
114    /// Max entries to show.
115    max_entries: usize,
116    /// Cached bounds.
117    bounds: Rect,
118}
119
120impl Default for FilesPanel {
121    fn default() -> Self {
122        Self::new()
123    }
124}
125
126impl FilesPanel {
127    /// Create a new files panel.
128    #[must_use]
129    pub fn new() -> Self {
130        Self {
131            entries: Vec::new(),
132            total_size: 0,
133            show_sizes: true,
134            show_bars: true,
135            max_entries: 8,
136            bounds: Rect::default(),
137        }
138    }
139
140    /// Add an entry.
141    pub fn add_entry(&mut self, entry: FileEntry) {
142        self.total_size += entry.size;
143        self.entries.push(entry);
144    }
145
146    /// Set all entries.
147    #[must_use]
148    pub fn with_entries(mut self, entries: Vec<FileEntry>) -> Self {
149        self.total_size = entries.iter().map(|e| e.size).sum();
150        self.entries = entries;
151        self
152    }
153
154    /// Set total size explicitly.
155    #[must_use]
156    pub fn with_total_size(mut self, total: u64) -> Self {
157        self.total_size = total;
158        self
159    }
160
161    /// Toggle size labels.
162    #[must_use]
163    pub fn show_sizes(mut self, show: bool) -> Self {
164        self.show_sizes = show;
165        self
166    }
167
168    /// Toggle percentage bars.
169    #[must_use]
170    pub fn show_bars(mut self, show: bool) -> Self {
171        self.show_bars = show;
172        self
173    }
174
175    /// Set max entries.
176    #[must_use]
177    pub fn max_entries(mut self, max: usize) -> Self {
178        self.max_entries = max;
179        self
180    }
181
182    /// Get sorted entries (by size descending).
183    fn sorted_entries(&self) -> Vec<&FileEntry> {
184        let mut sorted: Vec<_> = self.entries.iter().collect();
185        sorted.sort_by(|a, b| b.size.cmp(&a.size));
186        sorted
187    }
188
189    /// Draw an entry with mini bar.
190    fn draw_entry(&self, canvas: &mut dyn Canvas, entry: &FileEntry, x: f32, y: f32, width: f32) {
191        let pct = entry.percent_of(self.total_size);
192
193        if self.show_bars {
194            // Draw percentage bar first
195            let bar_width = ((width - 12.0) * pct / 100.0) as usize;
196            let bar: String = "▓".repeat(bar_width.min(20));
197            let empty: String = "░".repeat(20_usize.saturating_sub(bar_width));
198
199            canvas.draw_text(
200                &format!("{bar}{empty}"),
201                Point::new(x, y),
202                &TextStyle {
203                    color: entry.color,
204                    ..Default::default()
205                },
206            );
207
208            // Draw name after bar
209            let name = if entry.name.len() > 8 {
210                format!("{}...", &entry.name[..5])
211            } else {
212                entry.name.clone()
213            };
214
215            canvas.draw_text(
216                &name,
217                Point::new(x + 21.0, y),
218                &TextStyle {
219                    color: Color::WHITE,
220                    ..Default::default()
221                },
222            );
223        } else {
224            // Simple list mode
225            let name = if entry.name.len() > 12 {
226                format!("{}...", &entry.name[..9])
227            } else {
228                format!("{:<12}", entry.name)
229            };
230
231            canvas.draw_text(
232                &name,
233                Point::new(x, y),
234                &TextStyle {
235                    color: entry.color,
236                    ..Default::default()
237                },
238            );
239
240            if self.show_sizes {
241                canvas.draw_text(
242                    &entry.size_display(),
243                    Point::new(x + 13.0, y),
244                    &TextStyle {
245                        color: Color::new(0.7, 0.7, 0.7, 1.0),
246                        ..Default::default()
247                    },
248                );
249            }
250        }
251    }
252}
253
254impl Brick for FilesPanel {
255    fn brick_name(&self) -> &'static str {
256        "files_panel"
257    }
258
259    fn assertions(&self) -> &[BrickAssertion] {
260        static ASSERTIONS: &[BrickAssertion] = &[BrickAssertion::max_latency_ms(8)];
261        ASSERTIONS
262    }
263
264    fn budget(&self) -> BrickBudget {
265        BrickBudget::uniform(8)
266    }
267
268    fn verify(&self) -> BrickVerification {
269        BrickVerification {
270            passed: vec![BrickAssertion::max_latency_ms(8)],
271            failed: vec![],
272            verification_time: Duration::from_micros(25),
273        }
274    }
275
276    fn to_html(&self) -> String {
277        String::new()
278    }
279
280    fn to_css(&self) -> String {
281        String::new()
282    }
283}
284
285impl Widget for FilesPanel {
286    fn type_id(&self) -> TypeId {
287        TypeId::of::<Self>()
288    }
289
290    fn measure(&self, constraints: Constraints) -> Size {
291        let visible = self.entries.len().min(self.max_entries);
292        let height = (visible as f32).max(1.0).min(constraints.max_height);
293        Size::new(constraints.max_width, height)
294    }
295
296    fn layout(&mut self, bounds: Rect) -> LayoutResult {
297        self.bounds = bounds;
298        LayoutResult {
299            size: Size::new(bounds.width, bounds.height),
300        }
301    }
302
303    fn paint(&self, canvas: &mut dyn Canvas) {
304        if self.bounds.width < 10.0 || self.bounds.height < 1.0 {
305            return;
306        }
307
308        let mut y = self.bounds.y;
309        let x = self.bounds.x;
310
311        // Draw sorted entries
312        for entry in self.sorted_entries().iter().take(self.max_entries) {
313            if y >= self.bounds.y + self.bounds.height {
314                break;
315            }
316            self.draw_entry(canvas, entry, x, y, self.bounds.width);
317            y += 1.0;
318        }
319
320        // If no entries, show message
321        if self.entries.is_empty() {
322            canvas.draw_text(
323                "No data",
324                Point::new(x, self.bounds.y),
325                &TextStyle {
326                    color: Color::new(0.5, 0.5, 0.5, 1.0),
327                    ..Default::default()
328                },
329            );
330        }
331    }
332
333    fn event(&mut self, _event: &Event) -> Option<Box<dyn Any + Send>> {
334        None
335    }
336
337    fn children(&self) -> &[Box<dyn Widget>] {
338        &[]
339    }
340
341    fn children_mut(&mut self) -> &mut [Box<dyn Widget>] {
342        &mut []
343    }
344}
345
346#[cfg(test)]
347mod tests {
348    use super::*;
349
350    #[test]
351    fn test_file_entry_size_display() {
352        assert_eq!(FileEntry::file("test", 500).size_display(), "500B");
353        assert_eq!(FileEntry::file("test", 1024).size_display(), "1K");
354        assert_eq!(FileEntry::file("test", 1024 * 1024).size_display(), "1M");
355        assert_eq!(
356            FileEntry::file("test", 1024 * 1024 * 1024).size_display(),
357            "1.0G"
358        );
359        assert_eq!(
360            FileEntry::file("test", 1024u64 * 1024 * 1024 * 1024).size_display(),
361            "1.0T"
362        );
363    }
364
365    #[test]
366    fn test_file_entry_percent() {
367        let entry = FileEntry::file("test", 500);
368        assert!((entry.percent_of(1000) - 50.0).abs() < 0.1);
369        assert!((entry.percent_of(0) - 0.0).abs() < 0.1);
370    }
371
372    #[test]
373    fn test_directory_colors() {
374        let home = FileEntry::directory("home", 1000);
375        let var = FileEntry::directory("var", 1000);
376        // Just verify they get different colors
377        assert_ne!(format!("{:?}", home.color), format!("{:?}", var.color));
378    }
379
380    #[test]
381    fn test_panel_total_size() {
382        let mut panel = FilesPanel::new();
383        panel.add_entry(FileEntry::directory("home", 1000));
384        panel.add_entry(FileEntry::directory("var", 500));
385        assert_eq!(panel.total_size, 1500);
386    }
387
388    #[test]
389    fn test_panel_with_entries() {
390        let entries = vec![
391            FileEntry::directory("home", 1000),
392            FileEntry::directory("var", 500),
393        ];
394        let panel = FilesPanel::new().with_entries(entries);
395        assert_eq!(panel.total_size, 1500);
396        assert_eq!(panel.entries.len(), 2);
397    }
398
399    #[test]
400    fn test_sorted_entries() {
401        let mut panel = FilesPanel::new();
402        panel.add_entry(FileEntry::directory("small", 100));
403        panel.add_entry(FileEntry::directory("large", 1000));
404        panel.add_entry(FileEntry::directory("medium", 500));
405
406        let sorted = panel.sorted_entries();
407        assert_eq!(sorted[0].name, "large");
408        assert_eq!(sorted[1].name, "medium");
409        assert_eq!(sorted[2].name, "small");
410    }
411
412    #[test]
413    fn test_file_entry_with_color() {
414        let entry = FileEntry::file("test", 100).with_color(Color::RED);
415        assert_eq!(format!("{:?}", entry.color), format!("{:?}", Color::RED));
416    }
417
418    #[test]
419    fn test_file_entry_color_for_all_names() {
420        // Test all color categories
421        let names = vec![
422            "home", "users", "var", "log", "usr", "bin", "lib", "tmp", "cache", "etc", "config",
423            "opt", "local", "random",
424        ];
425        for name in names {
426            let entry = FileEntry::directory(name, 100);
427            assert!(entry.color.r >= 0.0 && entry.color.r <= 1.0);
428        }
429    }
430
431    #[test]
432    fn test_panel_show_sizes_builder() {
433        let panel = FilesPanel::new().show_sizes(false);
434        assert!(!panel.show_sizes);
435    }
436
437    #[test]
438    fn test_panel_show_bars_builder() {
439        let panel = FilesPanel::new().show_bars(false);
440        assert!(!panel.show_bars);
441    }
442
443    #[test]
444    fn test_panel_max_entries_builder() {
445        let panel = FilesPanel::new().max_entries(5);
446        assert_eq!(panel.max_entries, 5);
447    }
448
449    #[test]
450    fn test_panel_with_total_size() {
451        let panel = FilesPanel::new().with_total_size(10000);
452        assert_eq!(panel.total_size, 10000);
453    }
454
455    #[test]
456    fn test_files_panel_brick_traits() {
457        let panel = FilesPanel::new();
458        assert_eq!(panel.brick_name(), "files_panel");
459        assert!(!panel.assertions().is_empty());
460        assert!(panel.budget().paint_ms > 0);
461        assert!(panel.verify().is_valid());
462        assert!(panel.to_html().is_empty());
463        assert!(panel.to_css().is_empty());
464    }
465
466    #[test]
467    fn test_files_panel_widget_traits() {
468        let mut panel = FilesPanel::new().with_entries(vec![
469            FileEntry::directory("home", 1000),
470            FileEntry::directory("var", 500),
471        ]);
472
473        // Measure
474        let size = panel.measure(Constraints {
475            min_width: 0.0,
476            min_height: 0.0,
477            max_width: 80.0,
478            max_height: 20.0,
479        });
480        assert!(size.width > 0.0);
481        assert!(size.height > 0.0);
482
483        // Layout
484        let result = panel.layout(Rect::new(0.0, 0.0, 80.0, 10.0));
485        assert_eq!(result.size.width, 80.0);
486
487        // Type ID
488        assert_eq!(Widget::type_id(&panel), TypeId::of::<FilesPanel>());
489
490        // Event
491        assert!(panel
492            .event(&Event::key_down(presentar_core::Key::Enter))
493            .is_none());
494
495        // Children
496        assert!(panel.children().is_empty());
497        assert!(panel.children_mut().is_empty());
498    }
499
500    #[test]
501    fn test_files_panel_paint_with_bars() {
502        use crate::direct::{CellBuffer, DirectTerminalCanvas};
503
504        let mut panel = FilesPanel::new()
505            .with_entries(vec![
506                FileEntry::directory("home", 1000),
507                FileEntry::directory("var", 500),
508                FileEntry::directory("verylongdirectoryname", 300),
509            ])
510            .show_bars(true);
511
512        panel.layout(Rect::new(0.0, 0.0, 60.0, 10.0));
513
514        let mut buffer = CellBuffer::new(60, 10);
515        let mut canvas = DirectTerminalCanvas::new(&mut buffer);
516        panel.paint(&mut canvas);
517        // Verify it doesn't panic
518    }
519
520    #[test]
521    fn test_files_panel_paint_without_bars() {
522        use crate::direct::{CellBuffer, DirectTerminalCanvas};
523
524        let mut panel = FilesPanel::new()
525            .with_entries(vec![
526                FileEntry::directory("home", 1000),
527                FileEntry::directory("verylongdirectoryname", 500),
528            ])
529            .show_bars(false)
530            .show_sizes(true);
531
532        panel.layout(Rect::new(0.0, 0.0, 60.0, 10.0));
533
534        let mut buffer = CellBuffer::new(60, 10);
535        let mut canvas = DirectTerminalCanvas::new(&mut buffer);
536        panel.paint(&mut canvas);
537    }
538
539    #[test]
540    fn test_files_panel_paint_empty() {
541        use crate::direct::{CellBuffer, DirectTerminalCanvas};
542
543        let mut panel = FilesPanel::new();
544        panel.layout(Rect::new(0.0, 0.0, 60.0, 10.0));
545
546        let mut buffer = CellBuffer::new(60, 10);
547        let mut canvas = DirectTerminalCanvas::new(&mut buffer);
548        panel.paint(&mut canvas);
549    }
550
551    #[test]
552    fn test_files_panel_paint_small_bounds() {
553        use crate::direct::{CellBuffer, DirectTerminalCanvas};
554
555        let mut panel = FilesPanel::new().with_entries(vec![FileEntry::directory("home", 1000)]);
556
557        panel.layout(Rect::new(0.0, 0.0, 5.0, 0.5));
558
559        let mut buffer = CellBuffer::new(5, 1);
560        let mut canvas = DirectTerminalCanvas::new(&mut buffer);
561        panel.paint(&mut canvas); // Should early return
562    }
563
564    #[test]
565    fn test_files_panel_measure_empty() {
566        let panel = FilesPanel::new();
567        let size = panel.measure(Constraints {
568            min_width: 0.0,
569            min_height: 0.0,
570            max_width: 80.0,
571            max_height: 20.0,
572        });
573        assert!(size.height >= 1.0);
574    }
575
576    #[test]
577    fn test_file_entry_directory_constructor() {
578        let entry = FileEntry::directory("test", 100);
579        assert!(entry.is_dir);
580    }
581
582    #[test]
583    fn test_file_entry_file_constructor() {
584        let entry = FileEntry::file("test", 100);
585        assert!(!entry.is_dir);
586    }
587
588    #[test]
589    fn test_files_panel_default() {
590        let panel = FilesPanel::default();
591        assert!(panel.entries.is_empty());
592        assert!(panel.show_sizes);
593        assert!(panel.show_bars);
594        assert_eq!(panel.max_entries, 8);
595    }
596
597    #[test]
598    fn test_files_panel_paint_without_sizes() {
599        use crate::direct::{CellBuffer, DirectTerminalCanvas};
600
601        let mut panel = FilesPanel::new()
602            .with_entries(vec![FileEntry::directory("home", 1000)])
603            .show_bars(false)
604            .show_sizes(false);
605
606        panel.layout(Rect::new(0.0, 0.0, 60.0, 10.0));
607
608        let mut buffer = CellBuffer::new(60, 10);
609        let mut canvas = DirectTerminalCanvas::new(&mut buffer);
610        panel.paint(&mut canvas);
611    }
612
613    #[test]
614    fn test_files_panel_exceeds_max_entries() {
615        use crate::direct::{CellBuffer, DirectTerminalCanvas};
616
617        let entries: Vec<FileEntry> = (0..20)
618            .map(|i| FileEntry::directory(format!("dir{}", i), (20 - i) as u64 * 100))
619            .collect();
620
621        let mut panel = FilesPanel::new().with_entries(entries).max_entries(5);
622
623        panel.layout(Rect::new(0.0, 0.0, 60.0, 10.0));
624
625        let mut buffer = CellBuffer::new(60, 10);
626        let mut canvas = DirectTerminalCanvas::new(&mut buffer);
627        panel.paint(&mut canvas);
628    }
629}