This commit is contained in:
Mikhail Kilin
2026-02-13 19:52:53 +03:00
parent 6d08300daa
commit 6639dc876c
38 changed files with 961 additions and 123 deletions

View File

@@ -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<T>`
- `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)
### Завершённые фазы (краткий итог) ### Завершённые фазы (краткий итог)

View File

@@ -3,6 +3,16 @@
use crate::tdlib::{MessageInfo, ProfileInfo}; use crate::tdlib::{MessageInfo, ProfileInfo};
use crate::types::MessageId; 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)] #[derive(Debug, Clone)]
pub enum ChatState { pub enum ChatState {

View File

@@ -2,7 +2,7 @@
//! //!
//! Handles chat list navigation and selection //! 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::app::methods::search::SearchMethods;
use crate::tdlib::TdClientTrait; use crate::tdlib::TdClientTrait;
@@ -84,6 +84,7 @@ impl<T: TdClientTrait> NavigationMethods<T> for App<T> {
self.last_typing_sent = None; self.last_typing_sent = None;
// Сбрасываем состояние чата в нормальный режим // Сбрасываем состояние чата в нормальный режим
self.chat_state = ChatState::Normal; self.chat_state = ChatState::Normal;
self.input_mode = InputMode::Normal;
// Очищаем данные в TdClient // Очищаем данные в TdClient
self.td_client.set_current_chat_id(None); self.td_client.set_current_chat_id(None);
self.td_client.clear_current_chat_messages(); self.td_client.clear_current_chat_messages();

View File

@@ -9,7 +9,7 @@ mod state;
pub mod methods; pub mod methods;
pub use chat_filter::{ChatFilter, ChatFilterCriteria}; pub use chat_filter::{ChatFilter, ChatFilterCriteria};
pub use chat_state::ChatState; pub use chat_state::{ChatState, InputMode};
pub use state::AppScreen; pub use state::AppScreen;
pub use methods::*; pub use methods::*;
@@ -60,6 +60,8 @@ pub struct App<T: TdClientTrait = TdClient> {
pub td_client: T, pub td_client: T,
/// Состояние чата - type-safe state machine (новое!) /// Состояние чата - type-safe state machine (новое!)
pub chat_state: ChatState, pub chat_state: ChatState,
/// Vim-like input mode: Normal (navigation) / Insert (text input)
pub input_mode: InputMode,
// Auth state (приватные, доступ через геттеры) // Auth state (приватные, доступ через геттеры)
phone_input: String, phone_input: String,
code_input: String, code_input: String,
@@ -144,6 +146,7 @@ impl<T: TdClientTrait> App<T> {
screen: AppScreen::Loading, screen: AppScreen::Loading,
td_client, td_client,
chat_state: ChatState::Normal, chat_state: ChatState::Normal,
input_mode: InputMode::Normal,
phone_input: String::new(), phone_input: String::new(),
code_input: String::new(), code_input: String::new(),
password_input: String::new(), password_input: String::new(),

View File

@@ -65,6 +65,9 @@ pub enum Command {
MoveToStart, MoveToStart,
MoveToEnd, MoveToEnd,
// Vim mode
EnterInsertMode,
// Profile // Profile
OpenProfile, 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 { pub fn matches(&self, event: &KeyEvent) -> bool {
self.key == event.code && self.modifiers == event.modifiers self.key == event.code && self.modifiers == event.modifiers
} }
@@ -234,9 +244,7 @@ impl Keybindings {
bindings.insert(Command::Cancel, vec![ bindings.insert(Command::Cancel, vec![
KeyBinding::new(KeyCode::Esc), KeyBinding::new(KeyCode::Esc),
]); ]);
bindings.insert(Command::NewLine, vec![ bindings.insert(Command::NewLine, vec![]);
KeyBinding::with_shift(KeyCode::Enter),
]);
bindings.insert(Command::DeleteChar, vec![ bindings.insert(Command::DeleteChar, vec![
KeyBinding::new(KeyCode::Backspace), KeyBinding::new(KeyCode::Backspace),
]); ]);
@@ -253,6 +261,12 @@ impl Keybindings {
KeyBinding::with_ctrl(KeyCode::Char('e')), KeyBinding::with_ctrl(KeyCode::Char('e')),
]); ]);
// Vim mode
bindings.insert(Command::EnterInsertMode, vec![
KeyBinding::new(KeyCode::Char('i')),
KeyBinding::new(KeyCode::Char('ш')), // RU
]);
// Profile // Profile
bindings.insert(Command::OpenProfile, vec![ bindings.insert(Command::OpenProfile, vec![
KeyBinding::with_ctrl(KeyCode::Char('u')), // Ctrl+U instead of Ctrl+I KeyBinding::with_ctrl(KeyCode::Char('u')), // Ctrl+U instead of Ctrl+I

View File

@@ -7,6 +7,7 @@
//! - Loading older messages //! - Loading older messages
use crate::app::App; use crate::app::App;
use crate::app::InputMode;
use crate::app::methods::{ use crate::app::methods::{
compose::ComposeMethods, messages::MessageMethods, compose::ComposeMethods, messages::MessageMethods,
modal::ModalMethods, navigation::NavigationMethods, modal::ModalMethods, navigation::NavigationMethods,
@@ -48,8 +49,13 @@ pub async fn handle_message_selection<T: TdClientTrait>(app: &mut App<T>, _key:
}; };
} }
} }
Some(crate::config::Command::EnterInsertMode) => {
app.input_mode = InputMode::Insert;
app.chat_state = crate::app::ChatState::Normal;
}
Some(crate::config::Command::ReplyMessage) => { Some(crate::config::Command::ReplyMessage) => {
app.start_reply_to_selected(); app.start_reply_to_selected();
app.input_mode = InputMode::Insert;
} }
Some(crate::config::Command::ForwardMessage) => { Some(crate::config::Command::ForwardMessage) => {
app.start_forward_selected(); app.start_forward_selected();
@@ -243,7 +249,9 @@ pub async fn handle_enter_key<T: TdClientTrait>(app: &mut App<T>) {
// Сценарий 2: Режим выбора сообщения - начать редактирование // Сценарий 2: Режим выбора сообщения - начать редактирование
if app.is_selecting_message() { 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; app.chat_state = crate::app::ChatState::Normal;
} }
@@ -452,24 +460,16 @@ pub async fn handle_open_chat_keyboard_input<T: TdClientTrait>(app: &mut App<T>,
// Курсор в конец // Курсор в конец
app.cursor_position = app.message_input.chars().count(); app.cursor_position = app.message_input.chars().count();
} }
// Стрелки вверх/вниз - скролл сообщений или начало выбора // Стрелки вверх/вниз - скролл сообщений (в Insert mode)
KeyCode::Down => { KeyCode::Down => {
// Скролл вниз (к новым сообщениям)
if app.message_scroll_offset > 0 { if app.message_scroll_offset > 0 {
app.message_scroll_offset = app.message_scroll_offset.saturating_sub(3); app.message_scroll_offset = app.message_scroll_offset.saturating_sub(3);
} }
} }
KeyCode::Up => { KeyCode::Up => {
// Если инпут пустой и не в режиме редактирования — начать выбор сообщения // В Insert mode — только скролл
if app.message_input.is_empty() && !app.is_editing() { app.message_scroll_offset += 3;
app.start_message_selection(); load_older_messages_if_needed(app).await;
} else {
// Скролл вверх (к старым сообщениям)
app.message_scroll_offset += 3;
// Подгружаем старые сообщения если нужно
load_older_messages_if_needed(app).await;
}
} }
_ => {} _ => {}
} }

View File

@@ -6,7 +6,8 @@
//! - Opening chats //! - Opening chats
use crate::app::App; 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::tdlib::TdClientTrait;
use crate::types::{ChatId, MessageId}; use crate::types::{ChatId, MessageId};
use crate::utils::{with_timeout, with_timeout_msg, with_timeout_ignore}; use crate::utils::{with_timeout, with_timeout_msg, with_timeout_ignore};
@@ -135,6 +136,10 @@ pub async fn open_chat_and_load_data<T: TdClientTrait>(app: &mut App<T>, chat_id
// Загружаем черновик // Загружаем черновик
app.load_draft(); app.load_draft();
app.status_message = None; app.status_message = None;
// Vim mode: Normal + MessageSelection по умолчанию
app.input_mode = InputMode::Normal;
app.start_message_selection();
} }
Err(e) => { Err(e) => {
app.error_message = Some(e); app.error_message = Some(e);

View File

@@ -4,6 +4,7 @@
//! Priority order: modals → search → compose → chat → chat list. //! Priority order: modals → search → compose → chat → chat list.
use crate::app::App; use crate::app::App;
use crate::app::InputMode;
use crate::app::methods::{ use crate::app::methods::{
compose::ComposeMethods, compose::ComposeMethods,
messages::MessageMethods, messages::MessageMethods,
@@ -30,14 +31,10 @@ use crossterm::event::KeyEvent;
/// Обработка клавиши Esc /// Обработка клавиши Esc в Normal mode
/// ///
/// Обрабатывает отмену текущего действия или закрытие чата: /// Закрывает чат с сохранением черновика
/// - В режиме выбора сообщения: отменить выбор async fn handle_escape_normal<T: TdClientTrait>(app: &mut App<T>) {
/// - В режиме редактирования: отменить редактирование
/// - В режиме ответа: отменить ответ
/// - В открытом чате: сохранить черновик и закрыть чат
async fn handle_escape_key<T: TdClientTrait>(app: &mut App<T>) {
// Закрываем модальное окно изображения если открыто // Закрываем модальное окно изображения если открыто
#[cfg(feature = "images")] #[cfg(feature = "images")]
if app.image_modal.is_some() { if app.image_modal.is_some() {
@@ -46,34 +43,16 @@ async fn handle_escape_key<T: TdClientTrait>(app: &mut App<T>) {
return; 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 { let Some(chat_id) = app.selected_chat_id else {
return; 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 draft_text = app.message_input.clone();
let _ = app.td_client.set_draft_message(chat_id, draft_text).await; 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; let _ = app.td_client.set_draft_message(chat_id, String::new()).await;
} }
@@ -81,79 +60,169 @@ async fn handle_escape_key<T: TdClientTrait>(app: &mut App<T>) {
app.close_chat(); app.close_chat();
} }
/// Обработка клавиши Esc в Insert mode
///
/// Отменяет Reply/Editing и возвращает в Normal + MessageSelection
fn handle_escape_insert<T: TdClientTrait>(app: &mut App<T>) {
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<T: TdClientTrait>(app: &mut App<T>, key: KeyEvent) { pub async fn handle<T: TdClientTrait>(app: &mut App<T>, 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<char> = 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 { if handle_global_commands(app, key).await {
return; return;
} }
// Получаем команду из keybindings // 4. Модальное окно просмотра изображения
let command = app.get_command(key);
// Модальное окно просмотра изображения (приоритет высокий)
#[cfg(feature = "images")] #[cfg(feature = "images")]
if app.image_modal.is_some() { if app.image_modal.is_some() {
handle_image_modal_mode(app, key).await; handle_image_modal_mode(app, key).await;
return; return;
} }
// Режим профиля // 5. Режим профиля
if app.is_profile_mode() { if app.is_profile_mode() {
handle_profile_mode(app, key, command).await; handle_profile_mode(app, key, command).await;
return; return;
} }
// Режим поиска по сообщениям // 6. Режим поиска по сообщениям
if app.is_message_search_mode() { if app.is_message_search_mode() {
handle_message_search_mode(app, key, command).await; handle_message_search_mode(app, key, command).await;
return; return;
} }
// Режим просмотра закреплённых сообщений // 7. Режим просмотра закреплённых сообщений
if app.is_pinned_mode() { if app.is_pinned_mode() {
handle_pinned_mode(app, key, command).await; handle_pinned_mode(app, key, command).await;
return; return;
} }
// Обработка ввода в режиме выбора реакции // 8. Обработка ввода в режиме выбора реакции
if app.is_reaction_picker_mode() { if app.is_reaction_picker_mode() {
handle_reaction_picker_mode(app, key, command).await; handle_reaction_picker_mode(app, key, command).await;
return; return;
} }
// Модалка подтверждения удаления // 9. Модалка подтверждения удаления
if app.is_confirm_delete_shown() { if app.is_confirm_delete_shown() {
handle_delete_confirmation(app, key).await; handle_delete_confirmation(app, key).await;
return; return;
} }
// Режим выбора чата для пересылки // 10. Режим выбора чата для пересылки
if app.is_forwarding() { if app.is_forwarding() {
handle_forward_mode(app, key, command).await; handle_forward_mode(app, key, command).await;
return; return;
} }
// Режим поиска // 11. Режим поиска чатов
if app.is_searching { if app.is_searching {
handle_chat_search_mode(app, key, command).await; handle_chat_search_mode(app, key, command).await;
return; return;
} }
// Обработка команд через keybindings // 12. Normal mode commands (Enter, Esc, Profile)
match command { match command {
Some(crate::config::Command::SubmitMessage) => { Some(crate::config::Command::SubmitMessage) => {
// Enter - открыть чат, отправить сообщение или редактировать
handle_enter_key(app).await; handle_enter_key(app).await;
return; return;
} }
Some(crate::config::Command::Cancel) => { Some(crate::config::Command::Cancel) => {
// Esc - отменить выбор/редактирование/reply или закрыть чат handle_escape_normal(app).await;
handle_escape_key(app).await;
return; return;
} }
Some(crate::config::Command::OpenProfile) => { Some(crate::config::Command::OpenProfile) => {
// Открыть профиль (обычно 'i')
if app.selected_chat_id.is_some() { if app.selected_chat_id.is_some() {
handle_profile_open(app).await; handle_profile_open(app).await;
return; return;
@@ -162,17 +231,15 @@ pub async fn handle<T: TdClientTrait>(app: &mut App<T>, key: KeyEvent) {
_ => {} _ => {}
} }
// Режим открытого чата // 13. Normal mode в чате → MessageSelection
if app.selected_chat_id.is_some() { if app.selected_chat_id.is_some() {
// Режим выбора сообщения для редактирования/удаления // Auto-enter MessageSelection if not already in it
if app.is_selecting_message() { if !app.is_selecting_message() {
handle_message_selection(app, key, command).await; app.start_message_selection();
return;
} }
handle_message_selection(app, key, command).await;
handle_open_chat_keyboard_input(app, key).await;
} else { } else {
// В режиме списка чатов - навигация стрелками и переключение папок // 14. Список чатов
handle_chat_list_navigation(app, key, command).await; handle_chat_list_navigation(app, key, command).await;
} }
} }

View File

@@ -24,19 +24,40 @@ struct WrappedLine {
start_offset: usize, start_offset: usize,
} }
/// Разбивает текст на строки с учётом максимальной ширины /// Разбивает текст на строки с учётом максимальной ширины и `\n`
fn wrap_text_with_offsets(text: &str, max_width: usize) -> Vec<WrappedLine> { fn wrap_text_with_offsets(text: &str, max_width: usize) -> Vec<WrappedLine> {
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<WrappedLine> {
if max_width == 0 { if max_width == 0 {
return vec![WrappedLine { return vec![WrappedLine {
text: text.to_string(), text: text.to_string(),
start_offset: 0, start_offset: base_offset,
}]; }];
} }
let mut result = Vec::new(); let mut result = Vec::new();
let mut current_line = String::new(); let mut current_line = String::new();
let mut current_width = 0; let mut current_width = 0;
let mut line_start_offset = 0; let mut line_start_offset = base_offset;
let chars: Vec<char> = text.chars().collect(); let chars: Vec<char> = text.chars().collect();
let mut word_start = 0; let mut word_start = 0;
@@ -51,7 +72,7 @@ fn wrap_text_with_offsets(text: &str, max_width: usize) -> Vec<WrappedLine> {
if current_width == 0 { if current_width == 0 {
current_line = word; current_line = word;
current_width = word_width; 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 { } else if current_width + 1 + word_width <= max_width {
current_line.push(' '); current_line.push(' ');
current_line.push_str(&word); current_line.push_str(&word);
@@ -63,7 +84,7 @@ fn wrap_text_with_offsets(text: &str, max_width: usize) -> Vec<WrappedLine> {
}); });
current_line = word; current_line = word;
current_width = word_width; current_width = word_width;
line_start_offset = word_start; line_start_offset = base_offset + word_start;
} }
in_word = false; in_word = false;
} }
@@ -79,7 +100,7 @@ fn wrap_text_with_offsets(text: &str, max_width: usize) -> Vec<WrappedLine> {
if current_width == 0 { if current_width == 0 {
current_line = word; current_line = word;
line_start_offset = word_start; line_start_offset = base_offset + word_start;
} else if current_width + 1 + word_width <= max_width { } else if current_width + 1 + word_width <= max_width {
current_line.push(' '); current_line.push(' ');
current_line.push_str(&word); current_line.push_str(&word);
@@ -89,7 +110,7 @@ fn wrap_text_with_offsets(text: &str, max_width: usize) -> Vec<WrappedLine> {
start_offset: line_start_offset, start_offset: line_start_offset,
}); });
current_line = word; 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<WrappedLine> {
if result.is_empty() { if result.is_empty() {
result.push(WrappedLine { result.push(WrappedLine {
text: String::new(), 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 full_len = line_len + time_mark_len + marker_len;
let padding = content_width.saturating_sub(full_len + 1); let padding = content_width.saturating_sub(full_len + 1);
let mut line_spans = vec![Span::raw(" ".repeat(padding))]; let mut line_spans = vec![Span::raw(" ".repeat(padding))];
if is_selected { if is_selected && i == 0 {
// Одна строка — маркер на ней
line_spans.push(Span::styled( line_spans.push(Span::styled(
selection_marker, selection_marker,
Style::default().fg(Color::Yellow).add_modifier(Modifier::BOLD), 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.extend(formatted_spans);
line_spans.push(Span::styled(format!(" {}", time_mark), Style::default().fg(Color::Gray))); line_spans.push(Span::styled(format!(" {}", time_mark), Style::default().fg(Color::Gray)));
@@ -305,6 +330,9 @@ pub fn render_message_bubble(
selection_marker, selection_marker,
Style::default().fg(Color::Yellow).add_modifier(Modifier::BOLD), 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.extend(formatted_spans);
lines.push(Line::from(line_spans)); lines.push(Line::from(line_spans));

View File

@@ -1,6 +1,7 @@
//! Compose bar / input box rendering //! Compose bar / input box rendering
use crate::app::App; use crate::app::App;
use crate::app::InputMode;
use crate::app::methods::{compose::ComposeMethods, messages::MessageMethods}; use crate::app::methods::{compose::ComposeMethods, messages::MessageMethods};
use crate::tdlib::TdClientTrait; use crate::tdlib::TdClientTrait;
use crate::ui::components; use crate::ui::components;
@@ -19,13 +20,12 @@ fn render_input_with_cursor(
cursor_pos: usize, cursor_pos: usize,
color: Color, color: Color,
) -> Line<'static> { ) -> Line<'static> {
// Используем компонент input_field
components::render_input_field(prefix, text, cursor_pos, color) components::render_input_field(prefix, text, cursor_pos, color)
} }
/// Renders input box with support for different modes (forward/select/edit/reply/normal) /// Renders input box with support for different modes (forward/select/edit/reply/normal)
pub fn render<T: TdClientTrait>(f: &mut Frame, area: Rect, app: &App<T>) { pub fn render<T: TdClientTrait>(f: &mut Frame, area: Rect, app: &App<T>) {
let (input_line, input_title) = if app.is_forwarding() { let (input_line, input_title): (Line, &str) = if app.is_forwarding() {
// Режим пересылки - показываем превью сообщения // Режим пересылки - показываем превью сообщения
let forward_preview = app let forward_preview = app
.get_forwarding_message() .get_forwarding_message()
@@ -67,7 +67,6 @@ pub fn render<T: TdClientTrait>(f: &mut Frame, area: Rect, app: &App<T>) {
} else if app.is_editing() { } else if app.is_editing() {
// Режим редактирования // Режим редактирования
if app.message_input.is_empty() { if app.message_input.is_empty() {
// Пустой инпут - показываем курсор и placeholder
let line = Line::from(vec![ let line = Line::from(vec![
Span::raw(""), Span::raw(""),
Span::styled("", Style::default().fg(Color::Magenta)), Span::styled("", Style::default().fg(Color::Magenta)),
@@ -75,7 +74,6 @@ pub fn render<T: TdClientTrait>(f: &mut Frame, area: Rect, app: &App<T>) {
]); ]);
(line, " Редактирование (Esc отмена) ") (line, " Редактирование (Esc отмена) ")
} else { } else {
// Текст с курсором
let line = render_input_with_cursor( let line = render_input_with_cursor(
"", "",
&app.message_input, &app.message_input,
@@ -123,10 +121,25 @@ pub fn render<T: TdClientTrait>(f: &mut Frame, area: Rect, app: &App<T>) {
); );
(line, " Ответ (Esc отмена) ") (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() { if app.message_input.is_empty() {
// Пустой инпут - показываем курсор и placeholder
let line = Line::from(vec![ let line = Line::from(vec![
Span::raw("> "), Span::raw("> "),
Span::styled("", Style::default().fg(Color::Yellow)), Span::styled("", Style::default().fg(Color::Yellow)),
@@ -134,7 +147,6 @@ pub fn render<T: TdClientTrait>(f: &mut Frame, area: Rect, app: &App<T>) {
]); ]);
(line, "") (line, "")
} else { } else {
// Текст с курсором
let line = render_input_with_cursor( let line = render_input_with_cursor(
"> ", "> ",
&app.message_input, &app.message_input,
@@ -146,7 +158,12 @@ pub fn render<T: TdClientTrait>(f: &mut Frame, area: Rect, app: &App<T>) {
}; };
let input_block = if input_title.is_empty() { 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 { } else {
let title_color = if app.is_replying() || app.is_forwarding() { let title_color = if app.is_replying() || app.is_forwarding() {
Color::Cyan Color::Cyan

View File

@@ -1,4 +1,5 @@
use crate::app::App; use crate::app::App;
use crate::app::InputMode;
use crate::tdlib::TdClientTrait; use crate::tdlib::TdClientTrait;
use crate::tdlib::NetworkState; use crate::tdlib::NetworkState;
use ratatui::{ use ratatui::{
@@ -25,7 +26,11 @@ pub fn render<T: TdClientTrait>(f: &mut Frame, area: Rect, app: &App<T>) {
} else if app.is_searching { } else if app.is_searching {
format!(" {}↑/↓: Navigate | Enter: Select | Esc: Cancel ", network_indicator) format!(" {}↑/↓: Navigate | Enter: Select | Esc: Cancel ", network_indicator)
} else if app.selected_chat_id.is_some() { } 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 { } else {
format!( format!(
" {}↑/↓: Navigate | Enter: Open | Ctrl+S: Search | Ctrl+R: Refresh | Ctrl+C: Quit ", " {}↑/↓: Navigate | Enter: Open | Ctrl+S: Search | Ctrl+R: Refresh | Ctrl+C: Quit ",

View File

@@ -385,9 +385,9 @@ pub fn render<T: TdClientTrait>(f: &mut Frame, area: Rect, app: &mut App<T>) {
if let Some(chat) = app.get_selected_chat().cloned() { if let Some(chat) = app.get_selected_chat().cloned() {
// Вычисляем динамическую высоту инпута на основе длины текста // Вычисляем динамическую высоту инпута на основе длины текста
let input_width = area.width.saturating_sub(4) as usize; // -2 для рамок, -2 для "> " 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: u16 = if input_width > 0 {
let input_lines = if input_width > 0 { let len = app.message_input.chars().count() + 2; // +2 для "> "
((input_text_len as f32 / input_width as f32).ceil() as u16).max(1) ((len as f32 / input_width as f32).ceil() as u16).max(1)
} else { } else {
1 1
}; };

View File

@@ -3,7 +3,7 @@
use ratatui::widgets::ListState; use ratatui::widgets::ListState;
use std::collections::HashMap; use std::collections::HashMap;
use super::FakeTdClient; 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::config::Config;
use tele_tui::tdlib::AuthState; use tele_tui::tdlib::AuthState;
use tele_tui::tdlib::{ChatInfo, MessageInfo}; use tele_tui::tdlib::{ChatInfo, MessageInfo};
@@ -19,6 +19,7 @@ pub struct TestAppBuilder {
is_searching: bool, is_searching: bool,
search_query: String, search_query: String,
chat_state: Option<ChatState>, chat_state: Option<ChatState>,
input_mode: Option<InputMode>,
messages: HashMap<i64, Vec<MessageInfo>>, messages: HashMap<i64, Vec<MessageInfo>>,
status_message: Option<String>, status_message: Option<String>,
auth_state: Option<AuthState>, auth_state: Option<AuthState>,
@@ -44,6 +45,7 @@ impl TestAppBuilder {
is_searching: false, is_searching: false,
search_query: String::new(), search_query: String::new(),
chat_state: None, chat_state: None,
input_mode: None,
messages: HashMap::new(), messages: HashMap::new(),
status_message: None, status_message: None,
auth_state: None, auth_state: None,
@@ -171,6 +173,12 @@ impl TestAppBuilder {
self 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 { pub fn forward_mode(mut self, message_id: i64) -> Self {
self.chat_state = Some(ChatState::Forward { self.chat_state = Some(ChatState::Forward {
@@ -252,6 +260,11 @@ impl TestAppBuilder {
app.chat_state = chat_state; app.chat_state = chat_state;
} }
// Применяем input_mode если он установлен
if let Some(input_mode) = self.input_mode {
app.input_mode = input_mode;
}
// Применяем status_message // Применяем status_message
if let Some(status) = self.status_message { if let Some(status) = self.status_message {
app.status_message = Some(status); app.status_message = Some(status);

View File

@@ -31,6 +31,7 @@ fn snapshot_input_with_text() {
let mut app = TestAppBuilder::new() let mut app = TestAppBuilder::new()
.with_chat(chat) .with_chat(chat)
.selected_chat(123) .selected_chat(123)
.insert_mode()
.message_input("Hello, how are you?") .message_input("Hello, how are you?")
.build(); .build();
@@ -52,6 +53,7 @@ fn snapshot_input_long_text_2_lines() {
let mut app = TestAppBuilder::new() let mut app = TestAppBuilder::new()
.with_chat(chat) .with_chat(chat)
.selected_chat(123) .selected_chat(123)
.insert_mode()
.message_input(long_text) .message_input(long_text)
.build(); .build();
@@ -73,6 +75,7 @@ fn snapshot_input_long_text_max_lines() {
let mut app = TestAppBuilder::new() let mut app = TestAppBuilder::new()
.with_chat(chat) .with_chat(chat)
.selected_chat(123) .selected_chat(123)
.insert_mode()
.message_input(very_long_text) .message_input(very_long_text)
.build(); .build();
@@ -95,6 +98,7 @@ fn snapshot_input_editing_mode() {
.with_chat(chat) .with_chat(chat)
.with_message(123, message) .with_message(123, message)
.selected_chat(123) .selected_chat(123)
.insert_mode()
.editing_message(1, 0) .editing_message(1, 0)
.message_input("Edited text here") .message_input("Edited text here")
.build(); .build();
@@ -118,6 +122,7 @@ fn snapshot_input_reply_mode() {
.with_chat(chat) .with_chat(chat)
.with_message(123, original_msg) .with_message(123, original_msg)
.selected_chat(123) .selected_chat(123)
.insert_mode()
.replying_to(1) .replying_to(1)
.message_input("I think it's great!") .message_input("I think it's great!")
.build(); .build();

View File

@@ -145,6 +145,7 @@ async fn test_cursor_navigation_in_input() {
let mut app = TestAppBuilder::new() let mut app = TestAppBuilder::new()
.with_chats(vec![create_test_chat("Chat 1", 101)]) .with_chats(vec![create_test_chat("Chat 1", 101)])
.selected_chat(101) .selected_chat(101)
.insert_mode()
.build(); .build();
// Вводим текст "Hello" // Вводим текст "Hello"
@@ -182,6 +183,7 @@ async fn test_home_end_in_input() {
let mut app = TestAppBuilder::new() let mut app = TestAppBuilder::new()
.with_chats(vec![create_test_chat("Chat 1", 101)]) .with_chats(vec![create_test_chat("Chat 1", 101)])
.selected_chat(101) .selected_chat(101)
.insert_mode()
.build(); .build();
// Вводим текст // Вводим текст
@@ -206,6 +208,7 @@ async fn test_backspace_with_cursor() {
let mut app = TestAppBuilder::new() let mut app = TestAppBuilder::new()
.with_chats(vec![create_test_chat("Chat 1", 101)]) .with_chats(vec![create_test_chat("Chat 1", 101)])
.selected_chat(101) .selected_chat(101)
.insert_mode()
.build(); .build();
// Вводим "Hello" // Вводим "Hello"
@@ -238,6 +241,7 @@ async fn test_insert_char_at_cursor_position() {
let mut app = TestAppBuilder::new() let mut app = TestAppBuilder::new()
.with_chats(vec![create_test_chat("Chat 1", 101)]) .with_chats(vec![create_test_chat("Chat 1", 101)])
.selected_chat(101) .selected_chat(101)
.insert_mode()
.build(); .build();
// Вводим "Hllo" // Вводим "Hllo"
@@ -259,9 +263,9 @@ async fn test_insert_char_at_cursor_position() {
assert_eq!(app.cursor_position, 2); assert_eq!(app.cursor_position, 2);
} }
/// Test: Навигация вверх по сообщениям из пустого инпута /// Test: Normal mode автоматически входит в MessageSelection
#[tokio::test] #[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![ let messages = vec![
TestMessageBuilder::new("Msg 1", 1).outgoing().build(), TestMessageBuilder::new("Msg 1", 1).outgoing().build(),
TestMessageBuilder::new("Msg 2", 2).outgoing().build(), TestMessageBuilder::new("Msg 2", 2).outgoing().build(),
@@ -274,10 +278,10 @@ async fn test_up_arrow_selects_last_message_when_input_empty() {
.with_messages(101, messages) .with_messages(101, messages)
.build(); .build();
// Инпут пустой // Инпут пустой, Normal mode
assert_eq!(app.message_input, ""); assert_eq!(app.message_input, "");
// Up - должен начать выбор сообщения (последнего) // Любая клавиша в Normal mode — auto-enters MessageSelection
handle_main_input(&mut app, key(KeyCode::Up)).await; handle_main_input(&mut app, key(KeyCode::Up)).await;
// Проверяем что вошли в режим выбора сообщения // Проверяем что вошли в режим выбора сообщения

View File

@@ -24,5 +24,5 @@ expression: output
│ │ │ │
└──────────────────────────────────────────────────────────────────────────────┘ └──────────────────────────────────────────────────────────────────────────────┘
┌──────────────────────────────────────────────────────────────────────────────┐ ┌──────────────────────────────────────────────────────────────────────────────┐
│> █ Введите сообщение... │> Press i to type...
└──────────────────────────────────────────────────────────────────────────────┘ └──────────────────────────────────────────────────────────────────────────────┘

View File

@@ -24,5 +24,5 @@ expression: output
│ │ │ │
└──────────────────────────────────────────────────────────────────────────────┘ └──────────────────────────────────────────────────────────────────────────────┘
┌──────────────────────────────────────────────────────────────────────────────┐ ┌──────────────────────────────────────────────────────────────────────────────┐
│> █ Введите сообщение... │> Press i to type...
└──────────────────────────────────────────────────────────────────────────────┘ └──────────────────────────────────────────────────────────────────────────────┘

View File

@@ -24,5 +24,5 @@ expression: output
│ │ │ │
└──────────────────────────────────────────────────────────────────────────────┘ └──────────────────────────────────────────────────────────────────────────────┘
┌──────────────────────────────────────────────────────────────────────────────┐ ┌──────────────────────────────────────────────────────────────────────────────┐
│> █ Введите сообщение... │> Press i to type...
└──────────────────────────────────────────────────────────────────────────────┘ └──────────────────────────────────────────────────────────────────────────────┘

View File

@@ -24,5 +24,5 @@ expression: output
│ │ │ │
└──────────────────────────────────────────────────────────────────────────────┘ └──────────────────────────────────────────────────────────────────────────────┘
┌──────────────────────────────────────────────────────────────────────────────┐ ┌──────────────────────────────────────────────────────────────────────────────┐
│> █ Введите сообщение... │> Press i to type...
└──────────────────────────────────────────────────────────────────────────────┘ └──────────────────────────────────────────────────────────────────────────────┘

View File

@@ -24,5 +24,5 @@ expression: output
│ │ │ │
└──────────────────────────────────────────────────────────────────────────────┘ └──────────────────────────────────────────────────────────────────────────────┘
┌──────────────────────────────────────────────────────────────────────────────┐ ┌──────────────────────────────────────────────────────────────────────────────┐
│> █ Введите сообщение... │> Press i to type...
└──────────────────────────────────────────────────────────────────────────────┘ └──────────────────────────────────────────────────────────────────────────────┘

View File

@@ -24,5 +24,5 @@ expression: output
│ │ │ │
└──────────────────────────────────────────────────────────────────────────────┘ └──────────────────────────────────────────────────────────────────────────────┘
┌──────────────────────────────────────────────────────────────────────────────┐ ┌──────────────────────────────────────────────────────────────────────────────┐
│> █ Введите сообщение... │> Press i to type...
└──────────────────────────────────────────────────────────────────────────────┘ └──────────────────────────────────────────────────────────────────────────────┘

View File

@@ -24,5 +24,5 @@ expression: output
│ │ │ │
└──────────────────────────────────────────────────────────────────────────────┘ └──────────────────────────────────────────────────────────────────────────────┘
┌──────────────────────────────────────────────────────────────────────────────┐ ┌──────────────────────────────────────────────────────────────────────────────┐
│> █ Введите сообщение... │> Press i to type...
└──────────────────────────────────────────────────────────────────────────────┘ └──────────────────────────────────────────────────────────────────────────────┘

View File

@@ -24,5 +24,5 @@ expression: output
│ │ │ │
└──────────────────────────────────────────────────────────────────────────────┘ └──────────────────────────────────────────────────────────────────────────────┘
┌──────────────────────────────────────────────────────────────────────────────┐ ┌──────────────────────────────────────────────────────────────────────────────┐
│> █ Введите сообщение... │> Press i to type...
└──────────────────────────────────────────────────────────────────────────────┘ └──────────────────────────────────────────────────────────────────────────────┘

View File

@@ -24,5 +24,5 @@ expression: output
│ │ │ │
└──────────────────────────────────────────────────────────────────────────────┘ └──────────────────────────────────────────────────────────────────────────────┘
┌──────────────────────────────────────────────────────────────────────────────┐ ┌──────────────────────────────────────────────────────────────────────────────┐
│> █ Введите сообщение... │> Press i to type...
└──────────────────────────────────────────────────────────────────────────────┘ └──────────────────────────────────────────────────────────────────────────────┘

View File

@@ -24,5 +24,5 @@ expression: output
│ │ │ │
└──────────────────────────────────────────────────────────────────────────────┘ └──────────────────────────────────────────────────────────────────────────────┘
┌──────────────────────────────────────────────────────────────────────────────┐ ┌──────────────────────────────────────────────────────────────────────────────┐
│> █ Введите сообщение... │> Press i to type...
└──────────────────────────────────────────────────────────────────────────────┘ └──────────────────────────────────────────────────────────────────────────────┘

View File

@@ -24,5 +24,5 @@ expression: output
│ │ │ │
└──────────────────────────────────────────────────────────────────────────────┘ └──────────────────────────────────────────────────────────────────────────────┘
┌──────────────────────────────────────────────────────────────────────────────┐ ┌──────────────────────────────────────────────────────────────────────────────┐
│> █ Введите сообщение... │> Press i to type...
└──────────────────────────────────────────────────────────────────────────────┘ └──────────────────────────────────────────────────────────────────────────────┘

View File

@@ -24,5 +24,5 @@ expression: output
│ │ │ │
└──────────────────────────────────────────────────────────────────────────────┘ └──────────────────────────────────────────────────────────────────────────────┘
┌──────────────────────────────────────────────────────────────────────────────┐ ┌──────────────────────────────────────────────────────────────────────────────┐
│> █ Введите сообщение... │> Press i to type...
└──────────────────────────────────────────────────────────────────────────────┘ └──────────────────────────────────────────────────────────────────────────────┘

View File

@@ -24,5 +24,5 @@ expression: output
│ │ │ │
└──────────────────────────────────────────────────────────────────────────────┘ └──────────────────────────────────────────────────────────────────────────────┘
┌──────────────────────────────────────────────────────────────────────────────┐ ┌──────────────────────────────────────────────────────────────────────────────┐
│> █ Введите сообщение... │> Press i to type...
└──────────────────────────────────────────────────────────────────────────────┘ └──────────────────────────────────────────────────────────────────────────────┘

View File

@@ -24,5 +24,5 @@ expression: output
│ │ │ │
└──────────────────────────────────────────────────────────────────────────────┘ └──────────────────────────────────────────────────────────────────────────────┘
┌──────────────────────────────────────────────────────────────────────────────┐ ┌──────────────────────────────────────────────────────────────────────────────┐
│> █ Введите сообщение... │> Press i to type...
└──────────────────────────────────────────────────────────────────────────────┘ └──────────────────────────────────────────────────────────────────────────────┘

View File

@@ -24,5 +24,5 @@ expression: output
│ │ │ │
└──────────────────────────────────────────────────────────────────────────────┘ └──────────────────────────────────────────────────────────────────────────────┘
┌──────────────────────────────────────────────────────────────────────────────┐ ┌──────────────────────────────────────────────────────────────────────────────┐
│> █ Введите сообщение... │> Press i to type...
└──────────────────────────────────────────────────────────────────────────────┘ └──────────────────────────────────────────────────────────────────────────────┘

View File

@@ -24,5 +24,5 @@ expression: output
│ │ │ │
└──────────────────────────────────────────────────────────────────────────────┘ └──────────────────────────────────────────────────────────────────────────────┘
┌──────────────────────────────────────────────────────────────────────────────┐ ┌──────────────────────────────────────────────────────────────────────────────┐
│> █ Введите сообщение... │> Press i to type...
└──────────────────────────────────────────────────────────────────────────────┘ └──────────────────────────────────────────────────────────────────────────────┘

View File

@@ -24,5 +24,5 @@ expression: output
│ │ │ │
└──────────────────────────────────────────────────────────────────────────────┘ └──────────────────────────────────────────────────────────────────────────────┘
┌──────────────────────────────────────────────────────────────────────────────┐ ┌──────────────────────────────────────────────────────────────────────────────┐
│> █ Введите сообщение... │> Press i to type...
└──────────────────────────────────────────────────────────────────────────────┘ └──────────────────────────────────────────────────────────────────────────────┘

View File

@@ -24,5 +24,5 @@ expression: output
│ │ │ │
└──────────────────────────────────────────────────────────────────────────────┘ └──────────────────────────────────────────────────────────────────────────────┘
┌──────────────────────────────────────────────────────────────────────────────┐ ┌──────────────────────────────────────────────────────────────────────────────┐
│> █ Введите сообщение... │> Press i to type...
└──────────────────────────────────────────────────────────────────────────────┘ └──────────────────────────────────────────────────────────────────────────────┘

View File

@@ -24,5 +24,5 @@ expression: output
│ │ │ │
└──────────────────────────────────────────────────────────────────────────────┘ └──────────────────────────────────────────────────────────────────────────────┘
┌──────────────────────────────────────────────────────────────────────────────┐ ┌──────────────────────────────────────────────────────────────────────────────┐
│> █ Введите сообщение... │> Press i to type...
└──────────────────────────────────────────────────────────────────────────────┘ └──────────────────────────────────────────────────────────────────────────────┘

View File

@@ -24,5 +24,5 @@ expression: output
│ │ │ │
└──────────────────────────────────────────────────────────────────────────────┘ └──────────────────────────────────────────────────────────────────────────────┘
┌──────────────────────────────────────────────────────────────────────────────┐ ┌──────────────────────────────────────────────────────────────────────────────┐
│> █ Введите сообщение... │> Press i to type...
└──────────────────────────────────────────────────────────────────────────────┘ └──────────────────────────────────────────────────────────────────────────────┘

View File

@@ -24,5 +24,5 @@ expression: output
│ │ │ │
└──────────────────────────────────────────────────────────────────────────────┘ └──────────────────────────────────────────────────────────────────────────────┘
┌──────────────────────────────────────────────────────────────────────────────┐ ┌──────────────────────────────────────────────────────────────────────────────┐
│> █ Введите сообщение... │> Press i to type...
└──────────────────────────────────────────────────────────────────────────────┘ └──────────────────────────────────────────────────────────────────────────────┘

View File

@@ -24,5 +24,5 @@ expression: output
│ │ │ │
└──────────────────────────────────────────────────────────────────────────────┘ └──────────────────────────────────────────────────────────────────────────────┘
┌──────────────────────────────────────────────────────────────────────────────┐ ┌──────────────────────────────────────────────────────────────────────────────┐
│> █ Введите сообщение... │> Press i to type...
└──────────────────────────────────────────────────────────────────────────────┘ └──────────────────────────────────────────────────────────────────────────────┘

629
tests/vim_mode.rs Normal file
View File

@@ -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, "");
}