feat(tui): scroll long result names and page through results

Closes #55.

While [2] Results is focused the Info pane describes the highlighted
result, so a truncated path can be read in full. h/l (or the arrow keys)
scroll the filename column by display cells, 0 and $ jump to either end
of the highlighted name, and Home/End, g/G, PgUp/PgDn and Ctrl-f/b/d/u
move through the list; the same keys work while a filter is being typed.

Ctrl chords are no longer read as their bare letter: outside [2] they do
nothing, where before Ctrl-d deleted a search and Ctrl-c opened rooms.
Space after a filter that matches nothing no longer indexes an empty
list.
This commit is contained in:
Michel de Graaf
2026-09-06 20:10:59 +02:00
parent 7bc696cab6
commit 3193c73f5b
12 changed files with 577 additions and 117 deletions
+95 -43
View File
@@ -1,4 +1,4 @@
use crate::models::DownloadEntry;
use crate::models::{DownloadEntry, FileDisplayData};
use crate::ui::{
accent_style, dimmed_style, error_style, format_bytes, format_progress_bar,
format_speed, inactive_style, info_style, pane_block, pane_title,
@@ -17,11 +17,10 @@ use soulseek_rs::utils::path::expand_tilde;
const LABEL_WIDTH: usize = 20;
/// Whichever transfer the table has highlighted. Rows are downloads first and
/// uploads after, so a raw index has to be resolved against both lists.
pub enum SelectedTransfer<'a> {
pub enum InfoSubject<'a> {
Download(&'a DownloadEntry),
Upload(&'a UploadInfo),
Result(&'a FileDisplayData),
}
/// Resolve the transfers table's selected row index into the download or
@@ -31,28 +30,28 @@ pub fn selected_transfer<'a>(
index: Option<usize>,
downloads: &'a [DownloadEntry],
uploads: &'a [UploadInfo],
) -> Option<SelectedTransfer<'a>> {
) -> Option<InfoSubject<'a>> {
let index = index?;
if let Some(download) = downloads.get(index) {
return Some(SelectedTransfer::Download(download));
return Some(InfoSubject::Download(download));
}
// Past the downloads, so this row is an upload.
uploads
.get(index - downloads.len())
.map(SelectedTransfer::Upload)
.map(InfoSubject::Upload)
}
pub fn render_download_info_pane(
frame: &mut Frame,
area: Rect,
selected: Option<SelectedTransfer>,
selected: Option<InfoSubject>,
focused: bool,
) {
let block = pane_block(focused).title(pane_title("Info", "", focused));
let Some(selected) = selected else {
let paragraph = Paragraph::new(Line::from(Span::styled(
"Select a transfer for details.",
"Select a result or transfer for details.",
dimmed_style(),
)))
.block(block);
@@ -61,8 +60,9 @@ pub fn render_download_info_pane(
};
let lines = match selected {
SelectedTransfer::Download(entry) => build_info_lines(&entry.download),
SelectedTransfer::Upload(upload) => build_upload_info_lines(upload),
InfoSubject::Download(entry) => build_info_lines(&entry.download),
InfoSubject::Upload(upload) => build_upload_info_lines(upload),
InfoSubject::Result(file) => build_result_info_lines(file),
};
let paragraph = Paragraph::new(lines)
@@ -75,16 +75,7 @@ pub fn render_download_info_pane(
/// view so the pane reads the same whichever direction is selected.
fn build_upload_info_lines(upload: &UploadInfo) -> Vec<Line<'static>> {
let mut lines: Vec<Line<'static>> = Vec::new();
let (basename, parent_dir) = split_filename(&upload.filename);
lines.push(Line::from(Span::styled(
basename,
primary_style().add_modifier(ratatui::style::Modifier::BOLD),
)));
if !parent_dir.is_empty() {
lines.push(Line::from(Span::styled(parent_dir, dimmed_style())));
}
lines.push(Line::from(""));
push_file_header(&mut lines, &upload.filename, primary_style());
// The pane is shared with downloads, so name the direction explicitly.
lines.push(label_value("Direction", "↑ Upload"));
@@ -143,21 +134,75 @@ fn build_upload_info_lines(upload: &UploadInfo) -> Vec<Line<'static>> {
lines
}
fn build_info_lines(
download: &soulseek_rs::types::Download,
) -> Vec<Line<'static>> {
fn build_result_info_lines(file: &FileDisplayData) -> Vec<Line<'static>> {
let mut lines: Vec<Line<'static>> = Vec::new();
push_file_header(&mut lines, &file.filename, accent_style());
let (basename, parent_dir) = split_filename(&download.filename);
lines.push(label_value_styled(
"User",
file.username.clone(),
info_style(),
));
lines.push(label_value_styled(
"Size",
format_bytes(file.size),
warning_style(),
));
push_media_attributes(&mut lines, file.bitrate, file.length_seconds);
lines.push(Line::from(""));
lines.push(free_slots_line(file.slots));
lines.push(label_value(
"Upload speed",
&format_speed(f64::from(file.speed)),
));
lines
}
fn push_file_header(
lines: &mut Vec<Line<'static>>,
filename: &str,
style: Style,
) {
let (basename, parent_dir) = split_filename(filename);
lines.push(Line::from(Span::styled(
basename,
accent_style().add_modifier(ratatui::style::Modifier::BOLD),
style.add_modifier(ratatui::style::Modifier::BOLD),
)));
if !parent_dir.is_empty() {
lines.push(Line::from(Span::styled(parent_dir, dimmed_style())));
}
lines.push(Line::from(""));
}
fn push_media_attributes(
lines: &mut Vec<Line<'static>>,
bitrate: Option<u32>,
length_seconds: Option<u32>,
) {
if let Some(bitrate) = bitrate {
lines.push(label_value("Bitrate", &format!("{bitrate} kbps")));
}
if let Some(length) = length_seconds {
lines.push(label_value("Length", &format_duration(length)));
}
}
fn free_slots_line(slots: u8) -> Line<'static> {
let (slot_text, slot_style) = if slots > 0 {
(format!("{slots} available"), success_style())
} else {
("all busy".to_string(), inactive_style())
};
label_value_styled("Free slots", slot_text, slot_style)
}
fn build_info_lines(
download: &soulseek_rs::types::Download,
) -> Vec<Line<'static>> {
let mut lines: Vec<Line<'static>> = Vec::new();
push_file_header(&mut lines, &download.filename, accent_style());
lines.push(label_value_styled(
"User",
@@ -186,13 +231,11 @@ fn build_info_lines(
.to_string_lossy()
.to_string();
lines.push(label_value_styled("Save to", save_path, info_style()));
if let Some(bitrate) = download.metadata.bitrate {
lines.push(label_value("Bitrate", &format!("{bitrate} kbps")));
}
if let Some(length) = download.metadata.length_seconds {
lines.push(label_value("Length", &format_duration(length)));
}
push_media_attributes(
&mut lines,
download.metadata.bitrate,
download.metadata.length_seconds,
);
match &download.status {
DownloadStatus::Queued => {
@@ -204,16 +247,7 @@ fn build_info_lines(
lines.push(label_value("Queue position", &position_text));
if let Some(slots) = download.metadata.peer_free_slots {
let (slot_text, slot_style) = if slots > 0 {
(format!("{slots} available"), success_style())
} else {
("all busy".to_string(), inactive_style())
};
lines.push(label_value_styled(
"Free slots",
slot_text,
slot_style,
));
lines.push(free_slots_line(slots));
}
if let Some(speed) = download.metadata.peer_upload_speed {
@@ -417,6 +451,24 @@ mod tests {
.join("\n")
}
#[test]
fn a_search_result_lists_its_path_owner_and_attributes() {
let file = crate::models::FileDisplayData {
filename: "@@x\\Music\\Album\\track.mp3".to_string(),
size: 2048,
username: "bob".to_string(),
speed: 1_000_000,
slots: 2,
bitrate: Some(320),
length_seconds: Some(125),
};
let text = lines_to_text(&build_result_info_lines(&file));
assert!(text.starts_with("track.mp3\n@@x/Music/Album\n"), "{text}");
for needle in ["bob", "320 kbps", "2m 05s", "2 available"] {
assert!(text.contains(needle), "missing {needle}: {text}");
}
}
#[test]
fn failed_download_renders_its_error_reason() {
let download = download_with_status(DownloadStatus::Failed(Some(
+6 -2
View File
@@ -8,8 +8,12 @@ mod searches_pane;
pub use browse_pane::render_browse_pane;
pub use chat_pane::render_chat_pane;
pub use download_info_pane::{render_download_info_pane, selected_transfer};
pub use download_info_pane::{
InfoSubject, render_download_info_pane, selected_transfer,
};
pub use downloads_pane::render_downloads_pane;
pub use results_pane::{ResultsPaneParams, render_results_pane};
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;
+123 -14
View File
@@ -6,14 +6,25 @@ use crate::ui::{
};
use ratatui::{
Frame,
layout::Rect,
layout::{Constraint, Layout, Rect},
text::{Line, Span},
widgets::{Cell, HighlightSpacing, Paragraph, Row, Table, TableState},
};
use std::collections::HashSet;
use unicode_truncate::UnicodeTruncateStr;
const VERSION: &str = env!("CARGO_PKG_VERSION");
const WIDTHS: [Constraint; 7] = [
Constraint::Length(3),
Constraint::Fill(3),
Constraint::Length(12),
Constraint::Length(15),
Constraint::Length(10),
Constraint::Length(12),
Constraint::Length(6),
];
pub struct ResultsPaneParams<'a> {
pub items: &'a [FileDisplayData],
pub table_state: &'a mut TableState,
@@ -25,6 +36,38 @@ pub struct ResultsPaneParams<'a> {
pub is_filtering: bool,
pub focused: bool,
pub active_search_query: Option<&'a str>,
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 }
}
#[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 cells = Span::raw(name).width();
let skip = offset.min(overflow(cells, width));
if skip == 0 {
return Line::from(name);
}
let (tail, _) = name.unicode_truncate_start(cells - skip);
Line::from(vec![Span::raw("…"), Span::raw(tail)])
}
/// Whether the rendered row `display_idx` is selected. `selected_indices` holds
@@ -59,6 +102,7 @@ pub fn render_results_pane(
is_filtering,
focused,
active_search_query,
name_offset,
} = params;
if items.is_empty() {
let title = match active_search_query {
@@ -105,6 +149,7 @@ pub fn render_results_pane(
])
.height(1);
let name_width = name_column_width(area);
let rows: Vec<Row> = items
.iter()
.enumerate()
@@ -137,7 +182,12 @@ pub fn render_results_pane(
Row::new(vec![
Cell::from(checkbox).style(checkbox_style),
Cell::from(file.filename.clone()).style(body_style()),
Cell::from(scroll_name(
&file.filename,
name_offset,
name_width,
))
.style(body_style()),
Cell::from(format_bytes(file.size)).style(warning_style()),
Cell::from(file.username.clone()).style(info_style()),
Cell::from(bitrate_str).style(dimmed_style()),
@@ -147,16 +197,6 @@ pub fn render_results_pane(
})
.collect();
let widths = [
ratatui::layout::Constraint::Length(3),
ratatui::layout::Constraint::Fill(3),
ratatui::layout::Constraint::Length(12),
ratatui::layout::Constraint::Length(15),
ratatui::layout::Constraint::Length(10),
ratatui::layout::Constraint::Length(12),
ratatui::layout::Constraint::Length(6),
];
let title = if is_filtering {
format!("Results · filter: '{filter_query}'")
} else if let Some(query) = active_search_query {
@@ -165,7 +205,7 @@ pub fn render_results_pane(
"Results".to_string()
};
let table = Table::new(rows, widths)
let table = Table::new(rows, WIDTHS)
.header(header)
.row_highlight_style(row_highlight_style())
.highlight_symbol(HIGHLIGHT_SYMBOL)
@@ -177,9 +217,78 @@ pub fn render_results_pane(
#[cfg(test)]
mod tests {
use super::row_is_selected;
use super::{ResultsPaneParams, render_results_pane, row_is_selected};
use crate::models::FileDisplayData;
use ratatui::{Terminal, backend::TestBackend, widgets::TableState};
use std::collections::HashSet;
fn render_rows(items: &[FileDisplayData], name_offset: usize) -> String {
let mut state = TableState::default();
state.select(Some(0));
let mut terminal =
Terminal::new(TestBackend::new(80, 6)).expect("backend");
terminal
.draw(|frame| {
render_results_pane(
frame,
frame.area(),
ResultsPaneParams {
items,
table_state: &mut state,
selected_indices: &HashSet::new(),
original_indices: None,
filter_query: "",
is_filtering: false,
focused: true,
active_search_query: None,
name_offset,
},
);
})
.expect("draw");
terminal.backend().to_string()
}
fn file(filename: &str) -> FileDisplayData {
FileDisplayData {
filename: filename.to_string(),
username: "bob".to_string(),
..Default::default()
}
}
#[test]
fn a_scrolled_name_shows_its_tail_up_to_the_column_edge() {
let items = [
file("abcdefghijklmnopqrstuvwxyz0123456789ABCD"),
file("short.mp3"),
];
let screen = render_rows(&items, usize::MAX);
assert!(screen.contains("…456789ABCD"), "{screen}");
assert!(!screen.contains("3456789ABCD"), "{screen}");
assert!(screen.contains(" short.mp3 "), "{screen}");
let screen = render_rows(&items, 8);
assert!(screen.contains("…ijklmnopqr"), "{screen}");
assert!(!screen.contains("hijklmnopqr"), "{screen}");
}
#[test]
fn a_wide_glyph_name_scrolls_by_cells_not_chars() {
let items = [file("零一二三四五六七八九十百千万亿東西南北中")];
let screen = render_rows(&items, usize::MAX);
assert!(screen.contains("…東西南北中"), "{screen}");
assert!(!screen.contains("亿東"), "{screen}");
}
#[test]
fn an_unscrolled_name_starts_at_its_beginning() {
let items = [file("abcdefghijklmnopqrstuvwxyz0123456789ABCD")];
let screen = render_rows(&items, 0);
assert!(screen.contains("abcdefghijk"), "{screen}");
assert!(!screen.contains("…"), "{screen}");
}
#[test]
fn identity_mapping_used_when_no_filter() {
let selected: HashSet<usize> = std::iter::once(1).collect();