Added comprehensive rustdoc documentation for all TDLib modules, configuration, and utility functions. TDLib modules documented: - src/tdlib/auth.rs - AuthManager, AuthState (6 doctests) - src/tdlib/chats.rs - ChatManager (8 doctests) - src/tdlib/messages.rs - MessageManager (14 methods, 6 doctests) - src/tdlib/reactions.rs - ReactionManager (3 doctests) - src/tdlib/users.rs - UserCache, LruCache (2 doctests) Configuration and utilities: - src/config.rs - Config, ColorsConfig, GeneralConfig (4 doctests) - src/formatting.rs - format_text_with_entities (2 doctests) Documentation includes: - Detailed descriptions of all public structs and methods - Usage examples with code snippets - Parameter and return value documentation - Notes about async behavior and edge cases - Cross-references between related functions Total: 34 doctests (30 ignored for async, 4 compiled) All 464 unit tests passing ✅ Priority 4.12 (Rustdoc) - 100% complete Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
332 lines
11 KiB
Rust
332 lines
11 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
|
||
}
|
||
|
||
/// Преобразует текст с 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 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
|
||
/// Корректирует 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); // Нет пересечений
|
||
}
|
||
}
|