From 6c4e60bb0ef0898eed17c8d7eb767e905b419ef2 Mon Sep 17 00:00:00 2001 From: Michel de Graaf Date: Tue, 8 Sep 2026 10:50:04 +0200 Subject: [PATCH] perf(tui): wrap a chat log as it arrives instead of on every frame A room's chat log was wrapped to the pane width from the first line on every frame, so a busy room lagged more the longer it stayed open: in the harness 30,000 lines cost 16.6ms a frame. The wrapped rows now live with the log and a draw only wraps the lines that arrived since the last one, starting over when the width changes. The same cache serves the private-message box, and the room list draws only its visible page like the other tables. The 30,000-line room now draws in 0.4ms. --- soulseek-rs/src/models/app_state.rs | 66 ++++++++++++++++++++++- soulseek-rs/src/models/mod.rs | 4 +- soulseek-rs/src/models/rooms.rs | 4 +- soulseek-rs/src/ui/panes/chat_pane.rs | 46 ++++++++-------- soulseek-rs/src/ui/panes/rooms_pane.rs | 75 +++++++++++++++----------- 5 files changed, 137 insertions(+), 58 deletions(-) diff --git a/soulseek-rs/src/models/app_state.rs b/soulseek-rs/src/models/app_state.rs index 3c8a786..b00d1b7 100644 --- a/soulseek-rs/src/models/app_state.rs +++ b/soulseek-rs/src/models/app_state.rs @@ -1,6 +1,6 @@ use crate::models::{BrowseTabs, FileDisplayData, RoomsState, SettingsState}; use chrono::{DateTime, Local}; -use ratatui::{layout::Rect, widgets::TableState}; +use ratatui::{layout::Rect, text::Line, widgets::TableState}; use soulseek_rs::{DownloadStatus, types::Download}; use std::sync::atomic::AtomicBool; use std::sync::{Arc, mpsc::Receiver, mpsc::Sender}; @@ -174,6 +174,46 @@ impl LogView { } } +/// A log wrapped to a width, kept between frames. +/// +/// Wrapping every line of a long log on every draw is what made a busy room +/// lag. Lines only ever arrive at the end, so a draw wraps the new ones and +/// starts over only when the width or `key` (what is being shown, a filter +/// say) changes. +#[derive(Debug, Clone, Default)] +pub struct WrappedLog { + width: usize, + key: String, + /// How many source lines the rows cover. + wrapped: usize, + rows: Vec>, +} + +impl WrappedLog { + /// The rows for `lines`, `wrap` turning one line into its rows (none, + /// for a line a filter drops). + pub fn rows( + &mut self, + width: usize, + key: &str, + lines: &[T], + wrap: impl Fn(&T) -> Vec>, + ) -> &[Line<'static>] { + if width != self.width || key != self.key || lines.len() < self.wrapped + { + self.rows.clear(); + self.wrapped = 0; + self.width = width; + self.key = key.to_string(); + } + for line in &lines[self.wrapped..] { + self.rows.extend(wrap(line)); + } + self.wrapped = lines.len(); + &self.rows + } +} + /// What the shared command bar is currently capturing input for. #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub enum CommandBarMode { @@ -254,6 +294,8 @@ pub struct AppState { /// Compose buffer for the active conversation. pub chat_input: String, pub chat_composing: bool, + /// The open conversation wrapped for drawing. + pub chat_wrapped: WrappedLog, /// Where the conversation is being read. pub chat_view: LogView, @@ -332,6 +374,7 @@ impl AppState { chat_peer: None, chat_input: String::new(), chat_composing: false, + chat_wrapped: WrappedLog::default(), chat_view: LogView::default(), browse: BrowseTabs::new(), @@ -501,6 +544,27 @@ mod tests { layout.visible().collect() } + #[test] + fn a_wrapped_log_only_wraps_what_arrived_and_starts_over_on_a_change() { + let wrap = |line: &String| vec![Line::raw(line.clone())]; + let mut log = WrappedLog::default(); + let mut lines = vec!["a".to_string(), "b".to_string()]; + assert_eq!(log.rows(40, "", &lines, wrap).len(), 2); + lines.push("c".to_string()); + assert_eq!(log.rows(40, "", &lines, wrap).len(), 3); + // A narrower pane or another filter rebuilds; fewer lines do too. + let only_c = |line: &String| { + (line == "c") + .then(|| Line::raw(line.clone())) + .into_iter() + .collect() + }; + assert_eq!(log.rows(40, "c", &lines, only_c).len(), 1); + assert_eq!(log.rows(30, "c", &lines, wrap).len(), 3); + lines.truncate(1); + assert_eq!(log.rows(30, "c", &lines, wrap).len(), 1); + } + #[test] fn every_pane_starts_visible_and_unzoomed() { let state = AppState::new(); diff --git a/soulseek-rs/src/models/mod.rs b/soulseek-rs/src/models/mod.rs index 2630d63..7f80c3f 100644 --- a/soulseek-rs/src/models/mod.rs +++ b/soulseek-rs/src/models/mod.rs @@ -6,9 +6,9 @@ mod settings; pub use app_state::{ AppState, ChatMessage, CommandBarMode, DownloadEntry, FocusedPane, LogView, - MessageDirection, PaneLayout, SearchEntry, SearchStatus, + MessageDirection, PaneLayout, SearchEntry, SearchStatus, WrappedLog, }; pub use browse::{BrowseState, BrowseStatus, BrowseTabs}; pub use file_display_data::FileDisplayData; -pub use rooms::{RoomLine, RoomsState, RoomsView}; +pub use rooms::{OpenRoom, RoomLine, RoomsState, RoomsView}; pub use settings::{SettingsAction, SettingsMode, SettingsState}; diff --git a/soulseek-rs/src/models/rooms.rs b/soulseek-rs/src/models/rooms.rs index b4f3db1..9784dd8 100644 --- a/soulseek-rs/src/models/rooms.rs +++ b/soulseek-rs/src/models/rooms.rs @@ -1,4 +1,4 @@ -use crate::models::LogView; +use crate::models::{LogView, WrappedLog}; use chrono::{DateTime, Local}; use soulseek_rs::types::{RoomEvent, RoomInfo}; @@ -41,6 +41,8 @@ pub struct OpenRoom { pub input: String, /// Where the log is being read. pub view: LogView, + /// The log wrapped for drawing. + pub wrapped: WrappedLog, } impl OpenRoom { diff --git a/soulseek-rs/src/ui/panes/chat_pane.rs b/soulseek-rs/src/ui/panes/chat_pane.rs index 5a2ff83..04da308 100644 --- a/soulseek-rs/src/ui/panes/chat_pane.rs +++ b/soulseek-rs/src/ui/panes/chat_pane.rs @@ -1,4 +1,6 @@ -use crate::models::{AppState, ChatMessage, LogView, MessageDirection}; +use crate::models::{ + AppState, ChatMessage, LogView, MessageDirection, WrappedLog, +}; use crate::ui::{ accent_style, border_style, dimmed_style, highlight_style, info_style, primary_style, wrap_chat_line, @@ -57,6 +59,7 @@ pub fn render_chat_pane( body[0], &state.messages, &mut state.chat_view, + &mut state.chat_wrapped, peer, own_username, ); @@ -104,32 +107,29 @@ fn render_messages( area: Rect, messages: &[ChatMessage], view: &mut LogView, + wrapped: &mut WrappedLog, peer: &str, own_username: &str, ) { let width = area.width as usize; - let lines: Vec = messages - .iter() - .filter(|m| m.peer == peer) - .flat_map(|m| { - let (sender, sender_style) = match m.direction { - MessageDirection::Incoming => (peer, info_style()), - MessageDirection::Outgoing => (own_username, accent_style()), - }; - wrap_chat_line( - vec![ - Span::styled( - m.at.format("%H:%M ").to_string(), - dimmed_style(), - ), - Span::styled(format!("<{sender}> "), sender_style), - ], - &m.text, - primary_style(), - width, - ) - }) - .collect(); + let lines = wrapped.rows(width, peer, messages, |m| { + if m.peer != peer { + return Vec::new(); + } + let (sender, sender_style) = match m.direction { + MessageDirection::Incoming => (peer, info_style()), + MessageDirection::Outgoing => (own_username, accent_style()), + }; + wrap_chat_line( + vec![ + Span::styled(m.at.format("%H:%M ").to_string(), dimmed_style()), + Span::styled(format!("<{sender}> "), sender_style), + ], + &m.text, + primary_style(), + width, + ) + }); // The newest rows, unless the reader is holding a place in the history. let window = view.window(lines.len(), usize::from(area.height)); diff --git a/soulseek-rs/src/ui/panes/rooms_pane.rs b/soulseek-rs/src/ui/panes/rooms_pane.rs index 28a938f..d0e145a 100644 --- a/soulseek-rs/src/ui/panes/rooms_pane.rs +++ b/soulseek-rs/src/ui/panes/rooms_pane.rs @@ -1,8 +1,10 @@ -use crate::models::{LogView, RoomsState, RoomsView}; +use crate::models::{ + LogView, OpenRoom, RoomLine, RoomsState, RoomsView, WrappedLog, +}; use crate::ui::{ HIGHLIGHT_SYMBOL, PANE_PADDING, accent_style, dimmed_style, - highlight_style, info_style, pane_block, plain_title, primary_style, - row_highlight_style, wrap_chat_line, + highlight_style, info_style, page_of, pane_block, plain_title, + primary_style, row_highlight_style, visible_range, wrap_chat_line, }; use ratatui::{ Frame, @@ -61,7 +63,14 @@ fn render_list( Cell::from("room").style(dimmed_style()), Cell::from("users").style(dimmed_style()), ]); - let table_rows: Vec = filtered + let window = visible_range( + table_state.offset(), + Some(rooms.list_selected), + filtered.len(), + page_of(Some(area), 1), + ); + let start = window.start; + let table_rows: Vec = filtered[window] .iter() .map(|r| { let open = rooms.open_index(&r.name).is_some(); @@ -90,8 +99,12 @@ fn render_list( .highlight_spacing(HighlightSpacing::Always) .block(block); - table_state.select(Some(rooms.list_selected.min(filtered.len() - 1))); - frame.render_stateful_widget(table, area, table_state); + // The table holds one page, so the state it gets is shifted onto it. + let mut page_state = TableState::default().with_selected(Some( + rooms.list_selected.min(filtered.len() - 1) - start, + )); + frame.render_stateful_widget(table, area, &mut page_state); + *table_state.offset_mut() = start; } fn render_chat(frame: &mut Frame, area: Rect, rooms: &mut RoomsState) { @@ -131,8 +144,15 @@ fn render_chat(frame: &mut Frame, area: Rect, rooms: &mut RoomsState) { Layout::horizontal([Constraint::Fill(1), Constraint::Length(22)]) .split(chunks[1]); - render_messages(frame, body[0], active.lines.as_slice(), &mut active.view); - render_users(frame, body[1], &active.users, *user_selected); + let OpenRoom { + lines, + view, + wrapped, + users, + .. + } = active; + render_messages(frame, body[0], lines, view, wrapped); + render_users(frame, body[1], users, *user_selected); // Compose line or hint. if *composing { @@ -179,33 +199,26 @@ fn render_tab_bar(frame: &mut Frame, area: Rect, rooms: &RoomsState) { fn render_messages( frame: &mut Frame, area: Rect, - lines: &[crate::models::RoomLine], + lines: &[RoomLine], view: &mut LogView, + wrapped: &mut WrappedLog, ) { - // Wrap each message to the pane width, then show 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. let width = area.width as usize; - let rendered: Vec = lines - .iter() - .flat_map(|l| { - let time = - Span::styled(l.at.format("%H:%M ").to_string(), dimmed_style()); - match &l.username { - Some(user) => wrap_chat_line( - vec![ - time, - Span::styled(format!("<{user}> "), info_style()), - ], - &l.text, - primary_style(), - width, - ), - None => { - wrap_chat_line(vec![time], &l.text, dimmed_style(), width) - } - } - }) - .collect(); + let rendered = wrapped.rows(width, "", lines, |l| { + let time = + Span::styled(l.at.format("%H:%M ").to_string(), dimmed_style()); + match &l.username { + Some(user) => wrap_chat_line( + vec![time, Span::styled(format!("<{user}> "), info_style())], + &l.text, + primary_style(), + width, + ), + None => wrap_chat_line(vec![time], &l.text, dimmed_style(), width), + } + }); let window = view.window(rendered.len(), usize::from(area.height)); frame.render_widget(Paragraph::new(rendered[window].to_vec()), area); }