feat(tui): chat rooms — browsable list, multiple open rooms, unread badges
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).
This commit is contained in:
@@ -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<ChatMessage>,
|
||||
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<BrowseState>,
|
||||
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<Rect>,
|
||||
pub results_pane_area: Option<Rect>,
|
||||
@@ -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,
|
||||
|
||||
@@ -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};
|
||||
|
||||
@@ -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<String>,
|
||||
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<String>,
|
||||
pub lines: Vec<RoomLine>,
|
||||
/// 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<RoomInfo>,
|
||||
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<OpenRoom>,
|
||||
/// 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<RoomInfo> {
|
||||
let needle = self.list_filter.to_lowercase();
|
||||
let mut rooms: Vec<RoomInfo> = 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<String> {
|
||||
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<usize> {
|
||||
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<String> {
|
||||
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<String> =
|
||||
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<String> =
|
||||
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"]);
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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<Row> = 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<Span> = 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<Line> = 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<Line> = users
|
||||
.iter()
|
||||
.take(height)
|
||||
.map(|u| Line::from(Span::styled(u.clone(), primary_style())))
|
||||
.collect();
|
||||
frame.render_widget(Paragraph::new(shown), inner);
|
||||
}
|
||||
Reference in New Issue
Block a user