feat: folders in the browse tree carry their download's mark too
A folder queued from the browse tree is really the queue of every file under it, so its row reports the loudest status among those files: working while anything moves, paused, queued, a failure once quiet, and ✓ only when every file is done. A working folder shows the percent the queued bytes have come in its size cell.
This commit is contained in:
@@ -13,13 +13,15 @@ use ratatui::{
|
|||||||
widgets::{Cell, HighlightSpacing, Paragraph, Row, Table, TableState},
|
widgets::{Cell, HighlightSpacing, Paragraph, Row, Table, TableState},
|
||||||
};
|
};
|
||||||
use soulseek_rs::DownloadStatus;
|
use soulseek_rs::DownloadStatus;
|
||||||
|
use soulseek_rs::types::Download;
|
||||||
use std::collections::HashMap;
|
use std::collections::HashMap;
|
||||||
|
|
||||||
/// Render the browse popup: a tab bar of browsed users (when more than one is
|
/// Render the browse popup: a tab bar of browsed users (when more than one is
|
||||||
/// open) above the active user's collapsible shared-file tree.
|
/// open) above the active user's collapsible shared-file tree.
|
||||||
///
|
///
|
||||||
/// `downloads` is the window's transfer list; a file row whose download is
|
/// `downloads` is the window's transfer list; a file row whose download is
|
||||||
/// there shows the same status glyph the Downloads pane gives it.
|
/// there shows the same status glyph the Downloads pane gives it, and a
|
||||||
|
/// folder row summarises the downloads of the files queued below it.
|
||||||
pub fn render_browse_pane(
|
pub fn render_browse_pane(
|
||||||
frame: &mut Frame,
|
frame: &mut Frame,
|
||||||
area: Rect,
|
area: Rect,
|
||||||
@@ -145,12 +147,14 @@ fn render_browse_one(
|
|||||||
let all = browse.rows();
|
let all = browse.rows();
|
||||||
// Queued files carry the download's full share path, so a row
|
// Queued files carry the download's full share path, so a row
|
||||||
// matches a transfer of this user by path alone.
|
// matches a transfer of this user by path alone.
|
||||||
let status_of: HashMap<&str, &DownloadStatus> = downloads
|
let transfers: Vec<&Download> = downloads
|
||||||
.iter()
|
.iter()
|
||||||
.filter(|entry| entry.download.username == browse.username)
|
.filter(|entry| entry.download.username == browse.username)
|
||||||
.map(|entry| {
|
.map(|entry| &entry.download)
|
||||||
(entry.download.filename.as_str(), &entry.download.status)
|
.collect();
|
||||||
})
|
let status_of: HashMap<&str, &DownloadStatus> = transfers
|
||||||
|
.iter()
|
||||||
|
.map(|download| (download.filename.as_str(), &download.status))
|
||||||
.collect();
|
.collect();
|
||||||
table_state.select(Some(browse.selected_row));
|
table_state.select(Some(browse.selected_row));
|
||||||
let window = page_window(table_state, all.len(), area, 0);
|
let window = page_window(table_state, all.len(), area, 0);
|
||||||
@@ -160,14 +164,45 @@ fn render_browse_one(
|
|||||||
.map(|row| {
|
.map(|row| {
|
||||||
let indent = " ".repeat(row.depth);
|
let indent = " ".repeat(row.depth);
|
||||||
let (label, size) = if row.is_folder {
|
let (label, size) = if row.is_folder {
|
||||||
let glyph = if row.expanded { "▾" } else { "▸" };
|
// A folder's download is its files' downloads, so
|
||||||
|
// the folder reports the one status that describes
|
||||||
|
// the files queued below it.
|
||||||
|
let below: Vec<&Download> = transfers
|
||||||
|
.iter()
|
||||||
|
.copied()
|
||||||
|
.filter(|d| under_folder(&d.filename, &row.path))
|
||||||
|
.collect();
|
||||||
|
let transfer = folder_status(&below);
|
||||||
|
let marker = transfer.map_or_else(
|
||||||
|
|| Span::raw(String::new()),
|
||||||
|
|status| {
|
||||||
|
let (glyph, style) =
|
||||||
|
download_status_glyph(status);
|
||||||
|
Span::styled(format!("{glyph} "), style)
|
||||||
|
},
|
||||||
|
);
|
||||||
|
let open = if row.expanded { "▾" } else { "▸" };
|
||||||
|
let size = transfer
|
||||||
|
.filter(|status| {
|
||||||
|
matches!(
|
||||||
|
status,
|
||||||
|
DownloadStatus::InProgress { .. }
|
||||||
|
)
|
||||||
|
})
|
||||||
|
.map_or_else(String::new, |_| {
|
||||||
|
format!("{}%", folder_percent(&below))
|
||||||
|
});
|
||||||
(
|
(
|
||||||
Cell::from(format!("{indent}{glyph} {}", row.name))
|
Cell::from(Line::from(vec![
|
||||||
.style(
|
Span::raw(indent),
|
||||||
|
marker,
|
||||||
|
Span::styled(
|
||||||
|
format!("{open} {}", row.name),
|
||||||
primary_style()
|
primary_style()
|
||||||
.add_modifier(Modifier::BOLD),
|
.add_modifier(Modifier::BOLD),
|
||||||
),
|
),
|
||||||
Cell::from(String::new()),
|
])),
|
||||||
|
Cell::from(size).style(warning_style()),
|
||||||
)
|
)
|
||||||
} else {
|
} else {
|
||||||
let status = status_of.get(row.path.as_str()).copied();
|
let status = status_of.get(row.path.as_str()).copied();
|
||||||
@@ -221,6 +256,46 @@ fn render_browse_one(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Whether `file` is one of the folder's own files in a share path.
|
||||||
|
fn under_folder(file: &str, folder: &str) -> bool {
|
||||||
|
file.strip_prefix(folder)
|
||||||
|
.is_some_and(|rest| rest.starts_with('\\'))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// How loudly a status speaks for a whole folder: moving work first,
|
||||||
|
/// then paused, then queued, then a failure worth surfacing; done only
|
||||||
|
/// says so once every file is.
|
||||||
|
const fn status_rank(status: &DownloadStatus) -> u8 {
|
||||||
|
match status {
|
||||||
|
DownloadStatus::InProgress { .. } => 5,
|
||||||
|
DownloadStatus::Paused { .. } => 4,
|
||||||
|
DownloadStatus::Queued => 3,
|
||||||
|
DownloadStatus::Failed(_) | DownloadStatus::TimedOut => 2,
|
||||||
|
DownloadStatus::Cancelled => 1,
|
||||||
|
DownloadStatus::Completed => 0,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The status that describes a folder's download: the loudest of its
|
||||||
|
/// files', or nothing while none of them is in the transfer list.
|
||||||
|
fn folder_status<'a>(files: &[&'a Download]) -> Option<&'a DownloadStatus> {
|
||||||
|
files
|
||||||
|
.iter()
|
||||||
|
.map(|download| &download.status)
|
||||||
|
.max_by_key(|status| status_rank(status))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// How far a folder's queued bytes have come, whole percents.
|
||||||
|
fn folder_percent(files: &[&Download]) -> u8 {
|
||||||
|
let bytes: u64 = files.iter().map(|d| d.bytes_downloaded()).sum();
|
||||||
|
let total: u64 = files.iter().map(|d| d.size).sum();
|
||||||
|
if total == 0 {
|
||||||
|
0
|
||||||
|
} else {
|
||||||
|
(bytes * 100 / total) as u8
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
mod tests {
|
mod tests {
|
||||||
use super::render_browse_pane;
|
use super::render_browse_pane;
|
||||||
@@ -318,6 +393,43 @@ mod tests {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn a_folder_marks_with_its_files_download() {
|
||||||
|
// One file moving, one done: the folder row reports the job with
|
||||||
|
// the loudest status below it, and the bytes' percent beside it.
|
||||||
|
let screen = screen_with(&[
|
||||||
|
download(
|
||||||
|
"active.mp3",
|
||||||
|
DownloadStatus::InProgress {
|
||||||
|
bytes_downloaded: 1024,
|
||||||
|
total_bytes: 4096,
|
||||||
|
speed_bytes_per_sec: 1.0,
|
||||||
|
},
|
||||||
|
),
|
||||||
|
download("done.mp3", DownloadStatus::Completed),
|
||||||
|
]);
|
||||||
|
assert!(
|
||||||
|
screen.contains(&format!("{GLYPH_ACTIVE} ▾ share")),
|
||||||
|
"{screen}"
|
||||||
|
);
|
||||||
|
// 1024 of the 8192 bytes the two files hold has moved — the done
|
||||||
|
// one brings its own 4096 — so the folder sits at 62%.
|
||||||
|
assert!(screen.contains("62%"), "{screen}");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn a_folder_is_done_when_every_file_is() {
|
||||||
|
let screen = screen_with(&[
|
||||||
|
download("active.mp3", DownloadStatus::Completed),
|
||||||
|
download("done.mp3", DownloadStatus::Completed),
|
||||||
|
]);
|
||||||
|
assert!(
|
||||||
|
screen.contains(&format!("{GLYPH_DONE} ▾ share")),
|
||||||
|
"{screen}"
|
||||||
|
);
|
||||||
|
assert!(!screen.contains(GLYPH_ACTIVE), "{screen}");
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn another_user_s_transfers_mark_nothing() {
|
fn another_user_s_transfers_mark_nothing() {
|
||||||
let mut theirs = download("active.mp3", DownloadStatus::Completed);
|
let mut theirs = download("active.mp3", DownloadStatus::Completed);
|
||||||
|
|||||||
Reference in New Issue
Block a user