feat: main TUI
This commit is contained in:
@@ -373,7 +373,7 @@ impl Client {
|
|||||||
&& (filename.is_none_or(|f| download.filename == *f))
|
&& (filename.is_none_or(|f| download.filename == *f))
|
||||||
})
|
})
|
||||||
.map(|(token, download)| {
|
.map(|(token, download)| {
|
||||||
download.sender.send(DownloadStatus::Failed).unwrap();
|
let _ = download.sender.send(DownloadStatus::Failed);
|
||||||
*token
|
*token
|
||||||
})
|
})
|
||||||
.collect();
|
.collect();
|
||||||
@@ -494,13 +494,13 @@ impl Client {
|
|||||||
None
|
None
|
||||||
) {
|
) {
|
||||||
Ok((download, filename)) => {
|
Ok((download, filename)) => {
|
||||||
download.sender.send(DownloadStatus::Completed).unwrap();
|
let _ = download.sender.send(DownloadStatus::Completed);
|
||||||
info!("Successfully downloaded {} bytes to {}", download.size, filename);
|
info!("Successfully downloaded {} bytes to {}", download.size, filename);
|
||||||
}
|
}
|
||||||
Err(e) => {
|
Err(e) => {
|
||||||
download.sender.send(DownloadStatus::Failed).unwrap();
|
let _ = download.sender.send(DownloadStatus::Failed);
|
||||||
error!(
|
error!(
|
||||||
"Failed to download file '{}' from {}:{} (token: {}) - Error: {}",
|
"Failed to download file '{}' from {}:{} (token: {}) - Error: {}",
|
||||||
filename, peer.host, peer.port, download.token, e
|
filename, peer.host, peer.port, download.token, e
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -744,10 +744,7 @@ impl Client {
|
|||||||
filename,
|
filename,
|
||||||
download.size
|
download.size
|
||||||
);
|
);
|
||||||
download
|
let _ = download.sender.send(DownloadStatus::Completed);
|
||||||
.sender
|
|
||||||
.send(DownloadStatus::Completed)
|
|
||||||
.unwrap();
|
|
||||||
}
|
}
|
||||||
Err(e) => {
|
Err(e) => {
|
||||||
trace!("[client] failed to download: {}", e);
|
trace!("[client] failed to download: {}", e);
|
||||||
|
|||||||
@@ -157,7 +157,7 @@ fn handle_file_connection(
|
|||||||
Some(stream),
|
Some(stream),
|
||||||
) {
|
) {
|
||||||
Ok((download, filename)) => {
|
Ok((download, filename)) => {
|
||||||
download.sender.send(DownloadStatus::Completed).unwrap();
|
let _ = download.sender.send(DownloadStatus::Completed);
|
||||||
info!(
|
info!(
|
||||||
"Successfully downloaded {} bytes to {}",
|
"Successfully downloaded {} bytes to {}",
|
||||||
download.size, filename
|
download.size, filename
|
||||||
|
|||||||
+16
-3
@@ -6,8 +6,7 @@ use clap::{Parser, Subcommand};
|
|||||||
author,
|
author,
|
||||||
version,
|
version,
|
||||||
about = "Soulseek client in Rust 🦀",
|
about = "Soulseek client in Rust 🦀",
|
||||||
long_about = None,
|
long_about = None
|
||||||
arg_required_else_help = true
|
|
||||||
)]
|
)]
|
||||||
pub struct Cli {
|
pub struct Cli {
|
||||||
#[arg(short, long, env = "SOULSEEK_USERNAME")]
|
#[arg(short, long, env = "SOULSEEK_USERNAME")]
|
||||||
@@ -34,7 +33,21 @@ pub struct Cli {
|
|||||||
pub verbose: u8,
|
pub verbose: u8,
|
||||||
|
|
||||||
#[command(subcommand)]
|
#[command(subcommand)]
|
||||||
pub command: Commands,
|
pub command: Option<Commands>,
|
||||||
|
|
||||||
|
#[arg(short, long, default_value = "~/Downloads")]
|
||||||
|
pub download_dir: String,
|
||||||
|
|
||||||
|
#[arg(
|
||||||
|
short = 'c',
|
||||||
|
long,
|
||||||
|
env = "MAX_CONCURRENT_DOWNLOADS",
|
||||||
|
default_value = "5"
|
||||||
|
)]
|
||||||
|
pub max_concurrent_downloads: usize,
|
||||||
|
|
||||||
|
#[arg(long, default_value = "10")]
|
||||||
|
pub search_timeout: u64,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Subcommand, Debug)]
|
#[derive(Subcommand, Debug)]
|
||||||
|
|||||||
+49
-3
@@ -13,7 +13,7 @@ use std::{
|
|||||||
sync::{atomic::AtomicBool, Arc},
|
sync::{atomic::AtomicBool, Arc},
|
||||||
time::Duration,
|
time::Duration,
|
||||||
};
|
};
|
||||||
use ui::{show_multi_download_progress, FileSelector};
|
use ui::{launch_main_tui, show_multi_download_progress, FileSelector};
|
||||||
|
|
||||||
fn main() -> Result<()> {
|
fn main() -> Result<()> {
|
||||||
color_eyre::install()?;
|
color_eyre::install()?;
|
||||||
@@ -38,12 +38,12 @@ fn main() -> Result<()> {
|
|||||||
let (server_host, server_port) = parse_server_address(&cli.server)?;
|
let (server_host, server_port) = parse_server_address(&cli.server)?;
|
||||||
|
|
||||||
match cli.command {
|
match cli.command {
|
||||||
Commands::Search {
|
Some(Commands::Search {
|
||||||
query,
|
query,
|
||||||
timeout,
|
timeout,
|
||||||
download_dir,
|
download_dir,
|
||||||
max_concurrent_downloads,
|
max_concurrent_downloads,
|
||||||
} => {
|
}) => {
|
||||||
let config = SearchConfig {
|
let config = SearchConfig {
|
||||||
username,
|
username,
|
||||||
password,
|
password,
|
||||||
@@ -59,6 +59,52 @@ fn main() -> Result<()> {
|
|||||||
};
|
};
|
||||||
search_and_download(config)
|
search_and_download(config)
|
||||||
}
|
}
|
||||||
|
None => {
|
||||||
|
// Launch main TUI
|
||||||
|
// Enable logger buffering BEFORE connection to prevent log artifacts
|
||||||
|
soulseek_rs::utils::logger::enable_buffering();
|
||||||
|
|
||||||
|
let settings = ClientSettings {
|
||||||
|
username: username.clone(),
|
||||||
|
password: password.clone(),
|
||||||
|
server_address: PeerAddress::new(
|
||||||
|
server_host.clone(),
|
||||||
|
server_port,
|
||||||
|
),
|
||||||
|
enable_listen: !cli.disable_listener,
|
||||||
|
listen_port: cli.listener_port,
|
||||||
|
};
|
||||||
|
|
||||||
|
let mut client = Client::with_settings(settings);
|
||||||
|
client.connect();
|
||||||
|
client.login().map_err(|e| {
|
||||||
|
color_eyre::eyre::eyre!("Failed to login: {}", e)
|
||||||
|
})?;
|
||||||
|
|
||||||
|
let client = Arc::new(client);
|
||||||
|
|
||||||
|
// Clear screen and enable mouse capture before initializing TUI
|
||||||
|
use ratatui::crossterm::{
|
||||||
|
event::EnableMouseCapture,
|
||||||
|
execute,
|
||||||
|
terminal::{Clear, ClearType},
|
||||||
|
};
|
||||||
|
let _ = execute!(
|
||||||
|
std::io::stdout(),
|
||||||
|
Clear(ClearType::All),
|
||||||
|
EnableMouseCapture
|
||||||
|
);
|
||||||
|
|
||||||
|
let terminal = ratatui::init();
|
||||||
|
|
||||||
|
launch_main_tui(
|
||||||
|
terminal,
|
||||||
|
client,
|
||||||
|
cli.download_dir,
|
||||||
|
cli.max_concurrent_downloads,
|
||||||
|
Duration::from_secs(cli.search_timeout),
|
||||||
|
)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,127 @@
|
|||||||
|
use crate::models::{FileDisplayData, FileDownloadState};
|
||||||
|
use ratatui::{layout::Rect, widgets::TableState};
|
||||||
|
use soulseek_rs::DownloadStatus;
|
||||||
|
use std::sync::atomic::AtomicBool;
|
||||||
|
use std::sync::{mpsc::Receiver, Arc};
|
||||||
|
use std::time::Instant;
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, PartialEq)]
|
||||||
|
pub enum SearchStatus {
|
||||||
|
Active,
|
||||||
|
Completed,
|
||||||
|
Failed(String),
|
||||||
|
}
|
||||||
|
|
||||||
|
pub struct SearchEntry {
|
||||||
|
pub query: String,
|
||||||
|
pub status: SearchStatus,
|
||||||
|
pub results: Vec<FileDisplayData>,
|
||||||
|
pub start_time: Instant,
|
||||||
|
#[allow(dead_code)]
|
||||||
|
pub cancel_flag: Arc<AtomicBool>,
|
||||||
|
}
|
||||||
|
|
||||||
|
pub struct DownloadEntry {
|
||||||
|
pub state: FileDownloadState,
|
||||||
|
pub receiver: Option<Receiver<DownloadStatus>>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Copy, PartialEq)]
|
||||||
|
pub enum FocusedPane {
|
||||||
|
Searches,
|
||||||
|
Results,
|
||||||
|
Downloads,
|
||||||
|
}
|
||||||
|
|
||||||
|
pub struct AppState {
|
||||||
|
// Searches
|
||||||
|
pub searches: Vec<SearchEntry>,
|
||||||
|
pub searches_table_state: TableState,
|
||||||
|
pub selected_search_index: Option<usize>,
|
||||||
|
|
||||||
|
// Results
|
||||||
|
pub results_items: Vec<FileDisplayData>,
|
||||||
|
pub results_filtered_items: Vec<FileDisplayData>,
|
||||||
|
pub results_filtered_indices: Vec<usize>,
|
||||||
|
pub results_table_state: TableState,
|
||||||
|
pub results_selected_indices: std::collections::HashSet<usize>,
|
||||||
|
pub results_filter_query: String,
|
||||||
|
pub results_is_filtering: bool,
|
||||||
|
|
||||||
|
// Downloads
|
||||||
|
pub downloads: Vec<DownloadEntry>,
|
||||||
|
pub downloads_table_state: TableState,
|
||||||
|
pub downloads_receiver_channel:
|
||||||
|
Option<Receiver<(usize, Receiver<DownloadStatus>)>>,
|
||||||
|
pub active_downloads_count: usize,
|
||||||
|
|
||||||
|
// UI State
|
||||||
|
pub focused_pane: FocusedPane,
|
||||||
|
pub should_exit: bool,
|
||||||
|
pub command_bar_active: bool,
|
||||||
|
pub command_bar_input: String,
|
||||||
|
|
||||||
|
// Pane areas for mouse interaction
|
||||||
|
pub searches_pane_area: Option<Rect>,
|
||||||
|
pub results_pane_area: Option<Rect>,
|
||||||
|
pub downloads_pane_area: Option<Rect>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl AppState {
|
||||||
|
pub fn new() -> Self {
|
||||||
|
let mut searches_table_state = TableState::default();
|
||||||
|
searches_table_state.select(Some(0));
|
||||||
|
|
||||||
|
let mut results_table_state = TableState::default();
|
||||||
|
results_table_state.select(Some(0));
|
||||||
|
|
||||||
|
let mut downloads_table_state = TableState::default();
|
||||||
|
downloads_table_state.select(Some(0));
|
||||||
|
|
||||||
|
Self {
|
||||||
|
searches: Vec::new(),
|
||||||
|
searches_table_state,
|
||||||
|
selected_search_index: None,
|
||||||
|
|
||||||
|
results_items: Vec::new(),
|
||||||
|
results_filtered_items: Vec::new(),
|
||||||
|
results_filtered_indices: Vec::new(),
|
||||||
|
results_table_state,
|
||||||
|
results_selected_indices: std::collections::HashSet::new(),
|
||||||
|
results_filter_query: String::new(),
|
||||||
|
results_is_filtering: false,
|
||||||
|
|
||||||
|
downloads: Vec::new(),
|
||||||
|
downloads_table_state,
|
||||||
|
downloads_receiver_channel: None,
|
||||||
|
active_downloads_count: 0,
|
||||||
|
|
||||||
|
focused_pane: FocusedPane::Searches,
|
||||||
|
should_exit: false,
|
||||||
|
command_bar_active: false,
|
||||||
|
command_bar_input: String::new(),
|
||||||
|
|
||||||
|
searches_pane_area: None,
|
||||||
|
results_pane_area: None,
|
||||||
|
downloads_pane_area: None,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[allow(dead_code)]
|
||||||
|
pub fn get_selected_search(&self) -> Option<&SearchEntry> {
|
||||||
|
self.selected_search_index
|
||||||
|
.and_then(|idx| self.searches.get(idx))
|
||||||
|
}
|
||||||
|
|
||||||
|
#[allow(dead_code)]
|
||||||
|
pub fn get_selected_search_mut(&mut self) -> Option<&mut SearchEntry> {
|
||||||
|
self.selected_search_index
|
||||||
|
.and_then(|idx| self.searches.get_mut(idx))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Default for AppState {
|
||||||
|
fn default() -> Self {
|
||||||
|
Self::new()
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,5 +1,9 @@
|
|||||||
|
mod app_state;
|
||||||
mod download_state;
|
mod download_state;
|
||||||
mod file_display_data;
|
mod file_display_data;
|
||||||
|
|
||||||
|
pub use app_state::{
|
||||||
|
AppState, DownloadEntry, FocusedPane, SearchEntry, SearchStatus,
|
||||||
|
};
|
||||||
pub use download_state::FileDownloadState;
|
pub use download_state::FileDownloadState;
|
||||||
pub use file_display_data::FileDisplayData;
|
pub use file_display_data::FileDisplayData;
|
||||||
|
|||||||
+132
-118
@@ -24,6 +24,8 @@ use std::{
|
|||||||
time::Duration,
|
time::Duration,
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const VERSION: &str = env!("CARGO_PKG_VERSION");
|
||||||
|
|
||||||
pub struct MultiDownloadProgress {
|
pub struct MultiDownloadProgress {
|
||||||
downloads: Vec<FileDownloadState>,
|
downloads: Vec<FileDownloadState>,
|
||||||
receivers: Vec<Option<Receiver<DownloadStatus>>>,
|
receivers: Vec<Option<Receiver<DownloadStatus>>>,
|
||||||
@@ -199,124 +201,7 @@ impl MultiDownloadProgress {
|
|||||||
}
|
}
|
||||||
|
|
||||||
fn render_stats(&self, frame: &mut Frame, area: ratatui::layout::Rect) {
|
fn render_stats(&self, frame: &mut Frame, area: ratatui::layout::Rect) {
|
||||||
let completed = self
|
render_download_stats(frame, area, &self.downloads, self.active_count);
|
||||||
.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 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 * 100.0).round() / 100.0;
|
|
||||||
|
|
||||||
let block = Block::default()
|
|
||||||
.borders(Borders::ALL)
|
|
||||||
.border_type(border_type(false))
|
|
||||||
.title("Status");
|
|
||||||
|
|
||||||
let inner_area = block.inner(area);
|
|
||||||
frame.render_widget(block, 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(
|
fn render_downloads_list(
|
||||||
@@ -442,6 +327,135 @@ impl MultiDownloadProgress {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Renders download statistics in a reusable way
|
||||||
|
pub fn render_download_stats(
|
||||||
|
frame: &mut Frame,
|
||||||
|
area: ratatui::layout::Rect,
|
||||||
|
downloads: &[FileDownloadState],
|
||||||
|
active_count: usize,
|
||||||
|
) {
|
||||||
|
let completed = downloads
|
||||||
|
.iter()
|
||||||
|
.filter(|d| matches!(d.status, DownloadStatus::Completed))
|
||||||
|
.count();
|
||||||
|
let failed = downloads
|
||||||
|
.iter()
|
||||||
|
.filter(|d| {
|
||||||
|
matches!(
|
||||||
|
d.status,
|
||||||
|
DownloadStatus::Failed | DownloadStatus::TimedOut
|
||||||
|
)
|
||||||
|
})
|
||||||
|
.count();
|
||||||
|
let queued = downloads
|
||||||
|
.iter()
|
||||||
|
.filter(|d| matches!(d.status, DownloadStatus::Queued))
|
||||||
|
.count();
|
||||||
|
|
||||||
|
let total_downloaded: u64 =
|
||||||
|
downloads.iter().map(|d| d.bytes_downloaded).sum();
|
||||||
|
let total_size: u64 = 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 progress_ratio = if total_size > 0 {
|
||||||
|
total_downloaded as f64 / total_size as f64
|
||||||
|
} else {
|
||||||
|
0.0
|
||||||
|
};
|
||||||
|
let total_speed: f64 = 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 * 100.0).round() / 100.0;
|
||||||
|
|
||||||
|
let block = Block::default()
|
||||||
|
.borders(Borders::ALL)
|
||||||
|
.border_type(border_type(false))
|
||||||
|
.title("Status");
|
||||||
|
|
||||||
|
let inner_area = block.inner(area);
|
||||||
|
frame.render_widget(block, 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("soulseek-rs 🦀 "),
|
||||||
|
Span::styled(
|
||||||
|
format!("v{} ", VERSION),
|
||||||
|
Style::default()
|
||||||
|
.fg(COLOR_PRIMARY)
|
||||||
|
.add_modifier(Modifier::BOLD),
|
||||||
|
),
|
||||||
|
Span::raw(" Downloads: "),
|
||||||
|
Span::styled(
|
||||||
|
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]);
|
||||||
|
}
|
||||||
|
|
||||||
pub fn show_multi_download_progress(
|
pub fn show_multi_download_progress(
|
||||||
terminal: DefaultTerminal,
|
terminal: DefaultTerminal,
|
||||||
client: Arc<Client>,
|
client: Arc<Client>,
|
||||||
|
|||||||
@@ -0,0 +1,893 @@
|
|||||||
|
use crate::models::{
|
||||||
|
AppState, DownloadEntry, FileDisplayData, FileDownloadState, FocusedPane,
|
||||||
|
SearchEntry, SearchStatus,
|
||||||
|
};
|
||||||
|
use crate::ui::panes::{
|
||||||
|
render_downloads_pane, render_results_pane, render_searches_pane,
|
||||||
|
ResultsPaneParams,
|
||||||
|
};
|
||||||
|
use crate::ui::{
|
||||||
|
border_style, border_type, format_shortcuts_styled, render_download_stats,
|
||||||
|
};
|
||||||
|
use color_eyre::Result;
|
||||||
|
use ratatui::{
|
||||||
|
crossterm::event::{
|
||||||
|
self, poll, Event, KeyCode, KeyEvent, KeyEventKind, MouseButton,
|
||||||
|
MouseEvent, MouseEventKind,
|
||||||
|
},
|
||||||
|
layout::{Constraint, Layout},
|
||||||
|
widgets::{Block, Borders, Paragraph},
|
||||||
|
DefaultTerminal, Frame,
|
||||||
|
};
|
||||||
|
use soulseek_rs::{Client, DownloadStatus};
|
||||||
|
use std::{
|
||||||
|
sync::{
|
||||||
|
atomic::AtomicBool,
|
||||||
|
mpsc::{self},
|
||||||
|
Arc,
|
||||||
|
},
|
||||||
|
thread,
|
||||||
|
time::{Duration, Instant},
|
||||||
|
};
|
||||||
|
|
||||||
|
pub struct MainTui {
|
||||||
|
client: Arc<Client>,
|
||||||
|
state: AppState,
|
||||||
|
download_dir: String,
|
||||||
|
#[allow(dead_code)]
|
||||||
|
max_concurrent_downloads: usize,
|
||||||
|
search_timeout: Duration,
|
||||||
|
#[allow(dead_code)]
|
||||||
|
spinner_state: usize,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl MainTui {
|
||||||
|
pub fn new(
|
||||||
|
client: Arc<Client>,
|
||||||
|
download_dir: String,
|
||||||
|
max_concurrent_downloads: usize,
|
||||||
|
search_timeout: Duration,
|
||||||
|
) -> Self {
|
||||||
|
Self {
|
||||||
|
client,
|
||||||
|
state: AppState::new(),
|
||||||
|
download_dir,
|
||||||
|
max_concurrent_downloads,
|
||||||
|
search_timeout,
|
||||||
|
spinner_state: 0,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn run(mut self, mut terminal: DefaultTerminal) -> Result<()> {
|
||||||
|
while !self.state.should_exit {
|
||||||
|
terminal.draw(|frame| self.render(frame))?;
|
||||||
|
|
||||||
|
// Poll for search results updates
|
||||||
|
self.update_search_results();
|
||||||
|
|
||||||
|
// Poll for download updates
|
||||||
|
self.update_downloads();
|
||||||
|
|
||||||
|
// Update spinner
|
||||||
|
self.spinner_state = (self.spinner_state + 1) % 10;
|
||||||
|
|
||||||
|
// Poll for input events
|
||||||
|
if poll(Duration::from_millis(100))? {
|
||||||
|
match event::read()? {
|
||||||
|
Event::Key(key) if key.kind == KeyEventKind::Press => {
|
||||||
|
self.handle_key_event(key)?;
|
||||||
|
}
|
||||||
|
Event::Mouse(mouse) => {
|
||||||
|
self.handle_mouse_event(mouse)?;
|
||||||
|
}
|
||||||
|
_ => {}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Disable mouse capture before exiting
|
||||||
|
use ratatui::crossterm::{event::DisableMouseCapture, execute};
|
||||||
|
let _ = execute!(std::io::stdout(), DisableMouseCapture);
|
||||||
|
|
||||||
|
soulseek_rs::utils::logger::disable_buffering();
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn render(&mut self, frame: &mut Frame) {
|
||||||
|
// Create layout based on whether command bar is active
|
||||||
|
let main_chunks = if self.state.command_bar_active {
|
||||||
|
Layout::vertical([
|
||||||
|
Constraint::Length(3), // Status bar
|
||||||
|
Constraint::Fill(1), // Main content
|
||||||
|
Constraint::Length(3), // Command bar
|
||||||
|
Constraint::Length(3), // Shortcuts
|
||||||
|
])
|
||||||
|
.split(frame.area())
|
||||||
|
} else {
|
||||||
|
Layout::vertical([
|
||||||
|
Constraint::Length(3), // Status bar
|
||||||
|
Constraint::Fill(1), // Main content
|
||||||
|
Constraint::Length(3), // Shortcuts
|
||||||
|
])
|
||||||
|
.split(frame.area())
|
||||||
|
};
|
||||||
|
|
||||||
|
// Render status bar
|
||||||
|
let download_states: Vec<FileDownloadState> = self
|
||||||
|
.state
|
||||||
|
.downloads
|
||||||
|
.iter()
|
||||||
|
.map(|d| d.state.clone())
|
||||||
|
.collect();
|
||||||
|
render_download_stats(
|
||||||
|
frame,
|
||||||
|
main_chunks[0],
|
||||||
|
&download_states,
|
||||||
|
self.state.active_downloads_count,
|
||||||
|
);
|
||||||
|
|
||||||
|
// Split main content area
|
||||||
|
let content_chunks = Layout::horizontal([
|
||||||
|
Constraint::Percentage(30), // Searches pane
|
||||||
|
Constraint::Percentage(70), // Results + Downloads
|
||||||
|
])
|
||||||
|
.split(main_chunks[1]);
|
||||||
|
|
||||||
|
// Store searches pane area
|
||||||
|
self.state.searches_pane_area = Some(content_chunks[0]);
|
||||||
|
|
||||||
|
// Render Searches pane (left)
|
||||||
|
render_searches_pane(
|
||||||
|
frame,
|
||||||
|
content_chunks[0],
|
||||||
|
&self.state.searches,
|
||||||
|
&mut self.state.searches_table_state,
|
||||||
|
self.state.focused_pane == FocusedPane::Searches,
|
||||||
|
);
|
||||||
|
|
||||||
|
// Split right side into Results (top) and Downloads (bottom)
|
||||||
|
let right_chunks = Layout::vertical([
|
||||||
|
Constraint::Percentage(60), // Results
|
||||||
|
Constraint::Percentage(40), // Downloads
|
||||||
|
])
|
||||||
|
.split(content_chunks[1]);
|
||||||
|
|
||||||
|
// Store results and downloads pane areas
|
||||||
|
self.state.results_pane_area = Some(right_chunks[0]);
|
||||||
|
self.state.downloads_pane_area = Some(right_chunks[1]);
|
||||||
|
|
||||||
|
// Render Results pane
|
||||||
|
let results_items = if self.state.results_filter_query.is_empty() {
|
||||||
|
&self.state.results_items
|
||||||
|
} else {
|
||||||
|
&self.state.results_filtered_items
|
||||||
|
};
|
||||||
|
|
||||||
|
let active_search_query = self
|
||||||
|
.state
|
||||||
|
.selected_search_index
|
||||||
|
.and_then(|idx| self.state.searches.get(idx))
|
||||||
|
.map(|search| search.query.as_str());
|
||||||
|
|
||||||
|
render_results_pane(
|
||||||
|
frame,
|
||||||
|
right_chunks[0],
|
||||||
|
ResultsPaneParams {
|
||||||
|
items: results_items,
|
||||||
|
table_state: &mut self.state.results_table_state,
|
||||||
|
selected_indices: &self.state.results_selected_indices,
|
||||||
|
filter_query: &self.state.results_filter_query,
|
||||||
|
is_filtering: self.state.results_is_filtering,
|
||||||
|
focused: self.state.focused_pane == FocusedPane::Results,
|
||||||
|
active_search_query,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
// Render Downloads pane
|
||||||
|
let download_states: Vec<FileDownloadState> = self
|
||||||
|
.state
|
||||||
|
.downloads
|
||||||
|
.iter()
|
||||||
|
.map(|d| d.state.clone())
|
||||||
|
.collect();
|
||||||
|
render_downloads_pane(
|
||||||
|
frame,
|
||||||
|
right_chunks[1],
|
||||||
|
&download_states,
|
||||||
|
&mut self.state.downloads_table_state,
|
||||||
|
self.state.focused_pane == FocusedPane::Downloads,
|
||||||
|
);
|
||||||
|
|
||||||
|
// Render command bar if active (vim-style, above shortcuts)
|
||||||
|
if self.state.command_bar_active {
|
||||||
|
self.render_command_bar(frame, main_chunks[2]);
|
||||||
|
self.render_shortcuts(frame, main_chunks[3]);
|
||||||
|
} else {
|
||||||
|
self.render_shortcuts(frame, main_chunks[2]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn render_shortcuts(&self, frame: &mut Frame, area: ratatui::layout::Rect) {
|
||||||
|
let shortcuts = match self.state.focused_pane {
|
||||||
|
FocusedPane::Searches => vec![
|
||||||
|
("s", "search"),
|
||||||
|
("1-3", "focus pane"),
|
||||||
|
("↑↓", "navigate"),
|
||||||
|
("Enter", "view results"),
|
||||||
|
("d", "remove search"),
|
||||||
|
("C", "clear all"),
|
||||||
|
("q", "quit"),
|
||||||
|
],
|
||||||
|
FocusedPane::Results if self.state.results_is_filtering => vec![
|
||||||
|
("Type", "filter"),
|
||||||
|
("Esc", "clear filter"),
|
||||||
|
("1-3", "focus pane"),
|
||||||
|
("q", "quit"),
|
||||||
|
],
|
||||||
|
FocusedPane::Results => vec![
|
||||||
|
("Space", "select"),
|
||||||
|
("/", "filter"),
|
||||||
|
("a/A", "select all/none"),
|
||||||
|
("Enter", "download"),
|
||||||
|
("1-3", "focus pane"),
|
||||||
|
("↑↓", "navigate"),
|
||||||
|
("q", "quit"),
|
||||||
|
],
|
||||||
|
FocusedPane::Downloads => {
|
||||||
|
vec![("1-3", "focus pane"), ("↑↓", "navigate"), ("q", "quit")]
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
let shortcuts_line = format_shortcuts_styled(&shortcuts);
|
||||||
|
let shortcuts_widget = Paragraph::new(shortcuts_line).block(
|
||||||
|
Block::default()
|
||||||
|
.borders(Borders::ALL)
|
||||||
|
.border_type(border_type(false))
|
||||||
|
.title("Shortcuts"),
|
||||||
|
);
|
||||||
|
|
||||||
|
frame.render_widget(shortcuts_widget, area);
|
||||||
|
}
|
||||||
|
|
||||||
|
fn render_command_bar(
|
||||||
|
&self,
|
||||||
|
frame: &mut Frame,
|
||||||
|
area: ratatui::layout::Rect,
|
||||||
|
) {
|
||||||
|
// Vim-style command bar with ":" prefix
|
||||||
|
let command_text = format!("search: {}", self.state.command_bar_input);
|
||||||
|
|
||||||
|
let paragraph = Paragraph::new(command_text).block(
|
||||||
|
Block::default()
|
||||||
|
.borders(Borders::ALL)
|
||||||
|
.border_style(border_style(true))
|
||||||
|
.border_type(border_type(true)),
|
||||||
|
);
|
||||||
|
|
||||||
|
frame.render_widget(paragraph, area);
|
||||||
|
}
|
||||||
|
|
||||||
|
fn handle_key_event(&mut self, key: KeyEvent) -> Result<()> {
|
||||||
|
// Command bar takes priority
|
||||||
|
if self.state.command_bar_active {
|
||||||
|
return self.handle_command_bar_input(key);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Filter mode in Results pane
|
||||||
|
if self.state.results_is_filtering
|
||||||
|
&& self.state.focused_pane == FocusedPane::Results
|
||||||
|
{
|
||||||
|
return self.handle_filter_input(key);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Global shortcuts
|
||||||
|
match key.code {
|
||||||
|
KeyCode::Char('q') => {
|
||||||
|
self.state.should_exit = true;
|
||||||
|
return Ok(());
|
||||||
|
}
|
||||||
|
KeyCode::Char('1') => {
|
||||||
|
self.state.focused_pane = FocusedPane::Searches;
|
||||||
|
return Ok(());
|
||||||
|
}
|
||||||
|
KeyCode::Char('2') => {
|
||||||
|
self.state.focused_pane = FocusedPane::Results;
|
||||||
|
return Ok(());
|
||||||
|
}
|
||||||
|
KeyCode::Char('3') => {
|
||||||
|
self.state.focused_pane = FocusedPane::Downloads;
|
||||||
|
return Ok(());
|
||||||
|
}
|
||||||
|
KeyCode::Char('s') => {
|
||||||
|
self.state.command_bar_active = true;
|
||||||
|
self.state.command_bar_input.clear();
|
||||||
|
return Ok(());
|
||||||
|
}
|
||||||
|
_ => {}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Pane-specific shortcuts
|
||||||
|
match self.state.focused_pane {
|
||||||
|
FocusedPane::Searches => self.handle_searches_input(key),
|
||||||
|
FocusedPane::Results => self.handle_results_input(key),
|
||||||
|
FocusedPane::Downloads => self.handle_downloads_input(key),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn handle_command_bar_input(&mut self, key: KeyEvent) -> Result<()> {
|
||||||
|
match key.code {
|
||||||
|
KeyCode::Enter => {
|
||||||
|
let query = self.state.command_bar_input.trim().to_string();
|
||||||
|
if !query.is_empty() {
|
||||||
|
self.start_search(query);
|
||||||
|
}
|
||||||
|
self.state.command_bar_active = false;
|
||||||
|
self.state.command_bar_input.clear();
|
||||||
|
}
|
||||||
|
KeyCode::Esc => {
|
||||||
|
self.state.command_bar_active = false;
|
||||||
|
self.state.command_bar_input.clear();
|
||||||
|
}
|
||||||
|
KeyCode::Char(c) => {
|
||||||
|
self.state.command_bar_input.push(c);
|
||||||
|
}
|
||||||
|
KeyCode::Backspace => {
|
||||||
|
self.state.command_bar_input.pop();
|
||||||
|
}
|
||||||
|
_ => {}
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn handle_filter_input(&mut self, key: KeyEvent) -> Result<()> {
|
||||||
|
match key.code {
|
||||||
|
KeyCode::Esc => {
|
||||||
|
self.state.results_is_filtering = false;
|
||||||
|
self.state.results_filter_query.clear();
|
||||||
|
self.state.results_filtered_items =
|
||||||
|
self.state.results_items.clone();
|
||||||
|
self.state.results_filtered_indices =
|
||||||
|
(0..self.state.results_items.len()).collect();
|
||||||
|
}
|
||||||
|
KeyCode::Char(c) => {
|
||||||
|
self.state.results_filter_query.push(c);
|
||||||
|
self.apply_filter();
|
||||||
|
}
|
||||||
|
KeyCode::Backspace => {
|
||||||
|
self.state.results_filter_query.pop();
|
||||||
|
self.apply_filter();
|
||||||
|
}
|
||||||
|
_ => {}
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn handle_searches_input(&mut self, key: KeyEvent) -> Result<()> {
|
||||||
|
match key.code {
|
||||||
|
KeyCode::Up | KeyCode::Char('k') => {
|
||||||
|
if !self.state.searches.is_empty() {
|
||||||
|
let current =
|
||||||
|
self.state.searches_table_state.selected().unwrap_or(0);
|
||||||
|
let new = if current == 0 {
|
||||||
|
self.state.searches.len() - 1
|
||||||
|
} else {
|
||||||
|
current - 1
|
||||||
|
};
|
||||||
|
self.state.searches_table_state.select(Some(new));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
KeyCode::Down | KeyCode::Char('j') => {
|
||||||
|
if !self.state.searches.is_empty() {
|
||||||
|
let current =
|
||||||
|
self.state.searches_table_state.selected().unwrap_or(0);
|
||||||
|
let new = (current + 1) % self.state.searches.len();
|
||||||
|
self.state.searches_table_state.select(Some(new));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
KeyCode::Enter => {
|
||||||
|
if let Some(selected) =
|
||||||
|
self.state.searches_table_state.selected()
|
||||||
|
{
|
||||||
|
self.state.selected_search_index = Some(selected);
|
||||||
|
if let Some(search) = self.state.searches.get(selected) {
|
||||||
|
self.state.results_items = search.results.clone();
|
||||||
|
self.state.results_filtered_items =
|
||||||
|
search.results.clone();
|
||||||
|
self.state.results_filtered_indices =
|
||||||
|
(0..search.results.len()).collect();
|
||||||
|
self.state.results_selected_indices.clear();
|
||||||
|
self.state.results_table_state.select(Some(0));
|
||||||
|
self.state.focused_pane = FocusedPane::Results;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
KeyCode::Char('d') => {
|
||||||
|
if let Some(selected) =
|
||||||
|
self.state.searches_table_state.selected()
|
||||||
|
{
|
||||||
|
self.remove_search_at_index(selected);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
KeyCode::Char('C') => {
|
||||||
|
self.clear_all_searches();
|
||||||
|
}
|
||||||
|
_ => {}
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn handle_results_input(&mut self, key: KeyEvent) -> Result<()> {
|
||||||
|
let items_count = if self.state.results_filter_query.is_empty() {
|
||||||
|
self.state.results_items.len()
|
||||||
|
} else {
|
||||||
|
self.state.results_filtered_items.len()
|
||||||
|
};
|
||||||
|
|
||||||
|
match key.code {
|
||||||
|
KeyCode::Up | KeyCode::Char('k') => {
|
||||||
|
if items_count > 0 {
|
||||||
|
let current =
|
||||||
|
self.state.results_table_state.selected().unwrap_or(0);
|
||||||
|
let new = if current == 0 {
|
||||||
|
items_count - 1
|
||||||
|
} else {
|
||||||
|
current - 1
|
||||||
|
};
|
||||||
|
self.state.results_table_state.select(Some(new));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
KeyCode::Down | KeyCode::Char('j') => {
|
||||||
|
if items_count > 0 {
|
||||||
|
let current =
|
||||||
|
self.state.results_table_state.selected().unwrap_or(0);
|
||||||
|
let new = (current + 1) % items_count;
|
||||||
|
self.state.results_table_state.select(Some(new));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
KeyCode::Char(' ') => {
|
||||||
|
if let Some(current) = self.state.results_table_state.selected()
|
||||||
|
{
|
||||||
|
let actual_index =
|
||||||
|
if self.state.results_filter_query.is_empty() {
|
||||||
|
current
|
||||||
|
} else {
|
||||||
|
self.state.results_filtered_indices[current]
|
||||||
|
};
|
||||||
|
|
||||||
|
if self
|
||||||
|
.state
|
||||||
|
.results_selected_indices
|
||||||
|
.contains(&actual_index)
|
||||||
|
{
|
||||||
|
self.state
|
||||||
|
.results_selected_indices
|
||||||
|
.remove(&actual_index);
|
||||||
|
} else {
|
||||||
|
self.state
|
||||||
|
.results_selected_indices
|
||||||
|
.insert(actual_index);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
KeyCode::Char('/') => {
|
||||||
|
self.state.results_is_filtering = true;
|
||||||
|
self.state.results_filter_query.clear();
|
||||||
|
}
|
||||||
|
KeyCode::Char('a') => {
|
||||||
|
let indices: Vec<usize> =
|
||||||
|
if self.state.results_filter_query.is_empty() {
|
||||||
|
(0..self.state.results_items.len()).collect()
|
||||||
|
} else {
|
||||||
|
self.state.results_filtered_indices.clone()
|
||||||
|
};
|
||||||
|
self.state.results_selected_indices.extend(indices);
|
||||||
|
}
|
||||||
|
KeyCode::Char('A') => {
|
||||||
|
self.state.results_selected_indices.clear();
|
||||||
|
}
|
||||||
|
KeyCode::Enter => {
|
||||||
|
self.queue_selected_downloads();
|
||||||
|
}
|
||||||
|
_ => {}
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn handle_downloads_input(&mut self, key: KeyEvent) -> Result<()> {
|
||||||
|
match key.code {
|
||||||
|
KeyCode::Up | KeyCode::Char('k') => {
|
||||||
|
if !self.state.downloads.is_empty() {
|
||||||
|
let current = self
|
||||||
|
.state
|
||||||
|
.downloads_table_state
|
||||||
|
.selected()
|
||||||
|
.unwrap_or(0);
|
||||||
|
let new = if current == 0 {
|
||||||
|
self.state.downloads.len() - 1
|
||||||
|
} else {
|
||||||
|
current - 1
|
||||||
|
};
|
||||||
|
self.state.downloads_table_state.select(Some(new));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
KeyCode::Down | KeyCode::Char('j') => {
|
||||||
|
if !self.state.downloads.is_empty() {
|
||||||
|
let current = self
|
||||||
|
.state
|
||||||
|
.downloads_table_state
|
||||||
|
.selected()
|
||||||
|
.unwrap_or(0);
|
||||||
|
let new = (current + 1) % self.state.downloads.len();
|
||||||
|
self.state.downloads_table_state.select(Some(new));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
_ => {}
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn handle_mouse_event(&mut self, mouse: MouseEvent) -> Result<()> {
|
||||||
|
if let MouseEventKind::Down(MouseButton::Left) = mouse.kind {
|
||||||
|
let (col, row) = (mouse.column, mouse.row);
|
||||||
|
|
||||||
|
// Check if click is within searches pane
|
||||||
|
if let Some(area) = self.state.searches_pane_area {
|
||||||
|
if col >= area.x
|
||||||
|
&& col < area.x + area.width
|
||||||
|
&& row >= area.y
|
||||||
|
&& row < area.y + area.height
|
||||||
|
{
|
||||||
|
self.state.focused_pane = FocusedPane::Searches;
|
||||||
|
return Ok(());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if click is within results pane
|
||||||
|
if let Some(area) = self.state.results_pane_area {
|
||||||
|
if col >= area.x
|
||||||
|
&& col < area.x + area.width
|
||||||
|
&& row >= area.y
|
||||||
|
&& row < area.y + area.height
|
||||||
|
{
|
||||||
|
self.state.focused_pane = FocusedPane::Results;
|
||||||
|
return Ok(());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if click is within downloads pane
|
||||||
|
if let Some(area) = self.state.downloads_pane_area {
|
||||||
|
if col >= area.x
|
||||||
|
&& col < area.x + area.width
|
||||||
|
&& row >= area.y
|
||||||
|
&& row < area.y + area.height
|
||||||
|
{
|
||||||
|
self.state.focused_pane = FocusedPane::Downloads;
|
||||||
|
return Ok(());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn remove_search_at_index(&mut self, index: usize) {
|
||||||
|
if index >= self.state.searches.len() {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Cancel the search if it's active
|
||||||
|
if let Some(search) = self.state.searches.get(index) {
|
||||||
|
search
|
||||||
|
.cancel_flag
|
||||||
|
.store(true, std::sync::atomic::Ordering::Relaxed);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if we're removing the currently active search
|
||||||
|
let was_active_search = self.state.selected_search_index == Some(index);
|
||||||
|
|
||||||
|
// Remove the search
|
||||||
|
self.state.searches.remove(index);
|
||||||
|
|
||||||
|
// Update selected_search_index
|
||||||
|
if let Some(current_idx) = self.state.selected_search_index {
|
||||||
|
if current_idx == index {
|
||||||
|
// Removed the active search - clear it
|
||||||
|
self.state.selected_search_index = None;
|
||||||
|
} else if current_idx > index {
|
||||||
|
// Active search was after removed one - decrement index
|
||||||
|
self.state.selected_search_index = Some(current_idx - 1);
|
||||||
|
}
|
||||||
|
// If current_idx < index, no change needed
|
||||||
|
}
|
||||||
|
|
||||||
|
// If we removed the active search, clear results pane
|
||||||
|
if was_active_search {
|
||||||
|
self.clear_results_pane();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update table selection
|
||||||
|
if self.state.searches.is_empty() {
|
||||||
|
self.state.searches_table_state.select(None);
|
||||||
|
} else {
|
||||||
|
let new_selection = if index >= self.state.searches.len() {
|
||||||
|
self.state.searches.len() - 1
|
||||||
|
} else {
|
||||||
|
index
|
||||||
|
};
|
||||||
|
self.state.searches_table_state.select(Some(new_selection));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn clear_all_searches(&mut self) {
|
||||||
|
// Cancel all active searches
|
||||||
|
for search in &self.state.searches {
|
||||||
|
search
|
||||||
|
.cancel_flag
|
||||||
|
.store(true, std::sync::atomic::Ordering::Relaxed);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Clear searches
|
||||||
|
self.state.searches.clear();
|
||||||
|
self.state.searches_table_state.select(None);
|
||||||
|
self.state.selected_search_index = None;
|
||||||
|
|
||||||
|
// Clear results pane
|
||||||
|
self.clear_results_pane();
|
||||||
|
}
|
||||||
|
|
||||||
|
fn clear_results_pane(&mut self) {
|
||||||
|
self.state.results_items.clear();
|
||||||
|
self.state.results_filtered_items.clear();
|
||||||
|
self.state.results_filtered_indices.clear();
|
||||||
|
self.state.results_selected_indices.clear();
|
||||||
|
self.state.results_table_state.select(None);
|
||||||
|
self.state.results_filter_query.clear();
|
||||||
|
self.state.results_is_filtering = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
fn apply_filter(&mut self) {
|
||||||
|
let query = self.state.results_filter_query.to_lowercase();
|
||||||
|
if query.is_empty() {
|
||||||
|
self.state.results_filtered_items =
|
||||||
|
self.state.results_items.clone();
|
||||||
|
self.state.results_filtered_indices =
|
||||||
|
(0..self.state.results_items.len()).collect();
|
||||||
|
} else {
|
||||||
|
self.state.results_filtered_items.clear();
|
||||||
|
self.state.results_filtered_indices.clear();
|
||||||
|
|
||||||
|
for (idx, item) in self.state.results_items.iter().enumerate() {
|
||||||
|
if item.filename.to_lowercase().contains(&query)
|
||||||
|
|| item.username.to_lowercase().contains(&query)
|
||||||
|
{
|
||||||
|
self.state.results_filtered_items.push(item.clone());
|
||||||
|
self.state.results_filtered_indices.push(idx);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if !self.state.results_filtered_items.is_empty() {
|
||||||
|
self.state.results_table_state.select(Some(0));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn start_search(&mut self, query: String) {
|
||||||
|
let cancel_flag = Arc::new(AtomicBool::new(false));
|
||||||
|
let search_entry = SearchEntry {
|
||||||
|
query: query.clone(),
|
||||||
|
status: SearchStatus::Active,
|
||||||
|
results: Vec::new(),
|
||||||
|
start_time: Instant::now(),
|
||||||
|
cancel_flag: cancel_flag.clone(),
|
||||||
|
};
|
||||||
|
|
||||||
|
self.state.searches.push(search_entry);
|
||||||
|
let search_index = self.state.searches.len() - 1;
|
||||||
|
self.state.searches_table_state.select(Some(search_index));
|
||||||
|
|
||||||
|
// Make this search the active one
|
||||||
|
self.state.selected_search_index = Some(search_index);
|
||||||
|
|
||||||
|
// Initialize results display (empty at first)
|
||||||
|
self.state.results_items.clear();
|
||||||
|
self.state.results_filtered_items.clear();
|
||||||
|
self.state.results_filtered_indices.clear();
|
||||||
|
self.state.results_selected_indices.clear();
|
||||||
|
self.state.results_table_state.select(Some(0));
|
||||||
|
|
||||||
|
// Switch focus to Results pane
|
||||||
|
self.state.focused_pane = FocusedPane::Results;
|
||||||
|
|
||||||
|
// Start search in background
|
||||||
|
let client = self.client.clone();
|
||||||
|
let timeout = self.search_timeout;
|
||||||
|
|
||||||
|
thread::spawn(move || {
|
||||||
|
match client.search_with_cancel(
|
||||||
|
&query,
|
||||||
|
timeout,
|
||||||
|
Some(cancel_flag.clone()),
|
||||||
|
) {
|
||||||
|
Ok(_results) => {
|
||||||
|
// Results will be polled in update_search_results
|
||||||
|
}
|
||||||
|
Err(e) => {
|
||||||
|
eprintln!("Search failed: {}", e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
fn update_search_results(&mut self) {
|
||||||
|
let timeout = self.search_timeout;
|
||||||
|
let selected_search_index = self.state.selected_search_index;
|
||||||
|
|
||||||
|
for (idx, search) in self.state.searches.iter_mut().enumerate() {
|
||||||
|
if search.status != SearchStatus::Active {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check timeout
|
||||||
|
if search.start_time.elapsed() > timeout {
|
||||||
|
search.status = SearchStatus::Failed("Timeout".to_string());
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Poll for new results
|
||||||
|
let search_query = search.query.clone();
|
||||||
|
let search_results = self.client.get_search_results(&search_query);
|
||||||
|
|
||||||
|
if !search_results.is_empty() {
|
||||||
|
search.results.clear();
|
||||||
|
for result in search_results {
|
||||||
|
for file in result.files {
|
||||||
|
search.results.push(FileDisplayData {
|
||||||
|
filename: file.name.clone(),
|
||||||
|
size: file.size,
|
||||||
|
username: result.username.clone(),
|
||||||
|
speed: result.speed,
|
||||||
|
slots: result.slots,
|
||||||
|
bitrate: file.attribs.get(&0).copied(),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update selected search if this is the active one
|
||||||
|
if let Some(selected_idx) = selected_search_index {
|
||||||
|
if selected_idx == idx {
|
||||||
|
self.state.results_items = search.results.clone();
|
||||||
|
self.state.results_filtered_items =
|
||||||
|
search.results.clone();
|
||||||
|
self.state.results_filtered_indices =
|
||||||
|
(0..search.results.len()).collect();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Mark as completed if we have results and some time has passed
|
||||||
|
if !search.results.is_empty()
|
||||||
|
&& search.start_time.elapsed() > Duration::from_secs(5)
|
||||||
|
{
|
||||||
|
search.status = SearchStatus::Completed;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn queue_selected_downloads(&mut self) {
|
||||||
|
let selected_files: Vec<(String, String, u64)> = self
|
||||||
|
.state
|
||||||
|
.results_selected_indices
|
||||||
|
.iter()
|
||||||
|
.filter_map(|&idx| self.state.results_items.get(idx))
|
||||||
|
.map(|file| {
|
||||||
|
(file.filename.clone(), file.username.clone(), file.size)
|
||||||
|
})
|
||||||
|
.collect();
|
||||||
|
|
||||||
|
if selected_files.is_empty() {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create channel for async download initialization
|
||||||
|
let (sender, receiver) = mpsc::channel();
|
||||||
|
self.state.downloads_receiver_channel = Some(receiver);
|
||||||
|
|
||||||
|
// Add downloads to state
|
||||||
|
for (filename, username, size) in &selected_files {
|
||||||
|
let download_state = FileDownloadState::new(
|
||||||
|
filename.clone(),
|
||||||
|
username.clone(),
|
||||||
|
*size,
|
||||||
|
);
|
||||||
|
self.state.downloads.push(DownloadEntry {
|
||||||
|
state: download_state,
|
||||||
|
receiver: None,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Start downloads in background
|
||||||
|
let client = self.client.clone();
|
||||||
|
let download_dir = self.download_dir.clone();
|
||||||
|
|
||||||
|
thread::spawn(move || {
|
||||||
|
for (idx, (filename, username, size)) in
|
||||||
|
selected_files.into_iter().enumerate()
|
||||||
|
{
|
||||||
|
match client.download(
|
||||||
|
filename.clone(),
|
||||||
|
username.clone(),
|
||||||
|
size,
|
||||||
|
download_dir.clone(),
|
||||||
|
) {
|
||||||
|
Ok(rx) => {
|
||||||
|
let _ = sender.send((idx, rx));
|
||||||
|
}
|
||||||
|
Err(e) => {
|
||||||
|
eprintln!(
|
||||||
|
"Failed to start download for {}: {}",
|
||||||
|
filename, e
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Clear selection
|
||||||
|
self.state.results_selected_indices.clear();
|
||||||
|
}
|
||||||
|
|
||||||
|
fn update_downloads(&mut self) {
|
||||||
|
// Check for new download receivers
|
||||||
|
if let Some(ref receiver) = self.state.downloads_receiver_channel {
|
||||||
|
while let Ok((idx, download_receiver)) = receiver.try_recv() {
|
||||||
|
if let Some(download) = self.state.downloads.get_mut(idx) {
|
||||||
|
download.receiver = Some(download_receiver);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update download states
|
||||||
|
self.state.active_downloads_count = 0;
|
||||||
|
for download in &mut self.state.downloads {
|
||||||
|
if let Some(ref receiver) = download.receiver {
|
||||||
|
while let Ok(status) = receiver.try_recv() {
|
||||||
|
download.state.status = status.clone();
|
||||||
|
if let DownloadStatus::InProgress {
|
||||||
|
bytes_downloaded,
|
||||||
|
total_bytes,
|
||||||
|
speed_bytes_per_sec,
|
||||||
|
} = &status
|
||||||
|
{
|
||||||
|
download.state.bytes_downloaded = *bytes_downloaded;
|
||||||
|
download.state.total_bytes = *total_bytes;
|
||||||
|
download.state.speed_bytes_per_sec =
|
||||||
|
*speed_bytes_per_sec;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if matches!(
|
||||||
|
download.state.status,
|
||||||
|
DownloadStatus::InProgress { .. }
|
||||||
|
) {
|
||||||
|
self.state.active_downloads_count += 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn launch_main_tui(
|
||||||
|
terminal: DefaultTerminal,
|
||||||
|
client: Arc<Client>,
|
||||||
|
download_dir: String,
|
||||||
|
max_concurrent_downloads: usize,
|
||||||
|
search_timeout: Duration,
|
||||||
|
) -> Result<()> {
|
||||||
|
let tui = MainTui::new(
|
||||||
|
client,
|
||||||
|
download_dir,
|
||||||
|
max_concurrent_downloads,
|
||||||
|
search_timeout,
|
||||||
|
);
|
||||||
|
tui.run(terminal)
|
||||||
|
}
|
||||||
@@ -1,9 +1,12 @@
|
|||||||
mod download_selector;
|
mod download_selector;
|
||||||
mod downloads;
|
mod downloads;
|
||||||
|
mod main_tui;
|
||||||
|
mod panes;
|
||||||
mod styles;
|
mod styles;
|
||||||
mod utils;
|
mod utils;
|
||||||
|
|
||||||
pub use download_selector::FileSelector;
|
pub use download_selector::FileSelector;
|
||||||
pub use downloads::show_multi_download_progress;
|
pub use downloads::{render_download_stats, show_multi_download_progress};
|
||||||
|
pub use main_tui::launch_main_tui;
|
||||||
pub use styles::*;
|
pub use styles::*;
|
||||||
pub use utils::*;
|
pub use utils::*;
|
||||||
|
|||||||
@@ -0,0 +1,120 @@
|
|||||||
|
use crate::models::FileDownloadState;
|
||||||
|
use crate::ui::{
|
||||||
|
border_style, border_type, error_style, format_bytes, format_speed,
|
||||||
|
header_style, highlight_style, inactive_style, success_style,
|
||||||
|
warning_style, HIGHLIGHT_SYMBOL,
|
||||||
|
};
|
||||||
|
use ratatui::{
|
||||||
|
layout::Rect,
|
||||||
|
widgets::{
|
||||||
|
Block, Borders, Cell, HighlightSpacing, Paragraph, Row, Table,
|
||||||
|
TableState,
|
||||||
|
},
|
||||||
|
Frame,
|
||||||
|
};
|
||||||
|
use soulseek_rs::DownloadStatus;
|
||||||
|
|
||||||
|
pub fn render_downloads_pane(
|
||||||
|
frame: &mut Frame,
|
||||||
|
area: Rect,
|
||||||
|
downloads: &[FileDownloadState],
|
||||||
|
table_state: &mut TableState,
|
||||||
|
focused: bool,
|
||||||
|
) {
|
||||||
|
if downloads.is_empty() {
|
||||||
|
let empty_block = Block::default()
|
||||||
|
.borders(Borders::ALL)
|
||||||
|
.border_style(border_style(focused))
|
||||||
|
.border_type(border_type(focused))
|
||||||
|
.title("[3] Downloads");
|
||||||
|
|
||||||
|
let paragraph = Paragraph::new(
|
||||||
|
"No downloads. Select files from Results and press Enter.",
|
||||||
|
)
|
||||||
|
.block(empty_block);
|
||||||
|
frame.render_widget(paragraph, area);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
let header = Row::new(vec![
|
||||||
|
Cell::from("Status").style(header_style()),
|
||||||
|
Cell::from("Filename").style(header_style()),
|
||||||
|
Cell::from("User").style(header_style()),
|
||||||
|
Cell::from("Progress").style(header_style()),
|
||||||
|
Cell::from("Speed").style(header_style()),
|
||||||
|
])
|
||||||
|
.height(1);
|
||||||
|
|
||||||
|
let rows: Vec<Row> = downloads
|
||||||
|
.iter()
|
||||||
|
.map(|download| {
|
||||||
|
let (status_icon, status_style) = match &download.status {
|
||||||
|
DownloadStatus::Queued => ("⋯", inactive_style()),
|
||||||
|
DownloadStatus::InProgress { .. } => ("⧗", warning_style()),
|
||||||
|
DownloadStatus::Completed => ("✓", success_style()),
|
||||||
|
DownloadStatus::Failed => ("✗", error_style()),
|
||||||
|
DownloadStatus::TimedOut => ("⏱", error_style()),
|
||||||
|
};
|
||||||
|
|
||||||
|
let progress_text = match &download.status {
|
||||||
|
DownloadStatus::Queued => "Queued".to_string(),
|
||||||
|
DownloadStatus::InProgress { .. } => {
|
||||||
|
let percent = if download.total_bytes > 0 {
|
||||||
|
(download.bytes_downloaded as f64
|
||||||
|
/ download.total_bytes as f64
|
||||||
|
* 100.0) as u8
|
||||||
|
} else {
|
||||||
|
0
|
||||||
|
};
|
||||||
|
format!(
|
||||||
|
"{}/{} ({}%)",
|
||||||
|
format_bytes(download.bytes_downloaded),
|
||||||
|
format_bytes(download.total_bytes),
|
||||||
|
percent
|
||||||
|
)
|
||||||
|
}
|
||||||
|
DownloadStatus::Completed => "Completed".to_string(),
|
||||||
|
DownloadStatus::Failed => "Failed".to_string(),
|
||||||
|
DownloadStatus::TimedOut => "Timed out".to_string(),
|
||||||
|
};
|
||||||
|
|
||||||
|
let speed_text = match &download.status {
|
||||||
|
DownloadStatus::InProgress { .. } => {
|
||||||
|
format_speed(download.speed_bytes_per_sec)
|
||||||
|
}
|
||||||
|
_ => "-".to_string(),
|
||||||
|
};
|
||||||
|
|
||||||
|
Row::new(vec![
|
||||||
|
Cell::from(status_icon).style(status_style),
|
||||||
|
Cell::from(download.filename.clone()),
|
||||||
|
Cell::from(download.username.clone()),
|
||||||
|
Cell::from(progress_text),
|
||||||
|
Cell::from(speed_text),
|
||||||
|
])
|
||||||
|
})
|
||||||
|
.collect();
|
||||||
|
|
||||||
|
let widths = [
|
||||||
|
ratatui::layout::Constraint::Length(8),
|
||||||
|
ratatui::layout::Constraint::Fill(2),
|
||||||
|
ratatui::layout::Constraint::Length(15),
|
||||||
|
ratatui::layout::Constraint::Length(25),
|
||||||
|
ratatui::layout::Constraint::Length(12),
|
||||||
|
];
|
||||||
|
|
||||||
|
let table = Table::new(rows, widths)
|
||||||
|
.header(header)
|
||||||
|
.row_highlight_style(highlight_style())
|
||||||
|
.highlight_symbol(HIGHLIGHT_SYMBOL)
|
||||||
|
.highlight_spacing(HighlightSpacing::Always)
|
||||||
|
.block(
|
||||||
|
Block::default()
|
||||||
|
.borders(Borders::ALL)
|
||||||
|
.border_style(border_style(focused))
|
||||||
|
.border_type(border_type(focused))
|
||||||
|
.title("[3] Downloads"),
|
||||||
|
);
|
||||||
|
|
||||||
|
frame.render_stateful_widget(table, area, table_state);
|
||||||
|
}
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
mod downloads_pane;
|
||||||
|
mod results_pane;
|
||||||
|
mod searches_pane;
|
||||||
|
|
||||||
|
pub use downloads_pane::render_downloads_pane;
|
||||||
|
pub use results_pane::{render_results_pane, ResultsPaneParams};
|
||||||
|
pub use searches_pane::render_searches_pane;
|
||||||
@@ -0,0 +1,149 @@
|
|||||||
|
use crate::models::FileDisplayData;
|
||||||
|
use crate::ui::{
|
||||||
|
border_style, border_type, format_bytes, header_style, highlight_style,
|
||||||
|
HIGHLIGHT_SYMBOL,
|
||||||
|
};
|
||||||
|
use ratatui::{
|
||||||
|
layout::Rect,
|
||||||
|
widgets::{
|
||||||
|
Block, Borders, Cell, HighlightSpacing, Paragraph, Row, Table,
|
||||||
|
TableState,
|
||||||
|
},
|
||||||
|
Frame,
|
||||||
|
};
|
||||||
|
use std::collections::HashSet;
|
||||||
|
|
||||||
|
const VERSION: &str = env!("CARGO_PKG_VERSION");
|
||||||
|
|
||||||
|
pub struct ResultsPaneParams<'a> {
|
||||||
|
pub items: &'a [FileDisplayData],
|
||||||
|
pub table_state: &'a mut TableState,
|
||||||
|
pub selected_indices: &'a HashSet<usize>,
|
||||||
|
pub filter_query: &'a str,
|
||||||
|
pub is_filtering: bool,
|
||||||
|
pub focused: bool,
|
||||||
|
pub active_search_query: Option<&'a str>,
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn render_results_pane(
|
||||||
|
frame: &mut Frame,
|
||||||
|
area: Rect,
|
||||||
|
params: ResultsPaneParams,
|
||||||
|
) {
|
||||||
|
let ResultsPaneParams {
|
||||||
|
items,
|
||||||
|
table_state,
|
||||||
|
selected_indices,
|
||||||
|
filter_query,
|
||||||
|
is_filtering,
|
||||||
|
focused,
|
||||||
|
active_search_query,
|
||||||
|
} = params;
|
||||||
|
if items.is_empty() {
|
||||||
|
let title = if let Some(query) = active_search_query {
|
||||||
|
format!("[2] Results: {}", query)
|
||||||
|
} else {
|
||||||
|
"[2] Results".to_string()
|
||||||
|
};
|
||||||
|
|
||||||
|
let empty_block = Block::default()
|
||||||
|
.borders(Borders::ALL)
|
||||||
|
.border_style(border_style(focused))
|
||||||
|
.border_type(border_type(focused))
|
||||||
|
.title(title);
|
||||||
|
|
||||||
|
let message = if is_filtering {
|
||||||
|
format!("No results match filter: '{}'", filter_query)
|
||||||
|
} else {
|
||||||
|
format!(
|
||||||
|
"soulseek-rs 🦀 v{}
|
||||||
|
Michel de Graaf 2025\n
|
||||||
|
No results. Select a search from the Searches pane [1]. Or start new search [s → search] \n",
|
||||||
|
VERSION
|
||||||
|
)
|
||||||
|
};
|
||||||
|
|
||||||
|
let paragraph = Paragraph::new(message).block(empty_block);
|
||||||
|
frame.render_widget(paragraph, area);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
let header = Row::new(vec![
|
||||||
|
Cell::from("✓").style(header_style()),
|
||||||
|
Cell::from("Filename").style(header_style()),
|
||||||
|
Cell::from("Size").style(header_style()),
|
||||||
|
Cell::from("User").style(header_style()),
|
||||||
|
Cell::from("Bitrate").style(header_style()),
|
||||||
|
Cell::from("Speed").style(header_style()),
|
||||||
|
Cell::from("Slots").style(header_style()),
|
||||||
|
])
|
||||||
|
.height(1);
|
||||||
|
|
||||||
|
let rows: Vec<Row> = items
|
||||||
|
.iter()
|
||||||
|
.enumerate()
|
||||||
|
.map(|(idx, file)| {
|
||||||
|
let checkbox = if selected_indices.contains(&idx) {
|
||||||
|
"[✓]"
|
||||||
|
} else {
|
||||||
|
"[ ]"
|
||||||
|
};
|
||||||
|
|
||||||
|
let bitrate_str = file
|
||||||
|
.bitrate
|
||||||
|
.map(|br| format!("{} kbps", br))
|
||||||
|
.unwrap_or_else(|| "-".to_string());
|
||||||
|
|
||||||
|
let speed_str = if file.speed > 0 {
|
||||||
|
let speed_mb =
|
||||||
|
(file.speed as f64 / 1_048_576.0 * 100.0).round() / 100.0;
|
||||||
|
format!("{} MB/s", speed_mb)
|
||||||
|
} else {
|
||||||
|
"-".to_string()
|
||||||
|
};
|
||||||
|
|
||||||
|
Row::new(vec![
|
||||||
|
Cell::from(checkbox),
|
||||||
|
Cell::from(file.filename.clone()),
|
||||||
|
Cell::from(format_bytes(file.size)),
|
||||||
|
Cell::from(file.username.clone()),
|
||||||
|
Cell::from(bitrate_str),
|
||||||
|
Cell::from(speed_str),
|
||||||
|
Cell::from(file.slots.to_string()),
|
||||||
|
])
|
||||||
|
})
|
||||||
|
.collect();
|
||||||
|
|
||||||
|
let widths = [
|
||||||
|
ratatui::layout::Constraint::Length(3),
|
||||||
|
ratatui::layout::Constraint::Fill(3),
|
||||||
|
ratatui::layout::Constraint::Length(12),
|
||||||
|
ratatui::layout::Constraint::Length(15),
|
||||||
|
ratatui::layout::Constraint::Length(10),
|
||||||
|
ratatui::layout::Constraint::Length(12),
|
||||||
|
ratatui::layout::Constraint::Length(6),
|
||||||
|
];
|
||||||
|
|
||||||
|
let title = if is_filtering {
|
||||||
|
format!("[2] Results - Filter: '{}'", filter_query)
|
||||||
|
} else if let Some(query) = active_search_query {
|
||||||
|
format!("[2] Results: {}", query)
|
||||||
|
} else {
|
||||||
|
"[2] Results".to_string()
|
||||||
|
};
|
||||||
|
|
||||||
|
let table = Table::new(rows, widths)
|
||||||
|
.header(header)
|
||||||
|
.row_highlight_style(highlight_style())
|
||||||
|
.highlight_symbol(HIGHLIGHT_SYMBOL)
|
||||||
|
.highlight_spacing(HighlightSpacing::Always)
|
||||||
|
.block(
|
||||||
|
Block::default()
|
||||||
|
.borders(Borders::ALL)
|
||||||
|
.border_style(border_style(focused))
|
||||||
|
.border_type(border_type(focused))
|
||||||
|
.title(title),
|
||||||
|
);
|
||||||
|
|
||||||
|
frame.render_stateful_widget(table, area, table_state);
|
||||||
|
}
|
||||||
@@ -0,0 +1,78 @@
|
|||||||
|
use crate::models::{SearchEntry, SearchStatus};
|
||||||
|
use crate::ui::{
|
||||||
|
border_style, border_type, error_style, header_style, highlight_style,
|
||||||
|
success_style, warning_style, HIGHLIGHT_SYMBOL,
|
||||||
|
};
|
||||||
|
use ratatui::{
|
||||||
|
layout::Rect,
|
||||||
|
widgets::{Block, Borders, Cell, HighlightSpacing, Row, Table, TableState},
|
||||||
|
Frame,
|
||||||
|
};
|
||||||
|
|
||||||
|
pub fn render_searches_pane(
|
||||||
|
frame: &mut Frame,
|
||||||
|
area: Rect,
|
||||||
|
searches: &[SearchEntry],
|
||||||
|
table_state: &mut TableState,
|
||||||
|
focused: bool,
|
||||||
|
) {
|
||||||
|
let header = Row::new(vec![
|
||||||
|
Cell::from("Status").style(header_style()),
|
||||||
|
Cell::from("Query").style(header_style()),
|
||||||
|
Cell::from("Results").style(header_style()),
|
||||||
|
])
|
||||||
|
.height(1);
|
||||||
|
|
||||||
|
let rows: Vec<Row> = searches
|
||||||
|
.iter()
|
||||||
|
.map(|search| {
|
||||||
|
let status_cell = match &search.status {
|
||||||
|
SearchStatus::Active => {
|
||||||
|
Cell::from("Active").style(warning_style())
|
||||||
|
}
|
||||||
|
SearchStatus::Completed => {
|
||||||
|
Cell::from("Done").style(success_style())
|
||||||
|
}
|
||||||
|
SearchStatus::Failed(msg) => {
|
||||||
|
Cell::from(format!("Failed: {}", msg)).style(error_style())
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
let results_count = search.results.len();
|
||||||
|
let results_text = if results_count == 0
|
||||||
|
&& search.status == SearchStatus::Active
|
||||||
|
{
|
||||||
|
"Searching...".to_string()
|
||||||
|
} else {
|
||||||
|
format!("{}", results_count)
|
||||||
|
};
|
||||||
|
|
||||||
|
Row::new(vec![
|
||||||
|
status_cell,
|
||||||
|
Cell::from(search.query.clone()),
|
||||||
|
Cell::from(results_text),
|
||||||
|
])
|
||||||
|
})
|
||||||
|
.collect();
|
||||||
|
|
||||||
|
let widths = [
|
||||||
|
ratatui::layout::Constraint::Length(15),
|
||||||
|
ratatui::layout::Constraint::Fill(1),
|
||||||
|
ratatui::layout::Constraint::Length(10),
|
||||||
|
];
|
||||||
|
|
||||||
|
let table = Table::new(rows, widths)
|
||||||
|
.header(header)
|
||||||
|
.row_highlight_style(highlight_style())
|
||||||
|
.highlight_symbol(HIGHLIGHT_SYMBOL)
|
||||||
|
.highlight_spacing(HighlightSpacing::Always)
|
||||||
|
.block(
|
||||||
|
Block::default()
|
||||||
|
.borders(Borders::ALL)
|
||||||
|
.border_style(border_style(focused))
|
||||||
|
.border_type(border_type(focused))
|
||||||
|
.title("[1] Searches"),
|
||||||
|
);
|
||||||
|
|
||||||
|
frame.render_stateful_widget(table, area, table_state);
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user