//! Модуль для форматирования текста с markdown entities //! //! Предоставляет функции для преобразования текста с TDLib TextEntity //! в стилизованные Span для отображения в TUI. use ratatui::{ style::{Color, Modifier, Style}, text::Span, }; 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 { /// Преобразует CharStyle в ratatui Style 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 } } /// Проверяет равенство двух стилей 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 } /// Преобразует текст с TDLib entities в стилизованные Span для рендеринга. /// /// Обрабатывает Markdown форматирование (bold, italic, code и т.д.) и преобразует /// в визуальные стили для отображения в TUI. /// /// # Поддерживаемые стили /// /// - **Bold** - жирный текст /// - *Italic* - курсив /// - __Underline__ - подчёркнутый /// - ~~Strikethrough~~ - зачёркнутый /// - `Code` - моноширинный текст (cyan на тёмном фоне) /// - ||Spoiler|| - скрытый текст (серый) /// - [URL](url) - ссылки (синий с подчёркиванием) /// - @mentions - упоминания (синий с подчёркиванием) /// /// # Arguments /// /// * `text` - Текст для форматирования /// * `entities` - Массив TDLib TextEntity с информацией о форматировании /// * `base_color` - Базовый цвет для обычного текста /// /// # Returns /// /// Вектор стилизованных `Span<'static>` для рендеринга в ratatui. /// /// # Examples /// /// ```ignore /// let spans = format_text_with_entities( /// "Hello **world**!", /// &entities, /// Color::White /// ); /// ``` pub 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 } /// Фильтрует и корректирует entities для подстроки /// /// Используется для правильного отображения форматирования при переносе текста. /// /// # Аргументы /// /// * `entities` - Исходный массив entities /// * `start` - Начальная позиция подстроки (в символах) /// * `length` - Длина подстроки (в символах) /// /// # Возвращает /// /// Новый массив entities с откорректированными offset и length /// Корректирует offset entities для подстроки текста. /// /// Используется при обрезке текста (например, для preview) для сохранения /// корректных позиций форматирования. /// /// # Arguments /// /// * `entities` - Исходный массив entities /// * `start` - Начальная позиция подстроки (в символах) /// * `length` - Длина подстроки (в символах) /// /// # Returns /// /// Новый массив entities с скорректированными offset для подстроки. /// /// # Examples /// /// ```ignore /// let text = "Hello **world** test"; /// let substring = &text[0..15]; // "Hello **world**" /// let adjusted = adjust_entities_for_substring(&entities, 0, 15); /// ``` pub 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() } #[cfg(test)] mod tests { use super::*; #[test] fn test_format_text_no_entities() { let text = "Hello, world!"; let entities = vec![]; let spans = format_text_with_entities(text, &entities, Color::White); assert_eq!(spans.len(), 1); assert_eq!(spans[0].content, "Hello, world!"); } #[test] fn test_format_text_with_bold() { let text = "Hello"; let entities = vec![TextEntity { offset: 0, length: 5, r#type: TextEntityType::Bold, }]; let spans = format_text_with_entities(text, &entities, Color::White); assert_eq!(spans.len(), 1); assert_eq!(spans[0].content, "Hello"); assert!(spans[0].style.add_modifier.contains(Modifier::BOLD)); } #[test] fn test_adjust_entities_full_overlap() { let entities = vec![TextEntity { offset: 0, length: 10, r#type: TextEntityType::Bold, }]; let adjusted = adjust_entities_for_substring(&entities, 0, 10); assert_eq!(adjusted.len(), 1); assert_eq!(adjusted[0].offset, 0); assert_eq!(adjusted[0].length, 10); } #[test] fn test_adjust_entities_partial_overlap() { let entities = vec![TextEntity { offset: 5, length: 10, r#type: TextEntityType::Bold, }]; let adjusted = adjust_entities_for_substring(&entities, 0, 10); assert_eq!(adjusted.len(), 1); assert_eq!(adjusted[0].offset, 5); assert_eq!(adjusted[0].length, 5); // Обрезано до конца подстроки } #[test] fn test_adjust_entities_no_overlap() { let entities = vec![TextEntity { offset: 20, length: 10, r#type: TextEntityType::Bold, }]; let adjusted = adjust_entities_for_substring(&entities, 0, 10); assert_eq!(adjusted.len(), 0); // Нет пересечений } }