- replace hand-rolled clamp_cursor_to_char_boundary with str::floor_char_boundary at its four call sites, drop the helper - dedupe the duplicated Layout::vertical constraint arms in render() - drop stale #[allow(dead_code)] on spinner_state (field is read)
462 lines
17 KiB
Rust
462 lines
17 KiB
Rust
use super::MainTui;
|
|
use crate::models::{CommandBarMode, FocusedPane};
|
|
use ratatui::crossterm::event::{
|
|
KeyCode, KeyEvent, KeyModifiers, MouseButton, MouseEvent, MouseEventKind,
|
|
};
|
|
|
|
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);
|
|
}
|
|
|
|
// Messages popup: any of i/Esc/q closes it.
|
|
if self.state.show_messages {
|
|
if matches!(key.code, KeyCode::Char('i' | 'q') | KeyCode::Esc) {
|
|
self.state.show_messages = false;
|
|
}
|
|
return;
|
|
}
|
|
|
|
// Browse popup takes over navigation while open.
|
|
if self.state.show_browse {
|
|
return self.handle_browse_input(key);
|
|
}
|
|
|
|
// Rooms popup takes over navigation while open.
|
|
if self.state.show_rooms {
|
|
return self.handle_rooms_input(key);
|
|
}
|
|
|
|
// 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('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),
|
|
}
|
|
}
|
|
|
|
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 = previous_char_boundary(
|
|
&self.state.command_bar_input,
|
|
cursor_position,
|
|
);
|
|
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 = next_char_boundary(
|
|
&self.state.command_bar_input,
|
|
cursor_position,
|
|
);
|
|
self.state
|
|
.command_bar_input
|
|
.drain(cursor_position..next_position);
|
|
}
|
|
}
|
|
KeyCode::Left => {
|
|
self.state.command_bar_cursor_position = previous_char_boundary(
|
|
&self.state.command_bar_input,
|
|
self.state.command_bar_cursor_position,
|
|
);
|
|
}
|
|
KeyCode::Right => {
|
|
self.state.command_bar_cursor_position = next_char_boundary(
|
|
&self.state.command_bar_input,
|
|
self.state.command_bar_cursor_position,
|
|
);
|
|
}
|
|
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();
|
|
}
|
|
_ => {}
|
|
}
|
|
}
|
|
|
|
fn handle_searches_input(&mut self, key: KeyEvent) {
|
|
match key.code {
|
|
KeyCode::Up | KeyCode::Char('k')
|
|
if !self.state.searches.is_empty() =>
|
|
{
|
|
let current =
|
|
self.state.searches_table_state.selected().unwrap_or(0);
|
|
let new = if current == 0 {
|
|
self.state.searches.len() - 1
|
|
} else {
|
|
current - 1
|
|
};
|
|
self.state.searches_table_state.select(Some(new));
|
|
}
|
|
KeyCode::Down | KeyCode::Char('j')
|
|
if !self.state.searches.is_empty() =>
|
|
{
|
|
let current =
|
|
self.state.searches_table_state.selected().unwrap_or(0);
|
|
let new = (current + 1) % self.state.searches.len();
|
|
self.state.searches_table_state.select(Some(new));
|
|
}
|
|
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_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 items_count = if self.state.results_filter_query.is_empty() {
|
|
self.state.results_items.len()
|
|
} else {
|
|
self.state.results_filtered_items.len()
|
|
};
|
|
|
|
match key.code {
|
|
KeyCode::Up | KeyCode::Char('k') if items_count > 0 => {
|
|
let current =
|
|
self.state.results_table_state.selected().unwrap_or(0);
|
|
let new = if current == 0 {
|
|
items_count - 1
|
|
} else {
|
|
current - 1
|
|
};
|
|
self.state.results_table_state.select(Some(new));
|
|
}
|
|
KeyCode::Down | KeyCode::Char('j') if items_count > 0 => {
|
|
let current =
|
|
self.state.results_table_state.selected().unwrap_or(0);
|
|
let new = (current + 1) % items_count;
|
|
self.state.results_table_state.select(Some(new));
|
|
}
|
|
KeyCode::Char(' ') => {
|
|
if let Some(current) = self.state.results_table_state.selected()
|
|
{
|
|
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);
|
|
}
|
|
}
|
|
}
|
|
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 handle_downloads_input(&mut self, key: KeyEvent) {
|
|
match key.code {
|
|
KeyCode::Up | KeyCode::Char('k')
|
|
if !self.state.downloads.is_empty() =>
|
|
{
|
|
let current =
|
|
self.state.downloads_table_state.selected().unwrap_or(0);
|
|
let new = if current == 0 {
|
|
self.state.downloads.len() - 1
|
|
} else {
|
|
current - 1
|
|
};
|
|
self.state.downloads_table_state.select(Some(new));
|
|
}
|
|
KeyCode::Down | KeyCode::Char('j')
|
|
if !self.state.downloads.is_empty() =>
|
|
{
|
|
let current =
|
|
self.state.downloads_table_state.selected().unwrap_or(0);
|
|
let new = (current + 1) % self.state.downloads.len();
|
|
self.state.downloads_table_state.select(Some(new));
|
|
}
|
|
KeyCode::Char('p') => {
|
|
self.toggle_selected_download_pause();
|
|
}
|
|
KeyCode::Char('d') => {
|
|
self.remove_selected_queued_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) {
|
|
let (col, row) = (mouse.column, mouse.row);
|
|
|
|
// Check if click is within searches pane
|
|
if let Some(area) = self.state.searches_pane_area
|
|
&& col >= area.x
|
|
&& col < area.x + area.width
|
|
&& row >= area.y
|
|
&& row < area.y + area.height
|
|
{
|
|
self.state.focused_pane = FocusedPane::Searches;
|
|
return;
|
|
}
|
|
|
|
// Check if click is within results pane
|
|
if let Some(area) = self.state.results_pane_area
|
|
&& col >= area.x
|
|
&& col < area.x + area.width
|
|
&& row >= area.y
|
|
&& row < area.y + area.height
|
|
{
|
|
self.state.focused_pane = FocusedPane::Results;
|
|
return;
|
|
}
|
|
|
|
// Check if click is within downloads pane
|
|
if let Some(area) = self.state.downloads_pane_area
|
|
&& col >= area.x
|
|
&& col < area.x + area.width
|
|
&& row >= area.y
|
|
&& row < area.y + area.height
|
|
{
|
|
self.state.focused_pane = FocusedPane::Downloads;
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
fn previous_char_boundary(input: &str, cursor_position: usize) -> usize {
|
|
let cursor_position = input.floor_char_boundary(cursor_position);
|
|
|
|
input[..cursor_position]
|
|
.char_indices()
|
|
.last()
|
|
.map_or(0, |(index, _)| index)
|
|
}
|
|
|
|
fn next_char_boundary(input: &str, cursor_position: usize) -> usize {
|
|
let cursor_position = input.floor_char_boundary(cursor_position);
|
|
|
|
if cursor_position >= input.len() {
|
|
return input.len();
|
|
}
|
|
|
|
cursor_position
|
|
+ input[cursor_position..]
|
|
.chars()
|
|
.next()
|
|
.map_or(0, char::len_utf8)
|
|
}
|