feat: mark browsed files with their download status
Queueing a file or folder from a user's browse tree left no trace in the window it was queued from. A row whose download is in the transfer list now carries the same status glyph the Downloads pane uses — ⋯ queued, ↯ active, ⏸ paused, ✓ done — and an active row shows its percent beside the size. Folders mark through their files: queue a folder and watch the ✓ spread down it.
This commit is contained in:
@@ -419,6 +419,7 @@ impl MainTui {
|
|||||||
&self.state.browse,
|
&self.state.browse,
|
||||||
&mut self.state.browse_table_state,
|
&mut self.state.browse_table_state,
|
||||||
self.spinner_state,
|
self.spinner_state,
|
||||||
|
&self.state.downloads,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,8 +1,9 @@
|
|||||||
use crate::models::{BrowseState, BrowseStatus, BrowseTabs};
|
use crate::models::{BrowseState, BrowseStatus, BrowseTabs, DownloadEntry};
|
||||||
use crate::ui::{
|
use crate::ui::{
|
||||||
HIGHLIGHT_SYMBOL, body_style, dimmed_style, error_style, filter_title,
|
HIGHLIGHT_SYMBOL, body_style, dimmed_style, download_status_glyph,
|
||||||
format_bytes, get_spinner_char, highlight_style, page_window, pane_block,
|
error_style, filter_title, format_bytes, get_spinner_char, highlight_style,
|
||||||
primary_style, render_page, row_highlight_style, warning_style,
|
page_window, pane_block, primary_style, render_page, row_highlight_style,
|
||||||
|
warning_style,
|
||||||
};
|
};
|
||||||
use ratatui::{
|
use ratatui::{
|
||||||
Frame,
|
Frame,
|
||||||
@@ -11,15 +12,21 @@ use ratatui::{
|
|||||||
text::{Line, Span},
|
text::{Line, Span},
|
||||||
widgets::{Cell, HighlightSpacing, Paragraph, Row, Table, TableState},
|
widgets::{Cell, HighlightSpacing, Paragraph, Row, Table, TableState},
|
||||||
};
|
};
|
||||||
|
use soulseek_rs::DownloadStatus;
|
||||||
|
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
|
||||||
|
/// there shows its status glyph, the way the results pane marks its own.
|
||||||
pub fn render_browse_pane(
|
pub fn render_browse_pane(
|
||||||
frame: &mut Frame,
|
frame: &mut Frame,
|
||||||
area: Rect,
|
area: Rect,
|
||||||
tabs: &BrowseTabs,
|
tabs: &BrowseTabs,
|
||||||
table_state: &mut TableState,
|
table_state: &mut TableState,
|
||||||
spinner_state: usize,
|
spinner_state: usize,
|
||||||
|
downloads: &[DownloadEntry],
|
||||||
) {
|
) {
|
||||||
let Some(active) = tabs.active_tab() else {
|
let Some(active) = tabs.active_tab() else {
|
||||||
return;
|
return;
|
||||||
@@ -36,7 +43,14 @@ pub fn render_browse_pane(
|
|||||||
area
|
area
|
||||||
};
|
};
|
||||||
|
|
||||||
render_browse_one(frame, tree_area, active, table_state, spinner_state);
|
render_browse_one(
|
||||||
|
frame,
|
||||||
|
tree_area,
|
||||||
|
active,
|
||||||
|
table_state,
|
||||||
|
spinner_state,
|
||||||
|
downloads,
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
fn render_browse_tabs(frame: &mut Frame, area: Rect, tabs: &BrowseTabs) {
|
fn render_browse_tabs(frame: &mut Frame, area: Rect, tabs: &BrowseTabs) {
|
||||||
@@ -60,6 +74,7 @@ fn render_browse_one(
|
|||||||
browse: &BrowseState,
|
browse: &BrowseState,
|
||||||
table_state: &mut TableState,
|
table_state: &mut TableState,
|
||||||
spinner_state: usize,
|
spinner_state: usize,
|
||||||
|
downloads: &[DownloadEntry],
|
||||||
) {
|
) {
|
||||||
let title = match browse.status {
|
let title = match browse.status {
|
||||||
BrowseStatus::Loaded => {
|
BrowseStatus::Loaded => {
|
||||||
@@ -128,6 +143,15 @@ fn render_browse_one(
|
|||||||
}
|
}
|
||||||
BrowseStatus::Loaded => {
|
BrowseStatus::Loaded => {
|
||||||
let all = browse.rows();
|
let all = browse.rows();
|
||||||
|
// Queued files carry the download's full share path, so a row
|
||||||
|
// matches a transfer of this user by path alone.
|
||||||
|
let status_of: HashMap<&str, &DownloadStatus> = downloads
|
||||||
|
.iter()
|
||||||
|
.filter(|entry| entry.download.username == browse.username)
|
||||||
|
.map(|entry| {
|
||||||
|
(entry.download.filename.as_str(), &entry.download.status)
|
||||||
|
})
|
||||||
|
.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);
|
||||||
let start = window.start;
|
let start = window.start;
|
||||||
@@ -146,13 +170,39 @@ fn render_browse_one(
|
|||||||
Cell::from(String::new()),
|
Cell::from(String::new()),
|
||||||
)
|
)
|
||||||
} else {
|
} else {
|
||||||
|
let status = status_of.get(row.path.as_str()).copied();
|
||||||
|
// The two spaces before a file's name become the
|
||||||
|
// transfer's glyph while its download lives in the
|
||||||
|
// list: ⋯ queued, ↯ active, ⏸ paused, ✓ done.
|
||||||
|
let marker = status.map_or_else(
|
||||||
|
|| Span::raw(" ".to_string()),
|
||||||
|
|status| {
|
||||||
|
let (glyph, style) =
|
||||||
|
download_status_glyph(status);
|
||||||
|
Span::styled(format!("{glyph} "), style)
|
||||||
|
},
|
||||||
|
);
|
||||||
|
let plain_size =
|
||||||
|
row.size.map(format_bytes).unwrap_or_default();
|
||||||
|
let size_text = match status {
|
||||||
|
Some(DownloadStatus::InProgress {
|
||||||
|
bytes_downloaded,
|
||||||
|
total_bytes,
|
||||||
|
..
|
||||||
|
}) if *total_bytes > 0 => {
|
||||||
|
let percent =
|
||||||
|
bytes_downloaded * 100 / total_bytes;
|
||||||
|
format!("{plain_size} {percent}%")
|
||||||
|
}
|
||||||
|
_ => plain_size,
|
||||||
|
};
|
||||||
(
|
(
|
||||||
Cell::from(format!("{indent} {}", row.name))
|
Cell::from(Line::from(vec![
|
||||||
.style(body_style()),
|
Span::raw(indent),
|
||||||
Cell::from(
|
marker,
|
||||||
row.size.map(format_bytes).unwrap_or_default(),
|
Span::styled(row.name.clone(), body_style()),
|
||||||
)
|
])),
|
||||||
.style(warning_style()),
|
Cell::from(size_text).style(warning_style()),
|
||||||
)
|
)
|
||||||
};
|
};
|
||||||
Row::new(vec![label, size])
|
Row::new(vec![label, size])
|
||||||
@@ -170,3 +220,110 @@ fn render_browse_one(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::render_browse_pane;
|
||||||
|
use crate::models::{BrowseState, BrowseTabs, DownloadEntry};
|
||||||
|
use crate::ui::{GLYPH_ACTIVE, GLYPH_DONE};
|
||||||
|
use ratatui::{Terminal, backend::TestBackend, widgets::TableState};
|
||||||
|
use soulseek_rs::types::{Download, DownloadMetadata, DownloadStatus};
|
||||||
|
use soulseek_rs::{SharedDirectory, SharedFileEntry};
|
||||||
|
use std::sync::mpsc;
|
||||||
|
|
||||||
|
fn share() -> SharedDirectory {
|
||||||
|
SharedDirectory {
|
||||||
|
name: "share".to_string(),
|
||||||
|
files: ["active.mp3", "done.mp3", "plain.mp3"]
|
||||||
|
.iter()
|
||||||
|
.map(|name| SharedFileEntry {
|
||||||
|
name: (*name).to_string(),
|
||||||
|
size: 4096,
|
||||||
|
attributes: Vec::new(),
|
||||||
|
})
|
||||||
|
.collect(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn download(filename: &str, status: DownloadStatus) -> DownloadEntry {
|
||||||
|
let (sender, _receiver) = mpsc::channel();
|
||||||
|
DownloadEntry {
|
||||||
|
download: Download {
|
||||||
|
username: "bob".to_string(),
|
||||||
|
// Queued files carry the full share path, as in the tree.
|
||||||
|
filename: format!("share\\{filename}"),
|
||||||
|
token: 1,
|
||||||
|
size: 4096,
|
||||||
|
download_directory: "/tmp".to_string(),
|
||||||
|
status,
|
||||||
|
sender,
|
||||||
|
queue_position: None,
|
||||||
|
metadata: DownloadMetadata::default(),
|
||||||
|
},
|
||||||
|
receiver: None,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn screen_with(downloads: &[DownloadEntry]) -> String {
|
||||||
|
let mut browse = BrowseState::loading("bob".to_string());
|
||||||
|
browse.load(&[share()]);
|
||||||
|
let mut tabs = BrowseTabs::default();
|
||||||
|
tabs.tabs.push(browse);
|
||||||
|
let mut state = TableState::default();
|
||||||
|
let mut terminal =
|
||||||
|
Terminal::new(TestBackend::new(60, 8)).expect("backend");
|
||||||
|
terminal
|
||||||
|
.draw(|frame| {
|
||||||
|
render_browse_pane(
|
||||||
|
frame,
|
||||||
|
frame.area(),
|
||||||
|
&tabs,
|
||||||
|
&mut state,
|
||||||
|
0,
|
||||||
|
downloads,
|
||||||
|
);
|
||||||
|
})
|
||||||
|
.expect("draw");
|
||||||
|
terminal.backend().to_string()
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn a_downloading_file_carries_its_transfer_glyph() {
|
||||||
|
let screen = screen_with(&[
|
||||||
|
download(
|
||||||
|
"active.mp3",
|
||||||
|
DownloadStatus::InProgress {
|
||||||
|
bytes_downloaded: 2048,
|
||||||
|
total_bytes: 4096,
|
||||||
|
speed_bytes_per_sec: 1.0,
|
||||||
|
},
|
||||||
|
),
|
||||||
|
download("done.mp3", DownloadStatus::Completed),
|
||||||
|
]);
|
||||||
|
assert!(
|
||||||
|
screen.contains(&format!("{GLYPH_ACTIVE} active.mp3")),
|
||||||
|
"{screen}"
|
||||||
|
);
|
||||||
|
assert!(
|
||||||
|
screen.contains(&format!("{GLYPH_DONE} done.mp3")),
|
||||||
|
"{screen}"
|
||||||
|
);
|
||||||
|
assert!(
|
||||||
|
screen.contains("50%"),
|
||||||
|
"the size cell tells the progress: {screen}"
|
||||||
|
);
|
||||||
|
assert!(
|
||||||
|
screen.contains(" plain.mp3"),
|
||||||
|
"an unqueued row keeps the two plain spaces: {screen}"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn another_user_s_transfers_mark_nothing() {
|
||||||
|
let mut theirs = download("active.mp3", DownloadStatus::Completed);
|
||||||
|
theirs.download.username = "carol".to_string();
|
||||||
|
let screen = screen_with(&[theirs]);
|
||||||
|
assert!(!screen.contains(GLYPH_ACTIVE), "{screen}");
|
||||||
|
assert!(!screen.contains(GLYPH_DONE), "{screen}");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user