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
+9
View File
@@ -91,6 +91,15 @@ soulseek-rs
Everything else is a one-shot command that runs headless.
### The TUI
Results run across the top, with the whole width for long file names;
Searches, Downloads and Info share the row underneath. `Tab`/`Shift-Tab` or
`1`–`3` move the focus between panes, `z` zooms the focused pane to the whole
window, and `w` hides it so the others widen (its number brings it back).
Every list takes `↑`/`↓`, `Home`/`End`, `PgUp`/`PgDn` and the vim keys, and
`?` shows every key for where you are.
### Daemon mode
A one-shot command logs in, does its job, and exits. Run a daemon instead and
+1 -1
View File
File diff suppressed because one or more lines are too long

Before

Width:  |  Height:  |  Size: 1.0 MiB

After

Width:  |  Height:  |  Size: 908 KiB

+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,
+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));
}
+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);
+371 -97
View File
@@ -6,19 +6,92 @@ use crate::ui::panes::{
render_rooms_pane, render_searches_pane, selected_transfer,
};
use crate::ui::{
accent_style, dimmed_style, format_shortcuts_styled, pane_block,
SIGNAL, accent_style, body_style, dimmed_style, pack_shortcuts, pane_block,
plain_title, primary_style, render_download_stats, warning_style,
};
use ratatui::{
Frame,
layout::{Alignment, Constraint, Layout, Position, Rect},
style::Modifier,
style::{Modifier, Style},
text::{Line, Span},
widgets::Paragraph,
};
/// The keys overlay, grouped by where a key applies. Two columns, because a
/// single list runs longer than most terminals are tall.
const HELP_LEFT: &[(&str, &[(&str, &str)])] = &[
(
"Panes",
&[
("Tab / Shift-Tab", "next / previous pane"),
("1 2 3", "focus a pane, hidden or not"),
("z", "zoom the focused pane"),
("w", "hide the focused pane"),
("Esc", "leave zoom"),
("click", "focus a pane"),
],
),
(
"Anywhere",
&[
("s", "search the network"),
("b", "browse a user's files"),
("m", "compose a private message"),
("i", "inbox"),
("o", "settings"),
("?", "this list"),
("q", "quit"),
],
),
(
"Any list",
&[
("↑ ↓ / k j", "move one row"),
("Home End / g G", "first / last row"),
("PgUp PgDn / ^b ^f", "one page"),
("^u ^d", "half a page"),
],
),
];
const HELP_RIGHT: &[(&str, &[(&str, &str)])] = &[
(
"Searches",
&[
("Enter", "show its results"),
("d", "remove the search"),
("C", "clear every search"),
("c", "chat rooms"),
],
),
(
"Results",
&[
("Space", "select / deselect"),
("a / A", "select all / none"),
("Enter", "download the selection"),
("/", "filter, Enter keeps it, Esc clears"),
("h l / ← →", "scroll the file name"),
("0 / $", "start / end of the file name"),
("b", "browse the owner"),
("c", "chat rooms"),
],
),
(
"Downloads / Uploads",
&[
("p", "pause / resume"),
("x", "cancel"),
("r", "retry a failed one"),
("d", "delete a queued or finished one"),
("c", "clear every finished one"),
],
),
];
impl MainTui {
pub(super) fn render(&mut self, frame: &mut Frame) {
let shortcuts = self.shortcut_rows(frame.area().width);
let mut constraints = vec![
Constraint::Length(3), // Status bar
Constraint::Fill(1), // Main content
@@ -26,7 +99,7 @@ impl MainTui {
if self.state.command_bar_active {
constraints.push(Constraint::Length(3)); // Command bar
}
constraints.push(Constraint::Length(3)); // Shortcuts
constraints.push(Constraint::Length(shortcuts.len() as u16 + 2));
let main_chunks = Layout::vertical(constraints).split(frame.area());
let daemon = self.client.daemon_endpoint();
@@ -43,55 +116,111 @@ impl MainTui {
// Render command bar if active (vim-style, above shortcuts)
if self.state.command_bar_active {
self.render_command_bar(frame, main_chunks[2]);
self.render_shortcuts(frame, main_chunks[3]);
self.render_shortcuts(frame, main_chunks[3], shortcuts);
} else {
self.render_shortcuts(frame, main_chunks[2]);
self.render_shortcuts(frame, main_chunks[2], shortcuts);
}
self.render_overlays(frame);
}
/// Results across the top, where a long file name has the whole width;
/// Searches, Downloads and Info in a row underneath. A hidden pane drops
/// out of its row and the rest widen; a zoomed one has the area alone.
fn render_content(&mut self, frame: &mut Frame, area: Rect) {
// Split main content area
let content_chunks = Layout::horizontal([
Constraint::Percentage(30), // Searches pane
Constraint::Percentage(70), // Results + Downloads
])
.split(area);
// Only what is drawn can be clicked.
self.state.searches_pane_area = None;
self.state.results_pane_area = None;
self.state.downloads_pane_area = None;
// Store searches pane area
self.state.searches_pane_area = Some(content_chunks[0]);
if self.state.layout.zoomed {
self.render_pane(frame, area, self.state.focused_pane);
return;
}
// Render Searches pane (left)
render_searches_pane(
frame,
content_chunks[0],
&self.state.searches,
&mut self.state.searches_table_state,
self.state.focused_pane == FocusedPane::Searches,
);
let row_panes: Vec<FocusedPane> =
[FocusedPane::Searches, FocusedPane::Downloads]
.into_iter()
.filter(|pane| self.state.layout.is_visible(*pane))
.collect();
// Split right side into Results (top) and Downloads (bottom)
let right_chunks = Layout::vertical([
Constraint::Percentage(60), // Results
Constraint::Percentage(40), // Downloads
])
.split(content_chunks[1]);
let row_area = if self.state.layout.is_visible(FocusedPane::Results) {
// With only Info left in the row, the results deserve more of
// the height.
let row_weight = if row_panes.is_empty() { 1 } else { 2 };
let [top, bottom] = Layout::vertical([
Constraint::Fill(3),
Constraint::Fill(row_weight),
])
.areas(area);
self.render_pane(frame, top, FocusedPane::Results);
bottom
} else {
area
};
// Split bottom-right into Downloads list and Info pane
let downloads_chunks = Layout::horizontal([
Constraint::Percentage(60), // Downloads list
Constraint::Percentage(40), // Info pane
])
.split(right_chunks[1]);
// Transfers carry the most columns, so they get the most width.
let mut constraints: Vec<Constraint> = row_panes
.iter()
.map(|pane| match pane {
FocusedPane::Searches => Constraint::Fill(5),
FocusedPane::Downloads => Constraint::Fill(10),
FocusedPane::Results => unreachable!("not a row pane"),
})
.collect();
constraints.push(Constraint::Fill(6)); // Info
let chunks = Layout::horizontal(constraints).split(row_area);
for (chunk, pane) in chunks.iter().zip(&row_panes) {
self.render_pane(frame, *chunk, *pane);
}
self.render_info_pane(frame, chunks[row_panes.len()]);
}
// Store results and downloads pane areas
self.state.results_pane_area = Some(right_chunks[0]);
self.state.downloads_pane_area = Some(downloads_chunks[0]);
fn render_pane(
&mut self,
frame: &mut Frame,
area: Rect,
pane: FocusedPane,
) {
let focused = self.state.focused_pane == pane;
match pane {
FocusedPane::Searches => {
self.state.searches_pane_area = Some(area);
render_searches_pane(
frame,
area,
&self.state.searches,
&mut self.state.searches_table_state,
focused,
);
}
FocusedPane::Results => {
self.state.results_pane_area = Some(area);
self.render_results_pane(frame, area, focused);
}
FocusedPane::Downloads => {
self.state.downloads_pane_area = Some(area);
render_downloads_pane(
frame,
area,
&self.state.downloads,
&self.state.uploads,
&mut self.state.downloads_table_state,
focused,
);
}
}
}
// Render Results pane. When a filter is active the rendered rows are a
// subset, so the pane also needs the mapping back to unfiltered indices
// to render the selection checkboxes correctly.
fn render_results_pane(
&mut self,
frame: &mut Frame,
area: Rect,
focused: bool,
) {
// When a filter is active the rendered rows are a subset, so the pane
// also needs the mapping back to unfiltered indices to render the
// selection checkboxes correctly.
let (results_items, results_original_indices) =
if self.state.results_filter_query.is_empty() {
(&self.state.results_items, None)
@@ -110,7 +239,7 @@ impl MainTui {
render_results_pane(
frame,
right_chunks[0],
area,
ResultsPaneParams {
items: results_items,
table_state: &mut self.state.results_table_state,
@@ -118,21 +247,16 @@ impl MainTui {
original_indices: results_original_indices,
filter_query: &self.state.results_filter_query,
is_filtering: self.state.results_is_filtering,
focused: self.state.focused_pane == FocusedPane::Results,
focused,
active_search_query,
name_offset: self.state.results_name_offset,
},
);
}
render_downloads_pane(
frame,
downloads_chunks[0],
&self.state.downloads,
&self.state.uploads,
&mut self.state.downloads_table_state,
self.state.focused_pane == FocusedPane::Downloads,
);
/// The Info pane follows the focus: the highlighted result, or the
/// highlighted transfer.
fn render_info_pane(&self, frame: &mut Frame, area: Rect) {
let selected = if self.state.focused_pane == FocusedPane::Results {
self.highlighted_result().map(InfoSubject::Result)
} else {
@@ -144,7 +268,7 @@ impl MainTui {
};
render_download_info_pane(
frame,
downloads_chunks[1],
area,
selected,
matches!(
self.state.focused_pane,
@@ -153,6 +277,49 @@ impl MainTui {
);
}
/// The keys list, two columns side by side where the terminal is wide
/// enough and one on top of the other where it is not, scrolling when
/// even that is taller than the window.
fn render_help_popup(&mut self, frame: &mut Frame) {
let screen = frame.area();
// Borders and padding on either side of the text.
let frame_width = 4;
let width = screen.width.min(help_width() + frame_width);
let columns = help_columns(width.saturating_sub(frame_width));
let tallest = columns.iter().map(Vec::len).max().unwrap_or(0);
let height = screen
.height
.min(u16::try_from(tallest + 2).unwrap_or(u16::MAX));
let area = Rect::new(
screen.x + (screen.width - width) / 2,
screen.y + (screen.height - height) / 2,
width,
height,
);
frame.render_widget(ratatui::widgets::Clear, area);
let block = pane_block(true).title(plain_title("Keys", true));
let inner = block.inner(area);
frame.render_widget(block, area);
// Both columns scroll together, and no further than the taller one
// needs; the clamp is written back so the keys cannot run past it.
let furthest = tallest.saturating_sub(usize::from(inner.height));
self.state.help_scroll = self.state.help_scroll.min(furthest);
let scroll = u16::try_from(self.state.help_scroll).unwrap_or(u16::MAX);
let areas =
Layout::horizontal(vec![Constraint::Fill(1); columns.len()])
.spacing(HELP_GAP)
.split(inner);
for (area, lines) in areas.iter().zip(columns) {
frame.render_widget(
Paragraph::new(lines).scroll((scroll, 0)),
*area,
);
}
}
fn render_overlays(&mut self, frame: &mut Frame) {
// Messages inbox overlays everything when open.
if self.state.show_messages {
@@ -188,6 +355,12 @@ impl MainTui {
&mut self.state.rooms_list_table_state,
);
}
// The keys list only opens from the main view, so it is on top of
// nothing; drawn last so that stays true if that ever changes.
if self.state.show_help {
self.render_help_popup(frame);
}
}
fn render_settings_popup(&self, frame: &mut Frame) {
@@ -304,10 +477,31 @@ impl MainTui {
}
}
/// The keys every pane shares for moving between and resizing panes,
/// ending the bar the same way wherever the focus sits.
fn pane_shortcuts(&self) -> Vec<(&'static str, &'static str)> {
vec![
("Tab/1-3", "pane"),
(
"z",
if self.state.layout.zoomed {
"unzoom"
} else {
"zoom"
},
),
("w", "hide"),
("?", "keys"),
("q", "quit"),
]
}
/// Which shortcuts the bar offers, which is purely a question of what is
/// open and where the focus sits. Sibling of [`Self::rooms_shortcuts`].
fn shortcuts(&self) -> Vec<(&'static str, &'static str)> {
if self.state.settings.is_some() {
if self.state.show_help {
vec![("?/Esc", "close")]
} else if self.state.settings.is_some() {
vec![
("↑↓", "move"),
("Enter/e", "edit download dir"),
@@ -364,29 +558,28 @@ impl MainTui {
("Esc", "cancel"),
],
}
} else if self.state.results_is_filtering
&& self.state.focused_pane == FocusedPane::Results
{
// Typing goes to the filter, so no letter keys are on offer here.
vec![
("Type", "filter"),
("↑/↓", "navigate"),
("PgUp/PgDn", "page"),
("Enter", "keep filter"),
("Esc", "clear filter"),
]
} else {
match self.state.focused_pane {
let mut keys = match self.state.focused_pane {
FocusedPane::Searches => vec![
("s", "search"),
("Enter", "results"),
("d", "remove"),
("m", "message"),
("i", "inbox"),
("c", "chat"),
("b", "browse user"),
("1-3", "focus pane"),
("↑↓", "navigate"),
("Enter", "results"),
("q", "quit"),
],
FocusedPane::Results if self.state.results_is_filtering => {
vec![
("Type", "filter"),
("↑/↓", "navigate"),
("Enter", "confirm"),
("Esc", "clear filter"),
("1-3", "focus pane"),
("q", "quit"),
]
}
FocusedPane::Results => vec![
("Space", "select"),
("Enter", "download"),
@@ -394,29 +587,50 @@ impl MainTui {
("c", "chat"),
("/", "filter"),
("a/A", "select all/none"),
("h/l", "scroll"),
("h/l", "scroll name"),
("g/G", "top/end"),
("1-3", "focus pane"),
("q", "quit"),
],
FocusedPane::Downloads => {
vec![
("p", "pause/resume"),
("x", "cancel"),
("r", "retry failed"),
("d", "delete queued/done"),
("c", "clear finished"),
("b", "browse user"),
("1-3", "focus pane"),
("q", "quit"),
]
}
}
FocusedPane::Downloads => vec![
("p", "pause/resume"),
("x", "cancel"),
("r", "retry failed"),
("d", "delete queued/done"),
("c", "clear finished"),
("b", "browse user"),
],
};
keys.extend(self.pane_shortcuts());
keys
}
}
fn render_shortcuts(&self, frame: &mut Frame, area: Rect) {
let shortcuts_line = format_shortcuts_styled(&self.shortcuts());
/// The legend packed into rows for a window `width` wide, so a narrow
/// terminal grows the bar instead of cutting keys off its right edge.
fn shortcut_rows(&self, width: u16) -> Vec<Line<'static>> {
// Borders and padding, and the unread badge's column when it shows.
let badge = self.unread_badge().width() as u16;
let reserved = 4 + if badge > 0 { badge + 1 } else { 0 };
pack_shortcuts(&self.shortcuts(), width.saturating_sub(reserved))
}
/// A prominent unread badge for the shortcuts bar. It rides in the
/// always-visible bar, so unread mail/chat shows in every pane, not just
/// some. The spinner tick (0..10, ~100ms each) drives a ~1s blink: bright
/// for the first half of the cycle, dim for the second.
fn unread_badge(&self) -> Line<'static> {
unread_indicator(
self.state.unread_messages,
self.state.rooms.total_unread(),
self.spinner_state < 5,
)
}
fn render_shortcuts(
&self,
frame: &mut Frame,
area: Rect,
rows: Vec<Line<'static>>,
) {
// Surface our own sharing status in the block title.
let shared = self.client.shared_directories();
let sharing = match shared.as_slice() {
@@ -431,26 +645,20 @@ impl MainTui {
let inner = block.inner(area);
frame.render_widget(block, area);
// A prominent unread badge, right-aligned so the context shortcuts on
// the left never crowd it out. It rides in the always-visible shortcuts
// bar, so unread mail/chat shows in every pane, not just some. The
// spinner tick (0..10, ~100ms each) drives a ~1s blink: bright for the
// first half of the cycle, dim for the second.
let unread = unread_indicator(
self.state.unread_messages,
self.state.rooms.total_unread(),
self.spinner_state < 5,
);
// The unread badge is right-aligned so the context shortcuts on the
// left never crowd it out.
let unread = self.unread_badge();
if unread.spans.is_empty() {
frame.render_widget(Paragraph::new(shortcuts_line), inner);
frame.render_widget(Paragraph::new(rows), inner);
} else {
let badge_width = unread.width() as u16;
let cols = Layout::horizontal([
Constraint::Fill(1),
Constraint::Length(badge_width),
])
.spacing(1)
.split(inner);
frame.render_widget(Paragraph::new(shortcuts_line), cols[0]);
frame.render_widget(Paragraph::new(rows), cols[0]);
frame.render_widget(
Paragraph::new(unread).alignment(Alignment::Right),
cols[1],
@@ -493,6 +701,72 @@ impl MainTui {
}
}
/// Columns between the two halves of the keys list.
const HELP_GAP: u16 = 2;
/// The widest key in a column, so the descriptions line up.
fn key_column_width(sections: &[(&str, &[(&str, &str)])]) -> usize {
sections
.iter()
.flat_map(|(_, keys)| keys.iter())
.map(|(key, _)| Span::raw(*key).width())
.max()
.unwrap_or(0)
}
/// Cells a column of the keys list needs to show every line whole.
fn help_column_width(sections: &[(&str, &[(&str, &str)])]) -> u16 {
let widest = help_lines(sections)
.iter()
.map(Line::width)
.max()
.unwrap_or(0);
u16::try_from(widest).unwrap_or(u16::MAX)
}
/// The text width of the keys list with both halves side by side.
fn help_width() -> u16 {
help_column_width(HELP_LEFT) + HELP_GAP + help_column_width(HELP_RIGHT)
}
/// The keys list laid out for `width` cells of text: two columns when both
/// fit beside each other, otherwise the right half under the left.
fn help_columns(width: u16) -> Vec<Vec<Line<'static>>> {
let left = help_lines(HELP_LEFT);
let right = help_lines(HELP_RIGHT);
if width >= help_width() {
return vec![left, right];
}
let mut stacked = left;
stacked.push(Line::from(""));
stacked.extend(right);
vec![stacked]
}
fn help_lines(sections: &[(&str, &[(&str, &str)])]) -> Vec<Line<'static>> {
let key_width = key_column_width(sections);
let mut lines = Vec::new();
for (index, (heading, keys)) in sections.iter().enumerate() {
if index > 0 {
lines.push(Line::from(""));
}
lines.push(Line::styled(
(*heading).to_string(),
accent_style().add_modifier(Modifier::BOLD),
));
for (key, action) in *keys {
lines.push(Line::from(vec![
Span::styled(
format!("{key:<key_width$} "),
Style::default().fg(SIGNAL),
),
Span::styled((*action).to_string(), body_style()),
]));
}
}
lines
}
/// A `Rect` centered within `area`, sized to the given percentages.
fn centered_rect(percent_x: u16, percent_y: u16, area: Rect) -> Rect {
let vertical = Layout::vertical([
+2 -2
View File
@@ -231,8 +231,8 @@ impl MainTui {
self.state.results_table_state.select(Some(0));
self.state.results_name_offset = 0;
// Switch focus to Results pane
self.state.focused_pane = FocusedPane::Results;
// Switch focus to Results pane, bringing it back if it was hidden
self.state.focus_pane(FocusedPane::Results);
let client = self.client.clone();
let timeout = self.search_timeout;
+7 -4
View File
@@ -163,12 +163,15 @@ pub fn render_downloads_pane(
])
}));
// Sized to the widest value each column shows in ordinary use — a
// three-digit megabyte count on both sides of the progress — so the file
// name keeps whatever width the pane has beyond that.
let widths = [
ratatui::layout::Constraint::Length(8),
ratatui::layout::Constraint::Fill(2),
ratatui::layout::Constraint::Length(15),
ratatui::layout::Constraint::Length(25),
ratatui::layout::Constraint::Length(6),
ratatui::layout::Constraint::Fill(1),
ratatui::layout::Constraint::Length(12),
ratatui::layout::Constraint::Length(25),
ratatui::layout::Constraint::Length(10),
];
let table = Table::new(rows, widths)
+7 -3
View File
@@ -43,10 +43,12 @@ pub fn render_searches_pane(
} else {
search.results.len()
};
// Nothing yet from a search still out: the status column already
// says it is looking.
let results_text = if results_count == 0
&& search.status == SearchStatus::Active
{
"Searching...".to_string()
"…".to_string()
} else {
format!("{results_count}")
};
@@ -59,10 +61,12 @@ pub fn render_searches_pane(
})
.collect();
// The pane shares a row with two others, so the fixed columns are as
// narrow as their widest word: the query gets the rest.
let widths = [
ratatui::layout::Constraint::Length(15),
ratatui::layout::Constraint::Length(6),
ratatui::layout::Constraint::Fill(1),
ratatui::layout::Constraint::Length(10),
ratatui::layout::Constraint::Length(7),
];
let table = Table::new(rows, widths)
+89 -12
View File
@@ -158,22 +158,59 @@ pub fn download_status_glyph(status: &DownloadStatus) -> (&'static str, Style) {
}
}
pub fn format_shortcuts_styled(shortcuts: &[(&str, &str)]) -> Line<'static> {
let mut spans = Vec::new();
/// One `[key → action]` legend entry.
fn shortcut_spans(key: &str, action: &str) -> Vec<Span<'static>> {
vec![
Span::styled("[", dimmed_style()),
Span::styled(key.to_string(), Style::default().fg(SIGNAL)),
Span::styled(format!(" {SHORTCUT_ARROW} "), dimmed_style()),
Span::styled(action.to_string(), body_style()),
Span::styled("]", dimmed_style()),
]
}
for (i, (key, action)) in shortcuts.iter().enumerate() {
if i > 0 {
spans.push(Span::raw(" "));
/// The most rows the shortcuts bar grows to. Past that a terminal is too
/// narrow for the legend to be worth the content rows it would take.
pub const SHORTCUT_ROWS_MAX: usize = 3;
/// Lay the legend out in rows no wider than `width`, never breaking inside an
/// entry: a `[key → action]` split over two rows reads as two keys. Entries
/// that do not fit in [`SHORTCUT_ROWS_MAX`] rows are dropped, so a bar always
/// has at least one row and never more than that.
pub fn pack_shortcuts(
shortcuts: &[(&str, &str)],
width: u16,
) -> Vec<Line<'static>> {
let width = usize::from(width);
let mut rows: Vec<Line<'static>> = Vec::new();
let mut row: Vec<Span<'static>> = Vec::new();
let mut row_width = 0;
for (key, action) in shortcuts {
let entry = shortcut_spans(key, action);
let entry_width: usize = entry.iter().map(Span::width).sum();
let needed = if row.is_empty() {
entry_width
} else {
row_width + 1 + entry_width
};
if !row.is_empty() && needed > width {
rows.push(Line::from(std::mem::take(&mut row)));
if rows.len() == SHORTCUT_ROWS_MAX {
return rows;
}
row_width = entry_width;
} else {
if !row.is_empty() {
row.push(Span::raw(" "));
}
row_width = needed;
}
spans.push(Span::styled("[", dimmed_style()));
spans.push(Span::styled(key.to_string(), Style::default().fg(SIGNAL)));
spans.push(Span::styled(format!(" {SHORTCUT_ARROW} "), dimmed_style()));
spans.push(Span::styled(action.to_string(), body_style()));
spans.push(Span::styled("]", dimmed_style()));
row.extend(entry);
}
Line::from(spans)
rows.push(Line::from(row));
rows
}
pub fn format_progress_bar(
@@ -253,6 +290,46 @@ mod tests {
assert_eq!(cursor.bg, Some(SELECTION));
}
fn texts(rows: &[Line<'static>]) -> Vec<String> {
rows.iter()
.map(|line| line.spans.iter().map(|s| s.content.as_ref()).collect())
.collect()
}
#[test]
fn shortcuts_stay_on_one_row_when_they_fit() {
let rows = pack_shortcuts(&[("q", "quit"), ("s", "search")], 80);
assert_eq!(texts(&rows), ["[q → quit] [s → search]"]);
}
#[test]
fn shortcuts_wrap_between_entries_never_inside_one() {
let rows = pack_shortcuts(
&[("Space", "select"), ("Enter", "download"), ("q", "quit")],
30,
);
// "[Space → select] [Enter → download]" is 35 cells, over the 30 the
// bar has, so the second entry starts a new row whole.
assert_eq!(
texts(&rows),
["[Space → select]", "[Enter → download] [q → quit]"]
);
}
#[test]
fn an_entry_wider_than_the_bar_still_gets_a_row() {
let rows = pack_shortcuts(&[("Enter", "download"), ("q", "quit")], 5);
assert_eq!(texts(&rows), ["[Enter → download]", "[q → quit]"]);
}
#[test]
fn the_bar_never_grows_past_its_cap() {
let keys: Vec<(&str, &str)> = (0..10).map(|_| ("k", "act")).collect();
let rows = pack_shortcuts(&keys, 12);
assert_eq!(rows.len(), SHORTCUT_ROWS_MAX);
assert_eq!(texts(&rows)[0], "[k → act]");
}
#[test]
fn progress_bar_splits_fill_and_track() {
let bar = format_progress_bar(0.25, 20, 25);
+6 -1
View File
@@ -182,6 +182,11 @@ export const SHORTCUTS: readonly (readonly [string, string])[] = [
['c', 'chat'],
['/', 'filter'],
['a/A', 'select all/none'],
['1-3', 'focus pane'],
['h/l', 'scroll name'],
['g/G', 'top/end'],
['Tab/1-3', 'pane'],
['z', 'zoom'],
['w', 'hide'],
['?', 'keys'],
['q', 'quit'],
]
+33 -34
View File
@@ -37,45 +37,44 @@ export const HeroTerminal = () => {
>
<StatusLine counts={demo.counts} pct={demo.pct} />
<div className="mt-3 grid gap-2.5" style={{ gridTemplateColumns: '300px 1fr' }}>
<div className="min-h-[498px]">
<SearchesPane
searches={demo.searches}
activeId={demo.activeId}
active={demo.focus === 1}
onPick={demo.pickSearch}
onSubmit={demo.submitSearch}
{/* Results across the top with the whole width for long names;
Searches, Transfers and Info share the row underneath. */}
<div className="mt-3 grid gap-2.5" style={{ gridTemplateRows: '330px 300px' }}>
<div className="relative min-h-0 overflow-hidden">
<ResultsPane
query={demo.query}
rows={demo.rows}
selected={demo.selected}
active={demo.focus === 2}
onSelect={demo.selectRow}
onQueue={demo.queueRow}
/>
{demo.searching && (
<div className="pointer-events-none absolute inset-0 flex items-center justify-center bg-[color-mix(in_srgb,var(--color-sleeve)_35%,transparent)] text-[13.5px] tracking-[0.02em] text-[var(--color-dust)]">
searching the network
<span className="ml-1.5 inline-block h-[15px] w-2 translate-y-[2px] rounded-[1.5px] bg-[var(--color-oxide)] motion-safe:animate-cursor-blink" />
</div>
)}
</div>
<div className="grid min-h-0 gap-2.5" style={{ gridTemplateRows: '1fr 300px' }}>
<div className="relative min-h-[250px] overflow-hidden">
<ResultsPane
query={demo.query}
rows={demo.rows}
selected={demo.selected}
active={demo.focus === 2}
onSelect={demo.selectRow}
onQueue={demo.queueRow}
<div
className="grid min-h-0 gap-2.5"
style={{ gridTemplateColumns: '320px 1fr 340px' }}
>
<div className="min-h-0 min-w-0 overflow-hidden">
<SearchesPane
searches={demo.searches}
activeId={demo.activeId}
active={demo.focus === 1}
onPick={demo.pickSearch}
onSubmit={demo.submitSearch}
/>
{demo.searching && (
<div className="pointer-events-none absolute inset-0 flex items-center justify-center bg-[color-mix(in_srgb,var(--color-sleeve)_35%,transparent)] text-[13.5px] tracking-[0.02em] text-[var(--color-dust)]">
searching the network
<span className="ml-1.5 inline-block h-[15px] w-2 translate-y-[2px] rounded-[1.5px] bg-[var(--color-oxide)] motion-safe:animate-cursor-blink" />
</div>
)}
</div>
<div
className="grid min-h-0 gap-2.5"
style={{ gridTemplateColumns: '1fr 340px' }}
>
<div className="min-h-0 min-w-0 overflow-hidden">
<TransfersPane rows={demo.transfers.slice(0, 7)} active={demo.focus === 3} />
</div>
<div className="min-h-0 min-w-0 overflow-hidden">
<InfoPane row={demo.infoRow} />
</div>
<div className="min-h-0 min-w-0 overflow-hidden">
<TransfersPane rows={demo.transfers.slice(0, 7)} active={demo.focus === 3} />
</div>
<div className="min-h-0 min-w-0 overflow-hidden">
<InfoPane row={demo.infoRow} />
</div>
</div>
</div>
+1 -1
View File
@@ -6,7 +6,7 @@ import { TuiPane } from './chrome'
import type { ResultRow, SearchRow, TransferRow, TransferStatus } from './data'
const RESULT_COLS = '30px 1fr 74px 92px 78px 74px 42px'
const TRANSFER_COLS = '48px 1fr 96px 150px 74px'
const TRANSFER_COLS = '48px 1fr 96px 168px 74px'
interface SearchesPaneProps {
searches: readonly SearchRow[]
+32 -9
View File
@@ -25,10 +25,20 @@ const GLOBAL_KEYS: readonly (readonly [string, string])[] = [
['i', 'inbox (unread counter)'],
['/', 'filter the current list'],
['a / A', 'select all / none'],
['1–3', 'focus a pane'],
['?', 'every key, grouped by pane'],
['q', 'quit'],
]
const PANE_KEYS: readonly (readonly [string, string])[] = [
['Tab · ⇧Tab', 'next / previous pane'],
['1–3', 'focus a pane, and bring it back if hidden'],
['z', 'zoom the focused pane to the whole window'],
['w', 'hide the focused pane; the others widen'],
['↑ ↓ · j k', 'move a row in any list'],
['Home End · g G', 'first / last row'],
['PgUp PgDn · ^u ^d', 'a page, or half a page'],
]
const ROOM_KEYS: readonly (readonly [string, string])[] = [
['Enter', 'join room / send message'],
['Tab · ⇧Tab', 'switch between open rooms'],
@@ -40,16 +50,16 @@ const ROOM_KEYS: readonly (readonly [string, string])[] = [
]
const PANES: readonly { num?: number; title: string; body: string }[] = [
{
num: 2,
title: 'Results',
body: 'Files from the network across the whole width: size, user, bitrate, speed, free slots. Space selects, Enter queues a download. h/l or ←/→ scroll a long name, 0 and $ jump to either end.',
},
{
num: 1,
title: 'Searches',
body: 'Your queries and how many results each returned. Press s to search the network; each search keeps its own result set.',
},
{
num: 2,
title: 'Results',
body: 'Files from the network: size, user, bitrate, speed, free slots. Space selects, Enter queues a download. h/l or ←/→ scroll a long name, 0 and $ jump to either end; Home/End, g/G, PgUp/PgDn and Ctrl-f/b/d/u move through the list.',
},
{
num: 3,
title: 'Downloads / Uploads',
@@ -106,8 +116,10 @@ const FirstRun = () => (
const Layout = () => (
<Section>
<SectionHead eyebrow="2 · the layout" title="Four panes.">
The whole client is these four boxes. Press <Code>1</Code>–<Code>3</Code> to focus
one; the focused pane&rsquo;s legend turns green.
The whole client is these four boxes: results across the top, the rest in a row
underneath. <Code>Tab</Code> or <Code>1</Code>–<Code>3</Code> focuses one and its legend
turns green; <Code>z</Code> gives it the whole window, <Code>w</Code> hides it so the
others widen.
</SectionHead>
<Cols start>
<div className="overflow-hidden">
@@ -127,7 +139,8 @@ const Layout = () => (
const Keys = () => (
<Section>
<SectionHead eyebrow="3 · keys" title="It's all keyboard.">
The bottom bar always shows the keys for where you are. The essentials:
The bottom bar always shows the keys for where you are, and <Code>?</Code> lists every
one. The essentials:
</SectionHead>
<Cols>
<div>
@@ -138,6 +151,16 @@ const Keys = () => (
))}
</div>
</div>
<div>
<Eyebrow className="mb-4 block">panes and lists</Eyebrow>
<div className="flex flex-col">
{PANE_KEYS.map(([combo, description]) => (
<KeyRow key={combo} combo={combo} description={description} />
))}
</div>
</div>
</Cols>
<Cols className="mt-8">
<div>
<Eyebrow className="mb-4 block">
in the chat-rooms popup <span className="text-muted">(press c)</span>