use anyhow::{Context, Result};
use image::{DynamicImage, ImageFormat};
use ratatui::layout::Rect;
use ratatui_image::protocol::StatefulProtocol;
use std::collections::{HashMap, HashSet, VecDeque};
use std::path::Path;
pub const MAX_CACHE_DIMENSION: u32 = 1024;
pub const MAX_CACHED_IMAGES: usize = 10;
pub const FONT_WIDTH_PX: u8 = 8;
pub const FONT_HEIGHT_PX: u8 = 16;
pub const MAX_IMAGE_HEIGHT_ROWS: u16 = 40;
pub const METADATA_HEIGHT: u16 = 1;
pub const IMAGE_TOP_MARGIN: u16 = 1;
pub const IMAGE_BOTTOM_MARGIN: u16 = 1;
pub const IMAGE_PANEL_OVERHEAD: u16 = 2 + IMAGE_TOP_MARGIN + IMAGE_BOTTOM_MARGIN + METADATA_HEIGHT;
pub fn is_image_file(path: &str) -> bool {
if is_svg(path) {
return true;
}
let ext = Path::new(path)
.extension()
.and_then(|e| e.to_str())
.unwrap_or("");
ImageFormat::from_extension(ext)
.map(|fmt| fmt.can_read())
.unwrap_or(false)
}
pub fn is_svg(path: &str) -> bool {
path.to_lowercase().ends_with(".svg")
}
pub fn is_lfs_pointer(content: &[u8]) -> bool {
content.starts_with(b"version https://git-lfs.github.com/spec/")
}
pub struct CachedImage {
pub display_image: DynamicImage,
pub original_width: u32,
pub original_height: u32,
pub file_size: u64,
pub format_name: String,
pub protocol: Option<StatefulProtocol>,
}
impl CachedImage {
pub fn metadata_string(&self) -> String {
let size = format_file_size(self.file_size);
format!(
"{}x{} {}, {}",
self.original_width, self.original_height, self.format_name, size
)
}
pub fn display_width(&self) -> u32 {
self.display_image.width()
}
pub fn display_height(&self) -> u32 {
self.display_image.height()
}
pub fn ensure_protocol(
&mut self,
picker: &ratatui_image::picker::Picker,
) -> &mut StatefulProtocol {
if self.protocol.is_none() {
let protocol = picker.new_resize_protocol(self.display_image.clone());
self.protocol = Some(protocol);
}
self.protocol.as_mut().unwrap()
}
}
pub struct ImageDiffState {
pub before: Option<CachedImage>,
pub after: Option<CachedImage>,
}
pub struct ImageCache {
images: HashMap<String, ImageDiffState>,
access_order: VecDeque<String>, }
impl Default for ImageCache {
fn default() -> Self {
Self::new()
}
}
impl ImageCache {
pub fn new() -> Self {
Self {
images: HashMap::new(),
access_order: VecDeque::new(),
}
}
pub fn get(&mut self, path: &str) -> Option<&ImageDiffState> {
if self.images.contains_key(path) {
self.access_order.retain(|p| p != path);
self.access_order.push_back(path.to_string());
self.images.get(path)
} else {
None
}
}
pub fn get_mut(&mut self, path: &str) -> Option<&mut ImageDiffState> {
if self.images.contains_key(path) {
self.access_order.retain(|p| p != path);
self.access_order.push_back(path.to_string());
self.images.get_mut(path)
} else {
None
}
}
pub fn peek(&self, path: &str) -> Option<&ImageDiffState> {
self.images.get(path)
}
pub fn contains(&self, path: &str) -> bool {
self.images.contains_key(path)
}
pub fn insert(&mut self, path: String, state: ImageDiffState) {
while self.images.len() >= MAX_CACHED_IMAGES {
if let Some(oldest) = self.access_order.pop_front() {
self.images.remove(&oldest);
} else {
break;
}
}
self.access_order.push_back(path.clone());
self.images.insert(path, state);
}
pub fn evict_stale(&mut self, current_image_paths: &HashSet<&str>) {
self.images
.retain(|path, _| current_image_paths.contains(path.as_str()));
self.access_order
.retain(|path| current_image_paths.contains(path.as_str()));
}
pub fn clear(&mut self) {
self.images.clear();
self.access_order.clear();
}
pub fn len(&self) -> usize {
self.images.len()
}
pub fn is_empty(&self) -> bool {
self.images.is_empty()
}
}
pub fn load_and_cache(bytes: &[u8], format_name: &str) -> Result<CachedImage> {
let file_size = bytes.len() as u64;
let original = image::load_from_memory(bytes).context("Failed to decode image")?;
let (ow, oh) = (original.width(), original.height());
let display_image = if ow > MAX_CACHE_DIMENSION || oh > MAX_CACHE_DIMENSION {
let scale = MAX_CACHE_DIMENSION as f64 / ow.max(oh) as f64;
let new_w = ((ow as f64) * scale) as u32;
let new_h = ((oh as f64) * scale) as u32;
original.resize(new_w, new_h, image::imageops::FilterType::Lanczos3)
} else {
original
};
Ok(CachedImage {
display_image,
original_width: ow,
original_height: oh,
file_size,
format_name: format_name.to_string(),
protocol: None,
})
}
pub fn rasterize_svg(svg_bytes: &[u8], max_dimension: u32) -> Result<CachedImage> {
let file_size = svg_bytes.len() as u64;
let options = resvg::usvg::Options::default();
let tree = resvg::usvg::Tree::from_data(svg_bytes, &options).context("Failed to parse SVG")?;
let size = tree.size();
let max_size = size.width().max(size.height());
let scale = (max_dimension as f32 / max_size).min(1.0);
let width = ((size.width() * scale) as u32).max(1);
let height = ((size.height() * scale) as u32).max(1);
let mut pixmap = resvg::tiny_skia::Pixmap::new(width, height)
.ok_or_else(|| anyhow::anyhow!("Failed to create pixmap for {}x{}", width, height))?;
let transform = resvg::tiny_skia::Transform::from_scale(scale, scale);
resvg::render(&tree, transform, &mut pixmap.as_mut());
let rgba = image::RgbaImage::from_raw(width, height, pixmap.take())
.ok_or_else(|| anyhow::anyhow!("Failed to create image from pixmap"))?;
Ok(CachedImage {
display_image: DynamicImage::ImageRgba8(rgba),
original_width: size.width() as u32,
original_height: size.height() as u32,
file_size,
format_name: "SVG".to_string(),
protocol: None,
})
}
pub fn fit_dimensions(
img_width: u32,
img_height: u32,
max_w: u16,
max_h: u16,
font_size: (u16, u16),
) -> (u16, u16) {
if img_width == 0 || img_height == 0 || max_w == 0 || max_h == 0 {
return (1, 1);
}
let font_w = font_size.0.max(1) as f64;
let font_h = font_size.1.max(1) as f64;
let img_cells_w = img_width as f64 / font_w;
let img_cells_h = img_height as f64 / font_h;
let scale_w = max_w as f64 / img_cells_w;
let scale_h = max_h as f64 / img_cells_h;
let scale = scale_w.min(scale_h).min(1.0);
let display_w = (img_cells_w * scale).ceil() as u16;
let display_h = (img_cells_h * scale).ceil() as u16;
(display_w.max(1), display_h.max(1))
}
pub fn center_in_area(img_w: u16, img_h: u16, area: Rect) -> Rect {
let x = area.x + area.width.saturating_sub(img_w) / 2;
let y = area.y + area.height.saturating_sub(img_h) / 2;
Rect::new(x, y, img_w, img_h)
}
pub fn format_file_size(bytes: u64) -> String {
if bytes < 1024 {
format!("{} B", bytes)
} else if bytes < 1024 * 1024 {
format!("{:.1} KB", bytes as f64 / 1024.0)
} else {
format!("{:.1} MB", bytes as f64 / (1024.0 * 1024.0))
}
}
pub fn format_name_from_path(path: &str) -> String {
Path::new(path)
.extension()
.and_then(|e| e.to_str())
.map(|e| e.to_uppercase())
.unwrap_or_else(|| "Unknown".to_string())
}
pub fn load_image_diff(
vcs: &dyn crate::vcs::Vcs,
file_path: &str,
) -> Option<ImageDiffState> {
let format_name = format_name_from_path(file_path);
let load_bytes = |bytes: &[u8]| -> Option<CachedImage> {
if is_lfs_pointer(bytes) {
return None;
}
if is_svg(file_path) {
rasterize_svg(bytes, MAX_CACHE_DIMENSION).ok()
} else {
load_and_cache(bytes, &format_name).ok()
}
};
let before = vcs.base_file_bytes(file_path)
.ok()
.flatten()
.and_then(|bytes| load_bytes(&bytes));
let after = vcs.working_file_bytes(file_path)
.ok()
.flatten()
.and_then(|bytes| load_bytes(&bytes));
if before.is_none() && after.is_none() {
return None;
}
Some(ImageDiffState { before, after })
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_is_image_file_common_formats() {
assert!(is_image_file("photo.png"));
assert!(is_image_file("photo.PNG")); assert!(is_image_file("icon.jpeg"));
assert!(is_image_file("icon.jpg"));
assert!(is_image_file("anim.gif"));
assert!(is_image_file("modern.webp"));
assert!(is_image_file("icon.bmp"));
assert!(is_image_file("favicon.ico"));
}
#[test]
fn test_is_image_file_svg() {
assert!(is_image_file("LOGO.SVG"));
assert!(is_image_file("diagram.svg"));
}
#[test]
fn test_is_image_file_not_images() {
assert!(!is_image_file("document.pdf"));
assert!(!is_image_file("code.rs"));
assert!(!is_image_file("data.json"));
assert!(!is_image_file("video.mp4"));
assert!(!is_image_file("noextension"));
}
#[test]
fn test_is_svg() {
assert!(is_svg("logo.svg"));
assert!(is_svg("DIAGRAM.SVG"));
assert!(!is_svg("photo.png"));
}
#[test]
fn test_is_lfs_pointer() {
let lfs_content = b"version https://git-lfs.github.com/spec/v1\noid sha256:abc123\nsize 12345";
assert!(is_lfs_pointer(lfs_content));
let normal_content = b"\x89PNG\r\n\x1a\n"; assert!(!is_lfs_pointer(normal_content));
}
const TEST_FONT_SIZE: (u16, u16) = (FONT_WIDTH_PX as u16, FONT_HEIGHT_PX as u16);
#[test]
fn test_fit_dimensions_landscape() {
let (w, h) = fit_dimensions(1920, 1080, 80, 24, TEST_FONT_SIZE);
assert!(w <= 80);
assert!(h <= 24);
}
#[test]
fn test_fit_dimensions_portrait() {
let (w, h) = fit_dimensions(600, 1200, 40, 30, TEST_FONT_SIZE);
assert!(w <= 40);
assert!(h <= 30);
}
#[test]
fn test_fit_dimensions_no_upscale() {
let (w, h) = fit_dimensions(10, 10, 80, 24, TEST_FONT_SIZE);
assert!(w <= 10);
assert!(h <= 10);
}
#[test]
fn test_fit_dimensions_zero_input() {
let (w, h) = fit_dimensions(0, 0, 80, 24, TEST_FONT_SIZE);
assert!(w >= 1);
assert!(h >= 1);
}
#[test]
fn test_fit_dimensions_zero_container() {
let (w, h) = fit_dimensions(100, 100, 0, 0, TEST_FONT_SIZE);
assert!(w >= 1);
assert!(h >= 1);
}
#[test]
fn test_fit_dimensions_huge_image() {
let (w, h) = fit_dimensions(100_000, 100_000, 80, 24, TEST_FONT_SIZE);
assert!(w <= 80);
assert!(h <= 24);
}
#[test]
fn test_center_in_area() {
let area = Rect::new(0, 0, 80, 24);
let centered = center_in_area(20, 10, area);
assert_eq!(centered.x, 30); assert_eq!(centered.y, 7); assert_eq!(centered.width, 20);
assert_eq!(centered.height, 10);
}
#[test]
fn test_format_file_size() {
assert_eq!(format_file_size(512), "512 B");
assert_eq!(format_file_size(1024), "1.0 KB");
assert_eq!(format_file_size(1536), "1.5 KB");
assert_eq!(format_file_size(1024 * 1024), "1.0 MB");
assert_eq!(format_file_size(2 * 1024 * 1024 + 512 * 1024), "2.5 MB");
}
#[test]
fn test_format_name_from_path() {
assert_eq!(format_name_from_path("photo.png"), "PNG");
assert_eq!(format_name_from_path("icon.jpeg"), "JPEG");
assert_eq!(format_name_from_path("logo.svg"), "SVG");
assert_eq!(format_name_from_path("noextension"), "Unknown");
}
#[test]
fn test_image_cache_lru_eviction() {
let mut cache = ImageCache::new();
for i in 0..MAX_CACHED_IMAGES {
let path = format!("image{}.png", i);
cache.insert(
path,
ImageDiffState {
before: None,
after: None,
},
);
}
assert_eq!(cache.len(), MAX_CACHED_IMAGES);
cache.insert(
"new_image.png".to_string(),
ImageDiffState {
before: None,
after: None,
},
);
assert_eq!(cache.len(), MAX_CACHED_IMAGES);
assert!(!cache.contains("image0.png")); assert!(cache.contains("new_image.png")); }
#[test]
fn test_image_cache_evict_stale() {
let mut cache = ImageCache::new();
cache.insert(
"keep.png".to_string(),
ImageDiffState {
before: None,
after: None,
},
);
cache.insert(
"remove.png".to_string(),
ImageDiffState {
before: None,
after: None,
},
);
let current: HashSet<&str> = ["keep.png"].iter().copied().collect();
cache.evict_stale(¤t);
assert!(cache.contains("keep.png"));
assert!(!cache.contains("remove.png"));
}
#[test]
fn test_cached_image_metadata_string() {
let cached = CachedImage {
display_image: DynamicImage::new_rgba8(1, 1),
original_width: 1920,
original_height: 1080,
file_size: 2 * 1024 * 1024,
format_name: "PNG".to_string(),
protocol: None,
};
assert_eq!(cached.metadata_string(), "1920x1080 PNG, 2.0 MB");
}
#[test]
fn test_image_cache_peek_does_not_update_access_order() {
let mut cache = ImageCache::new();
cache.insert(
"first.png".to_string(),
ImageDiffState {
before: None,
after: None,
},
);
cache.insert(
"second.png".to_string(),
ImageDiffState {
before: None,
after: None,
},
);
assert!(cache.peek("first.png").is_some());
for i in 0..MAX_CACHED_IMAGES {
cache.insert(
format!("filler{}.png", i),
ImageDiffState {
before: None,
after: None,
},
);
}
assert!(!cache.contains("first.png"));
}
#[test]
fn test_image_cache_get_updates_access_order() {
let mut cache = ImageCache::new();
cache.insert(
"first.png".to_string(),
ImageDiffState {
before: None,
after: None,
},
);
cache.insert(
"second.png".to_string(),
ImageDiffState {
before: None,
after: None,
},
);
assert!(cache.get("first.png").is_some());
for i in 0..(MAX_CACHED_IMAGES - 1) {
cache.insert(
format!("filler{}.png", i),
ImageDiffState {
before: None,
after: None,
},
);
}
assert!(cache.contains("first.png"));
assert!(!cache.contains("second.png"));
}
#[test]
fn test_image_panel_layout_constants() {
assert_eq!(IMAGE_TOP_MARGIN, 1, "Top margin should be 1 row");
assert_eq!(IMAGE_BOTTOM_MARGIN, 1, "Bottom margin should be 1 row");
assert_eq!(METADATA_HEIGHT, 1, "Metadata should be 1 row");
}
#[test]
fn test_image_panel_overhead_calculation() {
let borders = 2u16; let expected = borders + IMAGE_TOP_MARGIN + IMAGE_BOTTOM_MARGIN + METADATA_HEIGHT;
assert_eq!(
IMAGE_PANEL_OVERHEAD, expected,
"IMAGE_PANEL_OVERHEAD should be borders + margins + metadata"
);
assert_eq!(IMAGE_PANEL_OVERHEAD, 5, "Total overhead should be 5 rows");
}
#[test]
fn test_expected_available_height_from_panel_height() {
let test_cases = [
(20u16, 15u16), (10u16, 5u16), (5u16, 0u16), (3u16, 0u16), ];
for (panel_height, expected_available) in test_cases {
let available = panel_height.saturating_sub(IMAGE_PANEL_OVERHEAD);
assert_eq!(
available, expected_available,
"For panel_height={}, expected_available_height should be {}",
panel_height, expected_available
);
}
}
}