Compare commits

..

3 Commits

Author SHA1 Message Date
65a73f35de Merge pull request 'yet-another-changes' (#4) from yet-another-changes into main
Reviewed-on: #4
2026-01-22 12:27:08 +00:00
Mikhail Kilin
c18f43664e fixes 2026-01-22 15:26:15 +03:00
Mikhail Kilin
1ef341d907 commit 2026-01-21 21:20:18 +03:00
14 changed files with 755 additions and 175 deletions

View File

@@ -1,6 +1,6 @@
# Текущий контекст проекта
## Статус: Фаза 3 — улучшение UX
## Статус: Фаза 6 завершена — Полировка
### Что сделано
@@ -10,20 +10,50 @@
- Сессия сохраняется автоматически в папке `tdlib_data/`
- Отключены логи TDLib через FFI вызов `td_execute` до создания клиента
- Updates обрабатываются в отдельном потоке через `mpsc` канал (неблокирующе)
- **Graceful shutdown**: корректное закрытие TDLib при выходе (Ctrl+C)
#### Функциональность
- Загрузка списка чатов (до 50 штук)
- **Фильтрация чатов**: показываются только чаты из ChatList::Main (без архива)
- **Фильтрация удалённых аккаунтов**: "Deleted Account" не отображаются в списке
- Отображение названия чата, счётчика непрочитанных и **@username**
- **Иконка 📌** для закреплённых чатов
- **Иконка 🔇** для замьюченных чатов
- **Индикатор @** для чатов с непрочитанными упоминаниями
- **Онлайн-статус**: зелёная точка ● для онлайн пользователей
- Загрузка истории сообщений при открытии чата (множественные попытки)
- **Группировка сообщений по дате** (разделители "Сегодня", "Вчера", дата)
- **Группировка сообщений по дате** (разделители "Сегодня", "Вчера", дата) — по центру
- **Группировка сообщений по отправителю** (заголовок с именем)
- **Отображение времени сообщений** в формате [HH:MM]
- **Галочки прочтения** (✓ отправлено, ✓✓ прочитано)
- **Выравнивание сообщений**: исходящие справа (зелёные), входящие слева
- **Перенос длинных сообщений**: автоматический wrap на несколько строк
- **Отображение времени и галочек**: `текст (HH:MM ✓✓)` для исходящих, `(HH:MM) текст` для входящих
- **Галочки прочтения** (✓ отправлено, ✓✓ прочитано) — обновляются в реальном времени
- **Отметка сообщений как прочитанных**: при открытии чата счётчик непрочитанных сбрасывается
- **Отправка текстовых сообщений**
- **Новые сообщения в реальном времени** при открытом чате
- **Поиск по чатам** (Ctrl+S): фильтрация по названию и @username
- **Кеширование имён пользователей**: имена загружаются асинхронно и обновляются в UI
- **Папки Telegram**: загрузка и переключение между папками (1-9)
- **Медиа-заглушки**: [Фото], [Видео], [Голосовое], [Стикер], [GIF] и др.
#### Состояние сети
- **Индикатор в футере**: показывает текущее состояние подключения
- `⚠ Нет сети` — красный, ожидание сети
- `⏳ Прокси...` — cyan, подключение к прокси
- `⏳ Подключение...` — cyan, подключение к серверам
- `⏳ Обновление...` — cyan, синхронизация данных
#### Оптимизации
- **60 FPS ready**: poll таймаут 16ms, рендеринг только при изменениях (`needs_redraw` флаг)
- **Оптимизация памяти**:
- Очистка сообщений при закрытии чата
- Лимит кэша пользователей (500)
- Периодическая очистка неактивных записей
- **Минимальное разрешение**: предупреждение если терминал меньше 80x20
#### Динамический инпут
- **Автоматическое расширение**: поле ввода увеличивается при длинном тексте (до 10 строк)
- **Перенос текста**: длинные сообщения переносятся на новые строки
#### Управление
- `↑/↓` стрелки — навигация по списку чатов
@@ -31,8 +61,8 @@
- `Esc` — закрыть открытый чат / отменить поиск
- `Ctrl+S` — поиск по чатам (фильтрация по названию и username)
- `Ctrl+R` — обновить список чатов
- `Ctrl+C` — выход
- `Cmd+↑/Cmd+↓` — скролл сообщений в открытом чате (с подгрузкой старых)
- `Ctrl+C` — выход (graceful shutdown)
- `↑/↓` в открытом чате — скролл сообщений (с подгрузкой старых)
- `1-9` — переключение папок (в списке чатов)
- Ввод текста в поле сообщения
@@ -40,26 +70,26 @@
```
src/
├── main.rs # Точка входа, event loop, TDLib инициализация
├── main.rs # Точка входа, event loop, TDLib инициализация, graceful shutdown
├── app/
│ ├── mod.rs # App структура и состояние
│ ├── mod.rs # App структура и состояние (needs_redraw флаг)
│ └── state.rs # AppScreen enum
├── ui/
│ ├── mod.rs # Роутинг UI по экранам
│ ├── mod.rs # Роутинг UI по экранам, проверка минимального размера
│ ├── loading.rs # Экран загрузки
│ ├── auth.rs # Экран авторизации
│ ├── main_screen.rs # Главный экран
│ ├── chat_list.rs # Список чатов (с pin и username)
│ ├── messages.rs # Область сообщений (группировка по дате/отправителю)
│ └── footer.rs # Подвал с командами
│ ├── main_screen.rs # Главный экран с папками
│ ├── chat_list.rs # Список чатов (pin, mute, online, mentions)
│ ├── messages.rs # Область сообщений (wrap, группировка, динамический инпут)
│ └── footer.rs # Подвал с командами и статусом сети
├── input/
│ ├── mod.rs # Роутинг ввода
│ ├── auth.rs # Обработка ввода на экране авторизации
│ └── main_input.rs # Обработка ввода на главном экране
├── utils.rs # Утилиты (disable_tdlib_logs, format_timestamp, format_date, get_day)
└── tdlib/
├── mod.rs # Модуль экспорта
└── client.rs # TdClient: авторизация, чаты, сообщения, кеш usernames
├── mod.rs # Модуль экспорта (TdClient, UserOnlineStatus, NetworkState)
└── client.rs # TdClient: авторизация, чаты, сообщения, кеш, NetworkState
```
### Ключевые решения
@@ -70,11 +100,17 @@ src/
3. **Синхронизация чатов**: Чаты загружаются асинхронно через updates. Main loop периодически синхронизирует `app.chats` с `td_client.chats`.
4. **Кеширование usernames**: При получении `Update::User` сохраняем username в HashMap. При получении приватного чата связываем chat_id с user_id.
4. **Кеширование имён**: При получении `Update::User` сохраняем имя (first_name + last_name) и username в HashMap. Имена подгружаются асинхронно через очередь `pending_user_ids`. Кэш ограничен 500 записями.
5. **Группировка сообщений**: Сообщения группируются по дате (разделители) и по отправителю (заголовки). Время отображается рядом с каждым сообщением.
5. **Группировка сообщений**: Сообщения группируются по дате (разделители по центру) и по отправителю (заголовки). Исходящие выравниваются вправо, входящие влево.
6. **Новые сообщения**: `current_chat_id` отслеживает открытый чат. При получении `NewMessage` для этого чата сообщение добавляется сразу.
6. **Отметка прочтения**: При открытии чата вызывается `view_messages` для всех сообщений. Новые входящие сообщения автоматически отмечаются как прочитанные. `Update::ChatReadOutbox` обновляет статус галочек.
7. **Graceful shutdown**: При Ctrl+C устанавливается флаг остановки, закрывается TDLib клиент, ожидается завершение polling задачи с таймаутом 2 сек.
8. **Оптимизация рендеринга**: Флаг `needs_redraw` позволяет пропускать перерисовку когда ничего не изменилось. Триггеры: TDLib updates, пользовательский ввод, изменение размера терминала.
9. **Перенос текста**: Длинные сообщения автоматически разбиваются на строки с учётом ширины терминала. Для исходящих — time_mark на последней строке, для входящих — время на первой строке с отступом для остальных.
### Зависимости (Cargo.toml)
@@ -86,6 +122,7 @@ tokio = { version = "1", features = ["full"] }
serde = { version = "1.0", features = ["derive"] }
serde_json = "1.0"
dotenvy = "0.15"
chrono = "0.4"
```
### Переменные окружения (.env)
@@ -95,13 +132,14 @@ API_ID=your_api_id
API_HASH=your_api_hash
```
## Что НЕ сделано / TODO
## Что НЕ сделано / TODO (Фаза 7)
- [ ] Папки телеграма (сейчас только "All")
- [ ] Отображение онлайн-статуса пользователя
- [ ] Удалить дублирование current_messages между App и TdClient
- [ ] Использовать единый источник данных для сообщений
- [ ] Реализовать LRU-кэш для user_names/user_statuses вместо простого лимита
- [ ] Lazy loading для имён пользователей (загружать только видимых)
- [ ] Профилирование памяти и устранение утечек
- [ ] Markdown форматирование в сообщениях
- [ ] Отметка сообщений как прочитанные
- [ ] Медиа-сообщения (фото, видео, голосовые)
## Известные проблемы

3
Cargo.lock generated
View File

@@ -153,8 +153,10 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "fac4744fb15ae8337dc853fee7fb3f4e48c0fbaa23d0afe49c447b4fab126118"
dependencies = [
"iana-time-zone",
"js-sys",
"num-traits",
"serde",
"wasm-bindgen",
"windows-link",
]
@@ -1873,6 +1875,7 @@ checksum = "87cbdfae498e57fb48d380fff8eb5c9c98d4497c998f6de0d30d5d6b12f5358b"
name = "tele-tui"
version = "0.1.0"
dependencies = [
"chrono",
"crossterm",
"dotenvy",
"ratatui",

View File

@@ -11,6 +11,7 @@ tokio = { version = "1", features = ["full"] }
serde = { version = "1.0", features = ["derive"] }
serde_json = "1.0"
dotenvy = "0.15"
chrono = "0.4"
[build-dependencies]
tdlib-rs = { version = "1.1", features = ["download-tdlib"] }

View File

@@ -17,33 +17,68 @@
- [x] Загрузка истории сообщений
- [x] Отключение логов TDLib
## Фаза 3: Улучшение UX [IN PROGRESS]
## Фаза 3: Улучшение UX [DONE]
- [x] Отправка сообщений
- [x] Фильтрация чатов (только Main, без архива)
- [x] Поиск по чатам (Ctrl+S)
- [ ] Скролл истории сообщений
- [ ] Загрузка имён пользователей (вместо User_ID)
- [ ] Отметка сообщений как прочитанные
- [ ] Реальное время: новые сообщения
- [x] Скролл истории сообщений
- [x] Загрузка имён пользователей (вместо User_ID)
- [x] Отметка сообщений как прочитанные
- [x] Реальное время: новые сообщения
## Фаза 4: Папки и фильтрация
## Фаза 4: Папки и фильтрация [DONE]
- [ ] Загрузка папок из Telegram
- [ ] Переключение между папками (Cmd+1, Cmd+2, ...)
- [ ] Фильтрация чатов по папке
- [x] Загрузка папок из Telegram
- [x] Переключение между папками (1-9)
- [x] Фильтрация чатов по папке
## Фаза 5: Расширенный функционал
## Фаза 5: Расширенный функционал [DONE]
- [ ] Отображение онлайн-статуса
- [ ] Статус доставки/прочтения (✓, ✓✓)
- [ ] Поддержка медиа-заглушек (фото, видео, голосовые)
- [ ] Mentions (@)
- [ ] Muted чаты (серый цвет)
- [x] Отображение онлайн-статуса (зелёная точка ●)
- [x] Статус доставки/прочтения (✓, ✓✓)
- [x] Поддержка медиа-заглушек (фото, видео, голосовые, стикеры и др.)
- [x] Mentions (@) — индикатор непрочитанных упоминаний
- [x] Muted чаты (иконка 🔇)
## Фаза 6: Полировка
## Фаза 6: Полировка [DONE]
- [ ] Оптимизация 60 FPS
- [ ] Минимальное разрешение 600 символов
- [ ] Обработка ошибок сети
- [ ] Graceful shutdown
- [x] Оптимизация использования памяти (базовая)
- Очистка сообщений при закрытии чата
- Лимит кэша пользователей (500)
- Периодическая очистка неактивных записей
- [x] Оптимизация 60 FPS
- Poll таймаут 16ms
- Флаг `needs_redraw` — рендеринг только при изменениях
- Обработка Event::Resize для перерисовки при изменении размера
- [x] Минимальное разрешение (80x20)
- Предупреждение если терминал слишком мал
- [x] Обработка ошибок сети
- NetworkState enum (WaitingForNetwork, Connecting, etc.)
- Индикатор в футере с цветовой индикацией
- [x] Graceful shutdown
- AtomicBool флаг для остановки polling
- Корректное закрытие TDLib клиента
- Таймаут ожидания завершения задач
- [x] Динамический инпут
- Автоматическое расширение до 10 строк
- Wrap для длинного текста
- [x] Перенос длинных сообщений
- Автоматический wrap на несколько строк
- Правильное выравнивание для исходящих/входящих
## Фаза 7: Глубокий рефакторинг памяти [TODO]
- [ ] Удалить дублирование current_messages между App и TdClient
- [ ] Использовать единый источник данных для сообщений
- [ ] Реализовать LRU-кэш для user_names/user_statuses вместо простого лимита
- [ ] Lazy loading для имён пользователей (загружать только видимых)
- [ ] Профилирование памяти и устранение утечек
## Фаза 8: Дополнительные фичи [TODO]
- [ ] Markdown форматирование в сообщениях
- [ ] Редактирование сообщений
- [ ] Удаление сообщений
- [ ] Reply на сообщения
- [ ] Forward сообщений

View File

@@ -22,12 +22,14 @@ pub struct App {
pub current_messages: Vec<MessageInfo>,
pub message_input: String,
pub message_scroll_offset: usize,
pub folders: Vec<String>,
pub selected_folder: usize,
/// None = All (основной список), Some(id) = папка с id
pub selected_folder_id: Option<i32>,
pub is_loading: bool,
// Search state
pub is_searching: bool,
pub search_query: String,
/// Флаг для оптимизации рендеринга - перерисовывать только при изменениях
pub needs_redraw: bool,
}
impl App {
@@ -49,21 +51,27 @@ impl App {
current_messages: Vec::new(),
message_input: String::new(),
message_scroll_offset: 0,
folders: vec!["All".to_string()],
selected_folder: 0,
selected_folder_id: None, // None = All
is_loading: true,
is_searching: false,
search_query: String::new(),
needs_redraw: true,
}
}
/// Помечает UI как требующий перерисовки
pub fn mark_dirty(&mut self) {
self.needs_redraw = true;
}
pub fn next_chat(&mut self) {
if self.chats.is_empty() {
let filtered = self.get_filtered_chats();
if filtered.is_empty() {
return;
}
let i = match self.chat_list_state.selected() {
Some(i) => {
if i >= self.chats.len() - 1 {
if i >= filtered.len() - 1 {
0
} else {
i + 1
@@ -75,13 +83,14 @@ impl App {
}
pub fn previous_chat(&mut self) {
if self.chats.is_empty() {
let filtered = self.get_filtered_chats();
if filtered.is_empty() {
return;
}
let i = match self.chat_list_state.selected() {
Some(i) => {
if i == 0 {
self.chats.len() - 1
filtered.len() - 1
} else {
i - 1
}
@@ -92,8 +101,9 @@ impl App {
}
pub fn select_current_chat(&mut self) {
let filtered = self.get_filtered_chats();
if let Some(i) = self.chat_list_state.selected() {
if let Some(chat) = self.chats.get(i) {
if let Some(chat) = filtered.get(i) {
self.selected_chat_id = Some(chat.id);
}
}
@@ -104,7 +114,9 @@ impl App {
self.current_messages.clear();
self.message_input.clear();
self.message_scroll_offset = 0;
// Очищаем данные в TdClient
self.td_client.current_chat_id = None;
self.td_client.current_chat_messages.clear();
}
pub fn select_first_chat(&mut self) {
@@ -134,12 +146,20 @@ impl App {
}
pub fn get_filtered_chats(&self) -> Vec<&ChatInfo> {
let folder_filtered: Vec<&ChatInfo> = match self.selected_folder_id {
None => self.chats.iter().collect(), // All - показываем все
Some(folder_id) => self.chats
.iter()
.filter(|c| c.folder_ids.contains(&folder_id))
.collect(),
};
if self.search_query.is_empty() {
self.chats.iter().collect()
folder_filtered
} else {
let query = self.search_query.to_lowercase();
self.chats
.iter()
folder_filtered
.into_iter()
.filter(|c| {
// Поиск по названию чата
c.title.to_lowercase().contains(&query) ||

View File

@@ -4,7 +4,6 @@ use tokio::time::timeout;
use crate::app::App;
pub async fn handle(app: &mut App, key: KeyEvent) {
let has_super = key.modifiers.contains(KeyModifiers::SUPER);
let has_ctrl = key.modifiers.contains(KeyModifiers::CONTROL);
// Глобальные команды (работают всегда)
@@ -74,56 +73,6 @@ pub async fn handle(app: &mut App, key: KeyEvent) {
return;
}
// Cmd+j/k - навигация (работает и в списке чатов, и для скролла сообщений)
if has_super {
match key.code {
// Cmd+Down - вниз (следующий чат ИЛИ скролл вниз)
KeyCode::Down => {
if app.selected_chat_id.is_some() {
// В открытом чате - скролл вниз (к новым сообщениям)
if app.message_scroll_offset > 0 {
app.message_scroll_offset = app.message_scroll_offset.saturating_sub(3);
}
} else {
// В списке чатов - следующий чат
app.next_chat();
}
}
// Cmd+Up - вверх (предыдущий чат ИЛИ скролл вверх)
KeyCode::Up => {
if app.selected_chat_id.is_some() {
// В открытом чате - скролл вверх (к старым сообщениям)
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;
}
}
}
}
}
} else {
// В списке чатов - предыдущий чат
app.previous_chat();
}
}
_ => {}
}
return;
}
// Enter - открыть чат или отправить сообщение
@@ -188,7 +137,7 @@ pub async fn handle(app: &mut App, key: KeyEvent) {
return;
}
// Ввод текста в режиме открытого чата
// Режим открытого чата
if app.selected_chat_id.is_some() {
match key.code {
KeyCode::Backspace => {
@@ -197,6 +146,38 @@ pub async fn handle(app: &mut App, key: KeyEvent) {
KeyCode::Char(c) => {
app.message_input.push(c);
}
// Стрелки - скролл сообщений
KeyCode::Down => {
// Скролл вниз (к новым сообщениям)
if app.message_scroll_offset > 0 {
app.message_scroll_offset = app.message_scroll_offset.saturating_sub(3);
}
}
KeyCode::Up => {
// Скролл вверх (к старым сообщениям)
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;
}
}
}
}
}
}
_ => {}
}
} else {
@@ -208,13 +189,25 @@ pub async fn handle(app: &mut App, key: KeyEvent) {
KeyCode::Up => {
app.previous_chat();
}
// Цифры - переключение папок
// Цифры 1-9 - переключение папок
KeyCode::Char(c) if c >= '1' && c <= '9' => {
let folder_idx = (c as usize) - ('1' as usize);
if folder_idx < app.folders.len() {
app.selected_folder = folder_idx;
let folder_num = (c as usize) - ('1' as usize); // 0-based
if folder_num == 0 {
// 1 = All
app.selected_folder_id = None;
} else {
// 2, 3, 4... = папки из TDLib
if let Some(folder) = app.td_client.folders.get(folder_num - 1) {
let folder_id = folder.id;
app.selected_folder_id = Some(folder_id);
// Загружаем чаты папки
app.status_message = Some("Загрузка чатов папки...".to_string());
let _ = timeout(Duration::from_secs(5), app.td_client.load_folder_chats(folder_id, 50)).await;
app.status_message = None;
}
}
app.chat_list_state.select(Some(0));
}
_ => {}
}
}

View File

@@ -11,6 +11,8 @@ use crossterm::{
};
use ratatui::{backend::CrosstermBackend, Terminal};
use std::io;
use std::sync::atomic::{AtomicBool, Ordering};
use std::sync::Arc;
use std::time::Duration;
use tdlib_rs::enums::Update;
@@ -58,16 +60,22 @@ async fn run_app<B: ratatui::backend::Backend>(
terminal: &mut Terminal<B>,
app: &mut App,
) -> io::Result<()> {
// Флаг для остановки polling задачи
let should_stop = Arc::new(AtomicBool::new(false));
let should_stop_clone = should_stop.clone();
// Канал для передачи updates из polling задачи в main loop
let (update_tx, mut update_rx) = tokio::sync::mpsc::unbounded_channel::<Update>();
// Запускаем polling TDLib receive() в отдельной задаче
tokio::spawn(async move {
loop {
// receive() блокирующий, поэтому запускаем в blocking thread
let polling_handle = tokio::spawn(async move {
while !should_stop_clone.load(Ordering::Relaxed) {
// receive() с таймаутом 0.1 сек чтобы периодически проверять флаг
let result = tokio::task::spawn_blocking(|| tdlib_rs::receive()).await;
if let Ok(Some((update, _client_id))) = result {
let _ = update_tx.send(update);
if update_tx.send(update).is_err() {
break; // Канал закрыт, выходим
}
}
}
});
@@ -100,8 +108,15 @@ async fn run_app<B: ratatui::backend::Backend>(
loop {
// Обрабатываем updates от TDLib из канала (неблокирующе)
let mut had_updates = false;
while let Ok(update) = update_rx.try_recv() {
app.td_client.handle_update(update);
had_updates = true;
}
// Помечаем UI как требующий перерисовки если были обновления
if had_updates {
app.needs_redraw = true;
}
// Обрабатываем очередь сообщений для отметки как прочитанных
@@ -116,6 +131,7 @@ async fn run_app<B: ratatui::backend::Backend>(
// Синхронизируем сообщения из 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) {
@@ -127,18 +143,43 @@ async fn run_app<B: ratatui::backend::Backend>(
app.current_messages.push(td_msg.clone());
}
}
// Если добавились новые сообщения - нужна перерисовка
if app.current_messages.len() != prev_messages_len {
app.needs_redraw = true;
}
}
// Обновляем состояние экрана на основе auth_state
update_screen_state(app).await;
let screen_changed = update_screen_state(app).await;
if screen_changed {
app.needs_redraw = true;
}
// Рендерим только если есть изменения
if app.needs_redraw {
terminal.draw(|f| ui::render(f, app))?;
app.needs_redraw = false;
}
// Используем poll для неблокирующего чтения событий
if event::poll(Duration::from_millis(100))? {
if let Event::Key(key) = event::read()? {
// Используем poll с коротким таймаутом для быстрой реакции на ввод
// 16ms ≈ 60 FPS потенциально, но рендерим только при изменениях
if event::poll(Duration::from_millis(16))? {
match event::read()? {
Event::Key(key) => {
// Global quit command
if key.code == KeyCode::Char('c') && key.modifiers.contains(KeyModifiers::CONTROL) {
// Graceful shutdown
should_stop.store(true, Ordering::Relaxed);
// Закрываем TDLib клиент
let _ = tdlib_rs::functions::close(app.td_client.client_id()).await;
// Ждём завершения polling задачи (с таймаутом)
let _ = tokio::time::timeout(
Duration::from_secs(2),
polling_handle
).await;
return Ok(());
}
@@ -149,15 +190,28 @@ async fn run_app<B: ratatui::backend::Backend>(
AppScreen::Auth => handle_auth_input(app, key.code).await,
AppScreen::Main => handle_main_input(app, key).await,
}
// Любой ввод требует перерисовки
app.needs_redraw = true;
}
Event::Resize(_, _) => {
// При изменении размера терминала нужна перерисовка
app.needs_redraw = true;
}
_ => {}
}
}
}
}
async fn update_screen_state(app: &mut App) {
/// Возвращает true если состояние изменилось и требуется перерисовка
async fn update_screen_state(app: &mut App) -> bool {
use tokio::time::timeout;
let prev_screen = app.screen.clone();
let prev_status = app.status_message.clone();
let prev_error = app.error_message.clone();
let prev_chats_len = app.chats.len();
match &app.td_client.auth_state {
AuthState::WaitTdlibParameters => {
@@ -198,4 +252,10 @@ async fn update_screen_state(app: &mut App) {
app.error_message = Some(e.clone());
}
}
// Проверяем, изменилось ли что-то
app.screen != prev_screen
|| app.status_message != prev_status
|| app.error_message != prev_error
|| app.chats.len() != prev_chats_len
}

View File

@@ -1,6 +1,9 @@
use std::env;
use std::collections::HashMap;
use tdlib_rs::enums::{AuthorizationState, ChatList, ChatType, MessageContent, Update, User};
use tdlib_rs::enums::{AuthorizationState, ChatList, ChatType, ConnectionState, MessageContent, Update, User, UserStatus};
/// Максимальный размер кэшей пользователей
const MAX_USER_CACHE_SIZE: usize = 500;
use tdlib_rs::functions;
use tdlib_rs::types::{Chat as TdChat, Message as TdMessage};
@@ -25,10 +28,16 @@ pub struct ChatInfo {
pub last_message: String,
pub last_message_date: i32,
pub unread_count: i32,
/// Количество непрочитанных упоминаний (@)
pub unread_mention_count: i32,
pub is_pinned: bool,
pub order: i64,
/// ID последнего прочитанного исходящего сообщения (для галочек)
pub last_read_outbox_message_id: i64,
/// ID папок, в которых находится чат
pub folder_ids: Vec<i32>,
/// Чат замьючен (уведомления отключены)
pub is_muted: bool,
}
#[derive(Debug, Clone)]
@@ -41,6 +50,44 @@ pub struct MessageInfo {
pub is_read: bool,
}
#[derive(Debug, Clone)]
pub struct FolderInfo {
pub id: i32,
pub name: String,
}
/// Состояние сетевого соединения
#[derive(Debug, Clone, PartialEq)]
pub enum NetworkState {
/// Ожидание подключения к сети
WaitingForNetwork,
/// Подключение к прокси
ConnectingToProxy,
/// Подключение к серверам Telegram
Connecting,
/// Обновление данных
Updating,
/// Подключено
Ready,
}
/// Онлайн-статус пользователя
#[derive(Debug, Clone, PartialEq)]
pub enum UserOnlineStatus {
/// Онлайн
Online,
/// Был недавно (менее часа назад)
Recently,
/// Был на этой неделе
LastWeek,
/// Был в этом месяце
LastMonth,
/// Давно не был
LongTimeAgo,
/// Оффлайн с указанием времени (unix timestamp)
Offline(i32),
}
pub struct TdClient {
pub auth_state: AuthState,
pub api_id: i32,
@@ -60,6 +107,14 @@ pub struct TdClient {
pub pending_view_messages: Vec<(i64, Vec<i64>)>,
/// Очередь user_id для загрузки имён
pub pending_user_ids: Vec<i64>,
/// Папки чатов
pub folders: Vec<FolderInfo>,
/// Позиция основного списка среди папок
pub main_chat_list_position: i32,
/// Онлайн-статусы пользователей: user_id -> status
user_statuses: HashMap<i64, UserOnlineStatus>,
/// Состояние сетевого соединения
pub network_state: NetworkState,
}
#[allow(dead_code)]
@@ -86,6 +141,10 @@ impl TdClient {
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(),
network_state: NetworkState::Connecting,
}
}
@@ -97,6 +156,25 @@ 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<i64> =
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));
}
}
/// Получение онлайн-статуса пользователя по chat_id (для приватных чатов)
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))
}
/// Инициализация TDLib с параметрами
pub async fn init(&mut self) -> Result<(), String> {
let result = functions::set_tdlib_parameters(
@@ -164,6 +242,17 @@ impl TdClient {
chat.unread_count = update.unread_count;
}
}
Update::ChatUnreadMentionCount(update) => {
if let Some(chat) = self.chats.iter_mut().find(|c| c.id == update.chat_id) {
chat.unread_mention_count = update.unread_mention_count;
}
}
Update::ChatNotificationSettings(update) => {
if let Some(chat) = self.chats.iter_mut().find(|c| c.id == update.chat_id) {
// mute_for > 0 означает что чат замьючен
chat.is_muted = update.notification_settings.mute_for > 0;
}
}
Update::ChatReadOutbox(update) => {
// Обновляем last_read_outbox_message_id когда собеседник прочитал сообщения
if let Some(chat) = self.chats.iter_mut().find(|c| c.id == update.chat_id) {
@@ -193,9 +282,22 @@ impl TdClient {
// Пересортируем по order
self.chats.sort_by(|a, b| b.order.cmp(&a.order));
}
ChatList::Archive | ChatList::Folder(_) => {
// Если чат добавляется в архив или папку, ничего не делаем
// (он уже должен быть удалён из Main)
ChatList::Folder(folder) => {
// Обновляем folder_ids для чата
if let Some(chat) = self.chats.iter_mut().find(|c| c.id == update.chat_id) {
if update.position.order == 0 {
// Чат удалён из папки
chat.folder_ids.retain(|&id| id != folder.chat_folder_id);
} else {
// Чат добавлен в папку
if !chat.folder_ids.contains(&folder.chat_folder_id) {
chat.folder_ids.push(folder.chat_folder_id);
}
}
}
}
ChatList::Archive => {
// Архив пока не обрабатываем
}
}
}
@@ -220,6 +322,16 @@ impl TdClient {
// Сохраняем имя и username пользователя
let user = update.user;
// Пропускаем удалённые аккаунты (пустое имя)
if user.first_name.is_empty() && user.last_name.is_empty() {
// Удаляем чаты с этим пользователем из списка
let user_id = user.id;
self.chats.retain(|c| {
self.chat_user_ids.get(&c.id) != Some(&user_id)
});
return;
}
// Сохраняем display name (first_name + last_name)
let display_name = if user.last_name.is_empty() {
user.first_name.clone()
@@ -242,6 +354,43 @@ impl TdClient {
}
}
}
// Периодически очищаем кэши
self.trim_caches();
}
Update::ChatFolders(update) => {
// Обновляем список папок
self.folders = update
.chat_folders
.into_iter()
.map(|f| FolderInfo {
id: f.id,
name: f.title,
})
.collect();
self.main_chat_list_position = update.main_chat_list_position;
}
Update::UserStatus(update) => {
// Обновляем онлайн-статус пользователя
let status = match update.status {
UserStatus::Online(_) => UserOnlineStatus::Online,
UserStatus::Offline(offline) => UserOnlineStatus::Offline(offline.was_online),
UserStatus::Recently(_) => UserOnlineStatus::Recently,
UserStatus::LastWeek(_) => UserOnlineStatus::LastWeek,
UserStatus::LastMonth(_) => UserOnlineStatus::LastMonth,
UserStatus::Empty => UserOnlineStatus::LongTimeAgo,
};
self.user_statuses.insert(update.user_id, status);
}
Update::ConnectionState(update) => {
// Обновляем состояние сетевого соединения
self.network_state = match update.state {
ConnectionState::WaitingForNetwork => NetworkState::WaitingForNetwork,
ConnectionState::ConnectingToProxy => NetworkState::ConnectingToProxy,
ConnectionState::Connecting => NetworkState::Connecting,
ConnectionState::Updating => NetworkState::Updating,
ConnectionState::Ready => NetworkState::Ready,
};
}
_ => {}
}
@@ -260,6 +409,13 @@ impl TdClient {
}
fn add_or_update_chat(&mut self, td_chat: &TdChat) {
// Пропускаем удалённые аккаунты
if td_chat.title == "Deleted Account" || td_chat.title.is_empty() {
// Удаляем из списка если уже был добавлен
self.chats.retain(|c| c.id != td_chat.id);
return;
}
// Ищем позицию в Main списке (если есть)
let main_position = td_chat.positions.iter().find(|pos| {
matches!(pos.list, ChatList::Main)
@@ -286,6 +442,22 @@ impl TdClient {
_ => None,
};
// Извлекаем ID папок из позиций
let folder_ids: Vec<i32> = td_chat
.positions
.iter()
.filter_map(|pos| {
if let ChatList::Folder(folder) = &pos.list {
Some(folder.chat_folder_id)
} else {
None
}
})
.collect();
// Проверяем mute статус
let is_muted = td_chat.notification_settings.mute_for > 0;
let chat_info = ChatInfo {
id: td_chat.id,
title: td_chat.title.clone(),
@@ -293,9 +465,12 @@ impl TdClient {
last_message,
last_message_date,
unread_count: td_chat.unread_count,
unread_mention_count: td_chat.unread_mention_count,
is_pinned,
order,
last_read_outbox_message_id: td_chat.last_read_outbox_message_id,
folder_ids,
is_muted,
};
if let Some(existing) = self.chats.iter_mut().find(|c| c.id == td_chat.id) {
@@ -303,7 +478,10 @@ impl TdClient {
existing.last_message = chat_info.last_message;
existing.last_message_date = chat_info.last_message_date;
existing.unread_count = chat_info.unread_count;
existing.unread_mention_count = chat_info.unread_mention_count;
existing.last_read_outbox_message_id = chat_info.last_read_outbox_message_id;
existing.folder_ids = chat_info.folder_ids;
existing.is_muted = chat_info.is_muted;
// Обновляем username если он появился
if chat_info.username.is_some() {
existing.username = chat_info.username;
@@ -417,6 +595,25 @@ impl TdClient {
}
}
/// Загрузка чатов для конкретной папки
pub async fn load_folder_chats(&mut self, folder_id: i32, limit: i32) -> Result<(), String> {
let chat_list = ChatList::Folder(tdlib_rs::types::ChatListFolder {
chat_folder_id: folder_id,
});
let result = functions::load_chats(
Some(chat_list),
limit,
self.client_id,
)
.await;
match result {
Ok(_) => Ok(()),
Err(e) => Err(format!("Ошибка загрузки чатов папки: {:?}", e)),
}
}
/// Загрузка истории сообщений чата
pub async fn get_chat_history(
&mut self,

View File

@@ -1,3 +1,5 @@
pub mod client;
pub use client::TdClient;
pub use client::UserOnlineStatus;
pub use client::NetworkState;

View File

@@ -5,6 +5,7 @@ use ratatui::{
Frame,
};
use crate::app::App;
use crate::tdlib::UserOnlineStatus;
pub fn render(f: &mut Frame, area: Rect, app: &mut App) {
let chat_chunks = Layout::default()
@@ -43,20 +44,40 @@ pub fn render(f: &mut Frame, area: Rect, app: &mut App) {
.map(|chat| {
let is_selected = app.selected_chat_id == Some(chat.id);
let pin_icon = if chat.is_pinned { "📌 " } else { "" };
let mute_icon = if chat.is_muted { "🔇 " } else { "" };
// Онлайн-статус (зелёная точка для онлайн)
let status_icon = match app.td_client.get_user_status_by_chat_id(chat.id) {
Some(UserOnlineStatus::Online) => "",
_ => " ",
};
let prefix = if is_selected { "" } else { " " };
let username_text = chat.username.as_ref()
.map(|u| format!(" {}", u))
.unwrap_or_default();
// Индикатор упоминаний @
let mention_badge = if chat.unread_mention_count > 0 {
" @".to_string()
} else {
String::new()
};
let unread_badge = if chat.unread_count > 0 {
format!(" ({})", chat.unread_count)
} else {
String::new()
};
let content = format!("{}{}{}{}{}", prefix, pin_icon, chat.title, username_text, unread_badge);
let style = Style::default().fg(Color::White);
let content = format!("{}{}{}{}{}{}{}{}", prefix, status_icon, pin_icon, mute_icon, chat.title, username_text, mention_badge, unread_badge);
// Цвет: онлайн — зелёные, остальные — белые
let style = match app.td_client.get_user_status_by_chat_id(chat.id) {
Some(UserOnlineStatus::Online) => Style::default().fg(Color::Green),
_ => Style::default().fg(Color::White),
};
ListItem::new(content).style(style)
})
@@ -72,9 +93,75 @@ pub fn render(f: &mut Frame, area: Rect, app: &mut App) {
f.render_stateful_widget(chats_list, chat_chunks[1], &mut app.chat_list_state);
// User status
let status = Paragraph::new("[User: Online]")
// User status - показываем статус выбранного чата
let (status_text, status_color) = if let Some(chat_id) = app.selected_chat_id {
match app.td_client.get_user_status_by_chat_id(chat_id) {
Some(UserOnlineStatus::Online) => ("● онлайн".to_string(), Color::Green),
Some(UserOnlineStatus::Recently) => ("был(а) недавно".to_string(), Color::Yellow),
Some(UserOnlineStatus::Offline(was_online)) => {
let formatted = format_was_online(*was_online);
(formatted, Color::Gray)
}
Some(UserOnlineStatus::LastWeek) => ("был(а) на этой неделе".to_string(), Color::DarkGray),
Some(UserOnlineStatus::LastMonth) => ("был(а) в этом месяце".to_string(), Color::DarkGray),
Some(UserOnlineStatus::LongTimeAgo) => ("был(а) давно".to_string(), Color::DarkGray),
None => ("".to_string(), Color::DarkGray), // Для групп/каналов
}
} else {
// Показываем статус выделенного в списке чата
let filtered = app.get_filtered_chats();
if let Some(i) = app.chat_list_state.selected() {
if let Some(chat) = filtered.get(i) {
match app.td_client.get_user_status_by_chat_id(chat.id) {
Some(UserOnlineStatus::Online) => ("● онлайн".to_string(), Color::Green),
Some(UserOnlineStatus::Recently) => ("был(а) недавно".to_string(), Color::Yellow),
Some(UserOnlineStatus::Offline(was_online)) => {
let formatted = format_was_online(*was_online);
(formatted, Color::Gray)
}
Some(UserOnlineStatus::LastWeek) => ("был(а) на этой неделе".to_string(), Color::DarkGray),
Some(UserOnlineStatus::LastMonth) => ("был(а) в этом месяце".to_string(), Color::DarkGray),
Some(UserOnlineStatus::LongTimeAgo) => ("был(а) давно".to_string(), Color::DarkGray),
None => ("".to_string(), Color::DarkGray),
}
} else {
("".to_string(), Color::DarkGray)
}
} else {
("".to_string(), Color::DarkGray)
}
};
let status = Paragraph::new(status_text)
.block(Block::default().borders(Borders::ALL))
.style(Style::default().fg(Color::Green));
.style(Style::default().fg(status_color));
f.render_widget(status, chat_chunks[2]);
}
/// Форматирование времени "был(а) в ..."
fn format_was_online(timestamp: i32) -> String {
use std::time::{SystemTime, UNIX_EPOCH};
let now = SystemTime::now()
.duration_since(UNIX_EPOCH)
.unwrap()
.as_secs() as i32;
let diff = now - timestamp;
if diff < 60 {
"был(а) только что".to_string()
} else if diff < 3600 {
let mins = diff / 60;
format!("был(а) {} мин. назад", mins)
} else if diff < 86400 {
let hours = diff / 3600;
format!("был(а) {} ч. назад", hours)
} else {
// Показываем дату
let datetime = chrono::DateTime::from_timestamp(timestamp as i64, 0)
.map(|dt| dt.format("%d.%m %H:%M").to_string())
.unwrap_or_else(|| "давно".to_string());
format!("был(а) {}", datetime)
}
}

View File

@@ -5,21 +5,35 @@ use ratatui::{
Frame,
};
use crate::app::App;
use crate::tdlib::NetworkState;
pub fn render(f: &mut Frame, area: Rect, app: &App) {
let status = if let Some(msg) = &app.status_message {
format!(" {} ", msg)
} else if let Some(err) = &app.error_message {
format!(" Error: {} ", err)
} else if app.is_searching {
" j/k: Navigate | Enter: Select | Esc: Cancel ".to_string()
} else if app.selected_chat_id.is_some() {
" Cmd+j/k: Scroll | Enter: Send | Esc: Close | Ctrl+R: Refresh | Ctrl+C: Quit ".to_string()
} else {
" j/k: Navigate | Enter: Open | Ctrl+S: Search | Ctrl+R: Refresh | Ctrl+C: Quit ".to_string()
// Индикатор состояния сети
let network_indicator = match app.td_client.network_state {
NetworkState::Ready => "",
NetworkState::WaitingForNetwork => "⚠ Нет сети | ",
NetworkState::ConnectingToProxy => "⏳ Прокси... | ",
NetworkState::Connecting => "⏳ Подключение... | ",
NetworkState::Updating => "⏳ Обновление... | ",
};
let style = if app.error_message.is_some() {
let status = if let Some(msg) = &app.status_message {
format!(" {}{} ", network_indicator, msg)
} else if let Some(err) = &app.error_message {
format!(" {}Error: {} ", network_indicator, err)
} else if app.is_searching {
format!(" {}↑/↓: Navigate | Enter: Select | Esc: Cancel ", network_indicator)
} else if app.selected_chat_id.is_some() {
format!(" {}↑/↓: Scroll | Enter: Send | Esc: Close | Ctrl+R: Refresh | Ctrl+C: Quit ", network_indicator)
} else {
format!(" {}↑/↓: Navigate | Enter: Open | Ctrl+S: Search | Ctrl+R: Refresh | Ctrl+C: Quit ", network_indicator)
};
let style = if matches!(app.td_client.network_state, NetworkState::WaitingForNetwork) {
Style::default().fg(Color::Red)
} else if !matches!(app.td_client.network_state, NetworkState::Ready) {
Style::default().fg(Color::Cyan)
} else if app.error_message.is_some() {
Style::default().fg(Color::Red)
} else if app.status_message.is_some() {
Style::default().fg(Color::Yellow)

View File

@@ -36,19 +36,29 @@ pub fn render(f: &mut Frame, app: &mut App) {
fn render_folders(f: &mut Frame, area: Rect, app: &App) {
let mut spans = vec![];
for (i, folder) in app.folders.iter().enumerate() {
let style = if i == app.selected_folder {
// "All" всегда первая (клавиша 1)
let all_style = if app.selected_folder_id.is_none() {
Style::default()
.fg(Color::Cyan)
.fg(Color::Yellow)
.add_modifier(Modifier::BOLD)
} else {
Style::default().fg(Color::White)
};
spans.push(Span::styled(" 1:All ", all_style));
// Папки из TDLib (клавиши 2, 3, 4...)
for (i, folder) in app.td_client.folders.iter().enumerate() {
spans.push(Span::raw(""));
let style = if app.selected_folder_id == Some(folder.id) {
Style::default()
.fg(Color::Yellow)
.add_modifier(Modifier::BOLD)
} else {
Style::default().fg(Color::White)
};
spans.push(Span::styled(format!(" {}:{} ", i + 1, folder), style));
if i < app.folders.len() - 1 {
spans.push(Span::raw(""));
}
spans.push(Span::styled(format!(" {}:{} ", i + 2, folder.name), style));
}
let folders_line = Line::from(spans);

View File

@@ -8,14 +8,66 @@ use ratatui::{
use crate::app::App;
use crate::utils::{format_timestamp, format_date, get_day};
/// Разбивает текст на строки с учётом максимальной ширины
fn wrap_text(text: &str, max_width: usize) -> Vec<String> {
if max_width == 0 {
return vec![text.to_string()];
}
let mut result = Vec::new();
let mut current_line = String::new();
let mut current_width = 0;
for word in text.split_whitespace() {
let word_width = word.chars().count();
if current_width == 0 {
// Первое слово в строке
current_line = word.to_string();
current_width = word_width;
} 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(current_line);
current_line = word.to_string();
current_width = word_width;
}
}
if !current_line.is_empty() {
result.push(current_line);
}
if result.is_empty() {
result.push(String::new());
}
result
}
pub fn render(f: &mut Frame, area: Rect, app: &App) {
if let Some(chat) = app.get_selected_chat() {
// Вычисляем динамическую высоту инпута на основе длины текста
let input_width = area.width.saturating_sub(4) as usize; // -2 для рамок, -2 для "> "
let input_text_len = app.message_input.chars().count() + 2; // +2 для "> "
let input_lines = if input_width > 0 {
((input_text_len as f32 / input_width as f32).ceil() as u16).max(1)
} else {
1
};
// Минимум 3 строки (1 контент + 2 рамки), максимум 10
let input_height = (input_lines + 2).min(10).max(3);
let message_chunks = Layout::default()
.direction(Direction::Vertical)
.constraints([
Constraint::Length(3), // Chat header
Constraint::Min(0), // Messages
Constraint::Length(3), // Input box
Constraint::Length(input_height), // Input box (динамическая высота)
])
.split(area);
@@ -112,22 +164,62 @@ pub fn render(f: &mut Frame, area: Rect, app: &App) {
// Исходящие: справа, формат "текст (HH:MM ✓✓)"
let read_mark = if msg.is_read { "✓✓" } else { "" };
let time_mark = format!("({} {})", time, read_mark);
let msg_text = format!("{} {}", msg.content, time_mark);
let msg_len = msg_text.chars().count();
let padding = content_width.saturating_sub(msg_len + 1);
let time_mark_len = time_mark.chars().count() + 1; // +1 для пробела
// Максимальная ширина для текста сообщения (оставляем место для time_mark)
let max_msg_width = content_width.saturating_sub(time_mark_len + 2);
let wrapped_lines = wrap_text(&msg.content, max_msg_width);
let total_wrapped = wrapped_lines.len();
for (i, line_text) in wrapped_lines.into_iter().enumerate() {
let is_last_line = i == total_wrapped - 1;
let line_len = line_text.chars().count();
if is_last_line {
// Последняя строка — добавляем time_mark
let full_len = line_len + time_mark_len;
let padding = content_width.saturating_sub(full_len + 1);
lines.push(Line::from(vec![
Span::raw(" ".repeat(padding)),
Span::styled(msg.content.clone(), Style::default().fg(Color::Green)),
Span::styled(line_text, Style::default().fg(Color::Green)),
Span::styled(format!(" {}", time_mark), Style::default().fg(Color::Gray)),
]));
} 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)),
]));
}
}
} else {
// Входящие: слева, формат "(HH:MM) текст"
let time_str = format!("({})", time);
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);
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!(" {}", msg.content)),
Span::raw(format!(" {}", line_text)),
]));
} else {
// Последующие строки — с отступом
let indent = " ".repeat(time_prefix_len);
lines.push(Line::from(vec![
Span::raw(indent),
Span::raw(line_text),
]));
}
}
}
}
@@ -155,7 +247,7 @@ pub fn render(f: &mut Frame, area: Rect, app: &App) {
.scroll((scroll_offset, 0));
f.render_widget(messages_widget, message_chunks[1]);
// Input box
// Input box с wrap для длинного текста
let input_text = if app.message_input.is_empty() {
"> Введите сообщение...".to_string()
} else {
@@ -168,7 +260,8 @@ pub fn render(f: &mut Frame, area: Rect, app: &App) {
};
let input = Paragraph::new(input_text)
.block(Block::default().borders(Borders::ALL))
.style(input_style);
.style(input_style)
.wrap(ratatui::widgets::Wrap { trim: false });
f.render_widget(input, message_chunks[2]);
} else {
let empty = Paragraph::new("Выберите чат")

View File

@@ -6,12 +6,39 @@ mod messages;
mod footer;
use ratatui::Frame;
use ratatui::layout::Alignment;
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;
pub fn render(f: &mut Frame, app: &mut App) {
let area = f.area();
// Проверяем минимальный размер терминала
if area.width < MIN_WIDTH || area.height < MIN_HEIGHT {
render_size_warning(f, area.width, area.height);
return;
}
match app.screen {
AppScreen::Loading => loading::render(f, app),
AppScreen::Auth => auth::render(f, app),
AppScreen::Main => main_screen::render(f, app),
}
}
fn render_size_warning(f: &mut Frame, width: u16, height: u16) {
let message = format!(
"Терминал слишком мал: {}x{}\n\nМинимум: {}x{}",
width, height, MIN_WIDTH, MIN_HEIGHT
);
let warning = Paragraph::new(message)
.style(Style::default().fg(Color::Yellow).add_modifier(Modifier::BOLD))
.alignment(Alignment::Center);
f.render_widget(warning, f.area());
}