Files
telegram-tui/src/ui/messages.rs
2026-01-27 12:09:05 +03:00

1128 lines
48 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

use ratatui::{
layout::{Alignment, Constraint, Direction, Layout, Rect},
style::{Color, Modifier, Style},
text::{Line, Span},
widgets::{Block, Borders, Paragraph},
Frame,
};
use crate::app::App;
use crate::utils::{format_timestamp, format_date, get_day};
use tdlib_rs::enums::TextEntityType;
use tdlib_rs::types::TextEntity;
/// Структура для хранения стиля символа
#[derive(Clone, Default)]
struct CharStyle {
bold: bool,
italic: bool,
underline: bool,
strikethrough: bool,
code: bool,
spoiler: bool,
url: bool,
mention: bool,
}
impl CharStyle {
fn to_style(&self, base_color: Color) -> Style {
let mut style = Style::default();
if self.code {
// Код отображается cyan на тёмном фоне
style = style.fg(Color::Cyan).bg(Color::DarkGray);
} else if self.spoiler {
// Спойлер — серый текст (скрытый)
style = style.fg(Color::DarkGray).bg(Color::DarkGray);
} else if self.url || self.mention {
// Ссылки и упоминания — синий с подчёркиванием
style = style.fg(Color::Blue).add_modifier(Modifier::UNDERLINED);
} else {
style = style.fg(base_color);
}
if self.bold {
style = style.add_modifier(Modifier::BOLD);
}
if self.italic {
style = style.add_modifier(Modifier::ITALIC);
}
if self.underline {
style = style.add_modifier(Modifier::UNDERLINED);
}
if self.strikethrough {
style = style.add_modifier(Modifier::CROSSED_OUT);
}
style
}
}
/// Преобразует текст с entities в вектор стилизованных Span (owned)
fn format_text_with_entities(text: &str, entities: &[TextEntity], base_color: Color) -> Vec<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())];
// Текст до курсора
if cursor_pos > 0 {
let before: String = chars[..cursor_pos].iter().collect();
spans.push(Span::styled(before, Style::default().fg(color)));
}
// Символ под курсором (или █ если курсор в конце)
if cursor_pos < chars.len() {
let cursor_char = chars[cursor_pos].to_string();
spans.push(Span::styled(cursor_char, Style::default().fg(Color::Black).bg(color)));
} else {
// Курсор в конце - показываем блок
spans.push(Span::styled("", Style::default().fg(color)));
}
// Текст после курсора
if cursor_pos + 1 < chars.len() {
let after: String = chars[cursor_pos + 1..].iter().collect();
spans.push(Span::styled(after, Style::default().fg(color)));
}
Line::from(spans)
}
/// Информация о строке после переноса: текст и позиция в оригинале
struct WrappedLine {
text: String,
/// Начальная позиция в символах от начала оригинального текста
start_offset: usize,
}
/// Разбивает текст на строки с учётом максимальной ширины
/// Возвращает строки с информацией о позициях для корректного применения entities
fn wrap_text_with_offsets(text: &str, max_width: usize) -> Vec<WrappedLine> {
if max_width == 0 {
return vec![WrappedLine {
text: text.to_string(),
start_offset: 0,
}];
}
let mut result = Vec::new();
let mut current_line = String::new();
let mut current_width = 0;
let mut line_start_offset = 0;
// Разбиваем текст на слова, сохраняя позиции
let chars: Vec<char> = text.chars().collect();
let mut word_start = 0;
let mut in_word = false;
for (i, ch) in chars.iter().enumerate() {
if ch.is_whitespace() {
if in_word {
// Конец слова
let word: String = chars[word_start..i].iter().collect();
let word_width = word.chars().count();
if current_width == 0 {
current_line = word;
current_width = word_width;
line_start_offset = word_start;
} else if current_width + 1 + word_width <= max_width {
current_line.push(' ');
current_line.push_str(&word);
current_width += 1 + word_width;
} else {
result.push(WrappedLine {
text: current_line,
start_offset: line_start_offset,
});
current_line = word;
current_width = word_width;
line_start_offset = word_start;
}
in_word = false;
}
} else if !in_word {
word_start = i;
in_word = true;
}
}
// Обрабатываем последнее слово
if in_word {
let word: String = chars[word_start..].iter().collect();
let word_width = word.chars().count();
if current_width == 0 {
current_line = word;
line_start_offset = word_start;
} else if current_width + 1 + word_width <= max_width {
current_line.push(' ');
current_line.push_str(&word);
} else {
result.push(WrappedLine {
text: current_line,
start_offset: line_start_offset,
});
current_line = word;
line_start_offset = word_start;
}
}
if !current_line.is_empty() {
result.push(WrappedLine {
text: current_line,
start_offset: line_start_offset,
});
}
if result.is_empty() {
result.push(WrappedLine {
text: String::new(),
start_offset: 0,
});
}
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_message_search_mode() {
render_search_mode(f, area, app);
return;
}
// Режим просмотра закреплённых сообщений
if app.is_pinned_mode() {
render_pinned_mode(f, area, app);
return;
}
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 has_pinned = app.td_client.current_pinned_message.is_some();
let message_chunks = if has_pinned {
Layout::default()
.direction(Direction::Vertical)
.constraints([
Constraint::Length(3), // Chat header
Constraint::Length(1), // Pinned bar
Constraint::Min(0), // Messages
Constraint::Length(input_height), // Input box (динамическая высота)
])
.split(area)
} else {
Layout::default()
.direction(Direction::Vertical)
.constraints([
Constraint::Length(3), // Chat header
Constraint::Length(0), // Pinned bar (hidden)
Constraint::Min(0), // Messages
Constraint::Length(input_height), // Input box (динамическая высота)
])
.split(area)
};
// Chat header с typing status
let typing_action = app.td_client.typing_status.as_ref().map(|(_, action, _)| action.clone());
let header_line = if let Some(action) = typing_action {
// Показываем typing status: "👤 Имя @username печатает..."
let mut spans = vec![
Span::styled(
format!("👤 {}", chat.title),
Style::default().fg(Color::Cyan).add_modifier(Modifier::BOLD),
),
];
if let Some(username) = &chat.username {
spans.push(Span::styled(
format!(" {}", username),
Style::default().fg(Color::Gray),
));
}
spans.push(Span::styled(
format!(" {}", action),
Style::default().fg(Color::Yellow).add_modifier(Modifier::ITALIC),
));
Line::from(spans)
} else {
// Показываем username
let header_text = match &chat.username {
Some(username) => format!("👤 {} {}", chat.title, username),
None => format!("👤 {}", chat.title),
};
Line::from(Span::styled(
header_text,
Style::default().fg(Color::Cyan).add_modifier(Modifier::BOLD),
))
};
let header = Paragraph::new(header_line)
.block(Block::default().borders(Borders::ALL));
f.render_widget(header, message_chunks[0]);
// Pinned bar (если есть закреплённое сообщение)
if let Some(pinned_msg) = &app.td_client.current_pinned_message {
let pinned_preview: String = pinned_msg.content.chars().take(40).collect();
let ellipsis = if pinned_msg.content.chars().count() > 40 { "..." } else { "" };
let pinned_datetime = crate::utils::format_datetime(pinned_msg.date);
let pinned_text = format!("📌 {} {}{}", pinned_datetime, pinned_preview, ellipsis);
let pinned_hint = "Ctrl+P";
let pinned_bar_width = message_chunks[1].width as usize;
let text_len = pinned_text.chars().count();
let hint_len = pinned_hint.chars().count();
let padding = pinned_bar_width.saturating_sub(text_len + hint_len + 2);
let pinned_line = Line::from(vec![
Span::styled(pinned_text, Style::default().fg(Color::Magenta)),
Span::raw(" ".repeat(padding)),
Span::styled(pinned_hint, Style::default().fg(Color::Gray)),
]);
let pinned_bar = Paragraph::new(pinned_line)
.style(Style::default().bg(Color::Rgb(40, 20, 40)));
f.render_widget(pinned_bar, message_chunks[1]);
}
// Ширина области сообщений (без рамок)
let content_width = message_chunks[2].width.saturating_sub(2) as usize;
// Messages с группировкой по дате и отправителю
let mut lines: Vec<Line> = Vec::new();
let mut last_day: Option<i64> = None;
let mut last_sender: Option<(bool, String)> = None; // (is_outgoing, sender_name)
// ID выбранного сообщения для подсветки
let selected_msg_id = app.get_selected_message().map(|m| m.id);
// Номер строки, где начинается выбранное сообщение (для автоскролла)
let mut selected_msg_line: Option<usize> = None;
for msg in &app.td_client.current_chat_messages {
// Проверяем, выбрано ли это сообщение
let is_selected = selected_msg_id == Some(msg.id);
// Запоминаем строку начала выбранного сообщения
if is_selected {
selected_msg_line = Some(lines.len());
}
// Проверяем, нужно ли добавить разделитель даты
let msg_day = get_day(msg.date);
if last_day != Some(msg_day) {
if last_day.is_some() {
lines.push(Line::from("")); // Пустая строка перед разделителем
}
// Добавляем разделитель даты по центру
let date_str = format_date(msg.date);
let date_line = format!("──────── {} ────────", date_str);
let padding = content_width.saturating_sub(date_line.chars().count()) / 2;
lines.push(Line::from(vec![
Span::raw(" ".repeat(padding)),
Span::styled(date_line, Style::default().fg(Color::Gray)),
]));
lines.push(Line::from(""));
last_day = Some(msg_day);
last_sender = None; // Сбрасываем отправителя при смене дня
}
let sender_name = if msg.is_outgoing {
"Вы".to_string()
} else {
msg.sender_name.clone()
};
let current_sender = (msg.is_outgoing, sender_name.clone());
// Проверяем, нужно ли показать заголовок отправителя
let show_sender_header = last_sender.as_ref() != Some(&current_sender);
if show_sender_header {
// Пустая строка между группами сообщений (кроме первой)
if last_sender.is_some() {
lines.push(Line::from(""));
}
let sender_style = if msg.is_outgoing {
Style::default().fg(Color::Green).add_modifier(Modifier::BOLD)
} else {
Style::default().fg(Color::Cyan).add_modifier(Modifier::BOLD)
};
if msg.is_outgoing {
// Заголовок "Вы" справа
let header_text = format!("{} ────────────────", sender_name);
let header_len = header_text.chars().count();
let padding = content_width.saturating_sub(header_len + 1);
lines.push(Line::from(vec![
Span::raw(" ".repeat(padding)),
Span::styled(format!("{} ", sender_name), sender_style),
Span::styled("────────────────", Style::default().fg(Color::Gray)),
]));
} else {
// Заголовок входящих слева
lines.push(Line::from(vec![
Span::styled(format!("{} ", sender_name), sender_style),
Span::styled("────────────────", Style::default().fg(Color::Gray)),
]));
}
last_sender = Some(current_sender);
}
// Форматируем время (HH:MM)
let time = format_timestamp(msg.date);
// Цвет сообщения (жёлтый если выбрано)
let msg_color = if is_selected {
Color::Yellow
} else if msg.is_outgoing {
Color::Green
} else {
Color::White
};
// Маркер выбора
let selection_marker = if is_selected { "" } else { "" };
let marker_len = selection_marker.chars().count();
// Отображаем forward если есть
if let Some(forward) = &msg.forward_from {
let forward_line = format!("↪ Переслано от {}", forward.sender_name);
let forward_len = forward_line.chars().count();
if msg.is_outgoing {
// Forward справа для исходящих
let padding = content_width.saturating_sub(forward_len + 1);
lines.push(Line::from(vec![
Span::raw(" ".repeat(padding)),
Span::styled(forward_line, Style::default().fg(Color::Magenta)),
]));
} else {
// Forward слева для входящих
lines.push(Line::from(vec![
Span::styled(forward_line, Style::default().fg(Color::Magenta)),
]));
}
}
// Отображаем reply если есть
if let Some(reply) = &msg.reply_to {
let reply_text: String = reply.text.chars().take(40).collect();
let ellipsis = if reply.text.chars().count() > 40 { "..." } else { "" };
let reply_line = format!("{}: {}{}", reply.sender_name, reply_text, ellipsis);
let reply_len = reply_line.chars().count();
if msg.is_outgoing {
// Reply справа для исходящих
let padding = content_width.saturating_sub(reply_len + 1);
lines.push(Line::from(vec![
Span::raw(" ".repeat(padding)),
Span::styled(reply_line, Style::default().fg(Color::Cyan)),
]));
} else {
// Reply слева для входящих
lines.push(Line::from(vec![
Span::styled(reply_line, Style::default().fg(Color::Cyan)),
]));
}
}
if msg.is_outgoing {
// Исходящие: справа, формат "текст (HH:MM ✎ ✓✓)"
let read_mark = if msg.is_read { "✓✓" } else { "" };
let edit_mark = if msg.edit_date > 0 { "" } else { "" };
let time_mark = format!("({} {}{})", time, edit_mark, read_mark);
let time_mark_len = time_mark.chars().count() + 1; // +1 для пробела
// Максимальная ширина для текста сообщения (оставляем место для time_mark и маркера)
let max_msg_width = content_width.saturating_sub(time_mark_len + marker_len + 2);
let wrapped_lines = wrap_text_with_offsets(&msg.content, max_msg_width);
let total_wrapped = wrapped_lines.len();
for (i, wrapped) in wrapped_lines.into_iter().enumerate() {
let is_last_line = i == total_wrapped - 1;
let line_len = wrapped.text.chars().count();
// Получаем entities для этой строки
let line_entities = adjust_entities_for_substring(
&msg.entities,
wrapped.start_offset,
line_len,
);
// Форматируем текст с entities
let formatted_spans = format_text_with_entities(
&wrapped.text,
&line_entities,
msg_color,
);
if is_last_line {
// Последняя строка — добавляем time_mark
let full_len = line_len + time_mark_len + marker_len;
let padding = content_width.saturating_sub(full_len + 1);
let mut line_spans = vec![Span::raw(" ".repeat(padding))];
if is_selected {
line_spans.push(Span::styled(selection_marker, Style::default().fg(Color::Yellow).add_modifier(Modifier::BOLD)));
}
line_spans.extend(formatted_spans);
line_spans.push(Span::styled(format!(" {}", time_mark), Style::default().fg(Color::Gray)));
lines.push(Line::from(line_spans));
} else {
// Промежуточные строки — просто текст справа
let padding = content_width.saturating_sub(line_len + marker_len + 1);
let mut line_spans = vec![Span::raw(" ".repeat(padding))];
if i == 0 && is_selected {
line_spans.push(Span::styled(selection_marker, Style::default().fg(Color::Yellow).add_modifier(Modifier::BOLD)));
}
line_spans.extend(formatted_spans);
lines.push(Line::from(line_spans));
}
}
} else {
// Входящие: слева, формат "(HH:MM ✎) текст"
let edit_mark = if msg.edit_date > 0 { "" } else { "" };
let time_str = format!("({}{})", time, edit_mark);
let time_prefix_len = time_str.chars().count() + 2; // " (HH:MM) "
// Максимальная ширина для текста
let max_msg_width = content_width.saturating_sub(time_prefix_len + 1);
let wrapped_lines = wrap_text_with_offsets(&msg.content, max_msg_width);
for (i, wrapped) in wrapped_lines.into_iter().enumerate() {
let line_len = wrapped.text.chars().count();
// Получаем entities для этой строки
let line_entities = adjust_entities_for_substring(
&msg.entities,
wrapped.start_offset,
line_len,
);
// Форматируем текст с entities
let formatted_spans = format_text_with_entities(
&wrapped.text,
&line_entities,
msg_color,
);
if i == 0 {
// Первая строка — с временем и маркером выбора
let mut line_spans = vec![];
if is_selected {
line_spans.push(Span::styled(selection_marker, Style::default().fg(Color::Yellow).add_modifier(Modifier::BOLD)));
}
line_spans.push(Span::styled(format!(" {}", time_str), Style::default().fg(Color::Gray)));
line_spans.push(Span::raw(" "));
line_spans.extend(formatted_spans);
lines.push(Line::from(line_spans));
} else {
// Последующие строки — с отступом
let indent = " ".repeat(time_prefix_len + marker_len);
let mut line_spans = vec![Span::raw(indent)];
line_spans.extend(formatted_spans);
lines.push(Line::from(line_spans));
}
}
}
}
if lines.is_empty() {
lines.push(Line::from(Span::styled(
"Нет сообщений",
Style::default().fg(Color::Gray),
)));
}
// Вычисляем скролл с учётом пользовательского offset
let visible_height = message_chunks[2].height.saturating_sub(2) as usize;
let total_lines = lines.len();
// Базовый скролл (показываем последние сообщения)
let base_scroll = if total_lines > visible_height {
total_lines - visible_height
} else {
0
};
// Если выбрано сообщение, автоскроллим к нему
let scroll_offset = if app.is_selecting_message() {
if let Some(selected_line) = selected_msg_line {
// Вычисляем нужный скролл, чтобы выбранное сообщение было видно
if selected_line < visible_height / 2 {
// Сообщение в начале — скроллим к началу
0
} else if selected_line > total_lines.saturating_sub(visible_height / 2) {
// Сообщение в конце — скроллим к концу
base_scroll
} else {
// Центрируем выбранное сообщение
selected_line.saturating_sub(visible_height / 2)
}
} else {
base_scroll.saturating_sub(app.message_scroll_offset)
}
} else {
base_scroll.saturating_sub(app.message_scroll_offset)
} as u16;
let messages_widget = Paragraph::new(lines)
.block(Block::default().borders(Borders::ALL))
.scroll((scroll_offset, 0));
f.render_widget(messages_widget, message_chunks[2]);
// Input box с wrap для длинного текста и блочным курсором
let (input_line, input_title) = if app.is_forwarding() {
// Режим пересылки - показываем превью сообщения
let forward_preview = app.get_forwarding_message()
.map(|m| {
let text_preview: String = m.content.chars().take(40).collect();
let ellipsis = if m.content.chars().count() > 40 { "..." } else { "" };
format!("{}{}", text_preview, ellipsis)
})
.unwrap_or_else(|| "↪ ...".to_string());
let line = Line::from(Span::styled(forward_preview, Style::default().fg(Color::Cyan)));
(line, " Выберите чат ← ")
} else if app.is_selecting_message() {
// Режим выбора сообщения - подсказка зависит от возможностей
let selected_msg = app.get_selected_message();
let can_edit = selected_msg.map(|m| m.can_be_edited && m.is_outgoing).unwrap_or(false);
let can_delete = selected_msg.map(|m| m.can_be_deleted_only_for_self || m.can_be_deleted_for_all_users).unwrap_or(false);
let hint = match (can_edit, can_delete) {
(true, true) => "↑↓ · Enter ред. · r ответ · f перслть · d удал. · Esc",
(true, false) => "↑↓ · Enter ред. · r ответ · f переслть · Esc",
(false, true) => "↑↓ · r ответ · f переслать · d удалить · Esc",
(false, false) => "↑↓ · r ответить · f переслать · Esc",
};
(Line::from(Span::styled(hint, Style::default().fg(Color::Cyan))), " Выбор сообщения ")
} else if app.is_editing() {
// Режим редактирования
if app.message_input.is_empty() {
// Пустой инпут - показываем курсор и placeholder
let line = Line::from(vec![
Span::raw(""),
Span::styled("", Style::default().fg(Color::Magenta)),
Span::styled(" Введите новый текст...", Style::default().fg(Color::Gray)),
]);
(line, " Редактирование (Esc отмена) ")
} else {
// Текст с курсором
let line = render_input_with_cursor("", &app.message_input, app.cursor_position, Color::Magenta);
(line, " Редактирование (Esc отмена) ")
}
} else if app.is_replying() {
// Режим ответа на сообщение
let reply_preview = app.get_replying_to_message()
.map(|m| {
let sender = if m.is_outgoing { "Вы" } else { &m.sender_name };
let text_preview: String = m.content.chars().take(30).collect();
let ellipsis = if m.content.chars().count() > 30 { "..." } else { "" };
format!("{}: {}{}", sender, text_preview, ellipsis)
})
.unwrap_or_else(|| "...".to_string());
if app.message_input.is_empty() {
let line = Line::from(vec![
Span::styled("", Style::default().fg(Color::Cyan)),
Span::styled(reply_preview, Style::default().fg(Color::Gray)),
Span::raw(" "),
Span::styled("", Style::default().fg(Color::Yellow)),
]);
(line, " Ответ (Esc отмена) ")
} else {
let short_preview: String = reply_preview.chars().take(15).collect();
let prefix = format!("{} > ", short_preview);
let line = render_input_with_cursor(&prefix, &app.message_input, app.cursor_position, Color::Yellow);
(line, " Ответ (Esc отмена) ")
}
} else {
// Обычный режим
if app.message_input.is_empty() {
// Пустой инпут - показываем курсор и placeholder
let line = Line::from(vec![
Span::raw("> "),
Span::styled("", Style::default().fg(Color::Yellow)),
Span::styled(" Введите сообщение...", Style::default().fg(Color::Gray)),
]);
(line, "")
} else {
// Текст с курсором
let line = render_input_with_cursor("> ", &app.message_input, app.cursor_position, Color::Yellow);
(line, "")
}
};
let input_block = if input_title.is_empty() {
Block::default().borders(Borders::ALL)
} else {
let title_color = if app.is_replying() || app.is_forwarding() {
Color::Cyan
} else {
Color::Magenta
};
Block::default()
.borders(Borders::ALL)
.title(input_title)
.title_style(Style::default().fg(title_color).add_modifier(Modifier::BOLD))
};
let input = Paragraph::new(input_line)
.block(input_block)
.wrap(ratatui::widgets::Wrap { trim: false });
f.render_widget(input, message_chunks[3]);
} else {
let empty = Paragraph::new("Выберите чат")
.block(Block::default().borders(Borders::ALL))
.style(Style::default().fg(Color::Gray))
.alignment(Alignment::Center);
f.render_widget(empty, area);
}
// Модалка подтверждения удаления
if app.is_confirm_delete_shown() {
render_delete_confirm_modal(f, area);
}
}
/// Рендерит режим поиска по сообщениям
fn render_search_mode(f: &mut Frame, area: Rect, app: &App) {
let chunks = Layout::default()
.direction(Direction::Vertical)
.constraints([
Constraint::Length(3), // Search input
Constraint::Min(0), // Search results
Constraint::Length(3), // Help bar
])
.split(area);
// Search input
let total = app.message_search_results.len();
let current = if total > 0 { app.selected_search_result_index + 1 } else { 0 };
let input_line = if app.message_search_query.is_empty() {
Line::from(vec![
Span::styled("🔍 ", Style::default().fg(Color::Yellow)),
Span::styled("", Style::default().fg(Color::Yellow)),
Span::styled(" Введите текст для поиска...", Style::default().fg(Color::Gray)),
])
} else {
Line::from(vec![
Span::styled("🔍 ", Style::default().fg(Color::Yellow)),
Span::styled(&app.message_search_query, Style::default().fg(Color::White)),
Span::styled("", Style::default().fg(Color::Yellow)),
Span::styled(format!(" ({}/{})", current, total), Style::default().fg(Color::Gray)),
])
};
let search_input = Paragraph::new(input_line)
.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))
);
f.render_widget(search_input, chunks[0]);
// Search results
let content_width = chunks[1].width.saturating_sub(2) as usize;
let mut lines: Vec<Line> = Vec::new();
if app.message_search_results.is_empty() {
if !app.message_search_query.is_empty() {
lines.push(Line::from(Span::styled(
"Ничего не найдено",
Style::default().fg(Color::Gray),
)));
}
} else {
for (idx, msg) in app.message_search_results.iter().enumerate() {
let is_selected = idx == app.selected_search_result_index;
// Пустая строка между результатами
if idx > 0 {
lines.push(Line::from(""));
}
// Маркер выбора, имя и дата
let marker = if is_selected { "" } else { " " };
let sender_color = if msg.is_outgoing { Color::Green } else { Color::Cyan };
let sender_name = if msg.is_outgoing { "Вы".to_string() } else { msg.sender_name.clone() };
lines.push(Line::from(vec![
Span::styled(marker, Style::default().fg(Color::Yellow).add_modifier(Modifier::BOLD)),
Span::styled(
format!("{} ", sender_name),
Style::default().fg(sender_color).add_modifier(Modifier::BOLD),
),
Span::styled(
format!("({})", crate::utils::format_datetime(msg.date)),
Style::default().fg(Color::Gray),
),
]));
// Текст сообщения (с переносом)
let msg_color = if is_selected { Color::Yellow } else { Color::White };
let max_width = content_width.saturating_sub(4);
let wrapped = wrap_text_with_offsets(&msg.content, max_width);
let wrapped_count = wrapped.len();
for wrapped_line in wrapped.into_iter().take(2) {
lines.push(Line::from(vec![
Span::raw(" "),
Span::styled(wrapped_line.text, Style::default().fg(msg_color)),
]));
}
if wrapped_count > 2 {
lines.push(Line::from(vec![
Span::raw(" "),
Span::styled("...", Style::default().fg(Color::Gray)),
]));
}
}
}
// Скролл к выбранному результату
let visible_height = chunks[1].height.saturating_sub(2) as usize;
let lines_per_result = 4;
let selected_line = app.selected_search_result_index * lines_per_result;
let scroll_offset = if selected_line > visible_height / 2 {
(selected_line - visible_height / 2) as u16
} else {
0
};
let results_widget = Paragraph::new(lines)
.block(
Block::default()
.borders(Borders::ALL)
.border_style(Style::default().fg(Color::Yellow))
)
.scroll((scroll_offset, 0));
f.render_widget(results_widget, chunks[1]);
// Help bar
let help_line = Line::from(vec![
Span::styled(" ↑↓ ", Style::default().fg(Color::Yellow).add_modifier(Modifier::BOLD)),
Span::raw("навигация"),
Span::raw(" "),
Span::styled(" n/N ", Style::default().fg(Color::Yellow).add_modifier(Modifier::BOLD)),
Span::raw("след./пред."),
Span::raw(" "),
Span::styled(" Enter ", Style::default().fg(Color::Green).add_modifier(Modifier::BOLD)),
Span::raw("перейти"),
Span::raw(" "),
Span::styled(" Esc ", Style::default().fg(Color::Red).add_modifier(Modifier::BOLD)),
Span::raw("выход"),
]);
let help = Paragraph::new(help_line)
.block(
Block::default()
.borders(Borders::ALL)
.border_style(Style::default().fg(Color::Yellow))
)
.alignment(Alignment::Center);
f.render_widget(help, chunks[2]);
}
/// Рендерит режим просмотра закреплённых сообщений
fn render_pinned_mode(f: &mut Frame, area: Rect, app: &App) {
let chunks = Layout::default()
.direction(Direction::Vertical)
.constraints([
Constraint::Length(3), // Header
Constraint::Min(0), // Pinned messages list
Constraint::Length(3), // Help bar
])
.split(area);
// Header
let total = app.pinned_messages.len();
let current = app.selected_pinned_index + 1;
let header_text = format!("📌 ЗАКРЕПЛЁННЫЕ ({}/{})", current, total);
let header = Paragraph::new(header_text)
.block(
Block::default()
.borders(Borders::ALL)
.border_style(Style::default().fg(Color::Magenta))
)
.style(Style::default().fg(Color::Magenta).add_modifier(Modifier::BOLD));
f.render_widget(header, chunks[0]);
// Pinned messages list
let content_width = chunks[1].width.saturating_sub(2) as usize;
let mut lines: Vec<Line> = Vec::new();
for (idx, msg) in app.pinned_messages.iter().enumerate() {
let is_selected = idx == app.selected_pinned_index;
// Пустая строка между сообщениями
if idx > 0 {
lines.push(Line::from(""));
}
// Маркер выбора и имя отправителя
let marker = if is_selected { "" } else { " " };
let sender_color = if msg.is_outgoing { Color::Green } else { Color::Cyan };
let sender_name = if msg.is_outgoing { "Вы".to_string() } else { msg.sender_name.clone() };
lines.push(Line::from(vec![
Span::styled(marker, Style::default().fg(Color::Yellow).add_modifier(Modifier::BOLD)),
Span::styled(
format!("{} ", sender_name),
Style::default().fg(sender_color).add_modifier(Modifier::BOLD),
),
Span::styled(
format!("({})", crate::utils::format_datetime(msg.date)),
Style::default().fg(Color::Gray),
),
]));
// Текст сообщения (с переносом)
let msg_color = if is_selected { Color::Yellow } else { Color::White };
let max_width = content_width.saturating_sub(4);
let wrapped = wrap_text_with_offsets(&msg.content, max_width);
let wrapped_count = wrapped.len();
for wrapped_line in wrapped.into_iter().take(3) { // Максимум 3 строки на сообщение
lines.push(Line::from(vec![
Span::raw(" "), // Отступ
Span::styled(wrapped_line.text, Style::default().fg(msg_color)),
]));
}
if wrapped_count > 3 {
lines.push(Line::from(vec![
Span::raw(" "),
Span::styled("...", Style::default().fg(Color::Gray)),
]));
}
}
if lines.is_empty() {
lines.push(Line::from(Span::styled(
"Нет закреплённых сообщений",
Style::default().fg(Color::Gray),
)));
}
// Скролл к выбранному сообщению
let visible_height = chunks[1].height.saturating_sub(2) as usize;
let lines_per_msg = 5; // Примерно строк на сообщение
let selected_line = app.selected_pinned_index * lines_per_msg;
let scroll_offset = if selected_line > visible_height / 2 {
(selected_line - visible_height / 2) as u16
} else {
0
};
let messages_widget = Paragraph::new(lines)
.block(
Block::default()
.borders(Borders::ALL)
.border_style(Style::default().fg(Color::Magenta))
)
.scroll((scroll_offset, 0));
f.render_widget(messages_widget, chunks[1]);
// Help bar
let help_line = Line::from(vec![
Span::styled(" ↑↓ ", Style::default().fg(Color::Yellow).add_modifier(Modifier::BOLD)),
Span::raw("навигация"),
Span::raw(" "),
Span::styled(" Enter ", Style::default().fg(Color::Green).add_modifier(Modifier::BOLD)),
Span::raw("перейти"),
Span::raw(" "),
Span::styled(" Esc ", Style::default().fg(Color::Red).add_modifier(Modifier::BOLD)),
Span::raw("выход"),
]);
let help = Paragraph::new(help_line)
.block(
Block::default()
.borders(Borders::ALL)
.border_style(Style::default().fg(Color::Magenta))
)
.alignment(Alignment::Center);
f.render_widget(help, chunks[2]);
}
/// Рендерит модалку подтверждения удаления
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);
}