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
Generated
+1
View File
@@ -2564,6 +2564,7 @@ dependencies = [
"soulseek-rs-lib", "soulseek-rs-lib",
"tempfile", "tempfile",
"toml", "toml",
"unicode-truncate",
] ]
[[package]] [[package]]
+1
View File
@@ -30,6 +30,7 @@ soulseek-rs-lib = { version = "14.1.2", path = "../soulseek-rs-lib" }
clap = { version = "4.6.2", features = ["derive", "color", "wrap_help", "env"] } clap = { version = "4.6.2", features = ["derive", "color", "wrap_help", "env"] }
clap_complete = "4.6.7" clap_complete = "4.6.7"
ratatui = "0.30.2" ratatui = "0.30.2"
unicode-truncate = "2"
color-eyre = "0.6.5" color-eyre = "0.6.5"
dotenv = "0.15.0" dotenv = "0.15.0"
igd-next = "0.17" igd-next = "0.17"
+2
View File
@@ -79,6 +79,7 @@ pub struct AppState {
pub results_selected_indices: std::collections::HashSet<usize>, pub results_selected_indices: std::collections::HashSet<usize>,
pub results_filter_query: String, pub results_filter_query: String,
pub results_is_filtering: bool, pub results_is_filtering: bool,
pub results_name_offset: usize,
// Downloads // Downloads
pub downloads: Vec<DownloadEntry>, pub downloads: Vec<DownloadEntry>,
@@ -154,6 +155,7 @@ impl AppState {
results_selected_indices: std::collections::HashSet::new(), results_selected_indices: std::collections::HashSet::new(),
results_filter_query: String::new(), results_filter_query: String::new(),
results_is_filtering: false, results_is_filtering: false,
results_name_offset: 0,
downloads: Vec::new(), downloads: Vec::new(),
downloads_table_state, downloads_table_state,
+1 -7
View File
@@ -192,13 +192,7 @@ impl MainTui {
/// The username of the highlighted search result (filter-aware). /// The username of the highlighted search result (filter-aware).
pub(super) fn highlighted_result_owner(&self) -> Option<String> { pub(super) fn highlighted_result_owner(&self) -> Option<String> {
let selected = self.state.results_table_state.selected()?; self.highlighted_result().map(|f| f.username.clone())
let items = if self.state.results_filter_query.is_empty() {
&self.state.results_items
} else {
&self.state.results_filtered_items
};
items.get(selected).map(|f| f.username.clone())
} }
/// Drain browse responses into any loading tabs, or time them out. /// Drain browse responses into any loading tabs, or time them out.
+84 -41
View File
@@ -1,11 +1,15 @@
use super::MainTui; use super::MainTui;
use crate::models::{CommandBarMode, FocusedPane}; use crate::models::{CommandBarMode, FocusedPane};
use crate::ui::pane_block;
use crate::ui::panes::name_end_offset;
use ratatui::crossterm::event::{ use ratatui::crossterm::event::{
KeyCode, KeyEvent, KeyModifiers, MouseButton, MouseEvent, MouseEventKind, KeyCode, KeyEvent, KeyModifiers, MouseButton, MouseEvent, MouseEventKind,
}; };
use ratatui::layout::Position; use ratatui::layout::Position;
use ratatui::widgets::TableState; use ratatui::widgets::TableState;
const NAME_SCROLL_STEP: isize = 8;
impl MainTui { impl MainTui {
pub(super) fn handle_key_event(&mut self, key: KeyEvent) { pub(super) fn handle_key_event(&mut self, key: KeyEvent) {
// Command bar takes priority // Command bar takes priority
@@ -29,6 +33,13 @@ impl MainTui {
return self.handle_settings_input(key); return self.handle_settings_input(key);
} }
if key.modifiers.contains(KeyModifiers::CONTROL) {
if self.state.focused_pane == FocusedPane::Results {
self.handle_results_input(key);
}
return;
}
// Filter mode in Results pane // Filter mode in Results pane
if self.state.results_is_filtering if self.state.results_is_filtering
&& self.state.focused_pane == FocusedPane::Results && self.state.focused_pane == FocusedPane::Results
@@ -291,21 +302,7 @@ impl MainTui {
KeyCode::Enter => { KeyCode::Enter => {
self.state.results_is_filtering = false; self.state.results_is_filtering = false;
} }
KeyCode::Up => { _ => self.handle_results_input(key),
cycle(
&mut self.state.results_table_state,
self.state.results_filtered_items.len(),
false,
);
}
KeyCode::Down => {
cycle(
&mut self.state.results_table_state,
self.state.results_filtered_items.len(),
true,
);
}
_ => {}
} }
} }
@@ -330,6 +327,7 @@ impl MainTui {
self.state.results_filtered_indices = self.state.results_filtered_indices =
(0..search.results.len()).collect(); (0..search.results.len()).collect();
self.state.results_selected_indices.clear(); self.state.results_selected_indices.clear();
self.state.results_name_offset = 0;
self.state.results_table_state.select(Some(0)); self.state.results_table_state.select(Some(0));
self.state.focused_pane = FocusedPane::Results; self.state.focused_pane = FocusedPane::Results;
} }
@@ -350,11 +348,13 @@ impl MainTui {
} }
fn handle_results_input(&mut self, key: KeyEvent) { fn handle_results_input(&mut self, key: KeyEvent) {
let items_count = if self.state.results_filter_query.is_empty() { let ctrl = key.modifiers.contains(KeyModifiers::CONTROL);
self.state.results_items.len() if ctrl && !matches!(key.code, KeyCode::Char('f' | 'b' | 'd' | 'u')) {
} else { return;
self.state.results_filtered_items.len() }
}; let items_count = self.visible_results().len();
let page = self.results_page_size();
let half = (page / 2).max(1);
match key.code { match key.code {
KeyCode::Up | KeyCode::Char('k') => { KeyCode::Up | KeyCode::Char('k') => {
@@ -363,29 +363,36 @@ impl MainTui {
KeyCode::Down | KeyCode::Char('j') => { KeyCode::Down | KeyCode::Char('j') => {
cycle(&mut self.state.results_table_state, items_count, true); cycle(&mut self.state.results_table_state, items_count, true);
} }
KeyCode::Home | KeyCode::Char('g') => self.select_row(0),
KeyCode::End | KeyCode::Char('G') => {
self.select_row(items_count.saturating_sub(1));
}
KeyCode::PageDown => self.scroll_rows(page),
KeyCode::PageUp => self.scroll_rows(-page),
KeyCode::Char('f') if ctrl => self.scroll_rows(page),
KeyCode::Char('b') if ctrl => self.scroll_rows(-page),
KeyCode::Char('d') if ctrl => self.scroll_rows(half),
KeyCode::Char('u') if ctrl => self.scroll_rows(-half),
KeyCode::Right | KeyCode::Char('l') => {
self.scroll_names(NAME_SCROLL_STEP);
}
KeyCode::Left | KeyCode::Char('h') => {
self.scroll_names(-NAME_SCROLL_STEP);
}
KeyCode::Char('0') => self.state.results_name_offset = 0,
KeyCode::Char('$') => {
self.state.results_name_offset = self.highlighted_name_end();
}
KeyCode::Char(' ') => { KeyCode::Char(' ') => {
if let Some(current) = self.state.results_table_state.selected() let index = self
.state
.results_table_state
.selected()
.and_then(|row| self.original_index(row));
if let Some(index) = index
&& !self.state.results_selected_indices.remove(&index)
{ {
let actual_index = self.state.results_selected_indices.insert(index);
if self.state.results_filter_query.is_empty() {
current
} else {
self.state.results_filtered_indices[current]
};
if self
.state
.results_selected_indices
.contains(&actual_index)
{
self.state
.results_selected_indices
.remove(&actual_index);
} else {
self.state
.results_selected_indices
.insert(actual_index);
}
} }
} }
KeyCode::Char('/') => { KeyCode::Char('/') => {
@@ -411,6 +418,42 @@ impl MainTui {
} }
} }
fn select_row(&mut self, target: usize) {
let len = self.visible_results().len();
if len > 0 {
self.state
.results_table_state
.select(Some(target.min(len - 1)));
}
}
fn scroll_rows(&mut self, delta: isize) {
let current = self.state.results_table_state.selected().unwrap_or(0);
self.select_row(current.saturating_add_signed(delta));
}
fn scroll_names(&mut self, delta: isize) {
let end = self.highlighted_name_end();
let current = self.state.results_name_offset.min(end);
self.state.results_name_offset =
current.saturating_add_signed(delta).min(end);
}
fn highlighted_name_end(&self) -> usize {
let Some(area) = self.state.results_pane_area else {
return 0;
};
self.highlighted_result()
.map_or(0, |file| name_end_offset(&file.filename, area))
}
fn results_page_size(&self) -> isize {
let rows = self.state.results_pane_area.map_or(1, |area| {
pane_block(false).inner(area).height.saturating_sub(1)
});
rows.max(1) as isize
}
fn handle_downloads_input(&mut self, key: KeyEvent) { fn handle_downloads_input(&mut self, key: KeyEvent) {
// The pane lists downloads first, then uploads; navigation spans both. // The pane lists downloads first, then uploads; navigation spans both.
let rows = self.state.downloads.len() + self.state.uploads.len(); let rows = self.state.downloads.len() + self.state.uploads.len();
+221
View File
@@ -1034,4 +1034,225 @@ mod tests {
assert!(downloads.is_dir(), "the folder is created here"); assert!(downloads.is_dir(), "the folder is created here");
assert_eq!(tui.download_dir, downloads_dir); assert_eq!(tui.download_dir, downloads_dir);
} }
fn results(n: usize) -> Vec<crate::models::FileDisplayData> {
(0..n)
.map(|i| crate::models::FileDisplayData {
filename: format!("@@x\\Music\\{i:02}.mp3"),
username: "bob".to_string(),
..Default::default()
})
.collect()
}
fn results_tui(n: usize) -> MainTui {
let mut tui = with_session(TalkativeSession::default());
tui.state.results_items = results(n);
tui.state.focused_pane = FocusedPane::Results;
tui.state.results_pane_area =
Some(ratatui::layout::Rect::new(0, 0, 80, 13));
tui
}
fn press(tui: &mut MainTui, code: KeyCode) {
tui.handle_key_event(KeyEvent::new(code, KeyModifiers::NONE));
}
fn ctrl(tui: &mut MainTui, ch: char) {
tui.handle_key_event(KeyEvent::new(
KeyCode::Char(ch),
KeyModifiers::CONTROL,
));
}
fn selected(tui: &MainTui) -> Option<usize> {
tui.state.results_table_state.selected()
}
#[test]
fn end_and_home_jump_to_the_last_and_first_result() {
let mut tui = results_tui(30);
press(&mut tui, KeyCode::End);
assert_eq!(selected(&tui), Some(29));
press(&mut tui, KeyCode::Home);
assert_eq!(selected(&tui), Some(0));
press(&mut tui, KeyCode::Char('G'));
assert_eq!(selected(&tui), Some(29));
press(&mut tui, KeyCode::Char('g'));
assert_eq!(selected(&tui), Some(0));
}
#[test]
fn page_keys_move_a_pane_of_rows_and_stop_at_the_edges() {
let mut tui = results_tui(30);
press(&mut tui, KeyCode::PageDown);
assert_eq!(selected(&tui), Some(10));
ctrl(&mut tui, 'f');
assert_eq!(selected(&tui), Some(20));
press(&mut tui, KeyCode::PageDown);
assert_eq!(selected(&tui), Some(29), "clamps instead of wrapping");
press(&mut tui, KeyCode::PageUp);
assert_eq!(selected(&tui), Some(19));
ctrl(&mut tui, 'b');
assert_eq!(selected(&tui), Some(9));
press(&mut tui, KeyCode::PageUp);
assert_eq!(selected(&tui), Some(0));
assert!(
!tui.state.show_browse && !tui.state.command_bar_active,
"ctrl-b pages up instead of opening browse"
);
}
#[test]
fn ctrl_d_and_ctrl_u_move_half_a_pane() {
let mut tui = results_tui(30);
ctrl(&mut tui, 'd');
assert_eq!(selected(&tui), Some(5));
ctrl(&mut tui, 'u');
assert_eq!(selected(&tui), Some(0));
}
#[test]
fn navigation_keys_do_nothing_without_results() {
let mut tui = results_tui(0);
tui.state.results_table_state.select(None);
press(&mut tui, KeyCode::End);
press(&mut tui, KeyCode::PageDown);
ctrl(&mut tui, 'd');
assert_eq!(selected(&tui), None);
}
#[test]
fn l_and_h_scroll_the_highlighted_name_up_to_its_end() {
let mut tui = results_tui(3);
tui.state.results_items[1].filename = "x".repeat(40);
tui.state.results_table_state.select(Some(1));
press(&mut tui, KeyCode::Char('l'));
assert_eq!(tui.state.results_name_offset, 8);
press(&mut tui, KeyCode::Right);
assert_eq!(tui.state.results_name_offset, 16);
press(&mut tui, KeyCode::Char('$'));
assert_eq!(tui.state.results_name_offset, 30);
press(&mut tui, KeyCode::Char('l'));
assert_eq!(tui.state.results_name_offset, 30, "stops at the end");
press(&mut tui, KeyCode::Char('h'));
assert_eq!(tui.state.results_name_offset, 22);
press(&mut tui, KeyCode::Left);
assert_eq!(tui.state.results_name_offset, 14);
press(&mut tui, KeyCode::Char('0'));
assert_eq!(tui.state.results_name_offset, 0);
press(&mut tui, KeyCode::Char('h'));
assert_eq!(tui.state.results_name_offset, 0);
}
#[test]
fn scrolling_follows_the_highlighted_row_not_the_longest_one() {
let mut tui = results_tui(3);
tui.state.results_items[0].filename = "short.mp3".to_string();
tui.state.results_items[1].filename = "x".repeat(40);
tui.state.results_items[2].filename = "short.mp3".to_string();
press(&mut tui, KeyCode::Char('$'));
assert_eq!(tui.state.results_name_offset, 0, "row 0 fits already");
tui.state.results_table_state.select(Some(1));
press(&mut tui, KeyCode::Char('$'));
assert_eq!(tui.state.results_name_offset, 30);
tui.state.results_table_state.select(Some(2));
press(&mut tui, KeyCode::Char('h'));
assert_eq!(
tui.state.results_name_offset, 0,
"a step left lands within the row now highlighted"
);
}
#[test]
fn picking_another_search_starts_its_results_unscrolled() {
let mut tui = results_tui(3);
tui.state.results_items[1].filename = "x".repeat(40);
tui.state.results_table_state.select(Some(1));
press(&mut tui, KeyCode::Char('$'));
assert_eq!(tui.state.results_name_offset, 30);
tui.state.searches.push(crate::models::SearchEntry {
query: "other".to_string(),
status: SearchStatus::Active,
results: results(2),
known_files: 2,
owned: true,
start_time: std::time::Instant::now(),
cancel_flag: Arc::new(std::sync::atomic::AtomicBool::new(false)),
});
tui.state.searches_table_state.select(Some(0));
tui.state.focused_pane = FocusedPane::Searches;
press(&mut tui, KeyCode::Enter);
assert_eq!(tui.state.results_name_offset, 0);
}
#[test]
fn ctrl_keys_are_ignored_outside_the_results_pane() {
let mut tui = results_tui(3);
tui.state.searches.push(crate::models::SearchEntry {
query: "keep me".to_string(),
status: SearchStatus::Active,
results: Vec::new(),
known_files: 0,
owned: true,
start_time: std::time::Instant::now(),
cancel_flag: Arc::new(std::sync::atomic::AtomicBool::new(false)),
});
tui.state.searches_table_state.select(Some(0));
tui.state.focused_pane = FocusedPane::Searches;
ctrl(&mut tui, 'd');
assert_eq!(tui.state.searches.len(), 1, "ctrl-d is not d");
ctrl(&mut tui, 'b');
assert!(!tui.state.command_bar_active, "ctrl-b is not b");
ctrl(&mut tui, 'q');
assert!(!tui.state.should_exit, "ctrl-q is not q");
}
#[test]
fn ctrl_keys_page_while_a_filter_is_being_typed() {
let mut tui = results_tui(30);
press(&mut tui, KeyCode::Char('/'));
press(&mut tui, KeyCode::Char('m'));
ctrl(&mut tui, 'd');
assert_eq!(selected(&tui), Some(5));
assert_eq!(tui.state.results_filter_query, "m", "no d appended");
press(&mut tui, KeyCode::End);
assert_eq!(selected(&tui), Some(29));
}
#[test]
fn space_after_a_filter_that_matches_nothing_does_not_panic() {
let mut tui = results_tui(3);
press(&mut tui, KeyCode::Char('/'));
press(&mut tui, KeyCode::Char('z'));
press(&mut tui, KeyCode::Enter);
assert!(tui.state.results_filtered_items.is_empty());
press(&mut tui, KeyCode::Char(' '));
assert!(tui.state.results_selected_indices.is_empty());
}
fn screen_of(tui: &mut MainTui) -> String {
let backend = ratatui::backend::TestBackend::new(160, 40);
let mut terminal = ratatui::Terminal::new(backend).expect("backend");
terminal.draw(|frame| tui.render(frame)).expect("draw");
terminal.backend().to_string()
}
#[test]
fn the_info_pane_describes_the_highlighted_result() {
let mut tui = results_tui(3);
tui.state.results_items[1].filename =
"@@x\\Music\\Album\\Second Track.mp3".to_string();
tui.state.results_items[1].bitrate = Some(320);
tui.state.results_table_state.select(Some(1));
let screen = screen_of(&mut tui);
assert!(screen.contains("@@x/Music/Album"), "{screen}");
assert!(screen.contains("Bitrate"), "{screen}");
tui.state.focused_pane = FocusedPane::Downloads;
let screen = screen_of(&mut tui);
assert!(!screen.contains("@@x/Music/Album"), "{screen}");
}
} }
+18 -8
View File
@@ -1,7 +1,7 @@
use super::MainTui; use super::MainTui;
use crate::models::{CommandBarMode, FocusedPane, RoomsView}; use crate::models::{CommandBarMode, FocusedPane, RoomsView};
use crate::ui::panes::{ use crate::ui::panes::{
ResultsPaneParams, render_browse_pane, render_chat_pane, InfoSubject, ResultsPaneParams, render_browse_pane, render_chat_pane,
render_download_info_pane, render_downloads_pane, render_results_pane, render_download_info_pane, render_downloads_pane, render_results_pane,
render_rooms_pane, render_searches_pane, selected_transfer, render_rooms_pane, render_searches_pane, selected_transfer,
}; };
@@ -120,6 +120,7 @@ impl MainTui {
is_filtering: self.state.results_is_filtering, is_filtering: self.state.results_is_filtering,
focused: self.state.focused_pane == FocusedPane::Results, focused: self.state.focused_pane == FocusedPane::Results,
active_search_query, active_search_query,
name_offset: self.state.results_name_offset,
}, },
); );
@@ -132,16 +133,23 @@ impl MainTui {
self.state.focused_pane == FocusedPane::Downloads, self.state.focused_pane == FocusedPane::Downloads,
); );
let selected_transfer = selected_transfer( let selected = if self.state.focused_pane == FocusedPane::Results {
self.state.downloads_table_state.selected(), self.highlighted_result().map(InfoSubject::Result)
&self.state.downloads, } else {
&self.state.uploads, selected_transfer(
); self.state.downloads_table_state.selected(),
&self.state.downloads,
&self.state.uploads,
)
};
render_download_info_pane( render_download_info_pane(
frame, frame,
downloads_chunks[1], downloads_chunks[1],
selected_transfer, selected,
self.state.focused_pane == FocusedPane::Downloads, matches!(
self.state.focused_pane,
FocusedPane::Results | FocusedPane::Downloads
),
); );
} }
@@ -386,6 +394,8 @@ impl MainTui {
("c", "chat"), ("c", "chat"),
("/", "filter"), ("/", "filter"),
("a/A", "select all/none"), ("a/A", "select all/none"),
("h/l", "scroll"),
("g/G", "top/end"),
("1-3", "focus pane"), ("1-3", "focus pane"),
("q", "quit"), ("q", "quit"),
], ],
+23
View File
@@ -100,6 +100,28 @@ impl MainTui {
self.state.results_table_state.select(None); self.state.results_table_state.select(None);
self.state.results_filter_query.clear(); self.state.results_filter_query.clear();
self.state.results_is_filtering = false; self.state.results_is_filtering = false;
self.state.results_name_offset = 0;
}
pub(super) fn visible_results(&self) -> &[FileDisplayData] {
if self.state.results_filter_query.is_empty() {
&self.state.results_items
} else {
&self.state.results_filtered_items
}
}
pub(super) fn highlighted_result(&self) -> Option<&FileDisplayData> {
self.visible_results()
.get(self.state.results_table_state.selected()?)
}
pub(super) fn original_index(&self, row: usize) -> Option<usize> {
if self.state.results_filter_query.is_empty() {
Some(row)
} else {
self.state.results_filtered_indices.get(row).copied()
}
} }
pub(super) fn apply_filter(&mut self) { pub(super) fn apply_filter(&mut self) {
@@ -207,6 +229,7 @@ impl MainTui {
self.state.results_filtered_indices.clear(); self.state.results_filtered_indices.clear();
self.state.results_selected_indices.clear(); self.state.results_selected_indices.clear();
self.state.results_table_state.select(Some(0)); self.state.results_table_state.select(Some(0));
self.state.results_name_offset = 0;
// Switch focus to Results pane // Switch focus to Results pane
self.state.focused_pane = FocusedPane::Results; self.state.focused_pane = FocusedPane::Results;
+95 -43
View File
@@ -1,4 +1,4 @@
use crate::models::DownloadEntry; use crate::models::{DownloadEntry, FileDisplayData};
use crate::ui::{ use crate::ui::{
accent_style, dimmed_style, error_style, format_bytes, format_progress_bar, accent_style, dimmed_style, error_style, format_bytes, format_progress_bar,
format_speed, inactive_style, info_style, pane_block, pane_title, 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; const LABEL_WIDTH: usize = 20;
/// Whichever transfer the table has highlighted. Rows are downloads first and pub enum InfoSubject<'a> {
/// uploads after, so a raw index has to be resolved against both lists.
pub enum SelectedTransfer<'a> {
Download(&'a DownloadEntry), Download(&'a DownloadEntry),
Upload(&'a UploadInfo), Upload(&'a UploadInfo),
Result(&'a FileDisplayData),
} }
/// Resolve the transfers table's selected row index into the download or /// Resolve the transfers table's selected row index into the download or
@@ -31,28 +30,28 @@ pub fn selected_transfer<'a>(
index: Option<usize>, index: Option<usize>,
downloads: &'a [DownloadEntry], downloads: &'a [DownloadEntry],
uploads: &'a [UploadInfo], uploads: &'a [UploadInfo],
) -> Option<SelectedTransfer<'a>> { ) -> Option<InfoSubject<'a>> {
let index = index?; let index = index?;
if let Some(download) = downloads.get(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. // Past the downloads, so this row is an upload.
uploads uploads
.get(index - downloads.len()) .get(index - downloads.len())
.map(SelectedTransfer::Upload) .map(InfoSubject::Upload)
} }
pub fn render_download_info_pane( pub fn render_download_info_pane(
frame: &mut Frame, frame: &mut Frame,
area: Rect, area: Rect,
selected: Option<SelectedTransfer>, selected: Option<InfoSubject>,
focused: bool, focused: bool,
) { ) {
let block = pane_block(focused).title(pane_title("Info", "", focused)); let block = pane_block(focused).title(pane_title("Info", "", focused));
let Some(selected) = selected else { let Some(selected) = selected else {
let paragraph = Paragraph::new(Line::from(Span::styled( let paragraph = Paragraph::new(Line::from(Span::styled(
"Select a transfer for details.", "Select a result or transfer for details.",
dimmed_style(), dimmed_style(),
))) )))
.block(block); .block(block);
@@ -61,8 +60,9 @@ pub fn render_download_info_pane(
}; };
let lines = match selected { let lines = match selected {
SelectedTransfer::Download(entry) => build_info_lines(&entry.download), InfoSubject::Download(entry) => build_info_lines(&entry.download),
SelectedTransfer::Upload(upload) => build_upload_info_lines(upload), InfoSubject::Upload(upload) => build_upload_info_lines(upload),
InfoSubject::Result(file) => build_result_info_lines(file),
}; };
let paragraph = Paragraph::new(lines) 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. /// view so the pane reads the same whichever direction is selected.
fn build_upload_info_lines(upload: &UploadInfo) -> Vec<Line<'static>> { fn build_upload_info_lines(upload: &UploadInfo) -> Vec<Line<'static>> {
let mut lines: Vec<Line<'static>> = Vec::new(); let mut lines: Vec<Line<'static>> = Vec::new();
push_file_header(&mut lines, &upload.filename, primary_style());
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(""));
// The pane is shared with downloads, so name the direction explicitly. // The pane is shared with downloads, so name the direction explicitly.
lines.push(label_value("Direction", "↑ Upload")); lines.push(label_value("Direction", "↑ Upload"));
@@ -143,21 +134,75 @@ fn build_upload_info_lines(upload: &UploadInfo) -> Vec<Line<'static>> {
lines lines
} }
fn build_info_lines( fn build_result_info_lines(file: &FileDisplayData) -> Vec<Line<'static>> {
download: &soulseek_rs::types::Download,
) -> Vec<Line<'static>> {
let mut lines: Vec<Line<'static>> = Vec::new(); 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( lines.push(Line::from(Span::styled(
basename, basename,
accent_style().add_modifier(ratatui::style::Modifier::BOLD), style.add_modifier(ratatui::style::Modifier::BOLD),
))); )));
if !parent_dir.is_empty() { if !parent_dir.is_empty() {
lines.push(Line::from(Span::styled(parent_dir, dimmed_style()))); lines.push(Line::from(Span::styled(parent_dir, dimmed_style())));
} }
lines.push(Line::from("")); 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( lines.push(label_value_styled(
"User", "User",
@@ -186,13 +231,11 @@ fn build_info_lines(
.to_string_lossy() .to_string_lossy()
.to_string(); .to_string();
lines.push(label_value_styled("Save to", save_path, info_style())); lines.push(label_value_styled("Save to", save_path, info_style()));
push_media_attributes(
if let Some(bitrate) = download.metadata.bitrate { &mut lines,
lines.push(label_value("Bitrate", &format!("{bitrate} kbps"))); download.metadata.bitrate,
} download.metadata.length_seconds,
if let Some(length) = download.metadata.length_seconds { );
lines.push(label_value("Length", &format_duration(length)));
}
match &download.status { match &download.status {
DownloadStatus::Queued => { DownloadStatus::Queued => {
@@ -204,16 +247,7 @@ fn build_info_lines(
lines.push(label_value("Queue position", &position_text)); lines.push(label_value("Queue position", &position_text));
if let Some(slots) = download.metadata.peer_free_slots { if let Some(slots) = download.metadata.peer_free_slots {
let (slot_text, slot_style) = if slots > 0 { lines.push(free_slots_line(slots));
(format!("{slots} available"), success_style())
} else {
("all busy".to_string(), inactive_style())
};
lines.push(label_value_styled(
"Free slots",
slot_text,
slot_style,
));
} }
if let Some(speed) = download.metadata.peer_upload_speed { if let Some(speed) = download.metadata.peer_upload_speed {
@@ -417,6 +451,24 @@ mod tests {
.join("\n") .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] #[test]
fn failed_download_renders_its_error_reason() { fn failed_download_renders_its_error_reason() {
let download = download_with_status(DownloadStatus::Failed(Some( 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 browse_pane::render_browse_pane;
pub use chat_pane::render_chat_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 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 rooms_pane::render_rooms_pane;
pub use searches_pane::render_searches_pane; pub use searches_pane::render_searches_pane;
+123 -14
View File
@@ -6,14 +6,25 @@ use crate::ui::{
}; };
use ratatui::{ use ratatui::{
Frame, Frame,
layout::Rect, layout::{Constraint, Layout, Rect},
text::{Line, Span}, text::{Line, Span},
widgets::{Cell, HighlightSpacing, Paragraph, Row, Table, TableState}, widgets::{Cell, HighlightSpacing, Paragraph, Row, Table, TableState},
}; };
use std::collections::HashSet; use std::collections::HashSet;
use unicode_truncate::UnicodeTruncateStr;
const VERSION: &str = env!("CARGO_PKG_VERSION"); 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 struct ResultsPaneParams<'a> {
pub items: &'a [FileDisplayData], pub items: &'a [FileDisplayData],
pub table_state: &'a mut TableState, pub table_state: &'a mut TableState,
@@ -25,6 +36,38 @@ pub struct ResultsPaneParams<'a> {
pub is_filtering: bool, pub is_filtering: bool,
pub focused: bool, pub focused: bool,
pub active_search_query: Option<&'a str>, 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 /// Whether the rendered row `display_idx` is selected. `selected_indices` holds
@@ -59,6 +102,7 @@ pub fn render_results_pane(
is_filtering, is_filtering,
focused, focused,
active_search_query, active_search_query,
name_offset,
} = params; } = params;
if items.is_empty() { if items.is_empty() {
let title = match active_search_query { let title = match active_search_query {
@@ -105,6 +149,7 @@ pub fn render_results_pane(
]) ])
.height(1); .height(1);
let name_width = name_column_width(area);
let rows: Vec<Row> = items let rows: Vec<Row> = items
.iter() .iter()
.enumerate() .enumerate()
@@ -137,7 +182,12 @@ pub fn render_results_pane(
Row::new(vec![ Row::new(vec![
Cell::from(checkbox).style(checkbox_style), 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(format_bytes(file.size)).style(warning_style()),
Cell::from(file.username.clone()).style(info_style()), Cell::from(file.username.clone()).style(info_style()),
Cell::from(bitrate_str).style(dimmed_style()), Cell::from(bitrate_str).style(dimmed_style()),
@@ -147,16 +197,6 @@ pub fn render_results_pane(
}) })
.collect(); .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 { let title = if is_filtering {
format!("Results · filter: '{filter_query}'") format!("Results · filter: '{filter_query}'")
} else if let Some(query) = active_search_query { } else if let Some(query) = active_search_query {
@@ -165,7 +205,7 @@ pub fn render_results_pane(
"Results".to_string() "Results".to_string()
}; };
let table = Table::new(rows, widths) let table = Table::new(rows, WIDTHS)
.header(header) .header(header)
.row_highlight_style(row_highlight_style()) .row_highlight_style(row_highlight_style())
.highlight_symbol(HIGHLIGHT_SYMBOL) .highlight_symbol(HIGHLIGHT_SYMBOL)
@@ -177,9 +217,78 @@ pub fn render_results_pane(
#[cfg(test)] #[cfg(test)]
mod tests { 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; 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] #[test]
fn identity_mapping_used_when_no_filter() { fn identity_mapping_used_when_no_filter() {
let selected: HashSet<usize> = std::iter::once(1).collect(); let selected: HashSet<usize> = std::iter::once(1).collect();
+2 -2
View File
@@ -48,7 +48,7 @@ const PANES: readonly { num?: number; title: string; body: string }[] = [
{ {
num: 2, num: 2,
title: 'Results', title: 'Results',
body: 'Files from the network: size, user, bitrate, speed, free slots. Space selects, Enter queues a download.', body: 'Files from the network: size, user, bitrate, speed, free slots. Space selects, Enter queues a download. h/l or ←/→ scroll a long name, 0 and $ jump to either end; Home/End, g/G, PgUp/PgDn and Ctrl-f/b/d/u move through the list.',
}, },
{ {
num: 3, num: 3,
@@ -57,7 +57,7 @@ const PANES: readonly { num?: number; title: string; body: string }[] = [
}, },
{ {
title: 'Info', title: 'Info',
body: 'Everything about the selected file: user, size, path, bitrate, length, queue position, free slots.', body: 'Everything about the highlighted result or transfer: user, size, full path, bitrate, length, queue position, free slots.',
}, },
] ]