diff --git a/README.md b/README.md index ddded7b..855b8fd 100644 --- a/README.md +++ b/README.md @@ -566,7 +566,8 @@ unread counter. `c` opens the chat-rooms popup: a `/`-filterable room list busiest first, `Enter` to join, several rooms open at once as tabs (`Tab`/`Shift-Tab` to switch, `x` to leave, `l` back to the list), and `↑`/`↓` through the member list with `b` to browse someone or `m` to message them. -Unread counts appear on the tabs and on the `c chat (n)` shortcut. +`/` searches the open room's log and `u` its member list; the inbox takes `/` +too. Unread counts appear on the tabs and on the `c chat (n)` shortcut. `b` browses a user's shared files as a tree: `←`/`→` close and open a folder, `J`/`K` jump between folders, `H`/`L` close or open every folder, `/` filters diff --git a/soulseek-rs/src/models/app_state.rs b/soulseek-rs/src/models/app_state.rs index b00d1b7..aa9c983 100644 --- a/soulseek-rs/src/models/app_state.rs +++ b/soulseek-rs/src/models/app_state.rs @@ -296,6 +296,9 @@ pub struct AppState { pub chat_composing: bool, /// The open conversation wrapped for drawing. pub chat_wrapped: WrappedLog, + /// Narrows the open conversation to messages mentioning it. + pub chat_filter: String, + pub chat_filtering: bool, /// Where the conversation is being read. pub chat_view: LogView, @@ -375,6 +378,8 @@ impl AppState { chat_input: String::new(), chat_composing: false, chat_wrapped: WrappedLog::default(), + chat_filter: String::new(), + chat_filtering: false, chat_view: LogView::default(), browse: BrowseTabs::new(), diff --git a/soulseek-rs/src/models/mod.rs b/soulseek-rs/src/models/mod.rs index 7f80c3f..2994800 100644 --- a/soulseek-rs/src/models/mod.rs +++ b/soulseek-rs/src/models/mod.rs @@ -10,5 +10,8 @@ pub use app_state::{ }; pub use browse::{BrowseState, BrowseStatus, BrowseTabs}; pub use file_display_data::FileDisplayData; -pub use rooms::{OpenRoom, RoomLine, RoomsState, RoomsView}; +pub use rooms::{ + ChatFilter, OpenRoom, RoomLine, RoomsState, RoomsView, contains_filter, + matching_users, +}; pub use settings::{SettingsAction, SettingsMode, SettingsState}; diff --git a/soulseek-rs/src/models/rooms.rs b/soulseek-rs/src/models/rooms.rs index 9784dd8..5250cdd 100644 --- a/soulseek-rs/src/models/rooms.rs +++ b/soulseek-rs/src/models/rooms.rs @@ -79,8 +79,22 @@ pub struct RoomsState { pub view: RoomsView, /// Whether the compose input for the active room is capturing keys. pub composing: bool, - /// Selected index into the active room's member list (for browse/PM). + /// Selected index into the active room's member list (for browse/PM), + /// as narrowed by `user_filter`. pub user_selected: usize, + /// Narrows the active room's log to lines mentioning it. + pub log_filter: String, + /// Narrows the active room's member list to names containing it. + pub user_filter: String, + /// Which of the two, if either, typing goes to. + pub filtering: Option, +} + +/// A room's two filterable lists. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum ChatFilter { + Log, + Users, } impl Default for RoomsState { @@ -102,6 +116,31 @@ impl RoomsState { view: RoomsView::List, composing: false, user_selected: 0, + log_filter: String::new(), + user_filter: String::new(), + filtering: None, + } + } + + /// The active room's members the filter lets through. + #[must_use] + pub fn visible_users(&self) -> Vec<&str> { + self.active_room().map_or_else(Vec::new, |room| { + matching_users(&room.users, &self.user_filter) + }) + } + + /// Narrow the member list, starting over from its top. + pub fn set_user_filter(&mut self, filter: String) { + self.user_filter = filter; + self.user_selected = 0; + } + + /// Narrow the log, back at its newest matching line. + pub fn set_log_filter(&mut self, filter: String) { + self.log_filter = filter; + if let Some(view) = self.active_view_mut() { + view.to_newest(); } } @@ -161,8 +200,9 @@ impl RoomsState { /// The username highlighted in the active room's member list, if any. #[must_use] pub fn selected_user(&self) -> Option { - self.active_room() - .and_then(|r| r.users.get(self.user_selected).cloned()) + self.visible_users() + .get(self.user_selected) + .map(|user| (*user).to_string()) } /// Where the active room's log is being read, to move it. @@ -175,11 +215,9 @@ impl RoomsState { } pub fn select_user_down(&mut self) { - if let Some(room) = self.active_room() - && !room.users.is_empty() - { - self.user_selected = - (self.user_selected + 1).min(room.users.len() - 1); + let len = self.visible_users().len(); + if len > 0 { + self.user_selected = (self.user_selected + 1).min(len - 1); } } @@ -307,7 +345,7 @@ impl RoomsState { /// Keep `user_selected` within the active room's member list. fn clamp_user_selected(&mut self) { - let len = self.active_room().map_or(0, |r| r.users.len()); + let len = self.visible_users().len(); self.user_selected = self.user_selected.min(len.saturating_sub(1)); } @@ -322,6 +360,25 @@ impl RoomsState { } } +/// Whether `text` contains `filter`, ignoring case; an empty filter matches. +#[must_use] +pub fn contains_filter(text: &str, filter: &str) -> bool { + filter.is_empty() || text.to_lowercase().contains(&filter.to_lowercase()) +} + +/// The names in `users` that contain `filter`, in their own order. +#[must_use] +pub fn matching_users<'a>(users: &'a [String], filter: &str) -> Vec<&'a str> { + let needle = filter.to_lowercase(); + users + .iter() + .filter(|user| { + needle.is_empty() || user.to_lowercase().contains(&needle) + }) + .map(String::as_str) + .collect() +} + #[cfg(test)] mod tests { use super::*; diff --git a/soulseek-rs/src/ui/main_tui/input.rs b/soulseek-rs/src/ui/main_tui/input.rs index 615fb29..b894c5a 100644 --- a/soulseek-rs/src/ui/main_tui/input.rs +++ b/soulseek-rs/src/ui/main_tui/input.rs @@ -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); diff --git a/soulseek-rs/src/ui/main_tui/mod.rs b/soulseek-rs/src/ui/main_tui/mod.rs index ec97cb4..0152f88 100644 --- a/soulseek-rs/src/ui/main_tui/mod.rs +++ b/soulseek-rs/src/ui/main_tui/mod.rs @@ -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 { diff --git a/soulseek-rs/src/ui/main_tui/render.rs b/soulseek-rs/src/ui/main_tui/render.rs index b6e4a5e..73841d8 100644 --- a/soulseek-rs/src/ui/main_tui/render.rs +++ b/soulseek-rs/src/ui/main_tui/render.rs @@ -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"), ] diff --git a/soulseek-rs/src/ui/main_tui/rooms.rs b/soulseek-rs/src/ui/main_tui/rooms.rs index 86c033a..d309831 100644 --- a/soulseek-rs/src/ui/main_tui/rooms.rs +++ b/soulseek-rs/src/ui/main_tui/rooms.rs @@ -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) { diff --git a/soulseek-rs/src/ui/panes/chat_pane.rs b/soulseek-rs/src/ui/panes/chat_pane.rs index 04da308..b3610f7 100644 --- a/soulseek-rs/src/ui/panes/chat_pane.rs +++ b/soulseek-rs/src/ui/panes/chat_pane.rs @@ -1,6 +1,4 @@ -use crate::models::{ - AppState, ChatMessage, LogView, MessageDirection, WrappedLog, -}; +use crate::models::{AppState, MessageDirection, contains_filter}; use crate::ui::{ accent_style, border_style, dimmed_style, highlight_style, info_style, primary_style, wrap_chat_line, @@ -24,10 +22,19 @@ pub fn render_chat_pane( ) { let peer = state.active_chat_peer().map(str::to_string); let peer = peer.as_deref(); - let title = peer.map_or_else( - || " Messages (m: compose, i/Esc: close) ".to_string(), - |peer| format!(" {peer} (↑↓/Tab: switch, m: to…, i/Esc: close) "), - ); + let title = match peer { + None => " Messages (m: compose, i/Esc: close) ".to_string(), + Some(peer) if state.chat_filtering || !state.chat_filter.is_empty() => { + format!( + " {peer} · filter: {}{} (Enter: keep, Esc: clear) ", + state.chat_filter, + if state.chat_filtering { "_" } else { "" } + ) + } + Some(peer) => { + format!(" {peer} (↑↓/Tab: switch, /: find, m: to…, i/Esc: close) ") + } + }; let block = Block::default() .borders(Borders::ALL) .border_style(border_style(true)) @@ -54,15 +61,7 @@ pub fn render_chat_pane( return; }; - render_messages( - frame, - body[0], - &state.messages, - &mut state.chat_view, - &mut state.chat_wrapped, - peer, - own_username, - ); + render_messages(frame, body[0], state, peer, own_username); render_compose(frame, chunks[1], state); } @@ -105,15 +104,21 @@ fn render_peers( fn render_messages( frame: &mut Frame, area: Rect, - messages: &[ChatMessage], - view: &mut LogView, - wrapped: &mut WrappedLog, + state: &mut AppState, peer: &str, own_username: &str, ) { + let AppState { + messages, + chat_view: view, + chat_wrapped: wrapped, + chat_filter: filter, + .. + } = state; let width = area.width as usize; - let lines = wrapped.rows(width, peer, messages, |m| { - if m.peer != peer { + let key = format!("{peer}\n{filter}"); + let lines = wrapped.rows(width, &key, messages, |m| { + if m.peer != peer || !contains_filter(&m.text, filter) { return Vec::new(); } let (sender, sender_style) = match m.direction { diff --git a/soulseek-rs/src/ui/panes/rooms_pane.rs b/soulseek-rs/src/ui/panes/rooms_pane.rs index d0e145a..28b501e 100644 --- a/soulseek-rs/src/ui/panes/rooms_pane.rs +++ b/soulseek-rs/src/ui/panes/rooms_pane.rs @@ -1,5 +1,6 @@ use crate::models::{ - LogView, OpenRoom, RoomLine, RoomsState, RoomsView, WrappedLog, + ChatFilter, LogView, OpenRoom, RoomLine, RoomsState, RoomsView, WrappedLog, + contains_filter, matching_users, }; use crate::ui::{ HIGHLIGHT_SYMBOL, PANE_PADDING, accent_style, dimmed_style, @@ -108,9 +109,18 @@ fn render_list( } fn render_chat(frame: &mut Frame, area: Rect, rooms: &mut RoomsState) { - let block = pane_block(true).title( - " Chat rooms (Tab: switch, l: room list, x: leave, Esc: back) ", - ); + let typing_log = rooms.filtering == Some(ChatFilter::Log); + let title = if typing_log || !rooms.log_filter.is_empty() { + format!( + " Chat rooms · filter: {}{} (Enter: keep, Esc: clear) ", + rooms.log_filter, + if typing_log { "_" } else { "" } + ) + } else { + " Chat rooms (Tab: switch, /: find, u: find user, l: room list, x: leave, Esc: back) " + .to_string() + }; + let block = pane_block(true).title(title); let inner = block.inner(area); frame.render_widget(block, area); @@ -128,6 +138,9 @@ fn render_chat(frame: &mut Frame, area: Rect, rooms: &mut RoomsState) { active: active_index, user_selected, composing, + log_filter, + user_filter, + filtering, .. } = rooms; let Some(active) = open.get_mut(*active_index) else { @@ -151,8 +164,21 @@ fn render_chat(frame: &mut Frame, area: Rect, rooms: &mut RoomsState) { users, .. } = active; - render_messages(frame, body[0], lines, view, wrapped); - render_users(frame, body[1], users, *user_selected); + 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 { @@ -202,11 +228,17 @@ fn render_messages( 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, "", lines, |l| { + 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 { @@ -226,14 +258,15 @@ fn render_messages( fn render_users( frame: &mut Frame, area: Rect, - users: &[String], + users: &[&str], selected: usize, + title: String, ) { let block = Block::default() .borders(Borders::LEFT) .border_style(dimmed_style()) .padding(PANE_PADDING) - .title(plain_title(format!("Users ({})", users.len()), false)); + .title(plain_title(title, false)); let inner = block.inner(area); frame.render_widget(block, area); diff --git a/website/src/pages/docs.tsx b/website/src/pages/docs.tsx index c7f8f89..0ab7b42 100644 --- a/website/src/pages/docs.tsx +++ b/website/src/pages/docs.tsx @@ -47,6 +47,8 @@ const ROOM_KEYS: readonly (readonly [string, string])[] = [ ['x', 'leave the active room'], ['l', 'back to the room list'], ['↑ ↓', 'select a member'], + ['/', 'search the chat log; in the inbox, the conversation'], + ['u', 'filter the member list'], ['b', "browse the member's files"], ['m', 'message the member'], ]