From 08272ac867309fa345d01f69ea3dc4e82ff4b5b5 Mon Sep 17 00:00:00 2001 From: michel Date: Sun, 19 Jul 2026 19:58:32 +0200 Subject: [PATCH] =?UTF-8?q?feat(tui):=20chat=20rooms=20=E2=80=94=20browsab?= =?UTF-8?q?le=20list,=20multiple=20open=20rooms,=20unread=20badges?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add a chat-rooms popup (open with 'c') satisfying the requested UX: - Browsable, /-filterable room list showing per-room user counts, busiest first; already-open rooms are marked. - Multiple rooms open at once as tabs; Tab/Shift-Tab switch, 'x' leaves. - Per-room chat log with a live member list and an inline compose line (Enter to type, Enter to send; the server echo renders the message). - Unread indicators: bold tab name + 'room (n)' badge per room, plus 'chat (n)' and 'inbox (n)' counters in the shortcut bar; viewing a room clears its badge. RoomsState is unit-tested (routing, unread accounting, tab lifecycle, filtering). --- soulseek-rs/src/models/app_state.rs | 14 +- soulseek-rs/src/models/mod.rs | 2 + soulseek-rs/src/models/rooms.rs | 407 +++++++++++++++++++++++++ soulseek-rs/src/ui/main_tui.rs | 288 ++++++++++++++++- soulseek-rs/src/ui/panes/mod.rs | 2 + soulseek-rs/src/ui/panes/rooms_pane.rs | 218 +++++++++++++ 6 files changed, 924 insertions(+), 7 deletions(-) create mode 100644 soulseek-rs/src/models/rooms.rs create mode 100644 soulseek-rs/src/ui/panes/rooms_pane.rs diff --git a/soulseek-rs/src/models/app_state.rs b/soulseek-rs/src/models/app_state.rs index 6cbab4d..47da45b 100644 --- a/soulseek-rs/src/models/app_state.rs +++ b/soulseek-rs/src/models/app_state.rs @@ -1,4 +1,4 @@ -use crate::models::{BrowseState, FileDisplayData}; +use crate::models::{BrowseState, FileDisplayData, RoomsState}; use ratatui::{layout::Rect, widgets::TableState}; use soulseek_rs::{DownloadStatus, types::Download}; use std::sync::atomic::AtomicBool; @@ -91,12 +91,19 @@ pub struct AppState { // Private messages pub messages: Vec, pub show_messages: bool, + /// Incoming private messages received while the inbox was closed. + pub unread_messages: usize, // Browse a user's shared files pub browse: Option, pub show_browse: bool, pub browse_table_state: TableState, + // Chat rooms + pub rooms: RoomsState, + pub show_rooms: bool, + pub rooms_list_table_state: TableState, + // Pane areas for mouse interaction pub searches_pane_area: Option, pub results_pane_area: Option, @@ -142,11 +149,16 @@ impl AppState { messages: Vec::new(), show_messages: false, + unread_messages: 0, browse: None, show_browse: false, browse_table_state: TableState::default(), + rooms: RoomsState::new(), + show_rooms: false, + rooms_list_table_state: TableState::default(), + 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 399e4c3..737be9b 100644 --- a/soulseek-rs/src/models/mod.rs +++ b/soulseek-rs/src/models/mod.rs @@ -1,6 +1,7 @@ mod app_state; mod browse; mod file_display_data; +mod rooms; pub use app_state::{ AppState, ChatMessage, CommandBarMode, DownloadEntry, FocusedPane, @@ -8,3 +9,4 @@ pub use app_state::{ }; pub use browse::{BrowseState, BrowseStatus, files_under, find_node}; pub use file_display_data::FileDisplayData; +pub use rooms::{RoomLine, RoomsState, RoomsView}; diff --git a/soulseek-rs/src/models/rooms.rs b/soulseek-rs/src/models/rooms.rs new file mode 100644 index 0000000..2e1196a --- /dev/null +++ b/soulseek-rs/src/models/rooms.rs @@ -0,0 +1,407 @@ +use soulseek_rs::types::{RoomEvent, RoomInfo}; + +/// One line in a room's chat log. A `None` username marks a system line +/// (joins/leaves), rendered dimmed. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct RoomLine { + pub username: Option, + pub text: String, +} + +impl RoomLine { + const fn chat(username: String, text: String) -> Self { + Self { + username: Some(username), + text, + } + } + const fn system(text: String) -> Self { + Self { + username: None, + text, + } + } +} + +/// A chat room the user has open (a tab). +#[derive(Debug, Clone, Default)] +pub struct OpenRoom { + pub name: String, + pub users: Vec, + pub lines: Vec, + /// Unread messages received while this room was not being viewed. + pub unread: usize, + /// Per-room compose buffer. + pub input: String, +} + +impl OpenRoom { + fn new(name: String) -> Self { + Self { + name, + ..Default::default() + } + } +} + +/// Which sub-view of the rooms popup is showing. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum RoomsView { + /// The browsable/filterable list of public rooms. + List, + /// The open-room tabs and their chat logs. + Chat, +} + +/// All chat-room UI state: the discoverable room list plus the set of open +/// rooms (tabs). +pub struct RoomsState { + /// Latest public-room snapshot from the server. + pub available: Vec, + pub list_filter: String, + pub list_is_filtering: bool, + /// Selected index into the *filtered* room list. + pub list_selected: usize, + /// Rooms the user has joined (tabs). + pub open: Vec, + /// Index into `open` of the active tab. + pub active: usize, + pub view: RoomsView, + /// Whether the compose input for the active room is capturing keys. + pub composing: bool, +} + +impl Default for RoomsState { + fn default() -> Self { + Self::new() + } +} + +impl RoomsState { + #[must_use] + pub const fn new() -> Self { + Self { + available: Vec::new(), + list_filter: String::new(), + list_is_filtering: false, + list_selected: 0, + open: Vec::new(), + active: 0, + view: RoomsView::List, + composing: false, + } + } + + /// The public rooms matching the current filter, busiest first then by name. + #[must_use] + pub fn filtered_rooms(&self) -> Vec { + let needle = self.list_filter.to_lowercase(); + let mut rooms: Vec = self + .available + .iter() + .filter(|r| { + needle.is_empty() || r.name.to_lowercase().contains(&needle) + }) + .cloned() + .collect(); + rooms.sort_by(|a, b| { + b.user_count + .cmp(&a.user_count) + .then_with(|| a.name.cmp(&b.name)) + }); + rooms + } + + /// The room name currently highlighted in the list view, if any. + #[must_use] + pub fn selected_room_name(&self) -> Option { + let rooms = self.filtered_rooms(); + rooms.get(self.list_selected).map(|r| r.name.clone()) + } + + #[must_use] + pub fn open_index(&self, name: &str) -> Option { + self.open.iter().position(|r| r.name == name) + } + + #[must_use] + pub fn active_room(&self) -> Option<&OpenRoom> { + self.open.get(self.active) + } + + /// Open (or focus) a room by name, switching to the Chat view. Returns + /// `true` if the room was newly opened (so the caller should `join_room`). + pub fn focus_or_open(&mut self, name: &str) -> bool { + self.view = RoomsView::Chat; + if let Some(idx) = self.open_index(name) { + self.active = idx; + self.mark_active_read(); + false + } else { + self.open.push(OpenRoom::new(name.to_string())); + self.active = self.open.len() - 1; + true + } + } + + /// Close the active tab, returning its room name (for `leave_room`). + pub fn close_active(&mut self) -> Option { + if self.active >= self.open.len() { + return None; + } + let room = self.open.remove(self.active); + if self.active >= self.open.len() { + self.active = self.open.len().saturating_sub(1); + } + if self.open.is_empty() { + self.view = RoomsView::List; + } + Some(room.name) + } + + pub fn next_tab(&mut self) { + if !self.open.is_empty() { + self.active = (self.active + 1) % self.open.len(); + self.mark_active_read(); + } + } + + pub fn prev_tab(&mut self) { + if !self.open.is_empty() { + self.active = (self.active + self.open.len() - 1) % self.open.len(); + self.mark_active_read(); + } + } + + /// Clear the unread badge on the active room. + pub fn mark_active_read(&mut self) { + if let Some(room) = self.open.get_mut(self.active) { + room.unread = 0; + } + } + + /// Total unread messages across all open rooms (for the closed-popup badge). + #[must_use] + pub fn total_unread(&self) -> usize { + self.open.iter().map(|r| r.unread).sum() + } + + /// Apply a room event. `viewing` is the room the user is actively looking + /// at (Chat view open on that tab), whose incoming messages should not + /// accrue an unread badge. + pub fn apply_event(&mut self, event: RoomEvent, viewing: Option<&str>) { + match event { + RoomEvent::List(rooms) => { + self.available = rooms; + let max = self.filtered_rooms().len().saturating_sub(1); + self.list_selected = self.list_selected.min(max); + } + RoomEvent::Joined { room, users } => { + let idx = self.ensure_open(&room); + self.open[idx].users = users; + self.open[idx] + .lines + .push(RoomLine::system("— joined —".to_string())); + } + RoomEvent::Left { room } => { + if let Some(idx) = self.open_index(&room) { + self.open[idx] + .lines + .push(RoomLine::system("— left —".to_string())); + } + } + RoomEvent::Message { + room, + username, + message, + } => { + let idx = self.ensure_open(&room); + self.open[idx].lines.push(RoomLine::chat(username, message)); + if viewing != Some(room.as_str()) { + self.open[idx].unread += 1; + } + } + RoomEvent::UserJoined { room, username } => { + if let Some(idx) = self.open_index(&room) { + if !self.open[idx].users.contains(&username) { + self.open[idx].users.push(username.clone()); + } + self.open[idx] + .lines + .push(RoomLine::system(format!("→ {username}"))); + } + } + RoomEvent::UserLeft { room, username } => { + if let Some(idx) = self.open_index(&room) { + self.open[idx].users.retain(|u| u != &username); + self.open[idx] + .lines + .push(RoomLine::system(format!("← {username}"))); + } + } + } + } + + /// Index of the open room `name`, creating an empty tab if necessary. + fn ensure_open(&mut self, name: &str) -> usize { + if let Some(idx) = self.open_index(name) { + idx + } else { + self.open.push(OpenRoom::new(name.to_string())); + self.open.len() - 1 + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + + fn room(name: &str, users: u32) -> RoomInfo { + RoomInfo { + name: name.to_string(), + user_count: users, + } + } + + #[test] + fn filtered_rooms_sorts_busiest_first_then_name() { + let mut state = RoomsState::new(); + state.apply_event( + RoomEvent::List(vec![ + room("blues", 3), + room("jazz", 10), + room("ambient", 10), + ]), + None, + ); + let names: Vec = + state.filtered_rooms().into_iter().map(|r| r.name).collect(); + assert_eq!(names, vec!["ambient", "jazz", "blues"]); + } + + #[test] + fn filter_matches_case_insensitively() { + let mut state = RoomsState::new(); + state.apply_event( + RoomEvent::List(vec![room("Jazz", 1), room("Blues", 1)]), + None, + ); + state.list_filter = "JAZ".to_string(); + let names: Vec = + state.filtered_rooms().into_iter().map(|r| r.name).collect(); + assert_eq!(names, vec!["Jazz"]); + } + + #[test] + fn joining_creates_a_tab_with_members() { + let mut state = RoomsState::new(); + state.apply_event( + RoomEvent::Joined { + room: "jazz".to_string(), + users: vec!["alice".to_string(), "bob".to_string()], + }, + None, + ); + assert_eq!(state.open.len(), 1); + assert_eq!(state.open[0].name, "jazz"); + assert_eq!(state.open[0].users, vec!["alice", "bob"]); + } + + #[test] + fn message_to_unviewed_room_increments_unread() { + let mut state = RoomsState::new(); + state.focus_or_open("jazz"); + state.apply_event( + RoomEvent::Message { + room: "jazz".to_string(), + username: "bob".to_string(), + message: "hi".to_string(), + }, + None, // not viewing anything + ); + assert_eq!(state.open[0].unread, 1); + assert_eq!(state.total_unread(), 1); + } + + #[test] + fn message_to_viewed_room_does_not_increment_unread() { + let mut state = RoomsState::new(); + state.focus_or_open("jazz"); + state.apply_event( + RoomEvent::Message { + room: "jazz".to_string(), + username: "bob".to_string(), + message: "hi".to_string(), + }, + Some("jazz"), + ); + assert_eq!(state.open[0].unread, 0); + assert_eq!(state.open[0].lines.len(), 1); + } + + #[test] + fn focus_or_open_is_idempotent_and_marks_read() { + let mut state = RoomsState::new(); + assert!(state.focus_or_open("jazz")); // newly opened + state.open[0].unread = 4; + assert!(!state.focus_or_open("jazz")); // already open + assert_eq!(state.open.len(), 1); + assert_eq!(state.open[0].unread, 0, "focusing clears unread"); + } + + #[test] + fn closing_active_tab_returns_name_and_adjusts_index() { + let mut state = RoomsState::new(); + state.focus_or_open("a"); + state.focus_or_open("b"); + assert_eq!(state.active, 1); + assert_eq!(state.close_active().as_deref(), Some("b")); + assert_eq!(state.active, 0); + assert_eq!(state.open.len(), 1); + assert_eq!(state.close_active().as_deref(), Some("a")); + assert!(state.open.is_empty()); + assert_eq!(state.view, RoomsView::List); + } + + #[test] + fn tab_cycling_wraps_and_marks_read() { + let mut state = RoomsState::new(); + state.focus_or_open("a"); + state.focus_or_open("b"); + state.open[0].unread = 2; + state.next_tab(); // 1 -> 0 + assert_eq!(state.active, 0); + assert_eq!(state.open[0].unread, 0); + state.prev_tab(); // 0 -> 1 + assert_eq!(state.active, 1); + } + + #[test] + fn user_joined_and_left_update_membership() { + let mut state = RoomsState::new(); + state.apply_event( + RoomEvent::Joined { + room: "jazz".to_string(), + users: vec!["alice".to_string()], + }, + None, + ); + state.apply_event( + RoomEvent::UserJoined { + room: "jazz".to_string(), + username: "bob".to_string(), + }, + None, + ); + assert_eq!(state.open[0].users, vec!["alice", "bob"]); + state.apply_event( + RoomEvent::UserLeft { + room: "jazz".to_string(), + username: "alice".to_string(), + }, + None, + ); + assert_eq!(state.open[0].users, vec!["bob"]); + } +} diff --git a/soulseek-rs/src/ui/main_tui.rs b/soulseek-rs/src/ui/main_tui.rs index 320cf9a..365ea61 100644 --- a/soulseek-rs/src/ui/main_tui.rs +++ b/soulseek-rs/src/ui/main_tui.rs @@ -1,11 +1,12 @@ use crate::models::{ AppState, BrowseState, BrowseStatus, ChatMessage, CommandBarMode, - DownloadEntry, FileDisplayData, FocusedPane, MessageDirection, SearchEntry, - SearchStatus, files_under, find_node, + DownloadEntry, FileDisplayData, FocusedPane, MessageDirection, RoomsView, + 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, + render_downloads_pane, render_results_pane, render_rooms_pane, + render_searches_pane, }; use crate::ui::{ border_style, border_type, format_shortcuts_styled, render_download_stats, @@ -106,6 +107,9 @@ impl MainTui { // Poll for a browse (shared-file listing) response self.poll_browse_result(); + // Poll for chat-room events + self.poll_room_events(); + // Update spinner self.spinner_state = (self.spinner_state + 1) % 10; @@ -273,6 +277,18 @@ impl MainTui { self.spinner_state, ); } + + // Chat rooms overlay everything when open. + if self.state.show_rooms { + let area = centered_rect(85, 80, frame.area()); + frame.render_widget(ratatui::widgets::Clear, area); + render_rooms_pane( + frame, + area, + &self.state.rooms, + &mut self.state.rooms_list_table_state, + ); + } } fn render_messages_popup(&self, frame: &mut Frame) { @@ -312,8 +328,60 @@ impl MainTui { frame.render_widget(popup, area); } + /// Context shortcuts for the chat-rooms popup. + fn rooms_shortcuts(&self) -> Vec<(&'static str, &'static str)> { + if self.state.rooms.composing { + return vec![ + ("Type", "message"), + ("Enter", "send"), + ("Esc", "cancel"), + ]; + } + match self.state.rooms.view { + RoomsView::List => { + if self.state.rooms.list_is_filtering { + vec![ + ("Type", "filter"), + ("Enter", "join match"), + ("Esc", "clear filter"), + ] + } else { + vec![ + ("↑↓", "move"), + ("Enter", "join"), + ("/", "filter"), + ("Tab", "open rooms"), + ("Esc", "close"), + ] + } + } + RoomsView::Chat => vec![ + ("Enter", "type message"), + ("Tab", "switch room"), + ("l", "room list"), + ("x", "leave"), + ("q", "close"), + ], + } + } + fn render_shortcuts(&self, frame: &mut Frame, area: Rect) { - let shortcuts = if self.state.show_browse { + // Unread badges for the inbox and chat shortcuts. + let inbox_label = if self.state.unread_messages > 0 { + format!("inbox ({})", self.state.unread_messages) + } else { + "inbox".to_string() + }; + let chat_unread = self.state.rooms.total_unread(); + let chat_label = if chat_unread > 0 { + format!("chat ({chat_unread})") + } else { + "chat".to_string() + }; + + let shortcuts = if self.state.show_rooms { + self.rooms_shortcuts() + } else if self.state.show_browse { vec![ ("↑↓", "move"), ("→←", "expand/collapse"), @@ -346,11 +414,12 @@ impl MainTui { FocusedPane::Searches => vec![ ("s", "search"), ("m", "message"), - ("i", "inbox"), + ("i", inbox_label.as_str()), + ("c", chat_label.as_str()), ("b", "browse user"), ("1-3", "focus pane"), ("↑↓", "navigate"), - ("Enter", "view results"), + ("Enter", "results"), ("q", "quit"), ], FocusedPane::Results if self.state.results_is_filtering => { @@ -365,6 +434,7 @@ impl MainTui { ("Space", "select"), ("Enter", "download"), ("b", "browse owner"), + ("c", chat_label.as_str()), ("/", "filter"), ("a/A", "select all/none"), ("1-3", "focus pane"), @@ -451,6 +521,11 @@ impl MainTui { return self.handle_browse_input(key); } + // Rooms popup takes over navigation while open. + if self.state.show_rooms { + return self.handle_rooms_input(key); + } + // Filter mode in Results pane if self.state.results_is_filtering && self.state.focused_pane == FocusedPane::Results @@ -492,6 +567,15 @@ impl MainTui { } KeyCode::Char('i') => { self.state.show_messages = true; + self.state.unread_messages = 0; + return; + } + // Chat rooms. In the Downloads pane `c` clears finished downloads, + // so only open chat from the other panes (like `b` is contextual). + KeyCode::Char('c') + if self.state.focused_pane != FocusedPane::Downloads => + { + self.start_rooms(); return; } KeyCode::Char('b') => { @@ -657,6 +741,194 @@ impl MainTui { }); } + /// Open the chat-rooms popup and refresh the room list. If rooms are + /// already open, jump straight to the chat view; otherwise show the list. + fn start_rooms(&mut self) { + let _ = self.client.request_room_list(); + self.state.show_rooms = true; + if self.state.rooms.open.is_empty() { + self.state.rooms.view = RoomsView::List; + } else { + self.state.rooms.view = RoomsView::Chat; + self.state.rooms.mark_active_read(); + } + } + + fn handle_rooms_input(&mut self, key: KeyEvent) { + // Composing a message captures typing. + if self.state.rooms.composing { + self.handle_room_compose_input(key); + return; + } + // Filtering the room list captures typing. + if self.state.rooms.view == RoomsView::List + && self.state.rooms.list_is_filtering + { + self.handle_room_filter_input(key); + return; + } + + match self.state.rooms.view { + RoomsView::List => self.handle_rooms_list_input(key), + RoomsView::Chat => self.handle_rooms_chat_input(key), + } + } + + fn handle_rooms_list_input(&mut self, key: KeyEvent) { + let len = self.state.rooms.filtered_rooms().len(); + match key.code { + KeyCode::Esc | KeyCode::Char('q') => { + self.state.show_rooms = false; + } + KeyCode::Char('/') => { + self.state.rooms.list_is_filtering = true; + } + KeyCode::Up | KeyCode::Char('k') => { + self.state.rooms.list_selected = + self.state.rooms.list_selected.saturating_sub(1); + } + KeyCode::Down | KeyCode::Char('j') => { + if len > 0 { + self.state.rooms.list_selected = + (self.state.rooms.list_selected + 1).min(len - 1); + } + } + KeyCode::Enter => self.join_selected_room(), + KeyCode::Tab if !self.state.rooms.open.is_empty() => { + self.state.rooms.view = RoomsView::Chat; + self.state.rooms.mark_active_read(); + } + _ => {} + } + } + + fn handle_room_filter_input(&mut self, key: KeyEvent) { + match key.code { + KeyCode::Esc => { + self.state.rooms.list_is_filtering = false; + self.state.rooms.list_filter.clear(); + self.state.rooms.list_selected = 0; + } + KeyCode::Enter => { + self.state.rooms.list_is_filtering = false; + self.join_selected_room(); + } + KeyCode::Char(c) => { + self.state.rooms.list_filter.push(c); + self.state.rooms.list_selected = 0; + } + KeyCode::Backspace => { + self.state.rooms.list_filter.pop(); + self.state.rooms.list_selected = 0; + } + _ => {} + } + } + + fn handle_rooms_chat_input(&mut self, key: KeyEvent) { + match key.code { + KeyCode::Char('q') => self.state.show_rooms = false, + KeyCode::Esc | KeyCode::Char('l') => { + self.state.rooms.view = RoomsView::List; + } + KeyCode::Tab => self.state.rooms.next_tab(), + KeyCode::BackTab => self.state.rooms.prev_tab(), + KeyCode::Char('x') => self.leave_active_room(), + KeyCode::Enter if self.state.rooms.active_room().is_some() => { + self.state.rooms.composing = true; + } + _ => {} + } + } + + fn handle_room_compose_input(&mut self, key: KeyEvent) { + match key.code { + KeyCode::Enter => self.send_room_message(), + KeyCode::Esc => { + self.state.rooms.composing = false; + if let Some(room) = + self.state.rooms.open.get_mut(self.state.rooms.active) + { + room.input.clear(); + } + } + KeyCode::Backspace => { + if let Some(room) = + self.state.rooms.open.get_mut(self.state.rooms.active) + { + room.input.pop(); + } + } + KeyCode::Char(c) + if !key + .modifiers + .intersects(KeyModifiers::CONTROL | KeyModifiers::ALT) => + { + if let Some(room) = + self.state.rooms.open.get_mut(self.state.rooms.active) + { + room.input.push(c); + } + } + _ => {} + } + } + + /// Join (or focus) the room highlighted in the list view. + fn join_selected_room(&mut self) { + if let Some(name) = self.state.rooms.selected_room_name() { + let newly_opened = self.state.rooms.focus_or_open(&name); + if newly_opened && let Err(e) = self.client.join_room(&name) { + eprintln!("Failed to join {name}: {e}"); + } + } + } + + /// Leave the active room and close its tab. + fn leave_active_room(&mut self) { + if let Some(name) = self.state.rooms.close_active() + && let Err(e) = self.client.leave_room(&name) + { + eprintln!("Failed to leave {name}: {e}"); + } + } + + /// Send the active room's compose buffer. The server echoes the message + /// back as a RoomEvent, which is what actually renders it in the log. + fn send_room_message(&mut self) { + let active = self.state.rooms.active; + let (room, text) = match self.state.rooms.open.get(active) { + Some(room) if !room.input.trim().is_empty() => { + (room.name.clone(), room.input.trim().to_string()) + } + _ => { + self.state.rooms.composing = false; + return; + } + }; + if let Err(e) = self.client.say_in_room(&room, &text) { + eprintln!("Failed to say in {room}: {e}"); + } + if let Some(room) = self.state.rooms.open.get_mut(active) { + room.input.clear(); + } + self.state.rooms.composing = false; + } + + /// Drain chat-room events into the rooms state, tracking unread badges. + fn poll_room_events(&mut self) { + let viewing = if self.state.show_rooms + && self.state.rooms.view == RoomsView::Chat + { + self.state.rooms.active_room().map(|r| r.name.clone()) + } else { + None + }; + for event in self.client.take_room_events() { + self.state.rooms.apply_event(event, viewing.as_deref()); + } + } + 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, @@ -1208,6 +1480,10 @@ impl MainTui { peer: msg.username().to_string(), text: msg.message().to_string(), }); + // Badge the inbox when it isn't currently open. + if !self.state.show_messages { + self.state.unread_messages += 1; + } } } diff --git a/soulseek-rs/src/ui/panes/mod.rs b/soulseek-rs/src/ui/panes/mod.rs index 43f1a87..994b9dd 100644 --- a/soulseek-rs/src/ui/panes/mod.rs +++ b/soulseek-rs/src/ui/panes/mod.rs @@ -2,10 +2,12 @@ mod browse_pane; mod download_info_pane; mod downloads_pane; mod results_pane; +mod rooms_pane; mod searches_pane; pub use browse_pane::render_browse_pane; pub use download_info_pane::render_download_info_pane; pub use downloads_pane::render_downloads_pane; pub use results_pane::{ResultsPaneParams, render_results_pane}; +pub use rooms_pane::render_rooms_pane; pub use searches_pane::render_searches_pane; diff --git a/soulseek-rs/src/ui/panes/rooms_pane.rs b/soulseek-rs/src/ui/panes/rooms_pane.rs new file mode 100644 index 0000000..9da220e --- /dev/null +++ b/soulseek-rs/src/ui/panes/rooms_pane.rs @@ -0,0 +1,218 @@ +use crate::models::{RoomsState, RoomsView}; +use crate::ui::{ + HIGHLIGHT_SYMBOL, accent_style, border_style, border_type, dimmed_style, + highlight_style, info_style, primary_style, +}; +use ratatui::{ + Frame, + layout::{Constraint, Layout, Rect}, + style::Modifier, + text::{Line, Span}, + widgets::{ + Block, Borders, Cell, HighlightSpacing, Paragraph, Row, Table, + TableState, Wrap, + }, +}; + +/// Render the chat-rooms popup: either the browsable room list or the open +/// rooms' tabbed chat view. +pub fn render_rooms_pane( + frame: &mut Frame, + area: Rect, + rooms: &RoomsState, + list_table_state: &mut TableState, +) { + match rooms.view { + RoomsView::List => render_list(frame, area, rooms, list_table_state), + RoomsView::Chat => render_chat(frame, area, rooms), + } +} + +fn render_list( + frame: &mut Frame, + area: Rect, + rooms: &RoomsState, + table_state: &mut TableState, +) { + let title = if rooms.list_is_filtering || !rooms.list_filter.is_empty() { + format!( + " Rooms · filter: {}_ (Enter: join, Esc: clear) ", + rooms.list_filter + ) + } else { + " Rooms (Enter: join, /: filter, Tab: open rooms, Esc: close) " + .to_string() + }; + let block = Block::default() + .borders(Borders::ALL) + .border_style(border_style(true)) + .border_type(border_type(true)) + .title(title); + + let filtered = rooms.filtered_rooms(); + if filtered.is_empty() { + let text = if rooms.available.is_empty() { + "Fetching room list…" + } else { + "No rooms match the filter." + }; + frame.render_widget(Paragraph::new(text).block(block), area); + return; + } + + let header = Row::new(vec![ + Cell::from("room").style(dimmed_style()), + Cell::from("users").style(dimmed_style()), + ]); + let table_rows: Vec = filtered + .iter() + .map(|r| { + let open = rooms.open_index(&r.name).is_some(); + let name = if open { + format!("● {}", r.name) + } else { + format!(" {}", r.name) + }; + let name_style = if open { + accent_style() + } else { + primary_style() + }; + Row::new(vec![ + Cell::from(name).style(name_style), + Cell::from(r.user_count.to_string()), + ]) + }) + .collect(); + + let table = + Table::new(table_rows, [Constraint::Fill(1), Constraint::Length(8)]) + .header(header) + .row_highlight_style(highlight_style()) + .highlight_symbol(HIGHLIGHT_SYMBOL) + .highlight_spacing(HighlightSpacing::Always) + .block(block); + + table_state.select(Some(rooms.list_selected.min(filtered.len() - 1))); + frame.render_stateful_widget(table, area, table_state); +} + +fn render_chat(frame: &mut Frame, area: Rect, rooms: &RoomsState) { + let block = Block::default() + .borders(Borders::ALL) + .border_style(border_style(true)) + .border_type(border_type(true)) + .title( + " Chat rooms (Tab: switch, l: room list, x: leave, Esc: back) ", + ); + let inner = block.inner(area); + frame.render_widget(block, area); + + let chunks = Layout::vertical([ + Constraint::Length(1), // tab bar + Constraint::Fill(1), // body + Constraint::Length(1), // compose / hint + ]) + .split(inner); + + render_tab_bar(frame, chunks[0], rooms); + + let Some(active) = rooms.active_room() else { + frame.render_widget( + Paragraph::new("No open rooms. Press l for the room list.") + .style(dimmed_style()), + chunks[1], + ); + return; + }; + + // Body: messages (left) + user list (right). + let body = + Layout::horizontal([Constraint::Fill(1), Constraint::Length(22)]) + .split(chunks[1]); + + render_messages(frame, body[0], active.lines.as_slice()); + render_users(frame, body[1], &active.users); + + // Compose line or hint. + if rooms.composing { + let line = Line::from(vec![ + Span::styled("› ", accent_style()), + Span::styled(active.input.clone(), primary_style()), + Span::styled("▏", accent_style()), + ]); + frame.render_widget(Paragraph::new(line), chunks[2]); + } else { + frame.render_widget( + Paragraph::new(Line::from(Span::styled( + "Enter: type a message", + dimmed_style(), + ))), + chunks[2], + ); + } +} + +fn render_tab_bar(frame: &mut Frame, area: Rect, rooms: &RoomsState) { + let mut spans: Vec = Vec::new(); + for (i, room) in rooms.open.iter().enumerate() { + let active = i == rooms.active; + let label = if room.unread > 0 { + format!(" {} ({}) ", room.name, room.unread) + } else { + format!(" {} ", room.name) + }; + let mut style = if active { + highlight_style() + } else { + primary_style() + }; + if room.unread > 0 { + style = style.add_modifier(Modifier::BOLD); + } + spans.push(Span::styled(label, style)); + spans.push(Span::raw(" ")); + } + frame.render_widget(Paragraph::new(Line::from(spans)), area); +} + +fn render_messages( + frame: &mut Frame, + area: Rect, + lines: &[crate::models::RoomLine], +) { + // Auto-scroll: keep the most recent lines that fit the height. + let height = area.height as usize; + let start = lines.len().saturating_sub(height.max(1)); + let rendered: Vec = lines[start..] + .iter() + .map(|l| match &l.username { + Some(user) => Line::from(vec![ + Span::styled(format!("<{user}> "), info_style()), + Span::styled(l.text.clone(), primary_style()), + ]), + None => Line::from(Span::styled(l.text.clone(), dimmed_style())), + }) + .collect(); + frame.render_widget( + Paragraph::new(rendered).wrap(Wrap { trim: false }), + area, + ); +} + +fn render_users(frame: &mut Frame, area: Rect, users: &[String]) { + let block = Block::default() + .borders(Borders::LEFT) + .border_style(dimmed_style()) + .title(format!(" Users ({}) ", users.len())); + let inner = block.inner(area); + frame.render_widget(block, area); + + let height = inner.height as usize; + let shown: Vec = users + .iter() + .take(height) + .map(|u| Line::from(Span::styled(u.clone(), primary_style()))) + .collect(); + frame.render_widget(Paragraph::new(shown), inner); +}