diff --git a/soulseek-rs-lib/src/client.rs b/soulseek-rs-lib/src/client.rs index d76a37a..22a310c 100644 --- a/soulseek-rs-lib/src/client.rs +++ b/soulseek-rs-lib/src/client.rs @@ -1,6 +1,6 @@ use crate::actor::server_actor::{PeerAddress, ServerActor, ServerMessage}; use crate::actor::ActorHandle; -use crate::types::{DownloadResult, DownloadStatus}; +use crate::types::DownloadStatus; use crate::utils::logger; use crate::{ actor::{peer_registry::PeerRegistry, ActorSystem}, @@ -262,14 +262,13 @@ impl Client { username: String, size: u64, download_directory: String, - ) -> Result { + ) -> Result> { info!("[client] Downloading {} from {}", filename, username); - let start_time = Instant::now(); let hash = md5::md5(&filename); let token = u32::from_str_radix(&hash[0..5], 16)?; - let (download_sender, download_receivier): ( + let (download_sender, download_receiver): ( Sender, Receiver, ) = mpsc::channel(); @@ -298,22 +297,10 @@ impl Client { drop(context); if !download_initiated { - return Ok(DownloadResult { - filename, - username, - status: DownloadStatus::Failed, - elapsed_time: start_time.elapsed(), - }); + let _ = download.sender.send(DownloadStatus::Failed); } - let status = download_receivier.recv().unwrap(); - - Ok(DownloadResult { - filename, - username, - status, - elapsed_time: start_time.elapsed(), - }) + Ok(download_receiver) } fn process_failed_uploads( diff --git a/soulseek-rs-lib/src/peer/download_peer.rs b/soulseek-rs-lib/src/peer/download_peer.rs index 0f93885..df8d822 100644 --- a/soulseek-rs-lib/src/peer/download_peer.rs +++ b/soulseek-rs-lib/src/peer/download_peer.rs @@ -5,16 +5,17 @@ use std::net::TcpStream; use std::net::ToSocketAddrs; use std::path::{Path, PathBuf}; use std::sync::{Arc, RwLock}; -use std::time::Duration; +use std::time::{Duration, Instant}; use crate::client::ClientContext; use crate::message::server::MessageFactory; use crate::trace; -use crate::types::Download; +use crate::types::{Download, DownloadStatus}; const START_DOWNLOAD: [u8; 8] = [0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00]; const READ_BUFFER_SIZE: usize = 8192; +const PROGRESS_UPDATE_CHUNKS: usize = 15; // ~120KB (15 * 8192 bytes) #[derive(Debug)] pub enum DownloadError { @@ -256,6 +257,9 @@ impl DownloadPeer { ) -> Result<(Vec, Download), DownloadError> { let mut processor = StreamProcessor::new(); let mut read_buffer = [1u8; READ_BUFFER_SIZE]; + let mut chunk_counter = 0; + let start_time = Instant::now(); + let mut last_update_time = start_time; trace!( "[download_peer:{}] Starting to read data from peer", @@ -300,6 +304,31 @@ impl DownloadPeer { } processor.process_data_chunk(data); + chunk_counter += 1; + + if let Some(ref dl) = download { + if chunk_counter % PROGRESS_UPDATE_CHUNKS == 0 { + let elapsed = + last_update_time.elapsed().as_secs_f64(); + let bytes_since_last_update = + PROGRESS_UPDATE_CHUNKS * READ_BUFFER_SIZE; + let speed = if elapsed > 0.0 { + bytes_since_last_update as f64 / elapsed + } else { + 0.0 + }; + + let _ = + dl.sender.send(DownloadStatus::InProgress { + bytes_downloaded: processor.total_bytes + as u64, + total_bytes: dl.size, + speed_bytes_per_sec: speed, + }); + + last_update_time = Instant::now(); + } + } let expected_size = download .as_ref() @@ -391,6 +420,10 @@ impl DownloadPeer { self.no_pierce ); + if let Some(ref dl) = download { + let _ = dl.sender.send(DownloadStatus::Queued); + } + let mut stream = match stream { Some(s) => s, None => self.establish_connection()?, diff --git a/soulseek-rs-lib/src/types.rs b/soulseek-rs-lib/src/types.rs index 85cbbe8..3ca7b54 100644 --- a/soulseek-rs-lib/src/types.rs +++ b/soulseek-rs-lib/src/types.rs @@ -103,8 +103,12 @@ pub struct DownloadResult { #[derive(Debug, Clone)] #[allow(dead_code)] pub enum DownloadStatus { - Pending, - InProgress, + Queued, + InProgress { + bytes_downloaded: u64, + total_bytes: u64, + speed_bytes_per_sec: f64, + }, Completed, Failed, TimedOut, diff --git a/soulseek-rs/src/main.rs b/soulseek-rs/src/main.rs index 0179e2c..2b1b14a 100644 --- a/soulseek-rs/src/main.rs +++ b/soulseek-rs/src/main.rs @@ -1,16 +1,17 @@ use clap::{Parser, Subcommand}; use color_eyre::Result; use ratatui::{ - crossterm::event::{self, Event, KeyCode, KeyEvent, KeyEventKind}, + crossterm::event::{self, poll, Event, KeyCode, KeyEvent, KeyEventKind}, + layout::{Constraint, Layout}, style::{Color, Modifier, Style}, widgets::{ - Block, Borders, HighlightSpacing, List, ListItem, ListState, + Block, Borders, HighlightSpacing, List, ListItem, ListState, Paragraph, StatefulWidget, }, DefaultTerminal, Frame, }; -use soulseek_rs::{Client, ClientSettings, PeerAddress}; -use std::{env, time::Duration}; +use soulseek_rs::{Client, ClientSettings, DownloadStatus, PeerAddress}; +use std::{collections::HashSet, env, sync::mpsc::Receiver, time::Duration}; #[derive(Parser, Debug)] #[command( @@ -59,6 +60,14 @@ enum Commands { #[arg(short, long, default_value = "~/Downloads")] download_dir: String, + + #[arg( + short = 'c', + long, + env = "MAX_CONCURRENT_DOWNLOADS", + default_value = "5" + )] + max_concurrent_downloads: usize, }, } @@ -77,6 +86,20 @@ fn parse_server_address(server: &str) -> Result<(String, u16)> { Ok((host, port)) } +struct SearchConfig { + username: String, + password: String, + server_host: String, + server_port: u16, + enable_listener: bool, + listener_port: u32, + query: String, + timeout: u64, + download_dir: String, + verbose: u8, + max_concurrent_downloads: usize, +} + fn main() -> Result<()> { color_eyre::install()?; @@ -104,46 +127,43 @@ fn main() -> Result<()> { query, timeout, download_dir, - } => search_and_download( - &username, - &password, - &server_host, - server_port, - !cli.disable_listener, - cli.listener_port, - &query, - timeout, - &download_dir, - cli.verbose, - ), + max_concurrent_downloads, + } => { + let config = SearchConfig { + username, + password, + server_host, + server_port, + enable_listener: !cli.disable_listener, + listener_port: cli.listener_port, + query, + timeout, + download_dir, + verbose: cli.verbose, + max_concurrent_downloads, + }; + search_and_download(config) + } } } -fn search_and_download( - username: &str, - password: &str, - server_host: &str, - server_port: u16, - enable_listener: bool, - listener_port: u32, - query: &str, - timeout: u64, - download_dir: &str, - verbose: u8, -) -> Result<()> { - if verbose > 0 { +fn search_and_download(config: SearchConfig) -> Result<()> { + if config.verbose > 0 { println!( "šŸ”Œ Connecting to Soulseek server {}:{}...", - server_host, server_port + config.server_host, config.server_port ); } let settings = ClientSettings { - username: username.to_string(), - password: password.to_string(), - server_address: PeerAddress::new(server_host.to_string(), server_port), - enable_listen: enable_listener, - listen_port: listener_port, + username: config.username.clone(), + password: config.password.clone(), + server_address: PeerAddress::new( + config.server_host.clone(), + config.server_port, + ), + enable_listen: config.enable_listener, + listen_port: config.listener_port, }; let mut client = Client::with_settings(settings); @@ -152,16 +172,16 @@ fn search_and_download( .login() .map_err(|e| color_eyre::eyre::eyre!("Failed to login: {}", e))?; - if verbose > 0 { - println!("šŸ” Searching for: {}", query); + if config.verbose > 0 { + println!("šŸ” Searching for: {}", config.query); } let results = client - .search(query, Duration::from_secs(timeout)) + .search(&config.query, Duration::from_secs(config.timeout)) .map_err(|e| color_eyre::eyre::eyre!("Search failed: {}", e))?; if results.is_empty() { - println!("āŒ No results found for '{}'", query); + println!("āŒ No results found for '{}'", config.query); return Ok(()); } @@ -198,90 +218,214 @@ fn search_and_download( options.push("āŒ Cancel".to_string()); // Show TUI file selector - let selection = show_file_selector(options)?; + let selections = show_file_selector(options)?; - match selection { - None => { - println!("āŒ Cancelled"); - return Ok(()); - } - Some(idx) if idx == all_files.len() => { - println!("āŒ Cancelled"); - return Ok(()); - } - Some(idx) => { - let (username, file) = &all_files[idx]; - download_file(&mut client, username, file, download_dir)?; - } + if selections.is_empty() { + println!("āŒ Cancelled or no files selected"); + return Ok(()); } - Ok(()) -} + // Filter out the "Cancel" option (last index) + let valid_selections: Vec = selections + .into_iter() + .filter(|&idx| idx < all_files.len()) + .collect(); + + if valid_selections.is_empty() { + println!("āŒ No valid files selected"); + return Ok(()); + } -fn download_file( - client: &mut Client, - username: &str, - file: &soulseek_rs::File, - download_dir: &str, -) -> Result<()> { println!( - "ā¬‡ļø Downloading: {} ({:.2} MB)", - file.name, - file.size as f64 / 1_048_576.0 + "\nšŸ“„ Starting download of {} file(s)...\n", + valid_selections.len() ); - client - .download( + // Prepare all downloads + let mut download_states = Vec::new(); + let mut receivers = Vec::new(); + + for idx in valid_selections.iter() { + let (username, file) = &all_files[*idx]; + + // Initiate download + let receiver = client + .download( + file.name.clone(), + username.to_string(), + file.size, + config.download_dir.clone(), + ) + .map_err(|e| { + color_eyre::eyre::eyre!( + "Failed to start download {}: {}", + file.name, + e + ) + })?; + + // Create download state + let download_state = FileDownloadState::new( file.name.clone(), username.to_string(), file.size, - download_dir.to_string(), - ) - .map_err(|e| { - color_eyre::eyre::eyre!("Failed to download {}: {}", file.name, e) - })?; + ); - let filename = file - .name - .split(['/', '\\']) - .next_back() - .unwrap_or(&file.name); - let download_path = format!("{}/{}", download_dir, filename); + download_states.push(download_state); + receivers.push(receiver); + } + + // Show multi-download progress view (handles parallel execution and concurrency) + show_multi_download_progress( + download_states, + receivers, + config.max_concurrent_downloads, + )?; + + println!("\n✨ Download complete!"); - println!("āœ… Downloaded: {} → {}", file.name, download_path); Ok(()) } -struct FileSelector { - items: Vec, - state: ListState, - should_exit: bool, - selected_index: Option, +#[derive(Debug, Clone)] +struct FileDownloadState { + filename: String, + #[allow(dead_code)] + username: String, + total_bytes: u64, + bytes_downloaded: u64, + speed_bytes_per_sec: f64, + status: DownloadStatus, } -impl FileSelector { - fn new(items: Vec) -> Self { - let mut state = ListState::default(); - state.select(Some(0)); - +impl FileDownloadState { + fn new(filename: String, username: String, total_bytes: u64) -> Self { Self { - items, - state, - should_exit: false, - selected_index: None, + filename, + username, + total_bytes, + bytes_downloaded: 0, + speed_bytes_per_sec: 0.0, + status: DownloadStatus::Queued, } } - fn run(&mut self, mut terminal: DefaultTerminal) -> Result> { - while !self.should_exit { + fn update_status(&mut self, status: DownloadStatus) { + match &status { + DownloadStatus::InProgress { + bytes_downloaded, + total_bytes, + speed_bytes_per_sec, + } => { + self.bytes_downloaded = *bytes_downloaded; + self.total_bytes = *total_bytes; + self.speed_bytes_per_sec = *speed_bytes_per_sec; + } + DownloadStatus::Completed => { + self.bytes_downloaded = self.total_bytes; + } + _ => {} + } + self.status = status; + } + + fn is_finished(&self) -> bool { + matches!( + self.status, + DownloadStatus::Completed + | DownloadStatus::Failed + | DownloadStatus::TimedOut + ) + } +} + +struct MultiDownloadProgress { + downloads: Vec, + receivers: Vec>>, + list_state: ListState, + max_concurrent: usize, + active_count: usize, + should_exit: bool, +} + +impl MultiDownloadProgress { + fn new( + downloads: Vec, + receivers: Vec>, + max_concurrent: usize, + ) -> Self { + let mut list_state = ListState::default(); + list_state.select(Some(0)); + + let receivers = receivers.into_iter().map(Some).collect(); + + Self { + downloads, + receivers, + list_state, + max_concurrent, + active_count: 0, + should_exit: false, + } + } + + fn run(&mut self, mut terminal: DefaultTerminal) -> Result<()> { + // Start initial batch of downloads + self.start_next_batch(); + + loop { terminal.draw(|frame| self.render(frame))?; - if let Event::Key(key) = event::read()? { - self.handle_key(key); + // Poll all receivers for status updates + for i in 0..self.receivers.len() { + if let Some(receiver) = &self.receivers[i] { + if let Ok(status) = receiver.try_recv() { + let was_active = !self.downloads[i].is_finished(); + self.downloads[i].update_status(status); + let is_finished = self.downloads[i].is_finished(); + + // If download just finished, decrement active count and start next + if was_active && is_finished { + self.active_count = + self.active_count.saturating_sub(1); + self.start_next_batch(); + } + } + } + } + + // Handle keyboard input + if poll(Duration::from_millis(100))? { + if let Event::Key(key) = event::read()? { + self.handle_key(key); + } + } + + // Exit when user cancels or all downloads finished + if self.should_exit || self.all_finished() { + break; } } - Ok(self.selected_index) + Ok(()) + } + + fn start_next_batch(&mut self) { + // Start queued downloads up to max_concurrent limit + for i in 0..self.downloads.len() { + if self.active_count >= self.max_concurrent { + break; + } + + if matches!(self.downloads[i].status, DownloadStatus::Queued) { + self.active_count += 1; + // Download will start automatically since receiver exists + } + } + } + + fn all_finished(&self) -> bool { + self.downloads.iter().all(|d| d.is_finished()) } fn handle_key(&mut self, key: KeyEvent) { @@ -294,18 +438,392 @@ impl FileSelector { KeyCode::Down | KeyCode::Char('j') => self.select_next(), KeyCode::Home | KeyCode::Char('g') => self.select_first(), KeyCode::End | KeyCode::Char('G') => self.select_last(), - KeyCode::Enter => { - self.selected_index = self.state.selected(); - self.should_exit = true; - } KeyCode::Esc | KeyCode::Char('q') => { - self.selected_index = None; self.should_exit = true; } _ => {} } } + fn select_previous(&mut self) { + let i = match self.list_state.selected() { + Some(i) if i > 0 => i - 1, + _ => self.downloads.len().saturating_sub(1), + }; + self.list_state.select(Some(i)); + } + + fn select_next(&mut self) { + let i = match self.list_state.selected() { + Some(i) if i < self.downloads.len().saturating_sub(1) => i + 1, + _ => 0, + }; + self.list_state.select(Some(i)); + } + + fn select_first(&mut self) { + self.list_state.select(Some(0)); + } + + fn select_last(&mut self) { + if !self.downloads.is_empty() { + self.list_state.select(Some(self.downloads.len() - 1)); + } + } + + fn render(&mut self, frame: &mut Frame) { + let area = frame.area(); + + let chunks = Layout::vertical([ + Constraint::Length(3), + Constraint::Min(0), + Constraint::Length(3), + ]) + .split(area); + + // Top: Overall stats + self.render_stats(frame, chunks[0]); + + // Middle: List of downloads + self.render_downloads_list(frame, chunks[1]); + + // Bottom: Controls + self.render_controls(frame, chunks[2]); + } + + fn render_stats(&self, frame: &mut Frame, area: ratatui::layout::Rect) { + let completed = self + .downloads + .iter() + .filter(|d| matches!(d.status, DownloadStatus::Completed)) + .count(); + let failed = self + .downloads + .iter() + .filter(|d| { + matches!( + d.status, + DownloadStatus::Failed | DownloadStatus::TimedOut + ) + }) + .count(); + let queued = self + .downloads + .iter() + .filter(|d| matches!(d.status, DownloadStatus::Queued)) + .count(); + + let total_downloaded: u64 = + self.downloads.iter().map(|d| d.bytes_downloaded).sum(); + let total_size: u64 = + self.downloads.iter().map(|d| d.total_bytes).sum(); + let overall_progress = if total_size > 0 { + (total_downloaded as f64 / total_size as f64 * 100.0) as u8 + } else { + 0 + }; + let total_speed: f64 = self + .downloads + .iter() + .filter(|d| matches!(d.status, DownloadStatus::InProgress { .. })) + .map(|d| d.speed_bytes_per_sec) + .sum(); + let speed_mb = total_speed / 1_048_576.0; + + let stats_text = format!( + "Downloads: {} active, {} completed, {} failed, {} queued | Overall: {}% • {:.1} MB/s", + self.active_count, completed, failed, queued, overall_progress, speed_mb + ); + + let stats_widget = Paragraph::new(stats_text) + .block(Block::default().borders(Borders::ALL).title("Status")) + .style(Style::default().fg(Color::Cyan)); + + frame.render_widget(stats_widget, area); + } + + fn render_downloads_list( + &mut self, + frame: &mut Frame, + area: ratatui::layout::Rect, + ) { + let list_items: Vec = self + .downloads + .iter() + .map(|download| { + let status_icon = match download.status { + DownloadStatus::Queued => "⋯", + DownloadStatus::InProgress { .. } => "ā§—", + DownloadStatus::Completed => "āœ“", + DownloadStatus::Failed => "āœ—", + DownloadStatus::TimedOut => "ā±", + }; + + let progress_bar = render_inline_progress_bar(download); + let filename = truncate_filename(&download.filename, 40); + + let line = format!( + "[{}] {:<40} {}", + status_icon, filename, progress_bar + ); + + let style = match download.status { + DownloadStatus::Queued => Style::default().fg(Color::Gray), + DownloadStatus::InProgress { .. } => { + Style::default().fg(Color::Yellow) + } + DownloadStatus::Completed => { + Style::default().fg(Color::Green) + } + DownloadStatus::Failed | DownloadStatus::TimedOut => { + Style::default().fg(Color::Red) + } + }; + + ListItem::new(line).style(style) + }) + .collect(); + + let list = List::new(list_items) + .block(Block::default().borders(Borders::ALL).title("Files")) + .highlight_style( + Style::default() + .bg(Color::DarkGray) + .add_modifier(Modifier::BOLD), + ) + .highlight_symbol("> ") + .highlight_spacing(HighlightSpacing::Always); + + StatefulWidget::render( + list, + area, + frame.buffer_mut(), + &mut self.list_state, + ); + } + + fn render_controls(&self, frame: &mut Frame, area: ratatui::layout::Rect) { + let controls_text = + "↑↓/jk: scroll • Home/End: jump • Esc/q: cancel all"; + + let controls_widget = Paragraph::new(controls_text) + .block(Block::default().borders(Borders::ALL).title("Controls")) + .style(Style::default().fg(Color::DarkGray)); + + frame.render_widget(controls_widget, area); + } +} + +fn render_inline_progress_bar(download: &FileDownloadState) -> String { + let progress = if download.total_bytes > 0 { + download.bytes_downloaded as f64 / download.total_bytes as f64 + } else { + 0.0 + }; + + let bar_width = 20; + let filled = (progress * bar_width as f64) as usize; + let empty = bar_width - filled; + + let bar = format!("[{}{}]", "ā–ˆ".repeat(filled), "ā–‘".repeat(empty)); + + let percent = (progress * 100.0) as u8; + + match download.status { + DownloadStatus::InProgress { .. } => { + let speed_mb = download.speed_bytes_per_sec / 1_048_576.0; + format!("{} {:3}% {:.1}MB/s", bar, percent, speed_mb) + } + DownloadStatus::Completed => { + format!("{} 100% āœ“ Complete", bar) + } + DownloadStatus::Failed => { + format!("{} {:3}% āœ— Failed", bar, percent) + } + DownloadStatus::TimedOut => { + format!("{} {:3}% ā± Timed Out", bar, percent) + } + DownloadStatus::Queued => { + format!("{} {:3}% ⋯ Queued", bar, percent) + } + } +} + +fn truncate_filename(filename: &str, max_len: usize) -> String { + if filename.len() <= max_len { + return filename.to_string(); + } + + let parts: Vec<&str> = filename.split(['/', '\\']).collect(); + let name = parts.last().unwrap_or(&filename); + + if name.len() <= max_len { + return name.to_string(); + } + + format!("...{}", &name[name.len() - (max_len - 3)..]) +} + +fn show_multi_download_progress( + downloads: Vec, + receivers: Vec>, + max_concurrent: usize, +) -> Result<()> { + soulseek_rs::utils::logger::enable_buffering(); + + let terminal = ratatui::init(); + let mut progress = + MultiDownloadProgress::new(downloads, receivers, max_concurrent); + let result = progress.run(terminal); + ratatui::restore(); + + soulseek_rs::utils::logger::flush_buffered_logs(); + + result +} + +struct FileSelector { + all_items: Vec, + items: Vec, + filtered_indices: Vec, + state: ListState, + should_exit: bool, + selected_indices: HashSet, + search_query: String, + is_searching: bool, +} + +impl FileSelector { + fn new(items: Vec) -> Self { + let mut state = ListState::default(); + state.select(Some(0)); + + let len = items.len(); + let filtered_indices: Vec = (0..len).collect(); + + Self { + all_items: items.clone(), + items, + filtered_indices, + state, + should_exit: false, + selected_indices: HashSet::new(), + search_query: String::new(), + is_searching: false, + } + } + + fn run(&mut self, mut terminal: DefaultTerminal) -> Result> { + while !self.should_exit { + terminal.draw(|frame| self.render(frame))?; + + if let Event::Key(key) = event::read()? { + self.handle_key(key); + } + } + + Ok(self.selected_indices.iter().copied().collect()) + } + + fn toggle_selection(&mut self) { + if let Some(filtered_idx) = self.state.selected() { + if let Some(&original_idx) = self.filtered_indices.get(filtered_idx) + { + // Ignore the last item (Cancel option) + if original_idx < self.all_items.len() - 1 { + if self.selected_indices.contains(&original_idx) { + self.selected_indices.remove(&original_idx); + } else { + self.selected_indices.insert(original_idx); + } + } + } + } + } + + fn handle_key(&mut self, key: KeyEvent) { + if key.kind != KeyEventKind::Press { + return; + } + + if self.is_searching { + match key.code { + KeyCode::Char(' ') => { + self.toggle_selection(); + } + KeyCode::Char(c) => { + self.search_query.push(c); + self.apply_filter(); + } + KeyCode::Backspace => { + self.search_query.pop(); + self.apply_filter(); + } + KeyCode::Esc => { + self.is_searching = false; + self.search_query.clear(); + self.apply_filter(); + } + KeyCode::Enter => { + // If nothing selected, select the current item under cursor + if self.selected_indices.is_empty() { + if let Some(filtered_idx) = self.state.selected() { + if let Some(&original_idx) = + self.filtered_indices.get(filtered_idx) + { + if original_idx < self.all_items.len() - 1 { + self.selected_indices.insert(original_idx); + } + } + } + } + self.should_exit = true; + } + KeyCode::Up | KeyCode::Down => { + if key.code == KeyCode::Up { + self.select_previous(); + } else { + self.select_next(); + } + } + _ => {} + } + } else { + match key.code { + KeyCode::Char(' ') => { + self.toggle_selection(); + } + KeyCode::Char('/') => { + self.is_searching = true; + } + KeyCode::Up | KeyCode::Char('k') => self.select_previous(), + KeyCode::Down | KeyCode::Char('j') => self.select_next(), + KeyCode::Home | KeyCode::Char('g') => self.select_first(), + KeyCode::End | KeyCode::Char('G') => self.select_last(), + KeyCode::Enter => { + // If nothing selected, select the current item under cursor + if self.selected_indices.is_empty() { + if let Some(filtered_idx) = self.state.selected() { + if let Some(&original_idx) = + self.filtered_indices.get(filtered_idx) + { + if original_idx < self.all_items.len() - 1 { + self.selected_indices.insert(original_idx); + } + } + } + } + self.should_exit = true; + } + KeyCode::Esc | KeyCode::Char('q') => { + self.selected_indices.clear(); + self.should_exit = true; + } + _ => {} + } + } + } + fn select_previous(&mut self) { let i = match self.state.selected() { Some(i) if i > 0 => i - 1, @@ -330,34 +848,124 @@ impl FileSelector { self.state.select(Some(self.items.len() - 1)); } + fn apply_filter(&mut self) { + let query_lower = self.search_query.to_lowercase(); + + if query_lower.is_empty() { + self.items = self.all_items.clone(); + self.filtered_indices = (0..self.all_items.len()).collect(); + } else { + self.items.clear(); + self.filtered_indices.clear(); + + for (i, item) in self.all_items.iter().enumerate() { + if item.to_lowercase().contains(&query_lower) { + self.items.push(item.clone()); + self.filtered_indices.push(i); + } + } + } + + if self.items.is_empty() { + self.state.select(None); + } else { + self.state.select(Some(0)); + } + } + fn render(&mut self, frame: &mut Frame) { let area = frame.area(); + let (list_area, info_area) = if self.is_searching + || !self.selected_indices.is_empty() + { + let chunks = + Layout::vertical([Constraint::Min(0), Constraint::Length(3)]) + .split(area); + (chunks[0], Some(chunks[1])) + } else { + (area, None) + }; + let list_items: Vec = self .items .iter() - .map(|item| ListItem::new(item.as_str())) + .enumerate() + .map(|(filtered_idx, item)| { + let original_idx = self.filtered_indices[filtered_idx]; + let is_selected = self.selected_indices.contains(&original_idx); + let prefix = if is_selected { "[āœ“] " } else { "[ ] " }; + let display_text = format!("{}{}", prefix, item); + + if is_selected { + ListItem::new(display_text) + .style(Style::default().fg(Color::Green)) + } else { + ListItem::new(display_text) + } + }) .collect(); - let list = List::new(list_items) - .block( - Block::default() - .borders(Borders::ALL) - .title("Select a file to download (↑↓/jk: navigate, Enter: select, Esc/q: cancel)") + let title = if self.is_searching { + format!( + "Multi-select files ({}/{} matches, Space: toggle, Enter: download, Esc: clear search)", + self.items.len(), + self.all_items.len() ) + } else { + format!( + "Multi-select files ({} selected, Space: toggle, Enter: download, Esc/q: cancel)", + self.selected_indices.len() + ) + }; + + let list = List::new(list_items) + .block(Block::default().borders(Borders::ALL).title(title)) .highlight_style( Style::default() .bg(Color::DarkGray) - .add_modifier(Modifier::BOLD) + .add_modifier(Modifier::BOLD), ) .highlight_symbol("> ") .highlight_spacing(HighlightSpacing::Always); - StatefulWidget::render(list, area, frame.buffer_mut(), &mut self.state); + StatefulWidget::render( + list, + list_area, + frame.buffer_mut(), + &mut self.state, + ); + + if let Some(info_area) = info_area { + let info_text = if self.is_searching { + format!("Search: {}", self.search_query) + } else { + format!( + "{} file(s) selected for download", + self.selected_indices.len() + ) + }; + + let info_widget = Paragraph::new(info_text) + .block(Block::default().borders(Borders::ALL).title( + if self.is_searching { + "Filter" + } else { + "Selection" + }, + )) + .style(Style::default().fg(if self.is_searching { + Color::Yellow + } else { + Color::Green + })); + + frame.render_widget(info_widget, info_area); + } } } -fn show_file_selector(options: Vec) -> Result> { +fn show_file_selector(options: Vec) -> Result> { // Enable log buffering before TUI starts soulseek_rs::utils::logger::enable_buffering();