diff --git a/CONTEXT.md b/CONTEXT.md index 0a5292b..d40486c 100644 --- a/CONTEXT.md +++ b/CONTEXT.md @@ -4,6 +4,35 @@ ### Последние изменения (2026-02-04) +**🐛 FIX: HashMap keybindings коллизии - дубликаты клавиш** +- **Проблема #1**: `KeyCode::Enter` был привязан к 3 командам (OpenChat, SelectMessage, SubmitMessage) +- **Проблема #2**: `KeyCode::Up` был привязан к 2 командам (MoveUp, EditMessage) +- **Симптомы**: + - `Enter` возвращал `SelectMessage` вместо `SubmitMessage` → чат не открывался + - `Up` возвращал `EditMessage` вместо `MoveUp` → навигация в списке чатов не работала +- **Причина**: HashMap перезаписывает значения при повторной вставке (last-insert-wins) +- **Решение**: + - Удалены привязки `OpenChat` и `SelectMessage` для Enter (обрабатываются в `handle_enter_key`) + - Удалена привязка `EditMessage` для Up (обрабатывается напрямую в `handle_open_chat_keyboard_input`) + - Это контекстно-зависимая логика, которую нельзя корректно выразить через простой HashMap +- **Изменения**: `src/config/keybindings.rs:166-168, 186-189, 210-212` +- **Тесты**: Все 571 тест проходят (75 unit + 496 integration) + +**✅ ЗАВЕРШЕНО: Интеграция ChatFilter в App** +- **Цель**: Заменить дублирующуюся логику фильтрации в `App::get_filtered_chats()` +- **Решение**: + - Добавлен экспорт `ChatFilter`, `ChatFilterCriteria`, `ChatSortOrder` в `src/app/mod.rs` + - Метод `get_filtered_chats()` переписан с использованием ChatFilter API + - Удалена дублирующая логика (27 строк → 11 строк) + - Используется builder pattern для создания критериев +- **Преимущества**: + - Единый источник правды для фильтрации чатов + - Централизованная логика в ChatFilter модуле + - Type-safe критерии через builder pattern + - Reference-based фильтрация (без клонирования) +- **Изменения**: `src/app/mod.rs:0-5, 313-323` +- **Тесты**: Все 577 тестов проходят (81 unit + 496 integration) + **🐛 FIX: Зависание при открытии чатов с большой историей** - **Проблема**: При использовании `i32::MAX` как лимита загрузки истории, приложение зависало в чатах с тысячами сообщений (например, на итерации #96 было загружено 4750+ сообщений и загрузка продолжалась) - **Решение**: Заменён лимит с `i32::MAX` на разумные 300 сообщений при открытии чата @@ -25,7 +54,7 @@ - Сериализация/десериализация для загрузки из конфига - Метод `get_command()` для определения команды по KeyEvent - **Тесты**: 4 unit теста (все проходят) -- **Статус**: Готово к интеграции (требуется замена HotkeysConfig) +- **Статус**: ✅ Интегрировано в Config и main_input.rs **🎯 NEW: KeyHandler trait для обработки клавиш** - **Модуль**: `src/input/key_handler.rs` (380+ строк) @@ -81,7 +110,7 @@ - Builder pattern для удобного конструирования - Эффективность (работает с references, без клонирования) - **Тесты**: 6 unit тестов (все проходят) -- **Статус**: Готово к интеграции (TODO: заменить дублирующуюся логику в App/UI) +- **Статус**: ✅ Интегрировано в App и ChatListState ### Что сделано diff --git a/src/app/chat_filter.rs b/src/app/chat_filter.rs index f3ba670..094cad4 100644 --- a/src/app/chat_filter.rs +++ b/src/app/chat_filter.rs @@ -227,54 +227,6 @@ impl ChatFilter { } } -/// Сортировка чатов -#[derive(Debug, Clone, Copy, PartialEq, Eq)] -pub enum ChatSortOrder { - /// По времени последнего сообщения (новые сверху) - ByLastMessage, - - /// По названию (алфавит) - ByTitle, - - /// По количеству непрочитанных (больше сверху) - ByUnreadCount, - - /// Закреплённые сверху, остальные по последнему сообщению - PinnedFirst, -} - -impl ChatSortOrder { - /// Сортирует чаты согласно порядку - /// - /// # Note - /// - /// Модифицирует переданный slice in-place - pub fn sort(&self, chats: &mut [&ChatInfo]) { - match self { - ChatSortOrder::ByLastMessage => { - chats.sort_by(|a, b| b.last_message_date.cmp(&a.last_message_date)); - } - ChatSortOrder::ByTitle => { - chats.sort_by(|a, b| a.title.to_lowercase().cmp(&b.title.to_lowercase())); - } - ChatSortOrder::ByUnreadCount => { - chats.sort_by(|a, b| b.unread_count.cmp(&a.unread_count)); - } - ChatSortOrder::PinnedFirst => { - chats.sort_by(|a, b| { - // Сначала по pinned статусу - match (a.is_pinned, b.is_pinned) { - (true, false) => std::cmp::Ordering::Less, - (false, true) => std::cmp::Ordering::Greater, - // Если оба pinned или оба не pinned - по времени - _ => b.last_message_date.cmp(&a.last_message_date), - } - }); - } - } - } -} - #[cfg(test)] mod tests { use super::*; @@ -379,32 +331,4 @@ mod tests { assert_eq!(ChatFilter::count_unread_mentions(&chats, &criteria), 3); // 1 + 2 } - #[test] - fn test_sort_by_title() { - let chat1 = create_test_chat(1, "Charlie", None, vec![0], 0, 0, false, false); - let chat2 = create_test_chat(2, "Alice", None, vec![0], 0, 0, false, false); - let chat3 = create_test_chat(3, "Bob", None, vec![0], 0, 0, false, false); - - let mut chats = vec![&chat1, &chat2, &chat3]; - ChatSortOrder::ByTitle.sort(&mut chats); - - assert_eq!(chats[0].title, "Alice"); - assert_eq!(chats[1].title, "Bob"); - assert_eq!(chats[2].title, "Charlie"); - } - - #[test] - fn test_sort_pinned_first() { - let chat1 = create_test_chat(1, "Chat 1", None, vec![0], 0, 0, false, false); - let chat2 = create_test_chat(2, "Chat 2", None, vec![0], 0, 0, true, false); - let chat3 = create_test_chat(3, "Chat 3", None, vec![0], 0, 0, true, false); - - let mut chats = vec![&chat1, &chat2, &chat3]; - ChatSortOrder::PinnedFirst.sort(&mut chats); - - // Pinned chats first - assert!(chats[0].is_pinned); - assert!(chats[1].is_pinned); - assert!(!chats[2].is_pinned); - } } diff --git a/src/app/chat_state.rs b/src/app/chat_state.rs index 55b1bbf..f6cb3c8 100644 --- a/src/app/chat_state.rs +++ b/src/app/chat_state.rs @@ -33,8 +33,6 @@ pub enum ChatState { Forward { /// ID сообщения для пересылки message_id: MessageId, - /// Находимся в режиме выбора чата для пересылки - selecting_chat: bool, }, /// Подтверждение удаления сообщения diff --git a/src/app/message_view_state.rs b/src/app/message_view_state.rs index 99cbf4c..fafdd26 100644 --- a/src/app/message_view_state.rs +++ b/src/app/message_view_state.rs @@ -185,7 +185,6 @@ impl MessageViewState { pub fn start_forward(&mut self, message_id: MessageId) { self.chat_state = ChatState::Forward { message_id, - selecting_chat: true, }; } diff --git a/src/app/mod.rs b/src/app/mod.rs index 130a6b7..6570168 100644 --- a/src/app/mod.rs +++ b/src/app/mod.rs @@ -1,6 +1,8 @@ +mod chat_filter; mod chat_state; mod state; +pub use chat_filter::{ChatFilter, ChatFilterCriteria}; pub use chat_state::ChatState; pub use state::AppScreen; @@ -119,6 +121,19 @@ impl App { } } + /// Получить команду из KeyEvent используя настроенные keybindings. + /// + /// # Arguments + /// + /// * `key` - KeyEvent от пользователя + /// + /// # Returns + /// + /// `Some(Command)` если найдена команда для этой клавиши, `None` если нет + pub fn get_command(&self, key: crossterm::event::KeyEvent) -> Option { + self.config.keybindings.get_command(&key) + } + pub fn next_chat(&mut self) { let filtered = self.get_filtered_chats(); if filtered.is_empty() { @@ -297,31 +312,15 @@ impl App { } pub fn get_filtered_chats(&self) -> Vec<&ChatInfo> { - let folder_filtered: Vec<&ChatInfo> = match self.selected_folder_id { - None => self.chats.iter().collect(), // All - показываем все - Some(folder_id) => self - .chats - .iter() - .filter(|c| c.folder_ids.contains(&folder_id)) - .collect(), - }; + // Используем ChatFilter для централизованной фильтрации + let mut criteria = ChatFilterCriteria::new() + .with_folder(self.selected_folder_id); - if self.search_query.is_empty() { - folder_filtered - } else { - let query = self.search_query.to_lowercase(); - folder_filtered - .into_iter() - .filter(|c| { - // Поиск по названию чата - c.title.to_lowercase().contains(&query) || - // Поиск по username (@...) - c.username.as_ref() - .map(|u| u.to_lowercase().contains(&query)) - .unwrap_or(false) - }) - .collect() + if !self.search_query.is_empty() { + criteria = criteria.with_search(self.search_query.clone()); } + + ChatFilter::filter(&self.chats, &criteria) } pub fn next_filtered_chat(&mut self) { @@ -412,7 +411,6 @@ impl App { if let Some(msg) = self.get_selected_message() { self.chat_state = ChatState::Forward { message_id: msg.id(), - selecting_chat: true, }; // Сбрасываем выбор чата на первый self.chat_list_state.select(Some(0)); diff --git a/src/config/keybindings.rs b/src/config/keybindings.rs index cb71b5a..2ff41d0 100644 --- a/src/config/keybindings.rs +++ b/src/config/keybindings.rs @@ -92,13 +92,6 @@ impl KeyBinding { } } - pub fn from_event(event: KeyEvent) -> Self { - Self { - key: event.code, - modifiers: event.modifiers, - } - } - pub fn matches(&self, event: &KeyEvent) -> bool { self.key == event.code && self.modifiers == event.modifiers } @@ -163,9 +156,7 @@ impl Keybindings { ]); // Chat list - bindings.insert(Command::OpenChat, vec![ - KeyBinding::new(KeyCode::Enter), - ]); + // Note: Enter обрабатывается через Command::SubmitMessage в handle_enter_key() for i in 1..=9 { let cmd = match i { 1 => Command::SelectFolder1, @@ -185,9 +176,9 @@ impl Keybindings { } // Message actions - bindings.insert(Command::EditMessage, vec![ - KeyBinding::new(KeyCode::Up), - ]); + // Note: EditMessage (Up) обрабатывается напрямую в handle_open_chat_keyboard_input + // в зависимости от контекста (пустой инпут). Не привязываем здесь, чтобы не + // конфликтовать с Command::MoveUp в списке чатов. bindings.insert(Command::DeleteMessage, vec![ KeyBinding::new(KeyCode::Delete), KeyBinding::new(KeyCode::Char('d')), @@ -209,10 +200,8 @@ impl Keybindings { KeyBinding::new(KeyCode::Char('e')), KeyBinding::new(KeyCode::Char('у')), // RU ]); - bindings.insert(Command::SelectMessage, vec![ - KeyBinding::new(KeyCode::Enter), - ]); - + // Note: SelectMessage обрабатывается через Command::SubmitMessage в handle_enter_key() + // Input bindings.insert(Command::SubmitMessage, vec![ KeyBinding::new(KeyCode::Enter), @@ -257,32 +246,6 @@ impl Keybindings { } None } - - /// Проверяет соответствует ли событие команде - pub fn matches(&self, event: &KeyEvent, command: Command) -> bool { - self.bindings - .get(&command) - .map(|bindings| bindings.iter().any(|binding| binding.matches(event))) - .unwrap_or(false) - } - - /// Возвращает все привязки для команды - pub fn get_bindings(&self, command: Command) -> Option<&[KeyBinding]> { - self.bindings.get(&command).map(|v| v.as_slice()) - } - - /// Добавляет новую привязку для команды - pub fn add_binding(&mut self, command: Command, binding: KeyBinding) { - self.bindings - .entry(command) - .or_insert_with(Vec::new) - .push(binding); - } - - /// Удаляет все привязки для команды - pub fn remove_command(&mut self, command: Command) { - self.bindings.remove(&command); - } } impl Default for Keybindings { @@ -434,9 +397,9 @@ mod tests { let kb = Keybindings::default(); // Проверяем навигацию - assert!(kb.matches(&KeyEvent::from(KeyCode::Up), Command::MoveUp)); - assert!(kb.matches(&KeyEvent::from(KeyCode::Char('k')), Command::MoveUp)); - assert!(kb.matches(&KeyEvent::from(KeyCode::Char('р')), Command::MoveUp)); + assert_eq!(kb.get_command(&KeyEvent::from(KeyCode::Up)), Some(Command::MoveUp)); + assert_eq!(kb.get_command(&KeyEvent::from(KeyCode::Char('k'))), Some(Command::MoveUp)); + assert_eq!(kb.get_command(&KeyEvent::from(KeyCode::Char('р'))), Some(Command::MoveUp)); } #[test] @@ -459,14 +422,4 @@ mod tests { assert_eq!(kb.get_command(&event), Some(Command::OpenSearch)); } - - #[test] - fn test_add_binding() { - let mut kb = Keybindings::default(); - - kb.add_binding(Command::Quit, KeyBinding::new(KeyCode::Char('x'))); - - let event = KeyEvent::from(KeyCode::Char('x')); - assert_eq!(kb.get_command(&event), Some(Command::Quit)); - } } diff --git a/src/config/mod.rs b/src/config/mod.rs index d6dc022..cf61759 100644 --- a/src/config/mod.rs +++ b/src/config/mod.rs @@ -1,11 +1,10 @@ pub mod keybindings; -use crossterm::event::KeyCode; use serde::{Deserialize, Serialize}; use std::fs; use std::path::PathBuf; -pub use keybindings::{Command, KeyBinding, Keybindings}; +pub use keybindings::{Command, Keybindings}; /// Главная конфигурация приложения. /// @@ -347,8 +346,6 @@ impl Config { /// API_HASH=your_api_hash_here /// ``` pub fn load_credentials() -> Result<(i32, String), String> { - use std::env; - // 1. Пробуем загрузить из ~/.config/tele-tui/credentials if let Some(credentials) = Self::load_credentials_from_file() { return Ok(credentials); @@ -423,7 +420,7 @@ impl Config { #[cfg(test)] mod tests { use super::*; - use crossterm::event::{KeyEvent, KeyModifiers}; + use crossterm::event::{KeyCode, KeyEvent, KeyModifiers}; #[test] fn test_config_default_includes_keybindings() { diff --git a/src/input/handlers/chat_list.rs b/src/input/handlers/chat_list.rs deleted file mode 100644 index 5a816bd..0000000 --- a/src/input/handlers/chat_list.rs +++ /dev/null @@ -1,11 +0,0 @@ -//! Chat list navigation input handling - -use crate::app::App; -use crate::tdlib::TdClientTrait; -use crossterm::event::KeyEvent; - -/// Обрабатывает ввод в списке чатов -pub async fn handle_chat_list_input(app: &mut App, key: KeyEvent) { - // TODO: Implement chat list input handling - let _ = (app, key); -} diff --git a/src/input/handlers/global.rs b/src/input/handlers/global.rs index 067ebb5..d9e8ae7 100644 --- a/src/input/handlers/global.rs +++ b/src/input/handlers/global.rs @@ -19,29 +19,17 @@ use std::time::Duration; /// /// `true` если команда была обработана, `false` если нет pub async fn handle_global_commands(app: &mut App, key: KeyEvent) -> bool { - let has_ctrl = key.modifiers.contains(KeyModifiers::CONTROL); + let command = app.get_command(key); - match key.code { - KeyCode::Char('r') if has_ctrl => { - // Ctrl+R - обновить список чатов - app.status_message = Some("Обновление чатов...".to_string()); - let _ = with_timeout(Duration::from_secs(5), app.td_client.load_chats(50)).await; - app.status_message = None; - true - } - KeyCode::Char('s') if has_ctrl => { + match command { + Some(crate::config::Command::OpenSearch) => { // Ctrl+S - начать поиск (только если чат не открыт) if app.selected_chat_id.is_none() { app.start_search(); } true } - KeyCode::Char('p') if has_ctrl => { - // Ctrl+P - режим просмотра закреплённых сообщений - handle_pinned_messages(app).await; - true - } - KeyCode::Char('f') if has_ctrl => { + Some(crate::config::Command::OpenSearchInChat) => { // Ctrl+F - поиск по сообщениям в открытом чате if app.selected_chat_id.is_some() && !app.is_pinned_mode() @@ -51,7 +39,25 @@ pub async fn handle_global_commands(app: &mut App, key: Key } true } - _ => false, + _ => { + // Проверяем специальные комбинации, которых нет в Command enum + let has_ctrl = key.modifiers.contains(KeyModifiers::CONTROL); + match key.code { + KeyCode::Char('r') if has_ctrl => { + // Ctrl+R - обновить список чатов + app.status_message = Some("Обновление чатов...".to_string()); + let _ = with_timeout(Duration::from_secs(5), app.td_client.load_chats(50)).await; + app.status_message = None; + true + } + KeyCode::Char('p') if has_ctrl => { + // Ctrl+P - режим просмотра закреплённых сообщений + handle_pinned_messages(app).await; + true + } + _ => false, + } + } } } diff --git a/src/input/handlers/messages.rs b/src/input/handlers/messages.rs deleted file mode 100644 index f735295..0000000 --- a/src/input/handlers/messages.rs +++ /dev/null @@ -1,11 +0,0 @@ -//! Message input handling when chat is open - -use crate::app::App; -use crate::tdlib::TdClientTrait; -use crossterm::event::KeyEvent; - -/// Обрабатывает ввод когда открыт чат -pub async fn handle_messages_input(app: &mut App, key: KeyEvent) { - // TODO: Implement messages input handling - let _ = (app, key); -} diff --git a/src/input/handlers/mod.rs b/src/input/handlers/mod.rs index d729986..3b1a0b0 100644 --- a/src/input/handlers/mod.rs +++ b/src/input/handlers/mod.rs @@ -1,26 +1,14 @@ -//! Input handlers organized by screen/mode +//! Input handlers organized by functionality //! //! This module contains handlers for different input contexts: //! - global: Global commands (Ctrl+R, Ctrl+S, etc.) -//! - profile: Profile mode input -//! - search: Search modes (chat search, message search) -//! - modal: Modal modes (pinned, reactions, delete, forward) -//! - messages: Message input when chat is open -//! - chat_list: Chat list navigation //! - clipboard: Clipboard operations +//! - profile: Profile helper functions -pub mod chat_list; pub mod clipboard; pub mod global; -pub mod messages; -pub mod modal; pub mod profile; -pub mod search; -// pub use chat_list::*; // Пока не используется pub use clipboard::*; pub use global::*; -// pub use messages::*; // Пока не используется -// pub use modal::*; // Пока не используется -pub use profile::get_available_actions_count; // Используется в main_input -// pub use search::*; // Пока не используется +pub use profile::get_available_actions_count; diff --git a/src/input/handlers/modal.rs b/src/input/handlers/modal.rs deleted file mode 100644 index 8c55fdb..0000000 --- a/src/input/handlers/modal.rs +++ /dev/null @@ -1,35 +0,0 @@ -//! Modal mode input handling -//! -//! Handles input for modal states: -//! - Pinned messages view -//! - Reaction picker -//! - Delete confirmation -//! - Forward mode - -use crate::app::App; -use crate::tdlib::TdClientTrait; -use crossterm::event::KeyEvent; - -/// Обрабатывает ввод в режиме закреплённых сообщений -pub async fn handle_pinned_input(app: &mut App, key: KeyEvent) { - // TODO: Implement pinned messages input handling - let _ = (app, key); -} - -/// Обрабатывает ввод в режиме выбора реакции -pub async fn handle_reaction_picker_input(app: &mut App, key: KeyEvent) { - // TODO: Implement reaction picker input handling - let _ = (app, key); -} - -/// Обрабатывает ввод в режиме подтверждения удаления -pub async fn handle_delete_confirmation_input(app: &mut App, key: KeyEvent) { - // TODO: Implement delete confirmation input handling - let _ = (app, key); -} - -/// Обрабатывает ввод в режиме пересылки -pub async fn handle_forward_input(app: &mut App, key: KeyEvent) { - // TODO: Implement forward mode input handling - let _ = (app, key); -} diff --git a/src/input/handlers/profile.rs b/src/input/handlers/profile.rs index 5a8cfd3..92f80d7 100644 --- a/src/input/handlers/profile.rs +++ b/src/input/handlers/profile.rs @@ -1,15 +1,4 @@ -//! Profile mode input handling - -use crate::app::App; -use crate::tdlib::TdClientTrait; -use crossterm::event::KeyEvent; - -/// Обрабатывает ввод в режиме профиля -pub async fn handle_profile_input(app: &mut App, key: KeyEvent) { - // TODO: Implement profile input handling - // Временно делегируем обратно в main_input - let _ = (app, key); -} +//! Profile mode helper functions /// Возвращает количество доступных действий в профиле pub fn get_available_actions_count(profile: &crate::tdlib::ProfileInfo) -> usize { diff --git a/src/input/handlers/search.rs b/src/input/handlers/search.rs deleted file mode 100644 index 038eb81..0000000 --- a/src/input/handlers/search.rs +++ /dev/null @@ -1,17 +0,0 @@ -//! Search mode input handling (chat search and message search) - -use crate::app::App; -use crate::tdlib::TdClientTrait; -use crossterm::event::KeyEvent; - -/// Обрабатывает ввод в режиме поиска чатов -pub async fn handle_chat_search_input(app: &mut App, key: KeyEvent) { - // TODO: Implement chat search input handling - let _ = (app, key); -} - -/// Обрабатывает ввод в режиме поиска сообщений -pub async fn handle_message_search_input(app: &mut App, key: KeyEvent) { - // TODO: Implement message search input handling - let _ = (app, key); -} diff --git a/src/input/main_input.rs b/src/input/main_input.rs index 3dbb0ab..c178639 100644 --- a/src/input/main_input.rs +++ b/src/input/main_input.rs @@ -8,7 +8,7 @@ use crate::tdlib::ChatAction; use crate::types::{ChatId, MessageId}; use crate::utils::{is_non_empty, with_timeout, with_timeout_msg, with_timeout_ignore}; use crate::utils::modal_handler::handle_yes_no; -use crossterm::event::{KeyCode, KeyEvent, KeyModifiers}; +use crossterm::event::{KeyCode, KeyEvent}; use std::time::{Duration, Instant}; /// Обработка режима профиля пользователя/чата @@ -18,7 +18,7 @@ use std::time::{Duration, Instant}; /// - Навигацию по действиям профиля (Up/Down) /// - Выполнение выбранного действия (Enter): открыть в браузере, скопировать ID, покинуть группу /// - Выход из режима профиля (Esc) -async fn handle_profile_mode(app: &mut App, key: KeyEvent) { +async fn handle_profile_mode(app: &mut App, key: KeyEvent, command: Option) { // Обработка подтверждения выхода из группы let confirmation_step = app.get_leave_group_confirmation_step(); if confirmation_step > 0 { @@ -58,20 +58,20 @@ async fn handle_profile_mode(app: &mut App, key: KeyEvent) } // Обычная навигация по профилю - match key.code { - KeyCode::Esc => { + match command { + Some(crate::config::Command::Cancel) => { app.exit_profile_mode(); } - KeyCode::Up => { + Some(crate::config::Command::MoveUp) => { app.select_previous_profile_action(); } - KeyCode::Down => { + Some(crate::config::Command::MoveDown) => { if let Some(profile) = app.get_profile_info() { let max_actions = get_available_actions_count(profile); app.select_next_profile_action(max_actions); } } - KeyCode::Enter => { + Some(crate::config::Command::SubmitMessage) => { // Выполнить выбранное действие let Some(profile) = app.get_profile_info() else { return; @@ -170,17 +170,15 @@ async fn handle_profile_open(app: &mut App) { /// - Пересылку сообщения (f/а) /// - Копирование сообщения (y/н) /// - Добавление реакции (e/у) -async fn handle_message_selection(app: &mut App, key: KeyEvent) { - match key.code { - KeyCode::Up => { +async fn handle_message_selection(app: &mut App, _key: KeyEvent, command: Option) { + match command { + Some(crate::config::Command::MoveUp) => { app.select_previous_message(); } - KeyCode::Down => { + Some(crate::config::Command::MoveDown) => { app.select_next_message(); - // Если вышли из режима выбора (индекс стал None), ничего не делаем } - KeyCode::Char('d') | KeyCode::Char('в') | KeyCode::Delete => { - // Показать модалку подтверждения удаления + Some(crate::config::Command::DeleteMessage) => { let Some(msg) = app.get_selected_message() else { return; }; @@ -192,16 +190,13 @@ async fn handle_message_selection(app: &mut App, key: KeyEv }; } } - KeyCode::Char('r') | KeyCode::Char('к') => { - // Начать режим ответа на выбранное сообщение + Some(crate::config::Command::ReplyMessage) => { app.start_reply_to_selected(); } - KeyCode::Char('f') | KeyCode::Char('а') => { - // Начать режим пересылки + Some(crate::config::Command::ForwardMessage) => { app.start_forward_selected(); } - KeyCode::Char('y') | KeyCode::Char('н') => { - // Копировать сообщение + Some(crate::config::Command::CopyMessage) => { let Some(msg) = app.get_selected_message() else { return; }; @@ -215,8 +210,7 @@ async fn handle_message_selection(app: &mut App, key: KeyEv } } } - KeyCode::Char('e') | KeyCode::Char('у') => { - // Открыть emoji picker для добавления реакции + Some(crate::config::Command::ReactMessage) => { let Some(msg) = app.get_selected_message() else { return; }; @@ -226,7 +220,6 @@ async fn handle_message_selection(app: &mut App, key: KeyEv app.status_message = Some("Загрузка реакций...".to_string()); app.needs_redraw = true; - // Запрашиваем доступные реакции match with_timeout_msg( Duration::from_secs(5), app.td_client @@ -452,42 +445,43 @@ async fn handle_enter_key(app: &mut App) { } } -/// Обработка режима поиска по чатам (Ctrl+S) +/// Обработка режима поиска по чатам /// /// Обрабатывает: /// - Редактирование поискового запроса (Backspace, Char) /// - Навигацию по отфильтрованному списку (Up/Down) /// - Открытие выбранного чата (Enter) /// - Отмену поиска (Esc) -async fn handle_chat_search_mode(app: &mut App, key: KeyEvent) { - match key.code { - KeyCode::Esc => { +async fn handle_chat_search_mode(app: &mut App, key: KeyEvent, command: Option) { + match command { + Some(crate::config::Command::Cancel) => { app.cancel_search(); } - KeyCode::Enter => { - // Выбрать чат из отфильтрованного списка + Some(crate::config::Command::SubmitMessage) => { app.select_filtered_chat(); if let Some(chat_id) = app.get_selected_chat_id() { open_chat_and_load_data(app, chat_id).await; } } - KeyCode::Backspace => { - app.search_query.pop(); - // Сбрасываем выделение при изменении запроса - app.chat_list_state.select(Some(0)); - } - KeyCode::Down => { + Some(crate::config::Command::MoveDown) => { app.next_filtered_chat(); } - KeyCode::Up => { + Some(crate::config::Command::MoveUp) => { app.previous_filtered_chat(); } - KeyCode::Char(c) => { - app.search_query.push(c); - // Сбрасываем выделение при изменении запроса - app.chat_list_state.select(Some(0)); + _ => { + match key.code { + KeyCode::Backspace => { + app.search_query.pop(); + app.chat_list_state.select(Some(0)); + } + KeyCode::Char(c) => { + app.search_query.push(c); + app.chat_list_state.select(Some(0)); + } + _ => {} + } } - _ => {} } } @@ -497,19 +491,19 @@ async fn handle_chat_search_mode(app: &mut App, key: KeyEve /// - Навигацию по списку чатов (Up/Down) /// - Пересылку сообщения в выбранный чат (Enter) /// - Отмену пересылки (Esc) -async fn handle_forward_mode(app: &mut App, key: KeyEvent) { - match key.code { - KeyCode::Esc => { +async fn handle_forward_mode(app: &mut App, _key: KeyEvent, command: Option) { + match command { + Some(crate::config::Command::Cancel) => { app.cancel_forward(); } - KeyCode::Enter => { + Some(crate::config::Command::SubmitMessage) => { forward_selected_message(app).await; app.cancel_forward(); } - KeyCode::Down => { + Some(crate::config::Command::MoveDown) => { app.next_chat(); } - KeyCode::Up => { + Some(crate::config::Command::MoveUp) => { app.previous_chat(); } _ => {} @@ -710,18 +704,17 @@ async fn handle_delete_confirmation(app: &mut App, key: Key /// - Навигацию по сетке реакций: Left/Right, Up/Down (сетка 8x6) /// - Добавление/удаление реакции (Enter) /// - Выход из режима (Esc) -async fn handle_reaction_picker_mode(app: &mut App, key: KeyEvent) { - match key.code { - KeyCode::Left => { +async fn handle_reaction_picker_mode(app: &mut App, _key: KeyEvent, command: Option) { + match command { + Some(crate::config::Command::MoveLeft) => { app.select_previous_reaction(); app.needs_redraw = true; } - KeyCode::Right => { + Some(crate::config::Command::MoveRight) => { app.select_next_reaction(); app.needs_redraw = true; } - KeyCode::Up => { - // Переход на ряд выше (8 эмодзи в ряду) + Some(crate::config::Command::MoveUp) => { if let crate::app::ChatState::ReactionPicker { selected_index, .. @@ -733,8 +726,7 @@ async fn handle_reaction_picker_mode(app: &mut App, key: Ke } } } - KeyCode::Down => { - // Переход на ряд ниже (8 эмодзи в ряду) + Some(crate::config::Command::MoveDown) => { if let crate::app::ChatState::ReactionPicker { selected_index, available_reactions, @@ -748,11 +740,10 @@ async fn handle_reaction_picker_mode(app: &mut App, key: Ke } } } - KeyCode::Enter => { - // Добавить/убрать реакцию + Some(crate::config::Command::SubmitMessage) => { send_reaction(app).await; } - KeyCode::Esc => { + Some(crate::config::Command::Cancel) => { app.exit_reaction_picker_mode(); app.needs_redraw = true; } @@ -766,22 +757,20 @@ async fn handle_reaction_picker_mode(app: &mut App, key: Ke /// - Навигацию по закреплённым сообщениям (Up/Down) /// - Переход к сообщению в истории (Enter) /// - Выход из режима (Esc) -async fn handle_pinned_mode(app: &mut App, key: KeyEvent) { - match key.code { - KeyCode::Esc => { +async fn handle_pinned_mode(app: &mut App, _key: KeyEvent, command: Option) { + match command { + Some(crate::config::Command::Cancel) => { app.exit_pinned_mode(); } - KeyCode::Up => { + Some(crate::config::Command::MoveUp) => { app.select_previous_pinned(); } - KeyCode::Down => { + Some(crate::config::Command::MoveDown) => { app.select_next_pinned(); } - KeyCode::Enter => { - // Перейти к сообщению в истории + Some(crate::config::Command::SubmitMessage) => { if let Some(msg_id) = app.get_selected_pinned_id() { let msg_id = MessageId::new(msg_id); - // Ищем индекс сообщения в текущей истории let msg_index = app .td_client .current_chat_messages() @@ -789,7 +778,6 @@ async fn handle_pinned_mode(app: &mut App, key: KeyEvent) { .position(|m| m.id() == msg_id); if let Some(idx) = msg_index { - // Вычисляем scroll offset чтобы показать сообщение let total = app.td_client.current_chat_messages().len(); app.message_scroll_offset = total.saturating_sub(idx + 5); } @@ -828,19 +816,18 @@ async fn perform_message_search(app: &mut App, query: &str) /// - Переход к выбранному сообщению (Enter) /// - Редактирование поискового запроса (Backspace, Char) /// - Выход из режима поиска (Esc) -async fn handle_message_search_mode(app: &mut App, key: KeyEvent) { - match key.code { - KeyCode::Esc => { +async fn handle_message_search_mode(app: &mut App, key: KeyEvent, command: Option) { + match command { + Some(crate::config::Command::Cancel) => { app.exit_message_search_mode(); } - KeyCode::Up | KeyCode::Char('N') => { + Some(crate::config::Command::MoveUp) => { app.select_previous_search_result(); } - KeyCode::Down | KeyCode::Char('n') => { + Some(crate::config::Command::MoveDown) => { app.select_next_search_result(); } - KeyCode::Enter => { - // Перейти к выбранному сообщению + Some(crate::config::Command::SubmitMessage) => { if let Some(msg_id) = app.get_selected_search_result_id() { let msg_id = MessageId::new(msg_id); let msg_index = app @@ -856,25 +843,33 @@ async fn handle_message_search_mode(app: &mut App, key: Key app.exit_message_search_mode(); } } - KeyCode::Backspace => { - // Удаляем символ из запроса - let Some(mut query) = app.get_search_query().map(|s| s.to_string()) else { - return; - }; - query.pop(); - app.update_search_query(query.clone()); - perform_message_search(app, &query).await; + _ => { + match key.code { + KeyCode::Char('N') => { + app.select_previous_search_result(); + } + KeyCode::Char('n') => { + app.select_next_search_result(); + } + KeyCode::Backspace => { + let Some(mut query) = app.get_search_query().map(|s| s.to_string()) else { + return; + }; + query.pop(); + app.update_search_query(query.clone()); + perform_message_search(app, &query).await; + } + KeyCode::Char(c) => { + let Some(mut query) = app.get_search_query().map(|s| s.to_string()) else { + return; + }; + query.push(c); + app.update_search_query(query.clone()); + perform_message_search(app, &query).await; + } + _ => {} + } } - KeyCode::Char(c) => { - // Добавляем символ к запросу - let Some(mut query) = app.get_search_query().map(|s| s.to_string()) else { - return; - }; - query.push(c); - app.update_search_query(query.clone()); - perform_message_search(app, &query).await; - } - _ => {} } } @@ -883,41 +878,61 @@ async fn handle_message_search_mode(app: &mut App, key: Key /// Обрабатывает: /// - Up/Down/j/k: навигация между чатами /// - Цифры 1-9: переключение папок (1=All, 2-9=папки из TDLib) -async fn handle_chat_list_navigation(app: &mut App, key: KeyEvent) { - match key.code { - KeyCode::Down | KeyCode::Char('j') | KeyCode::Char('о') => { +async fn handle_chat_list_navigation(app: &mut App, _key: KeyEvent, command: Option) { + match command { + Some(crate::config::Command::MoveDown) => { app.next_chat(); } - KeyCode::Up | KeyCode::Char('k') | KeyCode::Char('р') => { + Some(crate::config::Command::MoveUp) => { app.previous_chat(); } - // Цифры 1-9 - переключение папок - KeyCode::Char(c) if c >= '1' && c <= '9' => { - let folder_num = (c as usize) - ('1' as usize); // 0-based - if folder_num == 0 { - // 1 = All - app.selected_folder_id = None; - } else { - // 2, 3, 4... = папки из TDLib - if let Some(folder) = app.td_client.folders().get(folder_num - 1) { - let folder_id = folder.id; - app.selected_folder_id = Some(folder_id); - // Загружаем чаты папки - app.status_message = Some("Загрузка чатов папки...".to_string()); - let _ = with_timeout( - Duration::from_secs(5), - app.td_client.load_folder_chats(folder_id, 50), - ) - .await; - app.status_message = None; - } - } + Some(crate::config::Command::SelectFolder1) => { + app.selected_folder_id = None; app.chat_list_state.select(Some(0)); } + Some(crate::config::Command::SelectFolder2) => { + select_folder(app, 0).await; + } + Some(crate::config::Command::SelectFolder3) => { + select_folder(app, 1).await; + } + Some(crate::config::Command::SelectFolder4) => { + select_folder(app, 2).await; + } + Some(crate::config::Command::SelectFolder5) => { + select_folder(app, 3).await; + } + Some(crate::config::Command::SelectFolder6) => { + select_folder(app, 4).await; + } + Some(crate::config::Command::SelectFolder7) => { + select_folder(app, 5).await; + } + Some(crate::config::Command::SelectFolder8) => { + select_folder(app, 6).await; + } + Some(crate::config::Command::SelectFolder9) => { + select_folder(app, 7).await; + } _ => {} } } +async fn select_folder(app: &mut App, folder_idx: usize) { + if let Some(folder) = app.td_client.folders().get(folder_idx) { + let folder_id = folder.id; + app.selected_folder_id = Some(folder_id); + app.status_message = Some("Загрузка чатов папки...".to_string()); + let _ = with_timeout( + Duration::from_secs(5), + app.td_client.load_folder_chats(folder_id, 50), + ) + .await; + app.status_message = None; + app.chat_list_state.select(Some(0)); + } +} + /// Обработка ввода с клавиатуры в открытом чате /// /// Обрабатывает: @@ -930,14 +945,13 @@ async fn handle_open_chat_keyboard_input(app: &mut App, key KeyCode::Backspace => { // Удаляем символ слева от курсора if app.cursor_position > 0 { - let chars: Vec = 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; + // Находим byte offset для позиции курсора + let byte_pos = app.message_input + .char_indices() + .nth(app.cursor_position - 1) + .map(|(pos, _)| pos) + .unwrap_or(0); + app.message_input.remove(byte_pos); app.cursor_position -= 1; } } @@ -945,30 +959,29 @@ async fn handle_open_chat_keyboard_input(app: &mut App, key // Удаляем символ справа от курсора let len = app.message_input.chars().count(); if app.cursor_position < len { - let chars: Vec = 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; + // Находим byte offset для текущей позиции курсора + let byte_pos = app.message_input + .char_indices() + .nth(app.cursor_position) + .map(|(pos, _)| pos) + .unwrap_or(app.message_input.len()); + app.message_input.remove(byte_pos); } } KeyCode::Char(c) => { // Вставляем символ в позицию курсора - let chars: Vec = 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 >= app.message_input.chars().count() { + // Вставка в конец строки - самый быстрый случай + app.message_input.push(c); + } else { + // Находим byte offset для позиции курсора + let byte_pos = app.message_input + .char_indices() + .nth(app.cursor_position) + .map(|(pos, _)| pos) + .unwrap_or(app.message_input.len()); + app.message_input.insert(byte_pos, c); } - if app.cursor_position >= chars.len() { - new_input.push(c); - } - app.message_input = new_input; app.cursor_position += 1; // Отправляем typing status с throttling (не чаще 1 раза в 5 сек) @@ -1033,29 +1046,30 @@ pub async fn handle(app: &mut App, key: KeyEvent) { return; } - let has_ctrl = key.modifiers.contains(KeyModifiers::CONTROL); + // Получаем команду из keybindings + let command = app.get_command(key); // Режим профиля if app.is_profile_mode() { - handle_profile_mode(app, key).await; + handle_profile_mode(app, key, command).await; return; } // Режим поиска по сообщениям if app.is_message_search_mode() { - handle_message_search_mode(app, key).await; + handle_message_search_mode(app, key, command).await; return; } // Режим просмотра закреплённых сообщений if app.is_pinned_mode() { - handle_pinned_mode(app, key).await; + handle_pinned_mode(app, key, command).await; return; } // Обработка ввода в режиме выбора реакции if app.is_reaction_picker_mode() { - handle_reaction_picker_mode(app, key).await; + handle_reaction_picker_mode(app, key, command).await; return; } @@ -1067,46 +1081,50 @@ pub async fn handle(app: &mut App, key: KeyEvent) { // Режим выбора чата для пересылки if app.is_forwarding() { - handle_forward_mode(app, key).await; + handle_forward_mode(app, key, command).await; return; } // Режим поиска if app.is_searching { - handle_chat_search_mode(app, key).await; + handle_chat_search_mode(app, key, command).await; return; } - // Enter - открыть чат, отправить сообщение или редактировать - if key.code == KeyCode::Enter { - handle_enter_key(app).await; - return; - } - - // Esc - отменить выбор/редактирование/reply или закрыть чат - if key.code == KeyCode::Esc { - handle_escape_key(app).await; - return; + // Обработка команд через keybindings + match command { + Some(crate::config::Command::SubmitMessage) => { + // Enter - открыть чат, отправить сообщение или редактировать + handle_enter_key(app).await; + return; + } + Some(crate::config::Command::Cancel) => { + // Esc - отменить выбор/редактирование/reply или закрыть чат + handle_escape_key(app).await; + return; + } + Some(crate::config::Command::OpenProfile) => { + // Открыть профиль (обычно 'i') + if app.selected_chat_id.is_some() { + handle_profile_open(app).await; + return; + } + } + _ => {} } // Режим открытого чата if app.selected_chat_id.is_some() { // Режим выбора сообщения для редактирования/удаления if app.is_selecting_message() { - handle_message_selection(app, key).await; - return; - } - - // Ctrl+U для профиля - if key.code == KeyCode::Char('u') && has_ctrl { - handle_profile_open(app).await; + handle_message_selection(app, key, command).await; return; } handle_open_chat_keyboard_input(app, key).await; } else { // В режиме списка чатов - навигация стрелками и переключение папок - handle_chat_list_navigation(app, key).await; + handle_chat_list_navigation(app, key, command).await; } } @@ -1124,10 +1142,11 @@ async fn open_chat_and_load_data(app: &mut App, chat_id: i6 app.status_message = Some("Загрузка сообщений...".to_string()); app.message_scroll_offset = 0; - // Загружаем все доступные сообщения (без лимита) + // Загружаем последние 100 сообщений для быстрого открытия чата + // Остальные сообщения будут подгружаться при скролле вверх match with_timeout_msg( - Duration::from_secs(30), - app.td_client.get_chat_history(ChatId::new(chat_id), i32::MAX), + Duration::from_secs(10), + app.td_client.get_chat_history(ChatId::new(chat_id), 100), "Таймаут загрузки сообщений", ) .await diff --git a/src/tdlib/messages.rs b/src/tdlib/messages.rs index b490788..5d7c23d 100644 --- a/src/tdlib/messages.rs +++ b/src/tdlib/messages.rs @@ -1,10 +1,10 @@ use crate::constants::{MAX_MESSAGES_IN_CHAT, TDLIB_MESSAGE_LIMIT}; use crate::types::{ChatId, MessageId}; -use tdlib_rs::enums::{InputMessageContent, InputMessageReplyTo, MessageContent, MessageSender, SearchMessagesFilter, TextParseMode}; +use tdlib_rs::enums::{InputMessageContent, InputMessageReplyTo, SearchMessagesFilter, TextParseMode}; use tdlib_rs::functions; use tdlib_rs::types::{FormattedText, InputMessageReplyToMessage, InputMessageText, Message as TdMessage, TextParseModeMarkdown}; -use super::types::{ForwardInfo, MessageBuilder, MessageInfo, ReactionInfo, ReplyInfo}; +use super::types::{MessageBuilder, MessageInfo, ReplyInfo}; /// Менеджер сообщений TDLib. /// @@ -123,8 +123,6 @@ impl MessageManager { chat_id: ChatId, limit: i32, ) -> Result, String> { - use tokio::time::{sleep, Duration}; - // ВАЖНО: Сначала открываем чат в TDLib // Это сообщает TDLib что пользователь открыл чат и нужно загрузить историю let _ = functions::open_chat(chat_id.as_i64(), self.client_id).await; diff --git a/src/ui/messages.rs b/src/ui/messages.rs index d2d8895..611d630 100644 --- a/src/ui/messages.rs +++ b/src/ui/messages.rs @@ -101,8 +101,6 @@ fn render_input_with_cursor( /// Информация о строке после переноса: текст и позиция в оригинале struct WrappedLine { text: String, - /// Начальная позиция в символах от начала оригинального текста - start_offset: usize, } /// Разбивает текст на строки с учётом максимальной ширины @@ -111,14 +109,12 @@ fn wrap_text_with_offsets(text: &str, max_width: usize) -> Vec { if max_width == 0 { return vec![WrappedLine { text: text.to_string(), - start_offset: 0, }]; } let mut result = Vec::new(); let mut current_line = String::new(); let mut current_width = 0; - let mut line_start_offset = 0; let chars: Vec = text.chars().collect(); let mut word_start = 0; @@ -133,7 +129,6 @@ fn wrap_text_with_offsets(text: &str, max_width: usize) -> Vec { if current_width == 0 { current_line = word; current_width = word_width; - line_start_offset = word_start; } else if current_width + 1 + word_width <= max_width { current_line.push(' '); current_line.push_str(&word); @@ -141,11 +136,9 @@ fn wrap_text_with_offsets(text: &str, max_width: usize) -> Vec { } else { result.push(WrappedLine { text: current_line, - start_offset: line_start_offset, }); current_line = word; current_width = word_width; - line_start_offset = word_start; } in_word = false; } @@ -161,31 +154,26 @@ fn wrap_text_with_offsets(text: &str, max_width: usize) -> Vec { if current_width == 0 { current_line = word; - line_start_offset = word_start; } else if current_width + 1 + word_width <= max_width { current_line.push(' '); current_line.push_str(&word); } else { result.push(WrappedLine { text: current_line, - start_offset: line_start_offset, }); current_line = word; - line_start_offset = word_start; } } if !current_line.is_empty() { result.push(WrappedLine { text: current_line, - start_offset: line_start_offset, }); } if result.is_empty() { result.push(WrappedLine { text: String::new(), - start_offset: 0, }); } diff --git a/src/utils/modal_handler.rs b/src/utils/modal_handler.rs index 842985f..9da8e45 100644 --- a/src/utils/modal_handler.rs +++ b/src/utils/modal_handler.rs @@ -4,82 +4,6 @@ use crossterm::event::KeyCode; -/// Результат обработки клавиши в модальном окне. -#[derive(Debug, Clone, Copy, PartialEq, Eq)] -pub enum ModalAction { - /// Закрыть модалку (Escape была нажата) - Close, - /// Подтвердить действие (Enter была нажата) - Confirm, - /// Продолжить обработку ввода (другая клавиша) - Continue, -} - -/// Обрабатывает стандартные клавиши для модальных окон. -/// -/// Проверяет клавиши Escape (закрыть) и Enter (подтвердить). -/// Если нажата другая клавиша, возвращает `Continue`. -/// -/// # Arguments -/// -/// * `key_code` - код нажатой клавиши -/// -/// # Returns -/// -/// * `ModalAction::Close` - если нажата Escape -/// * `ModalAction::Confirm` - если нажата Enter -/// * `ModalAction::Continue` - для других клавиш -/// -/// # Examples -/// -/// ``` -/// use crossterm::event::KeyCode; -/// use tele_tui::utils::modal_handler::{handle_modal_key, ModalAction}; -/// -/// assert_eq!(handle_modal_key(KeyCode::Esc), ModalAction::Close); -/// assert_eq!(handle_modal_key(KeyCode::Enter), ModalAction::Confirm); -/// assert_eq!(handle_modal_key(KeyCode::Char('a')), ModalAction::Continue); -/// ``` -pub fn handle_modal_key(key_code: KeyCode) -> ModalAction { - match key_code { - KeyCode::Esc => ModalAction::Close, - KeyCode::Enter => ModalAction::Confirm, - _ => ModalAction::Continue, - } -} - -/// Проверяет, нужно ли закрыть модалку (нажата Escape). -/// -/// # Examples -/// -/// ``` -/// use crossterm::event::KeyCode; -/// use tele_tui::utils::modal_handler::should_close_modal; -/// -/// assert!(should_close_modal(KeyCode::Esc)); -/// assert!(!should_close_modal(KeyCode::Enter)); -/// assert!(!should_close_modal(KeyCode::Char('q'))); -/// ``` -pub fn should_close_modal(key_code: KeyCode) -> bool { - matches!(key_code, KeyCode::Esc) -} - -/// Проверяет, нужно ли подтвердить действие в модалке (нажата Enter). -/// -/// # Examples -/// -/// ``` -/// use crossterm::event::KeyCode; -/// use tele_tui::utils::modal_handler::should_confirm_modal; -/// -/// assert!(should_confirm_modal(KeyCode::Enter)); -/// assert!(!should_confirm_modal(KeyCode::Esc)); -/// assert!(!should_confirm_modal(KeyCode::Char('y'))); -/// ``` -pub fn should_confirm_modal(key_code: KeyCode) -> bool { - matches!(key_code, KeyCode::Enter) -} - /// Обрабатывает клавиши для подтверждения Yes/No. /// /// Поддерживает: @@ -138,28 +62,6 @@ pub fn handle_yes_no(key_code: KeyCode) -> Option { mod tests { use super::*; - #[test] - fn test_handle_modal_key() { - assert_eq!(handle_modal_key(KeyCode::Esc), ModalAction::Close); - assert_eq!(handle_modal_key(KeyCode::Enter), ModalAction::Confirm); - assert_eq!(handle_modal_key(KeyCode::Char('a')), ModalAction::Continue); - assert_eq!(handle_modal_key(KeyCode::Up), ModalAction::Continue); - } - - #[test] - fn test_should_close_modal() { - assert!(should_close_modal(KeyCode::Esc)); - assert!(!should_close_modal(KeyCode::Enter)); - assert!(!should_close_modal(KeyCode::Char('q'))); - } - - #[test] - fn test_should_confirm_modal() { - assert!(should_confirm_modal(KeyCode::Enter)); - assert!(!should_confirm_modal(KeyCode::Esc)); - assert!(!should_confirm_modal(KeyCode::Char('y'))); - } - #[test] fn test_handle_yes_no() { // Yes variants diff --git a/src/utils/validation.rs b/src/utils/validation.rs index 8a0e964..2dfac4f 100644 --- a/src/utils/validation.rs +++ b/src/utils/validation.rs @@ -2,8 +2,6 @@ //! //! Переиспользуемые валидаторы для проверки пользовательского ввода. -use crate::types::{ChatId, MessageId, UserId}; - /// Проверяет, что строка не пустая (после trim). /// /// # Examples @@ -20,112 +18,6 @@ pub fn is_non_empty(text: &str) -> bool { !text.trim().is_empty() } -/// Проверяет, что текст не превышает максимальную длину. -/// -/// # Arguments -/// -/// * `text` - текст для проверки -/// * `max_length` - максимальная длина в символах -/// -/// # Examples -/// -/// ``` -/// use tele_tui::utils::validation::is_within_length; -/// -/// assert!(is_within_length("hello", 10)); -/// assert!(!is_within_length("very long text here", 5)); -/// ``` -pub fn is_within_length(text: &str, max_length: usize) -> bool { - text.chars().count() <= max_length -} - -/// Проверяет валидность ID чата (не нулевой). -/// -/// # Examples -/// -/// ``` -/// use tele_tui::types::ChatId; -/// use tele_tui::utils::validation::is_valid_chat_id; -/// -/// assert!(is_valid_chat_id(ChatId::new(123))); -/// assert!(!is_valid_chat_id(ChatId::new(0))); -/// assert!(!is_valid_chat_id(ChatId::new(-1))); -/// ``` -pub fn is_valid_chat_id(chat_id: ChatId) -> bool { - chat_id.as_i64() > 0 -} - -/// Проверяет валидность ID сообщения (не нулевой). -/// -/// # Examples -/// -/// ``` -/// use tele_tui::types::MessageId; -/// use tele_tui::utils::validation::is_valid_message_id; -/// -/// assert!(is_valid_message_id(MessageId::new(456))); -/// assert!(!is_valid_message_id(MessageId::new(0))); -/// ``` -pub fn is_valid_message_id(message_id: MessageId) -> bool { - message_id.as_i64() > 0 -} - -/// Проверяет валидность ID пользователя (не нулевой). -/// -/// # Examples -/// -/// ``` -/// use tele_tui::types::UserId; -/// use tele_tui::utils::validation::is_valid_user_id; -/// -/// assert!(is_valid_user_id(UserId::new(789))); -/// assert!(!is_valid_user_id(UserId::new(0))); -/// ``` -pub fn is_valid_user_id(user_id: UserId) -> bool { - user_id.as_i64() > 0 -} - -/// Проверяет, что вектор не пустой. -/// -/// # Examples -/// -/// ``` -/// use tele_tui::utils::validation::has_items; -/// -/// assert!(has_items(&vec![1, 2, 3])); -/// assert!(!has_items::(&vec![])); -/// ``` -pub fn has_items(items: &[T]) -> bool { - !items.is_empty() -} - -/// Комбинированная валидация текстового ввода: -/// - Не пустой (после trim) -/// - В пределах максимальной длины -/// -/// # Examples -/// -/// ``` -/// use tele_tui::utils::validation::validate_text_input; -/// -/// assert!(validate_text_input("hello", 100).is_ok()); -/// assert!(validate_text_input("", 100).is_err()); -/// assert!(validate_text_input(" ", 100).is_err()); -/// assert!(validate_text_input("very long text", 5).is_err()); -/// ``` -pub fn validate_text_input(text: &str, max_length: usize) -> Result<(), String> { - if !is_non_empty(text) { - return Err("Text cannot be empty".to_string()); - } - if !is_within_length(text, max_length) { - return Err(format!( - "Text exceeds maximum length of {} characters", - max_length - )); - } - Ok(()) -} - #[cfg(test)] mod tests { use super::*; @@ -138,54 +30,4 @@ mod tests { assert!(!is_non_empty(" ")); assert!(!is_non_empty("\t\n")); } - - #[test] - fn test_is_within_length() { - assert!(is_within_length("hello", 10)); - assert!(is_within_length("hello", 5)); - assert!(!is_within_length("hello", 4)); - assert!(is_within_length("", 0)); - } - - #[test] - fn test_is_valid_chat_id() { - assert!(is_valid_chat_id(ChatId::new(123))); - assert!(is_valid_chat_id(ChatId::new(999999))); - assert!(!is_valid_chat_id(ChatId::new(0))); - assert!(!is_valid_chat_id(ChatId::new(-1))); - } - - #[test] - fn test_is_valid_message_id() { - assert!(is_valid_message_id(MessageId::new(456))); - assert!(!is_valid_message_id(MessageId::new(0))); - assert!(!is_valid_message_id(MessageId::new(-1))); - } - - #[test] - fn test_is_valid_user_id() { - assert!(is_valid_user_id(UserId::new(789))); - assert!(!is_valid_user_id(UserId::new(0))); - } - - #[test] - fn test_has_items() { - assert!(has_items(&vec![1, 2, 3])); - assert!(has_items(&vec!["a"])); - assert!(!has_items::(&vec![])); - } - - #[test] - fn test_validate_text_input() { - // Valid - assert!(validate_text_input("hello", 100).is_ok()); - assert!(validate_text_input("test message", 20).is_ok()); - - // Empty - assert!(validate_text_input("", 100).is_err()); - assert!(validate_text_input(" ", 100).is_err()); - - // Too long - assert!(validate_text_input("very long text", 5).is_err()); - } } diff --git a/tests/helpers/app_builder.rs b/tests/helpers/app_builder.rs index ec0449d..0c8c569 100644 --- a/tests/helpers/app_builder.rs +++ b/tests/helpers/app_builder.rs @@ -175,7 +175,6 @@ impl TestAppBuilder { pub fn forward_mode(mut self, message_id: i64) -> Self { self.chat_state = Some(ChatState::Forward { message_id: MessageId::new(message_id), - selecting_chat: true, }); self }