Merge pull request #60 from michel/feat/tui-pane-layout
feat(tui): results across the top, with panes you can hide, zoom and Tab through
This commit is contained in:
@@ -91,6 +91,17 @@ soulseek-rs
|
||||
|
||||
Everything else is a one-shot command that runs headless.
|
||||
|
||||
### The TUI
|
||||
|
||||
Results run across the top, with the whole width for long file names;
|
||||
Searches, Downloads and Info share the row underneath. `Tab`/`Shift-Tab` or
|
||||
`1`–`3` move the focus between panes, `z` zooms the focused pane to the whole
|
||||
window, and `w` hides it so the others widen (its number brings it back).
|
||||
Every list takes `↑`/`↓`, `Home`/`End`, `PgUp`/`PgDn` and the vim keys, and
|
||||
`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
|
||||
|
||||
A one-shot command logs in, does its job, and exits. Run a daemon instead and
|
||||
|
||||
+1
-1
File diff suppressed because one or more lines are too long
|
Before Width: | Height: | Size: 1.0 MiB After Width: | Height: | Size: 930 KiB |
@@ -39,6 +39,141 @@ pub enum FocusedPane {
|
||||
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.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum CommandBarMode {
|
||||
@@ -70,6 +205,8 @@ pub struct AppState {
|
||||
pub searches: Vec<SearchEntry>,
|
||||
pub searches_table_state: TableState,
|
||||
pub selected_search_index: Option<usize>,
|
||||
/// Cells the query column is scrolled to the left.
|
||||
pub searches_query_offset: usize,
|
||||
|
||||
// Results
|
||||
pub results_items: Vec<FileDisplayData>,
|
||||
@@ -79,11 +216,14 @@ pub struct AppState {
|
||||
pub results_selected_indices: std::collections::HashSet<usize>,
|
||||
pub results_filter_query: String,
|
||||
pub results_is_filtering: bool,
|
||||
/// Cells the file-name column is scrolled to the left.
|
||||
pub results_name_offset: usize,
|
||||
|
||||
// Downloads
|
||||
pub downloads: Vec<DownloadEntry>,
|
||||
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:
|
||||
Option<Receiver<(Download, Receiver<DownloadStatus>)>>,
|
||||
pub downloads_sender_channel:
|
||||
@@ -92,6 +232,12 @@ pub struct AppState {
|
||||
|
||||
// UI State
|
||||
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 command_bar_active: bool,
|
||||
pub command_bar_input: String,
|
||||
@@ -108,6 +254,8 @@ pub struct AppState {
|
||||
/// Compose buffer for the active conversation.
|
||||
pub chat_input: String,
|
||||
pub chat_composing: bool,
|
||||
/// Where the conversation is being read.
|
||||
pub chat_view: LogView,
|
||||
|
||||
// Browse users' shared files (one tab per user)
|
||||
pub browse: BrowseTabs,
|
||||
@@ -129,6 +277,9 @@ pub struct AppState {
|
||||
pub searches_pane_area: Option<Rect>,
|
||||
pub results_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 {
|
||||
@@ -147,6 +298,7 @@ impl AppState {
|
||||
searches: Vec::new(),
|
||||
searches_table_state,
|
||||
selected_search_index: None,
|
||||
searches_query_offset: 0,
|
||||
|
||||
results_items: Vec::new(),
|
||||
results_filtered_items: Vec::new(),
|
||||
@@ -159,11 +311,15 @@ impl AppState {
|
||||
|
||||
downloads: Vec::new(),
|
||||
downloads_table_state,
|
||||
downloads_name_offset: 0,
|
||||
downloads_receiver_channel: None,
|
||||
downloads_sender_channel: None,
|
||||
active_downloads_count: 0,
|
||||
|
||||
focused_pane: FocusedPane::Searches,
|
||||
layout: PaneLayout::default(),
|
||||
show_help: false,
|
||||
help_view: LogView::default(),
|
||||
should_exit: false,
|
||||
command_bar_active: false,
|
||||
command_bar_input: String::new(),
|
||||
@@ -176,6 +332,7 @@ impl AppState {
|
||||
chat_peer: None,
|
||||
chat_input: String::new(),
|
||||
chat_composing: false,
|
||||
chat_view: LogView::default(),
|
||||
|
||||
browse: BrowseTabs::new(),
|
||||
show_browse: false,
|
||||
@@ -192,13 +349,59 @@ impl AppState {
|
||||
searches_pane_area: None,
|
||||
results_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.
|
||||
pub fn open_chat(&mut self, peer: String) {
|
||||
self.chat_peer = Some(peer);
|
||||
self.chat_input.clear();
|
||||
self.chat_view.to_newest();
|
||||
self.chat_composing = true;
|
||||
self.show_messages = true;
|
||||
self.unread_messages = 0;
|
||||
@@ -248,6 +451,10 @@ impl AppState {
|
||||
(current + peers.len() - 1) % peers.len()
|
||||
};
|
||||
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);
|
||||
}
|
||||
}
|
||||
@@ -290,6 +497,148 @@ mod tests {
|
||||
assert_eq!(state.active_chat_peer(), Some("bob"));
|
||||
}
|
||||
|
||||
fn visible(layout: &PaneLayout) -> Vec<FocusedPane> {
|
||||
layout.visible().collect()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn every_pane_starts_visible_and_unzoomed() {
|
||||
let state = AppState::new();
|
||||
assert_eq!(visible(&state.layout), FocusedPane::ALL);
|
||||
assert!(!state.layout.zoomed);
|
||||
assert!(!state.show_help);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn tab_cycles_the_focus_through_the_visible_panes_and_wraps() {
|
||||
let mut state = AppState::new();
|
||||
assert_eq!(state.focused_pane, FocusedPane::Searches);
|
||||
state.cycle_focus(true);
|
||||
assert_eq!(state.focused_pane, FocusedPane::Results);
|
||||
state.cycle_focus(true);
|
||||
assert_eq!(state.focused_pane, FocusedPane::Downloads);
|
||||
state.cycle_focus(true);
|
||||
assert_eq!(state.focused_pane, FocusedPane::Searches, "wraps");
|
||||
state.cycle_focus(false);
|
||||
assert_eq!(state.focused_pane, FocusedPane::Downloads, "and back");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn hiding_the_focused_pane_moves_the_focus_past_it() {
|
||||
let mut state = AppState::new();
|
||||
state.focus_pane(FocusedPane::Results);
|
||||
state.hide_focused_pane();
|
||||
assert_eq!(
|
||||
visible(&state.layout),
|
||||
[FocusedPane::Searches, FocusedPane::Downloads]
|
||||
);
|
||||
assert_eq!(state.focused_pane, FocusedPane::Downloads);
|
||||
|
||||
// Tab now skips the hidden pane in both directions.
|
||||
state.cycle_focus(true);
|
||||
assert_eq!(state.focused_pane, FocusedPane::Searches);
|
||||
state.cycle_focus(false);
|
||||
assert_eq!(state.focused_pane, FocusedPane::Downloads);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn hiding_wraps_the_focus_to_the_first_pane() {
|
||||
let mut state = AppState::new();
|
||||
state.focus_pane(FocusedPane::Downloads);
|
||||
state.hide_focused_pane();
|
||||
assert_eq!(state.focused_pane, FocusedPane::Searches);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_last_visible_pane_cannot_be_hidden() {
|
||||
let mut state = AppState::new();
|
||||
state.hide_focused_pane();
|
||||
state.hide_focused_pane();
|
||||
assert_eq!(visible(&state.layout), [FocusedPane::Downloads]);
|
||||
state.hide_focused_pane();
|
||||
assert_eq!(
|
||||
visible(&state.layout),
|
||||
[FocusedPane::Downloads],
|
||||
"one pane always stays on screen"
|
||||
);
|
||||
assert_eq!(state.focused_pane, FocusedPane::Downloads);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn focusing_a_hidden_pane_by_number_brings_it_back() {
|
||||
let mut state = AppState::new();
|
||||
state.focus_pane(FocusedPane::Results);
|
||||
state.hide_focused_pane();
|
||||
assert!(!state.layout.is_visible(FocusedPane::Results));
|
||||
|
||||
state.focus_pane(FocusedPane::Results);
|
||||
|
||||
assert!(state.layout.is_visible(FocusedPane::Results));
|
||||
assert_eq!(state.focused_pane, FocusedPane::Results);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn zoom_toggles_and_leaves_hidden_panes_alone() {
|
||||
let mut state = AppState::new();
|
||||
state.focus_pane(FocusedPane::Searches);
|
||||
state.hide_focused_pane();
|
||||
state.toggle_zoom();
|
||||
assert!(state.layout.zoomed);
|
||||
assert!(!state.layout.is_visible(FocusedPane::Searches));
|
||||
state.toggle_zoom();
|
||||
assert!(!state.layout.zoomed);
|
||||
assert!(!state.layout.is_visible(FocusedPane::Searches));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn 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]
|
||||
fn opening_a_chat_pins_a_peer_with_no_messages_yet() {
|
||||
let mut state = AppState::new();
|
||||
|
||||
@@ -5,8 +5,8 @@ mod rooms;
|
||||
mod settings;
|
||||
|
||||
pub use app_state::{
|
||||
AppState, ChatMessage, CommandBarMode, DownloadEntry, FocusedPane,
|
||||
MessageDirection, SearchEntry, SearchStatus,
|
||||
AppState, ChatMessage, CommandBarMode, DownloadEntry, FocusedPane, LogView,
|
||||
MessageDirection, PaneLayout, SearchEntry, SearchStatus,
|
||||
};
|
||||
pub use browse::{
|
||||
BrowseState, BrowseStatus, BrowseTabs, files_under, find_node,
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
use crate::models::LogView;
|
||||
use chrono::{DateTime, Local};
|
||||
use soulseek_rs::types::{RoomEvent, RoomInfo};
|
||||
|
||||
@@ -38,6 +39,8 @@ pub struct OpenRoom {
|
||||
pub unread: usize,
|
||||
/// Per-room compose buffer.
|
||||
pub input: String,
|
||||
/// Where the log is being read.
|
||||
pub view: LogView,
|
||||
}
|
||||
|
||||
impl OpenRoom {
|
||||
@@ -160,6 +163,11 @@ impl RoomsState {
|
||||
.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) {
|
||||
self.user_selected = self.user_selected.saturating_sub(1);
|
||||
}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
use super::MainTui;
|
||||
use super::input::{jumped, list_jump};
|
||||
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};
|
||||
|
||||
/// How long to wait for a browse response before showing a timeout notice.
|
||||
@@ -12,6 +13,11 @@ impl MainTui {
|
||||
self.state.show_browse = false;
|
||||
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.
|
||||
match key.code {
|
||||
@@ -60,6 +66,14 @@ impl MainTui {
|
||||
(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.
|
||||
match key.code {
|
||||
KeyCode::Enter if !row.is_folder => {
|
||||
|
||||
@@ -1,11 +1,14 @@
|
||||
use super::MainTui;
|
||||
use crate::models::{CommandBarMode, FocusedPane};
|
||||
use crate::models::{CommandBarMode, FocusedPane, LogView};
|
||||
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::{
|
||||
KeyCode, KeyEvent, KeyModifiers, MouseButton, MouseEvent, MouseEventKind,
|
||||
};
|
||||
use ratatui::layout::Position;
|
||||
use ratatui::layout::{Position, Rect};
|
||||
use ratatui::widgets::TableState;
|
||||
|
||||
const NAME_SCROLL_STEP: isize = 8;
|
||||
@@ -17,6 +20,23 @@ impl MainTui {
|
||||
return self.handle_command_bar_input(key);
|
||||
}
|
||||
|
||||
// The keys list closes on the key that opened it, or the usual two,
|
||||
// and scrolls like any 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.
|
||||
if self.state.show_messages {
|
||||
return self.handle_chat_input(key);
|
||||
@@ -33,10 +53,10 @@ impl MainTui {
|
||||
return self.handle_settings_input(key);
|
||||
}
|
||||
|
||||
// Control combinations only ever page a list, so they never reach
|
||||
// the letter keys below: ctrl-d is not d.
|
||||
if key.modifiers.contains(KeyModifiers::CONTROL) {
|
||||
if self.state.focused_pane == FocusedPane::Results {
|
||||
self.handle_results_input(key);
|
||||
}
|
||||
self.navigate_focused_list(key);
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -53,16 +73,35 @@ impl MainTui {
|
||||
self.state.should_exit = true;
|
||||
return;
|
||||
}
|
||||
KeyCode::Char('1') => {
|
||||
self.state.focused_pane = FocusedPane::Searches;
|
||||
KeyCode::Char('?') => {
|
||||
self.state.show_help = true;
|
||||
self.state.help_view.to_oldest();
|
||||
return;
|
||||
}
|
||||
KeyCode::Char('2') => {
|
||||
self.state.focused_pane = FocusedPane::Results;
|
||||
// A pane's number is its place in the legend order.
|
||||
KeyCode::Char(digit @ '1'..='3') => {
|
||||
let index = usize::from(digit as u8 - b'1');
|
||||
self.state.focus_pane(FocusedPane::ALL[index]);
|
||||
return;
|
||||
}
|
||||
KeyCode::Char('3') => {
|
||||
self.state.focused_pane = FocusedPane::Downloads;
|
||||
KeyCode::Tab => {
|
||||
self.state.cycle_focus(true);
|
||||
return;
|
||||
}
|
||||
KeyCode::BackTab => {
|
||||
self.state.cycle_focus(false);
|
||||
return;
|
||||
}
|
||||
KeyCode::Char('z') => {
|
||||
self.state.toggle_zoom();
|
||||
return;
|
||||
}
|
||||
KeyCode::Char('w') => {
|
||||
self.state.hide_focused_pane();
|
||||
return;
|
||||
}
|
||||
KeyCode::Esc if self.state.layout.zoomed => {
|
||||
self.state.layout.zoomed = false;
|
||||
return;
|
||||
}
|
||||
KeyCode::Char('s') => {
|
||||
@@ -147,6 +186,14 @@ impl MainTui {
|
||||
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 {
|
||||
KeyCode::Char('i' | 'q') | KeyCode::Esc => {
|
||||
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) {
|
||||
let rows = self.state.searches.len();
|
||||
if self.navigate_focused_list(key) || self.scroll_focused_name(key) {
|
||||
return;
|
||||
}
|
||||
match key.code {
|
||||
KeyCode::Up | KeyCode::Char('k') => {
|
||||
cycle(&mut self.state.searches_table_state, rows, false);
|
||||
}
|
||||
KeyCode::Down | KeyCode::Char('j') => {
|
||||
cycle(&mut self.state.searches_table_state, rows, true);
|
||||
}
|
||||
KeyCode::Enter => {
|
||||
if let Some(selected) =
|
||||
self.state.searches_table_state.selected()
|
||||
@@ -329,7 +405,7 @@ impl MainTui {
|
||||
self.state.results_selected_indices.clear();
|
||||
self.state.results_name_offset = 0;
|
||||
self.state.results_table_state.select(Some(0));
|
||||
self.state.focused_pane = FocusedPane::Results;
|
||||
self.state.focus_pane(FocusedPane::Results);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -348,41 +424,10 @@ impl MainTui {
|
||||
}
|
||||
|
||||
fn handle_results_input(&mut self, key: KeyEvent) {
|
||||
let ctrl = key.modifiers.contains(KeyModifiers::CONTROL);
|
||||
if ctrl && !matches!(key.code, KeyCode::Char('f' | 'b' | 'd' | 'u')) {
|
||||
if self.navigate_focused_list(key) || self.scroll_focused_name(key) {
|
||||
return;
|
||||
}
|
||||
let items_count = self.visible_results().len();
|
||||
let page = self.results_page_size();
|
||||
let half = (page / 2).max(1);
|
||||
|
||||
match key.code {
|
||||
KeyCode::Up | KeyCode::Char('k') => {
|
||||
cycle(&mut self.state.results_table_state, items_count, false);
|
||||
}
|
||||
KeyCode::Down | KeyCode::Char('j') => {
|
||||
cycle(&mut self.state.results_table_state, items_count, true);
|
||||
}
|
||||
KeyCode::Home | KeyCode::Char('g') => self.select_row(0),
|
||||
KeyCode::End | KeyCode::Char('G') => {
|
||||
self.select_row(items_count.saturating_sub(1));
|
||||
}
|
||||
KeyCode::PageDown => self.scroll_rows(page),
|
||||
KeyCode::PageUp => self.scroll_rows(-page),
|
||||
KeyCode::Char('f') if ctrl => self.scroll_rows(page),
|
||||
KeyCode::Char('b') if ctrl => self.scroll_rows(-page),
|
||||
KeyCode::Char('d') if ctrl => self.scroll_rows(half),
|
||||
KeyCode::Char('u') if ctrl => self.scroll_rows(-half),
|
||||
KeyCode::Right | KeyCode::Char('l') => {
|
||||
self.scroll_names(NAME_SCROLL_STEP);
|
||||
}
|
||||
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(' ') => {
|
||||
let index = self
|
||||
.state
|
||||
@@ -418,52 +463,78 @@ impl MainTui {
|
||||
}
|
||||
}
|
||||
|
||||
fn select_row(&mut self, target: usize) {
|
||||
let len = self.visible_results().len();
|
||||
if len > 0 {
|
||||
self.state
|
||||
.results_table_state
|
||||
.select(Some(target.min(len - 1)));
|
||||
/// The keys that scroll the focused list's long column sideways, the
|
||||
/// same in every list: a file name in Results and Downloads, the query
|
||||
/// in Searches. Says whether `key` was one of them.
|
||||
fn scroll_focused_name(&mut self, key: KeyEvent) -> bool {
|
||||
match key.code {
|
||||
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.name_offset_mut(self.state.focused_pane) = 0;
|
||||
}
|
||||
KeyCode::Char('$') => {
|
||||
let end = self.highlighted_name_end();
|
||||
*self.state.name_offset_mut(self.state.focused_pane) = end;
|
||||
}
|
||||
_ => return false,
|
||||
}
|
||||
}
|
||||
|
||||
fn scroll_rows(&mut self, delta: isize) {
|
||||
let current = self.state.results_table_state.selected().unwrap_or(0);
|
||||
self.select_row(current.saturating_add_signed(delta));
|
||||
true
|
||||
}
|
||||
|
||||
fn scroll_names(&mut self, delta: isize) {
|
||||
let end = self.highlighted_name_end();
|
||||
let current = self.state.results_name_offset.min(end);
|
||||
self.state.results_name_offset =
|
||||
current.saturating_add_signed(delta).min(end);
|
||||
let offset = self.state.name_offset_mut(self.state.focused_pane);
|
||||
let current = (*offset).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 {
|
||||
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;
|
||||
};
|
||||
self.highlighted_result()
|
||||
.map_or(0, |file| name_end_offset(&file.filename, area))
|
||||
}
|
||||
|
||||
fn results_page_size(&self) -> isize {
|
||||
let rows = self.state.results_pane_area.map_or(1, |area| {
|
||||
pane_block(false).inner(area).height.saturating_sub(1)
|
||||
});
|
||||
rows.max(1) as isize
|
||||
match pane {
|
||||
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 handle_downloads_input(&mut self, key: KeyEvent) {
|
||||
// The pane lists downloads first, then uploads; navigation spans both.
|
||||
let rows = self.state.downloads.len() + self.state.uploads.len();
|
||||
if self.navigate_focused_list(key) || self.scroll_focused_name(key) {
|
||||
return;
|
||||
}
|
||||
match key.code {
|
||||
KeyCode::Up | KeyCode::Char('k') => {
|
||||
cycle(&mut self.state.downloads_table_state, rows, false);
|
||||
}
|
||||
KeyCode::Down | KeyCode::Char('j') => {
|
||||
cycle(&mut self.state.downloads_table_state, rows, true);
|
||||
}
|
||||
KeyCode::Char('x') => {
|
||||
self.cancel_selected_transfer();
|
||||
}
|
||||
@@ -489,18 +560,104 @@ impl MainTui {
|
||||
}
|
||||
|
||||
let clicked = Position::new(mouse.column, mouse.row);
|
||||
let panes = [
|
||||
(self.state.searches_pane_area, FocusedPane::Searches),
|
||||
(self.state.results_pane_area, FocusedPane::Results),
|
||||
(self.state.downloads_pane_area, FocusedPane::Downloads),
|
||||
];
|
||||
for (area, pane) in panes {
|
||||
if area.is_some_and(|area| area.contains(clicked)) {
|
||||
self.state.focused_pane = pane;
|
||||
return;
|
||||
// The areas are from the last draw. A pane hidden since then, in the
|
||||
// same batch of events, still has one, and is not there to click.
|
||||
let hit = FocusedPane::ALL.into_iter().find(|pane| {
|
||||
self.state.layout.is_visible(*pane)
|
||||
&& self
|
||||
.state
|
||||
.pane_area(*pane)
|
||||
.is_some_and(|area| area.contains(clicked))
|
||||
});
|
||||
if let Some(pane) = hit {
|
||||
self.state.focused_pane = pane;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// 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.
|
||||
|
||||
@@ -1341,13 +1341,522 @@ mod tests {
|
||||
assert!(tui.state.results_selected_indices.is_empty());
|
||||
}
|
||||
|
||||
fn screen_of(tui: &mut MainTui) -> String {
|
||||
let backend = ratatui::backend::TestBackend::new(160, 40);
|
||||
fn screen_sized(tui: &mut MainTui, width: u16, height: u16) -> String {
|
||||
let backend = ratatui::backend::TestBackend::new(width, height);
|
||||
let mut terminal = ratatui::Terminal::new(backend).expect("backend");
|
||||
terminal.draw(|frame| tui.render(frame)).expect("draw");
|
||||
terminal.backend().to_string()
|
||||
}
|
||||
|
||||
fn screen_of(tui: &mut MainTui) -> String {
|
||||
screen_sized(tui, 160, 40)
|
||||
}
|
||||
|
||||
/// The screen row carrying a pane's legend, `[2] Results` say, without
|
||||
/// the quotes the test backend wraps each row in.
|
||||
fn row_with<'a>(screen: &'a str, needle: &str) -> &'a str {
|
||||
let line = screen
|
||||
.lines()
|
||||
.find(|line| line.contains(needle))
|
||||
.unwrap_or_else(|| panic!("no row with {needle:?} in\n{screen}"));
|
||||
let start = line.find('"').map_or(0, |i| i + 1);
|
||||
let end = line.rfind('"').unwrap_or(line.len());
|
||||
&line[start..end]
|
||||
}
|
||||
|
||||
/// A window with a search, its results, and a transfer, so every pane
|
||||
/// has something to show.
|
||||
fn furnished_tui() -> MainTui {
|
||||
let mut tui = results_tui(3);
|
||||
tui.state.searches.push(crate::models::SearchEntry {
|
||||
query: "aphex twin".to_string(),
|
||||
status: SearchStatus::Completed,
|
||||
results: results(3),
|
||||
known_files: 3,
|
||||
owned: true,
|
||||
start_time: std::time::Instant::now(),
|
||||
cancel_flag: Arc::new(std::sync::atomic::AtomicBool::new(false)),
|
||||
});
|
||||
tui.state.selected_search_index = Some(0);
|
||||
tui.state.downloads.push(crate::models::DownloadEntry {
|
||||
download: queued("bob", "@@x\\Music\\00.mp3"),
|
||||
receiver: None,
|
||||
});
|
||||
tui
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_results_pane_has_the_whole_width_to_itself() {
|
||||
let mut tui = furnished_tui();
|
||||
let screen = screen_of(&mut tui);
|
||||
|
||||
let results = row_with(&screen, "[2] Results");
|
||||
assert!(results.starts_with('╭'), "{results}");
|
||||
assert!(results.trim_end().ends_with('╮'), "{results}");
|
||||
assert!(!results.contains("[1]"), "nothing beside it: {results}");
|
||||
|
||||
// The other three share the row underneath, in this order.
|
||||
let row = row_with(&screen, "[1] Searches");
|
||||
let searches = row.find("[1]").expect("searches");
|
||||
let downloads = row.find("[3]").expect("downloads");
|
||||
let info = row.find("[Info]").expect("info");
|
||||
assert!(searches < downloads && downloads < info, "{row}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn tab_and_shift_tab_walk_the_panes_in_legend_order() {
|
||||
let mut tui = furnished_tui();
|
||||
tui.state.focused_pane = FocusedPane::Searches;
|
||||
press(&mut tui, KeyCode::Tab);
|
||||
assert_eq!(tui.state.focused_pane, FocusedPane::Results);
|
||||
press(&mut tui, KeyCode::Tab);
|
||||
assert_eq!(tui.state.focused_pane, FocusedPane::Downloads);
|
||||
press(&mut tui, KeyCode::Tab);
|
||||
assert_eq!(tui.state.focused_pane, FocusedPane::Searches);
|
||||
press(&mut tui, KeyCode::BackTab);
|
||||
assert_eq!(tui.state.focused_pane, FocusedPane::Downloads);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn w_hides_the_focused_pane_and_its_number_brings_it_back() {
|
||||
let mut tui = furnished_tui();
|
||||
tui.state.focused_pane = FocusedPane::Searches;
|
||||
|
||||
press(&mut tui, KeyCode::Char('w'));
|
||||
|
||||
let screen = screen_of(&mut tui);
|
||||
assert!(!screen.contains("[1] Searches"), "{screen}");
|
||||
assert!(screen.contains("[2] Results"), "{screen}");
|
||||
assert!(screen.contains("[3] Downloads"), "{screen}");
|
||||
assert_eq!(tui.state.focused_pane, FocusedPane::Results);
|
||||
assert_eq!(tui.state.searches_pane_area, None, "nothing to click");
|
||||
// The transfers widen into the space.
|
||||
let row = row_with(&screen, "[3] Downloads");
|
||||
assert!(row.starts_with('╭'), "{row}");
|
||||
|
||||
press(&mut tui, KeyCode::Char('1'));
|
||||
|
||||
let screen = screen_of(&mut tui);
|
||||
assert!(screen.contains("[1] Searches"), "{screen}");
|
||||
assert_eq!(tui.state.focused_pane, FocusedPane::Searches);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn hiding_the_results_gives_the_row_the_whole_height() {
|
||||
let mut tui = furnished_tui();
|
||||
press(&mut tui, KeyCode::Char('w'));
|
||||
let screen = screen_of(&mut tui);
|
||||
assert!(!screen.contains("[2] Results"), "{screen}");
|
||||
// The row now starts right under the status bar.
|
||||
let status = screen
|
||||
.lines()
|
||||
.position(|line| line.contains("Status"))
|
||||
.expect("status bar");
|
||||
let row = screen
|
||||
.lines()
|
||||
.position(|line| line.contains("[1] Searches"))
|
||||
.expect("searches");
|
||||
assert_eq!(row, status + 3, "{screen}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_last_pane_standing_cannot_be_hidden() {
|
||||
// Results goes, then Downloads; Searches is what is left, and a
|
||||
// third press leaves it there.
|
||||
let mut tui = furnished_tui();
|
||||
press(&mut tui, KeyCode::Char('w'));
|
||||
press(&mut tui, KeyCode::Char('w'));
|
||||
press(&mut tui, KeyCode::Char('w'));
|
||||
let screen = screen_of(&mut tui);
|
||||
assert!(screen.contains("[1] Searches"), "{screen}");
|
||||
assert!(!screen.contains("[3] Downloads"), "{screen}");
|
||||
assert_eq!(tui.state.focused_pane, FocusedPane::Searches);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn z_zooms_the_focused_pane_and_esc_or_z_leaves_it() {
|
||||
let mut tui = furnished_tui();
|
||||
press(&mut tui, KeyCode::Char('z'));
|
||||
|
||||
let screen = screen_of(&mut tui);
|
||||
assert!(screen.contains("[2] Results"), "{screen}");
|
||||
assert!(!screen.contains("[1] Searches"), "{screen}");
|
||||
assert!(!screen.contains("[3] Downloads"), "{screen}");
|
||||
assert!(!screen.contains("[Info]"), "{screen}");
|
||||
assert!(screen.contains("[z → unzoom]"), "{screen}");
|
||||
|
||||
// Tab while zoomed switches which pane fills the window.
|
||||
press(&mut tui, KeyCode::Tab);
|
||||
let screen = screen_of(&mut tui);
|
||||
assert!(screen.contains("[3] Downloads"), "{screen}");
|
||||
assert!(!screen.contains("[2] Results"), "{screen}");
|
||||
|
||||
press(&mut tui, KeyCode::Esc);
|
||||
assert!(!tui.state.layout.zoomed);
|
||||
let screen = screen_of(&mut tui);
|
||||
assert!(screen.contains("[2] Results"), "{screen}");
|
||||
assert!(screen.contains("[Info]"), "{screen}");
|
||||
|
||||
press(&mut tui, KeyCode::Char('z'));
|
||||
press(&mut tui, KeyCode::Char('z'));
|
||||
assert!(!tui.state.layout.zoomed, "z toggles");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_search_brings_a_hidden_results_pane_back() {
|
||||
let mut tui = furnished_tui();
|
||||
press(&mut tui, KeyCode::Char('w'));
|
||||
assert!(!tui.state.layout.is_visible(FocusedPane::Results));
|
||||
|
||||
press(&mut tui, KeyCode::Char('s'));
|
||||
press(&mut tui, KeyCode::Char('x'));
|
||||
press(&mut tui, KeyCode::Enter);
|
||||
|
||||
assert!(tui.state.layout.is_visible(FocusedPane::Results));
|
||||
assert_eq!(tui.state.focused_pane, FocusedPane::Results);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn question_mark_opens_the_keys_list_and_closes_it_again() {
|
||||
let mut tui = furnished_tui();
|
||||
press(&mut tui, KeyCode::Char('?'));
|
||||
|
||||
let screen = screen_of(&mut tui);
|
||||
assert!(screen.contains("Keys"), "{screen}");
|
||||
assert!(screen.contains("Tab / Shift-Tab"), "{screen}");
|
||||
assert!(screen.contains("clear every finished one"), "{screen}");
|
||||
|
||||
// Other keys are swallowed while it is open.
|
||||
press(&mut tui, KeyCode::Char('q'));
|
||||
assert!(!tui.state.should_exit, "q closes the list, not the app");
|
||||
assert!(!tui.state.show_help);
|
||||
|
||||
press(&mut tui, KeyCode::Char('?'));
|
||||
press(&mut tui, KeyCode::Char('w'));
|
||||
assert!(tui.state.show_help, "w did not reach the panes");
|
||||
assert!(tui.state.layout.is_visible(FocusedPane::Results));
|
||||
press(&mut tui, KeyCode::Esc);
|
||||
assert!(!tui.state.show_help);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn on_a_small_terminal_the_keys_list_stacks_and_scrolls() {
|
||||
let mut tui = furnished_tui();
|
||||
press(&mut tui, KeyCode::Char('?'));
|
||||
|
||||
let screen = screen_sized(&mut tui, 80, 24);
|
||||
let panes = row_with(&screen, "Panes");
|
||||
assert!(!panes.contains("Searches"), "one column: {panes}");
|
||||
assert!(screen.contains("focus a pane, hidden or not"), "{screen}");
|
||||
assert!(!screen.contains("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]
|
||||
fn the_info_pane_describes_the_highlighted_result() {
|
||||
let mut tui = results_tui(3);
|
||||
|
||||
@@ -6,8 +6,9 @@ use crate::ui::panes::{
|
||||
render_rooms_pane, render_searches_pane, selected_transfer,
|
||||
};
|
||||
use crate::ui::{
|
||||
accent_style, dimmed_style, format_shortcuts_styled, pane_block,
|
||||
plain_title, primary_style, render_download_stats, warning_style,
|
||||
accent_style, body_style, dimmed_style, info_style, pack_shortcuts,
|
||||
pane_block, plain_title, primary_style, render_download_stats,
|
||||
warning_style,
|
||||
};
|
||||
use ratatui::{
|
||||
Frame,
|
||||
@@ -17,8 +18,90 @@ use ratatui::{
|
||||
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 {
|
||||
pub(super) fn render(&mut self, frame: &mut Frame) {
|
||||
let shortcuts = self.shortcut_rows(frame.area().width);
|
||||
let mut constraints = vec![
|
||||
Constraint::Length(3), // Status bar
|
||||
Constraint::Fill(1), // Main content
|
||||
@@ -26,7 +109,7 @@ impl MainTui {
|
||||
if self.state.command_bar_active {
|
||||
constraints.push(Constraint::Length(3)); // Command bar
|
||||
}
|
||||
constraints.push(Constraint::Length(3)); // Shortcuts
|
||||
constraints.push(Constraint::Length(shortcuts.len() as u16 + 2));
|
||||
let main_chunks = Layout::vertical(constraints).split(frame.area());
|
||||
|
||||
let daemon = self.client.daemon_endpoint();
|
||||
@@ -43,55 +126,109 @@ impl MainTui {
|
||||
// Render command bar if active (vim-style, above shortcuts)
|
||||
if self.state.command_bar_active {
|
||||
self.render_command_bar(frame, main_chunks[2]);
|
||||
self.render_shortcuts(frame, main_chunks[3]);
|
||||
self.render_shortcuts(frame, main_chunks[3], shortcuts);
|
||||
} else {
|
||||
self.render_shortcuts(frame, main_chunks[2]);
|
||||
self.render_shortcuts(frame, main_chunks[2], shortcuts);
|
||||
}
|
||||
|
||||
self.render_overlays(frame);
|
||||
}
|
||||
|
||||
/// Results across the top, where a long file name has the whole width;
|
||||
/// Searches, Downloads and Info in a row underneath. A hidden pane drops
|
||||
/// out of its row and the rest widen; a zoomed one has the area alone.
|
||||
fn render_content(&mut self, frame: &mut Frame, area: Rect) {
|
||||
// Split main content area
|
||||
let content_chunks = Layout::horizontal([
|
||||
Constraint::Percentage(30), // Searches pane
|
||||
Constraint::Percentage(70), // Results + Downloads
|
||||
])
|
||||
.split(area);
|
||||
// Only what is drawn can be clicked.
|
||||
self.state.searches_pane_area = None;
|
||||
self.state.results_pane_area = None;
|
||||
self.state.downloads_pane_area = None;
|
||||
|
||||
// Store searches pane area
|
||||
self.state.searches_pane_area = Some(content_chunks[0]);
|
||||
if self.state.layout.zoomed {
|
||||
self.render_pane(frame, area, self.state.focused_pane);
|
||||
return;
|
||||
}
|
||||
|
||||
// Render Searches pane (left)
|
||||
render_searches_pane(
|
||||
frame,
|
||||
content_chunks[0],
|
||||
&self.state.searches,
|
||||
&mut self.state.searches_table_state,
|
||||
self.state.focused_pane == FocusedPane::Searches,
|
||||
);
|
||||
// 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();
|
||||
|
||||
// Split right side into Results (top) and Downloads (bottom)
|
||||
let right_chunks = Layout::vertical([
|
||||
Constraint::Percentage(60), // Results
|
||||
Constraint::Percentage(40), // Downloads
|
||||
])
|
||||
.split(content_chunks[1]);
|
||||
let row_area = if self.state.layout.is_visible(FocusedPane::Results) {
|
||||
// With only Info left in the row, the results deserve more of
|
||||
// the height.
|
||||
let row_weight = if row_panes.is_empty() { 1 } else { 2 };
|
||||
let [top, bottom] = Layout::vertical([
|
||||
Constraint::Fill(3),
|
||||
Constraint::Fill(row_weight),
|
||||
])
|
||||
.areas(area);
|
||||
self.render_pane(frame, top, FocusedPane::Results);
|
||||
bottom
|
||||
} else {
|
||||
area
|
||||
};
|
||||
|
||||
// Split bottom-right into Downloads list and Info pane
|
||||
let downloads_chunks = Layout::horizontal([
|
||||
Constraint::Percentage(60), // Downloads list
|
||||
Constraint::Percentage(40), // Info pane
|
||||
])
|
||||
.split(right_chunks[1]);
|
||||
let mut constraints: Vec<Constraint> = row_panes
|
||||
.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()]);
|
||||
}
|
||||
|
||||
// Store results and downloads pane areas
|
||||
self.state.results_pane_area = Some(right_chunks[0]);
|
||||
self.state.downloads_pane_area = Some(downloads_chunks[0]);
|
||||
fn render_pane(
|
||||
&mut self,
|
||||
frame: &mut Frame,
|
||||
area: Rect,
|
||||
pane: FocusedPane,
|
||||
) {
|
||||
let focused = self.state.focused_pane == pane;
|
||||
match pane {
|
||||
FocusedPane::Searches => {
|
||||
self.state.searches_pane_area = Some(area);
|
||||
render_searches_pane(
|
||||
frame,
|
||||
area,
|
||||
&self.state.searches,
|
||||
&mut self.state.searches_table_state,
|
||||
focused,
|
||||
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,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Render Results pane. When a filter is active the rendered rows are a
|
||||
// subset, so the pane also needs the mapping back to unfiltered indices
|
||||
// to render the selection checkboxes correctly.
|
||||
fn render_results_pane(
|
||||
&mut self,
|
||||
frame: &mut Frame,
|
||||
area: Rect,
|
||||
focused: bool,
|
||||
) {
|
||||
// When a filter is active the rendered rows are a subset, so the pane
|
||||
// also needs the mapping back to unfiltered indices to render the
|
||||
// selection checkboxes correctly.
|
||||
let (results_items, results_original_indices) =
|
||||
if self.state.results_filter_query.is_empty() {
|
||||
(&self.state.results_items, None)
|
||||
@@ -110,7 +247,7 @@ impl MainTui {
|
||||
|
||||
render_results_pane(
|
||||
frame,
|
||||
right_chunks[0],
|
||||
area,
|
||||
ResultsPaneParams {
|
||||
items: results_items,
|
||||
table_state: &mut self.state.results_table_state,
|
||||
@@ -118,21 +255,16 @@ impl MainTui {
|
||||
original_indices: results_original_indices,
|
||||
filter_query: &self.state.results_filter_query,
|
||||
is_filtering: self.state.results_is_filtering,
|
||||
focused: self.state.focused_pane == FocusedPane::Results,
|
||||
focused,
|
||||
active_search_query,
|
||||
name_offset: self.state.results_name_offset,
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
render_downloads_pane(
|
||||
frame,
|
||||
downloads_chunks[0],
|
||||
&self.state.downloads,
|
||||
&self.state.uploads,
|
||||
&mut self.state.downloads_table_state,
|
||||
self.state.focused_pane == FocusedPane::Downloads,
|
||||
);
|
||||
|
||||
/// The Info pane follows the focus: the highlighted result, or the
|
||||
/// highlighted transfer.
|
||||
fn render_info_pane(&self, frame: &mut Frame, area: Rect) {
|
||||
let selected = if self.state.focused_pane == FocusedPane::Results {
|
||||
self.highlighted_result().map(InfoSubject::Result)
|
||||
} else {
|
||||
@@ -144,7 +276,7 @@ impl MainTui {
|
||||
};
|
||||
render_download_info_pane(
|
||||
frame,
|
||||
downloads_chunks[1],
|
||||
area,
|
||||
selected,
|
||||
matches!(
|
||||
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) {
|
||||
// Messages inbox overlays everything when open.
|
||||
if self.state.show_messages {
|
||||
@@ -162,6 +341,7 @@ impl MainTui {
|
||||
// Browse tree overlays everything when open.
|
||||
if self.state.show_browse && !self.state.browse.is_empty() {
|
||||
let area = centered_rect(80, 80, frame.area());
|
||||
self.state.popup_area = Some(area);
|
||||
frame.render_widget(ratatui::widgets::Clear, area);
|
||||
render_browse_pane(
|
||||
frame,
|
||||
@@ -180,14 +360,21 @@ impl MainTui {
|
||||
// Chat rooms overlay everything when open.
|
||||
if self.state.show_rooms {
|
||||
let area = centered_rect(85, 80, frame.area());
|
||||
self.state.popup_area = Some(area);
|
||||
frame.render_widget(ratatui::widgets::Clear, area);
|
||||
render_rooms_pane(
|
||||
frame,
|
||||
area,
|
||||
&self.state.rooms,
|
||||
&mut self.state.rooms,
|
||||
&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) {
|
||||
@@ -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;
|
||||
// it renders the messages and the compose line itself.
|
||||
let area = centered_rect(70, 60, frame.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.
|
||||
@@ -294,6 +483,7 @@ impl MainTui {
|
||||
}
|
||||
RoomsView::Chat => vec![
|
||||
("Enter", "say"),
|
||||
("PgUp/PgDn", "scroll"),
|
||||
("↑↓", "pick user"),
|
||||
("b", "browse 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
|
||||
/// open and where the focus sits. Sibling of [`Self::rooms_shortcuts`].
|
||||
fn shortcuts(&self) -> Vec<(&'static str, &'static str)> {
|
||||
if self.state.settings.is_some() {
|
||||
if self.state.show_help {
|
||||
vec![("?/Esc", "close")]
|
||||
} else if self.state.settings.is_some() {
|
||||
vec![
|
||||
("↑↓", "move"),
|
||||
("Enter/e", "edit download dir"),
|
||||
@@ -326,6 +537,7 @@ impl MainTui {
|
||||
} else {
|
||||
vec![
|
||||
("Enter", "type"),
|
||||
("PgUp/PgDn", "scroll"),
|
||||
("↑↓/Tab", "switch chat"),
|
||||
("m", "new chat"),
|
||||
("i/Esc", "close"),
|
||||
@@ -336,6 +548,7 @@ impl MainTui {
|
||||
} else if self.state.show_browse {
|
||||
vec![
|
||||
("↑↓", "move"),
|
||||
("PgUp/PgDn", "page"),
|
||||
("→←", "expand/collapse"),
|
||||
("Enter", "open/download"),
|
||||
("d", "download folder"),
|
||||
@@ -364,29 +577,29 @@ impl MainTui {
|
||||
("Esc", "cancel"),
|
||||
],
|
||||
}
|
||||
} else if self.state.results_is_filtering
|
||||
&& self.state.focused_pane == FocusedPane::Results
|
||||
{
|
||||
// Typing goes to the filter, so no letter keys are on offer here.
|
||||
vec![
|
||||
("Type", "filter"),
|
||||
("↑/↓", "navigate"),
|
||||
("PgUp/PgDn", "page"),
|
||||
("Enter", "keep filter"),
|
||||
("Esc", "clear filter"),
|
||||
]
|
||||
} else {
|
||||
match self.state.focused_pane {
|
||||
let mut keys = match self.state.focused_pane {
|
||||
FocusedPane::Searches => vec![
|
||||
("s", "search"),
|
||||
("Enter", "results"),
|
||||
("d", "remove"),
|
||||
("m", "message"),
|
||||
("i", "inbox"),
|
||||
("c", "chat"),
|
||||
("b", "browse user"),
|
||||
("1-3", "focus pane"),
|
||||
("↑↓", "navigate"),
|
||||
("Enter", "results"),
|
||||
("q", "quit"),
|
||||
("h/l", "scroll query"),
|
||||
],
|
||||
FocusedPane::Results if self.state.results_is_filtering => {
|
||||
vec![
|
||||
("Type", "filter"),
|
||||
("↑/↓", "navigate"),
|
||||
("Enter", "confirm"),
|
||||
("Esc", "clear filter"),
|
||||
("1-3", "focus pane"),
|
||||
("q", "quit"),
|
||||
]
|
||||
}
|
||||
FocusedPane::Results => vec![
|
||||
("Space", "select"),
|
||||
("Enter", "download"),
|
||||
@@ -394,29 +607,51 @@ impl MainTui {
|
||||
("c", "chat"),
|
||||
("/", "filter"),
|
||||
("a/A", "select all/none"),
|
||||
("h/l", "scroll"),
|
||||
("h/l", "scroll name"),
|
||||
("g/G", "top/end"),
|
||||
("1-3", "focus pane"),
|
||||
("q", "quit"),
|
||||
],
|
||||
FocusedPane::Downloads => {
|
||||
vec![
|
||||
("p", "pause/resume"),
|
||||
("x", "cancel"),
|
||||
("r", "retry failed"),
|
||||
("d", "delete queued/done"),
|
||||
("c", "clear finished"),
|
||||
("b", "browse user"),
|
||||
("1-3", "focus pane"),
|
||||
("q", "quit"),
|
||||
]
|
||||
}
|
||||
}
|
||||
FocusedPane::Downloads => vec![
|
||||
("p", "pause/resume"),
|
||||
("x", "cancel"),
|
||||
("r", "retry failed"),
|
||||
("d", "delete queued/done"),
|
||||
("c", "clear finished"),
|
||||
("b", "browse user"),
|
||||
("h/l", "scroll name"),
|
||||
],
|
||||
};
|
||||
keys.extend(self.pane_shortcuts());
|
||||
keys
|
||||
}
|
||||
}
|
||||
|
||||
fn render_shortcuts(&self, frame: &mut Frame, area: Rect) {
|
||||
let shortcuts_line = format_shortcuts_styled(&self.shortcuts());
|
||||
/// The legend packed into rows for a window `width` wide, so a narrow
|
||||
/// terminal grows the bar instead of cutting keys off its right edge.
|
||||
fn shortcut_rows(&self, width: u16) -> Vec<Line<'static>> {
|
||||
// Borders and padding, and the unread badge's column when it shows.
|
||||
let badge = self.unread_badge().width() as u16;
|
||||
let reserved = 4 + if badge > 0 { badge + 1 } else { 0 };
|
||||
pack_shortcuts(&self.shortcuts(), width.saturating_sub(reserved))
|
||||
}
|
||||
|
||||
/// A prominent unread badge for the shortcuts bar. It rides in the
|
||||
/// always-visible bar, so unread mail/chat shows in every pane, not just
|
||||
/// some. The spinner tick (0..10, ~100ms each) drives a ~1s blink: bright
|
||||
/// for the first half of the cycle, dim for the second.
|
||||
fn unread_badge(&self) -> Line<'static> {
|
||||
unread_indicator(
|
||||
self.state.unread_messages,
|
||||
self.state.rooms.total_unread(),
|
||||
self.spinner_state < 5,
|
||||
)
|
||||
}
|
||||
|
||||
fn render_shortcuts(
|
||||
&self,
|
||||
frame: &mut Frame,
|
||||
area: Rect,
|
||||
rows: Vec<Line<'static>>,
|
||||
) {
|
||||
// Surface our own sharing status in the block title.
|
||||
let shared = self.client.shared_directories();
|
||||
let sharing = match shared.as_slice() {
|
||||
@@ -431,26 +666,20 @@ impl MainTui {
|
||||
let inner = block.inner(area);
|
||||
frame.render_widget(block, area);
|
||||
|
||||
// A prominent unread badge, right-aligned so the context shortcuts on
|
||||
// the left never crowd it out. It rides in the always-visible shortcuts
|
||||
// bar, so unread mail/chat shows in every pane, not just some. The
|
||||
// spinner tick (0..10, ~100ms each) drives a ~1s blink: bright for the
|
||||
// first half of the cycle, dim for the second.
|
||||
let unread = unread_indicator(
|
||||
self.state.unread_messages,
|
||||
self.state.rooms.total_unread(),
|
||||
self.spinner_state < 5,
|
||||
);
|
||||
// The unread badge is right-aligned so the context shortcuts on the
|
||||
// left never crowd it out.
|
||||
let unread = self.unread_badge();
|
||||
if unread.spans.is_empty() {
|
||||
frame.render_widget(Paragraph::new(shortcuts_line), inner);
|
||||
frame.render_widget(Paragraph::new(rows), inner);
|
||||
} else {
|
||||
let badge_width = unread.width() as u16;
|
||||
let cols = Layout::horizontal([
|
||||
Constraint::Fill(1),
|
||||
Constraint::Length(badge_width),
|
||||
])
|
||||
.spacing(1)
|
||||
.split(inner);
|
||||
frame.render_widget(Paragraph::new(shortcuts_line), cols[0]);
|
||||
frame.render_widget(Paragraph::new(rows), cols[0]);
|
||||
frame.render_widget(
|
||||
Paragraph::new(unread).alignment(Alignment::Right),
|
||||
cols[1],
|
||||
@@ -493,6 +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.
|
||||
fn centered_rect(percent_x: u16, percent_y: u16, area: Rect) -> Rect {
|
||||
let vertical = Layout::vertical([
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
use super::MainTui;
|
||||
use super::input::{jumped, list_jump, scroll_log};
|
||||
use crate::models::RoomsView;
|
||||
use ratatui::crossterm::event::{KeyCode, KeyEvent, KeyModifiers};
|
||||
|
||||
@@ -38,6 +39,16 @@ impl MainTui {
|
||||
|
||||
fn handle_rooms_list_input(&mut self, key: KeyEvent) {
|
||||
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 {
|
||||
// Esc peels back one level: clear a lingering filter first (as the
|
||||
// title's "Esc: clear" promises), otherwise close the popup.
|
||||
@@ -92,6 +103,16 @@ impl MainTui {
|
||||
}
|
||||
|
||||
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 {
|
||||
KeyCode::Char('q') => self.state.show_rooms = false,
|
||||
KeyCode::Esc | KeyCode::Char('l') => {
|
||||
@@ -208,15 +229,23 @@ impl MainTui {
|
||||
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.
|
||||
pub(super) fn poll_room_events(&mut self) {
|
||||
let viewing = if self.state.show_rooms
|
||||
&& self.state.rooms.view == RoomsView::Chat
|
||||
{
|
||||
self.state.rooms.active_room().map(|r| r.name.clone())
|
||||
} else {
|
||||
None
|
||||
};
|
||||
let viewing = self.viewed_room();
|
||||
for event in self.client.take_room_events() {
|
||||
self.state.rooms.apply_event(event, viewing.as_deref());
|
||||
}
|
||||
|
||||
@@ -231,8 +231,8 @@ impl MainTui {
|
||||
self.state.results_table_state.select(Some(0));
|
||||
self.state.results_name_offset = 0;
|
||||
|
||||
// Switch focus to Results pane
|
||||
self.state.focused_pane = FocusedPane::Results;
|
||||
// Switch focus to Results pane, bringing it back if it was hidden
|
||||
self.state.focus_pane(FocusedPane::Results);
|
||||
|
||||
let client = self.client.clone();
|
||||
let timeout = self.search_timeout;
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
use crate::models::{AppState, MessageDirection};
|
||||
use crate::models::{AppState, ChatMessage, LogView, MessageDirection};
|
||||
use crate::ui::{
|
||||
accent_style, border_style, dimmed_style, highlight_style, info_style,
|
||||
primary_style, wrap_chat_line,
|
||||
@@ -17,10 +17,11 @@ use ratatui::{
|
||||
pub fn render_chat_pane(
|
||||
frame: &mut Frame,
|
||||
area: Rect,
|
||||
state: &AppState,
|
||||
state: &mut AppState,
|
||||
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(
|
||||
|| " Messages (m: compose, i/Esc: close) ".to_string(),
|
||||
|peer| format!(" {peer} (↑↓/Tab: switch, m: to…, i/Esc: close) "),
|
||||
@@ -51,7 +52,14 @@ pub fn render_chat_pane(
|
||||
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);
|
||||
}
|
||||
|
||||
@@ -94,13 +102,13 @@ fn render_peers(
|
||||
fn render_messages(
|
||||
frame: &mut Frame,
|
||||
area: Rect,
|
||||
state: &AppState,
|
||||
messages: &[ChatMessage],
|
||||
view: &mut LogView,
|
||||
peer: &str,
|
||||
own_username: &str,
|
||||
) {
|
||||
let width = area.width as usize;
|
||||
let lines: Vec<Line> = state
|
||||
.messages
|
||||
let lines: Vec<Line> = messages
|
||||
.iter()
|
||||
.filter(|m| m.peer == peer)
|
||||
.flat_map(|m| {
|
||||
@@ -123,9 +131,9 @@ fn render_messages(
|
||||
})
|
||||
.collect();
|
||||
|
||||
// Auto-scroll: tail by rendered rows so the newest message stays visible.
|
||||
let start = lines.len().saturating_sub((area.height as usize).max(1));
|
||||
frame.render_widget(Paragraph::new(lines[start..].to_vec()), area);
|
||||
// The newest rows, unless the reader is holding a place in the history.
|
||||
let window = view.window(lines.len(), usize::from(area.height));
|
||||
frame.render_widget(Paragraph::new(lines[window].to_vec()), area);
|
||||
}
|
||||
|
||||
fn render_compose(frame: &mut Frame, area: Rect, state: &AppState) {
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
use super::name_scroll::{column_width, end_offset, scroll_text};
|
||||
use crate::models::DownloadEntry;
|
||||
use crate::ui::{
|
||||
GLYPH_ACTIVE, GLYPH_DONE, GLYPH_FAILED, GLYPH_QUEUED, HIGHLIGHT_SYMBOL,
|
||||
@@ -8,12 +9,43 @@ use crate::ui::{
|
||||
};
|
||||
use ratatui::{
|
||||
Frame,
|
||||
layout::Rect,
|
||||
layout::{Constraint, Rect},
|
||||
widgets::{Cell, HighlightSpacing, Paragraph, Row, Table, TableState},
|
||||
};
|
||||
use soulseek_rs::DownloadStatus;
|
||||
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(
|
||||
frame: &mut Frame,
|
||||
area: Rect,
|
||||
@@ -21,6 +53,7 @@ pub fn render_downloads_pane(
|
||||
uploads: &[UploadInfo],
|
||||
table_state: &mut TableState,
|
||||
focused: bool,
|
||||
name_offset: usize,
|
||||
) {
|
||||
if downloads.is_empty() && uploads.is_empty() {
|
||||
let empty_block = pane_block(focused).title(pane_title(
|
||||
@@ -47,6 +80,7 @@ pub fn render_downloads_pane(
|
||||
])
|
||||
.height(1);
|
||||
|
||||
let name_width = column_width(area, &WIDTHS, NAME_COLUMN);
|
||||
let mut rows: Vec<Row> = downloads
|
||||
.iter()
|
||||
.map(|download_entry| {
|
||||
@@ -106,7 +140,12 @@ pub fn render_downloads_pane(
|
||||
|
||||
Row::new(vec![
|
||||
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(progress_text).style(progress_style),
|
||||
Cell::from(speed_text).style(warning_style()),
|
||||
@@ -149,29 +188,21 @@ pub fn render_downloads_pane(
|
||||
}
|
||||
_ => "-".to_string(),
|
||||
};
|
||||
let basename = upload
|
||||
.filename
|
||||
.rsplit(['\\', '/'])
|
||||
.next()
|
||||
.unwrap_or(&upload.filename);
|
||||
Row::new(vec![
|
||||
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(progress_text).style(primary_style()),
|
||||
Cell::from(speed_text).style(warning_style()),
|
||||
])
|
||||
}));
|
||||
|
||||
let widths = [
|
||||
ratatui::layout::Constraint::Length(8),
|
||||
ratatui::layout::Constraint::Fill(2),
|
||||
ratatui::layout::Constraint::Length(15),
|
||||
ratatui::layout::Constraint::Length(25),
|
||||
ratatui::layout::Constraint::Length(12),
|
||||
];
|
||||
|
||||
let table = Table::new(rows, widths)
|
||||
let table = Table::new(rows, WIDTHS)
|
||||
.header(header)
|
||||
.row_highlight_style(row_highlight_style())
|
||||
.highlight_symbol(HIGHLIGHT_SYMBOL)
|
||||
|
||||
@@ -2,6 +2,7 @@ mod browse_pane;
|
||||
mod chat_pane;
|
||||
mod download_info_pane;
|
||||
mod downloads_pane;
|
||||
mod name_scroll;
|
||||
mod results_pane;
|
||||
mod rooms_pane;
|
||||
mod searches_pane;
|
||||
@@ -11,9 +12,12 @@ pub use chat_pane::render_chat_pane;
|
||||
pub use download_info_pane::{
|
||||
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::{
|
||||
ResultsPaneParams, name_end_offset, render_results_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};
|
||||
|
||||
@@ -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");
|
||||
}
|
||||
}
|
||||
@@ -1,3 +1,4 @@
|
||||
use super::name_scroll::{column_width, end_offset, scroll_text};
|
||||
use crate::models::FileDisplayData;
|
||||
use crate::ui::{
|
||||
BYTES_PER_MB, HIGHLIGHT_SYMBOL, body_style, dimmed_style, format_bytes,
|
||||
@@ -6,12 +7,11 @@ use crate::ui::{
|
||||
};
|
||||
use ratatui::{
|
||||
Frame,
|
||||
layout::{Constraint, Layout, Rect},
|
||||
layout::{Constraint, Rect},
|
||||
text::{Line, Span},
|
||||
widgets::{Cell, HighlightSpacing, Paragraph, Row, Table, TableState},
|
||||
};
|
||||
use std::collections::HashSet;
|
||||
use unicode_width::UnicodeWidthChar;
|
||||
|
||||
const VERSION: &str = env!("CARGO_PKG_VERSION");
|
||||
|
||||
@@ -39,59 +39,12 @@ pub struct ResultsPaneParams<'a> {
|
||||
pub name_offset: usize,
|
||||
}
|
||||
|
||||
fn name_column_width(area: Rect) -> usize {
|
||||
let inner = pane_block(false).inner(area);
|
||||
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 }
|
||||
}
|
||||
/// Filename is the second column.
|
||||
const NAME_COLUMN: usize = 1;
|
||||
|
||||
#[must_use]
|
||||
pub fn name_end_offset(name: &str, area: Rect) -> usize {
|
||||
overflow(Span::raw(name).width(), name_column_width(area))
|
||||
}
|
||||
|
||||
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..])])
|
||||
end_offset(name, area, &WIDTHS, NAME_COLUMN)
|
||||
}
|
||||
|
||||
/// Whether the rendered row `display_idx` is selected. `selected_indices` holds
|
||||
@@ -173,7 +126,7 @@ pub fn render_results_pane(
|
||||
])
|
||||
.height(1);
|
||||
|
||||
let name_width = name_column_width(area);
|
||||
let name_width = column_width(area, &WIDTHS, NAME_COLUMN);
|
||||
let rows: Vec<Row> = items
|
||||
.iter()
|
||||
.enumerate()
|
||||
@@ -206,7 +159,7 @@ pub fn render_results_pane(
|
||||
|
||||
Row::new(vec![
|
||||
Cell::from(checkbox).style(checkbox_style),
|
||||
Cell::from(scroll_name(
|
||||
Cell::from(scroll_text(
|
||||
&file.filename,
|
||||
name_offset,
|
||||
name_width,
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
use crate::models::{RoomsState, RoomsView};
|
||||
use crate::models::{LogView, RoomsState, RoomsView};
|
||||
use crate::ui::{
|
||||
HIGHLIGHT_SYMBOL, PANE_PADDING, accent_style, dimmed_style,
|
||||
highlight_style, info_style, pane_block, plain_title, primary_style,
|
||||
@@ -20,7 +20,7 @@ use ratatui::{
|
||||
pub fn render_rooms_pane(
|
||||
frame: &mut Frame,
|
||||
area: Rect,
|
||||
rooms: &RoomsState,
|
||||
rooms: &mut RoomsState,
|
||||
list_table_state: &mut TableState,
|
||||
) {
|
||||
match rooms.view {
|
||||
@@ -94,7 +94,7 @@ fn render_list(
|
||||
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(
|
||||
" 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);
|
||||
|
||||
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(
|
||||
Paragraph::new("No open rooms. Press l for the room list.")
|
||||
.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)])
|
||||
.split(chunks[1]);
|
||||
|
||||
render_messages(frame, body[0], active.lines.as_slice());
|
||||
render_users(frame, body[1], &active.users, rooms.user_selected);
|
||||
render_messages(frame, body[0], active.lines.as_slice(), &mut active.view);
|
||||
render_users(frame, body[1], &active.users, *user_selected);
|
||||
|
||||
// Compose line or hint.
|
||||
if rooms.composing {
|
||||
if *composing {
|
||||
let line = Line::from(vec![
|
||||
Span::styled("› ", accent_style()),
|
||||
Span::styled(active.input.clone(), primary_style()),
|
||||
@@ -173,9 +180,10 @@ fn render_messages(
|
||||
frame: &mut Frame,
|
||||
area: Rect,
|
||||
lines: &[crate::models::RoomLine],
|
||||
view: &mut LogView,
|
||||
) {
|
||||
// Wrap each message to the pane width, then auto-scroll: tail by rendered
|
||||
// rows so the newest message is always fully visible.
|
||||
// Wrap each message to the pane width, then show the rows the reader is
|
||||
// at: the newest ones, unless they are holding a place in the history.
|
||||
let width = area.width as usize;
|
||||
let rendered: Vec<Line> = lines
|
||||
.iter()
|
||||
@@ -198,8 +206,8 @@ fn render_messages(
|
||||
}
|
||||
})
|
||||
.collect();
|
||||
let start = rendered.len().saturating_sub((area.height as usize).max(1));
|
||||
frame.render_widget(Paragraph::new(rendered[start..].to_vec()), area);
|
||||
let window = view.window(rendered.len(), usize::from(area.height));
|
||||
frame.render_widget(Paragraph::new(rendered[window].to_vec()), area);
|
||||
}
|
||||
|
||||
fn render_users(
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
use super::name_scroll::{column_width, end_offset, scroll_text};
|
||||
use crate::models::{SearchEntry, SearchStatus};
|
||||
use crate::ui::{
|
||||
HIGHLIGHT_SYMBOL, body_style, dimmed_style, header_style, pane_block,
|
||||
@@ -5,16 +6,34 @@ use crate::ui::{
|
||||
};
|
||||
use ratatui::{
|
||||
Frame,
|
||||
layout::Rect,
|
||||
layout::{Constraint, Rect},
|
||||
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(
|
||||
frame: &mut Frame,
|
||||
area: Rect,
|
||||
searches: &[SearchEntry],
|
||||
table_state: &mut TableState,
|
||||
focused: bool,
|
||||
query_offset: usize,
|
||||
) {
|
||||
let header = Row::new(vec![
|
||||
Cell::from("Status").style(header_style()),
|
||||
@@ -23,6 +42,7 @@ pub fn render_searches_pane(
|
||||
])
|
||||
.height(1);
|
||||
|
||||
let query_width = column_width(area, &WIDTHS, QUERY_COLUMN);
|
||||
let rows: Vec<Row> = searches
|
||||
.iter()
|
||||
.map(|search| {
|
||||
@@ -43,29 +63,30 @@ pub fn render_searches_pane(
|
||||
} else {
|
||||
search.results.len()
|
||||
};
|
||||
// Nothing yet from a search still out: the status column already
|
||||
// says it is looking.
|
||||
let results_text = if results_count == 0
|
||||
&& search.status == SearchStatus::Active
|
||||
{
|
||||
"Searching...".to_string()
|
||||
"…".to_string()
|
||||
} else {
|
||||
format!("{results_count}")
|
||||
};
|
||||
|
||||
Row::new(vec![
|
||||
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()),
|
||||
])
|
||||
})
|
||||
.collect();
|
||||
|
||||
let widths = [
|
||||
ratatui::layout::Constraint::Length(15),
|
||||
ratatui::layout::Constraint::Fill(1),
|
||||
ratatui::layout::Constraint::Length(10),
|
||||
];
|
||||
|
||||
let table = Table::new(rows, widths)
|
||||
let table = Table::new(rows, WIDTHS)
|
||||
.header(header)
|
||||
.row_highlight_style(row_highlight_style())
|
||||
.highlight_symbol(HIGHLIGHT_SYMBOL)
|
||||
|
||||
@@ -39,7 +39,8 @@ pub const GLYPH_FAILED: &str = "✗";
|
||||
pub const GLYPH_TIMED_OUT: &str = "⧗";
|
||||
pub const GLYPH_CURSOR: &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 {
|
||||
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> {
|
||||
let mut spans = Vec::new();
|
||||
/// One `[key → action]` legend entry.
|
||||
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() {
|
||||
if i > 0 {
|
||||
spans.push(Span::raw(" "));
|
||||
/// The most rows the shortcuts bar grows to. Past that a terminal is too
|
||||
/// narrow for the legend to be worth the content rows it would take.
|
||||
pub const SHORTCUT_ROWS_MAX: usize = 3;
|
||||
|
||||
/// Lay the legend out in rows no wider than `width`, never breaking inside an
|
||||
/// entry: a `[key → action]` split over two rows reads as two keys. Entries
|
||||
/// that do not fit in [`SHORTCUT_ROWS_MAX`] rows are dropped, so a bar always
|
||||
/// has at least one row and never more than that.
|
||||
pub fn pack_shortcuts(
|
||||
shortcuts: &[(&'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;
|
||||
}
|
||||
|
||||
spans.push(Span::styled("[", dimmed_style()));
|
||||
spans.push(Span::styled(key.to_string(), Style::default().fg(SIGNAL)));
|
||||
spans.push(Span::styled(format!(" {SHORTCUT_ARROW} "), dimmed_style()));
|
||||
spans.push(Span::styled(action.to_string(), body_style()));
|
||||
spans.push(Span::styled("]", dimmed_style()));
|
||||
if !row.is_empty() {
|
||||
row.push(Span::raw(" "));
|
||||
row_width += 1;
|
||||
}
|
||||
row.extend(entry);
|
||||
row_width += entry_width;
|
||||
}
|
||||
|
||||
Line::from(spans)
|
||||
rows.push(Line::from(row));
|
||||
rows
|
||||
}
|
||||
|
||||
pub fn format_progress_bar(
|
||||
@@ -253,6 +289,46 @@ mod tests {
|
||||
assert_eq!(cursor.bg, Some(SELECTION));
|
||||
}
|
||||
|
||||
fn texts(rows: &[Line<'static>]) -> Vec<String> {
|
||||
rows.iter()
|
||||
.map(|line| line.spans.iter().map(|s| s.content.as_ref()).collect())
|
||||
.collect()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn shortcuts_stay_on_one_row_when_they_fit() {
|
||||
let rows = pack_shortcuts(&[("q", "quit"), ("s", "search")], 80);
|
||||
assert_eq!(texts(&rows), ["[q → quit] [s → search]"]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn shortcuts_wrap_between_entries_never_inside_one() {
|
||||
let rows = pack_shortcuts(
|
||||
&[("Space", "select"), ("Enter", "download"), ("q", "quit")],
|
||||
30,
|
||||
);
|
||||
// "[Space → select] [Enter → download]" is 35 cells, over the 30 the
|
||||
// bar has, so the second entry starts a new row whole.
|
||||
assert_eq!(
|
||||
texts(&rows),
|
||||
["[Space → select]", "[Enter → download] [q → quit]"]
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_entry_wider_than_the_bar_still_gets_a_row() {
|
||||
let rows = pack_shortcuts(&[("Enter", "download"), ("q", "quit")], 5);
|
||||
assert_eq!(texts(&rows), ["[Enter → download]", "[q → quit]"]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_bar_never_grows_past_its_cap() {
|
||||
let keys: Vec<(&str, &str)> = (0..10).map(|_| ("k", "act")).collect();
|
||||
let rows = pack_shortcuts(&keys, 12);
|
||||
assert_eq!(rows.len(), SHORTCUT_ROWS_MAX);
|
||||
assert_eq!(texts(&rows)[0], "[k → act]");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn progress_bar_splits_fill_and_track() {
|
||||
let bar = format_progress_bar(0.25, 20, 25);
|
||||
|
||||
@@ -182,6 +182,11 @@ export const SHORTCUTS: readonly (readonly [string, string])[] = [
|
||||
['c', 'chat'],
|
||||
['/', 'filter'],
|
||||
['a/A', 'select all/none'],
|
||||
['1-3', 'focus pane'],
|
||||
['h/l', 'scroll name'],
|
||||
['g/G', 'top/end'],
|
||||
['Tab/1-3', 'pane'],
|
||||
['z', 'zoom'],
|
||||
['w', 'hide'],
|
||||
['?', 'keys'],
|
||||
['q', 'quit'],
|
||||
]
|
||||
|
||||
@@ -37,45 +37,44 @@ export const HeroTerminal = () => {
|
||||
>
|
||||
<StatusLine counts={demo.counts} pct={demo.pct} />
|
||||
|
||||
<div className="mt-3 grid gap-2.5" style={{ gridTemplateColumns: '300px 1fr' }}>
|
||||
<div className="min-h-[498px]">
|
||||
<SearchesPane
|
||||
searches={demo.searches}
|
||||
activeId={demo.activeId}
|
||||
active={demo.focus === 1}
|
||||
onPick={demo.pickSearch}
|
||||
onSubmit={demo.submitSearch}
|
||||
{/* Results across the top with the whole width for long names;
|
||||
Searches, Transfers and Info share the row underneath. */}
|
||||
<div className="mt-3 grid gap-2.5" style={{ gridTemplateRows: '330px 300px' }}>
|
||||
<div className="relative min-h-0 overflow-hidden">
|
||||
<ResultsPane
|
||||
query={demo.query}
|
||||
rows={demo.rows}
|
||||
selected={demo.selected}
|
||||
active={demo.focus === 2}
|
||||
onSelect={demo.selectRow}
|
||||
onQueue={demo.queueRow}
|
||||
/>
|
||||
{demo.searching && (
|
||||
<div className="pointer-events-none absolute inset-0 flex items-center justify-center bg-[color-mix(in_srgb,var(--color-sleeve)_35%,transparent)] text-[13.5px] tracking-[0.02em] text-[var(--color-dust)]">
|
||||
searching the network
|
||||
<span className="ml-1.5 inline-block h-[15px] w-2 translate-y-[2px] rounded-[1.5px] bg-[var(--color-oxide)] motion-safe:animate-cursor-blink" />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="grid min-h-0 gap-2.5" style={{ gridTemplateRows: '1fr 300px' }}>
|
||||
<div className="relative min-h-[250px] overflow-hidden">
|
||||
<ResultsPane
|
||||
query={demo.query}
|
||||
rows={demo.rows}
|
||||
selected={demo.selected}
|
||||
active={demo.focus === 2}
|
||||
onSelect={demo.selectRow}
|
||||
onQueue={demo.queueRow}
|
||||
<div
|
||||
className="grid min-h-0 gap-2.5"
|
||||
style={{ gridTemplateColumns: '320px 1fr 340px' }}
|
||||
>
|
||||
<div className="min-h-0 min-w-0 overflow-hidden">
|
||||
<SearchesPane
|
||||
searches={demo.searches}
|
||||
activeId={demo.activeId}
|
||||
active={demo.focus === 1}
|
||||
onPick={demo.pickSearch}
|
||||
onSubmit={demo.submitSearch}
|
||||
/>
|
||||
{demo.searching && (
|
||||
<div className="pointer-events-none absolute inset-0 flex items-center justify-center bg-[color-mix(in_srgb,var(--color-sleeve)_35%,transparent)] text-[13.5px] tracking-[0.02em] text-[var(--color-dust)]">
|
||||
searching the network
|
||||
<span className="ml-1.5 inline-block h-[15px] w-2 translate-y-[2px] rounded-[1.5px] bg-[var(--color-oxide)] motion-safe:animate-cursor-blink" />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div
|
||||
className="grid min-h-0 gap-2.5"
|
||||
style={{ gridTemplateColumns: '1fr 340px' }}
|
||||
>
|
||||
<div className="min-h-0 min-w-0 overflow-hidden">
|
||||
<TransfersPane rows={demo.transfers.slice(0, 7)} active={demo.focus === 3} />
|
||||
</div>
|
||||
<div className="min-h-0 min-w-0 overflow-hidden">
|
||||
<InfoPane row={demo.infoRow} />
|
||||
</div>
|
||||
<div className="min-h-0 min-w-0 overflow-hidden">
|
||||
<TransfersPane rows={demo.transfers.slice(0, 7)} active={demo.focus === 3} />
|
||||
</div>
|
||||
<div className="min-h-0 min-w-0 overflow-hidden">
|
||||
<InfoPane row={demo.infoRow} />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -6,7 +6,7 @@ import { TuiPane } from './chrome'
|
||||
import type { ResultRow, SearchRow, TransferRow, TransferStatus } from './data'
|
||||
|
||||
const RESULT_COLS = '30px 1fr 74px 92px 78px 74px 42px'
|
||||
const TRANSFER_COLS = '48px 1fr 96px 150px 74px'
|
||||
const TRANSFER_COLS = '48px 1fr 96px 168px 74px'
|
||||
|
||||
interface SearchesPaneProps {
|
||||
searches: readonly SearchRow[]
|
||||
|
||||
@@ -25,10 +25,21 @@ const GLOBAL_KEYS: readonly (readonly [string, string])[] = [
|
||||
['i', 'inbox (unread counter)'],
|
||||
['/', 'filter the current list'],
|
||||
['a / A', 'select all / none'],
|
||||
['1–3', 'focus a pane'],
|
||||
['?', 'every key, grouped by pane'],
|
||||
['q', 'quit'],
|
||||
]
|
||||
|
||||
const PANE_KEYS: readonly (readonly [string, string])[] = [
|
||||
['Tab · ⇧Tab', 'next / previous pane'],
|
||||
['1–3', 'focus a pane, and bring it back if hidden'],
|
||||
['z', 'zoom the focused pane to the whole window'],
|
||||
['w', 'hide the focused pane; the others widen'],
|
||||
['↑ ↓ · j k', 'move a row in any list'],
|
||||
['Home End · g G', 'first / last row'],
|
||||
['PgUp PgDn · ^u ^d', 'a page, or half a page; 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])[] = [
|
||||
['Enter', 'join room / send message'],
|
||||
['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 }[] = [
|
||||
{
|
||||
num: 2,
|
||||
title: 'Results',
|
||||
body: 'Files from the network across the whole width: size, user, bitrate, speed, free slots. Space selects, Enter queues a download. h/l or ←/→ scroll a long name, 0 and $ jump to either end.',
|
||||
},
|
||||
{
|
||||
num: 1,
|
||||
title: 'Searches',
|
||||
body: 'Your queries and how many results each returned. Press s to search the network; each search keeps its own result set.',
|
||||
},
|
||||
{
|
||||
num: 2,
|
||||
title: 'Results',
|
||||
body: 'Files from the network: size, user, bitrate, speed, free slots. Space selects, Enter queues a download. h/l or ←/→ scroll a long name, 0 and $ jump to either end; Home/End, g/G, PgUp/PgDn and Ctrl-f/b/d/u move through the list.',
|
||||
},
|
||||
{
|
||||
num: 3,
|
||||
title: 'Downloads / Uploads',
|
||||
@@ -106,8 +117,10 @@ const FirstRun = () => (
|
||||
const Layout = () => (
|
||||
<Section>
|
||||
<SectionHead eyebrow="2 · the layout" title="Four panes.">
|
||||
The whole client is these four boxes. Press <Code>1</Code>–<Code>3</Code> to focus
|
||||
one; the focused pane’s legend turns green.
|
||||
The whole client is these four boxes: results across the top, the rest in a row
|
||||
underneath. <Code>Tab</Code> or <Code>1</Code>–<Code>3</Code> focuses one and its legend
|
||||
turns green; <Code>z</Code> gives it the whole window, <Code>w</Code> hides it so the
|
||||
others widen.
|
||||
</SectionHead>
|
||||
<Cols start>
|
||||
<div className="overflow-hidden">
|
||||
@@ -127,7 +140,8 @@ const Layout = () => (
|
||||
const Keys = () => (
|
||||
<Section>
|
||||
<SectionHead eyebrow="3 · keys" title="It's all keyboard.">
|
||||
The bottom bar always shows the keys for where you are. The essentials:
|
||||
The bottom bar always shows the keys for where you are, and <Code>?</Code> lists every
|
||||
one. The essentials:
|
||||
</SectionHead>
|
||||
<Cols>
|
||||
<div>
|
||||
@@ -138,6 +152,16 @@ const Keys = () => (
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<Eyebrow className="mb-4 block">panes and lists</Eyebrow>
|
||||
<div className="flex flex-col">
|
||||
{PANE_KEYS.map(([combo, description]) => (
|
||||
<KeyRow key={combo} combo={combo} description={description} />
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</Cols>
|
||||
<Cols className="mt-8">
|
||||
<div>
|
||||
<Eyebrow className="mb-4 block">
|
||||
in the chat-rooms popup <span className="text-muted">(press c)</span>
|
||||
|
||||
Reference in New Issue
Block a user