Files
soulseek-rs-modded/soulseek-rs/src/ui/main_tui/input.rs
T
Michel de Graaf 0cacc8cc10 refactor(tui): one page window and one filter editor for every list
Four panes had grown the same dance around ratatui's table: clamp the
selection, take the visible page, hand the table a page-local state,
keep the page start. It is now page_window and render_page in a paging
module beside page_of, and the two popups page by the same count they
draw, tab bar or header included.

Five typed filters had grown the same key handling too. edit_filter now
takes a key for all of them, which also stops a control or alt chord
from typing its letter into a filter, stops Enter, which keeps a filter,
from resetting the view it is over, and lets the results and room-list
filters page like the others. Their titles and the typing shortcuts
come from one place as well.
2026-09-08 11:24:59 +02:00

710 lines
26 KiB
Rust

use super::MainTui;
use crate::models::{CommandBarMode, FocusedPane, LogView, PaneLayout};
use crate::ui::page_of;
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,
};
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
if self.state.command_bar_active {
return self.handle_command_bar_input(key);
}
// The keys list closes on the key that opened it, or the usual two,
// and scrolls like any log for a terminal too short to show it all.
if self.state.show_help {
let view = &mut self.state.help_view;
match key.code {
KeyCode::Char('?' | 'q') | KeyCode::Esc => {
self.state.show_help = false;
}
KeyCode::Down | KeyCode::Char('j') => view.scroll(1),
KeyCode::Up | KeyCode::Char('k') => view.scroll(-1),
_ => {
scroll_log(view, key);
}
}
return;
}
// Chat popup takes over navigation while open.
if self.state.show_messages {
return self.handle_chat_input(key);
}
// An open popup owns the keyboard, so these come before pane routing.
if self.state.show_browse {
return self.handle_browse_input(key);
}
if self.state.show_rooms {
return self.handle_rooms_input(key);
}
if self.state.settings.is_some() {
return self.handle_settings_input(key);
}
// Control combinations only ever page a list, so they never reach
// the letter keys below: ctrl-d is not d.
if key.modifiers.contains(KeyModifiers::CONTROL) {
self.navigate_focused_list(key);
return;
}
// Filter mode in Results pane
if self.state.results_is_filtering
&& self.state.focused_pane == FocusedPane::Results
{
return self.handle_filter_input(key);
}
// Global shortcuts
match key.code {
KeyCode::Char('q') => {
self.state.should_exit = true;
return;
}
KeyCode::Char('?') => {
self.state.show_help = true;
self.state.help_view.to_oldest();
return;
}
// A pane's number is its place in the legend order.
KeyCode::Char(digit @ '1'..='3') => {
let index = usize::from(digit as u8 - b'1');
self.state.focus_pane(FocusedPane::ALL[index]);
return;
}
KeyCode::Tab => {
self.state.cycle_focus(true);
return;
}
KeyCode::BackTab => {
self.state.cycle_focus(false);
return;
}
KeyCode::Char('z') => {
self.state.toggle_zoom();
return;
}
KeyCode::Char('w') => {
self.state.hide_focused_pane();
return;
}
KeyCode::Char('W') => {
self.state.layout = PaneLayout::default();
return;
}
KeyCode::Esc if self.state.layout.zoomed => {
self.state.layout.zoomed = false;
return;
}
KeyCode::Char('s') => {
self.state.command_bar_active = true;
self.state.command_bar_mode = CommandBarMode::Search;
self.state.command_bar_input.clear();
self.state.command_bar_cursor_position = 0;
return;
}
KeyCode::Char('m') => {
self.state.command_bar_active = true;
self.state.command_bar_mode = CommandBarMode::Message;
self.state.command_bar_input.clear();
self.state.command_bar_cursor_position = 0;
return;
}
KeyCode::Char('i') => {
self.state.show_messages = true;
self.state.unread_messages = 0;
return;
}
// Chat rooms. In the Downloads pane `c` clears finished downloads,
// so only open chat from the other panes (like `b` is contextual).
KeyCode::Char('c')
if self.state.focused_pane != FocusedPane::Downloads =>
{
self.start_rooms();
return;
}
KeyCode::Char('o') => {
self.open_settings();
return;
}
KeyCode::Char('b') => {
// From a highlighted search result, browse its owner directly;
// otherwise prompt for a username.
if self.state.focused_pane == FocusedPane::Results
&& let Some(owner) = self.highlighted_result_owner()
{
self.start_browse(owner);
} else {
self.state.command_bar_active = true;
self.state.command_bar_mode = CommandBarMode::Browse;
self.state.command_bar_input.clear();
self.state.command_bar_cursor_position = 0;
}
return;
}
_ => {}
}
// Pane-specific shortcuts
match self.state.focused_pane {
FocusedPane::Searches => self.handle_searches_input(key),
FocusedPane::Results => self.handle_results_input(key),
FocusedPane::Downloads => self.handle_downloads_input(key),
}
}
/// Chat popup: composing captures typing, otherwise these are navigation
/// keys over the conversation list.
fn handle_chat_input(&mut self, key: KeyEvent) {
if self.state.chat_composing {
match key.code {
KeyCode::Enter => self.send_chat_message(),
KeyCode::Esc => {
self.state.chat_composing = false;
self.state.chat_input.clear();
}
KeyCode::Backspace => {
self.state.chat_input.pop();
}
KeyCode::Char(c)
if !key.modifiers.intersects(
KeyModifiers::CONTROL | KeyModifiers::ALT,
) =>
{
self.state.chat_input.push(c);
}
_ => {}
}
return;
}
// Typing a filter captures the keys that edit it; the rest still
// move around.
if self.state.chat_filtering
&& let Some(edit) = edit_filter(&mut self.state.chat_filter, key)
{
if edit != FilterEdit::Changed {
self.state.chat_filtering = false;
}
if edit != FilterEdit::Kept {
self.state.chat_view.to_newest();
}
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 {
// Esc peels back one level: a filter first, then the popup.
KeyCode::Esc if !self.state.chat_filter.is_empty() => {
self.state.chat_filter.clear();
self.state.chat_view.to_newest();
}
KeyCode::Char('i' | 'q') | KeyCode::Esc => {
self.state.show_messages = false;
}
KeyCode::Char('/') => self.state.chat_filtering = true,
// Move through the conversation list on the right.
KeyCode::Tab | KeyCode::Down | KeyCode::Char('j') => {
self.state.cycle_chat_peer(true);
}
KeyCode::BackTab | KeyCode::Up | KeyCode::Char('k') => {
self.state.cycle_chat_peer(false);
}
KeyCode::Char('m') => {
self.state.show_messages = false;
self.state.command_bar_active = true;
self.state.command_bar_mode = CommandBarMode::Message;
self.state.command_bar_input.clear();
self.state.command_bar_cursor_position = 0;
}
KeyCode::Enter if self.state.active_chat_peer().is_some() => {
self.state.chat_composing = true;
}
_ => {}
}
}
fn handle_command_bar_input(&mut self, key: KeyEvent) {
self.state.command_bar_cursor_position = self
.state
.command_bar_input
.floor_char_boundary(self.state.command_bar_cursor_position);
match key.code {
KeyCode::Enter => {
let input = self.state.command_bar_input.trim().to_string();
if !input.is_empty() {
match self.state.command_bar_mode {
CommandBarMode::Search => self.start_search(input),
CommandBarMode::Message => {
self.send_message_from_input(&input);
}
CommandBarMode::Browse => self.start_browse(input),
}
}
self.state.command_bar_active = false;
self.state.command_bar_input.clear();
self.state.command_bar_cursor_position = 0;
}
KeyCode::Esc => {
self.state.command_bar_active = false;
self.state.command_bar_input.clear();
self.state.command_bar_cursor_position = 0;
}
KeyCode::Backspace => {
let cursor_position = self.state.command_bar_cursor_position;
if cursor_position > 0 {
let previous_position = self
.state
.command_bar_input
.floor_char_boundary(cursor_position.saturating_sub(1));
self.state
.command_bar_input
.drain(previous_position..cursor_position);
self.state.command_bar_cursor_position = previous_position;
}
}
KeyCode::Delete => {
let cursor_position = self.state.command_bar_cursor_position;
if cursor_position < self.state.command_bar_input.len() {
let next_position = self
.state
.command_bar_input
.ceil_char_boundary(cursor_position + 1);
self.state
.command_bar_input
.drain(cursor_position..next_position);
}
}
KeyCode::Left => {
let previous =
self.state.command_bar_cursor_position.saturating_sub(1);
self.state.command_bar_cursor_position =
self.state.command_bar_input.floor_char_boundary(previous);
}
KeyCode::Right => {
let input = &self.state.command_bar_input;
let next = self.state.command_bar_cursor_position + 1;
self.state.command_bar_cursor_position =
input.ceil_char_boundary(next.min(input.len()));
}
KeyCode::Home => {
self.state.command_bar_cursor_position = 0;
}
KeyCode::End => {
self.state.command_bar_cursor_position =
self.state.command_bar_input.len();
}
KeyCode::Char('a')
if key.modifiers.contains(KeyModifiers::CONTROL) =>
{
self.state.command_bar_cursor_position = 0;
}
KeyCode::Char('e')
if key.modifiers.contains(KeyModifiers::CONTROL) =>
{
self.state.command_bar_cursor_position =
self.state.command_bar_input.len();
}
KeyCode::Char('u')
if key.modifiers.contains(KeyModifiers::CONTROL) =>
{
self.state.command_bar_input.clear();
self.state.command_bar_cursor_position = 0;
}
KeyCode::Char(c)
if !key.modifiers.intersects(
KeyModifiers::CONTROL
| KeyModifiers::ALT
| KeyModifiers::SUPER
| KeyModifiers::HYPER
| KeyModifiers::META,
) =>
{
let cursor_position = self.state.command_bar_cursor_position;
self.state.command_bar_input.insert(cursor_position, c);
self.state.command_bar_cursor_position =
cursor_position + c.len_utf8();
}
_ => {}
}
}
fn handle_filter_input(&mut self, key: KeyEvent) {
let Some(edit) = edit_filter(&mut self.state.results_filter_query, key)
else {
return self.handle_results_input(key);
};
// A kept query stays: the normal Results keys act on the filtered
// list. An empty one shows everything without a rebuild.
if edit == FilterEdit::Changed {
self.apply_filter();
} else {
self.state.results_is_filtering = false;
}
}
/// The list the focus is on and how far one page moves it.
fn navigate_focused_list(&mut self, key: KeyEvent) -> bool {
let focused = self.state.focused_pane;
let page = self.page_size();
let (table, len) = match focused {
FocusedPane::Searches => (
&mut self.state.searches_table_state,
self.state.searches.len(),
),
FocusedPane::Results => {
let len = self.visible_results().len();
(&mut self.state.results_table_state, len)
}
FocusedPane::Downloads => (
&mut self.state.downloads_table_state,
self.state.downloads.len() + self.state.uploads.len(),
),
};
navigate_list(key, table, len, page)
}
/// Rows a popup's list shows at once: its inner height less the `chrome`
/// rows above the list, a tab bar or a header.
pub(super) fn popup_page(&self, chrome: u16) -> usize {
page_of(self.state.popup_area, chrome)
}
/// Rows the focused pane shows at once: its inner height less the table
/// header.
fn page_size(&self) -> usize {
page_of(self.state.pane_area(self.state.focused_pane), 1)
}
fn handle_searches_input(&mut self, key: KeyEvent) {
if self.navigate_focused_list(key) || self.scroll_focused_name(key) {
return;
}
match key.code {
KeyCode::Enter => {
if let Some(selected) =
self.state.searches_table_state.selected()
{
self.state.selected_search_index = Some(selected);
if let Some(search) = self.state.searches.get(selected) {
self.state.results_items = search.results.clone();
self.state.results_filtered_items =
search.results.clone();
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.focus_pane(FocusedPane::Results);
}
}
}
KeyCode::Char('d') => {
if let Some(selected) =
self.state.searches_table_state.selected()
{
self.remove_search_at_index(selected);
}
}
KeyCode::Char('C') => {
self.clear_all_searches();
}
_ => {}
}
}
fn handle_results_input(&mut self, key: KeyEvent) {
if self.navigate_focused_list(key) || self.scroll_focused_name(key) {
return;
}
match key.code {
KeyCode::Char(' ') => {
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)
{
self.state.results_selected_indices.insert(index);
}
}
KeyCode::Char('/') => self.state.results_is_filtering = true,
KeyCode::Char('a') => {
let indices: Vec<usize> =
if self.state.results_filter_query.is_empty() {
(0..self.state.results_items.len()).collect()
} else {
self.state.results_filtered_indices.clone()
};
self.state.results_selected_indices.extend(indices);
}
KeyCode::Char('A') => {
self.state.results_selected_indices.clear();
}
KeyCode::Enter => {
self.queue_selected_downloads();
}
_ => {}
}
}
/// 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 pane = self.state.focused_pane;
let Some(area) = self.state.pane_area(pane) else {
return 0;
};
match pane {
FocusedPane::Searches => 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 => self
.highlighted_result()
.map_or(0, |file| name_end_offset(&file.filename, area)),
FocusedPane::Downloads => {
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) || self.scroll_focused_name(key) {
return;
}
match key.code {
KeyCode::Char('x') => {
self.cancel_selected_transfer();
}
KeyCode::Char('p') => {
self.toggle_selected_download_pause();
}
KeyCode::Char('d') => {
self.remove_selected_download();
}
KeyCode::Char('r') => {
self.retry_selected_download();
}
KeyCode::Char('c') => {
self.clear_finished_downloads();
}
_ => {}
}
}
pub(super) fn handle_mouse_event(&mut self, mouse: MouseEvent) {
if mouse.kind != MouseEventKind::Down(MouseButton::Left) {
return;
}
let clicked = Position::new(mouse.column, mouse.row);
// The areas are from the last draw. A pane hidden since then, in the
// same batch of events, still has one, and is not there to click.
let hit = FocusedPane::ALL.into_iter().find(|pane| {
self.state.layout.is_visible(*pane)
&& self
.state
.pane_area(*pane)
.is_some_and(|area| area.contains(clicked))
});
if let Some(pane) = hit {
self.state.focused_pane = pane;
}
}
}
/// What a key did to a filter being typed.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub(super) enum FilterEdit {
Changed,
Kept,
Cleared,
}
/// Apply `key` to `filter` the way every typed filter takes it: characters
/// and Backspace edit it, Enter keeps it, Esc clears it. A control or alt
/// chord is not typing. `None` when the key was none of those.
pub(super) fn edit_filter(
filter: &mut String,
key: KeyEvent,
) -> Option<FilterEdit> {
match key.code {
KeyCode::Esc => {
filter.clear();
Some(FilterEdit::Cleared)
}
KeyCode::Enter => Some(FilterEdit::Kept),
KeyCode::Backspace => {
filter.pop();
Some(FilterEdit::Changed)
}
KeyCode::Char(c)
if !key
.modifiers
.intersects(KeyModifiers::CONTROL | KeyModifiers::ALT) =>
{
filter.push(c);
Some(FilterEdit::Changed)
}
_ => None,
}
}
/// 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: usize) -> Option<ListJump> {
let ctrl = key.modifiers.contains(KeyModifiers::CONTROL);
let page = isize::try_from(page).unwrap_or(isize::MAX);
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 {
match list_jump(key, view.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,
len: usize,
page: usize,
) -> bool {
let ctrl = key.modifiers.contains(KeyModifiers::CONTROL);
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),
_ => {
let Some(jump) = list_jump(key, page) else {
return false;
};
if len > 0 {
let current = table.selected().unwrap_or(0);
table.select(Some(jumped(current, len, jump)));
}
}
}
true
}
/// Move a table's selection one row, wrapping at either end.
fn cycle(table: &mut TableState, len: usize, forward: bool) {
if len == 0 {
return;
}
let current = table.selected().unwrap_or(0);
let next = if forward {
(current + 1) % len
} else {
(current + len - 1) % len
};
table.select(Some(next));
}