Files
soulseek-rs-modded/soulseek-rs/src/ui/panes/rooms_pane.rs
T
Michel de Graaf 0cacc8cc10 refactor(tui): one page window and one filter editor for every list
Four panes had grown the same dance around ratatui's table: clamp the
selection, take the visible page, hand the table a page-local state,
keep the page start. It is now page_window and render_page in a paging
module beside page_of, and the two popups page by the same count they
draw, tab bar or header included.

Five typed filters had grown the same key handling too. edit_filter now
takes a key for all of them, which also stops a control or alt chord
from typing its letter into a filter, stops Enter, which keeps a filter,
from resetting the view it is over, and lets the results and room-list
filters page like the others. Their titles and the typing shortcuts
come from one place as well.
2026-09-08 11:24:59 +02:00

278 lines
8.4 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
use crate::models::{
ChatFilter, LogView, OpenRoom, RoomLine, RoomsState, RoomsView, WrappedLog,
contains_filter, matching_users,
};
use crate::ui::{
HIGHLIGHT_SYMBOL, PANE_PADDING, accent_style, dimmed_style, filter_title,
highlight_style, info_style, page_window, pane_block, plain_title,
primary_style, render_page, row_highlight_style, wrap_chat_line,
};
use ratatui::{
Frame,
layout::{Constraint, Layout, Rect},
style::Modifier,
text::{Line, Span},
widgets::{
Block, Borders, Cell, HighlightSpacing, Paragraph, Row, Table,
TableState,
},
};
/// 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: &mut 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 = pane_block(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()),
]);
table_state.select(Some(rooms.list_selected));
let window = page_window(table_state, filtered.len(), area, 1);
let start = window.start;
let table_rows: Vec<Row> = filtered[window]
.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(row_highlight_style())
.highlight_symbol(HIGHLIGHT_SYMBOL)
.highlight_spacing(HighlightSpacing::Always)
.block(block);
render_page(frame, table, area, table_state, start);
}
fn render_chat(frame: &mut Frame, area: Rect, rooms: &mut RoomsState) {
let typing_log = rooms.filtering == Some(ChatFilter::Log);
let title = filter_title(
"Chat rooms",
&rooms.log_filter,
typing_log,
" Chat rooms (Tab: switch, /: find, u: find user, l: room list, x: leave, Esc: back) ",
);
let block = pane_block(true).title(title);
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 RoomsState {
open,
active: active_index,
user_selected,
composing,
log_filter,
user_filter,
filtering,
..
} = rooms;
let Some(active) = open.get_mut(*active_index) 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]);
let OpenRoom {
lines,
view,
wrapped,
users,
..
} = active;
render_messages(frame, body[0], lines, view, wrapped, log_filter);
let shown = matching_users(users, user_filter);
let typing_users = *filtering == Some(ChatFilter::Users);
let members = if typing_users || !user_filter.is_empty() {
format!(
"Users {}/{} · {}{}",
shown.len(),
users.len(),
user_filter,
if typing_users { "_" } else { "" }
)
} else {
format!("Users ({})", users.len())
};
render_users(frame, body[1], &shown, *user_selected, members);
// Compose line or hint.
if *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: &[RoomLine],
view: &mut LogView,
wrapped: &mut WrappedLog,
filter: &str,
) {
// Each message wrapped to the pane width, then the rows the reader is
// at: the newest ones, unless they are holding a place in the history.
let width = area.width as usize;
let rendered = wrapped.rows(width, filter, lines, |l| {
let sender = l.username.as_deref().unwrap_or_default();
if !contains_filter(&l.text, filter) && !contains_filter(sender, filter)
{
return Vec::new();
}
let time =
Span::styled(l.at.format("%H:%M ").to_string(), dimmed_style());
match &l.username {
Some(user) => wrap_chat_line(
vec![time, Span::styled(format!("<{user}> "), info_style())],
&l.text,
primary_style(),
width,
),
None => wrap_chat_line(vec![time], &l.text, dimmed_style(), width),
}
});
let window = view.window(rendered.len(), usize::from(area.height));
frame.render_widget(Paragraph::new(rendered[window].to_vec()), area);
}
fn render_users(
frame: &mut Frame,
area: Rect,
users: &[&str],
selected: usize,
title: String,
) {
let block = Block::default()
.borders(Borders::LEFT)
.border_style(dimmed_style())
.padding(PANE_PADDING)
.title(plain_title(title, false));
let inner = block.inner(area);
frame.render_widget(block, area);
let height = inner.height as usize;
// Keep the highlighted user in view by scrolling the window to it.
let start = selected.saturating_sub(height.saturating_sub(1));
let shown: Vec<Line> = users
.iter()
.enumerate()
.skip(start)
.take(height)
.map(|(i, u)| {
if i == selected {
Line::from(Span::styled(format!("▸ {u}"), highlight_style()))
} else {
Line::from(Span::styled(format!(" {u}"), primary_style()))
}
})
.collect();
frame.render_widget(Paragraph::new(shown), inner);
}