This commit is contained in:
Mikhail Kilin
2026-01-22 15:26:15 +03:00
parent 1ef341d907
commit c18f43664e
10 changed files with 436 additions and 87 deletions

View File

@@ -28,6 +28,8 @@ pub struct App {
// Search state
pub is_searching: bool,
pub search_query: String,
/// Флаг для оптимизации рендеринга - перерисовывать только при изменениях
pub needs_redraw: bool,
}
impl App {
@@ -53,9 +55,15 @@ impl App {
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) {
let filtered = self.get_filtered_chats();
if filtered.is_empty() {
@@ -106,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) {

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,37 +143,75 @@ 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;
}
terminal.draw(|f| ui::render(f, app))?;
// Рендерим только если есть изменения
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()? {
// Global quit command
if key.code == KeyCode::Char('c') && key.modifiers.contains(KeyModifiers::CONTROL) {
return Ok(());
}
// Используем 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);
match app.screen {
AppScreen::Loading => {
// В состоянии загрузки игнорируем ввод
// Закрываем 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(());
}
AppScreen::Auth => handle_auth_input(app, key.code).await,
AppScreen::Main => handle_main_input(app, key).await,
match app.screen {
AppScreen::Loading => {
// В состоянии загрузки игнорируем ввод
}
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, UserStatus};
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,12 +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)]
@@ -49,6 +56,21 @@ pub struct FolderInfo {
pub name: String,
}
/// Состояние сетевого соединения
#[derive(Debug, Clone, PartialEq)]
pub enum NetworkState {
/// Ожидание подключения к сети
WaitingForNetwork,
/// Подключение к прокси
ConnectingToProxy,
/// Подключение к серверам Telegram
Connecting,
/// Обновление данных
Updating,
/// Подключено
Ready,
}
/// Онлайн-статус пользователя
#[derive(Debug, Clone, PartialEq)]
pub enum UserOnlineStatus {
@@ -91,6 +113,8 @@ pub struct TdClient {
pub main_chat_list_position: i32,
/// Онлайн-статусы пользователей: user_id -> status
user_statuses: HashMap<i64, UserOnlineStatus>,
/// Состояние сетевого соединения
pub network_state: NetworkState,
}
#[allow(dead_code)]
@@ -120,6 +144,7 @@ impl TdClient {
folders: Vec::new(),
main_chat_list_position: 0,
user_statuses: HashMap::new(),
network_state: NetworkState::Connecting,
}
}
@@ -131,6 +156,18 @@ 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
@@ -205,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) {
@@ -306,6 +354,9 @@ impl TdClient {
}
}
}
// Периодически очищаем кэши
self.trim_caches();
}
Update::ChatFolders(update) => {
// Обновляем список папок
@@ -331,6 +382,16 @@ impl TdClient {
};
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,
};
}
_ => {}
}
}
@@ -394,6 +455,9 @@ impl TdClient {
})
.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(),
@@ -401,10 +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) {
@@ -412,8 +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;

View File

@@ -2,3 +2,4 @@ pub mod client;
pub use client::TdClient;
pub use client::UserOnlineStatus;
pub use client::NetworkState;

View File

@@ -44,6 +44,7 @@ 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) {
@@ -57,15 +58,22 @@ pub fn render(f: &mut Frame, area: Rect, app: &mut App) {
.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, status_icon, pin_icon, chat.title, username_text, unread_badge);
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),

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

@@ -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(3), // Chat header
Constraint::Min(0), // Messages
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 для пробела
lines.push(Line::from(vec![
Span::raw(" ".repeat(padding)),
Span::styled(msg.content.clone(), Style::default().fg(Color::Green)),
Span::styled(format!(" {}", time_mark), Style::default().fg(Color::Gray)),
]));
// Максимальная ширина для текста сообщения (оставляем место для 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(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);
lines.push(Line::from(vec![
Span::styled(format!(" {}", time_str), Style::default().fg(Color::Gray)),
Span::raw(format!(" {}", msg.content)),
]));
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!(" {}", 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());
}