chore: refactor UI to use Downloads struct

This commit is contained in:
Michel de Graaf
2025-11-18 14:33:33 +01:00
parent 79da19ed0f
commit 4c6d7c6850
8 changed files with 116 additions and 189 deletions
+4
View File
@@ -374,6 +374,10 @@ impl Client {
self.context.read().unwrap().searches.clone() self.context.read().unwrap().searches.clone()
} }
pub fn get_all_downloads(&self) -> Vec<Download> {
self.context.read().unwrap().get_downloads().clone()
}
pub fn download( pub fn download(
&self, &self,
filename: String, filename: String,
+24
View File
@@ -98,6 +98,30 @@ pub struct Download {
pub sender: Sender<DownloadStatus>, pub sender: Sender<DownloadStatus>,
} }
impl Download {
pub fn is_finished(&self) -> bool {
matches!(
self.status,
DownloadStatus::Completed | DownloadStatus::Failed | DownloadStatus::TimedOut
)
}
pub fn bytes_downloaded(&self) -> u64 {
match &self.status {
DownloadStatus::InProgress { bytes_downloaded, .. } => *bytes_downloaded,
DownloadStatus::Completed => self.size,
_ => 0,
}
}
pub fn speed_bytes_per_sec(&self) -> f64 {
match &self.status {
DownloadStatus::InProgress { speed_bytes_per_sec, .. } => *speed_bytes_per_sec,
_ => 0.0,
}
}
}
#[derive(Debug, Clone)] #[derive(Debug, Clone)]
#[allow(dead_code)] #[allow(dead_code)]
pub enum DownloadStatus { pub enum DownloadStatus {
+4 -4
View File
@@ -1,6 +1,6 @@
use crate::models::{FileDisplayData, FileDownloadState}; use crate::models::FileDisplayData;
use ratatui::{layout::Rect, widgets::TableState}; use ratatui::{layout::Rect, widgets::TableState};
use soulseek_rs::DownloadStatus; use soulseek_rs::{types::Download, DownloadStatus};
use std::sync::atomic::AtomicBool; use std::sync::atomic::AtomicBool;
use std::sync::{mpsc::Receiver, Arc}; use std::sync::{mpsc::Receiver, Arc};
use std::time::Instant; use std::time::Instant;
@@ -22,7 +22,7 @@ pub struct SearchEntry {
} }
pub struct DownloadEntry { pub struct DownloadEntry {
pub state: FileDownloadState, pub download: Download,
pub receiver: Option<Receiver<DownloadStatus>>, pub receiver: Option<Receiver<DownloadStatus>>,
} }
@@ -52,7 +52,7 @@ pub struct AppState {
pub downloads: Vec<DownloadEntry>, pub downloads: Vec<DownloadEntry>,
pub downloads_table_state: TableState, pub downloads_table_state: TableState,
pub downloads_receiver_channel: pub downloads_receiver_channel:
Option<Receiver<(usize, Receiver<DownloadStatus>)>>, Option<Receiver<(Download, Receiver<DownloadStatus>)>>,
pub active_downloads_count: usize, pub active_downloads_count: usize,
// UI State // UI State
-52
View File
@@ -1,52 +0,0 @@
use soulseek_rs::DownloadStatus;
#[derive(Debug, Clone)]
pub struct FileDownloadState {
pub filename: String,
pub username: String,
pub total_bytes: u64,
pub bytes_downloaded: u64,
pub speed_bytes_per_sec: f64,
pub status: DownloadStatus,
}
impl FileDownloadState {
pub fn new(filename: String, username: String, total_bytes: u64) -> Self {
Self {
filename,
username,
total_bytes,
bytes_downloaded: 0,
speed_bytes_per_sec: 0.0,
status: DownloadStatus::Queued,
}
}
pub 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;
}
pub fn is_finished(&self) -> bool {
matches!(
self.status,
DownloadStatus::Completed
| DownloadStatus::Failed
| DownloadStatus::TimedOut
)
}
}
-2
View File
@@ -1,9 +1,7 @@
mod app_state; mod app_state;
mod download_state;
mod file_display_data; mod file_display_data;
pub use app_state::{ pub use app_state::{
AppState, DownloadEntry, FocusedPane, SearchEntry, SearchStatus, AppState, DownloadEntry, FocusedPane, SearchEntry, SearchStatus,
}; };
pub use download_state::FileDownloadState;
pub use file_display_data::FileDisplayData; pub use file_display_data::FileDisplayData;
+58 -72
View File
@@ -1,4 +1,4 @@
use crate::models::FileDownloadState; use crate::models::DownloadEntry;
use crate::ui::{ use crate::ui::{
border_style, border_type, error_style, format_bytes_progress, border_style, border_type, error_style, format_bytes_progress,
format_progress_bar, format_shortcuts_styled, format_speed, header_style, format_progress_bar, format_shortcuts_styled, format_speed, header_style,
@@ -27,9 +27,8 @@ use std::{
const VERSION: &str = env!("CARGO_PKG_VERSION"); const VERSION: &str = env!("CARGO_PKG_VERSION");
pub struct MultiDownloadProgress { pub struct MultiDownloadProgress {
downloads: Vec<FileDownloadState>, downloads: Vec<DownloadEntry>,
receivers: Vec<Option<Receiver<DownloadStatus>>>, receiver_channel: Receiver<(soulseek_rs::types::Download, Receiver<DownloadStatus>)>,
receiver_channel: Receiver<(usize, Receiver<DownloadStatus>)>,
list_state: TableState, list_state: TableState,
max_concurrent: usize, max_concurrent: usize,
active_count: usize, active_count: usize,
@@ -39,19 +38,14 @@ pub struct MultiDownloadProgress {
impl MultiDownloadProgress { impl MultiDownloadProgress {
pub fn new( pub fn new(
downloads: Vec<FileDownloadState>, receiver_channel: Receiver<(soulseek_rs::types::Download, Receiver<DownloadStatus>)>,
receiver_channel: Receiver<(usize, Receiver<DownloadStatus>)>,
max_concurrent: usize, max_concurrent: usize,
) -> Self { ) -> Self {
let mut list_state = TableState::default(); let mut list_state = TableState::default();
list_state.select(Some(0)); list_state.select(Some(0));
// Initialize all receivers as None - they'll be populated asynchronously
let receivers = (0..downloads.len()).map(|_| None).collect();
Self { Self {
downloads, downloads: Vec::new(),
receivers,
receiver_channel, receiver_channel,
list_state, list_state,
max_concurrent, max_concurrent,
@@ -68,37 +62,38 @@ impl MultiDownloadProgress {
loop { loop {
terminal.draw(|frame| self.render(frame))?; terminal.draw(|frame| self.render(frame))?;
// Check for incoming download receivers from background thread // Check for incoming downloads from background thread
while let Ok((index, receiver)) = self.receiver_channel.try_recv() { while let Ok((download, receiver)) = self.receiver_channel.try_recv() {
self.receivers[index] = Some(receiver); self.downloads.push(DownloadEntry {
// Update status message download,
let queued = receiver: Some(receiver),
self.receivers.iter().filter(|r| r.is_none()).count(); });
if queued == 0 { self.queuing_status = format!("{} downloads queued", self.downloads.len());
self.queuing_status = String::from("All downloads queued");
} else {
self.queuing_status =
format!("Queuing... {} remaining", queued);
}
} }
// Poll all receivers for status updates // Poll all receivers for status updates
for i in 0..self.receivers.len() { let mut need_start_next = false;
if let Some(receiver) = &self.receivers[i] { for download_entry in &mut self.downloads {
if let Some(receiver) = &download_entry.receiver {
if let Ok(status) = receiver.try_recv() { if let Ok(status) = receiver.try_recv() {
let was_active = !self.downloads[i].is_finished(); let was_active = !download_entry.download.is_finished();
self.downloads[i].update_status(status); download_entry.download.status = status;
let is_finished = self.downloads[i].is_finished(); let is_finished = download_entry.download.is_finished();
// If download just finished, decrement active count and start next // If download just finished, decrement active count
if was_active && is_finished { if was_active && is_finished {
self.active_count = self.active_count =
self.active_count.saturating_sub(1); self.active_count.saturating_sub(1);
need_start_next = true;
}
}
}
}
// Start next batch after iteration completes
if need_start_next {
self.start_next_batch(); self.start_next_batch();
} }
}
}
}
// Handle keyboard input // Handle keyboard input
if poll(Duration::from_millis(100))? { if poll(Duration::from_millis(100))? {
@@ -118,14 +113,14 @@ impl MultiDownloadProgress {
fn start_next_batch(&mut self) { fn start_next_batch(&mut self) {
// Start queued downloads up to max_concurrent limit // Start queued downloads up to max_concurrent limit
for i in 0..self.downloads.len() { for download_entry in &self.downloads {
if self.active_count >= self.max_concurrent { if self.active_count >= self.max_concurrent {
break; break;
} }
// Only start if download is queued AND receiver is available // Only start if download is queued AND receiver is available
if matches!(self.downloads[i].status, DownloadStatus::Queued) if matches!(download_entry.download.status, DownloadStatus::Queued)
&& self.receivers[i].is_some() && download_entry.receiver.is_some()
{ {
self.active_count += 1; self.active_count += 1;
// Download will start automatically since receiver exists // Download will start automatically since receiver exists
@@ -134,7 +129,7 @@ impl MultiDownloadProgress {
} }
fn all_finished(&self) -> bool { fn all_finished(&self) -> bool {
self.downloads.iter().all(|d| d.is_finished()) self.downloads.iter().all(|d| d.download.is_finished())
} }
fn handle_key(&mut self, key: KeyEvent) { fn handle_key(&mut self, key: KeyEvent) {
@@ -223,7 +218,8 @@ impl MultiDownloadProgress {
let rows: Vec<Row> = self let rows: Vec<Row> = self
.downloads .downloads
.iter() .iter()
.map(|download| { .map(|download_entry| {
let download = &download_entry.download;
let status_icon = match download.status { let status_icon = match download.status {
DownloadStatus::Queued => "⋯", DownloadStatus::Queued => "⋯",
DownloadStatus::InProgress { .. } => "⧗", DownloadStatus::InProgress { .. } => "⧗",
@@ -232,9 +228,9 @@ impl MultiDownloadProgress {
DownloadStatus::TimedOut => "⏱", DownloadStatus::TimedOut => "⏱",
}; };
let progress = if download.total_bytes > 0 { let progress = if download.size > 0 {
download.bytes_downloaded as f64 download.bytes_downloaded() as f64
/ download.total_bytes as f64 / download.size as f64
} else { } else {
0.0 0.0
}; };
@@ -245,13 +241,13 @@ impl MultiDownloadProgress {
format_progress_bar(progress, bar_width, percent); format_progress_bar(progress, bar_width, percent);
let size_str = format_bytes_progress( let size_str = format_bytes_progress(
download.bytes_downloaded, download.bytes_downloaded(),
download.total_bytes, download.size,
); );
let speed_str = match download.status { let speed_str = match download.status {
DownloadStatus::InProgress { .. } => { DownloadStatus::InProgress { .. } => {
format_speed(download.speed_bytes_per_sec) format_speed(download.speed_bytes_per_sec())
} }
_ => "-".to_string(), _ => "-".to_string(),
}; };
@@ -331,30 +327,30 @@ impl MultiDownloadProgress {
pub fn render_download_stats( pub fn render_download_stats(
frame: &mut Frame, frame: &mut Frame,
area: ratatui::layout::Rect, area: ratatui::layout::Rect,
downloads: &[FileDownloadState], downloads: &[DownloadEntry],
active_count: usize, active_count: usize,
) { ) {
let completed = downloads let completed = downloads
.iter() .iter()
.filter(|d| matches!(d.status, DownloadStatus::Completed)) .filter(|d| matches!(d.download.status, DownloadStatus::Completed))
.count(); .count();
let failed = downloads let failed = downloads
.iter() .iter()
.filter(|d| { .filter(|d| {
matches!( matches!(
d.status, d.download.status,
DownloadStatus::Failed | DownloadStatus::TimedOut DownloadStatus::Failed | DownloadStatus::TimedOut
) )
}) })
.count(); .count();
let queued = downloads let queued = downloads
.iter() .iter()
.filter(|d| matches!(d.status, DownloadStatus::Queued)) .filter(|d| matches!(d.download.status, DownloadStatus::Queued))
.count(); .count();
let total_downloaded: u64 = let total_downloaded: u64 =
downloads.iter().map(|d| d.bytes_downloaded).sum(); downloads.iter().map(|d| d.download.bytes_downloaded()).sum();
let total_size: u64 = downloads.iter().map(|d| d.total_bytes).sum(); let total_size: u64 = downloads.iter().map(|d| d.download.size).sum();
let overall_progress = if total_size > 0 { let overall_progress = if total_size > 0 {
(total_downloaded as f64 / total_size as f64 * 100.0) as u8 (total_downloaded as f64 / total_size as f64 * 100.0) as u8
} else { } else {
@@ -367,8 +363,8 @@ pub fn render_download_stats(
}; };
let total_speed: f64 = downloads let total_speed: f64 = downloads
.iter() .iter()
.filter(|d| matches!(d.status, DownloadStatus::InProgress { .. })) .filter(|d| matches!(d.download.status, DownloadStatus::InProgress { .. }))
.map(|d| d.speed_bytes_per_sec) .map(|d| d.download.speed_bytes_per_sec())
.sum(); .sum();
let speed_mb = (total_speed / 1_048_576.0 * 100.0).round() / 100.0; let speed_mb = (total_speed / 1_048_576.0 * 100.0).round() / 100.0;
@@ -465,45 +461,35 @@ pub fn show_multi_download_progress(
) -> Result<()> { ) -> Result<()> {
soulseek_rs::utils::logger::enable_buffering(); soulseek_rs::utils::logger::enable_buffering();
// Create download states immediately (all with Queued status) // Create channel for sending downloads and receivers from background thread
let downloads: Vec<FileDownloadState> = selected_files
.iter()
.map(|(filename, username, size)| {
FileDownloadState::new(filename.clone(), username.clone(), *size)
})
.collect();
// Create channel for sending receivers from background thread
let (tx, rx) = mpsc::channel(); let (tx, rx) = mpsc::channel();
// Spawn background thread to initialize downloads // Spawn background thread to initialize downloads
let init_client = client.clone(); let init_client = client.clone();
let init_files = selected_files.clone();
let init_dir = download_dir.clone();
thread::spawn(move || { thread::spawn(move || {
for (index, (filename, username, size)) in for (filename, username, size) in selected_files.into_iter() {
init_files.into_iter().enumerate()
{
// Initiate download // Initiate download
match init_client.download( match init_client.download(
filename, filename.clone(),
username, username.clone(),
size, size,
init_dir.clone(), download_dir.clone(),
) { ) {
Ok(receiver) => { Ok(receiver) => {
// Send receiver back to main thread // Get the download from client
let _ = tx.send((index, receiver)); if let Some(download) = init_client.get_all_downloads().iter().find(|d| d.filename == filename && d.username == username).cloned() {
let _ = tx.send((download, receiver));
}
} }
Err(e) => { Err(e) => {
eprintln!("Failed to start download {}: {}", index, e); eprintln!("Failed to start download for {}: {}", filename, e);
} }
} }
} }
}); });
let mut progress = let mut progress =
MultiDownloadProgress::new(downloads, rx, max_concurrent); MultiDownloadProgress::new(rx, max_concurrent);
let result = progress.run(terminal); let result = progress.run(terminal);
ratatui::restore(); ratatui::restore();
+18 -52
View File
@@ -1,5 +1,5 @@
use crate::models::{ use crate::models::{
AppState, DownloadEntry, FileDisplayData, FileDownloadState, FocusedPane, AppState, DownloadEntry, FileDisplayData, FocusedPane,
SearchEntry, SearchStatus, SearchEntry, SearchStatus,
}; };
use crate::ui::panes::{ use crate::ui::panes::{
@@ -113,16 +113,10 @@ impl MainTui {
}; };
// Render status bar // Render status bar
let download_states: Vec<FileDownloadState> = self
.state
.downloads
.iter()
.map(|d| d.state.clone())
.collect();
render_download_stats( render_download_stats(
frame, frame,
main_chunks[0], main_chunks[0],
&download_states, &self.state.downloads,
self.state.active_downloads_count, self.state.active_downloads_count,
); );
@@ -184,16 +178,10 @@ impl MainTui {
); );
// Render Downloads pane // Render Downloads pane
let download_states: Vec<FileDownloadState> = self
.state
.downloads
.iter()
.map(|d| d.state.clone())
.collect();
render_downloads_pane( render_downloads_pane(
frame, frame,
right_chunks[1], right_chunks[1],
&download_states, &self.state.downloads,
&mut self.state.downloads_table_state, &mut self.state.downloads_table_state,
self.state.focused_pane == FocusedPane::Downloads, self.state.focused_pane == FocusedPane::Downloads,
); );
@@ -792,27 +780,12 @@ impl MainTui {
let (sender, receiver) = mpsc::channel(); let (sender, receiver) = mpsc::channel();
self.state.downloads_receiver_channel = Some(receiver); self.state.downloads_receiver_channel = Some(receiver);
// Add downloads to state
for (filename, username, size) in &selected_files {
let download_state = FileDownloadState::new(
filename.clone(),
username.clone(),
*size,
);
self.state.downloads.push(DownloadEntry {
state: download_state,
receiver: None,
});
}
// Start downloads in background // Start downloads in background
let client = self.client.clone(); let client = self.client.clone();
let download_dir = self.download_dir.clone(); let download_dir = self.download_dir.clone();
thread::spawn(move || { thread::spawn(move || {
for (idx, (filename, username, size)) in for (filename, username, size) in selected_files.into_iter() {
selected_files.into_iter().enumerate()
{
match client.download( match client.download(
filename.clone(), filename.clone(),
username.clone(), username.clone(),
@@ -820,7 +793,10 @@ impl MainTui {
download_dir.clone(), download_dir.clone(),
) { ) {
Ok(rx) => { Ok(rx) => {
let _ = sender.send((idx, rx)); // Get the download from client
if let Some(download) = client.get_all_downloads().iter().find(|d| d.filename == filename && d.username == username).cloned() {
let _ = sender.send((download, rx));
}
} }
Err(e) => { Err(e) => {
eprintln!( eprintln!(
@@ -837,37 +813,27 @@ impl MainTui {
} }
fn update_downloads(&mut self) { fn update_downloads(&mut self) {
// Check for new download receivers // Check for new downloads from background thread
if let Some(ref receiver) = self.state.downloads_receiver_channel { if let Some(ref receiver) = self.state.downloads_receiver_channel {
while let Ok((idx, download_receiver)) = receiver.try_recv() { while let Ok((download, download_receiver)) = receiver.try_recv() {
if let Some(download) = self.state.downloads.get_mut(idx) { self.state.downloads.push(DownloadEntry {
download.receiver = Some(download_receiver); download,
} receiver: Some(download_receiver),
});
} }
} }
// Update download states // Update download states
self.state.active_downloads_count = 0; self.state.active_downloads_count = 0;
for download in &mut self.state.downloads { for download_entry in &mut self.state.downloads {
if let Some(ref receiver) = download.receiver { if let Some(ref receiver) = download_entry.receiver {
while let Ok(status) = receiver.try_recv() { while let Ok(status) = receiver.try_recv() {
download.state.status = status.clone(); download_entry.download.status = status;
if let DownloadStatus::InProgress {
bytes_downloaded,
total_bytes,
speed_bytes_per_sec,
} = &status
{
download.state.bytes_downloaded = *bytes_downloaded;
download.state.total_bytes = *total_bytes;
download.state.speed_bytes_per_sec =
*speed_bytes_per_sec;
}
} }
} }
if matches!( if matches!(
download.state.status, download_entry.download.status,
DownloadStatus::InProgress { .. } DownloadStatus::InProgress { .. }
) { ) {
self.state.active_downloads_count += 1; self.state.active_downloads_count += 1;
+10 -9
View File
@@ -1,4 +1,4 @@
use crate::models::FileDownloadState; use crate::models::DownloadEntry;
use crate::ui::{ use crate::ui::{
border_style, border_type, error_style, format_bytes, format_speed, border_style, border_type, error_style, format_bytes, format_speed,
header_style, highlight_style, inactive_style, success_style, header_style, highlight_style, inactive_style, success_style,
@@ -17,7 +17,7 @@ use soulseek_rs::DownloadStatus;
pub fn render_downloads_pane( pub fn render_downloads_pane(
frame: &mut Frame, frame: &mut Frame,
area: Rect, area: Rect,
downloads: &[FileDownloadState], downloads: &[DownloadEntry],
table_state: &mut TableState, table_state: &mut TableState,
focused: bool, focused: bool,
) { ) {
@@ -47,7 +47,8 @@ pub fn render_downloads_pane(
let rows: Vec<Row> = downloads let rows: Vec<Row> = downloads
.iter() .iter()
.map(|download| { .map(|download_entry| {
let download = &download_entry.download;
let (status_icon, status_style) = match &download.status { let (status_icon, status_style) = match &download.status {
DownloadStatus::Queued => ("⋯", inactive_style()), DownloadStatus::Queued => ("⋯", inactive_style()),
DownloadStatus::InProgress { .. } => ("⧗", warning_style()), DownloadStatus::InProgress { .. } => ("⧗", warning_style()),
@@ -59,17 +60,17 @@ pub fn render_downloads_pane(
let progress_text = match &download.status { let progress_text = match &download.status {
DownloadStatus::Queued => "Queued".to_string(), DownloadStatus::Queued => "Queued".to_string(),
DownloadStatus::InProgress { .. } => { DownloadStatus::InProgress { .. } => {
let percent = if download.total_bytes > 0 { let percent = if download.size > 0 {
(download.bytes_downloaded as f64 (download.bytes_downloaded() as f64
/ download.total_bytes as f64 / download.size as f64
* 100.0) as u8 * 100.0) as u8
} else { } else {
0 0
}; };
format!( format!(
"{}/{} ({}%)", "{}/{} ({}%)",
format_bytes(download.bytes_downloaded), format_bytes(download.bytes_downloaded()),
format_bytes(download.total_bytes), format_bytes(download.size),
percent percent
) )
} }
@@ -80,7 +81,7 @@ pub fn render_downloads_pane(
let speed_text = match &download.status { let speed_text = match &download.status {
DownloadStatus::InProgress { .. } => { DownloadStatus::InProgress { .. } => {
format_speed(download.speed_bytes_per_sec) format_speed(download.speed_bytes_per_sec())
} }
_ => "-".to_string(), _ => "-".to_string(),
}; };