286 lines
9.3 KiB
Rust
286 lines
9.3 KiB
Rust
//! Модуль для форматирования текста с 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
|
||
}
|
||
|
||
/// Преобразует текст с entities в вектор стилизованных Span
|
||
///
|
||
/// # Аргументы
|
||
///
|
||
/// * `text` - Текст для форматирования
|
||
/// * `entities` - Массив TextEntity с информацией о форматировании
|
||
/// * `base_color` - Базовый цвет текста
|
||
///
|
||
/// # Возвращает
|
||
///
|
||
/// Вектор Span<'static> со стилизованными фрагментами текста
|
||
pub 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
|
||
}
|
||
|
||
/// Фильтрует и корректирует entities для подстроки
|
||
///
|
||
/// Используется для правильного отображения форматирования при переносе текста.
|
||
///
|
||
/// # Аргументы
|
||
///
|
||
/// * `entities` - Исходный массив entities
|
||
/// * `start` - Начальная позиция подстроки (в символах)
|
||
/// * `length` - Длина подстроки (в символах)
|
||
///
|
||
/// # Возвращает
|
||
///
|
||
/// Новый массив entities с откорректированными offset и length
|
||
pub 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()
|
||
}
|
||
|
||
#[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); // Нет пересечений
|
||
}
|
||
}
|