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

@@ -8,6 +8,8 @@
use crate::config::Config;
use crate::formatting;
use crate::tdlib::MessageInfo;
#[cfg(feature = "images")]
use crate::tdlib::PhotoDownloadState;
use crate::types::MessageId;
use crate::utils::{format_date, format_timestamp_with_tz};
use ratatui::{
@@ -392,5 +394,75 @@ pub fn render_message_bubble(
}
}
// Отображаем статус фото (если есть)
#[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(_) if photo.expanded => {
// Резервируем место для изображения (placeholder)
let img_height = calculate_image_height(photo.width, photo.height, content_width);
for _ in 0..img_height {
lines.push(Line::from(""));
}
}
_ => {
// NotDownloaded или Downloaded + !expanded — ничего не рендерим,
// текст сообщения уже содержит 📷 prefix
}
}
}
lines
}
/// Информация для отложенного рендеринга изображения поверх placeholder
#[cfg(feature = "images")]
pub struct DeferredImageRender {
pub message_id: MessageId,
/// Смещение в строках от начала всего списка сообщений
pub line_offset: usize,
pub width: u16,
pub height: u16,
}
/// Вычисляет высоту изображения (в строках) с учётом пропорций
#[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)
}