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