1use 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
17const ROBOT_SMALL: &str = "🤖";
19
20pub fn render(app: &mut App, frame: &mut Frame) {
22 let size = frame.area();
23
24 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), Constraint::Length(3), Constraint::Min(10), Constraint::Length(3), ])
36 .split(size)
37 } else {
38 Layout::default()
39 .direction(Direction::Vertical)
40 .constraints([
41 Constraint::Length(3), Constraint::Min(10), Constraint::Length(3), ])
45 .split(size)
46 };
47
48 render_header(app, frame, chunks[0]);
50
51 if show_tabs {
53 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 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(app, frame, chunks[3]);
79 } else {
80 render_ready_screen(app, frame, chunks[1]);
82 render_status_bar(app, frame, chunks[2]);
83 }
84
85 if app.show_help {
87 render_help_popup(frame, size);
88 }
89}
90
91fn 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
120fn 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
151fn render_ready_screen(app: &App, frame: &mut Frame, area: Rect) {
153 let chunks = Layout::default()
155 .direction(Direction::Horizontal)
156 .constraints([
157 Constraint::Percentage(40), Constraint::Percentage(60), ])
160 .split(area);
161
162 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 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
246fn 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 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
307fn render_results(app: &mut App, frame: &mut Frame, area: Rect) {
309 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 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 let checkbox = if entry.selected { "☑" } else { "☐" };
342
343 let expand = if is_expanded { "▾" } else { "▸" };
345
346 let icon = entry.project.kind.icon();
348
349 let name = &entry.project.name;
351 let size = format_size(entry.project.cleanable_size);
352
353 let age = format_age(&entry.project);
355
356 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 if is_expanded {
390 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 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 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
455fn 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
523fn 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
596fn 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
622fn 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 let chunks = Layout::default()
642 .direction(Direction::Vertical)
643 .margin(2)
644 .constraints([
645 Constraint::Length(3), Constraint::Length(2), Constraint::Length(3), Constraint::Length(2), Constraint::Length(2), Constraint::Min(0), ])
652 .split(area);
653
654 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 let progress = ((app.anim_frame % 20) as f64 / 20.0 * 0.3) + 0.7; 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 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 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
696fn 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
743fn 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 frame.render_widget(Clear, dialog_area);
752
753 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
801fn 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 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
897fn 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
904fn 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
913fn 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}