Some checks failed
ci/woodpecker/pr/check Pipeline was successful
CI / Check (pull_request) Has been cancelled
CI / Format (pull_request) Has been cancelled
CI / Clippy (pull_request) Has been cancelled
CI / Build (macos-latest) (pull_request) Has been cancelled
CI / Build (ubuntu-latest) (pull_request) Has been cancelled
CI / Build (windows-latest) (pull_request) Has been cancelled
- Add #[allow(unused_imports)] on pub re-exports used only by lib/tests - Add #[allow(dead_code)] on public API items unused in binary target - Fix collapsible_if, redundant_closure, unnecessary_map_or in main.rs - Prefix unused test variables with underscore Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
126 lines
4.8 KiB
Rust
126 lines
4.8 KiB
Rust
//! Terminal image renderer using ratatui-image.
|
||
//!
|
||
//! 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, 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 с автодетектом протокола (высокое качество для 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_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> {
|
||
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)
|
||
}
|
||
|
||
/// Удаляет протокол для сообщения
|
||
#[allow(dead_code)]
|
||
pub fn remove(&mut self, msg_id: &MessageId) {
|
||
let msg_id_i64 = msg_id.as_i64();
|
||
self.protocols.remove(&msg_id_i64);
|
||
self.access_order.remove(&msg_id_i64);
|
||
}
|
||
|
||
/// Очищает все протоколы
|
||
#[allow(dead_code)]
|
||
pub fn clear(&mut self) {
|
||
self.protocols.clear();
|
||
self.access_order.clear();
|
||
self.access_counter = 0;
|
||
}
|
||
}
|