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.', }, ]