From 3193c73f5bf09bb763a29c1e8d7a8ac1a04c499c Mon Sep 17 00:00:00 2001 From: Michel de Graaf Date: Sun, 6 Sep 2026 20:10:59 +0200 Subject: [PATCH 1/2] 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. --- Cargo.lock | 1 + soulseek-rs/Cargo.toml | 1 + soulseek-rs/src/models/app_state.rs | 2 + soulseek-rs/src/ui/main_tui/browse.rs | 8 +- soulseek-rs/src/ui/main_tui/input.rs | 125 ++++++---- soulseek-rs/src/ui/main_tui/mod.rs | 221 ++++++++++++++++++ soulseek-rs/src/ui/main_tui/render.rs | 26 ++- soulseek-rs/src/ui/main_tui/search.rs | 23 ++ .../src/ui/panes/download_info_pane.rs | 138 +++++++---- soulseek-rs/src/ui/panes/mod.rs | 8 +- soulseek-rs/src/ui/panes/results_pane.rs | 137 +++++++++-- website/src/pages/docs.tsx | 4 +- 12 files changed, 577 insertions(+), 117 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 882d003..426dbe4 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2564,6 +2564,7 @@ dependencies = [ "soulseek-rs-lib", "tempfile", "toml", + "unicode-truncate", ] [[package]] diff --git a/soulseek-rs/Cargo.toml b/soulseek-rs/Cargo.toml index d37be34..4821441 100644 --- a/soulseek-rs/Cargo.toml +++ b/soulseek-rs/Cargo.toml @@ -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_complete = "4.6.7" ratatui = "0.30.2" +unicode-truncate = "2" color-eyre = "0.6.5" dotenv = "0.15.0" igd-next = "0.17" diff --git a/soulseek-rs/src/models/app_state.rs b/soulseek-rs/src/models/app_state.rs index 295bab7..8cf44f2 100644 --- a/soulseek-rs/src/models/app_state.rs +++ b/soulseek-rs/src/models/app_state.rs @@ -79,6 +79,7 @@ pub struct AppState { pub results_selected_indices: std::collections::HashSet, pub results_filter_query: String, pub results_is_filtering: bool, + pub results_name_offset: usize, // Downloads pub downloads: Vec, @@ -154,6 +155,7 @@ impl AppState { results_selected_indices: std::collections::HashSet::new(), results_filter_query: String::new(), results_is_filtering: false, + results_name_offset: 0, downloads: Vec::new(), downloads_table_state, diff --git a/soulseek-rs/src/ui/main_tui/browse.rs b/soulseek-rs/src/ui/main_tui/browse.rs index 8cf5650..c3cbb23 100644 --- a/soulseek-rs/src/ui/main_tui/browse.rs +++ b/soulseek-rs/src/ui/main_tui/browse.rs @@ -192,13 +192,7 @@ impl MainTui { /// The username of the highlighted search result (filter-aware). pub(super) fn highlighted_result_owner(&self) -> Option { - let selected = self.state.results_table_state.selected()?; - 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()) + self.highlighted_result().map(|f| f.username.clone()) } /// Drain browse responses into any loading tabs, or time them out. diff --git a/soulseek-rs/src/ui/main_tui/input.rs b/soulseek-rs/src/ui/main_tui/input.rs index 4c320b4..394e54f 100644 --- a/soulseek-rs/src/ui/main_tui/input.rs +++ b/soulseek-rs/src/ui/main_tui/input.rs @@ -1,11 +1,15 @@ use super::MainTui; use crate::models::{CommandBarMode, FocusedPane}; +use crate::ui::pane_block; +use crate::ui::panes::name_end_offset; use ratatui::crossterm::event::{ KeyCode, KeyEvent, KeyModifiers, MouseButton, MouseEvent, MouseEventKind, }; use ratatui::layout::Position; use ratatui::widgets::TableState; +const NAME_SCROLL_STEP: isize = 8; + impl MainTui { pub(super) fn handle_key_event(&mut self, key: KeyEvent) { // Command bar takes priority @@ -29,6 +33,13 @@ impl MainTui { 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 if self.state.results_is_filtering && self.state.focused_pane == FocusedPane::Results @@ -291,21 +302,7 @@ impl MainTui { KeyCode::Enter => { self.state.results_is_filtering = false; } - KeyCode::Up => { - 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, - ); - } - _ => {} + _ => self.handle_results_input(key), } } @@ -330,6 +327,7 @@ impl MainTui { self.state.results_filtered_indices = (0..search.results.len()).collect(); self.state.results_selected_indices.clear(); + self.state.results_name_offset = 0; self.state.results_table_state.select(Some(0)); self.state.focused_pane = FocusedPane::Results; } @@ -350,11 +348,13 @@ impl MainTui { } fn handle_results_input(&mut self, key: KeyEvent) { - let items_count = if self.state.results_filter_query.is_empty() { - self.state.results_items.len() - } else { - self.state.results_filtered_items.len() - }; + let ctrl = key.modifiers.contains(KeyModifiers::CONTROL); + if ctrl && !matches!(key.code, KeyCode::Char('f' | 'b' | 'd' | 'u')) { + return; + } + let items_count = self.visible_results().len(); + let page = self.results_page_size(); + let half = (page / 2).max(1); match key.code { KeyCode::Up | KeyCode::Char('k') => { @@ -363,29 +363,36 @@ impl MainTui { KeyCode::Down | KeyCode::Char('j') => { 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(' ') => { - 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 = - 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); - } + self.state.results_selected_indices.insert(index); } } 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) { // The pane lists downloads first, then uploads; navigation spans both. let rows = self.state.downloads.len() + self.state.uploads.len(); diff --git a/soulseek-rs/src/ui/main_tui/mod.rs b/soulseek-rs/src/ui/main_tui/mod.rs index 61ccb06..6c114cf 100644 --- a/soulseek-rs/src/ui/main_tui/mod.rs +++ b/soulseek-rs/src/ui/main_tui/mod.rs @@ -1034,4 +1034,225 @@ mod tests { assert!(downloads.is_dir(), "the folder is created here"); assert_eq!(tui.download_dir, downloads_dir); } + + fn results(n: usize) -> Vec { + (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 { + 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}"); + } } diff --git a/soulseek-rs/src/ui/main_tui/render.rs b/soulseek-rs/src/ui/main_tui/render.rs index 9e7c42e..4b2ced1 100644 --- a/soulseek-rs/src/ui/main_tui/render.rs +++ b/soulseek-rs/src/ui/main_tui/render.rs @@ -1,7 +1,7 @@ use super::MainTui; use crate::models::{CommandBarMode, FocusedPane, RoomsView}; 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_rooms_pane, render_searches_pane, selected_transfer, }; @@ -120,6 +120,7 @@ impl MainTui { is_filtering: self.state.results_is_filtering, focused: self.state.focused_pane == FocusedPane::Results, active_search_query, + name_offset: self.state.results_name_offset, }, ); @@ -132,16 +133,23 @@ impl MainTui { self.state.focused_pane == FocusedPane::Downloads, ); - let selected_transfer = selected_transfer( - self.state.downloads_table_state.selected(), - &self.state.downloads, - &self.state.uploads, - ); + let selected = if self.state.focused_pane == FocusedPane::Results { + self.highlighted_result().map(InfoSubject::Result) + } else { + selected_transfer( + self.state.downloads_table_state.selected(), + &self.state.downloads, + &self.state.uploads, + ) + }; render_download_info_pane( frame, downloads_chunks[1], - selected_transfer, - self.state.focused_pane == FocusedPane::Downloads, + selected, + matches!( + self.state.focused_pane, + FocusedPane::Results | FocusedPane::Downloads + ), ); } @@ -386,6 +394,8 @@ impl MainTui { ("c", "chat"), ("/", "filter"), ("a/A", "select all/none"), + ("h/l", "scroll"), + ("g/G", "top/end"), ("1-3", "focus pane"), ("q", "quit"), ], diff --git a/soulseek-rs/src/ui/main_tui/search.rs b/soulseek-rs/src/ui/main_tui/search.rs index ca53f74..bea4d0f 100644 --- a/soulseek-rs/src/ui/main_tui/search.rs +++ b/soulseek-rs/src/ui/main_tui/search.rs @@ -100,6 +100,28 @@ impl MainTui { self.state.results_table_state.select(None); self.state.results_filter_query.clear(); 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 { + 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) { @@ -207,6 +229,7 @@ impl MainTui { self.state.results_filtered_indices.clear(); self.state.results_selected_indices.clear(); self.state.results_table_state.select(Some(0)); + self.state.results_name_offset = 0; // Switch focus to Results pane self.state.focused_pane = FocusedPane::Results; diff --git a/soulseek-rs/src/ui/panes/download_info_pane.rs b/soulseek-rs/src/ui/panes/download_info_pane.rs index 408a9f1..5b176c0 100644 --- a/soulseek-rs/src/ui/panes/download_info_pane.rs +++ b/soulseek-rs/src/ui/panes/download_info_pane.rs @@ -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, downloads: &'a [DownloadEntry], uploads: &'a [UploadInfo], -) -> Option> { +) -> Option> { 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, + selected: Option, 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> { let mut lines: Vec> = 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> { lines } -fn build_info_lines( - download: &soulseek_rs::types::Download, -) -> Vec> { +fn build_result_info_lines(file: &FileDisplayData) -> Vec> { let mut lines: Vec> = 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>, + 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>, + bitrate: Option, + length_seconds: Option, +) { + 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> { + let mut lines: Vec> = 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( diff --git a/soulseek-rs/src/ui/panes/mod.rs b/soulseek-rs/src/ui/panes/mod.rs index 32af3e8..cc31cd9 100644 --- a/soulseek-rs/src/ui/panes/mod.rs +++ b/soulseek-rs/src/ui/panes/mod.rs @@ -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; diff --git a/soulseek-rs/src/ui/panes/results_pane.rs b/soulseek-rs/src/ui/panes/results_pane.rs index fc6540a..37915ad 100644 --- a/soulseek-rs/src/ui/panes/results_pane.rs +++ b/soulseek-rs/src/ui/panes/results_pane.rs @@ -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 = 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 = std::iter::once(1).collect(); diff --git a/website/src/pages/docs.tsx b/website/src/pages/docs.tsx index a81aa33..a48b54e 100644 --- a/website/src/pages/docs.tsx +++ b/website/src/pages/docs.tsx @@ -48,7 +48,7 @@ const PANES: readonly { num?: number; title: string; body: string }[] = [ { num: 2, 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, @@ -57,7 +57,7 @@ const PANES: readonly { num?: number; title: string; body: string }[] = [ }, { 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.', }, ] From 06bc48fcd91acebcdaf2adf1e311f1023a3ee08f Mon Sep 17 00:00:00 2001 From: Michel de Graaf Date: Sun, 6 Sep 2026 21:42:36 +0200 Subject: [PATCH 2/2] perf(tui): bound the name scroll walk by the column width Scrolled rows cost 31.7ms per frame at 30k results against 13.3ms on develop, because every row segmented its whole filename into grapheme clusters. Walk back from the end for at most one column of cells and forward for at most the offset, so a row costs the column width rather than the filename, and drop unicode-truncate for unicode-width. A tail never starts on a combining mark. --- Cargo.lock | 2 +- soulseek-rs/Cargo.toml | 2 +- soulseek-rs/src/ui/panes/results_pane.rs | 45 ++++++++++++++++++++---- 3 files changed, 41 insertions(+), 8 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 426dbe4..3ab3e88 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2564,7 +2564,7 @@ dependencies = [ "soulseek-rs-lib", "tempfile", "toml", - "unicode-truncate", + "unicode-width", ] [[package]] diff --git a/soulseek-rs/Cargo.toml b/soulseek-rs/Cargo.toml index 4821441..d04ccc3 100644 --- a/soulseek-rs/Cargo.toml +++ b/soulseek-rs/Cargo.toml @@ -30,7 +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_complete = "4.6.7" ratatui = "0.30.2" -unicode-truncate = "2" +unicode-width = "0.2" color-eyre = "0.6.5" dotenv = "0.15.0" igd-next = "0.17" diff --git a/soulseek-rs/src/ui/panes/results_pane.rs b/soulseek-rs/src/ui/panes/results_pane.rs index 37915ad..1634047 100644 --- a/soulseek-rs/src/ui/panes/results_pane.rs +++ b/soulseek-rs/src/ui/panes/results_pane.rs @@ -11,7 +11,7 @@ use ratatui::{ widgets::{Cell, HighlightSpacing, Paragraph, Row, Table, TableState}, }; use std::collections::HashSet; -use unicode_truncate::UnicodeTruncateStr; +use unicode_width::UnicodeWidthChar; const VERSION: &str = env!("CARGO_PKG_VERSION"); @@ -61,13 +61,37 @@ 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 { + 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 (tail, _) = name.unicode_truncate_start(cells - skip); - Line::from(vec![Span::raw("…"), Span::raw(tail)]) + 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..])]) } /// Whether the rendered row `display_idx` is selected. `selected_indices` holds @@ -281,6 +305,15 @@ mod tests { assert!(!screen.contains("亿東"), "{screen}"); } + #[test] + fn a_scrolled_name_never_starts_with_a_combining_mark() { + let name = format!("{}e\u{301}{}", "x".repeat(5), "y".repeat(20)); + let items = [file(&name)]; + let screen = render_rows(&items, 6); + assert!(screen.contains("…yyyyyyyyyy"), "{screen}"); + assert!(!screen.contains("…\u{301}"), "{screen}"); + } + #[test] fn an_unscrolled_name_starts_at_its_beginning() { let items = [file("abcdefghijklmnopqrstuvwxyz0123456789ABCD")];