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:
@@ -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();
|
||||||
|
|||||||
@@ -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};
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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));
|
||||||
|
|||||||
@@ -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);
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user