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:
@@ -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
File diff suppressed because one or more lines are too long
|
Before Width: | Height: | Size: 1.0 MiB After Width: | Height: | Size: 908 KiB |
@@ -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();
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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));
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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([
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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'],
|
||||
]
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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[]
|
||||
|
||||
@@ -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’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>
|
||||
|
||||
Reference in New Issue
Block a user