feat(tui): search a room's log and members, and a conversation
A busy room's log scrolls past and its member list runs to a thousand names, with nothing to narrow either. In a room / now filters the log to lines mentioning the text and u filters the member list, the same way the room list and the results filter: type, Enter keeps it, Esc clears it before it leaves the room. The inbox takes / over the open conversation. The member keys, b and m among them, act on the narrowed list, and the titles say what is filtered and how many made it.
This commit is contained in:
@@ -190,6 +190,34 @@ impl MainTui {
|
||||
return;
|
||||
}
|
||||
|
||||
// Typing a filter captures the keys that edit it; the rest still
|
||||
// move around.
|
||||
if self.state.chat_filtering {
|
||||
match key.code {
|
||||
KeyCode::Esc => {
|
||||
self.state.chat_filtering = false;
|
||||
self.state.chat_filter.clear();
|
||||
self.state.chat_view.to_newest();
|
||||
return;
|
||||
}
|
||||
KeyCode::Enter => {
|
||||
self.state.chat_filtering = false;
|
||||
return;
|
||||
}
|
||||
KeyCode::Char(c) => {
|
||||
self.state.chat_filter.push(c);
|
||||
self.state.chat_view.to_newest();
|
||||
return;
|
||||
}
|
||||
KeyCode::Backspace => {
|
||||
self.state.chat_filter.pop();
|
||||
self.state.chat_view.to_newest();
|
||||
return;
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
|
||||
// Paging keys move through the conversation's history.
|
||||
if scroll_log(&mut self.state.chat_view, key) {
|
||||
return;
|
||||
@@ -199,9 +227,15 @@ impl MainTui {
|
||||
}
|
||||
|
||||
match key.code {
|
||||
// Esc peels back one level: a filter first, then the popup.
|
||||
KeyCode::Esc if !self.state.chat_filter.is_empty() => {
|
||||
self.state.chat_filter.clear();
|
||||
self.state.chat_view.to_newest();
|
||||
}
|
||||
KeyCode::Char('i' | 'q') | KeyCode::Esc => {
|
||||
self.state.show_messages = false;
|
||||
}
|
||||
KeyCode::Char('/') => self.state.chat_filtering = true,
|
||||
// Move through the conversation list on the right.
|
||||
KeyCode::Tab | KeyCode::Down | KeyCode::Char('j') => {
|
||||
self.state.cycle_chat_peer(true);
|
||||
|
||||
@@ -265,7 +265,9 @@ pub fn launch_main_tui(
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::daemon::proto::ChatMessageDto;
|
||||
use crate::models::{FocusedPane, MessageDirection, SearchStatus};
|
||||
use crate::models::{
|
||||
FocusedPane, MessageDirection, RoomsView, SearchStatus,
|
||||
};
|
||||
use ratatui::crossterm::event::{KeyCode, KeyEvent, KeyModifiers};
|
||||
|
||||
/// A stand-in for a daemon: it already holds a conversation, a transfer
|
||||
@@ -1747,6 +1749,68 @@ mod tests {
|
||||
assert!(!tui.state.show_browse, "then hides the popup");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn slash_and_u_filter_a_rooms_log_and_its_members() {
|
||||
let mut tui = attach(Arc::new(TalkativeSession::default()));
|
||||
in_a_room(&mut tui, 30);
|
||||
press(&mut tui, KeyCode::Char('u'));
|
||||
for c in "user2".chars() {
|
||||
press(&mut tui, KeyCode::Char(c));
|
||||
}
|
||||
press(&mut tui, KeyCode::Enter);
|
||||
press(&mut tui, KeyCode::Char('j'));
|
||||
assert_eq!(
|
||||
tui.state.rooms.selected_user().as_deref(),
|
||||
Some("user21"),
|
||||
"j moves within the matching members"
|
||||
);
|
||||
let screen = screen_of(&mut tui);
|
||||
assert!(screen.contains("Users 10/30"), "{screen}");
|
||||
|
||||
press(&mut tui, KeyCode::Char('/'));
|
||||
for c in "line 01".chars() {
|
||||
press(&mut tui, KeyCode::Char(c));
|
||||
}
|
||||
let screen = screen_of(&mut tui);
|
||||
assert!(screen.contains("line 010"), "{screen}");
|
||||
assert!(!screen.contains("line 029"), "{screen}");
|
||||
assert!(screen.contains("filter: line 01_"), "{screen}");
|
||||
press(&mut tui, KeyCode::Enter);
|
||||
press(&mut tui, KeyCode::Esc);
|
||||
assert!(tui.state.rooms.log_filter.is_empty());
|
||||
assert!(tui.state.rooms.user_filter.is_empty());
|
||||
assert_eq!(tui.state.rooms.view, RoomsView::Chat, "still in the room");
|
||||
press(&mut tui, KeyCode::Esc);
|
||||
assert_eq!(tui.state.rooms.view, RoomsView::List, "then the list");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn slash_filters_the_open_conversation() {
|
||||
let mut tui = attach(Arc::new(TalkativeSession::default()));
|
||||
for text in ["hello there", "see you", "hello again"] {
|
||||
tui.state.messages.push(crate::models::ChatMessage {
|
||||
direction: MessageDirection::Incoming,
|
||||
peer: "bob".to_string(),
|
||||
text: text.to_string(),
|
||||
at: chrono::Local::now(),
|
||||
});
|
||||
}
|
||||
tui.state.chat_peer = Some("bob".to_string());
|
||||
tui.state.show_messages = true;
|
||||
press(&mut tui, KeyCode::Char('/'));
|
||||
for c in "hello".chars() {
|
||||
press(&mut tui, KeyCode::Char(c));
|
||||
}
|
||||
let screen = screen_of(&mut tui);
|
||||
assert!(screen.contains("hello again"), "{screen}");
|
||||
assert!(!screen.contains("see you"), "{screen}");
|
||||
press(&mut tui, KeyCode::Enter);
|
||||
press(&mut tui, KeyCode::Esc);
|
||||
assert!(tui.state.chat_filter.is_empty() && tui.state.show_messages);
|
||||
press(&mut tui, KeyCode::Esc);
|
||||
assert!(!tui.state.show_messages);
|
||||
}
|
||||
|
||||
fn in_a_room(tui: &mut MainTui, messages: usize) {
|
||||
tui.state.rooms.apply_event(
|
||||
soulseek_rs::RoomEvent::Joined {
|
||||
|
||||
@@ -96,6 +96,8 @@ const HELP_RIGHT: &[(&str, &[(&str, &str)])] = &[
|
||||
("^u ^d", "half a page of them"),
|
||||
("Home End / g G", "oldest / newest, or first / last row"),
|
||||
("Tab / Shift-Tab", "next room, chat or user"),
|
||||
("/", "filter the messages, the room list or the tree"),
|
||||
("u", "filter a room's member list"),
|
||||
],
|
||||
),
|
||||
(
|
||||
@@ -494,10 +496,21 @@ impl MainTui {
|
||||
]
|
||||
}
|
||||
}
|
||||
RoomsView::Chat if self.state.rooms.filtering.is_some() => {
|
||||
vec![
|
||||
("Type", "filter"),
|
||||
("PgUp/PgDn", "scroll"),
|
||||
("↑↓", "pick user"),
|
||||
("Enter", "keep filter"),
|
||||
("Esc", "clear filter"),
|
||||
]
|
||||
}
|
||||
RoomsView::Chat => vec![
|
||||
("Enter", "say"),
|
||||
("PgUp/PgDn", "scroll"),
|
||||
("↑↓", "pick user"),
|
||||
("/", "find in chat"),
|
||||
("u", "find user"),
|
||||
("b", "browse user"),
|
||||
("m", "message user"),
|
||||
("Tab", "switch room"),
|
||||
@@ -551,11 +564,19 @@ impl MainTui {
|
||||
("Enter", "send"),
|
||||
("Esc", "stop typing"),
|
||||
]
|
||||
} else if self.state.chat_filtering {
|
||||
vec![
|
||||
("Type", "filter"),
|
||||
("PgUp/PgDn", "scroll"),
|
||||
("Enter", "keep filter"),
|
||||
("Esc", "clear filter"),
|
||||
]
|
||||
} else {
|
||||
vec![
|
||||
("Enter", "type"),
|
||||
("PgUp/PgDn", "scroll"),
|
||||
("↑↓/Tab", "switch chat"),
|
||||
("/", "find"),
|
||||
("m", "new chat"),
|
||||
("i/Esc", "close"),
|
||||
]
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
use super::MainTui;
|
||||
use super::input::{jumped, list_jump, scroll_log};
|
||||
use crate::models::RoomsView;
|
||||
use crate::models::{ChatFilter, RoomsView};
|
||||
use ratatui::crossterm::event::{KeyCode, KeyEvent, KeyModifiers};
|
||||
|
||||
impl MainTui {
|
||||
@@ -30,6 +30,14 @@ impl MainTui {
|
||||
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),
|
||||
@@ -113,11 +121,24 @@ impl MainTui {
|
||||
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(),
|
||||
@@ -137,6 +158,42 @@ impl MainTui {
|
||||
}
|
||||
}
|
||||
|
||||
/// 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 current = if over_log {
|
||||
rooms.log_filter.clone()
|
||||
} else {
|
||||
rooms.user_filter.clone()
|
||||
};
|
||||
let next = match key.code {
|
||||
KeyCode::Esc => String::new(),
|
||||
KeyCode::Enter => current,
|
||||
KeyCode::Char(c) => {
|
||||
let mut next = current;
|
||||
next.push(c);
|
||||
next
|
||||
}
|
||||
KeyCode::Backspace => {
|
||||
let mut next = current;
|
||||
next.pop();
|
||||
next
|
||||
}
|
||||
_ => return false,
|
||||
};
|
||||
if matches!(key.code, KeyCode::Esc | KeyCode::Enter) {
|
||||
rooms.filtering = None;
|
||||
}
|
||||
if over_log {
|
||||
rooms.set_log_filter(next);
|
||||
} else {
|
||||
rooms.set_user_filter(next);
|
||||
}
|
||||
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) {
|
||||
|
||||
Reference in New Issue
Block a user