From 6fe294f19b163d901ba62cd9651ef0b25675f25a Mon Sep 17 00:00:00 2001 From: cwj3rcb Date: Mon, 16 Feb 2026 15:44:26 -0600 Subject: [PATCH] Download Functionality Feature Complete --- src/main.rs | 185 +++++++++++++++++++++++++++++++++------------------- 1 file changed, 119 insertions(+), 66 deletions(-) diff --git a/src/main.rs b/src/main.rs index 7d52d5f..f2da5be 100644 --- a/src/main.rs +++ b/src/main.rs @@ -1562,7 +1562,8 @@ fn ui(frame: &mut Frame, app: &App) { "{connected}\n\np/P: play/autoplay r/R: resume/autoplay d: download / cancel -D: focus downloads\nq: quit\n/help: more help" +q: quit +/help: more help" ); frame.render_widget( @@ -2389,82 +2390,134 @@ fn mpv_progress(st: &MpvState) -> (u16, String) { } fn render_downloads_panel(frame: &mut Frame, app: &App, area: Rect) { - // Build rows - let mut rows: Vec = Vec::new(); - - if let Some(dm) = &app.downloads { - match dm.state.lock() { - Ok(st) => { - if st.queue.is_empty() { - rows.push(ListItem::new("(empty)")); - } else { - // Fit to available height inside borders - let max_rows = area.height.saturating_sub(2) as usize; - - for (i, it) in st.queue.iter().enumerate().take(max_rows) { - let status = match &it.status { - DownloadStatus::Queued => "queued", - DownloadStatus::Downloading => "downloading", - DownloadStatus::Finalizing => "finalizing", - DownloadStatus::Completed => "done", - DownloadStatus::Cancelled => "cancelled", - DownloadStatus::Failed(_) => "failed", - }; - - // Always mark the active (front) item with ▶ like before - let active_prefix = if i == 0 { "▶ " } else { " " }; - - // If downloading, show "NN% (ETA)" BEFORE the title - let prog_prefix = if matches!(it.status, DownloadStatus::Downloading) { - format_download_pct_eta(it.bytes_downloaded, it.bytes_total, it.added_at) - } else { - String::new() - }; - - rows.push(ListItem::new(format!( - "{active_prefix}{status}: {prog_prefix}{}", - it.title - ))); - } - - // NOTE: intentionally removed the footer "— {msg}" line - // because you said it’s unnecessary. - } - } - Err(_) => rows.push(ListItem::new("(downloads lock poisoned)")), - } - } else { - rows.push(ListItem::new("(downloads disabled)")); - } - - // Create the list widget let title = if app.downloads_focus { "Downloads (focus)" } else { "Downloads" }; + // rows available inside the border + let viewport = area.height.saturating_sub(2) as usize; + + let mut rows: Vec = Vec::new(); + let mut state = ListState::default(); + + // Helper: "basic" scrolling window (no centering) + // Ensures selected is always visible within [start, start+viewport) + fn basic_window(len: usize, selected: usize, viewport: usize) -> (usize, usize) { + if len == 0 || viewport == 0 { + return (0, 0); + } + let viewport = viewport.min(len); + let mut start = 0usize; + + // If selected is below the visible window, scroll down just enough + if selected >= start + viewport { + start = selected + 1 - viewport; + } + // If selected is above the visible window, scroll up just enough + if selected < start { + start = selected; + } + + // Clamp + if start + viewport > len { + start = len - viewport; + } + + (start, start + viewport) + } + + // Empty states + if app.downloads.is_none() { + rows.push(ListItem::new("(downloads disabled)")); + state.select(None); + + let list = List::new(rows) + .block(Block::bordered().title(title).merge_borders(MergeStrategy::Exact)) + .highlight_symbol(">> ") + .highlight_style(Style::default().add_modifier(Modifier::REVERSED)); + + frame.render_stateful_widget(list, area, &mut state); + return; + } + + let dm = app.downloads.as_ref().unwrap(); + + match dm.state.lock() { + Err(_) => { + rows.push(ListItem::new("(downloads lock poisoned)")); + state.select(None); + } + Ok(st) => { + let len = st.queue.len(); + + if len == 0 { + rows.push(ListItem::new("(empty)")); + state.select(None); + } else { + // Clamp selection + let selected = app.downloads_selected.min(len.saturating_sub(1)); + + // Basic scrolling window + let (start, end) = basic_window(len, selected, viewport); + + for i in start..end { + let it = match st.queue.get(i) { + Some(v) => v, + None => continue, + }; + + let status = match &it.status { + DownloadStatus::Queued => "queued", + DownloadStatus::Downloading => "downloading", + DownloadStatus::Finalizing => "finalizing", + DownloadStatus::Completed => "done", + DownloadStatus::Cancelled => "cancelled", + DownloadStatus::Failed(_) => "failed", + }; + + let active_prefix = if i == 0 { "▶ " } else { " " }; + + // Prefer aria2-derived info if you have it; otherwise it will show --% (--:--) + let prog_prefix = if matches!(it.status, DownloadStatus::Downloading) { + // uses the fields you now populate from aria2 RPC + // (bytes_downloaded, bytes_total, eta_secs) + let pct_str = match it.bytes_total { + Some(t) if t > 0 => { + let pct = (it.bytes_downloaded as f64 / t as f64) * 100.0; + format!("{:0.0}%", pct.clamp(0.0, 100.0)) + } + _ => "--%".to_string(), + }; + + let eta_str = match it.eta_secs { + Some(s) => fmt_hms(s as f64), + None => "--:--".to_string(), + }; + + format!("{pct_str} ({eta_str}) ") + } else { + String::new() + }; + + rows.push(ListItem::new(format!( + "{active_prefix}{status}: {prog_prefix}{}", + it.title + ))); + } + + // selection must be relative to the window slice + state.select(Some(selected - start)); + } + } + } + let list = List::new(rows) .block(Block::bordered().title(title).merge_borders(MergeStrategy::Exact)) .highlight_symbol(">> ") .highlight_style(Style::default().add_modifier(Modifier::REVERSED)); - let mut state = ListState::default(); - - // Clamp selection so it never goes out of bounds - let len = app - .downloads - .as_ref() - .and_then(|dm| dm.state.lock().ok().map(|st| st.queue.len())) - .unwrap_or(0); - - if len == 0 { - state.select(None); - } else { - let sel = app.downloads_selected.min(len.saturating_sub(1)); - state.select(Some(sel)); - } - frame.render_stateful_widget(list, area, &mut state); }