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:
Michel de Graaf
2026-09-07 08:16:20 +00:00
parent da8ab45303
commit 478d976776
18 changed files with 841 additions and 175 deletions
+146
View File
@@ -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.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum CommandBarMode {
@@ -139,6 +208,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>,
@@ -148,11 +219,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:
@@ -183,6 +257,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,
@@ -204,6 +280,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 {
@@ -222,6 +301,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(),
@@ -234,6 +314,7 @@ impl AppState {
downloads: Vec::new(),
downloads_table_state,
downloads_name_offset: 0,
downloads_receiver_channel: None,
downloads_sender_channel: None,
active_downloads_count: 0,
@@ -254,6 +335,7 @@ impl AppState {
chat_peer: None,
chat_input: String::new(),
chat_composing: false,
chat_view: LogView::default(),
browse: BrowseTabs::new(),
show_browse: false,
@@ -270,6 +352,16 @@ 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,
}
}
@@ -302,6 +394,7 @@ impl AppState {
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;
@@ -351,6 +444,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);
}
}
@@ -486,6 +583,55 @@ mod tests {
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();
+1 -1
View File
@@ -5,7 +5,7 @@ mod rooms;
mod settings;
pub use app_state::{
AppState, ChatMessage, CommandBarMode, DownloadEntry, FocusedPane,
AppState, ChatMessage, CommandBarMode, DownloadEntry, FocusedPane, LogView,
MessageDirection, PaneLayout, SearchEntry, SearchStatus,
};
pub use browse::{
+8
View File
@@ -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);
}