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:
Michel de Graaf
2026-09-07 07:49:32 +00:00
parent c91b97de7c
commit da8ab45303
15 changed files with 1209 additions and 232 deletions
+309 -2
View File
@@ -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);