This commit is contained in:
Mikhail Kilin
2026-01-31 03:48:50 +03:00
parent 1bf9b3d703
commit 644e36597d
37 changed files with 1070 additions and 600 deletions

View File

@@ -5,7 +5,7 @@ pub use chat_state::ChatState;
pub use state::AppScreen;
use crate::tdlib::{ChatInfo, TdClient};
use crate::types::ChatId;
use crate::types::{ChatId, MessageId};
use ratatui::widgets::ListState;
pub struct App {
@@ -189,7 +189,7 @@ impl App {
// Сначала извлекаем данные из сообщения
let msg_data = self.get_selected_message().and_then(|msg| {
if msg.can_be_edited() && msg.is_outgoing() {
Some((msg.id()(), msg.text().to_string(), selected_idx.unwrap()))
Some((msg.id(), msg.text().to_string(), selected_idx.unwrap()))
} else {
None
}
@@ -226,7 +226,7 @@ impl App {
}
pub fn get_selected_chat_id(&self) -> Option<i64> {
self.selected_chat_id
self.selected_chat_id.map(|id| id.as_i64())
}
pub fn get_selected_chat(&self) -> Option<&ChatInfo> {
@@ -451,7 +451,7 @@ impl App {
/// Получить ID текущего pinned для перехода в историю
pub fn get_selected_pinned_id(&self) -> Option<i64> {
self.get_selected_pinned().map(|m| m.id())
self.get_selected_pinned().map(|m| m.id().as_i64())
}
// === Message Search Mode ===
@@ -522,7 +522,7 @@ impl App {
/// Получить ID выбранного результата для перехода
pub fn get_selected_search_result_id(&self) -> Option<i64> {
self.get_selected_search_result().map(|m| m.id())
self.get_selected_search_result().map(|m| m.id().as_i64())
}
/// Получить поисковый запрос из режима поиска
@@ -703,7 +703,7 @@ impl App {
available_reactions: Vec<String>,
) {
self.chat_state = ChatState::ReactionPicker {
message_id,
message_id: MessageId::new(message_id),
available_reactions,
selected_index: 0,
};
@@ -748,6 +748,6 @@ impl App {
}
pub fn get_selected_message_for_reaction(&self) -> Option<i64> {
self.chat_state.selected_message_id()
self.chat_state.selected_message_id().map(|id| id.as_i64())
}
}

285
src/formatting.rs Normal file
View File

@@ -0,0 +1,285 @@
//! Модуль для форматирования текста с markdown entities
//!
//! Предоставляет функции для преобразования текста с TDLib TextEntity
//! в стилизованные Span для отображения в TUI.
use ratatui::{
style::{Color, Modifier, Style},
text::Span,
};
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 {
/// Преобразует CharStyle в ratatui Style
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
}
}
/// Проверяет равенство двух стилей
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
}
/// Преобразует текст с entities в вектор стилизованных Span
///
/// # Аргументы
///
/// * `text` - Текст для форматирования
/// * `entities` - Массив TextEntity с информацией о форматировании
/// * `base_color` - Базовый цвет текста
///
/// # Возвращает
///
/// Вектор Span<'static> со стилизованными фрагментами текста
pub fn format_text_with_entities(
text: &str,
entities: &[TextEntity],
base_color: Color,
) -> Vec<Span<'static>> {
if entities.is_empty() {
return vec![Span::styled(
text.to_string(),
Style::default().fg(base_color),
)];
}
// Создаём массив стилей для каждого символа
let chars: Vec<char> = text.chars().collect();
let mut char_styles: Vec<CharStyle> = 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<Span<'static>> = Vec::new();
let mut current_text = String::new();
let mut current_style: Option<CharStyle> = None;
for (i, ch) in chars.iter().enumerate() {
let style = &char_styles[i];
match &current_style {
Some(prev_style) if styles_equal(prev_style, style) => {
current_text.push(*ch);
}
_ => {
if !current_text.is_empty() {
if let Some(prev_style) = &current_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
}
/// Фильтрует и корректирует entities для подстроки
///
/// Используется для правильного отображения форматирования при переносе текста.
///
/// # Аргументы
///
/// * `entities` - Исходный массив entities
/// * `start` - Начальная позиция подстроки (в символах)
/// * `length` - Длина подстроки (в символах)
///
/// # Возвращает
///
/// Новый массив entities с откорректированными offset и length
pub fn adjust_entities_for_substring(
entities: &[TextEntity],
start: usize,
length: usize,
) -> Vec<TextEntity> {
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()
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_format_text_no_entities() {
let text = "Hello, world!";
let entities = vec![];
let spans = format_text_with_entities(text, &entities, Color::White);
assert_eq!(spans.len(), 1);
assert_eq!(spans[0].content, "Hello, world!");
}
#[test]
fn test_format_text_with_bold() {
let text = "Hello";
let entities = vec![TextEntity {
offset: 0,
length: 5,
r#type: TextEntityType::Bold,
}];
let spans = format_text_with_entities(text, &entities, Color::White);
assert_eq!(spans.len(), 1);
assert_eq!(spans[0].content, "Hello");
assert!(spans[0].style.add_modifier.contains(Modifier::BOLD));
}
#[test]
fn test_adjust_entities_full_overlap() {
let entities = vec![TextEntity {
offset: 0,
length: 10,
r#type: TextEntityType::Bold,
}];
let adjusted = adjust_entities_for_substring(&entities, 0, 10);
assert_eq!(adjusted.len(), 1);
assert_eq!(adjusted[0].offset, 0);
assert_eq!(adjusted[0].length, 10);
}
#[test]
fn test_adjust_entities_partial_overlap() {
let entities = vec![TextEntity {
offset: 5,
length: 10,
r#type: TextEntityType::Bold,
}];
let adjusted = adjust_entities_for_substring(&entities, 0, 10);
assert_eq!(adjusted.len(), 1);
assert_eq!(adjusted[0].offset, 5);
assert_eq!(adjusted[0].length, 5); // Обрезано до конца подстроки
}
#[test]
fn test_adjust_entities_no_overlap() {
let entities = vec![TextEntity {
offset: 20,
length: 10,
r#type: TextEntityType::Bold,
}];
let adjusted = adjust_entities_for_substring(&entities, 0, 10);
assert_eq!(adjusted.len(), 0); // Нет пересечений
}
}

View File

@@ -30,7 +30,7 @@ pub async fn handle(app: &mut App, key: KeyEvent) {
app.status_message = Some("Загрузка закреплённых...".to_string());
match timeout(
Duration::from_secs(5),
app.td_client.get_pinned_messages(chat_id),
app.td_client.get_pinned_messages(ChatId::new(chat_id)),
)
.await
{
@@ -212,7 +212,7 @@ pub async fn handle(app: &mut App, key: KeyEvent) {
if !query.is_empty() {
if let Ok(Ok(results)) = timeout(
Duration::from_secs(3),
app.td_client.search_messages(chat_id, &query),
app.td_client.search_messages(ChatId::new(chat_id), &query),
)
.await
{
@@ -233,7 +233,7 @@ pub async fn handle(app: &mut App, key: KeyEvent) {
if let Some(chat_id) = app.get_selected_chat_id() {
if let Ok(Ok(results)) = timeout(
Duration::from_secs(3),
app.td_client.search_messages(chat_id, &query),
app.td_client.search_messages(ChatId::new(chat_id), &query),
)
.await
{
@@ -388,7 +388,7 @@ pub async fn handle(app: &mut App, key: KeyEvent) {
match timeout(
Duration::from_secs(5),
app.td_client.delete_messages(
chat_id,
ChatId::new(chat_id),
vec![msg_id],
can_delete_for_all,
),
@@ -442,7 +442,7 @@ pub async fn handle(app: &mut App, key: KeyEvent) {
Duration::from_secs(5),
app.td_client.forward_messages(
to_chat_id,
from_chat_id,
ChatId::new(from_chat_id),
vec![msg_id],
),
)
@@ -490,7 +490,7 @@ pub async fn handle(app: &mut App, key: KeyEvent) {
app.message_scroll_offset = 0;
match timeout(
Duration::from_secs(10),
app.td_client.get_chat_history(chat_id, 100),
app.td_client.get_chat_history(ChatId::new(chat_id), 100),
)
.await
{
@@ -504,7 +504,7 @@ pub async fn handle(app: &mut App, key: KeyEvent) {
// Загружаем последнее закреплённое сообщение
let _ = timeout(
Duration::from_secs(2),
app.td_client.load_current_pinned_message(chat_id),
app.td_client.load_current_pinned_message(ChatId::new(chat_id)),
)
.await;
// Загружаем черновик
@@ -572,7 +572,7 @@ pub async fn handle(app: &mut App, key: KeyEvent) {
match timeout(
Duration::from_secs(5),
app.td_client.edit_message(chat_id, msg_id, text),
app.td_client.edit_message(ChatId::new(chat_id), msg_id, text),
)
.await
{
@@ -622,13 +622,13 @@ pub async fn handle(app: &mut App, key: KeyEvent) {
// Отменяем typing status
app.td_client
.send_chat_action(chat_id, ChatAction::Cancel)
.send_chat_action(ChatId::new(chat_id), ChatAction::Cancel)
.await;
match timeout(
Duration::from_secs(5),
app.td_client
.send_message(chat_id, text, reply_to_id, reply_info),
.send_message(ChatId::new(chat_id), text, reply_to_id, reply_info),
)
.await
{
@@ -659,7 +659,7 @@ pub async fn handle(app: &mut App, key: KeyEvent) {
app.message_scroll_offset = 0;
match timeout(
Duration::from_secs(10),
app.td_client.get_chat_history(chat_id, 100),
app.td_client.get_chat_history(ChatId::new(chat_id), 100),
)
.await
{
@@ -673,7 +673,7 @@ pub async fn handle(app: &mut App, key: KeyEvent) {
// Загружаем последнее закреплённое сообщение
let _ = timeout(
Duration::from_secs(2),
app.td_client.load_current_pinned_message(chat_id),
app.td_client.load_current_pinned_message(ChatId::new(chat_id)),
)
.await;
// Загружаем черновик
@@ -795,7 +795,7 @@ pub async fn handle(app: &mut App, key: KeyEvent) {
app.status_message = None;
app.needs_redraw = true;
} else {
app.enter_reaction_picker_mode(message_id, reactions);
app.enter_reaction_picker_mode(message_id.as_i64(), reactions);
app.status_message = None;
app.needs_redraw = true;
}
@@ -894,7 +894,7 @@ pub async fn handle(app: &mut App, key: KeyEvent) {
if should_send_typing {
if let Some(chat_id) = app.get_selected_chat_id() {
app.td_client
.send_chat_action(chat_id, ChatAction::Typing)
.send_chat_action(ChatId::new(chat_id), ChatAction::Typing)
.await;
app.last_typing_sent = Some(Instant::now());
}
@@ -943,7 +943,7 @@ pub async fn handle(app: &mut App, key: KeyEvent) {
.current_chat_messages()
.first()
.map(|m| m.id())
.unwrap_or(0);
.unwrap_or(MessageId::new(0));
if let Some(chat_id) = app.get_selected_chat_id() {
// Подгружаем больше сообщений если скролл близко к верху
if app.message_scroll_offset
@@ -952,7 +952,7 @@ pub async fn handle(app: &mut App, key: KeyEvent) {
if let Ok(Ok(older)) = timeout(
Duration::from_secs(3),
app.td_client
.load_older_messages(chat_id, oldest_msg_id),
.load_older_messages(ChatId::new(chat_id), oldest_msg_id),
)
.await
{
@@ -1041,12 +1041,12 @@ fn format_message_for_clipboard(msg: &crate::tdlib::MessageInfo) -> String {
let mut result = String::new();
// Добавляем forward контекст если есть
if let Some(forward) = &msg.forward_from {
if let Some(forward) = msg.forward_from() {
result.push_str(&format!("↪ Переслано от {}\n", forward.sender_name));
}
// Добавляем reply контекст если есть
if let Some(reply) = &msg.reply_to {
if let Some(reply) = msg.reply_to() {
result.push_str(&format!("{}: {}\n", reply.sender_name, reply.text));
}

View File

@@ -5,6 +5,7 @@ pub mod app;
pub mod config;
pub mod constants;
pub mod error;
pub mod formatting;
pub mod input;
pub mod tdlib;
pub mod types;

View File

@@ -1,8 +1,11 @@
mod app;
mod config;
mod constants;
mod error;
mod formatting;
mod input;
mod tdlib;
mod types;
mod ui;
mod utils;

View File

@@ -292,27 +292,27 @@ impl TdClient {
self.message_manager.current_pinned_message = msg;
}
pub fn typing_status(&self) -> Option<&(i64, String, std::time::Instant)> {
pub fn typing_status(&self) -> Option<&(crate::types::UserId, String, std::time::Instant)> {
self.chat_manager.typing_status.as_ref()
}
pub fn set_typing_status(&mut self, status: Option<(i64, String, std::time::Instant)>) {
pub fn set_typing_status(&mut self, status: Option<(crate::types::UserId, String, std::time::Instant)>) {
self.chat_manager.typing_status = status;
}
pub fn pending_view_messages(&self) -> &[(i64, Vec<i64>)] {
pub fn pending_view_messages(&self) -> &[(crate::types::ChatId, Vec<crate::types::MessageId>)] {
&self.message_manager.pending_view_messages
}
pub fn pending_view_messages_mut(&mut self) -> &mut Vec<(i64, Vec<i64>)> {
pub fn pending_view_messages_mut(&mut self) -> &mut Vec<(crate::types::ChatId, Vec<crate::types::MessageId>)> {
&mut self.message_manager.pending_view_messages
}
pub fn pending_user_ids(&self) -> &[i64] {
pub fn pending_user_ids(&self) -> &[crate::types::UserId] {
&self.user_cache.pending_user_ids
}
pub fn pending_user_ids_mut(&mut self) -> &mut Vec<i64> {
pub fn pending_user_ids_mut(&mut self) -> &mut Vec<crate::types::UserId> {
&mut self.user_cache.pending_user_ids
}
@@ -470,8 +470,8 @@ impl TdClient {
let chat_id = ChatId::new(new_msg.message.chat_id);
if Some(chat_id) == self.current_chat_id() {
let msg_info = self.convert_message(&new_msg.message, chat_id);
let msg_id = msg_info.id;
let is_incoming = !msg_info.is_outgoing;
let msg_id = msg_info.id();
let is_incoming = !msg_info.is_outgoing();
// Проверяем, есть ли уже сообщение с таким id
let existing_idx = self
@@ -488,12 +488,12 @@ impl TdClient {
// Для исходящих: обновляем can_be_edited и другие поля,
// но сохраняем reply_to (добавленный при отправке)
let existing = &mut self.current_chat_messages_mut()[idx];
existing.can_be_edited = msg_info.can_be_edited;
existing.can_be_deleted_only_for_self =
msg_info.can_be_deleted_only_for_self;
existing.can_be_deleted_for_all_users =
msg_info.can_be_deleted_for_all_users;
existing.is_read = msg_info.is_read;
existing.state.can_be_edited = msg_info.state.can_be_edited;
existing.state.can_be_deleted_only_for_self =
msg_info.state.can_be_deleted_only_for_self;
existing.state.can_be_deleted_for_all_users =
msg_info.state.can_be_deleted_for_all_users;
existing.state.is_read = msg_info.state.is_read;
}
}
None => {
@@ -518,7 +518,7 @@ impl TdClient {
// Clone chat_user_ids to avoid borrow conflict
let chat_user_ids = self.user_cache.chat_user_ids.clone();
self.chats_mut()
.retain(|c| chat_user_ids.get(&c.id) != Some(&user_id));
.retain(|c| chat_user_ids.get(&c.id) != Some(&UserId::new(user_id)));
return;
}
@@ -528,15 +528,15 @@ impl TdClient {
} else {
format!("{} {}", user.first_name, user.last_name)
};
self.user_cache.user_names.insert(user.id, display_name);
self.user_cache.user_names.insert(UserId::new(user.id), display_name);
// Сохраняем username если есть
if let Some(usernames) = user.usernames {
if let Some(username) = usernames.active_usernames.first() {
self.user_cache.user_usernames.insert(user.id, username.clone());
self.user_cache.user_usernames.insert(UserId::new(user.id), username.clone());
// Обновляем username в чатах, связанных с этим пользователем
for (&chat_id, &user_id) in &self.user_cache.chat_user_ids.clone() {
if user_id == user.id {
if user_id == UserId::new(user.id) {
if let Some(chat) = self.chats_mut().iter_mut().find(|c| c.id == chat_id)
{
chat.username = Some(format!("@{}", username));
@@ -991,20 +991,20 @@ impl TdClient {
match origin {
MessageOrigin::User(u) => self
.user_cache.user_names
.peek(&u.sender_user_id)
.peek(&UserId::new(u.sender_user_id))
.cloned()
.unwrap_or_else(|| format!("User_{}", u.sender_user_id)),
MessageOrigin::Chat(c) => self
.chats()
.iter()
.find(|chat| chat.id == c.sender_chat_id)
.find(|chat| chat.id == ChatId::new(c.sender_chat_id))
.map(|chat| chat.title.clone())
.unwrap_or_else(|| "Чат".to_string()),
MessageOrigin::HiddenUser(h) => h.sender_name.clone(),
MessageOrigin::Channel(c) => self
.chats()
.iter()
.find(|chat| chat.id == c.chat_id)
.find(|chat| chat.id == ChatId::new(c.chat_id))
.map(|chat| chat.title.clone())
.unwrap_or_else(|| "Канал".to_string()),
}
@@ -1017,15 +1017,15 @@ impl TdClient {
let msg_data: std::collections::HashMap<i64, (String, String)> = self
.current_chat_messages()
.iter()
.map(|m| (m.id(), (m.sender_name().to_string(), m.text().to_string())))
.map(|m| (m.id().as_i64(), (m.sender_name().to_string(), m.text().to_string())))
.collect();
// Обновляем reply_to для сообщений с неполными данными
for msg in self.current_chat_messages_mut().iter_mut() {
if let Some(ref mut reply) = msg.reply_to {
if let Some(ref mut reply) = msg.interactions.reply_to {
// Если sender_name = "..." или text пустой — пробуем заполнить
if reply.sender_name == "..." || reply.text.is_empty() {
if let Some((sender, content)) = msg_data.get(&reply.message_id) {
if let Some((sender, content)) = msg_data.get(&reply.message_id.as_i64()) {
if reply.sender_name == "..." {
reply.sender_name = sender.clone();
}

View File

@@ -4,7 +4,7 @@ use tdlib_rs::enums::{ChatAction, InputMessageContent, InputMessageReplyTo, Mess
use tdlib_rs::functions;
use tdlib_rs::types::{Chat as TdChat, FormattedText, InputMessageReplyToMessage, InputMessageText, Message as TdMessage, TextEntity, TextParseModeMarkdown};
use super::types::{ForwardInfo, MessageInfo, ReactionInfo, ReplyInfo};
use super::types::{ForwardInfo, MessageBuilder, MessageInfo, ReactionInfo, ReplyInfo};
/// Менеджер сообщений
pub struct MessageManager {
@@ -375,7 +375,8 @@ impl MessageManager {
let batch = std::mem::take(&mut self.pending_view_messages);
for (chat_id, message_ids) in batch {
let _ = functions::view_messages(chat_id, message_ids, None, true, self.client_id).await;
let ids: Vec<i64> = message_ids.iter().map(|id| id.as_i64()).collect();
let _ = functions::view_messages(chat_id.as_i64(), ids, None, true, self.client_id).await;
}
}
@@ -454,7 +455,7 @@ impl MessageManager {
if let tdlib_rs::enums::MessageReplyTo::Message(reply_msg) = reply_to {
// Здесь можно загрузить информацию об оригинальном сообщении
Some(ReplyInfo {
message_id: reply_msg.message_id,
message_id: MessageId::new(reply_msg.message_id),
sender_name: "Unknown".to_string(),
text: "...".to_string(),
})
@@ -488,22 +489,48 @@ impl MessageManager {
})
.unwrap_or_default();
Some(MessageInfo {
id: msg.id,
sender_name,
is_outgoing: msg.is_outgoing,
content: content_text,
entities,
date: msg.date,
edit_date: msg.edit_date,
is_read: !msg.contains_unread_mention,
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,
reply_to,
forward_from,
reactions,
})
let mut builder = MessageBuilder::new(MessageId::new(msg.id))
.sender_name(sender_name)
.text(content_text)
.entities(entities)
.date(msg.date)
.edit_date(msg.edit_date);
if msg.is_outgoing {
builder = builder.outgoing();
} else {
builder = builder.incoming();
}
if !msg.contains_unread_mention {
builder = builder.read();
} else {
builder = builder.unread();
}
if msg.can_be_edited {
builder = builder.editable();
}
if msg.can_be_deleted_only_for_self {
builder = builder.deletable_for_self();
}
if msg.can_be_deleted_for_all_users {
builder = builder.deletable_for_all();
}
if let Some(reply) = reply_to {
builder = builder.reply_to(reply);
}
if let Some(forward) = forward_from {
builder = builder.forward_from(forward);
}
builder = builder.reactions(reactions);
Some(builder.build())
}
/// Получить недостающую reply информацию для сообщений
@@ -511,7 +538,7 @@ impl MessageManager {
// Collect message IDs that need to be fetched
let mut to_fetch = Vec::new();
for msg in &self.current_chat_messages {
if let Some(ref reply) = msg.reply_to {
if let Some(ref reply) = msg.interactions.reply_to {
if reply.sender_name == "Unknown" {
to_fetch.push(reply.message_id);
}
@@ -522,17 +549,18 @@ impl MessageManager {
if let Some(chat_id) = self.current_chat_id {
for message_id in to_fetch {
if let Ok(original_msg_enum) =
functions::get_message(chat_id, message_id, self.client_id).await
functions::get_message(chat_id.as_i64(), message_id.as_i64(), self.client_id).await
{
if let tdlib_rs::enums::Message::Message(original_msg) = original_msg_enum {
if let Some(orig_info) = self.convert_message(&original_msg).await {
// Update the reply info
for msg in &mut self.current_chat_messages {
if let Some(ref mut reply) = msg.reply_to {
if let Some(ref mut reply) = msg.interactions.reply_to {
if reply.message_id == message_id {
reply.sender_name = orig_info.sender_name.clone();
reply.sender_name = orig_info.metadata.sender_name.clone();
reply.text = orig_info
.content
.text
.chars()
.take(50)
.collect::<String>();

View File

@@ -68,7 +68,7 @@ pub struct MessageMetadata {
}
/// Контент сообщения (текст и форматирование)
#[derive(Debug, Clone)]
#[derive(Debug, Clone, PartialEq)]
pub struct MessageContent {
pub text: String,
/// Сущности форматирования (bold, italic, code и т.д.)

View File

@@ -61,7 +61,7 @@ impl<V: Clone> LruCache<V> {
}
/// Проверить наличие ключа
pub fn contains_key(&self, key: &i64) -> bool {
pub fn contains_key(&self, key: &UserId) -> bool {
self.map.contains_key(key)
}
@@ -181,7 +181,7 @@ impl UserCache {
}
// Берём первые N user_ids для загрузки
let batch: Vec<i64> = self
let batch: Vec<UserId> = self
.pending_user_ids
.drain(..self.pending_user_ids.len().min(LAZY_LOAD_USERS_PER_TICK))
.collect();
@@ -191,7 +191,7 @@ impl UserCache {
continue; // Уже в кэше
}
match functions::get_user(user_id, self.client_id).await {
match functions::get_user(user_id.as_i64(), self.client_id).await {
Ok(user_enum) => {
self.handle_user_update(&user_enum);
}

View File

@@ -36,7 +36,7 @@ impl fmt::Display for ChatId {
}
/// Message identifier
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, PartialOrd, Ord, Serialize, Deserialize)]
pub struct MessageId(pub i64);
impl MessageId {

View File

@@ -1,5 +1,6 @@
use crate::app::App;
use crate::tdlib::UserOnlineStatus;
use crate::ui::components;
use ratatui::{
layout::{Constraint, Direction, Layout, Rect},
style::{Color, Modifier, Style},
@@ -43,63 +44,8 @@ pub fn render(f: &mut Frame, area: Rect, app: &mut App) {
.iter()
.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 draft_badge = if chat.draft_text.is_some() {
"".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,
mute_icon,
chat.title,
username_text,
mention_badge,
draft_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)
let user_status = app.td_client.get_user_status_by_chat_id(chat.id);
components::render_chat_list_item(chat, is_selected, user_status)
})
.collect();

View File

@@ -0,0 +1,78 @@
use crate::tdlib::{ChatInfo, UserOnlineStatus};
use ratatui::{
style::{Color, Style},
widgets::ListItem,
};
/// Рендерит элемент списка чатов
///
/// # Параметры
/// - `chat`: Информация о чате
/// - `is_selected`: Выбран ли этот чат
/// - `user_status`: Онлайн-статус пользователя (если доступен)
///
/// # Возвращает
/// ListItem с форматированным отображением чата
pub fn render_chat_list_item(
chat: &ChatInfo,
is_selected: bool,
user_status: Option<&UserOnlineStatus>,
) -> ListItem<'static> {
let pin_icon = if chat.is_pinned { "📌 " } else { "" };
let mute_icon = if chat.is_muted { "🔇 " } else { "" };
// Онлайн-статус (зелёная точка для онлайн)
let status_icon = match user_status {
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 draft_badge = if chat.draft_text.is_some() {
"".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,
mute_icon,
chat.title,
username_text,
mention_badge,
draft_badge,
unread_badge
);
// Цвет: онлайн — зелёные, остальные — белые
let style = match user_status {
Some(UserOnlineStatus::Online) => Style::default().fg(Color::Green),
_ => Style::default().fg(Color::White),
};
ListItem::new(content).style(style)
}

View File

@@ -0,0 +1,112 @@
use ratatui::{
layout::{Alignment, Rect},
style::{Color, Modifier, Style},
text::{Line, Span},
widgets::{Block, Borders, Clear, Paragraph},
Frame,
};
/// Рендерит модалку выбора реакций (emoji picker)
///
/// # Параметры
/// - `f`: Frame для рендеринга
/// - `area`: Область экрана
/// - `available_reactions`: Список доступных эмодзи
/// - `selected_index`: Индекс выбранного эмодзи
pub fn render_emoji_picker(
f: &mut Frame,
area: Rect,
available_reactions: &[String],
selected_index: usize,
) {
// Размеры модалки (зависят от количества реакций)
let emojis_per_row = 8;
let rows = (available_reactions.len() + emojis_per_row - 1) / emojis_per_row;
let modal_width = 50u16;
let modal_height = (rows + 4) as u16; // +4 для заголовка, отступов и подсказки
// Центрируем модалку
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 mut text_lines = vec![Line::from("")]; // Пустая строка сверху
for row in 0..rows {
let mut row_spans = vec![Span::raw(" ")]; // Отступ слева
for col in 0..emojis_per_row {
let idx = row * emojis_per_row + col;
if idx >= available_reactions.len() {
break;
}
let emoji = &available_reactions[idx];
let is_selected = idx == selected_index;
let style = if is_selected {
Style::default()
.fg(Color::Yellow)
.add_modifier(Modifier::BOLD)
.add_modifier(Modifier::REVERSED)
} else {
Style::default().fg(Color::White)
};
row_spans.push(Span::styled(format!(" {} ", emoji), style));
row_spans.push(Span::raw(" ")); // Пробел между эмодзи
}
text_lines.push(Line::from(row_spans));
}
// Добавляем пустую строку и подсказку
text_lines.push(Line::from(""));
text_lines.push(Line::from(vec![
Span::styled(
" [←/→/↑/↓] ",
Style::default()
.fg(Color::Cyan)
.add_modifier(Modifier::BOLD),
),
Span::raw("Выбор "),
Span::styled(
" [Enter] ",
Style::default()
.fg(Color::Green)
.add_modifier(Modifier::BOLD),
),
Span::raw("Добавить "),
Span::styled(
" [Esc] ",
Style::default().fg(Color::Red).add_modifier(Modifier::BOLD),
),
Span::raw("Отмена"),
]));
let modal = Paragraph::new(text_lines)
.block(
Block::default()
.borders(Borders::ALL)
.border_style(Style::default().fg(Color::Yellow))
.title(" Выбери реакцию ")
.title_style(
Style::default()
.fg(Color::Yellow)
.add_modifier(Modifier::BOLD),
),
)
.alignment(Alignment::Center);
f.render_widget(modal, modal_area);
}

View File

@@ -0,0 +1,53 @@
use ratatui::{
style::{Color, Style},
text::{Line, Span},
};
/// Рендерит текст с курсором в виде Line
///
/// # Параметры
/// - `prefix`: Префикс перед текстом (например, "Сообщение: ")
/// - `text`: Текст в поле ввода
/// - `cursor_pos`: Позиция курсора (индекс символа)
/// - `color`: Цвет текста и курсора
///
/// # Возвращает
/// Line с текстом и блочным курсором на указанной позиции
pub fn render_input_field(
prefix: &str,
text: &str,
cursor_pos: usize,
color: Color,
) -> Line<'static> {
let chars: Vec<char> = text.chars().collect();
let mut spans: Vec<Span> = vec![Span::raw(prefix.to_string())];
// Ограничиваем cursor_pos границами текста
let safe_cursor_pos = cursor_pos.min(chars.len());
// Текст до курсора
if safe_cursor_pos > 0 {
let before: String = chars[..safe_cursor_pos].iter().collect();
spans.push(Span::styled(before, Style::default().fg(color)));
}
// Символ под курсором (или █ если курсор в конце)
if safe_cursor_pos < chars.len() {
let cursor_char = chars[safe_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 safe_cursor_pos + 1 < chars.len() {
let after: String = chars[safe_cursor_pos + 1..].iter().collect();
spans.push(Span::styled(after, Style::default().fg(color)));
}
Line::from(spans)
}

View File

@@ -0,0 +1,26 @@
// Message bubble component
//
// TODO: Этот компонент требует дальнейшего рефакторинга.
// Логика рендеринга сообщений в messages.rs очень сложная и интегрированная,
// включая:
// - Группировку сообщений по дате и отправителю
// - Форматирование markdown (entities)
// - Перенос длинных текстов
// - Отображение reply, forward, reactions
// - Выравнивание (входящие/исходящие)
//
// Для полного выделения компонента нужно сначала:
// 1. Вынести форматирование в src/formatting.rs (P3.8)
// 2. Вынести группировку в src/message_grouping.rs (P3.9)
//
// Пока этот файл служит placeholder'ом для будущего рефакторинга.
use crate::tdlib::MessageInfo;
/// Placeholder для функции рендеринга пузыря сообщения
///
/// TODO: Реализовать после выполнения P3.8 и P3.9
pub fn render_message_bubble(_message: &MessageInfo) {
// Будет реализовано позже
unimplemented!("Message bubble rendering requires P3.8 and P3.9 first")
}

14
src/ui/components/mod.rs Normal file
View File

@@ -0,0 +1,14 @@
// UI компоненты для переиспользования
pub mod modal;
pub mod input_field;
pub mod message_bubble;
pub mod chat_list_item;
pub mod emoji_picker;
// Экспорт основных функций
pub use modal::render_modal;
pub use input_field::render_input_field;
pub use message_bubble::render_message_bubble;
pub use chat_list_item::render_chat_list_item;
pub use emoji_picker::render_emoji_picker;

View File

@@ -0,0 +1,86 @@
use ratatui::{
layout::{Alignment, Rect},
style::{Color, Modifier, Style},
text::Line,
widgets::{Block, Borders, Clear, Paragraph},
Frame,
};
/// Рендерит центрированную модалку с заданным содержимым
///
/// # Параметры
/// - `f`: Frame для рендеринга
/// - `area`: Область экрана
/// - `title`: Заголовок модалки
/// - `content`: Содержимое модалки (строки текста)
/// - `width`: Ширина модалки
/// - `height`: Высота модалки
/// - `border_color`: Цвет рамки
pub fn render_modal(
f: &mut Frame,
area: Rect,
title: &str,
content: Vec<Line>,
width: u16,
height: u16,
border_color: Color,
) {
// Центрируем модалку
let x = area.x + (area.width.saturating_sub(width)) / 2;
let y = area.y + (area.height.saturating_sub(height)) / 2;
let modal_area = Rect::new(x, y, width.min(area.width), height.min(area.height));
// Очищаем область под модалкой
f.render_widget(Clear, modal_area);
// Рендерим модалку
let modal = Paragraph::new(content)
.block(
Block::default()
.borders(Borders::ALL)
.border_style(Style::default().fg(border_color))
.title(format!(" {} ", title))
.title_style(
Style::default()
.fg(border_color)
.add_modifier(Modifier::BOLD),
),
)
.alignment(Alignment::Center);
f.render_widget(modal, modal_area);
}
/// Рендерит модалку подтверждения удаления
pub fn render_delete_confirm_modal(f: &mut Frame, area: Rect) {
use ratatui::text::Span;
let content = 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("Нет"),
]),
];
render_modal(f, area, "Подтверждение", content, 40, 7, Color::Red);
}

View File

@@ -1,4 +1,6 @@
use crate::app::App;
use crate::formatting;
use crate::ui::components;
use crate::utils::{format_date, format_timestamp_with_tz, get_day};
use ratatui::{
layout::{Alignment, Constraint, Direction, Layout, Rect},
@@ -7,188 +9,15 @@ use ratatui::{
widgets::{Block, Borders, Paragraph},
Frame,
};
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<Span<'static>> {
if entities.is_empty() {
return vec![Span::styled(
text.to_string(),
Style::default().fg(base_color),
)];
}
// Создаём массив стилей для каждого символа
let chars: Vec<char> = text.chars().collect();
let mut char_styles: Vec<CharStyle> = 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<Span<'static>> = Vec::new();
let mut current_text = String::new();
let mut current_style: Option<CharStyle> = None;
for (i, ch) in chars.iter().enumerate() {
let style = &char_styles[i];
match &current_style {
Some(prev_style) if styles_equal(prev_style, style) => {
current_text.push(*ch);
}
_ => {
if !current_text.is_empty() {
if let Some(prev_style) = &current_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(
prefix: &str,
text: &str,
cursor_pos: usize,
color: Color,
) -> Line<'static> {
let chars: Vec<char> = text.chars().collect();
let mut spans: Vec<Span> = vec![Span::raw(prefix.to_string())];
// Ограничиваем cursor_pos границами текста
let safe_cursor_pos = cursor_pos.min(chars.len());
// Текст до курсора
if safe_cursor_pos > 0 {
let before: String = chars[..safe_cursor_pos].iter().collect();
spans.push(Span::styled(before, Style::default().fg(color)));
}
// Символ под курсором (или █ если курсор в конце)
if safe_cursor_pos < chars.len() {
let cursor_char = chars[safe_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 safe_cursor_pos + 1 < chars.len() {
let after: String = chars[safe_cursor_pos + 1..].iter().collect();
spans.push(Span::styled(after, Style::default().fg(color)));
}
Line::from(spans)
// Используем компонент input_field
components::render_input_field(prefix, text, cursor_pos, color)
}
/// Информация о строке после переноса: текст и позиция в оригинале
@@ -282,43 +111,6 @@ fn wrap_text_with_offsets(text: &str, max_width: usize) -> Vec<WrappedLine> {
result
}
/// Фильтрует и корректирует entities для подстроки
fn adjust_entities_for_substring(
entities: &[TextEntity],
start: usize,
length: usize,
) -> Vec<TextEntity> {
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 app.is_profile_mode() {
@@ -615,7 +407,7 @@ pub fn render(f: &mut Frame, area: Rect, app: &App) {
let line_len = wrapped.text.chars().count();
// Получаем entities для этой строки
let line_entities = adjust_entities_for_substring(
let line_entities = formatting::adjust_entities_for_substring(
msg.entities(),
wrapped.start_offset,
line_len,
@@ -623,7 +415,7 @@ pub fn render(f: &mut Frame, area: Rect, app: &App) {
// Форматируем текст с entities
let formatted_spans =
format_text_with_entities(&wrapped.text, &line_entities, msg_color);
formatting::format_text_with_entities(&wrapped.text, &line_entities, msg_color);
if is_last_line {
// Последняя строка — добавляем time_mark
@@ -675,7 +467,7 @@ pub fn render(f: &mut Frame, area: Rect, app: &App) {
let line_len = wrapped.text.chars().count();
// Получаем entities для этой строки
let line_entities = adjust_entities_for_substring(
let line_entities = formatting::adjust_entities_for_substring(
msg.entities(),
wrapped.start_offset,
line_len,
@@ -683,7 +475,7 @@ pub fn render(f: &mut Frame, area: Rect, app: &App) {
// Форматируем текст с entities
let formatted_spans =
format_text_with_entities(&wrapped.text, &line_entities, msg_color);
formatting::format_text_with_entities(&wrapped.text, &line_entities, msg_color);
if i == 0 {
// Первая строка — с временем и маркером выбора
@@ -717,7 +509,7 @@ pub fn render(f: &mut Frame, area: Rect, app: &App) {
if !msg.reactions().is_empty() {
let mut reaction_spans = vec![];
for reaction in &msg.reactions() {
for reaction in msg.reactions() {
if !reaction_spans.is_empty() {
reaction_spans.push(Span::raw(" "));
}
@@ -831,10 +623,10 @@ pub fn render(f: &mut Frame, area: Rect, app: &App) {
// Режим выбора сообщения - подсказка зависит от возможностей
let selected_msg = app.get_selected_message();
let can_edit = selected_msg
.map(|m| m.can_be_edited && m.is_outgoing)
.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)
.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) {
@@ -872,7 +664,7 @@ pub fn render(f: &mut Frame, area: Rect, app: &App) {
let reply_preview = app
.get_replying_to_message()
.map(|m| {
let sender = if m.is_outgoing {
let sender = if m.is_outgoing() {
"Вы"
} else {
m.sender_name()
@@ -1336,56 +1128,7 @@ fn render_pinned_mode(f: &mut Frame, area: Rect, app: &App) {
/// Рендерит модалку подтверждения удаления
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);
components::modal::render_delete_confirm_modal(f, area);
}
/// Рендерит модалку выбора реакции
@@ -1395,88 +1138,5 @@ fn render_reaction_picker_modal(
available_reactions: &[String],
selected_index: usize,
) {
use ratatui::widgets::Clear;
// Размеры модалки (зависят от количества реакций)
let emojis_per_row = 8;
let rows = (available_reactions.len() + emojis_per_row - 1) / emojis_per_row;
let modal_width = 50u16;
let modal_height = (rows + 4) as u16; // +4 для заголовка, отступов и подсказки
// Центрируем модалку
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 mut text_lines = vec![Line::from("")]; // Пустая строка сверху
for row in 0..rows {
let mut row_spans = vec![Span::raw(" ")]; // Отступ слева
for col in 0..emojis_per_row {
let idx = row * emojis_per_row + col;
if idx >= available_reactions.len() {
break;
}
let emoji = &available_reactions[idx];
let is_selected = idx == selected_index;
let style = if is_selected {
Style::default()
.fg(Color::Yellow)
.add_modifier(Modifier::BOLD)
.add_modifier(Modifier::REVERSED)
} else {
Style::default().fg(Color::White)
};
row_spans.push(Span::styled(format!(" {} ", emoji), style));
row_spans.push(Span::raw(" ")); // Пробел между эмодзи
}
text_lines.push(Line::from(row_spans));
}
// Добавляем пустую строку и подсказку
text_lines.push(Line::from(""));
text_lines.push(Line::from(vec![
Span::styled(
" [←/→/↑/↓] ",
Style::default()
.fg(Color::Cyan)
.add_modifier(Modifier::BOLD),
),
Span::raw("Выбор "),
Span::styled(
" [Enter] ",
Style::default()
.fg(Color::Green)
.add_modifier(Modifier::BOLD),
),
Span::raw("Добавить "),
Span::styled(" [Esc] ", Style::default().fg(Color::Red).add_modifier(Modifier::BOLD)),
Span::raw("Отмена"),
]));
let modal = Paragraph::new(text_lines)
.block(
Block::default()
.borders(Borders::ALL)
.border_style(Style::default().fg(Color::Yellow))
.title(" Выбери реакцию ")
.title_style(
Style::default()
.fg(Color::Yellow)
.add_modifier(Modifier::BOLD),
),
)
.alignment(Alignment::Left);
f.render_widget(modal, modal_area);
components::render_emoji_picker(f, area, available_reactions, selected_index);
}

View File

@@ -1,5 +1,6 @@
mod auth;
pub mod chat_list;
pub mod components;
pub mod footer;
mod loading;
mod main_screen;