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, 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.size();
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 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
643fn 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
690fn 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 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
735fn 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 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
831fn 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
838fn 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
847fn 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}