diff --git a/CONTEXT.md b/CONTEXT.md index 71ce360..7d7bc59 100644 --- a/CONTEXT.md +++ b/CONTEXT.md @@ -1,6 +1,43 @@ # Текущий контекст проекта -## Статус: Фаза 12 — Прослушивание голосовых сообщений (DONE) +## Статус: Multiline Message Display (DONE) + +### Multiline в сообщениях + +- **Multiline в сообщениях**: `\n` корректно отображается в пузырях сообщений (split по `\n` + word wrap) +- **Маркер выделения**: ▶ показывается только на первой строке multiline-сообщения +- Перенос строки в инпуте отключён (Shift+Enter/Alt+Enter/Ctrl+J не вставляют `\n`) + +**Файлы изменены:** +- `ui/components/message_bubble.rs` — `wrap_text_with_offsets()` split по `\n` + `wrap_paragraph()` + selection marker fix + +--- + +### Vim Normal/Insert Mode (DONE) + +Реализован Vim-like режим работы с двумя состояниями: + +- **Normal mode** (по умолчанию при открытии чата): навигация j/k, команды d/r/f/y, автоматический вход в MessageSelection +- **Insert mode** (нажать `i`/`ш`): набор текста, Esc возвращает в Normal +- Автопереключение в Insert при Reply (`r`) и Edit (`Enter`) +- Визуальные индикаторы: `[NORMAL]`/`[INSERT]` в footer, зелёная/серая рамка compose bar +- В Insert mode блокируются все команды кроме текстового ввода и Esc + +**Файлы изменены:** +- `app/chat_state.rs` — enum `InputMode` +- `app/mod.rs` — поле `input_mode` в `App` +- `config/keybindings.rs` — `Command::EnterInsertMode` + keybinding `i`/`ш` +- `app/methods/navigation.rs` — `close_chat()` сбрасывает input_mode +- `input/main_input.rs` — главный роутер Insert/Normal +- `input/handlers/chat.rs` — EnterInsertMode, auto-Insert при Reply/Edit +- `input/handlers/chat_list.rs` — auto-MessageSelection при открытии чата +- `ui/footer.rs` — mode indicator +- `ui/compose_bar.rs` — visual mode differentiation +- `tests/` — обновлены для нового поведения + +--- + +## Предыдущий статус: Фаза 12 — Прослушивание голосовых сообщений (DONE) ### Завершённые фазы (краткий итог) diff --git a/src/app/chat_state.rs b/src/app/chat_state.rs index f6cb3c8..1f67e54 100644 --- a/src/app/chat_state.rs +++ b/src/app/chat_state.rs @@ -3,6 +3,16 @@ use crate::tdlib::{MessageInfo, ProfileInfo}; use crate::types::MessageId; +/// Vim-like input mode for chat view +#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)] +pub enum InputMode { + /// Normal mode — navigation and commands (default) + #[default] + Normal, + /// Insert mode — text input only + Insert, +} + /// Состояния чата - взаимоисключающие режимы работы с чатом #[derive(Debug, Clone)] pub enum ChatState { diff --git a/src/app/methods/navigation.rs b/src/app/methods/navigation.rs index fb0e203..7e66a97 100644 --- a/src/app/methods/navigation.rs +++ b/src/app/methods/navigation.rs @@ -2,7 +2,7 @@ //! //! Handles chat list navigation and selection -use crate::app::{App, ChatState}; +use crate::app::{App, ChatState, InputMode}; use crate::app::methods::search::SearchMethods; use crate::tdlib::TdClientTrait; @@ -84,6 +84,7 @@ impl NavigationMethods for App { self.last_typing_sent = None; // Сбрасываем состояние чата в нормальный режим self.chat_state = ChatState::Normal; + self.input_mode = InputMode::Normal; // Очищаем данные в TdClient self.td_client.set_current_chat_id(None); self.td_client.clear_current_chat_messages(); diff --git a/src/app/mod.rs b/src/app/mod.rs index b918e5a..b1c8fb7 100644 --- a/src/app/mod.rs +++ b/src/app/mod.rs @@ -9,7 +9,7 @@ mod state; pub mod methods; pub use chat_filter::{ChatFilter, ChatFilterCriteria}; -pub use chat_state::ChatState; +pub use chat_state::{ChatState, InputMode}; pub use state::AppScreen; pub use methods::*; @@ -60,6 +60,8 @@ pub struct App { pub td_client: T, /// Состояние чата - type-safe state machine (новое!) pub chat_state: ChatState, + /// Vim-like input mode: Normal (navigation) / Insert (text input) + pub input_mode: InputMode, // Auth state (приватные, доступ через геттеры) phone_input: String, code_input: String, @@ -144,6 +146,7 @@ impl App { screen: AppScreen::Loading, td_client, chat_state: ChatState::Normal, + input_mode: InputMode::Normal, phone_input: String::new(), code_input: String::new(), password_input: String::new(), diff --git a/src/config/keybindings.rs b/src/config/keybindings.rs index fcc0e81..e2e7833 100644 --- a/src/config/keybindings.rs +++ b/src/config/keybindings.rs @@ -65,6 +65,9 @@ pub enum Command { MoveToStart, MoveToEnd, + // Vim mode + EnterInsertMode, + // Profile OpenProfile, } @@ -100,6 +103,13 @@ impl KeyBinding { } } + pub fn with_alt(key: KeyCode) -> Self { + Self { + key, + modifiers: KeyModifiers::ALT, + } + } + pub fn matches(&self, event: &KeyEvent) -> bool { self.key == event.code && self.modifiers == event.modifiers } @@ -234,9 +244,7 @@ impl Keybindings { bindings.insert(Command::Cancel, vec![ KeyBinding::new(KeyCode::Esc), ]); - bindings.insert(Command::NewLine, vec![ - KeyBinding::with_shift(KeyCode::Enter), - ]); + bindings.insert(Command::NewLine, vec![]); bindings.insert(Command::DeleteChar, vec![ KeyBinding::new(KeyCode::Backspace), ]); @@ -253,6 +261,12 @@ impl Keybindings { KeyBinding::with_ctrl(KeyCode::Char('e')), ]); + // Vim mode + bindings.insert(Command::EnterInsertMode, vec![ + KeyBinding::new(KeyCode::Char('i')), + KeyBinding::new(KeyCode::Char('ш')), // RU + ]); + // Profile bindings.insert(Command::OpenProfile, vec![ KeyBinding::with_ctrl(KeyCode::Char('u')), // Ctrl+U instead of Ctrl+I diff --git a/src/input/handlers/chat.rs b/src/input/handlers/chat.rs index d986921..fd876c1 100644 --- a/src/input/handlers/chat.rs +++ b/src/input/handlers/chat.rs @@ -7,6 +7,7 @@ //! - Loading older messages use crate::app::App; +use crate::app::InputMode; use crate::app::methods::{ compose::ComposeMethods, messages::MessageMethods, modal::ModalMethods, navigation::NavigationMethods, @@ -48,8 +49,13 @@ pub async fn handle_message_selection(app: &mut App, _key: }; } } + Some(crate::config::Command::EnterInsertMode) => { + app.input_mode = InputMode::Insert; + app.chat_state = crate::app::ChatState::Normal; + } Some(crate::config::Command::ReplyMessage) => { app.start_reply_to_selected(); + app.input_mode = InputMode::Insert; } Some(crate::config::Command::ForwardMessage) => { app.start_forward_selected(); @@ -243,7 +249,9 @@ pub async fn handle_enter_key(app: &mut App) { // Сценарий 2: Режим выбора сообщения - начать редактирование if app.is_selecting_message() { - if !app.start_editing_selected() { + if app.start_editing_selected() { + app.input_mode = InputMode::Insert; + } else { // Нельзя редактировать это сообщение app.chat_state = crate::app::ChatState::Normal; } @@ -452,24 +460,16 @@ pub async fn handle_open_chat_keyboard_input(app: &mut App, // Курсор в конец app.cursor_position = app.message_input.chars().count(); } - // Стрелки вверх/вниз - скролл сообщений или начало выбора + // Стрелки вверх/вниз - скролл сообщений (в Insert mode) 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; - } + // В Insert mode — только скролл + app.message_scroll_offset += 3; + load_older_messages_if_needed(app).await; } _ => {} } diff --git a/src/input/handlers/chat_list.rs b/src/input/handlers/chat_list.rs index 5bfa34a..0bd8fbf 100644 --- a/src/input/handlers/chat_list.rs +++ b/src/input/handlers/chat_list.rs @@ -6,7 +6,8 @@ //! - Opening chats use crate::app::App; -use crate::app::methods::{compose::ComposeMethods, navigation::NavigationMethods}; +use crate::app::InputMode; +use crate::app::methods::{compose::ComposeMethods, messages::MessageMethods, navigation::NavigationMethods}; use crate::tdlib::TdClientTrait; use crate::types::{ChatId, MessageId}; use crate::utils::{with_timeout, with_timeout_msg, with_timeout_ignore}; @@ -135,6 +136,10 @@ pub async fn open_chat_and_load_data(app: &mut App, chat_id // Загружаем черновик app.load_draft(); app.status_message = None; + + // Vim mode: Normal + MessageSelection по умолчанию + app.input_mode = InputMode::Normal; + app.start_message_selection(); } Err(e) => { app.error_message = Some(e); diff --git a/src/input/main_input.rs b/src/input/main_input.rs index cf063e3..950eb8d 100644 --- a/src/input/main_input.rs +++ b/src/input/main_input.rs @@ -4,6 +4,7 @@ //! Priority order: modals → search → compose → chat → chat list. use crate::app::App; +use crate::app::InputMode; use crate::app::methods::{ compose::ComposeMethods, messages::MessageMethods, @@ -30,14 +31,10 @@ use crossterm::event::KeyEvent; -/// Обработка клавиши Esc -/// -/// Обрабатывает отмену текущего действия или закрытие чата: -/// - В режиме выбора сообщения: отменить выбор -/// - В режиме редактирования: отменить редактирование -/// - В режиме ответа: отменить ответ -/// - В открытом чате: сохранить черновик и закрыть чат -async fn handle_escape_key(app: &mut App) { +/// Обработка клавиши Esc в Normal mode +/// +/// Закрывает чат с сохранением черновика +async fn handle_escape_normal(app: &mut App) { // Закрываем модальное окно изображения если открыто #[cfg(feature = "images")] if app.image_modal.is_some() { @@ -46,34 +43,16 @@ async fn handle_escape_key(app: &mut App) { return; } - // Early return для режима выбора сообщения - if app.is_selecting_message() { - app.chat_state = crate::app::ChatState::Normal; - return; - } - - // Early return для режима редактирования - if app.is_editing() { - app.cancel_editing(); - return; - } - - // Early return для режима ответа - if app.is_replying() { - app.cancel_reply(); - return; - } - // Закрытие чата с сохранением черновика let Some(chat_id) = app.selected_chat_id else { return; }; // Сохраняем черновик если есть текст в инпуте - if !app.message_input.is_empty() && !app.is_editing() && !app.is_replying() { + if !app.message_input.is_empty() { let draft_text = app.message_input.clone(); let _ = app.td_client.set_draft_message(chat_id, draft_text).await; - } else if app.message_input.is_empty() { + } else { // Очищаем черновик если инпут пустой let _ = app.td_client.set_draft_message(chat_id, String::new()).await; } @@ -81,79 +60,169 @@ async fn handle_escape_key(app: &mut App) { app.close_chat(); } +/// Обработка клавиши Esc в Insert mode +/// +/// Отменяет Reply/Editing и возвращает в Normal + MessageSelection +fn handle_escape_insert(app: &mut App) { + if app.is_editing() { + app.cancel_editing(); + } + if app.is_replying() { + app.cancel_reply(); + } + app.input_mode = InputMode::Normal; + app.start_message_selection(); +} + /// Главный обработчик ввода - роутер для всех режимов приложения pub async fn handle(app: &mut App, key: KeyEvent) { - // Глобальные команды (работают всегда) + let command = app.get_command(key); + + // 1. Insert mode + чат открыт → только текст, Enter, Esc + // (Ctrl+C обрабатывается в main.rs до вызова router) + if app.selected_chat_id.is_some() && app.input_mode == InputMode::Insert { + // Модальные окна всё равно обрабатываем (image modal, delete confirmation etc.) + #[cfg(feature = "images")] + if app.image_modal.is_some() { + handle_image_modal_mode(app, key).await; + return; + } + if app.is_confirm_delete_shown() { + handle_delete_confirmation(app, key).await; + return; + } + if app.is_reaction_picker_mode() { + handle_reaction_picker_mode(app, key, command).await; + return; + } + if app.is_profile_mode() { + handle_profile_mode(app, key, command).await; + return; + } + if app.is_message_search_mode() { + handle_message_search_mode(app, key, command).await; + return; + } + if app.is_pinned_mode() { + handle_pinned_mode(app, key, command).await; + return; + } + if app.is_forwarding() { + handle_forward_mode(app, key, command).await; + return; + } + + match command { + Some(crate::config::Command::Cancel) => { + handle_escape_insert(app); + return; + } + Some(crate::config::Command::SubmitMessage) => { + handle_enter_key(app).await; + return; + } + Some(crate::config::Command::DeleteWord) => { + // Ctrl+W → удалить слово + if app.cursor_position > 0 { + let chars: Vec = app.message_input.chars().collect(); + let mut new_pos = app.cursor_position; + // Пропускаем пробелы + while new_pos > 0 && chars[new_pos - 1] == ' ' { + new_pos -= 1; + } + // Пропускаем слово + while new_pos > 0 && chars[new_pos - 1] != ' ' { + new_pos -= 1; + } + let new_input: String = chars[..new_pos] + .iter() + .chain(chars[app.cursor_position..].iter()) + .collect(); + app.message_input = new_input; + app.cursor_position = new_pos; + } + return; + } + Some(crate::config::Command::MoveToStart) => { + app.cursor_position = 0; + return; + } + Some(crate::config::Command::MoveToEnd) => { + app.cursor_position = app.message_input.chars().count(); + return; + } + _ => {} + } + // Весь остальной ввод → текст + handle_open_chat_keyboard_input(app, key).await; + return; + } + + // 3. Глобальные команды (Ctrl+R, Ctrl+S, Ctrl+P, Ctrl+F) if handle_global_commands(app, key).await { return; } - // Получаем команду из keybindings - let command = app.get_command(key); - - // Модальное окно просмотра изображения (приоритет высокий) + // 4. Модальное окно просмотра изображения #[cfg(feature = "images")] if app.image_modal.is_some() { handle_image_modal_mode(app, key).await; return; } - // Режим профиля + // 5. Режим профиля if app.is_profile_mode() { handle_profile_mode(app, key, command).await; return; } - // Режим поиска по сообщениям + // 6. Режим поиска по сообщениям if app.is_message_search_mode() { handle_message_search_mode(app, key, command).await; return; } - // Режим просмотра закреплённых сообщений + // 7. Режим просмотра закреплённых сообщений if app.is_pinned_mode() { handle_pinned_mode(app, key, command).await; return; } - // Обработка ввода в режиме выбора реакции + // 8. Обработка ввода в режиме выбора реакции if app.is_reaction_picker_mode() { handle_reaction_picker_mode(app, key, command).await; return; } - // Модалка подтверждения удаления + // 9. Модалка подтверждения удаления if app.is_confirm_delete_shown() { handle_delete_confirmation(app, key).await; return; } - // Режим выбора чата для пересылки + // 10. Режим выбора чата для пересылки if app.is_forwarding() { handle_forward_mode(app, key, command).await; return; } - // Режим поиска + // 11. Режим поиска чатов if app.is_searching { handle_chat_search_mode(app, key, command).await; return; } - // Обработка команд через keybindings + // 12. Normal mode commands (Enter, Esc, Profile) 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; + handle_escape_normal(app).await; return; } Some(crate::config::Command::OpenProfile) => { - // Открыть профиль (обычно 'i') if app.selected_chat_id.is_some() { handle_profile_open(app).await; return; @@ -162,17 +231,15 @@ pub async fn handle(app: &mut App, key: KeyEvent) { _ => {} } - // Режим открытого чата + // 13. Normal mode в чате → MessageSelection if app.selected_chat_id.is_some() { - // Режим выбора сообщения для редактирования/удаления - if app.is_selecting_message() { - handle_message_selection(app, key, command).await; - return; + // Auto-enter MessageSelection if not already in it + if !app.is_selecting_message() { + app.start_message_selection(); } - - handle_open_chat_keyboard_input(app, key).await; + handle_message_selection(app, key, command).await; } else { - // В режиме списка чатов - навигация стрелками и переключение папок + // 14. Список чатов handle_chat_list_navigation(app, key, command).await; } } diff --git a/src/ui/components/message_bubble.rs b/src/ui/components/message_bubble.rs index 8143f79..60d4058 100644 --- a/src/ui/components/message_bubble.rs +++ b/src/ui/components/message_bubble.rs @@ -24,19 +24,40 @@ struct WrappedLine { start_offset: usize, } -/// Разбивает текст на строки с учётом максимальной ширины +/// Разбивает текст на строки с учётом максимальной ширины и `\n` fn wrap_text_with_offsets(text: &str, max_width: usize) -> Vec { + let mut all_lines = Vec::new(); + let mut char_offset = 0; + + for segment in text.split('\n') { + let wrapped = wrap_paragraph(segment, max_width, char_offset); + all_lines.extend(wrapped); + char_offset += segment.chars().count() + 1; // +1 за '\n' + } + + if all_lines.is_empty() { + all_lines.push(WrappedLine { + text: String::new(), + start_offset: 0, + }); + } + + all_lines +} + +/// Разбивает один абзац (без `\n`) на строки по ширине +fn wrap_paragraph(text: &str, max_width: usize, base_offset: usize) -> Vec { if max_width == 0 { return vec![WrappedLine { text: text.to_string(), - start_offset: 0, + start_offset: base_offset, }]; } let mut result = Vec::new(); let mut current_line = String::new(); let mut current_width = 0; - let mut line_start_offset = 0; + let mut line_start_offset = base_offset; let chars: Vec = text.chars().collect(); let mut word_start = 0; @@ -51,7 +72,7 @@ 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; + line_start_offset = base_offset + word_start; } else if current_width + 1 + word_width <= max_width { current_line.push(' '); current_line.push_str(&word); @@ -63,7 +84,7 @@ fn wrap_text_with_offsets(text: &str, max_width: usize) -> Vec { }); current_line = word; current_width = word_width; - line_start_offset = word_start; + line_start_offset = base_offset + word_start; } in_word = false; } @@ -79,7 +100,7 @@ fn wrap_text_with_offsets(text: &str, max_width: usize) -> Vec { if current_width == 0 { current_line = word; - line_start_offset = word_start; + line_start_offset = base_offset + word_start; } else if current_width + 1 + word_width <= max_width { current_line.push(' '); current_line.push_str(&word); @@ -89,7 +110,7 @@ fn wrap_text_with_offsets(text: &str, max_width: usize) -> Vec { start_offset: line_start_offset, }); current_line = word; - line_start_offset = word_start; + line_start_offset = base_offset + word_start; } } @@ -103,7 +124,7 @@ fn wrap_text_with_offsets(text: &str, max_width: usize) -> Vec { if result.is_empty() { result.push(WrappedLine { text: String::new(), - start_offset: 0, + start_offset: base_offset, }); } @@ -288,11 +309,15 @@ pub fn render_message_bubble( let full_len = line_len + time_mark_len + marker_len; let padding = content_width.saturating_sub(full_len + 1); let mut line_spans = vec![Span::raw(" ".repeat(padding))]; - if is_selected { + if is_selected && i == 0 { + // Одна строка — маркер на ней line_spans.push(Span::styled( selection_marker, Style::default().fg(Color::Yellow).add_modifier(Modifier::BOLD), )); + } else if is_selected { + // Последняя строка multi-line — пробелы вместо маркера + line_spans.push(Span::raw(" ".repeat(marker_len))); } line_spans.extend(formatted_spans); line_spans.push(Span::styled(format!(" {}", time_mark), Style::default().fg(Color::Gray))); @@ -305,6 +330,9 @@ pub fn render_message_bubble( selection_marker, Style::default().fg(Color::Yellow).add_modifier(Modifier::BOLD), )); + } else if is_selected { + // Средние строки multi-line — пробелы вместо маркера + line_spans.push(Span::raw(" ".repeat(marker_len))); } line_spans.extend(formatted_spans); lines.push(Line::from(line_spans)); diff --git a/src/ui/compose_bar.rs b/src/ui/compose_bar.rs index c8407ee..daa5224 100644 --- a/src/ui/compose_bar.rs +++ b/src/ui/compose_bar.rs @@ -1,6 +1,7 @@ //! Compose bar / input box rendering use crate::app::App; +use crate::app::InputMode; use crate::app::methods::{compose::ComposeMethods, messages::MessageMethods}; use crate::tdlib::TdClientTrait; use crate::ui::components; @@ -19,13 +20,12 @@ fn render_input_with_cursor( cursor_pos: usize, color: Color, ) -> Line<'static> { - // Используем компонент input_field components::render_input_field(prefix, text, cursor_pos, color) } /// Renders input box with support for different modes (forward/select/edit/reply/normal) pub fn render(f: &mut Frame, area: Rect, app: &App) { - let (input_line, input_title) = if app.is_forwarding() { + let (input_line, input_title): (Line, &str) = if app.is_forwarding() { // Режим пересылки - показываем превью сообщения let forward_preview = app .get_forwarding_message() @@ -67,7 +67,6 @@ pub fn render(f: &mut Frame, area: Rect, app: &App) { } else if app.is_editing() { // Режим редактирования if app.message_input.is_empty() { - // Пустой инпут - показываем курсор и placeholder let line = Line::from(vec![ Span::raw("✏ "), Span::styled("█", Style::default().fg(Color::Magenta)), @@ -75,7 +74,6 @@ pub fn render(f: &mut Frame, area: Rect, app: &App) { ]); (line, " Редактирование (Esc отмена) ") } else { - // Текст с курсором let line = render_input_with_cursor( "✏ ", &app.message_input, @@ -123,10 +121,25 @@ pub fn render(f: &mut Frame, area: Rect, app: &App) { ); (line, " Ответ (Esc отмена) ") } - } else { - // Обычный режим + } else if app.input_mode == InputMode::Normal { + // Normal mode — dim, no cursor + if app.message_input.is_empty() { + let line = Line::from(vec![ + Span::styled("> Press i to type...", Style::default().fg(Color::DarkGray)), + ]); + (line, "") + } else { + let draft_preview: String = app.message_input.chars().take(60).collect(); + let ellipsis = if app.message_input.chars().count() > 60 { "..." } else { "" }; + let line = Line::from(Span::styled( + format!("> {}{}", draft_preview, ellipsis), + Style::default().fg(Color::DarkGray), + )); + (line, "") + } + } else { + // Insert mode — active, with cursor if app.message_input.is_empty() { - // Пустой инпут - показываем курсор и placeholder let line = Line::from(vec![ Span::raw("> "), Span::styled("█", Style::default().fg(Color::Yellow)), @@ -134,7 +147,6 @@ pub fn render(f: &mut Frame, area: Rect, app: &App) { ]); (line, "") } else { - // Текст с курсором let line = render_input_with_cursor( "> ", &app.message_input, @@ -146,7 +158,12 @@ pub fn render(f: &mut Frame, area: Rect, app: &App) { }; let input_block = if input_title.is_empty() { - Block::default().borders(Borders::ALL) + let border_style = if app.input_mode == InputMode::Insert { + Style::default().fg(Color::Green) + } else { + Style::default().fg(Color::DarkGray) + }; + Block::default().borders(Borders::ALL).border_style(border_style) } else { let title_color = if app.is_replying() || app.is_forwarding() { Color::Cyan diff --git a/src/ui/footer.rs b/src/ui/footer.rs index 34ee9f1..d3837fa 100644 --- a/src/ui/footer.rs +++ b/src/ui/footer.rs @@ -1,4 +1,5 @@ use crate::app::App; +use crate::app::InputMode; use crate::tdlib::TdClientTrait; use crate::tdlib::NetworkState; use ratatui::{ @@ -25,7 +26,11 @@ pub fn render(f: &mut Frame, area: Rect, app: &App) { } else if app.is_searching { format!(" {}↑/↓: Navigate | Enter: Select | Esc: Cancel ", network_indicator) } else if app.selected_chat_id.is_some() { - format!(" {}↑/↓: Scroll | Ctrl+U: Profile | Enter: Send | Esc: Close | Ctrl+R: Refresh | Ctrl+C: Quit ", network_indicator) + let mode_str = match app.input_mode { + InputMode::Normal => "[NORMAL] j/k: Nav | i: Insert | d/r/f/y: Actions | Esc: Close", + InputMode::Insert => "[INSERT] Type message | Esc: Normal mode", + }; + format!(" {}{} | Ctrl+C: Quit ", network_indicator, mode_str) } else { format!( " {}↑/↓: Navigate | Enter: Open | Ctrl+S: Search | Ctrl+R: Refresh | Ctrl+C: Quit ", diff --git a/src/ui/messages.rs b/src/ui/messages.rs index 3a9f4d2..b9121e6 100644 --- a/src/ui/messages.rs +++ b/src/ui/messages.rs @@ -385,9 +385,9 @@ pub fn render(f: &mut Frame, area: Rect, app: &mut App) { if let Some(chat) = app.get_selected_chat().cloned() { // Вычисляем динамическую высоту инпута на основе длины текста let input_width = area.width.saturating_sub(4) as usize; // -2 для рамок, -2 для "> " - let input_text_len = app.message_input.chars().count() + 2; // +2 для "> " - let input_lines = if input_width > 0 { - ((input_text_len as f32 / input_width as f32).ceil() as u16).max(1) + let input_lines: u16 = if input_width > 0 { + let len = app.message_input.chars().count() + 2; // +2 для "> " + ((len as f32 / input_width as f32).ceil() as u16).max(1) } else { 1 }; diff --git a/tests/helpers/app_builder.rs b/tests/helpers/app_builder.rs index f38803c..e89f286 100644 --- a/tests/helpers/app_builder.rs +++ b/tests/helpers/app_builder.rs @@ -3,7 +3,7 @@ use ratatui::widgets::ListState; use std::collections::HashMap; use super::FakeTdClient; -use tele_tui::app::{App, AppScreen, ChatState}; +use tele_tui::app::{App, AppScreen, ChatState, InputMode}; use tele_tui::config::Config; use tele_tui::tdlib::AuthState; use tele_tui::tdlib::{ChatInfo, MessageInfo}; @@ -19,6 +19,7 @@ pub struct TestAppBuilder { is_searching: bool, search_query: String, chat_state: Option, + input_mode: Option, messages: HashMap>, status_message: Option, auth_state: Option, @@ -44,6 +45,7 @@ impl TestAppBuilder { is_searching: false, search_query: String::new(), chat_state: None, + input_mode: None, messages: HashMap::new(), status_message: None, auth_state: None, @@ -171,6 +173,12 @@ impl TestAppBuilder { self } + /// Установить Insert mode + pub fn insert_mode(mut self) -> Self { + self.input_mode = Some(InputMode::Insert); + self + } + /// Режим пересылки сообщения pub fn forward_mode(mut self, message_id: i64) -> Self { self.chat_state = Some(ChatState::Forward { @@ -252,6 +260,11 @@ impl TestAppBuilder { app.chat_state = chat_state; } + // Применяем input_mode если он установлен + if let Some(input_mode) = self.input_mode { + app.input_mode = input_mode; + } + // Применяем status_message if let Some(status) = self.status_message { app.status_message = Some(status); diff --git a/tests/input_field.rs b/tests/input_field.rs index ea89687..c9212c7 100644 --- a/tests/input_field.rs +++ b/tests/input_field.rs @@ -31,6 +31,7 @@ fn snapshot_input_with_text() { let mut app = TestAppBuilder::new() .with_chat(chat) .selected_chat(123) + .insert_mode() .message_input("Hello, how are you?") .build(); @@ -52,6 +53,7 @@ fn snapshot_input_long_text_2_lines() { let mut app = TestAppBuilder::new() .with_chat(chat) .selected_chat(123) + .insert_mode() .message_input(long_text) .build(); @@ -73,6 +75,7 @@ fn snapshot_input_long_text_max_lines() { let mut app = TestAppBuilder::new() .with_chat(chat) .selected_chat(123) + .insert_mode() .message_input(very_long_text) .build(); @@ -95,6 +98,7 @@ fn snapshot_input_editing_mode() { .with_chat(chat) .with_message(123, message) .selected_chat(123) + .insert_mode() .editing_message(1, 0) .message_input("Edited text here") .build(); @@ -118,6 +122,7 @@ fn snapshot_input_reply_mode() { .with_chat(chat) .with_message(123, original_msg) .selected_chat(123) + .insert_mode() .replying_to(1) .message_input("I think it's great!") .build(); diff --git a/tests/input_navigation.rs b/tests/input_navigation.rs index 7051376..98dbbec 100644 --- a/tests/input_navigation.rs +++ b/tests/input_navigation.rs @@ -145,6 +145,7 @@ async fn test_cursor_navigation_in_input() { let mut app = TestAppBuilder::new() .with_chats(vec![create_test_chat("Chat 1", 101)]) .selected_chat(101) + .insert_mode() .build(); // Вводим текст "Hello" @@ -182,6 +183,7 @@ async fn test_home_end_in_input() { let mut app = TestAppBuilder::new() .with_chats(vec![create_test_chat("Chat 1", 101)]) .selected_chat(101) + .insert_mode() .build(); // Вводим текст @@ -206,6 +208,7 @@ async fn test_backspace_with_cursor() { let mut app = TestAppBuilder::new() .with_chats(vec![create_test_chat("Chat 1", 101)]) .selected_chat(101) + .insert_mode() .build(); // Вводим "Hello" @@ -238,6 +241,7 @@ async fn test_insert_char_at_cursor_position() { let mut app = TestAppBuilder::new() .with_chats(vec![create_test_chat("Chat 1", 101)]) .selected_chat(101) + .insert_mode() .build(); // Вводим "Hllo" @@ -259,25 +263,25 @@ async fn test_insert_char_at_cursor_position() { assert_eq!(app.cursor_position, 2); } -/// Test: Навигация вверх по сообщениям из пустого инпута +/// Test: Normal mode автоматически входит в MessageSelection #[tokio::test] -async fn test_up_arrow_selects_last_message_when_input_empty() { +async fn test_normal_mode_auto_enters_message_selection() { let messages = vec![ TestMessageBuilder::new("Msg 1", 1).outgoing().build(), TestMessageBuilder::new("Msg 2", 2).outgoing().build(), TestMessageBuilder::new("Msg 3", 3).outgoing().build(), ]; - + let mut app = TestAppBuilder::new() .with_chats(vec![create_test_chat("Chat 1", 101)]) .selected_chat(101) .with_messages(101, messages) .build(); - // Инпут пустой + // Инпут пустой, Normal mode assert_eq!(app.message_input, ""); - // Up - должен начать выбор сообщения (последнего) + // Любая клавиша в Normal mode — auto-enters MessageSelection handle_main_input(&mut app, key(KeyCode::Up)).await; // Проверяем что вошли в режим выбора сообщения diff --git a/tests/snapshots/input_field__empty_input.snap b/tests/snapshots/input_field__empty_input.snap index c988f85..6e3e581 100644 --- a/tests/snapshots/input_field__empty_input.snap +++ b/tests/snapshots/input_field__empty_input.snap @@ -24,5 +24,5 @@ expression: output │ │ └──────────────────────────────────────────────────────────────────────────────┘ ┌──────────────────────────────────────────────────────────────────────────────┐ -│> █ Введите сообщение... │ +│> Press i to type... │ └──────────────────────────────────────────────────────────────────────────────┘ diff --git a/tests/snapshots/messages__date_separator_old_date.snap b/tests/snapshots/messages__date_separator_old_date.snap index c208a55..7236593 100644 --- a/tests/snapshots/messages__date_separator_old_date.snap +++ b/tests/snapshots/messages__date_separator_old_date.snap @@ -24,5 +24,5 @@ expression: output │ │ └──────────────────────────────────────────────────────────────────────────────┘ ┌──────────────────────────────────────────────────────────────────────────────┐ -│> █ Введите сообщение... │ +│> Press i to type... │ └──────────────────────────────────────────────────────────────────────────────┘ diff --git a/tests/snapshots/messages__edited_message.snap b/tests/snapshots/messages__edited_message.snap index ae43e84..c98497c 100644 --- a/tests/snapshots/messages__edited_message.snap +++ b/tests/snapshots/messages__edited_message.snap @@ -24,5 +24,5 @@ expression: output │ │ └──────────────────────────────────────────────────────────────────────────────┘ ┌──────────────────────────────────────────────────────────────────────────────┐ -│> █ Введите сообщение... │ +│> Press i to type... │ └──────────────────────────────────────────────────────────────────────────────┘ diff --git a/tests/snapshots/messages__empty_chat.snap b/tests/snapshots/messages__empty_chat.snap index 1215be2..6390b52 100644 --- a/tests/snapshots/messages__empty_chat.snap +++ b/tests/snapshots/messages__empty_chat.snap @@ -24,5 +24,5 @@ expression: output │ │ └──────────────────────────────────────────────────────────────────────────────┘ ┌──────────────────────────────────────────────────────────────────────────────┐ -│> █ Введите сообщение... │ +│> Press i to type... │ └──────────────────────────────────────────────────────────────────────────────┘ diff --git a/tests/snapshots/messages__forwarded_message.snap b/tests/snapshots/messages__forwarded_message.snap index 810dff7..918bc8f 100644 --- a/tests/snapshots/messages__forwarded_message.snap +++ b/tests/snapshots/messages__forwarded_message.snap @@ -24,5 +24,5 @@ expression: output │ │ └──────────────────────────────────────────────────────────────────────────────┘ ┌──────────────────────────────────────────────────────────────────────────────┐ -│> █ Введите сообщение... │ +│> Press i to type... │ └──────────────────────────────────────────────────────────────────────────────┘ diff --git a/tests/snapshots/messages__long_message_wrap.snap b/tests/snapshots/messages__long_message_wrap.snap index b03e458..2beffe5 100644 --- a/tests/snapshots/messages__long_message_wrap.snap +++ b/tests/snapshots/messages__long_message_wrap.snap @@ -24,5 +24,5 @@ expression: output │ │ └──────────────────────────────────────────────────────────────────────────────┘ ┌──────────────────────────────────────────────────────────────────────────────┐ -│> █ Введите сообщение... │ +│> Press i to type... │ └──────────────────────────────────────────────────────────────────────────────┘ diff --git a/tests/snapshots/messages__markdown_bold_italic_code.snap b/tests/snapshots/messages__markdown_bold_italic_code.snap index 67b927b..cfe7134 100644 --- a/tests/snapshots/messages__markdown_bold_italic_code.snap +++ b/tests/snapshots/messages__markdown_bold_italic_code.snap @@ -24,5 +24,5 @@ expression: output │ │ └──────────────────────────────────────────────────────────────────────────────┘ ┌──────────────────────────────────────────────────────────────────────────────┐ -│> █ Введите сообщение... │ +│> Press i to type... │ └──────────────────────────────────────────────────────────────────────────────┘ diff --git a/tests/snapshots/messages__markdown_link_mention.snap b/tests/snapshots/messages__markdown_link_mention.snap index a6211be..aacbe63 100644 --- a/tests/snapshots/messages__markdown_link_mention.snap +++ b/tests/snapshots/messages__markdown_link_mention.snap @@ -24,5 +24,5 @@ expression: output │ │ └──────────────────────────────────────────────────────────────────────────────┘ ┌──────────────────────────────────────────────────────────────────────────────┐ -│> █ Введите сообщение... │ +│> Press i to type... │ └──────────────────────────────────────────────────────────────────────────────┘ diff --git a/tests/snapshots/messages__markdown_spoiler.snap b/tests/snapshots/messages__markdown_spoiler.snap index 8b8bac4..9458598 100644 --- a/tests/snapshots/messages__markdown_spoiler.snap +++ b/tests/snapshots/messages__markdown_spoiler.snap @@ -24,5 +24,5 @@ expression: output │ │ └──────────────────────────────────────────────────────────────────────────────┘ ┌──────────────────────────────────────────────────────────────────────────────┐ -│> █ Введите сообщение... │ +│> Press i to type... │ └──────────────────────────────────────────────────────────────────────────────┘ diff --git a/tests/snapshots/messages__media_placeholder.snap b/tests/snapshots/messages__media_placeholder.snap index aa6291a..210f9fd 100644 --- a/tests/snapshots/messages__media_placeholder.snap +++ b/tests/snapshots/messages__media_placeholder.snap @@ -24,5 +24,5 @@ expression: output │ │ └──────────────────────────────────────────────────────────────────────────────┘ ┌──────────────────────────────────────────────────────────────────────────────┐ -│> █ Введите сообщение... │ +│> Press i to type... │ └──────────────────────────────────────────────────────────────────────────────┘ diff --git a/tests/snapshots/messages__multiple_reactions.snap b/tests/snapshots/messages__multiple_reactions.snap index c8a2cf5..a8a8808 100644 --- a/tests/snapshots/messages__multiple_reactions.snap +++ b/tests/snapshots/messages__multiple_reactions.snap @@ -24,5 +24,5 @@ expression: output │ │ └──────────────────────────────────────────────────────────────────────────────┘ ┌──────────────────────────────────────────────────────────────────────────────┐ -│> █ Введите сообщение... │ +│> Press i to type... │ └──────────────────────────────────────────────────────────────────────────────┘ diff --git a/tests/snapshots/messages__outgoing_read.snap b/tests/snapshots/messages__outgoing_read.snap index 37da376..1b3077a 100644 --- a/tests/snapshots/messages__outgoing_read.snap +++ b/tests/snapshots/messages__outgoing_read.snap @@ -24,5 +24,5 @@ expression: output │ │ └──────────────────────────────────────────────────────────────────────────────┘ ┌──────────────────────────────────────────────────────────────────────────────┐ -│> █ Введите сообщение... │ +│> Press i to type... │ └──────────────────────────────────────────────────────────────────────────────┘ diff --git a/tests/snapshots/messages__outgoing_sent.snap b/tests/snapshots/messages__outgoing_sent.snap index c8586c1..8b001c0 100644 --- a/tests/snapshots/messages__outgoing_sent.snap +++ b/tests/snapshots/messages__outgoing_sent.snap @@ -24,5 +24,5 @@ expression: output │ │ └──────────────────────────────────────────────────────────────────────────────┘ ┌──────────────────────────────────────────────────────────────────────────────┐ -│> █ Введите сообщение... │ +│> Press i to type... │ └──────────────────────────────────────────────────────────────────────────────┘ diff --git a/tests/snapshots/messages__reply_message.snap b/tests/snapshots/messages__reply_message.snap index f4307c4..c0e65e8 100644 --- a/tests/snapshots/messages__reply_message.snap +++ b/tests/snapshots/messages__reply_message.snap @@ -24,5 +24,5 @@ expression: output │ │ └──────────────────────────────────────────────────────────────────────────────┘ ┌──────────────────────────────────────────────────────────────────────────────┐ -│> █ Введите сообщение... │ +│> Press i to type... │ └──────────────────────────────────────────────────────────────────────────────┘ diff --git a/tests/snapshots/messages__sender_grouping.snap b/tests/snapshots/messages__sender_grouping.snap index 345c13d..c2d894e 100644 --- a/tests/snapshots/messages__sender_grouping.snap +++ b/tests/snapshots/messages__sender_grouping.snap @@ -24,5 +24,5 @@ expression: output │ │ └──────────────────────────────────────────────────────────────────────────────┘ ┌──────────────────────────────────────────────────────────────────────────────┐ -│> █ Введите сообщение... │ +│> Press i to type... │ └──────────────────────────────────────────────────────────────────────────────┘ diff --git a/tests/snapshots/messages__single_incoming_message.snap b/tests/snapshots/messages__single_incoming_message.snap index 4eb04b1..9d23183 100644 --- a/tests/snapshots/messages__single_incoming_message.snap +++ b/tests/snapshots/messages__single_incoming_message.snap @@ -24,5 +24,5 @@ expression: output │ │ └──────────────────────────────────────────────────────────────────────────────┘ ┌──────────────────────────────────────────────────────────────────────────────┐ -│> █ Введите сообщение... │ +│> Press i to type... │ └──────────────────────────────────────────────────────────────────────────────┘ diff --git a/tests/snapshots/messages__single_outgoing_message.snap b/tests/snapshots/messages__single_outgoing_message.snap index 1221f7b..2736447 100644 --- a/tests/snapshots/messages__single_outgoing_message.snap +++ b/tests/snapshots/messages__single_outgoing_message.snap @@ -24,5 +24,5 @@ expression: output │ │ └──────────────────────────────────────────────────────────────────────────────┘ ┌──────────────────────────────────────────────────────────────────────────────┐ -│> █ Введите сообщение... │ +│> Press i to type... │ └──────────────────────────────────────────────────────────────────────────────┘ diff --git a/tests/snapshots/messages__single_reaction.snap b/tests/snapshots/messages__single_reaction.snap index b7f88e6..4c185b6 100644 --- a/tests/snapshots/messages__single_reaction.snap +++ b/tests/snapshots/messages__single_reaction.snap @@ -24,5 +24,5 @@ expression: output │ │ └──────────────────────────────────────────────────────────────────────────────┘ ┌──────────────────────────────────────────────────────────────────────────────┐ -│> █ Введите сообщение... │ +│> Press i to type... │ └──────────────────────────────────────────────────────────────────────────────┘ diff --git a/tests/snapshots/modals__delete_confirmation_modal.snap b/tests/snapshots/modals__delete_confirmation_modal.snap index c2ac787..17ec0e2 100644 --- a/tests/snapshots/modals__delete_confirmation_modal.snap +++ b/tests/snapshots/modals__delete_confirmation_modal.snap @@ -24,5 +24,5 @@ expression: output │ │ └──────────────────────────────────────────────────────────────────────────────┘ ┌──────────────────────────────────────────────────────────────────────────────┐ -│> █ Введите сообщение... │ +│> Press i to type... │ └──────────────────────────────────────────────────────────────────────────────┘ diff --git a/tests/snapshots/modals__emoji_picker_default.snap b/tests/snapshots/modals__emoji_picker_default.snap index 13a3e23..0a9e3de 100644 --- a/tests/snapshots/modals__emoji_picker_default.snap +++ b/tests/snapshots/modals__emoji_picker_default.snap @@ -24,5 +24,5 @@ expression: output │ │ └──────────────────────────────────────────────────────────────────────────────┘ ┌──────────────────────────────────────────────────────────────────────────────┐ -│> █ Введите сообщение... │ +│> Press i to type... │ └──────────────────────────────────────────────────────────────────────────────┘ diff --git a/tests/snapshots/modals__emoji_picker_with_selection.snap b/tests/snapshots/modals__emoji_picker_with_selection.snap index 13a3e23..0a9e3de 100644 --- a/tests/snapshots/modals__emoji_picker_with_selection.snap +++ b/tests/snapshots/modals__emoji_picker_with_selection.snap @@ -24,5 +24,5 @@ expression: output │ │ └──────────────────────────────────────────────────────────────────────────────┘ ┌──────────────────────────────────────────────────────────────────────────────┐ -│> █ Введите сообщение... │ +│> Press i to type... │ └──────────────────────────────────────────────────────────────────────────────┘ diff --git a/tests/snapshots/modals__pinned_message.snap b/tests/snapshots/modals__pinned_message.snap index ee14a2c..6c5b1aa 100644 --- a/tests/snapshots/modals__pinned_message.snap +++ b/tests/snapshots/modals__pinned_message.snap @@ -24,5 +24,5 @@ expression: output │ │ └──────────────────────────────────────────────────────────────────────────────┘ ┌──────────────────────────────────────────────────────────────────────────────┐ -│> █ Введите сообщение... │ +│> Press i to type... │ └──────────────────────────────────────────────────────────────────────────────┘ diff --git a/tests/vim_mode.rs b/tests/vim_mode.rs new file mode 100644 index 0000000..559ef08 --- /dev/null +++ b/tests/vim_mode.rs @@ -0,0 +1,629 @@ +//! Tests for Vim Normal/Insert mode feature +//! +//! Covers: +//! - Mode transitions (i→Insert, Esc→Normal, auto-Insert on Reply/Edit) +//! - Command blocking in Insert mode (vim keys type text) +//! - Insert mode input handling (NewLine, DeleteWord, MoveToStart, MoveToEnd) +//! - Close chat resets mode +//! - Edge cases (Esc cancels Reply/Editing from Insert) + +mod helpers; + +use crossterm::event::{KeyCode, KeyEvent, KeyModifiers}; +use helpers::app_builder::TestAppBuilder; +use helpers::test_data::{create_test_chat, TestMessageBuilder}; +use tele_tui::app::InputMode; +use tele_tui::app::methods::compose::ComposeMethods; +use tele_tui::app::methods::messages::MessageMethods; +use tele_tui::input::handle_main_input; + +fn key(code: KeyCode) -> KeyEvent { + KeyEvent::new(code, KeyModifiers::empty()) +} + +fn ctrl_key(c: char) -> KeyEvent { + KeyEvent::new(KeyCode::Char(c), KeyModifiers::CONTROL) +} + +// ============================================================ +// Mode Transitions +// ============================================================ + +/// `i` в Normal mode → переход в Insert mode +#[tokio::test] +async fn test_i_enters_insert_mode() { + let messages = vec![ + TestMessageBuilder::new("Hello", 1).outgoing().build(), + ]; + let mut app = TestAppBuilder::new() + .with_chats(vec![create_test_chat("Chat", 101)]) + .selected_chat(101) + .with_messages(101, messages) + .selecting_message(0) + .build(); + + assert_eq!(app.input_mode, InputMode::Normal); + + handle_main_input(&mut app, key(KeyCode::Char('i'))).await; + + assert_eq!(app.input_mode, InputMode::Insert); + // Выходим из MessageSelection + assert!(!app.is_selecting_message()); +} + +/// `ш` (русская i) в Normal mode → переход в Insert mode +#[tokio::test] +async fn test_russian_i_enters_insert_mode() { + let messages = vec![ + TestMessageBuilder::new("Hello", 1).outgoing().build(), + ]; + let mut app = TestAppBuilder::new() + .with_chats(vec![create_test_chat("Chat", 101)]) + .selected_chat(101) + .with_messages(101, messages) + .selecting_message(0) + .build(); + + handle_main_input(&mut app, key(KeyCode::Char('ш'))).await; + + assert_eq!(app.input_mode, InputMode::Insert); +} + +/// Esc в Insert mode → Normal mode + MessageSelection +#[tokio::test] +async fn test_esc_exits_insert_mode() { + let messages = vec![ + TestMessageBuilder::new("Hello", 1).outgoing().build(), + ]; + let mut app = TestAppBuilder::new() + .with_chats(vec![create_test_chat("Chat", 101)]) + .selected_chat(101) + .with_messages(101, messages) + .insert_mode() + .build(); + + assert_eq!(app.input_mode, InputMode::Insert); + + handle_main_input(&mut app, key(KeyCode::Esc)).await; + + assert_eq!(app.input_mode, InputMode::Normal); + assert!(app.is_selecting_message()); +} + +/// Esc в Normal mode → закрывает чат +#[tokio::test] +async fn test_esc_in_normal_closes_chat() { + let mut app = TestAppBuilder::new() + .with_chats(vec![create_test_chat("Chat", 101)]) + .selected_chat(101) + .build(); + + assert!(app.selected_chat_id.is_some()); + + handle_main_input(&mut app, key(KeyCode::Esc)).await; + + assert!(app.selected_chat_id.is_none()); + assert_eq!(app.input_mode, InputMode::Normal); +} + +/// close_chat() сбрасывает input_mode +#[tokio::test] +async fn test_close_chat_resets_input_mode() { + use tele_tui::app::methods::navigation::NavigationMethods; + + let mut app = TestAppBuilder::new() + .with_chats(vec![create_test_chat("Chat", 101)]) + .selected_chat(101) + .insert_mode() + .build(); + + assert_eq!(app.input_mode, InputMode::Insert); + + app.close_chat(); + + assert_eq!(app.input_mode, InputMode::Normal); +} + +/// Auto-Insert при Reply (`r` в MessageSelection) +#[tokio::test] +async fn test_reply_auto_enters_insert_mode() { + let messages = vec![ + TestMessageBuilder::new("Hello from friend", 1).sender("Friend").build(), + ]; + let mut app = TestAppBuilder::new() + .with_chats(vec![create_test_chat("Chat", 101)]) + .selected_chat(101) + .with_messages(101, messages) + .selecting_message(0) + .build(); + + assert_eq!(app.input_mode, InputMode::Normal); + + // `r` → reply + handle_main_input(&mut app, key(KeyCode::Char('r'))).await; + + assert_eq!(app.input_mode, InputMode::Insert); + assert!(app.is_replying()); +} + +/// Auto-Insert при Edit (Enter в MessageSelection) +#[tokio::test] +async fn test_edit_auto_enters_insert_mode() { + let messages = vec![ + TestMessageBuilder::new("My message", 1).outgoing().build(), + ]; + let mut app = TestAppBuilder::new() + .with_chats(vec![create_test_chat("Chat", 101)]) + .selected_chat(101) + .with_messages(101, messages) + .selecting_message(0) + .build(); + + assert_eq!(app.input_mode, InputMode::Normal); + + // Enter → edit selected message + handle_main_input(&mut app, key(KeyCode::Enter)).await; + + assert_eq!(app.input_mode, InputMode::Insert); + assert!(app.is_editing()); +} + +/// При открытии чата → Normal mode (selected_chat задан builder'ом, как после open) +#[test] +fn test_open_chat_defaults_to_normal_mode() { + // Проверяем что при настройке чата (аналог состояния после open_chat_and_load_data) + // режим = Normal, и start_message_selection() корректно входит в MessageSelection + let messages = vec![ + TestMessageBuilder::new("Msg 1", 1).build(), + TestMessageBuilder::new("Msg 2", 2).build(), + ]; + let mut app = TestAppBuilder::new() + .with_chats(vec![create_test_chat("Chat", 101)]) + .selected_chat(101) + .with_messages(101, messages) + .build(); + + assert_eq!(app.input_mode, InputMode::Normal); + assert!(app.selected_chat_id.is_some()); + + // open_chat_and_load_data вызывает start_message_selection() + app.start_message_selection(); + assert!(app.is_selecting_message()); +} + +/// После отправки сообщения — остаёмся в Insert +#[tokio::test] +async fn test_send_message_stays_in_insert() { + let mut app = TestAppBuilder::new() + .with_chats(vec![create_test_chat("Chat", 101)]) + .selected_chat(101) + .insert_mode() + .message_input("Hello!") + .build(); + app.cursor_position = 6; + + assert_eq!(app.input_mode, InputMode::Insert); + + // Enter → отправить + handle_main_input(&mut app, key(KeyCode::Enter)).await; + + // Остаёмся в Insert + assert_eq!(app.input_mode, InputMode::Insert); + // Инпут очищен + assert_eq!(app.message_input, ""); +} + +// ============================================================ +// Command Blocking in Insert Mode +// ============================================================ + +/// `j` в Insert mode → набирает символ, НЕ навигация +#[tokio::test] +async fn test_j_types_in_insert_mode() { + let mut app = TestAppBuilder::new() + .with_chats(vec![create_test_chat("Chat", 101)]) + .selected_chat(101) + .insert_mode() + .build(); + + handle_main_input(&mut app, key(KeyCode::Char('j'))).await; + + assert_eq!(app.message_input, "j"); +} + +/// `k` в Insert mode → набирает символ +#[tokio::test] +async fn test_k_types_in_insert_mode() { + let mut app = TestAppBuilder::new() + .with_chats(vec![create_test_chat("Chat", 101)]) + .selected_chat(101) + .insert_mode() + .build(); + + handle_main_input(&mut app, key(KeyCode::Char('k'))).await; + + assert_eq!(app.message_input, "k"); +} + +/// `d` в Insert mode → набирает "d", НЕ удаляет сообщение +#[tokio::test] +async fn test_d_types_in_insert_mode() { + let messages = vec![ + TestMessageBuilder::new("Hello", 1).outgoing().build(), + ]; + let mut app = TestAppBuilder::new() + .with_chats(vec![create_test_chat("Chat", 101)]) + .selected_chat(101) + .with_messages(101, messages) + .insert_mode() + .build(); + + handle_main_input(&mut app, key(KeyCode::Char('d'))).await; + + assert_eq!(app.message_input, "d"); + // НЕ вошли в delete confirmation + assert!(!app.chat_state.is_delete_confirmation()); +} + +/// `r` в Insert mode → набирает "r", НЕ reply +#[tokio::test] +async fn test_r_types_in_insert_mode() { + let messages = vec![ + TestMessageBuilder::new("Hello", 1).build(), + ]; + let mut app = TestAppBuilder::new() + .with_chats(vec![create_test_chat("Chat", 101)]) + .selected_chat(101) + .with_messages(101, messages) + .insert_mode() + .build(); + + handle_main_input(&mut app, key(KeyCode::Char('r'))).await; + + assert_eq!(app.message_input, "r"); + assert!(!app.is_replying()); +} + +/// `f` в Insert mode → набирает "f", НЕ forward +#[tokio::test] +async fn test_f_types_in_insert_mode() { + let mut app = TestAppBuilder::new() + .with_chats(vec![create_test_chat("Chat", 101)]) + .selected_chat(101) + .insert_mode() + .build(); + + handle_main_input(&mut app, key(KeyCode::Char('f'))).await; + + assert_eq!(app.message_input, "f"); + assert!(!app.is_forwarding()); +} + +/// `q` в Insert mode → набирает "q", НЕ quit +#[tokio::test] +async fn test_q_types_in_insert_mode() { + let mut app = TestAppBuilder::new() + .with_chats(vec![create_test_chat("Chat", 101)]) + .selected_chat(101) + .insert_mode() + .build(); + + handle_main_input(&mut app, key(KeyCode::Char('q'))).await; + + assert_eq!(app.message_input, "q"); +} + +/// Ctrl+S в Insert mode → НЕ открывает поиск +#[tokio::test] +async fn test_ctrl_s_blocked_in_insert_mode() { + let mut app = TestAppBuilder::new() + .with_chats(vec![create_test_chat("Chat", 101)]) + .selected_chat(101) + .insert_mode() + .build(); + + handle_main_input(&mut app, ctrl_key('s')).await; + + assert!(!app.is_searching); +} + +/// Ctrl+F в Insert mode → НЕ открывает поиск по сообщениям +#[tokio::test] +async fn test_ctrl_f_blocked_in_insert_mode() { + let mut app = TestAppBuilder::new() + .with_chats(vec![create_test_chat("Chat", 101)]) + .selected_chat(101) + .insert_mode() + .build(); + + handle_main_input(&mut app, ctrl_key('f')).await; + + assert!(!app.chat_state.is_search_in_chat()); +} + +// ============================================================ +// Normal Mode — commands work +// ============================================================ + +/// `j` в Normal mode → навигация вниз (MoveDown) в MessageSelection +#[tokio::test] +async fn test_j_navigates_in_normal_mode() { + let messages = vec![ + TestMessageBuilder::new("Msg 1", 1).build(), + TestMessageBuilder::new("Msg 2", 2).build(), + TestMessageBuilder::new("Msg 3", 3).build(), + ]; + let mut app = TestAppBuilder::new() + .with_chats(vec![create_test_chat("Chat", 101)]) + .selected_chat(101) + .with_messages(101, messages) + .selecting_message(1) + .build(); + + assert_eq!(app.input_mode, InputMode::Normal); + assert_eq!(app.chat_state.selected_message_index(), Some(1)); + + handle_main_input(&mut app, key(KeyCode::Char('j'))).await; + + // j = MoveDown = select_next_message + assert_eq!(app.chat_state.selected_message_index(), Some(2)); + // Текст НЕ добавился + assert_eq!(app.message_input, ""); +} + +/// `k` в Normal mode → навигация вверх в MessageSelection +#[tokio::test] +async fn test_k_navigates_in_normal_mode() { + let messages = vec![ + TestMessageBuilder::new("Msg 1", 1).build(), + TestMessageBuilder::new("Msg 2", 2).build(), + TestMessageBuilder::new("Msg 3", 3).build(), + ]; + let mut app = TestAppBuilder::new() + .with_chats(vec![create_test_chat("Chat", 101)]) + .selected_chat(101) + .with_messages(101, messages) + .selecting_message(2) + .build(); + + handle_main_input(&mut app, key(KeyCode::Char('k'))).await; + + assert_eq!(app.chat_state.selected_message_index(), Some(1)); + assert_eq!(app.message_input, ""); +} + +/// `d` в Normal mode → показывает подтверждение удаления +#[tokio::test] +async fn test_d_deletes_in_normal_mode() { + let messages = vec![ + TestMessageBuilder::new("My message", 1).outgoing().build(), + ]; + let mut app = TestAppBuilder::new() + .with_chats(vec![create_test_chat("Chat", 101)]) + .selected_chat(101) + .with_messages(101, messages) + .selecting_message(0) + .build(); + + handle_main_input(&mut app, key(KeyCode::Char('d'))).await; + + assert!(app.chat_state.is_delete_confirmation()); +} + +// ============================================================ +// Insert Mode Input Handling +// ============================================================ + +/// Ctrl+W → удаляет слово в Insert mode +#[tokio::test] +async fn test_ctrl_w_deletes_word_in_insert() { + let mut app = TestAppBuilder::new() + .with_chats(vec![create_test_chat("Chat", 101)]) + .selected_chat(101) + .insert_mode() + .message_input("Hello World") + .build(); + app.cursor_position = 11; // конец "Hello World" + + handle_main_input(&mut app, ctrl_key('w')).await; + + assert_eq!(app.message_input, "Hello "); + assert_eq!(app.cursor_position, 6); +} + +/// Ctrl+W → удаляет слово + пробелы перед ним +#[tokio::test] +async fn test_ctrl_w_deletes_word_with_spaces() { + let mut app = TestAppBuilder::new() + .with_chats(vec![create_test_chat("Chat", 101)]) + .selected_chat(101) + .insert_mode() + .message_input("one two three") + .build(); + app.cursor_position = 14; // конец + + handle_main_input(&mut app, ctrl_key('w')).await; + + // "one two " → удалили "three", осталось "one two " + assert_eq!(app.message_input, "one two "); + assert_eq!(app.cursor_position, 9); +} + +/// Ctrl+A → курсор в начало в Insert mode +#[tokio::test] +async fn test_ctrl_a_moves_to_start_in_insert() { + let mut app = TestAppBuilder::new() + .with_chats(vec![create_test_chat("Chat", 101)]) + .selected_chat(101) + .insert_mode() + .message_input("Hello World") + .build(); + app.cursor_position = 11; + + handle_main_input(&mut app, ctrl_key('a')).await; + + assert_eq!(app.cursor_position, 0); +} + +/// Ctrl+E → курсор в конец в Insert mode +#[tokio::test] +async fn test_ctrl_e_moves_to_end_in_insert() { + let mut app = TestAppBuilder::new() + .with_chats(vec![create_test_chat("Chat", 101)]) + .selected_chat(101) + .insert_mode() + .message_input("Hello World") + .build(); + app.cursor_position = 0; + + handle_main_input(&mut app, ctrl_key('e')).await; + + assert_eq!(app.cursor_position, 11); +} + +// ============================================================ +// Edge Cases — Esc from Insert cancels Reply/Editing +// ============================================================ + +/// Esc из Insert при активном Reply → отменяет reply + Normal + MessageSelection +#[tokio::test] +async fn test_esc_from_insert_cancels_reply() { + let messages = vec![ + TestMessageBuilder::new("Hello", 1).sender("Friend").build(), + ]; + let mut app = TestAppBuilder::new() + .with_chats(vec![create_test_chat("Chat", 101)]) + .selected_chat(101) + .with_messages(101, messages) + .insert_mode() + .replying_to(1) + .build(); + + assert!(app.is_replying()); + assert_eq!(app.input_mode, InputMode::Insert); + + handle_main_input(&mut app, key(KeyCode::Esc)).await; + + assert!(!app.is_replying()); + assert_eq!(app.input_mode, InputMode::Normal); + assert!(app.is_selecting_message()); +} + +/// Esc из Insert при активном Editing → отменяет editing + Normal + MessageSelection +#[tokio::test] +async fn test_esc_from_insert_cancels_editing() { + let messages = vec![ + TestMessageBuilder::new("My message", 1).outgoing().build(), + ]; + let mut app = TestAppBuilder::new() + .with_chats(vec![create_test_chat("Chat", 101)]) + .selected_chat(101) + .with_messages(101, messages) + .insert_mode() + .editing_message(1, 0) + .message_input("Edited text") + .build(); + + assert!(app.is_editing()); + assert_eq!(app.input_mode, InputMode::Insert); + + handle_main_input(&mut app, key(KeyCode::Esc)).await; + + assert!(!app.is_editing()); + assert_eq!(app.input_mode, InputMode::Normal); + assert!(app.is_selecting_message()); + // Инпут очищен (cancel_editing) + assert_eq!(app.message_input, ""); +} + +/// Normal mode auto-enters MessageSelection при первом нажатии +/// Используем `k` (MoveUp), т.к. `j` (MoveDown) на последнем сообщении выходит из selection +#[tokio::test] +async fn test_normal_mode_auto_enters_selection_on_any_key() { + let messages = vec![ + TestMessageBuilder::new("Msg 1", 1).build(), + TestMessageBuilder::new("Msg 2", 2).build(), + ]; + let mut app = TestAppBuilder::new() + .with_chats(vec![create_test_chat("Chat", 101)]) + .selected_chat(101) + .with_messages(101, messages) + .build(); + + // ChatState::Normal, InputMode::Normal — не в MessageSelection + assert!(!app.is_selecting_message()); + + // `k` (MoveUp) в Normal mode → auto-enter MessageSelection + move up + handle_main_input(&mut app, key(KeyCode::Char('k'))).await; + + assert!(app.is_selecting_message()); + // Начали с последнего (index 1), MoveUp → index 0 + assert_eq!(app.chat_state.selected_message_index(), Some(0)); +} + +/// Полный цикл: Normal → i → набор текста → Esc → Normal +#[tokio::test] +async fn test_full_mode_cycle() { + let messages = vec![ + TestMessageBuilder::new("Msg", 1).build(), + ]; + let mut app = TestAppBuilder::new() + .with_chats(vec![create_test_chat("Chat", 101)]) + .selected_chat(101) + .with_messages(101, messages) + .selecting_message(0) + .build(); + + // 1. Normal mode + assert_eq!(app.input_mode, InputMode::Normal); + assert!(app.is_selecting_message()); + + // 2. i → Insert + handle_main_input(&mut app, key(KeyCode::Char('i'))).await; + assert_eq!(app.input_mode, InputMode::Insert); + assert!(!app.is_selecting_message()); + + // 3. Набираем текст + handle_main_input(&mut app, key(KeyCode::Char('H'))).await; + handle_main_input(&mut app, key(KeyCode::Char('i'))).await; + assert_eq!(app.message_input, "Hi"); + + // 4. Esc → Normal + MessageSelection + handle_main_input(&mut app, key(KeyCode::Esc)).await; + assert_eq!(app.input_mode, InputMode::Normal); + assert!(app.is_selecting_message()); + // Текст сохранён (черновик) + assert_eq!(app.message_input, "Hi"); +} + +/// Полный цикл: Normal → r (reply) → набор → Enter (отправка) → остаёмся в Insert +#[tokio::test] +async fn test_reply_send_stays_insert() { + let messages = vec![ + TestMessageBuilder::new("Question?", 1).sender("Friend").build(), + ]; + let mut app = TestAppBuilder::new() + .with_chats(vec![create_test_chat("Chat", 101)]) + .selected_chat(101) + .with_messages(101, messages) + .selecting_message(0) + .build(); + + // 1. r → auto-Insert + Reply + handle_main_input(&mut app, key(KeyCode::Char('r'))).await; + assert_eq!(app.input_mode, InputMode::Insert); + assert!(app.is_replying()); + + // 2. Набираем ответ + for c in "Yes!".chars() { + handle_main_input(&mut app, key(KeyCode::Char(c))).await; + } + assert_eq!(app.message_input, "Yes!"); + + // 3. Enter → отправить + handle_main_input(&mut app, key(KeyCode::Enter)).await; + + // Остаёмся в Insert после отправки + assert_eq!(app.input_mode, InputMode::Insert); + assert_eq!(app.message_input, ""); +}