1128 lines
48 KiB
Rust
1128 lines
48 KiB
Rust
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 ¤t_style {
|
||
Some(prev_style) if styles_equal(prev_style, style) => {
|
||
current_text.push(*ch);
|
||
}
|
||
_ => {
|
||
if !current_text.is_empty() {
|
||
if let Some(prev_style) = ¤t_style {
|
||
spans.push(Span::styled(
|
||
current_text.clone(),
|
||
prev_style.to_style(base_color),
|
||
));
|
||
}
|
||
}
|
||
current_text = ch.to_string();
|
||
current_style = Some(style.clone());
|
||
}
|
||
}
|
||
}
|
||
|
||
// Добавляем последний span
|
||
if !current_text.is_empty() {
|
||
if let Some(style) = current_style {
|
||
spans.push(Span::styled(current_text, style.to_style(base_color)));
|
||
}
|
||
}
|
||
|
||
if spans.is_empty() {
|
||
spans.push(Span::styled(text.to_string(), Style::default().fg(base_color)));
|
||
}
|
||
|
||
spans
|
||
}
|
||
|
||
/// Проверяет равенство двух стилей
|
||
fn styles_equal(a: &CharStyle, b: &CharStyle) -> bool {
|
||
a.bold == b.bold
|
||
&& a.italic == b.italic
|
||
&& a.underline == b.underline
|
||
&& a.strikethrough == b.strikethrough
|
||
&& a.code == b.code
|
||
&& a.spoiler == b.spoiler
|
||
&& a.url == b.url
|
||
&& a.mention == b.mention
|
||
}
|
||
|
||
/// Рендерит текст инпута с блочным курсором
|
||
fn render_input_with_cursor(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(¤t_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);
|
||
}
|