chore: improve download selector
This commit is contained in:
@@ -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"),
|
||||||
])
|
])
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user