Some checks failed
ci/woodpecker/pr/check Pipeline failed
CI / Check (pull_request) Has been cancelled
CI / Format (pull_request) Has been cancelled
CI / Clippy (pull_request) Has been cancelled
CI / Build (macos-latest) (pull_request) Has been cancelled
CI / Build (ubuntu-latest) (pull_request) Has been cancelled
CI / Build (windows-latest) (pull_request) Has been cancelled
488 lines
20 KiB
Rust
488 lines
20 KiB
Rust
//! Chat message area rendering.
|
||
//!
|
||
//! Renders message bubbles grouped by date/sender, pinned bar, and delegates
|
||
//! to modals (search, pinned, reactions, delete) and compose_bar.
|
||
|
||
use crate::app::methods::{messages::MessageMethods, modal::ModalMethods, search::SearchMethods};
|
||
use crate::app::App;
|
||
use crate::message_grouping::{group_messages, MessageGroup};
|
||
use crate::tdlib::TdClientTrait;
|
||
use crate::ui::components;
|
||
use crate::ui::{compose_bar, modals};
|
||
use ratatui::{
|
||
layout::{Alignment, Constraint, Direction, Layout, Rect},
|
||
style::{Color, Modifier, Style},
|
||
text::{Line, Span},
|
||
widgets::{Block, Borders, Paragraph},
|
||
Frame,
|
||
};
|
||
|
||
/// Рендерит заголовок чата с typing status
|
||
fn render_chat_header<T: TdClientTrait>(
|
||
f: &mut Frame,
|
||
area: Rect,
|
||
app: &App<T>,
|
||
chat: &crate::tdlib::ChatInfo,
|
||
) {
|
||
let typing_action = app
|
||
.td_client
|
||
.typing_status()
|
||
.as_ref()
|
||
.map(|(_, action, _)| action.clone());
|
||
|
||
let header_line = if let Some(action) = typing_action {
|
||
// Показываем typing status: "👤 Имя @username печатает..."
|
||
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 {
|
||
// Показываем username
|
||
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);
|
||
}
|
||
|
||
/// Рендерит pinned bar с закреплённым сообщением
|
||
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);
|
||
}
|
||
|
||
/// Информация о строке после переноса: текст и позиция в оригинале
|
||
pub(super) struct WrappedLine {
|
||
pub text: String,
|
||
}
|
||
|
||
/// Разбивает текст на строки с учётом максимальной ширины
|
||
/// (используется только для search/pinned режимов, основной рендеринг через message_bubble)
|
||
pub(super) 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
|
||
}
|
||
/// Рендерит список сообщений с группировкой по дате/отправителю и автоскроллом
|
||
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;
|
||
|
||
// Messages с группировкой по дате и отправителю
|
||
let mut lines: Vec<Line> = Vec::new();
|
||
|
||
// ID выбранного сообщения для подсветки
|
||
let selected_msg_id = app.get_selected_message().map(|m| m.id());
|
||
// Номер строки, где начинается выбранное сообщение (для автоскролла)
|
||
let mut selected_msg_line: Option<usize> = None;
|
||
|
||
// ОПТИМИЗАЦИЯ: Убрали массовый preloading всех изображений.
|
||
// Теперь загружаем только видимые изображения во втором проходе (см. ниже).
|
||
|
||
// Собираем информацию о развёрнутых изображениях (для второго прохода)
|
||
#[cfg(feature = "images")]
|
||
let mut deferred_images: Vec<components::DeferredImageRender> = Vec::new();
|
||
|
||
// Используем message_grouping для группировки сообщений
|
||
let grouped = group_messages(&app.td_client.current_chat_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(),
|
||
);
|
||
|
||
// Собираем deferred image renders для всех загруженных фото
|
||
#[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"))]
|
||
{
|
||
// Fallback: рендерим каждое сообщение отдельно
|
||
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))));
|
||
}
|
||
|
||
// Вычисляем скролл с учётом пользовательского offset
|
||
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);
|
||
|
||
// Второй проход: рендерим изображения поверх placeholder-ов
|
||
#[cfg(feature = "images")]
|
||
{
|
||
use ratatui_image::StatefulImage;
|
||
|
||
// THROTTLING: Рендерим изображения максимум 15 FPS (каждые 66ms)
|
||
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 {
|
||
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);
|
||
|
||
// ОПТИМИЗАЦИЯ: Загружаем только видимые изображения (не все сразу)
|
||
// Используем inline_renderer с Halfblocks для скорости
|
||
if let Some(renderer) = &mut app.inline_image_renderer {
|
||
// Загружаем только если видимо (early return если уже в кеше)
|
||
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);
|
||
}
|
||
}
|
||
}
|
||
|
||
// Обновляем время последнего рендеринга (для throttling)
|
||
app.last_image_render_time = Some(std::time::Instant::now());
|
||
}
|
||
}
|
||
}
|
||
|
||
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; // -2 для рамок, -2 для "> "
|
||
let input_lines: u16 = if input_width > 0 {
|
||
let len = app.message_input.chars().count() + 2; // +2 для "> "
|
||
((len as f32 / input_width as f32).ceil() as u16).max(1)
|
||
} else {
|
||
1
|
||
};
|
||
// Минимум 3 строки (1 контент + 2 рамки), максимум 10
|
||
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), // Chat header
|
||
Constraint::Length(1), // Pinned bar
|
||
Constraint::Min(0), // Messages
|
||
Constraint::Length(input_height), // Input box (динамическая высота)
|
||
])
|
||
.split(area)
|
||
} else {
|
||
Layout::default()
|
||
.direction(Direction::Vertical)
|
||
.constraints([
|
||
Constraint::Length(3), // Chat header
|
||
Constraint::Length(0), // Pinned bar (hidden)
|
||
Constraint::Min(0), // Messages
|
||
Constraint::Length(input_height), // Input box (динамическая высота)
|
||
])
|
||
.split(area)
|
||
};
|
||
|
||
// Chat header с typing status
|
||
render_chat_header(f, message_chunks[0], app, &chat);
|
||
|
||
// Pinned bar (если есть закреплённое сообщение)
|
||
render_pinned_bar(f, message_chunks[1], app);
|
||
|
||
// Messages с группировкой по дате и отправителю
|
||
render_message_list(f, message_chunks[2], app);
|
||
|
||
// Input box с wrap для длинного текста и блочным курсором
|
||
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);
|
||
}
|
||
}
|