use ratatui::{
layout::{Constraint, Direction, Layout, Rect},
style::{Color, Modifier, Style},
symbols,
text::{Line, Span},
widgets::{Axis, Block, Borders, Chart, Clear, Dataset, List, ListItem, Paragraph, Wrap},
Frame,
};
use tui_checkbox::{symbols as checkbox_symbols, Checkbox};
use tui_spinner::{FluxFrames, FluxSpinner};
use crate::app::{App, ChartType, CleanedItemType};
use crate::pie_chart::create_pie_chart_from_distribution;
use cleansys_core::{format_size, Status};
pub fn ui(f: &mut Frame, app: &mut App) {
app.update_animation();
let (title_height, footer_height, min_content_height) = if app.terminal_height < 20 {
(2, 2, 6)
} else if app.terminal_height < 30 {
(2, 2, 8)
} else if app.terminal_height < 40 {
(3, 3, 10)
} else {
(3, 3, 12)
};
let chunks = Layout::default()
.direction(Direction::Vertical)
.constraints([
Constraint::Length(title_height), Constraint::Min(min_content_height), Constraint::Length(footer_height), ])
.split(f.area());
render_title(f, app, chunks[0]);
if app.show_help {
render_help(f, chunks[1]);
} else if app.is_running || app.show_progress_screen {
render_progress_screen(f, app, chunks[1]);
} else {
render_main_content(f, app, chunks[1]);
}
render_footer(f, app, chunks[2]);
if app.password_prompt.is_visible() {
app.password_prompt.render(f, f.area());
}
if app.needs_admin_notice {
render_admin_notice(f, f.area());
} else if app.awaiting_run_confirmation {
render_confirm_run(f, app, f.area());
} else if app.preview_open {
render_preview(f, app, f.area());
}
}
fn render_title(f: &mut Frame, app: &App, area: Rect) {
let title_lines = if app.terminal_width < 80 {
let mut lines = vec![Line::from(vec![
Span::styled(
"Cleansys",
Style::default()
.fg(Color::Cyan)
.add_modifier(Modifier::BOLD),
),
Span::raw(" - System Cleaner"),
if app.terminal_width < 60 || app.terminal_height < 20 {
Span::styled(
format!(" [{}x{}]", app.terminal_width, app.terminal_height),
Style::default().fg(Color::DarkGray),
)
} else {
Span::raw("")
},
])];
lines.push(Line::from(vec![
Span::styled("?", Style::default().add_modifier(Modifier::BOLD)),
Span::raw(" help | "),
Span::styled("q", Style::default().add_modifier(Modifier::BOLD)),
Span::raw(" quit"),
]));
lines
} else {
vec![
Line::from(vec![
Span::styled(
"Cleansys",
Style::default()
.fg(Color::Cyan)
.add_modifier(Modifier::BOLD),
),
Span::raw(" - Modern System Cleaner for Linux"),
]),
Line::from(vec![
Span::raw("Press "),
Span::styled("?", Style::default().add_modifier(Modifier::BOLD)),
Span::raw(" for help, "),
Span::styled("q", Style::default().add_modifier(Modifier::BOLD)),
Span::raw(" to quit"),
]),
]
};
let title = Paragraph::new(title_lines).block(Block::default().borders(Borders::BOTTOM));
f.render_widget(title, area);
if app.is_running && area.width > 14 {
let spinner_width = 12u16;
let spinner_area = Rect {
x: area.x + area.width.saturating_sub(spinner_width + 1),
y: area.y,
width: spinner_width,
height: 1,
};
let label = Line::from(vec![
Span::raw(" "),
Span::styled(
"RUNNING",
Style::default()
.fg(Color::Yellow)
.add_modifier(Modifier::BOLD),
),
Span::raw(" "),
]);
let label_width = label.width() as u16;
let (label_area, glyph_area) = (
Rect {
width: label_width.min(spinner_width),
..spinner_area
},
Rect {
x: spinner_area.x + label_width.min(spinner_width),
width: spinner_area.width.saturating_sub(label_width),
..spinner_area
},
);
f.render_widget(Paragraph::new(label), label_area);
f.render_widget(
FluxSpinner::new(app.animation_frame as u64)
.frames(FluxFrames::CLASSIC)
.color(Color::Cyan),
glyph_area,
);
}
}
fn render_main_content(f: &mut Frame, app: &mut App, area: Rect) {
let (categories_percent, content_percent) = if app.terminal_width < 80 {
(25, 75)
} else if app.terminal_width < 120 {
(30, 70)
} else {
(35, 65)
};
let horizontal_chunks = Layout::default()
.direction(Direction::Horizontal)
.constraints([
Constraint::Percentage(categories_percent), Constraint::Percentage(content_percent), ])
.split(area);
render_categories(f, app, horizontal_chunks[0]);
if app.detailed_view {
render_details(f, app, horizontal_chunks[1]);
} else {
render_cleaners(f, app, horizontal_chunks[1]);
}
}
fn render_progress_screen(f: &mut Frame, app: &mut App, area: Rect) {
render_unified_progress_view(f, app, area);
}
fn render_unified_progress_view(f: &mut Frame, app: &mut App, area: Rect) {
app.update_counters();
if area.width < 50 || area.height < 15 {
render_ultra_compact_view(f, app, area);
return;
}
let main_chunks = Layout::default()
.direction(Direction::Vertical)
.constraints([
Constraint::Percentage(if app.terminal_height >= 35 {
55
} else if app.terminal_height >= 25 {
50
} else {
45
}), Constraint::Percentage(if app.terminal_height >= 35 {
45
} else if app.terminal_height >= 25 {
50
} else {
55
}), ])
.margin(1)
.split(area);
render_combined_progress_overview(f, app, main_chunks[0]);
render_removed_items_window(f, app, main_chunks[1]);
}
fn render_combined_progress_overview(f: &mut Frame, app: &App, area: Rect) {
let block = Block::default()
.title("📊 Progress Overview & Operations")
.title_style(
Style::default()
.fg(Color::Cyan)
.add_modifier(Modifier::BOLD),
)
.borders(Borders::ALL)
.border_style(Style::default().fg(Color::Cyan));
let inner_area = block.inner(area);
let stats_height = if area.height < 15 {
5 } else if area.height < 20 {
7 } else if area.height < 25 {
9 } else {
12 };
let main_sections = Layout::default()
.direction(Direction::Vertical)
.constraints([
Constraint::Length(stats_height), Constraint::Min(6), ])
.split(inner_area);
render_progress_stats_and_chart(f, app, main_sections[0]);
render_operations_summary(f, app, main_sections[1]);
f.render_widget(block, area);
}
fn render_progress_stats_and_chart(f: &mut Frame, app: &App, area: Rect) {
let elapsed_time = app.get_elapsed_time();
let total_ops = app.operation_count;
let completed_ops = total_ops.saturating_sub(app.errors_count);
let progress_percent = completed_ops
.checked_mul(100)
.and_then(|v| v.checked_div(total_ops))
.unwrap_or(0);
let show_chart = area.width >= 80;
let horizontal_chunks = if show_chart {
let stats_percent = if area.width < 100 {
45 } else if area.width < 130 {
40 } else {
35 };
Layout::default()
.direction(Direction::Horizontal)
.constraints([
Constraint::Percentage(stats_percent),
Constraint::Percentage(100 - stats_percent),
])
.split(area)
} else {
Layout::default()
.direction(Direction::Horizontal)
.constraints([Constraint::Percentage(100)])
.split(area)
};
let stats_lines = vec![
Line::from(vec![
Span::styled(
"Progress: ",
Style::default()
.fg(Color::White)
.add_modifier(Modifier::BOLD),
),
Span::styled(
format!("{}%", progress_percent),
Style::default()
.fg(Color::Green)
.add_modifier(Modifier::BOLD),
),
Span::raw(format!(" ({}/{})", completed_ops, total_ops)),
Span::raw(" ⏱️ "),
Span::styled(
elapsed_time,
Style::default()
.fg(Color::Cyan)
.add_modifier(Modifier::BOLD),
),
]),
Line::from(vec![
Span::raw("█".repeat((progress_percent * 35) / 100)),
Span::styled(
"░".repeat(35 - (progress_percent * 35) / 100),
Style::default().fg(Color::DarkGray),
),
]),
Line::from(vec![
Span::styled("✅ ", Style::default().fg(Color::Green)),
Span::styled(
format!("{} OK", completed_ops),
Style::default().fg(Color::Green),
),
Span::raw(" "),
Span::styled("⚡ ", Style::default().fg(Color::Yellow)),
Span::styled(
format!(
"{} Active",
if app.is_running {
total_ops.saturating_sub(completed_ops)
} else {
0
}
),
Style::default().fg(Color::Yellow),
),
Span::raw(" "),
Span::styled("❌ ", Style::default().fg(Color::Red)),
Span::styled(
format!("{} Errors", app.errors_count),
Style::default().fg(Color::Red),
),
]),
Line::from(vec![
Span::styled(
"💾 Total freed: ",
Style::default()
.fg(Color::White)
.add_modifier(Modifier::BOLD),
),
Span::styled(
format_size(app.total_bytes_cleaned),
Style::default()
.fg(Color::Green)
.add_modifier(Modifier::BOLD),
),
]),
];
let stats_para = Paragraph::new(stats_lines);
f.render_widget(stats_para, horizontal_chunks[0]);
if show_chart && horizontal_chunks.len() > 1 {
match app.chart_type {
ChartType::Bar => {
render_vertical_bar_chart(f, app, horizontal_chunks[1]);
}
ChartType::PieCount => {
render_pie_chart_distribution(f, app, horizontal_chunks[1]);
}
ChartType::PieSize => {
render_pie_chart_size_distribution(f, app, horizontal_chunks[1]);
}
}
}
}
fn render_ultra_compact_view(f: &mut Frame, app: &App, area: Rect) {
let elapsed_time = app.get_elapsed_time();
let total_ops = app.operation_count;
let completed_ops = total_ops.saturating_sub(app.errors_count);
let progress_percent = completed_ops
.checked_mul(100)
.and_then(|v| v.checked_div(total_ops))
.unwrap_or(0);
let compact_lines = vec![
Line::from(vec![Span::styled(
format!("Cleansys [{}x{}]", area.width, area.height),
Style::default()
.fg(Color::Cyan)
.add_modifier(Modifier::BOLD),
)]),
Line::from(vec![
Span::styled(
format!("{}% ", progress_percent),
Style::default()
.fg(Color::Green)
.add_modifier(Modifier::BOLD),
),
Span::raw("█".repeat(
((progress_percent * (area.width.saturating_sub(10) as usize)) / 100).min(30),
)),
]),
Line::from(vec![
Span::styled(
format!("✅{} ❌{} ", completed_ops, app.errors_count),
Style::default().fg(Color::White),
),
Span::styled(
format_size(app.total_bytes_cleaned),
Style::default()
.fg(Color::Green)
.add_modifier(Modifier::BOLD),
),
]),
Line::from(vec![
Span::styled(
format!("⏱️{} ", elapsed_time),
Style::default().fg(Color::Cyan),
),
Span::styled(
if app.is_running { "RUNNING" } else { "DONE" },
Style::default().fg(if app.is_running {
Color::Yellow
} else {
Color::Green
}),
),
]),
];
let block = Block::default()
.borders(Borders::ALL)
.border_style(Style::default().fg(Color::DarkGray));
let para = Paragraph::new(compact_lines)
.block(block)
.wrap(Wrap { trim: true });
f.render_widget(para, area);
}
fn render_vertical_bar_chart(f: &mut Frame, app: &App, area: Rect) {
let category_distribution = app.get_category_distribution();
if !category_distribution.is_empty() {
let limited_data: Vec<_> = category_distribution.iter().take(6).collect();
let max_count = limited_data
.iter()
.map(|(_, count, _)| *count)
.max()
.unwrap_or(1) as f64;
let chart_data: Vec<(f64, f64)> = limited_data
.iter()
.enumerate()
.map(|(i, (_, count, _))| (i as f64, *count as f64))
.collect();
let category_names: Vec<&str> = limited_data
.iter()
.map(|(name, _, _)| {
if area.width < 80 {
if name.len() > 6 {
&name[..6]
} else {
name
}
} else if area.width < 100 {
if name.len() > 8 {
&name[..8]
} else {
name
}
} else if name.len() > 12 {
&name[..12]
} else {
name
}
})
.collect();
let dataset = Dataset::default()
.name("Cleaned Items")
.marker(symbols::Marker::Block)
.style(
Style::default()
.fg(Color::Cyan)
.add_modifier(Modifier::BOLD),
)
.data(&chart_data);
let x_labels = if category_names.len() <= 3 {
vec![
Span::raw(category_names.first().unwrap_or(&"").to_string()),
Span::raw(category_names.get(1).unwrap_or(&"").to_string()),
Span::raw(category_names.get(2).unwrap_or(&"").to_string()),
]
} else {
vec![
Span::raw(category_names.first().unwrap_or(&"").to_string()),
Span::raw(
category_names
.get(category_names.len() / 2)
.unwrap_or(&"")
.to_string(),
),
Span::raw(category_names.last().unwrap_or(&"").to_string()),
]
};
let y_max = (max_count * 1.1).max(1.0); let y_labels = vec![
Span::raw("0"),
Span::raw(format!("{}", (y_max / 2.0) as u64)),
Span::raw(format!("{}", y_max as u64)),
];
let chart = Chart::new(vec![dataset])
.block(
Block::default()
.title(if area.width < 50 {
"Items (Bar)"
} else {
"Items Distribution (Bar Chart)"
})
.title_style(
Style::default()
.fg(Color::Cyan)
.add_modifier(Modifier::BOLD),
)
.borders(Borders::ALL)
.border_style(Style::default().fg(Color::Cyan)),
)
.x_axis(
Axis::default()
.title(if area.width >= 80 { "Categories" } else { "" })
.style(Style::default().fg(Color::White))
.bounds([0.0, (category_names.len().max(3) - 1) as f64])
.labels(x_labels),
)
.y_axis(
Axis::default()
.title(if area.width >= 80 { "Count" } else { "" })
.style(Style::default().fg(Color::White))
.bounds([0.0, y_max])
.labels(y_labels),
);
f.render_widget(chart, area);
}
}
fn render_operations_summary(f: &mut Frame, app: &App, area: Rect) {
let columns = Layout::default()
.direction(Direction::Horizontal)
.constraints([
Constraint::Percentage(48), Constraint::Percentage(4), Constraint::Percentage(48), ])
.split(area);
let user_operations = vec![
ListItem::new(Line::from(vec![Span::styled(
"👤 USER OPERATIONS",
Style::default()
.fg(Color::Green)
.add_modifier(Modifier::BOLD),
)])),
ListItem::new(Line::from(vec![])),
ListItem::new(Line::from(vec![
Span::styled("📦 ", Style::default().fg(Color::Green)),
Span::styled("Package Caches", Style::default().fg(Color::White)),
])),
ListItem::new(Line::from(vec![
Span::styled("🗑️ ", Style::default().fg(Color::Green)),
Span::styled("Trash & Temp Files", Style::default().fg(Color::White)),
])),
ListItem::new(Line::from(vec![
Span::styled("🌐 ", Style::default().fg(Color::Green)),
Span::styled("Browser Caches", Style::default().fg(Color::White)),
])),
];
let system_operations = vec![
ListItem::new(Line::from(vec![Span::styled(
"🔒 SYSTEM OPERATIONS",
Style::default()
.fg(Color::Yellow)
.add_modifier(Modifier::BOLD),
)])),
ListItem::new(Line::from(vec![])),
ListItem::new(Line::from(vec![
Span::styled(
"📦 ",
if app.is_root {
Style::default().fg(Color::Green)
} else {
Style::default().fg(Color::Yellow)
},
),
Span::styled("Package Caches", Style::default().fg(Color::White)),
if !app.is_root {
Span::styled(" (sudo)", Style::default().fg(Color::Yellow))
} else {
Span::raw("")
},
])),
ListItem::new(Line::from(vec![
Span::styled(
"📝 ",
if app.is_root {
Style::default().fg(Color::Green)
} else {
Style::default().fg(Color::Yellow)
},
),
Span::styled("System Logs", Style::default().fg(Color::White)),
if !app.is_root {
Span::styled(" (sudo)", Style::default().fg(Color::Yellow))
} else {
Span::raw("")
},
])),
ListItem::new(Line::from(vec![
Span::styled(
"🗄️ ",
if app.is_root {
Style::default().fg(Color::Green)
} else {
Style::default().fg(Color::Yellow)
},
),
Span::styled("System Temp Files", Style::default().fg(Color::White)),
if !app.is_root {
Span::styled(" (sudo)", Style::default().fg(Color::Yellow))
} else {
Span::raw("")
},
])),
];
let user_list = List::new(user_operations);
let system_list = List::new(system_operations);
f.render_widget(user_list, columns[0]);
f.render_widget(system_list, columns[2]);
}
fn render_pie_chart_distribution(f: &mut Frame, app: &App, area: Rect) {
let category_distribution = app.get_category_distribution();
if !category_distribution.is_empty() && area.width >= 20 && area.height >= 8 {
let chart = create_pie_chart_from_distribution(
&category_distribution,
"Items Distribution (Count)",
false, )
.show_percentages(area.width >= 40)
.show_legend(area.width >= 50 || area.height >= 16);
f.render_widget(chart, area);
}
}
fn render_pie_chart_size_distribution(f: &mut Frame, app: &App, area: Rect) {
let category_distribution = app.get_category_distribution();
if !category_distribution.is_empty() && area.width >= 20 && area.height >= 8 {
let chart = create_pie_chart_from_distribution(
&category_distribution,
"Items Distribution (Size)",
true, )
.show_percentages(area.width >= 40)
.show_legend(area.width >= 50 || area.height >= 16);
f.render_widget(chart, area);
}
}
fn render_removed_items_window(f: &mut Frame, app: &mut App, area: Rect) {
let title = if app.is_running {
"📋 Operation Progress"
} else if app.show_progress_screen {
"📋 Cleaning Results - Removed Items"
} else {
"📋 Removed Items Details"
};
let block = Block::default()
.title(title)
.title_style(
Style::default()
.fg(Color::Yellow)
.add_modifier(Modifier::BOLD),
)
.borders(Borders::ALL)
.border_style(Style::default().fg(Color::Yellow));
let inner_area = block.inner(area);
let mut display_items = Vec::new();
if app.is_running && !app.operation_logs.is_empty() {
for log_entry in app.operation_logs.iter().rev().take(15) {
let (icon, color) = if log_entry.contains("✅") {
("✅", Color::Green)
} else if log_entry.contains("❌") {
("❌", Color::Red)
} else if log_entry.contains("🔄") {
("🔄", Color::Yellow)
} else if log_entry.contains("📊") {
("📊", Color::Cyan)
} else {
("ℹ️", Color::White)
};
display_items.push(ListItem::new(Line::from(vec![
Span::styled(format!("{} ", icon), Style::default().fg(color)),
Span::styled(log_entry.clone(), Style::default().fg(Color::White)),
])));
}
} else {
let filtered_items = app.get_filtered_detailed_items();
if !filtered_items.is_empty() {
for (index, item) in filtered_items.iter().enumerate() {
let icon = match item.item_type {
CleanedItemType::File => "📄",
CleanedItemType::Directory => "📁",
CleanedItemType::Log => "📝",
};
display_items.push(ListItem::new(Line::from(vec![
Span::styled(format!("{} ", icon), Style::default().fg(Color::Yellow)),
Span::styled(item.path.clone(), Style::default().fg(Color::White)),
Span::raw(" "),
Span::styled(
format!("({})", format_size(item.size)),
Style::default()
.fg(Color::Green)
.add_modifier(Modifier::BOLD),
),
])));
display_items.push(ListItem::new(Line::from(vec![
Span::raw(" "),
Span::styled("📂 ", Style::default().fg(Color::Blue)),
Span::styled(item.category.clone(), Style::default().fg(Color::Blue)),
Span::raw(" • "),
Span::styled("🔧 ", Style::default().fg(Color::Cyan)),
Span::styled(item.cleaner_name.clone(), Style::default().fg(Color::Cyan)),
])));
if index < filtered_items.len() - 1 {
display_items.push(ListItem::new(Line::from(vec![])));
}
}
} else if !app.is_running && app.show_progress_screen && app.total_bytes_cleaned > 0 {
display_items.push(ListItem::new(Line::from(vec![
Span::styled("✅ ", Style::default().fg(Color::Green)),
Span::styled(
"Cleaning completed successfully",
Style::default()
.fg(Color::Green)
.add_modifier(Modifier::BOLD),
),
])));
display_items.push(ListItem::new(Line::from(vec![])));
display_items.push(ListItem::new(Line::from(vec![
Span::styled("📊 ", Style::default().fg(Color::Cyan)),
Span::styled("Total space freed: ", Style::default().fg(Color::White)),
Span::styled(
format_size(app.total_bytes_cleaned),
Style::default()
.fg(Color::Green)
.add_modifier(Modifier::BOLD),
),
])));
display_items.push(ListItem::new(Line::from(vec![])));
for category in &app.categories {
for item in &category.items {
if item.bytes_cleaned > 0 {
display_items.push(ListItem::new(Line::from(vec![
Span::styled("🔧 ", Style::default().fg(Color::Yellow)),
Span::styled(item.name.clone(), Style::default().fg(Color::White)),
Span::raw(": "),
Span::styled(
format_size(item.bytes_cleaned),
Style::default().fg(Color::Green),
),
])));
}
}
}
if display_items.len() == 3 {
display_items.push(ListItem::new(Line::from(vec![])));
display_items.push(ListItem::new(Line::from(vec![
Span::styled("ℹ️ ", Style::default().fg(Color::Blue)),
Span::styled(
"Detailed file list not available in TUI mode",
Style::default().fg(Color::DarkGray),
),
])));
}
}
}
let items_list = List::new(display_items)
.block(Block::default())
.highlight_style(
Style::default()
.bg(Color::DarkGray)
.add_modifier(Modifier::BOLD),
)
.highlight_symbol("► ");
f.render_stateful_widget(items_list, inner_area, &mut app.detailed_list_scroll_state);
f.render_widget(block, area);
}
fn render_categories(f: &mut Frame, app: &App, area: Rect) {
let categories: Vec<ListItem> = app
.categories
.iter()
.enumerate()
.map(|(i, category)| {
let content = Line::from(format!("{} ({})", category.name, category.description));
let style = if i == app.category_index {
Style::default()
.fg(Color::Yellow)
.add_modifier(Modifier::BOLD)
} else {
Style::default()
};
ListItem::new(content).style(style)
})
.collect();
let categories_list = List::new(categories)
.block(
Block::default()
.title("📂 Categories")
.borders(Borders::ALL),
)
.highlight_style(
Style::default()
.add_modifier(Modifier::BOLD)
.fg(Color::Yellow),
);
f.render_widget(categories_list, area);
}
fn render_cleaners(f: &mut Frame, app: &mut App, area: Rect) {
let current_category = &app.categories[app.category_index];
let items: Vec<ListItem> = current_category
.items
.iter()
.map(|item| {
let mut parts = vec![];
let checkbox_style = if item.selected {
Style::default()
.fg(Color::Green)
.add_modifier(Modifier::BOLD)
} else {
Style::default().fg(Color::White)
};
let _checkbox = Checkbox::new("", item.selected)
.checked_symbol(checkbox_symbols::CHECKED_X)
.unchecked_symbol(checkbox_symbols::UNCHECKED_SPACE);
let checkbox_symbol = if item.selected {
checkbox_symbols::CHECKED_X
} else {
checkbox_symbols::UNCHECKED_SPACE
};
parts.push(Span::styled(checkbox_symbol, checkbox_style));
parts.push(Span::raw(" "));
let name_style = if item.requires_root && !app.is_root {
Style::default().fg(Color::DarkGray)
} else {
Style::default().fg(Color::White)
};
parts.push(Span::styled(&item.name, name_style));
if item.requires_root {
parts.push(Span::styled(" (root)", Style::default().fg(Color::Red)));
}
if let Some(status) = &item.status {
match status {
Status::Running => {
parts.push(Span::styled(
" [Running]",
Style::default().fg(Color::Yellow),
));
}
Status::Success(msg) => {
parts.push(Span::styled(
format!(" [{}]", msg),
Style::default().fg(Color::Green),
));
}
Status::Error(msg) => {
parts.push(Span::styled(
format!(" [Error: {}]", msg),
Style::default().fg(Color::Red),
));
}
Status::Pending => {
parts.push(Span::styled(
" [Pending]",
Style::default().fg(Color::DarkGray),
));
}
}
}
if item.bytes_cleaned > 0 {
parts.push(Span::styled(
format!(" (Freed: {})", format_size(item.bytes_cleaned)),
Style::default().fg(Color::Green),
));
}
ListItem::new(Line::from(parts))
})
.collect();
let items_list = List::new(items)
.block(
Block::default()
.title(format!("{} Items", current_category.name))
.borders(Borders::ALL),
)
.highlight_style(
Style::default()
.add_modifier(Modifier::BOLD)
.bg(Color::DarkGray),
)
.highlight_symbol("> ");
f.render_stateful_widget(items_list, area, &mut app.item_list_state);
}
fn render_details(f: &mut Frame, app: &App, area: Rect) {
let current_category = &app.categories[app.category_index];
if let Some(selected) = app.item_list_state.selected() {
if selected < current_category.items.len() {
let item = ¤t_category.items[selected];
let mut text = vec![
Line::from(vec![Span::styled(
format!("{} Keyboard Controls", item.name),
Style::default()
.fg(Color::Cyan)
.add_modifier(Modifier::BOLD),
)]),
Line::from(vec![Span::raw("")]),
Line::from(vec![
Span::raw("Description: "),
Span::styled(&item.description, Style::default().fg(Color::White)),
]),
Line::from(vec![Span::raw("")]),
Line::from(vec![
Span::raw("Requires root: "),
if item.requires_root {
Span::styled("Yes", Style::default().fg(Color::Red))
} else {
Span::styled("No", Style::default().fg(Color::Green))
},
]),
Line::from(vec![
Span::raw("Status: "),
match &item.status {
Some(Status::Running) => {
let spinner = Status::Running.get_animation_frame(app.animation_frame);
Span::styled(
format!("{} Running...", spinner),
Style::default().fg(Color::Yellow),
)
}
Some(Status::Success(msg)) => {
Span::styled(format!("✓ {}", msg), Style::default().fg(Color::Green))
}
Some(Status::Error(msg)) => Span::styled(
format!("✗ Error: {}", msg),
Style::default().fg(Color::Red),
),
Some(Status::Pending) => {
Span::styled("• Waiting to start", Style::default().fg(Color::DarkGray))
}
None => Span::raw("Not run"),
},
]),
];
if item.bytes_cleaned > 0 {
text.push(Line::from(vec![
Span::raw("Space freed: "),
Span::styled(
format!("{:.2} GB", item.bytes_cleaned as f64 / 1_073_741_824.0),
Style::default().fg(Color::Green),
),
]));
}
let details = Paragraph::new(text)
.block(Block::default().title("Details").borders(Borders::ALL))
.wrap(Wrap { trim: true });
f.render_widget(details, area);
}
}
}
fn render_footer(f: &mut Frame, app: &App, area: Rect) {
let block = Block::default()
.borders(Borders::TOP)
.border_style(Style::default().fg(Color::DarkGray));
let inner_area = block.inner(area);
if app.is_running || app.show_progress_screen {
let footer_chunks = Layout::default()
.direction(Direction::Horizontal)
.constraints([
Constraint::Percentage(60), Constraint::Percentage(40), ])
.split(inner_area);
let status_text = vec![Line::from(vec![
Span::styled(
"Status: ",
Style::default()
.fg(Color::White)
.add_modifier(Modifier::BOLD),
),
if app.paused {
Span::styled(
"PAUSED",
Style::default()
.fg(Color::Yellow)
.add_modifier(Modifier::BOLD),
)
} else if app.is_running {
Span::styled(
"CLEANING",
Style::default()
.fg(Color::Green)
.add_modifier(Modifier::BOLD),
)
} else if app.operation_end_time.is_some() {
Span::styled(
"FINISHED",
Style::default()
.fg(Color::Cyan)
.add_modifier(Modifier::BOLD),
)
} else {
Span::styled(
"READY",
Style::default()
.fg(Color::White)
.add_modifier(Modifier::BOLD),
)
},
Span::raw(" • "),
Span::styled("Total Freed: ", Style::default().fg(Color::White)),
Span::styled(
format_size(app.total_bytes_cleaned),
Style::default()
.fg(Color::Green)
.add_modifier(Modifier::BOLD),
),
])];
let controls_text = if app.is_running {
vec![Line::from(vec![
Span::styled(
"ESC",
Style::default()
.fg(Color::Yellow)
.add_modifier(Modifier::BOLD),
),
Span::raw(": Cancel "),
Span::styled(
"↑/↓",
Style::default()
.fg(Color::Cyan)
.add_modifier(Modifier::BOLD),
),
Span::raw(": Scroll Items "),
Span::styled(
"q",
Style::default().fg(Color::Red).add_modifier(Modifier::BOLD),
),
Span::raw(": Quit"),
])]
} else {
vec![Line::from(vec![
Span::styled(
"ESC",
Style::default()
.fg(Color::Yellow)
.add_modifier(Modifier::BOLD),
),
Span::raw(": Return to Menu "),
Span::styled(
"↑/↓",
Style::default()
.fg(Color::Cyan)
.add_modifier(Modifier::BOLD),
),
Span::raw(": Scroll Items "),
Span::styled(
"q",
Style::default().fg(Color::Red).add_modifier(Modifier::BOLD),
),
Span::raw(": Quit"),
])]
};
let status_para = Paragraph::new(status_text);
let controls_para =
Paragraph::new(controls_text).alignment(ratatui::layout::Alignment::Right);
f.render_widget(status_para, footer_chunks[0]);
f.render_widget(controls_para, footer_chunks[1]);
} else {
let footer_chunks = Layout::default()
.direction(Direction::Horizontal)
.constraints([
Constraint::Percentage(40), Constraint::Percentage(60), ])
.split(inner_area);
let status_text = vec![Line::from(vec![
Span::styled(
"User: ",
Style::default()
.fg(Color::White)
.add_modifier(Modifier::BOLD),
),
if app.is_root {
Span::styled(
"root",
Style::default().fg(Color::Red).add_modifier(Modifier::BOLD),
)
} else {
Span::styled(
"standard",
Style::default()
.fg(Color::Green)
.add_modifier(Modifier::BOLD),
)
},
Span::raw(" • "),
Span::styled("Selected: ", Style::default().fg(Color::White)),
Span::styled(
format!("{}", app.selected_cleaners_count),
Style::default()
.fg(Color::Blue)
.add_modifier(Modifier::BOLD),
),
])];
let controls_text = vec![Line::from(vec![
Span::styled(
"Space",
Style::default()
.fg(Color::Yellow)
.add_modifier(Modifier::BOLD),
),
Span::raw(": Select "),
Span::styled(
"Enter",
Style::default()
.fg(Color::Green)
.add_modifier(Modifier::BOLD),
),
Span::raw(": Run "),
Span::styled(
"Tab",
Style::default()
.fg(Color::Blue)
.add_modifier(Modifier::BOLD),
),
Span::raw(": Category "),
Span::styled(
"?",
Style::default()
.fg(Color::Magenta)
.add_modifier(Modifier::BOLD),
),
Span::raw(": Help "),
Span::styled(
"q",
Style::default().fg(Color::Red).add_modifier(Modifier::BOLD),
),
Span::raw(": Quit"),
])];
let status_para = Paragraph::new(status_text);
let controls_para =
Paragraph::new(controls_text).alignment(ratatui::layout::Alignment::Right);
f.render_widget(status_para, footer_chunks[0]);
f.render_widget(controls_para, footer_chunks[1]);
}
f.render_widget(block, area);
}
fn render_help(f: &mut Frame, area: Rect) {
let help_text = vec![
Line::from(vec![Span::styled(
"🔍 Cleansys Help",
Style::default()
.fg(Color::Cyan)
.add_modifier(Modifier::BOLD),
)]),
Line::from(vec![Span::raw("")]),
Line::from(vec![Span::styled(
"📍 Navigation:",
Style::default().add_modifier(Modifier::BOLD),
)]),
Line::from(vec![Span::raw(" ↑/↓: Navigate items")]),
Line::from(vec![Span::raw(" Tab/Shift+Tab: Switch categories")]),
Line::from(vec![Span::raw("")]),
Line::from(vec![Span::styled(
"🔧 Actions:",
Style::default().add_modifier(Modifier::BOLD),
)]),
Line::from(vec![Span::raw(" Space: Toggle selection")]),
Line::from(vec![Span::raw(
" Enter: Run selected cleaners (asks for confirmation)",
)]),
Line::from(vec![Span::raw(
" d: Preview selected cleaners (dry-run, deletes nothing)",
)]),
Line::from(vec![Span::raw(" a: Select all in current category")]),
Line::from(vec![Span::raw(" n: Deselect all in current category")]),
Line::from(vec![Span::raw(" A: Select all across every category")]),
Line::from(vec![Span::raw(" N: Deselect all across every category")]),
Line::from(vec![Span::raw(
" c: Cycle chart type (Bar → Count Pie → Size Pie → Bar)",
)]),
Line::from(vec![Span::raw(" /: Search in detailed view")]),
Line::from(vec![Span::raw("")]),
Line::from(vec![Span::styled(
"🎛️ Advanced Controls:",
Style::default().add_modifier(Modifier::BOLD),
)]),
Line::from(vec![Span::raw(" m: Toggle compact mode")]),
Line::from(vec![Span::raw(
" v: Cycle view mode (Standard/Compact/Detailed/Performance)",
)]),
Line::from(vec![Span::raw(" p: Toggle performance statistics")]),
Line::from(vec![Span::raw(
" s: Toggle auto-scroll log (during operations)",
)]),
Line::from(vec![Span::raw(" o: Cycle sort mode")]),
Line::from(vec![Span::raw(" f: Cycle filter mode")]),
Line::from(vec![Span::raw(" y: Toggle confirmation prompts")]),
Line::from(vec![Span::raw(" x: Clear all errors")]),
Line::from(vec![Span::raw(
" j/k: Scroll detailed items list (vi-style)",
)]),
Line::from(vec![Span::raw(" /: Search files/paths in detailed view")]),
Line::from(vec![Span::raw(
" ESC: Clear search / Cancel operation / Return to menu",
)]),
Line::from(vec![Span::raw(" Backspace: Remove search character")]),
Line::from(vec![Span::raw(" PgUp/PgDn: Scroll operation log")]),
Line::from(vec![Span::raw(" Home/End: Jump to first/last item")]),
Line::from(vec![Span::raw(" Ctrl+Space: Pause/Resume operations")]),
Line::from(vec![Span::raw("")]),
Line::from(vec![Span::styled(
"🔍 Search Features:",
Style::default().add_modifier(Modifier::BOLD),
)]),
Line::from(vec![Span::raw(
" Search matches file paths, categories, and cleaner names",
)]),
Line::from(vec![Span::raw(
" Real-time filtering with highlighted results",
)]),
Line::from(vec![Span::raw(" Category distribution shown at bottom")]),
Line::from(vec![Span::raw("")]),
Line::from(vec![Span::styled(
"📊 Chart Types (press 'c' to cycle):",
Style::default().add_modifier(Modifier::BOLD),
)]),
Line::from(vec![Span::raw(
" Bar Chart: Traditional vertical bars for comparison",
)]),
Line::from(vec![Span::raw(
" Pie Count: Circular chart showing item distribution by count",
)]),
Line::from(vec![Span::raw(
" Pie Size: Circular chart showing space usage by category",
)]),
Line::from(vec![Span::raw("")]),
Line::from(vec![Span::styled(
"🔒 System Operations:",
Style::default().add_modifier(Modifier::BOLD),
)]),
Line::from(vec![Span::raw(
" System cleaners require sudo/root privileges",
)]),
Line::from(vec![Span::raw(
" Run 'sudo cleansys' or provide password when prompted",
)]),
Line::from(vec![Span::raw(
" Items marked (sudo) will request elevated privileges",
)]),
Line::from(vec![Span::raw("")]),
Line::from(vec![Span::styled(
"🔄 Other:",
Style::default().add_modifier(Modifier::BOLD),
)]),
Line::from(vec![Span::raw(" ?: Show/hide help")]),
Line::from(vec![Span::raw(" q: Exit application")]),
];
let help = Paragraph::new(help_text)
.block(Block::default().title("📚 Help").borders(Borders::ALL))
.wrap(Wrap { trim: true });
f.render_widget(help, area);
}
fn centered_popup(area: Rect, width_pct: u16, height_pct: u16) -> Rect {
let width = (area.width * width_pct / 100).clamp(30, area.width.saturating_sub(2).max(30));
let height = (area.height * height_pct / 100).clamp(10, area.height.saturating_sub(2).max(10));
let x = area.x + (area.width.saturating_sub(width)) / 2;
let y = area.y + (area.height.saturating_sub(height)) / 2;
Rect {
x,
y,
width,
height,
}
}
fn render_confirm_run(f: &mut Frame, app: &App, area: Rect) {
let popup = centered_popup(area, 70, 60);
let selected_count = app.pending_run_selection.len();
let mut lines = vec![
Line::from(vec![Span::styled(
"⚠️ Confirm Cleaning",
Style::default()
.fg(Color::Yellow)
.add_modifier(Modifier::BOLD),
)]),
Line::from(vec![Span::raw("")]),
Line::from(vec![Span::raw(format!(
"This will permanently delete files for {selected_count} selected cleaner(s):"
))]),
Line::from(vec![Span::raw("")]),
];
for (_, _, name, _, requires_root) in app.pending_run_selection.iter().take(15) {
let suffix = if *requires_root { " (root)" } else { "" };
lines.push(Line::from(vec![Span::raw(format!(" • {name}{suffix}"))]));
}
if app.pending_run_selection.len() > 15 {
lines.push(Line::from(vec![Span::styled(
format!(" … and {} more", app.pending_run_selection.len() - 15),
Style::default().fg(Color::DarkGray),
)]));
}
lines.push(Line::from(vec![Span::raw("")]));
lines.push(Line::from(vec![Span::styled(
"Enter/y: Run now Esc/n: Cancel",
Style::default()
.fg(Color::Green)
.add_modifier(Modifier::BOLD),
)]));
let popup_widget = Paragraph::new(lines)
.block(
Block::default()
.title("Confirm")
.borders(Borders::ALL)
.border_style(Style::default().fg(Color::Yellow)),
)
.wrap(Wrap { trim: true });
f.render_widget(Clear, popup);
f.render_widget(popup_widget, popup);
}
fn render_preview(f: &mut Frame, app: &App, area: Rect) {
let popup = centered_popup(area, 80, 75);
let total_bytes: u64 = app.preview_results.iter().map(|(_, r)| r.total_bytes).sum();
let total_items: usize = app
.preview_results
.iter()
.map(|(_, r)| r.item_count())
.sum();
let mut lines = vec![
Line::from(vec![Span::styled(
"🔍 Preview (dry-run)",
Style::default()
.fg(Color::Cyan)
.add_modifier(Modifier::BOLD),
)]),
Line::from(vec![Span::raw("")]),
Line::from(vec![Span::raw(format!(
"Would free {} across {total_items} item(s). Nothing has been deleted.",
format_size(total_bytes)
))]),
Line::from(vec![Span::raw("")]),
];
if app.preview_results.is_empty() {
lines.push(Line::from(vec![Span::styled(
"Nothing to clean — all selected cleaners are already empty.",
Style::default().fg(Color::DarkGray),
)]));
}
for (name, result) in &app.preview_results {
lines.push(Line::from(vec![Span::styled(
format!(
"{name} — {} across {} item(s)",
format_size(result.total_bytes),
result.item_count()
),
Style::default().add_modifier(Modifier::BOLD),
)]));
for item in result.items.iter().take(3) {
lines.push(Line::from(vec![Span::raw(format!(
" • {} ({})",
item.path_str(),
format_size(item.size)
))]));
}
if result.items.len() > 3 {
lines.push(Line::from(vec![Span::styled(
format!(" … and {} more", result.items.len() - 3),
Style::default().fg(Color::DarkGray),
)]));
}
lines.push(Line::from(vec![Span::raw("")]));
}
lines.push(Line::from(vec![Span::styled(
"Enter/Esc/q: Close",
Style::default()
.fg(Color::Green)
.add_modifier(Modifier::BOLD),
)]));
let popup_widget = Paragraph::new(lines)
.block(
Block::default()
.title("Preview")
.borders(Borders::ALL)
.border_style(Style::default().fg(Color::Cyan)),
)
.wrap(Wrap { trim: true });
f.render_widget(Clear, popup);
f.render_widget(popup_widget, popup);
}
fn render_admin_notice(f: &mut Frame, area: Rect) {
let popup = centered_popup(area, 60, 30);
let lines = vec![
Line::from(vec![Span::styled(
"⚠️ Administrator privileges required",
Style::default()
.fg(Color::Yellow)
.add_modifier(Modifier::BOLD),
)]),
Line::from(vec![Span::raw("")]),
Line::from(vec![Span::raw(
"One or more selected cleaners need Administrator privileges.",
)]),
Line::from(vec![Span::raw(
"Restart CleanSys as Administrator to use them.",
)]),
Line::from(vec![Span::raw("")]),
Line::from(vec![Span::styled(
"Enter/Esc/q: Close",
Style::default()
.fg(Color::Green)
.add_modifier(Modifier::BOLD),
)]),
];
let popup_widget = Paragraph::new(lines)
.block(
Block::default()
.title("Administrator required")
.borders(Borders::ALL)
.border_style(Style::default().fg(Color::Yellow)),
)
.wrap(Wrap { trim: true });
f.render_widget(Clear, popup);
f.render_widget(popup_widget, popup);
}