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:
Michel de Graaf
2026-09-08 11:24:53 +02:00
parent 6c4e60bb0e
commit 607532cc44
11 changed files with 325 additions and 43 deletions
+26 -21
View File
@@ -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 {
+42 -9
View File
@@ -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);