romm-cli 0.37.0

Rust-based CLI and TUI for the ROMM API
Documentation
use ratatui::layout::{Alignment, Constraint, Direction, Layout, Rect};
use ratatui::text::{Line, Span};
use ratatui::widgets::{Gauge, Paragraph};
use ratatui::Frame;
use ratatui_image::{Resize, StatefulImage};

use crate::core::download::DownloadStatus;
use crate::core::utils::format_size;
use crate::tui::theme::RommStyles;
use crate::tui::utils::truncate;

use super::saves::save_lines;
use super::types::{CoverState, GameDetailScreen};

impl GameDetailScreen {
    pub fn render(&mut self, f: &mut Frame, area: Rect, styles: &RommStyles) {
        if let Some(picker) = self.save_upload_picker.as_mut() {
            picker.render(
                f,
                area,
                "Upload save file",
                "Esc: cancel   Enter: choose file   Ctrl+Enter: apply typed file",
                styles,
            );
            return;
        }
        let chunks = Layout::default()
            .constraints([Constraint::Min(10), Constraint::Length(3)])
            .direction(Direction::Vertical)
            .split(area);
        let body = Layout::default()
            .direction(Direction::Horizontal)
            .constraints([Constraint::Min(10), Constraint::Length(42)])
            .split(chunks[0]);

        self.render_metadata_panel(f, body[0], styles);
        self.render_cover_panel(f, body[1], styles);
        self.render_footer_panel(f, chunks[1], styles);
    }

    fn render_cover_panel(&mut self, f: &mut Frame, area: Rect, styles: &RommStyles) {
        let platform = self
            .rom
            .platform_display_name
            .as_deref()
            .or(self.rom.platform_custom_name.as_deref())
            .unwrap_or("");
        let name = truncate(&self.rom.name, 28);
        if matches!(self.cover_state, CoverState::Ready) {
            if let Some(image_state) = self.cover_image.as_mut() {
                let block = styles.panel_block("Cover");
                let inner = block.inner(area);
                f.render_widget(block, area);
                let widget = StatefulImage::default().resize(Resize::Fit(None));
                f.render_stateful_widget(widget, inner, image_state);
                return;
            }
        }

        let content = match &self.cover_state {
            CoverState::Ready => vec![
                Line::from(""),
                Line::from(Span::styled("Inline cover ready", styles.success())),
                Line::from(""),
                Line::from(self.cover_pipeline_label()),
                Line::from("Press o for browser view"),
            ],
            CoverState::Loading => vec![
                Line::from(""),
                Line::from(Span::styled("Loading cover...", styles.warning())),
                Line::from(""),
                Line::from("Fetching image"),
                Line::from("in background"),
            ],
            CoverState::Failed(message) => vec![
                Line::from(""),
                Line::from(Span::styled("Cover unavailable", styles.error())),
                Line::from(""),
                Line::from(truncate(message, 26)),
                Line::from(""),
                Line::from("Press o to open URL"),
            ],
            CoverState::Idle => vec![
                Line::from(""),
                Line::from(if self.rom.url_cover.is_some() {
                    "Cover available"
                } else {
                    "No cover URL"
                }),
                Line::from(""),
                Line::from("Press o to open cover"),
                Line::from("in browser"),
            ],
        };
        let lines = vec![
            Line::from(Span::styled(format!("[{}]", platform), styles.label())),
            Line::from(Span::styled(name, styles.primary_text())),
            Line::from(""),
        ]
        .into_iter()
        .chain(content)
        .collect::<Vec<_>>();
        let widget = Paragraph::new(lines)
            .alignment(Alignment::Center)
            .block(styles.panel_block("Cover"))
            .wrap(ratatui::widgets::Wrap { trim: true });
        f.render_widget(widget, area);
    }

    fn render_metadata_panel(&self, f: &mut Frame, area: Rect, styles: &RommStyles) {
        let title = self.rom.name.as_str();
        let platform = self
            .rom
            .platform_display_name
            .as_deref()
            .or(self.rom.platform_custom_name.as_deref())
            .unwrap_or("");
        let summary = self.rom.summary.as_deref().unwrap_or("").trim();
        let path = self.rom.fs_path.as_str();
        let size = format_size(self.rom.fs_size_bytes);
        let mut lines: Vec<Line> = vec![
            Line::from(vec![
                Span::styled("Title: ", styles.label()),
                Span::raw(title),
            ]),
            Line::from(vec![
                Span::styled("Platform: ", styles.label()),
                Span::raw(platform),
            ]),
            Line::from(""),
            Line::from(Span::styled("Overview:", styles.label())),
            Line::from(vec![
                Span::styled("Download: ", styles.muted()),
                Span::raw(if self.has_started_download {
                    "Started"
                } else {
                    "Not started"
                }),
            ]),
            Line::from(vec![
                Span::styled("Cover URL: ", styles.muted()),
                Span::raw(if self.rom.url_cover.is_some() {
                    "Available (o to open)"
                } else {
                    "Missing"
                }),
            ]),
            Line::from(""),
            Line::from(vec![Span::styled("Summary: ", styles.label())]),
            Line::from(if summary.is_empty() { "" } else { summary }),
            Line::from(""),
            Line::from(vec![
                Span::styled("File: ", styles.label()),
                Span::raw(path),
            ]),
            Line::from(vec![
                Span::styled("Size: ", styles.label()),
                Span::raw(size),
            ]),
        ];

        if !self.other_files.is_empty() {
            lines.push(Line::from(""));
            lines.push(Line::from(vec![
                Span::styled("Other files (updates/DLC): ", styles.label()),
                Span::raw(format!("{} file(s)", self.other_files.len())),
            ]));
            for other in self.other_files.iter().take(10) {
                let label = other.fs_name.as_str();
                lines.push(Line::from(format!("{}", label)));
            }
            if self.other_files.len() > 10 {
                lines.push(Line::from(format!(
                    "  … and {} more",
                    self.other_files.len() - 10
                )));
            }
        }

        if self.show_technical {
            lines.push(Line::from(""));
            lines.push(Line::from(Span::styled("Technical:", styles.warning())));
            lines.push(Line::from(format!("  ID: {}", self.rom.id)));
            lines.push(Line::from(format!(
                "  Platform ID: {}",
                self.rom.platform_id
            )));
            if let Some(s) = &self.rom.slug {
                lines.push(Line::from(format!("  Slug: {}", s)));
            }
            lines.push(Line::from(format!(
                "  Identified: {}",
                self.rom.is_identified
            )));
        }

        lines.push(Line::from(""));
        lines.push(Line::from(Span::styled("Saves:", styles.label())));
        lines.extend(save_lines(&self.saves_state, self.selected_save_index));

        let block = styles.panel_block("Game detail");
        let p = Paragraph::new(lines)
            .block(block)
            .style(styles.text())
            .wrap(ratatui::widgets::Wrap { trim: true });
        f.render_widget(p, area);
    }

    fn render_footer_panel(&mut self, f: &mut Frame, footer_area: Rect, styles: &RommStyles) {
        self.tick_message();
        // Footer: show progress bar if downloading, otherwise help text.
        if let Some(job) = self.active_download() {
            let (label, style) = match &job.status {
                DownloadStatus::Downloading => {
                    (format!("Downloading… {}%", job.percent()), styles.label())
                }
                DownloadStatus::Done => ("Download complete".to_string(), styles.success()),
                DownloadStatus::SkippedAlreadyExists => {
                    ("Already present (skipped)".to_string(), styles.warning())
                }
                DownloadStatus::Cancelled => ("Download cancelled".to_string(), styles.warning()),
                DownloadStatus::FinalizeFailed(msg) => (
                    format!("Finalize failed: {}", truncate(msg, 40)),
                    styles.error(),
                ),
                DownloadStatus::Error(msg) => {
                    (format!("Error: {}", truncate(msg, 50)), styles.error())
                }
            };
            let gauge = Gauge::default()
                .block(styles.panel_block_untitled())
                .gauge_style(style)
                .percent(job.percent())
                .label(label);
            f.render_widget(gauge, footer_area);
        } else {
            let msg = self.message.as_deref().unwrap_or(self.footer_help_text());
            let footer = Paragraph::new(msg)
                .style(styles.footer_hint())
                .block(styles.panel_block_untitled());
            f.render_widget(footer, footer_area);
        }
    }
}