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.
This commit is contained in:
Michel de Graaf
2026-09-08 11:24:46 +02:00
parent b8da8668b4
commit 6c4e60bb0e
5 changed files with 137 additions and 58 deletions
+65 -1
View File
@@ -1,6 +1,6 @@
use crate::models::{BrowseTabs, FileDisplayData, RoomsState, SettingsState}; use crate::models::{BrowseTabs, FileDisplayData, RoomsState, SettingsState};
use chrono::{DateTime, Local}; 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 soulseek_rs::{DownloadStatus, types::Download};
use std::sync::atomic::AtomicBool; use std::sync::atomic::AtomicBool;
use std::sync::{Arc, mpsc::Receiver, mpsc::Sender}; 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<Line<'static>>,
}
impl WrappedLog {
/// The rows for `lines`, `wrap` turning one line into its rows (none,
/// for a line a filter drops).
pub fn rows<T>(
&mut self,
width: usize,
key: &str,
lines: &[T],
wrap: impl Fn(&T) -> Vec<Line<'static>>,
) -> &[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. /// What the shared command bar is currently capturing input for.
#[derive(Debug, Clone, Copy, PartialEq, Eq)] #[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum CommandBarMode { pub enum CommandBarMode {
@@ -254,6 +294,8 @@ pub struct AppState {
/// Compose buffer for the active conversation. /// Compose buffer for the active conversation.
pub chat_input: String, pub chat_input: String,
pub chat_composing: bool, pub chat_composing: bool,
/// The open conversation wrapped for drawing.
pub chat_wrapped: WrappedLog,
/// Where the conversation is being read. /// Where the conversation is being read.
pub chat_view: LogView, pub chat_view: LogView,
@@ -332,6 +374,7 @@ impl AppState {
chat_peer: None, chat_peer: None,
chat_input: String::new(), chat_input: String::new(),
chat_composing: false, chat_composing: false,
chat_wrapped: WrappedLog::default(),
chat_view: LogView::default(), chat_view: LogView::default(),
browse: BrowseTabs::new(), browse: BrowseTabs::new(),
@@ -501,6 +544,27 @@ mod tests {
layout.visible().collect() 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] #[test]
fn every_pane_starts_visible_and_unzoomed() { fn every_pane_starts_visible_and_unzoomed() {
let state = AppState::new(); let state = AppState::new();
+2 -2
View File
@@ -6,9 +6,9 @@ mod settings;
pub use app_state::{ pub use app_state::{
AppState, ChatMessage, CommandBarMode, DownloadEntry, FocusedPane, LogView, AppState, ChatMessage, CommandBarMode, DownloadEntry, FocusedPane, LogView,
MessageDirection, PaneLayout, SearchEntry, SearchStatus, MessageDirection, PaneLayout, SearchEntry, SearchStatus, WrappedLog,
}; };
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::{RoomLine, RoomsState, RoomsView}; pub use rooms::{OpenRoom, RoomLine, RoomsState, RoomsView};
pub use settings::{SettingsAction, SettingsMode, SettingsState}; pub use settings::{SettingsAction, SettingsMode, SettingsState};
+3 -1
View File
@@ -1,4 +1,4 @@
use crate::models::LogView; use crate::models::{LogView, WrappedLog};
use chrono::{DateTime, Local}; use chrono::{DateTime, Local};
use soulseek_rs::types::{RoomEvent, RoomInfo}; use soulseek_rs::types::{RoomEvent, RoomInfo};
@@ -41,6 +41,8 @@ pub struct OpenRoom {
pub input: String, pub input: String,
/// Where the log is being read. /// Where the log is being read.
pub view: LogView, pub view: LogView,
/// The log wrapped for drawing.
pub wrapped: WrappedLog,
} }
impl OpenRoom { impl OpenRoom {
+23 -23
View File
@@ -1,4 +1,6 @@
use crate::models::{AppState, ChatMessage, LogView, MessageDirection}; use crate::models::{
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,
@@ -57,6 +59,7 @@ pub fn render_chat_pane(
body[0], body[0],
&state.messages, &state.messages,
&mut state.chat_view, &mut state.chat_view,
&mut state.chat_wrapped,
peer, peer,
own_username, own_username,
); );
@@ -104,32 +107,29 @@ fn render_messages(
area: Rect, area: Rect,
messages: &[ChatMessage], messages: &[ChatMessage],
view: &mut LogView, view: &mut LogView,
wrapped: &mut WrappedLog,
peer: &str, peer: &str,
own_username: &str, own_username: &str,
) { ) {
let width = area.width as usize; let width = area.width as usize;
let lines: Vec<Line> = messages let lines = wrapped.rows(width, peer, messages, |m| {
.iter() if m.peer != peer {
.filter(|m| m.peer == peer) return Vec::new();
.flat_map(|m| { }
let (sender, sender_style) = match m.direction { let (sender, sender_style) = match m.direction {
MessageDirection::Incoming => (peer, info_style()), MessageDirection::Incoming => (peer, info_style()),
MessageDirection::Outgoing => (own_username, accent_style()), MessageDirection::Outgoing => (own_username, accent_style()),
}; };
wrap_chat_line( wrap_chat_line(
vec![ vec![
Span::styled( Span::styled(m.at.format("%H:%M ").to_string(), dimmed_style()),
m.at.format("%H:%M ").to_string(), Span::styled(format!("<{sender}> "), sender_style),
dimmed_style(), ],
), &m.text,
Span::styled(format!("<{sender}> "), sender_style), primary_style(),
], width,
&m.text, )
primary_style(), });
width,
)
})
.collect();
// The newest rows, unless the reader is holding a place in the history. // The newest rows, unless the reader is holding a place in the history.
let window = view.window(lines.len(), usize::from(area.height)); let window = view.window(lines.len(), usize::from(area.height));
+44 -31
View File
@@ -1,8 +1,10 @@
use crate::models::{LogView, RoomsState, RoomsView}; use crate::models::{
LogView, OpenRoom, RoomLine, RoomsState, RoomsView, WrappedLog,
};
use crate::ui::{ use crate::ui::{
HIGHLIGHT_SYMBOL, PANE_PADDING, accent_style, dimmed_style, HIGHLIGHT_SYMBOL, PANE_PADDING, accent_style, dimmed_style,
highlight_style, info_style, pane_block, plain_title, primary_style, highlight_style, info_style, page_of, pane_block, plain_title,
row_highlight_style, wrap_chat_line, primary_style, row_highlight_style, visible_range, wrap_chat_line,
}; };
use ratatui::{ use ratatui::{
Frame, Frame,
@@ -61,7 +63,14 @@ fn render_list(
Cell::from("room").style(dimmed_style()), Cell::from("room").style(dimmed_style()),
Cell::from("users").style(dimmed_style()), Cell::from("users").style(dimmed_style()),
]); ]);
let table_rows: Vec<Row> = 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<Row> = filtered[window]
.iter() .iter()
.map(|r| { .map(|r| {
let open = rooms.open_index(&r.name).is_some(); let open = rooms.open_index(&r.name).is_some();
@@ -90,8 +99,12 @@ fn render_list(
.highlight_spacing(HighlightSpacing::Always) .highlight_spacing(HighlightSpacing::Always)
.block(block); .block(block);
table_state.select(Some(rooms.list_selected.min(filtered.len() - 1))); // The table holds one page, so the state it gets is shifted onto it.
frame.render_stateful_widget(table, area, table_state); 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) { 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)]) Layout::horizontal([Constraint::Fill(1), Constraint::Length(22)])
.split(chunks[1]); .split(chunks[1]);
render_messages(frame, body[0], active.lines.as_slice(), &mut active.view); let OpenRoom {
render_users(frame, body[1], &active.users, *user_selected); 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. // Compose line or hint.
if *composing { if *composing {
@@ -179,33 +199,26 @@ fn render_tab_bar(frame: &mut Frame, area: Rect, rooms: &RoomsState) {
fn render_messages( fn render_messages(
frame: &mut Frame, frame: &mut Frame,
area: Rect, area: Rect,
lines: &[crate::models::RoomLine], lines: &[RoomLine],
view: &mut LogView, 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. // 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: Vec<Line> = lines let rendered = wrapped.rows(width, "", lines, |l| {
.iter() let time =
.flat_map(|l| { Span::styled(l.at.format("%H:%M ").to_string(), dimmed_style());
let time = match &l.username {
Span::styled(l.at.format("%H:%M ").to_string(), dimmed_style()); Some(user) => wrap_chat_line(
match &l.username { vec![time, Span::styled(format!("<{user}> "), info_style())],
Some(user) => wrap_chat_line( &l.text,
vec![ primary_style(),
time, width,
Span::styled(format!("<{user}> "), info_style()), ),
], None => wrap_chat_line(vec![time], &l.text, dimmed_style(), width),
&l.text, }
primary_style(), });
width,
),
None => {
wrap_chat_line(vec![time], &l.text, dimmed_style(), width)
}
}
})
.collect();
let window = view.window(rendered.len(), usize::from(area.height)); let window = view.window(rendered.len(), usize::from(area.height));
frame.render_widget(Paragraph::new(rendered[window].to_vec()), area); frame.render_widget(Paragraph::new(rendered[window].to_vec()), area);
} }