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
+3 -1
View File
@@ -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
window, and `w` hides it so the others widen (its number brings it back).
Every list takes `↑`/`↓`, `Home`/`End`, `PgUp`/`PgDn` and the vim keys, and
`?` shows every key for where you are.
`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
+1 -1
View File
File diff suppressed because one or more lines are too long

Before

Width:  |  Height:  |  Size: 908 KiB

After

Width:  |  Height:  |  Size: 930 KiB

+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);
}
+15 -1
View File
@@ -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 => {
+151 -47
View File
@@ -1,7 +1,10 @@
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,
};
@@ -195,6 +198,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;
@@ -375,6 +386,15 @@ impl MainTui {
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.
fn page_size(&self, pane: FocusedPane) -> isize {
let area = match pane {
@@ -389,7 +409,7 @@ impl MainTui {
}
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;
}
match key.code {
@@ -426,20 +446,10 @@ impl MainTui {
}
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;
}
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(' ') => {
let index = self
.state
@@ -475,24 +485,82 @@ impl MainTui {
}
}
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);
/// 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,
}
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 {
let Some(area) = self.state.results_pane_area else {
return 0;
};
self.highlighted_result()
.map_or(0, |file| name_end_offset(&file.filename, area))
match self.state.focused_pane {
FocusedPane::Searches => {
let Some(area) = self.state.searches_pane_area else {
return 0;
};
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) {
// 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;
}
match key.code {
@@ -539,9 +607,58 @@ impl MainTui {
}
}
/// The keys every list answers alike: a row at a time wrapping at either
/// end, a page or half a page at a time stopping there, and the two ends.
/// 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: 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.
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(
key: KeyEvent,
table: &mut TableState,
@@ -549,23 +666,16 @@ fn navigate_list(
page: isize,
) -> bool {
let ctrl = key.modifiers.contains(KeyModifiers::CONTROL);
let half = (page / 2).max(1);
match key.code {
KeyCode::Up | KeyCode::Char('k') if !ctrl => cycle(table, len, false),
KeyCode::Down | KeyCode::Char('j') if !ctrl => cycle(table, len, true),
KeyCode::Home | KeyCode::Char('g') if !ctrl => {
select_row(table, len, 0);
}
KeyCode::End | KeyCode::Char('G') if !ctrl => {
select_row(table, len, len.saturating_sub(1));
}
KeyCode::PageDown => scroll_rows(table, len, page),
KeyCode::PageUp => scroll_rows(table, len, -page),
KeyCode::Char('f') if ctrl => scroll_rows(table, len, page),
KeyCode::Char('b') if ctrl => scroll_rows(table, len, -page),
KeyCode::Char('d') if ctrl => scroll_rows(table, len, half),
KeyCode::Char('u') if ctrl => scroll_rows(table, len, -half),
_ => return false,
_ => match list_jump(key, page) {
Some(jump) => {
let current = table.selected().unwrap_or(0);
select_row(table, len, jumped(current, len, jump));
}
None => return false,
},
}
true
}
@@ -590,9 +700,3 @@ fn select_row(table: &mut TableState, len: usize, target: usize) {
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));
}
+205 -3
View File
@@ -1548,12 +1548,12 @@ mod tests {
let panes = row_with(&screen, "Panes");
assert!(!panes.contains("Searches"), "one column: {panes}");
assert!(screen.contains("focus a pane, hidden or not"), "{screen}");
assert!(!screen.contains("clear every finished one"), "{screen}");
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("clear every finished one"), "{screen}");
assert!(!screen.contains("Tab / Shift-Tab"), "{screen}");
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);
@@ -1587,6 +1587,208 @@ mod tests {
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();
+25 -5
View File
@@ -50,6 +50,8 @@ const HELP_LEFT: &[(&str, &[(&str, &str)])] = &[
("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"),
],
),
];
@@ -71,8 +73,6 @@ const HELP_RIGHT: &[(&str, &[(&str, &str)])] = &[
("a / A", "select all / none"),
("Enter", "download the selection"),
("/", "filter, Enter keeps it, Esc clears"),
("h l / ← →", "scroll the file name"),
("0 / $", "start / end of the file name"),
("b", "browse the owner"),
("c", "chat rooms"),
],
@@ -87,6 +87,15 @@ const HELP_RIGHT: &[(&str, &[(&str, &str)])] = &[
("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 {
@@ -192,6 +201,7 @@ impl MainTui {
&self.state.searches,
&mut self.state.searches_table_state,
focused,
self.state.searches_query_offset,
);
}
FocusedPane::Results => {
@@ -207,6 +217,7 @@ impl MainTui {
&self.state.uploads,
&mut self.state.downloads_table_state,
focused,
self.state.downloads_name_offset,
);
}
}
@@ -329,6 +340,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,
@@ -347,11 +359,12 @@ 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,
);
}
@@ -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;
// 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.
@@ -467,6 +482,7 @@ impl MainTui {
}
RoomsView::Chat => vec![
("Enter", "say"),
("PgUp/PgDn", "scroll"),
("↑↓", "pick user"),
("b", "browse user"),
("m", "message user"),
@@ -520,6 +536,7 @@ impl MainTui {
} else {
vec![
("Enter", "type"),
("PgUp/PgDn", "scroll"),
("↑↓/Tab", "switch chat"),
("m", "new chat"),
("i/Esc", "close"),
@@ -530,6 +547,7 @@ impl MainTui {
} else if self.state.show_browse {
vec![
("↑↓", "move"),
("PgUp/PgDn", "page"),
("→←", "expand/collapse"),
("Enter", "open/download"),
("d", "download folder"),
@@ -579,6 +597,7 @@ impl MainTui {
("i", "inbox"),
("c", "chat"),
("b", "browse user"),
("h/l", "scroll query"),
],
FocusedPane::Results => vec![
("Space", "select"),
@@ -597,6 +616,7 @@ impl MainTui {
("d", "delete queued/done"),
("c", "clear finished"),
("b", "browse user"),
("h/l", "scroll name"),
],
};
keys.extend(self.pane_shortcuts());
+36 -7
View File
@@ -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());
}
+18 -10
View File
@@ -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) {
+48 -20
View File
@@ -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,32 +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()),
])
}));
// Sized to the widest value each column shows in ordinary use — a
// three-digit megabyte count on both sides of the progress — so the file
// name keeps whatever width the pane has beyond that.
let widths = [
ratatui::layout::Constraint::Length(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)
let table = Table::new(rows, WIDTHS)
.header(header)
.row_highlight_style(row_highlight_style())
.highlight_symbol(HIGHLIGHT_SYMBOL)
+6 -2
View File
@@ -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};
+126
View File
@@ -0,0 +1,126 @@
//! Sideways scrolling for a table column that holds text wider than it is:
//! a file name, a query. Every list scrolls the same way, so the arithmetic
//! lives here and each pane only says which of its columns it applies to.
use crate::ui::{HIGHLIGHT_SYMBOL, pane_block};
use ratatui::{
layout::{Constraint, Layout, Rect},
text::{Line, Span},
};
use unicode_width::UnicodeWidthChar;
/// 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");
}
}
+7 -54
View File
@@ -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,
+15 -11
View File
@@ -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,10 @@ 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 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(
Paragraph::new("No open rooms. Press l for the room list.")
.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)])
.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 +176,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 +202,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(
+28 -11
View File
@@ -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| {
@@ -55,21 +75,18 @@ pub fn render_searches_pane(
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();
// The pane shares a row with two others, so the fixed columns are as
// narrow as their widest word: the query gets the rest.
let widths = [
ratatui::layout::Constraint::Length(6),
ratatui::layout::Constraint::Fill(1),
ratatui::layout::Constraint::Length(7),
];
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 -1
View File
@@ -36,7 +36,8 @@ const PANE_KEYS: readonly (readonly [string, string])[] = [
['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'],
['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])[] = [