Files
soulseek-rs-modded/soulseek-rs/src/ui/download_selector.rs
T
Michel de Graaf 2918b0ce75 refactor: resolve all lint findings
- remove 34 trivial restating comments (auto-fix)
- replace all 20 production .unwrap() calls with graceful handling:
  ?-propagation via LockPoisoned, unwrap_or_default on infallible
  conversions, let _ = on actor-channel sends, and a downloads_sender()
  helper in the TUI that also dedupes three channel-init blocks
- collapse nested if into match guard (clippy::collapsible_match)
- split client.rs (2275 lines) into a client/ module: connection,
  search, downloads, uploads, rooms, operations
- split ui/main_tui.rs (2027 lines) into a main_tui/ module: render,
  input, browse, rooms, downloads, search
- extract oversized functions: peer_actor/server_actor handle_message
  into per-message helpers, main() into init_logging/run_default_tui,
  download_selector and main_tui render into per-section helpers

Mechanical only, zero behavior change. Verified: clippy clean
(pedantic+nursery), fmt clean, 143 unit tests + 16 e2e tests against
soulfind pass, lint clean.
2026-07-20 10:53:57 +02:00

657 lines
21 KiB
Rust

use crate::models::FileDisplayData;
use crate::ui::{
BYTES_PER_MB, HIGHLIGHT_SYMBOL, border_style, border_type, format_bytes,
format_shortcuts_styled, get_bitrate, get_spinner_char, header_style,
highlight_style, primary_style, success_style, warning_style,
};
use color_eyre::Result;
use ratatui::text::{Line, Span};
use ratatui::{
DefaultTerminal, Frame,
crossterm::event::{self, Event, KeyCode, KeyEvent, KeyEventKind, poll},
layout::{Alignment, Constraint, Layout},
style::Style,
widgets::{
Block, Borders, Cell, HighlightSpacing, Paragraph, Row, StatefulWidget,
Table, TableState, Wrap,
},
};
use soulseek_rs::Client;
use std::{
collections::HashSet,
sync::{
Arc,
atomic::{AtomicBool, Ordering},
},
time::{Duration, Instant},
};
pub struct FileSelector {
all_items: Vec<FileDisplayData>,
items: Vec<FileDisplayData>,
filtered_indices: Vec<usize>,
state: TableState,
should_exit: bool,
selected_indices: HashSet<usize>,
filter_query: String,
is_filtering: bool,
client: Option<Arc<Client>>,
soulseek_query: String,
search_timeout: Duration,
search_start_time: Instant,
search_cancel_flag: Arc<AtomicBool>,
search_active: bool,
spinner_state: usize,
last_spinner_update: Instant,
last_result_count: usize,
}
impl FileSelector {
pub fn new_with_live_search(
client: Arc<Client>,
query: String,
timeout: Duration,
cancel_flag: Arc<AtomicBool>,
) -> Self {
let mut state = TableState::default();
state.select(Some(0));
Self {
all_items: Vec::new(),
items: Vec::new(),
filtered_indices: Vec::new(),
state,
should_exit: false,
selected_indices: HashSet::new(),
filter_query: String::new(),
is_filtering: false,
client: Some(client),
soulseek_query: query,
search_timeout: timeout,
search_start_time: Instant::now(),
search_cancel_flag: cancel_flag,
search_active: true,
spinner_state: 0,
last_spinner_update: Instant::now(),
last_result_count: 0,
}
}
pub fn run(
&mut self,
mut terminal: DefaultTerminal,
) -> Result<(DefaultTerminal, Vec<usize>)> {
while !self.should_exit {
terminal.draw(|frame| self.render(frame))?;
self.spinner_state = (self.spinner_state + 1) % 10;
self.last_spinner_update = Instant::now();
// Poll for new search results if active
if self.search_active {
if let Some(ref client) = self.client {
let current_count =
client.get_search_results_count(&self.soulseek_query);
if current_count != self.last_result_count {
self.update_results_from_client();
self.last_result_count = current_count;
}
}
// Check if search timeout reached
if self.search_start_time.elapsed() >= self.search_timeout {
self.search_active = false;
self.search_cancel_flag.store(true, Ordering::Relaxed);
}
}
let timeout = if self.search_active {
Duration::from_millis(50)
} else {
Duration::from_millis(100)
};
if poll(timeout)?
&& let Event::Key(key) = event::read()?
{
self.handle_key(key);
}
}
Ok((terminal, self.selected_indices.iter().copied().collect()))
}
fn update_results_from_client(&mut self) {
if let Some(ref client) = self.client {
let search_results =
client.get_search_results(&self.soulseek_query);
// Convert search results to FileDisplayData
let mut new_items = Vec::new();
for result in &search_results {
for file in &result.files {
new_items.push(FileDisplayData {
filename: file.name.clone(),
size: file.size,
username: result.username.clone(),
speed: result.speed,
slots: result.slots,
bitrate: get_bitrate(&file.attribs),
length_seconds: file.attribs.get(&1).copied(),
});
}
}
let len = new_items.len();
self.all_items = new_items.clone();
self.items = new_items;
self.filtered_indices = (0..len).collect();
// Keep selection valid or set to 0
if self.state.selected().is_none() && !self.items.is_empty() {
self.state.select(Some(0));
}
}
}
fn toggle_selection(&mut self) {
if let Some(filtered_idx) = self.state.selected()
&& let Some(&original_idx) = self.filtered_indices.get(filtered_idx)
{
if self.selected_indices.contains(&original_idx) {
self.selected_indices.remove(&original_idx);
} else {
self.selected_indices.insert(original_idx);
}
}
}
fn handle_key(&mut self, key: KeyEvent) {
if key.kind != KeyEventKind::Press {
return;
}
if self.is_filtering {
match key.code {
KeyCode::Char(' ') => {
self.toggle_selection();
}
KeyCode::Char(c) => {
self.filter_query.push(c);
self.apply_filter();
}
KeyCode::Backspace => {
self.filter_query.pop();
self.apply_filter();
}
KeyCode::Esc => {
self.filter_query.clear();
self.apply_filter();
self.is_filtering = false;
}
KeyCode::Enter => {
// If nothing selected, select the current item under cursor
if self.selected_indices.is_empty()
&& let Some(filtered_idx) = self.state.selected()
&& let Some(&original_idx) =
self.filtered_indices.get(filtered_idx)
{
self.selected_indices.insert(original_idx);
}
self.should_exit = true;
}
KeyCode::Up | KeyCode::Down => {
if key.code == KeyCode::Up {
self.select_previous();
} else {
self.select_next();
}
}
_ => {}
}
} else {
match key.code {
KeyCode::Char(' ') => {
self.toggle_selection();
}
KeyCode::Char('/') => {
self.is_filtering = true;
}
KeyCode::Up | KeyCode::Char('k') => self.select_previous(),
KeyCode::Down | KeyCode::Char('j') => self.select_next(),
KeyCode::Home | KeyCode::Char('g') => self.select_first(),
KeyCode::End | KeyCode::Char('G') => self.select_last(),
KeyCode::Char('a') => {
// Select all visible/filtered items
for &original_idx in &self.filtered_indices {
self.selected_indices.insert(original_idx);
}
}
KeyCode::Char('A') => {
// Deselect all items
self.selected_indices.clear();
}
KeyCode::Enter => {
// If nothing selected, select the current item under cursor
if self.selected_indices.is_empty()
&& let Some(filtered_idx) = self.state.selected()
&& let Some(&original_idx) =
self.filtered_indices.get(filtered_idx)
{
self.selected_indices.insert(original_idx);
}
self.should_exit = true;
}
KeyCode::Esc | KeyCode::Char('q') => {
self.selected_indices.clear();
self.should_exit = true;
}
_ => {}
}
}
}
/// Previous index with wrap-around, or None when the list is empty.
const fn wrapping_prev(
selected: Option<usize>,
len: usize,
) -> Option<usize> {
if len == 0 {
return None;
}
Some(match selected {
Some(i) if i > 0 => i - 1,
_ => len - 1,
})
}
/// Next index with wrap-around, or None when the list is empty.
const fn wrapping_next(
selected: Option<usize>,
len: usize,
) -> Option<usize> {
if len == 0 {
return None;
}
Some(match selected {
Some(i) if i < len - 1 => i + 1,
_ => 0,
})
}
const fn select_previous(&mut self) {
let i = Self::wrapping_prev(self.state.selected(), self.items.len());
self.state.select(i);
}
const fn select_next(&mut self) {
let i = Self::wrapping_next(self.state.selected(), self.items.len());
self.state.select(i);
}
fn select_first(&mut self) {
self.state.select((!self.items.is_empty()).then_some(0));
}
const fn select_last(&mut self) {
self.state.select(self.items.len().checked_sub(1));
}
fn apply_filter(&mut self) {
let query_lower = self.filter_query.to_lowercase();
if query_lower.is_empty() {
self.items = self.all_items.clone();
self.filtered_indices = (0..self.all_items.len()).collect();
} else {
self.items.clear();
self.filtered_indices.clear();
for (i, item) in self.all_items.iter().enumerate() {
if item.filename.to_lowercase().contains(&query_lower)
|| item.username.to_lowercase().contains(&query_lower)
{
self.items.push(item.clone());
self.filtered_indices.push(i);
}
}
}
if self.items.is_empty() {
self.state.select(None);
} else {
self.state.select(Some(0));
}
}
fn render(&mut self, frame: &mut Frame) {
let area = frame.area();
// Always split layout into table, optional info, and controls footer
let has_info = self.is_filtering
|| !self.selected_indices.is_empty()
|| !self.filter_query.is_empty();
let chunks = if has_info {
Layout::vertical([
Constraint::Min(0),
Constraint::Length(3),
Constraint::Length(3),
])
.split(area)
} else {
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])
};
self.render_results_table(frame, table_area);
if self.items.is_empty() {
self.render_loading_message(frame, table_area);
}
if let Some(info_area) = info_area {
self.render_info(frame, info_area);
}
self.render_controls(frame, controls_area);
}
fn table_title(&self) -> String {
if self.search_active {
// Live Soulseek search is active
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)",
spinner,
self.soulseek_query,
self.all_items.len(),
elapsed,
total
)
} else if self.is_filtering {
format!(
"Multi-select files to download ({}/{} matches)",
self.items.len(),
self.all_items.len()
)
} else {
format!(
"Multi-select files to download ({} selected)",
self.selected_indices.len()
)
}
}
fn render_results_table(
&mut self,
frame: &mut Frame,
table_area: ratatui::layout::Rect,
) {
let title = self.table_title();
let header = Row::new(vec![
Cell::from(""),
Cell::from("Filename"),
Cell::from("Size"),
Cell::from("Username"),
Cell::from("Speed"),
Cell::from("Slots"),
Cell::from("Bitrate"),
])
.style(header_style())
.height(1);
let rows: Vec<Row> = self
.items
.iter()
.enumerate()
.map(|(filtered_idx, item)| {
let original_idx = self.filtered_indices[filtered_idx];
let is_selected = self.selected_indices.contains(&original_idx);
let checkbox = if is_selected { "[✓]" } else { "[ ]" };
let speed_mb = f64::from(item.speed) / BYTES_PER_MB;
let speed_str = if speed_mb > 0.0 {
format!("{speed_mb:.1} MB/s")
} else {
"-".to_string()
};
let slots_str = format!("{}", item.slots);
let bitrate_str = match item.bitrate {
Some(br) => format!("{br} kbps"),
None => "-".to_string(),
};
let cells = vec![
Cell::from(checkbox),
Cell::from(item.filename.clone()),
Cell::from(format_bytes(item.size)),
Cell::from(item.username.clone()),
Cell::from(speed_str),
Cell::from(slots_str),
Cell::from(bitrate_str),
];
let style = if is_selected {
success_style()
} else {
Style::default()
};
Row::new(cells).style(style).height(1)
})
.collect();
let widths = [
Constraint::Length(5),
Constraint::Min(30),
Constraint::Length(12),
Constraint::Length(15),
Constraint::Length(12),
Constraint::Length(8),
Constraint::Length(10),
];
let table = Table::new(rows, widths)
.header(header)
.block(
Block::default()
.borders(Borders::ALL)
.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_spacing(HighlightSpacing::Always);
StatefulWidget::render(
table,
table_area,
frame.buffer_mut(),
&mut self.state,
);
}
fn render_loading_message(
&self,
frame: &mut Frame,
table_area: ratatui::layout::Rect,
) {
let spinner = get_spinner_char(self.spinner_state);
let mut loading_message: Vec<Span> = Vec::new();
if self.items.is_empty() && self.search_active {
loading_message.extend(vec![
Span::raw(spinner),
Span::raw(" Searching for '"),
Span::styled(self.soulseek_query.clone(), primary_style()),
Span::raw(format!(
"' [{}/{}s]",
self.search_start_time.elapsed().as_secs(),
self.search_timeout.as_secs()
)),
]);
} else {
loading_message.extend(vec![
Span::raw(spinner),
Span::raw(" Searching for '"),
Span::styled(self.soulseek_query.clone(), primary_style()),
Span::raw("'; No results yet"),
]);
}
// Center the loading message
let vertical = Layout::vertical([
Constraint::Fill(1),
Constraint::Length(3),
Constraint::Fill(1),
])
.split(table_area);
let loading_message_line = Line::from(loading_message);
// Calculate responsive width: text + generous padding for borders and spacing, max 80% of screen
let text_width =
loading_message_line.to_string().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::Length(widget_width),
Constraint::Fill(1),
])
.split(vertical[1]);
let loading_widget = Paragraph::new(loading_message_line)
.style(Style::default())
.block(
Block::default()
.borders(Borders::ALL)
.border_style(primary_style())
.border_type(border_type(false)),
)
.alignment(Alignment::Center)
.wrap(Wrap { trim: true });
frame.render_widget(loading_widget, horizontal[1]);
}
fn render_info(&self, frame: &mut Frame, info_area: ratatui::layout::Rect) {
let (info_text, title, style) = if self.is_filtering {
(
format!("Filter: {}", self.filter_query),
"Filter",
warning_style(),
)
} else if !self.filter_query.is_empty() {
(
format!(
"Current filter: {} (press / to modify, Esc to clear)",
self.filter_query
),
"Filter",
primary_style(),
)
} else {
(
format!(
"{} file(s) selected for download",
self.selected_indices.len()
),
"Selection",
success_style(),
)
};
let info_widget = Paragraph::new(info_text)
.block(
Block::default()
.borders(Borders::ALL)
.border_type(border_type(false))
.title(title),
)
.style(style);
frame.render_widget(info_widget, info_area);
}
fn render_controls(&self, frame: &mut Frame, area: ratatui::layout::Rect) {
let controls_line = if self.is_filtering {
format_shortcuts_styled(&[
("Space", "toggle"),
("a", "select-all"),
("A", "deselect-all"),
("Enter", "download"),
("Esc", "exit filter"),
])
} 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"),
("/", "filter"),
])
};
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);
}
}
#[cfg(test)]
mod tests {
use super::FileSelector;
// Navigating an empty list (e.g. a filter that matched nothing) must not
// underflow `len - 1`; it should yield no selection.
#[test]
fn wrapping_prev_on_empty_list_is_none() {
assert_eq!(FileSelector::wrapping_prev(None, 0), None);
assert_eq!(FileSelector::wrapping_prev(Some(0), 0), None);
}
#[test]
fn wrapping_next_on_empty_list_is_none() {
assert_eq!(FileSelector::wrapping_next(None, 0), None);
assert_eq!(FileSelector::wrapping_next(Some(0), 0), None);
}
#[test]
fn wrapping_navigation_wraps_around() {
assert_eq!(FileSelector::wrapping_prev(Some(0), 3), Some(2));
assert_eq!(FileSelector::wrapping_prev(Some(2), 3), Some(1));
assert_eq!(FileSelector::wrapping_next(Some(2), 3), Some(0));
assert_eq!(FileSelector::wrapping_next(Some(1), 3), Some(2));
}
}