arhivach-downloader

Download arhivach.vc threads
git clone https://git.ea.contact/arhivach-downloader
Log | Files | Refs | README

main.rs (12414B)


      1 use arhivarch_downloader::config::Config;
      2 use arhivarch_downloader::event::Event;
      3 use arhivarch_downloader::export::{html::HtmlExporter, ExporterKind};
      4 
      5 use ratatui::{
      6     DefaultTerminal, Frame,
      7     crossterm::event::{
      8         self, DisableMouseCapture, EnableMouseCapture, Event as CEvent,
      9         KeyCode, KeyEventKind, KeyModifiers, MouseButton, MouseEventKind,
     10     },
     11     crossterm::execute,
     12     layout::{Constraint, Direction, Layout, Rect},
     13     style::{Color, Modifier, Style},
     14     widgets::{Block, Borders, List, ListItem, Paragraph},
     15 };
     16 
     17 use std::{io::stdout, path::PathBuf, sync::mpsc};
     18 
     19 // ── App state ────────────────────────────────────────────────────────────────
     20 
     21 enum AppState {
     22     Input,
     23     Running,
     24     Done,
     25 }
     26 
     27 #[derive(Clone, Copy, PartialEq)]
     28 enum Field {
     29     Url,
     30     Dir,
     31     Thumb,
     32     Files,
     33     Resume,
     34     Retries,
     35 }
     36 
     37 const FIELDS: &[Field] = &[
     38     Field::Url,
     39     Field::Dir,
     40     Field::Thumb,
     41     Field::Files,
     42     Field::Resume,
     43     Field::Retries,
     44 ];
     45 
     46 struct App {
     47     state: AppState,
     48     url: String,
     49     dir: String,
     50     thumb: bool,
     51     files: bool,
     52     resume: bool,
     53     retries: String,
     54     selected: usize,
     55     log: Vec<String>,
     56     rx: Option<mpsc::Receiver<Event>>,
     57     field_areas: Vec<Rect>,
     58 }
     59 
     60 impl App {
     61     fn new() -> Self {
     62         App {
     63             state: AppState::Input,
     64             url: String::new(),
     65             dir: String::from("."),
     66             thumb: false,
     67             files: false,
     68             resume: false,
     69             retries: String::from("3"),
     70             selected: 0,
     71             log: Vec::new(),
     72             rx: None,
     73             field_areas: vec![Rect::default(); FIELDS.len()],
     74         }
     75     }
     76 
     77     fn field(&self) -> Field {
     78         FIELDS[self.selected]
     79     }
     80 
     81     fn start(&mut self) {
     82         let config = Config {
     83             url: self.url.clone(),
     84             dir: PathBuf::from(&self.dir),
     85             exporter: ExporterKind::Html(HtmlExporter),
     86             thumb: self.thumb,
     87             files: self.files,
     88             resume: self.resume,
     89             download_retries: self.retries.parse().unwrap_or(3),
     90         };
     91         let (tx, rx) = mpsc::channel::<Event>();
     92         self.rx = Some(rx);
     93         self.state = AppState::Running;
     94         self.log.clear();
     95         std::thread::spawn(move || {
     96             let _ = arhivarch_downloader::run(&config, tx);
     97         });
     98     }
     99 
    100     fn poll(&mut self) {
    101         let Some(rx) = &self.rx else { return };
    102         loop {
    103             match rx.try_recv() {
    104                 Ok(ev) => self.log.push(event_label(&ev)),
    105                 Err(mpsc::TryRecvError::Empty) => break,
    106                 Err(mpsc::TryRecvError::Disconnected) => {
    107                     self.state = AppState::Done;
    108                     break;
    109                 }
    110             }
    111         }
    112     }
    113 }
    114 
    115 fn event_label(ev: &Event) -> String {
    116     match ev {
    117         Event::GetStarted => "Fetching thread...".into(),
    118         Event::GetDone => "Thread fetched.".into(),
    119         Event::GetFailed { error } => format!("ERROR: {error}"),
    120         Event::DownloadAllStarted => "Downloading assets...".into(),
    121         Event::DownloadAllDone => "All downloads complete.".into(),
    122         Event::DownloadAllFailed { error } => format!("Download error: {error}"),
    123         Event::DownloadStarted { index, max_index } => format!("  [{index}/{max_index}] Downloading..."),
    124         Event::DownloadDone { index, max_index } => format!("  [{index}/{max_index}] Done."),
    125         Event::DownloadSkipped { index, max_index } => format!("  [{index}/{max_index}] Skipped."),
    126         Event::DownloadFailed { url, error } => format!("  Failed {url}: {error}"),
    127         Event::DownloadFilesStarted => "Downloading files...".into(),
    128         Event::DownloadFilesDone => "Files downloaded.".into(),
    129         Event::DownloadThumbStarted => "Downloading thumbnails...".into(),
    130         Event::DownloadThumbDone => "Thumbnails downloaded.".into(),
    131         Event::ExportStarted => "Exporting...".into(),
    132         Event::ExportDone => "Export done.".into(),
    133         Event::ExportFailed { error } => format!("Export error: {error}"),
    134     }
    135 }
    136 
    137 // ── Drawing ──────────────────────────────────────────────────────────────────
    138 
    139 fn draw(frame: &mut Frame, app: &mut App) {
    140     match app.state {
    141         AppState::Input => draw_input(frame, app),
    142         AppState::Running | AppState::Done => draw_log(frame, app),
    143     }
    144 }
    145 
    146 fn draw_input(frame: &mut Frame, app: &mut App) {
    147     let area = frame.area();
    148     let chunks = Layout::default()
    149         .direction(Direction::Vertical)
    150         .margin(2)
    151         .constraints([
    152             Constraint::Length(3),
    153             Constraint::Length(3),
    154             Constraint::Length(3),
    155             Constraint::Length(3),
    156             Constraint::Length(3),
    157             Constraint::Length(3),
    158             Constraint::Length(3),
    159             Constraint::Min(0),
    160         ])
    161         .split(area);
    162 
    163     let rows: &[(&str, String, Field)] = &[
    164         ("URL", app.url.clone(), Field::Url),
    165         ("Output dir", app.dir.clone(), Field::Dir),
    166         ("Thumbnails", checkbox_label(app.thumb), Field::Thumb),
    167         ("Files", checkbox_label(app.files), Field::Files),
    168         ("Resume", checkbox_label(app.resume), Field::Resume),
    169         ("Retries", app.retries.clone(), Field::Retries),
    170     ];
    171 
    172     for (i, (label, value, field)) in rows.iter().enumerate() {
    173         let active = app.field() == *field;
    174         let border_style = if active {
    175             Style::default().fg(Color::Yellow).add_modifier(Modifier::BOLD)
    176         } else {
    177             Style::default()
    178         };
    179         let block = Block::default()
    180             .borders(Borders::ALL)
    181             .title(*label)
    182             .border_style(border_style);
    183         app.field_areas[i] = chunks[i];
    184         frame.render_widget(Paragraph::new(value.as_str()).block(block), chunks[i]);
    185     }
    186 
    187     let hint = "Tab/↑↓: navigate  Space/click: toggle  Enter: start  Ctrl+C: quit";
    188     frame.render_widget(
    189         Paragraph::new(hint).block(Block::default().borders(Borders::ALL).title("Help")),
    190         chunks[6],
    191     );
    192 }
    193 
    194 fn draw_log(frame: &mut Frame, app: &App) {
    195     let area = frame.area();
    196     let chunks = Layout::default()
    197         .direction(Direction::Vertical)
    198         .margin(1)
    199         .constraints([Constraint::Min(0), Constraint::Length(3)])
    200         .split(area);
    201 
    202     let title = match app.state {
    203         AppState::Running => "Running",
    204         AppState::Done => "Done",
    205         AppState::Input => unreachable!(),
    206     };
    207 
    208     let items: Vec<ListItem> = app.log.iter().map(|s| ListItem::new(s.as_str())).collect();
    209     frame.render_widget(
    210         List::new(items).block(Block::default().borders(Borders::ALL).title(title)),
    211         chunks[0],
    212     );
    213 
    214     let hint = match app.state {
    215         AppState::Done => "q / Enter: quit",
    216         _ => "Ctrl+C: quit",
    217     };
    218     frame.render_widget(
    219         Paragraph::new(hint).block(Block::default().borders(Borders::ALL)),
    220         chunks[1],
    221     );
    222 }
    223 
    224 fn checkbox_label(b: bool) -> String {
    225     if b { "[x]".into() } else { "[ ]".into() }
    226 }
    227 
    228 fn is_bool_field(field: Field) -> bool {
    229     matches!(field, Field::Thumb | Field::Files | Field::Resume)
    230 }
    231 
    232 fn paste_into_field(app: &mut App, text: &str) {
    233     match app.field() {
    234         Field::Url => app.url.push_str(text),
    235         Field::Dir => app.dir.push_str(text),
    236         Field::Retries => {
    237             for c in text.chars().filter(|c| c.is_ascii_digit()) {
    238                 app.retries.push(c);
    239             }
    240         }
    241         _ => {}
    242     }
    243 }
    244 
    245 fn toggle_field(app: &mut App, field: Field) {
    246     match field {
    247         Field::Thumb => app.thumb = !app.thumb,
    248         Field::Files => app.files = !app.files,
    249         Field::Resume => app.resume = !app.resume,
    250         _ => {}
    251     }
    252 }
    253 
    254 // ── Event handling ────────────────────────────────────────────────────────────
    255 
    256 fn handle_input_key(app: &mut App, key: event::KeyEvent) {
    257     match key.code {
    258         KeyCode::Tab | KeyCode::Down => {
    259             app.selected = (app.selected + 1) % FIELDS.len();
    260         }
    261         KeyCode::BackTab | KeyCode::Up => {
    262             app.selected = app.selected.checked_sub(1).unwrap_or(FIELDS.len() - 1);
    263         }
    264         KeyCode::Enter => {
    265             if !app.url.is_empty() {
    266                 app.start();
    267             }
    268         }
    269         KeyCode::Char(' ') => {
    270             let f = app.field();
    271             if is_bool_field(f) {
    272                 toggle_field(app, f);
    273             }
    274         }
    275         KeyCode::Char(c) => match app.field() {
    276             Field::Url => app.url.push(c),
    277             Field::Dir => app.dir.push(c),
    278             Field::Retries if c.is_ascii_digit() => app.retries.push(c),
    279             _ => {}
    280         },
    281         KeyCode::Backspace => match app.field() {
    282             Field::Url => { app.url.pop(); }
    283             Field::Dir => { app.dir.pop(); }
    284             Field::Retries => { app.retries.pop(); }
    285             _ => {}
    286         },
    287         _ => {}
    288     }
    289 }
    290 
    291 fn handle_mouse_click(app: &mut App, col: u16, row: u16) {
    292     for (i, area) in app.field_areas.iter().enumerate() {
    293         if col >= area.x && col < area.x + area.width
    294             && row >= area.y && row < area.y + area.height
    295         {
    296             if app.selected == i && is_bool_field(FIELDS[i]) {
    297                 toggle_field(app, FIELDS[i]);
    298             } else {
    299                 app.selected = i;
    300             }
    301             break;
    302         }
    303     }
    304 }
    305 
    306 // ── Main loop ─────────────────────────────────────────────────────────────────
    307 
    308 fn run_app(terminal: &mut DefaultTerminal, app: &mut App) -> Result<(), Box<dyn std::error::Error>> {
    309     execute!(stdout(), EnableMouseCapture)?;
    310     let result = run_loop(terminal, app);
    311     execute!(stdout(), DisableMouseCapture)?;
    312     result
    313 }
    314 
    315 fn run_loop(terminal: &mut DefaultTerminal, app: &mut App) -> Result<(), Box<dyn std::error::Error>> {
    316     loop {
    317         app.poll();
    318         terminal.draw(|f| draw(f, app))?;
    319 
    320         if event::poll(std::time::Duration::from_millis(100)).unwrap_or(false) {
    321             match event::read() {
    322                 Ok(CEvent::Key(key)) if key.kind == KeyEventKind::Press => {
    323                     if key.modifiers.contains(KeyModifiers::CONTROL)
    324                         && key.code == KeyCode::Char('c')
    325                     {
    326                         return Ok(());
    327                     }
    328                     if key.modifiers.contains(KeyModifiers::CONTROL)
    329                         && key.code == KeyCode::Char('v')
    330                     {
    331                         if matches!(app.state, AppState::Input) {
    332                             if let Ok(mut cb) = arboard::Clipboard::new() {
    333                                 if let Ok(text) = cb.get_text() {
    334                                     paste_into_field(app, &text);
    335                                 }
    336                             }
    337                         }
    338                         continue;
    339                     }
    340                     match app.state {
    341                         AppState::Input => handle_input_key(app, key),
    342                         AppState::Running => {}
    343                         AppState::Done => {
    344                             if matches!(key.code, KeyCode::Char('q') | KeyCode::Enter) {
    345                                 return Ok(());
    346                             }
    347                         }
    348                     }
    349                 }
    350                 Ok(CEvent::Mouse(mouse)) => {
    351                     if matches!(app.state, AppState::Input)
    352                         && matches!(mouse.kind, MouseEventKind::Down(MouseButton::Left))
    353                     {
    354                         handle_mouse_click(app, mouse.column, mouse.row);
    355                     }
    356                 }
    357                 _ => {}
    358             }
    359         }
    360     }
    361 }
    362 
    363 fn main() {
    364     let mut terminal = ratatui::init();
    365     let result = run_app(&mut terminal, &mut App::new());
    366     ratatui::restore();
    367     if let Err(e) = result {
    368         eprintln!("ERROR: {e}");
    369         std::process::exit(1);
    370     }
    371 }