feat: better search TUI, multi select downloads and parallel downloads

This commit is contained in:
Michel de Graaf
2025-11-12 00:26:03 +01:00
parent 98a8f53094
commit 30c4fd2aad
4 changed files with 765 additions and 133 deletions
+5 -18
View File
@@ -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<crate::types::DownloadResult> {
) -> Result<Receiver<DownloadStatus>> {
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<DownloadStatus>,
Receiver<DownloadStatus>,
) = 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(
+35 -2
View File
@@ -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<u8>, 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()?,
+6 -2
View File
@@ -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,
+719 -111
View File
@@ -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<usize> = 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<String>,
state: ListState,
should_exit: bool,
selected_index: Option<usize>,
#[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<String>) -> 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<Option<usize>> {
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<FileDownloadState>,
receivers: Vec<Option<Receiver<DownloadStatus>>>,
list_state: ListState,
max_concurrent: usize,
active_count: usize,
should_exit: bool,
}
impl MultiDownloadProgress {
fn new(
downloads: Vec<FileDownloadState>,
receivers: Vec<Receiver<DownloadStatus>>,
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<ListItem> = 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<FileDownloadState>,
receivers: Vec<Receiver<DownloadStatus>>,
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<String>,
items: Vec<String>,
filtered_indices: Vec<usize>,
state: ListState,
should_exit: bool,
selected_indices: HashSet<usize>,
search_query: String,
is_searching: bool,
}
impl FileSelector {
fn new(items: Vec<String>) -> Self {
let mut state = ListState::default();
state.select(Some(0));
let len = items.len();
let filtered_indices: Vec<usize> = (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<Vec<usize>> {
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<ListItem> = 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<String>) -> Result<Option<usize>> {
fn show_file_selector(options: Vec<String>) -> Result<Vec<usize>> {
// Enable log buffering before TUI starts
soulseek_rs::utils::logger::enable_buffering();