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:
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user