feat: main TUI
This commit is contained in:
@@ -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