refactor: split main_input.rs into modular handlers (1199→164 lines)

Split monolithic input handler into 5 specialized modules:
- handlers/chat.rs (452 lines) - chat keyboard input
- handlers/modal.rs (316 lines) - modal dialogs
- handlers/chat_list.rs (142 lines) - chat list navigation
- handlers/search.rs (140 lines) - search functionality
- handlers/compose.rs (80 lines) - forward/reply/edit modes

Changes:
- main_input.rs: 1199→164 lines (removed 1035 lines, -86%)
- Preserved existing handlers: clipboard, global, profile
- Created clean router pattern in main_input.rs
- Fixed keybinding conflict: Ctrl+I→Ctrl+U for profile
- Fixed modifier handling in chat input (ignore Ctrl/Alt chars)
- Updated CONTEXT.md with refactoring metrics
- Updated ROADMAP.md: Phase 13 Etap 1 marked as DONE

Phase 13 Etap 1: COMPLETED (100%)

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
This commit is contained in:
Mikhail Kilin
2026-02-06 00:43:52 +03:00
parent c5235de6e2
commit 1d0bfb53e0
10 changed files with 1228 additions and 1072 deletions

460
src/input/handlers/chat.rs Normal file
View File

@@ -0,0 +1,460 @@
//! Chat input handlers
//!
//! Handles keyboard input when a chat is open, including:
//! - Message scrolling and navigation
//! - Message selection and actions
//! - Editing and sending messages
//! - Loading older messages
use crate::app::App;
use crate::tdlib::{TdClientTrait, ChatAction, ReplyInfo};
use crate::types::{ChatId, MessageId};
use crate::utils::{is_non_empty, with_timeout, with_timeout_msg};
use crate::input::handlers::{copy_to_clipboard, format_message_for_clipboard};
use super::chat_list::open_chat_and_load_data;
use crossterm::event::{KeyCode, KeyEvent, KeyModifiers};
use std::time::{Duration, Instant};
/// Обработка режима выбора сообщения для действий
///
/// Обрабатывает:
/// - Навигацию по сообщениям (Up/Down)
/// - Удаление сообщения (d/в/Delete)
/// - Ответ на сообщение (r/к)
/// - Пересылку сообщения (f/а)
/// - Копирование сообщения (y/н)
/// - Добавление реакции (e/у)
pub async fn handle_message_selection<T: TdClientTrait>(app: &mut App<T>, _key: KeyEvent, command: Option<crate::config::Command>) {
match command {
Some(crate::config::Command::MoveUp) => {
app.select_previous_message();
}
Some(crate::config::Command::MoveDown) => {
app.select_next_message();
}
Some(crate::config::Command::DeleteMessage) => {
let Some(msg) = app.get_selected_message() else {
return;
};
let can_delete =
msg.can_be_deleted_only_for_self() || msg.can_be_deleted_for_all_users();
if can_delete {
app.chat_state = crate::app::ChatState::DeleteConfirmation {
message_id: msg.id(),
};
}
}
Some(crate::config::Command::ReplyMessage) => {
app.start_reply_to_selected();
}
Some(crate::config::Command::ForwardMessage) => {
app.start_forward_selected();
}
Some(crate::config::Command::CopyMessage) => {
let Some(msg) = app.get_selected_message() else {
return;
};
let text = format_message_for_clipboard(&msg);
match copy_to_clipboard(&text) {
Ok(_) => {
app.status_message = Some("Сообщение скопировано".to_string());
}
Err(e) => {
app.error_message = Some(format!("Ошибка копирования: {}", e));
}
}
}
Some(crate::config::Command::ReactMessage) => {
let Some(msg) = app.get_selected_message() else {
return;
};
let chat_id = app.selected_chat_id.unwrap();
let message_id = msg.id();
app.status_message = Some("Загрузка реакций...".to_string());
app.needs_redraw = true;
match with_timeout_msg(
Duration::from_secs(5),
app.td_client
.get_message_available_reactions(chat_id, message_id),
"Таймаут загрузки реакций",
)
.await
{
Ok(reactions) => {
let reactions: Vec<String> = reactions;
if reactions.is_empty() {
app.error_message =
Some("Реакции недоступны для этого сообщения".to_string());
app.status_message = None;
app.needs_redraw = true;
} else {
app.enter_reaction_picker_mode(message_id.as_i64(), reactions);
app.status_message = None;
app.needs_redraw = true;
}
}
Err(e) => {
app.error_message = Some(e);
app.status_message = None;
app.needs_redraw = true;
}
}
}
_ => {}
}
}
/// Редактирование существующего сообщения
pub async fn edit_message<T: TdClientTrait>(app: &mut App<T>, chat_id: i64, msg_id: MessageId, text: String) {
// Проверяем, что сообщение есть в локальном кэше
let msg_exists = app.td_client.current_chat_messages()
.iter()
.any(|m| m.id() == msg_id);
if !msg_exists {
app.error_message = Some(format!(
"Сообщение {} не найдено в кэше чата {}",
msg_id.as_i64(), chat_id
));
app.chat_state = crate::app::ChatState::Normal;
app.message_input.clear();
app.cursor_position = 0;
return;
}
match with_timeout_msg(
Duration::from_secs(5),
app.td_client.edit_message(ChatId::new(chat_id), msg_id, text),
"Таймаут редактирования",
)
.await
{
Ok(mut edited_msg) => {
// Сохраняем reply_to из старого сообщения (если есть)
let messages = app.td_client.current_chat_messages_mut();
if let Some(pos) = messages.iter().position(|m| m.id() == msg_id) {
let old_reply_to = messages[pos].interactions.reply_to.clone();
// Если в старом сообщении был reply и в новом он "Unknown" - сохраняем старый
if let Some(old_reply) = old_reply_to {
if edited_msg.interactions.reply_to.as_ref()
.map_or(true, |r| r.sender_name == "Unknown") {
edited_msg.interactions.reply_to = Some(old_reply);
}
}
// Заменяем сообщение
messages[pos] = edited_msg;
}
// Очищаем инпут и сбрасываем состояние ПОСЛЕ успешного редактирования
app.message_input.clear();
app.cursor_position = 0;
app.chat_state = crate::app::ChatState::Normal;
app.needs_redraw = true;
}
Err(e) => {
app.error_message = Some(e);
}
}
}
/// Отправка нового сообщения (с опциональным reply)
pub async fn send_new_message<T: TdClientTrait>(app: &mut App<T>, chat_id: i64, text: String) {
let reply_to_id = if app.is_replying() {
app.chat_state.selected_message_id()
} else {
None
};
// Создаём ReplyInfo ДО отправки, пока сообщение точно доступно
let reply_info = app.get_replying_to_message().map(|m| {
crate::tdlib::ReplyInfo {
message_id: m.id(),
sender_name: m.sender_name().to_string(),
text: m.text().to_string(),
}
});
app.message_input.clear();
app.cursor_position = 0;
// Сбрасываем режим reply если он был активен
if app.is_replying() {
app.chat_state = crate::app::ChatState::Normal;
}
app.last_typing_sent = None;
// Отменяем typing status
app.td_client.send_chat_action(ChatId::new(chat_id), ChatAction::Cancel).await;
match with_timeout_msg(
Duration::from_secs(5),
app.td_client.send_message(ChatId::new(chat_id), text, reply_to_id, reply_info),
"Таймаут отправки",
)
.await
{
Ok(sent_msg) => {
// Добавляем отправленное сообщение в список (с лимитом)
app.td_client.push_message(sent_msg);
// Сбрасываем скролл чтобы видеть новое сообщение
app.message_scroll_offset = 0;
}
Err(e) => {
app.error_message = Some(e);
}
}
}
/// Обработка клавиши Enter
///
/// Обрабатывает три сценария:
/// 1. В режиме выбора сообщения: начать редактирование
/// 2. В открытом чате: отправить новое или редактировать существующее сообщение
/// 3. В списке чатов: открыть выбранный чат
pub async fn handle_enter_key<T: TdClientTrait>(app: &mut App<T>) {
// Сценарий 1: Открытие чата из списка
if app.selected_chat_id.is_none() {
let prev_selected = app.selected_chat_id;
app.select_current_chat();
if app.selected_chat_id != prev_selected {
if let Some(chat_id) = app.get_selected_chat_id() {
open_chat_and_load_data(app, chat_id).await;
}
}
return;
}
// Сценарий 2: Режим выбора сообщения - начать редактирование
if app.is_selecting_message() {
if !app.start_editing_selected() {
// Нельзя редактировать это сообщение
app.chat_state = crate::app::ChatState::Normal;
}
return;
}
// Сценарий 3: Отправка или редактирование сообщения
if !is_non_empty(&app.message_input) {
return;
}
let Some(chat_id) = app.get_selected_chat_id() else {
return;
};
let text = app.message_input.clone();
if app.is_editing() {
// Редактирование существующего сообщения
if let Some(msg_id) = app.chat_state.selected_message_id() {
edit_message(app, chat_id, msg_id, text).await;
}
} else {
// Отправка нового сообщения
send_new_message(app, chat_id, text).await;
}
}
/// Отправляет реакцию на выбранное сообщение
pub async fn send_reaction<T: TdClientTrait>(app: &mut App<T>) {
// Get selected reaction emoji
let Some(emoji) = app.get_selected_reaction().cloned() else {
return;
};
// Get selected message ID
let Some(message_id) = app.get_selected_message_for_reaction() else {
return;
};
// Get chat ID
let Some(chat_id) = app.selected_chat_id else {
return;
};
let message_id = MessageId::new(message_id);
app.status_message = Some("Отправка реакции...".to_string());
app.needs_redraw = true;
// Send reaction with timeout
let result = with_timeout_msg(
Duration::from_secs(5),
app.td_client.toggle_reaction(chat_id, message_id, emoji.clone()),
"Таймаут отправки реакции",
)
.await;
// Handle result
match result {
Ok(_) => {
app.status_message = Some(format!("Реакция {} добавлена", emoji));
app.exit_reaction_picker_mode();
app.needs_redraw = true;
}
Err(e) => {
app.error_message = Some(e);
app.status_message = None;
app.needs_redraw = true;
}
}
}
/// Подгружает старые сообщения если скролл близко к верху
pub async fn load_older_messages_if_needed<T: TdClientTrait>(app: &mut App<T>) {
// Check if there are messages to load from
if app.td_client.current_chat_messages().is_empty() {
return;
}
// Get the oldest message ID
let oldest_msg_id = app
.td_client
.current_chat_messages()
.first()
.map(|m| m.id())
.unwrap_or(MessageId::new(0));
// Get current chat ID
let Some(chat_id) = app.get_selected_chat_id() else {
return;
};
// Check if scroll is near the top
let message_count = app.td_client.current_chat_messages().len();
if app.message_scroll_offset <= message_count.saturating_sub(10) {
return;
}
// Load older messages with timeout
let Ok(older) = with_timeout(
Duration::from_secs(3),
app.td_client.load_older_messages(ChatId::new(chat_id), oldest_msg_id),
)
.await
else {
return;
};
// Add older messages to the beginning if any were loaded
if !older.is_empty() {
let msgs = app.td_client.current_chat_messages_mut();
msgs.splice(0..0, older);
}
}
/// Обработка ввода клавиатуры в открытом чате
///
/// Обрабатывает:
/// - Backspace/Delete: удаление символов относительно курсора
/// - Char: вставка символов в позицию курсора + typing status
/// - Left/Right/Home/End: навигация курсора
/// - Up/Down: скролл сообщений или начало режима выбора
pub async fn handle_open_chat_keyboard_input<T: TdClientTrait>(app: &mut App<T>, key: KeyEvent) {
match key.code {
KeyCode::Backspace => {
// Удаляем символ слева от курсора
if app.cursor_position > 0 {
let chars: Vec<char> = app.message_input.chars().collect();
let mut new_input = String::new();
for (i, ch) in chars.iter().enumerate() {
if i != app.cursor_position - 1 {
new_input.push(*ch);
}
}
app.message_input = new_input;
app.cursor_position -= 1;
}
}
KeyCode::Delete => {
// Удаляем символ справа от курсора
let len = app.message_input.chars().count();
if app.cursor_position < len {
let chars: Vec<char> = app.message_input.chars().collect();
let mut new_input = String::new();
for (i, ch) in chars.iter().enumerate() {
if i != app.cursor_position {
new_input.push(*ch);
}
}
app.message_input = new_input;
}
}
KeyCode::Char(c) => {
// Игнорируем символы с Ctrl/Alt модификаторами (кроме Shift)
// Это позволяет обрабатывать хоткеи типа Ctrl+U для профиля
if key.modifiers.contains(KeyModifiers::CONTROL)
|| key.modifiers.contains(KeyModifiers::ALT) {
return;
}
// Вставляем символ в позицию курсора
let chars: Vec<char> = app.message_input.chars().collect();
let mut new_input = String::new();
for (i, ch) in chars.iter().enumerate() {
if i == app.cursor_position {
new_input.push(c);
}
new_input.push(*ch);
}
if app.cursor_position >= chars.len() {
new_input.push(c);
}
app.message_input = new_input;
app.cursor_position += 1;
// Отправляем typing status с throttling (не чаще 1 раза в 5 сек)
let should_send_typing = app
.last_typing_sent
.map(|t| t.elapsed().as_secs() >= 5)
.unwrap_or(true);
if should_send_typing {
if let Some(chat_id) = app.get_selected_chat_id() {
app.td_client.send_chat_action(ChatId::new(chat_id), ChatAction::Typing).await;
app.last_typing_sent = Some(Instant::now());
}
}
}
KeyCode::Left => {
// Курсор влево
if app.cursor_position > 0 {
app.cursor_position -= 1;
}
}
KeyCode::Right => {
// Курсор вправо
let len = app.message_input.chars().count();
if app.cursor_position < len {
app.cursor_position += 1;
}
}
KeyCode::Home => {
// Курсор в начало
app.cursor_position = 0;
}
KeyCode::End => {
// Курсор в конец
app.cursor_position = app.message_input.chars().count();
}
// Стрелки вверх/вниз - скролл сообщений или начало выбора
KeyCode::Down => {
// Скролл вниз (к новым сообщениям)
if app.message_scroll_offset > 0 {
app.message_scroll_offset = app.message_scroll_offset.saturating_sub(3);
}
}
KeyCode::Up => {
// Если инпут пустой и не в режиме редактирования — начать выбор сообщения
if app.message_input.is_empty() && !app.is_editing() {
app.start_message_selection();
} else {
// Скролл вверх (к старым сообщениям)
app.message_scroll_offset += 3;
// Подгружаем старые сообщения если нужно
load_older_messages_if_needed(app).await;
}
}
_ => {}
}
}