feat(tui): scroll long result names and page through results

Closes #55.

While [2] Results is focused the Info pane describes the highlighted
result, so a truncated path can be read in full. h/l (or the arrow keys)
scroll the filename column by display cells, 0 and $ jump to either end
of the highlighted name, and Home/End, g/G, PgUp/PgDn and Ctrl-f/b/d/u
move through the list; the same keys work while a filter is being typed.

Ctrl chords are no longer read as their bare letter: outside [2] they do
nothing, where before Ctrl-d deleted a search and Ctrl-c opened rooms.
Space after a filter that matches nothing no longer indexes an empty
list.
This commit is contained in:
Michel de Graaf
2026-09-06 20:10:59 +02:00
parent 7bc696cab6
commit 3193c73f5b
12 changed files with 577 additions and 117 deletions
+1 -7
View File
@@ -192,13 +192,7 @@ impl MainTui {
/// The username of the highlighted search result (filter-aware).
pub(super) fn highlighted_result_owner(&self) -> Option<String> {
let selected = self.state.results_table_state.selected()?;
let items = if self.state.results_filter_query.is_empty() {
&self.state.results_items
} else {
&self.state.results_filtered_items
};
items.get(selected).map(|f| f.username.clone())
self.highlighted_result().map(|f| f.username.clone())
}
/// Drain browse responses into any loading tabs, or time them out.
+84 -41
View File
@@ -1,11 +1,15 @@
use super::MainTui;
use crate::models::{CommandBarMode, FocusedPane};
use crate::ui::pane_block;
use crate::ui::panes::name_end_offset;
use ratatui::crossterm::event::{
KeyCode, KeyEvent, KeyModifiers, MouseButton, MouseEvent, MouseEventKind,
};
use ratatui::layout::Position;
use ratatui::widgets::TableState;
const NAME_SCROLL_STEP: isize = 8;
impl MainTui {
pub(super) fn handle_key_event(&mut self, key: KeyEvent) {
// Command bar takes priority
@@ -29,6 +33,13 @@ impl MainTui {
return self.handle_settings_input(key);
}
if key.modifiers.contains(KeyModifiers::CONTROL) {
if self.state.focused_pane == FocusedPane::Results {
self.handle_results_input(key);
}
return;
}
// Filter mode in Results pane
if self.state.results_is_filtering
&& self.state.focused_pane == FocusedPane::Results
@@ -291,21 +302,7 @@ impl MainTui {
KeyCode::Enter => {
self.state.results_is_filtering = false;
}
KeyCode::Up => {
cycle(
&mut self.state.results_table_state,
self.state.results_filtered_items.len(),
false,
);
}
KeyCode::Down => {
cycle(
&mut self.state.results_table_state,
self.state.results_filtered_items.len(),
true,
);
}
_ => {}
_ => self.handle_results_input(key),
}
}
@@ -330,6 +327,7 @@ impl MainTui {
self.state.results_filtered_indices =
(0..search.results.len()).collect();
self.state.results_selected_indices.clear();
self.state.results_name_offset = 0;
self.state.results_table_state.select(Some(0));
self.state.focused_pane = FocusedPane::Results;
}
@@ -350,11 +348,13 @@ impl MainTui {
}
fn handle_results_input(&mut self, key: KeyEvent) {
let items_count = if self.state.results_filter_query.is_empty() {
self.state.results_items.len()
} else {
self.state.results_filtered_items.len()
};
let ctrl = key.modifiers.contains(KeyModifiers::CONTROL);
if ctrl && !matches!(key.code, KeyCode::Char('f' | 'b' | 'd' | 'u')) {
return;
}
let items_count = self.visible_results().len();
let page = self.results_page_size();
let half = (page / 2).max(1);
match key.code {
KeyCode::Up | KeyCode::Char('k') => {
@@ -363,29 +363,36 @@ impl MainTui {
KeyCode::Down | KeyCode::Char('j') => {
cycle(&mut self.state.results_table_state, items_count, true);
}
KeyCode::Home | KeyCode::Char('g') => self.select_row(0),
KeyCode::End | KeyCode::Char('G') => {
self.select_row(items_count.saturating_sub(1));
}
KeyCode::PageDown => self.scroll_rows(page),
KeyCode::PageUp => self.scroll_rows(-page),
KeyCode::Char('f') if ctrl => self.scroll_rows(page),
KeyCode::Char('b') if ctrl => self.scroll_rows(-page),
KeyCode::Char('d') if ctrl => self.scroll_rows(half),
KeyCode::Char('u') if ctrl => self.scroll_rows(-half),
KeyCode::Right | KeyCode::Char('l') => {
self.scroll_names(NAME_SCROLL_STEP);
}
KeyCode::Left | KeyCode::Char('h') => {
self.scroll_names(-NAME_SCROLL_STEP);
}
KeyCode::Char('0') => self.state.results_name_offset = 0,
KeyCode::Char('$') => {
self.state.results_name_offset = self.highlighted_name_end();
}
KeyCode::Char(' ') => {
if let Some(current) = self.state.results_table_state.selected()
let index = self
.state
.results_table_state
.selected()
.and_then(|row| self.original_index(row));
if let Some(index) = index
&& !self.state.results_selected_indices.remove(&index)
{
let actual_index =
if self.state.results_filter_query.is_empty() {
current
} else {
self.state.results_filtered_indices[current]
};
if self
.state
.results_selected_indices
.contains(&actual_index)
{
self.state
.results_selected_indices
.remove(&actual_index);
} else {
self.state
.results_selected_indices
.insert(actual_index);
}
self.state.results_selected_indices.insert(index);
}
}
KeyCode::Char('/') => {
@@ -411,6 +418,42 @@ impl MainTui {
}
}
fn select_row(&mut self, target: usize) {
let len = self.visible_results().len();
if len > 0 {
self.state
.results_table_state
.select(Some(target.min(len - 1)));
}
}
fn scroll_rows(&mut self, delta: isize) {
let current = self.state.results_table_state.selected().unwrap_or(0);
self.select_row(current.saturating_add_signed(delta));
}
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);
}
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))
}
fn results_page_size(&self) -> isize {
let rows = self.state.results_pane_area.map_or(1, |area| {
pane_block(false).inner(area).height.saturating_sub(1)
});
rows.max(1) as isize
}
fn handle_downloads_input(&mut self, key: KeyEvent) {
// The pane lists downloads first, then uploads; navigation spans both.
let rows = self.state.downloads.len() + self.state.uploads.len();
+221
View File
@@ -1034,4 +1034,225 @@ mod tests {
assert!(downloads.is_dir(), "the folder is created here");
assert_eq!(tui.download_dir, downloads_dir);
}
fn results(n: usize) -> Vec<crate::models::FileDisplayData> {
(0..n)
.map(|i| crate::models::FileDisplayData {
filename: format!("@@x\\Music\\{i:02}.mp3"),
username: "bob".to_string(),
..Default::default()
})
.collect()
}
fn results_tui(n: usize) -> MainTui {
let mut tui = with_session(TalkativeSession::default());
tui.state.results_items = results(n);
tui.state.focused_pane = FocusedPane::Results;
tui.state.results_pane_area =
Some(ratatui::layout::Rect::new(0, 0, 80, 13));
tui
}
fn press(tui: &mut MainTui, code: KeyCode) {
tui.handle_key_event(KeyEvent::new(code, KeyModifiers::NONE));
}
fn ctrl(tui: &mut MainTui, ch: char) {
tui.handle_key_event(KeyEvent::new(
KeyCode::Char(ch),
KeyModifiers::CONTROL,
));
}
fn selected(tui: &MainTui) -> Option<usize> {
tui.state.results_table_state.selected()
}
#[test]
fn end_and_home_jump_to_the_last_and_first_result() {
let mut tui = results_tui(30);
press(&mut tui, KeyCode::End);
assert_eq!(selected(&tui), Some(29));
press(&mut tui, KeyCode::Home);
assert_eq!(selected(&tui), Some(0));
press(&mut tui, KeyCode::Char('G'));
assert_eq!(selected(&tui), Some(29));
press(&mut tui, KeyCode::Char('g'));
assert_eq!(selected(&tui), Some(0));
}
#[test]
fn page_keys_move_a_pane_of_rows_and_stop_at_the_edges() {
let mut tui = results_tui(30);
press(&mut tui, KeyCode::PageDown);
assert_eq!(selected(&tui), Some(10));
ctrl(&mut tui, 'f');
assert_eq!(selected(&tui), Some(20));
press(&mut tui, KeyCode::PageDown);
assert_eq!(selected(&tui), Some(29), "clamps instead of wrapping");
press(&mut tui, KeyCode::PageUp);
assert_eq!(selected(&tui), Some(19));
ctrl(&mut tui, 'b');
assert_eq!(selected(&tui), Some(9));
press(&mut tui, KeyCode::PageUp);
assert_eq!(selected(&tui), Some(0));
assert!(
!tui.state.show_browse && !tui.state.command_bar_active,
"ctrl-b pages up instead of opening browse"
);
}
#[test]
fn ctrl_d_and_ctrl_u_move_half_a_pane() {
let mut tui = results_tui(30);
ctrl(&mut tui, 'd');
assert_eq!(selected(&tui), Some(5));
ctrl(&mut tui, 'u');
assert_eq!(selected(&tui), Some(0));
}
#[test]
fn navigation_keys_do_nothing_without_results() {
let mut tui = results_tui(0);
tui.state.results_table_state.select(None);
press(&mut tui, KeyCode::End);
press(&mut tui, KeyCode::PageDown);
ctrl(&mut tui, 'd');
assert_eq!(selected(&tui), None);
}
#[test]
fn l_and_h_scroll_the_highlighted_name_up_to_its_end() {
let mut tui = results_tui(3);
tui.state.results_items[1].filename = "x".repeat(40);
tui.state.results_table_state.select(Some(1));
press(&mut tui, KeyCode::Char('l'));
assert_eq!(tui.state.results_name_offset, 8);
press(&mut tui, KeyCode::Right);
assert_eq!(tui.state.results_name_offset, 16);
press(&mut tui, KeyCode::Char('$'));
assert_eq!(tui.state.results_name_offset, 30);
press(&mut tui, KeyCode::Char('l'));
assert_eq!(tui.state.results_name_offset, 30, "stops at the end");
press(&mut tui, KeyCode::Char('h'));
assert_eq!(tui.state.results_name_offset, 22);
press(&mut tui, KeyCode::Left);
assert_eq!(tui.state.results_name_offset, 14);
press(&mut tui, KeyCode::Char('0'));
assert_eq!(tui.state.results_name_offset, 0);
press(&mut tui, KeyCode::Char('h'));
assert_eq!(tui.state.results_name_offset, 0);
}
#[test]
fn scrolling_follows_the_highlighted_row_not_the_longest_one() {
let mut tui = results_tui(3);
tui.state.results_items[0].filename = "short.mp3".to_string();
tui.state.results_items[1].filename = "x".repeat(40);
tui.state.results_items[2].filename = "short.mp3".to_string();
press(&mut tui, KeyCode::Char('$'));
assert_eq!(tui.state.results_name_offset, 0, "row 0 fits already");
tui.state.results_table_state.select(Some(1));
press(&mut tui, KeyCode::Char('$'));
assert_eq!(tui.state.results_name_offset, 30);
tui.state.results_table_state.select(Some(2));
press(&mut tui, KeyCode::Char('h'));
assert_eq!(
tui.state.results_name_offset, 0,
"a step left lands within the row now highlighted"
);
}
#[test]
fn picking_another_search_starts_its_results_unscrolled() {
let mut tui = results_tui(3);
tui.state.results_items[1].filename = "x".repeat(40);
tui.state.results_table_state.select(Some(1));
press(&mut tui, KeyCode::Char('$'));
assert_eq!(tui.state.results_name_offset, 30);
tui.state.searches.push(crate::models::SearchEntry {
query: "other".to_string(),
status: SearchStatus::Active,
results: results(2),
known_files: 2,
owned: true,
start_time: std::time::Instant::now(),
cancel_flag: Arc::new(std::sync::atomic::AtomicBool::new(false)),
});
tui.state.searches_table_state.select(Some(0));
tui.state.focused_pane = FocusedPane::Searches;
press(&mut tui, KeyCode::Enter);
assert_eq!(tui.state.results_name_offset, 0);
}
#[test]
fn ctrl_keys_are_ignored_outside_the_results_pane() {
let mut tui = results_tui(3);
tui.state.searches.push(crate::models::SearchEntry {
query: "keep me".to_string(),
status: SearchStatus::Active,
results: Vec::new(),
known_files: 0,
owned: true,
start_time: std::time::Instant::now(),
cancel_flag: Arc::new(std::sync::atomic::AtomicBool::new(false)),
});
tui.state.searches_table_state.select(Some(0));
tui.state.focused_pane = FocusedPane::Searches;
ctrl(&mut tui, 'd');
assert_eq!(tui.state.searches.len(), 1, "ctrl-d is not d");
ctrl(&mut tui, 'b');
assert!(!tui.state.command_bar_active, "ctrl-b is not b");
ctrl(&mut tui, 'q');
assert!(!tui.state.should_exit, "ctrl-q is not q");
}
#[test]
fn ctrl_keys_page_while_a_filter_is_being_typed() {
let mut tui = results_tui(30);
press(&mut tui, KeyCode::Char('/'));
press(&mut tui, KeyCode::Char('m'));
ctrl(&mut tui, 'd');
assert_eq!(selected(&tui), Some(5));
assert_eq!(tui.state.results_filter_query, "m", "no d appended");
press(&mut tui, KeyCode::End);
assert_eq!(selected(&tui), Some(29));
}
#[test]
fn space_after_a_filter_that_matches_nothing_does_not_panic() {
let mut tui = results_tui(3);
press(&mut tui, KeyCode::Char('/'));
press(&mut tui, KeyCode::Char('z'));
press(&mut tui, KeyCode::Enter);
assert!(tui.state.results_filtered_items.is_empty());
press(&mut tui, KeyCode::Char(' '));
assert!(tui.state.results_selected_indices.is_empty());
}
fn screen_of(tui: &mut MainTui) -> String {
let backend = ratatui::backend::TestBackend::new(160, 40);
let mut terminal = ratatui::Terminal::new(backend).expect("backend");
terminal.draw(|frame| tui.render(frame)).expect("draw");
terminal.backend().to_string()
}
#[test]
fn the_info_pane_describes_the_highlighted_result() {
let mut tui = results_tui(3);
tui.state.results_items[1].filename =
"@@x\\Music\\Album\\Second Track.mp3".to_string();
tui.state.results_items[1].bitrate = Some(320);
tui.state.results_table_state.select(Some(1));
let screen = screen_of(&mut tui);
assert!(screen.contains("@@x/Music/Album"), "{screen}");
assert!(screen.contains("Bitrate"), "{screen}");
tui.state.focused_pane = FocusedPane::Downloads;
let screen = screen_of(&mut tui);
assert!(!screen.contains("@@x/Music/Album"), "{screen}");
}
}
+18 -8
View File
@@ -1,7 +1,7 @@
use super::MainTui;
use crate::models::{CommandBarMode, FocusedPane, RoomsView};
use crate::ui::panes::{
ResultsPaneParams, render_browse_pane, render_chat_pane,
InfoSubject, ResultsPaneParams, render_browse_pane, render_chat_pane,
render_download_info_pane, render_downloads_pane, render_results_pane,
render_rooms_pane, render_searches_pane, selected_transfer,
};
@@ -120,6 +120,7 @@ impl MainTui {
is_filtering: self.state.results_is_filtering,
focused: self.state.focused_pane == FocusedPane::Results,
active_search_query,
name_offset: self.state.results_name_offset,
},
);
@@ -132,16 +133,23 @@ impl MainTui {
self.state.focused_pane == FocusedPane::Downloads,
);
let selected_transfer = selected_transfer(
self.state.downloads_table_state.selected(),
&self.state.downloads,
&self.state.uploads,
);
let selected = if self.state.focused_pane == FocusedPane::Results {
self.highlighted_result().map(InfoSubject::Result)
} else {
selected_transfer(
self.state.downloads_table_state.selected(),
&self.state.downloads,
&self.state.uploads,
)
};
render_download_info_pane(
frame,
downloads_chunks[1],
selected_transfer,
self.state.focused_pane == FocusedPane::Downloads,
selected,
matches!(
self.state.focused_pane,
FocusedPane::Results | FocusedPane::Downloads
),
);
}
@@ -386,6 +394,8 @@ impl MainTui {
("c", "chat"),
("/", "filter"),
("a/A", "select all/none"),
("h/l", "scroll"),
("g/G", "top/end"),
("1-3", "focus pane"),
("q", "quit"),
],
+23
View File
@@ -100,6 +100,28 @@ impl MainTui {
self.state.results_table_state.select(None);
self.state.results_filter_query.clear();
self.state.results_is_filtering = false;
self.state.results_name_offset = 0;
}
pub(super) fn visible_results(&self) -> &[FileDisplayData] {
if self.state.results_filter_query.is_empty() {
&self.state.results_items
} else {
&self.state.results_filtered_items
}
}
pub(super) fn highlighted_result(&self) -> Option<&FileDisplayData> {
self.visible_results()
.get(self.state.results_table_state.selected()?)
}
pub(super) fn original_index(&self, row: usize) -> Option<usize> {
if self.state.results_filter_query.is_empty() {
Some(row)
} else {
self.state.results_filtered_indices.get(row).copied()
}
}
pub(super) fn apply_filter(&mut self) {
@@ -207,6 +229,7 @@ impl MainTui {
self.state.results_filtered_indices.clear();
self.state.results_selected_indices.clear();
self.state.results_table_state.select(Some(0));
self.state.results_name_offset = 0;
// Switch focus to Results pane
self.state.focused_pane = FocusedPane::Results;