use super::name_scroll::{column_width, end_offset, scroll_text}; use crate::models::DownloadEntry; use crate::ui::{ GLYPH_ACTIVE, GLYPH_DONE, GLYPH_FAILED, GLYPH_QUEUED, HIGHLIGHT_SYMBOL, accent_style, body_style, dimmed_style, download_status_glyph, error_style, format_bytes, format_speed, header_style, inactive_style, info_style, page_of, pane_block, pane_title, primary_style, row_highlight_style, success_style, visible_range, warning_style, }; use ratatui::{ Frame, layout::{Constraint, Rect}, widgets::{Cell, HighlightSpacing, Paragraph, Row, Table, TableState}, }; use soulseek_rs::DownloadStatus; use soulseek_rs::types::{UploadInfo, UploadStatus}; // Sized to the widest value each column shows in ordinary use — a // three-digit megabyte count on both sides of the progress — so the file // name keeps whatever width the pane has beyond that. const WIDTHS: [Constraint; 5] = [ Constraint::Length(6), Constraint::Fill(1), Constraint::Length(12), Constraint::Length(25), Constraint::Length(10), ]; /// Filename is the second column. const NAME_COLUMN: usize = 1; /// The offset that brings the end of a transfer's name into view. #[must_use] pub fn name_end_offset(name: &str, area: Rect) -> usize { end_offset(name, area, &WIDTHS, NAME_COLUMN) } /// What the list shows for an upload: the peer-facing path is ours, so only /// the file's own name is worth a column. #[must_use] pub fn upload_display_name(upload: &UploadInfo) -> &str { upload .filename .rsplit(['\\', '/']) .next() .unwrap_or(&upload.filename) } pub fn render_downloads_pane( frame: &mut Frame, area: Rect, downloads: &[DownloadEntry], uploads: &[UploadInfo], table_state: &mut TableState, focused: bool, name_offset: usize, ) { if downloads.is_empty() && uploads.is_empty() { let empty_block = pane_block(focused).title(pane_title( "3", "Downloads/Uploads", focused, )); let paragraph = Paragraph::new( "No transfers. Select files in [2] Results and press Enter.", ) .style(dimmed_style()) .block(empty_block); frame.render_widget(paragraph, area); return; } let header = Row::new(vec![ Cell::from("Status").style(header_style()), Cell::from("Filename").style(header_style()), Cell::from("User").style(header_style()), Cell::from("Progress").style(header_style()), Cell::from("Speed").style(header_style()), ]) .height(1); let name_width = column_width(area, &WIDTHS, NAME_COLUMN); // Downloads come first, uploads after; one page of the two together. let total = downloads.len() + uploads.len(); if let Some(selected) = table_state.selected_mut() { *selected = (*selected).min(total - 1); } let window = visible_range( table_state.offset(), table_state.selected(), total, page_of(Some(area), 1), ); let start = window.start; let split = downloads.len(); let shown_downloads = &downloads[start.min(split)..window.end.min(split)]; let shown_uploads = &uploads[start.saturating_sub(split)..window.end.saturating_sub(split)]; let mut rows: Vec = shown_downloads .iter() .map(|download_entry| { let download = &download_entry.download; let (status_icon, status_style) = download_status_glyph(&download.status); let progress_text = match &download.status { DownloadStatus::Queued => "Queued".to_string(), DownloadStatus::InProgress { .. } | DownloadStatus::Paused { .. } => { let prefix = if matches!( download.status, DownloadStatus::Paused { .. } ) { "Paused " } else { "" }; let percent = if download.size > 0 { (download.bytes_downloaded() as f64 / download.size as f64 * 100.0) as u8 } else { 0 }; format!( "{prefix}{}/{} ({}%)", format_bytes(download.bytes_downloaded()), format_bytes(download.size), percent ) } DownloadStatus::Completed => "Completed".to_string(), DownloadStatus::Cancelled => "Cancelled".to_string(), DownloadStatus::Failed(_) => "Failed".to_string(), DownloadStatus::TimedOut => "Timed out".to_string(), }; let speed_text = match &download.status { DownloadStatus::InProgress { .. } => { format_speed(download.speed_bytes_per_sec()) } _ => "-".to_string(), }; let progress_style = match &download.status { DownloadStatus::Completed => success_style(), DownloadStatus::Failed(_) | DownloadStatus::TimedOut => { error_style() } DownloadStatus::Queued | DownloadStatus::Cancelled => { inactive_style() } _ => primary_style(), }; Row::new(vec![ Cell::from(status_icon).style(status_style), Cell::from(scroll_text( &download.filename, name_offset, name_width, )) .style(body_style()), Cell::from(download.username.clone()).style(info_style()), Cell::from(progress_text).style(progress_style), Cell::from(speed_text).style(warning_style()), ]) }) .collect(); rows.extend(shown_uploads.iter().map(|upload| { let (status_icon, status_style) = match &upload.status { UploadStatus::Queued(_) => (GLYPH_QUEUED, warning_style()), UploadStatus::InProgress => (GLYPH_ACTIVE, accent_style()), UploadStatus::Completed => (GLYPH_DONE, success_style()), UploadStatus::Cancelled => (GLYPH_FAILED, inactive_style()), UploadStatus::Failed(_) => (GLYPH_FAILED, error_style()), }; let progress_text = match &upload.status { UploadStatus::Queued(place) => format!("Queued #{place}"), UploadStatus::InProgress => { let percent = if upload.size > 0 { (upload.bytes_sent as f64 / upload.size as f64 * 100.0) as u8 } else { 0 }; format!( "{}/{} ({}%)", format_bytes(upload.bytes_sent), format_bytes(upload.size), percent ) } UploadStatus::Completed => "Completed".to_string(), UploadStatus::Cancelled => "Cancelled".to_string(), UploadStatus::Failed(_) => "Failed".to_string(), }; // Same rule as a download: a rate only while the transfer is running. let speed_text = match &upload.status { UploadStatus::InProgress => { format_speed(upload.speed_bytes_per_sec) } _ => "-".to_string(), }; Row::new(vec![ Cell::from(format!("↑ {status_icon}")).style(status_style), Cell::from(scroll_text( upload_display_name(upload), name_offset, name_width, )) .style(body_style()), Cell::from(upload.username.clone()).style(info_style()), Cell::from(progress_text).style(primary_style()), Cell::from(speed_text).style(warning_style()), ]) })); let table = Table::new(rows, WIDTHS) .header(header) .row_highlight_style(row_highlight_style()) .highlight_symbol(HIGHLIGHT_SYMBOL) .highlight_spacing(HighlightSpacing::Always) .block(pane_block(focused).title(pane_title( "3", "Downloads/Uploads", focused, ))); // The table holds one page, so the state it gets is shifted onto it. let mut page_state = TableState::default() .with_selected(table_state.selected().map(|selected| selected - start)); frame.render_stateful_widget(table, area, &mut page_state); *table_state.offset_mut() = start; }