mod app; mod events; mod file_dialog; mod logo; mod network; mod qbittorrent; mod ui; use std::{ io, panic, time::Duration, }; use crossterm::{ event::{self, DisableMouseCapture, EnableMouseCapture, Event, KeyCode, KeyEventKind}, execute, terminal::{disable_raw_mode, enable_raw_mode, EnterAlternateScreen, LeaveAlternateScreen}, }; use ratatui::{backend::CrosstermBackend, Terminal}; use tokio::sync::mpsc; use tui_input::backend::crossterm::EventHandler; use simplelog::{Config, LevelFilter, WriteLogger}; use crate::app::App; use crate::events::{Command, NetworkEvent}; use crate::network::spawn_network_worker; use crate::ui::draw; #[tokio::main] async fn main() -> Result<(), Box> { setup_panic_hook(); setup_logging(); enable_raw_mode()?; let mut stdout = io::stdout(); execute!(stdout, EnterAlternateScreen, EnableMouseCapture)?; let backend = CrosstermBackend::new(stdout); let mut terminal = Terminal::new(backend)?; let base_url = std::env::var("QBIT_URL").unwrap_or_else(|_| "http://localhost:8080".to_string()); let username = std::env::var("QBIT_USER").unwrap_or_else(|_| "admin".to_string()); let password = std::env::var("QBIT_PASS").unwrap_or_else(|_| "adminadmin".to_string()); let mut app = App::new(base_url.clone(), username.clone(), password.clone()); let (cmd_tx, cmd_rx) = mpsc::channel::(100); let (net_tx, mut net_rx) = mpsc::channel::(100); // The worker is spawned immediately but waits for an Authenticate command. // We pass the credentials from env so the user sees them pre-filled. spawn_network_worker(base_url, username, password, cmd_rx, net_tx); let (key_tx, mut key_rx) = mpsc::channel::(100); tokio::task::spawn_blocking(move || { loop { if let Ok(evt) = event::read() { if key_tx.blocking_send(evt).is_err() { break; } } } }); let mut ticker = tokio::time::interval(Duration::from_millis(16)); let result = run_app( &mut terminal, &mut app, &mut key_rx, &mut net_rx, &cmd_tx, &mut ticker, ) .await; let _ = cmd_tx.send(Command::Shutdown).await; disable_raw_mode()?; execute!( terminal.backend_mut(), LeaveAlternateScreen, DisableMouseCapture )?; terminal.show_cursor()?; if let Err(err) = result { eprintln!("Application error: {:?}", err); } Ok(()) } fn setup_logging() { let log_dir = std::env::var("HOME") .map(std::path::PathBuf::from) .unwrap_or_else(|_| std::path::PathBuf::from(".")) .join(".local/share/qbit-tui"); let _ = std::fs::create_dir_all(&log_dir); let log_file = log_dir.join("qbit-tui.log"); let file = std::fs::OpenOptions::new() .create(true) .append(true) .open(&log_file) .unwrap_or_else(|_| std::fs::File::create("/dev/null").unwrap()); let _ = WriteLogger::init(LevelFilter::Info, Config::default(), file); } fn notify_completed(name: &str) { let _ = notify_rust::Notification::new() .summary("qbit-tui") .body(&format!("Download completed: {}", name)) .timeout(notify_rust::Timeout::Milliseconds(5000)) .show(); } fn setup_panic_hook() { let original_hook = panic::take_hook(); panic::set_hook(Box::new(move |info| { let _ = disable_raw_mode(); let _ = execute!(io::stdout(), LeaveAlternateScreen, DisableMouseCapture); original_hook(info); })); } async fn run_app( terminal: &mut Terminal>, app: &mut App, key_rx: &mut mpsc::Receiver, net_rx: &mut mpsc::Receiver, cmd_tx: &mpsc::Sender, ticker: &mut tokio::time::Interval, ) -> Result<(), Box> { while app.running { tokio::select! { _ = ticker.tick() => { draw_and_cursor(terminal, app)?; } Some(evt) = key_rx.recv() => { handle_input(evt, app, cmd_tx).await?; draw_and_cursor(terminal, app)?; } Some(net_evt) = net_rx.recv() => { // Detect completed downloads before updating state if let NetworkEvent::TorrentList(ref list) = net_evt { let old_status: std::collections::HashMap<&str, &str> = app.torrents.iter().map(|t| (t.hash.as_str(), t.status.as_str())).collect(); for t in list { let was_downloading = old_status.get(t.hash.as_str()).map(|s| { *s == "downloading" || *s == "stalledDL" || *s == "metaDL" || *s == "forcedDL" || *s == "checkingDL" }).unwrap_or(false); let is_completed = t.status == "uploading" || t.status == "stalledUP" || t.status == "pausedUP" || t.status == "stoppedUP" || t.status == "forcedUP"; if was_downloading && is_completed { notify_completed(&t.name); } } } app.handle_network_event(net_evt); // Switch from login to main on successful auth if app.screen == app::Screen::Login && app.authenticated { app.screen = app::Screen::Main; } draw_and_cursor(terminal, app)?; } } } Ok(()) } /// Render the frame and position the physical cursor when the add-modal is active. fn draw_and_cursor( terminal: &mut Terminal>, app: &mut App, ) -> Result<(), Box> { let mut cursor_pos: Option<(u16, u16)> = None; terminal.draw(|f| draw(f, app, &mut cursor_pos))?; if let Some((x, y)) = cursor_pos { terminal.set_cursor(x, y)?; terminal.show_cursor()?; } else { terminal.hide_cursor()?; } Ok(()) } /// Send tracker/file/property fetch commands when the details panel is open. async fn refresh_details(app: &mut App, cmd_tx: &mpsc::Sender) { if !app.show_details { return; } if let Some(hash) = app.selected_torrent_hash() { match app.details_tab { app::DetailsTab::General => { let _ = cmd_tx.send(Command::FetchProperties(hash)).await; } app::DetailsTab::Trackers => { let _ = cmd_tx.send(Command::FetchTrackers(hash)).await; } app::DetailsTab::Files => { let _ = cmd_tx.send(Command::FetchFiles(hash)).await; } app::DetailsTab::Peers => { let _ = cmd_tx.send(Command::FetchPeers(hash)).await; } } } } async fn handle_input( evt: Event, app: &mut App, cmd_tx: &mpsc::Sender, ) -> Result<(), Box> { if let Event::Key(key) = evt { if key.kind != KeyEventKind::Press { return Ok(()); } // ── Login Screen ─────────────────────────────────────────────── if app.screen == app::Screen::Login { match key.code { KeyCode::Esc | KeyCode::Char('q') => app.quit(), KeyCode::Tab => { app.login_focus = app.login_focus.next(); } KeyCode::BackTab => { app.login_focus = app.login_focus.prev(); } KeyCode::Enter => { if app.login_focus == app::LoginFocus::Button { let url = app.login_url_input.value().to_string(); let user = app.login_user_input.value().to_string(); let pass = app.login_pass_input.value().to_string(); app.base_url = url.clone(); app.username = user.clone(); app.password = pass.clone(); app.status_message = Some("Authenticating...".to_string()); let _ = cmd_tx .send(Command::Authenticate { username: user, password: pass, }) .await; } else { app.login_focus = app.login_focus.next(); } } KeyCode::Up => { app.login_focus = app.login_focus.prev(); } KeyCode::Down => { app.login_focus = app.login_focus.next(); } _ => { match app.login_focus { app::LoginFocus::Url => { app.login_url_input.handle_event(&evt); } app::LoginFocus::Username => { app.login_user_input.handle_event(&evt); } app::LoginFocus::Password => { app.login_pass_input.handle_event(&evt); } app::LoginFocus::Button => {} } } } return Ok(()); } // ── Search Mode ──────────────────────────────────────────────── if app.show_search { match key.code { KeyCode::Esc => { app.show_search = false; app.search_input.reset(); app.needs_filter_recalc = true; } KeyCode::Enter => { app.show_search = false; } _ => { app.search_input.handle_event(&evt); app.needs_filter_recalc = true; } } return Ok(()); } // ── Add Torrent Advanced Modal ───────────────────────────────── if app.show_add_modal { match key.code { KeyCode::Esc => { app.show_add_modal = false; app.add_input.reset(); app.add_path_input.reset(); app.add_category_input.reset(); app.add_tags_input.reset(); app.add_paused = false; app.add_skip_checking = false; } KeyCode::Enter => { let urls = app.add_input.value().to_string(); if !urls.is_empty() { let tags = app.add_tags_input.value().to_string(); let tags_vec = if tags.is_empty() { Vec::new() } else { tags.split(',').map(|t| t.trim().to_string()).collect() }; let _ = cmd_tx.send(Command::AddTorrentAdvanced { urls, save_path: app.add_path_input.value().to_string(), category: app.add_category_input.value().to_string(), tags: tags_vec, paused: app.add_paused, skip_checking: app.add_skip_checking, }).await; } app.show_add_modal = false; app.add_input.reset(); app.add_path_input.reset(); app.add_category_input.reset(); app.add_tags_input.reset(); app.add_paused = false; app.add_skip_checking = false; } KeyCode::Tab => { app.add_modal_focus = app.add_modal_focus.next(); } KeyCode::Char(' ') => { match app.add_modal_focus { app::AddModalFocus::Url => {} app::AddModalFocus::Path => {} app::AddModalFocus::Category => {} app::AddModalFocus::Tags => {} } // Space toggles advanced options regardless of focus app.add_paused = !app.add_paused; app.status_message = Some(format!("Add paused: {}", if app.add_paused { "Yes" } else { "No" })); } _ => { match app.add_modal_focus { app::AddModalFocus::Url => { app.add_input.handle_event(&evt); } app::AddModalFocus::Path => { app.add_path_input.handle_event(&evt); } app::AddModalFocus::Category => { app.add_category_input.handle_event(&evt); } app::AddModalFocus::Tags => { app.add_tags_input.handle_event(&evt); } } } } return Ok(()); } // ── Delete Confirmation Modal ────────────────────────────────── if app.show_delete_confirm { match key.code { KeyCode::Char('y') | KeyCode::Char('Y') => { let hashes = if !app.selected_hashes.is_empty() { app.selected_hashes.iter().cloned().collect() } else if let Some(hash) = app.selected_torrent_hash() { vec![hash] } else { Vec::new() }; if !hashes.is_empty() { let _ = cmd_tx .send(Command::Delete { hashes, delete_files: app.delete_with_files, }) .await; } app.show_delete_confirm = false; app.clear_selection(); } KeyCode::Char('n') | KeyCode::Char('N') | KeyCode::Esc => { app.show_delete_confirm = false; } _ => {} } return Ok(()); } // ── Normal Mode ──────────────────────────────────────────────── match key.code { // Quit KeyCode::Char('q') => app.quit(), KeyCode::Char('c') if key.modifiers.contains(event::KeyModifiers::CONTROL) => { app.quit() } // Focus cycling KeyCode::Tab => app.next_focus(), KeyCode::BackTab => app.prev_focus(), KeyCode::Char('h') | KeyCode::Left => app.prev_focus(), KeyCode::Char('l') | KeyCode::Right => app.next_focus(), // Details panel toggle KeyCode::Char('i') => { app.toggle_details(); refresh_details(app, cmd_tx).await; } // Details tabs KeyCode::Char('1') => { app.details_tab = app::DetailsTab::General; refresh_details(app, cmd_tx).await; } KeyCode::Char('2') => { app.details_tab = app::DetailsTab::Trackers; refresh_details(app, cmd_tx).await; } KeyCode::Char('3') => { app.details_tab = app::DetailsTab::Files; refresh_details(app, cmd_tx).await; } KeyCode::Char('4') => { app.details_tab = app::DetailsTab::Peers; refresh_details(app, cmd_tx).await; } KeyCode::Char(']') => { app.next_tab(); refresh_details(app, cmd_tx).await; } KeyCode::Char('[') => { app.previous_tab(); refresh_details(app, cmd_tx).await; } // Search toggle KeyCode::Char('/') => { app.show_search = true; } // Sorting KeyCode::Char('.') => { app.next_sort_column(); app.status_message = Some(format!("Sort by {:?} {}", app.sort_column, if app.sort_ascending { "asc" } else { "desc" })); } KeyCode::Char(',') => { app.toggle_sort_direction(); app.status_message = Some(format!("Sort by {:?} {}", app.sort_column, if app.sort_ascending { "asc" } else { "desc" })); } // Multi-selection KeyCode::Char('s') => { app.toggle_select_current(); } KeyCode::Char('S') => { app.clear_selection(); } // Add torrent file via native file dialog KeyCode::Char('a') => { let paths = file_dialog::pick_torrent_files(); for path in paths { let _ = cmd_tx.send(Command::AddFile(path)).await; } } // Add magnet modal KeyCode::Char('m') => { app.show_add_modal = true; } // Toggle speed limits KeyCode::Char('L') => { let _ = cmd_tx.send(Command::ToggleSpeedLimits).await; } // Recheck selected / current torrents KeyCode::Char('r') => { let hashes = if !app.selected_hashes.is_empty() { app.selected_hashes.iter().cloned().collect() } else if let Some(t) = app.selected_torrent() { vec![t.hash.clone()] } else { Vec::new() }; if !hashes.is_empty() { let _ = cmd_tx.send(Command::Recheck(hashes)).await; } } // Navigation depends on current focus KeyCode::Up | KeyCode::Char('k') => { match app.focus { app::Focus::Sidebar => app.previous_sidebar(), app::Focus::TorrentList => { app.previous_torrent(); refresh_details(app, cmd_tx).await; } app::Focus::DetailsPanel => {} } } KeyCode::Down | KeyCode::Char('j') => { match app.focus { app::Focus::Sidebar => app.next_sidebar(), app::Focus::TorrentList => { app.next_torrent(); refresh_details(app, cmd_tx).await; } app::Focus::DetailsPanel => {} } } // Pause / Resume (supports multi-selection) KeyCode::Char('p') => { if !app.selected_hashes.is_empty() { let hashes: Vec = app.selected_hashes.iter().cloned().collect(); // Check majority state of selected torrents let paused_count = app.torrents.iter() .filter(|t| app.selected_hashes.contains(&t.hash)) .filter(|t| t.status == "pausedDL" || t.status == "pausedUP" || t.status == "stoppedDL" || t.status == "stoppedUP") .count(); let is_majority_paused = paused_count * 2 >= hashes.len(); let (cmd, msg) = if is_majority_paused { (Command::Resume(hashes.clone()), format!("Resuming {} torrent(s)...", hashes.len())) } else { (Command::Pause(hashes.clone()), format!("Pausing {} torrent(s)...", hashes.len())) }; app.status_message = Some(msg); let _ = cmd_tx.send(cmd).await; } else if let Some(torrent) = app.selected_torrent() { let hash = torrent.hash.clone(); let is_paused = torrent.status == "pausedDL" || torrent.status == "pausedUP" || torrent.status == "stoppedDL" || torrent.status == "stoppedUP"; let (cmd, msg) = if is_paused { (Command::Resume(vec![hash.clone()]), format!("Resuming {}...", &hash[..8.min(hash.len())])) } else { (Command::Pause(vec![hash.clone()]), format!("Pausing {}...", &hash[..8.min(hash.len())])) }; app.status_message = Some(msg); let _ = cmd_tx.send(cmd).await; } } // Delete (supports multi-selection) KeyCode::Char('x') => { if !app.selected_hashes.is_empty() || app.selected_torrent_hash().is_some() { app.show_delete_confirm = true; app.delete_with_files = false; } } KeyCode::Delete => { if !app.selected_hashes.is_empty() || app.selected_torrent_hash().is_some() { app.show_delete_confirm = true; app.delete_with_files = false; } } KeyCode::Char('X') => { if !app.selected_hashes.is_empty() || app.selected_torrent_hash().is_some() { app.show_delete_confirm = true; app.delete_with_files = true; } } _ => {} } } Ok(()) }