chore: UI tweaks

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