feat(tui): scroll long names in every list and page through popups and chat
The sideways keys the results had (h/l, arrows, 0 and $) now scroll a transfer's file name and a query too, each list on its own. The browse tree and the room list take Home/End, PgUp/PgDn and the vim jumps like the main lists, and a chat log, room or private, pages back through its history and holds its place while new messages arrive; what arrives meanwhile counts as unread until the reader returns to the end.
This commit is contained in:
@@ -98,7 +98,9 @@ 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
|
`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).
|
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
|
Every list takes `↑`/`↓`, `Home`/`End`, `PgUp`/`PgDn` and the vim keys, and
|
||||||
`?` shows every key for where you are.
|
`h`/`l` or `←`/`→` scroll a long file name or query sideways, `0` and `$` to
|
||||||
|
either end. `PgUp`/`PgDn` scroll a chat log back through its history, and `?`
|
||||||
|
shows every key for where you are.
|
||||||
|
|
||||||
### Daemon mode
|
### Daemon mode
|
||||||
|
|
||||||
|
|||||||
+1
-1
File diff suppressed because one or more lines are too long
|
Before Width: | Height: | Size: 908 KiB After Width: | Height: | Size: 930 KiB |
@@ -108,6 +108,75 @@ impl PaneLayout {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Where a scrolling log is being read.
|
||||||
|
///
|
||||||
|
/// Either following its newest line, or held at a row of its history so
|
||||||
|
/// that arriving messages do not push the reader along. The renderer records
|
||||||
|
/// the log's size at each draw, which is what lets the keys move by a
|
||||||
|
/// screenful and stop at the ends.
|
||||||
|
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
|
||||||
|
pub struct LogView {
|
||||||
|
/// The first visible row while held in the history; `None` follows the
|
||||||
|
/// tail.
|
||||||
|
top: Option<usize>,
|
||||||
|
/// Rows the log had, and rows the window showed, at the last draw.
|
||||||
|
rows: usize,
|
||||||
|
height: usize,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl LogView {
|
||||||
|
/// Whether the newest line is on screen, so new messages are being seen.
|
||||||
|
#[must_use]
|
||||||
|
pub const fn following(&self) -> bool {
|
||||||
|
self.top.is_none()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Rows a page key moves: what the window showed last time.
|
||||||
|
#[must_use]
|
||||||
|
pub const fn page(&self) -> usize {
|
||||||
|
self.height
|
||||||
|
}
|
||||||
|
|
||||||
|
const fn last_top(&self) -> usize {
|
||||||
|
self.rows.saturating_sub(self.height)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The rows of a log `len` long to draw in a window `height` tall. A
|
||||||
|
/// held position that has reached the tail goes back to following it.
|
||||||
|
pub fn window(
|
||||||
|
&mut self,
|
||||||
|
len: usize,
|
||||||
|
height: usize,
|
||||||
|
) -> std::ops::Range<usize> {
|
||||||
|
self.rows = len;
|
||||||
|
self.height = height.max(1);
|
||||||
|
let top = match self.top {
|
||||||
|
Some(top) if top < self.last_top() => top,
|
||||||
|
_ => {
|
||||||
|
self.top = None;
|
||||||
|
self.last_top()
|
||||||
|
}
|
||||||
|
};
|
||||||
|
top..(top + self.height).min(len)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Move the window `rows` down the log (up, when negative), holding
|
||||||
|
/// wherever it lands unless that is the tail.
|
||||||
|
pub fn scroll(&mut self, rows: isize) {
|
||||||
|
let current = self.top.unwrap_or_else(|| self.last_top());
|
||||||
|
let next = current.saturating_add_signed(rows);
|
||||||
|
self.top = (next < self.last_top()).then_some(next);
|
||||||
|
}
|
||||||
|
|
||||||
|
pub const fn to_oldest(&mut self) {
|
||||||
|
self.top = Some(0);
|
||||||
|
}
|
||||||
|
|
||||||
|
pub const fn to_newest(&mut self) {
|
||||||
|
self.top = None;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/// What the shared command bar is currently capturing input for.
|
/// What the shared command bar is currently capturing input for.
|
||||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||||
pub enum CommandBarMode {
|
pub enum CommandBarMode {
|
||||||
@@ -139,6 +208,8 @@ pub struct AppState {
|
|||||||
pub searches: Vec<SearchEntry>,
|
pub searches: Vec<SearchEntry>,
|
||||||
pub searches_table_state: TableState,
|
pub searches_table_state: TableState,
|
||||||
pub selected_search_index: Option<usize>,
|
pub selected_search_index: Option<usize>,
|
||||||
|
/// Cells the query column is scrolled to the left.
|
||||||
|
pub searches_query_offset: usize,
|
||||||
|
|
||||||
// Results
|
// Results
|
||||||
pub results_items: Vec<FileDisplayData>,
|
pub results_items: Vec<FileDisplayData>,
|
||||||
@@ -148,11 +219,14 @@ pub struct AppState {
|
|||||||
pub results_selected_indices: std::collections::HashSet<usize>,
|
pub results_selected_indices: std::collections::HashSet<usize>,
|
||||||
pub results_filter_query: String,
|
pub results_filter_query: String,
|
||||||
pub results_is_filtering: bool,
|
pub results_is_filtering: bool,
|
||||||
|
/// Cells the file-name column is scrolled to the left.
|
||||||
pub results_name_offset: usize,
|
pub results_name_offset: usize,
|
||||||
|
|
||||||
// Downloads
|
// Downloads
|
||||||
pub downloads: Vec<DownloadEntry>,
|
pub downloads: Vec<DownloadEntry>,
|
||||||
pub downloads_table_state: TableState,
|
pub downloads_table_state: TableState,
|
||||||
|
/// Cells the transfers' file-name column is scrolled to the left.
|
||||||
|
pub downloads_name_offset: usize,
|
||||||
pub downloads_receiver_channel:
|
pub downloads_receiver_channel:
|
||||||
Option<Receiver<(Download, Receiver<DownloadStatus>)>>,
|
Option<Receiver<(Download, Receiver<DownloadStatus>)>>,
|
||||||
pub downloads_sender_channel:
|
pub downloads_sender_channel:
|
||||||
@@ -183,6 +257,8 @@ pub struct AppState {
|
|||||||
/// Compose buffer for the active conversation.
|
/// Compose buffer for the active conversation.
|
||||||
pub chat_input: String,
|
pub chat_input: String,
|
||||||
pub chat_composing: bool,
|
pub chat_composing: bool,
|
||||||
|
/// Where the conversation is being read.
|
||||||
|
pub chat_view: LogView,
|
||||||
|
|
||||||
// Browse users' shared files (one tab per user)
|
// Browse users' shared files (one tab per user)
|
||||||
pub browse: BrowseTabs,
|
pub browse: BrowseTabs,
|
||||||
@@ -204,6 +280,9 @@ pub struct AppState {
|
|||||||
pub searches_pane_area: Option<Rect>,
|
pub searches_pane_area: Option<Rect>,
|
||||||
pub results_pane_area: Option<Rect>,
|
pub results_pane_area: Option<Rect>,
|
||||||
pub downloads_pane_area: Option<Rect>,
|
pub downloads_pane_area: Option<Rect>,
|
||||||
|
/// Where the open popup was last drawn, for its page size. Only one is
|
||||||
|
/// ever open, so one record serves them all.
|
||||||
|
pub popup_area: Option<Rect>,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl AppState {
|
impl AppState {
|
||||||
@@ -222,6 +301,7 @@ impl AppState {
|
|||||||
searches: Vec::new(),
|
searches: Vec::new(),
|
||||||
searches_table_state,
|
searches_table_state,
|
||||||
selected_search_index: None,
|
selected_search_index: None,
|
||||||
|
searches_query_offset: 0,
|
||||||
|
|
||||||
results_items: Vec::new(),
|
results_items: Vec::new(),
|
||||||
results_filtered_items: Vec::new(),
|
results_filtered_items: Vec::new(),
|
||||||
@@ -234,6 +314,7 @@ impl AppState {
|
|||||||
|
|
||||||
downloads: Vec::new(),
|
downloads: Vec::new(),
|
||||||
downloads_table_state,
|
downloads_table_state,
|
||||||
|
downloads_name_offset: 0,
|
||||||
downloads_receiver_channel: None,
|
downloads_receiver_channel: None,
|
||||||
downloads_sender_channel: None,
|
downloads_sender_channel: None,
|
||||||
active_downloads_count: 0,
|
active_downloads_count: 0,
|
||||||
@@ -254,6 +335,7 @@ impl AppState {
|
|||||||
chat_peer: None,
|
chat_peer: None,
|
||||||
chat_input: String::new(),
|
chat_input: String::new(),
|
||||||
chat_composing: false,
|
chat_composing: false,
|
||||||
|
chat_view: LogView::default(),
|
||||||
|
|
||||||
browse: BrowseTabs::new(),
|
browse: BrowseTabs::new(),
|
||||||
show_browse: false,
|
show_browse: false,
|
||||||
@@ -270,6 +352,16 @@ impl AppState {
|
|||||||
searches_pane_area: None,
|
searches_pane_area: None,
|
||||||
results_pane_area: None,
|
results_pane_area: None,
|
||||||
downloads_pane_area: None,
|
downloads_pane_area: None,
|
||||||
|
popup_area: None,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// How far the focused list's long column is scrolled sideways.
|
||||||
|
pub const fn name_offset_mut(&mut self, pane: FocusedPane) -> &mut usize {
|
||||||
|
match pane {
|
||||||
|
FocusedPane::Searches => &mut self.searches_query_offset,
|
||||||
|
FocusedPane::Results => &mut self.results_name_offset,
|
||||||
|
FocusedPane::Downloads => &mut self.downloads_name_offset,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -302,6 +394,7 @@ impl AppState {
|
|||||||
pub fn open_chat(&mut self, peer: String) {
|
pub fn open_chat(&mut self, peer: String) {
|
||||||
self.chat_peer = Some(peer);
|
self.chat_peer = Some(peer);
|
||||||
self.chat_input.clear();
|
self.chat_input.clear();
|
||||||
|
self.chat_view.to_newest();
|
||||||
self.chat_composing = true;
|
self.chat_composing = true;
|
||||||
self.show_messages = true;
|
self.show_messages = true;
|
||||||
self.unread_messages = 0;
|
self.unread_messages = 0;
|
||||||
@@ -351,6 +444,10 @@ impl AppState {
|
|||||||
(current + peers.len() - 1) % peers.len()
|
(current + peers.len() - 1) % peers.len()
|
||||||
};
|
};
|
||||||
let peer = peers[next].to_string();
|
let peer = peers[next].to_string();
|
||||||
|
// A different conversation opens at its end; the same one stays put.
|
||||||
|
if next != current {
|
||||||
|
self.chat_view.to_newest();
|
||||||
|
}
|
||||||
self.chat_peer = Some(peer);
|
self.chat_peer = Some(peer);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -486,6 +583,55 @@ mod tests {
|
|||||||
assert!(!state.layout.is_visible(FocusedPane::Searches));
|
assert!(!state.layout.is_visible(FocusedPane::Searches));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn a_log_view_follows_the_tail_until_held_and_holds_against_new_rows() {
|
||||||
|
let mut view = LogView::default();
|
||||||
|
assert_eq!(view.window(100, 10), 90..100);
|
||||||
|
assert!(view.following());
|
||||||
|
|
||||||
|
view.scroll(-10);
|
||||||
|
assert_eq!(view.window(100, 10), 80..90);
|
||||||
|
assert!(!view.following());
|
||||||
|
|
||||||
|
// Five more rows arrive: the held window does not move.
|
||||||
|
assert_eq!(view.window(105, 10), 80..90);
|
||||||
|
|
||||||
|
// Back down to the tail resumes following, including new rows.
|
||||||
|
view.scroll(10);
|
||||||
|
view.scroll(10);
|
||||||
|
assert_eq!(view.window(105, 10), 95..105);
|
||||||
|
assert!(view.following());
|
||||||
|
|
||||||
|
view.to_oldest();
|
||||||
|
assert_eq!(view.window(105, 10), 0..10);
|
||||||
|
view.scroll(-10);
|
||||||
|
assert_eq!(view.window(105, 10), 0..10, "clamped at the top");
|
||||||
|
view.to_newest();
|
||||||
|
assert_eq!(view.window(105, 10), 95..105);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn a_log_shorter_than_its_window_is_never_held() {
|
||||||
|
let mut view = LogView::default();
|
||||||
|
assert_eq!(view.window(3, 10), 0..3);
|
||||||
|
view.to_oldest();
|
||||||
|
assert_eq!(view.window(3, 10), 0..3);
|
||||||
|
assert!(view.following(), "nothing to hold");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn cycling_to_the_same_chat_keeps_the_place() {
|
||||||
|
let mut state = AppState::new();
|
||||||
|
say(&mut state, "alice");
|
||||||
|
state.chat_view.window(50, 10);
|
||||||
|
state.chat_view.scroll(-10);
|
||||||
|
state.cycle_chat_peer(true);
|
||||||
|
assert!(!state.chat_view.following(), "only one chat: nothing moved");
|
||||||
|
say(&mut state, "bob");
|
||||||
|
state.cycle_chat_peer(true);
|
||||||
|
assert!(state.chat_view.following(), "a new chat opens at its end");
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn opening_a_chat_pins_a_peer_with_no_messages_yet() {
|
fn opening_a_chat_pins_a_peer_with_no_messages_yet() {
|
||||||
let mut state = AppState::new();
|
let mut state = AppState::new();
|
||||||
|
|||||||
@@ -5,7 +5,7 @@ mod rooms;
|
|||||||
mod settings;
|
mod settings;
|
||||||
|
|
||||||
pub use app_state::{
|
pub use app_state::{
|
||||||
AppState, ChatMessage, CommandBarMode, DownloadEntry, FocusedPane,
|
AppState, ChatMessage, CommandBarMode, DownloadEntry, FocusedPane, LogView,
|
||||||
MessageDirection, PaneLayout, SearchEntry, SearchStatus,
|
MessageDirection, PaneLayout, SearchEntry, SearchStatus,
|
||||||
};
|
};
|
||||||
pub use browse::{
|
pub use browse::{
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
use crate::models::LogView;
|
||||||
use chrono::{DateTime, Local};
|
use chrono::{DateTime, Local};
|
||||||
use soulseek_rs::types::{RoomEvent, RoomInfo};
|
use soulseek_rs::types::{RoomEvent, RoomInfo};
|
||||||
|
|
||||||
@@ -38,6 +39,8 @@ pub struct OpenRoom {
|
|||||||
pub unread: usize,
|
pub unread: usize,
|
||||||
/// Per-room compose buffer.
|
/// Per-room compose buffer.
|
||||||
pub input: String,
|
pub input: String,
|
||||||
|
/// Where the log is being read.
|
||||||
|
pub view: LogView,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl OpenRoom {
|
impl OpenRoom {
|
||||||
@@ -160,6 +163,11 @@ impl RoomsState {
|
|||||||
.and_then(|r| r.users.get(self.user_selected).cloned())
|
.and_then(|r| r.users.get(self.user_selected).cloned())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Where the active room's log is being read, to move it.
|
||||||
|
pub fn active_view_mut(&mut self) -> Option<&mut LogView> {
|
||||||
|
self.open.get_mut(self.active).map(|room| &mut room.view)
|
||||||
|
}
|
||||||
|
|
||||||
pub const fn select_user_up(&mut self) {
|
pub const fn select_user_up(&mut self) {
|
||||||
self.user_selected = self.user_selected.saturating_sub(1);
|
self.user_selected = self.user_selected.saturating_sub(1);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
use super::MainTui;
|
use super::MainTui;
|
||||||
|
use super::input::{jumped, list_jump};
|
||||||
use crate::models::{BrowseStatus, files_under, find_node};
|
use crate::models::{BrowseStatus, files_under, find_node};
|
||||||
use ratatui::crossterm::event::{KeyCode, KeyEvent};
|
use ratatui::crossterm::event::{KeyCode, KeyEvent, KeyModifiers};
|
||||||
use std::{thread, time::Duration};
|
use std::{thread, time::Duration};
|
||||||
|
|
||||||
/// How long to wait for a browse response before showing a timeout notice.
|
/// How long to wait for a browse response before showing a timeout notice.
|
||||||
@@ -12,6 +13,11 @@ impl MainTui {
|
|||||||
self.state.show_browse = false;
|
self.state.show_browse = false;
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
let jump = list_jump(key, self.popup_page());
|
||||||
|
// Control combinations are only ever jumps: ctrl-d pages, d downloads.
|
||||||
|
if jump.is_none() && key.modifiers.contains(KeyModifiers::CONTROL) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
// Tab management: switch between browsed users or close the active tab.
|
// Tab management: switch between browsed users or close the active tab.
|
||||||
match key.code {
|
match key.code {
|
||||||
@@ -60,6 +66,14 @@ impl MainTui {
|
|||||||
(rows, sel, row)
|
(rows, sel, row)
|
||||||
};
|
};
|
||||||
|
|
||||||
|
if let Some(jump) = jump {
|
||||||
|
if let Some(browse) = self.state.browse.active_tab_mut() {
|
||||||
|
browse.selected_row = jumped(sel, rows.len(), jump);
|
||||||
|
}
|
||||||
|
self.sync_browse_selection();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
// Downloads need `&self.client` free of the browse borrow.
|
// Downloads need `&self.client` free of the browse borrow.
|
||||||
match key.code {
|
match key.code {
|
||||||
KeyCode::Enter if !row.is_folder => {
|
KeyCode::Enter if !row.is_folder => {
|
||||||
|
|||||||
@@ -1,7 +1,10 @@
|
|||||||
use super::MainTui;
|
use super::MainTui;
|
||||||
use crate::models::{CommandBarMode, FocusedPane};
|
use crate::models::{CommandBarMode, FocusedPane, LogView};
|
||||||
use crate::ui::pane_block;
|
use crate::ui::pane_block;
|
||||||
use crate::ui::panes::name_end_offset;
|
use crate::ui::panes::{
|
||||||
|
InfoSubject, name_end_offset, query_end_offset, selected_transfer,
|
||||||
|
transfer_name_end_offset, upload_display_name,
|
||||||
|
};
|
||||||
use ratatui::crossterm::event::{
|
use ratatui::crossterm::event::{
|
||||||
KeyCode, KeyEvent, KeyModifiers, MouseButton, MouseEvent, MouseEventKind,
|
KeyCode, KeyEvent, KeyModifiers, MouseButton, MouseEvent, MouseEventKind,
|
||||||
};
|
};
|
||||||
@@ -195,6 +198,14 @@ impl MainTui {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Paging keys move through the conversation's history.
|
||||||
|
if scroll_log(&mut self.state.chat_view, key) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if key.modifiers.contains(KeyModifiers::CONTROL) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
match key.code {
|
match key.code {
|
||||||
KeyCode::Char('i' | 'q') | KeyCode::Esc => {
|
KeyCode::Char('i' | 'q') | KeyCode::Esc => {
|
||||||
self.state.show_messages = false;
|
self.state.show_messages = false;
|
||||||
@@ -375,6 +386,15 @@ impl MainTui {
|
|||||||
navigate_list(key, table, len, page)
|
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) -> isize {
|
||||||
|
let rows = self.state.popup_area.map_or(1, |area| {
|
||||||
|
pane_block(false).inner(area).height.saturating_sub(2)
|
||||||
|
});
|
||||||
|
isize::try_from(rows.max(1)).unwrap_or(isize::MAX)
|
||||||
|
}
|
||||||
|
|
||||||
/// Rows a pane shows at once: its inner height less the table header.
|
/// Rows a pane shows at once: its inner height less the table header.
|
||||||
fn page_size(&self, pane: FocusedPane) -> isize {
|
fn page_size(&self, pane: FocusedPane) -> isize {
|
||||||
let area = match pane {
|
let area = match pane {
|
||||||
@@ -389,7 +409,7 @@ impl MainTui {
|
|||||||
}
|
}
|
||||||
|
|
||||||
fn handle_searches_input(&mut self, key: KeyEvent) {
|
fn handle_searches_input(&mut self, key: KeyEvent) {
|
||||||
if self.navigate_focused_list(key) {
|
if self.navigate_focused_list(key) || self.scroll_focused_name(key) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
match key.code {
|
match key.code {
|
||||||
@@ -426,20 +446,10 @@ impl MainTui {
|
|||||||
}
|
}
|
||||||
|
|
||||||
fn handle_results_input(&mut self, key: KeyEvent) {
|
fn handle_results_input(&mut self, key: KeyEvent) {
|
||||||
if self.navigate_focused_list(key) {
|
if self.navigate_focused_list(key) || self.scroll_focused_name(key) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
match key.code {
|
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.results_name_offset = 0,
|
|
||||||
KeyCode::Char('$') => {
|
|
||||||
self.state.results_name_offset = self.highlighted_name_end();
|
|
||||||
}
|
|
||||||
KeyCode::Char(' ') => {
|
KeyCode::Char(' ') => {
|
||||||
let index = self
|
let index = self
|
||||||
.state
|
.state
|
||||||
@@ -475,24 +485,82 @@ impl MainTui {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fn scroll_names(&mut self, delta: isize) {
|
/// The keys that scroll the focused list's long column sideways, the
|
||||||
let end = self.highlighted_name_end();
|
/// same in every list: a file name in Results and Downloads, the query
|
||||||
let current = self.state.results_name_offset.min(end);
|
/// in Searches. Says whether `key` was one of them.
|
||||||
self.state.results_name_offset =
|
fn scroll_focused_name(&mut self, key: KeyEvent) -> bool {
|
||||||
current.saturating_add_signed(delta).min(end);
|
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,
|
||||||
|
}
|
||||||
|
true
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn scroll_names(&mut self, delta: isize) {
|
||||||
|
let end = self.highlighted_name_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 {
|
fn highlighted_name_end(&self) -> usize {
|
||||||
let Some(area) = self.state.results_pane_area else {
|
match self.state.focused_pane {
|
||||||
return 0;
|
FocusedPane::Searches => {
|
||||||
};
|
let Some(area) = self.state.searches_pane_area else {
|
||||||
self.highlighted_result()
|
return 0;
|
||||||
.map_or(0, |file| name_end_offset(&file.filename, area))
|
};
|
||||||
|
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 => {
|
||||||
|
let Some(area) = self.state.results_pane_area else {
|
||||||
|
return 0;
|
||||||
|
};
|
||||||
|
self.highlighted_result()
|
||||||
|
.map_or(0, |file| name_end_offset(&file.filename, area))
|
||||||
|
}
|
||||||
|
FocusedPane::Downloads => {
|
||||||
|
let Some(area) = self.state.downloads_pane_area else {
|
||||||
|
return 0;
|
||||||
|
};
|
||||||
|
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) {
|
fn handle_downloads_input(&mut self, key: KeyEvent) {
|
||||||
// The pane lists downloads first, then uploads; navigation spans both.
|
// The pane lists downloads first, then uploads; navigation spans both.
|
||||||
if self.navigate_focused_list(key) {
|
if self.navigate_focused_list(key) || self.scroll_focused_name(key) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
match key.code {
|
match key.code {
|
||||||
@@ -539,9 +607,58 @@ impl MainTui {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// The keys every list answers alike: a row at a time wrapping at either
|
/// A move over a whole list at once: to either end, or by a number of rows
|
||||||
/// end, a page or half a page at a time stopping there, and the two ends.
|
/// (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: isize) -> Option<ListJump> {
|
||||||
|
let ctrl = key.modifiers.contains(KeyModifiers::CONTROL);
|
||||||
|
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.
|
/// Says whether `key` was one of them.
|
||||||
|
pub(super) fn scroll_log(view: &mut LogView, key: KeyEvent) -> bool {
|
||||||
|
let page = isize::try_from(view.page()).unwrap_or(isize::MAX);
|
||||||
|
match list_jump(key, 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(
|
fn navigate_list(
|
||||||
key: KeyEvent,
|
key: KeyEvent,
|
||||||
table: &mut TableState,
|
table: &mut TableState,
|
||||||
@@ -549,23 +666,16 @@ fn navigate_list(
|
|||||||
page: isize,
|
page: isize,
|
||||||
) -> bool {
|
) -> bool {
|
||||||
let ctrl = key.modifiers.contains(KeyModifiers::CONTROL);
|
let ctrl = key.modifiers.contains(KeyModifiers::CONTROL);
|
||||||
let half = (page / 2).max(1);
|
|
||||||
match key.code {
|
match key.code {
|
||||||
KeyCode::Up | KeyCode::Char('k') if !ctrl => cycle(table, len, false),
|
KeyCode::Up | KeyCode::Char('k') if !ctrl => cycle(table, len, false),
|
||||||
KeyCode::Down | KeyCode::Char('j') if !ctrl => cycle(table, len, true),
|
KeyCode::Down | KeyCode::Char('j') if !ctrl => cycle(table, len, true),
|
||||||
KeyCode::Home | KeyCode::Char('g') if !ctrl => {
|
_ => match list_jump(key, page) {
|
||||||
select_row(table, len, 0);
|
Some(jump) => {
|
||||||
}
|
let current = table.selected().unwrap_or(0);
|
||||||
KeyCode::End | KeyCode::Char('G') if !ctrl => {
|
select_row(table, len, jumped(current, len, jump));
|
||||||
select_row(table, len, len.saturating_sub(1));
|
}
|
||||||
}
|
None => return false,
|
||||||
KeyCode::PageDown => scroll_rows(table, len, page),
|
},
|
||||||
KeyCode::PageUp => scroll_rows(table, len, -page),
|
|
||||||
KeyCode::Char('f') if ctrl => scroll_rows(table, len, page),
|
|
||||||
KeyCode::Char('b') if ctrl => scroll_rows(table, len, -page),
|
|
||||||
KeyCode::Char('d') if ctrl => scroll_rows(table, len, half),
|
|
||||||
KeyCode::Char('u') if ctrl => scroll_rows(table, len, -half),
|
|
||||||
_ => return false,
|
|
||||||
}
|
}
|
||||||
true
|
true
|
||||||
}
|
}
|
||||||
@@ -590,9 +700,3 @@ fn select_row(table: &mut TableState, len: usize, target: usize) {
|
|||||||
table.select(Some(target.min(len - 1)));
|
table.select(Some(target.min(len - 1)));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Move the selection `delta` rows, stopping at either end.
|
|
||||||
fn scroll_rows(table: &mut TableState, len: usize, delta: isize) {
|
|
||||||
let current = table.selected().unwrap_or(0);
|
|
||||||
select_row(table, len, current.saturating_add_signed(delta));
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -1548,12 +1548,12 @@ mod tests {
|
|||||||
let panes = row_with(&screen, "Panes");
|
let panes = row_with(&screen, "Panes");
|
||||||
assert!(!panes.contains("Searches"), "one column: {panes}");
|
assert!(!panes.contains("Searches"), "one column: {panes}");
|
||||||
assert!(screen.contains("focus a pane, hidden or not"), "{screen}");
|
assert!(screen.contains("focus a pane, hidden or not"), "{screen}");
|
||||||
assert!(!screen.contains("clear every finished one"), "{screen}");
|
assert!(!screen.contains("next room, chat or user"), "{screen}");
|
||||||
|
|
||||||
press(&mut tui, KeyCode::End);
|
press(&mut tui, KeyCode::End);
|
||||||
let screen = screen_sized(&mut tui, 80, 24);
|
let screen = screen_sized(&mut tui, 80, 24);
|
||||||
assert!(screen.contains("clear every finished one"), "{screen}");
|
assert!(screen.contains("next room, chat or user"), "{screen}");
|
||||||
assert!(!screen.contains("Tab / Shift-Tab"), "{screen}");
|
assert!(!screen.contains("next / previous pane"), "{screen}");
|
||||||
|
|
||||||
press(&mut tui, KeyCode::Home);
|
press(&mut tui, KeyCode::Home);
|
||||||
let screen = screen_sized(&mut tui, 80, 24);
|
let screen = screen_sized(&mut tui, 80, 24);
|
||||||
@@ -1587,6 +1587,208 @@ mod tests {
|
|||||||
assert_eq!(tui.state.focused_pane, FocusedPane::Results);
|
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]
|
#[test]
|
||||||
fn the_shortcut_bar_wraps_instead_of_cutting_keys_off() {
|
fn the_shortcut_bar_wraps_instead_of_cutting_keys_off() {
|
||||||
let mut tui = furnished_tui();
|
let mut tui = furnished_tui();
|
||||||
|
|||||||
@@ -50,6 +50,8 @@ const HELP_LEFT: &[(&str, &[(&str, &str)])] = &[
|
|||||||
("Home End / g G", "first / last row"),
|
("Home End / g G", "first / last row"),
|
||||||
("PgUp PgDn / ^b ^f", "one page"),
|
("PgUp PgDn / ^b ^f", "one page"),
|
||||||
("^u ^d", "half a page"),
|
("^u ^d", "half a page"),
|
||||||
|
("h l / ← →", "scroll a long name sideways"),
|
||||||
|
("0 / $", "its start / its end"),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
];
|
];
|
||||||
@@ -71,8 +73,6 @@ const HELP_RIGHT: &[(&str, &[(&str, &str)])] = &[
|
|||||||
("a / A", "select all / none"),
|
("a / A", "select all / none"),
|
||||||
("Enter", "download the selection"),
|
("Enter", "download the selection"),
|
||||||
("/", "filter, Enter keeps it, Esc clears"),
|
("/", "filter, Enter keeps it, Esc clears"),
|
||||||
("h l / ← →", "scroll the file name"),
|
|
||||||
("0 / $", "start / end of the file name"),
|
|
||||||
("b", "browse the owner"),
|
("b", "browse the owner"),
|
||||||
("c", "chat rooms"),
|
("c", "chat rooms"),
|
||||||
],
|
],
|
||||||
@@ -87,6 +87,15 @@ const HELP_RIGHT: &[(&str, &[(&str, &str)])] = &[
|
|||||||
("c", "clear every finished one"),
|
("c", "clear every finished one"),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
|
(
|
||||||
|
"Chat and browse popups",
|
||||||
|
&[
|
||||||
|
("PgUp PgDn / ^b ^f", "a page of messages, or of the tree"),
|
||||||
|
("^u ^d", "half a page of them"),
|
||||||
|
("Home End / g G", "oldest / newest, or first / last row"),
|
||||||
|
("Tab / Shift-Tab", "next room, chat or user"),
|
||||||
|
],
|
||||||
|
),
|
||||||
];
|
];
|
||||||
|
|
||||||
impl MainTui {
|
impl MainTui {
|
||||||
@@ -192,6 +201,7 @@ impl MainTui {
|
|||||||
&self.state.searches,
|
&self.state.searches,
|
||||||
&mut self.state.searches_table_state,
|
&mut self.state.searches_table_state,
|
||||||
focused,
|
focused,
|
||||||
|
self.state.searches_query_offset,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
FocusedPane::Results => {
|
FocusedPane::Results => {
|
||||||
@@ -207,6 +217,7 @@ impl MainTui {
|
|||||||
&self.state.uploads,
|
&self.state.uploads,
|
||||||
&mut self.state.downloads_table_state,
|
&mut self.state.downloads_table_state,
|
||||||
focused,
|
focused,
|
||||||
|
self.state.downloads_name_offset,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -329,6 +340,7 @@ impl MainTui {
|
|||||||
// Browse tree overlays everything when open.
|
// Browse tree overlays everything when open.
|
||||||
if self.state.show_browse && !self.state.browse.is_empty() {
|
if self.state.show_browse && !self.state.browse.is_empty() {
|
||||||
let area = centered_rect(80, 80, frame.area());
|
let area = centered_rect(80, 80, frame.area());
|
||||||
|
self.state.popup_area = Some(area);
|
||||||
frame.render_widget(ratatui::widgets::Clear, area);
|
frame.render_widget(ratatui::widgets::Clear, area);
|
||||||
render_browse_pane(
|
render_browse_pane(
|
||||||
frame,
|
frame,
|
||||||
@@ -347,11 +359,12 @@ impl MainTui {
|
|||||||
// Chat rooms overlay everything when open.
|
// Chat rooms overlay everything when open.
|
||||||
if self.state.show_rooms {
|
if self.state.show_rooms {
|
||||||
let area = centered_rect(85, 80, frame.area());
|
let area = centered_rect(85, 80, frame.area());
|
||||||
|
self.state.popup_area = Some(area);
|
||||||
frame.render_widget(ratatui::widgets::Clear, area);
|
frame.render_widget(ratatui::widgets::Clear, area);
|
||||||
render_rooms_pane(
|
render_rooms_pane(
|
||||||
frame,
|
frame,
|
||||||
area,
|
area,
|
||||||
&self.state.rooms,
|
&mut self.state.rooms,
|
||||||
&mut self.state.rooms_list_table_state,
|
&mut self.state.rooms_list_table_state,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -430,12 +443,14 @@ impl MainTui {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
fn render_messages_popup(&self, frame: &mut Frame) {
|
fn render_messages_popup(&mut self, frame: &mut Frame) {
|
||||||
// The per-conversation chat box supersedes the old flat message list;
|
// The per-conversation chat box supersedes the old flat message list;
|
||||||
// it renders the messages and the compose line itself.
|
// it renders the messages and the compose line itself.
|
||||||
let area = centered_rect(70, 60, frame.area());
|
let area = centered_rect(70, 60, frame.area());
|
||||||
frame.render_widget(ratatui::widgets::Clear, area);
|
frame.render_widget(ratatui::widgets::Clear, area);
|
||||||
render_chat_pane(frame, area, &self.state, &self.client.username());
|
self.state.popup_area = Some(area);
|
||||||
|
let own = self.client.username();
|
||||||
|
render_chat_pane(frame, area, &mut self.state, &own);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Context shortcuts for the chat-rooms popup.
|
/// Context shortcuts for the chat-rooms popup.
|
||||||
@@ -467,6 +482,7 @@ impl MainTui {
|
|||||||
}
|
}
|
||||||
RoomsView::Chat => vec![
|
RoomsView::Chat => vec![
|
||||||
("Enter", "say"),
|
("Enter", "say"),
|
||||||
|
("PgUp/PgDn", "scroll"),
|
||||||
("↑↓", "pick user"),
|
("↑↓", "pick user"),
|
||||||
("b", "browse user"),
|
("b", "browse user"),
|
||||||
("m", "message user"),
|
("m", "message user"),
|
||||||
@@ -520,6 +536,7 @@ impl MainTui {
|
|||||||
} else {
|
} else {
|
||||||
vec![
|
vec![
|
||||||
("Enter", "type"),
|
("Enter", "type"),
|
||||||
|
("PgUp/PgDn", "scroll"),
|
||||||
("↑↓/Tab", "switch chat"),
|
("↑↓/Tab", "switch chat"),
|
||||||
("m", "new chat"),
|
("m", "new chat"),
|
||||||
("i/Esc", "close"),
|
("i/Esc", "close"),
|
||||||
@@ -530,6 +547,7 @@ impl MainTui {
|
|||||||
} else if self.state.show_browse {
|
} else if self.state.show_browse {
|
||||||
vec![
|
vec![
|
||||||
("↑↓", "move"),
|
("↑↓", "move"),
|
||||||
|
("PgUp/PgDn", "page"),
|
||||||
("→←", "expand/collapse"),
|
("→←", "expand/collapse"),
|
||||||
("Enter", "open/download"),
|
("Enter", "open/download"),
|
||||||
("d", "download folder"),
|
("d", "download folder"),
|
||||||
@@ -579,6 +597,7 @@ impl MainTui {
|
|||||||
("i", "inbox"),
|
("i", "inbox"),
|
||||||
("c", "chat"),
|
("c", "chat"),
|
||||||
("b", "browse user"),
|
("b", "browse user"),
|
||||||
|
("h/l", "scroll query"),
|
||||||
],
|
],
|
||||||
FocusedPane::Results => vec![
|
FocusedPane::Results => vec![
|
||||||
("Space", "select"),
|
("Space", "select"),
|
||||||
@@ -597,6 +616,7 @@ impl MainTui {
|
|||||||
("d", "delete queued/done"),
|
("d", "delete queued/done"),
|
||||||
("c", "clear finished"),
|
("c", "clear finished"),
|
||||||
("b", "browse user"),
|
("b", "browse user"),
|
||||||
|
("h/l", "scroll name"),
|
||||||
],
|
],
|
||||||
};
|
};
|
||||||
keys.extend(self.pane_shortcuts());
|
keys.extend(self.pane_shortcuts());
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
use super::MainTui;
|
use super::MainTui;
|
||||||
|
use super::input::{jumped, list_jump, scroll_log};
|
||||||
use crate::models::RoomsView;
|
use crate::models::RoomsView;
|
||||||
use ratatui::crossterm::event::{KeyCode, KeyEvent, KeyModifiers};
|
use ratatui::crossterm::event::{KeyCode, KeyEvent, KeyModifiers};
|
||||||
|
|
||||||
@@ -38,6 +39,16 @@ impl MainTui {
|
|||||||
|
|
||||||
fn handle_rooms_list_input(&mut self, key: KeyEvent) {
|
fn handle_rooms_list_input(&mut self, key: KeyEvent) {
|
||||||
let len = self.state.rooms.filtered_rooms().len();
|
let len = self.state.rooms.filtered_rooms().len();
|
||||||
|
if let Some(jump) = list_jump(key, self.popup_page()) {
|
||||||
|
self.state.rooms.list_selected =
|
||||||
|
jumped(self.state.rooms.list_selected, len, jump);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
// Control combinations are only ever jumps; the letters below are
|
||||||
|
// not theirs to trigger.
|
||||||
|
if key.modifiers.contains(KeyModifiers::CONTROL) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
match key.code {
|
match key.code {
|
||||||
// Esc peels back one level: clear a lingering filter first (as the
|
// Esc peels back one level: clear a lingering filter first (as the
|
||||||
// title's "Esc: clear" promises), otherwise close the popup.
|
// title's "Esc: clear" promises), otherwise close the popup.
|
||||||
@@ -92,6 +103,16 @@ impl MainTui {
|
|||||||
}
|
}
|
||||||
|
|
||||||
fn handle_rooms_chat_input(&mut self, key: KeyEvent) {
|
fn handle_rooms_chat_input(&mut self, key: KeyEvent) {
|
||||||
|
// Paging keys move through the room's history; the member list is a
|
||||||
|
// row at a time.
|
||||||
|
if let Some(view) = self.state.rooms.active_view_mut()
|
||||||
|
&& scroll_log(view, key)
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if key.modifiers.contains(KeyModifiers::CONTROL) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
match key.code {
|
match key.code {
|
||||||
KeyCode::Char('q') => self.state.show_rooms = false,
|
KeyCode::Char('q') => self.state.show_rooms = false,
|
||||||
KeyCode::Esc | KeyCode::Char('l') => {
|
KeyCode::Esc | KeyCode::Char('l') => {
|
||||||
@@ -208,15 +229,23 @@ impl MainTui {
|
|||||||
self.state.rooms.composing = false;
|
self.state.rooms.composing = false;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// The room whose newest messages are on screen, if any: the active one
|
||||||
|
/// in the chat view, unless the reader is held back in its history, when
|
||||||
|
/// what arrives still counts as unread.
|
||||||
|
pub(super) fn viewed_room(&self) -> Option<String> {
|
||||||
|
if !self.state.show_rooms || self.state.rooms.view != RoomsView::Chat {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
self.state
|
||||||
|
.rooms
|
||||||
|
.active_room()
|
||||||
|
.filter(|room| room.view.following())
|
||||||
|
.map(|room| room.name.clone())
|
||||||
|
}
|
||||||
|
|
||||||
/// Drain chat-room events into the rooms state, tracking unread badges.
|
/// Drain chat-room events into the rooms state, tracking unread badges.
|
||||||
pub(super) fn poll_room_events(&mut self) {
|
pub(super) fn poll_room_events(&mut self) {
|
||||||
let viewing = if self.state.show_rooms
|
let viewing = self.viewed_room();
|
||||||
&& self.state.rooms.view == RoomsView::Chat
|
|
||||||
{
|
|
||||||
self.state.rooms.active_room().map(|r| r.name.clone())
|
|
||||||
} else {
|
|
||||||
None
|
|
||||||
};
|
|
||||||
for event in self.client.take_room_events() {
|
for event in self.client.take_room_events() {
|
||||||
self.state.rooms.apply_event(event, viewing.as_deref());
|
self.state.rooms.apply_event(event, viewing.as_deref());
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
use crate::models::{AppState, MessageDirection};
|
use crate::models::{AppState, ChatMessage, LogView, MessageDirection};
|
||||||
use crate::ui::{
|
use crate::ui::{
|
||||||
accent_style, border_style, dimmed_style, highlight_style, info_style,
|
accent_style, border_style, dimmed_style, highlight_style, info_style,
|
||||||
primary_style, wrap_chat_line,
|
primary_style, wrap_chat_line,
|
||||||
@@ -17,10 +17,11 @@ use ratatui::{
|
|||||||
pub fn render_chat_pane(
|
pub fn render_chat_pane(
|
||||||
frame: &mut Frame,
|
frame: &mut Frame,
|
||||||
area: Rect,
|
area: Rect,
|
||||||
state: &AppState,
|
state: &mut AppState,
|
||||||
own_username: &str,
|
own_username: &str,
|
||||||
) {
|
) {
|
||||||
let peer = state.active_chat_peer();
|
let peer = state.active_chat_peer().map(str::to_string);
|
||||||
|
let peer = peer.as_deref();
|
||||||
let title = peer.map_or_else(
|
let title = peer.map_or_else(
|
||||||
|| " Messages (m: compose, i/Esc: close) ".to_string(),
|
|| " Messages (m: compose, i/Esc: close) ".to_string(),
|
||||||
|peer| format!(" {peer} (↑↓/Tab: switch, m: to…, i/Esc: close) "),
|
|peer| format!(" {peer} (↑↓/Tab: switch, m: to…, i/Esc: close) "),
|
||||||
@@ -51,7 +52,14 @@ pub fn render_chat_pane(
|
|||||||
return;
|
return;
|
||||||
};
|
};
|
||||||
|
|
||||||
render_messages(frame, body[0], state, peer, own_username);
|
render_messages(
|
||||||
|
frame,
|
||||||
|
body[0],
|
||||||
|
&state.messages,
|
||||||
|
&mut state.chat_view,
|
||||||
|
peer,
|
||||||
|
own_username,
|
||||||
|
);
|
||||||
render_compose(frame, chunks[1], state);
|
render_compose(frame, chunks[1], state);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -94,13 +102,13 @@ fn render_peers(
|
|||||||
fn render_messages(
|
fn render_messages(
|
||||||
frame: &mut Frame,
|
frame: &mut Frame,
|
||||||
area: Rect,
|
area: Rect,
|
||||||
state: &AppState,
|
messages: &[ChatMessage],
|
||||||
|
view: &mut LogView,
|
||||||
peer: &str,
|
peer: &str,
|
||||||
own_username: &str,
|
own_username: &str,
|
||||||
) {
|
) {
|
||||||
let width = area.width as usize;
|
let width = area.width as usize;
|
||||||
let lines: Vec<Line> = state
|
let lines: Vec<Line> = messages
|
||||||
.messages
|
|
||||||
.iter()
|
.iter()
|
||||||
.filter(|m| m.peer == peer)
|
.filter(|m| m.peer == peer)
|
||||||
.flat_map(|m| {
|
.flat_map(|m| {
|
||||||
@@ -123,9 +131,9 @@ fn render_messages(
|
|||||||
})
|
})
|
||||||
.collect();
|
.collect();
|
||||||
|
|
||||||
// Auto-scroll: tail by rendered rows so the newest message stays visible.
|
// The newest rows, unless the reader is holding a place in the history.
|
||||||
let start = lines.len().saturating_sub((area.height as usize).max(1));
|
let window = view.window(lines.len(), usize::from(area.height));
|
||||||
frame.render_widget(Paragraph::new(lines[start..].to_vec()), area);
|
frame.render_widget(Paragraph::new(lines[window].to_vec()), area);
|
||||||
}
|
}
|
||||||
|
|
||||||
fn render_compose(frame: &mut Frame, area: Rect, state: &AppState) {
|
fn render_compose(frame: &mut Frame, area: Rect, state: &AppState) {
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
use super::name_scroll::{column_width, end_offset, scroll_text};
|
||||||
use crate::models::DownloadEntry;
|
use crate::models::DownloadEntry;
|
||||||
use crate::ui::{
|
use crate::ui::{
|
||||||
GLYPH_ACTIVE, GLYPH_DONE, GLYPH_FAILED, GLYPH_QUEUED, HIGHLIGHT_SYMBOL,
|
GLYPH_ACTIVE, GLYPH_DONE, GLYPH_FAILED, GLYPH_QUEUED, HIGHLIGHT_SYMBOL,
|
||||||
@@ -8,12 +9,43 @@ use crate::ui::{
|
|||||||
};
|
};
|
||||||
use ratatui::{
|
use ratatui::{
|
||||||
Frame,
|
Frame,
|
||||||
layout::Rect,
|
layout::{Constraint, Rect},
|
||||||
widgets::{Cell, HighlightSpacing, Paragraph, Row, Table, TableState},
|
widgets::{Cell, HighlightSpacing, Paragraph, Row, Table, TableState},
|
||||||
};
|
};
|
||||||
use soulseek_rs::DownloadStatus;
|
use soulseek_rs::DownloadStatus;
|
||||||
use soulseek_rs::types::{UploadInfo, UploadStatus};
|
use soulseek_rs::types::{UploadInfo, UploadStatus};
|
||||||
|
|
||||||
|
// Sized to the widest value each column shows in ordinary use — a
|
||||||
|
// three-digit megabyte count on both sides of the progress — so the file
|
||||||
|
// name keeps whatever width the pane has beyond that.
|
||||||
|
const WIDTHS: [Constraint; 5] = [
|
||||||
|
Constraint::Length(6),
|
||||||
|
Constraint::Fill(1),
|
||||||
|
Constraint::Length(12),
|
||||||
|
Constraint::Length(25),
|
||||||
|
Constraint::Length(10),
|
||||||
|
];
|
||||||
|
|
||||||
|
/// Filename is the second column.
|
||||||
|
const NAME_COLUMN: usize = 1;
|
||||||
|
|
||||||
|
/// The offset that brings the end of a transfer's name into view.
|
||||||
|
#[must_use]
|
||||||
|
pub fn name_end_offset(name: &str, area: Rect) -> usize {
|
||||||
|
end_offset(name, area, &WIDTHS, NAME_COLUMN)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// What the list shows for an upload: the peer-facing path is ours, so only
|
||||||
|
/// the file's own name is worth a column.
|
||||||
|
#[must_use]
|
||||||
|
pub fn upload_display_name(upload: &UploadInfo) -> &str {
|
||||||
|
upload
|
||||||
|
.filename
|
||||||
|
.rsplit(['\\', '/'])
|
||||||
|
.next()
|
||||||
|
.unwrap_or(&upload.filename)
|
||||||
|
}
|
||||||
|
|
||||||
pub fn render_downloads_pane(
|
pub fn render_downloads_pane(
|
||||||
frame: &mut Frame,
|
frame: &mut Frame,
|
||||||
area: Rect,
|
area: Rect,
|
||||||
@@ -21,6 +53,7 @@ pub fn render_downloads_pane(
|
|||||||
uploads: &[UploadInfo],
|
uploads: &[UploadInfo],
|
||||||
table_state: &mut TableState,
|
table_state: &mut TableState,
|
||||||
focused: bool,
|
focused: bool,
|
||||||
|
name_offset: usize,
|
||||||
) {
|
) {
|
||||||
if downloads.is_empty() && uploads.is_empty() {
|
if downloads.is_empty() && uploads.is_empty() {
|
||||||
let empty_block = pane_block(focused).title(pane_title(
|
let empty_block = pane_block(focused).title(pane_title(
|
||||||
@@ -47,6 +80,7 @@ pub fn render_downloads_pane(
|
|||||||
])
|
])
|
||||||
.height(1);
|
.height(1);
|
||||||
|
|
||||||
|
let name_width = column_width(area, &WIDTHS, NAME_COLUMN);
|
||||||
let mut rows: Vec<Row> = downloads
|
let mut rows: Vec<Row> = downloads
|
||||||
.iter()
|
.iter()
|
||||||
.map(|download_entry| {
|
.map(|download_entry| {
|
||||||
@@ -106,7 +140,12 @@ pub fn render_downloads_pane(
|
|||||||
|
|
||||||
Row::new(vec![
|
Row::new(vec![
|
||||||
Cell::from(status_icon).style(status_style),
|
Cell::from(status_icon).style(status_style),
|
||||||
Cell::from(download.filename.clone()).style(body_style()),
|
Cell::from(scroll_text(
|
||||||
|
&download.filename,
|
||||||
|
name_offset,
|
||||||
|
name_width,
|
||||||
|
))
|
||||||
|
.style(body_style()),
|
||||||
Cell::from(download.username.clone()).style(info_style()),
|
Cell::from(download.username.clone()).style(info_style()),
|
||||||
Cell::from(progress_text).style(progress_style),
|
Cell::from(progress_text).style(progress_style),
|
||||||
Cell::from(speed_text).style(warning_style()),
|
Cell::from(speed_text).style(warning_style()),
|
||||||
@@ -149,32 +188,21 @@ pub fn render_downloads_pane(
|
|||||||
}
|
}
|
||||||
_ => "-".to_string(),
|
_ => "-".to_string(),
|
||||||
};
|
};
|
||||||
let basename = upload
|
|
||||||
.filename
|
|
||||||
.rsplit(['\\', '/'])
|
|
||||||
.next()
|
|
||||||
.unwrap_or(&upload.filename);
|
|
||||||
Row::new(vec![
|
Row::new(vec![
|
||||||
Cell::from(format!("↑ {status_icon}")).style(status_style),
|
Cell::from(format!("↑ {status_icon}")).style(status_style),
|
||||||
Cell::from(basename.to_string()).style(body_style()),
|
Cell::from(scroll_text(
|
||||||
|
upload_display_name(upload),
|
||||||
|
name_offset,
|
||||||
|
name_width,
|
||||||
|
))
|
||||||
|
.style(body_style()),
|
||||||
Cell::from(upload.username.clone()).style(info_style()),
|
Cell::from(upload.username.clone()).style(info_style()),
|
||||||
Cell::from(progress_text).style(primary_style()),
|
Cell::from(progress_text).style(primary_style()),
|
||||||
Cell::from(speed_text).style(warning_style()),
|
Cell::from(speed_text).style(warning_style()),
|
||||||
])
|
])
|
||||||
}));
|
}));
|
||||||
|
|
||||||
// Sized to the widest value each column shows in ordinary use — a
|
let table = Table::new(rows, WIDTHS)
|
||||||
// three-digit megabyte count on both sides of the progress — so the file
|
|
||||||
// name keeps whatever width the pane has beyond that.
|
|
||||||
let widths = [
|
|
||||||
ratatui::layout::Constraint::Length(6),
|
|
||||||
ratatui::layout::Constraint::Fill(1),
|
|
||||||
ratatui::layout::Constraint::Length(12),
|
|
||||||
ratatui::layout::Constraint::Length(25),
|
|
||||||
ratatui::layout::Constraint::Length(10),
|
|
||||||
];
|
|
||||||
|
|
||||||
let table = Table::new(rows, widths)
|
|
||||||
.header(header)
|
.header(header)
|
||||||
.row_highlight_style(row_highlight_style())
|
.row_highlight_style(row_highlight_style())
|
||||||
.highlight_symbol(HIGHLIGHT_SYMBOL)
|
.highlight_symbol(HIGHLIGHT_SYMBOL)
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ mod browse_pane;
|
|||||||
mod chat_pane;
|
mod chat_pane;
|
||||||
mod download_info_pane;
|
mod download_info_pane;
|
||||||
mod downloads_pane;
|
mod downloads_pane;
|
||||||
|
mod name_scroll;
|
||||||
mod results_pane;
|
mod results_pane;
|
||||||
mod rooms_pane;
|
mod rooms_pane;
|
||||||
mod searches_pane;
|
mod searches_pane;
|
||||||
@@ -11,9 +12,12 @@ pub use chat_pane::render_chat_pane;
|
|||||||
pub use download_info_pane::{
|
pub use download_info_pane::{
|
||||||
InfoSubject, render_download_info_pane, selected_transfer,
|
InfoSubject, render_download_info_pane, selected_transfer,
|
||||||
};
|
};
|
||||||
pub use downloads_pane::render_downloads_pane;
|
pub use downloads_pane::{
|
||||||
|
name_end_offset as transfer_name_end_offset, render_downloads_pane,
|
||||||
|
upload_display_name,
|
||||||
|
};
|
||||||
pub use results_pane::{
|
pub use results_pane::{
|
||||||
ResultsPaneParams, name_end_offset, render_results_pane,
|
ResultsPaneParams, name_end_offset, render_results_pane,
|
||||||
};
|
};
|
||||||
pub use rooms_pane::render_rooms_pane;
|
pub use rooms_pane::render_rooms_pane;
|
||||||
pub use searches_pane::render_searches_pane;
|
pub use searches_pane::{query_end_offset, render_searches_pane};
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|
||||||
|
/// 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 = 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)[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(Span::raw(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::models::FileDisplayData;
|
||||||
use crate::ui::{
|
use crate::ui::{
|
||||||
BYTES_PER_MB, HIGHLIGHT_SYMBOL, body_style, dimmed_style, format_bytes,
|
BYTES_PER_MB, HIGHLIGHT_SYMBOL, body_style, dimmed_style, format_bytes,
|
||||||
@@ -6,12 +7,11 @@ use crate::ui::{
|
|||||||
};
|
};
|
||||||
use ratatui::{
|
use ratatui::{
|
||||||
Frame,
|
Frame,
|
||||||
layout::{Constraint, Layout, Rect},
|
layout::{Constraint, Rect},
|
||||||
text::{Line, Span},
|
text::{Line, Span},
|
||||||
widgets::{Cell, HighlightSpacing, Paragraph, Row, Table, TableState},
|
widgets::{Cell, HighlightSpacing, Paragraph, Row, Table, TableState},
|
||||||
};
|
};
|
||||||
use std::collections::HashSet;
|
use std::collections::HashSet;
|
||||||
use unicode_width::UnicodeWidthChar;
|
|
||||||
|
|
||||||
const VERSION: &str = env!("CARGO_PKG_VERSION");
|
const VERSION: &str = env!("CARGO_PKG_VERSION");
|
||||||
|
|
||||||
@@ -39,59 +39,12 @@ pub struct ResultsPaneParams<'a> {
|
|||||||
pub name_offset: usize,
|
pub name_offset: usize,
|
||||||
}
|
}
|
||||||
|
|
||||||
fn name_column_width(area: Rect) -> usize {
|
/// Filename is the second column.
|
||||||
let inner = pane_block(false).inner(area);
|
const NAME_COLUMN: usize = 1;
|
||||||
let symbol = Line::from(HIGHLIGHT_SYMBOL).width() as u16;
|
|
||||||
let [_, columns] =
|
|
||||||
Layout::horizontal([Constraint::Length(symbol), Constraint::Fill(0)])
|
|
||||||
.areas(inner);
|
|
||||||
usize::from(Layout::horizontal(WIDTHS).spacing(1).split(columns)[1].width)
|
|
||||||
}
|
|
||||||
|
|
||||||
const fn overflow(cells: usize, width: usize) -> usize {
|
|
||||||
if cells <= width { 0 } else { cells + 1 - width }
|
|
||||||
}
|
|
||||||
|
|
||||||
#[must_use]
|
#[must_use]
|
||||||
pub fn name_end_offset(name: &str, area: Rect) -> usize {
|
pub fn name_end_offset(name: &str, area: Rect) -> usize {
|
||||||
overflow(Span::raw(name).width(), name_column_width(area))
|
end_offset(name, area, &WIDTHS, NAME_COLUMN)
|
||||||
}
|
|
||||||
|
|
||||||
fn scroll_name(name: &str, offset: usize, width: usize) -> Line<'_> {
|
|
||||||
if offset == 0 {
|
|
||||||
return Line::from(name);
|
|
||||||
}
|
|
||||||
let mut tail_cells = 0;
|
|
||||||
let mut furthest = name.len();
|
|
||||||
for (index, ch) in name.char_indices().rev() {
|
|
||||||
let cells = ch.width().unwrap_or(0);
|
|
||||||
if tail_cells + cells >= width {
|
|
||||||
if index == 0 && tail_cells + cells == width {
|
|
||||||
return Line::from(name);
|
|
||||||
}
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
tail_cells += cells;
|
|
||||||
furthest = index;
|
|
||||||
}
|
|
||||||
if furthest == 0 {
|
|
||||||
return Line::from(name);
|
|
||||||
}
|
|
||||||
let mut skipped = 0;
|
|
||||||
let mut start = furthest;
|
|
||||||
for (index, ch) in name.char_indices() {
|
|
||||||
if index >= furthest || skipped >= offset {
|
|
||||||
start = index;
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
skipped += ch.width().unwrap_or(0);
|
|
||||||
}
|
|
||||||
while let Some(ch) = name[start..].chars().next()
|
|
||||||
&& ch.width() == Some(0)
|
|
||||||
{
|
|
||||||
start += ch.len_utf8();
|
|
||||||
}
|
|
||||||
Line::from(vec![Span::raw("…"), Span::raw(&name[start..])])
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Whether the rendered row `display_idx` is selected. `selected_indices` holds
|
/// Whether the rendered row `display_idx` is selected. `selected_indices` holds
|
||||||
@@ -173,7 +126,7 @@ pub fn render_results_pane(
|
|||||||
])
|
])
|
||||||
.height(1);
|
.height(1);
|
||||||
|
|
||||||
let name_width = name_column_width(area);
|
let name_width = column_width(area, &WIDTHS, NAME_COLUMN);
|
||||||
let rows: Vec<Row> = items
|
let rows: Vec<Row> = items
|
||||||
.iter()
|
.iter()
|
||||||
.enumerate()
|
.enumerate()
|
||||||
@@ -206,7 +159,7 @@ pub fn render_results_pane(
|
|||||||
|
|
||||||
Row::new(vec![
|
Row::new(vec![
|
||||||
Cell::from(checkbox).style(checkbox_style),
|
Cell::from(checkbox).style(checkbox_style),
|
||||||
Cell::from(scroll_name(
|
Cell::from(scroll_text(
|
||||||
&file.filename,
|
&file.filename,
|
||||||
name_offset,
|
name_offset,
|
||||||
name_width,
|
name_width,
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
use crate::models::{RoomsState, RoomsView};
|
use crate::models::{LogView, RoomsState, RoomsView};
|
||||||
use crate::ui::{
|
use crate::ui::{
|
||||||
HIGHLIGHT_SYMBOL, PANE_PADDING, accent_style, dimmed_style,
|
HIGHLIGHT_SYMBOL, PANE_PADDING, accent_style, dimmed_style,
|
||||||
highlight_style, info_style, pane_block, plain_title, primary_style,
|
highlight_style, info_style, pane_block, plain_title, primary_style,
|
||||||
@@ -20,7 +20,7 @@ use ratatui::{
|
|||||||
pub fn render_rooms_pane(
|
pub fn render_rooms_pane(
|
||||||
frame: &mut Frame,
|
frame: &mut Frame,
|
||||||
area: Rect,
|
area: Rect,
|
||||||
rooms: &RoomsState,
|
rooms: &mut RoomsState,
|
||||||
list_table_state: &mut TableState,
|
list_table_state: &mut TableState,
|
||||||
) {
|
) {
|
||||||
match rooms.view {
|
match rooms.view {
|
||||||
@@ -94,7 +94,7 @@ fn render_list(
|
|||||||
frame.render_stateful_widget(table, area, table_state);
|
frame.render_stateful_widget(table, area, table_state);
|
||||||
}
|
}
|
||||||
|
|
||||||
fn render_chat(frame: &mut Frame, area: Rect, rooms: &RoomsState) {
|
fn render_chat(frame: &mut Frame, area: Rect, rooms: &mut RoomsState) {
|
||||||
let block = pane_block(true).title(
|
let block = pane_block(true).title(
|
||||||
" Chat rooms (Tab: switch, l: room list, x: leave, Esc: back) ",
|
" Chat rooms (Tab: switch, l: room list, x: leave, Esc: back) ",
|
||||||
);
|
);
|
||||||
@@ -110,7 +110,10 @@ fn render_chat(frame: &mut Frame, area: Rect, rooms: &RoomsState) {
|
|||||||
|
|
||||||
render_tab_bar(frame, chunks[0], rooms);
|
render_tab_bar(frame, chunks[0], rooms);
|
||||||
|
|
||||||
let Some(active) = rooms.active_room() else {
|
let user_selected = rooms.user_selected;
|
||||||
|
let composing = rooms.composing;
|
||||||
|
let active_index = rooms.active;
|
||||||
|
let Some(active) = rooms.open.get_mut(active_index) else {
|
||||||
frame.render_widget(
|
frame.render_widget(
|
||||||
Paragraph::new("No open rooms. Press l for the room list.")
|
Paragraph::new("No open rooms. Press l for the room list.")
|
||||||
.style(dimmed_style()),
|
.style(dimmed_style()),
|
||||||
@@ -124,11 +127,11 @@ fn render_chat(frame: &mut Frame, area: Rect, rooms: &RoomsState) {
|
|||||||
Layout::horizontal([Constraint::Fill(1), Constraint::Length(22)])
|
Layout::horizontal([Constraint::Fill(1), Constraint::Length(22)])
|
||||||
.split(chunks[1]);
|
.split(chunks[1]);
|
||||||
|
|
||||||
render_messages(frame, body[0], active.lines.as_slice());
|
render_messages(frame, body[0], active.lines.as_slice(), &mut active.view);
|
||||||
render_users(frame, body[1], &active.users, rooms.user_selected);
|
render_users(frame, body[1], &active.users, user_selected);
|
||||||
|
|
||||||
// Compose line or hint.
|
// Compose line or hint.
|
||||||
if rooms.composing {
|
if composing {
|
||||||
let line = Line::from(vec![
|
let line = Line::from(vec![
|
||||||
Span::styled("› ", accent_style()),
|
Span::styled("› ", accent_style()),
|
||||||
Span::styled(active.input.clone(), primary_style()),
|
Span::styled(active.input.clone(), primary_style()),
|
||||||
@@ -173,9 +176,10 @@ fn render_messages(
|
|||||||
frame: &mut Frame,
|
frame: &mut Frame,
|
||||||
area: Rect,
|
area: Rect,
|
||||||
lines: &[crate::models::RoomLine],
|
lines: &[crate::models::RoomLine],
|
||||||
|
view: &mut LogView,
|
||||||
) {
|
) {
|
||||||
// Wrap each message to the pane width, then auto-scroll: tail by rendered
|
// Wrap each message to the pane width, then show the rows the reader is
|
||||||
// rows so the newest message is always fully visible.
|
// at: the newest ones, unless they are holding a place in the history.
|
||||||
let width = area.width as usize;
|
let width = area.width as usize;
|
||||||
let rendered: Vec<Line> = lines
|
let rendered: Vec<Line> = lines
|
||||||
.iter()
|
.iter()
|
||||||
@@ -198,8 +202,8 @@ fn render_messages(
|
|||||||
}
|
}
|
||||||
})
|
})
|
||||||
.collect();
|
.collect();
|
||||||
let start = rendered.len().saturating_sub((area.height as usize).max(1));
|
let window = view.window(rendered.len(), usize::from(area.height));
|
||||||
frame.render_widget(Paragraph::new(rendered[start..].to_vec()), area);
|
frame.render_widget(Paragraph::new(rendered[window].to_vec()), area);
|
||||||
}
|
}
|
||||||
|
|
||||||
fn render_users(
|
fn render_users(
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
use super::name_scroll::{column_width, end_offset, scroll_text};
|
||||||
use crate::models::{SearchEntry, SearchStatus};
|
use crate::models::{SearchEntry, SearchStatus};
|
||||||
use crate::ui::{
|
use crate::ui::{
|
||||||
HIGHLIGHT_SYMBOL, body_style, dimmed_style, header_style, pane_block,
|
HIGHLIGHT_SYMBOL, body_style, dimmed_style, header_style, pane_block,
|
||||||
@@ -5,16 +6,34 @@ use crate::ui::{
|
|||||||
};
|
};
|
||||||
use ratatui::{
|
use ratatui::{
|
||||||
Frame,
|
Frame,
|
||||||
layout::Rect,
|
layout::{Constraint, Rect},
|
||||||
widgets::{Cell, HighlightSpacing, Row, Table, TableState},
|
widgets::{Cell, HighlightSpacing, Row, Table, TableState},
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// The pane shares a row with two others, so the fixed columns are as
|
||||||
|
// narrow as their widest word: the query gets the rest.
|
||||||
|
const WIDTHS: [Constraint; 3] = [
|
||||||
|
Constraint::Length(6),
|
||||||
|
Constraint::Fill(1),
|
||||||
|
Constraint::Length(7),
|
||||||
|
];
|
||||||
|
|
||||||
|
/// Query is the second column.
|
||||||
|
const QUERY_COLUMN: usize = 1;
|
||||||
|
|
||||||
|
/// The offset that brings the end of a query into view.
|
||||||
|
#[must_use]
|
||||||
|
pub fn query_end_offset(query: &str, area: Rect) -> usize {
|
||||||
|
end_offset(query, area, &WIDTHS, QUERY_COLUMN)
|
||||||
|
}
|
||||||
|
|
||||||
pub fn render_searches_pane(
|
pub fn render_searches_pane(
|
||||||
frame: &mut Frame,
|
frame: &mut Frame,
|
||||||
area: Rect,
|
area: Rect,
|
||||||
searches: &[SearchEntry],
|
searches: &[SearchEntry],
|
||||||
table_state: &mut TableState,
|
table_state: &mut TableState,
|
||||||
focused: bool,
|
focused: bool,
|
||||||
|
query_offset: usize,
|
||||||
) {
|
) {
|
||||||
let header = Row::new(vec![
|
let header = Row::new(vec![
|
||||||
Cell::from("Status").style(header_style()),
|
Cell::from("Status").style(header_style()),
|
||||||
@@ -23,6 +42,7 @@ pub fn render_searches_pane(
|
|||||||
])
|
])
|
||||||
.height(1);
|
.height(1);
|
||||||
|
|
||||||
|
let query_width = column_width(area, &WIDTHS, QUERY_COLUMN);
|
||||||
let rows: Vec<Row> = searches
|
let rows: Vec<Row> = searches
|
||||||
.iter()
|
.iter()
|
||||||
.map(|search| {
|
.map(|search| {
|
||||||
@@ -55,21 +75,18 @@ pub fn render_searches_pane(
|
|||||||
|
|
||||||
Row::new(vec![
|
Row::new(vec![
|
||||||
status_cell,
|
status_cell,
|
||||||
Cell::from(search.query.clone()).style(body_style()),
|
Cell::from(scroll_text(
|
||||||
|
&search.query,
|
||||||
|
query_offset,
|
||||||
|
query_width,
|
||||||
|
))
|
||||||
|
.style(body_style()),
|
||||||
Cell::from(results_text).style(dimmed_style()),
|
Cell::from(results_text).style(dimmed_style()),
|
||||||
])
|
])
|
||||||
})
|
})
|
||||||
.collect();
|
.collect();
|
||||||
|
|
||||||
// The pane shares a row with two others, so the fixed columns are as
|
let table = Table::new(rows, WIDTHS)
|
||||||
// narrow as their widest word: the query gets the rest.
|
|
||||||
let widths = [
|
|
||||||
ratatui::layout::Constraint::Length(6),
|
|
||||||
ratatui::layout::Constraint::Fill(1),
|
|
||||||
ratatui::layout::Constraint::Length(7),
|
|
||||||
];
|
|
||||||
|
|
||||||
let table = Table::new(rows, widths)
|
|
||||||
.header(header)
|
.header(header)
|
||||||
.row_highlight_style(row_highlight_style())
|
.row_highlight_style(row_highlight_style())
|
||||||
.highlight_symbol(HIGHLIGHT_SYMBOL)
|
.highlight_symbol(HIGHLIGHT_SYMBOL)
|
||||||
|
|||||||
@@ -36,7 +36,8 @@ const PANE_KEYS: readonly (readonly [string, string])[] = [
|
|||||||
['w', 'hide the focused pane; the others widen'],
|
['w', 'hide the focused pane; the others widen'],
|
||||||
['↑ ↓ · j k', 'move a row in any list'],
|
['↑ ↓ · j k', 'move a row in any list'],
|
||||||
['Home End · g G', 'first / last row'],
|
['Home End · g G', 'first / last row'],
|
||||||
['PgUp PgDn · ^u ^d', 'a page, or half a page'],
|
['PgUp PgDn · ^u ^d', 'a page, or half a page; in a chat, back through the log'],
|
||||||
|
['h l · ← →', 'scroll a long name or query sideways, 0 and $ to either end'],
|
||||||
]
|
]
|
||||||
|
|
||||||
const ROOM_KEYS: readonly (readonly [string, string])[] = [
|
const ROOM_KEYS: readonly (readonly [string, string])[] = [
|
||||||
|
|||||||
Reference in New Issue
Block a user