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