1459 lines
57 KiB
Rust
1459 lines
57 KiB
Rust
use crate::app::App;
|
||
use crate::utils::{format_date, format_timestamp_with_tz, get_day};
|
||
use ratatui::{
|
||
layout::{Alignment, Constraint, Direction, Layout, Rect},
|
||
style::{Color, Modifier, Style},
|
||
text::{Line, Span},
|
||
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 ¤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())];
|
||
|
||
// Ограничиваем 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)
|
||
}
|
||
|
||
/// Информация о строке после переноса: текст и позиция в оригинале
|
||
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_profile_mode() {
|
||
if let Some(profile) = &app.profile_info {
|
||
crate::ui::profile::render(f, area, app, profile);
|
||
}
|
||
return;
|
||
}
|
||
|
||
// Режим поиска по сообщениям
|
||
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) с учётом timezone из config
|
||
let time = format_timestamp_with_tz(msg.date, &app.config.general.timezone);
|
||
|
||
// Цвет сообщения (из config или жёлтый если выбрано)
|
||
let msg_color = if is_selected {
|
||
app.config.parse_color(&app.config.colors.selected_message)
|
||
} else if msg.is_outgoing {
|
||
app.config.parse_color(&app.config.colors.outgoing_message)
|
||
} else {
|
||
app.config.parse_color(&app.config.colors.incoming_message)
|
||
};
|
||
|
||
// Маркер выбора
|
||
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 !msg.reactions.is_empty() {
|
||
let mut reaction_spans = vec![];
|
||
|
||
for reaction in &msg.reactions {
|
||
if !reaction_spans.is_empty() {
|
||
reaction_spans.push(Span::raw(" "));
|
||
}
|
||
|
||
// Свои реакции в рамках [emoji], чужие просто emoji
|
||
let reaction_text = if reaction.is_chosen {
|
||
if reaction.count > 1 {
|
||
format!("[{}] {}", reaction.emoji, reaction.count)
|
||
} else {
|
||
format!("[{}]", reaction.emoji)
|
||
}
|
||
} else {
|
||
if reaction.count > 1 {
|
||
format!("{} {}", reaction.emoji, reaction.count)
|
||
} else {
|
||
reaction.emoji.clone()
|
||
}
|
||
};
|
||
|
||
let style = if reaction.is_chosen {
|
||
Style::default()
|
||
.fg(app.config.parse_color(&app.config.colors.reaction_chosen))
|
||
} else {
|
||
Style::default()
|
||
.fg(app.config.parse_color(&app.config.colors.reaction_other))
|
||
};
|
||
|
||
reaction_spans.push(Span::styled(reaction_text, style));
|
||
}
|
||
|
||
// Выравниваем реакции в зависимости от типа сообщения
|
||
if msg.is_outgoing {
|
||
// Реакции справа для исходящих
|
||
let reactions_text: String = reaction_spans
|
||
.iter()
|
||
.map(|s| s.content.as_ref())
|
||
.collect::<Vec<_>>()
|
||
.join(" ");
|
||
let reactions_len = reactions_text.chars().count();
|
||
let padding = content_width.saturating_sub(reactions_len + 1);
|
||
let mut line_spans = vec![Span::raw(" ".repeat(padding))];
|
||
line_spans.extend(reaction_spans);
|
||
lines.push(Line::from(line_spans));
|
||
} else {
|
||
// Реакции слева для входящих
|
||
lines.push(Line::from(reaction_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 перслть · y копир. · d удал. · Esc",
|
||
(true, false) => "↑↓ · Enter ред. · r ответ · f переслть · y копир. · Esc",
|
||
(false, true) => "↑↓ · r ответ · f переслать · y копир. · d удалить · Esc",
|
||
(false, false) => "↑↓ · r ответить · f переслать · y копировать · 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);
|
||
}
|
||
|
||
// Модалка выбора реакции
|
||
if app.is_reaction_picker_mode() {
|
||
render_reaction_picker_modal(
|
||
f,
|
||
area,
|
||
&app.available_reactions,
|
||
app.selected_reaction_index,
|
||
);
|
||
}
|
||
}
|
||
|
||
/// Рендерит режим поиска по сообщениям
|
||
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);
|
||
}
|
||
|
||
/// Рендерит модалку выбора реакции
|
||
fn render_reaction_picker_modal(
|
||
f: &mut Frame,
|
||
area: Rect,
|
||
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);
|
||
}
|