Files
soulseek-rs-modded/soulseek-rs/src/ui/main_tui/input.rs
T
Michel de Graaf 3193c73f5b 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.
2026-09-06 20:10:59 +02:00

519 lines
19 KiB
Rust

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
if self.state.command_bar_active {
return self.handle_command_bar_input(key);
}
// 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);
}
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
{
return self.handle_filter_input(key);
}
// Global shortcuts
match key.code {
KeyCode::Char('q') => {
self.state.should_exit = true;
return;
}
KeyCode::Char('1') => {
self.state.focused_pane = FocusedPane::Searches;
return;
}
KeyCode::Char('2') => {
self.state.focused_pane = FocusedPane::Results;
return;
}
KeyCode::Char('3') => {
self.state.focused_pane = FocusedPane::Downloads;
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;
}
match key.code {
KeyCode::Char('i' | 'q') | KeyCode::Esc => {
self.state.show_messages = false;
}
// 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) {
match key.code {
KeyCode::Esc => {
self.state.results_is_filtering = false;
self.state.results_filter_query.clear();
self.state.results_filtered_items =
self.state.results_items.clone();
self.state.results_filtered_indices =
(0..self.state.results_items.len()).collect();
}
KeyCode::Char(c) => {
self.state.results_filter_query.push(c);
self.apply_filter();
}
KeyCode::Backspace => {
self.state.results_filter_query.pop();
self.apply_filter();
}
// Confirm the filter: leave typing mode but keep the query, so
// the normal Results keys (j/k, space, enter) act on the
// filtered list.
KeyCode::Enter => {
self.state.results_is_filtering = false;
}
_ => self.handle_results_input(key),
}
}
fn handle_searches_input(&mut self, key: KeyEvent) {
let rows = self.state.searches.len();
match key.code {
KeyCode::Up | KeyCode::Char('k') => {
cycle(&mut self.state.searches_table_state, rows, false);
}
KeyCode::Down | KeyCode::Char('j') => {
cycle(&mut self.state.searches_table_state, rows, true);
}
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.focused_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) {
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') => {
cycle(&mut self.state.results_table_state, items_count, false);
}
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(' ') => {
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;
self.state.results_filter_query.clear();
}
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();
}
_ => {}
}
}
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();
match key.code {
KeyCode::Up | KeyCode::Char('k') => {
cycle(&mut self.state.downloads_table_state, rows, false);
}
KeyCode::Down | KeyCode::Char('j') => {
cycle(&mut self.state.downloads_table_state, rows, true);
}
KeyCode::Char('x') => {
self.cancel_selected_upload();
}
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);
let panes = [
(self.state.searches_pane_area, FocusedPane::Searches),
(self.state.results_pane_area, FocusedPane::Results),
(self.state.downloads_pane_area, FocusedPane::Downloads),
];
for (area, pane) in panes {
if area.is_some_and(|area| area.contains(clicked)) {
self.state.focused_pane = pane;
return;
}
}
}
}
/// 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));
}