use std::path::Path;
use image::imageops::FilterType;
use image::{DynamicImage, GenericImageView};
use ratatui::style::{Color, Style};
use ratatui::text::{Line, Span};
pub const MAX_PREVIEW_WIDTH: u32 = 64;
pub const MAX_PREVIEW_ROWS: u32 = 24;
#[derive(Debug)]
pub enum ImageError {
Decode(String),
}
impl std::fmt::Display for ImageError {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
ImageError::Decode(m) => write!(f, "{m}"),
}
}
}
impl std::error::Error for ImageError {}
pub fn looks_like_image(path: &str) -> bool {
let lower = path.to_ascii_lowercase();
[".png", ".jpg", ".jpeg", ".gif", ".webp", ".bmp"]
.iter()
.any(|ext| lower.ends_with(ext))
}
pub const MAX_VISION_DIM: u32 = 1568;
pub fn downscale_for_vision(bytes: &[u8], max_dim: u32) -> Option<Vec<u8>> {
if max_dim == 0 {
return None;
}
let img = image::load_from_memory(bytes).ok()?;
let (w, h) = img.dimensions();
if w.max(h) <= max_dim {
return None; }
let scaled = img.resize(max_dim, max_dim, FilterType::Triangle);
let mut out = Vec::new();
scaled
.write_to(&mut std::io::Cursor::new(&mut out), image::ImageFormat::Png)
.ok()?;
Some(out)
}
pub fn render(path: &Path) -> Result<RenderedImage, ImageError> {
render_bounded(path, MAX_PREVIEW_WIDTH, MAX_PREVIEW_ROWS)
}
pub fn render_bounded(
path: &Path,
max_cols: u32,
max_rows: u32,
) -> Result<RenderedImage, ImageError> {
let img = image::open(path).map_err(|e| ImageError::Decode(format!("decode {path:?}: {e}")))?;
Ok(half_block_lines(&img, max_cols, max_rows))
}
#[derive(Debug, Clone)]
pub struct RenderedImage {
pub width: u32,
pub height: u32,
pub lines: Vec<Line<'static>>,
}
fn half_block_lines(img: &DynamicImage, max_cols: u32, max_rows: u32) -> RenderedImage {
let (orig_w, orig_h) = img.dimensions();
let target_w = max_cols.max(1);
let target_h = max_rows.max(1).saturating_mul(2);
let scaled = if orig_w == 0 || orig_h == 0 {
img.clone()
} else {
img.resize(target_w, target_h, FilterType::Triangle)
};
let rgba = scaled.to_rgba8();
let (w, h) = rgba.dimensions();
let mut lines = Vec::with_capacity((h as usize).div_ceil(2));
let mut y = 0u32;
while y < h {
let mut spans: Vec<Span<'static>> = Vec::with_capacity(w as usize);
for x in 0..w {
let top = rgba.get_pixel(x, y).0;
let bottom = if y + 1 < h {
rgba.get_pixel(x, y + 1).0
} else {
[0, 0, 0, 0]
};
spans.push(half_block_span(top, bottom));
}
lines.push(Line::from(spans));
y += 2;
}
RenderedImage {
width: orig_w,
height: orig_h,
lines,
}
}
fn half_block_span(top: [u8; 4], bottom: [u8; 4]) -> Span<'static> {
let top_visible = top[3] >= 16;
let bot_visible = bottom[3] >= 16;
let style = match (top_visible, bot_visible) {
(true, true) => Style::default()
.fg(Color::Rgb(top[0], top[1], top[2]))
.bg(Color::Rgb(bottom[0], bottom[1], bottom[2])),
(true, false) => Style::default().fg(Color::Rgb(top[0], top[1], top[2])),
(false, true) => Style::default().bg(Color::Rgb(bottom[0], bottom[1], bottom[2])),
(false, false) => Style::default(),
};
Span::styled("\u{2580}", style) }
#[cfg(test)]
mod tests {
use super::*;
use image::{ImageBuffer, Rgba};
#[test]
fn looks_like_image_matches_common_extensions() {
assert!(looks_like_image("foo.png"));
assert!(looks_like_image("FOO.PNG"));
assert!(looks_like_image("path/to/bar.jpeg"));
assert!(looks_like_image("baz.gif"));
assert!(looks_like_image("a.webp"));
assert!(!looks_like_image("foo.rs"));
assert!(!looks_like_image("README.md"));
assert!(!looks_like_image(""));
}
#[test]
fn half_block_lines_clamps_to_max_cells() {
let buf: ImageBuffer<Rgba<u8>, Vec<u8>> =
ImageBuffer::from_pixel(100, 100, Rgba([200, 0, 0, 255]));
let img = DynamicImage::ImageRgba8(buf);
let rendered = half_block_lines(&img, 64, 24);
assert!(rendered.lines.len() <= 24);
assert!(rendered.lines.iter().all(|l| l.spans.len() <= 64));
assert_eq!(rendered.width, 100);
assert_eq!(rendered.height, 100);
}
#[test]
fn half_block_lines_preserves_aspect_for_wide_images() {
let buf: ImageBuffer<Rgba<u8>, Vec<u8>> =
ImageBuffer::from_pixel(200, 50, Rgba([50, 150, 50, 255]));
let img = DynamicImage::ImageRgba8(buf);
let rendered = half_block_lines(&img, 64, 24);
let cols = rendered.lines.first().map(|l| l.spans.len()).unwrap_or(0);
let rows = rendered.lines.len();
assert!(cols >= 32);
assert!(rows < 24);
}
#[test]
fn downscale_for_vision_shrinks_oversized_and_keeps_aspect() {
let buf: ImageBuffer<Rgba<u8>, Vec<u8>> =
ImageBuffer::from_pixel(3000, 1000, Rgba([10, 20, 30, 255]));
let mut png = Vec::new();
DynamicImage::ImageRgba8(buf)
.write_to(&mut std::io::Cursor::new(&mut png), image::ImageFormat::Png)
.unwrap();
let out = downscale_for_vision(&png, 1568).expect("downscaled");
let decoded = image::load_from_memory(&out).unwrap();
let (w, h) = decoded.dimensions();
assert_eq!(w.max(h), 1568);
assert!(w > h); }
#[test]
fn downscale_for_vision_leaves_small_images_alone() {
let buf: ImageBuffer<Rgba<u8>, Vec<u8>> =
ImageBuffer::from_pixel(100, 100, Rgba([1, 2, 3, 255]));
let mut png = Vec::new();
DynamicImage::ImageRgba8(buf)
.write_to(&mut std::io::Cursor::new(&mut png), image::ImageFormat::Png)
.unwrap();
assert!(downscale_for_vision(&png, 1568).is_none());
}
#[test]
fn downscale_for_vision_ignores_undecodable_bytes() {
assert!(downscale_for_vision(b"not an image", 1568).is_none());
assert!(downscale_for_vision(&[0u8; 4], 0).is_none());
}
#[test]
fn render_bounded_decodes_a_round_trip_png() {
let tmp = tempfile::NamedTempFile::new().unwrap();
let path = tmp.path().with_extension("png");
let buf: ImageBuffer<Rgba<u8>, Vec<u8>> =
ImageBuffer::from_pixel(10, 10, Rgba([0, 100, 200, 255]));
DynamicImage::ImageRgba8(buf).save(&path).unwrap();
let r = render_bounded(&path, 64, 24).expect("decodes");
assert_eq!(r.width, 10);
assert_eq!(r.height, 10);
assert!(!r.lines.is_empty());
}
}