feat(tui): scroll long names in every list and page through popups and chat

The sideways keys the results had (h/l, arrows, 0 and $) now scroll a
transfer's file name and a query too, each list on its own. The browse tree
and the room list take Home/End, PgUp/PgDn and the vim jumps like the main
lists, and a chat log, room or private, pages back through its history and
holds its place while new messages arrive; what arrives meanwhile counts as
unread until the reader returns to the end.
This commit is contained in:
Michel de Graaf
2026-09-07 08:16:20 +00:00
parent da8ab45303
commit 478d976776
18 changed files with 841 additions and 175 deletions
+18 -10
View File
@@ -1,4 +1,4 @@
use crate::models::{AppState, MessageDirection};
use crate::models::{AppState, ChatMessage, LogView, MessageDirection};
use crate::ui::{
accent_style, border_style, dimmed_style, highlight_style, info_style,
primary_style, wrap_chat_line,
@@ -17,10 +17,11 @@ use ratatui::{
pub fn render_chat_pane(
frame: &mut Frame,
area: Rect,
state: &AppState,
state: &mut AppState,
own_username: &str,
) {
let peer = state.active_chat_peer();
let peer = state.active_chat_peer().map(str::to_string);
let peer = peer.as_deref();
let title = peer.map_or_else(
|| " Messages (m: compose, i/Esc: close) ".to_string(),
|peer| format!(" {peer} (↑↓/Tab: switch, m: to…, i/Esc: close) "),
@@ -51,7 +52,14 @@ pub fn render_chat_pane(
return;
};
render_messages(frame, body[0], state, peer, own_username);
render_messages(
frame,
body[0],
&state.messages,
&mut state.chat_view,
peer,
own_username,
);
render_compose(frame, chunks[1], state);
}
@@ -94,13 +102,13 @@ fn render_peers(
fn render_messages(
frame: &mut Frame,
area: Rect,
state: &AppState,
messages: &[ChatMessage],
view: &mut LogView,
peer: &str,
own_username: &str,
) {
let width = area.width as usize;
let lines: Vec<Line> = state
.messages
let lines: Vec<Line> = messages
.iter()
.filter(|m| m.peer == peer)
.flat_map(|m| {
@@ -123,9 +131,9 @@ fn render_messages(
})
.collect();
// 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);
// The newest rows, unless the reader is holding a place in the history.
let window = view.window(lines.len(), usize::from(area.height));
frame.render_widget(Paragraph::new(lines[window].to_vec()), area);
}
fn render_compose(frame: &mut Frame, area: Rect, state: &AppState) {
+48 -20
View File
@@ -1,3 +1,4 @@
use super::name_scroll::{column_width, end_offset, scroll_text};
use crate::models::DownloadEntry;
use crate::ui::{
GLYPH_ACTIVE, GLYPH_DONE, GLYPH_FAILED, GLYPH_QUEUED, HIGHLIGHT_SYMBOL,
@@ -8,12 +9,43 @@ use crate::ui::{
};
use ratatui::{
Frame,
layout::Rect,
layout::{Constraint, Rect},
widgets::{Cell, HighlightSpacing, Paragraph, Row, Table, TableState},
};
use soulseek_rs::DownloadStatus;
use soulseek_rs::types::{UploadInfo, UploadStatus};
// Sized to the widest value each column shows in ordinary use — a
// three-digit megabyte count on both sides of the progress — so the file
// name keeps whatever width the pane has beyond that.
const WIDTHS: [Constraint; 5] = [
Constraint::Length(6),
Constraint::Fill(1),
Constraint::Length(12),
Constraint::Length(25),
Constraint::Length(10),
];
/// Filename is the second column.
const NAME_COLUMN: usize = 1;
/// The offset that brings the end of a transfer's name into view.
#[must_use]
pub fn name_end_offset(name: &str, area: Rect) -> usize {
end_offset(name, area, &WIDTHS, NAME_COLUMN)
}
/// What the list shows for an upload: the peer-facing path is ours, so only
/// the file's own name is worth a column.
#[must_use]
pub fn upload_display_name(upload: &UploadInfo) -> &str {
upload
.filename
.rsplit(['\\', '/'])
.next()
.unwrap_or(&upload.filename)
}
pub fn render_downloads_pane(
frame: &mut Frame,
area: Rect,
@@ -21,6 +53,7 @@ pub fn render_downloads_pane(
uploads: &[UploadInfo],
table_state: &mut TableState,
focused: bool,
name_offset: usize,
) {
if downloads.is_empty() && uploads.is_empty() {
let empty_block = pane_block(focused).title(pane_title(
@@ -47,6 +80,7 @@ pub fn render_downloads_pane(
])
.height(1);
let name_width = column_width(area, &WIDTHS, NAME_COLUMN);
let mut rows: Vec<Row> = downloads
.iter()
.map(|download_entry| {
@@ -106,7 +140,12 @@ pub fn render_downloads_pane(
Row::new(vec![
Cell::from(status_icon).style(status_style),
Cell::from(download.filename.clone()).style(body_style()),
Cell::from(scroll_text(
&download.filename,
name_offset,
name_width,
))
.style(body_style()),
Cell::from(download.username.clone()).style(info_style()),
Cell::from(progress_text).style(progress_style),
Cell::from(speed_text).style(warning_style()),
@@ -149,32 +188,21 @@ pub fn render_downloads_pane(
}
_ => "-".to_string(),
};
let basename = upload
.filename
.rsplit(['\\', '/'])
.next()
.unwrap_or(&upload.filename);
Row::new(vec![
Cell::from(format!("↑ {status_icon}")).style(status_style),
Cell::from(basename.to_string()).style(body_style()),
Cell::from(scroll_text(
upload_display_name(upload),
name_offset,
name_width,
))
.style(body_style()),
Cell::from(upload.username.clone()).style(info_style()),
Cell::from(progress_text).style(primary_style()),
Cell::from(speed_text).style(warning_style()),
])
}));
// Sized to the widest value each column shows in ordinary use — a
// three-digit megabyte count on both sides of the progress — so the file
// name keeps whatever width the pane has beyond that.
let widths = [
ratatui::layout::Constraint::Length(6),
ratatui::layout::Constraint::Fill(1),
ratatui::layout::Constraint::Length(12),
ratatui::layout::Constraint::Length(25),
ratatui::layout::Constraint::Length(10),
];
let table = Table::new(rows, widths)
let table = Table::new(rows, WIDTHS)
.header(header)
.row_highlight_style(row_highlight_style())
.highlight_symbol(HIGHLIGHT_SYMBOL)
+6 -2
View File
@@ -2,6 +2,7 @@ mod browse_pane;
mod chat_pane;
mod download_info_pane;
mod downloads_pane;
mod name_scroll;
mod results_pane;
mod rooms_pane;
mod searches_pane;
@@ -11,9 +12,12 @@ pub use chat_pane::render_chat_pane;
pub use download_info_pane::{
InfoSubject, render_download_info_pane, selected_transfer,
};
pub use downloads_pane::render_downloads_pane;
pub use downloads_pane::{
name_end_offset as transfer_name_end_offset, render_downloads_pane,
upload_display_name,
};
pub use results_pane::{
ResultsPaneParams, name_end_offset, render_results_pane,
};
pub use rooms_pane::render_rooms_pane;
pub use searches_pane::render_searches_pane;
pub use searches_pane::{query_end_offset, render_searches_pane};
+126
View File
@@ -0,0 +1,126 @@
//! Sideways scrolling for a table column that holds text wider than it is:
//! a file name, a query. Every list scrolls the same way, so the arithmetic
//! lives here and each pane only says which of its columns it applies to.
use crate::ui::{HIGHLIGHT_SYMBOL, pane_block};
use ratatui::{
layout::{Constraint, Layout, Rect},
text::{Line, Span},
};
use unicode_width::UnicodeWidthChar;
/// Cells column `column` of a table laid out with `widths` gets inside a pane
/// drawn over `area`: what the pane keeps for its border, padding and the
/// highlight symbol comes off first.
#[must_use]
pub fn column_width(area: Rect, widths: &[Constraint], column: usize) -> usize {
let inner = pane_block(false).inner(area);
let symbol = Line::from(HIGHLIGHT_SYMBOL).width() as u16;
let [_, columns] =
Layout::horizontal([Constraint::Length(symbol), Constraint::Fill(0)])
.areas(inner);
usize::from(
Layout::horizontal(widths).spacing(1).split(columns)[column].width,
)
}
/// How far a text of `cells` can scroll before its tail is in view, keeping
/// one cell for the ellipsis that marks the cut.
const fn overflow(cells: usize, width: usize) -> usize {
if cells <= width { 0 } else { cells + 1 - width }
}
/// The offset that brings the end of `text` into view in the column.
#[must_use]
pub fn end_offset(
text: &str,
area: Rect,
widths: &[Constraint],
column: usize,
) -> usize {
overflow(Span::raw(text).width(), column_width(area, widths, column))
}
/// `text` scrolled `offset` cells to the left, with an ellipsis where it was
/// cut. Scrolls by cells, so a wide glyph counts double, and never starts on
/// a combining mark. Beyond the end it shows the tail.
#[must_use]
pub fn scroll_text(text: &str, offset: usize, width: usize) -> Line<'_> {
if offset == 0 {
return Line::from(text);
}
let mut tail_cells = 0;
let mut furthest = text.len();
for (index, ch) in text.char_indices().rev() {
let cells = ch.width().unwrap_or(0);
if tail_cells + cells >= width {
if index == 0 && tail_cells + cells == width {
return Line::from(text);
}
break;
}
tail_cells += cells;
furthest = index;
}
if furthest == 0 {
return Line::from(text);
}
let mut skipped = 0;
let mut start = furthest;
for (index, ch) in text.char_indices() {
if index >= furthest || skipped >= offset {
start = index;
break;
}
skipped += ch.width().unwrap_or(0);
}
while let Some(ch) = text[start..].chars().next()
&& ch.width() == Some(0)
{
start += ch.len_utf8();
}
Line::from(vec![Span::raw("…"), Span::raw(&text[start..])])
}
#[cfg(test)]
mod tests {
use super::*;
fn text_of(line: &Line<'_>) -> String {
line.spans.iter().map(|s| s.content.as_ref()).collect()
}
#[test]
fn a_text_that_fits_never_scrolls() {
assert_eq!(text_of(&scroll_text("short", 8, 10)), "short");
assert_eq!(
end_offset(
"short",
Rect::new(0, 0, 40, 5),
&[Constraint::Fill(1)],
0
),
0
);
}
#[test]
fn the_end_offset_leaves_room_for_the_ellipsis() {
// Column of 40 - 2 border - 2 padding - 1 symbol = 35 cells.
let area = Rect::new(0, 0, 40, 5);
let widths = [Constraint::Fill(1)];
let fits = "x".repeat(35);
let over = "x".repeat(36);
assert_eq!(end_offset(&fits, area, &widths, 0), 0);
assert_eq!(end_offset(&over, area, &widths, 0), 2);
let line = scroll_text(&over, 2, 35);
assert_eq!(text_of(&line).chars().count(), 35);
assert!(text_of(&line).starts_with('…'));
}
#[test]
fn scrolling_past_the_end_shows_the_tail() {
let line = scroll_text("abcdefghij", usize::MAX, 5);
assert_eq!(text_of(&line), "…ghij");
}
}
+7 -54
View File
@@ -1,3 +1,4 @@
use super::name_scroll::{column_width, end_offset, scroll_text};
use crate::models::FileDisplayData;
use crate::ui::{
BYTES_PER_MB, HIGHLIGHT_SYMBOL, body_style, dimmed_style, format_bytes,
@@ -6,12 +7,11 @@ use crate::ui::{
};
use ratatui::{
Frame,
layout::{Constraint, Layout, Rect},
layout::{Constraint, Rect},
text::{Line, Span},
widgets::{Cell, HighlightSpacing, Paragraph, Row, Table, TableState},
};
use std::collections::HashSet;
use unicode_width::UnicodeWidthChar;
const VERSION: &str = env!("CARGO_PKG_VERSION");
@@ -39,59 +39,12 @@ pub struct ResultsPaneParams<'a> {
pub name_offset: usize,
}
fn name_column_width(area: Rect) -> usize {
let inner = pane_block(false).inner(area);
let symbol = Line::from(HIGHLIGHT_SYMBOL).width() as u16;
let [_, columns] =
Layout::horizontal([Constraint::Length(symbol), Constraint::Fill(0)])
.areas(inner);
usize::from(Layout::horizontal(WIDTHS).spacing(1).split(columns)[1].width)
}
const fn overflow(cells: usize, width: usize) -> usize {
if cells <= width { 0 } else { cells + 1 - width }
}
/// Filename is the second column.
const NAME_COLUMN: usize = 1;
#[must_use]
pub fn name_end_offset(name: &str, area: Rect) -> usize {
overflow(Span::raw(name).width(), name_column_width(area))
}
fn scroll_name(name: &str, offset: usize, width: usize) -> Line<'_> {
if offset == 0 {
return Line::from(name);
}
let mut tail_cells = 0;
let mut furthest = name.len();
for (index, ch) in name.char_indices().rev() {
let cells = ch.width().unwrap_or(0);
if tail_cells + cells >= width {
if index == 0 && tail_cells + cells == width {
return Line::from(name);
}
break;
}
tail_cells += cells;
furthest = index;
}
if furthest == 0 {
return Line::from(name);
}
let mut skipped = 0;
let mut start = furthest;
for (index, ch) in name.char_indices() {
if index >= furthest || skipped >= offset {
start = index;
break;
}
skipped += ch.width().unwrap_or(0);
}
while let Some(ch) = name[start..].chars().next()
&& ch.width() == Some(0)
{
start += ch.len_utf8();
}
Line::from(vec![Span::raw("…"), Span::raw(&name[start..])])
end_offset(name, area, &WIDTHS, NAME_COLUMN)
}
/// Whether the rendered row `display_idx` is selected. `selected_indices` holds
@@ -173,7 +126,7 @@ pub fn render_results_pane(
])
.height(1);
let name_width = name_column_width(area);
let name_width = column_width(area, &WIDTHS, NAME_COLUMN);
let rows: Vec<Row> = items
.iter()
.enumerate()
@@ -206,7 +159,7 @@ pub fn render_results_pane(
Row::new(vec![
Cell::from(checkbox).style(checkbox_style),
Cell::from(scroll_name(
Cell::from(scroll_text(
&file.filename,
name_offset,
name_width,
+15 -11
View File
@@ -1,4 +1,4 @@
use crate::models::{RoomsState, RoomsView};
use crate::models::{LogView, RoomsState, RoomsView};
use crate::ui::{
HIGHLIGHT_SYMBOL, PANE_PADDING, accent_style, dimmed_style,
highlight_style, info_style, pane_block, plain_title, primary_style,
@@ -20,7 +20,7 @@ use ratatui::{
pub fn render_rooms_pane(
frame: &mut Frame,
area: Rect,
rooms: &RoomsState,
rooms: &mut RoomsState,
list_table_state: &mut TableState,
) {
match rooms.view {
@@ -94,7 +94,7 @@ fn render_list(
frame.render_stateful_widget(table, area, table_state);
}
fn render_chat(frame: &mut Frame, area: Rect, rooms: &RoomsState) {
fn render_chat(frame: &mut Frame, area: Rect, rooms: &mut RoomsState) {
let block = pane_block(true).title(
" Chat rooms (Tab: switch, l: room list, x: leave, Esc: back) ",
);
@@ -110,7 +110,10 @@ fn render_chat(frame: &mut Frame, area: Rect, rooms: &RoomsState) {
render_tab_bar(frame, chunks[0], rooms);
let Some(active) = rooms.active_room() else {
let user_selected = rooms.user_selected;
let composing = rooms.composing;
let active_index = rooms.active;
let Some(active) = rooms.open.get_mut(active_index) else {
frame.render_widget(
Paragraph::new("No open rooms. Press l for the room list.")
.style(dimmed_style()),
@@ -124,11 +127,11 @@ fn render_chat(frame: &mut Frame, area: Rect, rooms: &RoomsState) {
Layout::horizontal([Constraint::Fill(1), Constraint::Length(22)])
.split(chunks[1]);
render_messages(frame, body[0], active.lines.as_slice());
render_users(frame, body[1], &active.users, rooms.user_selected);
render_messages(frame, body[0], active.lines.as_slice(), &mut active.view);
render_users(frame, body[1], &active.users, user_selected);
// Compose line or hint.
if rooms.composing {
if composing {
let line = Line::from(vec![
Span::styled("› ", accent_style()),
Span::styled(active.input.clone(), primary_style()),
@@ -173,9 +176,10 @@ fn render_messages(
frame: &mut Frame,
area: Rect,
lines: &[crate::models::RoomLine],
view: &mut LogView,
) {
// Wrap each message to the pane width, then auto-scroll: tail by rendered
// rows so the newest message is always fully visible.
// Wrap each message to the pane width, then show 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<Line> = lines
.iter()
@@ -198,8 +202,8 @@ fn render_messages(
}
})
.collect();
let start = rendered.len().saturating_sub((area.height as usize).max(1));
frame.render_widget(Paragraph::new(rendered[start..].to_vec()), area);
let window = view.window(rendered.len(), usize::from(area.height));
frame.render_widget(Paragraph::new(rendered[window].to_vec()), area);
}
fn render_users(
+28 -11
View File
@@ -1,3 +1,4 @@
use super::name_scroll::{column_width, end_offset, scroll_text};
use crate::models::{SearchEntry, SearchStatus};
use crate::ui::{
HIGHLIGHT_SYMBOL, body_style, dimmed_style, header_style, pane_block,
@@ -5,16 +6,34 @@ use crate::ui::{
};
use ratatui::{
Frame,
layout::Rect,
layout::{Constraint, Rect},
widgets::{Cell, HighlightSpacing, Row, Table, TableState},
};
// The pane shares a row with two others, so the fixed columns are as
// narrow as their widest word: the query gets the rest.
const WIDTHS: [Constraint; 3] = [
Constraint::Length(6),
Constraint::Fill(1),
Constraint::Length(7),
];
/// Query is the second column.
const QUERY_COLUMN: usize = 1;
/// The offset that brings the end of a query into view.
#[must_use]
pub fn query_end_offset(query: &str, area: Rect) -> usize {
end_offset(query, area, &WIDTHS, QUERY_COLUMN)
}
pub fn render_searches_pane(
frame: &mut Frame,
area: Rect,
searches: &[SearchEntry],
table_state: &mut TableState,
focused: bool,
query_offset: usize,
) {
let header = Row::new(vec![
Cell::from("Status").style(header_style()),
@@ -23,6 +42,7 @@ pub fn render_searches_pane(
])
.height(1);
let query_width = column_width(area, &WIDTHS, QUERY_COLUMN);
let rows: Vec<Row> = searches
.iter()
.map(|search| {
@@ -55,21 +75,18 @@ pub fn render_searches_pane(
Row::new(vec![
status_cell,
Cell::from(search.query.clone()).style(body_style()),
Cell::from(scroll_text(
&search.query,
query_offset,
query_width,
))
.style(body_style()),
Cell::from(results_text).style(dimmed_style()),
])
})
.collect();
// The pane shares a row with two others, so the fixed columns are as
// narrow as their widest word: the query gets the rest.
let widths = [
ratatui::layout::Constraint::Length(6),
ratatui::layout::Constraint::Fill(1),
ratatui::layout::Constraint::Length(7),
];
let table = Table::new(rows, widths)
let table = Table::new(rows, WIDTHS)
.header(header)
.row_highlight_style(row_highlight_style())
.highlight_symbol(HIGHLIGHT_SYMBOL)