use crate::models::{ AppState, BrowseState, BrowseStatus, ChatMessage, CommandBarMode, DownloadEntry, FileDisplayData, FocusedPane, MessageDirection, SearchEntry, SearchStatus, files_under, find_node, }; use crate::ui::panes::{ ResultsPaneParams, render_browse_pane, render_download_info_pane, render_downloads_pane, render_results_pane, render_searches_pane, }; use crate::ui::{ border_style, border_type, format_shortcuts_styled, render_download_stats, }; use color_eyre::Result; use ratatui::{ DefaultTerminal, Frame, crossterm::event::{ self, Event, KeyCode, KeyEvent, KeyEventKind, KeyModifiers, MouseButton, MouseEvent, MouseEventKind, poll, }, layout::{Constraint, Layout, Position, Rect}, widgets::{Block, Borders, Paragraph}, }; use soulseek_rs::{Client, DownloadStatus}; use std::{ sync::{ Arc, atomic::AtomicBool, mpsc::{self}, }, thread, time::{Duration, Instant}, }; const COMMAND_BAR_PREFIX: &str = "search: "; const MESSAGE_BAR_PREFIX: &str = "message (to: recipient text): "; const BROWSE_BAR_PREFIX: &str = "browse user: "; /// How long to wait for a browse response before showing a timeout notice. const BROWSE_TIMEOUT: Duration = Duration::from_secs(20); const fn command_bar_prefix(mode: CommandBarMode) -> &'static str { match mode { CommandBarMode::Search => COMMAND_BAR_PREFIX, CommandBarMode::Message => MESSAGE_BAR_PREFIX, CommandBarMode::Browse => BROWSE_BAR_PREFIX, } } 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<()> { use ratatui::crossterm::{event::DisableMouseCapture, execute}; // Run the event loop, then restore the terminal unconditionally: if the // loop returns early with an error the terminal must still be taken out // of raw mode / the alternate screen and mouse capture disabled, or the // user is left with a corrupted terminal. let result = self.run_event_loop(&mut terminal); let _ = execute!(std::io::stdout(), DisableMouseCapture); ratatui::restore(); soulseek_rs::utils::logger::disable_buffering(); result } fn run_event_loop(&mut self, terminal: &mut 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(); // Poll for incoming private messages self.poll_private_messages(); // Poll for a browse (shared-file listing) response self.poll_browse_result(); // 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); } _ => {} } } } 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 render_download_stats( frame, main_chunks[0], &self.state.downloads, 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]); // Split bottom-right into Downloads list and Info pane let downloads_chunks = Layout::horizontal([ Constraint::Percentage(60), // Downloads list Constraint::Percentage(40), // Info pane ]) .split(right_chunks[1]); // Store results and downloads pane areas self.state.results_pane_area = Some(right_chunks[0]); self.state.downloads_pane_area = Some(downloads_chunks[0]); // Render Results pane. When a filter is active the rendered rows are a // subset, so the pane also needs the mapping back to unfiltered indices // to render the selection checkboxes correctly. let (results_items, results_original_indices) = if self.state.results_filter_query.is_empty() { (&self.state.results_items, None) } else { ( &self.state.results_filtered_items, Some(self.state.results_filtered_indices.as_slice()), ) }; 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, original_indices: results_original_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 render_downloads_pane( frame, downloads_chunks[0], &self.state.downloads, &mut self.state.downloads_table_state, self.state.focused_pane == FocusedPane::Downloads, ); // Render Info pane next to downloads let selected_download = self .state .downloads_table_state .selected() .and_then(|idx| self.state.downloads.get(idx)); render_download_info_pane( frame, downloads_chunks[1], selected_download, 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]); } // Messages inbox overlays everything when open. if self.state.show_messages { self.render_messages_popup(frame); } // Browse tree overlays everything when open. if self.state.show_browse && let Some(browse) = self.state.browse.as_ref() { let area = centered_rect(80, 80, frame.area()); frame.render_widget(ratatui::widgets::Clear, area); render_browse_pane( frame, area, browse, &mut self.state.browse_table_state, self.spinner_state, ); } } fn render_messages_popup(&self, frame: &mut Frame) { let area = centered_rect(70, 60, frame.area()); let lines: Vec = if self.state.messages.is_empty() { vec![ratatui::text::Line::from( "No messages yet. Press 'm' to send one.", )] } else { self.state .messages .iter() .map(|m| { let (arrow, who) = match m.direction { MessageDirection::Incoming => ("⇦ from", &m.peer), MessageDirection::Outgoing => ("⇨ to ", &m.peer), }; ratatui::text::Line::from(format!( "{arrow} {who}: {}", m.text )) }) .collect() }; let popup = Paragraph::new(lines).block( Block::default() .borders(Borders::ALL) .border_style(border_style(true)) .border_type(border_type(true)) .title(" Messages (m: compose, i/Esc: close) "), ); frame.render_widget(ratatui::widgets::Clear, area); frame.render_widget(popup, area); } fn render_shortcuts(&self, frame: &mut Frame, area: Rect) { let shortcuts = if self.state.show_browse { vec![ ("↑↓", "move"), ("→←", "expand/collapse"), ("Enter", "open/download"), ("d", "download folder"), ("Esc", "close"), ] } else if self.state.command_bar_active { match self.state.command_bar_mode { CommandBarMode::Search => vec![ ("Type", "search term"), ("←→", "move cursor"), ("Backspace/Del", "edit"), ("Enter", "search"), ("Esc", "cancel"), ], CommandBarMode::Message => vec![ ("Type", "recipient then message"), ("Enter", "send"), ("Esc", "cancel"), ], CommandBarMode::Browse => vec![ ("Type", "username"), ("Enter", "browse"), ("Esc", "cancel"), ], } } else { match self.state.focused_pane { FocusedPane::Searches => vec![ ("s", "search"), ("m", "message"), ("i", "inbox"), ("b", "browse user"), ("1-3", "focus pane"), ("↑↓", "navigate"), ("Enter", "view results"), ("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"), ("Enter", "download"), ("b", "browse owner"), ("/", "filter"), ("a/A", "select all/none"), ("1-3", "focus pane"), ("q", "quit"), ], FocusedPane::Downloads => { vec![ ("p", "pause/resume"), ("r", "retry failed"), ("d", "delete queued"), ("c", "clear finished"), ("b", "browse user"), ("1-3", "focus pane"), ("q", "quit"), ] } } }; let shortcuts_line = format_shortcuts_styled(&shortcuts); // Surface our own sharing status in the block title. let title = format!( "Shortcuts · Sharing: {}", self.client.shared_directory().unwrap_or("off") ); let shortcuts_widget = Paragraph::new(shortcuts_line).block( Block::default() .borders(Borders::ALL) .border_type(border_type(false)) .title(title), ); frame.render_widget(shortcuts_widget, area); } fn render_command_bar(&self, frame: &mut Frame, area: Rect) { let prefix = command_bar_prefix(self.state.command_bar_mode); let content_width = area.width.saturating_sub(2); let prefix_width = prefix.chars().count() as u16; let input_width = content_width.saturating_sub(prefix_width); let (visible_input, cursor_column) = visible_input_at_cursor( &self.state.command_bar_input, self.state.command_bar_cursor_position, input_width, ); let command_text = format!("{prefix}{visible_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); if area.width > 2 && area.height > 2 { let cursor_x = area .x .saturating_add(1) .saturating_add(prefix_width) .saturating_add(cursor_column) .min(area.x.saturating_add(area.width.saturating_sub(2))); frame.set_cursor_position(Position::new(cursor_x, area.y + 1)); } } fn handle_key_event(&mut self, key: KeyEvent) { // Command bar takes priority if self.state.command_bar_active { return self.handle_command_bar_input(key); } // Messages popup: any of i/Esc/q closes it. if self.state.show_messages { if matches!(key.code, KeyCode::Char('i' | 'q') | KeyCode::Esc) { self.state.show_messages = false; } return; } // Browse popup takes over navigation while open. if self.state.show_browse { return self.handle_browse_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; } KeyCode::Char('1') => { self.state.focused_pane = FocusedPane::Searches; return; } KeyCode::Char('2') => { self.state.focused_pane = FocusedPane::Results; return; } KeyCode::Char('3') => { self.state.focused_pane = FocusedPane::Downloads; return; } KeyCode::Char('s') => { self.state.command_bar_active = true; self.state.command_bar_mode = CommandBarMode::Search; self.state.command_bar_input.clear(); self.state.command_bar_cursor_position = 0; return; } KeyCode::Char('m') => { self.state.command_bar_active = true; self.state.command_bar_mode = CommandBarMode::Message; self.state.command_bar_input.clear(); self.state.command_bar_cursor_position = 0; return; } KeyCode::Char('i') => { self.state.show_messages = true; return; } KeyCode::Char('b') => { // From a highlighted search result, browse its owner directly; // otherwise prompt for a username. if self.state.focused_pane == FocusedPane::Results && let Some(owner) = self.highlighted_result_owner() { self.start_browse(owner); } else { self.state.command_bar_active = true; self.state.command_bar_mode = CommandBarMode::Browse; self.state.command_bar_input.clear(); self.state.command_bar_cursor_position = 0; } return; } _ => {} } // 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_browse_input(&mut self, key: KeyEvent) { if matches!(key.code, KeyCode::Esc | KeyCode::Char('q')) { self.state.show_browse = false; return; } // Snapshot the flattened rows + current selection, then drop the borrow. let (rows, sel, row) = { let Some(browse) = self.state.browse.as_ref() else { self.state.show_browse = false; return; }; if browse.status != BrowseStatus::Loaded { return; } let rows = browse.rows(); if rows.is_empty() { return; } let sel = browse.selected_row.min(rows.len() - 1); let row = rows[sel].clone(); (rows, sel, row) }; // Downloads need `&self.client` free of the browse borrow. match key.code { KeyCode::Enter if !row.is_folder => { self.queue_browse_files(vec![( row.path.clone(), row.size.unwrap_or(0), )]); return; } KeyCode::Char('d') => { let files = if row.is_folder { self.browse_folder_files(&row.path) } else { vec![(row.path.clone(), row.size.unwrap_or(0))] }; self.queue_browse_files(files); return; } _ => {} } // Navigation and expand/collapse mutate the browse state. if let Some(browse) = self.state.browse.as_mut() { match key.code { KeyCode::Up | KeyCode::Char('k') => { browse.selected_row = sel.saturating_sub(1); } KeyCode::Down | KeyCode::Char('j') => { browse.selected_row = (sel + 1).min(rows.len() - 1); } KeyCode::Right | KeyCode::Char('l') => { if row.is_folder && !row.expanded { browse.expanded.insert(row.path.clone()); } else if row.is_folder { browse.selected_row = (sel + 1).min(rows.len() - 1); } } KeyCode::Left | KeyCode::Char('h') => { if row.is_folder && row.expanded { browse.expanded.remove(&row.path); } else if let Some(parent) = (0..sel).rev().find(|&i| rows[i].depth < row.depth) { browse.selected_row = parent; } } KeyCode::Enter => { // Folder toggle (files handled above). if row.expanded { browse.expanded.remove(&row.path); } else { browse.expanded.insert(row.path.clone()); } } _ => {} } // Re-clamp against the new flattened length. let new_len = browse.rows().len(); browse.selected_row = browse.selected_row.min(new_len.saturating_sub(1)); } let selected = self.state.browse.as_ref().map(|b| b.selected_row); self.state.browse_table_state.select(selected); } /// Files (`path`, `size`) under the browse-tree folder at `path`. fn browse_folder_files(&self, path: &str) -> Vec<(String, u64)> { self.state .browse .as_ref() .and_then(|b| find_node(&b.tree, path)) .map(files_under) .unwrap_or_default() } /// Queue downloads of `files` (path, size) from the currently-browsed user. fn queue_browse_files(&mut self, files: Vec<(String, u64)>) { let Some(username) = self.state.browse.as_ref().map(|b| b.username.clone()) else { return; }; if files.is_empty() { return; } if self.state.downloads_receiver_channel.is_none() { let (sender, receiver) = mpsc::channel(); self.state.downloads_receiver_channel = Some(receiver); self.state.downloads_sender_channel = Some(sender); } let client = self.client.clone(); let download_dir = self.download_dir.clone(); let sender = self.state.downloads_sender_channel.clone().unwrap(); thread::spawn(move || { for (path, size) in files { match client.download( path.clone(), username.clone(), size, download_dir.clone(), ) { Ok((download, rx)) => { let _ = sender.send((download, rx)); } Err(e) => { eprintln!("Failed to queue download {path}: {e}"); } } } }); } fn handle_command_bar_input(&mut self, key: KeyEvent) { self.state.command_bar_cursor_position = clamp_cursor_to_char_boundary( &self.state.command_bar_input, self.state.command_bar_cursor_position, ); match key.code { KeyCode::Enter => { let input = self.state.command_bar_input.trim().to_string(); if !input.is_empty() { match self.state.command_bar_mode { CommandBarMode::Search => self.start_search(input), CommandBarMode::Message => { self.send_message_from_input(&input); } CommandBarMode::Browse => self.start_browse(input), } } self.state.command_bar_active = false; self.state.command_bar_input.clear(); self.state.command_bar_cursor_position = 0; } KeyCode::Esc => { self.state.command_bar_active = false; self.state.command_bar_input.clear(); self.state.command_bar_cursor_position = 0; } KeyCode::Backspace => { let cursor_position = self.state.command_bar_cursor_position; if cursor_position > 0 { let previous_position = previous_char_boundary( &self.state.command_bar_input, cursor_position, ); self.state .command_bar_input .drain(previous_position..cursor_position); self.state.command_bar_cursor_position = previous_position; } } KeyCode::Delete => { let cursor_position = self.state.command_bar_cursor_position; if cursor_position < self.state.command_bar_input.len() { let next_position = next_char_boundary( &self.state.command_bar_input, cursor_position, ); self.state .command_bar_input .drain(cursor_position..next_position); } } KeyCode::Left => { self.state.command_bar_cursor_position = previous_char_boundary( &self.state.command_bar_input, self.state.command_bar_cursor_position, ); } KeyCode::Right => { self.state.command_bar_cursor_position = next_char_boundary( &self.state.command_bar_input, self.state.command_bar_cursor_position, ); } KeyCode::Home => { self.state.command_bar_cursor_position = 0; } KeyCode::End => { self.state.command_bar_cursor_position = self.state.command_bar_input.len(); } KeyCode::Char('a') if key.modifiers.contains(KeyModifiers::CONTROL) => { self.state.command_bar_cursor_position = 0; } KeyCode::Char('e') if key.modifiers.contains(KeyModifiers::CONTROL) => { self.state.command_bar_cursor_position = self.state.command_bar_input.len(); } KeyCode::Char('u') if key.modifiers.contains(KeyModifiers::CONTROL) => { self.state.command_bar_input.clear(); self.state.command_bar_cursor_position = 0; } KeyCode::Char(c) if !key.modifiers.intersects( KeyModifiers::CONTROL | KeyModifiers::ALT | KeyModifiers::SUPER | KeyModifiers::HYPER | KeyModifiers::META, ) => { let cursor_position = self.state.command_bar_cursor_position; self.state.command_bar_input.insert(cursor_position, c); self.state.command_bar_cursor_position = cursor_position + c.len_utf8(); } _ => {} } } fn handle_filter_input(&mut self, key: KeyEvent) { 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(); } _ => {} } } fn handle_searches_input(&mut self, key: KeyEvent) { 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(); } _ => {} } } fn handle_results_input(&mut self, key: KeyEvent) { 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(); } _ => {} } } fn handle_downloads_input(&mut self, key: KeyEvent) { 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)); } KeyCode::Char('p') => { self.toggle_selected_download_pause(); } KeyCode::Char('d') => { self.remove_selected_queued_download(); } KeyCode::Char('r') => { self.retry_selected_download(); } KeyCode::Char('c') => { self.clear_finished_downloads(); } _ => {} } } /// Re-queue the selected download if it failed or timed out. fn retry_selected_download(&mut self) { let Some(index) = self.state.downloads_table_state.selected() else { return; }; let Some(entry) = self.state.downloads.get(index) else { return; }; if !matches!( entry.download.status, DownloadStatus::Failed | DownloadStatus::TimedOut ) { return; } let filename = entry.download.filename.clone(); let username = entry.download.username.clone(); let size = entry.download.size; let directory = entry.download.download_directory.clone(); if self.state.downloads_receiver_channel.is_none() { let (sender, receiver) = mpsc::channel(); self.state.downloads_receiver_channel = Some(receiver); self.state.downloads_sender_channel = Some(sender); } let client = self.client.clone(); let sender = self.state.downloads_sender_channel.clone().unwrap(); // Drop the old failed entry; the retry pushes a fresh one. self.state.downloads.remove(index); self.select_download_after_removal(index); thread::spawn(move || { match client.download(filename.clone(), username, size, directory) { Ok((download, rx)) => { let _ = sender.send((download, rx)); } Err(e) => eprintln!("Failed to retry {filename}: {e}"), } }); } /// Remove all completed / failed / timed-out downloads from the list. fn clear_finished_downloads(&mut self) { self.state.downloads.retain(|entry| { !matches!( entry.download.status, DownloadStatus::Completed | DownloadStatus::Failed | DownloadStatus::TimedOut ) }); let len = self.state.downloads.len(); if len == 0 { self.state.downloads_table_state.select(None); } else { let selected = self .state .downloads_table_state .selected() .unwrap_or(0) .min(len - 1); self.state.downloads_table_state.select(Some(selected)); } } fn toggle_selected_download_pause(&self) { let Some(index) = self.state.downloads_table_state.selected() else { return; }; let Some(download_entry) = self.state.downloads.get(index) else { return; }; let download = &download_entry.download; match download.status { DownloadStatus::InProgress { .. } => { let _ = self .client .pause_download(&download.username, &download.filename); } DownloadStatus::Paused { .. } => { let _ = self .client .resume_download(&download.username, &download.filename); } _ => {} } } fn remove_selected_queued_download(&mut self) { let Some(index) = self.state.downloads_table_state.selected() else { return; }; let Some(download_entry) = self.state.downloads.get(index) else { return; }; let download = &download_entry.download; if !matches!(download.status, DownloadStatus::Queued) { return; } if !self .client .remove_queued_download(&download.username, &download.filename) { return; } self.state.downloads.remove(index); self.select_download_after_removal(index); } fn select_download_after_removal(&mut self, removed_index: usize) { if self.state.downloads.is_empty() { self.state.downloads_table_state.select(None); return; } let next_index = removed_index.min(self.state.downloads.len() - 1); self.state.downloads_table_state.select(Some(next_index)); } fn handle_mouse_event(&mut self, mouse: MouseEvent) { if mouse.kind == MouseEventKind::Down(MouseButton::Left) { let (col, row) = (mouse.column, mouse.row); // Check if click is within searches pane if let Some(area) = self.state.searches_pane_area && col >= area.x && col < area.x + area.width && row >= area.y && row < area.y + area.height { self.state.focused_pane = FocusedPane::Searches; return; } // Check if click is within results pane if let Some(area) = self.state.results_pane_area && col >= area.x && col < area.x + area.width && row >= area.y && row < area.y + area.height { self.state.focused_pane = FocusedPane::Results; return; } // Check if click is within downloads pane if let Some(area) = self.state.downloads_pane_area && col >= area.x && col < area.x + area.width && row >= area.y && row < area.y + area.height { self.state.focused_pane = FocusedPane::Downloads; } } } 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 recompute_results_filter(&mut self) { let (items, indices) = filter_results( &self.state.results_items, &self.state.results_filter_query, ); self.state.results_filtered_items = items; self.state.results_filtered_indices = indices; } fn apply_filter(&mut self) { self.recompute_results_filter(); if !self.state.results_filtered_items.is_empty() { self.state.results_table_state.select(Some(0)); } } /// Drain any private messages received since the last tick into the inbox. fn poll_private_messages(&mut self) { for msg in self.client.take_private_messages() { self.state.messages.push(ChatMessage { direction: MessageDirection::Incoming, peer: msg.username().to_string(), text: msg.message().to_string(), }); } } /// Request a user's shared files and open the browse popup. fn start_browse(&mut self, username: String) { let username = username.trim().to_string(); if username.is_empty() { return; } let _ = self.client.browse_user(&username); self.state.browse = Some(BrowseState::loading(username)); self.state.show_browse = true; self.state.browse_table_state.select(Some(0)); } /// The username of the highlighted search result (filter-aware). fn highlighted_result_owner(&self) -> Option { let selected = self.state.results_table_state.selected()?; let items = if self.state.results_filter_query.is_empty() { &self.state.results_items } else { &self.state.results_filtered_items }; items.get(selected).map(|f| f.username.clone()) } /// Drain a browse response into the browse state, or time it out. fn poll_browse_result(&mut self) { let Some((username, status, requested_at)) = self .state .browse .as_ref() .map(|b| (b.username.clone(), b.status, b.requested_at)) else { return; }; if status != BrowseStatus::Loading { return; } if let Some(directories) = self.client.take_browse_result(&username) { if let Some(browse) = self.state.browse.as_mut() { browse.load(&directories); } self.state.browse_table_state.select(Some(0)); } else if requested_at.elapsed() > BROWSE_TIMEOUT && let Some(browse) = self.state.browse.as_mut() { browse.status = BrowseStatus::TimedOut; } } /// Parse a ` ` compose line and send it. fn send_message_from_input(&mut self, input: &str) { let Some((recipient, text)) = input.split_once(char::is_whitespace) else { return; }; let recipient = recipient.trim(); let text = text.trim(); if recipient.is_empty() || text.is_empty() { return; } match self.client.send_private_message(recipient, text) { Ok(()) => self.state.messages.push(ChatMessage { direction: MessageDirection::Outgoing, peer: recipient.to_string(), text: text.to_string(), }), Err(e) => eprintln!("Failed to send message: {e}"), } } 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; // Fetch all results in one go (single lock acquisition per query) // Use try_get_search_results to avoid blocking the UI thread let all_results: Vec<(usize, Vec<_>)> = self .state .searches .iter() .enumerate() .map(|(idx, s)| (idx, s.query.clone())) .filter_map(|(idx, query)| { self.client .try_get_search_results(&query) .map(|results| (idx, results)) }) .collect(); // Now update state without holding any client locks for (idx, search_results) in all_results { if let Some(search) = self.state.searches.get_mut(idx) { 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(), length_seconds: file.attribs.get(&1).copied(), }); } } // Update selected search if this is the active one. Re-derive // the filtered view from the current query so an active // filter is preserved as new results stream in, rather than // being clobbered by the full unfiltered list. if let Some(selected_idx) = selected_search_index && selected_idx == idx { self.state.results_items = search.results.clone(); let (items, indices) = filter_results( &self.state.results_items, &self.state.results_filter_query, ); self.state.results_filtered_items = items; self.state.results_filtered_indices = indices; } } // Mark as completed after timeout if search.status == SearchStatus::Active && search.start_time.elapsed() > timeout { search.status = SearchStatus::Completed; } } } } fn queue_selected_downloads(&mut self) { let selected_files: Vec = self .state .results_selected_indices .iter() .filter_map(|&idx| self.state.results_items.get(idx)) .cloned() .collect(); if selected_files.is_empty() { return; } if self.state.downloads_receiver_channel.is_none() { let (sender, receiver) = mpsc::channel(); self.state.downloads_receiver_channel = Some(receiver); self.state.downloads_sender_channel = Some(sender); } let client = self.client.clone(); let download_dir = self.download_dir.clone(); let sender = self.state.downloads_sender_channel.clone().unwrap(); thread::spawn(move || { for file in selected_files { let metadata = soulseek_rs::types::DownloadMetadata { bitrate: file.bitrate, length_seconds: file.length_seconds, peer_upload_speed: Some(file.speed), peer_free_slots: Some(file.slots), }; match client.download_with_metadata( file.filename.clone(), file.username.clone(), file.size, download_dir.clone(), metadata, ) { Ok((download, rx)) => { let _ = sender.send((download, rx)); } Err(e) => { eprintln!( "Failed to start download for {}: {}", file.filename, e ); } } } }); // Clear selection self.state.results_selected_indices.clear(); } fn update_downloads(&mut self) { // Check for new downloads from background thread if let Some(ref receiver) = self.state.downloads_receiver_channel { while let Ok((download, download_receiver)) = receiver.try_recv() { self.state.downloads.push(DownloadEntry { download, receiver: Some(download_receiver), }); } } // Update download states self.state.active_downloads_count = 0; for download_entry in &mut self.state.downloads { if let Some(ref receiver) = download_entry.receiver { while let Ok(status) = receiver.try_recv() { download_entry.download.status = status; } } if matches!( download_entry.download.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) } /// A `Rect` centered within `area`, sized to the given percentages. fn centered_rect(percent_x: u16, percent_y: u16, area: Rect) -> Rect { let vertical = Layout::vertical([ Constraint::Percentage((100 - percent_y) / 2), Constraint::Percentage(percent_y), Constraint::Percentage((100 - percent_y) / 2), ]) .split(area); Layout::horizontal([ Constraint::Percentage((100 - percent_x) / 2), Constraint::Percentage(percent_x), Constraint::Percentage((100 - percent_x) / 2), ]) .split(vertical[1])[1] } const fn clamp_cursor_to_char_boundary( input: &str, cursor_position: usize, ) -> usize { if cursor_position >= input.len() { return input.len(); } let mut cursor_position = cursor_position; while cursor_position > 0 && !input.is_char_boundary(cursor_position) { cursor_position -= 1; } cursor_position } fn previous_char_boundary(input: &str, cursor_position: usize) -> usize { let cursor_position = clamp_cursor_to_char_boundary(input, cursor_position); input[..cursor_position] .char_indices() .last() .map_or(0, |(index, _)| index) } fn next_char_boundary(input: &str, cursor_position: usize) -> usize { let cursor_position = clamp_cursor_to_char_boundary(input, cursor_position); if cursor_position >= input.len() { return input.len(); } cursor_position + input[cursor_position..] .chars() .next() .map_or(0, char::len_utf8) } fn visible_input_at_cursor( input: &str, cursor_position: usize, width: u16, ) -> (String, u16) { if width == 0 { return (String::new(), 0); } let cursor_position = clamp_cursor_to_char_boundary(input, cursor_position); let cursor_character_index = input[..cursor_position].chars().count(); let max_cursor_column = usize::from(width.saturating_sub(1)); let start_character_index = cursor_character_index.saturating_sub(max_cursor_column); let visible_input = input .chars() .skip(start_character_index) .take(usize::from(width)) .collect(); let cursor_column = cursor_character_index .saturating_sub(start_character_index) .min(max_cursor_column); (visible_input, cursor_column as u16) } /// Filter `items` by a case-insensitive substring match on filename or /// username. Returns the matching items alongside their indices in the original /// list, so callers can translate a filtered display index back to the /// unfiltered results. An empty query returns everything (identity mapping). fn filter_results( items: &[FileDisplayData], query: &str, ) -> (Vec, Vec) { let query = query.to_lowercase(); if query.is_empty() { return (items.to_vec(), (0..items.len()).collect()); } let mut filtered_items = Vec::new(); let mut filtered_indices = Vec::new(); for (idx, item) in items.iter().enumerate() { if item.filename.to_lowercase().contains(&query) || item.username.to_lowercase().contains(&query) { filtered_items.push(item.clone()); filtered_indices.push(idx); } } (filtered_items, filtered_indices) } #[cfg(test)] mod tests { use super::filter_results; use crate::models::FileDisplayData; fn file(filename: &str, username: &str) -> FileDisplayData { FileDisplayData { filename: filename.to_string(), username: username.to_string(), ..Default::default() } } #[test] fn empty_query_returns_identity_mapping() { let items = vec![file("a.mp3", "bob"), file("b.flac", "amy")]; let (filtered, indices) = filter_results(&items, ""); assert_eq!(filtered.len(), 2); assert_eq!(indices, vec![0, 1]); } #[test] fn query_matches_filename_and_username_and_maps_indices() { let items = vec![ file("track.mp3", "bob"), file("song.flac", "alice"), file("alice_demo.mp3", "carol"), ]; // "alice" matches item 1 (username) and item 2 (filename). let (filtered, indices) = filter_results(&items, "alice"); assert_eq!(filtered.len(), 2); assert_eq!(indices, vec![1, 2]); assert_eq!(filtered[0].filename, "song.flac"); assert_eq!(filtered[1].filename, "alice_demo.mp3"); } #[test] fn query_is_case_insensitive() { let items = vec![file("The Weeknd.mp3", "dj")]; let (filtered, indices) = filter_results(&items, "WEEKND"); assert_eq!(filtered.len(), 1); assert_eq!(indices, vec![0]); } }