diff --git a/soulseek-rs-lib/src/client/mod.rs b/soulseek-rs-lib/src/client/mod.rs index f012016..3eed85a 100644 --- a/soulseek-rs-lib/src/client/mod.rs +++ b/soulseek-rs-lib/src/client/mod.rs @@ -671,6 +671,14 @@ impl Client { .unwrap_or_default() } + /// `(folders, files)` currently shared with peers. + #[must_use] + pub fn shared_counts(&self) -> (u32, u32) { + self.context.read_safe().map_or((0, 0), |ctx| { + (ctx.shares.folder_count(), ctx.shares.file_count()) + }) + } + /// Snapshot of the uploads served this session (active and finished), /// most recent last. #[must_use] diff --git a/soulseek-rs/src/models/app_state.rs b/soulseek-rs/src/models/app_state.rs index 027e12a..5315f8d 100644 --- a/soulseek-rs/src/models/app_state.rs +++ b/soulseek-rs/src/models/app_state.rs @@ -1,4 +1,4 @@ -use crate::models::{BrowseTabs, FileDisplayData, RoomsState}; +use crate::models::{BrowseTabs, FileDisplayData, RoomsState, SettingsState}; use ratatui::{layout::Rect, widgets::TableState}; use soulseek_rs::{DownloadStatus, types::Download}; use std::sync::atomic::AtomicBool; @@ -104,6 +104,12 @@ pub struct AppState { pub show_rooms: bool, pub rooms_list_table_state: TableState, + // Settings popup (download folder + share paths) + pub settings: Option, + + // Uploads we are serving (refreshed from the client every tick) + pub uploads: Vec, + // Pane areas for mouse interaction pub searches_pane_area: Option, pub results_pane_area: Option, @@ -160,6 +166,10 @@ impl AppState { show_rooms: false, rooms_list_table_state: TableState::default(), + settings: None, + + uploads: Vec::new(), + searches_pane_area: None, results_pane_area: None, downloads_pane_area: None, diff --git a/soulseek-rs/src/models/mod.rs b/soulseek-rs/src/models/mod.rs index 44c74d0..f62e346 100644 --- a/soulseek-rs/src/models/mod.rs +++ b/soulseek-rs/src/models/mod.rs @@ -2,6 +2,7 @@ mod app_state; mod browse; mod file_display_data; mod rooms; +mod settings; pub use app_state::{ AppState, ChatMessage, CommandBarMode, DownloadEntry, FocusedPane, @@ -12,3 +13,4 @@ pub use browse::{ }; pub use file_display_data::FileDisplayData; pub use rooms::{RoomLine, RoomsState, RoomsView}; +pub use settings::{SettingsAction, SettingsMode, SettingsState}; diff --git a/soulseek-rs/src/models/settings.rs b/soulseek-rs/src/models/settings.rs new file mode 100644 index 0000000..191378e --- /dev/null +++ b/soulseek-rs/src/models/settings.rs @@ -0,0 +1,247 @@ +//! State machine for the settings popup (download folder + share paths). +//! Pure — no client or terminal — so every transition is unit-testable. + +use ratatui::crossterm::event::{KeyCode, KeyEvent}; + +/// Which row is highlighted: 0 = download dir, 1.. = share paths. +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum SettingsMode { + /// Moving between rows. + Navigate, + /// Typing a new download directory. + EditingDownloadDir, + /// Typing a new share path to add. + AddingShare, +} + +/// What the TUI should do after a key was handled. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum SettingsAction { + None, + /// Settings changed: persist config and apply the new share set live. + Apply, + /// Re-scan the current share paths (files changed on disk). + Reindex, + /// Close the popup. + Close, +} + +pub struct SettingsState { + pub download_dir: String, + pub share_dirs: Vec, + /// 0 = download dir row; 1 + i = share path i. + pub selected: usize, + pub mode: SettingsMode, + /// Edit buffer while typing. + pub input: String, + /// One-line feedback ("Re-indexed: 1234 files in 56 folders"). + pub status: Option, +} + +impl SettingsState { + #[must_use] + pub const fn new(download_dir: String, share_dirs: Vec) -> Self { + Self { + download_dir, + share_dirs, + selected: 0, + mode: SettingsMode::Navigate, + input: String::new(), + status: None, + } + } + + const fn rows(&self) -> usize { + 1 + self.share_dirs.len() + } + + pub fn handle_key(&mut self, key: KeyEvent) -> SettingsAction { + match self.mode { + SettingsMode::Navigate => self.handle_navigate(key), + SettingsMode::EditingDownloadDir | SettingsMode::AddingShare => { + self.handle_typing(key) + } + } + } + + fn handle_navigate(&mut self, key: KeyEvent) -> SettingsAction { + self.status = None; + match key.code { + KeyCode::Esc | KeyCode::Char('q' | 'o') => SettingsAction::Close, + KeyCode::Up | KeyCode::Char('k') => { + self.selected = self.selected.saturating_sub(1); + SettingsAction::None + } + KeyCode::Down | KeyCode::Char('j') => { + self.selected = (self.selected + 1).min(self.rows() - 1); + SettingsAction::None + } + KeyCode::Enter | KeyCode::Char('e') if self.selected == 0 => { + self.mode = SettingsMode::EditingDownloadDir; + self.input = self.download_dir.clone(); + SettingsAction::None + } + KeyCode::Char('a') => { + self.mode = SettingsMode::AddingShare; + self.input.clear(); + SettingsAction::None + } + KeyCode::Char('d') if self.selected > 0 => { + self.share_dirs.remove(self.selected - 1); + self.selected = self.selected.min(self.rows() - 1); + SettingsAction::Apply + } + KeyCode::Char('r') => SettingsAction::Reindex, + _ => SettingsAction::None, + } + } + + fn handle_typing(&mut self, key: KeyEvent) -> SettingsAction { + match key.code { + KeyCode::Esc => { + self.mode = SettingsMode::Navigate; + self.input.clear(); + SettingsAction::None + } + KeyCode::Enter => { + let value = self.input.trim().to_string(); + let adding = self.mode == SettingsMode::AddingShare; + self.mode = SettingsMode::Navigate; + self.input.clear(); + if value.is_empty() { + return SettingsAction::None; + } + if adding { + if self.share_dirs.contains(&value) { + return SettingsAction::None; + } + self.share_dirs.push(value); + self.selected = self.rows() - 1; + } else { + self.download_dir = value; + } + SettingsAction::Apply + } + KeyCode::Backspace => { + self.input.pop(); + SettingsAction::None + } + KeyCode::Char(c) => { + self.input.push(c); + SettingsAction::None + } + _ => SettingsAction::None, + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + use ratatui::crossterm::event::KeyModifiers; + + fn key(code: KeyCode) -> KeyEvent { + KeyEvent::new(code, KeyModifiers::NONE) + } + + fn type_str(state: &mut SettingsState, text: &str) { + for c in text.chars() { + state.handle_key(key(KeyCode::Char(c))); + } + } + + fn state() -> SettingsState { + SettingsState::new("/dl".into(), vec!["/dl".into(), "/music".into()]) + } + + #[test] + fn escape_closes_from_navigation() { + assert_eq!( + state().handle_key(key(KeyCode::Esc)), + SettingsAction::Close + ); + } + + #[test] + fn adding_a_share_path_applies() { + let mut s = state(); + s.handle_key(key(KeyCode::Char('a'))); + assert_eq!(s.mode, SettingsMode::AddingShare); + type_str(&mut s, "/flacs"); + let action = s.handle_key(key(KeyCode::Enter)); + assert_eq!(action, SettingsAction::Apply); + assert_eq!(s.share_dirs, vec!["/dl", "/music", "/flacs"]); + assert_eq!(s.mode, SettingsMode::Navigate); + } + + #[test] + fn adding_a_duplicate_share_is_a_no_op() { + let mut s = state(); + s.handle_key(key(KeyCode::Char('a'))); + type_str(&mut s, "/music"); + assert_eq!(s.handle_key(key(KeyCode::Enter)), SettingsAction::None); + assert_eq!(s.share_dirs.len(), 2); + } + + #[test] + fn deleting_the_selected_share_applies() { + let mut s = state(); + s.handle_key(key(KeyCode::Down)); // select share 0 + s.handle_key(key(KeyCode::Down)); // select share 1 (/music) + let action = s.handle_key(key(KeyCode::Char('d'))); + assert_eq!(action, SettingsAction::Apply); + assert_eq!(s.share_dirs, vec!["/dl"]); + } + + #[test] + fn delete_on_the_download_dir_row_does_nothing() { + let mut s = state(); + assert_eq!(s.handle_key(key(KeyCode::Char('d'))), SettingsAction::None); + assert_eq!(s.share_dirs.len(), 2); + } + + #[test] + fn editing_the_download_dir_applies() { + let mut s = state(); + s.handle_key(key(KeyCode::Enter)); + assert_eq!(s.mode, SettingsMode::EditingDownloadDir); + assert_eq!(s.input, "/dl"); + s.handle_key(key(KeyCode::Backspace)); + s.handle_key(key(KeyCode::Backspace)); + s.handle_key(key(KeyCode::Backspace)); + type_str(&mut s, "/new"); + assert_eq!(s.handle_key(key(KeyCode::Enter)), SettingsAction::Apply); + assert_eq!(s.download_dir, "/new"); + } + + #[test] + fn escape_cancels_an_edit_without_applying() { + let mut s = state(); + s.handle_key(key(KeyCode::Enter)); + type_str(&mut s, "junk"); + assert_eq!(s.handle_key(key(KeyCode::Esc)), SettingsAction::None); + assert_eq!(s.download_dir, "/dl"); + assert_eq!(s.mode, SettingsMode::Navigate); + } + + #[test] + fn r_requests_a_reindex() { + assert_eq!( + state().handle_key(key(KeyCode::Char('r'))), + SettingsAction::Reindex + ); + } + + #[test] + fn selection_is_clamped_to_the_row_count() { + let mut s = state(); + for _ in 0..10 { + s.handle_key(key(KeyCode::Down)); + } + assert_eq!(s.selected, 2); + for _ in 0..10 { + s.handle_key(key(KeyCode::Up)); + } + assert_eq!(s.selected, 0); + } +} diff --git a/soulseek-rs/src/ui/main_tui/downloads.rs b/soulseek-rs/src/ui/main_tui/downloads.rs index 2c8ddff..1180e7f 100644 --- a/soulseek-rs/src/ui/main_tui/downloads.rs +++ b/soulseek-rs/src/ui/main_tui/downloads.rs @@ -189,6 +189,32 @@ impl MainTui { self.state.results_selected_indices.clear(); } + /// Cancel the selected transfer when it is an upload row (uploads are + /// listed after the downloads in the shared pane). + pub(super) fn cancel_selected_upload(&self) { + let Some(index) = self.state.downloads_table_state.selected() else { + return; + }; + let Some(upload) = self + .state + .uploads + .get(index.wrapping_sub(self.state.downloads.len())) + else { + return; + }; + if upload.status == soulseek_rs::types::UploadStatus::InProgress + && !self + .client + .cancel_upload(&upload.username, &upload.filename) + { + soulseek_rs::warn!( + "No in-progress upload of {} to {} to cancel", + upload.filename, + upload.username + ); + } + } + pub(super) fn update_downloads(&mut self) { if let Some(ref receiver) = self.state.downloads_receiver_channel { while let Ok((download, download_receiver)) = receiver.try_recv() { diff --git a/soulseek-rs/src/ui/main_tui/input.rs b/soulseek-rs/src/ui/main_tui/input.rs index a68a9ca..8257ac8 100644 --- a/soulseek-rs/src/ui/main_tui/input.rs +++ b/soulseek-rs/src/ui/main_tui/input.rs @@ -29,6 +29,11 @@ impl MainTui { return self.handle_rooms_input(key); } + // Settings popup takes over navigation while open. + if self.state.settings.is_some() { + return self.handle_settings_input(key); + } + // Filter mode in Results pane if self.state.results_is_filtering && self.state.focused_pane == FocusedPane::Results @@ -81,6 +86,10 @@ impl MainTui { self.start_rooms(); return; } + KeyCode::Char('o') => { + self.open_settings(); + return; + } KeyCode::Char('b') => { // From a highlighted search result, browse its owner directly; // otherwise prompt for a username. @@ -361,27 +370,24 @@ impl MainTui { } fn handle_downloads_input(&mut self, key: KeyEvent) { + // The pane lists downloads first, then uploads; navigation spans both. + let rows = self.state.downloads.len() + self.state.uploads.len(); match key.code { - KeyCode::Up | KeyCode::Char('k') - if !self.state.downloads.is_empty() => - { + KeyCode::Up | KeyCode::Char('k') if rows > 0 => { let current = self.state.downloads_table_state.selected().unwrap_or(0); - let new = if current == 0 { - self.state.downloads.len() - 1 - } else { - current - 1 - }; + let new = if current == 0 { rows - 1 } else { current - 1 }; self.state.downloads_table_state.select(Some(new)); } - KeyCode::Down | KeyCode::Char('j') - if !self.state.downloads.is_empty() => - { + KeyCode::Down | KeyCode::Char('j') if rows > 0 => { let current = self.state.downloads_table_state.selected().unwrap_or(0); - let new = (current + 1) % self.state.downloads.len(); + let new = (current + 1) % rows; self.state.downloads_table_state.select(Some(new)); } + KeyCode::Char('x') => { + self.cancel_selected_upload(); + } KeyCode::Char('p') => { self.toggle_selected_download_pause(); } diff --git a/soulseek-rs/src/ui/main_tui/mod.rs b/soulseek-rs/src/ui/main_tui/mod.rs index 47728bc..c7d542f 100644 --- a/soulseek-rs/src/ui/main_tui/mod.rs +++ b/soulseek-rs/src/ui/main_tui/mod.rs @@ -4,6 +4,7 @@ mod input; mod render; mod rooms; mod search; +mod settings; use crate::models::AppState; use crate::persist::{ @@ -177,6 +178,9 @@ impl MainTui { // Poll for chat-room events self.poll_room_events(); + // Refresh the uploads we are serving to peers + self.state.uploads = self.client.uploads(); + self.spinner_state = (self.spinner_state + 1) % 10; self.save_persisted_state(); diff --git a/soulseek-rs/src/ui/main_tui/render.rs b/soulseek-rs/src/ui/main_tui/render.rs index d89b089..df248f8 100644 --- a/soulseek-rs/src/ui/main_tui/render.rs +++ b/soulseek-rs/src/ui/main_tui/render.rs @@ -134,6 +134,7 @@ impl MainTui { frame, downloads_chunks[0], &self.state.downloads, + &self.state.uploads, &mut self.state.downloads_table_state, self.state.focused_pane == FocusedPane::Downloads, ); @@ -170,6 +171,11 @@ impl MainTui { ); } + // Settings popup overlays everything when open. + if self.state.settings.is_some() { + self.render_settings_popup(frame); + } + // Chat rooms overlay everything when open. if self.state.show_rooms { let area = centered_rect(85, 80, frame.area()); @@ -183,6 +189,67 @@ impl MainTui { } } + fn render_settings_popup(&self, frame: &mut Frame) { + use crate::models::SettingsMode; + let Some(settings) = self.state.settings.as_ref() else { + return; + }; + let area = centered_rect(70, 60, frame.area()); + frame.render_widget(ratatui::widgets::Clear, area); + + let mut lines: Vec = Vec::new(); + let marker = |selected: bool| if selected { "> " } else { " " }; + + let download_line = if settings.mode == SettingsMode::EditingDownloadDir + { + format!("> Download folder: {}▏", settings.input) + } else { + format!( + "{}Download folder: {}", + marker(settings.selected == 0), + settings.download_dir + ) + }; + lines.push(ratatui::text::Line::from(download_line)); + lines.push(ratatui::text::Line::from("")); + lines.push(ratatui::text::Line::from(format!( + "Shared folders ({}):", + settings.share_dirs.len() + ))); + for (i, dir) in settings.share_dirs.iter().enumerate() { + lines.push(ratatui::text::Line::from(format!( + "{}{}", + marker(settings.selected == i + 1), + dir + ))); + } + if settings.share_dirs.is_empty() { + lines.push(ratatui::text::Line::from( + " (nothing shared — press 'a' to add a folder)", + )); + } + if settings.mode == SettingsMode::AddingShare { + lines.push(ratatui::text::Line::from(format!( + " Add share: {}▏", + settings.input + ))); + } + if let Some(status) = &settings.status { + lines.push(ratatui::text::Line::from("")); + lines.push(ratatui::text::Line::from(status.clone())); + } + + let block = ratatui::widgets::Block::default() + .borders(ratatui::widgets::Borders::ALL) + .title(" Settings "); + frame.render_widget( + ratatui::widgets::Paragraph::new(lines) + .block(block) + .wrap(ratatui::widgets::Wrap { trim: false }), + area, + ); + } + fn render_messages_popup(&self, frame: &mut Frame) { let area = centered_rect(70, 60, frame.area()); @@ -273,7 +340,16 @@ impl MainTui { "chat".to_string() }; - let shortcuts = if self.state.show_rooms { + let shortcuts = if self.state.settings.is_some() { + vec![ + ("↑↓", "move"), + ("Enter/e", "edit download dir"), + ("a", "add share"), + ("d", "remove share"), + ("r", "re-index"), + ("Esc", "close"), + ] + } else if self.state.show_rooms { self.rooms_shortcuts() } else if self.state.show_browse { vec![ diff --git a/soulseek-rs/src/ui/main_tui/settings.rs b/soulseek-rs/src/ui/main_tui/settings.rs new file mode 100644 index 0000000..fc9d5e2 --- /dev/null +++ b/soulseek-rs/src/ui/main_tui/settings.rs @@ -0,0 +1,93 @@ +//! Settings popup behavior: open, key handling, and applying changes +//! (persist to config.toml + live share rescan on the client). + +use super::MainTui; +use crate::models::{SettingsAction, SettingsState}; +use ratatui::crossterm::event::KeyEvent; + +impl MainTui { + pub(super) fn open_settings(&mut self) { + self.state.settings = Some(SettingsState::new( + self.download_dir.clone(), + self.client.shared_directories(), + )); + } + + pub(super) fn handle_settings_input(&mut self, key: KeyEvent) { + let Some(settings) = self.state.settings.as_mut() else { + return; + }; + match settings.handle_key(key) { + SettingsAction::None => {} + SettingsAction::Close => self.state.settings = None, + SettingsAction::Apply => self.apply_settings(), + SettingsAction::Reindex => self.reindex_shares(), + } + } + + /// Persist the edited settings and apply the share set live. + fn apply_settings(&mut self) { + let Some(settings) = self.state.settings.as_ref() else { + return; + }; + let download_dir = settings.download_dir.clone(); + let share_dirs = settings.share_dirs.clone(); + + if let Err(e) = std::fs::create_dir_all(&download_dir) { + self.set_settings_status(format!( + "Cannot create {download_dir}: {e}" + )); + return; + } + self.download_dir.clone_from(&download_dir); + + // Validate the share paths (tilde-expand, must exist) and apply. + let valid = crate::directories::resolve_shared_directories(&share_dirs); + let dropped = share_dirs.len() - valid.len(); + let mut status = match self.client.set_shared_directories(valid) { + Ok(()) if dropped > 0 => { + format!("Applied ({dropped} invalid path(s) ignored)") + } + Ok(()) => format!("Applied · sharing {}", self.share_counts()), + Err(e) => format!("Could not apply shares: {e}"), + }; + + // Persist to config.toml so the change survives a restart. + if let Some(path) = crate::persist::paths::config_file() { + let result = crate::persist::config::FileConfig::load(&path) + .and_then(|mut config| { + config.download_dir = Some(download_dir); + config.shared_dirs = Some(share_dirs); + config.save(&path) + }); + if let Err(e) = result { + status = format!("Could not save config: {e}"); + } + } + self.set_settings_status(status); + } + + fn set_settings_status(&mut self, status: String) { + if let Some(settings) = self.state.settings.as_mut() { + settings.status = Some(status); + } + } + + /// Re-scan the current share paths (picks up files changed on disk). + fn reindex_shares(&mut self) { + let dirs = self.client.shared_directories(); + let result = self.client.set_shared_directories(dirs); + let counts = self.share_counts(); + if let Some(settings) = self.state.settings.as_mut() { + settings.status = Some(match result { + Ok(()) => format!("Re-indexed · sharing {counts}"), + Err(e) => format!("Re-index failed: {e}"), + }); + } + } + + fn share_counts(&self) -> String { + let (folders, files) = self.client.shared_counts(); + format!("{files} files in {folders} folders") + } +} diff --git a/soulseek-rs/src/ui/panes/downloads_pane.rs b/soulseek-rs/src/ui/panes/downloads_pane.rs index 0b354f4..2605f14 100644 --- a/soulseek-rs/src/ui/panes/downloads_pane.rs +++ b/soulseek-rs/src/ui/panes/downloads_pane.rs @@ -13,23 +13,25 @@ use ratatui::{ }, }; use soulseek_rs::DownloadStatus; +use soulseek_rs::types::{UploadInfo, UploadStatus}; pub fn render_downloads_pane( frame: &mut Frame, area: Rect, downloads: &[DownloadEntry], + uploads: &[UploadInfo], table_state: &mut TableState, focused: bool, ) { - if downloads.is_empty() { + if downloads.is_empty() && uploads.is_empty() { let empty_block = Block::default() .borders(Borders::ALL) .border_style(border_style(focused)) .border_type(border_type(focused)) - .title("[3] Downloads"); + .title("[3] Downloads/Uploads"); let paragraph = Paragraph::new( - "No downloads. Select files from Results and press Enter.", + "No transfers. Select files from Results and press Enter.", ) .block(empty_block); frame.render_widget(paragraph, area); @@ -45,7 +47,7 @@ pub fn render_downloads_pane( ]) .height(1); - let rows: Vec = downloads + let mut rows: Vec = downloads .iter() .map(|download_entry| { let download = &download_entry.download; @@ -112,6 +114,46 @@ pub fn render_downloads_pane( }) .collect(); + rows.extend(uploads.iter().map(|upload| { + let (status_icon, status_style) = match &upload.status { + UploadStatus::InProgress => ("⧗", warning_style()), + UploadStatus::Completed => ("✓", success_style()), + UploadStatus::Cancelled => ("✗", inactive_style()), + UploadStatus::Failed(_) => ("✗", error_style()), + }; + let progress_text = match &upload.status { + UploadStatus::InProgress => { + let percent = if upload.size > 0 { + (upload.bytes_sent as f64 / upload.size as f64 * 100.0) + as u8 + } else { + 0 + }; + format!( + "{}/{} ({}%)", + format_bytes(upload.bytes_sent), + format_bytes(upload.size), + percent + ) + } + UploadStatus::Completed => format_bytes(upload.size), + UploadStatus::Cancelled => "Cancelled".to_string(), + UploadStatus::Failed(_) => "Failed".to_string(), + }; + let basename = upload + .filename + .rsplit(['\\', '/']) + .next() + .unwrap_or(&upload.filename); + Row::new(vec![ + Cell::from(format!("↑ {status_icon}")).style(status_style), + Cell::from(basename.to_string()), + Cell::from(upload.username.clone()), + Cell::from(progress_text), + Cell::from(String::new()), + ]) + })); + let widths = [ ratatui::layout::Constraint::Length(8), ratatui::layout::Constraint::Fill(2), @@ -130,7 +172,7 @@ pub fn render_downloads_pane( .borders(Borders::ALL) .border_style(border_style(focused)) .border_type(border_type(focused)) - .title("[3] Downloads"), + .title("[3] Downloads/Uploads"), ); frame.render_stateful_widget(table, area, table_state);