//! Message bubble component //! //! Отвечает за рендеринг отдельных элементов списка сообщений: //! - Разделители дат //! - Заголовки отправителей //! - Сами сообщения (с forward, reply, reactions) use crate::config::Config; use crate::formatting; use crate::tdlib::{MessageInfo, PlaybackState, PlaybackStatus}; #[cfg(feature = "images")] use crate::tdlib::PhotoDownloadState; use crate::types::MessageId; use crate::utils::{format_date, format_timestamp_with_tz}; use ratatui::{ style::{Color, Modifier, Style}, text::{Line, Span}, }; /// Информация о строке после переноса: текст и позиция в оригинале struct WrappedLine { text: String, /// Начальная позиция в символах от начала оригинального текста start_offset: usize, } /// Разбивает текст на строки с учётом максимальной ширины и `\n` fn wrap_text_with_offsets(text: &str, max_width: usize) -> Vec { let mut all_lines = Vec::new(); let mut char_offset = 0; for segment in text.split('\n') { let wrapped = wrap_paragraph(segment, max_width, char_offset); all_lines.extend(wrapped); char_offset += segment.chars().count() + 1; // +1 за '\n' } if all_lines.is_empty() { all_lines.push(WrappedLine { text: String::new(), start_offset: 0, }); } all_lines } /// Разбивает один абзац (без `\n`) на строки по ширине fn wrap_paragraph(text: &str, max_width: usize, base_offset: usize) -> Vec { if max_width == 0 { return vec![WrappedLine { text: text.to_string(), start_offset: base_offset, }]; } let mut result = Vec::new(); let mut current_line = String::new(); let mut current_width = 0; let mut line_start_offset = base_offset; 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 = base_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 = base_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 = base_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 = base_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: base_offset, }); } result } /// Рендерит разделитель даты /// /// # Аргументы /// /// * `date` - timestamp сообщения /// * `content_width` - ширина области для центрирования /// * `is_first` - первый ли это разделитель (если нет, добавляется пустая строка сверху) pub fn render_date_separator(date: i32, content_width: usize, is_first: bool) -> Vec> { let mut lines = Vec::new(); if !is_first { lines.push(Line::from("")); // Пустая строка перед разделителем } let date_str = format_date(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("")); lines } /// Рендерит заголовок отправителя /// /// # Аргументы /// /// * `is_outgoing` - исходящее ли сообщение /// * `sender_name` - имя отправителя /// * `content_width` - ширина области для выравнивания /// * `is_first` - первый ли это заголовок в группе (если нет, добавляется пустая строка сверху) pub fn render_sender_header( is_outgoing: bool, sender_name: &str, content_width: usize, is_first: bool, ) -> Vec> { let mut lines = Vec::new(); if !is_first { lines.push(Line::from("")); // Пустая строка между группами } let sender_style = if is_outgoing { Style::default() .fg(Color::Green) .add_modifier(Modifier::BOLD) } else { Style::default() .fg(Color::Cyan) .add_modifier(Modifier::BOLD) }; if 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)), ])); } lines } /// Рендерит bubble одного сообщения /// /// # Аргументы /// /// * `msg` - сообщение для рендеринга /// * `config` - конфигурация (цвета, timezone) /// * `content_width` - ширина области для рендеринга /// * `selected_msg_id` - ID выбранного сообщения (для подсветки) pub fn render_message_bubble( msg: &MessageInfo, config: &Config, content_width: usize, selected_msg_id: Option, playback_state: Option<&PlaybackState>, ) -> Vec> { let mut lines = Vec::new(); let is_selected = selected_msg_id == Some(msg.id()); // Маркер выбора let selection_marker = if is_selected { "▶ " } else { "" }; let marker_len = selection_marker.chars().count(); // Цвет сообщения let msg_color = if is_selected { config.parse_color(&config.colors.selected_message) } else if msg.is_outgoing() { config.parse_color(&config.colors.outgoing_message) } else { config.parse_color(&config.colors.incoming_message) }; // Отображаем 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() { 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 { 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() { 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 { lines.push(Line::from(vec![Span::styled( reply_line, Style::default().fg(Color::Cyan), )])); } } // Форматируем время let time = format_timestamp_with_tz(msg.date(), &config.general.timezone); if msg.is_outgoing() { // Исходящие: справа, формат "текст (HH:MM ✎ ✓✓)" let read_mark = if msg.is_read() { "✓✓" } else { "✓" }; let edit_mark = if msg.is_edited() { "✎ " } else { "" }; let time_mark = format!("({} {}{})", time, edit_mark, read_mark); let time_mark_len = time_mark.chars().count() + 1; let max_msg_width = content_width.saturating_sub(time_mark_len + marker_len + 2); let wrapped_lines = wrap_text_with_offsets(msg.text(), 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(); let line_entities = formatting::adjust_entities_for_substring(msg.entities(), wrapped.start_offset, line_len); let formatted_spans = formatting::format_text_with_entities(&wrapped.text, &line_entities, msg_color); if is_last_line { 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 && i == 0 { // Одна строка — маркер на ней line_spans.push(Span::styled( selection_marker, Style::default().fg(Color::Yellow).add_modifier(Modifier::BOLD), )); } else if is_selected { // Последняя строка multi-line — пробелы вместо маркера line_spans.push(Span::raw(" ".repeat(marker_len))); } 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), )); } else if is_selected { // Средние строки multi-line — пробелы вместо маркера line_spans.push(Span::raw(" ".repeat(marker_len))); } line_spans.extend(formatted_spans); lines.push(Line::from(line_spans)); } } } else { // Входящие: слева, формат "(HH:MM ✎) текст" let edit_mark = if msg.is_edited() { " ✎" } else { "" }; let time_str = format!("({}{})", time, edit_mark); let time_prefix_len = time_str.chars().count() + 2; let max_msg_width = content_width.saturating_sub(time_prefix_len + 1); let wrapped_lines = wrap_text_with_offsets(msg.text(), max_msg_width); for (i, wrapped) in wrapped_lines.into_iter().enumerate() { let line_len = wrapped.text.chars().count(); let line_entities = formatting::adjust_entities_for_substring(msg.entities(), wrapped.start_offset, line_len); let formatted_spans = formatting::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(" ")); } 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(config.parse_color(&config.colors.reaction_chosen)) } else { Style::default().fg(config.parse_color(&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::>() .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 msg.has_voice() { if let Some(voice) = msg.voice_info() { let is_this_playing = playback_state .map(|ps| ps.message_id == msg.id()) .unwrap_or(false); let status_line = if is_this_playing { let ps = playback_state.unwrap(); let icon = match ps.status { PlaybackStatus::Playing => "▶", PlaybackStatus::Paused => "⏸", PlaybackStatus::Loading => "⏳", _ => "⏹", }; let bar = render_progress_bar(ps.position, ps.duration, 20); format!( "{} {} {:.0}s/{:.0}s", icon, bar, ps.position, ps.duration ) } else { let waveform = render_waveform(&voice.waveform, 20); format!(" {} {:.0}s", waveform, voice.duration) }; let status_len = status_line.chars().count(); if msg.is_outgoing() { let padding = content_width.saturating_sub(status_len + 1); lines.push(Line::from(vec![ Span::raw(" ".repeat(padding)), Span::styled(status_line, Style::default().fg(Color::Cyan)), ])); } else { lines.push(Line::from(Span::styled( status_line, Style::default().fg(Color::Cyan), ))); } } } // Отображаем статус фото (если есть) #[cfg(feature = "images")] if let Some(photo) = msg.photo_info() { match &photo.download_state { PhotoDownloadState::Downloading => { let status = "📷 ⏳ Загрузка..."; if msg.is_outgoing() { let padding = content_width.saturating_sub(status.chars().count() + 1); lines.push(Line::from(vec![ Span::raw(" ".repeat(padding)), Span::styled(status, Style::default().fg(Color::Yellow)), ])); } else { lines.push(Line::from(Span::styled( status, Style::default().fg(Color::Yellow), ))); } } PhotoDownloadState::Error(e) => { let status = format!("📷 [Ошибка: {}]", e); if msg.is_outgoing() { let padding = content_width.saturating_sub(status.chars().count() + 1); lines.push(Line::from(vec![ Span::raw(" ".repeat(padding)), Span::styled(status, Style::default().fg(Color::Red)), ])); } else { lines.push(Line::from(Span::styled( status, Style::default().fg(Color::Red), ))); } } PhotoDownloadState::Downloaded(_) => { // Всегда показываем inline превью для загруженных фото let inline_width = content_width.min(crate::constants::INLINE_IMAGE_MAX_WIDTH); let img_height = calculate_image_height(photo.width, photo.height, inline_width); for _ in 0..img_height { lines.push(Line::from("")); } } PhotoDownloadState::NotDownloaded => { // Для незагруженных фото ничего не рендерим, // текст сообщения уже содержит 📷 prefix } } } lines } /// Информация для отложенного рендеринга изображения поверх placeholder #[cfg(feature = "images")] pub struct DeferredImageRender { pub message_id: MessageId, /// Путь к файлу изображения pub photo_path: String, /// Смещение в строках от начала всего списка сообщений pub line_offset: usize, /// Горизонтальное смещение от левого края контента (для сетки альбомов) pub x_offset: u16, pub width: u16, pub height: u16, } /// Рендерит bubble для альбома (группы фото с общим media_album_id) /// /// Фото отображаются в сетке (до 3 в ряд), с общей подписью и timestamp. #[cfg(feature = "images")] pub fn render_album_bubble( messages: &[MessageInfo], config: &Config, content_width: usize, selected_msg_id: Option, ) -> (Vec>, Vec) { use crate::constants::{ALBUM_GRID_MAX_COLS, ALBUM_PHOTO_GAP, ALBUM_PHOTO_HEIGHT, ALBUM_PHOTO_WIDTH}; let mut lines: Vec> = Vec::new(); let mut deferred: Vec = Vec::new(); let is_selected = messages.iter().any(|m| selected_msg_id == Some(m.id())); let is_outgoing = messages.first().map_or(false, |m| m.is_outgoing()); // Selection marker let selection_marker = if is_selected { "▶ " } else { "" }; // Фильтруем фото let photos: Vec<&MessageInfo> = messages.iter().filter(|m| m.has_photo()).collect(); let photo_count = photos.len(); if photo_count == 0 { // Нет фото — рендерим как обычные сообщения for msg in messages { lines.extend(render_message_bubble(msg, config, content_width, selected_msg_id, None)); } return (lines, deferred); } // Grid layout let cols = photo_count.min(ALBUM_GRID_MAX_COLS); let rows = (photo_count + cols - 1) / cols; // Добавляем маркер выбора на первую строку if is_selected { lines.push(Line::from(vec![ Span::styled( selection_marker, Style::default().fg(Color::Yellow).add_modifier(Modifier::BOLD), ), ])); } let grid_start_line = lines.len(); // Генерируем placeholder-строки для сетки for row in 0..rows { for line_in_row in 0..ALBUM_PHOTO_HEIGHT { let mut spans = Vec::new(); // Для исходящих — добавляем отступ справа if is_outgoing { let grid_width = cols as u16 * ALBUM_PHOTO_WIDTH + (cols as u16).saturating_sub(1) * ALBUM_PHOTO_GAP; let padding = content_width.saturating_sub(grid_width as usize + 1); spans.push(Span::raw(" ".repeat(padding))); } // Для каждого столбца в этом ряду for col in 0..cols { let photo_idx = row * cols + col; if photo_idx >= photo_count { break; } let msg = photos[photo_idx]; if let Some(photo) = msg.photo_info() { match &photo.download_state { PhotoDownloadState::Downloaded(path) => { if line_in_row == 0 { // Регистрируем deferred render для этого фото let x_off = if is_outgoing { let grid_width = cols as u16 * ALBUM_PHOTO_WIDTH + (cols as u16).saturating_sub(1) * ALBUM_PHOTO_GAP; let padding = content_width.saturating_sub(grid_width as usize + 1) as u16; padding + col as u16 * (ALBUM_PHOTO_WIDTH + ALBUM_PHOTO_GAP) } else { col as u16 * (ALBUM_PHOTO_WIDTH + ALBUM_PHOTO_GAP) }; deferred.push(DeferredImageRender { message_id: msg.id(), photo_path: path.clone(), line_offset: grid_start_line + row * ALBUM_PHOTO_HEIGHT as usize, x_offset: x_off, width: ALBUM_PHOTO_WIDTH, height: ALBUM_PHOTO_HEIGHT, }); } // Пустая строка — placeholder для изображения } PhotoDownloadState::Downloading => { if line_in_row == ALBUM_PHOTO_HEIGHT / 2 { spans.push(Span::styled( "⏳ Загрузка...", Style::default().fg(Color::Yellow), )); } } PhotoDownloadState::Error(e) => { if line_in_row == ALBUM_PHOTO_HEIGHT / 2 { let err_text: String = e.chars().take(14).collect(); spans.push(Span::styled( format!("❌ {}", err_text), Style::default().fg(Color::Red), )); } } PhotoDownloadState::NotDownloaded => { if line_in_row == ALBUM_PHOTO_HEIGHT / 2 { spans.push(Span::styled( "📷", Style::default().fg(Color::Gray), )); } } } } } lines.push(Line::from(spans)); } } // Caption: собираем непустые тексты (без "📷 [Фото]" prefix) let captions: Vec<&str> = messages .iter() .map(|m| m.text()) .filter(|t| !t.is_empty() && !t.starts_with("📷")) .collect(); let msg_color = if is_selected { config.parse_color(&config.colors.selected_message) } else if is_outgoing { config.parse_color(&config.colors.outgoing_message) } else { config.parse_color(&config.colors.incoming_message) }; // Timestamp из последнего сообщения let last_msg = messages.last().unwrap(); let time = format_timestamp_with_tz(last_msg.date(), &config.general.timezone); if !captions.is_empty() { let caption_text = captions.join(" "); let time_suffix = format!(" ({})", time); if is_outgoing { let total_len = caption_text.chars().count() + time_suffix.chars().count(); let padding = content_width.saturating_sub(total_len + 1); lines.push(Line::from(vec![ Span::raw(" ".repeat(padding)), Span::styled(caption_text, Style::default().fg(msg_color)), Span::styled(time_suffix, Style::default().fg(Color::Gray)), ])); } else { lines.push(Line::from(vec![ Span::styled(format!(" ({})", time), Style::default().fg(Color::Gray)), Span::raw(" "), Span::styled(caption_text, Style::default().fg(msg_color)), ])); } } else { // Без подписи — только timestamp let time_text = format!("({})", time); if is_outgoing { let padding = content_width.saturating_sub(time_text.chars().count() + 1); lines.push(Line::from(vec![ Span::raw(" ".repeat(padding)), Span::styled(time_text, Style::default().fg(Color::Gray)), ])); } else { lines.push(Line::from(vec![ Span::styled(format!(" {}", time_text), Style::default().fg(Color::Gray)), ])); } } (lines, deferred) } /// Вычисляет высоту изображения (в строках) с учётом пропорций #[cfg(feature = "images")] pub fn calculate_image_height(img_width: i32, img_height: i32, content_width: usize) -> u16 { use crate::constants::{MAX_IMAGE_HEIGHT, MAX_IMAGE_WIDTH, MIN_IMAGE_HEIGHT}; let display_width = (content_width as u16).min(MAX_IMAGE_WIDTH); let aspect = img_height as f64 / img_width as f64; // Терминальные символы ~2:1 по высоте, компенсируем let raw_height = (display_width as f64 * aspect * 0.5) as u16; raw_height.clamp(MIN_IMAGE_HEIGHT, MAX_IMAGE_HEIGHT) } /// Рендерит progress bar для воспроизведения fn render_progress_bar(position: f32, duration: f32, width: usize) -> String { if duration <= 0.0 { return "─".repeat(width); } let ratio = (position / duration).clamp(0.0, 1.0); let filled = (ratio * width as f32) as usize; let empty = width.saturating_sub(filled + 1); format!("{}●{}", "━".repeat(filled), "─".repeat(empty)) } /// Рендерит waveform из base64-encoded данных TDLib fn render_waveform(waveform_b64: &str, width: usize) -> String { const BARS: &[char] = &['▁', '▂', '▃', '▄', '▅', '▆', '▇', '█']; if waveform_b64.is_empty() { return "▁".repeat(width); } // Декодируем waveform (каждый байт = амплитуда 0-255) use base64::Engine; let bytes = base64::engine::general_purpose::STANDARD .decode(waveform_b64) .unwrap_or_default(); if bytes.is_empty() { return "▁".repeat(width); } // Сэмплируем до нужной ширины let mut result = String::with_capacity(width * 4); for i in 0..width { let byte_idx = i * bytes.len() / width; let amplitude = bytes.get(byte_idx).copied().unwrap_or(0); let bar_idx = (amplitude as usize * (BARS.len() - 1)) / 255; result.push(BARS[bar_idx]); } result }