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, 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.size();
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
623fn render_cleaning_screen(_app: &App, frame: &mut Frame, area: Rect) {
624    let text = vec![
625        Line::from(""),
626        Line::from(Span::styled(
627            "🗑️ Cleaning...",
628            Style::default()
629                .fg(Color::Yellow)
630                .add_modifier(Modifier::BOLD),
631        )),
632        Line::from(""),
633        Line::from("Moving items to trash..."),
634    ];
635
636    let paragraph = Paragraph::new(text)
637        .block(Block::default().borders(Borders::ALL).title(" Cleaning "))
638        .alignment(Alignment::Center);
639
640    frame.render_widget(paragraph, area);
641}
642
643/// Render status bar
644fn render_status_bar(app: &App, frame: &mut Frame, area: Rect) {
645    let selected_info = if app.selected_count() > 0 {
646        format!(
647            "Selected: {} ({}) | ",
648            app.selected_count(),
649            format_size(app.selected_size())
650        )
651    } else {
652        String::new()
653    };
654
655    let search_info = if app.is_searching {
656        format!("Search: {} | ", app.search_query)
657    } else if !app.search_query.is_empty() {
658        format!("Filter: {} | ", app.search_query)
659    } else {
660        String::new()
661    };
662
663    let status = app
664        .status_message
665        .clone()
666        .unwrap_or_else(|| "Ready".to_string());
667
668    let help_hint = " [?] Help  [q] Quit ";
669
670    let status_bar = Paragraph::new(Line::from(vec![
671        Span::styled(&selected_info, Style::default().fg(Color::Green)),
672        Span::styled(&search_info, Style::default().fg(Color::Cyan)),
673        Span::styled(&status, Style::default().fg(Color::White)),
674        Span::raw(" ".repeat(
675            area.width
676                .saturating_sub((selected_info.len() + search_info.len() + status.len() + help_hint.len()) as u16)
677                as usize,
678        )),
679        Span::styled(help_hint, Style::default().fg(Color::Gray)),
680    ]))
681    .block(
682        Block::default()
683            .borders(Borders::ALL)
684            .border_style(Style::default().fg(Color::DarkGray)),
685    );
686
687    frame.render_widget(status_bar, area);
688}
689
690/// Render confirmation dialog
691fn render_confirm_dialog(app: &App, frame: &mut Frame, area: Rect) {
692    let dialog_width = 50;
693    let dialog_height = 8;
694
695    let dialog_area = centered_rect(dialog_width, dialog_height, area);
696
697    // Clear the area behind the dialog
698    frame.render_widget(Clear, dialog_area);
699
700    let text = vec![
701        Line::from(""),
702        Line::from(Span::styled(
703            "⚠ Confirm Deletion",
704            Style::default()
705                .fg(Color::Yellow)
706                .add_modifier(Modifier::BOLD),
707        )),
708        Line::from(""),
709        Line::from(format!(
710            "Delete {} items ({})?",
711            app.selected_count(),
712            format_size(app.selected_size())
713        )),
714        Line::from(""),
715        Line::from(vec![
716            Span::styled(" [y] ", Style::default().fg(Color::Green)),
717            Span::raw("Yes  "),
718            Span::styled(" [n] ", Style::default().fg(Color::Red)),
719            Span::raw("No"),
720        ]),
721    ];
722
723    let dialog = Paragraph::new(text)
724        .block(
725            Block::default()
726                .borders(Borders::ALL)
727                .border_style(Style::default().fg(Color::Yellow))
728                .title(" Confirm "),
729        )
730        .alignment(Alignment::Center);
731
732    frame.render_widget(dialog, dialog_area);
733}
734
735/// Render help popup
736fn render_help_popup(frame: &mut Frame, area: Rect) {
737    let dialog_width = 65;
738    let dialog_height = 24;
739
740    let dialog_area = centered_rect(dialog_width, dialog_height, area);
741
742    // Clear the area behind the dialog
743    frame.render_widget(Clear, dialog_area);
744
745    let help_text = vec![
746        Line::from(Span::styled(
747            "🤖 null-e Keyboard Shortcuts",
748            Style::default().add_modifier(Modifier::BOLD).fg(Color::Green),
749        )),
750        Line::from(""),
751        Line::from(Span::styled(" Navigation", Style::default().fg(Color::Cyan))),
752        Line::from(vec![
753            Span::styled("  j/↓      ", Style::default().fg(Color::Yellow)),
754            Span::raw("Move down"),
755        ]),
756        Line::from(vec![
757            Span::styled("  k/↑      ", Style::default().fg(Color::Yellow)),
758            Span::raw("Move up"),
759        ]),
760        Line::from(vec![
761            Span::styled("  →/l      ", Style::default().fg(Color::Yellow)),
762            Span::raw("Expand item details"),
763        ]),
764        Line::from(vec![
765            Span::styled("  ←/h      ", Style::default().fg(Color::Yellow)),
766            Span::raw("Collapse item details"),
767        ]),
768        Line::from(vec![
769            Span::styled("  g/Home   ", Style::default().fg(Color::Yellow)),
770            Span::raw("Go to top"),
771        ]),
772        Line::from(vec![
773            Span::styled("  G/End    ", Style::default().fg(Color::Yellow)),
774            Span::raw("Go to bottom"),
775        ]),
776        Line::from(""),
777        Line::from(Span::styled(" Selection", Style::default().fg(Color::Cyan))),
778        Line::from(vec![
779            Span::styled("  Space    ", Style::default().fg(Color::Yellow)),
780            Span::raw("Toggle selection"),
781        ]),
782        Line::from(vec![
783            Span::styled("  Enter    ", Style::default().fg(Color::Yellow)),
784            Span::raw("Start scan / Toggle expand"),
785        ]),
786        Line::from(vec![
787            Span::styled("  a        ", Style::default().fg(Color::Yellow)),
788            Span::raw("Select all"),
789        ]),
790        Line::from(vec![
791            Span::styled("  u/A      ", Style::default().fg(Color::Yellow)),
792            Span::raw("Deselect all"),
793        ]),
794        Line::from(""),
795        Line::from(Span::styled(" Actions", Style::default().fg(Color::Cyan))),
796        Line::from(vec![
797            Span::styled("  d        ", Style::default().fg(Color::Yellow)),
798            Span::raw("Delete selected"),
799        ]),
800        Line::from(vec![
801            Span::styled("  Esc/⌫   ", Style::default().fg(Color::Yellow)),
802            Span::raw("Go back to menu"),
803        ]),
804        Line::from(vec![
805            Span::styled("  /        ", Style::default().fg(Color::Yellow)),
806            Span::raw("Search/filter"),
807        ]),
808        Line::from(vec![
809            Span::styled("  q        ", Style::default().fg(Color::Yellow)),
810            Span::raw("Quit"),
811        ]),
812        Line::from(""),
813        Line::from(Span::styled(
814            "Mouse: Scroll wheel to navigate | Press any key to close",
815            Style::default().fg(Color::DarkGray),
816        )),
817    ];
818
819    let help = Paragraph::new(help_text)
820        .block(
821            Block::default()
822                .borders(Borders::ALL)
823                .border_style(Style::default().fg(Color::Cyan))
824                .title(" Help "),
825        )
826        .alignment(Alignment::Left);
827
828    frame.render_widget(help, dialog_area);
829}
830
831/// Create a centered rect
832fn centered_rect(width: u16, height: u16, area: Rect) -> Rect {
833    let x = area.x + (area.width.saturating_sub(width)) / 2;
834    let y = area.y + (area.height.saturating_sub(height)) / 2;
835    Rect::new(x, y, width.min(area.width), height.min(area.height))
836}
837
838/// Truncate string to max length
839fn truncate(s: &str, max: usize) -> String {
840    if s.len() > max {
841        format!("{}...", &s[..max.saturating_sub(3)])
842    } else {
843        s.to_string()
844    }
845}
846
847/// Format age of a project
848fn format_age(project: &crate::core::Project) -> String {
849    if let Some(modified) = project.last_modified {
850        if let Ok(age) = modified.elapsed() {
851            let days = age.as_secs() / 86400;
852            if days == 0 {
853                return "today".to_string();
854            } else if days == 1 {
855                return "1 day".to_string();
856            } else if days < 30 {
857                return format!("{} days", days);
858            } else if days < 365 {
859                return format!("{} months", days / 30);
860            } else {
861                return format!("{} years", days / 365);
862            }
863        }
864    }
865    "unknown".to_string()
866}