Split core and TUI crates

This commit is contained in:
Mikhail Kilin
2026-05-20 00:31:18 +03:00
parent 91a8700b8e
commit eefac431e5
238 changed files with 624 additions and 191 deletions

View File

@@ -0,0 +1,137 @@
use crate::app::App;
use crate::tdlib::AuthState;
use crate::tdlib::TdClientTrait;
use ratatui::{
layout::{Alignment, Constraint, Direction, Layout},
style::{Color, Modifier, Style},
text::Line,
widgets::{Block, Borders, Paragraph},
Frame,
};
pub fn render<T: TdClientTrait>(f: &mut Frame, app: &App<T>) {
let area = f.area();
let vertical_chunks = Layout::default()
.direction(Direction::Vertical)
.constraints([
Constraint::Percentage(30),
Constraint::Length(15),
Constraint::Percentage(30),
])
.split(area);
let horizontal_chunks = Layout::default()
.direction(Direction::Horizontal)
.constraints([
Constraint::Percentage(25),
Constraint::Percentage(50),
Constraint::Percentage(25),
])
.split(vertical_chunks[1]);
let auth_area = horizontal_chunks[1];
let auth_chunks = Layout::default()
.direction(Direction::Vertical)
.constraints([
Constraint::Length(3), // Title
Constraint::Length(4), // Instructions
Constraint::Length(3), // Input
Constraint::Length(2), // Error/Status message
Constraint::Min(0), // Spacer
])
.split(auth_area);
// Title
let title = Paragraph::new("TTUI - Telegram Authentication")
.style(
Style::default()
.fg(Color::Cyan)
.add_modifier(Modifier::BOLD),
)
.alignment(Alignment::Center)
.block(Block::default().borders(Borders::ALL));
f.render_widget(title, auth_chunks[0]);
// Instructions and Input based on auth state
match &app.td_client.auth_state() {
AuthState::WaitPhoneNumber => {
let instructions = vec![
Line::from("Введите номер телефона в международном формате"),
Line::from("Пример: +79991111111"),
];
let instructions_widget = Paragraph::new(instructions)
.style(Style::default().fg(Color::Gray))
.alignment(Alignment::Center)
.block(Block::default().borders(Borders::NONE));
f.render_widget(instructions_widget, auth_chunks[1]);
let input_text = format!("📱 {}", app.phone_input());
let input = Paragraph::new(input_text)
.style(Style::default().fg(Color::Yellow))
.alignment(Alignment::Center)
.block(
Block::default()
.borders(Borders::ALL)
.title(" Phone Number "),
);
f.render_widget(input, auth_chunks[2]);
}
AuthState::WaitCode => {
let instructions = vec![
Line::from("Введите код подтверждения из Telegram"),
Line::from("Код был отправлен на ваш номер"),
];
let instructions_widget = Paragraph::new(instructions)
.style(Style::default().fg(Color::Gray))
.alignment(Alignment::Center)
.block(Block::default().borders(Borders::NONE));
f.render_widget(instructions_widget, auth_chunks[1]);
let input_text = format!("🔐 {}", app.code_input());
let input = Paragraph::new(input_text)
.style(Style::default().fg(Color::Yellow))
.alignment(Alignment::Center)
.block(
Block::default()
.borders(Borders::ALL)
.title(" Verification Code "),
);
f.render_widget(input, auth_chunks[2]);
}
AuthState::WaitPassword => {
let instructions = vec![
Line::from("Введите пароль двухфакторной аутентификации"),
Line::from(""),
];
let instructions_widget = Paragraph::new(instructions)
.style(Style::default().fg(Color::Gray))
.alignment(Alignment::Center)
.block(Block::default().borders(Borders::NONE));
f.render_widget(instructions_widget, auth_chunks[1]);
let masked_password = "*".repeat(app.password_input().len());
let input_text = format!("🔒 {}", masked_password);
let input = Paragraph::new(input_text)
.style(Style::default().fg(Color::Yellow))
.alignment(Alignment::Center)
.block(Block::default().borders(Borders::ALL).title(" Password "));
f.render_widget(input, auth_chunks[2]);
}
_ => {}
}
// Error or status message
if let Some(error) = &app.error_message {
let error_widget = Paragraph::new(error.as_str())
.style(Style::default().fg(Color::Red))
.alignment(Alignment::Center);
f.render_widget(error_widget, auth_chunks[3]);
} else if let Some(status) = &app.status_message {
let status_widget = Paragraph::new(status.as_str())
.style(Style::default().fg(Color::Yellow))
.alignment(Alignment::Center);
f.render_widget(status_widget, auth_chunks[3]);
}
}

View File

@@ -0,0 +1,107 @@
//! Chat list panel: search box, chat items, and user online status.
use crate::app::methods::{compose::ComposeMethods, search::SearchMethods};
use crate::app::App;
use crate::tdlib::TdClientTrait;
use crate::tdlib::UserOnlineStatus;
use crate::ui::components;
use ratatui::{
layout::{Constraint, Direction, Layout, Rect},
style::{Color, Modifier, Style},
widgets::{Block, Borders, List, ListItem, Paragraph},
Frame,
};
pub fn render<T: TdClientTrait>(f: &mut Frame, area: Rect, app: &mut App<T>) {
let chat_chunks = Layout::default()
.direction(Direction::Vertical)
.constraints([
Constraint::Length(3), // Search box
Constraint::Min(0), // Chat list
Constraint::Length(3), // User status
])
.split(area);
// Search box
let search_text = if app.is_searching {
if app.search_query.is_empty() {
"🔍 Введите для поиска...".to_string()
} else {
format!("🔍 {}", app.search_query)
}
} else {
"🔍 Ctrl+S для поиска".to_string()
};
let search_style = if app.is_searching {
Style::default().fg(Color::Yellow)
} else {
Style::default().fg(Color::Rgb(160, 160, 160))
};
let search = Paragraph::new(search_text)
.block(Block::default().borders(Borders::ALL))
.style(search_style);
f.render_widget(search, chat_chunks[0]);
// Chat list (filtered if searching)
let filtered_chats = app.get_filtered_chats();
let items: Vec<ListItem> = filtered_chats
.iter()
.map(|chat| {
let is_selected = app.selected_chat_id == Some(chat.id);
let user_status = app.td_client.get_user_status_by_chat_id(chat.id);
components::render_chat_list_item(chat, is_selected, user_status)
})
.collect();
// Заголовок блока: обычный или режим пересылки
let block = if app.is_forwarding() {
Block::default()
.title(" ↪ Выберите чат ")
.borders(Borders::ALL)
.border_style(Style::default().fg(Color::Cyan))
} else {
Block::default().borders(Borders::ALL)
};
let chats_list = List::new(items).block(block).highlight_style(
Style::default()
.add_modifier(Modifier::ITALIC)
.fg(Color::Yellow),
);
f.render_stateful_widget(chats_list, chat_chunks[1], &mut app.chat_list_state);
// User status - показываем статус выбранного или выделенного чата
let status_chat_id = if app.selected_chat_id.is_some() {
app.selected_chat_id
} else {
let filtered = app.get_filtered_chats();
app.chat_list_state
.selected()
.and_then(|i| filtered.get(i).map(|c| c.id))
};
let (status_text, status_color) = match status_chat_id {
Some(chat_id) => format_user_status(app.td_client.get_user_status_by_chat_id(chat_id)),
None => ("".to_string(), Color::DarkGray),
};
let status = Paragraph::new(status_text)
.block(Block::default().borders(Borders::ALL))
.style(Style::default().fg(status_color));
f.render_widget(status, chat_chunks[2]);
}
/// Форматирует статус пользователя для отображения в статус-баре
fn format_user_status(status: Option<&UserOnlineStatus>) -> (String, Color) {
match status {
Some(UserOnlineStatus::Online) => ("● онлайн".to_string(), Color::Green),
Some(UserOnlineStatus::Recently) => ("был(а) недавно".to_string(), Color::Yellow),
Some(UserOnlineStatus::Offline(was_online)) => {
(crate::utils::format_was_online(*was_online), Color::Gray)
}
Some(UserOnlineStatus::LastWeek) => ("был(а) на этой неделе".to_string(), Color::DarkGray),
Some(UserOnlineStatus::LastMonth) => ("был(а) в этом месяце".to_string(), Color::DarkGray),
Some(UserOnlineStatus::LongTimeAgo) => ("был(а) давно".to_string(), Color::DarkGray),
None => ("".to_string(), Color::DarkGray),
}
}

View File

@@ -0,0 +1,78 @@
use crate::tdlib::{ChatInfo, UserOnlineStatus};
use ratatui::{
style::{Color, Style},
widgets::ListItem,
};
/// Рендерит элемент списка чатов
///
/// # Параметры
/// - `chat`: Информация о чате
/// - `is_selected`: Выбран ли этот чат
/// - `user_status`: Онлайн-статус пользователя (если доступен)
///
/// # Возвращает
/// ListItem с форматированным отображением чата
pub fn render_chat_list_item(
chat: &ChatInfo,
is_selected: bool,
user_status: Option<&UserOnlineStatus>,
) -> ListItem<'static> {
let pin_icon = if chat.is_pinned { "📌 " } else { "" };
let mute_icon = if chat.is_muted { "🔇 " } else { "" };
// Онлайн-статус (зелёная точка для онлайн)
let status_icon = match user_status {
Some(UserOnlineStatus::Online) => "",
_ => " ",
};
let prefix = if is_selected { "" } else { " " };
let username_text = chat
.username
.as_ref()
.map(|u| format!(" {}", u))
.unwrap_or_default();
// Индикатор упоминаний @
let mention_badge = if chat.unread_mention_count > 0 {
" @".to_string()
} else {
String::new()
};
// Индикатор черновика ✎
let draft_badge = if chat.draft_text.is_some() {
"".to_string()
} else {
String::new()
};
let unread_badge = if chat.unread_count > 0 {
format!(" ({})", chat.unread_count)
} else {
String::new()
};
let content = format!(
"{}{}{}{}{}{}{}{}{}",
prefix,
status_icon,
pin_icon,
mute_icon,
chat.title,
username_text,
mention_badge,
draft_badge,
unread_badge
);
// Цвет: онлайн — зелёные, остальные — белые
let style = match user_status {
Some(UserOnlineStatus::Online) => Style::default().fg(Color::Green),
_ => Style::default().fg(Color::White),
};
ListItem::new(content).style(style)
}

View File

@@ -0,0 +1,104 @@
use ratatui::{
layout::{Alignment, Rect},
style::{Color, Modifier, Style},
text::{Line, Span},
widgets::{Block, Borders, Clear, Paragraph},
Frame,
};
/// Рендерит модалку выбора реакций (emoji picker)
///
/// # Параметры
/// - `f`: Frame для рендеринга
/// - `area`: Область экрана
/// - `available_reactions`: Список доступных эмодзи
/// - `selected_index`: Индекс выбранного эмодзи
pub fn render_emoji_picker(
f: &mut Frame,
area: Rect,
available_reactions: &[String],
selected_index: usize,
) {
// Размеры модалки (зависят от количества реакций)
let emojis_per_row = 8;
let rows = available_reactions.len().div_ceil(emojis_per_row);
let modal_width = 50u16;
let modal_height = (rows + 4) as u16; // +4 для заголовка, отступов и подсказки
// Центрируем модалку
let x = area.x + (area.width.saturating_sub(modal_width)) / 2;
let y = area.y + (area.height.saturating_sub(modal_height)) / 2;
let modal_area = Rect::new(x, y, modal_width.min(area.width), modal_height.min(area.height));
// Очищаем область под модалкой
f.render_widget(Clear, modal_area);
// Формируем содержимое - сетка эмодзи
let mut text_lines = vec![Line::from("")]; // Пустая строка сверху
for row in 0..rows {
let mut row_spans = vec![Span::raw(" ")]; // Отступ слева
for col in 0..emojis_per_row {
let idx = row * emojis_per_row + col;
if idx >= available_reactions.len() {
break;
}
let emoji = &available_reactions[idx];
let is_selected = idx == selected_index;
let style = if is_selected {
Style::default()
.fg(Color::Yellow)
.add_modifier(Modifier::BOLD)
.add_modifier(Modifier::REVERSED)
} else {
Style::default().fg(Color::White)
};
row_spans.push(Span::styled(format!(" {} ", emoji), style));
row_spans.push(Span::raw(" ")); // Пробел между эмодзи
}
text_lines.push(Line::from(row_spans));
}
// Добавляем пустую строку и подсказку
text_lines.push(Line::from(""));
text_lines.push(Line::from(vec![
Span::styled(
" [←/→/↑/↓] ",
Style::default()
.fg(Color::Cyan)
.add_modifier(Modifier::BOLD),
),
Span::raw("Выбор "),
Span::styled(
" [Enter] ",
Style::default()
.fg(Color::Green)
.add_modifier(Modifier::BOLD),
),
Span::raw("Добавить "),
Span::styled(" [Esc] ", Style::default().fg(Color::Red).add_modifier(Modifier::BOLD)),
Span::raw("Отмена"),
]));
let modal = Paragraph::new(text_lines)
.block(
Block::default()
.borders(Borders::ALL)
.border_style(Style::default().fg(Color::Yellow))
.title(" Выбери реакцию ")
.title_style(
Style::default()
.fg(Color::Yellow)
.add_modifier(Modifier::BOLD),
),
)
.alignment(Alignment::Center);
f.render_widget(modal, modal_area);
}

View File

@@ -0,0 +1,50 @@
use ratatui::{
style::{Color, Style},
text::{Line, Span},
};
/// Рендерит текст с курсором в виде Line
///
/// # Параметры
/// - `prefix`: Префикс перед текстом (например, "Сообщение: ")
/// - `text`: Текст в поле ввода
/// - `cursor_pos`: Позиция курсора (индекс символа)
/// - `color`: Цвет текста и курсора
///
/// # Возвращает
/// Line с текстом и блочным курсором на указанной позиции
pub fn render_input_field(
prefix: &str,
text: &str,
cursor_pos: usize,
color: Color,
) -> Line<'static> {
let chars: Vec<char> = text.chars().collect();
let mut spans: Vec<Span> = vec![Span::raw(prefix.to_string())];
// Ограничиваем cursor_pos границами текста
let safe_cursor_pos = cursor_pos.min(chars.len());
// Текст до курсора
if safe_cursor_pos > 0 {
let before: String = chars[..safe_cursor_pos].iter().collect();
spans.push(Span::styled(before, Style::default().fg(color)));
}
// Символ под курсором (или █ если курсор в конце)
if safe_cursor_pos < chars.len() {
let cursor_char = chars[safe_cursor_pos].to_string();
spans.push(Span::styled(cursor_char, Style::default().fg(Color::Black).bg(color)));
} else {
// Курсор в конце - показываем блок
spans.push(Span::styled("", Style::default().fg(color)));
}
// Текст после курсора
if safe_cursor_pos + 1 < chars.len() {
let after: String = chars[safe_cursor_pos + 1..].iter().collect();
spans.push(Span::styled(after, Style::default().fg(color)));
}
Line::from(spans)
}

View File

@@ -0,0 +1,763 @@
//! Message bubble component
//!
//! Отвечает за рендеринг отдельных элементов списка сообщений:
//! - Разделители дат
//! - Заголовки отправителей
//! - Сами сообщения (с forward, reply, reactions)
use crate::config::Config;
use crate::formatting;
#[cfg(feature = "images")]
use crate::tdlib::PhotoDownloadState;
use crate::tdlib::{MessageInfo, PlaybackState, PlaybackStatus};
use crate::types::MessageId;
use crate::utils::{format_date, format_timestamp};
use ratatui::{
style::{Color, Modifier, Style},
text::{Line, Span},
};
/// Информация о строке после переноса: текст и позиция в оригинале
struct WrappedLine {
text: String,
/// Начальная позиция в символах от начала оригинального текста
start_offset: usize,
}
/// Разбивает текст на строки с учётом максимальной ширины и `\n`
fn wrap_text_with_offsets(text: &str, max_width: usize) -> Vec<WrappedLine> {
let mut all_lines = Vec::new();
let mut char_offset = 0;
for segment in text.split('\n') {
let wrapped = wrap_paragraph(segment, max_width, char_offset);
all_lines.extend(wrapped);
char_offset += segment.chars().count() + 1; // +1 за '\n'
}
if all_lines.is_empty() {
all_lines.push(WrappedLine { text: String::new(), start_offset: 0 });
}
all_lines
}
/// Разбивает один абзац (без `\n`) на строки по ширине
fn wrap_paragraph(text: &str, max_width: usize, base_offset: usize) -> Vec<WrappedLine> {
if max_width == 0 {
return vec![WrappedLine { text: text.to_string(), start_offset: base_offset }];
}
let mut result = Vec::new();
let mut current_line = String::new();
let mut current_width = 0;
let mut line_start_offset = base_offset;
let chars: Vec<char> = text.chars().collect();
let mut word_start = 0;
let mut in_word = false;
for (i, ch) in chars.iter().enumerate() {
if ch.is_whitespace() {
if in_word {
let word: String = chars[word_start..i].iter().collect();
let word_width = word.chars().count();
if current_width == 0 {
current_line = word;
current_width = word_width;
line_start_offset = base_offset + word_start;
} else if current_width + 1 + word_width <= max_width {
current_line.push(' ');
current_line.push_str(&word);
current_width += 1 + word_width;
} else {
result.push(WrappedLine {
text: current_line,
start_offset: line_start_offset,
});
current_line = word;
current_width = word_width;
line_start_offset = base_offset + word_start;
}
in_word = false;
}
} else if !in_word {
word_start = i;
in_word = true;
}
}
if in_word {
let word: String = chars[word_start..].iter().collect();
let word_width = word.chars().count();
if current_width == 0 {
current_line = word;
line_start_offset = base_offset + word_start;
} else if current_width + 1 + word_width <= max_width {
current_line.push(' ');
current_line.push_str(&word);
} else {
result.push(WrappedLine {
text: current_line,
start_offset: line_start_offset,
});
current_line = word;
line_start_offset = base_offset + word_start;
}
}
if !current_line.is_empty() {
result.push(WrappedLine {
text: current_line,
start_offset: line_start_offset,
});
}
if result.is_empty() {
result.push(WrappedLine { text: String::new(), start_offset: base_offset });
}
result
}
/// Рендерит разделитель даты
///
/// # Аргументы
///
/// * `date` - timestamp сообщения
/// * `content_width` - ширина области для центрирования
/// * `is_first` - первый ли это разделитель (если нет, добавляется пустая строка сверху)
pub fn render_date_separator(
date: i32,
content_width: usize,
is_first: bool,
) -> Vec<Line<'static>> {
let mut lines = Vec::new();
if !is_first {
lines.push(Line::from("")); // Пустая строка перед разделителем
}
let date_str = format_date(date);
let date_line = format!("──────── {} ────────", date_str);
let padding = content_width.saturating_sub(date_line.chars().count()) / 2;
lines.push(Line::from(vec![
Span::raw(" ".repeat(padding)),
Span::styled(date_line, Style::default().fg(Color::Gray)),
]));
lines.push(Line::from(""));
lines
}
/// Рендерит заголовок отправителя
///
/// # Аргументы
///
/// * `is_outgoing` - исходящее ли сообщение
/// * `sender_name` - имя отправителя
/// * `content_width` - ширина области для выравнивания
/// * `is_first` - первый ли это заголовок в группе (если нет, добавляется пустая строка сверху)
pub fn render_sender_header(
is_outgoing: bool,
sender_name: &str,
content_width: usize,
is_first: bool,
) -> Vec<Line<'static>> {
let mut lines = Vec::new();
if !is_first {
lines.push(Line::from("")); // Пустая строка между группами
}
let sender_style = if is_outgoing {
Style::default()
.fg(Color::Green)
.add_modifier(Modifier::BOLD)
} else {
Style::default()
.fg(Color::Cyan)
.add_modifier(Modifier::BOLD)
};
if is_outgoing {
// Заголовок "Вы" справа
let header_text = format!("{} ────────────────", sender_name);
let header_len = header_text.chars().count();
let padding = content_width.saturating_sub(header_len + 1);
lines.push(Line::from(vec![
Span::raw(" ".repeat(padding)),
Span::styled(format!("{} ", sender_name), sender_style),
Span::styled("────────────────", Style::default().fg(Color::Gray)),
]));
} else {
// Заголовок входящих слева
lines.push(Line::from(vec![
Span::styled(format!("{} ", sender_name), sender_style),
Span::styled("────────────────", Style::default().fg(Color::Gray)),
]));
}
lines
}
/// Рендерит bubble одного сообщения
///
/// # Аргументы
///
/// * `msg` - сообщение для рендеринга
/// * `config` - конфигурация (цвета)
/// * `content_width` - ширина области для рендеринга
/// * `selected_msg_id` - ID выбранного сообщения (для подсветки)
pub fn render_message_bubble(
msg: &MessageInfo,
config: &Config,
content_width: usize,
selected_msg_id: Option<MessageId>,
playback_state: Option<&PlaybackState>,
) -> Vec<Line<'static>> {
let mut lines = Vec::new();
let is_selected = selected_msg_id == Some(msg.id());
// Маркер выбора (всегда резервируем место для ▶, чтобы текст не сдвигался)
let selection_marker = if is_selected { "" } else { " " };
let marker_len = 2;
// Цвет сообщения
let msg_color = if is_selected {
config.parse_color(&config.colors.selected_message)
} else if msg.is_outgoing() {
config.parse_color(&config.colors.outgoing_message)
} else {
config.parse_color(&config.colors.incoming_message)
};
// Отображаем forward если есть
if let Some(forward) = msg.forward_from() {
let forward_line = format!("↪ Переслано от {}", forward.sender_name);
let forward_len = forward_line.chars().count();
if msg.is_outgoing() {
let padding = content_width.saturating_sub(forward_len + 1);
lines.push(Line::from(vec![
Span::raw(" ".repeat(padding)),
Span::styled(forward_line, Style::default().fg(Color::Magenta)),
]));
} else {
lines.push(Line::from(vec![Span::styled(
forward_line,
Style::default().fg(Color::Magenta),
)]));
}
}
// Отображаем reply если есть
if let Some(reply) = msg.reply_to() {
let reply_text: String = reply.text.chars().take(40).collect();
let ellipsis = if reply.text.chars().count() > 40 {
"..."
} else {
""
};
let reply_line = format!("{}: {}{}", reply.sender_name, reply_text, ellipsis);
let reply_len = reply_line.chars().count();
if msg.is_outgoing() {
let padding = content_width.saturating_sub(reply_len + 1);
lines.push(Line::from(vec![
Span::raw(" ".repeat(padding)),
Span::styled(reply_line, Style::default().fg(Color::Cyan)),
]));
} else {
lines
.push(Line::from(vec![Span::styled(reply_line, Style::default().fg(Color::Cyan))]));
}
}
// Форматируем время
let time = format_timestamp(msg.date());
if msg.is_outgoing() {
// Исходящие: справа, формат "текст (HH:MM ✎ ✓✓)"
let read_mark = if msg.is_read() { "✓✓" } else { "" };
let edit_mark = if msg.is_edited() { "" } else { "" };
let time_mark = format!("({} {}{})", time, edit_mark, read_mark);
let time_mark_len = time_mark.chars().count() + 1;
let max_msg_width = content_width.saturating_sub(time_mark_len + marker_len + 2);
let wrapped_lines = wrap_text_with_offsets(msg.text(), max_msg_width);
let total_wrapped = wrapped_lines.len();
for (i, wrapped) in wrapped_lines.into_iter().enumerate() {
let is_last_line = i == total_wrapped - 1;
let line_len = wrapped.text.chars().count();
let line_entities = formatting::adjust_entities_for_substring(
msg.entities(),
wrapped.start_offset,
line_len,
);
let formatted_spans =
formatting::format_text_with_entities(&wrapped.text, &line_entities, msg_color);
if is_last_line {
let full_len = line_len + time_mark_len + marker_len;
let padding = content_width.saturating_sub(full_len + 1);
let mut line_spans = vec![Span::raw(" ".repeat(padding))];
if i == 0 {
// Первая (или единственная) строка — маркер
line_spans.push(Span::styled(
selection_marker,
Style::default()
.fg(Color::Yellow)
.add_modifier(Modifier::BOLD),
));
} else {
// Остальные строки multi-line — пробелы вместо маркера
line_spans.push(Span::raw(" ".repeat(marker_len)));
}
line_spans.extend(formatted_spans);
line_spans.push(Span::styled(
format!(" {}", time_mark),
Style::default().fg(Color::Gray),
));
lines.push(Line::from(line_spans));
} else {
let padding = content_width.saturating_sub(line_len + marker_len + 1);
let mut line_spans = vec![Span::raw(" ".repeat(padding))];
if i == 0 {
line_spans.push(Span::styled(
selection_marker,
Style::default()
.fg(Color::Yellow)
.add_modifier(Modifier::BOLD),
));
} else {
// Средние строки multi-line — пробелы вместо маркера
line_spans.push(Span::raw(" ".repeat(marker_len)));
}
line_spans.extend(formatted_spans);
lines.push(Line::from(line_spans));
}
}
} else {
// Входящие: слева, формат "(HH:MM ✎) текст"
let edit_mark = if msg.is_edited() { "" } else { "" };
let time_str = format!("({}{})", time, edit_mark);
let time_prefix_len = time_str.chars().count() + 2;
let max_msg_width = content_width.saturating_sub(time_prefix_len + 1);
let wrapped_lines = wrap_text_with_offsets(msg.text(), max_msg_width);
for (i, wrapped) in wrapped_lines.into_iter().enumerate() {
let line_len = wrapped.text.chars().count();
let line_entities = formatting::adjust_entities_for_substring(
msg.entities(),
wrapped.start_offset,
line_len,
);
let formatted_spans =
formatting::format_text_with_entities(&wrapped.text, &line_entities, msg_color);
if i == 0 {
let mut line_spans = vec![];
line_spans.push(Span::styled(
selection_marker,
Style::default()
.fg(Color::Yellow)
.add_modifier(Modifier::BOLD),
));
line_spans
.push(Span::styled(format!(" {}", time_str), Style::default().fg(Color::Gray)));
line_spans.push(Span::raw(" "));
line_spans.extend(formatted_spans);
lines.push(Line::from(line_spans));
} else {
let indent = " ".repeat(time_prefix_len + marker_len);
let mut line_spans = vec![Span::raw(indent)];
line_spans.extend(formatted_spans);
lines.push(Line::from(line_spans));
}
}
}
// Отображаем реакции под сообщением
if !msg.reactions().is_empty() {
let mut reaction_spans = vec![];
for reaction in msg.reactions() {
if !reaction_spans.is_empty() {
reaction_spans.push(Span::raw(" "));
}
let reaction_text = if reaction.is_chosen {
if reaction.count > 1 {
format!("[{}] {}", reaction.emoji, reaction.count)
} else {
format!("[{}]", reaction.emoji)
}
} else if reaction.count > 1 {
format!("{} {}", reaction.emoji, reaction.count)
} else {
reaction.emoji.clone()
};
let style = if reaction.is_chosen {
Style::default().fg(config.parse_color(&config.colors.reaction_chosen))
} else {
Style::default().fg(config.parse_color(&config.colors.reaction_other))
};
reaction_spans.push(Span::styled(reaction_text, style));
}
if msg.is_outgoing() {
let reactions_text: String = reaction_spans
.iter()
.map(|s| s.content.as_ref())
.collect::<Vec<_>>()
.join(" ");
let reactions_len = reactions_text.chars().count();
let padding = content_width.saturating_sub(reactions_len + 1);
let mut line_spans = vec![Span::raw(" ".repeat(padding))];
line_spans.extend(reaction_spans);
lines.push(Line::from(line_spans));
} else {
lines.push(Line::from(reaction_spans));
}
}
// Отображаем индикатор воспроизведения голосового
if msg.has_voice() {
if let Some(voice) = msg.voice_info() {
let status_line =
if let Some(ps) = playback_state.filter(|ps| ps.message_id == msg.id()) {
let icon = match ps.status {
PlaybackStatus::Playing => "",
PlaybackStatus::Paused => "",
PlaybackStatus::Loading => "",
_ => "",
};
let bar = render_progress_bar(ps.position, ps.duration, 20);
format!("{} {} {:.0}s/{:.0}s", icon, bar, ps.position, ps.duration)
} else {
let waveform = render_waveform(&voice.waveform, 20);
format!(" {} {:.0}s", waveform, voice.duration)
};
let status_len = status_line.chars().count();
if msg.is_outgoing() {
let padding = content_width.saturating_sub(status_len + 1);
lines.push(Line::from(vec![
Span::raw(" ".repeat(padding)),
Span::styled(status_line, Style::default().fg(Color::Cyan)),
]));
} else {
lines.push(Line::from(Span::styled(status_line, Style::default().fg(Color::Cyan))));
}
}
}
// Отображаем статус фото (если есть)
#[cfg(feature = "images")]
if let Some(photo) = msg.photo_info() {
match &photo.download_state {
PhotoDownloadState::Downloading => {
let status = "📷 ⏳ Загрузка...";
if msg.is_outgoing() {
let padding = content_width.saturating_sub(status.chars().count() + 1);
lines.push(Line::from(vec![
Span::raw(" ".repeat(padding)),
Span::styled(status, Style::default().fg(Color::Yellow)),
]));
} else {
lines
.push(Line::from(Span::styled(status, Style::default().fg(Color::Yellow))));
}
}
PhotoDownloadState::Error(e) => {
let status = format!("📷 [Ошибка: {}]", e);
if msg.is_outgoing() {
let padding = content_width.saturating_sub(status.chars().count() + 1);
lines.push(Line::from(vec![
Span::raw(" ".repeat(padding)),
Span::styled(status, Style::default().fg(Color::Red)),
]));
} else {
lines.push(Line::from(Span::styled(status, Style::default().fg(Color::Red))));
}
}
PhotoDownloadState::Downloaded(_) => {
// Всегда показываем inline превью для загруженных фото
let inline_width = content_width.min(crate::constants::INLINE_IMAGE_MAX_WIDTH);
let img_height = calculate_image_height(photo.width, photo.height, inline_width);
for _ in 0..img_height {
lines.push(Line::from(""));
}
}
PhotoDownloadState::NotDownloaded => {
// Для незагруженных фото ничего не рендерим,
// текст сообщения уже содержит 📷 prefix
}
}
}
lines
}
/// Информация для отложенного рендеринга изображения поверх placeholder
#[cfg(feature = "images")]
pub struct DeferredImageRender {
pub message_id: MessageId,
/// Путь к файлу изображения
pub photo_path: String,
/// Смещение в строках от начала всего списка сообщений
pub line_offset: usize,
/// Горизонтальное смещение от левого края контента (для сетки альбомов)
pub x_offset: u16,
pub width: u16,
pub height: u16,
}
/// Рендерит bubble для альбома (группы фото с общим media_album_id)
///
/// Фото отображаются в сетке (до 3 в ряд), с общей подписью и timestamp.
#[cfg(feature = "images")]
pub fn render_album_bubble(
messages: &[&MessageInfo],
config: &Config,
content_width: usize,
selected_msg_id: Option<MessageId>,
) -> (Vec<Line<'static>>, Vec<DeferredImageRender>) {
use crate::constants::{
ALBUM_GRID_MAX_COLS, ALBUM_PHOTO_GAP, ALBUM_PHOTO_HEIGHT, ALBUM_PHOTO_WIDTH,
};
let mut lines: Vec<Line<'static>> = Vec::new();
let mut deferred: Vec<DeferredImageRender> = Vec::new();
let is_selected = messages.iter().any(|m| selected_msg_id == Some(m.id()));
let is_outgoing = messages.first().is_some_and(|m| m.is_outgoing());
// Selection marker (всегда резервируем место)
let selection_marker = if is_selected { "" } else { " " };
// Фильтруем фото
let photos: Vec<&MessageInfo> = messages.iter().copied().filter(|m| m.has_photo()).collect();
let photo_count = photos.len();
if photo_count == 0 {
// Нет фото — рендерим как обычные сообщения
for msg in messages {
lines.extend(render_message_bubble(msg, config, content_width, selected_msg_id, None));
}
return (lines, deferred);
}
// Grid layout
let cols = photo_count.min(ALBUM_GRID_MAX_COLS);
let rows = photo_count.div_ceil(cols);
// Добавляем маркер выбора на первую строку (всегда — для постоянного отступа)
lines.push(Line::from(vec![Span::styled(
selection_marker,
Style::default()
.fg(Color::Yellow)
.add_modifier(Modifier::BOLD),
)]));
let grid_start_line = lines.len();
// Генерируем placeholder-строки для сетки
for row in 0..rows {
for line_in_row in 0..ALBUM_PHOTO_HEIGHT {
let mut spans = Vec::new();
// Для исходящих — добавляем отступ справа
if is_outgoing {
let grid_width = cols as u16 * ALBUM_PHOTO_WIDTH
+ (cols as u16).saturating_sub(1) * ALBUM_PHOTO_GAP;
let padding = content_width.saturating_sub(grid_width as usize + 1);
spans.push(Span::raw(" ".repeat(padding)));
}
// Для каждого столбца в этом ряду
for col in 0..cols {
let photo_idx = row * cols + col;
if photo_idx >= photo_count {
break;
}
let msg = photos[photo_idx];
if let Some(photo) = msg.photo_info() {
match &photo.download_state {
PhotoDownloadState::Downloaded(path) => {
if line_in_row == 0 {
// Регистрируем deferred render для этого фото
let x_off = if is_outgoing {
let grid_width = cols as u16 * ALBUM_PHOTO_WIDTH
+ (cols as u16).saturating_sub(1) * ALBUM_PHOTO_GAP;
let padding = content_width
.saturating_sub(grid_width as usize + 1)
as u16;
padding + col as u16 * (ALBUM_PHOTO_WIDTH + ALBUM_PHOTO_GAP)
} else {
col as u16 * (ALBUM_PHOTO_WIDTH + ALBUM_PHOTO_GAP)
};
deferred.push(DeferredImageRender {
message_id: msg.id(),
photo_path: path.clone(),
line_offset: grid_start_line
+ row * ALBUM_PHOTO_HEIGHT as usize,
x_offset: x_off,
width: ALBUM_PHOTO_WIDTH,
height: ALBUM_PHOTO_HEIGHT,
});
}
// Пустая строка — placeholder для изображения
}
PhotoDownloadState::Downloading => {
if line_in_row == ALBUM_PHOTO_HEIGHT / 2 {
spans.push(Span::styled(
"⏳ Загрузка...",
Style::default().fg(Color::Yellow),
));
}
}
PhotoDownloadState::Error(e) => {
if line_in_row == ALBUM_PHOTO_HEIGHT / 2 {
let err_text: String = e.chars().take(14).collect();
spans.push(Span::styled(
format!("{}", err_text),
Style::default().fg(Color::Red),
));
}
}
PhotoDownloadState::NotDownloaded => {
if line_in_row == ALBUM_PHOTO_HEIGHT / 2 {
spans.push(Span::styled("📷", Style::default().fg(Color::Gray)));
}
}
}
}
}
lines.push(Line::from(spans));
}
}
// Caption: собираем непустые тексты (без "📷 [Фото]" prefix)
let captions: Vec<&str> = messages
.iter()
.map(|m| m.text())
.filter(|t| !t.is_empty() && !t.starts_with("📷"))
.collect();
let msg_color = if is_selected {
config.parse_color(&config.colors.selected_message)
} else if is_outgoing {
config.parse_color(&config.colors.outgoing_message)
} else {
config.parse_color(&config.colors.incoming_message)
};
// Timestamp из последнего сообщения
let Some(last_msg) = messages.last() else {
return (lines, deferred);
};
let time = format_timestamp(last_msg.date());
if !captions.is_empty() {
let caption_text = captions.join(" ");
let time_suffix = format!(" ({})", time);
if is_outgoing {
let total_len = caption_text.chars().count() + time_suffix.chars().count();
let padding = content_width.saturating_sub(total_len + 1);
lines.push(Line::from(vec![
Span::raw(" ".repeat(padding)),
Span::styled(caption_text, Style::default().fg(msg_color)),
Span::styled(time_suffix, Style::default().fg(Color::Gray)),
]));
} else {
lines.push(Line::from(vec![
Span::styled(format!(" ({})", time), Style::default().fg(Color::Gray)),
Span::raw(" "),
Span::styled(caption_text, Style::default().fg(msg_color)),
]));
}
} else {
// Без подписи — только timestamp
let time_text = format!("({})", time);
if is_outgoing {
let padding = content_width.saturating_sub(time_text.chars().count() + 1);
lines.push(Line::from(vec![
Span::raw(" ".repeat(padding)),
Span::styled(time_text, Style::default().fg(Color::Gray)),
]));
} else {
lines.push(Line::from(vec![Span::styled(
format!(" {}", time_text),
Style::default().fg(Color::Gray),
)]));
}
}
(lines, deferred)
}
/// Вычисляет высоту изображения (в строках) с учётом пропорций
#[cfg(feature = "images")]
pub fn calculate_image_height(img_width: i32, img_height: i32, content_width: usize) -> u16 {
use crate::constants::{MAX_IMAGE_HEIGHT, MAX_IMAGE_WIDTH, MIN_IMAGE_HEIGHT};
let display_width = (content_width as u16).min(MAX_IMAGE_WIDTH);
let aspect = img_height as f64 / img_width as f64;
// Терминальные символы ~2:1 по высоте, компенсируем
let raw_height = (display_width as f64 * aspect * 0.5) as u16;
raw_height.clamp(MIN_IMAGE_HEIGHT, MAX_IMAGE_HEIGHT)
}
/// Рендерит progress bar для воспроизведения
fn render_progress_bar(position: f32, duration: f32, width: usize) -> String {
if duration <= 0.0 {
return "".repeat(width);
}
let ratio = (position / duration).clamp(0.0, 1.0);
let filled = (ratio * width as f32) as usize;
let empty = width.saturating_sub(filled + 1);
format!("{}{}", "".repeat(filled), "".repeat(empty))
}
/// Рендерит waveform из base64-encoded данных TDLib
fn render_waveform(waveform_b64: &str, width: usize) -> String {
const BARS: &[char] = &['▁', '▂', '▃', '▄', '▅', '▆', '▇', '█'];
if waveform_b64.is_empty() {
return "".repeat(width);
}
// Декодируем waveform (каждый байт = амплитуда 0-255)
use base64::Engine;
let bytes = base64::engine::general_purpose::STANDARD
.decode(waveform_b64)
.unwrap_or_default();
if bytes.is_empty() {
return "".repeat(width);
}
// Сэмплируем до нужной ширины
let mut result = String::with_capacity(width * 4);
for i in 0..width {
let byte_idx = i * bytes.len() / width;
let amplitude = bytes.get(byte_idx).copied().unwrap_or(0);
let bar_idx = (amplitude as usize * (BARS.len() - 1)) / 255;
result.push(BARS[bar_idx]);
}
result
}

View File

@@ -0,0 +1,117 @@
//! Shared message list rendering for search and pinned modals
use crate::tdlib::MessageInfo;
use ratatui::{
layout::Alignment,
style::{Color, Modifier, Style},
text::{Line, Span},
widgets::{Block, Borders, Paragraph},
};
/// Renders a single message item with marker, sender, date, and wrapped text
pub fn render_message_item(
msg: &MessageInfo,
is_selected: bool,
content_width: usize,
max_preview_lines: usize,
) -> Vec<Line<'static>> {
let mut lines = Vec::new();
// Marker, sender name, and date
let marker = if is_selected { "" } else { " " };
let sender_color = if msg.is_outgoing() {
Color::Green
} else {
Color::Cyan
};
let sender_name = if msg.is_outgoing() {
"Вы".to_string()
} else {
msg.sender_name().to_string()
};
lines.push(Line::from(vec![
Span::styled(
marker.to_string(),
Style::default()
.fg(Color::Yellow)
.add_modifier(Modifier::BOLD),
),
Span::styled(
format!("{} ", sender_name),
Style::default()
.fg(sender_color)
.add_modifier(Modifier::BOLD),
),
Span::styled(
format!("({})", crate::utils::format_datetime(msg.date())),
Style::default().fg(Color::Gray),
),
]));
// Wrapped message text
let msg_color = if is_selected {
Color::Yellow
} else {
Color::White
};
let max_width = content_width.saturating_sub(4);
let wrapped = crate::ui::messages::wrap_text_with_offsets(msg.text(), max_width);
let wrapped_count = wrapped.len();
for wrapped_line in wrapped.into_iter().take(max_preview_lines) {
lines.push(Line::from(vec![
Span::raw(" ".to_string()),
Span::styled(wrapped_line.text, Style::default().fg(msg_color)),
]));
}
if wrapped_count > max_preview_lines {
lines.push(Line::from(vec![
Span::raw(" ".to_string()),
Span::styled("...".to_string(), Style::default().fg(Color::Gray)),
]));
}
lines
}
/// Calculates scroll offset to keep selected item visible
pub fn calculate_scroll_offset(
selected_index: usize,
lines_per_item: usize,
visible_height: u16,
) -> u16 {
let visible = visible_height.saturating_sub(2) as usize;
let selected_line = selected_index * lines_per_item;
if selected_line > visible / 2 {
(selected_line - visible / 2) as u16
} else {
0
}
}
/// Renders a help bar with keyboard shortcuts
pub fn render_help_bar(
shortcuts: &[(&str, &str, Color)],
border_color: Color,
) -> Paragraph<'static> {
let mut spans: Vec<Span<'static>> = Vec::new();
for (i, (key, label, color)) in shortcuts.iter().enumerate() {
if i > 0 {
spans.push(Span::raw(" ".to_string()));
}
spans.push(Span::styled(
format!(" {} ", key),
Style::default().fg(*color).add_modifier(Modifier::BOLD),
));
spans.push(Span::raw(label.to_string()));
}
Paragraph::new(Line::from(spans))
.block(
Block::default()
.borders(Borders::ALL)
.border_style(Style::default().fg(border_color)),
)
.alignment(Alignment::Center)
}

View File

@@ -0,0 +1,17 @@
//! Reusable UI components: message bubbles, input fields, modals, lists.
pub mod chat_list_item;
pub mod emoji_picker;
pub mod input_field;
pub mod message_bubble;
pub mod message_list;
pub mod modal;
// Экспорт основных функций
pub use chat_list_item::render_chat_list_item;
pub use emoji_picker::render_emoji_picker;
pub use input_field::render_input_field;
#[cfg(feature = "images")]
pub use message_bubble::{calculate_image_height, render_album_bubble, DeferredImageRender};
pub use message_bubble::{render_date_separator, render_message_bubble, render_sender_header};
pub use message_list::{calculate_scroll_offset, render_help_bar, render_message_item};

View File

@@ -0,0 +1,83 @@
use ratatui::{
layout::{Alignment, Rect},
style::{Color, Modifier, Style},
text::Line,
widgets::{Block, Borders, Clear, Paragraph},
Frame,
};
/// Рендерит центрированную модалку с заданным содержимым
///
/// # Параметры
/// - `f`: Frame для рендеринга
/// - `area`: Область экрана
/// - `title`: Заголовок модалки
/// - `content`: Содержимое модалки (строки текста)
/// - `width`: Ширина модалки
/// - `height`: Высота модалки
/// - `border_color`: Цвет рамки
pub fn render_modal(
f: &mut Frame,
area: Rect,
title: &str,
content: Vec<Line>,
width: u16,
height: u16,
border_color: Color,
) {
// Центрируем модалку
let x = area.x + (area.width.saturating_sub(width)) / 2;
let y = area.y + (area.height.saturating_sub(height)) / 2;
let modal_area = Rect::new(x, y, width.min(area.width), height.min(area.height));
// Очищаем область под модалкой
f.render_widget(Clear, modal_area);
// Рендерим модалку
let modal = Paragraph::new(content)
.block(
Block::default()
.borders(Borders::ALL)
.border_style(Style::default().fg(border_color))
.title(format!(" {} ", title))
.title_style(
Style::default()
.fg(border_color)
.add_modifier(Modifier::BOLD),
),
)
.alignment(Alignment::Center);
f.render_widget(modal, modal_area);
}
/// Рендерит модалку подтверждения удаления
pub fn render_delete_confirm_modal(f: &mut Frame, area: Rect) {
use ratatui::text::Span;
let content = vec![
Line::from(""),
Line::from(Span::styled(
"Удалить сообщение?",
Style::default()
.fg(Color::White)
.add_modifier(Modifier::BOLD),
)),
Line::from(""),
Line::from(vec![
Span::styled(
" [y/Enter] ",
Style::default()
.fg(Color::Green)
.add_modifier(Modifier::BOLD),
),
Span::raw("Да"),
Span::raw(" "),
Span::styled(" [n/Esc] ", Style::default().fg(Color::Red).add_modifier(Modifier::BOLD)),
Span::raw("Нет"),
]),
];
render_modal(f, area, "Подтверждение", content, 40, 7, Color::Red);
}

View File

@@ -0,0 +1,194 @@
//! Compose bar / input box rendering
use crate::app::methods::{compose::ComposeMethods, messages::MessageMethods};
use crate::app::App;
use crate::app::InputMode;
use crate::tdlib::TdClientTrait;
use crate::ui::components;
use ratatui::{
layout::Rect,
style::{Color, Modifier, Style},
text::{Line, Span},
widgets::{Block, Borders, Paragraph},
Frame,
};
/// Renders input field with cursor at the specified position
fn render_input_with_cursor(
prefix: &str,
text: &str,
cursor_pos: usize,
color: Color,
) -> Line<'static> {
components::render_input_field(prefix, text, cursor_pos, color)
}
/// Renders input box with support for different modes (forward/select/edit/reply/normal)
pub fn render<T: TdClientTrait>(f: &mut Frame, area: Rect, app: &App<T>) {
let (input_line, input_title): (Line, &str) = if app.is_forwarding() {
// Режим пересылки - показываем превью сообщения
let forward_preview = app
.get_forwarding_message()
.map(|m| {
let text_preview: String = m.text().chars().take(40).collect();
let ellipsis = if m.text().chars().count() > 40 {
"..."
} else {
""
};
format!("{}{}", text_preview, ellipsis)
})
.unwrap_or_else(|| "↪ ...".to_string());
let line = Line::from(Span::styled(forward_preview, Style::default().fg(Color::Cyan)));
(line, " Выберите чат ← ")
} else if app.is_selecting_message() {
// Режим выбора сообщения - подсказка зависит от возможностей
let selected_msg = app.get_selected_message();
let can_edit = selected_msg
.as_ref()
.map(|m| m.can_be_edited() && m.is_outgoing())
.unwrap_or(false);
let can_delete = selected_msg
.as_ref()
.map(|m| m.can_be_deleted_only_for_self() || m.can_be_deleted_for_all_users())
.unwrap_or(false);
let hint = match (can_edit, can_delete) {
(true, true) => "↑↓ · Enter ред. · r ответ · f перслть · y копир. · d удал. · Esc",
(true, false) => "↑↓ · Enter ред. · r ответ · f переслть · y копир. · Esc",
(false, true) => "↑↓ · r ответ · f переслать · y копир. · d удалить · Esc",
(false, false) => "↑↓ · r ответить · f переслать · y копировать · Esc",
};
(
Line::from(Span::styled(hint, Style::default().fg(Color::Cyan))),
" Выбор сообщения ",
)
} else if app.is_editing() {
// Режим редактирования
if app.message_input.is_empty() {
let line = Line::from(vec![
Span::raw(""),
Span::styled("", Style::default().fg(Color::Magenta)),
Span::styled(" Введите новый текст...", Style::default().fg(Color::Gray)),
]);
(line, " Редактирование (Esc отмена) ")
} else {
let line = render_input_with_cursor(
"",
&app.message_input,
app.cursor_position,
Color::Magenta,
);
(line, " Редактирование (Esc отмена) ")
}
} else if app.is_replying() {
// Режим ответа на сообщение
let reply_preview = app
.get_replying_to_message()
.map(|m| {
let sender = if m.is_outgoing() {
"Вы"
} else {
m.sender_name()
};
let text_preview: String = m.text().chars().take(30).collect();
let ellipsis = if m.text().chars().count() > 30 {
"..."
} else {
""
};
format!("{}: {}{}", sender, text_preview, ellipsis)
})
.unwrap_or_else(|| "...".to_string());
if app.message_input.is_empty() {
let line = Line::from(vec![
Span::styled("", Style::default().fg(Color::Cyan)),
Span::styled(reply_preview, Style::default().fg(Color::Gray)),
Span::raw(" "),
Span::styled("", Style::default().fg(Color::Yellow)),
]);
(line, " Ответ (Esc отмена) ")
} else {
let short_preview: String = reply_preview.chars().take(15).collect();
let prefix = format!("{} > ", short_preview);
let line = render_input_with_cursor(
&prefix,
&app.message_input,
app.cursor_position,
Color::Yellow,
);
(line, " Ответ (Esc отмена) ")
}
} else if app.input_mode == InputMode::Normal {
// Normal mode — dim, no cursor
if app.message_input.is_empty() {
let line = Line::from(vec![Span::styled(
"> Press i to type...",
Style::default().fg(Color::DarkGray),
)]);
(line, "")
} else {
let draft_preview: String = app.message_input.chars().take(60).collect();
let ellipsis = if app.message_input.chars().count() > 60 {
"..."
} else {
""
};
let line = Line::from(Span::styled(
format!("> {}{}", draft_preview, ellipsis),
Style::default().fg(Color::DarkGray),
));
(line, "")
}
} else {
// Insert mode — active, with cursor
if app.message_input.is_empty() {
let line = Line::from(vec![
Span::raw("> "),
Span::styled("", Style::default().fg(Color::Yellow)),
Span::styled(" Введите сообщение...", Style::default().fg(Color::Gray)),
]);
(line, "")
} else {
let line = render_input_with_cursor(
"> ",
&app.message_input,
app.cursor_position,
Color::Yellow,
);
(line, "")
}
};
let input_block = if input_title.is_empty() {
let border_style = if app.input_mode == InputMode::Insert {
Style::default().fg(Color::Green)
} else {
Style::default().fg(Color::DarkGray)
};
Block::default()
.borders(Borders::ALL)
.border_style(border_style)
} else {
let title_color = if app.is_replying() || app.is_forwarding() {
Color::Cyan
} else {
Color::Magenta
};
Block::default()
.borders(Borders::ALL)
.title(input_title)
.title_style(
Style::default()
.fg(title_color)
.add_modifier(Modifier::BOLD),
)
};
let input = Paragraph::new(input_line)
.block(input_block)
.wrap(ratatui::widgets::Wrap { trim: false });
f.render_widget(input, area);
}

View File

@@ -0,0 +1,60 @@
use crate::app::App;
use crate::app::InputMode;
use crate::tdlib::NetworkState;
use crate::tdlib::TdClientTrait;
use ratatui::{
layout::Rect,
style::{Color, Style},
widgets::Paragraph,
Frame,
};
pub fn render<T: TdClientTrait>(f: &mut Frame, area: Rect, app: &App<T>) {
// Индикатор состояния сети
let network_indicator = match app.td_client.network_state() {
NetworkState::Ready => "",
NetworkState::WaitingForNetwork => "⚠ Нет сети | ",
NetworkState::ConnectingToProxy => "⏳ Прокси... | ",
NetworkState::Connecting => "⏳ Подключение... | ",
NetworkState::Updating => "⏳ Обновление... | ",
};
let account_indicator = format!("[{}] ", app.current_account_name);
let status = if let Some(msg) = &app.status_message {
format!(" {}{}{} ", account_indicator, network_indicator, msg)
} else if let Some(err) = &app.error_message {
format!(" {}{}Error: {} ", account_indicator, network_indicator, err)
} else if app.is_searching {
format!(
" {}{}↑/↓: Navigate | Enter: Select | Esc: Cancel ",
account_indicator, network_indicator
)
} else if app.selected_chat_id.is_some() {
let mode_str = match app.input_mode {
InputMode::Normal => "[NORMAL] j/k: Nav | i: Insert | d/r/f/y: Actions | Esc: Close",
InputMode::Insert => "[INSERT] Type message | Esc: Normal mode",
};
format!(" {}{}{} | Ctrl+C: Quit ", account_indicator, network_indicator, mode_str)
} else {
format!(
" {}{}↑/↓: Navigate | Enter: Open | Ctrl+S: Search | Ctrl+R: Refresh | Ctrl+C: Quit ",
account_indicator, network_indicator
)
};
let style = if matches!(app.td_client.network_state(), NetworkState::WaitingForNetwork) {
Style::default().fg(Color::Red)
} else if !matches!(app.td_client.network_state(), NetworkState::Ready) {
Style::default().fg(Color::Cyan)
} else if app.error_message.is_some() {
Style::default().fg(Color::Red)
} else if app.status_message.is_some() {
Style::default().fg(Color::Yellow)
} else {
Style::default().fg(Color::Rgb(160, 160, 160))
};
let footer = Paragraph::new(status).style(style);
f.render_widget(footer, area);
}

View File

@@ -0,0 +1,34 @@
use crate::app::App;
use crate::tdlib::TdClientTrait;
use ratatui::{
layout::{Alignment, Constraint, Direction, Layout},
style::{Color, Modifier, Style},
widgets::{Block, Borders, Paragraph},
Frame,
};
pub fn render<T: TdClientTrait>(f: &mut Frame, app: &App<T>) {
let area = f.area();
let chunks = Layout::default()
.direction(Direction::Vertical)
.constraints([
Constraint::Percentage(40),
Constraint::Length(5),
Constraint::Percentage(40),
])
.split(area);
let message = app.status_message.as_deref().unwrap_or("Загрузка...");
let loading = Paragraph::new(message)
.style(
Style::default()
.fg(Color::Cyan)
.add_modifier(Modifier::BOLD),
)
.alignment(Alignment::Center)
.block(Block::default().borders(Borders::ALL).title(" TTUI "));
f.render_widget(loading, chunks[1]);
}

View File

@@ -0,0 +1,89 @@
use super::{chat_list, footer, messages};
use crate::app::App;
use crate::tdlib::TdClientTrait;
use ratatui::{
layout::{Constraint, Direction, Layout, Rect},
style::{Color, Modifier, Style},
text::{Line, Span},
widgets::{Block, Borders, Paragraph},
Frame,
};
/// Порог ширины для компактного режима (одна панель)
const COMPACT_WIDTH: u16 = 80;
pub fn render<T: TdClientTrait>(f: &mut Frame, app: &mut App<T>) {
let area = f.area();
let is_compact = area.width < COMPACT_WIDTH;
let chunks = Layout::default()
.direction(Direction::Vertical)
.constraints([
Constraint::Length(3), // Folders/tabs
Constraint::Min(0), // Main content
Constraint::Length(1), // Commands footer
])
.split(area);
render_folders(f, chunks[0], app);
if is_compact {
// Компактный режим: показываем либо список чатов, либо открытый чат
if app.selected_chat_id.is_some() {
// Чат открыт — показываем только сообщения
messages::render(f, chunks[1], app);
} else {
// Чат не открыт — показываем только список чатов
chat_list::render(f, chunks[1], app);
}
} else {
// Обычный режим: две панели
let main_chunks = Layout::default()
.direction(Direction::Horizontal)
.constraints([
Constraint::Percentage(30), // Chat list
Constraint::Percentage(70), // Messages area
])
.split(chunks[1]);
chat_list::render(f, main_chunks[0], app);
messages::render(f, main_chunks[1], app);
}
footer::render(f, chunks[2], app);
}
fn render_folders<T: TdClientTrait>(f: &mut Frame, area: Rect, app: &App<T>) {
let mut spans = vec![];
// "All" всегда первая (клавиша 1)
let all_style = if app.selected_folder_id.is_none() {
Style::default()
.fg(Color::Yellow)
.add_modifier(Modifier::BOLD)
} else {
Style::default().fg(Color::White)
};
spans.push(Span::styled(" 1:All ", all_style));
// Папки из TDLib (клавиши 2, 3, 4...)
for (i, folder) in app.td_client.folders().iter().enumerate() {
spans.push(Span::raw(""));
let style = if app.selected_folder_id == Some(folder.id) {
Style::default()
.fg(Color::Yellow)
.add_modifier(Modifier::BOLD)
} else {
Style::default().fg(Color::White)
};
spans.push(Span::styled(format!(" {}:{} ", i + 2, folder.name), style));
}
let folders_line = Line::from(spans);
let folders_widget =
Paragraph::new(folders_line).block(Block::default().title(" TTUI ").borders(Borders::ALL));
f.render_widget(folders_widget, area);
}

View File

@@ -0,0 +1,102 @@
//! Chat message area rendering.
mod header;
mod list;
mod pinned;
use crate::app::methods::{modal::ModalMethods, search::SearchMethods};
use crate::app::App;
use crate::tdlib::TdClientTrait;
use crate::ui::{compose_bar, modals};
use ratatui::{
layout::{Alignment, Constraint, Direction, Layout, Rect},
style::{Color, Style},
widgets::{Block, Borders, Paragraph},
Frame,
};
use header::render_chat_header;
use list::render_message_list;
use pinned::render_pinned_bar;
pub(crate) use list::wrap_text_with_offsets;
pub fn render<T: TdClientTrait>(f: &mut Frame, area: Rect, app: &mut App<T>) {
#[cfg(feature = "images")]
if let Some(modal_state) = app.image_modal.clone() {
modals::render_image_viewer(f, app, &modal_state);
return;
}
if app.is_profile_mode() {
if let Some(profile) = app.get_profile_info() {
crate::ui::profile::render(f, area, app, profile);
}
return;
}
if app.is_message_search_mode() {
modals::render_search(f, area, app);
return;
}
if app.is_pinned_mode() {
modals::render_pinned(f, area, app);
return;
}
if let Some(chat) = app.get_selected_chat().cloned() {
let input_width = area.width.saturating_sub(4) as usize;
let input_lines: u16 = if input_width > 0 {
let len = app.message_input.chars().count() + 2;
((len as f32 / input_width as f32).ceil() as u16).max(1)
} else {
1
};
let input_height = (input_lines + 2).clamp(3, 10);
let has_pinned = app.td_client.current_pinned_message().is_some();
let message_chunks = if has_pinned {
Layout::default()
.direction(Direction::Vertical)
.constraints([
Constraint::Length(3),
Constraint::Length(1),
Constraint::Min(0),
Constraint::Length(input_height),
])
.split(area)
} else {
Layout::default()
.direction(Direction::Vertical)
.constraints([
Constraint::Length(3),
Constraint::Length(0),
Constraint::Min(0),
Constraint::Length(input_height),
])
.split(area)
};
render_chat_header(f, message_chunks[0], app, &chat);
render_pinned_bar(f, message_chunks[1], app);
render_message_list(f, message_chunks[2], app);
compose_bar::render(f, message_chunks[3], app);
} else {
let empty = Paragraph::new("Выберите чат")
.block(Block::default().borders(Borders::ALL))
.style(Style::default().fg(Color::Gray))
.alignment(Alignment::Center);
f.render_widget(empty, area);
}
if app.is_confirm_delete_shown() {
modals::render_delete_confirm(f, area);
}
if let crate::app::ChatState::ReactionPicker { available_reactions, selected_index, .. } =
&app.chat_state
{
modals::render_reaction_picker(f, area, available_reactions, *selected_index);
}
}

View File

@@ -0,0 +1,55 @@
use crate::app::App;
use crate::tdlib::{ChatInfo, TdClientTrait};
use ratatui::{
layout::Rect,
style::{Color, Modifier, Style},
text::{Line, Span},
widgets::{Block, Borders, Paragraph},
Frame,
};
pub(super) fn render_chat_header<T: TdClientTrait>(
f: &mut Frame,
area: Rect,
app: &App<T>,
chat: &ChatInfo,
) {
let typing_action = app
.td_client
.typing_status()
.as_ref()
.map(|(_, action, _)| action.clone());
let header_line = if let Some(action) = typing_action {
let mut spans = vec![Span::styled(
format!("👤 {}", chat.title),
Style::default()
.fg(Color::Cyan)
.add_modifier(Modifier::BOLD),
)];
if let Some(username) = &chat.username {
spans.push(Span::styled(format!(" {}", username), Style::default().fg(Color::Gray)));
}
spans.push(Span::styled(
format!(" {}", action),
Style::default()
.fg(Color::Yellow)
.add_modifier(Modifier::ITALIC),
));
Line::from(spans)
} else {
let header_text = match &chat.username {
Some(username) => format!("👤 {} {}", chat.title, username),
None => format!("👤 {}", chat.title),
};
Line::from(Span::styled(
header_text,
Style::default()
.fg(Color::Cyan)
.add_modifier(Modifier::BOLD),
))
};
let header = Paragraph::new(header_line).block(Block::default().borders(Borders::ALL));
f.render_widget(header, area);
}

View File

@@ -0,0 +1,286 @@
use crate::app::methods::messages::MessageMethods;
use crate::app::App;
use crate::message_grouping::{group_messages, MessageGroup};
use crate::tdlib::TdClientTrait;
use crate::ui::components;
use ratatui::{
layout::Rect,
style::{Color, Style},
text::{Line, Span},
widgets::{Block, Borders, Paragraph},
Frame,
};
/// Информация о строке после переноса: текст и позиция в оригинале.
pub(crate) struct WrappedLine {
pub text: String,
}
/// Разбивает текст на строки с учётом максимальной ширины.
pub(crate) fn wrap_text_with_offsets(text: &str, max_width: usize) -> Vec<WrappedLine> {
if max_width == 0 {
return vec![WrappedLine { text: text.to_string() }];
}
let mut result = Vec::new();
let mut current_line = String::new();
let mut current_width = 0;
let chars: Vec<char> = text.chars().collect();
let mut word_start = 0;
let mut in_word = false;
for (i, ch) in chars.iter().enumerate() {
if ch.is_whitespace() {
if in_word {
let word: String = chars[word_start..i].iter().collect();
let word_width = word.chars().count();
if current_width == 0 {
current_line = word;
current_width = word_width;
} else if current_width + 1 + word_width <= max_width {
current_line.push(' ');
current_line.push_str(&word);
current_width += 1 + word_width;
} else {
result.push(WrappedLine { text: current_line });
current_line = word;
current_width = word_width;
}
in_word = false;
}
} else if !in_word {
word_start = i;
in_word = true;
}
}
if in_word {
let word: String = chars[word_start..].iter().collect();
let word_width = word.chars().count();
if current_width == 0 {
current_line = word;
} else if current_width + 1 + word_width <= max_width {
current_line.push(' ');
current_line.push_str(&word);
} else {
result.push(WrappedLine { text: current_line });
current_line = word;
}
}
if !current_line.is_empty() {
result.push(WrappedLine { text: current_line });
}
if result.is_empty() {
result.push(WrappedLine { text: String::new() });
}
result
}
/// Рендерит список сообщений с группировкой по дате/отправителю и автоскроллом.
pub(super) fn render_message_list<T: TdClientTrait>(f: &mut Frame, area: Rect, app: &mut App<T>) {
let content_width = area.width.saturating_sub(2) as usize;
let mut lines: Vec<Line> = Vec::new();
let selected_msg_id = app.get_selected_message().map(|m| m.id());
let mut selected_msg_line: Option<usize> = None;
#[cfg(feature = "images")]
let mut deferred_images: Vec<components::DeferredImageRender> = Vec::new();
let current_messages = app.td_client.current_chat_messages();
let grouped = group_messages(&current_messages);
let mut is_first_date = true;
let mut is_first_sender = true;
for group in grouped {
match group {
MessageGroup::DateSeparator(date) => {
lines.extend(components::render_date_separator(date, content_width, is_first_date));
is_first_date = false;
is_first_sender = true;
}
MessageGroup::SenderHeader { is_outgoing, sender_name } => {
lines.extend(components::render_sender_header(
is_outgoing,
&sender_name,
content_width,
is_first_sender,
));
is_first_sender = false;
}
MessageGroup::Message(msg) => {
let is_selected = selected_msg_id == Some(msg.id());
if is_selected {
selected_msg_line = Some(lines.len());
}
let bubble_lines = components::render_message_bubble(
msg,
app.config(),
content_width,
selected_msg_id,
app.playback_state.as_ref(),
);
#[cfg(feature = "images")]
if let Some(photo) = msg.photo_info() {
if let crate::tdlib::PhotoDownloadState::Downloaded(path) =
&photo.download_state
{
let inline_width =
content_width.min(crate::constants::INLINE_IMAGE_MAX_WIDTH);
let img_height = components::calculate_image_height(
photo.width,
photo.height,
inline_width,
);
let img_width = inline_width as u16;
let bubble_len = bubble_lines.len();
let placeholder_start = lines.len() + bubble_len - img_height as usize;
deferred_images.push(components::DeferredImageRender {
message_id: msg.id(),
photo_path: path.clone(),
line_offset: placeholder_start,
x_offset: 0,
width: img_width,
height: img_height,
});
}
}
lines.extend(bubble_lines);
}
MessageGroup::Album(album_messages) => {
#[cfg(feature = "images")]
{
let is_selected = album_messages
.iter()
.any(|m| selected_msg_id == Some(m.id()));
if is_selected {
selected_msg_line = Some(lines.len());
}
let (bubble_lines, album_deferred) = components::render_album_bubble(
&album_messages,
app.config(),
content_width,
selected_msg_id,
);
for mut d in album_deferred {
d.line_offset += lines.len();
deferred_images.push(d);
}
lines.extend(bubble_lines);
}
#[cfg(not(feature = "images"))]
{
for msg in &album_messages {
let is_selected = selected_msg_id == Some(msg.id());
if is_selected {
selected_msg_line = Some(lines.len());
}
lines.extend(components::render_message_bubble(
msg,
app.config(),
content_width,
selected_msg_id,
app.playback_state.as_ref(),
));
}
}
}
}
}
if lines.is_empty() {
lines.push(Line::from(Span::styled("Нет сообщений", Style::default().fg(Color::Gray))));
}
let visible_height = area.height.saturating_sub(2) as usize;
let total_lines = lines.len();
let base_scroll = total_lines.saturating_sub(visible_height);
let scroll_offset = if app.is_selecting_message() {
if let Some(selected_line) = selected_msg_line {
if selected_line < visible_height / 2 {
0
} else if selected_line > total_lines.saturating_sub(visible_height / 2) {
base_scroll
} else {
selected_line.saturating_sub(visible_height / 2)
}
} else {
base_scroll.saturating_sub(app.message_scroll_offset)
}
} else {
base_scroll.saturating_sub(app.message_scroll_offset)
} as u16;
let messages_widget = Paragraph::new(lines)
.block(Block::default().borders(Borders::ALL))
.scroll((scroll_offset, 0));
f.render_widget(messages_widget, area);
#[cfg(feature = "images")]
render_deferred_images(f, area, app, &deferred_images, visible_height, scroll_offset);
}
#[cfg(feature = "images")]
fn render_deferred_images<T: TdClientTrait>(
f: &mut Frame,
area: Rect,
app: &mut App<T>,
deferred_images: &[components::DeferredImageRender],
visible_height: usize,
scroll_offset: u16,
) {
use ratatui_image::StatefulImage;
let should_render_images = app
.last_image_render_time
.map(|t| t.elapsed() > std::time::Duration::from_millis(66))
.unwrap_or(true);
if deferred_images.is_empty() || !should_render_images {
return;
}
let content_x = area.x + 1;
let content_y = area.y + 1;
for d in deferred_images {
let y_in_content = d.line_offset as i32 - scroll_offset as i32;
if y_in_content < 0 || y_in_content as usize >= visible_height {
continue;
}
let img_y = content_y + y_in_content as u16;
let remaining_height = (content_y + visible_height as u16).saturating_sub(img_y);
if d.height > remaining_height {
continue;
}
let img_rect = Rect::new(content_x + d.x_offset, img_y, d.width, d.height);
if let Some(renderer) = &mut app.inline_image_renderer {
let _ = renderer.load_image(d.message_id, &d.photo_path);
if let Some(protocol) = renderer.get_protocol(&d.message_id) {
f.render_stateful_widget(StatefulImage::default(), img_rect, protocol);
}
}
}
app.last_image_render_time = Some(std::time::Instant::now());
}

View File

@@ -0,0 +1,38 @@
use crate::app::App;
use crate::tdlib::TdClientTrait;
use ratatui::{
layout::Rect,
style::{Color, Style},
text::{Line, Span},
widgets::Paragraph,
Frame,
};
pub(super) fn render_pinned_bar<T: TdClientTrait>(f: &mut Frame, area: Rect, app: &App<T>) {
let Some(pinned_msg) = app.td_client.current_pinned_message() else {
return;
};
let pinned_preview: String = pinned_msg.text().chars().take(40).collect();
let ellipsis = if pinned_msg.text().chars().count() > 40 {
"..."
} else {
""
};
let pinned_datetime = crate::utils::format_datetime(pinned_msg.date());
let pinned_text = format!("📌 {} {}{}", pinned_datetime, pinned_preview, ellipsis);
let pinned_hint = "Ctrl+P";
let pinned_bar_width = area.width as usize;
let text_len = pinned_text.chars().count();
let hint_len = pinned_hint.chars().count();
let padding = pinned_bar_width.saturating_sub(text_len + hint_len + 2);
let pinned_line = Line::from(vec![
Span::styled(pinned_text, Style::default().fg(Color::Magenta)),
Span::raw(" ".repeat(padding)),
Span::styled(pinned_hint, Style::default().fg(Color::Gray)),
]);
let pinned_bar = Paragraph::new(pinned_line).style(Style::default().bg(Color::Rgb(40, 20, 40)));
f.render_widget(pinned_bar, area);
}

View File

@@ -0,0 +1,59 @@
//! UI rendering module.
//!
//! Routes rendering by screen (Loading → Auth → Main) and checks terminal size.
mod auth;
pub mod chat_list;
pub mod components;
mod compose_bar;
pub mod footer;
mod loading;
mod main_screen;
pub mod messages;
mod modals;
pub mod profile;
use crate::app::{App, AppScreen};
use crate::tdlib::TdClientTrait;
use ratatui::layout::Alignment;
use ratatui::style::{Color, Modifier, Style};
use ratatui::widgets::Paragraph;
use ratatui::Frame;
/// Минимальная высота терминала
const MIN_HEIGHT: u16 = 10;
/// Минимальная ширина терминала
const MIN_WIDTH: u16 = 40;
pub fn render<T: TdClientTrait>(f: &mut Frame, app: &mut App<T>) {
let area = f.area();
// Проверяем минимальный размер терминала
if area.width < MIN_WIDTH || area.height < MIN_HEIGHT {
render_size_warning(f, area.width, area.height);
return;
}
match app.screen {
AppScreen::Loading => loading::render(f, app),
AppScreen::Auth => auth::render(f, app),
AppScreen::Main => main_screen::render(f, app),
}
// Global overlay: account switcher (renders on top of ANY screen)
if app.account_switcher.is_some() {
modals::render_account_switcher(f, area, app);
}
}
fn render_size_warning(f: &mut Frame, width: u16, height: u16) {
let message = format!("{}x{}\nМинимум: {}x{}", width, height, MIN_WIDTH, MIN_HEIGHT);
let warning = Paragraph::new(message)
.style(
Style::default()
.fg(Color::Yellow)
.add_modifier(Modifier::BOLD),
)
.alignment(Alignment::Center);
f.render_widget(warning, f.area());
}

View File

@@ -0,0 +1,190 @@
//! Account switcher modal
//!
//! Renders a centered popup with account list (SelectAccount) or
//! new account name input (AddAccount).
use crate::app::{AccountSwitcherState, App};
use crate::tdlib::TdClientTrait;
use ratatui::{
layout::Rect,
style::{Color, Modifier, Style},
text::{Line, Span},
widgets::{Block, Borders, Clear, Paragraph},
Frame,
};
/// Renders the account switcher modal overlay.
pub fn render<T: TdClientTrait>(f: &mut Frame, area: Rect, app: &App<T>) {
let Some(state) = &app.account_switcher else {
return;
};
match state {
AccountSwitcherState::SelectAccount { accounts, selected_index, current_account } => {
render_select_account(f, area, accounts, *selected_index, current_account);
}
AccountSwitcherState::AddAccount { name_input, cursor_position, error } => {
render_add_account(f, area, name_input, *cursor_position, error.as_deref());
}
}
}
fn render_select_account(
f: &mut Frame,
area: Rect,
accounts: &[crate::accounts::AccountProfile],
selected_index: usize,
current_account: &str,
) {
let mut lines: Vec<Line> = Vec::new();
lines.push(Line::from(""));
for (idx, account) in accounts.iter().enumerate() {
let is_selected = idx == selected_index;
let is_current = account.name == current_account;
let marker = if is_current { "" } else { " " };
let suffix = if is_current { " (текущий)" } else { "" };
let display = format!("{}{} ({}){}", marker, account.name, account.display_name, suffix);
let style = if is_selected {
Style::default()
.fg(Color::Yellow)
.add_modifier(Modifier::BOLD)
} else if is_current {
Style::default().fg(Color::Green)
} else {
Style::default().fg(Color::White)
};
lines.push(Line::from(Span::styled(format!(" {}", display), style)));
}
// Separator
lines.push(Line::from(Span::styled(
" ──────────────────────",
Style::default().fg(Color::DarkGray),
)));
// Add account item
let add_selected = selected_index == accounts.len();
let add_style = if add_selected {
Style::default()
.fg(Color::Yellow)
.add_modifier(Modifier::BOLD)
} else {
Style::default().fg(Color::Cyan)
};
lines.push(Line::from(Span::styled(" + Добавить аккаунт", add_style)));
lines.push(Line::from(""));
// Help bar
lines.push(Line::from(vec![
Span::styled(" j/k ", Style::default().fg(Color::Yellow)),
Span::styled("Nav", Style::default().fg(Color::DarkGray)),
Span::raw(" "),
Span::styled(" Enter ", Style::default().fg(Color::Green)),
Span::styled("Select", Style::default().fg(Color::DarkGray)),
Span::raw(" "),
Span::styled(" a ", Style::default().fg(Color::Cyan)),
Span::styled("Add", Style::default().fg(Color::DarkGray)),
Span::raw(" "),
Span::styled(" Esc ", Style::default().fg(Color::Red)),
Span::styled("Close", Style::default().fg(Color::DarkGray)),
]));
// Calculate dynamic height: header(3) + accounts + separator(1) + add(1) + empty(1) + help(1) + footer(1)
let content_height = (accounts.len() as u16) + 7;
let height = content_height.min(area.height.saturating_sub(4));
let width = 40u16.min(area.width.saturating_sub(4));
let x = area.x + (area.width.saturating_sub(width)) / 2;
let y = area.y + (area.height.saturating_sub(height)) / 2;
let modal_area = Rect::new(x, y, width, height);
f.render_widget(Clear, modal_area);
let modal = Paragraph::new(lines).block(
Block::default()
.borders(Borders::ALL)
.border_style(Style::default().fg(Color::Cyan))
.title(" АККАУНТЫ ")
.title_style(
Style::default()
.fg(Color::Cyan)
.add_modifier(Modifier::BOLD),
),
);
f.render_widget(modal, modal_area);
}
fn render_add_account(
f: &mut Frame,
area: Rect,
name_input: &str,
_cursor_position: usize,
error: Option<&str>,
) {
let mut lines: Vec<Line> = Vec::new();
lines.push(Line::from(""));
// Input field
let input_display = if name_input.is_empty() {
Span::styled("_", Style::default().fg(Color::DarkGray))
} else {
Span::styled(format!("{}_", name_input), Style::default().fg(Color::White))
};
lines.push(Line::from(vec![
Span::styled(" Имя: ", Style::default().fg(Color::Cyan)),
input_display,
]));
// Hint
lines.push(Line::from(Span::styled(
" (a-z, 0-9, -, _)",
Style::default().fg(Color::DarkGray),
)));
lines.push(Line::from(""));
// Error
if let Some(err) = error {
lines.push(Line::from(Span::styled(format!(" {}", err), Style::default().fg(Color::Red))));
lines.push(Line::from(""));
}
// Help bar
lines.push(Line::from(vec![
Span::styled(" Enter ", Style::default().fg(Color::Green)),
Span::styled("Create", Style::default().fg(Color::DarkGray)),
Span::raw(" "),
Span::styled(" Esc ", Style::default().fg(Color::Red)),
Span::styled("Back", Style::default().fg(Color::DarkGray)),
]));
let height = if error.is_some() { 10 } else { 8 };
let height = (height as u16).min(area.height.saturating_sub(4));
let width = 40u16.min(area.width.saturating_sub(4));
let x = area.x + (area.width.saturating_sub(width)) / 2;
let y = area.y + (area.height.saturating_sub(height)) / 2;
let modal_area = Rect::new(x, y, width, height);
f.render_widget(Clear, modal_area);
let modal = Paragraph::new(lines).block(
Block::default()
.borders(Borders::ALL)
.border_style(Style::default().fg(Color::Cyan))
.title(" НОВЫЙ АККАУНТ ")
.title_style(
Style::default()
.fg(Color::Cyan)
.add_modifier(Modifier::BOLD),
),
);
f.render_widget(modal, modal_area);
}

View File

@@ -0,0 +1,8 @@
//! Delete confirmation modal
use ratatui::{layout::Rect, Frame};
/// Renders delete confirmation modal
pub fn render(f: &mut Frame, area: Rect) {
crate::ui::components::modal::render_delete_confirm_modal(f, area);
}

View File

@@ -0,0 +1,178 @@
//! Модальное окно для полноэкранного просмотра изображений.
//!
//! Поддерживает:
//! - Автоматическое масштабирование с сохранением aspect ratio
//! - Максимизация по ширине/высоте терминала
//! - Затемнение фона
//! - Hotkeys: Esc/q для закрытия, ←/→ для навигации между фото
use crate::app::App;
use crate::tdlib::r#trait::TdClientTrait;
use crate::tdlib::ImageModalState;
use ratatui::{
layout::{Alignment, Rect},
style::{Color, Style},
text::{Line, Span},
widgets::{Block, Clear, Paragraph},
Frame,
};
use ratatui_image::StatefulImage;
/// Рендерит модальное окно с полноэкранным изображением
pub fn render<T: TdClientTrait>(f: &mut Frame, app: &mut App<T>, modal_state: &ImageModalState) {
let area = f.area();
// Затемняем весь фон
f.render_widget(Clear, area);
f.render_widget(Block::default().style(Style::default().bg(Color::Black)), area);
// Резервируем место для подсказок (2 строки внизу)
let image_area_height = area.height.saturating_sub(2);
// Вычисляем размер изображения с сохранением aspect ratio
let (img_width, img_height) = calculate_modal_size(
modal_state.photo_width,
modal_state.photo_height,
area.width,
image_area_height,
);
// Центрируем изображение
let img_x = (area.width.saturating_sub(img_width)) / 2;
let img_y = (image_area_height.saturating_sub(img_height)) / 2;
let img_rect = Rect::new(img_x, img_y, img_width, img_height);
// Рендерим изображение (используем modal_renderer для высокого качества)
if let Some(renderer) = &mut app.modal_image_renderer {
// Проверяем есть ли протокол уже в кеше
if let Some(protocol) = renderer.get_protocol(&modal_state.message_id) {
// Протокол готов - рендерим изображение (iTerm2/Sixel - высокое качество)
f.render_stateful_widget(StatefulImage::default(), img_rect, protocol);
} else {
// Протокола нет - показываем индикатор загрузки
let loading_text = vec![
Line::from(""),
Line::from(Span::styled(
"⏳ Загрузка изображения...",
Style::default().fg(Color::Gray),
)),
Line::from(""),
Line::from(Span::styled(
"(декодирование в высоком качестве)",
Style::default().fg(Color::DarkGray),
)),
];
let loading = Paragraph::new(loading_text)
.alignment(Alignment::Center)
.block(Block::default());
f.render_widget(loading, img_rect);
// Загружаем изображение (может занять время для iTerm2/Sixel)
let _ = renderer.load_image(modal_state.message_id, &modal_state.photo_path);
// Триггерим перерисовку для показа загруженного изображения
app.needs_redraw = true;
}
}
// Подсказки внизу
let hint = "[Esc/q] Закрыть [←/→] Пред/След фото";
let hint_y = area.height.saturating_sub(1);
let hint_rect = Rect::new(0, hint_y, area.width, 1);
f.render_widget(
Paragraph::new(Span::styled(hint, Style::default().fg(Color::Gray)))
.alignment(Alignment::Center),
hint_rect,
);
// Информация о размере (опционально)
let info = format!(
"{}x{} | {:.1}%",
modal_state.photo_width,
modal_state.photo_height,
(img_width as f64 / modal_state.photo_width as f64) * 100.0
);
let info_y = area.height.saturating_sub(2);
let info_rect = Rect::new(0, info_y, area.width, 1);
f.render_widget(
Paragraph::new(Span::styled(info, Style::default().fg(Color::DarkGray)))
.alignment(Alignment::Center),
info_rect,
);
}
/// Вычисляет размер изображения для модалки с сохранением aspect ratio.
///
/// # Логика масштабирования:
/// - Если изображение меньше терминала → показываем как есть
/// - Если ширина больше → масштабируем по ширине
/// - Если высота больше → масштабируем по высоте
/// - Сохраняем aspect ratio
fn calculate_modal_size(
img_width: i32,
img_height: i32,
term_width: u16,
term_height: u16,
) -> (u16, u16) {
let aspect_ratio = img_width as f64 / img_height as f64;
// Если изображение помещается целиком
if img_width <= term_width as i32 && img_height <= term_height as i32 {
return (img_width as u16, img_height as u16);
}
// Начинаем с максимального размера терминала
let mut width = term_width as f64;
let mut height = term_height as f64;
// Подгоняем по aspect ratio
let term_aspect = width / height;
if term_aspect > aspect_ratio {
// Терминал шире → ограничены по высоте
width = height * aspect_ratio;
} else {
// Терминал выше → ограничены по ширине
height = width / aspect_ratio;
}
(width as u16, height as u16)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_calculate_modal_size_fits() {
// Изображение помещается целиком
let (w, h) = calculate_modal_size(50, 30, 100, 50);
assert_eq!(w, 50);
assert_eq!(h, 30);
}
#[test]
fn test_calculate_modal_size_scale_width() {
// Ограничены по ширине (изображение шире терминала)
let (w, h) = calculate_modal_size(200, 100, 100, 100);
assert_eq!(w, 100);
assert_eq!(h, 50); // aspect ratio 2:1
}
#[test]
fn test_calculate_modal_size_scale_height() {
// Ограничены по высоте (изображение выше терминала)
let (w, h) = calculate_modal_size(100, 200, 100, 100);
assert_eq!(w, 50); // aspect ratio 1:2
assert_eq!(h, 100);
}
#[test]
fn test_calculate_modal_size_aspect_ratio() {
// Проверка сохранения aspect ratio
let (w, h) = calculate_modal_size(1920, 1080, 100, 100);
let aspect = w as f64 / h as f64;
let expected_aspect = 1920.0 / 1080.0;
assert!((aspect - expected_aspect).abs() < 0.01);
}
}

View File

@@ -0,0 +1,27 @@
//! Modal dialog rendering modules
//!
//! Contains UI rendering for various modal dialogs:
//! - account_switcher: Account switcher modal (global overlay)
//! - delete_confirm: Delete confirmation modal
//! - reaction_picker: Emoji reaction picker modal
//! - search: Message search modal
//! - pinned: Pinned messages viewer modal
//! - image_viewer: Full-screen image viewer modal (images feature)
pub mod account_switcher;
pub mod delete_confirm;
pub mod pinned;
pub mod reaction_picker;
pub mod search;
#[cfg(feature = "images")]
pub mod image_viewer;
pub use account_switcher::render as render_account_switcher;
pub use delete_confirm::render as render_delete_confirm;
pub use pinned::render as render_pinned;
pub use reaction_picker::render as render_reaction_picker;
pub use search::render as render_search;
#[cfg(feature = "images")]
pub use image_viewer::render as render_image_viewer;

View File

@@ -0,0 +1,91 @@
//! Pinned messages viewer modal
use crate::app::App;
use crate::tdlib::TdClientTrait;
use crate::ui::components::{calculate_scroll_offset, render_help_bar, render_message_item};
use ratatui::{
layout::{Constraint, Direction, Layout, Rect},
style::{Color, Modifier, Style},
text::{Line, Span},
widgets::{Block, Borders, Paragraph},
Frame,
};
/// Renders pinned messages mode
pub fn render<T: TdClientTrait>(f: &mut Frame, area: Rect, app: &App<T>) {
// Извлекаем данные из ChatState
let (messages, selected_index) =
if let crate::app::ChatState::PinnedMessages { messages, selected_index } = &app.chat_state
{
(messages.as_slice(), *selected_index)
} else {
return; // Некорректное состояние
};
let chunks = Layout::default()
.direction(Direction::Vertical)
.constraints([
Constraint::Length(3), // Header
Constraint::Min(0), // Pinned messages list
Constraint::Length(3), // Help bar
])
.split(area);
// Header
let total = messages.len();
let current = selected_index + 1;
let header_text = format!("📌 ЗАКРЕПЛЁННЫЕ ({}/{})", current, total);
let header = Paragraph::new(header_text)
.block(
Block::default()
.borders(Borders::ALL)
.border_style(Style::default().fg(Color::Magenta)),
)
.style(
Style::default()
.fg(Color::Magenta)
.add_modifier(Modifier::BOLD),
);
f.render_widget(header, chunks[0]);
// Pinned messages list
let content_width = chunks[1].width.saturating_sub(2) as usize;
let mut lines: Vec<Line> = Vec::new();
for (idx, msg) in messages.iter().enumerate() {
if idx > 0 {
lines.push(Line::from(""));
}
lines.extend(render_message_item(msg, idx == selected_index, content_width, 3));
}
if lines.is_empty() {
lines.push(Line::from(Span::styled(
"Нет закреплённых сообщений",
Style::default().fg(Color::Gray),
)));
}
// Скролл к выбранному сообщению
let scroll_offset = calculate_scroll_offset(selected_index, 5, chunks[1].height);
let messages_widget = Paragraph::new(lines)
.block(
Block::default()
.borders(Borders::ALL)
.border_style(Style::default().fg(Color::Magenta)),
)
.scroll((scroll_offset, 0));
f.render_widget(messages_widget, chunks[1]);
// Help bar
let help = render_help_bar(
&[
("↑↓", "навигация", Color::Yellow),
("Enter", "перейти", Color::Green),
("Esc", "выход", Color::Red),
],
Color::Magenta,
);
f.render_widget(help, chunks[2]);
}

View File

@@ -0,0 +1,8 @@
//! Reaction picker modal
use ratatui::{layout::Rect, Frame};
/// Renders emoji reaction picker modal
pub fn render(f: &mut Frame, area: Rect, available_reactions: &[String], selected_index: usize) {
crate::ui::components::render_emoji_picker(f, area, available_reactions, selected_index);
}

View File

@@ -0,0 +1,110 @@
//! Message search modal
use crate::app::App;
use crate::tdlib::TdClientTrait;
use crate::ui::components::{calculate_scroll_offset, render_help_bar, render_message_item};
use ratatui::{
layout::{Constraint, Direction, Layout, Rect},
style::{Color, Modifier, Style},
text::{Line, Span},
widgets::{Block, Borders, Paragraph},
Frame,
};
/// Renders message search mode
pub fn render<T: TdClientTrait>(f: &mut Frame, area: Rect, app: &App<T>) {
// Извлекаем данные из ChatState
let (query, results, selected_index) =
if let crate::app::ChatState::SearchInChat { query, results, selected_index } =
&app.chat_state
{
(query.as_str(), results.as_slice(), *selected_index)
} else {
return; // Некорректное состояние, не рендерим
};
let chunks = Layout::default()
.direction(Direction::Vertical)
.constraints([
Constraint::Length(3), // Search input
Constraint::Min(0), // Search results
Constraint::Length(3), // Help bar
])
.split(area);
// Search input
let total = results.len();
let current = if total > 0 { selected_index + 1 } else { 0 };
let input_line = if query.is_empty() {
Line::from(vec![
Span::styled("🔍 ", Style::default().fg(Color::Yellow)),
Span::styled("", Style::default().fg(Color::Yellow)),
Span::styled(" Введите текст для поиска...", Style::default().fg(Color::Gray)),
])
} else {
Line::from(vec![
Span::styled("🔍 ", Style::default().fg(Color::Yellow)),
Span::styled(query, Style::default().fg(Color::White)),
Span::styled("", Style::default().fg(Color::Yellow)),
Span::styled(format!(" ({}/{})", current, total), Style::default().fg(Color::Gray)),
])
};
let search_input = Paragraph::new(input_line).block(
Block::default()
.borders(Borders::ALL)
.border_style(Style::default().fg(Color::Yellow))
.title(" Поиск по сообщениям ")
.title_style(
Style::default()
.fg(Color::Yellow)
.add_modifier(Modifier::BOLD),
),
);
f.render_widget(search_input, chunks[0]);
// Search results
let content_width = chunks[1].width.saturating_sub(2) as usize;
let mut lines: Vec<Line> = Vec::new();
if results.is_empty() {
if !query.is_empty() {
lines.push(Line::from(Span::styled(
"Ничего не найдено",
Style::default().fg(Color::Gray),
)));
}
} else {
for (idx, msg) in results.iter().enumerate() {
if idx > 0 {
lines.push(Line::from(""));
}
lines.extend(render_message_item(msg, idx == selected_index, content_width, 2));
}
}
// Скролл к выбранному результату
let scroll_offset = calculate_scroll_offset(selected_index, 4, chunks[1].height);
let results_widget = Paragraph::new(lines)
.block(
Block::default()
.borders(Borders::ALL)
.border_style(Style::default().fg(Color::Yellow)),
)
.scroll((scroll_offset, 0));
f.render_widget(results_widget, chunks[1]);
// Help bar
let help = render_help_bar(
&[
("↑↓", "навигация", Color::Yellow),
("n/N", "след./пред.", Color::Yellow),
("Enter", "перейти", Color::Green),
("Esc", "выход", Color::Red),
],
Color::Yellow,
);
f.render_widget(help, chunks[2]);
}

View File

@@ -0,0 +1,287 @@
use crate::app::methods::modal::ModalMethods;
use crate::app::App;
use crate::tdlib::ProfileInfo;
use crate::tdlib::TdClientTrait;
use ratatui::{
layout::{Alignment, Constraint, Direction, Layout, Rect},
style::{Color, Modifier, Style},
text::{Line, Span},
widgets::{Block, Borders, Paragraph},
Frame,
};
/// Рендерит режим просмотра профиля
pub fn render<T: TdClientTrait>(f: &mut Frame, area: Rect, app: &App<T>, profile: &ProfileInfo) {
// Проверяем, показывать ли модалку подтверждения
let confirmation_step = app.get_leave_group_confirmation_step();
if confirmation_step > 0 {
render_leave_confirmation_modal(f, area, confirmation_step);
return;
}
let chunks = Layout::default()
.direction(Direction::Vertical)
.constraints([
Constraint::Length(3), // Header
Constraint::Min(0), // Profile info
Constraint::Length(3), // Actions help
])
.split(area);
// Header
let header_text = format!("👤 ПРОФИЛЬ: {}", profile.title);
let header = Paragraph::new(header_text)
.block(
Block::default()
.borders(Borders::ALL)
.border_style(Style::default().fg(Color::Cyan)),
)
.style(
Style::default()
.fg(Color::Cyan)
.add_modifier(Modifier::BOLD),
);
f.render_widget(header, chunks[0]);
// Profile info
let mut lines: Vec<Line> = Vec::new();
// Тип чата
lines.push(Line::from(vec![
Span::styled("Тип: ", Style::default().fg(Color::Gray)),
Span::styled(&profile.chat_type, Style::default().fg(Color::White)),
]));
lines.push(Line::from(""));
// ID
lines.push(Line::from(vec![
Span::styled("ID: ", Style::default().fg(Color::Gray)),
Span::styled(format!("{}", profile.chat_id), Style::default().fg(Color::White)),
]));
lines.push(Line::from(""));
// Username
if let Some(username) = &profile.username {
lines.push(Line::from(vec![
Span::styled("Username: ", Style::default().fg(Color::Gray)),
Span::styled(username, Style::default().fg(Color::Cyan)),
]));
lines.push(Line::from(""));
}
// Phone number (только для личных чатов)
if let Some(phone) = &profile.phone_number {
lines.push(Line::from(vec![
Span::styled("Телефон: ", Style::default().fg(Color::Gray)),
Span::styled(phone, Style::default().fg(Color::White)),
]));
lines.push(Line::from(""));
}
// Online status (только для личных чатов)
if let Some(status) = &profile.online_status {
lines.push(Line::from(vec![
Span::styled("Статус: ", Style::default().fg(Color::Gray)),
Span::styled(status, Style::default().fg(Color::Green)),
]));
lines.push(Line::from(""));
}
// Bio (только для личных чатов)
if let Some(bio) = &profile.bio {
lines.push(Line::from(vec![Span::styled("О себе: ", Style::default().fg(Color::Gray))]));
// Разбиваем bio на строки если длинное
let bio_lines: Vec<&str> = bio.lines().collect();
for bio_line in bio_lines {
lines.push(Line::from(Span::styled(bio_line, Style::default().fg(Color::White))));
}
lines.push(Line::from(""));
}
// Member count (для групп/каналов)
if let Some(count) = profile.member_count {
lines.push(Line::from(vec![
Span::styled("Участников: ", Style::default().fg(Color::Gray)),
Span::styled(format!("{}", count), Style::default().fg(Color::White)),
]));
lines.push(Line::from(""));
}
// Description (для групп/каналов)
if let Some(desc) = &profile.description {
lines.push(Line::from(vec![Span::styled("Описание: ", Style::default().fg(Color::Gray))]));
let desc_lines: Vec<&str> = desc.lines().collect();
for desc_line in desc_lines {
lines.push(Line::from(Span::styled(desc_line, Style::default().fg(Color::White))));
}
lines.push(Line::from(""));
}
// Invite link (для групп/каналов)
if let Some(link) = &profile.invite_link {
lines.push(Line::from(vec![
Span::styled("Ссылка: ", Style::default().fg(Color::Gray)),
Span::styled(
link,
Style::default()
.fg(Color::Blue)
.add_modifier(Modifier::UNDERLINED),
),
]));
lines.push(Line::from(""));
}
// Разделитель
lines.push(Line::from("────────────────────────────────"));
lines.push(Line::from(""));
// Действия
lines.push(Line::from(Span::styled(
"Действия:",
Style::default()
.fg(Color::Yellow)
.add_modifier(Modifier::BOLD),
)));
lines.push(Line::from(""));
let actions = get_available_actions(profile);
for (idx, action) in actions.iter().enumerate() {
let is_selected = idx == app.get_selected_profile_action().unwrap_or(0);
let marker = if is_selected { "" } else { " " };
let style = if is_selected {
Style::default()
.fg(Color::Yellow)
.add_modifier(Modifier::BOLD)
} else {
Style::default().fg(Color::White)
};
lines.push(Line::from(vec![
Span::styled(marker, Style::default().fg(Color::Yellow)),
Span::styled(*action, style),
]));
}
let info_widget = Paragraph::new(lines)
.block(
Block::default()
.borders(Borders::ALL)
.border_style(Style::default().fg(Color::Cyan)),
)
.scroll((0, 0));
f.render_widget(info_widget, chunks[1]);
// Help bar
let help_line = Line::from(vec![
Span::styled(
" ↑↓ ",
Style::default()
.fg(Color::Yellow)
.add_modifier(Modifier::BOLD),
),
Span::raw("навигация"),
Span::raw(" "),
Span::styled(
" Enter ",
Style::default()
.fg(Color::Green)
.add_modifier(Modifier::BOLD),
),
Span::raw("выбрать"),
Span::raw(" "),
Span::styled(" Esc ", Style::default().fg(Color::Red).add_modifier(Modifier::BOLD)),
Span::raw("выход"),
]);
let help = Paragraph::new(help_line)
.block(
Block::default()
.borders(Borders::ALL)
.border_style(Style::default().fg(Color::Cyan)),
)
.alignment(Alignment::Center);
f.render_widget(help, chunks[2]);
}
/// Получить список доступных действий
fn get_available_actions(profile: &ProfileInfo) -> Vec<&'static str> {
let mut actions = vec![];
if profile.username.is_some() {
actions.push("Открыть в браузере");
}
actions.push("Скопировать ID");
if profile.is_group {
actions.push("Покинуть группу");
}
actions
}
/// Рендерит модалку подтверждения выхода из группы
fn render_leave_confirmation_modal(f: &mut Frame, area: Rect, step: u8) {
// Затемняем фон
let modal_area = centered_rect(60, 30, area);
let text = if step == 1 {
"Вы хотите выйти из группы?"
} else {
"Вы ТОЧНО хотите выйти из группы?!?!?"
};
let lines = vec![
Line::from(""),
Line::from(Span::styled(
text,
Style::default()
.fg(Color::Yellow)
.add_modifier(Modifier::BOLD),
)),
Line::from(""),
Line::from(""),
Line::from(vec![
Span::styled(
"y/н/Enter",
Style::default()
.fg(Color::Green)
.add_modifier(Modifier::BOLD),
),
Span::raw(" — да "),
Span::styled("n/т/Esc", Style::default().fg(Color::Red).add_modifier(Modifier::BOLD)),
Span::raw(" — нет"),
]),
];
let modal = Paragraph::new(lines)
.block(
Block::default()
.borders(Borders::ALL)
.border_style(Style::default().fg(Color::Red))
.title(" ⚠ ВНИМАНИЕ ")
.title_style(Style::default().fg(Color::Red).add_modifier(Modifier::BOLD)),
)
.alignment(Alignment::Center);
f.render_widget(modal, modal_area);
}
/// Вспомогательная функция для центрирования прямоугольника
fn centered_rect(percent_x: u16, percent_y: u16, r: Rect) -> Rect {
let popup_layout = Layout::default()
.direction(Direction::Vertical)
.constraints([
Constraint::Percentage((100 - percent_y) / 2),
Constraint::Percentage(percent_y),
Constraint::Percentage((100 - percent_y) / 2),
])
.split(r);
Layout::default()
.direction(Direction::Horizontal)
.constraints([
Constraint::Percentage((100 - percent_x) / 2),
Constraint::Percentage(percent_x),
Constraint::Percentage((100 - percent_x) / 2),
])
.split(popup_layout[1])[1]
}