Skip to main content

null_e/tui/
ui.rs

1//! TUI rendering with Ratatui
2//!
3//! This module handles all the UI rendering for the TUI.
4
5use super::app::{format_size, App, AppState, ScanMode};
6use ratatui::{
7    layout::{Alignment, Constraint, Direction, Layout, Margin, Rect},
8    style::{Color, Modifier, Style},
9    text::{Line, Span},
10    widgets::{
11        Block, Borders, Clear, Gauge, List, ListItem, Paragraph, Scrollbar, ScrollbarOrientation,
12        ScrollbarState, Tabs,
13    },
14    Frame,
15};
16
17/// Robot banner for the header
18const ROBOT_SMALL: &str = "🤖";
19
20/// Main UI rendering function
21pub fn render(app: &mut App, frame: &mut Frame) {
22    let size = frame.area();
23
24    // Create main layout - hide tabs on ready screen
25    let show_tabs = !matches!(app.state, AppState::Ready);
26
27    let chunks = if show_tabs {
28        Layout::default()
29            .direction(Direction::Vertical)
30            .constraints([
31                Constraint::Length(3), // Header
32                Constraint::Length(3), // Tabs
33                Constraint::Min(10),   // Content
34                Constraint::Length(3), // Status bar
35            ])
36            .split(size)
37    } else {
38        Layout::default()
39            .direction(Direction::Vertical)
40            .constraints([
41                Constraint::Length(3), // Header
42                Constraint::Min(10),   // Content (no tabs)
43                Constraint::Length(3), // Status bar
44            ])
45            .split(size)
46    };
47
48    // Render header
49    render_header(app, frame, chunks[0]);
50
51    // Render main content based on state
52    if show_tabs {
53        // Render tabs for results screens
54        render_tabs(app, frame, chunks[1]);
55
56        match &app.state {
57            AppState::Scanning => render_scanning_screen(app, frame, chunks[2]),
58            AppState::Results => render_results(app, frame, chunks[2]),
59            AppState::CacheResults => render_cache_results(app, frame, chunks[2]),
60            AppState::CleanerResults => render_cleaner_results(app, frame, chunks[2]),
61            AppState::Confirming => {
62                // Show appropriate results behind dialog
63                if !app.projects.is_empty() {
64                    render_results(app, frame, chunks[2]);
65                } else if !app.caches.is_empty() {
66                    render_cache_results(app, frame, chunks[2]);
67                } else {
68                    render_cleaner_results(app, frame, chunks[2]);
69                }
70                render_confirm_dialog(app, frame, size);
71            }
72            AppState::Cleaning => render_cleaning_screen(app, frame, chunks[2]),
73            AppState::Error(msg) => render_error_screen(msg, frame, chunks[2]),
74            _ => {}
75        }
76
77        // Render status bar
78        render_status_bar(app, frame, chunks[3]);
79    } else {
80        // Ready screen - no tabs
81        render_ready_screen(app, frame, chunks[1]);
82        render_status_bar(app, frame, chunks[2]);
83    }
84
85    // Render help popup if active
86    if app.show_help {
87        render_help_popup(frame, size);
88    }
89}
90
91/// Render the header
92fn render_header(app: &App, frame: &mut Frame, area: Rect) {
93    let title = format!(
94        " {} null-e v{} ",
95        ROBOT_SMALL,
96        env!("CARGO_PKG_VERSION")
97    );
98
99    let total_info = if app.total_size > 0 {
100        format!("Total: {} ", format_size(app.total_size))
101    } else {
102        String::new()
103    };
104
105    let header = Paragraph::new(Line::from(vec![
106        Span::styled(title, Style::default().fg(Color::Green).add_modifier(Modifier::BOLD)),
107        Span::raw(" - The friendly disk cleanup robot"),
108        Span::raw(" ".repeat(area.width.saturating_sub(60) as usize)),
109        Span::styled(total_info, Style::default().fg(Color::Yellow)),
110    ]))
111    .block(
112        Block::default()
113            .borders(Borders::ALL)
114            .border_style(Style::default().fg(Color::Green)),
115    );
116
117    frame.render_widget(header, area);
118}
119
120/// Render the tabs
121fn render_tabs(app: &App, frame: &mut Frame, area: Rect) {
122    let titles: Vec<Line> = app
123        .tabs
124        .iter()
125        .enumerate()
126        .map(|(i, t)| {
127            let style = if i == app.current_tab {
128                Style::default()
129                    .fg(Color::Yellow)
130                    .add_modifier(Modifier::BOLD)
131            } else {
132                Style::default().fg(Color::Gray)
133            };
134            Line::from(Span::styled(format!(" {} ", t), style))
135        })
136        .collect();
137
138    let tabs = Tabs::new(titles)
139        .block(
140            Block::default()
141                .borders(Borders::ALL)
142                .title(" Categories (Tab/Shift+Tab) "),
143        )
144        .select(app.current_tab)
145        .style(Style::default().fg(Color::White))
146        .highlight_style(Style::default().fg(Color::Yellow));
147
148    frame.render_widget(tabs, area);
149}
150
151/// Render the ready/welcome screen with scan mode menu
152fn render_ready_screen(app: &App, frame: &mut Frame, area: Rect) {
153    // Split into robot area and menu area
154    let chunks = Layout::default()
155        .direction(Direction::Horizontal)
156        .constraints([
157            Constraint::Percentage(40), // Robot
158            Constraint::Percentage(60), // Menu
159        ])
160        .split(area);
161
162    // Robot ASCII art - each line separate
163    let robot_lines = vec![
164        Line::from(""),
165        Line::from(Span::styled("       .---.      ", Style::default().fg(Color::Green))),
166        Line::from(Span::styled("      |o   o|     ", Style::default().fg(Color::Green))),
167        Line::from(Span::styled("      |  ^  |     ", Style::default().fg(Color::Green))),
168        Line::from(Span::styled("      | === |     ", Style::default().fg(Color::Green))),
169        Line::from(Span::styled("      `-----'     ", Style::default().fg(Color::Green))),
170        Line::from(Span::styled("       /| |\\      ", Style::default().fg(Color::Green))),
171        Line::from(""),
172        Line::from(Span::styled(
173            "  Welcome to null-e!",
174            Style::default().fg(Color::Green).add_modifier(Modifier::BOLD),
175        )),
176        Line::from(""),
177        Line::from(Span::styled(
178            "  Send your dev cruft",
179            Style::default().fg(Color::Gray),
180        )),
181        Line::from(Span::styled(
182            "    to /dev/null",
183            Style::default().fg(Color::Gray),
184        )),
185    ];
186
187    let robot = Paragraph::new(robot_lines)
188        .block(Block::default().borders(Borders::ALL).title(" 🤖 "))
189        .alignment(Alignment::Center);
190
191    frame.render_widget(robot, chunks[0]);
192
193    // Menu items
194    let modes = ScanMode::all_modes();
195    let menu_items: Vec<ListItem> = modes
196        .iter()
197        .enumerate()
198        .map(|(i, mode)| {
199            let is_selected = i == app.menu_index;
200
201            let marker = if is_selected { "▸ " } else { "  " };
202
203            let style = if is_selected {
204                Style::default()
205                    .fg(Color::Yellow)
206                    .add_modifier(Modifier::BOLD)
207            } else {
208                Style::default().fg(Color::White)
209            };
210
211            let desc_style = if is_selected {
212                Style::default().fg(Color::Gray)
213            } else {
214                Style::default().fg(Color::DarkGray)
215            };
216
217            let lines = vec![
218                Line::from(vec![
219                    Span::styled(marker, style),
220                    Span::styled(mode.icon(), Style::default()),
221                    Span::raw(" "),
222                    Span::styled(mode.name(), style),
223                ]),
224                Line::from(vec![
225                    Span::raw("     "),
226                    Span::styled(mode.description(), desc_style),
227                ]),
228            ];
229
230            ListItem::new(lines)
231        })
232        .collect();
233
234    let menu = List::new(menu_items)
235        .block(
236            Block::default()
237                .borders(Borders::ALL)
238                .title(" Select Scan Mode (j/k + Enter) ")
239                .border_style(Style::default().fg(Color::Cyan)),
240        )
241        .highlight_style(Style::default().bg(Color::DarkGray));
242
243    frame.render_widget(menu, chunks[1]);
244}
245
246/// Render the scanning screen
247fn render_scanning_screen(app: &App, frame: &mut Frame, area: Rect) {
248    let spinner_frames = ["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"];
249    let spinner_idx = app.anim_frame % spinner_frames.len();
250
251    // Progress bar animation
252    let bar_width = 30;
253    let progress_pos = (app.anim_frame / 2) % (bar_width * 2);
254    let progress_bar: String = (0..bar_width)
255        .map(|i| {
256            let pos = if progress_pos < bar_width {
257                progress_pos
258            } else {
259                bar_width * 2 - progress_pos - 1
260            };
261            if i == pos || i == pos.saturating_sub(1) || i == pos + 1 {
262                '█'
263            } else {
264                '░'
265            }
266        })
267        .collect();
268
269    let text = vec![
270        Line::from(""),
271        Line::from(""),
272        Line::from(Span::styled("       .---.      ", Style::default().fg(Color::Green))),
273        Line::from(Span::styled("      |o   o|     ", Style::default().fg(Color::Green))),
274        Line::from(Span::styled("      |  ^  |     ", Style::default().fg(Color::Green))),
275        Line::from(Span::styled("      | === |     ", Style::default().fg(Color::Green))),
276        Line::from(Span::styled("      `-----'     ", Style::default().fg(Color::Green))),
277        Line::from(Span::styled("       /| |\\      ", Style::default().fg(Color::Green))),
278        Line::from(""),
279        Line::from(Span::styled(
280            format!("  {} {}  ", spinner_frames[spinner_idx], app.scan_message),
281            Style::default().fg(Color::Yellow).add_modifier(Modifier::BOLD),
282        )),
283        Line::from(""),
284        Line::from(Span::styled(
285            format!("  [{}]  ", progress_bar),
286            Style::default().fg(Color::Cyan),
287        )),
288        Line::from(""),
289        Line::from(Span::styled(
290            format!("  Scanning: {}  ", app.scan_mode.name()),
291            Style::default().fg(Color::Gray),
292        )),
293        Line::from(""),
294        Line::from(Span::styled(
295            "Press 'q' to cancel",
296            Style::default().fg(Color::DarkGray),
297        )),
298    ];
299
300    let paragraph = Paragraph::new(text)
301        .block(Block::default().borders(Borders::ALL).title(" 🔍 Scanning "))
302        .alignment(Alignment::Center);
303
304    frame.render_widget(paragraph, area);
305}
306
307/// Render the project results list
308fn render_results(app: &mut App, frame: &mut Frame, area: Rect) {
309    // Calculate viewport height for scrolling
310    let viewport_height = area.height.saturating_sub(2) as usize;
311    app.ensure_visible_with_height(viewport_height);
312
313    let visible_projects = app.visible_projects();
314
315    if visible_projects.is_empty() {
316        let text = if app.projects.is_empty() {
317            "No projects found. Press 'b' to go back."
318        } else {
319            "No projects match the current filter."
320        };
321
322        let paragraph = Paragraph::new(text)
323            .block(Block::default().borders(Borders::ALL).title(" Projects "))
324            .alignment(Alignment::Center);
325
326        frame.render_widget(paragraph, area);
327        return;
328    }
329
330    // Build list items
331    let items: Vec<ListItem> = visible_projects
332        .iter()
333        .enumerate()
334        .skip(app.scroll_offset)
335        .take(viewport_height)
336        .map(|(i, entry)| {
337            let is_selected = i == app.selected;
338            let is_expanded = app.is_expanded(i);
339
340            // Checkbox
341            let checkbox = if entry.selected { "☑" } else { "☐" };
342
343            // Expand indicator
344            let expand = if is_expanded { "▾" } else { "▸" };
345
346            // Project icon based on kind
347            let icon = entry.project.kind.icon();
348
349            // Project name and size
350            let name = &entry.project.name;
351            let size = format_size(entry.project.cleanable_size);
352
353            // Age info
354            let age = format_age(&entry.project);
355
356            // Build the line
357            let line_style = if is_selected {
358                Style::default()
359                    .bg(Color::DarkGray)
360                    .add_modifier(Modifier::BOLD)
361            } else {
362                Style::default()
363            };
364
365            let checkbox_style = if entry.selected {
366                Style::default().fg(Color::Green)
367            } else {
368                Style::default().fg(Color::Gray)
369            };
370
371            let spans = vec![
372                Span::styled(format!(" {} ", checkbox), checkbox_style),
373                Span::styled(format!("{} ", expand), Style::default().fg(Color::Gray)),
374                Span::styled(format!("{} ", icon), Style::default()),
375                Span::styled(
376                    format!("{:<30}", truncate(name, 30)),
377                    Style::default().fg(Color::White),
378                ),
379                Span::styled(
380                    format!("{:>10}", size),
381                    Style::default().fg(Color::Yellow),
382                ),
383                Span::styled(format!("  {}", age), Style::default().fg(Color::Gray)),
384            ];
385
386            let mut lines = vec![Line::from(spans).style(line_style)];
387
388            // Add expanded details
389            if is_expanded {
390                // Show path
391                lines.push(Line::from(vec![
392                    Span::raw("      "),
393                    Span::styled("Path: ", Style::default().fg(Color::Gray)),
394                    Span::styled(
395                        entry.project.root.to_string_lossy().to_string(),
396                        Style::default().fg(Color::Blue),
397                    ),
398                ]));
399
400                // Show artifacts
401                for artifact in &entry.project.artifacts {
402                    lines.push(Line::from(vec![
403                        Span::raw("      └── "),
404                        Span::styled(
405                            artifact.name().to_string(),
406                            Style::default().fg(Color::Cyan),
407                        ),
408                        Span::raw(" "),
409                        Span::styled(
410                            format_size(artifact.size),
411                            Style::default().fg(Color::Gray),
412                        ),
413                    ]));
414                }
415            }
416
417            ListItem::new(lines)
418        })
419        .collect();
420
421    let list = List::new(items)
422        .block(
423            Block::default()
424                .borders(Borders::ALL)
425                .title(format!(
426                    " Projects ({}/{}) Esc=back ",
427                    app.visible_count(),
428                    app.projects.len()
429                )),
430        )
431        .highlight_style(Style::default().bg(Color::DarkGray));
432
433    frame.render_widget(list, area);
434
435    // Render scrollbar if needed
436    if visible_projects.len() > viewport_height {
437        let scrollbar = Scrollbar::new(ScrollbarOrientation::VerticalRight)
438            .begin_symbol(Some("↑"))
439            .end_symbol(Some("↓"));
440
441        let mut scrollbar_state = ScrollbarState::new(visible_projects.len())
442            .position(app.scroll_offset);
443
444        frame.render_stateful_widget(
445            scrollbar,
446            area.inner(Margin {
447                vertical: 1,
448                horizontal: 0,
449            }),
450            &mut scrollbar_state,
451        );
452    }
453}
454
455/// Render cache results
456fn render_cache_results(app: &mut App, frame: &mut Frame, area: Rect) {
457    let viewport_height = area.height.saturating_sub(2) as usize;
458    app.ensure_visible_with_height(viewport_height);
459
460    if app.caches.is_empty() {
461        let paragraph = Paragraph::new("No caches found. Press 'b' to go back.")
462            .block(Block::default().borders(Borders::ALL).title(" Global Caches "))
463            .alignment(Alignment::Center);
464        frame.render_widget(paragraph, area);
465        return;
466    }
467
468    let items: Vec<ListItem> = app.caches
469        .iter()
470        .enumerate()
471        .skip(app.scroll_offset)
472        .take(viewport_height)
473        .map(|(i, cache)| {
474            let is_selected = i == app.selected;
475            let checkbox = if cache.selected { "☑" } else { "☐" };
476
477            let line_style = if is_selected {
478                Style::default().bg(Color::DarkGray).add_modifier(Modifier::BOLD)
479            } else {
480                Style::default()
481            };
482
483            let checkbox_style = if cache.selected {
484                Style::default().fg(Color::Green)
485            } else {
486                Style::default().fg(Color::Gray)
487            };
488
489            let lines = vec![
490                Line::from(vec![
491                    Span::styled(format!(" {} ", checkbox), checkbox_style),
492                    Span::styled(&cache.icon, Style::default()),
493                    Span::raw(" "),
494                    Span::styled(
495                        format!("{:<25}", truncate(&cache.name, 25)),
496                        Style::default().fg(Color::White),
497                    ),
498                    Span::styled(
499                        format!("{:>12}", format_size(cache.size)),
500                        Style::default().fg(Color::Yellow),
501                    ),
502                ]).style(line_style),
503                Line::from(vec![
504                    Span::raw("      "),
505                    Span::styled(&cache.description, Style::default().fg(Color::DarkGray)),
506                ]),
507            ];
508
509            ListItem::new(lines)
510        })
511        .collect();
512
513    let list = List::new(items)
514        .block(
515            Block::default()
516                .borders(Borders::ALL)
517                .title(format!(" Global Caches ({}) Esc=back ", app.caches.len())),
518        );
519
520    frame.render_widget(list, area);
521}
522
523/// Render cleaner results (Xcode, Docker, IDE, ML)
524fn render_cleaner_results(app: &mut App, frame: &mut Frame, area: Rect) {
525    let viewport_height = area.height.saturating_sub(2) as usize;
526    app.ensure_visible_with_height(viewport_height);
527
528    if app.cleaners.is_empty() {
529        let paragraph = Paragraph::new("No items found. Press 'b' to go back.")
530            .block(Block::default().borders(Borders::ALL).title(format!(" {} ", app.scan_mode.name())))
531            .alignment(Alignment::Center);
532        frame.render_widget(paragraph, area);
533        return;
534    }
535
536    let items: Vec<ListItem> = app.cleaners
537        .iter()
538        .enumerate()
539        .skip(app.scroll_offset)
540        .take(viewport_height)
541        .map(|(i, item)| {
542            let is_selected = i == app.selected;
543            let checkbox = if item.selected { "☑" } else { "☐" };
544
545            let line_style = if is_selected {
546                Style::default().bg(Color::DarkGray).add_modifier(Modifier::BOLD)
547            } else {
548                Style::default()
549            };
550
551            let checkbox_style = if item.selected {
552                Style::default().fg(Color::Green)
553            } else {
554                Style::default().fg(Color::Gray)
555            };
556
557            let lines = vec![
558                Line::from(vec![
559                    Span::styled(format!(" {} ", checkbox), checkbox_style),
560                    Span::styled(&item.icon, Style::default()),
561                    Span::raw(" "),
562                    Span::styled(
563                        format!("{:<30}", truncate(&item.name, 30)),
564                        Style::default().fg(Color::White),
565                    ),
566                    Span::styled(
567                        format!("{:>12}", format_size(item.size)),
568                        Style::default().fg(Color::Yellow),
569                    ),
570                ]).style(line_style),
571                Line::from(vec![
572                    Span::raw("      "),
573                    Span::styled(&item.category, Style::default().fg(Color::Cyan)),
574                    Span::raw(" - "),
575                    Span::styled(
576                        truncate(&item.path.to_string_lossy(), 50),
577                        Style::default().fg(Color::DarkGray),
578                    ),
579                ]),
580            ];
581
582            ListItem::new(lines)
583        })
584        .collect();
585
586    let list = List::new(items)
587        .block(
588            Block::default()
589                .borders(Borders::ALL)
590                .title(format!(" {} ({}) Esc=back ", app.scan_mode.name(), app.cleaners.len())),
591        );
592
593    frame.render_widget(list, area);
594}
595
596/// Render error screen
597fn render_error_screen(message: &str, frame: &mut Frame, area: Rect) {
598    let text = vec![
599        Line::from(""),
600        Line::from(Span::styled(
601            "⚠ Error",
602            Style::default()
603                .fg(Color::Red)
604                .add_modifier(Modifier::BOLD),
605        )),
606        Line::from(""),
607        Line::from(message),
608        Line::from(""),
609        Line::from(Span::styled(
610            "Press 'b' to go back or 'q' to quit",
611            Style::default().fg(Color::Gray),
612        )),
613    ];
614
615    let paragraph = Paragraph::new(text)
616        .block(Block::default().borders(Borders::ALL).title(" Error "))
617        .alignment(Alignment::Center);
618
619    frame.render_widget(paragraph, area);
620}
621
622/// Render cleaning screen with progress bar
623fn render_cleaning_screen(app: &App, frame: &mut Frame, area: Rect) {
624    let (icon, title, message, color) = if app.permanent_delete {
625        (
626            "🔥",
627            " PERMANENTLY DELETING ",
628            "Removing files permanently (cannot be recovered)...",
629            Color::Red,
630        )
631    } else {
632        (
633            "🗑️",
634            " Cleaning ",
635            "Moving items to trash...",
636            Color::Yellow,
637        )
638    };
639
640    // Create layout with space for progress bar
641    let chunks = Layout::default()
642        .direction(Direction::Vertical)
643        .margin(2)
644        .constraints([
645            Constraint::Length(3),  // Title
646            Constraint::Length(2),  // Spacer
647            Constraint::Length(3),  // Progress bar
648            Constraint::Length(2),  // Spacer
649            Constraint::Length(2),  // Item count
650            Constraint::Min(0),     // Rest
651        ])
652        .split(area);
653
654    // Title text
655    let title_text = Paragraph::new(Line::from(vec![
656        Span::styled(
657            format!("{} ", icon),
658            Style::default().fg(color),
659        ),
660        Span::styled(
661            "Cleaning...",
662            Style::default().fg(color).add_modifier(Modifier::BOLD),
663        ),
664    ]))
665    .alignment(Alignment::Center);
666    frame.render_widget(title_text, chunks[0]);
667
668    // Animated progress bar (pulses since we don't track individual file progress)
669    let progress = ((app.anim_frame % 20) as f64 / 20.0 * 0.3) + 0.7; // Pulse between 70-100%
670    let gauge = Gauge::default()
671        .block(Block::default().borders(Borders::ALL).border_style(Style::default().fg(color)))
672        .gauge_style(Style::default().fg(color).add_modifier(Modifier::BOLD))
673        .percent((progress * 100.0) as u16)
674        .label(Span::styled(
675            message,
676            Style::default().fg(Color::White).add_modifier(Modifier::BOLD),
677        ));
678    frame.render_widget(gauge, chunks[2]);
679
680    // Item count
681    let item_text = Paragraph::new(Line::from(Span::styled(
682        format!("Processing {} items...", app.pending_delete_items.len()),
683        Style::default().fg(Color::Cyan),
684    )))
685    .alignment(Alignment::Center);
686    frame.render_widget(item_text, chunks[4]);
687
688    // Border around the whole area
689    let block = Block::default()
690        .borders(Borders::ALL)
691        .title(title)
692        .border_style(Style::default().fg(color));
693    frame.render_widget(block, area);
694}
695
696/// Render status bar
697fn render_status_bar(app: &App, frame: &mut Frame, area: Rect) {
698    let selected_info = if app.selected_count() > 0 {
699        format!(
700            "Selected: {} ({}) | ",
701            app.selected_count(),
702            format_size(app.selected_size())
703        )
704    } else {
705        String::new()
706    };
707
708    let search_info = if app.is_searching {
709        format!("Search: {} | ", app.search_query)
710    } else if !app.search_query.is_empty() {
711        format!("Filter: {} | ", app.search_query)
712    } else {
713        String::new()
714    };
715
716    let status = app
717        .status_message
718        .clone()
719        .unwrap_or_else(|| "Ready".to_string());
720
721    let help_hint = " [?] Help  [q] Quit ";
722
723    let status_bar = Paragraph::new(Line::from(vec![
724        Span::styled(&selected_info, Style::default().fg(Color::Green)),
725        Span::styled(&search_info, Style::default().fg(Color::Cyan)),
726        Span::styled(&status, Style::default().fg(Color::White)),
727        Span::raw(" ".repeat(
728            area.width
729                .saturating_sub((selected_info.len() + search_info.len() + status.len() + help_hint.len()) as u16)
730                as usize,
731        )),
732        Span::styled(help_hint, Style::default().fg(Color::Gray)),
733    ]))
734    .block(
735        Block::default()
736            .borders(Borders::ALL)
737            .border_style(Style::default().fg(Color::DarkGray)),
738    );
739
740    frame.render_widget(status_bar, area);
741}
742
743/// Render confirmation dialog
744fn render_confirm_dialog(app: &App, frame: &mut Frame, area: Rect) {
745    let dialog_width = 55;
746    let dialog_height = 11;
747
748    let dialog_area = centered_rect(dialog_width, dialog_height, area);
749
750    // Clear the area behind the dialog
751    frame.render_widget(Clear, dialog_area);
752
753    // Show delete mode
754    let mode_text = if app.permanent_delete {
755        Span::styled("🔥 PERMANENT (rm -rf)", Style::default().fg(Color::Red).add_modifier(Modifier::BOLD))
756    } else {
757        Span::styled("🗑️  Move to Trash", Style::default().fg(Color::Green))
758    };
759
760    let text = vec![
761        Line::from(""),
762        Line::from(Span::styled(
763            "⚠ Confirm Deletion",
764            Style::default()
765                .fg(Color::Yellow)
766                .add_modifier(Modifier::BOLD),
767        )),
768        Line::from(""),
769        Line::from(format!(
770            "Delete {} items ({})?",
771            app.selected_count(),
772            format_size(app.selected_size())
773        )),
774        Line::from(""),
775        Line::from(vec![Span::raw("Mode: "), mode_text]),
776        Line::from(""),
777        Line::from(vec![
778            Span::styled(" [y] ", Style::default().fg(Color::Green)),
779            Span::raw("Yes  "),
780            Span::styled(" [n] ", Style::default().fg(Color::Red)),
781            Span::raw("No  "),
782            Span::styled(" [p] ", Style::default().fg(Color::Magenta)),
783            Span::raw("Toggle Permanent"),
784        ]),
785    ];
786
787    let border_color = if app.permanent_delete { Color::Red } else { Color::Yellow };
788
789    let dialog = Paragraph::new(text)
790        .block(
791            Block::default()
792                .borders(Borders::ALL)
793                .border_style(Style::default().fg(border_color))
794                .title(" Confirm "),
795        )
796        .alignment(Alignment::Center);
797
798    frame.render_widget(dialog, dialog_area);
799}
800
801/// Render help popup
802fn render_help_popup(frame: &mut Frame, area: Rect) {
803    let dialog_width = 65;
804    let dialog_height = 24;
805
806    let dialog_area = centered_rect(dialog_width, dialog_height, area);
807
808    // Clear the area behind the dialog
809    frame.render_widget(Clear, dialog_area);
810
811    let help_text = vec![
812        Line::from(Span::styled(
813            "🤖 null-e Keyboard Shortcuts",
814            Style::default().add_modifier(Modifier::BOLD).fg(Color::Green),
815        )),
816        Line::from(""),
817        Line::from(Span::styled(" Navigation", Style::default().fg(Color::Cyan))),
818        Line::from(vec![
819            Span::styled("  j/↓      ", Style::default().fg(Color::Yellow)),
820            Span::raw("Move down"),
821        ]),
822        Line::from(vec![
823            Span::styled("  k/↑      ", Style::default().fg(Color::Yellow)),
824            Span::raw("Move up"),
825        ]),
826        Line::from(vec![
827            Span::styled("  →/l      ", Style::default().fg(Color::Yellow)),
828            Span::raw("Expand item details"),
829        ]),
830        Line::from(vec![
831            Span::styled("  ←/h      ", Style::default().fg(Color::Yellow)),
832            Span::raw("Collapse item details"),
833        ]),
834        Line::from(vec![
835            Span::styled("  g/Home   ", Style::default().fg(Color::Yellow)),
836            Span::raw("Go to top"),
837        ]),
838        Line::from(vec![
839            Span::styled("  G/End    ", Style::default().fg(Color::Yellow)),
840            Span::raw("Go to bottom"),
841        ]),
842        Line::from(""),
843        Line::from(Span::styled(" Selection", Style::default().fg(Color::Cyan))),
844        Line::from(vec![
845            Span::styled("  Space    ", Style::default().fg(Color::Yellow)),
846            Span::raw("Toggle selection"),
847        ]),
848        Line::from(vec![
849            Span::styled("  Enter    ", Style::default().fg(Color::Yellow)),
850            Span::raw("Start scan / Toggle expand"),
851        ]),
852        Line::from(vec![
853            Span::styled("  a        ", Style::default().fg(Color::Yellow)),
854            Span::raw("Select all"),
855        ]),
856        Line::from(vec![
857            Span::styled("  u/A      ", Style::default().fg(Color::Yellow)),
858            Span::raw("Deselect all"),
859        ]),
860        Line::from(""),
861        Line::from(Span::styled(" Actions", Style::default().fg(Color::Cyan))),
862        Line::from(vec![
863            Span::styled("  d        ", Style::default().fg(Color::Yellow)),
864            Span::raw("Delete selected"),
865        ]),
866        Line::from(vec![
867            Span::styled("  Esc/⌫   ", Style::default().fg(Color::Yellow)),
868            Span::raw("Go back to menu"),
869        ]),
870        Line::from(vec![
871            Span::styled("  /        ", Style::default().fg(Color::Yellow)),
872            Span::raw("Search/filter"),
873        ]),
874        Line::from(vec![
875            Span::styled("  q        ", Style::default().fg(Color::Yellow)),
876            Span::raw("Quit"),
877        ]),
878        Line::from(""),
879        Line::from(Span::styled(
880            "Mouse: Scroll wheel to navigate | Press any key to close",
881            Style::default().fg(Color::DarkGray),
882        )),
883    ];
884
885    let help = Paragraph::new(help_text)
886        .block(
887            Block::default()
888                .borders(Borders::ALL)
889                .border_style(Style::default().fg(Color::Cyan))
890                .title(" Help "),
891        )
892        .alignment(Alignment::Left);
893
894    frame.render_widget(help, dialog_area);
895}
896
897/// Create a centered rect
898fn centered_rect(width: u16, height: u16, area: Rect) -> Rect {
899    let x = area.x + (area.width.saturating_sub(width)) / 2;
900    let y = area.y + (area.height.saturating_sub(height)) / 2;
901    Rect::new(x, y, width.min(area.width), height.min(area.height))
902}
903
904/// Truncate string to max length
905fn truncate(s: &str, max: usize) -> String {
906    if s.len() > max {
907        format!("{}...", &s[..max.saturating_sub(3)])
908    } else {
909        s.to_string()
910    }
911}
912
913/// Format age of a project
914fn format_age(project: &crate::core::Project) -> String {
915    if let Some(modified) = project.last_modified {
916        if let Ok(age) = modified.elapsed() {
917            let days = age.as_secs() / 86400;
918            if days == 0 {
919                return "today".to_string();
920            } else if days == 1 {
921                return "1 day".to_string();
922            } else if days < 30 {
923                return format!("{} days", days);
924            } else if days < 365 {
925                return format!("{} months", days / 30);
926            } else {
927                return format!("{} years", days / 365);
928            }
929        }
930    }
931    "unknown".to_string()
932}