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
+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));
}