diff --git a/soulseek-rs-lib/src/client.rs b/soulseek-rs-lib/src/client.rs index c1900bb..4dbf643 100644 --- a/soulseek-rs-lib/src/client.rs +++ b/soulseek-rs-lib/src/client.rs @@ -373,7 +373,7 @@ impl Client { && (filename.is_none_or(|f| download.filename == *f)) }) .map(|(token, download)| { - download.sender.send(DownloadStatus::Failed).unwrap(); + let _ = download.sender.send(DownloadStatus::Failed); *token }) .collect(); @@ -494,13 +494,13 @@ impl Client { None ) { Ok((download, filename)) => { - download.sender.send(DownloadStatus::Completed).unwrap(); + let _ = download.sender.send(DownloadStatus::Completed); info!("Successfully downloaded {} bytes to {}", download.size, filename); } Err(e) => { - download.sender.send(DownloadStatus::Failed).unwrap(); + let _ = download.sender.send(DownloadStatus::Failed); error!( - "Failed to download file '{}' from {}:{} (token: {}) - Error: {}", + "Failed to download file '{}' from {}:{} (token: {}) - Error: {}", filename, peer.host, peer.port, download.token, e ); } @@ -744,10 +744,7 @@ impl Client { filename, download.size ); - download - .sender - .send(DownloadStatus::Completed) - .unwrap(); + let _ = download.sender.send(DownloadStatus::Completed); } Err(e) => { trace!("[client] failed to download: {}", e); diff --git a/soulseek-rs-lib/src/peer/listen.rs b/soulseek-rs-lib/src/peer/listen.rs index 080fc31..9d3e1e9 100644 --- a/soulseek-rs-lib/src/peer/listen.rs +++ b/soulseek-rs-lib/src/peer/listen.rs @@ -157,7 +157,7 @@ fn handle_file_connection( Some(stream), ) { Ok((download, filename)) => { - download.sender.send(DownloadStatus::Completed).unwrap(); + let _ = download.sender.send(DownloadStatus::Completed); info!( "Successfully downloaded {} bytes to {}", download.size, filename diff --git a/soulseek-rs/src/cli.rs b/soulseek-rs/src/cli.rs index f0cb6d5..eeb04d9 100644 --- a/soulseek-rs/src/cli.rs +++ b/soulseek-rs/src/cli.rs @@ -6,8 +6,7 @@ use clap::{Parser, Subcommand}; author, version, about = "Soulseek client in Rust 🦀", - long_about = None, - arg_required_else_help = true + long_about = None )] pub struct Cli { #[arg(short, long, env = "SOULSEEK_USERNAME")] @@ -34,7 +33,21 @@ pub struct Cli { pub verbose: u8, #[command(subcommand)] - pub command: Commands, + pub command: Option, + + #[arg(short, long, default_value = "~/Downloads")] + pub download_dir: String, + + #[arg( + short = 'c', + long, + env = "MAX_CONCURRENT_DOWNLOADS", + default_value = "5" + )] + pub max_concurrent_downloads: usize, + + #[arg(long, default_value = "10")] + pub search_timeout: u64, } #[derive(Subcommand, Debug)] diff --git a/soulseek-rs/src/main.rs b/soulseek-rs/src/main.rs index f529acf..682243d 100644 --- a/soulseek-rs/src/main.rs +++ b/soulseek-rs/src/main.rs @@ -13,7 +13,7 @@ use std::{ sync::{atomic::AtomicBool, Arc}, time::Duration, }; -use ui::{show_multi_download_progress, FileSelector}; +use ui::{launch_main_tui, show_multi_download_progress, FileSelector}; fn main() -> Result<()> { color_eyre::install()?; @@ -38,12 +38,12 @@ fn main() -> Result<()> { let (server_host, server_port) = parse_server_address(&cli.server)?; match cli.command { - Commands::Search { + Some(Commands::Search { query, timeout, download_dir, max_concurrent_downloads, - } => { + }) => { let config = SearchConfig { username, password, @@ -59,6 +59,52 @@ fn main() -> Result<()> { }; search_and_download(config) } + None => { + // Launch main TUI + // Enable logger buffering BEFORE connection to prevent log artifacts + soulseek_rs::utils::logger::enable_buffering(); + + let settings = ClientSettings { + username: username.clone(), + password: password.clone(), + server_address: PeerAddress::new( + server_host.clone(), + server_port, + ), + enable_listen: !cli.disable_listener, + listen_port: cli.listener_port, + }; + + let mut client = Client::with_settings(settings); + client.connect(); + client.login().map_err(|e| { + color_eyre::eyre::eyre!("Failed to login: {}", e) + })?; + + let client = Arc::new(client); + + // Clear screen and enable mouse capture before initializing TUI + use ratatui::crossterm::{ + event::EnableMouseCapture, + execute, + terminal::{Clear, ClearType}, + }; + let _ = execute!( + std::io::stdout(), + Clear(ClearType::All), + EnableMouseCapture + ); + + let terminal = ratatui::init(); + + launch_main_tui( + terminal, + client, + cli.download_dir, + cli.max_concurrent_downloads, + Duration::from_secs(cli.search_timeout), + ) + } } } diff --git a/soulseek-rs/src/models/app_state.rs b/soulseek-rs/src/models/app_state.rs new file mode 100644 index 0000000..9eefa2c --- /dev/null +++ b/soulseek-rs/src/models/app_state.rs @@ -0,0 +1,127 @@ +use crate::models::{FileDisplayData, FileDownloadState}; +use ratatui::{layout::Rect, widgets::TableState}; +use soulseek_rs::DownloadStatus; +use std::sync::atomic::AtomicBool; +use std::sync::{mpsc::Receiver, Arc}; +use std::time::Instant; + +#[derive(Debug, Clone, PartialEq)] +pub enum SearchStatus { + Active, + Completed, + Failed(String), +} + +pub struct SearchEntry { + pub query: String, + pub status: SearchStatus, + pub results: Vec, + pub start_time: Instant, + #[allow(dead_code)] + pub cancel_flag: Arc, +} + +pub struct DownloadEntry { + pub state: FileDownloadState, + pub receiver: Option>, +} + +#[derive(Debug, Clone, Copy, PartialEq)] +pub enum FocusedPane { + Searches, + Results, + Downloads, +} + +pub struct AppState { + // Searches + pub searches: Vec, + pub searches_table_state: TableState, + pub selected_search_index: Option, + + // Results + pub results_items: Vec, + pub results_filtered_items: Vec, + pub results_filtered_indices: Vec, + pub results_table_state: TableState, + pub results_selected_indices: std::collections::HashSet, + pub results_filter_query: String, + pub results_is_filtering: bool, + + // Downloads + pub downloads: Vec, + pub downloads_table_state: TableState, + pub downloads_receiver_channel: + Option)>>, + pub active_downloads_count: usize, + + // UI State + pub focused_pane: FocusedPane, + pub should_exit: bool, + pub command_bar_active: bool, + pub command_bar_input: String, + + // Pane areas for mouse interaction + pub searches_pane_area: Option, + pub results_pane_area: Option, + pub downloads_pane_area: Option, +} + +impl AppState { + pub fn new() -> Self { + let mut searches_table_state = TableState::default(); + searches_table_state.select(Some(0)); + + let mut results_table_state = TableState::default(); + results_table_state.select(Some(0)); + + let mut downloads_table_state = TableState::default(); + downloads_table_state.select(Some(0)); + + Self { + searches: Vec::new(), + searches_table_state, + selected_search_index: None, + + results_items: Vec::new(), + results_filtered_items: Vec::new(), + results_filtered_indices: Vec::new(), + results_table_state, + results_selected_indices: std::collections::HashSet::new(), + results_filter_query: String::new(), + results_is_filtering: false, + + downloads: Vec::new(), + downloads_table_state, + downloads_receiver_channel: None, + active_downloads_count: 0, + + focused_pane: FocusedPane::Searches, + should_exit: false, + command_bar_active: false, + command_bar_input: String::new(), + + searches_pane_area: None, + results_pane_area: None, + downloads_pane_area: None, + } + } + + #[allow(dead_code)] + pub fn get_selected_search(&self) -> Option<&SearchEntry> { + self.selected_search_index + .and_then(|idx| self.searches.get(idx)) + } + + #[allow(dead_code)] + pub fn get_selected_search_mut(&mut self) -> Option<&mut SearchEntry> { + self.selected_search_index + .and_then(|idx| self.searches.get_mut(idx)) + } +} + +impl Default for AppState { + fn default() -> Self { + Self::new() + } +} diff --git a/soulseek-rs/src/models/mod.rs b/soulseek-rs/src/models/mod.rs index 14ee190..2a69b0a 100644 --- a/soulseek-rs/src/models/mod.rs +++ b/soulseek-rs/src/models/mod.rs @@ -1,5 +1,9 @@ +mod app_state; mod download_state; mod file_display_data; +pub use app_state::{ + AppState, DownloadEntry, FocusedPane, SearchEntry, SearchStatus, +}; pub use download_state::FileDownloadState; pub use file_display_data::FileDisplayData; diff --git a/soulseek-rs/src/ui/downloads.rs b/soulseek-rs/src/ui/downloads.rs index cef2f9c..9bc0eb5 100644 --- a/soulseek-rs/src/ui/downloads.rs +++ b/soulseek-rs/src/ui/downloads.rs @@ -24,6 +24,8 @@ use std::{ time::Duration, }; +const VERSION: &str = env!("CARGO_PKG_VERSION"); + pub struct MultiDownloadProgress { downloads: Vec, receivers: Vec>>, @@ -199,124 +201,7 @@ impl MultiDownloadProgress { } fn render_stats(&self, frame: &mut Frame, area: ratatui::layout::Rect) { - let completed = self - .downloads - .iter() - .filter(|d| matches!(d.status, DownloadStatus::Completed)) - .count(); - let failed = self - .downloads - .iter() - .filter(|d| { - matches!( - d.status, - DownloadStatus::Failed | DownloadStatus::TimedOut - ) - }) - .count(); - let queued = self - .downloads - .iter() - .filter(|d| matches!(d.status, DownloadStatus::Queued)) - .count(); - - let total_downloaded: u64 = - self.downloads.iter().map(|d| d.bytes_downloaded).sum(); - let total_size: u64 = - self.downloads.iter().map(|d| d.total_bytes).sum(); - let overall_progress = if total_size > 0 { - (total_downloaded as f64 / total_size as f64 * 100.0) as u8 - } 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 * 100.0).round() / 100.0; - - let block = Block::default() - .borders(Borders::ALL) - .border_type(border_type(false)) - .title("Status"); - - let inner_area = block.inner(area); - frame.render_widget(block, 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 = 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]); + render_download_stats(frame, area, &self.downloads, self.active_count); } fn render_downloads_list( @@ -442,6 +327,135 @@ impl MultiDownloadProgress { } } +/// Renders download statistics in a reusable way +pub fn render_download_stats( + frame: &mut Frame, + area: ratatui::layout::Rect, + downloads: &[FileDownloadState], + active_count: usize, +) { + let completed = downloads + .iter() + .filter(|d| matches!(d.status, DownloadStatus::Completed)) + .count(); + let failed = downloads + .iter() + .filter(|d| { + matches!( + d.status, + DownloadStatus::Failed | DownloadStatus::TimedOut + ) + }) + .count(); + let queued = downloads + .iter() + .filter(|d| matches!(d.status, DownloadStatus::Queued)) + .count(); + + let total_downloaded: u64 = + downloads.iter().map(|d| d.bytes_downloaded).sum(); + let total_size: u64 = downloads.iter().map(|d| d.total_bytes).sum(); + let overall_progress = if total_size > 0 { + (total_downloaded as f64 / total_size as f64 * 100.0) as u8 + } else { + 0 + }; + let progress_ratio = if total_size > 0 { + total_downloaded as f64 / total_size as f64 + } else { + 0.0 + }; + let total_speed: f64 = 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 * 100.0).round() / 100.0; + + let block = Block::default() + .borders(Borders::ALL) + .border_type(border_type(false)) + .title("Status"); + + let inner_area = block.inner(area); + frame.render_widget(block, 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("soulseek-rs 🦀 "), + Span::styled( + format!("v{} ", VERSION), + Style::default() + .fg(COLOR_PRIMARY) + .add_modifier(Modifier::BOLD), + ), + Span::raw(" Downloads: "), + Span::styled( + 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 = 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]); +} + pub fn show_multi_download_progress( terminal: DefaultTerminal, client: Arc, diff --git a/soulseek-rs/src/ui/main_tui.rs b/soulseek-rs/src/ui/main_tui.rs new file mode 100644 index 0000000..405a328 --- /dev/null +++ b/soulseek-rs/src/ui/main_tui.rs @@ -0,0 +1,893 @@ +use crate::models::{ + AppState, DownloadEntry, FileDisplayData, FileDownloadState, FocusedPane, + SearchEntry, SearchStatus, +}; +use crate::ui::panes::{ + render_downloads_pane, render_results_pane, render_searches_pane, + ResultsPaneParams, +}; +use crate::ui::{ + border_style, border_type, format_shortcuts_styled, render_download_stats, +}; +use color_eyre::Result; +use ratatui::{ + crossterm::event::{ + self, poll, Event, KeyCode, KeyEvent, KeyEventKind, MouseButton, + MouseEvent, MouseEventKind, + }, + layout::{Constraint, Layout}, + widgets::{Block, Borders, Paragraph}, + DefaultTerminal, Frame, +}; +use soulseek_rs::{Client, DownloadStatus}; +use std::{ + sync::{ + atomic::AtomicBool, + mpsc::{self}, + Arc, + }, + thread, + time::{Duration, Instant}, +}; + +pub struct MainTui { + client: Arc, + state: AppState, + download_dir: String, + #[allow(dead_code)] + max_concurrent_downloads: usize, + search_timeout: Duration, + #[allow(dead_code)] + spinner_state: usize, +} + +impl MainTui { + pub fn new( + client: Arc, + download_dir: String, + max_concurrent_downloads: usize, + search_timeout: Duration, + ) -> Self { + Self { + client, + state: AppState::new(), + download_dir, + max_concurrent_downloads, + search_timeout, + spinner_state: 0, + } + } + + pub fn run(mut self, mut terminal: DefaultTerminal) -> Result<()> { + while !self.state.should_exit { + terminal.draw(|frame| self.render(frame))?; + + // Poll for search results updates + self.update_search_results(); + + // Poll for download updates + self.update_downloads(); + + // Update spinner + self.spinner_state = (self.spinner_state + 1) % 10; + + // Poll for input events + if poll(Duration::from_millis(100))? { + match event::read()? { + Event::Key(key) if key.kind == KeyEventKind::Press => { + self.handle_key_event(key)?; + } + Event::Mouse(mouse) => { + self.handle_mouse_event(mouse)?; + } + _ => {} + } + } + } + + // Disable mouse capture before exiting + use ratatui::crossterm::{event::DisableMouseCapture, execute}; + let _ = execute!(std::io::stdout(), DisableMouseCapture); + + soulseek_rs::utils::logger::disable_buffering(); + Ok(()) + } + + fn render(&mut self, frame: &mut Frame) { + // Create layout based on whether command bar is active + let main_chunks = if self.state.command_bar_active { + Layout::vertical([ + Constraint::Length(3), // Status bar + Constraint::Fill(1), // Main content + Constraint::Length(3), // Command bar + Constraint::Length(3), // Shortcuts + ]) + .split(frame.area()) + } else { + Layout::vertical([ + Constraint::Length(3), // Status bar + Constraint::Fill(1), // Main content + Constraint::Length(3), // Shortcuts + ]) + .split(frame.area()) + }; + + // Render status bar + let download_states: Vec = self + .state + .downloads + .iter() + .map(|d| d.state.clone()) + .collect(); + render_download_stats( + frame, + main_chunks[0], + &download_states, + self.state.active_downloads_count, + ); + + // Split main content area + let content_chunks = Layout::horizontal([ + Constraint::Percentage(30), // Searches pane + Constraint::Percentage(70), // Results + Downloads + ]) + .split(main_chunks[1]); + + // Store searches pane area + self.state.searches_pane_area = Some(content_chunks[0]); + + // Render Searches pane (left) + render_searches_pane( + frame, + content_chunks[0], + &self.state.searches, + &mut self.state.searches_table_state, + self.state.focused_pane == FocusedPane::Searches, + ); + + // Split right side into Results (top) and Downloads (bottom) + let right_chunks = Layout::vertical([ + Constraint::Percentage(60), // Results + Constraint::Percentage(40), // Downloads + ]) + .split(content_chunks[1]); + + // Store results and downloads pane areas + self.state.results_pane_area = Some(right_chunks[0]); + self.state.downloads_pane_area = Some(right_chunks[1]); + + // Render Results pane + let results_items = if self.state.results_filter_query.is_empty() { + &self.state.results_items + } else { + &self.state.results_filtered_items + }; + + let active_search_query = self + .state + .selected_search_index + .and_then(|idx| self.state.searches.get(idx)) + .map(|search| search.query.as_str()); + + render_results_pane( + frame, + right_chunks[0], + ResultsPaneParams { + items: results_items, + table_state: &mut self.state.results_table_state, + selected_indices: &self.state.results_selected_indices, + filter_query: &self.state.results_filter_query, + is_filtering: self.state.results_is_filtering, + focused: self.state.focused_pane == FocusedPane::Results, + active_search_query, + }, + ); + + // Render Downloads pane + let download_states: Vec = self + .state + .downloads + .iter() + .map(|d| d.state.clone()) + .collect(); + render_downloads_pane( + frame, + right_chunks[1], + &download_states, + &mut self.state.downloads_table_state, + self.state.focused_pane == FocusedPane::Downloads, + ); + + // Render command bar if active (vim-style, above shortcuts) + if self.state.command_bar_active { + self.render_command_bar(frame, main_chunks[2]); + self.render_shortcuts(frame, main_chunks[3]); + } else { + self.render_shortcuts(frame, main_chunks[2]); + } + } + + fn render_shortcuts(&self, frame: &mut Frame, area: ratatui::layout::Rect) { + let shortcuts = match self.state.focused_pane { + FocusedPane::Searches => vec![ + ("s", "search"), + ("1-3", "focus pane"), + ("↑↓", "navigate"), + ("Enter", "view results"), + ("d", "remove search"), + ("C", "clear all"), + ("q", "quit"), + ], + FocusedPane::Results if self.state.results_is_filtering => vec![ + ("Type", "filter"), + ("Esc", "clear filter"), + ("1-3", "focus pane"), + ("q", "quit"), + ], + FocusedPane::Results => vec![ + ("Space", "select"), + ("/", "filter"), + ("a/A", "select all/none"), + ("Enter", "download"), + ("1-3", "focus pane"), + ("↑↓", "navigate"), + ("q", "quit"), + ], + FocusedPane::Downloads => { + vec![("1-3", "focus pane"), ("↑↓", "navigate"), ("q", "quit")] + } + }; + + let shortcuts_line = format_shortcuts_styled(&shortcuts); + let shortcuts_widget = Paragraph::new(shortcuts_line).block( + Block::default() + .borders(Borders::ALL) + .border_type(border_type(false)) + .title("Shortcuts"), + ); + + frame.render_widget(shortcuts_widget, area); + } + + fn render_command_bar( + &self, + frame: &mut Frame, + area: ratatui::layout::Rect, + ) { + // Vim-style command bar with ":" prefix + let command_text = format!("search: {}", self.state.command_bar_input); + + let paragraph = Paragraph::new(command_text).block( + Block::default() + .borders(Borders::ALL) + .border_style(border_style(true)) + .border_type(border_type(true)), + ); + + frame.render_widget(paragraph, area); + } + + fn handle_key_event(&mut self, key: KeyEvent) -> Result<()> { + // Command bar takes priority + if self.state.command_bar_active { + return self.handle_command_bar_input(key); + } + + // Filter mode in Results pane + if self.state.results_is_filtering + && self.state.focused_pane == FocusedPane::Results + { + return self.handle_filter_input(key); + } + + // Global shortcuts + match key.code { + KeyCode::Char('q') => { + self.state.should_exit = true; + return Ok(()); + } + KeyCode::Char('1') => { + self.state.focused_pane = FocusedPane::Searches; + return Ok(()); + } + KeyCode::Char('2') => { + self.state.focused_pane = FocusedPane::Results; + return Ok(()); + } + KeyCode::Char('3') => { + self.state.focused_pane = FocusedPane::Downloads; + return Ok(()); + } + KeyCode::Char('s') => { + self.state.command_bar_active = true; + self.state.command_bar_input.clear(); + return Ok(()); + } + _ => {} + } + + // Pane-specific shortcuts + match self.state.focused_pane { + FocusedPane::Searches => self.handle_searches_input(key), + FocusedPane::Results => self.handle_results_input(key), + FocusedPane::Downloads => self.handle_downloads_input(key), + } + } + + fn handle_command_bar_input(&mut self, key: KeyEvent) -> Result<()> { + match key.code { + KeyCode::Enter => { + let query = self.state.command_bar_input.trim().to_string(); + if !query.is_empty() { + self.start_search(query); + } + self.state.command_bar_active = false; + self.state.command_bar_input.clear(); + } + KeyCode::Esc => { + self.state.command_bar_active = false; + self.state.command_bar_input.clear(); + } + KeyCode::Char(c) => { + self.state.command_bar_input.push(c); + } + KeyCode::Backspace => { + self.state.command_bar_input.pop(); + } + _ => {} + } + Ok(()) + } + + fn handle_filter_input(&mut self, key: KeyEvent) -> Result<()> { + match key.code { + KeyCode::Esc => { + self.state.results_is_filtering = false; + self.state.results_filter_query.clear(); + self.state.results_filtered_items = + self.state.results_items.clone(); + self.state.results_filtered_indices = + (0..self.state.results_items.len()).collect(); + } + KeyCode::Char(c) => { + self.state.results_filter_query.push(c); + self.apply_filter(); + } + KeyCode::Backspace => { + self.state.results_filter_query.pop(); + self.apply_filter(); + } + _ => {} + } + Ok(()) + } + + fn handle_searches_input(&mut self, key: KeyEvent) -> Result<()> { + match key.code { + KeyCode::Up | KeyCode::Char('k') => { + if !self.state.searches.is_empty() { + let current = + self.state.searches_table_state.selected().unwrap_or(0); + let new = if current == 0 { + self.state.searches.len() - 1 + } else { + current - 1 + }; + self.state.searches_table_state.select(Some(new)); + } + } + KeyCode::Down | KeyCode::Char('j') => { + if !self.state.searches.is_empty() { + let current = + self.state.searches_table_state.selected().unwrap_or(0); + let new = (current + 1) % self.state.searches.len(); + self.state.searches_table_state.select(Some(new)); + } + } + KeyCode::Enter => { + if let Some(selected) = + self.state.searches_table_state.selected() + { + self.state.selected_search_index = Some(selected); + if let Some(search) = self.state.searches.get(selected) { + self.state.results_items = search.results.clone(); + self.state.results_filtered_items = + search.results.clone(); + self.state.results_filtered_indices = + (0..search.results.len()).collect(); + self.state.results_selected_indices.clear(); + self.state.results_table_state.select(Some(0)); + self.state.focused_pane = FocusedPane::Results; + } + } + } + KeyCode::Char('d') => { + if let Some(selected) = + self.state.searches_table_state.selected() + { + self.remove_search_at_index(selected); + } + } + KeyCode::Char('C') => { + self.clear_all_searches(); + } + _ => {} + } + Ok(()) + } + + fn handle_results_input(&mut self, key: KeyEvent) -> Result<()> { + let items_count = if self.state.results_filter_query.is_empty() { + self.state.results_items.len() + } else { + self.state.results_filtered_items.len() + }; + + match key.code { + KeyCode::Up | KeyCode::Char('k') => { + if items_count > 0 { + let current = + self.state.results_table_state.selected().unwrap_or(0); + let new = if current == 0 { + items_count - 1 + } else { + current - 1 + }; + self.state.results_table_state.select(Some(new)); + } + } + KeyCode::Down | KeyCode::Char('j') => { + if items_count > 0 { + let current = + self.state.results_table_state.selected().unwrap_or(0); + let new = (current + 1) % items_count; + self.state.results_table_state.select(Some(new)); + } + } + KeyCode::Char(' ') => { + if let Some(current) = self.state.results_table_state.selected() + { + let actual_index = + if self.state.results_filter_query.is_empty() { + current + } else { + self.state.results_filtered_indices[current] + }; + + if self + .state + .results_selected_indices + .contains(&actual_index) + { + self.state + .results_selected_indices + .remove(&actual_index); + } else { + self.state + .results_selected_indices + .insert(actual_index); + } + } + } + KeyCode::Char('/') => { + self.state.results_is_filtering = true; + self.state.results_filter_query.clear(); + } + KeyCode::Char('a') => { + let indices: Vec = + if self.state.results_filter_query.is_empty() { + (0..self.state.results_items.len()).collect() + } else { + self.state.results_filtered_indices.clone() + }; + self.state.results_selected_indices.extend(indices); + } + KeyCode::Char('A') => { + self.state.results_selected_indices.clear(); + } + KeyCode::Enter => { + self.queue_selected_downloads(); + } + _ => {} + } + Ok(()) + } + + fn handle_downloads_input(&mut self, key: KeyEvent) -> Result<()> { + match key.code { + KeyCode::Up | KeyCode::Char('k') => { + if !self.state.downloads.is_empty() { + let current = self + .state + .downloads_table_state + .selected() + .unwrap_or(0); + let new = if current == 0 { + self.state.downloads.len() - 1 + } else { + current - 1 + }; + self.state.downloads_table_state.select(Some(new)); + } + } + KeyCode::Down | KeyCode::Char('j') => { + if !self.state.downloads.is_empty() { + let current = self + .state + .downloads_table_state + .selected() + .unwrap_or(0); + let new = (current + 1) % self.state.downloads.len(); + self.state.downloads_table_state.select(Some(new)); + } + } + _ => {} + } + Ok(()) + } + + fn handle_mouse_event(&mut self, mouse: MouseEvent) -> Result<()> { + if let MouseEventKind::Down(MouseButton::Left) = mouse.kind { + let (col, row) = (mouse.column, mouse.row); + + // Check if click is within searches pane + if let Some(area) = self.state.searches_pane_area { + if col >= area.x + && col < area.x + area.width + && row >= area.y + && row < area.y + area.height + { + self.state.focused_pane = FocusedPane::Searches; + return Ok(()); + } + } + + // Check if click is within results pane + if let Some(area) = self.state.results_pane_area { + if col >= area.x + && col < area.x + area.width + && row >= area.y + && row < area.y + area.height + { + self.state.focused_pane = FocusedPane::Results; + return Ok(()); + } + } + + // Check if click is within downloads pane + if let Some(area) = self.state.downloads_pane_area { + if col >= area.x + && col < area.x + area.width + && row >= area.y + && row < area.y + area.height + { + self.state.focused_pane = FocusedPane::Downloads; + return Ok(()); + } + } + } + + Ok(()) + } + + fn remove_search_at_index(&mut self, index: usize) { + if index >= self.state.searches.len() { + return; + } + + // Cancel the search if it's active + if let Some(search) = self.state.searches.get(index) { + search + .cancel_flag + .store(true, std::sync::atomic::Ordering::Relaxed); + } + + // Check if we're removing the currently active search + let was_active_search = self.state.selected_search_index == Some(index); + + // Remove the search + self.state.searches.remove(index); + + // Update selected_search_index + if let Some(current_idx) = self.state.selected_search_index { + if current_idx == index { + // Removed the active search - clear it + self.state.selected_search_index = None; + } else if current_idx > index { + // Active search was after removed one - decrement index + self.state.selected_search_index = Some(current_idx - 1); + } + // If current_idx < index, no change needed + } + + // If we removed the active search, clear results pane + if was_active_search { + self.clear_results_pane(); + } + + // Update table selection + if self.state.searches.is_empty() { + self.state.searches_table_state.select(None); + } else { + let new_selection = if index >= self.state.searches.len() { + self.state.searches.len() - 1 + } else { + index + }; + self.state.searches_table_state.select(Some(new_selection)); + } + } + + fn clear_all_searches(&mut self) { + // Cancel all active searches + for search in &self.state.searches { + search + .cancel_flag + .store(true, std::sync::atomic::Ordering::Relaxed); + } + + // Clear searches + self.state.searches.clear(); + self.state.searches_table_state.select(None); + self.state.selected_search_index = None; + + // Clear results pane + self.clear_results_pane(); + } + + fn clear_results_pane(&mut self) { + self.state.results_items.clear(); + self.state.results_filtered_items.clear(); + self.state.results_filtered_indices.clear(); + self.state.results_selected_indices.clear(); + self.state.results_table_state.select(None); + self.state.results_filter_query.clear(); + self.state.results_is_filtering = false; + } + + fn apply_filter(&mut self) { + let query = self.state.results_filter_query.to_lowercase(); + if query.is_empty() { + self.state.results_filtered_items = + self.state.results_items.clone(); + self.state.results_filtered_indices = + (0..self.state.results_items.len()).collect(); + } else { + self.state.results_filtered_items.clear(); + self.state.results_filtered_indices.clear(); + + for (idx, item) in self.state.results_items.iter().enumerate() { + if item.filename.to_lowercase().contains(&query) + || item.username.to_lowercase().contains(&query) + { + self.state.results_filtered_items.push(item.clone()); + self.state.results_filtered_indices.push(idx); + } + } + } + + if !self.state.results_filtered_items.is_empty() { + self.state.results_table_state.select(Some(0)); + } + } + + fn start_search(&mut self, query: String) { + let cancel_flag = Arc::new(AtomicBool::new(false)); + let search_entry = SearchEntry { + query: query.clone(), + status: SearchStatus::Active, + results: Vec::new(), + start_time: Instant::now(), + cancel_flag: cancel_flag.clone(), + }; + + self.state.searches.push(search_entry); + let search_index = self.state.searches.len() - 1; + self.state.searches_table_state.select(Some(search_index)); + + // Make this search the active one + self.state.selected_search_index = Some(search_index); + + // Initialize results display (empty at first) + self.state.results_items.clear(); + self.state.results_filtered_items.clear(); + self.state.results_filtered_indices.clear(); + self.state.results_selected_indices.clear(); + self.state.results_table_state.select(Some(0)); + + // Switch focus to Results pane + self.state.focused_pane = FocusedPane::Results; + + // Start search in background + let client = self.client.clone(); + let timeout = self.search_timeout; + + thread::spawn(move || { + match client.search_with_cancel( + &query, + timeout, + Some(cancel_flag.clone()), + ) { + Ok(_results) => { + // Results will be polled in update_search_results + } + Err(e) => { + eprintln!("Search failed: {}", e); + } + } + }); + } + + fn update_search_results(&mut self) { + let timeout = self.search_timeout; + let selected_search_index = self.state.selected_search_index; + + for (idx, search) in self.state.searches.iter_mut().enumerate() { + if search.status != SearchStatus::Active { + continue; + } + + // Check timeout + if search.start_time.elapsed() > timeout { + search.status = SearchStatus::Failed("Timeout".to_string()); + continue; + } + + // Poll for new results + let search_query = search.query.clone(); + let search_results = self.client.get_search_results(&search_query); + + if !search_results.is_empty() { + search.results.clear(); + for result in search_results { + for file in result.files { + search.results.push(FileDisplayData { + filename: file.name.clone(), + size: file.size, + username: result.username.clone(), + speed: result.speed, + slots: result.slots, + bitrate: file.attribs.get(&0).copied(), + }); + } + } + + // Update selected search if this is the active one + if let Some(selected_idx) = selected_search_index { + if selected_idx == idx { + self.state.results_items = search.results.clone(); + self.state.results_filtered_items = + search.results.clone(); + self.state.results_filtered_indices = + (0..search.results.len()).collect(); + } + } + } + + // Mark as completed if we have results and some time has passed + if !search.results.is_empty() + && search.start_time.elapsed() > Duration::from_secs(5) + { + search.status = SearchStatus::Completed; + } + } + } + + fn queue_selected_downloads(&mut self) { + let selected_files: Vec<(String, String, u64)> = self + .state + .results_selected_indices + .iter() + .filter_map(|&idx| self.state.results_items.get(idx)) + .map(|file| { + (file.filename.clone(), file.username.clone(), file.size) + }) + .collect(); + + if selected_files.is_empty() { + return; + } + + // Create channel for async download initialization + let (sender, receiver) = mpsc::channel(); + self.state.downloads_receiver_channel = Some(receiver); + + // Add downloads to state + for (filename, username, size) in &selected_files { + let download_state = FileDownloadState::new( + filename.clone(), + username.clone(), + *size, + ); + self.state.downloads.push(DownloadEntry { + state: download_state, + receiver: None, + }); + } + + // Start downloads in background + let client = self.client.clone(); + let download_dir = self.download_dir.clone(); + + thread::spawn(move || { + for (idx, (filename, username, size)) in + selected_files.into_iter().enumerate() + { + match client.download( + filename.clone(), + username.clone(), + size, + download_dir.clone(), + ) { + Ok(rx) => { + let _ = sender.send((idx, rx)); + } + Err(e) => { + eprintln!( + "Failed to start download for {}: {}", + filename, e + ); + } + } + } + }); + + // Clear selection + self.state.results_selected_indices.clear(); + } + + fn update_downloads(&mut self) { + // Check for new download receivers + if let Some(ref receiver) = self.state.downloads_receiver_channel { + while let Ok((idx, download_receiver)) = receiver.try_recv() { + if let Some(download) = self.state.downloads.get_mut(idx) { + download.receiver = Some(download_receiver); + } + } + } + + // Update download states + self.state.active_downloads_count = 0; + for download in &mut self.state.downloads { + if let Some(ref receiver) = download.receiver { + while let Ok(status) = receiver.try_recv() { + download.state.status = status.clone(); + if let DownloadStatus::InProgress { + bytes_downloaded, + total_bytes, + speed_bytes_per_sec, + } = &status + { + download.state.bytes_downloaded = *bytes_downloaded; + download.state.total_bytes = *total_bytes; + download.state.speed_bytes_per_sec = + *speed_bytes_per_sec; + } + } + } + + if matches!( + download.state.status, + DownloadStatus::InProgress { .. } + ) { + self.state.active_downloads_count += 1; + } + } + } +} + +pub fn launch_main_tui( + terminal: DefaultTerminal, + client: Arc, + download_dir: String, + max_concurrent_downloads: usize, + search_timeout: Duration, +) -> Result<()> { + let tui = MainTui::new( + client, + download_dir, + max_concurrent_downloads, + search_timeout, + ); + tui.run(terminal) +} diff --git a/soulseek-rs/src/ui/mod.rs b/soulseek-rs/src/ui/mod.rs index e3df545..e00a956 100644 --- a/soulseek-rs/src/ui/mod.rs +++ b/soulseek-rs/src/ui/mod.rs @@ -1,9 +1,12 @@ mod download_selector; mod downloads; +mod main_tui; +mod panes; mod styles; mod utils; pub use download_selector::FileSelector; -pub use downloads::show_multi_download_progress; +pub use downloads::{render_download_stats, show_multi_download_progress}; +pub use main_tui::launch_main_tui; pub use styles::*; pub use utils::*; diff --git a/soulseek-rs/src/ui/panes/downloads_pane.rs b/soulseek-rs/src/ui/panes/downloads_pane.rs new file mode 100644 index 0000000..65ffd01 --- /dev/null +++ b/soulseek-rs/src/ui/panes/downloads_pane.rs @@ -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 = 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); +} diff --git a/soulseek-rs/src/ui/panes/mod.rs b/soulseek-rs/src/ui/panes/mod.rs new file mode 100644 index 0000000..61521e0 --- /dev/null +++ b/soulseek-rs/src/ui/panes/mod.rs @@ -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; diff --git a/soulseek-rs/src/ui/panes/results_pane.rs b/soulseek-rs/src/ui/panes/results_pane.rs new file mode 100644 index 0000000..9095f4b --- /dev/null +++ b/soulseek-rs/src/ui/panes/results_pane.rs @@ -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, + 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 = 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); +} diff --git a/soulseek-rs/src/ui/panes/searches_pane.rs b/soulseek-rs/src/ui/panes/searches_pane.rs new file mode 100644 index 0000000..431a4c4 --- /dev/null +++ b/soulseek-rs/src/ui/panes/searches_pane.rs @@ -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 = 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); +}