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
+196
View File
@@ -39,6 +39,75 @@ pub enum FocusedPane {
Downloads,
}
impl FocusedPane {
/// Every focusable pane, in the order `Tab` walks them: the number each
/// carries in its legend.
pub const ALL: [Self; 3] = [Self::Searches, Self::Results, Self::Downloads];
const fn index(self) -> usize {
match self {
Self::Searches => 0,
Self::Results => 1,
Self::Downloads => 2,
}
}
}
/// Which panes are on screen. A hidden pane gives its space to the others; a
/// zoomed one takes the whole content area for itself, so `Tab` through the
/// panes while zoomed reads as switching windows.
#[derive(Debug, Clone, PartialEq, Eq, Default)]
pub struct PaneLayout {
hidden: [bool; 3],
pub zoomed: bool,
}
impl PaneLayout {
#[must_use]
pub const fn is_visible(&self, pane: FocusedPane) -> bool {
!self.hidden[pane.index()]
}
/// The panes still on screen, in `Tab` order.
pub fn visible(&self) -> impl Iterator<Item = FocusedPane> + '_ {
FocusedPane::ALL
.into_iter()
.filter(move |pane| self.is_visible(*pane))
}
/// Take `pane` off the screen. Refuses when it is the last one showing —
/// an empty window has nothing to press a key in — and says so.
pub fn hide(&mut self, pane: FocusedPane) -> bool {
if self.visible().count() <= 1 {
return false;
}
self.hidden[pane.index()] = true;
true
}
pub const fn show(&mut self, pane: FocusedPane) {
self.hidden[pane.index()] = false;
}
/// The visible pane after (or before) `from`, wrapping around. `from`
/// itself may already be hidden, which is how focus leaves a pane that
/// was just hidden.
#[must_use]
pub fn neighbour(&self, from: FocusedPane, forward: bool) -> FocusedPane {
let count = FocusedPane::ALL.len();
let step = if forward { 1 } else { count - 1 };
let mut index = from.index();
for _ in 0..count {
index = (index + step) % count;
let candidate = FocusedPane::ALL[index];
if self.is_visible(candidate) {
return candidate;
}
}
from
}
}
/// What the shared command bar is currently capturing input for.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum CommandBarMode {
@@ -92,6 +161,12 @@ pub struct AppState {
// UI State
pub focused_pane: FocusedPane,
pub layout: PaneLayout,
/// The keys overlay (`?`) is open.
pub show_help: bool,
/// Rows the keys overlay is scrolled down, for a terminal too short to
/// show it whole. The renderer clamps it to what there is to scroll.
pub help_scroll: usize,
pub should_exit: bool,
pub command_bar_active: bool,
pub command_bar_input: String,
@@ -164,6 +239,9 @@ impl AppState {
active_downloads_count: 0,
focused_pane: FocusedPane::Searches,
layout: PaneLayout::default(),
show_help: false,
help_scroll: 0,
should_exit: false,
command_bar_active: false,
command_bar_input: String::new(),
@@ -195,6 +273,31 @@ impl AppState {
}
}
/// Put the focus on `pane`, bringing it back on screen if it was hidden:
/// pressing a pane's number is also how it comes back.
pub const fn focus_pane(&mut self, pane: FocusedPane) {
self.layout.show(pane);
self.focused_pane = pane;
}
/// Move the focus to the next (or previous) visible pane.
pub fn cycle_focus(&mut self, forward: bool) {
self.focused_pane = self.layout.neighbour(self.focused_pane, forward);
}
/// Hide the focused pane and move the focus to its neighbour. The last
/// visible pane stays; there would be nowhere for the focus to go.
pub fn hide_focused_pane(&mut self) {
let pane = self.focused_pane;
if self.layout.hide(pane) {
self.focused_pane = self.layout.neighbour(pane, true);
}
}
pub const fn toggle_zoom(&mut self) {
self.layout.zoomed = !self.layout.zoomed;
}
/// Open the chat popup on a conversation with `peer`, ready to type.
pub fn open_chat(&mut self, peer: String) {
self.chat_peer = Some(peer);
@@ -290,6 +393,99 @@ mod tests {
assert_eq!(state.active_chat_peer(), Some("bob"));
}
fn visible(layout: &PaneLayout) -> Vec<FocusedPane> {
layout.visible().collect()
}
#[test]
fn every_pane_starts_visible_and_unzoomed() {
let state = AppState::new();
assert_eq!(visible(&state.layout), FocusedPane::ALL);
assert!(!state.layout.zoomed);
assert!(!state.show_help);
}
#[test]
fn tab_cycles_the_focus_through_the_visible_panes_and_wraps() {
let mut state = AppState::new();
assert_eq!(state.focused_pane, FocusedPane::Searches);
state.cycle_focus(true);
assert_eq!(state.focused_pane, FocusedPane::Results);
state.cycle_focus(true);
assert_eq!(state.focused_pane, FocusedPane::Downloads);
state.cycle_focus(true);
assert_eq!(state.focused_pane, FocusedPane::Searches, "wraps");
state.cycle_focus(false);
assert_eq!(state.focused_pane, FocusedPane::Downloads, "and back");
}
#[test]
fn hiding_the_focused_pane_moves_the_focus_past_it() {
let mut state = AppState::new();
state.focus_pane(FocusedPane::Results);
state.hide_focused_pane();
assert_eq!(
visible(&state.layout),
[FocusedPane::Searches, FocusedPane::Downloads]
);
assert_eq!(state.focused_pane, FocusedPane::Downloads);
// Tab now skips the hidden pane in both directions.
state.cycle_focus(true);
assert_eq!(state.focused_pane, FocusedPane::Searches);
state.cycle_focus(false);
assert_eq!(state.focused_pane, FocusedPane::Downloads);
}
#[test]
fn hiding_wraps_the_focus_to_the_first_pane() {
let mut state = AppState::new();
state.focus_pane(FocusedPane::Downloads);
state.hide_focused_pane();
assert_eq!(state.focused_pane, FocusedPane::Searches);
}
#[test]
fn the_last_visible_pane_cannot_be_hidden() {
let mut state = AppState::new();
state.hide_focused_pane();
state.hide_focused_pane();
assert_eq!(visible(&state.layout), [FocusedPane::Downloads]);
state.hide_focused_pane();
assert_eq!(
visible(&state.layout),
[FocusedPane::Downloads],
"one pane always stays on screen"
);
assert_eq!(state.focused_pane, FocusedPane::Downloads);
}
#[test]
fn focusing_a_hidden_pane_by_number_brings_it_back() {
let mut state = AppState::new();
state.focus_pane(FocusedPane::Results);
state.hide_focused_pane();
assert!(!state.layout.is_visible(FocusedPane::Results));
state.focus_pane(FocusedPane::Results);
assert!(state.layout.is_visible(FocusedPane::Results));
assert_eq!(state.focused_pane, FocusedPane::Results);
}
#[test]
fn zoom_toggles_and_leaves_hidden_panes_alone() {
let mut state = AppState::new();
state.focus_pane(FocusedPane::Searches);
state.hide_focused_pane();
state.toggle_zoom();
assert!(state.layout.zoomed);
assert!(!state.layout.is_visible(FocusedPane::Searches));
state.toggle_zoom();
assert!(!state.layout.zoomed);
assert!(!state.layout.is_visible(FocusedPane::Searches));
}
#[test]
fn opening_a_chat_pins_a_peer_with_no_messages_yet() {
let mut state = AppState::new();
+1 -1
View File
@@ -6,7 +6,7 @@ mod settings;
pub use app_state::{
AppState, ChatMessage, CommandBarMode, DownloadEntry, FocusedPane,
MessageDirection, SearchEntry, SearchStatus,
MessageDirection, PaneLayout, SearchEntry, SearchStatus,
};
pub use browse::{
BrowseState, BrowseStatus, BrowseTabs, files_under, find_node,