Files
telegram-tui/src/formatting.rs
Mikhail Kilin 166fda93a4
Some checks failed
ci/woodpecker/pr/check Pipeline failed
CI / Check (pull_request) Has been cancelled
CI / Format (pull_request) Has been cancelled
CI / Clippy (pull_request) Has been cancelled
CI / Build (macos-latest) (pull_request) Has been cancelled
CI / Build (ubuntu-latest) (pull_request) Has been cancelled
CI / Build (windows-latest) (pull_request) Has been cancelled
style: fix formatting after clippy changes
2026-02-22 17:33:48 +03:00

330 lines
11 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
}
/// Преобразует текст с 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<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 item in char_styles
.iter_mut()
.take(end.min(chars.len()))
.skip(start)
{
match &entity.r#type {
TextEntityType::Bold => item.bold = true,
TextEntityType::Italic => item.italic = true,
TextEntityType::Underline => item.underline = true,
TextEntityType::Strikethrough => item.strikethrough = true,
TextEntityType::Code | TextEntityType::Pre | TextEntityType::PreCode(_) => {
item.code = true
}
TextEntityType::Spoiler => item.spoiler = true,
TextEntityType::Url
| TextEntityType::TextUrl(_)
| TextEntityType::EmailAddress
| TextEntityType::PhoneNumber => item.url = true,
TextEntityType::Mention | TextEntityType::MentionName(_) => item.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
/// Корректирует 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<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); // Нет пересечений
}
}