feat(tui): wrap long chat messages instead of clipping them
This commit is contained in:
@@ -1,7 +1,7 @@
|
|||||||
use crate::models::{AppState, MessageDirection};
|
use crate::models::{AppState, MessageDirection};
|
||||||
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,
|
primary_style, wrap_chat_line,
|
||||||
};
|
};
|
||||||
use ratatui::{
|
use ratatui::{
|
||||||
Frame,
|
Frame,
|
||||||
@@ -98,25 +98,32 @@ fn render_messages(
|
|||||||
peer: &str,
|
peer: &str,
|
||||||
own_username: &str,
|
own_username: &str,
|
||||||
) {
|
) {
|
||||||
|
let width = area.width as usize;
|
||||||
let lines: Vec<Line> = state
|
let lines: Vec<Line> = state
|
||||||
.messages
|
.messages
|
||||||
.iter()
|
.iter()
|
||||||
.filter(|m| m.peer == peer)
|
.filter(|m| m.peer == peer)
|
||||||
.map(|m| {
|
.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()),
|
||||||
};
|
};
|
||||||
Line::from(vec![
|
wrap_chat_line(
|
||||||
Span::styled(m.at.format("%H:%M ").to_string(), dimmed_style()),
|
vec![
|
||||||
|
Span::styled(
|
||||||
|
m.at.format("%H:%M ").to_string(),
|
||||||
|
dimmed_style(),
|
||||||
|
),
|
||||||
Span::styled(format!("<{sender}> "), sender_style),
|
Span::styled(format!("<{sender}> "), sender_style),
|
||||||
Span::styled(m.text.clone(), primary_style()),
|
],
|
||||||
])
|
&m.text,
|
||||||
|
primary_style(),
|
||||||
|
width,
|
||||||
|
)
|
||||||
})
|
})
|
||||||
.collect();
|
.collect();
|
||||||
|
|
||||||
// Auto-scroll: one row per message (no wrapping), so tailing by the pane
|
// Auto-scroll: tail by rendered rows so the newest message stays visible.
|
||||||
// height keeps the newest message visible.
|
|
||||||
let start = lines.len().saturating_sub((area.height as usize).max(1));
|
let start = lines.len().saturating_sub((area.height as usize).max(1));
|
||||||
frame.render_widget(Paragraph::new(lines[start..].to_vec()), area);
|
frame.render_widget(Paragraph::new(lines[start..].to_vec()), area);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,7 +2,7 @@ use crate::models::{RoomsState, RoomsView};
|
|||||||
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, pane_block, plain_title, primary_style,
|
||||||
row_highlight_style,
|
row_highlight_style, wrap_chat_line,
|
||||||
};
|
};
|
||||||
use ratatui::{
|
use ratatui::{
|
||||||
Frame,
|
Frame,
|
||||||
@@ -174,31 +174,32 @@ fn render_messages(
|
|||||||
area: Rect,
|
area: Rect,
|
||||||
lines: &[crate::models::RoomLine],
|
lines: &[crate::models::RoomLine],
|
||||||
) {
|
) {
|
||||||
// Auto-scroll: show the most recent lines. Each RoomLine renders as exactly
|
// Wrap each message to the pane width, then auto-scroll: tail by rendered
|
||||||
// one row (no wrapping) so the tail count is exact and the newest message
|
// rows so the newest message is always fully visible.
|
||||||
// is always visible; over-long lines are clipped at the right edge rather
|
let width = area.width as usize;
|
||||||
// than wrapping and pushing newer lines off the bottom.
|
let rendered: Vec<Line> = lines
|
||||||
let height = area.height as usize;
|
|
||||||
let start = lines.len().saturating_sub(height.max(1));
|
|
||||||
let rendered: Vec<Line> = lines[start..]
|
|
||||||
.iter()
|
.iter()
|
||||||
.map(|l| {
|
.flat_map(|l| {
|
||||||
let time =
|
let time =
|
||||||
Span::styled(l.at.format("%H:%M ").to_string(), dimmed_style());
|
Span::styled(l.at.format("%H:%M ").to_string(), dimmed_style());
|
||||||
match &l.username {
|
match &l.username {
|
||||||
Some(user) => Line::from(vec![
|
Some(user) => wrap_chat_line(
|
||||||
|
vec![
|
||||||
time,
|
time,
|
||||||
Span::styled(format!("<{user}> "), info_style()),
|
Span::styled(format!("<{user}> "), info_style()),
|
||||||
Span::styled(l.text.clone(), primary_style()),
|
],
|
||||||
]),
|
&l.text,
|
||||||
None => Line::from(vec![
|
primary_style(),
|
||||||
time,
|
width,
|
||||||
Span::styled(l.text.clone(), dimmed_style()),
|
),
|
||||||
]),
|
None => {
|
||||||
|
wrap_chat_line(vec![time], &l.text, dimmed_style(), width)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
.collect();
|
.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(
|
fn render_users(
|
||||||
|
|||||||
@@ -30,9 +30,68 @@ pub fn format_speed(speed_bytes_per_sec: f64) -> String {
|
|||||||
format!("{mb:.1} MB/s")
|
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<Span<'static>>,
|
||||||
|
text: &str,
|
||||||
|
style: Style,
|
||||||
|
width: usize,
|
||||||
|
) -> Vec<Line<'static>> {
|
||||||
|
let prefix_width: usize = prefix.iter().map(Span::width).sum();
|
||||||
|
let avail = width.saturating_sub(prefix_width).max(1);
|
||||||
|
let chars: Vec<char> = 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] =
|
const SPINNER_CHARS: [&str; 10] =
|
||||||
["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"];
|
["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"];
|
||||||
|
|
||||||
pub const fn get_spinner_char(state: usize) -> &'static str {
|
pub const fn get_spinner_char(state: usize) -> &'static str {
|
||||||
SPINNER_CHARS[state % SPINNER_CHARS.len()]
|
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("<bob> ")];
|
||||||
|
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 <bob> 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user