diff --git a/soulseek-rs/src/ui/panes/chat_pane.rs b/soulseek-rs/src/ui/panes/chat_pane.rs index 9d31a52..80bfc23 100644 --- a/soulseek-rs/src/ui/panes/chat_pane.rs +++ b/soulseek-rs/src/ui/panes/chat_pane.rs @@ -1,7 +1,7 @@ use crate::models::{AppState, MessageDirection}; use crate::ui::{ accent_style, border_style, dimmed_style, highlight_style, info_style, - primary_style, + primary_style, wrap_chat_line, }; use ratatui::{ Frame, @@ -98,25 +98,32 @@ fn render_messages( peer: &str, own_username: &str, ) { + let width = area.width as usize; let lines: Vec = state .messages .iter() .filter(|m| m.peer == peer) - .map(|m| { + .flat_map(|m| { let (sender, sender_style) = match m.direction { MessageDirection::Incoming => (peer, info_style()), MessageDirection::Outgoing => (own_username, accent_style()), }; - Line::from(vec![ - Span::styled(m.at.format("%H:%M ").to_string(), dimmed_style()), - Span::styled(format!("<{sender}> "), sender_style), - Span::styled(m.text.clone(), primary_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(); - // Auto-scroll: one row per message (no wrapping), so tailing by the pane - // height keeps the newest message visible. + // Auto-scroll: tail by rendered rows so the newest message stays visible. let start = lines.len().saturating_sub((area.height as usize).max(1)); frame.render_widget(Paragraph::new(lines[start..].to_vec()), area); } diff --git a/soulseek-rs/src/ui/panes/rooms_pane.rs b/soulseek-rs/src/ui/panes/rooms_pane.rs index fa5fe49..53cd407 100644 --- a/soulseek-rs/src/ui/panes/rooms_pane.rs +++ b/soulseek-rs/src/ui/panes/rooms_pane.rs @@ -2,7 +2,7 @@ use crate::models::{RoomsState, RoomsView}; use crate::ui::{ HIGHLIGHT_SYMBOL, PANE_PADDING, accent_style, dimmed_style, highlight_style, info_style, pane_block, plain_title, primary_style, - row_highlight_style, + row_highlight_style, wrap_chat_line, }; use ratatui::{ Frame, @@ -174,31 +174,32 @@ fn render_messages( area: Rect, lines: &[crate::models::RoomLine], ) { - // Auto-scroll: show the most recent lines. Each RoomLine renders as exactly - // one row (no wrapping) so the tail count is exact and the newest message - // is always visible; over-long lines are clipped at the right edge rather - // than wrapping and pushing newer lines off the bottom. - let height = area.height as usize; - let start = lines.len().saturating_sub(height.max(1)); - let rendered: Vec = lines[start..] + // Wrap each message to the pane width, then auto-scroll: tail by rendered + // rows so the newest message is always fully visible. + let width = area.width as usize; + let rendered: Vec = lines .iter() - .map(|l| { + .flat_map(|l| { let time = Span::styled(l.at.format("%H:%M ").to_string(), dimmed_style()); match &l.username { - Some(user) => Line::from(vec![ - time, - Span::styled(format!("<{user}> "), info_style()), - Span::styled(l.text.clone(), primary_style()), - ]), - None => Line::from(vec![ - time, - Span::styled(l.text.clone(), dimmed_style()), - ]), + 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(); - frame.render_widget(Paragraph::new(rendered), area); + let start = rendered.len().saturating_sub((area.height as usize).max(1)); + frame.render_widget(Paragraph::new(rendered[start..].to_vec()), area); } fn render_users( diff --git a/soulseek-rs/src/ui/utils.rs b/soulseek-rs/src/ui/utils.rs index c0bbf35..973a505 100644 --- a/soulseek-rs/src/ui/utils.rs +++ b/soulseek-rs/src/ui/utils.rs @@ -30,9 +30,68 @@ pub fn format_speed(speed_bytes_per_sec: f64) -> String { format!("{mb:.1} MB/s") } +/// Wrap a chat message into display lines: the prefix spans (time, sender) +/// start the first line, and continuation lines are indented to the prefix +/// width so the message text stays column-aligned. +// ponytail: char-count wrap; wide glyphs may spill a cell — switch to +// unicode-width if that ever matters. +pub fn wrap_chat_line( + prefix: Vec>, + text: &str, + style: Style, + width: usize, +) -> Vec> { + let prefix_width: usize = prefix.iter().map(Span::width).sum(); + let avail = width.saturating_sub(prefix_width).max(1); + let chars: Vec = text.chars().collect(); + let indent = " ".repeat(prefix_width); + + let mut lines = Vec::new(); + let mut i = 0; + loop { + let chunk: String = chars[i..].iter().take(avail).collect(); + let spans = if i == 0 { + let mut spans = prefix.clone(); + spans.push(Span::styled(chunk, style)); + spans + } else { + vec![Span::raw(indent.clone()), Span::styled(chunk, style)] + }; + lines.push(Line::from(spans)); + i += avail; + if i >= chars.len() { + return lines; + } + } +} + const SPINNER_CHARS: [&str; 10] = ["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"]; pub const fn get_spinner_char(state: usize) -> &'static str { SPINNER_CHARS[state % SPINNER_CHARS.len()] } + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn wrap_chat_line_wraps_and_indents() { + let prefix = vec![Span::raw("12:00 "), Span::raw(" ")]; + let lines = + wrap_chat_line(prefix, "abcdefghij", Style::default(), 16); + // prefix width 12, so 4 text chars per row. + assert_eq!(lines.len(), 3); + assert_eq!(lines[0].to_string(), "12:00 abcd"); + assert_eq!(lines[1].to_string(), " efgh"); + assert_eq!(lines[2].to_string(), " ij"); + } + + #[test] + fn wrap_chat_line_short_message_is_one_line() { + let lines = + wrap_chat_line(vec![Span::raw("x ")], "hi", Style::default(), 80); + assert_eq!(lines.len(), 1); + } +}