Moving through a browsed share felt laggy for the same reason the results did: the popup re-flattened the whole expanded tree on every frame, cloning every row's name and path, then built a table row for each, and every keypress flattened it twice more. A heavy sharer's tree runs to tens of thousands of rows once a few folders are open. The flattened rows now live on the browse state and are rebuilt only when a folder opens or closes, and the popup draws the visible page alone. In the harness a 30,000-row tree went from 11ms to 0.35ms a frame and a keypress from 2.7ms to under a microsecond. The downloads pane had the same shape (every transfer became a row each frame, and a select-all can queue ten thousand), so it takes the same page window, which now lives beside page_of for every pane to share.
238 lines
8.6 KiB
Rust
238 lines
8.6 KiB
Rust
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<Row> = 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;
|
|
}
|