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> { if entities.is_empty() { return vec![Span::styled(text.to_string(), Style::default().fg(base_color))]; } // Создаём массив стилей для каждого символа let chars: Vec = text.chars().collect(); let mut char_styles: Vec = 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> = Vec::new(); let mut current_text = String::new(); let mut current_style: Option = 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 = text.chars().collect(); let mut spans: Vec = 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 { 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 = 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 { 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 = Vec::new(); let mut last_day: Option = 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 = 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 = 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 = 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); }