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:
@@ -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
|
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 `↑`/`↓`
|
(`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.
|
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,
|
`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
|
`J`/`K` jump between folders, `H`/`L` close or open every folder, `/` filters
|
||||||
|
|||||||
@@ -296,6 +296,9 @@ pub struct AppState {
|
|||||||
pub chat_composing: bool,
|
pub chat_composing: bool,
|
||||||
/// The open conversation wrapped for drawing.
|
/// The open conversation wrapped for drawing.
|
||||||
pub chat_wrapped: WrappedLog,
|
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.
|
/// Where the conversation is being read.
|
||||||
pub chat_view: LogView,
|
pub chat_view: LogView,
|
||||||
|
|
||||||
@@ -375,6 +378,8 @@ impl AppState {
|
|||||||
chat_input: String::new(),
|
chat_input: String::new(),
|
||||||
chat_composing: false,
|
chat_composing: false,
|
||||||
chat_wrapped: WrappedLog::default(),
|
chat_wrapped: WrappedLog::default(),
|
||||||
|
chat_filter: String::new(),
|
||||||
|
chat_filtering: false,
|
||||||
chat_view: LogView::default(),
|
chat_view: LogView::default(),
|
||||||
|
|
||||||
browse: BrowseTabs::new(),
|
browse: BrowseTabs::new(),
|
||||||
|
|||||||
@@ -10,5 +10,8 @@ pub use app_state::{
|
|||||||
};
|
};
|
||||||
pub use browse::{BrowseState, BrowseStatus, BrowseTabs};
|
pub use browse::{BrowseState, BrowseStatus, BrowseTabs};
|
||||||
pub use file_display_data::FileDisplayData;
|
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};
|
pub use settings::{SettingsAction, SettingsMode, SettingsState};
|
||||||
|
|||||||
@@ -79,8 +79,22 @@ pub struct RoomsState {
|
|||||||
pub view: RoomsView,
|
pub view: RoomsView,
|
||||||
/// Whether the compose input for the active room is capturing keys.
|
/// Whether the compose input for the active room is capturing keys.
|
||||||
pub composing: bool,
|
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,
|
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<ChatFilter>,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A room's two filterable lists.
|
||||||
|
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||||
|
pub enum ChatFilter {
|
||||||
|
Log,
|
||||||
|
Users,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl Default for RoomsState {
|
impl Default for RoomsState {
|
||||||
@@ -102,6 +116,31 @@ impl RoomsState {
|
|||||||
view: RoomsView::List,
|
view: RoomsView::List,
|
||||||
composing: false,
|
composing: false,
|
||||||
user_selected: 0,
|
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.
|
/// The username highlighted in the active room's member list, if any.
|
||||||
#[must_use]
|
#[must_use]
|
||||||
pub fn selected_user(&self) -> Option<String> {
|
pub fn selected_user(&self) -> Option<String> {
|
||||||
self.active_room()
|
self.visible_users()
|
||||||
.and_then(|r| r.users.get(self.user_selected).cloned())
|
.get(self.user_selected)
|
||||||
|
.map(|user| (*user).to_string())
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Where the active room's log is being read, to move it.
|
/// 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) {
|
pub fn select_user_down(&mut self) {
|
||||||
if let Some(room) = self.active_room()
|
let len = self.visible_users().len();
|
||||||
&& !room.users.is_empty()
|
if len > 0 {
|
||||||
{
|
self.user_selected = (self.user_selected + 1).min(len - 1);
|
||||||
self.user_selected =
|
|
||||||
(self.user_selected + 1).min(room.users.len() - 1);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -307,7 +345,7 @@ impl RoomsState {
|
|||||||
|
|
||||||
/// Keep `user_selected` within the active room's member list.
|
/// Keep `user_selected` within the active room's member list.
|
||||||
fn clamp_user_selected(&mut self) {
|
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));
|
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)]
|
#[cfg(test)]
|
||||||
mod tests {
|
mod tests {
|
||||||
use super::*;
|
use super::*;
|
||||||
|
|||||||
@@ -190,6 +190,34 @@ impl MainTui {
|
|||||||
return;
|
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.
|
// Paging keys move through the conversation's history.
|
||||||
if scroll_log(&mut self.state.chat_view, key) {
|
if scroll_log(&mut self.state.chat_view, key) {
|
||||||
return;
|
return;
|
||||||
@@ -199,9 +227,15 @@ impl MainTui {
|
|||||||
}
|
}
|
||||||
|
|
||||||
match key.code {
|
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 => {
|
KeyCode::Char('i' | 'q') | KeyCode::Esc => {
|
||||||
self.state.show_messages = false;
|
self.state.show_messages = false;
|
||||||
}
|
}
|
||||||
|
KeyCode::Char('/') => self.state.chat_filtering = true,
|
||||||
// Move through the conversation list on the right.
|
// Move through the conversation list on the right.
|
||||||
KeyCode::Tab | KeyCode::Down | KeyCode::Char('j') => {
|
KeyCode::Tab | KeyCode::Down | KeyCode::Char('j') => {
|
||||||
self.state.cycle_chat_peer(true);
|
self.state.cycle_chat_peer(true);
|
||||||
|
|||||||
@@ -265,7 +265,9 @@ pub fn launch_main_tui(
|
|||||||
mod tests {
|
mod tests {
|
||||||
use super::*;
|
use super::*;
|
||||||
use crate::daemon::proto::ChatMessageDto;
|
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};
|
use ratatui::crossterm::event::{KeyCode, KeyEvent, KeyModifiers};
|
||||||
|
|
||||||
/// A stand-in for a daemon: it already holds a conversation, a transfer
|
/// 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");
|
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) {
|
fn in_a_room(tui: &mut MainTui, messages: usize) {
|
||||||
tui.state.rooms.apply_event(
|
tui.state.rooms.apply_event(
|
||||||
soulseek_rs::RoomEvent::Joined {
|
soulseek_rs::RoomEvent::Joined {
|
||||||
|
|||||||
@@ -96,6 +96,8 @@ const HELP_RIGHT: &[(&str, &[(&str, &str)])] = &[
|
|||||||
("^u ^d", "half a page of them"),
|
("^u ^d", "half a page of them"),
|
||||||
("Home End / g G", "oldest / newest, or first / last row"),
|
("Home End / g G", "oldest / newest, or first / last row"),
|
||||||
("Tab / Shift-Tab", "next room, chat or user"),
|
("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![
|
RoomsView::Chat => vec![
|
||||||
("Enter", "say"),
|
("Enter", "say"),
|
||||||
("PgUp/PgDn", "scroll"),
|
("PgUp/PgDn", "scroll"),
|
||||||
("↑↓", "pick user"),
|
("↑↓", "pick user"),
|
||||||
|
("/", "find in chat"),
|
||||||
|
("u", "find user"),
|
||||||
("b", "browse user"),
|
("b", "browse user"),
|
||||||
("m", "message user"),
|
("m", "message user"),
|
||||||
("Tab", "switch room"),
|
("Tab", "switch room"),
|
||||||
@@ -551,11 +564,19 @@ impl MainTui {
|
|||||||
("Enter", "send"),
|
("Enter", "send"),
|
||||||
("Esc", "stop typing"),
|
("Esc", "stop typing"),
|
||||||
]
|
]
|
||||||
|
} else if self.state.chat_filtering {
|
||||||
|
vec![
|
||||||
|
("Type", "filter"),
|
||||||
|
("PgUp/PgDn", "scroll"),
|
||||||
|
("Enter", "keep filter"),
|
||||||
|
("Esc", "clear filter"),
|
||||||
|
]
|
||||||
} else {
|
} else {
|
||||||
vec![
|
vec![
|
||||||
("Enter", "type"),
|
("Enter", "type"),
|
||||||
("PgUp/PgDn", "scroll"),
|
("PgUp/PgDn", "scroll"),
|
||||||
("↑↓/Tab", "switch chat"),
|
("↑↓/Tab", "switch chat"),
|
||||||
|
("/", "find"),
|
||||||
("m", "new chat"),
|
("m", "new chat"),
|
||||||
("i/Esc", "close"),
|
("i/Esc", "close"),
|
||||||
]
|
]
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
use super::MainTui;
|
use super::MainTui;
|
||||||
use super::input::{jumped, list_jump, scroll_log};
|
use super::input::{jumped, list_jump, scroll_log};
|
||||||
use crate::models::RoomsView;
|
use crate::models::{ChatFilter, RoomsView};
|
||||||
use ratatui::crossterm::event::{KeyCode, KeyEvent, KeyModifiers};
|
use ratatui::crossterm::event::{KeyCode, KeyEvent, KeyModifiers};
|
||||||
|
|
||||||
impl MainTui {
|
impl MainTui {
|
||||||
@@ -30,6 +30,14 @@ impl MainTui {
|
|||||||
self.handle_room_filter_input(key);
|
self.handle_room_filter_input(key);
|
||||||
return;
|
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 {
|
match self.state.rooms.view {
|
||||||
RoomsView::List => self.handle_rooms_list_input(key),
|
RoomsView::List => self.handle_rooms_list_input(key),
|
||||||
@@ -113,11 +121,24 @@ impl MainTui {
|
|||||||
if key.modifiers.contains(KeyModifiers::CONTROL) {
|
if key.modifiers.contains(KeyModifiers::CONTROL) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
let filtered = !self.state.rooms.log_filter.is_empty()
|
||||||
|
|| !self.state.rooms.user_filter.is_empty();
|
||||||
match key.code {
|
match key.code {
|
||||||
KeyCode::Char('q') => self.state.show_rooms = false,
|
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') => {
|
KeyCode::Esc | KeyCode::Char('l') => {
|
||||||
self.state.rooms.view = RoomsView::List;
|
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::Tab => self.state.rooms.next_tab(),
|
||||||
KeyCode::BackTab => self.state.rooms.prev_tab(),
|
KeyCode::BackTab => self.state.rooms.prev_tab(),
|
||||||
KeyCode::Char('x') => self.leave_active_room(),
|
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
|
/// Browse the shares of the member highlighted in the active room. Closes
|
||||||
/// the rooms popup so the browse tree is the sole overlay.
|
/// the rooms popup so the browse tree is the sole overlay.
|
||||||
fn browse_selected_room_user(&mut self) {
|
fn browse_selected_room_user(&mut self) {
|
||||||
|
|||||||
@@ -1,6 +1,4 @@
|
|||||||
use crate::models::{
|
use crate::models::{AppState, MessageDirection, contains_filter};
|
||||||
AppState, ChatMessage, LogView, MessageDirection, WrappedLog,
|
|
||||||
};
|
|
||||||
use crate::ui::{
|
use crate::ui::{
|
||||||
accent_style, border_style, dimmed_style, highlight_style, info_style,
|
accent_style, border_style, dimmed_style, highlight_style, info_style,
|
||||||
primary_style, wrap_chat_line,
|
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 = state.active_chat_peer().map(str::to_string);
|
||||||
let peer = peer.as_deref();
|
let peer = peer.as_deref();
|
||||||
let title = peer.map_or_else(
|
let title = match peer {
|
||||||
|| " Messages (m: compose, i/Esc: close) ".to_string(),
|
None => " Messages (m: compose, i/Esc: close) ".to_string(),
|
||||||
|peer| format!(" {peer} (↑↓/Tab: switch, m: to…, i/Esc: close) "),
|
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()
|
let block = Block::default()
|
||||||
.borders(Borders::ALL)
|
.borders(Borders::ALL)
|
||||||
.border_style(border_style(true))
|
.border_style(border_style(true))
|
||||||
@@ -54,15 +61,7 @@ pub fn render_chat_pane(
|
|||||||
return;
|
return;
|
||||||
};
|
};
|
||||||
|
|
||||||
render_messages(
|
render_messages(frame, body[0], state, peer, own_username);
|
||||||
frame,
|
|
||||||
body[0],
|
|
||||||
&state.messages,
|
|
||||||
&mut state.chat_view,
|
|
||||||
&mut state.chat_wrapped,
|
|
||||||
peer,
|
|
||||||
own_username,
|
|
||||||
);
|
|
||||||
render_compose(frame, chunks[1], state);
|
render_compose(frame, chunks[1], state);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -105,15 +104,21 @@ fn render_peers(
|
|||||||
fn render_messages(
|
fn render_messages(
|
||||||
frame: &mut Frame,
|
frame: &mut Frame,
|
||||||
area: Rect,
|
area: Rect,
|
||||||
messages: &[ChatMessage],
|
state: &mut AppState,
|
||||||
view: &mut LogView,
|
|
||||||
wrapped: &mut WrappedLog,
|
|
||||||
peer: &str,
|
peer: &str,
|
||||||
own_username: &str,
|
own_username: &str,
|
||||||
) {
|
) {
|
||||||
|
let AppState {
|
||||||
|
messages,
|
||||||
|
chat_view: view,
|
||||||
|
chat_wrapped: wrapped,
|
||||||
|
chat_filter: filter,
|
||||||
|
..
|
||||||
|
} = state;
|
||||||
let width = area.width as usize;
|
let width = area.width as usize;
|
||||||
let lines = wrapped.rows(width, peer, messages, |m| {
|
let key = format!("{peer}\n{filter}");
|
||||||
if m.peer != peer {
|
let lines = wrapped.rows(width, &key, messages, |m| {
|
||||||
|
if m.peer != peer || !contains_filter(&m.text, filter) {
|
||||||
return Vec::new();
|
return Vec::new();
|
||||||
}
|
}
|
||||||
let (sender, sender_style) = match m.direction {
|
let (sender, sender_style) = match m.direction {
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
use crate::models::{
|
use crate::models::{
|
||||||
LogView, OpenRoom, RoomLine, RoomsState, RoomsView, WrappedLog,
|
ChatFilter, LogView, OpenRoom, RoomLine, RoomsState, RoomsView, WrappedLog,
|
||||||
|
contains_filter, matching_users,
|
||||||
};
|
};
|
||||||
use crate::ui::{
|
use crate::ui::{
|
||||||
HIGHLIGHT_SYMBOL, PANE_PADDING, accent_style, dimmed_style,
|
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) {
|
fn render_chat(frame: &mut Frame, area: Rect, rooms: &mut RoomsState) {
|
||||||
let block = pane_block(true).title(
|
let typing_log = rooms.filtering == Some(ChatFilter::Log);
|
||||||
" Chat rooms (Tab: switch, l: room list, x: leave, Esc: back) ",
|
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);
|
let inner = block.inner(area);
|
||||||
frame.render_widget(block, area);
|
frame.render_widget(block, area);
|
||||||
|
|
||||||
@@ -128,6 +138,9 @@ fn render_chat(frame: &mut Frame, area: Rect, rooms: &mut RoomsState) {
|
|||||||
active: active_index,
|
active: active_index,
|
||||||
user_selected,
|
user_selected,
|
||||||
composing,
|
composing,
|
||||||
|
log_filter,
|
||||||
|
user_filter,
|
||||||
|
filtering,
|
||||||
..
|
..
|
||||||
} = rooms;
|
} = rooms;
|
||||||
let Some(active) = open.get_mut(*active_index) else {
|
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,
|
users,
|
||||||
..
|
..
|
||||||
} = active;
|
} = active;
|
||||||
render_messages(frame, body[0], lines, view, wrapped);
|
render_messages(frame, body[0], lines, view, wrapped, log_filter);
|
||||||
render_users(frame, body[1], users, *user_selected);
|
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.
|
// Compose line or hint.
|
||||||
if *composing {
|
if *composing {
|
||||||
@@ -202,11 +228,17 @@ fn render_messages(
|
|||||||
lines: &[RoomLine],
|
lines: &[RoomLine],
|
||||||
view: &mut LogView,
|
view: &mut LogView,
|
||||||
wrapped: &mut WrappedLog,
|
wrapped: &mut WrappedLog,
|
||||||
|
filter: &str,
|
||||||
) {
|
) {
|
||||||
// Each message wrapped to the pane width, then the rows the reader is
|
// 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.
|
// at: the newest ones, unless they are holding a place in the history.
|
||||||
let width = area.width as usize;
|
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 =
|
let time =
|
||||||
Span::styled(l.at.format("%H:%M ").to_string(), dimmed_style());
|
Span::styled(l.at.format("%H:%M ").to_string(), dimmed_style());
|
||||||
match &l.username {
|
match &l.username {
|
||||||
@@ -226,14 +258,15 @@ fn render_messages(
|
|||||||
fn render_users(
|
fn render_users(
|
||||||
frame: &mut Frame,
|
frame: &mut Frame,
|
||||||
area: Rect,
|
area: Rect,
|
||||||
users: &[String],
|
users: &[&str],
|
||||||
selected: usize,
|
selected: usize,
|
||||||
|
title: String,
|
||||||
) {
|
) {
|
||||||
let block = Block::default()
|
let block = Block::default()
|
||||||
.borders(Borders::LEFT)
|
.borders(Borders::LEFT)
|
||||||
.border_style(dimmed_style())
|
.border_style(dimmed_style())
|
||||||
.padding(PANE_PADDING)
|
.padding(PANE_PADDING)
|
||||||
.title(plain_title(format!("Users ({})", users.len()), false));
|
.title(plain_title(title, false));
|
||||||
let inner = block.inner(area);
|
let inner = block.inner(area);
|
||||||
frame.render_widget(block, area);
|
frame.render_widget(block, area);
|
||||||
|
|
||||||
|
|||||||
@@ -47,6 +47,8 @@ const ROOM_KEYS: readonly (readonly [string, string])[] = [
|
|||||||
['x', 'leave the active room'],
|
['x', 'leave the active room'],
|
||||||
['l', 'back to the room list'],
|
['l', 'back to the room list'],
|
||||||
['↑ ↓', 'select a member'],
|
['↑ ↓', 'select a member'],
|
||||||
|
['/', 'search the chat log; in the inbox, the conversation'],
|
||||||
|
['u', 'filter the member list'],
|
||||||
['b', "browse the member's files"],
|
['b', "browse the member's files"],
|
||||||
['m', 'message the member'],
|
['m', 'message the member'],
|
||||||
]
|
]
|
||||||
|
|||||||
Reference in New Issue
Block a user