tui-torrent 1.1.0

A terminal user interface for managing torrents.
Documentation
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;

// Draw the UI using an existing terminal instance (prevents flicker & overlap)
pub fn render_ui<B: ratatui::backend::Backend>(
    terminal: &mut Terminal<B>,
    app: &App,
) -> Result<()> {
    terminal.draw(|f| {
        // Clear whole frame first so shorter new content does not leave remnants
        let size = f.size();
        f.render_widget(Clear, size);
        let chunks = Layout::default()
            .direction(Direction::Vertical)
            .margin(1)
            .constraints([
                Constraint::Length(3), // Header
                Constraint::Min(0),    // Main content
                Constraint::Length(3), // Status bar
            ].as_ref())
            .split(f.size());

        // Render header/search bar
        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]);
                // Set cursor position. "Search: " is 8 characters. Add 1 for the margin.
                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]);
            }
        }

        // Status bar
        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]);

    // Render main content based on app mode
        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 => {
                // Show loading animation with spinner and progress
                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)| {
                            // Color code by source
                            let source_color = match result.source.as_str() {
                                "YTS" => Color::Green,
                                "PirateBay" => Color::Blue,
                                _ => Color::Gray,
                            };

                            // Truncate long names
                            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(())
}