Files
telegram-tui/src/formatting.rs
Mikhail Kilin 644e36597d fixes
2026-01-31 03:48:50 +03:00

286 lines
9.3 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
//! Модуль для форматирования текста с 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); // Нет пересечений
}
}