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.
295 lines
11 KiB
Rust
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());
|
|
}
|
|
}
|
|
}
|