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 }