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
+145 -65
View File
@@ -9,6 +9,8 @@ use ratatui::layout::Position;
use ratatui::widgets::TableState;
const NAME_SCROLL_STEP: isize = 8;
/// Rows a page key moves the keys overlay, which has no table to measure.
const HELP_PAGE: usize = 10;
impl MainTui {
pub(super) fn handle_key_event(&mut self, key: KeyEvent) {
@@ -17,6 +19,27 @@ impl MainTui {
return self.handle_command_bar_input(key);
}
// The keys list closes on the key that opened it, or the usual two,
// and scrolls like any list for a terminal too short to show it all.
if self.state.show_help {
let scroll = &mut self.state.help_scroll;
match key.code {
KeyCode::Char('?' | 'q') | KeyCode::Esc => {
self.state.show_help = false;
}
KeyCode::Down | KeyCode::Char('j') => *scroll += 1,
KeyCode::Up | KeyCode::Char('k') => {
*scroll = scroll.saturating_sub(1);
}
KeyCode::PageDown => *scroll += HELP_PAGE,
KeyCode::PageUp => *scroll = scroll.saturating_sub(HELP_PAGE),
KeyCode::Home | KeyCode::Char('g') => *scroll = 0,
KeyCode::End | KeyCode::Char('G') => *scroll = usize::MAX,
_ => {}
}
return;
}
// Chat popup takes over navigation while open.
if self.state.show_messages {
return self.handle_chat_input(key);
@@ -33,10 +56,10 @@ impl MainTui {
return self.handle_settings_input(key);
}
// Control combinations only ever page a list, so they never reach
// the letter keys below: ctrl-d is not d.
if key.modifiers.contains(KeyModifiers::CONTROL) {
if self.state.focused_pane == FocusedPane::Results {
self.handle_results_input(key);
}
self.navigate_focused_list(key);
return;
}
@@ -53,16 +76,41 @@ impl MainTui {
self.state.should_exit = true;
return;
}
KeyCode::Char('?') => {
self.state.show_help = true;
self.state.help_scroll = 0;
return;
}
KeyCode::Char('1') => {
self.state.focused_pane = FocusedPane::Searches;
self.state.focus_pane(FocusedPane::Searches);
return;
}
KeyCode::Char('2') => {
self.state.focused_pane = FocusedPane::Results;
self.state.focus_pane(FocusedPane::Results);
return;
}
KeyCode::Char('3') => {
self.state.focused_pane = FocusedPane::Downloads;
self.state.focus_pane(FocusedPane::Downloads);
return;
}
KeyCode::Tab => {
self.state.cycle_focus(true);
return;
}
KeyCode::BackTab => {
self.state.cycle_focus(false);
return;
}
KeyCode::Char('z') => {
self.state.toggle_zoom();
return;
}
KeyCode::Char('w') => {
self.state.hide_focused_pane();
return;
}
KeyCode::Esc if self.state.layout.zoomed => {
self.state.layout.zoomed = false;
return;
}
KeyCode::Char('s') => {
@@ -306,15 +354,45 @@ impl MainTui {
}
}
/// The list the focus is on and how far one page moves it.
fn navigate_focused_list(&mut self, key: KeyEvent) -> bool {
let focused = self.state.focused_pane;
let page = self.page_size(focused);
let (table, len) = match focused {
FocusedPane::Searches => (
&mut self.state.searches_table_state,
self.state.searches.len(),
),
FocusedPane::Results => {
let len = self.visible_results().len();
(&mut self.state.results_table_state, len)
}
FocusedPane::Downloads => (
&mut self.state.downloads_table_state,
self.state.downloads.len() + self.state.uploads.len(),
),
};
navigate_list(key, table, len, page)
}
/// Rows a pane shows at once: its inner height less the table header.
fn page_size(&self, pane: FocusedPane) -> isize {
let area = match pane {
FocusedPane::Searches => self.state.searches_pane_area,
FocusedPane::Results => self.state.results_pane_area,
FocusedPane::Downloads => self.state.downloads_pane_area,
};
let rows = area.map_or(1, |area| {
pane_block(false).inner(area).height.saturating_sub(1)
});
isize::try_from(rows.max(1)).unwrap_or(isize::MAX)
}
fn handle_searches_input(&mut self, key: KeyEvent) {
let rows = self.state.searches.len();
if self.navigate_focused_list(key) {
return;
}
match key.code {
KeyCode::Up | KeyCode::Char('k') => {
cycle(&mut self.state.searches_table_state, rows, false);
}
KeyCode::Down | KeyCode::Char('j') => {
cycle(&mut self.state.searches_table_state, rows, true);
}
KeyCode::Enter => {
if let Some(selected) =
self.state.searches_table_state.selected()
@@ -329,7 +407,7 @@ impl MainTui {
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;
self.state.focus_pane(FocusedPane::Results);
}
}
}
@@ -348,31 +426,10 @@ impl MainTui {
}
fn handle_results_input(&mut self, key: KeyEvent) {
let ctrl = key.modifiers.contains(KeyModifiers::CONTROL);
if ctrl && !matches!(key.code, KeyCode::Char('f' | 'b' | 'd' | 'u')) {
if self.navigate_focused_list(key) {
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') => {
cycle(&mut self.state.results_table_state, items_count, false);
}
KeyCode::Down | KeyCode::Char('j') => {
cycle(&mut self.state.results_table_state, items_count, true);
}
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);
}
@@ -418,20 +475,6 @@ 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);
@@ -447,23 +490,12 @@ impl MainTui {
.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();
if self.navigate_focused_list(key) {
return;
}
match key.code {
KeyCode::Up | KeyCode::Char('k') => {
cycle(&mut self.state.downloads_table_state, rows, false);
}
KeyCode::Down | KeyCode::Char('j') => {
cycle(&mut self.state.downloads_table_state, rows, true);
}
KeyCode::Char('x') => {
self.cancel_selected_transfer();
}
@@ -494,8 +526,12 @@ impl MainTui {
(self.state.results_pane_area, FocusedPane::Results),
(self.state.downloads_pane_area, FocusedPane::Downloads),
];
// The areas are from the last draw. A pane hidden since then, in the
// same batch of events, still has one, and is not there to click.
for (area, pane) in panes {
if area.is_some_and(|area| area.contains(clicked)) {
if area.is_some_and(|area| area.contains(clicked))
&& self.state.layout.is_visible(pane)
{
self.state.focused_pane = pane;
return;
}
@@ -503,6 +539,37 @@ impl MainTui {
}
}
/// The keys every list answers alike: a row at a time wrapping at either
/// end, a page or half a page at a time stopping there, and the two ends.
/// Says whether `key` was one of them.
fn navigate_list(
key: KeyEvent,
table: &mut TableState,
len: usize,
page: isize,
) -> bool {
let ctrl = key.modifiers.contains(KeyModifiers::CONTROL);
let half = (page / 2).max(1);
match key.code {
KeyCode::Up | KeyCode::Char('k') if !ctrl => cycle(table, len, false),
KeyCode::Down | KeyCode::Char('j') if !ctrl => cycle(table, len, true),
KeyCode::Home | KeyCode::Char('g') if !ctrl => {
select_row(table, len, 0);
}
KeyCode::End | KeyCode::Char('G') if !ctrl => {
select_row(table, len, len.saturating_sub(1));
}
KeyCode::PageDown => scroll_rows(table, len, page),
KeyCode::PageUp => scroll_rows(table, len, -page),
KeyCode::Char('f') if ctrl => scroll_rows(table, len, page),
KeyCode::Char('b') if ctrl => scroll_rows(table, len, -page),
KeyCode::Char('d') if ctrl => scroll_rows(table, len, half),
KeyCode::Char('u') if ctrl => scroll_rows(table, len, -half),
_ => return false,
}
true
}
/// Move a table's selection one row, wrapping at either end.
fn cycle(table: &mut TableState, len: usize, forward: bool) {
if len == 0 {
@@ -516,3 +583,16 @@ fn cycle(table: &mut TableState, len: usize, forward: bool) {
};
table.select(Some(next));
}
/// Put the selection on `target`, or the last row when that is past the end.
fn select_row(table: &mut TableState, len: usize, target: usize) {
if len > 0 {
table.select(Some(target.min(len - 1)));
}
}
/// Move the selection `delta` rows, stopping at either end.
fn scroll_rows(table: &mut TableState, len: usize, delta: isize) {
let current = table.selected().unwrap_or(0);
select_row(table, len, current.saturating_add_signed(delta));
}