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:
@@ -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 => {
|
||||
|
||||
@@ -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));
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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());
|
||||
|
||||
@@ -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());
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user