nyaa 0.6.0

A tui tool for browsing and downloading torrents from nyaa.si
use std::{
    cmp::max,
    io::{BufReader, Read},
    process::{Command, Stdio},
};

use crossterm::event::{Event, KeyCode, KeyEvent, KeyEventKind, KeyModifiers};
use ratatui::{
    layout::{Constraint, Margin, Rect},
    style::{Modifier, Style, Stylize},
    text::Text,
    widgets::{Clear, Paragraph, Row, Scrollbar, ScrollbarOrientation, Table},
    Frame,
};

use crate::{
    app::{App, LoadType, Mode},
    source::nyaa_html::Item,
};

use super::{centered_rect, create_block, StatefulTable};

pub struct ResultsWidget {
    pub table: StatefulTable<Item>,
}

impl ResultsWidget {
    pub fn with_items(&mut self, items: Vec<Item>) {
        let len = items.len();
        self.table.items = items;
        self.table.select(0);
        self.table.scrollbar_state = self.table.scrollbar_state.content_length(len);
    }
}

impl Default for ResultsWidget {
    fn default() -> Self {
        ResultsWidget {
            table: StatefulTable::with_items(vec![]),
        }
    }
}

fn shorten_number(mut n: u32) -> String {
    if n >= 10000 {
        n /= 1000;
        return n.to_string() + "K";
    }
    n.to_string()
}

impl super::Widget for ResultsWidget {
    fn draw(&self, f: &mut Frame, app: &App, area: Rect) {
        let focus_color = match app.mode {
            Mode::Normal => app.theme.border_focused_color,
            _ => app.theme.border_color,
        };
        let binding = Constraint::from_lengths([3, area.width - 32, 9, 4, 4, 5]);
        let header = Row::new([
            "Cat".to_owned(),
            "Name".to_owned(),
            format!("{:^9}", " Size"),
            format!("{:^4}", ""),
            format!("{:^4}", ""),
            format!("{:^4}", ""),
        ])
        .add_modifier(Modifier::BOLD)
        .add_modifier(Modifier::UNDERLINED)
        .fg(focus_color)
        .height(1)
        .bottom_margin(0);

        f.render_widget(Clear, area);
        let items: Vec<Row> = match app.mode {
            Mode::Loading(_) => {
                let area = centered_rect(8, 1, f.size());
                f.render_widget(Paragraph::new("Loading…"), area);
                vec![]
            }
            _ => self
                .table
                .items
                .iter()
                .map(|item| {
                    Row::new(vec![
                        Text::styled(item.icon.label, Style::new().fg(item.icon.color)),
                        Text::styled(
                            item.title.to_owned(),
                            Style::new().fg(if item.trusted {
                                app.theme.trusted
                            } else if item.remake {
                                app.theme.remake
                            } else {
                                app.theme.fg
                            }),
                        ),
                        Text::raw(format!("{:>9}", item.size)),
                        Text::styled(
                            format!("{:>4}", item.seeders),
                            Style::new().fg(app.theme.trusted),
                        ),
                        Text::styled(
                            format!("{:>4}", item.leechers),
                            Style::new().fg(app.theme.remake),
                        ),
                        Text::raw(shorten_number(item.downloads)),
                    ])
                    .fg(app.theme.fg)
                    .height(1)
                    .bottom_margin(0)
                })
                .collect(),
        };
        let sb = Scrollbar::default()
            .orientation(ScrollbarOrientation::VerticalRight)
            .track_symbol(Some(""))
            .begin_symbol(Some(""))
            .end_symbol(None);
        let sb_area = area.inner(&Margin {
            vertical: 1,
            horizontal: 0,
        });

        let num_items = items.len();
        let first_item = (app.page - 1) * 75;
        let table = Table::new(items, [Constraint::Percentage(100)])
            .header(header)
            .block(
                create_block(app.theme, app.mode == Mode::Normal).title(format!(
                    "Results {}-{} ({} total): Page {}/{}",
                    first_item + 1,
                    num_items + first_item,
                    app.total_results,
                    app.page,
                    app.last_page
                )),
            )
            .highlight_style(Style::default().bg(app.theme.hl_bg))
            .widths(&binding);
        f.render_stateful_widget(table, area, &mut self.table.state.to_owned());
        f.render_stateful_widget(sb, sb_area, &mut self.table.scrollbar_state.to_owned());

        let source_str = format!("Source: {}", app.src.to_string());
        let text = Paragraph::new(source_str.clone());
        let right = Rect::new(
            area.right() - 1 - source_str.len() as u16,
            area.top(),
            source_str.len() as u16,
            1,
        );
        f.render_widget(text, right);

        match app.mode {
            Mode::Loading(_) => {}
            _ => {
                if num_items == 0 {
                    let center = centered_rect(10, 1, f.size());
                    f.render_widget(Paragraph::new("No results"), center);
                }
            }
        }
    }

    fn handle_event(&mut self, app: &mut crate::app::App, e: &crossterm::event::Event) {
        if let Event::Key(KeyEvent {
            code,
            kind: KeyEventKind::Press,
            modifiers,
            ..
        }) = e
        {
            use KeyCode::*;
            match (code, modifiers) {
                (Char('c'), &KeyModifiers::NONE) => {
                    app.mode = Mode::Category;
                }
                (Char('s'), &KeyModifiers::NONE) => {
                    app.mode = Mode::Sort;
                    app.reverse = false;
                }
                (Char('S'), &KeyModifiers::SHIFT) => {
                    app.mode = Mode::Sort;
                    app.reverse = true;
                }
                (Char('f'), &KeyModifiers::NONE) => {
                    app.mode = Mode::Filter;
                }
                (Char('t'), &KeyModifiers::NONE) => {
                    app.mode = Mode::Theme;
                }
                (Char('/') | Char('i'), &KeyModifiers::NONE) => {
                    app.mode = Mode::Search;
                }
                (Char('p'), &KeyModifiers::CONTROL) => {
                    app.mode = Mode::Page;
                }
                (Char('p') | Char('h') | Left, &KeyModifiers::NONE) => {
                    if app.page > 1 {
                        app.page -= 1;
                        app.mode = Mode::Loading(LoadType::Searching);
                    }
                }
                (Char('n') | Char('l') | Right, &KeyModifiers::NONE) => {
                    if app.page < app.last_page {
                        app.page += 1;
                        app.mode = Mode::Loading(LoadType::Searching);
                    }
                }
                (Char('r'), &KeyModifiers::NONE) => {
                    app.mode = Mode::Loading(LoadType::Searching);
                }
                (Char('q'), &KeyModifiers::NONE) => {
                    app.quit();
                }
                (Char('j') | KeyCode::Down, &KeyModifiers::NONE) => {
                    self.table.next(1);
                }
                (Char('k') | KeyCode::Up, &KeyModifiers::NONE) => {
                    self.table.next(-1);
                }
                (Char('J'), &KeyModifiers::SHIFT) => {
                    self.table.next(4);
                }
                (Char('K'), &KeyModifiers::SHIFT) => {
                    self.table.next(-4);
                }
                (Char('G'), &KeyModifiers::SHIFT) => {
                    self.table.select(max(self.table.items.len(), 1) - 1);
                }
                (Char('g'), &KeyModifiers::NONE) => {
                    self.table.select(0);
                }
                (Char('H') | Char('P'), &KeyModifiers::SHIFT) => {
                    if app.page != 1 {
                        app.page = 1;
                        app.mode = Mode::Loading(LoadType::Searching);
                    }
                }
                (Char('L') | Char('N'), &KeyModifiers::SHIFT) => {
                    if app.page != app.last_page && app.last_page > 0 {
                        app.page = app.last_page;
                        app.mode = Mode::Loading(LoadType::Searching);
                    }
                }
                (Enter, &KeyModifiers::NONE) => {
                    let item = match self
                        .table
                        .state
                        .selected()
                        .and_then(|i| self.table.items.get(i))
                    {
                        Some(i) => i,
                        None => return,
                    };
                    let cmd_str = app
                        .config
                        .torrent_client_cmd
                        .replace("{magnet}", &shellwords::escape(item.magnet_link.as_str()))
                        .replace("{torrent}", &shellwords::escape(item.torrent_link.as_str()))
                        .replace("{title}", &shellwords::escape(item.title.as_str()))
                        .replace("{file}", &shellwords::escape(item.file_name.as_str()));
                    let cmd = match shellwords::split(&cmd_str) {
                        Ok(cmd) => cmd,
                        Err(e) => {
                            app.errors.push(format!(
                                "{}\n{}:\nfailed to split command:\n{}",
                                cmd_str, app.config.torrent_client_cmd, e
                            ));
                            return;
                        }
                    };
                    if let [exec, args @ ..] = cmd.as_slice() {
                        let cmd = Command::new(exec)
                            .args(args)
                            .stdin(Stdio::null())
                            .stdout(Stdio::null())
                            .stderr(Stdio::piped())
                            .spawn();
                        let child = match cmd {
                            Ok(child) => child,
                            Err(e) => {
                                app.errors
                                    .push(format!("{}:\nFailed to run:\n{}", cmd_str, e));
                                return;
                            }
                        };
                        let output = match child.wait_with_output() {
                            Ok(output) => output,
                            Err(e) => {
                                app.errors
                                    .push(format!("{}:\nFailed to get output:\n{}", cmd_str, e));
                                return;
                            }
                        };

                        if output.status.code() != Some(0) {
                            let mut err = BufReader::new(&*output.stderr);
                            let mut err_str = String::new();
                            err.read_to_string(&mut err_str).unwrap_or(0);
                            app.errors.push(format!(
                                "{}:\nExited with status code {}:\n{}",
                                cmd_str, output.status, err_str
                            ));
                        }
                    } else {
                        app.errors
                            .push(format!("{}:\nThe command is not valid.", cmd_str));
                    }
                }
                (Char('s'), &KeyModifiers::CONTROL) => {
                    app.mode = Mode::Sources;
                }
                _ => {}
            }
        }
    }

    fn get_help() -> Option<Vec<(&'static str, &'static str)>> {
        Some(vec![
            ("Enter", "Confirm"),
            ("q", "Exit App"),
            ("g", "Top"),
            ("G", "Bottom"),
            ("j, ↓", "Down"),
            ("k, ↑", "Up"),
            ("n, l, →", "Next Page"),
            ("p, h, ←", "Prev Page"),
            ("N, L, →", "Last Page"),
            ("P, H, ←", "First Page"),
            ("r", "Reload"),
            ("/, i", "Search"),
            ("c", "Categories"),
            ("f", "Filters"),
            ("s", "Sort"),
            ("S", "Sort reversed"),
            ("t", "Themes"),
            ("Ctrl-p", "Goto page"),
            ("Ctrl-s", "Select source"),
        ])
    }
}