//! Chat message area rendering. //! //! Renders message bubbles grouped by date/sender, pinned bar, and delegates //! to modals (search, pinned, reactions, delete) and compose_bar. use crate::app::methods::{messages::MessageMethods, modal::ModalMethods, search::SearchMethods}; use crate::app::App; use crate::message_grouping::{group_messages, MessageGroup}; use crate::tdlib::TdClientTrait; use crate::ui::components; use crate::ui::{compose_bar, modals}; use ratatui::{ layout::{Alignment, Constraint, Direction, Layout, Rect}, style::{Color, Modifier, Style}, text::{Line, Span}, widgets::{Block, Borders, Paragraph}, Frame, }; /// Рендерит заголовок чата с typing status fn render_chat_header( f: &mut Frame, area: Rect, app: &App, chat: &crate::tdlib::ChatInfo, ) { 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, area); } /// Рендерит pinned bar с закреплённым сообщением fn render_pinned_bar(f: &mut Frame, area: Rect, app: &App) { let Some(pinned_msg) = app.td_client.current_pinned_message() else { return; }; let pinned_preview: String = pinned_msg.text().chars().take(40).collect(); let ellipsis = if pinned_msg.text().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 = area.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, area); } /// Информация о строке после переноса: текст и позиция в оригинале pub(super) struct WrappedLine { pub text: String, } /// Разбивает текст на строки с учётом максимальной ширины /// (используется только для search/pinned режимов, основной рендеринг через message_bubble) pub(super) fn wrap_text_with_offsets(text: &str, max_width: usize) -> Vec { if max_width == 0 { return vec![WrappedLine { text: text.to_string() }]; } let mut result = Vec::new(); let mut current_line = String::new(); let mut current_width = 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; } 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 }); current_line = word; current_width = word_width; } 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; } else if current_width + 1 + word_width <= max_width { current_line.push(' '); current_line.push_str(&word); } else { result.push(WrappedLine { text: current_line }); current_line = word; } } if !current_line.is_empty() { result.push(WrappedLine { text: current_line }); } if result.is_empty() { result.push(WrappedLine { text: String::new() }); } result } /// Рендерит список сообщений с группировкой по дате/отправителю и автоскроллом fn render_message_list(f: &mut Frame, area: Rect, app: &mut App) { let content_width = area.width.saturating_sub(2) as usize; // Messages с группировкой по дате и отправителю let mut lines: Vec = Vec::new(); // ID выбранного сообщения для подсветки let selected_msg_id = app.get_selected_message().map(|m| m.id()); // Номер строки, где начинается выбранное сообщение (для автоскролла) let mut selected_msg_line: Option = None; // ОПТИМИЗАЦИЯ: Убрали массовый preloading всех изображений. // Теперь загружаем только видимые изображения во втором проходе (см. ниже). // Собираем информацию о развёрнутых изображениях (для второго прохода) #[cfg(feature = "images")] let mut deferred_images: Vec = Vec::new(); // Используем message_grouping для группировки сообщений let grouped = group_messages(&app.td_client.current_chat_messages()); let mut is_first_date = true; let mut is_first_sender = true; for group in grouped { match group { MessageGroup::DateSeparator(date) => { // Рендерим разделитель даты lines.extend(components::render_date_separator(date, content_width, is_first_date)); is_first_date = false; is_first_sender = true; // Сбрасываем счётчик заголовков после даты } MessageGroup::SenderHeader { is_outgoing, sender_name } => { // Рендерим заголовок отправителя lines.extend(components::render_sender_header( is_outgoing, &sender_name, content_width, is_first_sender, )); is_first_sender = false; } MessageGroup::Message(msg) => { // Запоминаем строку начала выбранного сообщения let is_selected = selected_msg_id == Some(msg.id()); if is_selected { selected_msg_line = Some(lines.len()); } // Рендерим сообщение let bubble_lines = components::render_message_bubble( &msg, app.config(), content_width, selected_msg_id, app.playback_state.as_ref(), ); // Собираем deferred image renders для всех загруженных фото #[cfg(feature = "images")] if let Some(photo) = msg.photo_info() { if let crate::tdlib::PhotoDownloadState::Downloaded(path) = &photo.download_state { let inline_width = content_width.min(crate::constants::INLINE_IMAGE_MAX_WIDTH); let img_height = components::calculate_image_height( photo.width, photo.height, inline_width, ); let img_width = inline_width as u16; let bubble_len = bubble_lines.len(); let placeholder_start = lines.len() + bubble_len - img_height as usize; deferred_images.push(components::DeferredImageRender { message_id: msg.id(), photo_path: path.clone(), line_offset: placeholder_start, x_offset: 0, width: img_width, height: img_height, }); } } lines.extend(bubble_lines); } MessageGroup::Album(album_messages) => { #[cfg(feature = "images")] { let is_selected = album_messages .iter() .any(|m| selected_msg_id == Some(m.id())); if is_selected { selected_msg_line = Some(lines.len()); } let (bubble_lines, album_deferred) = components::render_album_bubble( &album_messages, app.config(), content_width, selected_msg_id, ); for mut d in album_deferred { d.line_offset += lines.len(); deferred_images.push(d); } lines.extend(bubble_lines); } #[cfg(not(feature = "images"))] { // Fallback: рендерим каждое сообщение отдельно for msg in &album_messages { let is_selected = selected_msg_id == Some(msg.id()); if is_selected { selected_msg_line = Some(lines.len()); } lines.extend(components::render_message_bubble( msg, app.config(), content_width, selected_msg_id, app.playback_state.as_ref(), )); } } } } } if lines.is_empty() { lines.push(Line::from(Span::styled("Нет сообщений", Style::default().fg(Color::Gray)))); } // Вычисляем скролл с учётом пользовательского offset let visible_height = area.height.saturating_sub(2) as usize; let total_lines = lines.len(); // Базовый скролл (показываем последние сообщения) let base_scroll = total_lines.saturating_sub(visible_height); // Если выбрано сообщение, автоскроллим к нему 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, area); // Второй проход: рендерим изображения поверх placeholder-ов #[cfg(feature = "images")] { use ratatui_image::StatefulImage; // THROTTLING: Рендерим изображения максимум 15 FPS (каждые 66ms) let should_render_images = app .last_image_render_time .map(|t| t.elapsed() > std::time::Duration::from_millis(66)) .unwrap_or(true); if !deferred_images.is_empty() && should_render_images { let content_x = area.x + 1; let content_y = area.y + 1; for d in &deferred_images { let y_in_content = d.line_offset as i32 - scroll_offset as i32; // Пропускаем изображения, которые полностью за пределами видимости if y_in_content < 0 || y_in_content as usize >= visible_height { continue; } let img_y = content_y + y_in_content as u16; let remaining_height = (content_y + visible_height as u16).saturating_sub(img_y); // ВАЖНО: Не рендерим частично видимые изображения (убирает сжатие и мигание) if d.height > remaining_height { continue; } // Рендерим с ПОЛНОЙ высотой (не сжимаем) let img_rect = Rect::new(content_x + d.x_offset, img_y, d.width, d.height); // ОПТИМИЗАЦИЯ: Загружаем только видимые изображения (не все сразу) // Используем inline_renderer с Halfblocks для скорости if let Some(renderer) = &mut app.inline_image_renderer { // Загружаем только если видимо (early return если уже в кеше) let _ = renderer.load_image(d.message_id, &d.photo_path); if let Some(protocol) = renderer.get_protocol(&d.message_id) { f.render_stateful_widget(StatefulImage::default(), img_rect, protocol); } } } // Обновляем время последнего рендеринга (для throttling) app.last_image_render_time = Some(std::time::Instant::now()); } } } pub fn render(f: &mut Frame, area: Rect, app: &mut App) { // Модальное окно просмотра изображения (приоритет выше всех) #[cfg(feature = "images")] if let Some(modal_state) = app.image_modal.clone() { modals::render_image_viewer(f, app, &modal_state); return; } // Режим профиля if app.is_profile_mode() { if let Some(profile) = app.get_profile_info() { crate::ui::profile::render(f, area, app, profile); } return; } // Режим поиска по сообщениям if app.is_message_search_mode() { modals::render_search(f, area, app); return; } // Режим просмотра закреплённых сообщений if app.is_pinned_mode() { modals::render_pinned(f, area, app); return; } if let Some(chat) = app.get_selected_chat().cloned() { // Вычисляем динамическую высоту инпута на основе длины текста let input_width = area.width.saturating_sub(4) as usize; // -2 для рамок, -2 для "> " let input_lines: u16 = if input_width > 0 { let len = app.message_input.chars().count() + 2; // +2 для "> " ((len as f32 / input_width as f32).ceil() as u16).max(1) } else { 1 }; // Минимум 3 строки (1 контент + 2 рамки), максимум 10 let input_height = (input_lines + 2).clamp(3, 10); // Проверяем, есть ли закреплённое сообщение 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 render_chat_header(f, message_chunks[0], app, &chat); // Pinned bar (если есть закреплённое сообщение) render_pinned_bar(f, message_chunks[1], app); // Messages с группировкой по дате и отправителю render_message_list(f, message_chunks[2], app); // Input box с wrap для длинного текста и блочным курсором compose_bar::render(f, message_chunks[3], app); } 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() { modals::render_delete_confirm(f, area); } // Модалка выбора реакции if let crate::app::ChatState::ReactionPicker { available_reactions, selected_index, .. } = &app.chat_state { modals::render_reaction_picker(f, area, available_reactions, *selected_index); } }