Skip to main content

cleansys_tui/
render.rs

1use ratatui::{
2    layout::{Constraint, Direction, Layout, Rect},
3    style::{Color, Modifier, Style},
4    symbols,
5    text::{Line, Span},
6    widgets::{Axis, Block, Borders, Chart, Clear, Dataset, List, ListItem, Paragraph, Wrap},
7    Frame,
8};
9// Using tui-checkbox library for consistent checkbox symbols across the application
10use tui_checkbox::{symbols as checkbox_symbols, Checkbox};
11use tui_spinner::{FluxFrames, FluxSpinner};
12
13use crate::app::{App, ChartType, CleanedItemType};
14use crate::pie_chart::create_pie_chart_from_distribution;
15use cleansys_core::{format_size, Status};
16
17pub fn ui(f: &mut Frame, app: &mut App) {
18    // Update animation frame if needed
19    app.update_animation();
20
21    // Adjust title and footer heights based on terminal size
22    let (title_height, footer_height, min_content_height) = if app.terminal_height < 20 {
23        // Very small terminals: minimal UI
24        (2, 2, 6)
25    } else if app.terminal_height < 30 {
26        // Small terminals: compact UI
27        (2, 2, 8)
28    } else if app.terminal_height < 40 {
29        // Medium terminals: standard UI
30        (3, 3, 10)
31    } else {
32        // Large terminals: spacious UI
33        (3, 3, 12)
34    };
35
36    let chunks = Layout::default()
37        .direction(Direction::Vertical)
38        .constraints([
39            Constraint::Length(title_height),    // Title
40            Constraint::Min(min_content_height), // Main content
41            Constraint::Length(footer_height),   // Footer
42        ])
43        .split(f.area());
44
45    render_title(f, app, chunks[0]);
46
47    if app.show_help {
48        render_help(f, chunks[1]);
49    } else if app.is_running || app.show_progress_screen {
50        render_progress_screen(f, app, chunks[1]);
51    } else {
52        render_main_content(f, app, chunks[1]);
53    }
54
55    render_footer(f, app, chunks[2]);
56
57    // Render password prompt as overlay if visible
58    if app.password_prompt.is_visible() {
59        app.password_prompt.render(f, f.area());
60    }
61
62    if app.needs_admin_notice {
63        render_admin_notice(f, f.area());
64    } else if app.awaiting_run_confirmation {
65        render_confirm_run(f, app, f.area());
66    } else if app.preview_open {
67        render_preview(f, app, f.area());
68    }
69}
70
71fn render_title(f: &mut Frame, app: &App, area: Rect) {
72    // Adjust title content based on terminal width
73    let title_lines = if app.terminal_width < 80 {
74        // Narrow terminals: shortened version with dimensions indicator
75        let mut lines = vec![Line::from(vec![
76            Span::styled(
77                "Cleansys",
78                Style::default()
79                    .fg(Color::Cyan)
80                    .add_modifier(Modifier::BOLD),
81            ),
82            Span::raw(" - System Cleaner"),
83            if app.terminal_width < 60 || app.terminal_height < 20 {
84                Span::styled(
85                    format!(" [{}x{}]", app.terminal_width, app.terminal_height),
86                    Style::default().fg(Color::DarkGray),
87                )
88            } else {
89                Span::raw("")
90            },
91        ])];
92
93        // Add help line
94        lines.push(Line::from(vec![
95            Span::styled("?", Style::default().add_modifier(Modifier::BOLD)),
96            Span::raw(" help | "),
97            Span::styled("q", Style::default().add_modifier(Modifier::BOLD)),
98            Span::raw(" quit"),
99        ]));
100
101        lines
102    } else {
103        // Wide terminals: full version
104        vec![
105            Line::from(vec![
106                Span::styled(
107                    "Cleansys",
108                    Style::default()
109                        .fg(Color::Cyan)
110                        .add_modifier(Modifier::BOLD),
111                ),
112                Span::raw(" - Modern System Cleaner for Linux"),
113            ]),
114            Line::from(vec![
115                Span::raw("Press "),
116                Span::styled("?", Style::default().add_modifier(Modifier::BOLD)),
117                Span::raw(" for help, "),
118                Span::styled("q", Style::default().add_modifier(Modifier::BOLD)),
119                Span::raw(" to quit"),
120            ]),
121        ]
122    };
123
124    let title = Paragraph::new(title_lines).block(Block::default().borders(Borders::BOTTOM));
125
126    f.render_widget(title, area);
127
128    // Animated "loading" spinner (via the tui-spinner crate) in the
129    // top-right corner of the title bar while a cleaning run is active.
130    if app.is_running && area.width > 14 {
131        let spinner_width = 12u16;
132        let spinner_area = Rect {
133            x: area.x + area.width.saturating_sub(spinner_width + 1),
134            y: area.y,
135            width: spinner_width,
136            height: 1,
137        };
138
139        let label = Line::from(vec![
140            Span::raw(" "),
141            Span::styled(
142                "RUNNING",
143                Style::default()
144                    .fg(Color::Yellow)
145                    .add_modifier(Modifier::BOLD),
146            ),
147            Span::raw(" "),
148        ]);
149        let label_width = label.width() as u16;
150
151        let (label_area, glyph_area) = (
152            Rect {
153                width: label_width.min(spinner_width),
154                ..spinner_area
155            },
156            Rect {
157                x: spinner_area.x + label_width.min(spinner_width),
158                width: spinner_area.width.saturating_sub(label_width),
159                ..spinner_area
160            },
161        );
162
163        f.render_widget(Paragraph::new(label), label_area);
164        f.render_widget(
165            FluxSpinner::new(app.animation_frame as u64)
166                .frames(FluxFrames::CLASSIC)
167                .color(Color::Cyan),
168            glyph_area,
169        );
170    }
171}
172
173fn render_main_content(f: &mut Frame, app: &mut App, area: Rect) {
174    // Adjust layout based on terminal width
175    let (categories_percent, content_percent) = if app.terminal_width < 80 {
176        // Narrow terminals: give more space to content
177        (25, 75)
178    } else if app.terminal_width < 120 {
179        // Medium terminals: balanced layout
180        (30, 70)
181    } else {
182        // Wide terminals: can afford more space for categories
183        (35, 65)
184    };
185
186    let horizontal_chunks = Layout::default()
187        .direction(Direction::Horizontal)
188        .constraints([
189            Constraint::Percentage(categories_percent), // Categories
190            Constraint::Percentage(content_percent),    // Cleaners/Details
191        ])
192        .split(area);
193
194    render_categories(f, app, horizontal_chunks[0]);
195
196    if app.detailed_view {
197        render_details(f, app, horizontal_chunks[1]);
198    } else {
199        render_cleaners(f, app, horizontal_chunks[1]);
200    }
201}
202
203fn render_progress_screen(f: &mut Frame, app: &mut App, area: Rect) {
204    // Render both progress and details in a unified view
205    render_unified_progress_view(f, app, area);
206}
207
208fn render_unified_progress_view(f: &mut Frame, app: &mut App, area: Rect) {
209    // Update app counters first
210    app.update_counters();
211
212    // Ultra-compact layout for extremely small terminals
213    if area.width < 50 || area.height < 15 {
214        render_ultra_compact_view(f, app, area);
215        return;
216    }
217
218    // Show 2-section layout: Combined Progress Overview + Removed Items
219    let main_chunks = Layout::default()
220        .direction(Direction::Vertical)
221        .constraints([
222            Constraint::Percentage(if app.terminal_height >= 35 {
223                55
224            } else if app.terminal_height >= 25 {
225                50
226            } else {
227                45
228            }), // Combined progress overview - responsive percentage
229            Constraint::Percentage(if app.terminal_height >= 35 {
230                45
231            } else if app.terminal_height >= 25 {
232                50
233            } else {
234                55
235            }), // Removed items window - responsive percentage
236        ])
237        .margin(1)
238        .split(area);
239
240    // ===== TOP SECTION: Combined Progress Overview =====
241    render_combined_progress_overview(f, app, main_chunks[0]);
242
243    // ===== BOTTOM SECTION: Removed Items Window =====
244    render_removed_items_window(f, app, main_chunks[1]);
245}
246
247fn render_combined_progress_overview(f: &mut Frame, app: &App, area: Rect) {
248    let block = Block::default()
249        .title("📊 Progress Overview & Operations")
250        .title_style(
251            Style::default()
252                .fg(Color::Cyan)
253                .add_modifier(Modifier::BOLD),
254        )
255        .borders(Borders::ALL)
256        .border_style(Style::default().fg(Color::Cyan));
257
258    let inner_area = block.inner(area);
259
260    // Responsive height allocation based on terminal size - make chart area bigger
261    let stats_height = if area.height < 15 {
262        5 // Minimal height for very short terminals
263    } else if area.height < 20 {
264        7 // Compact layout for short terminals
265    } else if area.height < 25 {
266        9 // Medium layout
267    } else {
268        12 // Standard height for normal terminals - much bigger for better chart
269    };
270
271    // Split into top (stats + chart) and bottom (operations)
272    let main_sections = Layout::default()
273        .direction(Direction::Vertical)
274        .constraints([
275            Constraint::Length(stats_height), // Stats and chart section
276            Constraint::Min(6),               // Operations section
277        ])
278        .split(inner_area);
279
280    // Top section: Progress stats and chart
281    render_progress_stats_and_chart(f, app, main_sections[0]);
282
283    // Bottom section: Operations summary
284    render_operations_summary(f, app, main_sections[1]);
285
286    f.render_widget(block, area);
287}
288
289fn render_progress_stats_and_chart(f: &mut Frame, app: &App, area: Rect) {
290    let elapsed_time = app.get_elapsed_time();
291    let total_ops = app.operation_count;
292    let completed_ops = total_ops.saturating_sub(app.errors_count);
293    let progress_percent = completed_ops
294        .checked_mul(100)
295        .and_then(|v| v.checked_div(total_ops))
296        .unwrap_or(0);
297
298    // Responsive layout based on terminal width - give chart much more space
299    let show_chart = area.width >= 80; // Hide chart on narrow terminals
300
301    let horizontal_chunks = if show_chart {
302        let stats_percent = if area.width < 100 {
303            45 // Much more space for chart on narrow terminals
304        } else if area.width < 130 {
305            40 // Balanced layout for medium terminals - chart gets 60%
306        } else {
307            35 // Even more space for chart on wide terminals - chart gets 65%
308        };
309
310        Layout::default()
311            .direction(Direction::Horizontal)
312            .constraints([
313                Constraint::Percentage(stats_percent),
314                Constraint::Percentage(100 - stats_percent),
315            ])
316            .split(area)
317    } else {
318        // Use full width for stats when chart is hidden
319        Layout::default()
320            .direction(Direction::Horizontal)
321            .constraints([Constraint::Percentage(100)])
322            .split(area)
323    };
324
325    // Left side: Progress stats
326    let stats_lines = vec![
327        Line::from(vec![
328            Span::styled(
329                "Progress: ",
330                Style::default()
331                    .fg(Color::White)
332                    .add_modifier(Modifier::BOLD),
333            ),
334            Span::styled(
335                format!("{}%", progress_percent),
336                Style::default()
337                    .fg(Color::Green)
338                    .add_modifier(Modifier::BOLD),
339            ),
340            Span::raw(format!(" ({}/{})", completed_ops, total_ops)),
341            Span::raw("  ⏱️ "),
342            Span::styled(
343                elapsed_time,
344                Style::default()
345                    .fg(Color::Cyan)
346                    .add_modifier(Modifier::BOLD),
347            ),
348        ]),
349        Line::from(vec![
350            Span::raw("█".repeat((progress_percent * 35) / 100)),
351            Span::styled(
352                "░".repeat(35 - (progress_percent * 35) / 100),
353                Style::default().fg(Color::DarkGray),
354            ),
355        ]),
356        Line::from(vec![
357            Span::styled("✅ ", Style::default().fg(Color::Green)),
358            Span::styled(
359                format!("{} OK", completed_ops),
360                Style::default().fg(Color::Green),
361            ),
362            Span::raw("  "),
363            Span::styled("⚡ ", Style::default().fg(Color::Yellow)),
364            Span::styled(
365                format!(
366                    "{} Active",
367                    if app.is_running {
368                        total_ops.saturating_sub(completed_ops)
369                    } else {
370                        0
371                    }
372                ),
373                Style::default().fg(Color::Yellow),
374            ),
375            Span::raw("  "),
376            Span::styled("❌ ", Style::default().fg(Color::Red)),
377            Span::styled(
378                format!("{} Errors", app.errors_count),
379                Style::default().fg(Color::Red),
380            ),
381        ]),
382        Line::from(vec![
383            Span::styled(
384                "💾 Total freed: ",
385                Style::default()
386                    .fg(Color::White)
387                    .add_modifier(Modifier::BOLD),
388            ),
389            Span::styled(
390                format_size(app.total_bytes_cleaned),
391                Style::default()
392                    .fg(Color::Green)
393                    .add_modifier(Modifier::BOLD),
394            ),
395        ]),
396    ];
397
398    let stats_para = Paragraph::new(stats_lines);
399    f.render_widget(stats_para, horizontal_chunks[0]);
400
401    // Right side: Chart (only if terminal is wide enough)
402    if show_chart && horizontal_chunks.len() > 1 {
403        match app.chart_type {
404            ChartType::Bar => {
405                render_vertical_bar_chart(f, app, horizontal_chunks[1]);
406            }
407            ChartType::PieCount => {
408                render_pie_chart_distribution(f, app, horizontal_chunks[1]);
409            }
410            ChartType::PieSize => {
411                render_pie_chart_size_distribution(f, app, horizontal_chunks[1]);
412            }
413        }
414    }
415}
416
417fn render_ultra_compact_view(f: &mut Frame, app: &App, area: Rect) {
418    let elapsed_time = app.get_elapsed_time();
419    let total_ops = app.operation_count;
420    let completed_ops = total_ops.saturating_sub(app.errors_count);
421    let progress_percent = completed_ops
422        .checked_mul(100)
423        .and_then(|v| v.checked_div(total_ops))
424        .unwrap_or(0);
425
426    // Ultra-compact single block with essential info only
427    let compact_lines = vec![
428        Line::from(vec![Span::styled(
429            format!("Cleansys [{}x{}]", area.width, area.height),
430            Style::default()
431                .fg(Color::Cyan)
432                .add_modifier(Modifier::BOLD),
433        )]),
434        Line::from(vec![
435            Span::styled(
436                format!("{}% ", progress_percent),
437                Style::default()
438                    .fg(Color::Green)
439                    .add_modifier(Modifier::BOLD),
440            ),
441            Span::raw("█".repeat(
442                ((progress_percent * (area.width.saturating_sub(10) as usize)) / 100).min(30),
443            )),
444        ]),
445        Line::from(vec![
446            Span::styled(
447                format!("✅{} ❌{} ", completed_ops, app.errors_count),
448                Style::default().fg(Color::White),
449            ),
450            Span::styled(
451                format_size(app.total_bytes_cleaned),
452                Style::default()
453                    .fg(Color::Green)
454                    .add_modifier(Modifier::BOLD),
455            ),
456        ]),
457        Line::from(vec![
458            Span::styled(
459                format!("⏱️{} ", elapsed_time),
460                Style::default().fg(Color::Cyan),
461            ),
462            Span::styled(
463                if app.is_running { "RUNNING" } else { "DONE" },
464                Style::default().fg(if app.is_running {
465                    Color::Yellow
466                } else {
467                    Color::Green
468                }),
469            ),
470        ]),
471    ];
472
473    let block = Block::default()
474        .borders(Borders::ALL)
475        .border_style(Style::default().fg(Color::DarkGray));
476
477    let para = Paragraph::new(compact_lines)
478        .block(block)
479        .wrap(Wrap { trim: true });
480
481    f.render_widget(para, area);
482}
483
484fn render_vertical_bar_chart(f: &mut Frame, app: &App, area: Rect) {
485    // Get real data from cleaned items
486    let category_distribution = app.get_category_distribution();
487
488    // Only show chart if we have real data
489    if !category_distribution.is_empty() {
490        // Use real data, limit to top 6 categories to fit in chart
491        let limited_data: Vec<_> = category_distribution.iter().take(6).collect();
492        let max_count = limited_data
493            .iter()
494            .map(|(_, count, _)| *count)
495            .max()
496            .unwrap_or(1) as f64;
497
498        let chart_data: Vec<(f64, f64)> = limited_data
499            .iter()
500            .enumerate()
501            .map(|(i, (_, count, _))| (i as f64, *count as f64))
502            .collect();
503
504        let category_names: Vec<&str> = limited_data
505            .iter()
506            .map(|(name, _, _)| {
507                // Truncate label for narrow terminals
508                if area.width < 80 {
509                    if name.len() > 6 {
510                        &name[..6]
511                    } else {
512                        name
513                    }
514                } else if area.width < 100 {
515                    if name.len() > 8 {
516                        &name[..8]
517                    } else {
518                        name
519                    }
520                } else if name.len() > 12 {
521                    &name[..12]
522                } else {
523                    name
524                }
525            })
526            .collect();
527
528        // Create dataset for the chart
529        let dataset = Dataset::default()
530            .name("Cleaned Items")
531            .marker(symbols::Marker::Block)
532            .style(
533                Style::default()
534                    .fg(Color::Cyan)
535                    .add_modifier(Modifier::BOLD),
536            )
537            .data(&chart_data);
538
539        // Create x-axis labels
540        let x_labels = if category_names.len() <= 3 {
541            vec![
542                Span::raw(category_names.first().unwrap_or(&"").to_string()),
543                Span::raw(category_names.get(1).unwrap_or(&"").to_string()),
544                Span::raw(category_names.get(2).unwrap_or(&"").to_string()),
545            ]
546        } else {
547            vec![
548                Span::raw(category_names.first().unwrap_or(&"").to_string()),
549                Span::raw(
550                    category_names
551                        .get(category_names.len() / 2)
552                        .unwrap_or(&"")
553                        .to_string(),
554                ),
555                Span::raw(category_names.last().unwrap_or(&"").to_string()),
556            ]
557        };
558
559        // Create y-axis labels
560        let y_max = (max_count * 1.1).max(1.0); // Add 10% padding, minimum 1
561        let y_labels = vec![
562            Span::raw("0"),
563            Span::raw(format!("{}", (y_max / 2.0) as u64)),
564            Span::raw(format!("{}", y_max as u64)),
565        ];
566
567        let chart = Chart::new(vec![dataset])
568            .block(
569                Block::default()
570                    .title(if area.width < 50 {
571                        "Items (Bar)"
572                    } else {
573                        "Items Distribution (Bar Chart)"
574                    })
575                    .title_style(
576                        Style::default()
577                            .fg(Color::Cyan)
578                            .add_modifier(Modifier::BOLD),
579                    )
580                    .borders(Borders::ALL)
581                    .border_style(Style::default().fg(Color::Cyan)),
582            )
583            .x_axis(
584                Axis::default()
585                    .title(if area.width >= 80 { "Categories" } else { "" })
586                    .style(Style::default().fg(Color::White))
587                    .bounds([0.0, (category_names.len().max(3) - 1) as f64])
588                    .labels(x_labels),
589            )
590            .y_axis(
591                Axis::default()
592                    .title(if area.width >= 80 { "Count" } else { "" })
593                    .style(Style::default().fg(Color::White))
594                    .bounds([0.0, y_max])
595                    .labels(y_labels),
596            );
597
598        f.render_widget(chart, area);
599    }
600}
601
602fn render_operations_summary(f: &mut Frame, app: &App, area: Rect) {
603    // Split into user and system operations columns
604    let columns = Layout::default()
605        .direction(Direction::Horizontal)
606        .constraints([
607            Constraint::Percentage(48), // User operations
608            Constraint::Percentage(4),  // Spacing
609            Constraint::Percentage(48), // System operations
610        ])
611        .split(area);
612
613    // User operations
614    let user_operations = vec![
615        ListItem::new(Line::from(vec![Span::styled(
616            "👤 USER OPERATIONS",
617            Style::default()
618                .fg(Color::Green)
619                .add_modifier(Modifier::BOLD),
620        )])),
621        ListItem::new(Line::from(vec![])),
622        ListItem::new(Line::from(vec![
623            Span::styled("📦 ", Style::default().fg(Color::Green)),
624            Span::styled("Package Caches", Style::default().fg(Color::White)),
625        ])),
626        ListItem::new(Line::from(vec![
627            Span::styled("🗑️ ", Style::default().fg(Color::Green)),
628            Span::styled("Trash & Temp Files", Style::default().fg(Color::White)),
629        ])),
630        ListItem::new(Line::from(vec![
631            Span::styled("🌐 ", Style::default().fg(Color::Green)),
632            Span::styled("Browser Caches", Style::default().fg(Color::White)),
633        ])),
634    ];
635
636    // System operations
637    let system_operations = vec![
638        ListItem::new(Line::from(vec![Span::styled(
639            "🔒 SYSTEM OPERATIONS",
640            Style::default()
641                .fg(Color::Yellow)
642                .add_modifier(Modifier::BOLD),
643        )])),
644        ListItem::new(Line::from(vec![])),
645        ListItem::new(Line::from(vec![
646            Span::styled(
647                "📦 ",
648                if app.is_root {
649                    Style::default().fg(Color::Green)
650                } else {
651                    Style::default().fg(Color::Yellow)
652                },
653            ),
654            Span::styled("Package Caches", Style::default().fg(Color::White)),
655            if !app.is_root {
656                Span::styled(" (sudo)", Style::default().fg(Color::Yellow))
657            } else {
658                Span::raw("")
659            },
660        ])),
661        ListItem::new(Line::from(vec![
662            Span::styled(
663                "📝 ",
664                if app.is_root {
665                    Style::default().fg(Color::Green)
666                } else {
667                    Style::default().fg(Color::Yellow)
668                },
669            ),
670            Span::styled("System Logs", Style::default().fg(Color::White)),
671            if !app.is_root {
672                Span::styled(" (sudo)", Style::default().fg(Color::Yellow))
673            } else {
674                Span::raw("")
675            },
676        ])),
677        ListItem::new(Line::from(vec![
678            Span::styled(
679                "🗄️ ",
680                if app.is_root {
681                    Style::default().fg(Color::Green)
682                } else {
683                    Style::default().fg(Color::Yellow)
684                },
685            ),
686            Span::styled("System Temp Files", Style::default().fg(Color::White)),
687            if !app.is_root {
688                Span::styled(" (sudo)", Style::default().fg(Color::Yellow))
689            } else {
690                Span::raw("")
691            },
692        ])),
693    ];
694
695    let user_list = List::new(user_operations);
696    let system_list = List::new(system_operations);
697
698    f.render_widget(user_list, columns[0]);
699    f.render_widget(system_list, columns[2]);
700}
701
702fn render_pie_chart_distribution(f: &mut Frame, app: &App, area: Rect) {
703    let category_distribution = app.get_category_distribution();
704
705    // Only show real data from actual cleaning operations, and only when
706    // there's enough room for tui-piechart to draw something meaningful.
707    if !category_distribution.is_empty() && area.width >= 20 && area.height >= 8 {
708        let chart = create_pie_chart_from_distribution(
709            &category_distribution,
710            "Items Distribution (Count)",
711            false, // Use count-based distribution
712        )
713        .show_percentages(area.width >= 40)
714        .show_legend(area.width >= 50 || area.height >= 16);
715
716        f.render_widget(chart, area);
717    }
718}
719
720fn render_pie_chart_size_distribution(f: &mut Frame, app: &App, area: Rect) {
721    let category_distribution = app.get_category_distribution();
722
723    // Only show real data from actual cleaning operations, and only when
724    // there's enough room for tui-piechart to draw something meaningful.
725    if !category_distribution.is_empty() && area.width >= 20 && area.height >= 8 {
726        let chart = create_pie_chart_from_distribution(
727            &category_distribution,
728            "Items Distribution (Size)",
729            true, // Use size-based distribution
730        )
731        .show_percentages(area.width >= 40)
732        .show_legend(area.width >= 50 || area.height >= 16);
733
734        f.render_widget(chart, area);
735    }
736}
737
738fn render_removed_items_window(f: &mut Frame, app: &mut App, area: Rect) {
739    let title = if app.is_running {
740        "📋 Operation Progress"
741    } else if app.show_progress_screen {
742        "📋 Cleaning Results - Removed Items"
743    } else {
744        "📋 Removed Items Details"
745    };
746
747    let block = Block::default()
748        .title(title)
749        .title_style(
750            Style::default()
751                .fg(Color::Yellow)
752                .add_modifier(Modifier::BOLD),
753        )
754        .borders(Borders::ALL)
755        .border_style(Style::default().fg(Color::Yellow));
756
757    let inner_area = block.inner(area);
758
759    let mut display_items = Vec::new();
760
761    // Show operation logs if running, otherwise show removed items
762    if app.is_running && !app.operation_logs.is_empty() {
763        for log_entry in app.operation_logs.iter().rev().take(15) {
764            let (icon, color) = if log_entry.contains("✅") {
765                ("✅", Color::Green)
766            } else if log_entry.contains("❌") {
767                ("❌", Color::Red)
768            } else if log_entry.contains("🔄") {
769                ("🔄", Color::Yellow)
770            } else if log_entry.contains("📊") {
771                ("📊", Color::Cyan)
772            } else {
773                ("ℹ️", Color::White)
774            };
775
776            display_items.push(ListItem::new(Line::from(vec![
777                Span::styled(format!("{} ", icon), Style::default().fg(color)),
778                Span::styled(log_entry.clone(), Style::default().fg(Color::White)),
779            ])));
780        }
781    } else {
782        // Get sample cleaned items for display plus additional entries for demo
783        let filtered_items = app.get_filtered_detailed_items();
784
785        if !filtered_items.is_empty() {
786            for (index, item) in filtered_items.iter().enumerate() {
787                let icon = match item.item_type {
788                    CleanedItemType::File => "📄",
789                    CleanedItemType::Directory => "📁",
790                    CleanedItemType::Log => "📝",
791                };
792
793                // File path and size on one line
794                display_items.push(ListItem::new(Line::from(vec![
795                    Span::styled(format!("{} ", icon), Style::default().fg(Color::Yellow)),
796                    Span::styled(item.path.clone(), Style::default().fg(Color::White)),
797                    Span::raw(" "),
798                    Span::styled(
799                        format!("({})", format_size(item.size)),
800                        Style::default()
801                            .fg(Color::Green)
802                            .add_modifier(Modifier::BOLD),
803                    ),
804                ])));
805
806                // Category and cleaner info on next line (indented)
807                display_items.push(ListItem::new(Line::from(vec![
808                    Span::raw("   "),
809                    Span::styled("📂 ", Style::default().fg(Color::Blue)),
810                    Span::styled(item.category.clone(), Style::default().fg(Color::Blue)),
811                    Span::raw(" • "),
812                    Span::styled("🔧 ", Style::default().fg(Color::Cyan)),
813                    Span::styled(item.cleaner_name.clone(), Style::default().fg(Color::Cyan)),
814                ])));
815
816                // Add spacing between entries
817                if index < filtered_items.len() - 1 {
818                    display_items.push(ListItem::new(Line::from(vec![])));
819                }
820            }
821        } else if !app.is_running && app.show_progress_screen && app.total_bytes_cleaned > 0 {
822            // Show summary when cleaning is complete but no detailed items
823            display_items.push(ListItem::new(Line::from(vec![
824                Span::styled("✅ ", Style::default().fg(Color::Green)),
825                Span::styled(
826                    "Cleaning completed successfully",
827                    Style::default()
828                        .fg(Color::Green)
829                        .add_modifier(Modifier::BOLD),
830                ),
831            ])));
832            display_items.push(ListItem::new(Line::from(vec![])));
833
834            display_items.push(ListItem::new(Line::from(vec![
835                Span::styled("📊 ", Style::default().fg(Color::Cyan)),
836                Span::styled("Total space freed: ", Style::default().fg(Color::White)),
837                Span::styled(
838                    format_size(app.total_bytes_cleaned),
839                    Style::default()
840                        .fg(Color::Green)
841                        .add_modifier(Modifier::BOLD),
842                ),
843            ])));
844            display_items.push(ListItem::new(Line::from(vec![])));
845
846            // Show which cleaners were executed
847            for category in &app.categories {
848                for item in &category.items {
849                    if item.bytes_cleaned > 0 {
850                        display_items.push(ListItem::new(Line::from(vec![
851                            Span::styled("🔧 ", Style::default().fg(Color::Yellow)),
852                            Span::styled(item.name.clone(), Style::default().fg(Color::White)),
853                            Span::raw(": "),
854                            Span::styled(
855                                format_size(item.bytes_cleaned),
856                                Style::default().fg(Color::Green),
857                            ),
858                        ])));
859                    }
860                }
861            }
862
863            if display_items.len() == 3 {
864                // No items were cleaned with bytes > 0
865                display_items.push(ListItem::new(Line::from(vec![])));
866                display_items.push(ListItem::new(Line::from(vec![
867                    Span::styled("ℹ️ ", Style::default().fg(Color::Blue)),
868                    Span::styled(
869                        "Detailed file list not available in TUI mode",
870                        Style::default().fg(Color::DarkGray),
871                    ),
872                ])));
873            }
874        }
875    }
876
877    let items_list = List::new(display_items)
878        .block(Block::default())
879        .highlight_style(
880            Style::default()
881                .bg(Color::DarkGray)
882                .add_modifier(Modifier::BOLD),
883        )
884        .highlight_symbol("► ");
885
886    f.render_stateful_widget(items_list, inner_area, &mut app.detailed_list_scroll_state);
887    f.render_widget(block, area);
888}
889
890fn render_categories(f: &mut Frame, app: &App, area: Rect) {
891    // Add icons to category names
892    let categories: Vec<ListItem> = app
893        .categories
894        .iter()
895        .enumerate()
896        .map(|(i, category)| {
897            let content = Line::from(format!("{} ({})", category.name, category.description));
898            let style = if i == app.category_index {
899                Style::default()
900                    .fg(Color::Yellow)
901                    .add_modifier(Modifier::BOLD)
902            } else {
903                Style::default()
904            };
905            ListItem::new(content).style(style)
906        })
907        .collect();
908
909    let categories_list = List::new(categories)
910        .block(
911            Block::default()
912                .title("📂 Categories")
913                .borders(Borders::ALL),
914        )
915        .highlight_style(
916            Style::default()
917                .add_modifier(Modifier::BOLD)
918                .fg(Color::Yellow),
919        );
920
921    f.render_widget(categories_list, area);
922}
923
924fn render_cleaners(f: &mut Frame, app: &mut App, area: Rect) {
925    let current_category = &app.categories[app.category_index];
926
927    let items: Vec<ListItem> = current_category
928        .items
929        .iter()
930        .map(|item| {
931            let mut parts = vec![];
932
933            // Create checkbox using tui-checkbox with predefined symbols
934            // We use the ASCII bracket symbols for maximum terminal compatibility
935            let checkbox_style = if item.selected {
936                Style::default()
937                    .fg(Color::Green)
938                    .add_modifier(Modifier::BOLD)
939            } else {
940                Style::default().fg(Color::White)
941            };
942
943            // Use Checkbox::new() with predefined symbols from the library
944            let _checkbox = Checkbox::new("", item.selected)
945                .checked_symbol(checkbox_symbols::CHECKED_X)
946                .unchecked_symbol(checkbox_symbols::UNCHECKED_SPACE);
947
948            // Extract the symbol for use in our composite List item
949            let checkbox_symbol = if item.selected {
950                checkbox_symbols::CHECKED_X
951            } else {
952                checkbox_symbols::UNCHECKED_SPACE
953            };
954
955            parts.push(Span::styled(checkbox_symbol, checkbox_style));
956            parts.push(Span::raw(" "));
957
958            // Name
959            let name_style = if item.requires_root && !app.is_root {
960                Style::default().fg(Color::DarkGray)
961            } else {
962                Style::default().fg(Color::White)
963            };
964            parts.push(Span::styled(&item.name, name_style));
965
966            // Root indicator
967            if item.requires_root {
968                parts.push(Span::styled(" (root)", Style::default().fg(Color::Red)));
969            }
970
971            // Status indicator
972            if let Some(status) = &item.status {
973                match status {
974                    Status::Running => {
975                        parts.push(Span::styled(
976                            " [Running]",
977                            Style::default().fg(Color::Yellow),
978                        ));
979                    }
980                    Status::Success(msg) => {
981                        parts.push(Span::styled(
982                            format!(" [{}]", msg),
983                            Style::default().fg(Color::Green),
984                        ));
985                    }
986                    Status::Error(msg) => {
987                        parts.push(Span::styled(
988                            format!(" [Error: {}]", msg),
989                            Style::default().fg(Color::Red),
990                        ));
991                    }
992                    Status::Pending => {
993                        parts.push(Span::styled(
994                            " [Pending]",
995                            Style::default().fg(Color::DarkGray),
996                        ));
997                    }
998                }
999            }
1000
1001            // If item has cleaned bytes, show it
1002            if item.bytes_cleaned > 0 {
1003                parts.push(Span::styled(
1004                    format!(" (Freed: {})", format_size(item.bytes_cleaned)),
1005                    Style::default().fg(Color::Green),
1006                ));
1007            }
1008
1009            ListItem::new(Line::from(parts))
1010        })
1011        .collect();
1012
1013    let items_list = List::new(items)
1014        .block(
1015            Block::default()
1016                .title(format!("{} Items", current_category.name))
1017                .borders(Borders::ALL),
1018        )
1019        .highlight_style(
1020            Style::default()
1021                .add_modifier(Modifier::BOLD)
1022                .bg(Color::DarkGray),
1023        )
1024        .highlight_symbol("> ");
1025
1026    f.render_stateful_widget(items_list, area, &mut app.item_list_state);
1027}
1028
1029fn render_details(f: &mut Frame, app: &App, area: Rect) {
1030    let current_category = &app.categories[app.category_index];
1031
1032    if let Some(selected) = app.item_list_state.selected() {
1033        if selected < current_category.items.len() {
1034            let item = &current_category.items[selected];
1035
1036            let mut text = vec![
1037                Line::from(vec![Span::styled(
1038                    format!("{} Keyboard Controls", item.name),
1039                    Style::default()
1040                        .fg(Color::Cyan)
1041                        .add_modifier(Modifier::BOLD),
1042                )]),
1043                Line::from(vec![Span::raw("")]),
1044                Line::from(vec![
1045                    Span::raw("Description: "),
1046                    Span::styled(&item.description, Style::default().fg(Color::White)),
1047                ]),
1048                Line::from(vec![Span::raw("")]),
1049                Line::from(vec![
1050                    Span::raw("Requires root: "),
1051                    if item.requires_root {
1052                        Span::styled("Yes", Style::default().fg(Color::Red))
1053                    } else {
1054                        Span::styled("No", Style::default().fg(Color::Green))
1055                    },
1056                ]),
1057                Line::from(vec![
1058                    Span::raw("Status: "),
1059                    match &item.status {
1060                        Some(Status::Running) => {
1061                            let spinner = Status::Running.get_animation_frame(app.animation_frame);
1062                            Span::styled(
1063                                format!("{} Running...", spinner),
1064                                Style::default().fg(Color::Yellow),
1065                            )
1066                        }
1067                        Some(Status::Success(msg)) => {
1068                            Span::styled(format!("✓ {}", msg), Style::default().fg(Color::Green))
1069                        }
1070                        Some(Status::Error(msg)) => Span::styled(
1071                            format!("✗ Error: {}", msg),
1072                            Style::default().fg(Color::Red),
1073                        ),
1074                        Some(Status::Pending) => {
1075                            Span::styled("• Waiting to start", Style::default().fg(Color::DarkGray))
1076                        }
1077                        None => Span::raw("Not run"),
1078                    },
1079                ]),
1080            ];
1081
1082            if item.bytes_cleaned > 0 {
1083                text.push(Line::from(vec![
1084                    Span::raw("Space freed: "),
1085                    Span::styled(
1086                        format!("{:.2} GB", item.bytes_cleaned as f64 / 1_073_741_824.0),
1087                        Style::default().fg(Color::Green),
1088                    ),
1089                ]));
1090            }
1091
1092            let details = Paragraph::new(text)
1093                .block(Block::default().title("Details").borders(Borders::ALL))
1094                .wrap(Wrap { trim: true });
1095
1096            f.render_widget(details, area);
1097        }
1098    }
1099}
1100
1101fn render_footer(f: &mut Frame, app: &App, area: Rect) {
1102    let block = Block::default()
1103        .borders(Borders::TOP)
1104        .border_style(Style::default().fg(Color::DarkGray));
1105
1106    let inner_area = block.inner(area);
1107
1108    if app.is_running || app.show_progress_screen {
1109        // Progress mode footer - clean and simple
1110        let footer_chunks = Layout::default()
1111            .direction(Direction::Horizontal)
1112            .constraints([
1113                Constraint::Percentage(60), // Status info
1114                Constraint::Percentage(40), // Controls
1115            ])
1116            .split(inner_area);
1117
1118        // Status information
1119        let status_text = vec![Line::from(vec![
1120            Span::styled(
1121                "Status: ",
1122                Style::default()
1123                    .fg(Color::White)
1124                    .add_modifier(Modifier::BOLD),
1125            ),
1126            if app.paused {
1127                Span::styled(
1128                    "PAUSED",
1129                    Style::default()
1130                        .fg(Color::Yellow)
1131                        .add_modifier(Modifier::BOLD),
1132                )
1133            } else if app.is_running {
1134                Span::styled(
1135                    "CLEANING",
1136                    Style::default()
1137                        .fg(Color::Green)
1138                        .add_modifier(Modifier::BOLD),
1139                )
1140            } else if app.operation_end_time.is_some() {
1141                Span::styled(
1142                    "FINISHED",
1143                    Style::default()
1144                        .fg(Color::Cyan)
1145                        .add_modifier(Modifier::BOLD),
1146                )
1147            } else {
1148                Span::styled(
1149                    "READY",
1150                    Style::default()
1151                        .fg(Color::White)
1152                        .add_modifier(Modifier::BOLD),
1153                )
1154            },
1155            Span::raw("  •  "),
1156            Span::styled("Total Freed: ", Style::default().fg(Color::White)),
1157            Span::styled(
1158                format_size(app.total_bytes_cleaned),
1159                Style::default()
1160                    .fg(Color::Green)
1161                    .add_modifier(Modifier::BOLD),
1162            ),
1163        ])];
1164
1165        // Controls - different for running vs completed operations
1166        let controls_text = if app.is_running {
1167            vec![Line::from(vec![
1168                Span::styled(
1169                    "ESC",
1170                    Style::default()
1171                        .fg(Color::Yellow)
1172                        .add_modifier(Modifier::BOLD),
1173                ),
1174                Span::raw(": Cancel  "),
1175                Span::styled(
1176                    "↑/↓",
1177                    Style::default()
1178                        .fg(Color::Cyan)
1179                        .add_modifier(Modifier::BOLD),
1180                ),
1181                Span::raw(": Scroll Items  "),
1182                Span::styled(
1183                    "q",
1184                    Style::default().fg(Color::Red).add_modifier(Modifier::BOLD),
1185                ),
1186                Span::raw(": Quit"),
1187            ])]
1188        } else {
1189            // Operations completed - show different controls
1190            vec![Line::from(vec![
1191                Span::styled(
1192                    "ESC",
1193                    Style::default()
1194                        .fg(Color::Yellow)
1195                        .add_modifier(Modifier::BOLD),
1196                ),
1197                Span::raw(": Return to Menu  "),
1198                Span::styled(
1199                    "↑/↓",
1200                    Style::default()
1201                        .fg(Color::Cyan)
1202                        .add_modifier(Modifier::BOLD),
1203                ),
1204                Span::raw(": Scroll Items  "),
1205                Span::styled(
1206                    "q",
1207                    Style::default().fg(Color::Red).add_modifier(Modifier::BOLD),
1208                ),
1209                Span::raw(": Quit"),
1210            ])]
1211        };
1212
1213        let status_para = Paragraph::new(status_text);
1214        let controls_para =
1215            Paragraph::new(controls_text).alignment(ratatui::layout::Alignment::Right);
1216
1217        f.render_widget(status_para, footer_chunks[0]);
1218        f.render_widget(controls_para, footer_chunks[1]);
1219    } else {
1220        // Main menu footer - organized and clean
1221        let footer_chunks = Layout::default()
1222            .direction(Direction::Horizontal)
1223            .constraints([
1224                Constraint::Percentage(40), // Status info
1225                Constraint::Percentage(60), // Controls
1226            ])
1227            .split(inner_area);
1228
1229        // Status information
1230        let status_text = vec![Line::from(vec![
1231            Span::styled(
1232                "User: ",
1233                Style::default()
1234                    .fg(Color::White)
1235                    .add_modifier(Modifier::BOLD),
1236            ),
1237            if app.is_root {
1238                Span::styled(
1239                    "root",
1240                    Style::default().fg(Color::Red).add_modifier(Modifier::BOLD),
1241                )
1242            } else {
1243                Span::styled(
1244                    "standard",
1245                    Style::default()
1246                        .fg(Color::Green)
1247                        .add_modifier(Modifier::BOLD),
1248                )
1249            },
1250            Span::raw("  •  "),
1251            Span::styled("Selected: ", Style::default().fg(Color::White)),
1252            Span::styled(
1253                format!("{}", app.selected_cleaners_count),
1254                Style::default()
1255                    .fg(Color::Blue)
1256                    .add_modifier(Modifier::BOLD),
1257            ),
1258        ])];
1259
1260        // Controls - organized by function
1261        let controls_text = vec![Line::from(vec![
1262            Span::styled(
1263                "Space",
1264                Style::default()
1265                    .fg(Color::Yellow)
1266                    .add_modifier(Modifier::BOLD),
1267            ),
1268            Span::raw(": Select  "),
1269            Span::styled(
1270                "Enter",
1271                Style::default()
1272                    .fg(Color::Green)
1273                    .add_modifier(Modifier::BOLD),
1274            ),
1275            Span::raw(": Run  "),
1276            Span::styled(
1277                "Tab",
1278                Style::default()
1279                    .fg(Color::Blue)
1280                    .add_modifier(Modifier::BOLD),
1281            ),
1282            Span::raw(": Category  "),
1283            Span::styled(
1284                "?",
1285                Style::default()
1286                    .fg(Color::Magenta)
1287                    .add_modifier(Modifier::BOLD),
1288            ),
1289            Span::raw(": Help  "),
1290            Span::styled(
1291                "q",
1292                Style::default().fg(Color::Red).add_modifier(Modifier::BOLD),
1293            ),
1294            Span::raw(": Quit"),
1295        ])];
1296
1297        let status_para = Paragraph::new(status_text);
1298        let controls_para =
1299            Paragraph::new(controls_text).alignment(ratatui::layout::Alignment::Right);
1300
1301        f.render_widget(status_para, footer_chunks[0]);
1302        f.render_widget(controls_para, footer_chunks[1]);
1303    }
1304
1305    f.render_widget(block, area);
1306}
1307
1308fn render_help(f: &mut Frame, area: Rect) {
1309    let help_text = vec![
1310        Line::from(vec![Span::styled(
1311            "🔍 Cleansys Help",
1312            Style::default()
1313                .fg(Color::Cyan)
1314                .add_modifier(Modifier::BOLD),
1315        )]),
1316        Line::from(vec![Span::raw("")]),
1317        Line::from(vec![Span::styled(
1318            "📍 Navigation:",
1319            Style::default().add_modifier(Modifier::BOLD),
1320        )]),
1321        Line::from(vec![Span::raw("  ↑/↓: Navigate items")]),
1322        Line::from(vec![Span::raw("  Tab/Shift+Tab: Switch categories")]),
1323        Line::from(vec![Span::raw("")]),
1324        Line::from(vec![Span::styled(
1325            "🔧 Actions:",
1326            Style::default().add_modifier(Modifier::BOLD),
1327        )]),
1328        Line::from(vec![Span::raw("  Space: Toggle selection")]),
1329        Line::from(vec![Span::raw(
1330            "  Enter: Run selected cleaners (asks for confirmation)",
1331        )]),
1332        Line::from(vec![Span::raw(
1333            "  d: Preview selected cleaners (dry-run, deletes nothing)",
1334        )]),
1335        Line::from(vec![Span::raw("  a: Select all in current category")]),
1336        Line::from(vec![Span::raw("  n: Deselect all in current category")]),
1337        Line::from(vec![Span::raw("  A: Select all across every category")]),
1338        Line::from(vec![Span::raw("  N: Deselect all across every category")]),
1339        Line::from(vec![Span::raw(
1340            "  c: Cycle chart type (Bar → Count Pie → Size Pie → Bar)",
1341        )]),
1342        Line::from(vec![Span::raw("  /: Search in detailed view")]),
1343        Line::from(vec![Span::raw("")]),
1344        Line::from(vec![Span::styled(
1345            "🎛️ Advanced Controls:",
1346            Style::default().add_modifier(Modifier::BOLD),
1347        )]),
1348        Line::from(vec![Span::raw("  m: Toggle compact mode")]),
1349        Line::from(vec![Span::raw(
1350            "  v: Cycle view mode (Standard/Compact/Detailed/Performance)",
1351        )]),
1352        Line::from(vec![Span::raw("  p: Toggle performance statistics")]),
1353        Line::from(vec![Span::raw(
1354            "  s: Toggle auto-scroll log (during operations)",
1355        )]),
1356        Line::from(vec![Span::raw("  o: Cycle sort mode")]),
1357        Line::from(vec![Span::raw("  f: Cycle filter mode")]),
1358        Line::from(vec![Span::raw("  y: Toggle confirmation prompts")]),
1359        Line::from(vec![Span::raw("  x: Clear all errors")]),
1360        Line::from(vec![Span::raw(
1361            "  j/k: Scroll detailed items list (vi-style)",
1362        )]),
1363        Line::from(vec![Span::raw("  /: Search files/paths in detailed view")]),
1364        Line::from(vec![Span::raw(
1365            "  ESC: Clear search / Cancel operation / Return to menu",
1366        )]),
1367        Line::from(vec![Span::raw("  Backspace: Remove search character")]),
1368        Line::from(vec![Span::raw("  PgUp/PgDn: Scroll operation log")]),
1369        Line::from(vec![Span::raw("  Home/End: Jump to first/last item")]),
1370        Line::from(vec![Span::raw("  Ctrl+Space: Pause/Resume operations")]),
1371        Line::from(vec![Span::raw("")]),
1372        Line::from(vec![Span::styled(
1373            "🔍 Search Features:",
1374            Style::default().add_modifier(Modifier::BOLD),
1375        )]),
1376        Line::from(vec![Span::raw(
1377            "  Search matches file paths, categories, and cleaner names",
1378        )]),
1379        Line::from(vec![Span::raw(
1380            "  Real-time filtering with highlighted results",
1381        )]),
1382        Line::from(vec![Span::raw("  Category distribution shown at bottom")]),
1383        Line::from(vec![Span::raw("")]),
1384        Line::from(vec![Span::styled(
1385            "📊 Chart Types (press 'c' to cycle):",
1386            Style::default().add_modifier(Modifier::BOLD),
1387        )]),
1388        Line::from(vec![Span::raw(
1389            "  Bar Chart: Traditional vertical bars for comparison",
1390        )]),
1391        Line::from(vec![Span::raw(
1392            "  Pie Count: Circular chart showing item distribution by count",
1393        )]),
1394        Line::from(vec![Span::raw(
1395            "  Pie Size: Circular chart showing space usage by category",
1396        )]),
1397        Line::from(vec![Span::raw("")]),
1398        Line::from(vec![Span::styled(
1399            "🔒 System Operations:",
1400            Style::default().add_modifier(Modifier::BOLD),
1401        )]),
1402        Line::from(vec![Span::raw(
1403            "  System cleaners require sudo/root privileges",
1404        )]),
1405        Line::from(vec![Span::raw(
1406            "  Run 'sudo cleansys' or provide password when prompted",
1407        )]),
1408        Line::from(vec![Span::raw(
1409            "  Items marked (sudo) will request elevated privileges",
1410        )]),
1411        Line::from(vec![Span::raw("")]),
1412        Line::from(vec![Span::styled(
1413            "🔄 Other:",
1414            Style::default().add_modifier(Modifier::BOLD),
1415        )]),
1416        Line::from(vec![Span::raw("  ?: Show/hide help")]),
1417        Line::from(vec![Span::raw("  q: Exit application")]),
1418    ];
1419
1420    let help = Paragraph::new(help_text)
1421        .block(Block::default().title("📚 Help").borders(Borders::ALL))
1422        .wrap(Wrap { trim: true });
1423
1424    f.render_widget(help, area);
1425}
1426
1427/// Compute a centered popup `Rect` covering roughly `width_pct`/`height_pct`
1428/// of `area`, clamped to a sensible minimum/maximum size.
1429fn centered_popup(area: Rect, width_pct: u16, height_pct: u16) -> Rect {
1430    let width = (area.width * width_pct / 100).clamp(30, area.width.saturating_sub(2).max(30));
1431    let height = (area.height * height_pct / 100).clamp(10, area.height.saturating_sub(2).max(10));
1432    let x = area.x + (area.width.saturating_sub(width)) / 2;
1433    let y = area.y + (area.height.saturating_sub(height)) / 2;
1434    Rect {
1435        x,
1436        y,
1437        width,
1438        height,
1439    }
1440}
1441
1442/// Overlay shown before actually cleaning: lists exactly what's selected and
1443/// requires an explicit Enter/y (confirm) or Esc/n (cancel).
1444fn render_confirm_run(f: &mut Frame, app: &App, area: Rect) {
1445    let popup = centered_popup(area, 70, 60);
1446
1447    let selected_count = app.pending_run_selection.len();
1448    let mut lines = vec![
1449        Line::from(vec![Span::styled(
1450            "⚠️  Confirm Cleaning",
1451            Style::default()
1452                .fg(Color::Yellow)
1453                .add_modifier(Modifier::BOLD),
1454        )]),
1455        Line::from(vec![Span::raw("")]),
1456        Line::from(vec![Span::raw(format!(
1457            "This will permanently delete files for {selected_count} selected cleaner(s):"
1458        ))]),
1459        Line::from(vec![Span::raw("")]),
1460    ];
1461
1462    for (_, _, name, _, requires_root) in app.pending_run_selection.iter().take(15) {
1463        let suffix = if *requires_root { " (root)" } else { "" };
1464        lines.push(Line::from(vec![Span::raw(format!("  • {name}{suffix}"))]));
1465    }
1466    if app.pending_run_selection.len() > 15 {
1467        lines.push(Line::from(vec![Span::styled(
1468            format!("  … and {} more", app.pending_run_selection.len() - 15),
1469            Style::default().fg(Color::DarkGray),
1470        )]));
1471    }
1472
1473    lines.push(Line::from(vec![Span::raw("")]));
1474    lines.push(Line::from(vec![Span::styled(
1475        "Enter/y: Run now    Esc/n: Cancel",
1476        Style::default()
1477            .fg(Color::Green)
1478            .add_modifier(Modifier::BOLD),
1479    )]));
1480
1481    let popup_widget = Paragraph::new(lines)
1482        .block(
1483            Block::default()
1484                .title("Confirm")
1485                .borders(Borders::ALL)
1486                .border_style(Style::default().fg(Color::Yellow)),
1487        )
1488        .wrap(Wrap { trim: true });
1489
1490    f.render_widget(Clear, popup);
1491    f.render_widget(popup_widget, popup);
1492}
1493
1494/// Overlay shown for a preview (dry-run): lists what *would* be cleaned and
1495/// its real measured size, without anything having been deleted.
1496fn render_preview(f: &mut Frame, app: &App, area: Rect) {
1497    let popup = centered_popup(area, 80, 75);
1498
1499    let total_bytes: u64 = app.preview_results.iter().map(|(_, r)| r.total_bytes).sum();
1500    let total_items: usize = app
1501        .preview_results
1502        .iter()
1503        .map(|(_, r)| r.item_count())
1504        .sum();
1505
1506    let mut lines = vec![
1507        Line::from(vec![Span::styled(
1508            "🔍 Preview (dry-run)",
1509            Style::default()
1510                .fg(Color::Cyan)
1511                .add_modifier(Modifier::BOLD),
1512        )]),
1513        Line::from(vec![Span::raw("")]),
1514        Line::from(vec![Span::raw(format!(
1515            "Would free {} across {total_items} item(s). Nothing has been deleted.",
1516            format_size(total_bytes)
1517        ))]),
1518        Line::from(vec![Span::raw("")]),
1519    ];
1520
1521    if app.preview_results.is_empty() {
1522        lines.push(Line::from(vec![Span::styled(
1523            "Nothing to clean — all selected cleaners are already empty.",
1524            Style::default().fg(Color::DarkGray),
1525        )]));
1526    }
1527
1528    for (name, result) in &app.preview_results {
1529        lines.push(Line::from(vec![Span::styled(
1530            format!(
1531                "{name} — {} across {} item(s)",
1532                format_size(result.total_bytes),
1533                result.item_count()
1534            ),
1535            Style::default().add_modifier(Modifier::BOLD),
1536        )]));
1537        for item in result.items.iter().take(3) {
1538            lines.push(Line::from(vec![Span::raw(format!(
1539                "    • {} ({})",
1540                item.path_str(),
1541                format_size(item.size)
1542            ))]));
1543        }
1544        if result.items.len() > 3 {
1545            lines.push(Line::from(vec![Span::styled(
1546                format!("    … and {} more", result.items.len() - 3),
1547                Style::default().fg(Color::DarkGray),
1548            )]));
1549        }
1550        lines.push(Line::from(vec![Span::raw("")]));
1551    }
1552
1553    lines.push(Line::from(vec![Span::styled(
1554        "Enter/Esc/q: Close",
1555        Style::default()
1556            .fg(Color::Green)
1557            .add_modifier(Modifier::BOLD),
1558    )]));
1559
1560    let popup_widget = Paragraph::new(lines)
1561        .block(
1562            Block::default()
1563                .title("Preview")
1564                .borders(Borders::ALL)
1565                .border_style(Style::default().fg(Color::Cyan)),
1566        )
1567        .wrap(Wrap { trim: true });
1568
1569    f.render_widget(Clear, popup);
1570    f.render_widget(popup_widget, popup);
1571}
1572
1573/// Overlay shown when a selected cleaner needs Administrator privileges on
1574/// Windows, where there is no interactive sudo-password prompt to fall back
1575/// to — the user must restart the process elevated themselves.
1576fn render_admin_notice(f: &mut Frame, area: Rect) {
1577    let popup = centered_popup(area, 60, 30);
1578
1579    let lines = vec![
1580        Line::from(vec![Span::styled(
1581            "⚠️  Administrator privileges required",
1582            Style::default()
1583                .fg(Color::Yellow)
1584                .add_modifier(Modifier::BOLD),
1585        )]),
1586        Line::from(vec![Span::raw("")]),
1587        Line::from(vec![Span::raw(
1588            "One or more selected cleaners need Administrator privileges.",
1589        )]),
1590        Line::from(vec![Span::raw(
1591            "Restart CleanSys as Administrator to use them.",
1592        )]),
1593        Line::from(vec![Span::raw("")]),
1594        Line::from(vec![Span::styled(
1595            "Enter/Esc/q: Close",
1596            Style::default()
1597                .fg(Color::Green)
1598                .add_modifier(Modifier::BOLD),
1599        )]),
1600    ];
1601
1602    let popup_widget = Paragraph::new(lines)
1603        .block(
1604            Block::default()
1605                .title("Administrator required")
1606                .borders(Borders::ALL)
1607                .border_style(Style::default().fg(Color::Yellow)),
1608        )
1609        .wrap(Wrap { trim: true });
1610
1611    f.render_widget(Clear, popup);
1612    f.render_widget(popup_widget, popup);
1613}