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