chore: refactor CLI app with new modules for seperation of concerns, reusable components and consistent styling
This commit is contained in:
@@ -0,0 +1,74 @@
|
|||||||
|
use clap::{Parser, Subcommand};
|
||||||
|
|
||||||
|
#[derive(Parser, Debug)]
|
||||||
|
#[command(
|
||||||
|
name = "soulseek-rs",
|
||||||
|
author,
|
||||||
|
version,
|
||||||
|
about = "Soulseek client in Rust 🦀",
|
||||||
|
long_about = None,
|
||||||
|
arg_required_else_help = true
|
||||||
|
)]
|
||||||
|
pub struct Cli {
|
||||||
|
#[arg(short, long, env = "SOULSEEK_USERNAME")]
|
||||||
|
pub username: Option<String>,
|
||||||
|
|
||||||
|
#[arg(short, long, env = "SOULSEEK_PASSWORD")]
|
||||||
|
pub password: Option<String>,
|
||||||
|
|
||||||
|
#[arg(
|
||||||
|
short,
|
||||||
|
long,
|
||||||
|
env = "SOULSEEK_SERVER",
|
||||||
|
default_value = "server.slsknet.org:2416"
|
||||||
|
)]
|
||||||
|
pub server: String,
|
||||||
|
|
||||||
|
#[arg(long, env = "DISABLE_LISTENER")]
|
||||||
|
pub disable_listener: bool,
|
||||||
|
|
||||||
|
#[arg(short, long, env = "LISTENER_PORT", default_value = "2234")]
|
||||||
|
pub listener_port: u32,
|
||||||
|
|
||||||
|
#[arg(short, long, global = true, action = clap::ArgAction::Count)]
|
||||||
|
pub verbose: u8,
|
||||||
|
|
||||||
|
#[command(subcommand)]
|
||||||
|
pub command: Commands,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Subcommand, Debug)]
|
||||||
|
pub enum Commands {
|
||||||
|
Search {
|
||||||
|
query: String,
|
||||||
|
|
||||||
|
#[arg(short, long, default_value = "10")]
|
||||||
|
timeout: u64,
|
||||||
|
|
||||||
|
#[arg(short, long, default_value = "~/Downloads")]
|
||||||
|
download_dir: String,
|
||||||
|
|
||||||
|
#[arg(
|
||||||
|
short = 'c',
|
||||||
|
long,
|
||||||
|
env = "MAX_CONCURRENT_DOWNLOADS",
|
||||||
|
default_value = "5"
|
||||||
|
)]
|
||||||
|
max_concurrent_downloads: usize,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn parse_server_address(server: &str) -> color_eyre::Result<(String, u16)> {
|
||||||
|
let parts: Vec<&str> = server.split(':').collect();
|
||||||
|
if parts.len() != 2 {
|
||||||
|
return Err(color_eyre::eyre::eyre!(
|
||||||
|
"Invalid server format. Expected 'host:port', got '{}'",
|
||||||
|
server
|
||||||
|
));
|
||||||
|
}
|
||||||
|
let host = parts[0].to_string();
|
||||||
|
let port = parts[1].parse::<u16>().map_err(|_| {
|
||||||
|
color_eyre::eyre::eyre!("Invalid port number: {}", parts[1])
|
||||||
|
})?;
|
||||||
|
Ok((host, port))
|
||||||
|
}
|
||||||
@@ -0,0 +1,13 @@
|
|||||||
|
pub struct SearchConfig {
|
||||||
|
pub username: String,
|
||||||
|
pub password: String,
|
||||||
|
pub server_host: String,
|
||||||
|
pub server_port: u16,
|
||||||
|
pub enable_listener: bool,
|
||||||
|
pub listener_port: u32,
|
||||||
|
pub query: String,
|
||||||
|
pub timeout: u64,
|
||||||
|
pub download_dir: String,
|
||||||
|
pub verbose: u8,
|
||||||
|
pub max_concurrent_downloads: usize,
|
||||||
|
}
|
||||||
+13
-1034
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,52 @@
|
|||||||
|
use soulseek_rs::DownloadStatus;
|
||||||
|
|
||||||
|
#[derive(Debug, Clone)]
|
||||||
|
pub struct FileDownloadState {
|
||||||
|
pub filename: String,
|
||||||
|
pub username: String,
|
||||||
|
pub total_bytes: u64,
|
||||||
|
pub bytes_downloaded: u64,
|
||||||
|
pub speed_bytes_per_sec: f64,
|
||||||
|
pub status: DownloadStatus,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl FileDownloadState {
|
||||||
|
pub fn new(filename: String, username: String, total_bytes: u64) -> Self {
|
||||||
|
Self {
|
||||||
|
filename,
|
||||||
|
username,
|
||||||
|
total_bytes,
|
||||||
|
bytes_downloaded: 0,
|
||||||
|
speed_bytes_per_sec: 0.0,
|
||||||
|
status: DownloadStatus::Queued,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn update_status(&mut self, status: DownloadStatus) {
|
||||||
|
match &status {
|
||||||
|
DownloadStatus::InProgress {
|
||||||
|
bytes_downloaded,
|
||||||
|
total_bytes,
|
||||||
|
speed_bytes_per_sec,
|
||||||
|
} => {
|
||||||
|
self.bytes_downloaded = *bytes_downloaded;
|
||||||
|
self.total_bytes = *total_bytes;
|
||||||
|
self.speed_bytes_per_sec = *speed_bytes_per_sec;
|
||||||
|
}
|
||||||
|
DownloadStatus::Completed => {
|
||||||
|
self.bytes_downloaded = self.total_bytes;
|
||||||
|
}
|
||||||
|
_ => {}
|
||||||
|
}
|
||||||
|
self.status = status;
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn is_finished(&self) -> bool {
|
||||||
|
matches!(
|
||||||
|
self.status,
|
||||||
|
DownloadStatus::Completed
|
||||||
|
| DownloadStatus::Failed
|
||||||
|
| DownloadStatus::TimedOut
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,9 @@
|
|||||||
|
#[derive(Clone)]
|
||||||
|
pub struct FileDisplayData {
|
||||||
|
pub filename: String,
|
||||||
|
pub size: u64,
|
||||||
|
pub username: String,
|
||||||
|
pub speed: u32,
|
||||||
|
pub slots: u8,
|
||||||
|
pub bitrate: Option<u32>,
|
||||||
|
}
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
mod download_state;
|
||||||
|
mod file_display_data;
|
||||||
|
|
||||||
|
pub use download_state::FileDownloadState;
|
||||||
|
pub use file_display_data::FileDisplayData;
|
||||||
@@ -0,0 +1,478 @@
|
|||||||
|
use crate::models::FileDisplayData;
|
||||||
|
use crate::ui::{format_bytes, get_bitrate, header_style, highlight_style};
|
||||||
|
use color_eyre::Result;
|
||||||
|
use ratatui::{
|
||||||
|
crossterm::event::{self, poll, Event, KeyCode, KeyEvent, KeyEventKind},
|
||||||
|
layout::{Alignment, Constraint, Layout},
|
||||||
|
style::{Color, Style},
|
||||||
|
widgets::{Block, Borders, Cell, Paragraph, Row, StatefulWidget, Table, TableState, Wrap},
|
||||||
|
DefaultTerminal, Frame,
|
||||||
|
};
|
||||||
|
use soulseek_rs::Client;
|
||||||
|
use std::{
|
||||||
|
collections::HashSet,
|
||||||
|
sync::{
|
||||||
|
atomic::{AtomicBool, Ordering},
|
||||||
|
Arc,
|
||||||
|
},
|
||||||
|
time::{Duration, Instant},
|
||||||
|
};
|
||||||
|
|
||||||
|
pub struct FileSelector {
|
||||||
|
all_items: Vec<FileDisplayData>,
|
||||||
|
items: Vec<FileDisplayData>,
|
||||||
|
filtered_indices: Vec<usize>,
|
||||||
|
state: TableState,
|
||||||
|
should_exit: bool,
|
||||||
|
selected_indices: HashSet<usize>,
|
||||||
|
search_query: String,
|
||||||
|
is_searching: bool,
|
||||||
|
client: Option<Arc<Client>>,
|
||||||
|
soulseek_query: String,
|
||||||
|
search_timeout: Duration,
|
||||||
|
search_start_time: Instant,
|
||||||
|
search_cancel_flag: Arc<AtomicBool>,
|
||||||
|
search_active: bool,
|
||||||
|
spinner_state: usize,
|
||||||
|
last_spinner_update: Instant,
|
||||||
|
last_result_count: usize,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl FileSelector {
|
||||||
|
pub fn new_with_live_search(
|
||||||
|
client: Arc<Client>,
|
||||||
|
query: String,
|
||||||
|
timeout: Duration,
|
||||||
|
cancel_flag: Arc<AtomicBool>,
|
||||||
|
) -> Self {
|
||||||
|
let mut state = TableState::default();
|
||||||
|
state.select(Some(0));
|
||||||
|
|
||||||
|
Self {
|
||||||
|
all_items: Vec::new(),
|
||||||
|
items: Vec::new(),
|
||||||
|
filtered_indices: Vec::new(),
|
||||||
|
state,
|
||||||
|
should_exit: false,
|
||||||
|
selected_indices: HashSet::new(),
|
||||||
|
search_query: String::new(),
|
||||||
|
is_searching: false,
|
||||||
|
client: Some(client),
|
||||||
|
soulseek_query: query,
|
||||||
|
search_timeout: timeout,
|
||||||
|
search_start_time: Instant::now(),
|
||||||
|
search_cancel_flag: cancel_flag,
|
||||||
|
search_active: true,
|
||||||
|
spinner_state: 0,
|
||||||
|
last_spinner_update: Instant::now(),
|
||||||
|
last_result_count: 0,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn run(&mut self, mut terminal: DefaultTerminal) -> Result<Vec<usize>> {
|
||||||
|
while !self.should_exit {
|
||||||
|
terminal.draw(|frame| self.render(frame))?;
|
||||||
|
|
||||||
|
// Update spinner animation every 80ms
|
||||||
|
if self.search_active
|
||||||
|
&& self.last_spinner_update.elapsed()
|
||||||
|
>= Duration::from_millis(80)
|
||||||
|
{
|
||||||
|
self.spinner_state = (self.spinner_state + 1) % 10;
|
||||||
|
self.last_spinner_update = Instant::now();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Poll for new search results if active
|
||||||
|
if self.search_active {
|
||||||
|
if let Some(ref client) = self.client {
|
||||||
|
let current_count = client.get_search_results_count();
|
||||||
|
if current_count != self.last_result_count {
|
||||||
|
// New results available - update the list
|
||||||
|
self.update_results_from_client();
|
||||||
|
self.last_result_count = current_count;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if search timeout reached
|
||||||
|
if self.search_start_time.elapsed() >= self.search_timeout {
|
||||||
|
self.search_active = false;
|
||||||
|
self.search_cancel_flag.store(true, Ordering::Relaxed);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check for keyboard input with timeout for polling
|
||||||
|
let timeout = if self.search_active {
|
||||||
|
Duration::from_millis(50)
|
||||||
|
} else {
|
||||||
|
Duration::from_millis(100)
|
||||||
|
};
|
||||||
|
|
||||||
|
if poll(timeout)? {
|
||||||
|
if let Event::Key(key) = event::read()? {
|
||||||
|
self.handle_key(key);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(self.selected_indices.iter().copied().collect())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn update_results_from_client(&mut self) {
|
||||||
|
if let Some(ref client) = self.client {
|
||||||
|
let search_results = client.get_search_results();
|
||||||
|
|
||||||
|
// Convert search results to FileDisplayData
|
||||||
|
let mut new_items = Vec::new();
|
||||||
|
for result in &search_results {
|
||||||
|
for file in &result.files {
|
||||||
|
new_items.push(FileDisplayData {
|
||||||
|
filename: file.name.clone(),
|
||||||
|
size: file.size,
|
||||||
|
username: result.username.clone(),
|
||||||
|
speed: result.speed,
|
||||||
|
slots: result.slots,
|
||||||
|
bitrate: get_bitrate(&file.attribs),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update items and reset state
|
||||||
|
let len = new_items.len();
|
||||||
|
self.all_items = new_items.clone();
|
||||||
|
self.items = new_items;
|
||||||
|
self.filtered_indices = (0..len).collect();
|
||||||
|
|
||||||
|
// Keep selection valid or set to 0
|
||||||
|
if self.state.selected().is_none() && !self.items.is_empty() {
|
||||||
|
self.state.select(Some(0));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn toggle_selection(&mut self) {
|
||||||
|
if let Some(filtered_idx) = self.state.selected() {
|
||||||
|
if let Some(&original_idx) = self.filtered_indices.get(filtered_idx)
|
||||||
|
{
|
||||||
|
if self.selected_indices.contains(&original_idx) {
|
||||||
|
self.selected_indices.remove(&original_idx);
|
||||||
|
} else {
|
||||||
|
self.selected_indices.insert(original_idx);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn handle_key(&mut self, key: KeyEvent) {
|
||||||
|
if key.kind != KeyEventKind::Press {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if self.is_searching {
|
||||||
|
match key.code {
|
||||||
|
KeyCode::Char(' ') => {
|
||||||
|
self.toggle_selection();
|
||||||
|
}
|
||||||
|
KeyCode::Char(c) => {
|
||||||
|
self.search_query.push(c);
|
||||||
|
self.apply_filter();
|
||||||
|
}
|
||||||
|
KeyCode::Backspace => {
|
||||||
|
self.search_query.pop();
|
||||||
|
self.apply_filter();
|
||||||
|
}
|
||||||
|
KeyCode::Esc => {
|
||||||
|
self.is_searching = false;
|
||||||
|
}
|
||||||
|
KeyCode::Enter => {
|
||||||
|
// If nothing selected, select the current item under cursor
|
||||||
|
if self.selected_indices.is_empty() {
|
||||||
|
if let Some(filtered_idx) = self.state.selected() {
|
||||||
|
if let Some(&original_idx) =
|
||||||
|
self.filtered_indices.get(filtered_idx)
|
||||||
|
{
|
||||||
|
self.selected_indices.insert(original_idx);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
self.should_exit = true;
|
||||||
|
}
|
||||||
|
KeyCode::Up | KeyCode::Down => {
|
||||||
|
if key.code == KeyCode::Up {
|
||||||
|
self.select_previous();
|
||||||
|
} else {
|
||||||
|
self.select_next();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
_ => {}
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
match key.code {
|
||||||
|
KeyCode::Char(' ') => {
|
||||||
|
self.toggle_selection();
|
||||||
|
}
|
||||||
|
KeyCode::Char('/') => {
|
||||||
|
self.is_searching = true;
|
||||||
|
}
|
||||||
|
KeyCode::Up | KeyCode::Char('k') => self.select_previous(),
|
||||||
|
KeyCode::Down | KeyCode::Char('j') => self.select_next(),
|
||||||
|
KeyCode::Home | KeyCode::Char('g') => self.select_first(),
|
||||||
|
KeyCode::End | KeyCode::Char('G') => self.select_last(),
|
||||||
|
KeyCode::Char('a') => {
|
||||||
|
// Select all visible/filtered items
|
||||||
|
for &original_idx in &self.filtered_indices {
|
||||||
|
self.selected_indices.insert(original_idx);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
KeyCode::Char('A') => {
|
||||||
|
// Deselect all items
|
||||||
|
self.selected_indices.clear();
|
||||||
|
}
|
||||||
|
KeyCode::Enter => {
|
||||||
|
// If nothing selected, select the current item under cursor
|
||||||
|
if self.selected_indices.is_empty() {
|
||||||
|
if let Some(filtered_idx) = self.state.selected() {
|
||||||
|
if let Some(&original_idx) =
|
||||||
|
self.filtered_indices.get(filtered_idx)
|
||||||
|
{
|
||||||
|
self.selected_indices.insert(original_idx);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
self.should_exit = true;
|
||||||
|
}
|
||||||
|
KeyCode::Esc | KeyCode::Char('q') => {
|
||||||
|
self.selected_indices.clear();
|
||||||
|
self.should_exit = true;
|
||||||
|
}
|
||||||
|
_ => {}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn select_previous(&mut self) {
|
||||||
|
let i = match self.state.selected() {
|
||||||
|
Some(i) if i > 0 => i - 1,
|
||||||
|
_ => self.items.len() - 1,
|
||||||
|
};
|
||||||
|
self.state.select(Some(i));
|
||||||
|
}
|
||||||
|
|
||||||
|
fn select_next(&mut self) {
|
||||||
|
let i = match self.state.selected() {
|
||||||
|
Some(i) if i < self.items.len() - 1 => i + 1,
|
||||||
|
_ => 0,
|
||||||
|
};
|
||||||
|
self.state.select(Some(i));
|
||||||
|
}
|
||||||
|
|
||||||
|
fn select_first(&mut self) {
|
||||||
|
self.state.select(Some(0));
|
||||||
|
}
|
||||||
|
|
||||||
|
fn select_last(&mut self) {
|
||||||
|
self.state.select(Some(self.items.len() - 1));
|
||||||
|
}
|
||||||
|
|
||||||
|
fn apply_filter(&mut self) {
|
||||||
|
let query_lower = self.search_query.to_lowercase();
|
||||||
|
|
||||||
|
if query_lower.is_empty() {
|
||||||
|
self.items = self.all_items.clone();
|
||||||
|
self.filtered_indices = (0..self.all_items.len()).collect();
|
||||||
|
} else {
|
||||||
|
self.items.clear();
|
||||||
|
self.filtered_indices.clear();
|
||||||
|
|
||||||
|
for (i, item) in self.all_items.iter().enumerate() {
|
||||||
|
if item.filename.to_lowercase().contains(&query_lower)
|
||||||
|
|| item.username.to_lowercase().contains(&query_lower)
|
||||||
|
{
|
||||||
|
self.items.push(item.clone());
|
||||||
|
self.filtered_indices.push(i);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if self.items.is_empty() {
|
||||||
|
self.state.select(None);
|
||||||
|
} else {
|
||||||
|
self.state.select(Some(0));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn render(&mut self, frame: &mut Frame) {
|
||||||
|
let area = frame.area();
|
||||||
|
|
||||||
|
let (table_area, info_area) = if 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]))
|
||||||
|
} else {
|
||||||
|
(area, None)
|
||||||
|
};
|
||||||
|
|
||||||
|
let title = if self.search_active {
|
||||||
|
// Live Soulseek search is active
|
||||||
|
let spinner_chars =
|
||||||
|
["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"];
|
||||||
|
let spinner = spinner_chars[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",
|
||||||
|
spinner,
|
||||||
|
self.soulseek_query,
|
||||||
|
self.all_items.len(),
|
||||||
|
elapsed,
|
||||||
|
total
|
||||||
|
)
|
||||||
|
} 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)",
|
||||||
|
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)",
|
||||||
|
self.selected_indices.len()
|
||||||
|
)
|
||||||
|
};
|
||||||
|
|
||||||
|
let header = Row::new(vec![
|
||||||
|
Cell::from(""),
|
||||||
|
Cell::from("Filename"),
|
||||||
|
Cell::from("Size"),
|
||||||
|
Cell::from("Username"),
|
||||||
|
Cell::from("Speed"),
|
||||||
|
Cell::from("Slots"),
|
||||||
|
Cell::from("Bitrate"),
|
||||||
|
])
|
||||||
|
.style(header_style())
|
||||||
|
.height(1);
|
||||||
|
|
||||||
|
let rows: Vec<Row> = self
|
||||||
|
.items
|
||||||
|
.iter()
|
||||||
|
.enumerate()
|
||||||
|
.map(|(filtered_idx, item)| {
|
||||||
|
let original_idx = self.filtered_indices[filtered_idx];
|
||||||
|
let is_selected = self.selected_indices.contains(&original_idx);
|
||||||
|
let checkbox = if is_selected { "[✓]" } else { "[ ]" };
|
||||||
|
|
||||||
|
let speed_mb = item.speed as f64 / 1_048_576.0;
|
||||||
|
let speed_str = if speed_mb > 0.0 {
|
||||||
|
format!("{:.1} MB/s", speed_mb)
|
||||||
|
} else {
|
||||||
|
"-".to_string()
|
||||||
|
};
|
||||||
|
|
||||||
|
let slots_str = format!("{}", item.slots);
|
||||||
|
|
||||||
|
let bitrate_str = match item.bitrate {
|
||||||
|
Some(br) => format!("{} kbps", br),
|
||||||
|
None => "-".to_string(),
|
||||||
|
};
|
||||||
|
|
||||||
|
let cells = vec![
|
||||||
|
Cell::from(checkbox),
|
||||||
|
Cell::from(item.filename.clone()),
|
||||||
|
Cell::from(format_bytes(item.size)),
|
||||||
|
Cell::from(item.username.clone()),
|
||||||
|
Cell::from(speed_str),
|
||||||
|
Cell::from(slots_str),
|
||||||
|
Cell::from(bitrate_str),
|
||||||
|
];
|
||||||
|
|
||||||
|
let style = if is_selected {
|
||||||
|
Style::default().fg(Color::Green)
|
||||||
|
} else {
|
||||||
|
Style::default()
|
||||||
|
};
|
||||||
|
|
||||||
|
Row::new(cells).style(style).height(1)
|
||||||
|
})
|
||||||
|
.collect();
|
||||||
|
|
||||||
|
let widths = [
|
||||||
|
Constraint::Length(5),
|
||||||
|
Constraint::Min(30),
|
||||||
|
Constraint::Length(12),
|
||||||
|
Constraint::Length(15),
|
||||||
|
Constraint::Length(12),
|
||||||
|
Constraint::Length(8),
|
||||||
|
Constraint::Length(10),
|
||||||
|
];
|
||||||
|
|
||||||
|
let table = Table::new(rows, widths)
|
||||||
|
.header(header)
|
||||||
|
.block(Block::default().borders(Borders::ALL).title(title))
|
||||||
|
.column_spacing(1)
|
||||||
|
.row_highlight_style(highlight_style())
|
||||||
|
.highlight_symbol("> ");
|
||||||
|
|
||||||
|
StatefulWidget::render(
|
||||||
|
table,
|
||||||
|
table_area,
|
||||||
|
frame.buffer_mut(),
|
||||||
|
&mut self.state,
|
||||||
|
);
|
||||||
|
|
||||||
|
// 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 loading_text =
|
||||||
|
format!("{} Searching for '{}'", spinner, self.soulseek_query);
|
||||||
|
|
||||||
|
// Center the loading message
|
||||||
|
let vertical = Layout::vertical([
|
||||||
|
Constraint::Fill(1),
|
||||||
|
Constraint::Length(3),
|
||||||
|
Constraint::Fill(1),
|
||||||
|
])
|
||||||
|
.split(table_area);
|
||||||
|
|
||||||
|
let horizontal = Layout::horizontal([
|
||||||
|
Constraint::Fill(1),
|
||||||
|
Constraint::Max(80),
|
||||||
|
Constraint::Fill(1),
|
||||||
|
])
|
||||||
|
.split(vertical[1]);
|
||||||
|
|
||||||
|
let loading_widget = Paragraph::new(loading_text)
|
||||||
|
.style(Style::default().fg(Color::Cyan))
|
||||||
|
.block(
|
||||||
|
Block::default()
|
||||||
|
.borders(Borders::ALL)
|
||||||
|
.border_style(Style::default().fg(Color::Cyan)),
|
||||||
|
)
|
||||||
|
.alignment(Alignment::Center)
|
||||||
|
.wrap(Wrap { trim: true });
|
||||||
|
|
||||||
|
frame.render_widget(loading_widget, horizontal[1]);
|
||||||
|
}
|
||||||
|
|
||||||
|
if let Some(info_area) = info_area {
|
||||||
|
let (info_text, title, color) = if self.is_searching {
|
||||||
|
(format!("Search: {}", self.search_query), "Filter", Color::Yellow)
|
||||||
|
} else if !self.search_query.is_empty() {
|
||||||
|
(format!("Current filter: {} (press / to modify, c to clear)", self.search_query), "Filter", Color::Cyan)
|
||||||
|
} else {
|
||||||
|
(format!("{} file(s) selected for download", self.selected_indices.len()), "Selection", Color::Green)
|
||||||
|
};
|
||||||
|
|
||||||
|
let info_widget = Paragraph::new(info_text)
|
||||||
|
.block(Block::default().borders(Borders::ALL).title(title))
|
||||||
|
.style(Style::default().fg(color));
|
||||||
|
|
||||||
|
frame.render_widget(info_widget, info_area);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,9 @@
|
|||||||
|
mod file_selector;
|
||||||
|
mod multi_download_progress;
|
||||||
|
mod styles;
|
||||||
|
mod utils;
|
||||||
|
|
||||||
|
pub use file_selector::FileSelector;
|
||||||
|
pub use multi_download_progress::show_multi_download_progress;
|
||||||
|
pub use styles::*;
|
||||||
|
pub use utils::*;
|
||||||
@@ -0,0 +1,354 @@
|
|||||||
|
use crate::models::FileDownloadState;
|
||||||
|
use crate::ui::{format_bytes_progress, format_speed, header_style, highlight_style};
|
||||||
|
use color_eyre::Result;
|
||||||
|
use ratatui::{
|
||||||
|
crossterm::event::{self, poll, Event, KeyCode, KeyEvent, KeyEventKind},
|
||||||
|
layout::{Constraint, Layout},
|
||||||
|
style::{Color, Style},
|
||||||
|
widgets::{Block, Borders, Cell, Paragraph, Row, StatefulWidget, Table, TableState},
|
||||||
|
DefaultTerminal, Frame,
|
||||||
|
};
|
||||||
|
use soulseek_rs::DownloadStatus;
|
||||||
|
use std::{sync::mpsc::Receiver, time::Duration};
|
||||||
|
|
||||||
|
pub struct MultiDownloadProgress {
|
||||||
|
downloads: Vec<FileDownloadState>,
|
||||||
|
receivers: Vec<Option<Receiver<DownloadStatus>>>,
|
||||||
|
list_state: TableState,
|
||||||
|
max_concurrent: usize,
|
||||||
|
active_count: usize,
|
||||||
|
should_exit: bool,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl MultiDownloadProgress {
|
||||||
|
pub fn new(
|
||||||
|
downloads: Vec<FileDownloadState>,
|
||||||
|
receivers: Vec<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();
|
||||||
|
|
||||||
|
Self {
|
||||||
|
downloads,
|
||||||
|
receivers,
|
||||||
|
list_state,
|
||||||
|
max_concurrent,
|
||||||
|
active_count: 0,
|
||||||
|
should_exit: false,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn run(&mut self, mut terminal: DefaultTerminal) -> Result<()> {
|
||||||
|
// Start initial batch of downloads
|
||||||
|
self.start_next_batch();
|
||||||
|
|
||||||
|
loop {
|
||||||
|
terminal.draw(|frame| self.render(frame))?;
|
||||||
|
|
||||||
|
// Poll all receivers for status updates
|
||||||
|
for i in 0..self.receivers.len() {
|
||||||
|
if let Some(receiver) = &self.receivers[i] {
|
||||||
|
if let Ok(status) = receiver.try_recv() {
|
||||||
|
let was_active = !self.downloads[i].is_finished();
|
||||||
|
self.downloads[i].update_status(status);
|
||||||
|
let is_finished = self.downloads[i].is_finished();
|
||||||
|
|
||||||
|
// If download just finished, decrement active count and start next
|
||||||
|
if was_active && is_finished {
|
||||||
|
self.active_count =
|
||||||
|
self.active_count.saturating_sub(1);
|
||||||
|
self.start_next_batch();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Handle keyboard input
|
||||||
|
if poll(Duration::from_millis(100))? {
|
||||||
|
if let Event::Key(key) = event::read()? {
|
||||||
|
self.handle_key(key);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Exit when user cancels or all downloads finished
|
||||||
|
if self.should_exit || self.all_finished() {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn start_next_batch(&mut self) {
|
||||||
|
// Start queued downloads up to max_concurrent limit
|
||||||
|
for i in 0..self.downloads.len() {
|
||||||
|
if self.active_count >= self.max_concurrent {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
if matches!(self.downloads[i].status, DownloadStatus::Queued) {
|
||||||
|
self.active_count += 1;
|
||||||
|
// Download will start automatically since receiver exists
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn all_finished(&self) -> bool {
|
||||||
|
self.downloads.iter().all(|d| d.is_finished())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn handle_key(&mut self, key: KeyEvent) {
|
||||||
|
if key.kind != KeyEventKind::Press {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
match key.code {
|
||||||
|
KeyCode::Up | KeyCode::Char('k') => self.select_previous(),
|
||||||
|
KeyCode::Down | KeyCode::Char('j') => self.select_next(),
|
||||||
|
KeyCode::Home | KeyCode::Char('g') => self.select_first(),
|
||||||
|
KeyCode::End | KeyCode::Char('G') => self.select_last(),
|
||||||
|
KeyCode::Esc | KeyCode::Char('q') => {
|
||||||
|
self.should_exit = true;
|
||||||
|
}
|
||||||
|
_ => {}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn select_previous(&mut self) {
|
||||||
|
let i = match self.list_state.selected() {
|
||||||
|
Some(i) if i > 0 => i - 1,
|
||||||
|
_ => self.downloads.len().saturating_sub(1),
|
||||||
|
};
|
||||||
|
self.list_state.select(Some(i));
|
||||||
|
}
|
||||||
|
|
||||||
|
fn select_next(&mut self) {
|
||||||
|
let i = match self.list_state.selected() {
|
||||||
|
Some(i) if i < self.downloads.len().saturating_sub(1) => i + 1,
|
||||||
|
_ => 0,
|
||||||
|
};
|
||||||
|
self.list_state.select(Some(i));
|
||||||
|
}
|
||||||
|
|
||||||
|
fn select_first(&mut self) {
|
||||||
|
self.list_state.select(Some(0));
|
||||||
|
}
|
||||||
|
|
||||||
|
fn select_last(&mut self) {
|
||||||
|
if !self.downloads.is_empty() {
|
||||||
|
self.list_state.select(Some(self.downloads.len() - 1));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn render(&mut self, frame: &mut Frame) {
|
||||||
|
let area = frame.area();
|
||||||
|
|
||||||
|
let chunks = Layout::vertical([
|
||||||
|
Constraint::Length(3),
|
||||||
|
Constraint::Min(0),
|
||||||
|
Constraint::Length(3),
|
||||||
|
])
|
||||||
|
.split(area);
|
||||||
|
|
||||||
|
// Top: Overall stats
|
||||||
|
self.render_stats(frame, chunks[0]);
|
||||||
|
|
||||||
|
// Middle: List of downloads
|
||||||
|
self.render_downloads_list(frame, chunks[1]);
|
||||||
|
|
||||||
|
// Bottom: Controls
|
||||||
|
self.render_controls(frame, chunks[2]);
|
||||||
|
}
|
||||||
|
|
||||||
|
fn render_stats(&self, frame: &mut Frame, area: ratatui::layout::Rect) {
|
||||||
|
let completed = self
|
||||||
|
.downloads
|
||||||
|
.iter()
|
||||||
|
.filter(|d| matches!(d.status, DownloadStatus::Completed))
|
||||||
|
.count();
|
||||||
|
let failed = self
|
||||||
|
.downloads
|
||||||
|
.iter()
|
||||||
|
.filter(|d| {
|
||||||
|
matches!(
|
||||||
|
d.status,
|
||||||
|
DownloadStatus::Failed | DownloadStatus::TimedOut
|
||||||
|
)
|
||||||
|
})
|
||||||
|
.count();
|
||||||
|
let queued = self
|
||||||
|
.downloads
|
||||||
|
.iter()
|
||||||
|
.filter(|d| matches!(d.status, DownloadStatus::Queued))
|
||||||
|
.count();
|
||||||
|
|
||||||
|
let total_downloaded: u64 =
|
||||||
|
self.downloads.iter().map(|d| d.bytes_downloaded).sum();
|
||||||
|
let total_size: u64 =
|
||||||
|
self.downloads.iter().map(|d| d.total_bytes).sum();
|
||||||
|
let overall_progress = if total_size > 0 {
|
||||||
|
(total_downloaded as f64 / total_size as f64 * 100.0) as u8
|
||||||
|
} else {
|
||||||
|
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 stats_text = format!(
|
||||||
|
"Downloads: {} active, {} completed, {} failed, {} queued | Overall: {}% • {:.1} MB/s",
|
||||||
|
self.active_count, completed, failed, queued, overall_progress, speed_mb
|
||||||
|
);
|
||||||
|
|
||||||
|
let stats_widget = Paragraph::new(stats_text)
|
||||||
|
.block(Block::default().borders(Borders::ALL).title("Status"))
|
||||||
|
.style(Style::default().fg(Color::Cyan));
|
||||||
|
|
||||||
|
frame.render_widget(stats_widget, area);
|
||||||
|
}
|
||||||
|
|
||||||
|
fn render_downloads_list(
|
||||||
|
&mut self,
|
||||||
|
frame: &mut Frame,
|
||||||
|
area: ratatui::layout::Rect,
|
||||||
|
) {
|
||||||
|
let header = Row::new(vec![
|
||||||
|
Cell::from("St"),
|
||||||
|
Cell::from("Filename"),
|
||||||
|
Cell::from("Username"),
|
||||||
|
Cell::from("Size"),
|
||||||
|
Cell::from("Progress"),
|
||||||
|
Cell::from("%"),
|
||||||
|
Cell::from("Speed"),
|
||||||
|
])
|
||||||
|
.style(header_style())
|
||||||
|
.height(1);
|
||||||
|
|
||||||
|
let rows: Vec<Row> = self
|
||||||
|
.downloads
|
||||||
|
.iter()
|
||||||
|
.map(|download| {
|
||||||
|
let status_icon = match download.status {
|
||||||
|
DownloadStatus::Queued => "⋯",
|
||||||
|
DownloadStatus::InProgress { .. } => "⧗",
|
||||||
|
DownloadStatus::Completed => "✓",
|
||||||
|
DownloadStatus::Failed => "✗",
|
||||||
|
DownloadStatus::TimedOut => "⏱",
|
||||||
|
};
|
||||||
|
|
||||||
|
let progress = if download.total_bytes > 0 {
|
||||||
|
download.bytes_downloaded as f64
|
||||||
|
/ download.total_bytes as f64
|
||||||
|
} else {
|
||||||
|
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 size_str = format_bytes_progress(
|
||||||
|
download.bytes_downloaded,
|
||||||
|
download.total_bytes,
|
||||||
|
);
|
||||||
|
|
||||||
|
let speed_str = match download.status {
|
||||||
|
DownloadStatus::InProgress { .. } => {
|
||||||
|
format_speed(download.speed_bytes_per_sec)
|
||||||
|
}
|
||||||
|
_ => "-".to_string(),
|
||||||
|
};
|
||||||
|
|
||||||
|
let cells = vec![
|
||||||
|
Cell::from(status_icon),
|
||||||
|
Cell::from(download.filename.clone()),
|
||||||
|
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::Failed | DownloadStatus::TimedOut => {
|
||||||
|
Style::default().fg(Color::Red)
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
Row::new(cells).style(style).height(1)
|
||||||
|
})
|
||||||
|
.collect();
|
||||||
|
|
||||||
|
let widths = [
|
||||||
|
Constraint::Length(3),
|
||||||
|
Constraint::Min(30),
|
||||||
|
Constraint::Length(15),
|
||||||
|
Constraint::Length(20),
|
||||||
|
Constraint::Length(22),
|
||||||
|
Constraint::Length(5),
|
||||||
|
Constraint::Length(12),
|
||||||
|
];
|
||||||
|
|
||||||
|
let table = Table::new(rows, widths)
|
||||||
|
.header(header)
|
||||||
|
.block(Block::default().borders(Borders::ALL).title("Files"))
|
||||||
|
.column_spacing(1)
|
||||||
|
.row_highlight_style(highlight_style())
|
||||||
|
.highlight_symbol("> ");
|
||||||
|
|
||||||
|
StatefulWidget::render(
|
||||||
|
table,
|
||||||
|
area,
|
||||||
|
frame.buffer_mut(),
|
||||||
|
&mut self.list_state,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
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_widget = Paragraph::new(controls_text)
|
||||||
|
.block(Block::default().borders(Borders::ALL).title("Controls"))
|
||||||
|
.style(Style::default().fg(Color::DarkGray));
|
||||||
|
|
||||||
|
frame.render_widget(controls_widget, area);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn show_multi_download_progress(
|
||||||
|
downloads: Vec<FileDownloadState>,
|
||||||
|
receivers: Vec<Receiver<DownloadStatus>>,
|
||||||
|
max_concurrent: usize,
|
||||||
|
) -> Result<()> {
|
||||||
|
soulseek_rs::utils::logger::enable_buffering();
|
||||||
|
|
||||||
|
let terminal = ratatui::init();
|
||||||
|
let mut progress =
|
||||||
|
MultiDownloadProgress::new(downloads, receivers, max_concurrent);
|
||||||
|
let result = progress.run(terminal);
|
||||||
|
ratatui::restore();
|
||||||
|
|
||||||
|
soulseek_rs::utils::logger::flush_buffered_logs();
|
||||||
|
|
||||||
|
result
|
||||||
|
}
|
||||||
@@ -0,0 +1,49 @@
|
|||||||
|
// Reusable styles and colors for consistent UI appearance
|
||||||
|
#![allow(dead_code)]
|
||||||
|
|
||||||
|
use ratatui::style::{Color, Modifier, Style};
|
||||||
|
|
||||||
|
// 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;
|
||||||
|
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;
|
||||||
|
|
||||||
|
// Reusable styles
|
||||||
|
pub fn header_style() -> Style {
|
||||||
|
Style::default()
|
||||||
|
.fg(COLOR_PRIMARY)
|
||||||
|
.bg(COLOR_BACKGROUND)
|
||||||
|
.add_modifier(Modifier::BOLD)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn highlight_style() -> Style {
|
||||||
|
Style::default()
|
||||||
|
.bg(COLOR_HIGHLIGHT_BG)
|
||||||
|
.fg(COLOR_HIGHLIGHT_FG)
|
||||||
|
.add_modifier(Modifier::BOLD)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn success_style() -> Style {
|
||||||
|
Style::default().fg(COLOR_SUCCESS)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn warning_style() -> Style {
|
||||||
|
Style::default().fg(COLOR_WARNING)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn error_style() -> Style {
|
||||||
|
Style::default().fg(COLOR_ERROR)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn inactive_style() -> Style {
|
||||||
|
Style::default().fg(COLOR_INACTIVE)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn primary_style() -> Style {
|
||||||
|
Style::default().fg(COLOR_PRIMARY)
|
||||||
|
}
|
||||||
@@ -0,0 +1,19 @@
|
|||||||
|
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 {
|
||||||
|
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)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn format_speed(speed_bytes_per_sec: f64) -> String {
|
||||||
|
let mb = speed_bytes_per_sec / 1_048_576.0;
|
||||||
|
format!("{:.1} MB/s", mb)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn get_bitrate(attribs: &std::collections::HashMap<u32, u32>) -> Option<u32> {
|
||||||
|
attribs.get(&0).copied()
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user