This commit is contained in:
Mikhail Kilin
2026-01-31 03:48:50 +03:00
parent 1bf9b3d703
commit 644e36597d
37 changed files with 1070 additions and 600 deletions

285
src/formatting.rs Normal file
View File

@@ -0,0 +1,285 @@
//! Модуль для форматирования текста с 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 &current_style {
Some(prev_style) if styles_equal(prev_style, style) => {
current_text.push(*ch);
}
_ => {
if !current_text.is_empty() {
if let Some(prev_style) = &current_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); // Нет пересечений
}
}