arhivach-downloader

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

commit a5140bb7a950b3c7497fee5907d7836110fea6d2
parent 766cc139ec89a1ac4d6c4da3bbbb5398fe493856
Author: egor-achkasov <eaachkasov@gmail.com>
Date:   Mon,  9 Mar 2026 11:20:54 +0000

Implement tui

Diffstat:
MCargo.toml | 5+++++
Asrc/bin/tui/main.rs | 343+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
2 files changed, 348 insertions(+), 0 deletions(-)

diff --git a/Cargo.toml b/Cargo.toml @@ -11,8 +11,13 @@ path = "src/lib/lib.rs" name = "arhivach-downloader-cli" path = "src/bin/cli/main.rs" +[[bin]] +name = "arhivach-downloader-tui" +path = "src/bin/tui/main.rs" + [dependencies] anyhow = "1.0.102" clap = { version = "4.5.57", features = ["derive"] } +ratatui = "0.29" reqwest = { version = "0.12", features = ["blocking"] } scraper = "0.25.0" diff --git a/src/bin/tui/main.rs b/src/bin/tui/main.rs @@ -0,0 +1,343 @@ +use arhivarch_downloader::config::Config; +use arhivarch_downloader::event::Event; +use arhivarch_downloader::export::{html::HtmlExporter, ExporterKind}; + +use ratatui::{ + DefaultTerminal, Frame, + crossterm::event::{ + self, DisableMouseCapture, EnableMouseCapture, Event as CEvent, + KeyCode, KeyEventKind, KeyModifiers, MouseButton, MouseEventKind, + }, + crossterm::execute, + layout::{Constraint, Direction, Layout, Rect}, + style::{Color, Modifier, Style}, + widgets::{Block, Borders, List, ListItem, Paragraph}, +}; + +use std::{io::stdout, path::PathBuf, sync::mpsc}; + +// ── App state ──────────────────────────────────────────────────────────────── + +enum AppState { + Input, + Running, + Done, +} + +#[derive(Clone, Copy, PartialEq)] +enum Field { + Url, + Dir, + Thumb, + Files, + Resume, + Retries, +} + +const FIELDS: &[Field] = &[ + Field::Url, + Field::Dir, + Field::Thumb, + Field::Files, + Field::Resume, + Field::Retries, +]; + +struct App { + state: AppState, + url: String, + dir: String, + thumb: bool, + files: bool, + resume: bool, + retries: String, + selected: usize, + log: Vec<String>, + rx: Option<mpsc::Receiver<Event>>, + field_areas: Vec<Rect>, +} + +impl App { + fn new() -> Self { + App { + state: AppState::Input, + url: String::new(), + dir: String::from("."), + thumb: false, + files: false, + resume: false, + retries: String::from("3"), + selected: 0, + log: Vec::new(), + rx: None, + field_areas: vec![Rect::default(); FIELDS.len()], + } + } + + fn field(&self) -> Field { + FIELDS[self.selected] + } + + fn start(&mut self) { + let config = Config { + url: self.url.clone(), + dir: PathBuf::from(&self.dir), + exporter: ExporterKind::Html(HtmlExporter), + thumb: self.thumb, + files: self.files, + resume: self.resume, + download_retries: self.retries.parse().unwrap_or(3), + }; + let (tx, rx) = mpsc::channel::<Event>(); + self.rx = Some(rx); + self.state = AppState::Running; + self.log.clear(); + std::thread::spawn(move || { + let _ = arhivarch_downloader::run(&config, tx); + }); + } + + fn poll(&mut self) { + let Some(rx) = &self.rx else { return }; + loop { + match rx.try_recv() { + Ok(ev) => self.log.push(event_label(&ev)), + Err(mpsc::TryRecvError::Empty) => break, + Err(mpsc::TryRecvError::Disconnected) => { + self.state = AppState::Done; + break; + } + } + } + } +} + +fn event_label(ev: &Event) -> String { + match ev { + Event::GetStarted => "Fetching thread...".into(), + Event::GetDone => "Thread fetched.".into(), + Event::GetFailed { error } => format!("ERROR: {error}"), + Event::DownloadAllStarted => "Downloading assets...".into(), + Event::DownloadAllDone => "All downloads complete.".into(), + Event::DownloadAllFailed { error } => format!("Download error: {error}"), + Event::DownloadStarted { index, max_index } => format!(" [{index}/{max_index}] Downloading..."), + Event::DownloadDone { index, max_index } => format!(" [{index}/{max_index}] Done."), + Event::DownloadSkipped { index, max_index } => format!(" [{index}/{max_index}] Skipped."), + Event::DownloadFailed { url, error } => format!(" Failed {url}: {error}"), + Event::DownloadFilesStarted => "Downloading files...".into(), + Event::DownloadFilesDone => "Files downloaded.".into(), + Event::DownloadThumbStarted => "Downloading thumbnails...".into(), + Event::DownloadThumbDone => "Thumbnails downloaded.".into(), + Event::ExportStarted => "Exporting...".into(), + Event::ExportDone => "Export done.".into(), + Event::ExportFailed { error } => format!("Export error: {error}"), + } +} + +// ── Drawing ────────────────────────────────────────────────────────────────── + +fn draw(frame: &mut Frame, app: &mut App) { + match app.state { + AppState::Input => draw_input(frame, app), + AppState::Running | AppState::Done => draw_log(frame, app), + } +} + +fn draw_input(frame: &mut Frame, app: &mut App) { + let area = frame.area(); + let chunks = Layout::default() + .direction(Direction::Vertical) + .margin(2) + .constraints([ + Constraint::Length(3), + Constraint::Length(3), + Constraint::Length(3), + Constraint::Length(3), + Constraint::Length(3), + Constraint::Length(3), + Constraint::Length(3), + Constraint::Min(0), + ]) + .split(area); + + let rows: &[(&str, String, Field)] = &[ + ("URL", app.url.clone(), Field::Url), + ("Output dir", app.dir.clone(), Field::Dir), + ("Thumbnails", checkbox_label(app.thumb), Field::Thumb), + ("Files", checkbox_label(app.files), Field::Files), + ("Resume", checkbox_label(app.resume), Field::Resume), + ("Retries", app.retries.clone(), Field::Retries), + ]; + + for (i, (label, value, field)) in rows.iter().enumerate() { + let active = app.field() == *field; + let border_style = if active { + Style::default().fg(Color::Yellow).add_modifier(Modifier::BOLD) + } else { + Style::default() + }; + let block = Block::default() + .borders(Borders::ALL) + .title(*label) + .border_style(border_style); + app.field_areas[i] = chunks[i]; + frame.render_widget(Paragraph::new(value.as_str()).block(block), chunks[i]); + } + + let hint = "Tab/↑↓: navigate Space/click: toggle Enter: start Ctrl+C: quit"; + frame.render_widget( + Paragraph::new(hint).block(Block::default().borders(Borders::ALL).title("Help")), + chunks[6], + ); +} + +fn draw_log(frame: &mut Frame, app: &App) { + let area = frame.area(); + let chunks = Layout::default() + .direction(Direction::Vertical) + .margin(1) + .constraints([Constraint::Min(0), Constraint::Length(3)]) + .split(area); + + let title = match app.state { + AppState::Running => "Running", + AppState::Done => "Done", + AppState::Input => unreachable!(), + }; + + let items: Vec<ListItem> = app.log.iter().map(|s| ListItem::new(s.as_str())).collect(); + frame.render_widget( + List::new(items).block(Block::default().borders(Borders::ALL).title(title)), + chunks[0], + ); + + let hint = match app.state { + AppState::Done => "q / Enter: quit", + _ => "Ctrl+C: quit", + }; + frame.render_widget( + Paragraph::new(hint).block(Block::default().borders(Borders::ALL)), + chunks[1], + ); +} + +fn checkbox_label(b: bool) -> String { + if b { "[x]".into() } else { "[ ]".into() } +} + +fn is_bool_field(field: Field) -> bool { + matches!(field, Field::Thumb | Field::Files | Field::Resume) +} + +fn toggle_field(app: &mut App, field: Field) { + match field { + Field::Thumb => app.thumb = !app.thumb, + Field::Files => app.files = !app.files, + Field::Resume => app.resume = !app.resume, + _ => {} + } +} + +// ── Event handling ──────────────────────────────────────────────────────────── + +fn handle_input_key(app: &mut App, key: event::KeyEvent) { + match key.code { + KeyCode::Tab | KeyCode::Down => { + app.selected = (app.selected + 1) % FIELDS.len(); + } + KeyCode::BackTab | KeyCode::Up => { + app.selected = app.selected.checked_sub(1).unwrap_or(FIELDS.len() - 1); + } + KeyCode::Enter => { + if !app.url.is_empty() { + app.start(); + } + } + KeyCode::Char(' ') => { + let f = app.field(); + if is_bool_field(f) { + toggle_field(app, f); + } + } + KeyCode::Char(c) => match app.field() { + Field::Url => app.url.push(c), + Field::Dir => app.dir.push(c), + Field::Retries if c.is_ascii_digit() => app.retries.push(c), + _ => {} + }, + KeyCode::Backspace => match app.field() { + Field::Url => { app.url.pop(); } + Field::Dir => { app.dir.pop(); } + Field::Retries => { app.retries.pop(); } + _ => {} + }, + _ => {} + } +} + +fn handle_mouse_click(app: &mut App, col: u16, row: u16) { + for (i, area) in app.field_areas.iter().enumerate() { + if col >= area.x && col < area.x + area.width + && row >= area.y && row < area.y + area.height + { + if app.selected == i && is_bool_field(FIELDS[i]) { + toggle_field(app, FIELDS[i]); + } else { + app.selected = i; + } + break; + } + } +} + +// ── Main loop ───────────────────────────────────────────────────────────────── + +fn run_app(terminal: &mut DefaultTerminal, app: &mut App) -> anyhow::Result<()> { + execute!(stdout(), EnableMouseCapture)?; + let result = run_loop(terminal, app); + execute!(stdout(), DisableMouseCapture)?; + result +} + +fn run_loop(terminal: &mut DefaultTerminal, app: &mut App) -> anyhow::Result<()> { + loop { + app.poll(); + terminal.draw(|f| draw(f, app))?; + + if event::poll(std::time::Duration::from_millis(100))? { + match event::read()? { + CEvent::Key(key) if key.kind == KeyEventKind::Press => { + if key.modifiers.contains(KeyModifiers::CONTROL) + && key.code == KeyCode::Char('c') + { + return Ok(()); + } + match app.state { + AppState::Input => handle_input_key(app, key), + AppState::Running => {} + AppState::Done => { + if matches!(key.code, KeyCode::Char('q') | KeyCode::Enter) { + return Ok(()); + } + } + } + } + CEvent::Mouse(mouse) => { + if matches!(app.state, AppState::Input) + && matches!(mouse.kind, MouseEventKind::Down(MouseButton::Left)) + { + handle_mouse_click(app, mouse.column, mouse.row); + } + } + _ => {} + } + } + } +} + +fn main() -> anyhow::Result<()> { + let mut terminal = ratatui::init(); + let result = run_app(&mut terminal, &mut App::new()); + ratatui::restore(); + result +}