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