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:
michel
2026-07-19 19:58:32 +02:00
parent 7d94b2caa3
commit 08272ac867
6 changed files with 924 additions and 7 deletions
+282 -6
View File
@@ -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
View File
@@ -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;
+218
View File
@@ -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);
}