romm-cli 0.33.1

Rust-based CLI and TUI for the ROMM API
Documentation
use ratatui::layout::{Constraint, Layout, Rect};
use ratatui::style::{Color, Style};
use ratatui::widgets::{Block, Borders, Gauge, Paragraph};
use ratatui::Frame;
use std::sync::{Arc, Mutex};

use crate::core::download::{DownloadJob, DownloadStatus, ExtrasJob, ExtrasJobStatus};
use crate::tui::utils::truncate;

/// Overlay screen listing active and completed downloads.
///
/// This screen is read-only; it observes `DownloadJob`s and composite `ExtrasJob`s from
/// [`DownloadManager`](crate::core::download::DownloadManager).
pub struct DownloadScreen {
    pub downloads: Arc<Mutex<Vec<DownloadJob>>>,
    pub extras_jobs: Arc<Mutex<Vec<ExtrasJob>>>,
}

impl DownloadScreen {
    pub fn new(
        downloads: Arc<Mutex<Vec<DownloadJob>>>,
        extras_jobs: Arc<Mutex<Vec<ExtrasJob>>>,
    ) -> Self {
        Self {
            downloads,
            extras_jobs,
        }
    }

    pub fn render(&self, f: &mut Frame, area: Rect) {
        let chunks = Layout::default()
            .constraints([Constraint::Min(3), Constraint::Length(3)])
            .direction(ratatui::layout::Direction::Vertical)
            .split(area);

        let jobs = match self.downloads.lock() {
            Ok(guard) => guard.clone(),
            Err(err) => {
                eprintln!("warning: download list lock poisoned: {}", err);
                Vec::new()
            }
        };
        let extras = match self.extras_jobs.lock() {
            Ok(guard) => guard.clone(),
            Err(err) => {
                eprintln!("warning: extras job list lock poisoned: {}", err);
                Vec::new()
            }
        };

        let block = Block::default()
            .title("Downloads (d: close)")
            .borders(Borders::ALL);

        if jobs.is_empty() && extras.is_empty() {
            let p = Paragraph::new(
                "No downloads. Press Enter on a game detail for the ROM, or e for extras.",
            )
            .block(block);
            f.render_widget(p, chunks[0]);
        } else {
            let inner = block.inner(chunks[0]);
            let max_rows = inner.height as usize;
            let rows = Layout::default()
                .constraints(
                    (0..max_rows.max(1))
                        .map(|_| Constraint::Length(1))
                        .collect::<Vec<_>>(),
                )
                .direction(ratatui::layout::Direction::Vertical)
                .split(inner);

            f.render_widget(block, chunks[0]);

            let mut row_i = 0usize;

            for job in jobs.iter() {
                if row_i >= max_rows {
                    break;
                }
                if let Some(row_area) = rows.get(row_i) {
                    let percent = job.percent();
                    let (label, gauge_style) = match &job.status {
                        DownloadStatus::Downloading => {
                            (format!("{}%", percent), Style::default().fg(Color::Cyan))
                        }
                        DownloadStatus::Done => ("Done".into(), Style::default().fg(Color::Green)),
                        DownloadStatus::SkippedAlreadyExists => (
                            "Skipped (already exists)".into(),
                            Style::default().fg(Color::Yellow),
                        ),
                        DownloadStatus::Cancelled => {
                            ("Cancelled".into(), Style::default().fg(Color::Yellow))
                        }
                        DownloadStatus::FinalizeFailed(msg) => (
                            format!("Finalize failed: {}", truncate(msg, 40)),
                            Style::default().fg(Color::Red),
                        ),
                        DownloadStatus::Error(msg) => (
                            format!("Error: {}", truncate(msg, 50)),
                            Style::default().fg(Color::Red),
                        ),
                    };
                    let gauge = Gauge::default()
                        .gauge_style(gauge_style)
                        .percent(percent)
                        .label(label);

                    let line = format!(
                        "{} | {} | ",
                        truncate(&job.name, 30),
                        truncate(&job.platform, 15)
                    );
                    let line_len = line.chars().count().min(row_area.width as usize) as u16;
                    let line_area = Rect {
                        x: row_area.x,
                        y: row_area.y,
                        width: line_len,
                        height: 1,
                    };
                    let gauge_width = row_area.width.saturating_sub(line_len);
                    let gauge_area = Rect {
                        x: row_area.x + line_len,
                        y: row_area.y,
                        width: gauge_width,
                        height: 1,
                    };
                    f.render_widget(Paragraph::new(line.as_str()), line_area);
                    if gauge_width > 0 {
                        f.render_widget(gauge, gauge_area);
                    }
                }
                row_i += 1;
            }

            for job in extras.iter() {
                if row_i >= max_rows {
                    break;
                }
                if let Some(row_area) = rows.get(row_i) {
                    let percent = job.percent();
                    let (label, gauge_style) = match &job.status {
                        ExtrasJobStatus::Running => (
                            format!("{}% {}/{}", percent, job.completed_items, job.total_items),
                            Style::default().fg(Color::Cyan),
                        ),
                        ExtrasJobStatus::Done => ("Done".into(), Style::default().fg(Color::Green)),
                        ExtrasJobStatus::PartialFailure(n) => (
                            format!("Partial ({n} failed)"),
                            Style::default().fg(Color::Yellow),
                        ),
                        ExtrasJobStatus::AllFailed => {
                            ("All failed".into(), Style::default().fg(Color::Red))
                        }
                    };
                    let gauge = Gauge::default()
                        .gauge_style(gauge_style)
                        .percent(percent)
                        .label(label);

                    let line = format!("Extras | {} | ", truncate(&job.name, 36),);
                    let line_len = line.chars().count().min(row_area.width as usize) as u16;
                    let line_area = Rect {
                        x: row_area.x,
                        y: row_area.y,
                        width: line_len,
                        height: 1,
                    };
                    let gauge_width = row_area.width.saturating_sub(line_len);
                    let gauge_area = Rect {
                        x: row_area.x + line_len,
                        y: row_area.y,
                        width: gauge_width,
                        height: 1,
                    };
                    f.render_widget(Paragraph::new(line.as_str()), line_area);
                    if gauge_width > 0 {
                        f.render_widget(gauge, gauge_area);
                    }
                }
                row_i += 1;
            }
        }

        let help = "d or Esc: Back to previous screen";
        let footer = Paragraph::new(help).block(Block::default().borders(Borders::ALL));
        f.render_widget(footer, chunks[1]);
    }
}