Merge remote-tracking branch 'origin/develop'

This commit is contained in:
Michel de Graaf
2026-09-07 09:12:18 +00:00
23 changed files with 1984 additions and 369 deletions
+11
View File
@@ -91,6 +91,17 @@ soulseek-rs
Everything else is a one-shot command that runs headless. 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
`h`/`l` or `←`/`→` scroll a long file name or query sideways, `0` and `$` to
either end. `PgUp`/`PgDn` scroll a chat log back through its history, and `?`
shows every key for where you are.
### Daemon mode ### Daemon mode
A one-shot command logs in, does its job, and exits. Run a daemon instead and 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: 930 KiB

+349
View File
@@ -39,6 +39,141 @@ pub enum FocusedPane {
Downloads, Downloads,
} }
impl FocusedPane {
/// Every focusable pane, in the order `Tab` walks them: the number each
/// carries in its legend, which is also declaration order.
pub const ALL: [Self; 3] = [Self::Searches, Self::Results, Self::Downloads];
/// Its position in [`Self::ALL`].
const fn index(self) -> usize {
self as usize
}
}
/// 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; FocusedPane::ALL.len()],
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
}
}
/// Where a scrolling log is being read.
///
/// Either following its newest line, or held at a row of its history so
/// that arriving messages do not push the reader along. The renderer records
/// the log's size at each draw, which is what lets the keys move by a
/// screenful and stop at the ends.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
pub struct LogView {
/// The first visible row while held in the history; `None` follows the
/// tail.
top: Option<usize>,
/// Rows the log had, and rows the window showed, at the last draw.
rows: usize,
height: usize,
}
impl LogView {
/// Whether the newest line is on screen, so new messages are being seen.
#[must_use]
pub const fn following(&self) -> bool {
self.top.is_none()
}
/// Rows a page key moves: what the window showed last time.
#[must_use]
pub const fn page(&self) -> usize {
self.height
}
const fn last_top(&self) -> usize {
self.rows.saturating_sub(self.height)
}
/// The rows of a log `len` long to draw in a window `height` tall. A
/// held position that has reached the tail goes back to following it.
pub fn window(
&mut self,
len: usize,
height: usize,
) -> std::ops::Range<usize> {
self.rows = len;
self.height = height.max(1);
let top = match self.top {
Some(top) if top < self.last_top() => top,
_ => {
self.top = None;
self.last_top()
}
};
top..(top + self.height).min(len)
}
/// Move the window `rows` down the log (up, when negative), holding
/// wherever it lands unless that is the tail.
pub fn scroll(&mut self, rows: isize) {
let current = self.top.unwrap_or_else(|| self.last_top());
let next = current.saturating_add_signed(rows);
self.top = (next < self.last_top()).then_some(next);
}
pub const fn to_oldest(&mut self) {
self.top = Some(0);
}
pub const fn to_newest(&mut self) {
self.top = None;
}
}
/// What the shared command bar is currently capturing input for. /// What the shared command bar is currently capturing input for.
#[derive(Debug, Clone, Copy, PartialEq, Eq)] #[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum CommandBarMode { pub enum CommandBarMode {
@@ -70,6 +205,8 @@ pub struct AppState {
pub searches: Vec<SearchEntry>, pub searches: Vec<SearchEntry>,
pub searches_table_state: TableState, pub searches_table_state: TableState,
pub selected_search_index: Option<usize>, pub selected_search_index: Option<usize>,
/// Cells the query column is scrolled to the left.
pub searches_query_offset: usize,
// Results // Results
pub results_items: Vec<FileDisplayData>, pub results_items: Vec<FileDisplayData>,
@@ -79,11 +216,14 @@ pub struct AppState {
pub results_selected_indices: std::collections::HashSet<usize>, pub results_selected_indices: std::collections::HashSet<usize>,
pub results_filter_query: String, pub results_filter_query: String,
pub results_is_filtering: bool, pub results_is_filtering: bool,
/// Cells the file-name column is scrolled to the left.
pub results_name_offset: usize, pub results_name_offset: usize,
// Downloads // Downloads
pub downloads: Vec<DownloadEntry>, pub downloads: Vec<DownloadEntry>,
pub downloads_table_state: TableState, pub downloads_table_state: TableState,
/// Cells the transfers' file-name column is scrolled to the left.
pub downloads_name_offset: usize,
pub downloads_receiver_channel: pub downloads_receiver_channel:
Option<Receiver<(Download, Receiver<DownloadStatus>)>>, Option<Receiver<(Download, Receiver<DownloadStatus>)>>,
pub downloads_sender_channel: pub downloads_sender_channel:
@@ -92,6 +232,12 @@ pub struct AppState {
// UI State // UI State
pub focused_pane: FocusedPane, pub focused_pane: FocusedPane,
pub layout: PaneLayout,
/// The keys overlay (`?`) is open.
pub show_help: bool,
/// Where the keys overlay is being read, for a terminal too short to
/// show it whole.
pub help_view: LogView,
pub should_exit: bool, pub should_exit: bool,
pub command_bar_active: bool, pub command_bar_active: bool,
pub command_bar_input: String, pub command_bar_input: String,
@@ -108,6 +254,8 @@ pub struct AppState {
/// Compose buffer for the active conversation. /// Compose buffer for the active conversation.
pub chat_input: String, pub chat_input: String,
pub chat_composing: bool, pub chat_composing: bool,
/// Where the conversation is being read.
pub chat_view: LogView,
// Browse users' shared files (one tab per user) // Browse users' shared files (one tab per user)
pub browse: BrowseTabs, pub browse: BrowseTabs,
@@ -129,6 +277,9 @@ pub struct AppState {
pub searches_pane_area: Option<Rect>, pub searches_pane_area: Option<Rect>,
pub results_pane_area: Option<Rect>, pub results_pane_area: Option<Rect>,
pub downloads_pane_area: Option<Rect>, pub downloads_pane_area: Option<Rect>,
/// Where the open popup was last drawn, for its page size. Only one is
/// ever open, so one record serves them all.
pub popup_area: Option<Rect>,
} }
impl AppState { impl AppState {
@@ -147,6 +298,7 @@ impl AppState {
searches: Vec::new(), searches: Vec::new(),
searches_table_state, searches_table_state,
selected_search_index: None, selected_search_index: None,
searches_query_offset: 0,
results_items: Vec::new(), results_items: Vec::new(),
results_filtered_items: Vec::new(), results_filtered_items: Vec::new(),
@@ -159,11 +311,15 @@ impl AppState {
downloads: Vec::new(), downloads: Vec::new(),
downloads_table_state, downloads_table_state,
downloads_name_offset: 0,
downloads_receiver_channel: None, downloads_receiver_channel: None,
downloads_sender_channel: None, downloads_sender_channel: None,
active_downloads_count: 0, active_downloads_count: 0,
focused_pane: FocusedPane::Searches, focused_pane: FocusedPane::Searches,
layout: PaneLayout::default(),
show_help: false,
help_view: LogView::default(),
should_exit: false, should_exit: false,
command_bar_active: false, command_bar_active: false,
command_bar_input: String::new(), command_bar_input: String::new(),
@@ -176,6 +332,7 @@ impl AppState {
chat_peer: None, chat_peer: None,
chat_input: String::new(), chat_input: String::new(),
chat_composing: false, chat_composing: false,
chat_view: LogView::default(),
browse: BrowseTabs::new(), browse: BrowseTabs::new(),
show_browse: false, show_browse: false,
@@ -192,13 +349,59 @@ impl AppState {
searches_pane_area: None, searches_pane_area: None,
results_pane_area: None, results_pane_area: None,
downloads_pane_area: None, downloads_pane_area: None,
popup_area: None,
} }
} }
/// How far the focused list's long column is scrolled sideways.
pub const fn name_offset_mut(&mut self, pane: FocusedPane) -> &mut usize {
match pane {
FocusedPane::Searches => &mut self.searches_query_offset,
FocusedPane::Results => &mut self.results_name_offset,
FocusedPane::Downloads => &mut self.downloads_name_offset,
}
}
/// Where `pane` was last drawn; `None` while it is hidden.
#[must_use]
pub const fn pane_area(&self, pane: FocusedPane) -> Option<Rect> {
match pane {
FocusedPane::Searches => self.searches_pane_area,
FocusedPane::Results => self.results_pane_area,
FocusedPane::Downloads => self.downloads_pane_area,
}
}
/// 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. /// Open the chat popup on a conversation with `peer`, ready to type.
pub fn open_chat(&mut self, peer: String) { pub fn open_chat(&mut self, peer: String) {
self.chat_peer = Some(peer); self.chat_peer = Some(peer);
self.chat_input.clear(); self.chat_input.clear();
self.chat_view.to_newest();
self.chat_composing = true; self.chat_composing = true;
self.show_messages = true; self.show_messages = true;
self.unread_messages = 0; self.unread_messages = 0;
@@ -248,6 +451,10 @@ impl AppState {
(current + peers.len() - 1) % peers.len() (current + peers.len() - 1) % peers.len()
}; };
let peer = peers[next].to_string(); let peer = peers[next].to_string();
// A different conversation opens at its end; the same one stays put.
if next != current {
self.chat_view.to_newest();
}
self.chat_peer = Some(peer); self.chat_peer = Some(peer);
} }
} }
@@ -290,6 +497,148 @@ mod tests {
assert_eq!(state.active_chat_peer(), Some("bob")); 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 a_log_view_follows_the_tail_until_held_and_holds_against_new_rows() {
let mut view = LogView::default();
assert_eq!(view.window(100, 10), 90..100);
assert!(view.following());
view.scroll(-10);
assert_eq!(view.window(100, 10), 80..90);
assert!(!view.following());
// Five more rows arrive: the held window does not move.
assert_eq!(view.window(105, 10), 80..90);
// Back down to the tail resumes following, including new rows.
view.scroll(10);
view.scroll(10);
assert_eq!(view.window(105, 10), 95..105);
assert!(view.following());
view.to_oldest();
assert_eq!(view.window(105, 10), 0..10);
view.scroll(-10);
assert_eq!(view.window(105, 10), 0..10, "clamped at the top");
view.to_newest();
assert_eq!(view.window(105, 10), 95..105);
}
#[test]
fn a_log_shorter_than_its_window_is_never_held() {
let mut view = LogView::default();
assert_eq!(view.window(3, 10), 0..3);
view.to_oldest();
assert_eq!(view.window(3, 10), 0..3);
assert!(view.following(), "nothing to hold");
}
#[test]
fn cycling_to_the_same_chat_keeps_the_place() {
let mut state = AppState::new();
say(&mut state, "alice");
state.chat_view.window(50, 10);
state.chat_view.scroll(-10);
state.cycle_chat_peer(true);
assert!(!state.chat_view.following(), "only one chat: nothing moved");
say(&mut state, "bob");
state.cycle_chat_peer(true);
assert!(state.chat_view.following(), "a new chat opens at its end");
}
#[test] #[test]
fn opening_a_chat_pins_a_peer_with_no_messages_yet() { fn opening_a_chat_pins_a_peer_with_no_messages_yet() {
let mut state = AppState::new(); let mut state = AppState::new();
+2 -2
View File
@@ -5,8 +5,8 @@ mod rooms;
mod settings; mod settings;
pub use app_state::{ pub use app_state::{
AppState, ChatMessage, CommandBarMode, DownloadEntry, FocusedPane, AppState, ChatMessage, CommandBarMode, DownloadEntry, FocusedPane, LogView,
MessageDirection, SearchEntry, SearchStatus, MessageDirection, PaneLayout, SearchEntry, SearchStatus,
}; };
pub use browse::{ pub use browse::{
BrowseState, BrowseStatus, BrowseTabs, files_under, find_node, BrowseState, BrowseStatus, BrowseTabs, files_under, find_node,
+8
View File
@@ -1,3 +1,4 @@
use crate::models::LogView;
use chrono::{DateTime, Local}; use chrono::{DateTime, Local};
use soulseek_rs::types::{RoomEvent, RoomInfo}; use soulseek_rs::types::{RoomEvent, RoomInfo};
@@ -38,6 +39,8 @@ pub struct OpenRoom {
pub unread: usize, pub unread: usize,
/// Per-room compose buffer. /// Per-room compose buffer.
pub input: String, pub input: String,
/// Where the log is being read.
pub view: LogView,
} }
impl OpenRoom { impl OpenRoom {
@@ -160,6 +163,11 @@ impl RoomsState {
.and_then(|r| r.users.get(self.user_selected).cloned()) .and_then(|r| r.users.get(self.user_selected).cloned())
} }
/// Where the active room's log is being read, to move it.
pub fn active_view_mut(&mut self) -> Option<&mut LogView> {
self.open.get_mut(self.active).map(|room| &mut room.view)
}
pub const fn select_user_up(&mut self) { pub const fn select_user_up(&mut self) {
self.user_selected = self.user_selected.saturating_sub(1); self.user_selected = self.user_selected.saturating_sub(1);
} }
+15 -1
View File
@@ -1,6 +1,7 @@
use super::MainTui; use super::MainTui;
use super::input::{jumped, list_jump};
use crate::models::{BrowseStatus, files_under, find_node}; use crate::models::{BrowseStatus, files_under, find_node};
use ratatui::crossterm::event::{KeyCode, KeyEvent}; use ratatui::crossterm::event::{KeyCode, KeyEvent, KeyModifiers};
use std::{thread, time::Duration}; use std::{thread, time::Duration};
/// How long to wait for a browse response before showing a timeout notice. /// How long to wait for a browse response before showing a timeout notice.
@@ -12,6 +13,11 @@ impl MainTui {
self.state.show_browse = false; self.state.show_browse = false;
return; return;
} }
let jump = list_jump(key, self.popup_page());
// Control combinations are only ever jumps: ctrl-d pages, d downloads.
if jump.is_none() && key.modifiers.contains(KeyModifiers::CONTROL) {
return;
}
// Tab management: switch between browsed users or close the active tab. // Tab management: switch between browsed users or close the active tab.
match key.code { match key.code {
@@ -60,6 +66,14 @@ impl MainTui {
(rows, sel, row) (rows, sel, row)
}; };
if let Some(jump) = jump {
if let Some(browse) = self.state.browse.active_tab_mut() {
browse.selected_row = jumped(sel, rows.len(), jump);
}
self.sync_browse_selection();
return;
}
// Downloads need `&self.client` free of the browse borrow. // Downloads need `&self.client` free of the browse borrow.
match key.code { match key.code {
KeyCode::Enter if !row.is_folder => { KeyCode::Enter if !row.is_folder => {
+246 -89
View File
@@ -1,11 +1,14 @@
use super::MainTui; use super::MainTui;
use crate::models::{CommandBarMode, FocusedPane}; use crate::models::{CommandBarMode, FocusedPane, LogView};
use crate::ui::pane_block; use crate::ui::pane_block;
use crate::ui::panes::name_end_offset; use crate::ui::panes::{
InfoSubject, name_end_offset, query_end_offset, selected_transfer,
transfer_name_end_offset, upload_display_name,
};
use ratatui::crossterm::event::{ use ratatui::crossterm::event::{
KeyCode, KeyEvent, KeyModifiers, MouseButton, MouseEvent, MouseEventKind, KeyCode, KeyEvent, KeyModifiers, MouseButton, MouseEvent, MouseEventKind,
}; };
use ratatui::layout::Position; use ratatui::layout::{Position, Rect};
use ratatui::widgets::TableState; use ratatui::widgets::TableState;
const NAME_SCROLL_STEP: isize = 8; const NAME_SCROLL_STEP: isize = 8;
@@ -17,6 +20,23 @@ impl MainTui {
return self.handle_command_bar_input(key); 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 log for a terminal too short to show it all.
if self.state.show_help {
let view = &mut self.state.help_view;
match key.code {
KeyCode::Char('?' | 'q') | KeyCode::Esc => {
self.state.show_help = false;
}
KeyCode::Down | KeyCode::Char('j') => view.scroll(1),
KeyCode::Up | KeyCode::Char('k') => view.scroll(-1),
_ => {
scroll_log(view, key);
}
}
return;
}
// Chat popup takes over navigation while open. // Chat popup takes over navigation while open.
if self.state.show_messages { if self.state.show_messages {
return self.handle_chat_input(key); return self.handle_chat_input(key);
@@ -33,10 +53,10 @@ impl MainTui {
return self.handle_settings_input(key); 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 key.modifiers.contains(KeyModifiers::CONTROL) {
if self.state.focused_pane == FocusedPane::Results { self.navigate_focused_list(key);
self.handle_results_input(key);
}
return; return;
} }
@@ -53,16 +73,35 @@ impl MainTui {
self.state.should_exit = true; self.state.should_exit = true;
return; return;
} }
KeyCode::Char('1') => { KeyCode::Char('?') => {
self.state.focused_pane = FocusedPane::Searches; self.state.show_help = true;
self.state.help_view.to_oldest();
return; return;
} }
KeyCode::Char('2') => { // A pane's number is its place in the legend order.
self.state.focused_pane = FocusedPane::Results; KeyCode::Char(digit @ '1'..='3') => {
let index = usize::from(digit as u8 - b'1');
self.state.focus_pane(FocusedPane::ALL[index]);
return; return;
} }
KeyCode::Char('3') => { KeyCode::Tab => {
self.state.focused_pane = FocusedPane::Downloads; 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; return;
} }
KeyCode::Char('s') => { KeyCode::Char('s') => {
@@ -147,6 +186,14 @@ impl MainTui {
return; return;
} }
// Paging keys move through the conversation's history.
if scroll_log(&mut self.state.chat_view, key) {
return;
}
if key.modifiers.contains(KeyModifiers::CONTROL) {
return;
}
match key.code { match key.code {
KeyCode::Char('i' | 'q') | KeyCode::Esc => { KeyCode::Char('i' | 'q') | KeyCode::Esc => {
self.state.show_messages = false; self.state.show_messages = false;
@@ -306,15 +353,44 @@ 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();
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 popup shows at once: its inner height less a tab bar and a
/// compose line, which is what its list or log has left.
pub(super) fn popup_page(&self) -> usize {
page_of(self.state.popup_area, 2)
}
/// Rows the focused pane shows at once: its inner height less the table
/// header.
fn page_size(&self) -> usize {
page_of(self.state.pane_area(self.state.focused_pane), 1)
}
fn handle_searches_input(&mut self, key: KeyEvent) { fn handle_searches_input(&mut self, key: KeyEvent) {
let rows = self.state.searches.len(); if self.navigate_focused_list(key) || self.scroll_focused_name(key) {
return;
}
match key.code { 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 => { KeyCode::Enter => {
if let Some(selected) = if let Some(selected) =
self.state.searches_table_state.selected() self.state.searches_table_state.selected()
@@ -329,7 +405,7 @@ impl MainTui {
self.state.results_selected_indices.clear(); self.state.results_selected_indices.clear();
self.state.results_name_offset = 0; self.state.results_name_offset = 0;
self.state.results_table_state.select(Some(0)); self.state.results_table_state.select(Some(0));
self.state.focused_pane = FocusedPane::Results; self.state.focus_pane(FocusedPane::Results);
} }
} }
} }
@@ -348,41 +424,10 @@ impl MainTui {
} }
fn handle_results_input(&mut self, key: KeyEvent) { fn handle_results_input(&mut self, key: KeyEvent) {
let ctrl = key.modifiers.contains(KeyModifiers::CONTROL); if self.navigate_focused_list(key) || self.scroll_focused_name(key) {
if ctrl && !matches!(key.code, KeyCode::Char('f' | 'b' | 'd' | 'u')) {
return; return;
} }
let items_count = self.visible_results().len();
let page = self.results_page_size();
let half = (page / 2).max(1);
match key.code { 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);
}
KeyCode::Left | KeyCode::Char('h') => {
self.scroll_names(-NAME_SCROLL_STEP);
}
KeyCode::Char('0') => self.state.results_name_offset = 0,
KeyCode::Char('$') => {
self.state.results_name_offset = self.highlighted_name_end();
}
KeyCode::Char(' ') => { KeyCode::Char(' ') => {
let index = self let index = self
.state .state
@@ -418,52 +463,78 @@ impl MainTui {
} }
} }
fn select_row(&mut self, target: usize) { /// The keys that scroll the focused list's long column sideways, the
let len = self.visible_results().len(); /// same in every list: a file name in Results and Downloads, the query
if len > 0 { /// in Searches. Says whether `key` was one of them.
self.state fn scroll_focused_name(&mut self, key: KeyEvent) -> bool {
.results_table_state match key.code {
.select(Some(target.min(len - 1))); KeyCode::Right | KeyCode::Char('l') => {
self.scroll_names(NAME_SCROLL_STEP);
} }
KeyCode::Left | KeyCode::Char('h') => {
self.scroll_names(-NAME_SCROLL_STEP);
} }
KeyCode::Char('0') => {
fn scroll_rows(&mut self, delta: isize) { *self.state.name_offset_mut(self.state.focused_pane) = 0;
let current = self.state.results_table_state.selected().unwrap_or(0); }
self.select_row(current.saturating_add_signed(delta)); KeyCode::Char('$') => {
let end = self.highlighted_name_end();
*self.state.name_offset_mut(self.state.focused_pane) = end;
}
_ => return false,
}
true
} }
fn scroll_names(&mut self, delta: isize) { fn scroll_names(&mut self, delta: isize) {
let end = self.highlighted_name_end(); let end = self.highlighted_name_end();
let current = self.state.results_name_offset.min(end); let offset = self.state.name_offset_mut(self.state.focused_pane);
self.state.results_name_offset = let current = (*offset).min(end);
current.saturating_add_signed(delta).min(end); *offset = current.saturating_add_signed(delta).min(end);
} }
/// How far the highlighted row's long column can scroll before its end
/// is in view. Zero when nothing is highlighted or the pane is not drawn.
fn highlighted_name_end(&self) -> usize { fn highlighted_name_end(&self) -> usize {
let Some(area) = self.state.results_pane_area else { let pane = self.state.focused_pane;
let Some(area) = self.state.pane_area(pane) else {
return 0; return 0;
}; };
self.highlighted_result() match pane {
.map_or(0, |file| name_end_offset(&file.filename, area)) FocusedPane::Searches => self
.state
.searches_table_state
.selected()
.and_then(|row| self.state.searches.get(row))
.map_or(0, |search| query_end_offset(&search.query, area)),
FocusedPane::Results => self
.highlighted_result()
.map_or(0, |file| name_end_offset(&file.filename, area)),
FocusedPane::Downloads => {
let shown = match selected_transfer(
self.state.downloads_table_state.selected(),
&self.state.downloads,
&self.state.uploads,
) {
Some(InfoSubject::Download(entry)) => {
entry.download.filename.as_str()
}
Some(InfoSubject::Upload(upload)) => {
upload_display_name(upload)
}
Some(InfoSubject::Result(_)) | None => return 0,
};
transfer_name_end_offset(shown, 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) { fn handle_downloads_input(&mut self, key: KeyEvent) {
// The pane lists downloads first, then uploads; navigation spans both. // 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) || self.scroll_focused_name(key) {
return;
}
match key.code { 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') => { KeyCode::Char('x') => {
self.cancel_selected_transfer(); self.cancel_selected_transfer();
} }
@@ -489,18 +560,104 @@ impl MainTui {
} }
let clicked = Position::new(mouse.column, mouse.row); let clicked = Position::new(mouse.column, mouse.row);
let panes = [ // The areas are from the last draw. A pane hidden since then, in the
(self.state.searches_pane_area, FocusedPane::Searches), // same batch of events, still has one, and is not there to click.
(self.state.results_pane_area, FocusedPane::Results), let hit = FocusedPane::ALL.into_iter().find(|pane| {
(self.state.downloads_pane_area, FocusedPane::Downloads), self.state.layout.is_visible(*pane)
]; && self
for (area, pane) in panes { .state
if area.is_some_and(|area| area.contains(clicked)) { .pane_area(*pane)
.is_some_and(|area| area.contains(clicked))
});
if let Some(pane) = hit {
self.state.focused_pane = pane; self.state.focused_pane = pane;
return; }
}
}
/// Rows a list in `area` shows at once, less the `chrome` rows above it: a
/// table header, a popup's tab bar and compose line. At least one, so a page
/// key always moves, even before the first draw.
fn page_of(area: Option<Rect>, chrome: u16) -> usize {
area.map_or(0, |area| {
usize::from(pane_block(false).inner(area).height.saturating_sub(chrome))
})
.max(1)
}
/// A move over a whole list at once: to either end, or by a number of rows
/// (negative is up).
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub(super) enum ListJump {
First,
Last,
Rows(isize),
}
/// The keys every list and log answers alike for moving further than a row:
/// the two ends, a page, half a page. `page` is how many rows the list shows.
pub(super) fn list_jump(key: KeyEvent, page: usize) -> Option<ListJump> {
let ctrl = key.modifiers.contains(KeyModifiers::CONTROL);
let page = isize::try_from(page).unwrap_or(isize::MAX);
let half = (page / 2).max(1);
Some(match key.code {
KeyCode::Home | KeyCode::Char('g') if !ctrl => ListJump::First,
KeyCode::End | KeyCode::Char('G') if !ctrl => ListJump::Last,
KeyCode::PageDown => ListJump::Rows(page),
KeyCode::PageUp => ListJump::Rows(-page),
KeyCode::Char('f') if ctrl => ListJump::Rows(page),
KeyCode::Char('b') if ctrl => ListJump::Rows(-page),
KeyCode::Char('d') if ctrl => ListJump::Rows(half),
KeyCode::Char('u') if ctrl => ListJump::Rows(-half),
_ => return None,
})
}
/// Apply the jump keys to a log, moving by the screenful it last showed.
/// Says whether `key` was one of them.
pub(super) fn scroll_log(view: &mut LogView, key: KeyEvent) -> bool {
match list_jump(key, view.page()) {
Some(ListJump::First) => view.to_oldest(),
Some(ListJump::Last) => view.to_newest(),
Some(ListJump::Rows(rows)) => view.scroll(rows),
None => return false,
}
true
}
/// `selected` moved by `jump` over a list `len` long, stopping at the ends.
pub(super) fn jumped(selected: usize, len: usize, jump: ListJump) -> usize {
let last = len.saturating_sub(1);
match jump {
ListJump::First => 0,
ListJump::Last => last,
ListJump::Rows(rows) => selected.saturating_add_signed(rows).min(last),
}
}
/// The keys every list answers alike: a row at a time wrapping at either
/// end, and the jumps of [`list_jump`]. Says whether `key` was one of them.
fn navigate_list(
key: KeyEvent,
table: &mut TableState,
len: usize,
page: usize,
) -> bool {
let ctrl = key.modifiers.contains(KeyModifiers::CONTROL);
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),
_ => {
let Some(jump) = list_jump(key, page) else {
return false;
};
if len > 0 {
let current = table.selected().unwrap_or(0);
table.select(Some(jumped(current, len, jump)));
} }
} }
} }
true
} }
/// Move a table's selection one row, wrapping at either end. /// Move a table's selection one row, wrapping at either end.
+511 -2
View File
@@ -1341,13 +1341,522 @@ mod tests {
assert!(tui.state.results_selected_indices.is_empty()); assert!(tui.state.results_selected_indices.is_empty());
} }
fn screen_of(tui: &mut MainTui) -> String { fn screen_sized(tui: &mut MainTui, width: u16, height: u16) -> String {
let backend = ratatui::backend::TestBackend::new(160, 40); let backend = ratatui::backend::TestBackend::new(width, height);
let mut terminal = ratatui::Terminal::new(backend).expect("backend"); let mut terminal = ratatui::Terminal::new(backend).expect("backend");
terminal.draw(|frame| tui.render(frame)).expect("draw"); terminal.draw(|frame| tui.render(frame)).expect("draw");
terminal.backend().to_string() 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("next room, chat or user"), "{screen}");
press(&mut tui, KeyCode::End);
let screen = screen_sized(&mut tui, 80, 24);
assert!(screen.contains("next room, chat or user"), "{screen}");
assert!(!screen.contains("next / previous pane"), "{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 l_and_h_scroll_a_transfer_name_and_a_query_too() {
let mut tui = furnished_tui();
tui.state.downloads[0].download.filename = "y".repeat(120);
tui.state.searches[0].query = "z".repeat(60);
let _ = screen_of(&mut tui);
tui.state.focused_pane = FocusedPane::Downloads;
press(&mut tui, KeyCode::Char('l'));
assert_eq!(tui.state.downloads_name_offset, 8);
press(&mut tui, KeyCode::Char('$'));
let end = tui.state.downloads_name_offset;
assert!(end > 8, "the end is past a step: {end}");
let screen = screen_of(&mut tui);
assert!(row_with(&screen, "…yyyy").contains("…yyyy"), "{screen}");
press(&mut tui, KeyCode::Char('0'));
assert_eq!(tui.state.downloads_name_offset, 0);
tui.state.focused_pane = FocusedPane::Searches;
press(&mut tui, KeyCode::Right);
assert_eq!(tui.state.searches_query_offset, 8);
press(&mut tui, KeyCode::End);
assert_eq!(
tui.state.searches_table_state.selected(),
Some(0),
"End is still a row key, $ is the name key"
);
press(&mut tui, KeyCode::Char('$'));
assert!(tui.state.searches_query_offset > 8);
let screen = screen_of(&mut tui);
assert!(screen.contains("…zzzz"), "{screen}");
assert!(
tui.state.results_name_offset == 0,
"each list scrolls on its own"
);
}
fn browsed(tui: &mut MainTui, files: usize) {
tui.state.browse.open("bob");
let listing = vec![soulseek_rs::SharedDirectory {
name: "Music".to_string(),
files: (0..files).map(|i| (format!("{i:02}.mp3"), 1)).collect(),
}];
tui.state
.browse
.active_tab_mut()
.expect("tab")
.load(&listing);
tui.state.show_browse = true;
let _ = screen_of(tui);
}
#[test]
fn page_keys_walk_the_browse_tree_and_ctrl_d_does_not_download() {
let session = Arc::new(TalkativeSession::default());
let mut tui = attach(session);
browsed(&mut tui, 40);
let rows = tui.state.browse.active_tab().expect("tab").rows().len();
assert_eq!(rows, 41, "the folder and its files");
press(&mut tui, KeyCode::End);
let selected = |tui: &MainTui| {
tui.state.browse.active_tab().expect("tab").selected_row
};
assert_eq!(selected(&tui), 40);
press(&mut tui, KeyCode::Home);
assert_eq!(selected(&tui), 0);
press(&mut tui, KeyCode::PageDown);
let page = selected(&tui);
assert!(page > 1 && page < 40, "a page of rows: {page}");
ctrl(&mut tui, 'd');
assert!(selected(&tui) > page, "ctrl-d pages");
assert!(
tui.state.downloads.is_empty()
&& tui.state.downloads_receiver_channel.is_none(),
"and does not download the folder"
);
press(&mut tui, KeyCode::Char('G'));
assert_eq!(selected(&tui), 40);
assert!(tui.state.show_browse, "still open");
}
fn in_a_room(tui: &mut MainTui, messages: usize) {
tui.state.rooms.apply_event(
soulseek_rs::RoomEvent::Joined {
room: "jazz".to_string(),
users: (0..30).map(|i| format!("user{i:02}")).collect(),
},
None,
);
tui.state.rooms.focus_or_open("jazz");
for i in 0..messages {
tui.state.rooms.apply_event(
soulseek_rs::RoomEvent::Message {
room: "jazz".to_string(),
username: "alice".to_string(),
message: format!("line {i:03}"),
},
Some("jazz"),
);
}
tui.state.show_rooms = true;
let _ = screen_of(tui);
}
#[test]
fn page_up_scrolls_a_room_log_back_and_end_returns_to_the_newest() {
let mut tui = with_session(TalkativeSession::default());
in_a_room(&mut tui, 120);
let screen = screen_of(&mut tui);
assert!(screen.contains("line 119"), "tails by default: {screen}");
assert!(!screen.contains("line 000"), "{screen}");
press(&mut tui, KeyCode::PageUp);
let screen = screen_of(&mut tui);
assert!(!screen.contains("line 119"), "scrolled back: {screen}");
press(&mut tui, KeyCode::Home);
let screen = screen_of(&mut tui);
assert!(screen.contains("line 000"), "the oldest: {screen}");
press(&mut tui, KeyCode::PageUp);
let screen = screen_of(&mut tui);
assert!(screen.contains("line 000"), "clamped at the top: {screen}");
press(&mut tui, KeyCode::End);
let screen = screen_of(&mut tui);
assert!(screen.contains("line 119"), "{screen}");
// The member list keeps its own keys, and ctrl-x is not x.
press(&mut tui, KeyCode::Down);
assert_eq!(tui.state.rooms.user_selected, 1);
ctrl(&mut tui, 'x');
assert_eq!(tui.state.rooms.open.len(), 1, "still in the room");
}
#[test]
fn page_keys_move_through_the_room_list() {
let mut tui = with_session(TalkativeSession::default());
tui.state.rooms.apply_event(
soulseek_rs::RoomEvent::List(
(0..60)
.map(|i| soulseek_rs::types::RoomInfo {
name: format!("room{i:02}"),
user_count: 60 - i,
})
.collect(),
),
None,
);
tui.state.show_rooms = true;
let _ = screen_of(&mut tui);
press(&mut tui, KeyCode::End);
assert_eq!(tui.state.rooms.list_selected, 59);
press(&mut tui, KeyCode::PageUp);
assert!(tui.state.rooms.list_selected < 59);
press(&mut tui, KeyCode::Char('g'));
assert_eq!(tui.state.rooms.list_selected, 0);
ctrl(&mut tui, 'f');
assert!(tui.state.rooms.list_selected > 1);
}
#[test]
fn page_up_scrolls_a_conversation_and_switching_chats_resets_it() {
let mut tui = tui((0..80)
.map(|i| ChatMessageDto {
peer: "bob".into(),
outgoing: i % 2 == 0,
text: format!("msg {i:03}"),
at: i,
})
.chain(std::iter::once(ChatMessageDto {
peer: "carol".into(),
outgoing: false,
text: "hello".into(),
at: 100,
}))
.collect());
tui.state.chat_peer = Some("bob".to_string());
tui.state.show_messages = true;
let screen = screen_of(&mut tui);
assert!(screen.contains("msg 079"), "{screen}");
press(&mut tui, KeyCode::PageUp);
let screen = screen_of(&mut tui);
assert!(!screen.contains("msg 079"), "scrolled back: {screen}");
press(&mut tui, KeyCode::Char('g'));
let screen = screen_of(&mut tui);
assert!(screen.contains("msg 000"), "{screen}");
press(&mut tui, KeyCode::Tab);
assert_eq!(tui.state.active_chat_peer(), Some("carol"));
assert!(
tui.state.chat_view.following(),
"a fresh chat starts at its end"
);
press(&mut tui, KeyCode::BackTab);
let screen = screen_of(&mut tui);
assert!(screen.contains("msg 079"), "{screen}");
}
#[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] #[test]
fn the_info_pane_describes_the_highlighted_result() { fn the_info_pane_describes_the_highlighted_result() {
let mut tui = results_tui(3); let mut tui = results_tui(3);
+374 -91
View File
@@ -6,8 +6,9 @@ use crate::ui::panes::{
render_rooms_pane, render_searches_pane, selected_transfer, render_rooms_pane, render_searches_pane, selected_transfer,
}; };
use crate::ui::{ use crate::ui::{
accent_style, dimmed_style, format_shortcuts_styled, pane_block, accent_style, body_style, dimmed_style, info_style, pack_shortcuts,
plain_title, primary_style, render_download_stats, warning_style, pane_block, plain_title, primary_style, render_download_stats,
warning_style,
}; };
use ratatui::{ use ratatui::{
Frame, Frame,
@@ -17,8 +18,90 @@ use ratatui::{
widgets::Paragraph, 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"),
("h l / ← →", "scroll a long name sideways"),
("0 / $", "its start / its end"),
],
),
];
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"),
("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"),
],
),
(
"Chat and browse popups",
&[
("PgUp PgDn / ^b ^f", "a page of messages, or of the tree"),
("^u ^d", "half a page of them"),
("Home End / g G", "oldest / newest, or first / last row"),
("Tab / Shift-Tab", "next room, chat or user"),
],
),
];
impl MainTui { impl MainTui {
pub(super) fn render(&mut self, frame: &mut Frame) { pub(super) fn render(&mut self, frame: &mut Frame) {
let shortcuts = self.shortcut_rows(frame.area().width);
let mut constraints = vec![ let mut constraints = vec![
Constraint::Length(3), // Status bar Constraint::Length(3), // Status bar
Constraint::Fill(1), // Main content Constraint::Fill(1), // Main content
@@ -26,7 +109,7 @@ impl MainTui {
if self.state.command_bar_active { if self.state.command_bar_active {
constraints.push(Constraint::Length(3)); // Command bar 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 main_chunks = Layout::vertical(constraints).split(frame.area());
let daemon = self.client.daemon_endpoint(); let daemon = self.client.daemon_endpoint();
@@ -43,55 +126,109 @@ impl MainTui {
// Render command bar if active (vim-style, above shortcuts) // Render command bar if active (vim-style, above shortcuts)
if self.state.command_bar_active { if self.state.command_bar_active {
self.render_command_bar(frame, main_chunks[2]); self.render_command_bar(frame, main_chunks[2]);
self.render_shortcuts(frame, main_chunks[3]); self.render_shortcuts(frame, main_chunks[3], shortcuts);
} else { } else {
self.render_shortcuts(frame, main_chunks[2]); self.render_shortcuts(frame, main_chunks[2], shortcuts);
} }
self.render_overlays(frame); 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) { fn render_content(&mut self, frame: &mut Frame, area: Rect) {
// Split main content area // Only what is drawn can be clicked.
let content_chunks = Layout::horizontal([ self.state.searches_pane_area = None;
Constraint::Percentage(30), // Searches pane self.state.results_pane_area = None;
Constraint::Percentage(70), // Results + Downloads self.state.downloads_pane_area = None;
if self.state.layout.zoomed {
self.render_pane(frame, area, self.state.focused_pane);
return;
}
// Transfers carry the most columns, so they get the most width.
let row_panes: Vec<(FocusedPane, u16)> =
[(FocusedPane::Searches, 5), (FocusedPane::Downloads, 10)]
.into_iter()
.filter(|(pane, _)| self.state.layout.is_visible(*pane))
.collect();
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),
]) ])
.split(area); .areas(area);
self.render_pane(frame, top, FocusedPane::Results);
bottom
} else {
area
};
// Store searches pane area let mut constraints: Vec<Constraint> = row_panes
self.state.searches_pane_area = Some(content_chunks[0]); .iter()
.map(|(_, weight)| Constraint::Fill(*weight))
.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()]);
}
// Render Searches pane (left) 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( render_searches_pane(
frame, frame,
content_chunks[0], area,
&self.state.searches, &self.state.searches,
&mut self.state.searches_table_state, &mut self.state.searches_table_state,
self.state.focused_pane == FocusedPane::Searches, focused,
self.state.searches_query_offset,
); );
}
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,
self.state.downloads_name_offset,
);
}
}
}
// Split right side into Results (top) and Downloads (bottom) fn render_results_pane(
let right_chunks = Layout::vertical([ &mut self,
Constraint::Percentage(60), // Results frame: &mut Frame,
Constraint::Percentage(40), // Downloads area: Rect,
]) focused: bool,
.split(content_chunks[1]); ) {
// When a filter is active the rendered rows are a subset, so the pane
// Split bottom-right into Downloads list and Info pane // also needs the mapping back to unfiltered indices to render the
let downloads_chunks = Layout::horizontal([ // selection checkboxes correctly.
Constraint::Percentage(60), // Downloads list
Constraint::Percentage(40), // Info pane
])
.split(right_chunks[1]);
// Store results and downloads pane areas
self.state.results_pane_area = Some(right_chunks[0]);
self.state.downloads_pane_area = Some(downloads_chunks[0]);
// 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.
let (results_items, results_original_indices) = let (results_items, results_original_indices) =
if self.state.results_filter_query.is_empty() { if self.state.results_filter_query.is_empty() {
(&self.state.results_items, None) (&self.state.results_items, None)
@@ -110,7 +247,7 @@ impl MainTui {
render_results_pane( render_results_pane(
frame, frame,
right_chunks[0], area,
ResultsPaneParams { ResultsPaneParams {
items: results_items, items: results_items,
table_state: &mut self.state.results_table_state, table_state: &mut self.state.results_table_state,
@@ -118,21 +255,16 @@ impl MainTui {
original_indices: results_original_indices, original_indices: results_original_indices,
filter_query: &self.state.results_filter_query, filter_query: &self.state.results_filter_query,
is_filtering: self.state.results_is_filtering, is_filtering: self.state.results_is_filtering,
focused: self.state.focused_pane == FocusedPane::Results, focused,
active_search_query, active_search_query,
name_offset: self.state.results_name_offset, name_offset: self.state.results_name_offset,
}, },
); );
}
render_downloads_pane( /// The Info pane follows the focus: the highlighted result, or the
frame, /// highlighted transfer.
downloads_chunks[0], fn render_info_pane(&self, frame: &mut Frame, area: Rect) {
&self.state.downloads,
&self.state.uploads,
&mut self.state.downloads_table_state,
self.state.focused_pane == FocusedPane::Downloads,
);
let selected = if self.state.focused_pane == FocusedPane::Results { let selected = if self.state.focused_pane == FocusedPane::Results {
self.highlighted_result().map(InfoSubject::Result) self.highlighted_result().map(InfoSubject::Result)
} else { } else {
@@ -144,7 +276,7 @@ impl MainTui {
}; };
render_download_info_pane( render_download_info_pane(
frame, frame,
downloads_chunks[1], area,
selected, selected,
matches!( matches!(
self.state.focused_pane, self.state.focused_pane,
@@ -153,6 +285,53 @@ 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 columns = help_columns(screen.width.saturating_sub(frame_width));
let gaps = HELP_GAP * (columns.len() as u16 - 1);
let text_width = columns.iter().map(|lines| widest(lines)).sum::<u16>();
let width = screen.width.min(text_width + gaps + 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.
let window = self
.state
.help_view
.window(tallest, usize::from(inner.height));
let scroll = u16::try_from(window.start).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) { fn render_overlays(&mut self, frame: &mut Frame) {
// Messages inbox overlays everything when open. // Messages inbox overlays everything when open.
if self.state.show_messages { if self.state.show_messages {
@@ -162,6 +341,7 @@ impl MainTui {
// Browse tree overlays everything when open. // Browse tree overlays everything when open.
if self.state.show_browse && !self.state.browse.is_empty() { if self.state.show_browse && !self.state.browse.is_empty() {
let area = centered_rect(80, 80, frame.area()); let area = centered_rect(80, 80, frame.area());
self.state.popup_area = Some(area);
frame.render_widget(ratatui::widgets::Clear, area); frame.render_widget(ratatui::widgets::Clear, area);
render_browse_pane( render_browse_pane(
frame, frame,
@@ -180,14 +360,21 @@ impl MainTui {
// Chat rooms overlay everything when open. // Chat rooms overlay everything when open.
if self.state.show_rooms { if self.state.show_rooms {
let area = centered_rect(85, 80, frame.area()); let area = centered_rect(85, 80, frame.area());
self.state.popup_area = Some(area);
frame.render_widget(ratatui::widgets::Clear, area); frame.render_widget(ratatui::widgets::Clear, area);
render_rooms_pane( render_rooms_pane(
frame, frame,
area, area,
&self.state.rooms, &mut self.state.rooms,
&mut self.state.rooms_list_table_state, &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) { fn render_settings_popup(&self, frame: &mut Frame) {
@@ -257,12 +444,14 @@ impl MainTui {
); );
} }
fn render_messages_popup(&self, frame: &mut Frame) { fn render_messages_popup(&mut self, frame: &mut Frame) {
// The per-conversation chat box supersedes the old flat message list; // The per-conversation chat box supersedes the old flat message list;
// it renders the messages and the compose line itself. // it renders the messages and the compose line itself.
let area = centered_rect(70, 60, frame.area()); let area = centered_rect(70, 60, frame.area());
frame.render_widget(ratatui::widgets::Clear, area); frame.render_widget(ratatui::widgets::Clear, area);
render_chat_pane(frame, area, &self.state, &self.client.username()); self.state.popup_area = Some(area);
let own = self.client.username();
render_chat_pane(frame, area, &mut self.state, &own);
} }
/// Context shortcuts for the chat-rooms popup. /// Context shortcuts for the chat-rooms popup.
@@ -294,6 +483,7 @@ impl MainTui {
} }
RoomsView::Chat => vec![ RoomsView::Chat => vec![
("Enter", "say"), ("Enter", "say"),
("PgUp/PgDn", "scroll"),
("↑↓", "pick user"), ("↑↓", "pick user"),
("b", "browse user"), ("b", "browse user"),
("m", "message user"), ("m", "message user"),
@@ -304,10 +494,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 /// Which shortcuts the bar offers, which is purely a question of what is
/// open and where the focus sits. Sibling of [`Self::rooms_shortcuts`]. /// open and where the focus sits. Sibling of [`Self::rooms_shortcuts`].
fn shortcuts(&self) -> Vec<(&'static str, &'static str)> { 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![ vec![
("↑↓", "move"), ("↑↓", "move"),
("Enter/e", "edit download dir"), ("Enter/e", "edit download dir"),
@@ -326,6 +537,7 @@ impl MainTui {
} else { } else {
vec![ vec![
("Enter", "type"), ("Enter", "type"),
("PgUp/PgDn", "scroll"),
("↑↓/Tab", "switch chat"), ("↑↓/Tab", "switch chat"),
("m", "new chat"), ("m", "new chat"),
("i/Esc", "close"), ("i/Esc", "close"),
@@ -336,6 +548,7 @@ impl MainTui {
} else if self.state.show_browse { } else if self.state.show_browse {
vec![ vec![
("↑↓", "move"), ("↑↓", "move"),
("PgUp/PgDn", "page"),
("→←", "expand/collapse"), ("→←", "expand/collapse"),
("Enter", "open/download"), ("Enter", "open/download"),
("d", "download folder"), ("d", "download folder"),
@@ -364,29 +577,29 @@ impl MainTui {
("Esc", "cancel"), ("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 { } else {
match self.state.focused_pane { let mut keys = match self.state.focused_pane {
FocusedPane::Searches => vec![ FocusedPane::Searches => vec![
("s", "search"), ("s", "search"),
("Enter", "results"),
("d", "remove"),
("m", "message"), ("m", "message"),
("i", "inbox"), ("i", "inbox"),
("c", "chat"), ("c", "chat"),
("b", "browse user"), ("b", "browse user"),
("1-3", "focus pane"), ("h/l", "scroll query"),
("↑↓", "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![ FocusedPane::Results => vec![
("Space", "select"), ("Space", "select"),
("Enter", "download"), ("Enter", "download"),
@@ -394,29 +607,51 @@ impl MainTui {
("c", "chat"), ("c", "chat"),
("/", "filter"), ("/", "filter"),
("a/A", "select all/none"), ("a/A", "select all/none"),
("h/l", "scroll"), ("h/l", "scroll name"),
("g/G", "top/end"), ("g/G", "top/end"),
("1-3", "focus pane"),
("q", "quit"),
], ],
FocusedPane::Downloads => { FocusedPane::Downloads => vec![
vec![
("p", "pause/resume"), ("p", "pause/resume"),
("x", "cancel"), ("x", "cancel"),
("r", "retry failed"), ("r", "retry failed"),
("d", "delete queued/done"), ("d", "delete queued/done"),
("c", "clear finished"), ("c", "clear finished"),
("b", "browse user"), ("b", "browse user"),
("1-3", "focus pane"), ("h/l", "scroll name"),
("q", "quit"), ],
] };
} keys.extend(self.pane_shortcuts());
} keys
} }
} }
fn render_shortcuts(&self, frame: &mut Frame, area: Rect) { /// The legend packed into rows for a window `width` wide, so a narrow
let shortcuts_line = format_shortcuts_styled(&self.shortcuts()); /// 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. // Surface our own sharing status in the block title.
let shared = self.client.shared_directories(); let shared = self.client.shared_directories();
let sharing = match shared.as_slice() { let sharing = match shared.as_slice() {
@@ -431,26 +666,20 @@ impl MainTui {
let inner = block.inner(area); let inner = block.inner(area);
frame.render_widget(block, area); frame.render_widget(block, area);
// A prominent unread badge, right-aligned so the context shortcuts on // The unread badge is right-aligned so the context shortcuts on the
// the left never crowd it out. It rides in the always-visible shortcuts // left never crowd it out.
// bar, so unread mail/chat shows in every pane, not just some. The let unread = self.unread_badge();
// 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,
);
if unread.spans.is_empty() { if unread.spans.is_empty() {
frame.render_widget(Paragraph::new(shortcuts_line), inner); frame.render_widget(Paragraph::new(rows), inner);
} else { } else {
let badge_width = unread.width() as u16; let badge_width = unread.width() as u16;
let cols = Layout::horizontal([ let cols = Layout::horizontal([
Constraint::Fill(1), Constraint::Fill(1),
Constraint::Length(badge_width), Constraint::Length(badge_width),
]) ])
.spacing(1)
.split(inner); .split(inner);
frame.render_widget(Paragraph::new(shortcuts_line), cols[0]); frame.render_widget(Paragraph::new(rows), cols[0]);
frame.render_widget( frame.render_widget(
Paragraph::new(unread).alignment(Alignment::Right), Paragraph::new(unread).alignment(Alignment::Right),
cols[1], cols[1],
@@ -493,6 +722,60 @@ 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 the widest of `lines` takes.
fn widest(lines: &[Line<'_>]) -> u16 {
let cells = lines.iter().map(Line::width).max().unwrap_or(0);
u16::try_from(cells).unwrap_or(u16::MAX)
}
/// 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 >= widest(&left) + HELP_GAP + widest(&right) {
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$} "), info_style()),
Span::styled((*action).to_string(), body_style()),
]));
}
}
lines
}
/// A `Rect` centered within `area`, sized to the given percentages. /// A `Rect` centered within `area`, sized to the given percentages.
fn centered_rect(percent_x: u16, percent_y: u16, area: Rect) -> Rect { fn centered_rect(percent_x: u16, percent_y: u16, area: Rect) -> Rect {
let vertical = Layout::vertical([ let vertical = Layout::vertical([
+36 -7
View File
@@ -1,4 +1,5 @@
use super::MainTui; use super::MainTui;
use super::input::{jumped, list_jump, scroll_log};
use crate::models::RoomsView; use crate::models::RoomsView;
use ratatui::crossterm::event::{KeyCode, KeyEvent, KeyModifiers}; use ratatui::crossterm::event::{KeyCode, KeyEvent, KeyModifiers};
@@ -38,6 +39,16 @@ impl MainTui {
fn handle_rooms_list_input(&mut self, key: KeyEvent) { fn handle_rooms_list_input(&mut self, key: KeyEvent) {
let len = self.state.rooms.filtered_rooms().len(); let len = self.state.rooms.filtered_rooms().len();
if let Some(jump) = list_jump(key, self.popup_page()) {
self.state.rooms.list_selected =
jumped(self.state.rooms.list_selected, len, jump);
return;
}
// Control combinations are only ever jumps; the letters below are
// not theirs to trigger.
if key.modifiers.contains(KeyModifiers::CONTROL) {
return;
}
match key.code { match key.code {
// Esc peels back one level: clear a lingering filter first (as the // Esc peels back one level: clear a lingering filter first (as the
// title's "Esc: clear" promises), otherwise close the popup. // title's "Esc: clear" promises), otherwise close the popup.
@@ -92,6 +103,16 @@ impl MainTui {
} }
fn handle_rooms_chat_input(&mut self, key: KeyEvent) { fn handle_rooms_chat_input(&mut self, key: KeyEvent) {
// Paging keys move through the room's history; the member list is a
// row at a time.
if let Some(view) = self.state.rooms.active_view_mut()
&& scroll_log(view, key)
{
return;
}
if key.modifiers.contains(KeyModifiers::CONTROL) {
return;
}
match key.code { match key.code {
KeyCode::Char('q') => self.state.show_rooms = false, KeyCode::Char('q') => self.state.show_rooms = false,
KeyCode::Esc | KeyCode::Char('l') => { KeyCode::Esc | KeyCode::Char('l') => {
@@ -208,15 +229,23 @@ impl MainTui {
self.state.rooms.composing = false; self.state.rooms.composing = false;
} }
/// The room whose newest messages are on screen, if any: the active one
/// in the chat view, unless the reader is held back in its history, when
/// what arrives still counts as unread.
pub(super) fn viewed_room(&self) -> Option<String> {
if !self.state.show_rooms || self.state.rooms.view != RoomsView::Chat {
return None;
}
self.state
.rooms
.active_room()
.filter(|room| room.view.following())
.map(|room| room.name.clone())
}
/// Drain chat-room events into the rooms state, tracking unread badges. /// Drain chat-room events into the rooms state, tracking unread badges.
pub(super) fn poll_room_events(&mut self) { pub(super) fn poll_room_events(&mut self) {
let viewing = if self.state.show_rooms let viewing = self.viewed_room();
&& self.state.rooms.view == RoomsView::Chat
{
self.state.rooms.active_room().map(|r| r.name.clone())
} else {
None
};
for event in self.client.take_room_events() { for event in self.client.take_room_events() {
self.state.rooms.apply_event(event, viewing.as_deref()); self.state.rooms.apply_event(event, viewing.as_deref());
} }
+2 -2
View File
@@ -231,8 +231,8 @@ impl MainTui {
self.state.results_table_state.select(Some(0)); self.state.results_table_state.select(Some(0));
self.state.results_name_offset = 0; self.state.results_name_offset = 0;
// Switch focus to Results pane // Switch focus to Results pane, bringing it back if it was hidden
self.state.focused_pane = FocusedPane::Results; self.state.focus_pane(FocusedPane::Results);
let client = self.client.clone(); let client = self.client.clone();
let timeout = self.search_timeout; let timeout = self.search_timeout;
+18 -10
View File
@@ -1,4 +1,4 @@
use crate::models::{AppState, MessageDirection}; use crate::models::{AppState, ChatMessage, LogView, MessageDirection};
use crate::ui::{ use crate::ui::{
accent_style, border_style, dimmed_style, highlight_style, info_style, accent_style, border_style, dimmed_style, highlight_style, info_style,
primary_style, wrap_chat_line, primary_style, wrap_chat_line,
@@ -17,10 +17,11 @@ use ratatui::{
pub fn render_chat_pane( pub fn render_chat_pane(
frame: &mut Frame, frame: &mut Frame,
area: Rect, area: Rect,
state: &AppState, state: &mut AppState,
own_username: &str, own_username: &str,
) { ) {
let peer = state.active_chat_peer(); let peer = state.active_chat_peer().map(str::to_string);
let peer = peer.as_deref();
let title = peer.map_or_else( let title = peer.map_or_else(
|| " Messages (m: compose, i/Esc: close) ".to_string(), || " Messages (m: compose, i/Esc: close) ".to_string(),
|peer| format!(" {peer} (↑↓/Tab: switch, m: to…, i/Esc: close) "), |peer| format!(" {peer} (↑↓/Tab: switch, m: to…, i/Esc: close) "),
@@ -51,7 +52,14 @@ pub fn render_chat_pane(
return; return;
}; };
render_messages(frame, body[0], state, peer, own_username); render_messages(
frame,
body[0],
&state.messages,
&mut state.chat_view,
peer,
own_username,
);
render_compose(frame, chunks[1], state); render_compose(frame, chunks[1], state);
} }
@@ -94,13 +102,13 @@ fn render_peers(
fn render_messages( fn render_messages(
frame: &mut Frame, frame: &mut Frame,
area: Rect, area: Rect,
state: &AppState, messages: &[ChatMessage],
view: &mut LogView,
peer: &str, peer: &str,
own_username: &str, own_username: &str,
) { ) {
let width = area.width as usize; let width = area.width as usize;
let lines: Vec<Line> = state let lines: Vec<Line> = messages
.messages
.iter() .iter()
.filter(|m| m.peer == peer) .filter(|m| m.peer == peer)
.flat_map(|m| { .flat_map(|m| {
@@ -123,9 +131,9 @@ fn render_messages(
}) })
.collect(); .collect();
// Auto-scroll: tail by rendered rows so the newest message stays visible. // The newest rows, unless the reader is holding a place in the history.
let start = lines.len().saturating_sub((area.height as usize).max(1)); let window = view.window(lines.len(), usize::from(area.height));
frame.render_widget(Paragraph::new(lines[start..].to_vec()), area); frame.render_widget(Paragraph::new(lines[window].to_vec()), area);
} }
fn render_compose(frame: &mut Frame, area: Rect, state: &AppState) { fn render_compose(frame: &mut Frame, area: Rect, state: &AppState) {
+48 -17
View File
@@ -1,3 +1,4 @@
use super::name_scroll::{column_width, end_offset, scroll_text};
use crate::models::DownloadEntry; use crate::models::DownloadEntry;
use crate::ui::{ use crate::ui::{
GLYPH_ACTIVE, GLYPH_DONE, GLYPH_FAILED, GLYPH_QUEUED, HIGHLIGHT_SYMBOL, GLYPH_ACTIVE, GLYPH_DONE, GLYPH_FAILED, GLYPH_QUEUED, HIGHLIGHT_SYMBOL,
@@ -8,12 +9,43 @@ use crate::ui::{
}; };
use ratatui::{ use ratatui::{
Frame, Frame,
layout::Rect, layout::{Constraint, Rect},
widgets::{Cell, HighlightSpacing, Paragraph, Row, Table, TableState}, widgets::{Cell, HighlightSpacing, Paragraph, Row, Table, TableState},
}; };
use soulseek_rs::DownloadStatus; use soulseek_rs::DownloadStatus;
use soulseek_rs::types::{UploadInfo, UploadStatus}; use soulseek_rs::types::{UploadInfo, UploadStatus};
// 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.
const WIDTHS: [Constraint; 5] = [
Constraint::Length(6),
Constraint::Fill(1),
Constraint::Length(12),
Constraint::Length(25),
Constraint::Length(10),
];
/// Filename is the second column.
const NAME_COLUMN: usize = 1;
/// The offset that brings the end of a transfer's name into view.
#[must_use]
pub fn name_end_offset(name: &str, area: Rect) -> usize {
end_offset(name, area, &WIDTHS, NAME_COLUMN)
}
/// What the list shows for an upload: the peer-facing path is ours, so only
/// the file's own name is worth a column.
#[must_use]
pub fn upload_display_name(upload: &UploadInfo) -> &str {
upload
.filename
.rsplit(['\\', '/'])
.next()
.unwrap_or(&upload.filename)
}
pub fn render_downloads_pane( pub fn render_downloads_pane(
frame: &mut Frame, frame: &mut Frame,
area: Rect, area: Rect,
@@ -21,6 +53,7 @@ pub fn render_downloads_pane(
uploads: &[UploadInfo], uploads: &[UploadInfo],
table_state: &mut TableState, table_state: &mut TableState,
focused: bool, focused: bool,
name_offset: usize,
) { ) {
if downloads.is_empty() && uploads.is_empty() { if downloads.is_empty() && uploads.is_empty() {
let empty_block = pane_block(focused).title(pane_title( let empty_block = pane_block(focused).title(pane_title(
@@ -47,6 +80,7 @@ pub fn render_downloads_pane(
]) ])
.height(1); .height(1);
let name_width = column_width(area, &WIDTHS, NAME_COLUMN);
let mut rows: Vec<Row> = downloads let mut rows: Vec<Row> = downloads
.iter() .iter()
.map(|download_entry| { .map(|download_entry| {
@@ -106,7 +140,12 @@ pub fn render_downloads_pane(
Row::new(vec![ Row::new(vec![
Cell::from(status_icon).style(status_style), Cell::from(status_icon).style(status_style),
Cell::from(download.filename.clone()).style(body_style()), Cell::from(scroll_text(
&download.filename,
name_offset,
name_width,
))
.style(body_style()),
Cell::from(download.username.clone()).style(info_style()), Cell::from(download.username.clone()).style(info_style()),
Cell::from(progress_text).style(progress_style), Cell::from(progress_text).style(progress_style),
Cell::from(speed_text).style(warning_style()), Cell::from(speed_text).style(warning_style()),
@@ -149,29 +188,21 @@ pub fn render_downloads_pane(
} }
_ => "-".to_string(), _ => "-".to_string(),
}; };
let basename = upload
.filename
.rsplit(['\\', '/'])
.next()
.unwrap_or(&upload.filename);
Row::new(vec![ Row::new(vec![
Cell::from(format!("↑ {status_icon}")).style(status_style), Cell::from(format!("↑ {status_icon}")).style(status_style),
Cell::from(basename.to_string()).style(body_style()), Cell::from(scroll_text(
upload_display_name(upload),
name_offset,
name_width,
))
.style(body_style()),
Cell::from(upload.username.clone()).style(info_style()), Cell::from(upload.username.clone()).style(info_style()),
Cell::from(progress_text).style(primary_style()), Cell::from(progress_text).style(primary_style()),
Cell::from(speed_text).style(warning_style()), Cell::from(speed_text).style(warning_style()),
]) ])
})); }));
let widths = [ let table = Table::new(rows, 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(12),
];
let table = Table::new(rows, widths)
.header(header) .header(header)
.row_highlight_style(row_highlight_style()) .row_highlight_style(row_highlight_style())
.highlight_symbol(HIGHLIGHT_SYMBOL) .highlight_symbol(HIGHLIGHT_SYMBOL)
+6 -2
View File
@@ -2,6 +2,7 @@ mod browse_pane;
mod chat_pane; mod chat_pane;
mod download_info_pane; mod download_info_pane;
mod downloads_pane; mod downloads_pane;
mod name_scroll;
mod results_pane; mod results_pane;
mod rooms_pane; mod rooms_pane;
mod searches_pane; mod searches_pane;
@@ -11,9 +12,12 @@ pub use chat_pane::render_chat_pane;
pub use download_info_pane::{ pub use download_info_pane::{
InfoSubject, render_download_info_pane, selected_transfer, InfoSubject, render_download_info_pane, selected_transfer,
}; };
pub use downloads_pane::render_downloads_pane; pub use downloads_pane::{
name_end_offset as transfer_name_end_offset, render_downloads_pane,
upload_display_name,
};
pub use results_pane::{ pub use results_pane::{
ResultsPaneParams, name_end_offset, render_results_pane, ResultsPaneParams, name_end_offset, render_results_pane,
}; };
pub use rooms_pane::render_rooms_pane; pub use rooms_pane::render_rooms_pane;
pub use searches_pane::render_searches_pane; pub use searches_pane::{query_end_offset, render_searches_pane};
+126
View File
@@ -0,0 +1,126 @@
//! Sideways scrolling for a table column that holds text wider than it is:
//! a file name, a query. Every list scrolls the same way, so the arithmetic
//! lives here and each pane only says which of its columns it applies to.
use crate::ui::{HIGHLIGHT_SYMBOL, pane_block};
use ratatui::{
layout::{Constraint, Layout, Rect},
text::{Line, Span},
};
use unicode_width::{UnicodeWidthChar, UnicodeWidthStr};
/// Cells column `column` of a table laid out with `widths` gets inside a pane
/// drawn over `area`: what the pane keeps for its border, padding and the
/// highlight symbol comes off first.
#[must_use]
pub fn column_width(area: Rect, widths: &[Constraint], column: usize) -> usize {
let inner = pane_block(false).inner(area);
let symbol = HIGHLIGHT_SYMBOL.width() as u16;
let [_, columns] =
Layout::horizontal([Constraint::Length(symbol), Constraint::Fill(0)])
.areas(inner);
usize::from(
Layout::horizontal(widths).spacing(1).split(columns)[column].width,
)
}
/// How far a text of `cells` can scroll before its tail is in view, keeping
/// one cell for the ellipsis that marks the cut.
const fn overflow(cells: usize, width: usize) -> usize {
if cells <= width { 0 } else { cells + 1 - width }
}
/// The offset that brings the end of `text` into view in the column.
#[must_use]
pub fn end_offset(
text: &str,
area: Rect,
widths: &[Constraint],
column: usize,
) -> usize {
overflow(text.width(), column_width(area, widths, column))
}
/// `text` scrolled `offset` cells to the left, with an ellipsis where it was
/// cut. Scrolls by cells, so a wide glyph counts double, and never starts on
/// a combining mark. Beyond the end it shows the tail.
#[must_use]
pub fn scroll_text(text: &str, offset: usize, width: usize) -> Line<'_> {
if offset == 0 {
return Line::from(text);
}
let mut tail_cells = 0;
let mut furthest = text.len();
for (index, ch) in text.char_indices().rev() {
let cells = ch.width().unwrap_or(0);
if tail_cells + cells >= width {
if index == 0 && tail_cells + cells == width {
return Line::from(text);
}
break;
}
tail_cells += cells;
furthest = index;
}
if furthest == 0 {
return Line::from(text);
}
let mut skipped = 0;
let mut start = furthest;
for (index, ch) in text.char_indices() {
if index >= furthest || skipped >= offset {
start = index;
break;
}
skipped += ch.width().unwrap_or(0);
}
while let Some(ch) = text[start..].chars().next()
&& ch.width() == Some(0)
{
start += ch.len_utf8();
}
Line::from(vec![Span::raw("…"), Span::raw(&text[start..])])
}
#[cfg(test)]
mod tests {
use super::*;
fn text_of(line: &Line<'_>) -> String {
line.spans.iter().map(|s| s.content.as_ref()).collect()
}
#[test]
fn a_text_that_fits_never_scrolls() {
assert_eq!(text_of(&scroll_text("short", 8, 10)), "short");
assert_eq!(
end_offset(
"short",
Rect::new(0, 0, 40, 5),
&[Constraint::Fill(1)],
0
),
0
);
}
#[test]
fn the_end_offset_leaves_room_for_the_ellipsis() {
// Column of 40 - 2 border - 2 padding - 1 symbol = 35 cells.
let area = Rect::new(0, 0, 40, 5);
let widths = [Constraint::Fill(1)];
let fits = "x".repeat(35);
let over = "x".repeat(36);
assert_eq!(end_offset(&fits, area, &widths, 0), 0);
assert_eq!(end_offset(&over, area, &widths, 0), 2);
let line = scroll_text(&over, 2, 35);
assert_eq!(text_of(&line).chars().count(), 35);
assert!(text_of(&line).starts_with('…'));
}
#[test]
fn scrolling_past_the_end_shows_the_tail() {
let line = scroll_text("abcdefghij", usize::MAX, 5);
assert_eq!(text_of(&line), "…ghij");
}
}
+7 -54
View File
@@ -1,3 +1,4 @@
use super::name_scroll::{column_width, end_offset, scroll_text};
use crate::models::FileDisplayData; use crate::models::FileDisplayData;
use crate::ui::{ use crate::ui::{
BYTES_PER_MB, HIGHLIGHT_SYMBOL, body_style, dimmed_style, format_bytes, BYTES_PER_MB, HIGHLIGHT_SYMBOL, body_style, dimmed_style, format_bytes,
@@ -6,12 +7,11 @@ use crate::ui::{
}; };
use ratatui::{ use ratatui::{
Frame, Frame,
layout::{Constraint, Layout, Rect}, layout::{Constraint, Rect},
text::{Line, Span}, text::{Line, Span},
widgets::{Cell, HighlightSpacing, Paragraph, Row, Table, TableState}, widgets::{Cell, HighlightSpacing, Paragraph, Row, Table, TableState},
}; };
use std::collections::HashSet; use std::collections::HashSet;
use unicode_width::UnicodeWidthChar;
const VERSION: &str = env!("CARGO_PKG_VERSION"); const VERSION: &str = env!("CARGO_PKG_VERSION");
@@ -39,59 +39,12 @@ pub struct ResultsPaneParams<'a> {
pub name_offset: usize, pub name_offset: usize,
} }
fn name_column_width(area: Rect) -> usize { /// Filename is the second column.
let inner = pane_block(false).inner(area); const NAME_COLUMN: usize = 1;
let symbol = Line::from(HIGHLIGHT_SYMBOL).width() as u16;
let [_, columns] =
Layout::horizontal([Constraint::Length(symbol), Constraint::Fill(0)])
.areas(inner);
usize::from(Layout::horizontal(WIDTHS).spacing(1).split(columns)[1].width)
}
const fn overflow(cells: usize, width: usize) -> usize {
if cells <= width { 0 } else { cells + 1 - width }
}
#[must_use] #[must_use]
pub fn name_end_offset(name: &str, area: Rect) -> usize { pub fn name_end_offset(name: &str, area: Rect) -> usize {
overflow(Span::raw(name).width(), name_column_width(area)) end_offset(name, area, &WIDTHS, NAME_COLUMN)
}
fn scroll_name(name: &str, offset: usize, width: usize) -> Line<'_> {
if offset == 0 {
return Line::from(name);
}
let mut tail_cells = 0;
let mut furthest = name.len();
for (index, ch) in name.char_indices().rev() {
let cells = ch.width().unwrap_or(0);
if tail_cells + cells >= width {
if index == 0 && tail_cells + cells == width {
return Line::from(name);
}
break;
}
tail_cells += cells;
furthest = index;
}
if furthest == 0 {
return Line::from(name);
}
let mut skipped = 0;
let mut start = furthest;
for (index, ch) in name.char_indices() {
if index >= furthest || skipped >= offset {
start = index;
break;
}
skipped += ch.width().unwrap_or(0);
}
while let Some(ch) = name[start..].chars().next()
&& ch.width() == Some(0)
{
start += ch.len_utf8();
}
Line::from(vec![Span::raw("…"), Span::raw(&name[start..])])
} }
/// Whether the rendered row `display_idx` is selected. `selected_indices` holds /// Whether the rendered row `display_idx` is selected. `selected_indices` holds
@@ -173,7 +126,7 @@ pub fn render_results_pane(
]) ])
.height(1); .height(1);
let name_width = name_column_width(area); let name_width = column_width(area, &WIDTHS, NAME_COLUMN);
let rows: Vec<Row> = items let rows: Vec<Row> = items
.iter() .iter()
.enumerate() .enumerate()
@@ -206,7 +159,7 @@ pub fn render_results_pane(
Row::new(vec![ Row::new(vec![
Cell::from(checkbox).style(checkbox_style), Cell::from(checkbox).style(checkbox_style),
Cell::from(scroll_name( Cell::from(scroll_text(
&file.filename, &file.filename,
name_offset, name_offset,
name_width, name_width,
+19 -11
View File
@@ -1,4 +1,4 @@
use crate::models::{RoomsState, RoomsView}; use crate::models::{LogView, RoomsState, RoomsView};
use crate::ui::{ use crate::ui::{
HIGHLIGHT_SYMBOL, PANE_PADDING, accent_style, dimmed_style, HIGHLIGHT_SYMBOL, PANE_PADDING, accent_style, dimmed_style,
highlight_style, info_style, pane_block, plain_title, primary_style, highlight_style, info_style, pane_block, plain_title, primary_style,
@@ -20,7 +20,7 @@ use ratatui::{
pub fn render_rooms_pane( pub fn render_rooms_pane(
frame: &mut Frame, frame: &mut Frame,
area: Rect, area: Rect,
rooms: &RoomsState, rooms: &mut RoomsState,
list_table_state: &mut TableState, list_table_state: &mut TableState,
) { ) {
match rooms.view { match rooms.view {
@@ -94,7 +94,7 @@ fn render_list(
frame.render_stateful_widget(table, area, table_state); frame.render_stateful_widget(table, area, table_state);
} }
fn render_chat(frame: &mut Frame, area: Rect, rooms: &RoomsState) { fn render_chat(frame: &mut Frame, area: Rect, rooms: &mut RoomsState) {
let block = pane_block(true).title( let block = pane_block(true).title(
" Chat rooms (Tab: switch, l: room list, x: leave, Esc: back) ", " Chat rooms (Tab: switch, l: room list, x: leave, Esc: back) ",
); );
@@ -110,7 +110,14 @@ fn render_chat(frame: &mut Frame, area: Rect, rooms: &RoomsState) {
render_tab_bar(frame, chunks[0], rooms); render_tab_bar(frame, chunks[0], rooms);
let Some(active) = rooms.active_room() else { let RoomsState {
open,
active: active_index,
user_selected,
composing,
..
} = rooms;
let Some(active) = open.get_mut(*active_index) else {
frame.render_widget( frame.render_widget(
Paragraph::new("No open rooms. Press l for the room list.") Paragraph::new("No open rooms. Press l for the room list.")
.style(dimmed_style()), .style(dimmed_style()),
@@ -124,11 +131,11 @@ fn render_chat(frame: &mut Frame, area: Rect, rooms: &RoomsState) {
Layout::horizontal([Constraint::Fill(1), Constraint::Length(22)]) Layout::horizontal([Constraint::Fill(1), Constraint::Length(22)])
.split(chunks[1]); .split(chunks[1]);
render_messages(frame, body[0], active.lines.as_slice()); render_messages(frame, body[0], active.lines.as_slice(), &mut active.view);
render_users(frame, body[1], &active.users, rooms.user_selected); render_users(frame, body[1], &active.users, *user_selected);
// Compose line or hint. // Compose line or hint.
if rooms.composing { if *composing {
let line = Line::from(vec![ let line = Line::from(vec![
Span::styled("› ", accent_style()), Span::styled("› ", accent_style()),
Span::styled(active.input.clone(), primary_style()), Span::styled(active.input.clone(), primary_style()),
@@ -173,9 +180,10 @@ fn render_messages(
frame: &mut Frame, frame: &mut Frame,
area: Rect, area: Rect,
lines: &[crate::models::RoomLine], lines: &[crate::models::RoomLine],
view: &mut LogView,
) { ) {
// Wrap each message to the pane width, then auto-scroll: tail by rendered // Wrap each message to the pane width, then show the rows the reader is
// rows so the newest message is always fully visible. // at: the newest ones, unless they are holding a place in the history.
let width = area.width as usize; let width = area.width as usize;
let rendered: Vec<Line> = lines let rendered: Vec<Line> = lines
.iter() .iter()
@@ -198,8 +206,8 @@ fn render_messages(
} }
}) })
.collect(); .collect();
let start = rendered.len().saturating_sub((area.height as usize).max(1)); let window = view.window(rendered.len(), usize::from(area.height));
frame.render_widget(Paragraph::new(rendered[start..].to_vec()), area); frame.render_widget(Paragraph::new(rendered[window].to_vec()), area);
} }
fn render_users( fn render_users(
+31 -10
View File
@@ -1,3 +1,4 @@
use super::name_scroll::{column_width, end_offset, scroll_text};
use crate::models::{SearchEntry, SearchStatus}; use crate::models::{SearchEntry, SearchStatus};
use crate::ui::{ use crate::ui::{
HIGHLIGHT_SYMBOL, body_style, dimmed_style, header_style, pane_block, HIGHLIGHT_SYMBOL, body_style, dimmed_style, header_style, pane_block,
@@ -5,16 +6,34 @@ use crate::ui::{
}; };
use ratatui::{ use ratatui::{
Frame, Frame,
layout::Rect, layout::{Constraint, Rect},
widgets::{Cell, HighlightSpacing, Row, Table, TableState}, widgets::{Cell, HighlightSpacing, Row, Table, TableState},
}; };
// The pane shares a row with two others, so the fixed columns are as
// narrow as their widest word: the query gets the rest.
const WIDTHS: [Constraint; 3] = [
Constraint::Length(6),
Constraint::Fill(1),
Constraint::Length(7),
];
/// Query is the second column.
const QUERY_COLUMN: usize = 1;
/// The offset that brings the end of a query into view.
#[must_use]
pub fn query_end_offset(query: &str, area: Rect) -> usize {
end_offset(query, area, &WIDTHS, QUERY_COLUMN)
}
pub fn render_searches_pane( pub fn render_searches_pane(
frame: &mut Frame, frame: &mut Frame,
area: Rect, area: Rect,
searches: &[SearchEntry], searches: &[SearchEntry],
table_state: &mut TableState, table_state: &mut TableState,
focused: bool, focused: bool,
query_offset: usize,
) { ) {
let header = Row::new(vec![ let header = Row::new(vec![
Cell::from("Status").style(header_style()), Cell::from("Status").style(header_style()),
@@ -23,6 +42,7 @@ pub fn render_searches_pane(
]) ])
.height(1); .height(1);
let query_width = column_width(area, &WIDTHS, QUERY_COLUMN);
let rows: Vec<Row> = searches let rows: Vec<Row> = searches
.iter() .iter()
.map(|search| { .map(|search| {
@@ -43,29 +63,30 @@ pub fn render_searches_pane(
} else { } else {
search.results.len() 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 let results_text = if results_count == 0
&& search.status == SearchStatus::Active && search.status == SearchStatus::Active
{ {
"Searching...".to_string() "…".to_string()
} else { } else {
format!("{results_count}") format!("{results_count}")
}; };
Row::new(vec![ Row::new(vec![
status_cell, status_cell,
Cell::from(search.query.clone()).style(body_style()), Cell::from(scroll_text(
&search.query,
query_offset,
query_width,
))
.style(body_style()),
Cell::from(results_text).style(dimmed_style()), Cell::from(results_text).style(dimmed_style()),
]) ])
}) })
.collect(); .collect();
let widths = [ let table = Table::new(rows, WIDTHS)
ratatui::layout::Constraint::Length(15),
ratatui::layout::Constraint::Fill(1),
ratatui::layout::Constraint::Length(10),
];
let table = Table::new(rows, widths)
.header(header) .header(header)
.row_highlight_style(row_highlight_style()) .row_highlight_style(row_highlight_style())
.highlight_symbol(HIGHLIGHT_SYMBOL) .highlight_symbol(HIGHLIGHT_SYMBOL)
+90 -14
View File
@@ -39,7 +39,8 @@ pub const GLYPH_FAILED: &str = "✗";
pub const GLYPH_TIMED_OUT: &str = "⧗"; pub const GLYPH_TIMED_OUT: &str = "⧗";
pub const GLYPH_CURSOR: &str = "▮"; pub const GLYPH_CURSOR: &str = "▮";
pub const HIGHLIGHT_SYMBOL: &str = "›"; pub const HIGHLIGHT_SYMBOL: &str = "›";
pub const SHORTCUT_ARROW: &str = "→"; /// Between a key and its action in the legend, spaced.
pub const SHORTCUT_ARROW: &str = " → ";
pub fn header_style() -> Style { pub fn header_style() -> Style {
Style::default().fg(DUST).add_modifier(Modifier::BOLD) Style::default().fg(DUST).add_modifier(Modifier::BOLD)
@@ -158,22 +159,57 @@ pub fn download_status_glyph(status: &DownloadStatus) -> (&'static str, Style) {
} }
} }
pub fn format_shortcuts_styled(shortcuts: &[(&str, &str)]) -> Line<'static> { /// One `[key → action]` legend entry.
let mut spans = Vec::new(); fn shortcut_spans(
key: &'static str,
action: &'static str,
) -> [Span<'static>; 5] {
[
Span::styled("[", dimmed_style()),
Span::styled(key, info_style()),
Span::styled(SHORTCUT_ARROW, dimmed_style()),
Span::styled(action, body_style()),
Span::styled("]", dimmed_style()),
]
}
for (i, (key, action)) in shortcuts.iter().enumerate() { /// The most rows the shortcuts bar grows to. Past that a terminal is too
if i > 0 { /// narrow for the legend to be worth the content rows it would take.
spans.push(Span::raw(" ")); 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: &[(&'static str, &'static 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();
if !row.is_empty() && row_width + 1 + entry_width > width {
rows.push(Line::from(std::mem::take(&mut row)));
if rows.len() == SHORTCUT_ROWS_MAX {
return rows;
}
row_width = 0;
}
if !row.is_empty() {
row.push(Span::raw(" "));
row_width += 1;
}
row.extend(entry);
row_width += entry_width;
} }
spans.push(Span::styled("[", dimmed_style())); rows.push(Line::from(row));
spans.push(Span::styled(key.to_string(), Style::default().fg(SIGNAL))); rows
spans.push(Span::styled(format!(" {SHORTCUT_ARROW} "), dimmed_style()));
spans.push(Span::styled(action.to_string(), body_style()));
spans.push(Span::styled("]", dimmed_style()));
}
Line::from(spans)
} }
pub fn format_progress_bar( pub fn format_progress_bar(
@@ -253,6 +289,46 @@ mod tests {
assert_eq!(cursor.bg, Some(SELECTION)); 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] #[test]
fn progress_bar_splits_fill_and_track() { fn progress_bar_splits_fill_and_track() {
let bar = format_progress_bar(0.25, 20, 25); 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'], ['c', 'chat'],
['/', 'filter'], ['/', 'filter'],
['a/A', 'select all/none'], ['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'], ['q', 'quit'],
] ]
+14 -15
View File
@@ -37,19 +37,10 @@ export const HeroTerminal = () => {
> >
<StatusLine counts={demo.counts} pct={demo.pct} /> <StatusLine counts={demo.counts} pct={demo.pct} />
<div className="mt-3 grid gap-2.5" style={{ gridTemplateColumns: '300px 1fr' }}> {/* Results across the top with the whole width for long names;
<div className="min-h-[498px]"> Searches, Transfers and Info share the row underneath. */}
<SearchesPane <div className="mt-3 grid gap-2.5" style={{ gridTemplateRows: '330px 300px' }}>
searches={demo.searches} <div className="relative min-h-0 overflow-hidden">
activeId={demo.activeId}
active={demo.focus === 1}
onPick={demo.pickSearch}
onSubmit={demo.submitSearch}
/>
</div>
<div className="grid min-h-0 gap-2.5" style={{ gridTemplateRows: '1fr 300px' }}>
<div className="relative min-h-[250px] overflow-hidden">
<ResultsPane <ResultsPane
query={demo.query} query={demo.query}
rows={demo.rows} rows={demo.rows}
@@ -68,8 +59,17 @@ export const HeroTerminal = () => {
<div <div
className="grid min-h-0 gap-2.5" className="grid min-h-0 gap-2.5"
style={{ gridTemplateColumns: '1fr 340px' }} 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}
/>
</div>
<div className="min-h-0 min-w-0 overflow-hidden"> <div className="min-h-0 min-w-0 overflow-hidden">
<TransfersPane rows={demo.transfers.slice(0, 7)} active={demo.focus === 3} /> <TransfersPane rows={demo.transfers.slice(0, 7)} active={demo.focus === 3} />
</div> </div>
@@ -78,7 +78,6 @@ export const HeroTerminal = () => {
</div> </div>
</div> </div>
</div> </div>
</div>
<ShortcutBar share={SHARE} /> <ShortcutBar share={SHARE} />
</div> </div>
+1 -1
View File
@@ -6,7 +6,7 @@ import { TuiPane } from './chrome'
import type { ResultRow, SearchRow, TransferRow, TransferStatus } from './data' import type { ResultRow, SearchRow, TransferRow, TransferStatus } from './data'
const RESULT_COLS = '30px 1fr 74px 92px 78px 74px 42px' 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 { interface SearchesPaneProps {
searches: readonly SearchRow[] searches: readonly SearchRow[]
+33 -9
View File
@@ -25,10 +25,21 @@ const GLOBAL_KEYS: readonly (readonly [string, string])[] = [
['i', 'inbox (unread counter)'], ['i', 'inbox (unread counter)'],
['/', 'filter the current list'], ['/', 'filter the current list'],
['a / A', 'select all / none'], ['a / A', 'select all / none'],
['1–3', 'focus a pane'], ['?', 'every key, grouped by pane'],
['q', 'quit'], ['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; in a chat, back through the log'],
['h l · ← →', 'scroll a long name or query sideways, 0 and $ to either end'],
]
const ROOM_KEYS: readonly (readonly [string, string])[] = [ const ROOM_KEYS: readonly (readonly [string, string])[] = [
['Enter', 'join room / send message'], ['Enter', 'join room / send message'],
['Tab · ⇧Tab', 'switch between open rooms'], ['Tab · ⇧Tab', 'switch between open rooms'],
@@ -40,16 +51,16 @@ const ROOM_KEYS: readonly (readonly [string, string])[] = [
] ]
const PANES: readonly { num?: number; title: string; body: 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, num: 1,
title: 'Searches', title: 'Searches',
body: 'Your queries and how many results each returned. Press s to search the network; each search keeps its own result set.', 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, num: 3,
title: 'Downloads / Uploads', title: 'Downloads / Uploads',
@@ -106,8 +117,10 @@ const FirstRun = () => (
const Layout = () => ( const Layout = () => (
<Section> <Section>
<SectionHead eyebrow="2 · the layout" title="Four panes."> <SectionHead eyebrow="2 · the layout" title="Four panes.">
The whole client is these four boxes. Press <Code>1</Code>–<Code>3</Code> to focus The whole client is these four boxes: results across the top, the rest in a row
one; the focused pane&rsquo;s legend turns green. 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> </SectionHead>
<Cols start> <Cols start>
<div className="overflow-hidden"> <div className="overflow-hidden">
@@ -127,7 +140,8 @@ const Layout = () => (
const Keys = () => ( const Keys = () => (
<Section> <Section>
<SectionHead eyebrow="3 · keys" title="It's all keyboard."> <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> </SectionHead>
<Cols> <Cols>
<div> <div>
@@ -138,6 +152,16 @@ const Keys = () => (
))} ))}
</div> </div>
</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> <div>
<Eyebrow className="mb-4 block"> <Eyebrow className="mb-4 block">
in the chat-rooms popup <span className="text-muted">(press c)</span> in the chat-rooms popup <span className="text-muted">(press c)</span>