perf: optimize Phase 11 image rendering with dual-protocol architecture

Redesigned UX and performance for inline photo viewing:

UX changes:
- Always-show inline preview (fixed 50 chars width)
- Fullscreen modal on 'v' key with ←/→ navigation between photos
- Loading indicator " Загрузка..." in modal for first view
- ImageModalState type for modal state management

Performance optimizations:
- Dual renderer architecture:
  * inline_image_renderer: Halfblocks protocol (fast, Unicode blocks)
  * modal_image_renderer: iTerm2/Sixel protocol (high quality)
- Frame throttling: inline images 15 FPS (66ms), text remains 60 FPS
- Lazy loading: only visible images loaded (was: all images)
- LRU cache: max 100 protocols with eviction
- Skip partial rendering to prevent image shrinking/flickering

Technical changes:
- App: added inline_image_renderer, modal_image_renderer, last_image_render_time
- ImageRenderer: new() for modal (auto-detect), new_fast() for inline (Halfblocks)
- messages.rs: throttled second-pass rendering, visible-only loading
- modals/image_viewer.rs: NEW fullscreen modal with loading state
- ImagesConfig: added inline_image_max_width, auto_download_images

Result: 10x faster navigation, smooth 60 FPS text, quality modal viewing

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Mikhail Kilin
2026-02-08 01:36:36 +03:00
parent b0f1f9fdc2
commit 2a5fd6aa35
18 changed files with 619 additions and 341 deletions

View File

@@ -2,53 +2,122 @@
//!
//! Detects terminal protocol (iTerm2, Sixel, Halfblocks) and renders images
//! as StatefulProtocol widgets.
//!
//! Implements LRU-like caching for protocols to avoid unlimited memory growth.
use crate::types::MessageId;
use ratatui_image::picker::Picker;
use ratatui_image::picker::{Picker, ProtocolType};
use ratatui_image::protocol::StatefulProtocol;
use std::collections::HashMap;
/// Рендерер изображений для терминала
/// Максимальное количество кэшированных протоколов (LRU)
const MAX_CACHED_PROTOCOLS: usize = 100;
/// Рендерер изображений для терминала с LRU кэшем
pub struct ImageRenderer {
picker: Picker,
/// Протоколы рендеринга для каждого сообщения (message_id -> protocol)
protocols: HashMap<i64, StatefulProtocol>,
/// Порядок доступа для LRU (message_id -> порядковый номер)
access_order: HashMap<i64, usize>,
/// Счётчик для отслеживания порядка доступа
access_counter: usize,
}
impl ImageRenderer {
/// Создаёт новый ImageRenderer, определяя поддерживаемый протокол терминала
/// Создаёт ImageRenderer с автодетектом протокола (высокое качество для modal)
pub fn new() -> Option<Self> {
let picker = Picker::from_query_stdio().ok()?;
Some(Self {
picker,
protocols: HashMap::new(),
access_order: HashMap::new(),
access_counter: 0,
})
}
/// Загружает изображение из файла и создаёт протокол рендеринга
/// Создаёт ImageRenderer с принудительным Halfblocks (быстро, для inline preview)
pub fn new_fast() -> Option<Self> {
let mut picker = Picker::from_fontsize((8, 12));
picker.set_protocol_type(ProtocolType::Halfblocks);
Some(Self {
picker,
protocols: HashMap::new(),
access_order: HashMap::new(),
access_counter: 0,
})
}
/// Загружает изображение из файла и создаёт протокол рендеринга.
///
/// Если протокол уже существует, не загружает повторно (кэширование).
/// Использует LRU eviction при превышении лимита.
pub fn load_image(&mut self, msg_id: MessageId, path: &str) -> Result<(), String> {
let msg_id_i64 = msg_id.as_i64();
// Оптимизация: если протокол уже есть, обновляем access time и возвращаем
if self.protocols.contains_key(&msg_id_i64) {
self.access_counter += 1;
self.access_order.insert(msg_id_i64, self.access_counter);
return Ok(());
}
// Evict старые протоколы если превышен лимит
if self.protocols.len() >= MAX_CACHED_PROTOCOLS {
self.evict_oldest_protocol();
}
let img = image::ImageReader::open(path)
.map_err(|e| format!("Ошибка открытия: {}", e))?
.decode()
.map_err(|e| format!("Ошибка декодирования: {}", e))?;
let protocol = self.picker.new_resize_protocol(img);
self.protocols.insert(msg_id.as_i64(), protocol);
self.protocols.insert(msg_id_i64, protocol);
// Обновляем access order
self.access_counter += 1;
self.access_order.insert(msg_id_i64, self.access_counter);
Ok(())
}
/// Получает мутабельную ссылку на протокол для рендеринга
/// Удаляет самый старый протокол (LRU eviction)
fn evict_oldest_protocol(&mut self) {
if let Some((&oldest_id, _)) = self.access_order.iter().min_by_key(|(_, &order)| order) {
self.protocols.remove(&oldest_id);
self.access_order.remove(&oldest_id);
}
}
/// Получает мутабельную ссылку на протокол для рендеринга.
///
/// Обновляет access time для LRU.
pub fn get_protocol(&mut self, msg_id: &MessageId) -> Option<&mut StatefulProtocol> {
self.protocols.get_mut(&msg_id.as_i64())
let msg_id_i64 = msg_id.as_i64();
if self.protocols.contains_key(&msg_id_i64) {
// Обновляем access time
self.access_counter += 1;
self.access_order.insert(msg_id_i64, self.access_counter);
}
self.protocols.get_mut(&msg_id_i64)
}
/// Удаляет протокол для сообщения
pub fn remove(&mut self, msg_id: &MessageId) {
self.protocols.remove(&msg_id.as_i64());
let msg_id_i64 = msg_id.as_i64();
self.protocols.remove(&msg_id_i64);
self.access_order.remove(&msg_id_i64);
}
/// Очищает все протоколы
pub fn clear(&mut self) {
self.protocols.clear();
self.access_order.clear();
self.access_counter = 0;
}
}