use crate::app::{App, AppMode};
use crate::utils::{format_bytes, format_speed};
use ratatui::{
Terminal,
layout::{Alignment, Constraint, Direction, Layout},
style::{Color, Modifier, Style},
text::{Line, Span},
widgets::{Block, Borders, Clear, List, ListItem, Paragraph},
};
use std::io::Result;
pub fn render_ui<B: ratatui::backend::Backend>(
terminal: &mut Terminal<B>,
app: &App,
) -> Result<()> {
terminal.draw(|f| {
let size = f.size();
f.render_widget(Clear, size);
let chunks = Layout::default()
.direction(Direction::Vertical)
.margin(1)
.constraints([
Constraint::Length(3), Constraint::Min(0), Constraint::Length(3), ].as_ref())
.split(f.size());
match app.mode {
AppMode::Normal => {
let instructions = Paragraph::new(Line::from(vec![
Span::raw("Press "),
Span::styled("s", Style::default().add_modifier(Modifier::BOLD).fg(Color::Green)),
Span::raw(" to search, "),
Span::styled("↑↓/jk", Style::default().add_modifier(Modifier::BOLD).fg(Color::Yellow)),
Span::raw(" to navigate, "),
Span::styled("p", Style::default().add_modifier(Modifier::BOLD).fg(Color::Cyan)),
Span::raw(" to pause/resume, "),
Span::styled("q", Style::default().add_modifier(Modifier::BOLD).fg(Color::Red)),
Span::raw(" to quit"),
]))
.block(Block::default().title("🏴☠️ TUI Torrent").borders(Borders::ALL));
f.render_widget(instructions, chunks[0]);
},
AppMode::Search => {
let search_text = format!("Search: {}", app.search_query.value());
let search_bar = Paragraph::new(search_text)
.style(Style::default().fg(Color::Yellow))
.block(Block::default().title("🔍 Enter Search Query (Press Enter to search, Esc to cancel)").borders(Borders::ALL));
f.render_widget(search_bar, chunks[0]);
f.set_cursor(
chunks[0].x + 1 + 8 + app.search_query.visual_cursor() as u16,
chunks[0].y + 1
);
},
AppMode::Searching => {
let searching_text = format!("Searching for: {}", app.search_query.value());
let loading_indicator = app.get_loading_indicator();
let title = format!("{} Searching Multiple Sources...", loading_indicator);
let search_bar = Paragraph::new(searching_text)
.style(Style::default().fg(Color::LightYellow))
.block(Block::default().title(title).borders(Borders::ALL));
f.render_widget(search_bar, chunks[0]);
},
AppMode::Results => {
let result_info = Paragraph::new(Line::from(vec![
Span::raw("Press "),
Span::styled("Enter", Style::default().add_modifier(Modifier::BOLD).fg(Color::Green)),
Span::raw(" to download, "),
Span::styled("Esc", Style::default().add_modifier(Modifier::BOLD).fg(Color::Red)),
Span::raw(" to go back, "),
Span::styled("↑↓/jk", Style::default().add_modifier(Modifier::BOLD).fg(Color::Yellow)),
Span::raw(" to navigate"),
]))
.block(Block::default().title(format!("📋 Search Results ({})", app.search_results.len())).borders(Borders::ALL));
f.render_widget(result_info, chunks[0]);
}
}
let status_color = if app.search_in_progress {
Color::Blue
} else if app.status_message.contains("failed")
|| app.status_message.contains("error")
|| app.status_message.contains("Warning")
|| !app.aria2_installed
{
Color::Red
} else {
Color::Green
};
let status_text = if app.search_in_progress {
format!("{} {}", app.get_loading_indicator(), app.search_progress)
} else {
app.status_message.clone()
};
let status_bar = Paragraph::new(status_text)
.style(Style::default().fg(status_color))
.alignment(Alignment::Center)
.block(Block::default().borders(Borders::ALL));
f.render_widget(status_bar, chunks[2]);
match app.mode {
AppMode::Normal | AppMode::Search => {
if !app.aria2_installed {
let warn_msg = Paragraph::new(
"\n⚠️ WARNING: aria2 daemon (aria2c) is not running or not installed!\n\n\
Downloads will not work. Please install or start aria2c:\n\
- macOS: brew install aria2\n\
- Ubuntu/Debian: sudo apt install aria2\n\n\
Press 's' to search torrents anyway (search-only mode)."
)
.style(Style::default().fg(Color::Red))
.alignment(Alignment::Center)
.block(
Block::default()
.title("⚠️ Warning")
.borders(Borders::ALL)
.border_style(Style::default().fg(Color::Red))
);
f.render_widget(warn_msg, chunks[1]);
} else if app.active_downloads.is_empty() {
let empty_msg = Paragraph::new("No active downloads. Press 's' to search for torrents.")
.style(Style::default().fg(Color::Gray))
.alignment(Alignment::Center)
.block(Block::default().title("📥 Active Downloads").borders(Borders::ALL));
f.render_widget(empty_msg, chunks[1]);
} else {
let items: Vec<ListItem> = app.active_downloads
.iter()
.enumerate()
.map(|(i, t)| {
let (formatted_completed, formatted_total, progress) = if let (Ok(completed), Ok(total)) = (t.completed_length.parse::<u64>(), t.total_length.parse::<u64>()) {
let formatted_completed = format_bytes(completed);
let formatted_total = format_bytes(total);
let progress = if total > 0 {
format!(" ({:.1}%)", (completed as f64 / total as f64) * 100.0)
} else {
String::new()
};
(formatted_completed, formatted_total, progress)
} else {
(t.completed_length.clone(), t.total_length.clone(), String::new())
};
let name = t.bittorrent
.as_ref()
.and_then(|b| b.info.as_ref())
.and_then(|info| info.name.clone());
let display_name = match name {
Some(n) => {
if n.len() > 50 {
format!("{}...", &n[..47])
} else {
n
}
}
None => format!("Torrent (GID: {})", t.gid),
};
let name_style = if i == app.selected_index && app.mode == AppMode::Normal {
Style::default().fg(Color::Yellow).add_modifier(Modifier::BOLD)
} else {
Style::default()
};
let status_style = Style::default().fg(match t.status.as_str() {
"active" => Color::Green,
"paused" => Color::DarkGray,
"waiting" => Color::Blue,
_ => Color::Red,
}).add_modifier(Modifier::BOLD);
let mut spans = vec![
Span::styled("📁 ", Style::default()),
Span::styled(display_name, name_style),
Span::raw(" ["),
Span::styled(t.status.clone(), status_style),
Span::raw("] - "),
Span::styled(format!("{}/{}", formatted_completed, formatted_total), Style::default().fg(Color::Cyan)),
Span::raw(progress),
];
if t.download_speed != "0" && !t.download_speed.is_empty() {
spans.push(Span::raw(" @ "));
spans.push(Span::styled(format_speed(&t.download_speed), Style::default().fg(Color::Green)));
}
ListItem::new(Line::from(spans))
})
.collect();
let downloads = List::new(items).block(
Block::default()
.title("📥 Active Downloads")
.borders(Borders::ALL),
);
f.render_widget(downloads, chunks[1]);
}
},
AppMode::Searching => {
let loading_indicator = app.get_loading_indicator();
let loading_msg = format!("{} Searching Multiple Sources\n\n{} {}\n\n{} Please wait while we fetch results...",
loading_indicator, loading_indicator, app.search_progress, loading_indicator);
let loading_widget = Paragraph::new(loading_msg)
.style(Style::default().fg(Color::Blue))
.alignment(Alignment::Center)
.block(Block::default().title(format!("{} Searching", loading_indicator)).borders(Borders::ALL));
f.render_widget(loading_widget, chunks[1]);
},
AppMode::Results => {
if app.search_results.is_empty() {
let empty_msg = Paragraph::new("No results found. Try a different search term.")
.style(Style::default().fg(Color::Red))
.alignment(Alignment::Center)
.block(Block::default().title("📋 Search Results").borders(Borders::ALL));
f.render_widget(empty_msg, chunks[1]);
} else {
let items: Vec<ListItem> = app.search_results
.iter()
.enumerate()
.map(|(i, result)| {
let source_color = match result.source.as_str() {
"YTS" => Color::Green,
"PirateBay" => Color::Blue,
_ => Color::Gray,
};
let display_name = if result.name.len() > 60 {
format!("{}...", &result.name[..57])
} else {
result.name.clone()
};
let title = Line::from(vec![
Span::styled(format!("[{}] ", result.source), Style::default().fg(source_color).add_modifier(Modifier::BOLD)),
Span::raw(display_name),
Span::styled(format!(" | {} | ", result.size), Style::default().fg(Color::Cyan)),
Span::styled(format!("S:{}", result.seeders), Style::default().fg(Color::Green)),
Span::raw(" "),
Span::styled(format!("L:{}", result.leechers), Style::default().fg(Color::Red)),
]);
let style = if i == app.selected_index {
Style::default().bg(Color::DarkGray)
} else {
Style::default()
};
ListItem::new(title).style(style)
})
.collect();
let results_list = List::new(items).block(
Block::default()
.title("📋 Torrent Results")
.borders(Borders::ALL),
);
f.render_widget(results_list, chunks[1]);
}
}
}
})?;
Ok(())
}