From 22c4e17377a0e0ec72fa6607f657c4d68671d4e7 Mon Sep 17 00:00:00 2001 From: Mikhail Kilin Date: Sat, 24 Jan 2026 02:22:47 +0300 Subject: [PATCH] fixes --- CONTEXT.md | 46 +++- ROADMAP.md | 41 +++- src/app/mod.rs | 113 ++++++++- src/input/main_input.rs | 256 +++++++++++++++++--- src/main.rs | 20 -- src/tdlib/client.rs | 377 ++++++++++++++++++++++++----- src/ui/main_screen.rs | 39 ++- src/ui/messages.rs | 525 +++++++++++++++++++++++++++++++++++----- src/ui/mod.rs | 8 +- 9 files changed, 1199 insertions(+), 226 deletions(-) diff --git a/CONTEXT.md b/CONTEXT.md index 8dc03c8..56e1423 100644 --- a/CONTEXT.md +++ b/CONTEXT.md @@ -1,6 +1,6 @@ # Текущий контекст проекта -## Статус: Фаза 6 завершена — Полировка +## Статус: Фаза 8 в процессе — Дополнительные фичи ### Что сделано @@ -30,11 +30,23 @@ - **Галочки прочтения** (✓ отправлено, ✓✓ прочитано) — обновляются в реальном времени - **Отметка сообщений как прочитанных**: при открытии чата счётчик непрочитанных сбрасывается - **Отправка текстовых сообщений** +- **Редактирование сообщений**: ↑ при пустом инпуте → выбор → Enter → редактирование +- **Удаление сообщений**: в режиме выбора нажать `d` / `в` / `Delete` → модалка подтверждения +- **Индикатор редактирования**: ✎ рядом с временем для отредактированных сообщений - **Новые сообщения в реальном времени** при открытом чате - **Поиск по чатам** (Ctrl+S): фильтрация по названию и @username - **Кеширование имён пользователей**: имена загружаются асинхронно и обновляются в UI - **Папки Telegram**: загрузка и переключение между папками (1-9) - **Медиа-заглушки**: [Фото], [Видео], [Голосовое], [Стикер], [GIF] и др. +- **Markdown форматирование в сообщениях**: + - **Жирный** (bold) + - *Курсив* (italic) + - __Подчёркнутый__ (underline) + - ~~Зачёркнутый~~ (strikethrough) + - `Код` (inline code, Pre, PreCode) — cyan на тёмном фоне + - Спойлеры — скрытый текст (серый на сером) + - Ссылки (URL, TextUrl, Email, Phone) — синий с подчёркиванием + - @Упоминания — синий с подчёркиванием #### Состояние сети - **Индикатор в футере**: показывает текущее состояние подключения @@ -54,6 +66,7 @@ #### Динамический инпут - **Автоматическое расширение**: поле ввода увеличивается при длинном тексте (до 10 строк) - **Перенос текста**: длинные сообщения переносятся на новые строки +- **Блочный курсор**: vim-style курсор █ с возможностью перемещения по тексту #### Управление - `↑/↓` стрелки — навигация по списку чатов @@ -63,8 +76,19 @@ - `Ctrl+R` — обновить список чатов - `Ctrl+C` — выход (graceful shutdown) - `↑/↓` в открытом чате — скролл сообщений (с подгрузкой старых) +- `↑` при пустом инпуте — выбор сообщения для редактирования +- `Enter` в режиме выбора — начать редактирование +- `d` / `в` / `Delete` в режиме выбора — удалить сообщение (с подтверждением) +- `y` / `н` / `Enter` — подтвердить удаление в модалке +- `n` / `т` / `Esc` — отменить удаление в модалке +- `Esc` — отменить выбор/редактирование - `1-9` — переключение папок (в списке чатов) -- Ввод текста в поле сообщения +- **Редактирование текста в инпуте:** + - `←` / `→` — перемещение курсора + - `Home` — курсор в начало + - `End` — курсор в конец + - `Backspace` — удалить символ слева + - `Delete` — удалить символ справа ### Структура проекта @@ -132,14 +156,18 @@ API_ID=your_api_id API_HASH=your_api_hash ``` -## Что НЕ сделано / TODO (Фаза 7) +## Что НЕ сделано / TODO (Фаза 8) -- [ ] Удалить дублирование current_messages между App и TdClient -- [ ] Использовать единый источник данных для сообщений -- [ ] Реализовать LRU-кэш для user_names/user_statuses вместо простого лимита -- [ ] Lazy loading для имён пользователей (загружать только видимых) -- [ ] Профилирование памяти и устранение утечек -- [ ] Markdown форматирование в сообщениях +- [x] Удалить дублирование current_messages между App и TdClient +- [x] Использовать единый источник данных для сообщений (td_client.current_chat_messages) +- [x] Реализовать LRU-кэш для user_names/user_statuses/user_usernames +- [x] Lazy loading для имён пользователей (батчевая загрузка последних 5 за цикл, лимит очереди 50) +- [x] Лимиты памяти: сообщения (500), чаты (200), chat_user_ids (500) +- [x] Markdown форматирование в сообщениях +- [x] Редактирование сообщений +- [x] Удаление сообщений +- [ ] Reply на сообщения +- [ ] Forward сообщений ## Известные проблемы diff --git a/ROADMAP.md b/ROADMAP.md index eef626f..64b4957 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -67,18 +67,39 @@ - Автоматический wrap на несколько строк - Правильное выравнивание для исходящих/входящих -## Фаза 7: Глубокий рефакторинг памяти [TODO] +## Фаза 7: Глубокий рефакторинг памяти [DONE] -- [ ] Удалить дублирование current_messages между App и TdClient -- [ ] Использовать единый источник данных для сообщений -- [ ] Реализовать LRU-кэш для user_names/user_statuses вместо простого лимита -- [ ] Lazy loading для имён пользователей (загружать только видимых) -- [ ] Профилирование памяти и устранение утечек +- [x] Удалить дублирование current_messages между App и TdClient +- [x] Использовать единый источник данных для сообщений +- [x] Реализовать LRU-кэш для user_names/user_statuses вместо простого лимита +- [x] Lazy loading для имён пользователей (батчевая загрузка последних 5 за цикл) +- [x] Лимиты памяти: + - MAX_MESSAGES_IN_CHAT = 500 + - MAX_CHATS = 200 + - MAX_CHAT_USER_IDS = 500 + - MAX_USER_CACHE_SIZE = 500 (LRU) -## Фаза 8: Дополнительные фичи [TODO] +## Фаза 8: Дополнительные фичи [IN PROGRESS] -- [ ] Markdown форматирование в сообщениях -- [ ] Редактирование сообщений -- [ ] Удаление сообщений +- [x] Markdown форматирование в сообщениях + - Bold, Italic, Underline, Strikethrough + - Code (inline, Pre, PreCode) + - Spoiler (скрытый текст) + - URLs, упоминания (@) +- [x] Редактирование сообщений + - ↑ при пустом инпуте → выбор сообщения + - Enter для начала редактирования + - Подсветка выбранного сообщения (▶) + - Esc для отмены +- [x] Удаление сообщений + - d / в / Delete в режиме выбора + - Модалка подтверждения (y/n) + - Удаление для всех если возможно +- [x] Индикатор редактирования (✎) + - Отображается рядом с временем для отредактированных сообщений +- [x] Блочный курсор в поле ввода + - Vim-style курсор █ + - Перемещение ←/→, Home/End + - Редактирование в любой позиции - [ ] Reply на сообщения - [ ] Forward сообщений diff --git a/src/app/mod.rs b/src/app/mod.rs index bf3b3a0..f1a4c52 100644 --- a/src/app/mod.rs +++ b/src/app/mod.rs @@ -3,7 +3,7 @@ mod state; pub use state::AppScreen; use ratatui::widgets::ListState; -use crate::tdlib::client::{ChatInfo, MessageInfo}; +use crate::tdlib::client::ChatInfo; use crate::tdlib::TdClient; pub struct App { @@ -19,8 +19,9 @@ pub struct App { pub chats: Vec, pub chat_list_state: ListState, pub selected_chat_id: Option, - pub current_messages: Vec, pub message_input: String, + /// Позиция курсора в message_input (в символах) + pub cursor_position: usize, pub message_scroll_offset: usize, /// None = All (основной список), Some(id) = папка с id pub selected_folder_id: Option, @@ -30,6 +31,14 @@ pub struct App { pub search_query: String, /// Флаг для оптимизации рендеринга - перерисовывать только при изменениях pub needs_redraw: bool, + // Edit message state + /// ID сообщения, которое редактируется (None = режим отправки нового) + pub editing_message_id: Option, + /// Индекс выбранного сообщения для навигации (снизу вверх, 0 = последнее) + pub selected_message_index: Option, + // Delete confirmation + /// ID сообщения для подтверждения удаления (показывает модалку) + pub confirm_delete_message_id: Option, } impl App { @@ -48,22 +57,20 @@ impl App { chats: Vec::new(), chat_list_state: state, selected_chat_id: None, - current_messages: Vec::new(), message_input: String::new(), + cursor_position: 0, message_scroll_offset: 0, selected_folder_id: None, // None = All is_loading: true, is_searching: false, search_query: String::new(), needs_redraw: true, + editing_message_id: None, + selected_message_index: None, + confirm_delete_message_id: None, } } - /// Помечает UI как требующий перерисовки - pub fn mark_dirty(&mut self) { - self.needs_redraw = true; - } - pub fn next_chat(&mut self) { let filtered = self.get_filtered_chats(); if filtered.is_empty() { @@ -111,18 +118,95 @@ impl App { pub fn close_chat(&mut self) { self.selected_chat_id = None; - self.current_messages.clear(); self.message_input.clear(); + self.cursor_position = 0; self.message_scroll_offset = 0; + self.editing_message_id = None; + self.selected_message_index = None; // Очищаем данные в TdClient self.td_client.current_chat_id = None; self.td_client.current_chat_messages.clear(); } - pub fn select_first_chat(&mut self) { - if !self.chats.is_empty() { - self.chat_list_state.select(Some(0)); + /// Начать выбор сообщения для редактирования (при стрелке вверх в пустом инпуте) + pub fn start_message_selection(&mut self) { + if self.td_client.current_chat_messages.is_empty() { + return; } + // Начинаем с последнего сообщения (индекс 0 = самое новое снизу) + self.selected_message_index = Some(0); + } + + /// Выбрать предыдущее сообщение (вверх по списку = увеличить индекс) + pub fn select_previous_message(&mut self) { + let total = self.td_client.current_chat_messages.len(); + if total == 0 { + return; + } + self.selected_message_index = Some( + self.selected_message_index + .map(|i| (i + 1).min(total - 1)) + .unwrap_or(0) + ); + } + + /// Выбрать следующее сообщение (вниз по списку = уменьшить индекс) + pub fn select_next_message(&mut self) { + self.selected_message_index = self.selected_message_index + .map(|i| if i > 0 { Some(i - 1) } else { None }) + .flatten(); + } + + /// Получить выбранное сообщение + pub fn get_selected_message(&self) -> Option<&crate::tdlib::client::MessageInfo> { + self.selected_message_index.and_then(|idx| { + let total = self.td_client.current_chat_messages.len(); + if total == 0 || idx >= total { + return None; + } + // idx=0 это последнее сообщение (total-1), idx=1 это предпоследнее (total-2), и т.д. + self.td_client.current_chat_messages.get(total - 1 - idx) + }) + } + + /// Начать редактирование выбранного сообщения + pub fn start_editing_selected(&mut self) -> bool { + // Сначала извлекаем данные из сообщения + let msg_data = self.get_selected_message().and_then(|msg| { + if msg.can_be_edited && msg.is_outgoing { + Some((msg.id, msg.content.clone())) + } else { + None + } + }); + + // Затем присваиваем + if let Some((id, content)) = msg_data { + self.editing_message_id = Some(id); + self.cursor_position = content.chars().count(); + self.message_input = content; + self.selected_message_index = None; + return true; + } + false + } + + /// Отменить редактирование + pub fn cancel_editing(&mut self) { + self.editing_message_id = None; + self.selected_message_index = None; + self.message_input.clear(); + self.cursor_position = 0; + } + + /// Проверить, находимся ли в режиме редактирования + pub fn is_editing(&self) -> bool { + self.editing_message_id.is_some() + } + + /// Проверить, находимся ли в режиме выбора сообщения + pub fn is_selecting_message(&self) -> bool { + self.selected_message_index.is_some() } pub fn get_selected_chat_id(&self) -> Option { @@ -217,4 +301,9 @@ impl App { } } } + + /// Проверить, показывается ли модалка подтверждения удаления + pub fn is_confirm_delete_shown(&self) -> bool { + self.confirm_delete_message_id.is_some() + } } diff --git a/src/input/main_input.rs b/src/input/main_input.rs index 4812e86..dd5801a 100644 --- a/src/input/main_input.rs +++ b/src/input/main_input.rs @@ -24,6 +24,49 @@ pub async fn handle(app: &mut App, key: KeyEvent) { _ => {} } + // Модалка подтверждения удаления + if app.is_confirm_delete_shown() { + match key.code { + KeyCode::Char('y') | KeyCode::Char('н') | KeyCode::Enter => { + // Подтверждение удаления + if let Some(msg_id) = app.confirm_delete_message_id { + if let Some(chat_id) = app.get_selected_chat_id() { + // Находим сообщение для проверки can_be_deleted_for_all_users + let can_delete_for_all = app.td_client.current_chat_messages + .iter() + .find(|m| m.id == msg_id) + .map(|m| m.can_be_deleted_for_all_users) + .unwrap_or(false); + + match timeout( + Duration::from_secs(5), + app.td_client.delete_messages(chat_id, vec![msg_id], can_delete_for_all) + ).await { + Ok(Ok(_)) => { + // Удаляем из локального списка + app.td_client.current_chat_messages.retain(|m| m.id != msg_id); + app.selected_message_index = None; + } + Ok(Err(e)) => { + app.error_message = Some(e); + } + Err(_) => { + app.error_message = Some("Таймаут удаления".to_string()); + } + } + } + } + app.confirm_delete_message_id = None; + } + KeyCode::Char('n') | KeyCode::Char('т') | KeyCode::Esc => { + // Отмена удаления + app.confirm_delete_message_id = None; + } + _ => {} + } + return; + } + // Режим поиска if app.is_searching { match key.code { @@ -37,8 +80,8 @@ pub async fn handle(app: &mut App, key: KeyEvent) { app.status_message = Some("Загрузка сообщений...".to_string()); app.message_scroll_offset = 0; match timeout(Duration::from_secs(10), app.td_client.get_chat_history(chat_id, 100)).await { - Ok(Ok(messages)) => { - app.current_messages = messages; + Ok(Ok(_)) => { + // Сообщения уже сохранены в td_client.current_chat_messages app.status_message = None; } Ok(Err(e)) => { @@ -75,27 +118,66 @@ pub async fn handle(app: &mut App, key: KeyEvent) { - // Enter - открыть чат или отправить сообщение + // Enter - открыть чат, отправить сообщение или редактировать if key.code == KeyCode::Enter { if app.selected_chat_id.is_some() { - // Отправка сообщения + // Режим выбора сообщения + if app.is_selecting_message() { + // Начать редактирование выбранного сообщения + if app.start_editing_selected() { + // Редактирование начато + } else { + // Нельзя редактировать это сообщение + app.selected_message_index = None; + } + return; + } + + // Отправка или редактирование сообщения if !app.message_input.is_empty() { if let Some(chat_id) = app.get_selected_chat_id() { let text = app.message_input.clone(); - app.message_input.clear(); - match timeout(Duration::from_secs(5), app.td_client.send_message(chat_id, text.clone())).await { - Ok(Ok(sent_msg)) => { - // Добавляем отправленное сообщение в список - app.current_messages.push(sent_msg); - // Сбрасываем скролл чтобы видеть новое сообщение - app.message_scroll_offset = 0; + if let Some(msg_id) = app.editing_message_id { + // Режим редактирования + app.message_input.clear(); + app.cursor_position = 0; + app.editing_message_id = None; + + match timeout(Duration::from_secs(5), app.td_client.edit_message(chat_id, msg_id, text)).await { + Ok(Ok(edited_msg)) => { + // Обновляем сообщение в списке + if let Some(msg) = app.td_client.current_chat_messages.iter_mut().find(|m| m.id == msg_id) { + msg.content = edited_msg.content; + msg.entities = edited_msg.entities; + msg.edit_date = edited_msg.edit_date; + } + } + Ok(Err(e)) => { + app.error_message = Some(e); + } + Err(_) => { + app.error_message = Some("Таймаут редактирования".to_string()); + } } - Ok(Err(e)) => { - app.error_message = Some(e); - } - Err(_) => { - app.error_message = Some("Таймаут отправки".to_string()); + } else { + // Обычная отправка + app.message_input.clear(); + app.cursor_position = 0; + + match timeout(Duration::from_secs(5), app.td_client.send_message(chat_id, text)).await { + Ok(Ok(sent_msg)) => { + // Добавляем отправленное сообщение в список (с лимитом) + app.td_client.push_message(sent_msg); + // Сбрасываем скролл чтобы видеть новое сообщение + app.message_scroll_offset = 0; + } + Ok(Err(e)) => { + app.error_message = Some(e); + } + Err(_) => { + app.error_message = Some("Таймаут отправки".to_string()); + } } } } @@ -110,8 +192,8 @@ pub async fn handle(app: &mut App, key: KeyEvent) { app.status_message = Some("Загрузка сообщений...".to_string()); app.message_scroll_offset = 0; match timeout(Duration::from_secs(10), app.td_client.get_chat_history(chat_id, 100)).await { - Ok(Ok(messages)) => { - app.current_messages = messages; + Ok(Ok(_)) => { + // Сообщения уже сохранены в td_client.current_chat_messages app.status_message = None; } Ok(Err(e)) => { @@ -129,9 +211,15 @@ pub async fn handle(app: &mut App, key: KeyEvent) { return; } - // Esc - закрыть чат + // Esc - отменить выбор/редактирование или закрыть чат if key.code == KeyCode::Esc { - if app.selected_chat_id.is_some() { + if app.is_selecting_message() { + // Отменить выбор сообщения + app.selected_message_index = None; + } else if app.is_editing() { + // Отменить редактирование + app.cancel_editing(); + } else if app.selected_chat_id.is_some() { app.close_chat(); } return; @@ -139,14 +227,97 @@ pub async fn handle(app: &mut App, key: KeyEvent) { // Режим открытого чата if app.selected_chat_id.is_some() { + // Режим выбора сообщения для редактирования/удаления + if app.is_selecting_message() { + match key.code { + KeyCode::Up => { + app.select_previous_message(); + } + KeyCode::Down => { + app.select_next_message(); + // Если вышли из режима выбора (индекс стал None), ничего не делаем + } + KeyCode::Char('d') | KeyCode::Char('в') | KeyCode::Delete => { + // Показать модалку подтверждения удаления + if let Some(msg) = app.get_selected_message() { + let can_delete = msg.can_be_deleted_only_for_self || msg.can_be_deleted_for_all_users; + if can_delete { + app.confirm_delete_message_id = Some(msg.id); + } + } + } + _ => {} + } + return; + } + match key.code { KeyCode::Backspace => { - app.message_input.pop(); + // Удаляем символ слева от курсора + if app.cursor_position > 0 { + let chars: Vec = app.message_input.chars().collect(); + let mut new_input = String::new(); + for (i, ch) in chars.iter().enumerate() { + if i != app.cursor_position - 1 { + new_input.push(*ch); + } + } + app.message_input = new_input; + app.cursor_position -= 1; + } + } + KeyCode::Delete => { + // Удаляем символ справа от курсора + let len = app.message_input.chars().count(); + if app.cursor_position < len { + let chars: Vec = app.message_input.chars().collect(); + let mut new_input = String::new(); + for (i, ch) in chars.iter().enumerate() { + if i != app.cursor_position { + new_input.push(*ch); + } + } + app.message_input = new_input; + } } KeyCode::Char(c) => { - app.message_input.push(c); + // Вставляем символ в позицию курсора + let chars: Vec = app.message_input.chars().collect(); + let mut new_input = String::new(); + for (i, ch) in chars.iter().enumerate() { + if i == app.cursor_position { + new_input.push(c); + } + new_input.push(*ch); + } + if app.cursor_position >= chars.len() { + new_input.push(c); + } + app.message_input = new_input; + app.cursor_position += 1; } - // Стрелки - скролл сообщений + KeyCode::Left => { + // Курсор влево + if app.cursor_position > 0 { + app.cursor_position -= 1; + } + } + KeyCode::Right => { + // Курсор вправо + let len = app.message_input.chars().count(); + if app.cursor_position < len { + app.cursor_position += 1; + } + } + KeyCode::Home => { + // Курсор в начало + app.cursor_position = 0; + } + KeyCode::End => { + // Курсор в конец + app.cursor_position = app.message_input.chars().count(); + } + // Стрелки вверх/вниз - скролл сообщений или начало выбора KeyCode::Down => { // Скролл вниз (к новым сообщениям) if app.message_scroll_offset > 0 { @@ -154,24 +325,29 @@ pub async fn handle(app: &mut App, key: KeyEvent) { } } KeyCode::Up => { - // Скролл вверх (к старым сообщениям) - app.message_scroll_offset += 3; + // Если инпут пустой и не в режиме редактирования — начать выбор сообщения + if app.message_input.is_empty() && !app.is_editing() { + app.start_message_selection(); + } else { + // Скролл вверх (к старым сообщениям) + app.message_scroll_offset += 3; - // Проверяем, нужно ли подгрузить старые сообщения - if !app.current_messages.is_empty() { - let oldest_msg_id = app.current_messages.first().map(|m| m.id).unwrap_or(0); - if let Some(chat_id) = app.get_selected_chat_id() { - // Подгружаем больше сообщений если скролл близко к верху - if app.message_scroll_offset > app.current_messages.len().saturating_sub(10) { - if let Ok(Ok(older)) = timeout( - Duration::from_secs(3), - app.td_client.load_older_messages(chat_id, oldest_msg_id, 20) - ).await { - if !older.is_empty() { - // Добавляем старые сообщения в начало - let mut new_messages = older; - new_messages.extend(app.current_messages.drain(..)); - app.current_messages = new_messages; + // Проверяем, нужно ли подгрузить старые сообщения + if !app.td_client.current_chat_messages.is_empty() { + let oldest_msg_id = app.td_client.current_chat_messages.first().map(|m| m.id).unwrap_or(0); + if let Some(chat_id) = app.get_selected_chat_id() { + // Подгружаем больше сообщений если скролл близко к верху + if app.message_scroll_offset > app.td_client.current_chat_messages.len().saturating_sub(10) { + if let Ok(Ok(older)) = timeout( + Duration::from_secs(3), + app.td_client.load_older_messages(chat_id, oldest_msg_id, 20) + ).await { + if !older.is_empty() { + // Добавляем старые сообщения в начало + let mut new_messages = older; + new_messages.extend(app.td_client.current_chat_messages.drain(..)); + app.td_client.current_chat_messages = new_messages; + } } } } diff --git a/src/main.rs b/src/main.rs index 012b78c..e972970 100644 --- a/src/main.rs +++ b/src/main.rs @@ -129,26 +129,6 @@ async fn run_app( app.td_client.process_pending_user_ids().await; } - // Синхронизируем сообщения из td_client в app (для новых сообщений в реальном времени) - if app.selected_chat_id.is_some() && !app.td_client.current_chat_messages.is_empty() { - let prev_messages_len = app.current_messages.len(); - // Синхронизируем все сообщения (включая обновлённые имена и is_read) - for td_msg in &app.td_client.current_chat_messages { - if let Some(app_msg) = app.current_messages.iter_mut().find(|m| m.id == td_msg.id) { - // Обновляем существующее сообщение - app_msg.sender_name = td_msg.sender_name.clone(); - app_msg.is_read = td_msg.is_read; - } else { - // Добавляем новое сообщение - app.current_messages.push(td_msg.clone()); - } - } - // Если добавились новые сообщения - нужна перерисовка - if app.current_messages.len() != prev_messages_len { - app.needs_redraw = true; - } - } - // Обновляем состояние экрана на основе auth_state let screen_changed = update_screen_state(app).await; if screen_changed { diff --git a/src/tdlib/client.rs b/src/tdlib/client.rs index ec4184b..025a261 100644 --- a/src/tdlib/client.rs +++ b/src/tdlib/client.rs @@ -1,9 +1,82 @@ use std::env; use std::collections::HashMap; use tdlib_rs::enums::{AuthorizationState, ChatList, ChatType, ConnectionState, MessageContent, Update, User, UserStatus}; +use tdlib_rs::types::TextEntity; /// Максимальный размер кэшей пользователей const MAX_USER_CACHE_SIZE: usize = 500; +/// Максимальное количество сообщений в текущем чате +const MAX_MESSAGES_IN_CHAT: usize = 500; +/// Максимальное количество чатов +const MAX_CHATS: usize = 200; +/// Максимальный размер кэша chat_user_ids +const MAX_CHAT_USER_IDS: usize = 500; + +/// Простой LRU-кэш на основе HashMap + Vec для отслеживания порядка +pub struct LruCache { + map: HashMap, + /// Порядок доступа: последний элемент — самый недавно использованный + order: Vec, + capacity: usize, +} + +impl LruCache { + pub fn new(capacity: usize) -> Self { + Self { + map: HashMap::with_capacity(capacity), + order: Vec::with_capacity(capacity), + capacity, + } + } + + /// Получить значение и обновить порядок доступа + pub fn get(&mut self, key: &i64) -> Option<&V> { + if self.map.contains_key(key) { + // Перемещаем ключ в конец (самый недавно использованный) + self.order.retain(|k| k != key); + self.order.push(*key); + self.map.get(key) + } else { + None + } + } + + /// Получить значение без обновления порядка (для read-only доступа) + pub fn peek(&self, key: &i64) -> Option<&V> { + self.map.get(key) + } + + /// Вставить значение + pub fn insert(&mut self, key: i64, value: V) { + if self.map.contains_key(&key) { + // Обновляем существующее значение + self.map.insert(key, value); + self.order.retain(|k| *k != key); + self.order.push(key); + } else { + // Если кэш полон, удаляем самый старый элемент + if self.map.len() >= self.capacity { + if let Some(oldest) = self.order.first().copied() { + self.order.remove(0); + self.map.remove(&oldest); + } + } + self.map.insert(key, value); + self.order.push(key); + } + } + + /// Проверить наличие ключа + pub fn contains_key(&self, key: &i64) -> bool { + self.map.contains_key(key) + } + + /// Количество элементов + #[allow(dead_code)] + pub fn len(&self) -> usize { + self.map.len() + } +} use tdlib_rs::functions; use tdlib_rs::types::{Chat as TdChat, Message as TdMessage}; @@ -46,8 +119,18 @@ pub struct MessageInfo { pub sender_name: String, pub is_outgoing: bool, pub content: String, + /// Сущности форматирования (bold, italic, code и т.д.) + pub entities: Vec, pub date: i32, + /// Дата редактирования (0 если не редактировалось) + pub edit_date: i32, pub is_read: bool, + /// Можно ли редактировать сообщение + pub can_be_edited: bool, + /// Можно ли удалить только для себя + pub can_be_deleted_only_for_self: bool, + /// Можно ли удалить для всех + pub can_be_deleted_for_all_users: bool, } #[derive(Debug, Clone)] @@ -97,10 +180,10 @@ pub struct TdClient { pub current_chat_messages: Vec, /// ID текущего открытого чата (для получения новых сообщений) pub current_chat_id: Option, - /// Кэш usernames: user_id -> username - user_usernames: HashMap, - /// Кэш имён: user_id -> display_name (first_name + last_name) - user_names: HashMap, + /// LRU-кэш usernames: user_id -> username + user_usernames: LruCache, + /// LRU-кэш имён: user_id -> display_name (first_name + last_name) + user_names: LruCache, /// Связь chat_id -> user_id для приватных чатов chat_user_ids: HashMap, /// Очередь сообщений для отметки как прочитанных: (chat_id, message_ids) @@ -111,8 +194,8 @@ pub struct TdClient { pub folders: Vec, /// Позиция основного списка среди папок pub main_chat_list_position: i32, - /// Онлайн-статусы пользователей: user_id -> status - user_statuses: HashMap, + /// LRU-кэш онлайн-статусов пользователей: user_id -> status + user_statuses: LruCache, /// Состояние сетевого соединения pub network_state: NetworkState, } @@ -136,14 +219,14 @@ impl TdClient { chats: Vec::new(), current_chat_messages: Vec::new(), current_chat_id: None, - user_usernames: HashMap::new(), - user_names: HashMap::new(), + user_usernames: LruCache::new(MAX_USER_CACHE_SIZE), + user_names: LruCache::new(MAX_USER_CACHE_SIZE), chat_user_ids: HashMap::new(), pending_view_messages: Vec::new(), pending_user_ids: Vec::new(), folders: Vec::new(), main_chat_list_position: 0, - user_statuses: HashMap::new(), + user_statuses: LruCache::new(MAX_USER_CACHE_SIZE), network_state: NetworkState::Connecting, } } @@ -156,23 +239,21 @@ impl TdClient { self.client_id } - /// Очистка кэшей если они превышают лимит - fn trim_caches(&mut self) { - if self.user_names.len() > MAX_USER_CACHE_SIZE { - // Оставляем только пользователей из текущих чатов - let active_user_ids: std::collections::HashSet = - self.chat_user_ids.values().copied().collect(); - self.user_names.retain(|id, _| active_user_ids.contains(id)); - self.user_usernames.retain(|id, _| active_user_ids.contains(id)); - self.user_statuses.retain(|id, _| active_user_ids.contains(id)); + /// Добавляет сообщение в текущий чат с соблюдением лимита + pub fn push_message(&mut self, msg: MessageInfo) { + self.current_chat_messages.push(msg); + // Ограничиваем количество сообщений (удаляем старые) + if self.current_chat_messages.len() > MAX_MESSAGES_IN_CHAT { + self.current_chat_messages.remove(0); } } /// Получение онлайн-статуса пользователя по chat_id (для приватных чатов) + /// Использует peek для read-only доступа (не обновляет LRU порядок) pub fn get_user_status_by_chat_id(&self, chat_id: i64) -> Option<&UserOnlineStatus> { self.chat_user_ids .get(&chat_id) - .and_then(|user_id| self.user_statuses.get(user_id)) + .and_then(|user_id| self.user_statuses.peek(user_id)) } /// Инициализация TDLib с параметрами @@ -216,7 +297,7 @@ impl TdClient { let (last_message_text, last_message_date) = update .last_message .as_ref() - .map(|msg| (extract_message_text_static(msg), msg.date)) + .map(|msg| (extract_message_text_static(msg).0, msg.date)) .unwrap_or_default(); if let Some(chat) = self.chats.iter_mut().find(|c| c.id == chat_id) { @@ -310,7 +391,7 @@ impl TdClient { let is_incoming = !msg_info.is_outgoing; // Проверяем, что сообщение ещё не добавлено (по id) if !self.current_chat_messages.iter().any(|m| m.id == msg_info.id) { - self.current_chat_messages.push(msg_info); + self.push_message(msg_info); // Если это входящее сообщение — добавляем в очередь для отметки как прочитанное if is_incoming { self.pending_view_messages.push((chat_id, vec![msg_id])); @@ -354,9 +435,7 @@ impl TdClient { } } } - - // Периодически очищаем кэши - self.trim_caches(); + // LRU-кэш автоматически удаляет старые записи при вставке } Update::ChatFolders(update) => { // Обновляем список папок @@ -429,15 +508,22 @@ impl TdClient { let (last_message, last_message_date) = td_chat .last_message .as_ref() - .map(|m| (extract_message_text_static(m), m.date)) + .map(|m| (extract_message_text_static(m).0, m.date)) .unwrap_or_default(); // Извлекаем user_id для приватных чатов и сохраняем связь let username = match &td_chat.r#type { ChatType::Private(private) => { + // Ограничиваем размер chat_user_ids + if self.chat_user_ids.len() >= MAX_CHAT_USER_IDS && !self.chat_user_ids.contains_key(&td_chat.id) { + // Удаляем случайную запись (первую найденную) + if let Some(&key) = self.chat_user_ids.keys().next() { + self.chat_user_ids.remove(&key); + } + } self.chat_user_ids.insert(td_chat.id, private.user_id); - // Проверяем, есть ли уже username в кэше - self.user_usernames.get(&private.user_id).map(|u| format!("@{}", u)) + // Проверяем, есть ли уже username в кэше (peek не обновляет LRU) + self.user_usernames.peek(&private.user_id).map(|u| format!("@{}", u)) } _ => None, }; @@ -493,6 +579,13 @@ impl TdClient { } } else { self.chats.push(chat_info); + // Ограничиваем количество чатов + if self.chats.len() > MAX_CHATS { + // Удаляем чат с наименьшим order (наименее активный) + if let Some(min_idx) = self.chats.iter().enumerate().min_by_key(|(_, c)| c.order).map(|(i, _)| i) { + self.chats.remove(min_idx); + } + } } // Сортируем чаты по order (TDLib order учитывает pinned и время) @@ -502,9 +595,9 @@ impl TdClient { fn convert_message(&mut self, message: &TdMessage, chat_id: i64) -> MessageInfo { let sender_name = match &message.sender_id { tdlib_rs::enums::MessageSender::User(user) => { - // Пробуем получить имя из кеша - if let Some(name) = self.user_names.get(&user.user_id) { - name.clone() + // Пробуем получить имя из кеша (get обновляет LRU порядок) + if let Some(name) = self.user_names.get(&user.user_id).cloned() { + name } else { // Добавляем в очередь для загрузки if !self.pending_user_ids.contains(&user.user_id) { @@ -535,13 +628,20 @@ impl TdClient { true // Входящие сообщения не показывают галочки }; + let (content, entities) = extract_message_text_static(message); + MessageInfo { id: message.id, sender_name, is_outgoing: message.is_outgoing, - content: extract_message_text_static(message), + content, + entities, date: message.date, + edit_date: message.edit_date, is_read, + can_be_edited: message.can_be_edited, + can_be_deleted_only_for_self: message.can_be_deleted_only_for_self, + can_be_deleted_for_all_users: message.can_be_deleted_for_all_users, } } @@ -760,16 +860,32 @@ impl TdClient { } } - /// Отправка текстового сообщения + /// Отправка текстового сообщения с поддержкой Markdown pub async fn send_message(&self, chat_id: i64, text: String) -> Result { - use tdlib_rs::types::{FormattedText, InputMessageText}; - use tdlib_rs::enums::InputMessageContent; + use tdlib_rs::types::{FormattedText, InputMessageText, TextParseModeMarkdown}; + use tdlib_rs::enums::{InputMessageContent, TextParseMode}; + + // Парсим markdown в тексте + let formatted_text = match functions::parse_text_entities( + text.clone(), + TextParseMode::Markdown(TextParseModeMarkdown { version: 2 }), + self.client_id, + ).await { + Ok(tdlib_rs::enums::FormattedText::FormattedText(ft)) => FormattedText { + text: ft.text, + entities: ft.entities, + }, + Err(_) => { + // Если парсинг не удался, отправляем как plain text + FormattedText { + text: text.clone(), + entities: vec![], + } + } + }; let content = InputMessageContent::InputMessageText(InputMessageText { - text: FormattedText { - text: text.clone(), - entities: vec![], - }, + text: formatted_text, link_preview_options: None, clear_draft: true, }); @@ -786,20 +902,102 @@ impl TdClient { match result { Ok(tdlib_rs::enums::Message::Message(msg)) => { - // Конвертируем отправленное сообщение в MessageInfo + // Извлекаем текст и entities из отправленного сообщения + let (content, entities) = extract_message_text_static(&msg); Ok(MessageInfo { id: msg.id, - sender_name: "You".to_string(), + sender_name: "Вы".to_string(), is_outgoing: true, - content: text, + content, + entities, date: msg.date, + edit_date: msg.edit_date, is_read: false, + can_be_edited: msg.can_be_edited, + can_be_deleted_only_for_self: msg.can_be_deleted_only_for_self, + can_be_deleted_for_all_users: msg.can_be_deleted_for_all_users, }) } Err(e) => Err(format!("Ошибка отправки сообщения: {:?}", e)), } } + /// Редактирование текстового сообщения с поддержкой Markdown + pub async fn edit_message(&self, chat_id: i64, message_id: i64, text: String) -> Result { + use tdlib_rs::types::{FormattedText, InputMessageText, TextParseModeMarkdown}; + use tdlib_rs::enums::{InputMessageContent, TextParseMode}; + + // Парсим markdown в тексте + let formatted_text = match functions::parse_text_entities( + text.clone(), + TextParseMode::Markdown(TextParseModeMarkdown { version: 2 }), + self.client_id, + ).await { + Ok(tdlib_rs::enums::FormattedText::FormattedText(ft)) => FormattedText { + text: ft.text, + entities: ft.entities, + }, + Err(_) => { + // Если парсинг не удался, отправляем как plain text + FormattedText { + text: text.clone(), + entities: vec![], + } + } + }; + + let content = InputMessageContent::InputMessageText(InputMessageText { + text: formatted_text, + link_preview_options: None, + clear_draft: true, + }); + + let result = functions::edit_message_text( + chat_id, + message_id, + content, + self.client_id, + ) + .await; + + match result { + Ok(tdlib_rs::enums::Message::Message(msg)) => { + let (content, entities) = extract_message_text_static(&msg); + Ok(MessageInfo { + id: msg.id, + sender_name: "Вы".to_string(), + is_outgoing: true, + content, + entities, + date: msg.date, + edit_date: msg.edit_date, + is_read: true, + can_be_edited: msg.can_be_edited, + can_be_deleted_only_for_self: msg.can_be_deleted_only_for_self, + can_be_deleted_for_all_users: msg.can_be_deleted_for_all_users, + }) + } + Err(e) => Err(format!("Ошибка редактирования сообщения: {:?}", e)), + } + } + + /// Удаление сообщений + /// revoke = true удаляет для всех, false только для себя + pub async fn delete_messages(&self, chat_id: i64, message_ids: Vec, revoke: bool) -> Result<(), String> { + let result = functions::delete_messages( + chat_id, + message_ids, + revoke, + self.client_id, + ) + .await; + + match result { + Ok(_) => Ok(()), + Err(e) => Err(format!("Ошибка удаления сообщения: {:?}", e)), + } + } + /// Обработка очереди сообщений для отметки как прочитанных pub async fn process_pending_view_messages(&mut self) { let pending = std::mem::take(&mut self.pending_view_messages); @@ -815,14 +1013,21 @@ impl TdClient { } } - /// Обработка очереди user_id для загрузки имён + /// Обработка очереди user_id для загрузки имён (lazy loading) + /// Загружает только последние 5 запросов за цикл для снижения нагрузки pub async fn process_pending_user_ids(&mut self) { - let pending = std::mem::take(&mut self.pending_user_ids); - for user_id in pending { - // Пропускаем если имя уже есть - if self.user_names.contains_key(&user_id) { - continue; - } + // Берём только последние запросы (они актуальнее — от недавних сообщений) + const BATCH_SIZE: usize = 5; + + // Убираем дубликаты и уже загруженные + self.pending_user_ids.retain(|id| !self.user_names.contains_key(id)); + self.pending_user_ids.dedup(); + + // Берём последние BATCH_SIZE элементов + let start = self.pending_user_ids.len().saturating_sub(BATCH_SIZE); + let batch: Vec = self.pending_user_ids.drain(start..).collect(); + + for user_id in batch { // Загружаем информацию о пользователе if let Ok(User::User(user)) = functions::get_user(user_id, self.client_id).await { let display_name = if user.last_name.is_empty() { @@ -840,37 +1045,83 @@ impl TdClient { } } } + + // Ограничиваем размер очереди (старые запросы отбрасываем) + const MAX_QUEUE_SIZE: usize = 50; + if self.pending_user_ids.len() > MAX_QUEUE_SIZE { + let excess = self.pending_user_ids.len() - MAX_QUEUE_SIZE; + self.pending_user_ids.drain(0..excess); + } } } -/// Статическая функция для извлечения текста сообщения (без &self) -fn extract_message_text_static(message: &TdMessage) -> String { +/// Статическая функция для извлечения текста и entities сообщения (без &self) +fn extract_message_text_static(message: &TdMessage) -> (String, Vec) { match &message.content { - MessageContent::MessageText(text) => text.text.text.clone(), + MessageContent::MessageText(text) => { + (text.text.text.clone(), text.text.entities.clone()) + } MessageContent::MessagePhoto(photo) => { if photo.caption.text.is_empty() { - "[Фото]".to_string() + ("[Фото]".to_string(), vec![]) } else { - format!("[Фото] {}", photo.caption.text) + // Добавляем смещение для "[Фото] " к entities + let prefix_len = "[Фото] ".chars().count() as i32; + let adjusted_entities: Vec = photo.caption.entities.iter() + .map(|e| TextEntity { + offset: e.offset + prefix_len, + length: e.length, + r#type: e.r#type.clone(), + }) + .collect(); + (format!("[Фото] {}", photo.caption.text), adjusted_entities) + } + } + MessageContent::MessageVideo(video) => { + if video.caption.text.is_empty() { + ("[Видео]".to_string(), vec![]) + } else { + let prefix_len = "[Видео] ".chars().count() as i32; + let adjusted_entities: Vec = video.caption.entities.iter() + .map(|e| TextEntity { + offset: e.offset + prefix_len, + length: e.length, + r#type: e.r#type.clone(), + }) + .collect(); + (format!("[Видео] {}", video.caption.text), adjusted_entities) } } - MessageContent::MessageVideo(_) => "[Видео]".to_string(), MessageContent::MessageDocument(doc) => { - format!("[Файл: {}]", doc.document.file_name) + (format!("[Файл: {}]", doc.document.file_name), vec![]) } - MessageContent::MessageVoiceNote(_) => "[Голосовое сообщение]".to_string(), - MessageContent::MessageVideoNote(_) => "[Видеосообщение]".to_string(), + MessageContent::MessageVoiceNote(_) => ("[Голосовое сообщение]".to_string(), vec![]), + MessageContent::MessageVideoNote(_) => ("[Видеосообщение]".to_string(), vec![]), MessageContent::MessageSticker(sticker) => { - format!("[Стикер: {}]", sticker.sticker.emoji) + (format!("[Стикер: {}]", sticker.sticker.emoji), vec![]) + } + MessageContent::MessageAnimation(anim) => { + if anim.caption.text.is_empty() { + ("[GIF]".to_string(), vec![]) + } else { + let prefix_len = "[GIF] ".chars().count() as i32; + let adjusted_entities: Vec = anim.caption.entities.iter() + .map(|e| TextEntity { + offset: e.offset + prefix_len, + length: e.length, + r#type: e.r#type.clone(), + }) + .collect(); + (format!("[GIF] {}", anim.caption.text), adjusted_entities) + } } - MessageContent::MessageAnimation(_) => "[GIF]".to_string(), MessageContent::MessageAudio(audio) => { - format!("[Аудио: {}]", audio.audio.title) + (format!("[Аудио: {}]", audio.audio.title), vec![]) } - MessageContent::MessageCall(_) => "[Звонок]".to_string(), + MessageContent::MessageCall(_) => ("[Звонок]".to_string(), vec![]), MessageContent::MessagePoll(poll) => { - format!("[Опрос: {}]", poll.poll.question.text) + (format!("[Опрос: {}]", poll.poll.question.text), vec![]) } - _ => "[Сообщение]".to_string(), + _ => ("[Сообщение]".to_string(), vec![]), } } diff --git a/src/ui/main_screen.rs b/src/ui/main_screen.rs index a6ca668..39106e9 100644 --- a/src/ui/main_screen.rs +++ b/src/ui/main_screen.rs @@ -8,7 +8,13 @@ use ratatui::{ use crate::app::App; use super::{chat_list, messages, footer}; +/// Порог ширины для компактного режима (одна панель) +const COMPACT_WIDTH: u16 = 80; + pub fn render(f: &mut Frame, app: &mut App) { + let area = f.area(); + let is_compact = area.width < COMPACT_WIDTH; + let chunks = Layout::default() .direction(Direction::Vertical) .constraints([ @@ -16,20 +22,33 @@ pub fn render(f: &mut Frame, app: &mut App) { Constraint::Min(0), // Main content Constraint::Length(1), // Commands footer ]) - .split(f.area()); + .split(area); render_folders(f, chunks[0], app); - let main_chunks = Layout::default() - .direction(Direction::Horizontal) - .constraints([ - Constraint::Percentage(30), // Chat list - Constraint::Percentage(70), // Messages area - ]) - .split(chunks[1]); + if is_compact { + // Компактный режим: показываем либо список чатов, либо открытый чат + if app.selected_chat_id.is_some() { + // Чат открыт — показываем только сообщения + messages::render(f, chunks[1], app); + } else { + // Чат не открыт — показываем только список чатов + chat_list::render(f, chunks[1], app); + } + } else { + // Обычный режим: две панели + let main_chunks = Layout::default() + .direction(Direction::Horizontal) + .constraints([ + Constraint::Percentage(30), // Chat list + Constraint::Percentage(70), // Messages area + ]) + .split(chunks[1]); + + chat_list::render(f, main_chunks[0], app); + messages::render(f, main_chunks[1], app); + } - chat_list::render(f, main_chunks[0], app); - messages::render(f, main_chunks[1], app); footer::render(f, chunks[2], app); } diff --git a/src/ui/messages.rs b/src/ui/messages.rs index 207a72e..f611b73 100644 --- a/src/ui/messages.rs +++ b/src/ui/messages.rs @@ -7,48 +7,305 @@ use ratatui::{ }; use crate::app::App; use crate::utils::{format_timestamp, format_date, get_day}; +use tdlib_rs::enums::TextEntityType; +use tdlib_rs::types::TextEntity; + +/// Структура для хранения стиля символа +#[derive(Clone, Default)] +struct CharStyle { + bold: bool, + italic: bool, + underline: bool, + strikethrough: bool, + code: bool, + spoiler: bool, + url: bool, + mention: bool, +} + +impl CharStyle { + fn to_style(&self, base_color: Color) -> Style { + let mut style = Style::default(); + + if self.code { + // Код отображается cyan на тёмном фоне + style = style.fg(Color::Cyan).bg(Color::DarkGray); + } else if self.spoiler { + // Спойлер — серый текст (скрытый) + style = style.fg(Color::DarkGray).bg(Color::DarkGray); + } else if self.url || self.mention { + // Ссылки и упоминания — синий с подчёркиванием + style = style.fg(Color::Blue).add_modifier(Modifier::UNDERLINED); + } else { + style = style.fg(base_color); + } + + if self.bold { + style = style.add_modifier(Modifier::BOLD); + } + if self.italic { + style = style.add_modifier(Modifier::ITALIC); + } + if self.underline { + style = style.add_modifier(Modifier::UNDERLINED); + } + if self.strikethrough { + style = style.add_modifier(Modifier::CROSSED_OUT); + } + + style + } +} + +/// Преобразует текст с entities в вектор стилизованных Span (owned) +fn format_text_with_entities(text: &str, entities: &[TextEntity], base_color: Color) -> Vec> { + if entities.is_empty() { + return vec![Span::styled(text.to_string(), Style::default().fg(base_color))]; + } + + // Создаём массив стилей для каждого символа + let chars: Vec = text.chars().collect(); + let mut char_styles: Vec = vec![CharStyle::default(); chars.len()]; + + // Применяем entities к символам + for entity in entities { + let start = entity.offset as usize; + let end = (entity.offset + entity.length) as usize; + + for i in start..end.min(chars.len()) { + match &entity.r#type { + TextEntityType::Bold => char_styles[i].bold = true, + TextEntityType::Italic => char_styles[i].italic = true, + TextEntityType::Underline => char_styles[i].underline = true, + TextEntityType::Strikethrough => char_styles[i].strikethrough = true, + TextEntityType::Code | TextEntityType::Pre | TextEntityType::PreCode(_) => { + char_styles[i].code = true + } + TextEntityType::Spoiler => char_styles[i].spoiler = true, + TextEntityType::Url | TextEntityType::TextUrl(_) | TextEntityType::EmailAddress + | TextEntityType::PhoneNumber => char_styles[i].url = true, + TextEntityType::Mention | TextEntityType::MentionName(_) => char_styles[i].mention = true, + _ => {} + } + } + } + + // Группируем последовательные символы с одинаковым стилем + let mut spans: Vec> = Vec::new(); + let mut current_text = String::new(); + let mut current_style: Option = None; + + for (i, ch) in chars.iter().enumerate() { + let style = &char_styles[i]; + + match ¤t_style { + Some(prev_style) if styles_equal(prev_style, style) => { + current_text.push(*ch); + } + _ => { + if !current_text.is_empty() { + if let Some(prev_style) = ¤t_style { + spans.push(Span::styled( + current_text.clone(), + prev_style.to_style(base_color), + )); + } + } + current_text = ch.to_string(); + current_style = Some(style.clone()); + } + } + } + + // Добавляем последний span + if !current_text.is_empty() { + if let Some(style) = current_style { + spans.push(Span::styled(current_text, style.to_style(base_color))); + } + } + + if spans.is_empty() { + spans.push(Span::styled(text.to_string(), Style::default().fg(base_color))); + } + + spans +} + +/// Проверяет равенство двух стилей +fn styles_equal(a: &CharStyle, b: &CharStyle) -> bool { + a.bold == b.bold + && a.italic == b.italic + && a.underline == b.underline + && a.strikethrough == b.strikethrough + && a.code == b.code + && a.spoiler == b.spoiler + && a.url == b.url + && a.mention == b.mention +} + +/// Рендерит текст инпута с блочным курсором +fn render_input_with_cursor<'a>(prefix: &'a str, text: &str, cursor_pos: usize, color: Color) -> Line<'a> { + let chars: Vec = text.chars().collect(); + let mut spans: Vec = vec![Span::raw(prefix.to_string())]; + + // Текст до курсора + if cursor_pos > 0 { + let before: String = chars[..cursor_pos].iter().collect(); + spans.push(Span::styled(before, Style::default().fg(color))); + } + + // Символ под курсором (или █ если курсор в конце) + if cursor_pos < chars.len() { + let cursor_char = chars[cursor_pos].to_string(); + spans.push(Span::styled(cursor_char, Style::default().fg(Color::Black).bg(color))); + } else { + // Курсор в конце - показываем блок + spans.push(Span::styled("█", Style::default().fg(color))); + } + + // Текст после курсора + if cursor_pos + 1 < chars.len() { + let after: String = chars[cursor_pos + 1..].iter().collect(); + spans.push(Span::styled(after, Style::default().fg(color))); + } + + Line::from(spans) +} + +/// Информация о строке после переноса: текст и позиция в оригинале +struct WrappedLine { + text: String, + /// Начальная позиция в символах от начала оригинального текста + start_offset: usize, +} /// Разбивает текст на строки с учётом максимальной ширины -fn wrap_text(text: &str, max_width: usize) -> Vec { +/// Возвращает строки с информацией о позициях для корректного применения entities +fn wrap_text_with_offsets(text: &str, max_width: usize) -> Vec { if max_width == 0 { - return vec![text.to_string()]; + return vec![WrappedLine { + text: text.to_string(), + start_offset: 0, + }]; } let mut result = Vec::new(); let mut current_line = String::new(); let mut current_width = 0; + let mut line_start_offset = 0; - for word in text.split_whitespace() { + // Разбиваем текст на слова, сохраняя позиции + let chars: Vec = text.chars().collect(); + let mut word_start = 0; + let mut in_word = false; + + for (i, ch) in chars.iter().enumerate() { + if ch.is_whitespace() { + if in_word { + // Конец слова + let word: String = chars[word_start..i].iter().collect(); + let word_width = word.chars().count(); + + if current_width == 0 { + current_line = word; + current_width = word_width; + line_start_offset = word_start; + } else if current_width + 1 + word_width <= max_width { + current_line.push(' '); + current_line.push_str(&word); + current_width += 1 + word_width; + } else { + result.push(WrappedLine { + text: current_line, + start_offset: line_start_offset, + }); + current_line = word; + current_width = word_width; + line_start_offset = word_start; + } + in_word = false; + } + } else if !in_word { + word_start = i; + in_word = true; + } + } + + // Обрабатываем последнее слово + if in_word { + let word: String = chars[word_start..].iter().collect(); let word_width = word.chars().count(); if current_width == 0 { - // Первое слово в строке - current_line = word.to_string(); - current_width = word_width; + current_line = word; + line_start_offset = word_start; } else if current_width + 1 + word_width <= max_width { - // Слово помещается current_line.push(' '); - current_line.push_str(word); - current_width += 1 + word_width; + current_line.push_str(&word); } else { - // Слово не помещается, начинаем новую строку - result.push(current_line); - current_line = word.to_string(); - current_width = word_width; + result.push(WrappedLine { + text: current_line, + start_offset: line_start_offset, + }); + current_line = word; + line_start_offset = word_start; } } if !current_line.is_empty() { - result.push(current_line); + result.push(WrappedLine { + text: current_line, + start_offset: line_start_offset, + }); } if result.is_empty() { - result.push(String::new()); + result.push(WrappedLine { + text: String::new(), + start_offset: 0, + }); } result } +/// Фильтрует и корректирует entities для подстроки +fn adjust_entities_for_substring( + entities: &[TextEntity], + start: usize, + length: usize, +) -> Vec { + let start = start as i32; + let end = start + length as i32; + + entities + .iter() + .filter_map(|e| { + let e_start = e.offset; + let e_end = e.offset + e.length; + + // Проверяем пересечение с нашей подстрокой + if e_end <= start || e_start >= end { + return None; + } + + // Вычисляем пересечение + let new_start = (e_start - start).max(0); + let new_end = (e_end - start).min(length as i32); + + if new_end > new_start { + Some(TextEntity { + offset: new_start, + length: new_end - new_start, + r#type: e.r#type.clone(), + }) + } else { + None + } + }) + .collect() +} + pub fn render(f: &mut Frame, area: Rect, app: &App) { if let Some(chat) = app.get_selected_chat() { // Вычисляем динамическую высоту инпута на основе длины текста @@ -93,7 +350,12 @@ pub fn render(f: &mut Frame, area: Rect, app: &App) { let mut last_day: Option = None; let mut last_sender: Option<(bool, String)> = None; // (is_outgoing, sender_name) - for msg in &app.current_messages { + // ID выбранного сообщения для подсветки + let selected_msg_id = app.get_selected_message().map(|m| m.id); + + for msg in &app.td_client.current_chat_messages { + // Проверяем, выбрано ли это сообщение + let is_selected = selected_msg_id == Some(msg.id); // Проверяем, нужно ли добавить разделитель даты let msg_day = get_day(msg.date); if last_day != Some(msg_day) { @@ -160,64 +422,116 @@ pub fn render(f: &mut Frame, area: Rect, app: &App) { // Форматируем время (HH:MM) let time = format_timestamp(msg.date); + // Цвет сообщения (жёлтый если выбрано) + let msg_color = if is_selected { + Color::Yellow + } else if msg.is_outgoing { + Color::Green + } else { + Color::White + }; + + // Маркер выбора + let selection_marker = if is_selected { "▶ " } else { "" }; + let marker_len = selection_marker.chars().count(); + if msg.is_outgoing { - // Исходящие: справа, формат "текст (HH:MM ✓✓)" + // Исходящие: справа, формат "текст (HH:MM ✎ ✓✓)" let read_mark = if msg.is_read { "✓✓" } else { "✓" }; - let time_mark = format!("({} {})", time, read_mark); + let edit_mark = if msg.edit_date > 0 { "✎ " } else { "" }; + let time_mark = format!("({} {}{})", time, edit_mark, read_mark); let time_mark_len = time_mark.chars().count() + 1; // +1 для пробела - // Максимальная ширина для текста сообщения (оставляем место для time_mark) - let max_msg_width = content_width.saturating_sub(time_mark_len + 2); + // Максимальная ширина для текста сообщения (оставляем место для time_mark и маркера) + let max_msg_width = content_width.saturating_sub(time_mark_len + marker_len + 2); - let wrapped_lines = wrap_text(&msg.content, max_msg_width); + let wrapped_lines = wrap_text_with_offsets(&msg.content, max_msg_width); let total_wrapped = wrapped_lines.len(); - for (i, line_text) in wrapped_lines.into_iter().enumerate() { + for (i, wrapped) in wrapped_lines.into_iter().enumerate() { let is_last_line = i == total_wrapped - 1; - let line_len = line_text.chars().count(); + let line_len = wrapped.text.chars().count(); + + // Получаем entities для этой строки + let line_entities = adjust_entities_for_substring( + &msg.entities, + wrapped.start_offset, + line_len, + ); + + // Форматируем текст с entities + let formatted_spans = format_text_with_entities( + &wrapped.text, + &line_entities, + msg_color, + ); if is_last_line { // Последняя строка — добавляем time_mark - let full_len = line_len + time_mark_len; + let full_len = line_len + time_mark_len + marker_len; let padding = content_width.saturating_sub(full_len + 1); - lines.push(Line::from(vec![ - Span::raw(" ".repeat(padding)), - Span::styled(line_text, Style::default().fg(Color::Green)), - Span::styled(format!(" {}", time_mark), Style::default().fg(Color::Gray)), - ])); + let mut line_spans = vec![Span::raw(" ".repeat(padding))]; + if is_selected { + line_spans.push(Span::styled(selection_marker, Style::default().fg(Color::Yellow).add_modifier(Modifier::BOLD))); + } + line_spans.extend(formatted_spans); + line_spans.push(Span::styled(format!(" {}", time_mark), Style::default().fg(Color::Gray))); + lines.push(Line::from(line_spans)); } else { // Промежуточные строки — просто текст справа - let padding = content_width.saturating_sub(line_len + 1); - lines.push(Line::from(vec![ - Span::raw(" ".repeat(padding)), - Span::styled(line_text, Style::default().fg(Color::Green)), - ])); + let padding = content_width.saturating_sub(line_len + marker_len + 1); + let mut line_spans = vec![Span::raw(" ".repeat(padding))]; + if i == 0 && is_selected { + line_spans.push(Span::styled(selection_marker, Style::default().fg(Color::Yellow).add_modifier(Modifier::BOLD))); + } + line_spans.extend(formatted_spans); + lines.push(Line::from(line_spans)); } } } else { - // Входящие: слева, формат "(HH:MM) текст" - let time_str = format!("({})", time); + // Входящие: слева, формат "(HH:MM ✎) текст" + let edit_mark = if msg.edit_date > 0 { " ✎" } else { "" }; + let time_str = format!("({}{})", time, edit_mark); let time_prefix_len = time_str.chars().count() + 2; // " (HH:MM) " // Максимальная ширина для текста let max_msg_width = content_width.saturating_sub(time_prefix_len + 1); - let wrapped_lines = wrap_text(&msg.content, max_msg_width); + let wrapped_lines = wrap_text_with_offsets(&msg.content, max_msg_width); + + for (i, wrapped) in wrapped_lines.into_iter().enumerate() { + let line_len = wrapped.text.chars().count(); + + // Получаем entities для этой строки + let line_entities = adjust_entities_for_substring( + &msg.entities, + wrapped.start_offset, + line_len, + ); + + // Форматируем текст с entities + let formatted_spans = format_text_with_entities( + &wrapped.text, + &line_entities, + msg_color, + ); - for (i, line_text) in wrapped_lines.into_iter().enumerate() { if i == 0 { - // Первая строка — с временем - lines.push(Line::from(vec![ - Span::styled(format!(" {}", time_str), Style::default().fg(Color::Gray)), - Span::raw(format!(" {}", line_text)), - ])); + // Первая строка — с временем и маркером выбора + let mut line_spans = vec![]; + if is_selected { + line_spans.push(Span::styled(selection_marker, Style::default().fg(Color::Yellow).add_modifier(Modifier::BOLD))); + } + line_spans.push(Span::styled(format!(" {}", time_str), Style::default().fg(Color::Gray))); + line_spans.push(Span::raw(" ")); + line_spans.extend(formatted_spans); + lines.push(Line::from(line_spans)); } else { // Последующие строки — с отступом - let indent = " ".repeat(time_prefix_len); - lines.push(Line::from(vec![ - Span::raw(indent), - Span::raw(line_text), - ])); + let indent = " ".repeat(time_prefix_len + marker_len); + let mut line_spans = vec![Span::raw(indent)]; + line_spans.extend(formatted_spans); + lines.push(Line::from(line_spans)); } } } @@ -247,20 +561,63 @@ pub fn render(f: &mut Frame, area: Rect, app: &App) { .scroll((scroll_offset, 0)); f.render_widget(messages_widget, message_chunks[1]); - // Input box с wrap для длинного текста - let input_text = if app.message_input.is_empty() { - "> Введите сообщение...".to_string() + // Input box с wrap для длинного текста и блочным курсором + let (input_line, input_title) = if app.is_selecting_message() { + // Режим выбора сообщения - подсказка зависит от возможностей + let selected_msg = app.get_selected_message(); + let can_edit = selected_msg.map(|m| m.can_be_edited && m.is_outgoing).unwrap_or(false); + let can_delete = selected_msg.map(|m| m.can_be_deleted_only_for_self || m.can_be_deleted_for_all_users).unwrap_or(false); + + let hint = match (can_edit, can_delete) { + (true, true) => "↑↓ выбрать · Enter редакт. · d удалить · Esc отмена", + (true, false) => "↑↓ выбрать · Enter редакт. · Esc отмена", + (false, true) => "↑↓ выбрать · d удалить · Esc отмена", + (false, false) => "↑↓ выбрать · Esc отмена", + }; + (Line::from(Span::styled(hint, Style::default().fg(Color::Cyan))), " Выбор сообщения ") + } 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)), + Span::styled(" Введите новый текст...", Style::default().fg(Color::Gray)), + ]); + (line, " Редактирование (Esc отмена) ") + } else { + // Текст с курсором + let line = render_input_with_cursor("✏ ", &app.message_input, app.cursor_position, Color::Magenta); + (line, " Редактирование (Esc отмена) ") + } } else { - format!("> {}", app.message_input) + // Обычный режим + if app.message_input.is_empty() { + // Пустой инпут - показываем курсор и placeholder + let line = Line::from(vec![ + Span::raw("> "), + Span::styled("█", Style::default().fg(Color::Yellow)), + Span::styled(" Введите сообщение...", Style::default().fg(Color::Gray)), + ]); + (line, "") + } else { + // Текст с курсором + let line = render_input_with_cursor("> ", &app.message_input, app.cursor_position, Color::Yellow); + (line, "") + } }; - let input_style = if app.message_input.is_empty() { - Style::default().fg(Color::Gray) + + let input_block = if input_title.is_empty() { + Block::default().borders(Borders::ALL) } else { - Style::default().fg(Color::Yellow) + Block::default() + .borders(Borders::ALL) + .title(input_title) + .title_style(Style::default().fg(Color::Magenta).add_modifier(Modifier::BOLD)) }; - let input = Paragraph::new(input_text) - .block(Block::default().borders(Borders::ALL)) - .style(input_style) + + let input = Paragraph::new(input_line) + .block(input_block) .wrap(ratatui::widgets::Wrap { trim: false }); f.render_widget(input, message_chunks[2]); } else { @@ -270,4 +627,56 @@ pub fn render(f: &mut Frame, area: Rect, app: &App) { .alignment(Alignment::Center); f.render_widget(empty, area); } + + // Модалка подтверждения удаления + if app.is_confirm_delete_shown() { + render_delete_confirm_modal(f, area); + } +} + +/// Рендерит модалку подтверждения удаления +fn render_delete_confirm_modal(f: &mut Frame, area: Rect) { + use ratatui::widgets::Clear; + + // Размеры модалки + let modal_width = 40u16; + let modal_height = 7u16; + + // Центрируем модалку + let x = area.x + (area.width.saturating_sub(modal_width)) / 2; + let y = area.y + (area.height.saturating_sub(modal_height)) / 2; + + let modal_area = Rect::new(x, y, modal_width.min(area.width), modal_height.min(area.height)); + + // Очищаем область под модалкой + f.render_widget(Clear, modal_area); + + // Содержимое модалки + let text = vec![ + Line::from(""), + Line::from(Span::styled( + "Удалить сообщение?", + Style::default().fg(Color::White).add_modifier(Modifier::BOLD), + )), + Line::from(""), + Line::from(vec![ + Span::styled(" [y/Enter] ", Style::default().fg(Color::Green).add_modifier(Modifier::BOLD)), + Span::raw("Да"), + Span::raw(" "), + Span::styled(" [n/Esc] ", Style::default().fg(Color::Red).add_modifier(Modifier::BOLD)), + Span::raw("Нет"), + ]), + ]; + + let modal = Paragraph::new(text) + .block( + Block::default() + .borders(Borders::ALL) + .border_style(Style::default().fg(Color::Red)) + .title(" Подтверждение ") + .title_style(Style::default().fg(Color::Red).add_modifier(Modifier::BOLD)), + ) + .alignment(Alignment::Center); + + f.render_widget(modal, modal_area); } diff --git a/src/ui/mod.rs b/src/ui/mod.rs index 9b01638..ba751c1 100644 --- a/src/ui/mod.rs +++ b/src/ui/mod.rs @@ -11,10 +11,10 @@ use ratatui::style::{Color, Modifier, Style}; use ratatui::widgets::Paragraph; use crate::app::{App, AppScreen}; -/// Минимальная ширина терминала -const MIN_WIDTH: u16 = 80; /// Минимальная высота терминала -const MIN_HEIGHT: u16 = 20; +const MIN_HEIGHT: u16 = 10; +/// Минимальная ширина терминала +const MIN_WIDTH: u16 = 40; pub fn render(f: &mut Frame, app: &mut App) { let area = f.area(); @@ -34,7 +34,7 @@ pub fn render(f: &mut Frame, app: &mut App) { fn render_size_warning(f: &mut Frame, width: u16, height: u16) { let message = format!( - "Терминал слишком мал: {}x{}\n\nМинимум: {}x{}", + "{}x{}\nМинимум: {}x{}", width, height, MIN_WIDTH, MIN_HEIGHT ); let warning = Paragraph::new(message)