feat(tui): results across the top, with panes you can hide, zoom and Tab through
The results pane now has the whole width, where a long file name needs it, and Searches, Downloads and Info share the row underneath. Tab and Shift-Tab walk the panes, z zooms the focused one to the window, w hides it so the others widen, and its number brings it back. Every list takes the same navigation keys, the shortcuts bar wraps instead of cutting keys off, and ? lists every key by pane, scrolling on a short terminal. Follows up on #55.
This commit is contained in:
@@ -1341,13 +1341,320 @@ mod tests {
|
||||
assert!(tui.state.results_selected_indices.is_empty());
|
||||
}
|
||||
|
||||
fn screen_of(tui: &mut MainTui) -> String {
|
||||
let backend = ratatui::backend::TestBackend::new(160, 40);
|
||||
fn screen_sized(tui: &mut MainTui, width: u16, height: u16) -> String {
|
||||
let backend = ratatui::backend::TestBackend::new(width, height);
|
||||
let mut terminal = ratatui::Terminal::new(backend).expect("backend");
|
||||
terminal.draw(|frame| tui.render(frame)).expect("draw");
|
||||
terminal.backend().to_string()
|
||||
}
|
||||
|
||||
fn screen_of(tui: &mut MainTui) -> String {
|
||||
screen_sized(tui, 160, 40)
|
||||
}
|
||||
|
||||
/// The screen row carrying a pane's legend, `[2] Results` say, without
|
||||
/// the quotes the test backend wraps each row in.
|
||||
fn row_with<'a>(screen: &'a str, needle: &str) -> &'a str {
|
||||
let line = screen
|
||||
.lines()
|
||||
.find(|line| line.contains(needle))
|
||||
.unwrap_or_else(|| panic!("no row with {needle:?} in\n{screen}"));
|
||||
let start = line.find('"').map_or(0, |i| i + 1);
|
||||
let end = line.rfind('"').unwrap_or(line.len());
|
||||
&line[start..end]
|
||||
}
|
||||
|
||||
/// A window with a search, its results, and a transfer, so every pane
|
||||
/// has something to show.
|
||||
fn furnished_tui() -> MainTui {
|
||||
let mut tui = results_tui(3);
|
||||
tui.state.searches.push(crate::models::SearchEntry {
|
||||
query: "aphex twin".to_string(),
|
||||
status: SearchStatus::Completed,
|
||||
results: results(3),
|
||||
known_files: 3,
|
||||
owned: true,
|
||||
start_time: std::time::Instant::now(),
|
||||
cancel_flag: Arc::new(std::sync::atomic::AtomicBool::new(false)),
|
||||
});
|
||||
tui.state.selected_search_index = Some(0);
|
||||
tui.state.downloads.push(crate::models::DownloadEntry {
|
||||
download: queued("bob", "@@x\\Music\\00.mp3"),
|
||||
receiver: None,
|
||||
});
|
||||
tui
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_results_pane_has_the_whole_width_to_itself() {
|
||||
let mut tui = furnished_tui();
|
||||
let screen = screen_of(&mut tui);
|
||||
|
||||
let results = row_with(&screen, "[2] Results");
|
||||
assert!(results.starts_with('╭'), "{results}");
|
||||
assert!(results.trim_end().ends_with('╮'), "{results}");
|
||||
assert!(!results.contains("[1]"), "nothing beside it: {results}");
|
||||
|
||||
// The other three share the row underneath, in this order.
|
||||
let row = row_with(&screen, "[1] Searches");
|
||||
let searches = row.find("[1]").expect("searches");
|
||||
let downloads = row.find("[3]").expect("downloads");
|
||||
let info = row.find("[Info]").expect("info");
|
||||
assert!(searches < downloads && downloads < info, "{row}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn tab_and_shift_tab_walk_the_panes_in_legend_order() {
|
||||
let mut tui = furnished_tui();
|
||||
tui.state.focused_pane = FocusedPane::Searches;
|
||||
press(&mut tui, KeyCode::Tab);
|
||||
assert_eq!(tui.state.focused_pane, FocusedPane::Results);
|
||||
press(&mut tui, KeyCode::Tab);
|
||||
assert_eq!(tui.state.focused_pane, FocusedPane::Downloads);
|
||||
press(&mut tui, KeyCode::Tab);
|
||||
assert_eq!(tui.state.focused_pane, FocusedPane::Searches);
|
||||
press(&mut tui, KeyCode::BackTab);
|
||||
assert_eq!(tui.state.focused_pane, FocusedPane::Downloads);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn w_hides_the_focused_pane_and_its_number_brings_it_back() {
|
||||
let mut tui = furnished_tui();
|
||||
tui.state.focused_pane = FocusedPane::Searches;
|
||||
|
||||
press(&mut tui, KeyCode::Char('w'));
|
||||
|
||||
let screen = screen_of(&mut tui);
|
||||
assert!(!screen.contains("[1] Searches"), "{screen}");
|
||||
assert!(screen.contains("[2] Results"), "{screen}");
|
||||
assert!(screen.contains("[3] Downloads"), "{screen}");
|
||||
assert_eq!(tui.state.focused_pane, FocusedPane::Results);
|
||||
assert_eq!(tui.state.searches_pane_area, None, "nothing to click");
|
||||
// The transfers widen into the space.
|
||||
let row = row_with(&screen, "[3] Downloads");
|
||||
assert!(row.starts_with('╭'), "{row}");
|
||||
|
||||
press(&mut tui, KeyCode::Char('1'));
|
||||
|
||||
let screen = screen_of(&mut tui);
|
||||
assert!(screen.contains("[1] Searches"), "{screen}");
|
||||
assert_eq!(tui.state.focused_pane, FocusedPane::Searches);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn hiding_the_results_gives_the_row_the_whole_height() {
|
||||
let mut tui = furnished_tui();
|
||||
press(&mut tui, KeyCode::Char('w'));
|
||||
let screen = screen_of(&mut tui);
|
||||
assert!(!screen.contains("[2] Results"), "{screen}");
|
||||
// The row now starts right under the status bar.
|
||||
let status = screen
|
||||
.lines()
|
||||
.position(|line| line.contains("Status"))
|
||||
.expect("status bar");
|
||||
let row = screen
|
||||
.lines()
|
||||
.position(|line| line.contains("[1] Searches"))
|
||||
.expect("searches");
|
||||
assert_eq!(row, status + 3, "{screen}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_last_pane_standing_cannot_be_hidden() {
|
||||
// Results goes, then Downloads; Searches is what is left, and a
|
||||
// third press leaves it there.
|
||||
let mut tui = furnished_tui();
|
||||
press(&mut tui, KeyCode::Char('w'));
|
||||
press(&mut tui, KeyCode::Char('w'));
|
||||
press(&mut tui, KeyCode::Char('w'));
|
||||
let screen = screen_of(&mut tui);
|
||||
assert!(screen.contains("[1] Searches"), "{screen}");
|
||||
assert!(!screen.contains("[3] Downloads"), "{screen}");
|
||||
assert_eq!(tui.state.focused_pane, FocusedPane::Searches);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn z_zooms_the_focused_pane_and_esc_or_z_leaves_it() {
|
||||
let mut tui = furnished_tui();
|
||||
press(&mut tui, KeyCode::Char('z'));
|
||||
|
||||
let screen = screen_of(&mut tui);
|
||||
assert!(screen.contains("[2] Results"), "{screen}");
|
||||
assert!(!screen.contains("[1] Searches"), "{screen}");
|
||||
assert!(!screen.contains("[3] Downloads"), "{screen}");
|
||||
assert!(!screen.contains("[Info]"), "{screen}");
|
||||
assert!(screen.contains("[z → unzoom]"), "{screen}");
|
||||
|
||||
// Tab while zoomed switches which pane fills the window.
|
||||
press(&mut tui, KeyCode::Tab);
|
||||
let screen = screen_of(&mut tui);
|
||||
assert!(screen.contains("[3] Downloads"), "{screen}");
|
||||
assert!(!screen.contains("[2] Results"), "{screen}");
|
||||
|
||||
press(&mut tui, KeyCode::Esc);
|
||||
assert!(!tui.state.layout.zoomed);
|
||||
let screen = screen_of(&mut tui);
|
||||
assert!(screen.contains("[2] Results"), "{screen}");
|
||||
assert!(screen.contains("[Info]"), "{screen}");
|
||||
|
||||
press(&mut tui, KeyCode::Char('z'));
|
||||
press(&mut tui, KeyCode::Char('z'));
|
||||
assert!(!tui.state.layout.zoomed, "z toggles");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_search_brings_a_hidden_results_pane_back() {
|
||||
let mut tui = furnished_tui();
|
||||
press(&mut tui, KeyCode::Char('w'));
|
||||
assert!(!tui.state.layout.is_visible(FocusedPane::Results));
|
||||
|
||||
press(&mut tui, KeyCode::Char('s'));
|
||||
press(&mut tui, KeyCode::Char('x'));
|
||||
press(&mut tui, KeyCode::Enter);
|
||||
|
||||
assert!(tui.state.layout.is_visible(FocusedPane::Results));
|
||||
assert_eq!(tui.state.focused_pane, FocusedPane::Results);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn question_mark_opens_the_keys_list_and_closes_it_again() {
|
||||
let mut tui = furnished_tui();
|
||||
press(&mut tui, KeyCode::Char('?'));
|
||||
|
||||
let screen = screen_of(&mut tui);
|
||||
assert!(screen.contains("Keys"), "{screen}");
|
||||
assert!(screen.contains("Tab / Shift-Tab"), "{screen}");
|
||||
assert!(screen.contains("clear every finished one"), "{screen}");
|
||||
|
||||
// Other keys are swallowed while it is open.
|
||||
press(&mut tui, KeyCode::Char('q'));
|
||||
assert!(!tui.state.should_exit, "q closes the list, not the app");
|
||||
assert!(!tui.state.show_help);
|
||||
|
||||
press(&mut tui, KeyCode::Char('?'));
|
||||
press(&mut tui, KeyCode::Char('w'));
|
||||
assert!(tui.state.show_help, "w did not reach the panes");
|
||||
assert!(tui.state.layout.is_visible(FocusedPane::Results));
|
||||
press(&mut tui, KeyCode::Esc);
|
||||
assert!(!tui.state.show_help);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn on_a_small_terminal_the_keys_list_stacks_and_scrolls() {
|
||||
let mut tui = furnished_tui();
|
||||
press(&mut tui, KeyCode::Char('?'));
|
||||
|
||||
let screen = screen_sized(&mut tui, 80, 24);
|
||||
let panes = row_with(&screen, "Panes");
|
||||
assert!(!panes.contains("Searches"), "one column: {panes}");
|
||||
assert!(screen.contains("focus a pane, hidden or not"), "{screen}");
|
||||
assert!(!screen.contains("clear every finished one"), "{screen}");
|
||||
|
||||
press(&mut tui, KeyCode::End);
|
||||
let screen = screen_sized(&mut tui, 80, 24);
|
||||
assert!(screen.contains("clear every finished one"), "{screen}");
|
||||
assert!(!screen.contains("Tab / Shift-Tab"), "{screen}");
|
||||
|
||||
press(&mut tui, KeyCode::Home);
|
||||
let screen = screen_sized(&mut tui, 80, 24);
|
||||
assert!(screen.contains("Tab / Shift-Tab"), "{screen}");
|
||||
|
||||
// A wide window shows both columns at once, nothing to scroll.
|
||||
press(&mut tui, KeyCode::End);
|
||||
let screen = screen_of(&mut tui);
|
||||
let panes = row_with(&screen, "Panes");
|
||||
assert!(panes.contains("Searches"), "two columns: {panes}");
|
||||
assert!(screen.contains("Tab / Shift-Tab"), "{screen}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_click_where_a_pane_just_hid_does_not_focus_it() {
|
||||
let mut tui = furnished_tui();
|
||||
let _ = screen_of(&mut tui);
|
||||
let searches = tui.state.searches_pane_area.expect("laid out");
|
||||
// Hidden, but not yet redrawn: the old area is still on record.
|
||||
tui.state.focused_pane = FocusedPane::Searches;
|
||||
press(&mut tui, KeyCode::Char('w'));
|
||||
assert_eq!(tui.state.focused_pane, FocusedPane::Results);
|
||||
tui.handle_mouse_event(ratatui::crossterm::event::MouseEvent {
|
||||
kind: ratatui::crossterm::event::MouseEventKind::Down(
|
||||
ratatui::crossterm::event::MouseButton::Left,
|
||||
),
|
||||
column: searches.x + 1,
|
||||
row: searches.y + 1,
|
||||
modifiers: KeyModifiers::NONE,
|
||||
});
|
||||
assert_eq!(tui.state.focused_pane, FocusedPane::Results);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_shortcut_bar_wraps_instead_of_cutting_keys_off() {
|
||||
let mut tui = furnished_tui();
|
||||
let screen = screen_of(&mut tui);
|
||||
assert!(screen.contains("[q → quit]"), "{screen}");
|
||||
let bar_rows =
|
||||
screen.lines().filter(|line| line.contains(" → ")).count();
|
||||
assert_eq!(bar_rows, 2, "{screen}");
|
||||
|
||||
let wide = screen_sized(&mut tui, 300, 40);
|
||||
let bar_rows = wide.lines().filter(|line| line.contains(" → ")).count();
|
||||
assert_eq!(bar_rows, 1, "{wide}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn page_keys_work_in_the_transfers_and_searches_lists_too() {
|
||||
let mut tui = furnished_tui();
|
||||
for i in 0..30 {
|
||||
tui.state.downloads.push(crate::models::DownloadEntry {
|
||||
download: queued("bob", &format!("{i}.mp3")),
|
||||
receiver: None,
|
||||
});
|
||||
}
|
||||
tui.state.focused_pane = FocusedPane::Downloads;
|
||||
tui.state.downloads_table_state.select(Some(0));
|
||||
let _ = screen_of(&mut tui); // lays the panes out, which sets the page size
|
||||
|
||||
press(&mut tui, KeyCode::End);
|
||||
assert_eq!(tui.state.downloads_table_state.selected(), Some(30));
|
||||
press(&mut tui, KeyCode::Home);
|
||||
assert_eq!(tui.state.downloads_table_state.selected(), Some(0));
|
||||
press(&mut tui, KeyCode::PageDown);
|
||||
let page = tui.state.downloads_table_state.selected().expect("row");
|
||||
assert!(page > 1 && page < 30, "a pane of rows, not one: {page}");
|
||||
ctrl(&mut tui, 'd');
|
||||
assert_eq!(tui.state.downloads.len(), 31, "ctrl-d pages, d deletes");
|
||||
assert!(tui.state.downloads_table_state.selected() > Some(page));
|
||||
|
||||
tui.state.focused_pane = FocusedPane::Searches;
|
||||
press(&mut tui, KeyCode::Char('G'));
|
||||
assert_eq!(tui.state.searches_table_state.selected(), Some(0));
|
||||
press(&mut tui, KeyCode::Char('g'));
|
||||
assert_eq!(tui.state.searches_table_state.selected(), Some(0));
|
||||
assert_eq!(tui.state.searches.len(), 1, "g and G only move");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn clicking_where_a_hidden_pane_was_focuses_whatever_is_there_now() {
|
||||
let mut tui = furnished_tui();
|
||||
let _ = screen_of(&mut tui);
|
||||
let searches = tui.state.searches_pane_area.expect("laid out");
|
||||
let inside = ratatui::crossterm::event::MouseEvent {
|
||||
kind: ratatui::crossterm::event::MouseEventKind::Down(
|
||||
ratatui::crossterm::event::MouseButton::Left,
|
||||
),
|
||||
column: searches.x + 1,
|
||||
row: searches.y + 1,
|
||||
modifiers: KeyModifiers::NONE,
|
||||
};
|
||||
tui.handle_mouse_event(inside);
|
||||
assert_eq!(tui.state.focused_pane, FocusedPane::Searches);
|
||||
|
||||
press(&mut tui, KeyCode::Char('w'));
|
||||
let _ = screen_of(&mut tui);
|
||||
tui.handle_mouse_event(inside);
|
||||
assert_eq!(tui.state.focused_pane, FocusedPane::Downloads);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_info_pane_describes_the_highlighted_result() {
|
||||
let mut tui = results_tui(3);
|
||||
|
||||
Reference in New Issue
Block a user