Files
soulseek-rs-modded/soulseek-rs/src/ui/panes/downloads_pane.rs
T
Michel de Graaf 0931bb01b4 perf(tui): flatten a browsed share once and draw one page of it
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.
2026-09-08 11:24:36 +02:00

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;
}