Group photos with shared media_album_id into single album bubbles with grid layout (up to 3x cols). Album navigation treats grouped photos as one unit (j/k skip entire album). Persist selected account to accounts.toml so it survives app restart. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
769 lines
30 KiB
Rust
769 lines
30 KiB
Rust
//! Message bubble component
|
||
//!
|
||
//! Отвечает за рендеринг отдельных элементов списка сообщений:
|
||
//! - Разделители дат
|
||
//! - Заголовки отправителей
|
||
//! - Сами сообщения (с forward, reply, reactions)
|
||
|
||
use crate::config::Config;
|
||
use crate::formatting;
|
||
use crate::tdlib::{MessageInfo, PlaybackState, PlaybackStatus};
|
||
#[cfg(feature = "images")]
|
||
use crate::tdlib::PhotoDownloadState;
|
||
use crate::types::MessageId;
|
||
use crate::utils::{format_date, format_timestamp_with_tz};
|
||
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` - конфигурация (цвета, timezone)
|
||
/// * `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 = selection_marker.chars().count();
|
||
|
||
// Цвет сообщения
|
||
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_with_tz(msg.date(), &config.general.timezone);
|
||
|
||
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 is_selected && i == 0 {
|
||
// Одна строка — маркер на ней
|
||
line_spans.push(Span::styled(
|
||
selection_marker,
|
||
Style::default().fg(Color::Yellow).add_modifier(Modifier::BOLD),
|
||
));
|
||
} else if is_selected {
|
||
// Последняя строка 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 && is_selected {
|
||
line_spans.push(Span::styled(
|
||
selection_marker,
|
||
Style::default().fg(Color::Yellow).add_modifier(Modifier::BOLD),
|
||
));
|
||
} else if is_selected {
|
||
// Средние строки 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![];
|
||
if is_selected {
|
||
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 is_this_playing = playback_state
|
||
.map(|ps| ps.message_id == msg.id())
|
||
.unwrap_or(false);
|
||
|
||
let status_line = if is_this_playing {
|
||
let ps = playback_state.unwrap();
|
||
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().map_or(false, |m| m.is_outgoing());
|
||
|
||
// Selection marker
|
||
let selection_marker = if is_selected { "▶ " } else { "" };
|
||
|
||
// Фильтруем фото
|
||
let photos: Vec<&MessageInfo> = messages.iter().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 + cols - 1) / cols;
|
||
|
||
// Добавляем маркер выбора на первую строку
|
||
if is_selected {
|
||
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 last_msg = messages.last().unwrap();
|
||
let time = format_timestamp_with_tz(last_msg.date(), &config.general.timezone);
|
||
|
||
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
|
||
}
|