chore: improve download selector

This commit is contained in:
Michel de Graaf
2025-11-13 10:08:16 +01:00
parent 6ca179373f
commit 4a34583f9c
+52 -36
View File
@@ -5,6 +5,7 @@ use crate::ui::{
primary_style, success_style, warning_style, HIGHLIGHT_SYMBOL, primary_style, success_style, warning_style, HIGHLIGHT_SYMBOL,
}; };
use color_eyre::Result; use color_eyre::Result;
use ratatui::text::{Line, Span};
use ratatui::{ use ratatui::{
crossterm::event::{self, poll, Event, KeyCode, KeyEvent, KeyEventKind}, crossterm::event::{self, poll, Event, KeyCode, KeyEvent, KeyEventKind},
layout::{Alignment, Constraint, Layout}, layout::{Alignment, Constraint, Layout},
@@ -32,8 +33,8 @@ pub struct FileSelector {
state: TableState, state: TableState,
should_exit: bool, should_exit: bool,
selected_indices: HashSet<usize>, selected_indices: HashSet<usize>,
search_query: String, filter_query: String,
is_searching: bool, is_filtering: bool,
client: Option<Arc<Client>>, client: Option<Arc<Client>>,
soulseek_query: String, soulseek_query: String,
search_timeout: Duration, search_timeout: Duration,
@@ -62,8 +63,8 @@ impl FileSelector {
state, state,
should_exit: false, should_exit: false,
selected_indices: HashSet::new(), selected_indices: HashSet::new(),
search_query: String::new(), filter_query: String::new(),
is_searching: false, is_filtering: false,
client: Some(client), client: Some(client),
soulseek_query: query, soulseek_query: query,
search_timeout: timeout, search_timeout: timeout,
@@ -82,22 +83,16 @@ impl FileSelector {
) -> Result<(DefaultTerminal, Vec<usize>)> { ) -> Result<(DefaultTerminal, Vec<usize>)> {
while !self.should_exit { while !self.should_exit {
terminal.draw(|frame| self.render(frame))?; terminal.draw(|frame| self.render(frame))?;
self.spinner_state = (self.spinner_state + 1) % 10;
// Update spinner animation every 80ms // Update spinner animation every 80ms
if self.search_active self.last_spinner_update = Instant::now();
&& self.last_spinner_update.elapsed()
>= Duration::from_millis(80)
{
self.spinner_state = (self.spinner_state + 1) % 10;
self.last_spinner_update = Instant::now();
}
// Poll for new search results if active // Poll for new search results if active
if self.search_active { if self.search_active {
if let Some(ref client) = self.client { if let Some(ref client) = self.client {
let current_count = client.get_search_results_count(); let current_count = client.get_search_results_count();
if current_count != self.last_result_count { if current_count != self.last_result_count {
// New results available - update the list
self.update_results_from_client(); self.update_results_from_client();
self.last_result_count = current_count; self.last_result_count = current_count;
} }
@@ -177,21 +172,23 @@ impl FileSelector {
return; return;
} }
if self.is_searching { if self.is_filtering {
match key.code { match key.code {
KeyCode::Char(' ') => { KeyCode::Char(' ') => {
self.toggle_selection(); self.toggle_selection();
} }
KeyCode::Char(c) => { KeyCode::Char(c) => {
self.search_query.push(c); self.filter_query.push(c);
self.apply_filter(); self.apply_filter();
} }
KeyCode::Backspace => { KeyCode::Backspace => {
self.search_query.pop(); self.filter_query.pop();
self.apply_filter(); self.apply_filter();
} }
KeyCode::Esc => { KeyCode::Esc => {
self.is_searching = false; self.filter_query.clear();
self.apply_filter();
self.is_filtering = false;
} }
KeyCode::Enter => { KeyCode::Enter => {
// If nothing selected, select the current item under cursor // If nothing selected, select the current item under cursor
@@ -221,7 +218,7 @@ impl FileSelector {
self.toggle_selection(); self.toggle_selection();
} }
KeyCode::Char('/') => { KeyCode::Char('/') => {
self.is_searching = true; self.is_filtering = true;
} }
KeyCode::Up | KeyCode::Char('k') => self.select_previous(), KeyCode::Up | KeyCode::Char('k') => self.select_previous(),
KeyCode::Down | KeyCode::Char('j') => self.select_next(), KeyCode::Down | KeyCode::Char('j') => self.select_next(),
@@ -284,7 +281,7 @@ impl FileSelector {
} }
fn apply_filter(&mut self) { fn apply_filter(&mut self) {
let query_lower = self.search_query.to_lowercase(); let query_lower = self.filter_query.to_lowercase();
if query_lower.is_empty() { if query_lower.is_empty() {
self.items = self.all_items.clone(); self.items = self.all_items.clone();
@@ -314,9 +311,9 @@ impl FileSelector {
let area = frame.area(); let area = frame.area();
// Always split layout into table, optional info, and controls footer // Always split layout into table, optional info, and controls footer
let has_info = self.is_searching let has_info = self.is_filtering
|| !self.selected_indices.is_empty() || !self.selected_indices.is_empty()
|| !self.search_query.is_empty(); || !self.filter_query.is_empty();
let chunks = if has_info { let chunks = if has_info {
Layout::vertical([ Layout::vertical([
@@ -350,7 +347,7 @@ impl FileSelector {
elapsed, elapsed,
total total
) )
} else if self.is_searching { } else if self.is_filtering {
format!( format!(
"Multi-select files to download ({}/{} matches)", "Multi-select files to download ({}/{} matches)",
self.items.len(), self.items.len(),
@@ -449,12 +446,29 @@ impl FileSelector {
&mut self.state, &mut self.state,
); );
// Show loading placeholder when search is active but no results yet if self.items.is_empty() {
if self.search_active && self.items.is_empty() {
let spinner = get_spinner_char(self.spinner_state); let spinner = get_spinner_char(self.spinner_state);
let loading_text = let mut loading_message: Vec<Span> = Vec::new();
format!("{} Searching for '{}'", spinner, self.soulseek_query); 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 // Center the loading message
let vertical = Layout::vertical([ let vertical = Layout::vertical([
@@ -464,8 +478,10 @@ impl FileSelector {
]) ])
.split(table_area); .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 // 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 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 max_width = (table_area.width * 80) / 100;
let widget_width = text_width.min(max_width); let widget_width = text_width.min(max_width);
@@ -476,8 +492,8 @@ impl FileSelector {
]) ])
.split(vertical[1]); .split(vertical[1]);
let loading_widget = Paragraph::new(loading_text) let loading_widget = Paragraph::new(loading_message_line)
.style(primary_style()) .style(Style::default())
.block( .block(
Block::default() Block::default()
.borders(Borders::ALL) .borders(Borders::ALL)
@@ -491,17 +507,17 @@ impl FileSelector {
} }
if let Some(info_area) = info_area { if let Some(info_area) = info_area {
let (info_text, title, style) = if self.is_searching { let (info_text, title, style) = if self.is_filtering {
( (
format!("Search: {}", self.search_query), format!("Filter: {}", self.filter_query),
"Filter", "Filter",
warning_style(), warning_style(),
) )
} else if !self.search_query.is_empty() { } else if !self.filter_query.is_empty() {
( (
format!( format!(
"Current filter: {} (press / to modify, c to clear)", "Current filter: {} (press / to modify, Esc to clear)",
self.search_query self.filter_query
), ),
"Filter", "Filter",
primary_style(), primary_style(),
@@ -534,13 +550,13 @@ impl FileSelector {
} }
fn render_controls(&self, frame: &mut Frame, area: ratatui::layout::Rect) { fn render_controls(&self, frame: &mut Frame, area: ratatui::layout::Rect) {
let controls_line = if self.is_searching { let controls_line = if self.is_filtering {
format_shortcuts_styled(&[ format_shortcuts_styled(&[
("Space", "toggle"), ("Space", "toggle"),
("a", "select-all"), ("a", "select-all"),
("A", "deselect-all"), ("A", "deselect-all"),
("Enter", "download"), ("Enter", "download"),
("Esc", "exit search"), ("Esc", "exit filter"),
]) ])
} else if self.search_active { } else if self.search_active {
format_shortcuts_styled(&[ format_shortcuts_styled(&[
@@ -557,7 +573,7 @@ impl FileSelector {
("A", "deselect-all"), ("A", "deselect-all"),
("Enter", "download"), ("Enter", "download"),
("Esc/q", "cancel"), ("Esc/q", "cancel"),
("/", "search"), ("/", "filter"),
]) ])
}; };