1use 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#[derive(Debug, Clone)]
15pub struct FileEntry {
16 pub name: String,
18 pub size: u64,
20 pub is_dir: bool,
22 pub color: Color,
24}
25
26impl FileEntry {
27 #[must_use]
29 pub fn new(name: impl Into<String>, size: u64, is_dir: bool) -> Self {
30 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 #[must_use]
43 pub fn directory(name: impl Into<String>, size: u64) -> Self {
44 Self::new(name, size, true)
45 }
46
47 #[must_use]
49 pub fn file(name: impl Into<String>, size: u64) -> Self {
50 Self::new(name, size, false)
51 }
52
53 #[must_use]
55 pub fn with_color(mut self, color: Color) -> Self {
56 self.color = color;
57 self
58 }
59
60 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), "var" | "log" => Color::new(0.9, 0.6, 0.3, 1.0), "usr" | "bin" | "lib" => Color::new(0.5, 0.8, 0.5, 1.0), "tmp" | "cache" => Color::new(0.7, 0.7, 0.7, 1.0), "etc" | "config" => Color::new(0.8, 0.5, 0.8, 1.0), "opt" | "local" => Color::new(0.6, 0.8, 0.6, 1.0), _ => Color::new(0.6, 0.6, 0.8, 1.0), }
71 }
72
73 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 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#[derive(Debug, Clone)]
105pub struct FilesPanel {
106 entries: Vec<FileEntry>,
108 total_size: u64,
110 show_sizes: bool,
112 show_bars: bool,
114 max_entries: usize,
116 bounds: Rect,
118}
119
120impl Default for FilesPanel {
121 fn default() -> Self {
122 Self::new()
123 }
124}
125
126impl FilesPanel {
127 #[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 pub fn add_entry(&mut self, entry: FileEntry) {
142 self.total_size += entry.size;
143 self.entries.push(entry);
144 }
145
146 #[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 #[must_use]
156 pub fn with_total_size(mut self, total: u64) -> Self {
157 self.total_size = total;
158 self
159 }
160
161 #[must_use]
163 pub fn show_sizes(mut self, show: bool) -> Self {
164 self.show_sizes = show;
165 self
166 }
167
168 #[must_use]
170 pub fn show_bars(mut self, show: bool) -> Self {
171 self.show_bars = show;
172 self
173 }
174
175 #[must_use]
177 pub fn max_entries(mut self, max: usize) -> Self {
178 self.max_entries = max;
179 self
180 }
181
182 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 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 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 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 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 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 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 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 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 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 let result = panel.layout(Rect::new(0.0, 0.0, 80.0, 10.0));
485 assert_eq!(result.size.width, 80.0);
486
487 assert_eq!(Widget::type_id(&panel), TypeId::of::<FilesPanel>());
489
490 assert!(panel
492 .event(&Event::key_down(presentar_core::Key::Enter))
493 .is_none());
494
495 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 }
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); }
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}