Files
soulseek-rs-modded/soulseek-rs/src/ui/main_tui/rooms.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

295 lines
11 KiB
Rust

use super::MainTui;
use super::input::{FilterEdit, edit_filter, jumped, list_jump, scroll_log};
use crate::models::{ChatFilter, RoomsView};
use ratatui::crossterm::event::{KeyCode, KeyEvent, KeyModifiers};
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.
pub(super) 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();
}
}
pub(super) 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;
}
// So does typing a filter over the log or the member list; the
// other keys still move around.
if self.state.rooms.view == RoomsView::Chat
&& self.state.rooms.filtering.is_some()
&& self.handle_chat_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();
if let Some(jump) = list_jump(key, self.popup_page(1)) {
self.state.rooms.list_selected =
jumped(self.state.rooms.list_selected, len, jump);
return;
}
// Control combinations are only ever jumps; the letters below are
// not theirs to trigger.
if key.modifiers.contains(KeyModifiers::CONTROL) {
return;
}
match key.code {
// Esc peels back one level: clear a lingering filter first (as the
// title's "Esc: clear" promises), otherwise close the popup.
KeyCode::Esc if !self.state.rooms.list_filter.is_empty() => {
self.state.rooms.list_filter.clear();
self.state.rooms.list_selected = 0;
}
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) {
let Some(edit) = edit_filter(&mut self.state.rooms.list_filter, key)
else {
return self.handle_rooms_list_input(key);
};
match edit {
FilterEdit::Changed | FilterEdit::Cleared => {
self.state.rooms.list_selected = 0;
}
FilterEdit::Kept => self.join_selected_room(),
}
if edit != FilterEdit::Changed {
self.state.rooms.list_is_filtering = false;
}
}
fn handle_rooms_chat_input(&mut self, key: KeyEvent) {
// Paging keys move through the room's history; the member list is a
// row at a time.
if let Some(view) = self.state.rooms.active_view_mut()
&& scroll_log(view, key)
{
return;
}
if key.modifiers.contains(KeyModifiers::CONTROL) {
return;
}
let filtered = !self.state.rooms.log_filter.is_empty()
|| !self.state.rooms.user_filter.is_empty();
match key.code {
KeyCode::Char('q') => self.state.show_rooms = false,
// Esc peels back one level: the filters first, then the list.
KeyCode::Esc if filtered => {
self.state.rooms.set_log_filter(String::new());
self.state.rooms.set_user_filter(String::new());
}
KeyCode::Esc | KeyCode::Char('l') => {
self.state.rooms.view = RoomsView::List;
}
KeyCode::Char('/') => {
self.state.rooms.filtering = Some(ChatFilter::Log);
}
KeyCode::Char('u') => {
self.state.rooms.filtering = Some(ChatFilter::Users);
}
KeyCode::Tab => self.state.rooms.next_tab(),
KeyCode::BackTab => self.state.rooms.prev_tab(),
KeyCode::Char('x') => self.leave_active_room(),
KeyCode::Up | KeyCode::Char('k') => {
self.state.rooms.select_user_up();
}
KeyCode::Down | KeyCode::Char('j') => {
self.state.rooms.select_user_down();
}
// Act on the highlighted member of the room.
KeyCode::Char('b') => self.browse_selected_room_user(),
KeyCode::Char('m') => self.message_selected_room_user(),
KeyCode::Enter if self.state.rooms.active_room().is_some() => {
self.state.rooms.composing = true;
}
_ => {}
}
}
/// The keys that edit whichever filter is being typed, the log's or the
/// member list's. Says whether `key` was one of them.
fn handle_chat_filter_input(&mut self, key: KeyEvent) -> bool {
let rooms = &mut self.state.rooms;
let over_log = rooms.filtering == Some(ChatFilter::Log);
let mut filter = if over_log {
rooms.log_filter.clone()
} else {
rooms.user_filter.clone()
};
let Some(edit) = edit_filter(&mut filter, key) else {
return false;
};
if edit != FilterEdit::Changed {
rooms.filtering = None;
}
// Keeping a filter leaves the view where it is.
if edit != FilterEdit::Kept {
if over_log {
rooms.set_log_filter(filter);
} else {
rooms.set_user_filter(filter);
}
}
true
}
/// Browse the shares of the member highlighted in the active room. Closes
/// the rooms popup so the browse tree is the sole overlay.
fn browse_selected_room_user(&mut self) {
if let Some(user) = self.state.rooms.selected_user() {
self.state.show_rooms = false;
self.start_browse(user);
}
}
/// Compose a private message to the member highlighted in the active room.
/// Closes the rooms popup and opens the chat box on that conversation.
fn message_selected_room_user(&mut self) {
if let Some(user) = self.state.rooms.selected_user() {
self.state.show_rooms = false;
self.state.open_chat(user);
}
}
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) {
soulseek_rs::warn!("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)
{
soulseek_rs::warn!("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) {
soulseek_rs::warn!("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;
}
/// The room whose newest messages are on screen, if any: the active one
/// in the chat view, unless the reader is held back in its history, when
/// what arrives still counts as unread.
pub(super) fn viewed_room(&self) -> Option<String> {
if !self.state.show_rooms || self.state.rooms.view != RoomsView::Chat {
return None;
}
self.state
.rooms
.active_room()
.filter(|room| room.view.following())
.map(|room| room.name.clone())
}
/// Drain chat-room events into the rooms state, tracking unread badges.
pub(super) fn poll_room_events(&mut self) {
let viewing = self.viewed_room();
for event in self.client.take_room_events() {
self.state.rooms.apply_event(event, viewing.as_deref());
}
}
}