chore: UI tweaks
This commit is contained in:
+33
-63
@@ -7,7 +7,6 @@ use clap::Parser;
|
||||
use cli::{parse_server_address, Cli, Commands};
|
||||
use color_eyre::Result;
|
||||
use config::SearchConfig;
|
||||
use models::FileDownloadState;
|
||||
use soulseek_rs::{Client, ClientSettings, PeerAddress};
|
||||
use std::{
|
||||
env,
|
||||
@@ -104,7 +103,7 @@ fn search_and_download(config: SearchConfig) -> Result<()> {
|
||||
let search_timeout = Duration::from_secs(config.timeout);
|
||||
let search_cancel = cancel_flag.clone();
|
||||
|
||||
let search_handle = std::thread::spawn(move || {
|
||||
let _search_handle = std::thread::spawn(move || {
|
||||
search_client.search_with_cancel(
|
||||
&search_query,
|
||||
search_timeout,
|
||||
@@ -120,83 +119,54 @@ fn search_and_download(config: SearchConfig) -> Result<()> {
|
||||
Duration::from_secs(config.timeout),
|
||||
cancel_flag.clone(),
|
||||
);
|
||||
let selected_indices = file_selector.run(terminal)?;
|
||||
ratatui::restore();
|
||||
let (terminal, selected_indices) = file_selector.run(terminal)?;
|
||||
|
||||
// Wait for search thread to complete
|
||||
let _ = search_handle.join();
|
||||
// Cancel search thread - no need to wait for it
|
||||
cancel_flag.store(true, std::sync::atomic::Ordering::Relaxed);
|
||||
|
||||
// Get final results
|
||||
let results = client.get_search_results();
|
||||
|
||||
if selected_indices.is_empty() {
|
||||
ratatui::restore();
|
||||
println!("❌ No files selected for download");
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
// Convert results to all_files format
|
||||
let mut all_files: Vec<(String, soulseek_rs::File, u8, u32)> = Vec::new();
|
||||
for result in &results {
|
||||
for file in &result.files {
|
||||
all_files.push((
|
||||
result.username.clone(),
|
||||
file.clone(),
|
||||
result.slots,
|
||||
result.speed,
|
||||
));
|
||||
}
|
||||
}
|
||||
// Directly map selected indices to file data (skip expensive all_files conversion)
|
||||
let selected_files: Vec<_> = selected_indices
|
||||
.iter()
|
||||
.filter_map(|&idx| {
|
||||
let mut current = 0;
|
||||
for result in &results {
|
||||
let next = current + result.files.len();
|
||||
if idx < next {
|
||||
let file_idx = idx - current;
|
||||
let file = &result.files[file_idx];
|
||||
return Some((
|
||||
file.name.clone(),
|
||||
result.username.clone(),
|
||||
file.size,
|
||||
));
|
||||
}
|
||||
current = next;
|
||||
}
|
||||
None
|
||||
})
|
||||
.collect();
|
||||
|
||||
if all_files.is_empty() {
|
||||
if selected_files.is_empty() {
|
||||
ratatui::restore();
|
||||
println!("❌ No files found in search results");
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
println!("\n📋 Found {} file(s)\n", all_files.len());
|
||||
|
||||
println!(
|
||||
"\n📥 Starting download of {} file(s)...\n",
|
||||
selected_indices.len()
|
||||
);
|
||||
|
||||
// Prepare all downloads
|
||||
let mut download_states = Vec::new();
|
||||
let mut receivers = Vec::new();
|
||||
|
||||
for idx in selected_indices.iter() {
|
||||
let (username, file, _, _) = &all_files[*idx];
|
||||
|
||||
// Initiate download
|
||||
let receiver = client
|
||||
.download(
|
||||
file.name.clone(),
|
||||
username.to_string(),
|
||||
file.size,
|
||||
config.download_dir.clone(),
|
||||
)
|
||||
.map_err(|e| {
|
||||
color_eyre::eyre::eyre!(
|
||||
"Failed to start download {}: {}",
|
||||
file.name,
|
||||
e
|
||||
)
|
||||
})?;
|
||||
|
||||
// Create download state
|
||||
let download_state = FileDownloadState::new(
|
||||
file.name.clone(),
|
||||
username.to_string(),
|
||||
file.size,
|
||||
);
|
||||
|
||||
download_states.push(download_state);
|
||||
receivers.push(receiver);
|
||||
}
|
||||
|
||||
// Show multi-download progress view (handles parallel execution and concurrency)
|
||||
// Show multi-download progress view immediately (initializes downloads asynchronously)
|
||||
show_multi_download_progress(
|
||||
download_states,
|
||||
receivers,
|
||||
terminal,
|
||||
client,
|
||||
selected_files,
|
||||
config.download_dir,
|
||||
config.max_concurrent_downloads,
|
||||
)?;
|
||||
|
||||
|
||||
@@ -1,13 +1,17 @@
|
||||
use crate::models::FileDisplayData;
|
||||
use crate::ui::{format_bytes, get_bitrate, header_style, highlight_style};
|
||||
use crate::ui::{
|
||||
border_style, border_type, format_bytes, format_shortcuts_styled,
|
||||
get_bitrate, get_spinner_char, header_style, highlight_style,
|
||||
primary_style, success_style, warning_style, HIGHLIGHT_SYMBOL,
|
||||
};
|
||||
use color_eyre::Result;
|
||||
use ratatui::{
|
||||
crossterm::event::{self, poll, Event, KeyCode, KeyEvent, KeyEventKind},
|
||||
layout::{Alignment, Constraint, Layout},
|
||||
style::{Color, Style},
|
||||
style::Style,
|
||||
widgets::{
|
||||
Block, Borders, Cell, Paragraph, Row, StatefulWidget, Table,
|
||||
TableState, Wrap,
|
||||
Block, Borders, Cell, HighlightSpacing, Paragraph, Row, StatefulWidget,
|
||||
Table, TableState, Wrap,
|
||||
},
|
||||
DefaultTerminal, Frame,
|
||||
};
|
||||
@@ -72,7 +76,10 @@ impl FileSelector {
|
||||
}
|
||||
}
|
||||
|
||||
pub fn run(&mut self, mut terminal: DefaultTerminal) -> Result<Vec<usize>> {
|
||||
pub fn run(
|
||||
&mut self,
|
||||
mut terminal: DefaultTerminal,
|
||||
) -> Result<(DefaultTerminal, Vec<usize>)> {
|
||||
while !self.should_exit {
|
||||
terminal.draw(|frame| self.render(frame))?;
|
||||
|
||||
@@ -117,7 +124,7 @@ impl FileSelector {
|
||||
}
|
||||
}
|
||||
|
||||
Ok(self.selected_indices.iter().copied().collect())
|
||||
Ok((terminal, self.selected_indices.iter().copied().collect()))
|
||||
}
|
||||
|
||||
fn update_results_from_client(&mut self) {
|
||||
@@ -306,27 +313,37 @@ impl FileSelector {
|
||||
fn render(&mut self, frame: &mut Frame) {
|
||||
let area = frame.area();
|
||||
|
||||
let (table_area, info_area) = if self.is_searching
|
||||
// Always split layout into table, optional info, and controls footer
|
||||
let has_info = self.is_searching
|
||||
|| !self.selected_indices.is_empty()
|
||||
|| !self.search_query.is_empty()
|
||||
{
|
||||
let chunks =
|
||||
Layout::vertical([Constraint::Min(0), Constraint::Length(3)])
|
||||
.split(area);
|
||||
(chunks[0], Some(chunks[1]))
|
||||
|| !self.search_query.is_empty();
|
||||
|
||||
let chunks = if has_info {
|
||||
Layout::vertical([
|
||||
Constraint::Min(0),
|
||||
Constraint::Length(3),
|
||||
Constraint::Length(3),
|
||||
])
|
||||
.split(area)
|
||||
} else {
|
||||
(area, None)
|
||||
Layout::vertical([Constraint::Min(0), Constraint::Length(3)])
|
||||
.split(area)
|
||||
};
|
||||
|
||||
let table_area = chunks[0];
|
||||
let (info_area, controls_area) = if has_info {
|
||||
(Some(chunks[1]), chunks[2])
|
||||
} else {
|
||||
(None, chunks[1])
|
||||
};
|
||||
|
||||
let title = if self.search_active {
|
||||
// Live Soulseek search is active
|
||||
let spinner_chars =
|
||||
["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"];
|
||||
let spinner = spinner_chars[self.spinner_state];
|
||||
let spinner = get_spinner_char(self.spinner_state);
|
||||
let elapsed = self.search_start_time.elapsed().as_secs();
|
||||
let total = self.search_timeout.as_secs();
|
||||
format!(
|
||||
"{} Searching: '{}' - {} results ({}/{}s) - Space: toggle, a: select-all, A: deselect-all, Enter: download, Esc/q: cancel",
|
||||
"{} Searching: '{}' - {} results ({}/{}s)",
|
||||
spinner,
|
||||
self.soulseek_query,
|
||||
self.all_items.len(),
|
||||
@@ -335,13 +352,13 @@ impl FileSelector {
|
||||
)
|
||||
} else if self.is_searching {
|
||||
format!(
|
||||
"Multi-select files to download ({}/{} matches, Space: toggle, a: select-all, A: deselect-all, Enter: download, Esc: exit search)",
|
||||
"Multi-select files to download ({}/{} matches)",
|
||||
self.items.len(),
|
||||
self.all_items.len()
|
||||
)
|
||||
} else {
|
||||
format!(
|
||||
"Multi-select files to download ({} selected, Space: toggle, a: select-all, A: deselect-all, Enter: download, Esc/q: cancel, /: search)",
|
||||
"Multi-select files to download ({} selected)",
|
||||
self.selected_indices.len()
|
||||
)
|
||||
};
|
||||
@@ -392,7 +409,7 @@ impl FileSelector {
|
||||
];
|
||||
|
||||
let style = if is_selected {
|
||||
Style::default().fg(Color::Green)
|
||||
success_style()
|
||||
} else {
|
||||
Style::default()
|
||||
};
|
||||
@@ -413,10 +430,17 @@ impl FileSelector {
|
||||
|
||||
let table = Table::new(rows, widths)
|
||||
.header(header)
|
||||
.block(Block::default().borders(Borders::ALL).title(title))
|
||||
.block(
|
||||
Block::default()
|
||||
.borders(Borders::ALL)
|
||||
.border_style(border_style(true))
|
||||
.border_type(border_type(true))
|
||||
.title(title),
|
||||
)
|
||||
.column_spacing(1)
|
||||
.row_highlight_style(highlight_style())
|
||||
.highlight_symbol("> ");
|
||||
.highlight_symbol(HIGHLIGHT_SYMBOL)
|
||||
.highlight_spacing(HighlightSpacing::Always);
|
||||
|
||||
StatefulWidget::render(
|
||||
table,
|
||||
@@ -427,9 +451,7 @@ impl FileSelector {
|
||||
|
||||
// Show loading placeholder when search is active but no results yet
|
||||
if self.search_active && self.items.is_empty() {
|
||||
let spinner_chars =
|
||||
["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"];
|
||||
let spinner = spinner_chars[self.spinner_state];
|
||||
let spinner = get_spinner_char(self.spinner_state);
|
||||
|
||||
let loading_text =
|
||||
format!("{} Searching for '{}'", spinner, self.soulseek_query);
|
||||
@@ -442,19 +464,25 @@ impl FileSelector {
|
||||
])
|
||||
.split(table_area);
|
||||
|
||||
// Calculate responsive width: text + generous padding for borders and spacing, max 80% of screen
|
||||
let text_width = loading_text.chars().count() as u16 + 5; // +10 for borders, padding, and safety margin
|
||||
let max_width = (table_area.width * 80) / 100;
|
||||
let widget_width = text_width.min(max_width);
|
||||
|
||||
let horizontal = Layout::horizontal([
|
||||
Constraint::Fill(1),
|
||||
Constraint::Max(80),
|
||||
Constraint::Length(widget_width),
|
||||
Constraint::Fill(1),
|
||||
])
|
||||
.split(vertical[1]);
|
||||
|
||||
let loading_widget = Paragraph::new(loading_text)
|
||||
.style(Style::default().fg(Color::Cyan))
|
||||
.style(primary_style())
|
||||
.block(
|
||||
Block::default()
|
||||
.borders(Borders::ALL)
|
||||
.border_style(Style::default().fg(Color::Cyan)),
|
||||
.border_style(primary_style())
|
||||
.border_type(border_type(false)),
|
||||
)
|
||||
.alignment(Alignment::Center)
|
||||
.wrap(Wrap { trim: true });
|
||||
@@ -463,11 +491,11 @@ impl FileSelector {
|
||||
}
|
||||
|
||||
if let Some(info_area) = info_area {
|
||||
let (info_text, title, color) = if self.is_searching {
|
||||
let (info_text, title, style) = if self.is_searching {
|
||||
(
|
||||
format!("Search: {}", self.search_query),
|
||||
"Filter",
|
||||
Color::Yellow,
|
||||
warning_style(),
|
||||
)
|
||||
} else if !self.search_query.is_empty() {
|
||||
(
|
||||
@@ -476,7 +504,7 @@ impl FileSelector {
|
||||
self.search_query
|
||||
),
|
||||
"Filter",
|
||||
Color::Cyan,
|
||||
primary_style(),
|
||||
)
|
||||
} else {
|
||||
(
|
||||
@@ -485,15 +513,61 @@ impl FileSelector {
|
||||
self.selected_indices.len()
|
||||
),
|
||||
"Selection",
|
||||
Color::Green,
|
||||
success_style(),
|
||||
)
|
||||
};
|
||||
|
||||
let info_widget = Paragraph::new(info_text)
|
||||
.block(Block::default().borders(Borders::ALL).title(title))
|
||||
.style(Style::default().fg(color));
|
||||
.block(
|
||||
Block::default()
|
||||
.borders(Borders::ALL)
|
||||
.border_type(border_type(false))
|
||||
.title(title),
|
||||
)
|
||||
.style(style);
|
||||
|
||||
frame.render_widget(info_widget, info_area);
|
||||
}
|
||||
|
||||
// Render controls footer
|
||||
self.render_controls(frame, controls_area);
|
||||
}
|
||||
|
||||
fn render_controls(&self, frame: &mut Frame, area: ratatui::layout::Rect) {
|
||||
let controls_line = if self.is_searching {
|
||||
format_shortcuts_styled(&[
|
||||
("Space", "toggle"),
|
||||
("a", "select-all"),
|
||||
("A", "deselect-all"),
|
||||
("Enter", "download"),
|
||||
("Esc", "exit search"),
|
||||
])
|
||||
} else if self.search_active {
|
||||
format_shortcuts_styled(&[
|
||||
("Space", "toggle"),
|
||||
("a", "select-all"),
|
||||
("A", "deselect-all"),
|
||||
("Enter", "download"),
|
||||
("Esc/q", "cancel"),
|
||||
])
|
||||
} else {
|
||||
format_shortcuts_styled(&[
|
||||
("Space", "toggle"),
|
||||
("a", "select-all"),
|
||||
("A", "deselect-all"),
|
||||
("Enter", "download"),
|
||||
("Esc/q", "cancel"),
|
||||
("/", "search"),
|
||||
])
|
||||
};
|
||||
|
||||
let controls_widget = Paragraph::new(controls_line).block(
|
||||
Block::default()
|
||||
.borders(Borders::ALL)
|
||||
.border_type(border_type(false))
|
||||
.title("Controls"),
|
||||
);
|
||||
|
||||
frame.render_widget(controls_widget, area);
|
||||
}
|
||||
}
|
||||
@@ -1,47 +1,61 @@
|
||||
use crate::models::FileDownloadState;
|
||||
use crate::ui::{
|
||||
format_bytes_progress, format_speed, header_style, highlight_style,
|
||||
border_style, border_type, error_style, format_bytes_progress,
|
||||
format_progress_bar, format_shortcuts_styled, format_speed, header_style,
|
||||
highlight_style, inactive_style, primary_style, warning_style,
|
||||
COLOR_PRIMARY, HIGHLIGHT_SYMBOL,
|
||||
};
|
||||
use color_eyre::Result;
|
||||
use ratatui::{
|
||||
crossterm::event::{self, poll, Event, KeyCode, KeyEvent, KeyEventKind},
|
||||
layout::{Constraint, Layout},
|
||||
style::{Color, Style},
|
||||
layout::{Alignment, Constraint, Layout},
|
||||
style::{Modifier, Style},
|
||||
text::{Line, Span},
|
||||
widgets::{
|
||||
Block, Borders, Cell, Paragraph, Row, StatefulWidget, Table, TableState,
|
||||
Block, Borders, Cell, HighlightSpacing, Paragraph, Row, StatefulWidget,
|
||||
Table, TableState,
|
||||
},
|
||||
DefaultTerminal, Frame,
|
||||
};
|
||||
use soulseek_rs::DownloadStatus;
|
||||
use std::{sync::mpsc::Receiver, time::Duration};
|
||||
use soulseek_rs::{Client, DownloadStatus};
|
||||
use std::{
|
||||
sync::{mpsc, mpsc::Receiver, Arc},
|
||||
thread,
|
||||
time::Duration,
|
||||
};
|
||||
|
||||
pub struct MultiDownloadProgress {
|
||||
downloads: Vec<FileDownloadState>,
|
||||
receivers: Vec<Option<Receiver<DownloadStatus>>>,
|
||||
receiver_channel: Receiver<(usize, Receiver<DownloadStatus>)>,
|
||||
list_state: TableState,
|
||||
max_concurrent: usize,
|
||||
active_count: usize,
|
||||
should_exit: bool,
|
||||
queuing_status: String,
|
||||
}
|
||||
|
||||
impl MultiDownloadProgress {
|
||||
pub fn new(
|
||||
downloads: Vec<FileDownloadState>,
|
||||
receivers: Vec<Receiver<DownloadStatus>>,
|
||||
receiver_channel: Receiver<(usize, Receiver<DownloadStatus>)>,
|
||||
max_concurrent: usize,
|
||||
) -> Self {
|
||||
let mut list_state = TableState::default();
|
||||
list_state.select(Some(0));
|
||||
|
||||
let receivers = receivers.into_iter().map(Some).collect();
|
||||
// Initialize all receivers as None - they'll be populated asynchronously
|
||||
let receivers = (0..downloads.len()).map(|_| None).collect();
|
||||
|
||||
Self {
|
||||
downloads,
|
||||
receivers,
|
||||
receiver_channel,
|
||||
list_state,
|
||||
max_concurrent,
|
||||
active_count: 0,
|
||||
should_exit: false,
|
||||
queuing_status: String::from("Queuing downloads..."),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -52,6 +66,20 @@ impl MultiDownloadProgress {
|
||||
loop {
|
||||
terminal.draw(|frame| self.render(frame))?;
|
||||
|
||||
// Check for incoming download receivers from background thread
|
||||
while let Ok((index, receiver)) = self.receiver_channel.try_recv() {
|
||||
self.receivers[index] = Some(receiver);
|
||||
// Update status message
|
||||
let queued =
|
||||
self.receivers.iter().filter(|r| r.is_none()).count();
|
||||
if queued == 0 {
|
||||
self.queuing_status = String::from("All downloads queued");
|
||||
} else {
|
||||
self.queuing_status =
|
||||
format!("Queuing... {} remaining", queued);
|
||||
}
|
||||
}
|
||||
|
||||
// Poll all receivers for status updates
|
||||
for i in 0..self.receivers.len() {
|
||||
if let Some(receiver) = &self.receivers[i] {
|
||||
@@ -93,7 +121,10 @@ impl MultiDownloadProgress {
|
||||
break;
|
||||
}
|
||||
|
||||
if matches!(self.downloads[i].status, DownloadStatus::Queued) {
|
||||
// Only start if download is queued AND receiver is available
|
||||
if matches!(self.downloads[i].status, DownloadStatus::Queued)
|
||||
&& self.receivers[i].is_some()
|
||||
{
|
||||
self.active_count += 1;
|
||||
// Download will start automatically since receiver exists
|
||||
}
|
||||
@@ -198,24 +229,94 @@ impl MultiDownloadProgress {
|
||||
} else {
|
||||
0
|
||||
};
|
||||
let progress_ratio = if total_size > 0 {
|
||||
total_downloaded as f64 / total_size as f64
|
||||
} else {
|
||||
0.0
|
||||
};
|
||||
let total_speed: f64 = self
|
||||
.downloads
|
||||
.iter()
|
||||
.filter(|d| matches!(d.status, DownloadStatus::InProgress { .. }))
|
||||
.map(|d| d.speed_bytes_per_sec)
|
||||
.sum();
|
||||
let speed_mb = total_speed / 1_048_576.0;
|
||||
let speed_mb = (total_speed / 1_048_576.0 * 100.0).round() / 100.0;
|
||||
|
||||
let stats_text = format!(
|
||||
"Downloads: {} active, {} completed, {} failed, {} queued | Overall: {}% • {:.1} MB/s",
|
||||
self.active_count, completed, failed, queued, overall_progress, speed_mb
|
||||
);
|
||||
let block = Block::default()
|
||||
.borders(Borders::ALL)
|
||||
.border_type(border_type(false))
|
||||
.title("Status");
|
||||
|
||||
let stats_widget = Paragraph::new(stats_text)
|
||||
.block(Block::default().borders(Borders::ALL).title("Status"))
|
||||
.style(Style::default().fg(Color::Cyan));
|
||||
let inner_area = block.inner(area);
|
||||
frame.render_widget(block, area);
|
||||
|
||||
frame.render_widget(stats_widget, area);
|
||||
// Split into two equal 50% containers
|
||||
let chunks = Layout::horizontal([
|
||||
Constraint::Percentage(50),
|
||||
Constraint::Percentage(50),
|
||||
])
|
||||
.split(inner_area);
|
||||
|
||||
// Left container: Statistics with styled values
|
||||
let stats_line = Line::from(vec![
|
||||
Span::raw("Downloads: "),
|
||||
Span::styled(
|
||||
self.active_count.to_string(),
|
||||
Style::default()
|
||||
.fg(COLOR_PRIMARY)
|
||||
.add_modifier(Modifier::BOLD),
|
||||
),
|
||||
Span::raw(" active, "),
|
||||
Span::styled(
|
||||
completed.to_string(),
|
||||
Style::default()
|
||||
.fg(COLOR_PRIMARY)
|
||||
.add_modifier(Modifier::BOLD),
|
||||
),
|
||||
Span::raw(" completed, "),
|
||||
Span::styled(
|
||||
failed.to_string(),
|
||||
Style::default()
|
||||
.fg(COLOR_PRIMARY)
|
||||
.add_modifier(Modifier::BOLD),
|
||||
),
|
||||
Span::raw(" failed, "),
|
||||
Span::styled(
|
||||
queued.to_string(),
|
||||
Style::default()
|
||||
.fg(COLOR_PRIMARY)
|
||||
.add_modifier(Modifier::BOLD),
|
||||
),
|
||||
Span::raw(" queued"),
|
||||
]);
|
||||
|
||||
let stats_paragraph = Paragraph::new(stats_line);
|
||||
frame.render_widget(stats_paragraph, chunks[0]);
|
||||
|
||||
let right_width = chunks[1].width as usize;
|
||||
let bar_width = right_width.saturating_sub(42).max(10);
|
||||
let progress_bar =
|
||||
format_progress_bar(progress_ratio, bar_width, overall_progress);
|
||||
let data_str = format_bytes_progress(total_downloaded, total_size);
|
||||
|
||||
let mut spans: Vec<Span> = Vec::new();
|
||||
|
||||
spans.extend(data_str.spans);
|
||||
spans.push(Span::raw(" • "));
|
||||
spans.push(Span::styled(
|
||||
format!("{}", speed_mb),
|
||||
Style::default()
|
||||
.fg(COLOR_PRIMARY)
|
||||
.add_modifier(Modifier::BOLD),
|
||||
));
|
||||
spans.push(Span::raw(" MB/s"));
|
||||
spans.push(Span::raw(" • "));
|
||||
spans.extend(progress_bar.spans);
|
||||
|
||||
let progress_line = Line::from(spans);
|
||||
let progress_paragraph =
|
||||
Paragraph::new(progress_line).alignment(Alignment::Right);
|
||||
frame.render_widget(progress_paragraph, chunks[1]);
|
||||
}
|
||||
|
||||
fn render_downloads_list(
|
||||
@@ -229,7 +330,6 @@ impl MultiDownloadProgress {
|
||||
Cell::from("Username"),
|
||||
Cell::from("Size"),
|
||||
Cell::from("Progress"),
|
||||
Cell::from("%"),
|
||||
Cell::from("Speed"),
|
||||
])
|
||||
.style(header_style())
|
||||
@@ -254,14 +354,10 @@ impl MultiDownloadProgress {
|
||||
0.0
|
||||
};
|
||||
|
||||
let bar_width = 20;
|
||||
let filled = (progress * bar_width as f64) as usize;
|
||||
let empty = bar_width - filled;
|
||||
let progress_bar =
|
||||
format!("[{}{}]", "█".repeat(filled), "░".repeat(empty));
|
||||
|
||||
let percent = (progress * 100.0) as u8;
|
||||
let percent_str = format!("{}%", percent);
|
||||
let bar_width = 20;
|
||||
let progress_bar =
|
||||
format_progress_bar(progress, bar_width, percent);
|
||||
|
||||
let size_str = format_bytes_progress(
|
||||
download.bytes_downloaded,
|
||||
@@ -281,20 +377,15 @@ impl MultiDownloadProgress {
|
||||
Cell::from(download.username.clone()),
|
||||
Cell::from(size_str),
|
||||
Cell::from(progress_bar),
|
||||
Cell::from(percent_str),
|
||||
Cell::from(speed_str),
|
||||
];
|
||||
|
||||
let style = match download.status {
|
||||
DownloadStatus::Queued => Style::default().fg(Color::Gray),
|
||||
DownloadStatus::InProgress { .. } => {
|
||||
Style::default().fg(Color::Yellow)
|
||||
}
|
||||
DownloadStatus::Completed => {
|
||||
Style::default().fg(Color::Green)
|
||||
}
|
||||
DownloadStatus::Queued => inactive_style(),
|
||||
DownloadStatus::InProgress { .. } => warning_style(),
|
||||
DownloadStatus::Completed => primary_style(),
|
||||
DownloadStatus::Failed | DownloadStatus::TimedOut => {
|
||||
Style::default().fg(Color::Red)
|
||||
error_style()
|
||||
}
|
||||
};
|
||||
|
||||
@@ -307,17 +398,23 @@ impl MultiDownloadProgress {
|
||||
Constraint::Min(30),
|
||||
Constraint::Length(15),
|
||||
Constraint::Length(20),
|
||||
Constraint::Length(22),
|
||||
Constraint::Length(5),
|
||||
Constraint::Length(28),
|
||||
Constraint::Length(12),
|
||||
];
|
||||
|
||||
let table = Table::new(rows, widths)
|
||||
.header(header)
|
||||
.block(Block::default().borders(Borders::ALL).title("Downloads"))
|
||||
.block(
|
||||
Block::default()
|
||||
.borders(Borders::ALL)
|
||||
.border_style(border_style(true))
|
||||
.border_type(border_type(true))
|
||||
.title("Downloads"),
|
||||
)
|
||||
.column_spacing(1)
|
||||
.row_highlight_style(highlight_style())
|
||||
.highlight_symbol("> ");
|
||||
.highlight_symbol(HIGHLIGHT_SYMBOL)
|
||||
.highlight_spacing(HighlightSpacing::Always);
|
||||
|
||||
StatefulWidget::render(
|
||||
table,
|
||||
@@ -328,27 +425,71 @@ impl MultiDownloadProgress {
|
||||
}
|
||||
|
||||
fn render_controls(&self, frame: &mut Frame, area: ratatui::layout::Rect) {
|
||||
let controls_text =
|
||||
"↑↓/jk: scroll • Home/End: jump • Esc/q: cancel all";
|
||||
let controls_line = format_shortcuts_styled(&[
|
||||
("↑↓/jk", "scroll"),
|
||||
("Home/End", "jump"),
|
||||
("Esc/q", "cancel all"),
|
||||
]);
|
||||
|
||||
let controls_widget = Paragraph::new(controls_text)
|
||||
.block(Block::default().borders(Borders::ALL).title("Controls"))
|
||||
.style(Style::default().fg(Color::DarkGray));
|
||||
let controls_widget = Paragraph::new(controls_line).block(
|
||||
Block::default()
|
||||
.borders(Borders::ALL)
|
||||
.border_type(border_type(false))
|
||||
.title("Controls"),
|
||||
);
|
||||
|
||||
frame.render_widget(controls_widget, area);
|
||||
}
|
||||
}
|
||||
|
||||
pub fn show_multi_download_progress(
|
||||
downloads: Vec<FileDownloadState>,
|
||||
receivers: Vec<Receiver<DownloadStatus>>,
|
||||
terminal: DefaultTerminal,
|
||||
client: Arc<Client>,
|
||||
selected_files: Vec<(String, String, u64)>,
|
||||
download_dir: String,
|
||||
max_concurrent: usize,
|
||||
) -> Result<()> {
|
||||
soulseek_rs::utils::logger::enable_buffering();
|
||||
|
||||
let terminal = ratatui::init();
|
||||
// Create download states immediately (all with Queued status)
|
||||
let downloads: Vec<FileDownloadState> = selected_files
|
||||
.iter()
|
||||
.map(|(filename, username, size)| {
|
||||
FileDownloadState::new(filename.clone(), username.clone(), *size)
|
||||
})
|
||||
.collect();
|
||||
|
||||
// Create channel for sending receivers from background thread
|
||||
let (tx, rx) = mpsc::channel();
|
||||
|
||||
// Spawn background thread to initialize downloads
|
||||
let init_client = client.clone();
|
||||
let init_files = selected_files.clone();
|
||||
let init_dir = download_dir.clone();
|
||||
thread::spawn(move || {
|
||||
for (index, (filename, username, size)) in
|
||||
init_files.into_iter().enumerate()
|
||||
{
|
||||
// Initiate download
|
||||
match init_client.download(
|
||||
filename,
|
||||
username,
|
||||
size,
|
||||
init_dir.clone(),
|
||||
) {
|
||||
Ok(receiver) => {
|
||||
// Send receiver back to main thread
|
||||
let _ = tx.send((index, receiver));
|
||||
}
|
||||
Err(e) => {
|
||||
eprintln!("Failed to start download {}: {}", index, e);
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
let mut progress =
|
||||
MultiDownloadProgress::new(downloads, receivers, max_concurrent);
|
||||
MultiDownloadProgress::new(downloads, rx, max_concurrent);
|
||||
let result = progress.run(terminal);
|
||||
ratatui::restore();
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
mod file_selector;
|
||||
mod multi_download_progress;
|
||||
mod download_selector;
|
||||
mod downloads;
|
||||
mod styles;
|
||||
mod utils;
|
||||
|
||||
pub use file_selector::FileSelector;
|
||||
pub use multi_download_progress::show_multi_download_progress;
|
||||
pub use download_selector::FileSelector;
|
||||
pub use downloads::show_multi_download_progress;
|
||||
pub use styles::*;
|
||||
pub use utils::*;
|
||||
|
||||
+142
-16
@@ -2,30 +2,32 @@
|
||||
#![allow(dead_code)]
|
||||
|
||||
use ratatui::style::{Color, Modifier, Style};
|
||||
use ratatui::symbols;
|
||||
use ratatui::text::{Line, Span};
|
||||
use ratatui::widgets::BorderType;
|
||||
|
||||
// Color scheme
|
||||
pub const COLOR_PRIMARY: Color = Color::Cyan;
|
||||
pub const COLOR_SUCCESS: Color = Color::Green;
|
||||
pub const COLOR_WARNING: Color = Color::Yellow;
|
||||
pub const COLOR_ERROR: Color = Color::Red;
|
||||
// Color scheme - using Light variants to match openapi-tui
|
||||
pub const COLOR_PRIMARY: Color = Color::LightCyan;
|
||||
pub const COLOR_SUCCESS: Color = Color::LightGreen;
|
||||
pub const COLOR_WARNING: Color = Color::LightYellow;
|
||||
pub const COLOR_ERROR: Color = Color::LightRed;
|
||||
pub const COLOR_INACTIVE: Color = Color::Gray;
|
||||
pub const COLOR_BACKGROUND: Color = Color::DarkGray;
|
||||
pub const COLOR_HIGHLIGHT_BG: Color = Color::Magenta;
|
||||
pub const COLOR_HIGHLIGHT_FG: Color = Color::Black;
|
||||
pub const COLOR_INFO: Color = Color::LightBlue;
|
||||
pub const COLOR_ACCENT: Color = Color::LightMagenta;
|
||||
|
||||
// Border colors
|
||||
pub const COLOR_FOCUSED_BORDER: Color = Color::LightGreen;
|
||||
|
||||
// Highlight symbol - using arrow like openapi-tui
|
||||
pub const HIGHLIGHT_SYMBOL: &str = symbols::scrollbar::HORIZONTAL.end;
|
||||
|
||||
// Reusable styles
|
||||
pub fn header_style() -> Style {
|
||||
Style::default()
|
||||
.fg(COLOR_PRIMARY)
|
||||
.bg(COLOR_BACKGROUND)
|
||||
.add_modifier(Modifier::BOLD)
|
||||
Style::default().add_modifier(Modifier::BOLD)
|
||||
}
|
||||
|
||||
pub fn highlight_style() -> Style {
|
||||
Style::default()
|
||||
.bg(COLOR_HIGHLIGHT_BG)
|
||||
.fg(COLOR_HIGHLIGHT_FG)
|
||||
.add_modifier(Modifier::BOLD)
|
||||
Style::default().add_modifier(Modifier::BOLD)
|
||||
}
|
||||
|
||||
pub fn success_style() -> Style {
|
||||
@@ -47,3 +49,127 @@ pub fn inactive_style() -> Style {
|
||||
pub fn primary_style() -> Style {
|
||||
Style::default().fg(COLOR_PRIMARY)
|
||||
}
|
||||
|
||||
pub fn info_style() -> Style {
|
||||
Style::default().fg(COLOR_INFO)
|
||||
}
|
||||
|
||||
pub fn accent_style() -> Style {
|
||||
Style::default().fg(COLOR_ACCENT)
|
||||
}
|
||||
|
||||
pub fn dimmed_style() -> Style {
|
||||
Style::default().add_modifier(Modifier::DIM)
|
||||
}
|
||||
|
||||
// Focus-based border styling (matching openapi-tui)
|
||||
pub fn focused_border_style() -> Style {
|
||||
Style::default().fg(COLOR_FOCUSED_BORDER)
|
||||
}
|
||||
|
||||
pub fn unfocused_border_style() -> Style {
|
||||
Style::default()
|
||||
}
|
||||
|
||||
pub fn focused_border_type() -> BorderType {
|
||||
BorderType::Rounded
|
||||
}
|
||||
|
||||
pub fn unfocused_border_type() -> BorderType {
|
||||
BorderType::Rounded
|
||||
}
|
||||
|
||||
// Helper function to get border style based on focus state
|
||||
pub fn border_style(focused: bool) -> Style {
|
||||
if focused {
|
||||
focused_border_style()
|
||||
} else {
|
||||
unfocused_border_style()
|
||||
}
|
||||
}
|
||||
|
||||
pub fn border_type(focused: bool) -> BorderType {
|
||||
if focused {
|
||||
focused_border_type()
|
||||
} else {
|
||||
unfocused_border_type()
|
||||
}
|
||||
}
|
||||
|
||||
// Shortcut formatting helper (matching openapi-tui format)
|
||||
pub fn format_shortcuts(shortcuts: &[(&str, &str)]) -> String {
|
||||
shortcuts
|
||||
.iter()
|
||||
.map(|(key, action)| {
|
||||
format!("[{} {} {}]", key, HIGHLIGHT_SYMBOL, action)
|
||||
})
|
||||
.collect::<Vec<_>>()
|
||||
.join(" ")
|
||||
}
|
||||
|
||||
// Styled shortcut formatting helper (returns Line with colored Spans)
|
||||
pub fn format_shortcuts_styled(shortcuts: &[(&str, &str)]) -> Line<'static> {
|
||||
let mut spans = Vec::new();
|
||||
|
||||
for (i, (key, action)) in shortcuts.iter().enumerate() {
|
||||
if i > 0 {
|
||||
spans.push(Span::raw(" "));
|
||||
}
|
||||
|
||||
spans.push(Span::styled("[", dimmed_style()));
|
||||
spans.push(Span::styled(
|
||||
key.to_string(),
|
||||
Style::default()
|
||||
.fg(COLOR_PRIMARY)
|
||||
.add_modifier(Modifier::BOLD),
|
||||
));
|
||||
spans.push(Span::styled(
|
||||
format!(" {} ", HIGHLIGHT_SYMBOL),
|
||||
dimmed_style(),
|
||||
));
|
||||
spans.push(Span::raw(action.to_string()));
|
||||
spans.push(Span::styled("]", dimmed_style()));
|
||||
}
|
||||
|
||||
Line::from(spans)
|
||||
}
|
||||
|
||||
// Status colors for downloads
|
||||
pub fn status_color(status: &str) -> Color {
|
||||
match status {
|
||||
"queued" => COLOR_INACTIVE,
|
||||
"in_progress" => COLOR_WARNING,
|
||||
"completed" => COLOR_PRIMARY,
|
||||
"failed" | "timed_out" => COLOR_ERROR,
|
||||
_ => Color::default(),
|
||||
}
|
||||
}
|
||||
|
||||
// Format progress bar with styled filled/unfilled portions
|
||||
pub fn format_progress_bar(
|
||||
progress: f64,
|
||||
width: usize,
|
||||
percentage: u8,
|
||||
) -> Line<'static> {
|
||||
let filled = (progress * width as f64) as usize;
|
||||
let empty = width.saturating_sub(filled);
|
||||
|
||||
let filled_part = "█".repeat(filled);
|
||||
let empty_part = "░".repeat(empty);
|
||||
|
||||
let spans = vec![
|
||||
Span::raw("["),
|
||||
Span::styled(filled_part, primary_style()),
|
||||
Span::styled(empty_part, dimmed_style()),
|
||||
Span::raw("]"),
|
||||
Span::raw(" "),
|
||||
Span::styled(
|
||||
format!("{}%", percentage),
|
||||
primary_style()
|
||||
.add_modifier(Modifier::BOLD)
|
||||
.fg(Color::default()),
|
||||
),
|
||||
];
|
||||
|
||||
Line::from(spans)
|
||||
}
|
||||
|
||||
@@ -1,12 +1,27 @@
|
||||
use ratatui::{
|
||||
style::Style,
|
||||
text::{Line, Span},
|
||||
};
|
||||
|
||||
use crate::ui::COLOR_PRIMARY;
|
||||
|
||||
pub fn format_bytes(bytes: u64) -> String {
|
||||
let mb = bytes as f64 / 1_048_576.0;
|
||||
format!("{:.1} MB", mb)
|
||||
}
|
||||
|
||||
pub fn format_bytes_progress(downloaded: u64, total: u64) -> String {
|
||||
pub fn format_bytes_progress(downloaded: u64, total: u64) -> Line<'static> {
|
||||
let downloaded_mb = downloaded as f64 / 1_048_576.0;
|
||||
let total_mb = total as f64 / 1_048_576.0;
|
||||
format!("{:.1}/{:.1} MB", downloaded_mb, total_mb)
|
||||
|
||||
let mut spans = vec![];
|
||||
spans.push(Span::styled(
|
||||
format!("{:.1}/{:.1}", downloaded_mb, total_mb),
|
||||
Style::default().fg(COLOR_PRIMARY),
|
||||
));
|
||||
spans.push(Span::raw(" MB"));
|
||||
|
||||
Line::from(spans)
|
||||
}
|
||||
|
||||
pub fn format_speed(speed_bytes_per_sec: f64) -> String {
|
||||
@@ -19,3 +34,11 @@ pub fn get_bitrate(
|
||||
) -> Option<u32> {
|
||||
attribs.get(&0).copied()
|
||||
}
|
||||
|
||||
// Spinner animation chars
|
||||
const SPINNER_CHARS: [&str; 10] =
|
||||
["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"];
|
||||
|
||||
pub fn get_spinner_char(state: usize) -> &'static str {
|
||||
SPINNER_CHARS[state % SPINNER_CHARS.len()]
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user