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:
@@ -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}");
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user