feat: implement Phase 11 — inline photo viewing with ratatui-image

Add feature-gated (`images`) inline photo support:
- New types: MediaInfo, PhotoInfo, PhotoDownloadState, ImagesConfig
- Media module: ImageCache (LRU filesystem cache), ImageRenderer (terminal protocol detection)
- Photo metadata extraction from TDLib MessagePhoto with download_file() API
- ViewImage command (v/м) to toggle photo expand/collapse in message selection
- Two-pass UI rendering: placeholder lines in message bubbles + StatefulImage overlay
- Collapse all expanded photos on Esc (exit selection mode)

Dependencies: ratatui-image 8.1, image 0.25 (optional, behind `images` feature flag)

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Mikhail Kilin
2026-02-06 21:25:17 +03:00
parent 6845ee69bf
commit b0f1f9fdc2
29 changed files with 1505 additions and 102 deletions

View File

@@ -367,3 +367,126 @@ pub fn render<T: TdClientTrait>(f: &mut Frame, area: Rect, app: &App<T>) {
}
}
/// Рендерит изображения поверх placeholder-ов в списке сообщений (второй проход).
///
/// Вызывается из main_screen после основного render(), т.к. требует &mut App
/// для доступа к ImageRenderer.get_protocol() (StatefulImage — stateful widget).
#[cfg(feature = "images")]
pub fn render_images<T: TdClientTrait>(f: &mut Frame, messages_area: Rect, app: &mut App<T>) {
use crate::ui::components::{calculate_image_height, DeferredImageRender};
use ratatui_image::StatefulImage;
// Собираем информацию о развёрнутых изображениях
let content_width = messages_area.width.saturating_sub(2) as usize;
let mut deferred: Vec<DeferredImageRender> = Vec::new();
let mut lines_count: usize = 0;
let selected_msg_id = app.get_selected_message().map(|m| m.id());
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) => {
let separator_lines = components::render_date_separator(date, content_width, is_first_date);
lines_count += separator_lines.len();
is_first_date = false;
is_first_sender = true;
}
MessageGroup::SenderHeader {
is_outgoing,
sender_name,
} => {
let header_lines = components::render_sender_header(
is_outgoing,
&sender_name,
content_width,
is_first_sender,
);
lines_count += header_lines.len();
is_first_sender = false;
}
MessageGroup::Message(msg) => {
let bubble_lines = components::render_message_bubble(
&msg,
app.config(),
content_width,
selected_msg_id,
);
let bubble_len = bubble_lines.len();
// Проверяем, есть ли развёрнутое фото
if let Some(photo) = msg.photo_info() {
if photo.expanded {
if let crate::tdlib::PhotoDownloadState::Downloaded(_) = &photo.download_state {
let img_height = calculate_image_height(photo.width, photo.height, content_width);
let img_width = (content_width as u16).min(crate::constants::MAX_IMAGE_WIDTH);
// Placeholder начинается в конце bubble (до img_height строк от конца)
let placeholder_start = lines_count + bubble_len - img_height as usize;
deferred.push(DeferredImageRender {
message_id: msg.id(),
line_offset: placeholder_start,
width: img_width,
height: img_height,
});
}
}
}
lines_count += bubble_len;
}
}
}
if deferred.is_empty() {
return;
}
// Вычисляем scroll offset (повторяем логику из render_message_list)
let visible_height = messages_area.height.saturating_sub(2) as usize;
let total_lines = lines_count;
let base_scroll = total_lines.saturating_sub(visible_height);
let scroll_offset = if app.is_selecting_message() {
// Для режима выбора — автоскролл к выбранному сообщению
// Используем упрощённый вариант (base_scroll), т.к. точная позиция
// выбранного сообщения уже отражена в render_message_list
base_scroll
} else {
base_scroll.saturating_sub(app.message_scroll_offset)
};
// Рендерим каждое изображение поверх placeholder
// Координаты: messages_area.x+1 (рамка), messages_area.y+1 (рамка)
let content_x = messages_area.x + 1;
let content_y = messages_area.y + 1;
for d in &deferred {
// Позиция placeholder в контенте (с учётом скролла)
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);
let render_height = d.height.min(remaining_height);
if render_height == 0 {
continue;
}
let img_rect = Rect::new(content_x, img_y, d.width, render_height);
if let Some(renderer) = &mut app.image_renderer {
if let Some(protocol) = renderer.get_protocol(&d.message_id) {
f.render_stateful_widget(StatefulImage::default(), img_rect, protocol);
}
}
}
}