diff --git a/README.md b/README.md index 0dd16d2..787ef84 100644 --- a/README.md +++ b/README.md @@ -92,13 +92,13 @@ Everything else is a one-shot command that runs headless. ### The TUI -Results run across the top, with the whole width for long file names; -Searches, Downloads and Info share the row underneath. `Tab`/`Shift-Tab` or -`1`–`3` move the focus between panes, `z` zooms the focused pane to the whole -window, and `w` hides it so the others widen (its number brings it back). -Every list takes `↑`/`↓`, `Home`/`End`, `PgUp`/`PgDn` and the vim keys, and -`h`/`l` or `←`/`→` scroll a long file name or query sideways, `0` and `$` to -either end. `PgUp`/`PgDn` scroll a chat log back through its history, and `?` +Results run across the top, each file name in a column of its own with the +folder it came from beside it; Searches, Downloads and Info share the row +underneath. `Tab`/`Shift-Tab` or `1`–`3` move the focus between panes, `z` +zooms the focused pane to the whole window, and `w` hides it so the others +widen (its number brings it back). Every list takes `↑`/`↓`, `Home`/`End`, +`PgUp`/`PgDn` and the vim keys, and `h`/`l` or `←`/`→` scroll a long file name, +folder or query sideways, `0` and `$` to either end. `PgUp`/`PgDn` scroll a chat log back through its history, and `?` shows every key for where you are. ### Daemon mode diff --git a/soulseek-rs/src/ui/main_tui/mod.rs b/soulseek-rs/src/ui/main_tui/mod.rs index 75ee091..127386c 100644 --- a/soulseek-rs/src/ui/main_tui/mod.rs +++ b/soulseek-rs/src/ui/main_tui/mod.rs @@ -1158,8 +1158,9 @@ mod tests { let mut tui = with_session(TalkativeSession::default()); tui.state.results_items = results(n); tui.state.focused_pane = FocusedPane::Results; + // 88 cells wide gives the name column 11 cells and the folder 7. tui.state.results_pane_area = - Some(ratatui::layout::Rect::new(0, 0, 80, 13)); + Some(ratatui::layout::Rect::new(0, 0, 88, 13)); tui } @@ -1273,6 +1274,18 @@ mod tests { ); } + #[test] + fn scrolling_runs_to_the_end_of_the_folder_when_it_is_the_longer() { + let mut tui = results_tui(3); + tui.state.results_items[1].filename = + format!("{}\\short.mp3", "f".repeat(20)); + tui.state.results_table_state.select(Some(1)); + press(&mut tui, KeyCode::Char('$')); + assert_eq!(tui.state.results_name_offset, 14); + press(&mut tui, KeyCode::Char('l')); + assert_eq!(tui.state.results_name_offset, 14, "stops at the end"); + } + #[test] fn picking_another_search_starts_its_results_unscrolled() { let mut tui = results_tui(3); diff --git a/soulseek-rs/src/ui/panes/results_pane.rs b/soulseek-rs/src/ui/panes/results_pane.rs index 039c7bf..af0f003 100644 --- a/soulseek-rs/src/ui/panes/results_pane.rs +++ b/soulseek-rs/src/ui/panes/results_pane.rs @@ -3,7 +3,7 @@ use crate::models::FileDisplayData; use crate::ui::{ BYTES_PER_MB, HIGHLIGHT_SYMBOL, body_style, dimmed_style, format_bytes, header_style, info_style, pane_block, pane_title, row_highlight_style, - success_style, warning_style, + split_path, success_style, warning_style, }; use ratatui::{ Frame, @@ -15,9 +15,13 @@ use std::collections::HashSet; const VERSION: &str = env!("CARGO_PKG_VERSION"); -const WIDTHS: [Constraint; 7] = [ +// The file name and its folder share whatever width the fixed columns leave, +// the name getting the larger part: it is what a search is after, and a +// folder's tail is a scroll away. +const WIDTHS: [Constraint; 8] = [ Constraint::Length(3), Constraint::Fill(3), + Constraint::Fill(2), Constraint::Length(12), Constraint::Length(15), Constraint::Length(10), @@ -39,12 +43,22 @@ pub struct ResultsPaneParams<'a> { pub name_offset: usize, } -/// Filename is the second column. +/// The file name is the second column, the folder it sits in the third. const NAME_COLUMN: usize = 1; +const FOLDER_COLUMN: usize = 2; +/// The offset that brings the end of a result's longer column into view. +/// One offset scrolls the name and the folder together, so it runs until +/// both tails are showing. #[must_use] -pub fn name_end_offset(name: &str, area: Rect) -> usize { - end_offset(name, area, &WIDTHS, NAME_COLUMN) +pub fn name_end_offset(path: &str, area: Rect) -> usize { + let (folder, name) = split_path(path); + end_offset(name, area, &WIDTHS, NAME_COLUMN).max(end_offset( + folder, + area, + &WIDTHS, + FOLDER_COLUMN, + )) } /// Whether the rendered row `display_idx` is selected. `selected_indices` holds @@ -118,6 +132,7 @@ pub fn render_results_pane( let header = Row::new(vec![ Cell::from("✓").style(header_style()), Cell::from("Filename").style(header_style()), + Cell::from("Folder").style(header_style()), Cell::from("Size").style(header_style()), Cell::from("User").style(header_style()), Cell::from("Bitrate").style(header_style()), @@ -127,10 +142,12 @@ pub fn render_results_pane( .height(1); let name_width = column_width(area, &WIDTHS, NAME_COLUMN); + let folder_width = column_width(area, &WIDTHS, FOLDER_COLUMN); let rows: Vec = items .iter() .enumerate() .map(|(idx, file)| { + let (folder, name) = split_path(&file.filename); let checkbox = if row_is_selected(idx, original_indices, selected_indices) { "[✓]" @@ -159,12 +176,10 @@ pub fn render_results_pane( Row::new(vec![ Cell::from(checkbox).style(checkbox_style), - Cell::from(scroll_text( - &file.filename, - name_offset, - name_width, - )) - .style(body_style()), + Cell::from(scroll_text(name, name_offset, name_width)) + .style(body_style()), + Cell::from(scroll_text(folder, name_offset, folder_width)) + .style(dimmed_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()), @@ -202,8 +217,10 @@ mod tests { fn render_rows(items: &[FileDisplayData], name_offset: usize) -> String { let mut state = TableState::default(); state.select(Some(0)); + // Wide enough for the name column to hold 11 cells, with 7 for the + // folder beside it. let mut terminal = - Terminal::new(TestBackend::new(80, 6)).expect("backend"); + Terminal::new(TestBackend::new(88, 6)).expect("backend"); terminal .draw(|frame| { render_results_pane( @@ -267,6 +284,59 @@ mod tests { assert!(!screen.contains("…\u{301}"), "{screen}"); } + #[test] + fn a_path_shows_its_name_and_folder_in_separate_columns() { + let items = [file("@@abc\\Music\\Album\\01.flac"), file("cover.jpg")]; + let screen = render_rows(&items, 0); + let header = screen.lines().nth(1).expect("header"); + let name = header.find("Filename").expect("name header"); + let folder = header.find("Folder").expect("folder header"); + assert!(name < folder, "{header}"); + + let row = screen.lines().nth(2).expect("first row"); + assert!(row.contains("01.flac "), "{row}"); + assert!(!row.contains("Album\\01.flac"), "the name alone: {row}"); + assert!(row.contains("@@abc\\M"), "{row}"); + assert!( + row.find("01.flac") < row.find("@@abc"), + "the name before the folder: {row}" + ); + + let row = screen.lines().nth(3).expect("second row"); + assert!(row.contains("cover.jpg"), "{row}"); + } + + #[test] + fn one_offset_scrolls_the_name_and_the_folder_together() { + let items = [file("@@abc\\Music\\Album\\abcdefghijklmnopqrstuvwxyz")]; + let screen = render_rows(&items, 4); + assert!(screen.contains("…efghijklmn"), "{screen}"); + assert!(screen.contains("…c\\Musi"), "{screen}"); + } + + #[test] + fn the_end_offset_is_the_longer_columns() { + use super::name_end_offset; + use ratatui::layout::Rect; + // 88 cells wide: 11 for the name, 7 for the folder. + let area = Rect::new(0, 0, 88, 6); + assert_eq!(name_end_offset("short.mp3", area), 0); + assert_eq!(name_end_offset(&"x".repeat(40), area), 30); + assert_eq!( + name_end_offset(&format!("{}\\short.mp3", "f".repeat(20)), area), + 14, + "the folder overflows its narrower column" + ); + assert_eq!( + name_end_offset( + &format!("{}\\{}", "f".repeat(20), "x".repeat(40)), + area + ), + 30, + "whichever runs further" + ); + } + #[test] fn an_unscrolled_name_starts_at_its_beginning() { let items = [file("abcdefghijklmnopqrstuvwxyz0123456789ABCD")]; diff --git a/website/src/components/tui/panes.tsx b/website/src/components/tui/panes.tsx index 7bb8344..5d897d9 100644 --- a/website/src/components/tui/panes.tsx +++ b/website/src/components/tui/panes.tsx @@ -5,7 +5,15 @@ import { cn } from '@/lib/utils' import { TuiPane } from './chrome' import type { ResultRow, SearchRow, TransferRow, TransferStatus } from './data' -const RESULT_COLS = '30px 1fr 74px 92px 78px 74px 42px' +const RESULT_COLS = '30px 3fr 2fr 74px 92px 78px 74px 42px' + +/** A shared path split at its last separator, as the TUI's two columns show it. */ +const splitPath = (path: string): { folder: string; name: string } => { + const at = Math.max(path.lastIndexOf('\\'), path.lastIndexOf('/')) + return at < 0 + ? { folder: '', name: path } + : { folder: path.slice(0, at), name: path.slice(at + 1) } +} const TRANSFER_COLS = '48px 1fr 96px 168px 74px' interface SearchesPaneProps { @@ -134,6 +142,7 @@ export const ResultsPane = ({ > ✓ Filename + Folder Size User Bitrate @@ -143,6 +152,7 @@ export const ResultsPane = ({
{rows.map((row, i) => { const { glyph, tone } = resultGlyph(row) + const { folder, name } = splitPath(row.name) const isSelected = selected === i return (