pub mod iterm2;
pub mod kitty;
pub mod pane;
pub mod sixel;
pub use pane::ImagePane;
#[derive(Debug, Clone)]
pub struct PaintRequest {
pub pane_id: crate::layout::PaneId,
pub area: ratatui::layout::Rect,
pub png_bytes: std::sync::Arc<Vec<u8>>,
}
use std::path::{Path, PathBuf};
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum ImageProtocol {
Kitty,
Iterm2,
Sixel,
None,
}
#[cfg(unix)]
pub fn probe_cell_pixel_size() -> Option<(u16, u16)> {
use std::os::unix::io::AsRawFd;
#[repr(C)]
struct WinSize {
ws_row: u16,
ws_col: u16,
ws_xpixel: u16,
ws_ypixel: u16,
}
#[cfg(any(target_os = "macos", target_os = "freebsd", target_os = "openbsd"))]
const TIOCGWINSZ: u64 = 0x40087468;
#[cfg(target_os = "linux")]
const TIOCGWINSZ: u64 = 0x5413;
#[cfg(not(any(
target_os = "macos",
target_os = "freebsd",
target_os = "openbsd",
target_os = "linux",
)))]
const TIOCGWINSZ: u64 = 0x5413;
unsafe extern "C" {
fn ioctl(fd: i32, req: u64, arg: *mut WinSize) -> i32;
}
let fd = std::io::stdout().as_raw_fd();
let mut ws = WinSize {
ws_row: 0,
ws_col: 0,
ws_xpixel: 0,
ws_ypixel: 0,
};
let ret = unsafe { ioctl(fd, TIOCGWINSZ, &mut ws) };
if ret != 0 || ws.ws_row == 0 || ws.ws_col == 0 || ws.ws_xpixel == 0 || ws.ws_ypixel == 0 {
return None;
}
let cell_w = ws.ws_xpixel / ws.ws_col;
let cell_h = ws.ws_ypixel / ws.ws_row;
if cell_w == 0 || cell_h == 0 {
return None;
}
Some((cell_w, cell_h))
}
#[cfg(not(unix))]
pub fn probe_cell_pixel_size() -> Option<(u16, u16)> {
None
}
pub fn detect_protocol() -> ImageProtocol {
if let Ok(forced) = std::env::var("MNML_IMAGE_PROTOCOL") {
match forced.to_ascii_lowercase().as_str() {
"kitty" => return ImageProtocol::Kitty,
"iterm2" | "iterm" => return ImageProtocol::Iterm2,
"sixel" => return ImageProtocol::Sixel,
"none" | "off" => return ImageProtocol::None,
_ => {} }
}
if std::env::var_os("KITTY_WINDOW_ID").is_some() {
return ImageProtocol::Kitty;
}
if let Ok(term) = std::env::var("TERM")
&& term.to_lowercase().contains("kitty")
{
return ImageProtocol::Kitty;
}
if let Ok(tp) = std::env::var("TERM_PROGRAM") {
let l = tp.to_lowercase();
if l.contains("wezterm") || l == "ghostty" {
return ImageProtocol::Kitty;
}
if l.contains("iterm") {
return ImageProtocol::Iterm2;
}
if l.contains("black box") || l == "blackbox" {
return ImageProtocol::Sixel;
}
}
if let Ok(term) = std::env::var("TERM") {
let t = term.to_lowercase();
if t == "foot" || t.starts_with("foot-") || t.starts_with("mlterm") {
return ImageProtocol::Sixel;
}
}
ImageProtocol::None
}
#[derive(Debug, Clone)]
pub struct ImageData {
pub path: PathBuf,
pub bytes: Vec<u8>,
pub format: ImageFormat,
pub png_bytes: Option<std::sync::Arc<Vec<u8>>>,
pub pixel_size: Option<(u32, u32)>,
}
impl ImageData {
pub fn ensure_png_bytes(&mut self) -> Result<std::sync::Arc<Vec<u8>>, String> {
if let Some(arc) = self.png_bytes.as_ref() {
return Ok(arc.clone());
}
let arc = if matches!(self.format, ImageFormat::Png) {
if self.pixel_size.is_none() {
self.pixel_size = parse_png_size(&self.bytes);
}
std::sync::Arc::new(self.bytes.clone())
} else {
let img = image::load_from_memory(&self.bytes)
.map_err(|e| format!("decode {}: {e}", format_label(self.format)))?;
self.pixel_size = Some((img.width(), img.height()));
let mut out: Vec<u8> = Vec::with_capacity(self.bytes.len());
img.write_to(&mut std::io::Cursor::new(&mut out), image::ImageFormat::Png)
.map_err(|e| format!("encode PNG: {e}"))?;
std::sync::Arc::new(out)
};
self.png_bytes = Some(arc.clone());
Ok(arc)
}
}
fn format_label(f: ImageFormat) -> &'static str {
match f {
ImageFormat::Png => "PNG",
ImageFormat::Jpeg => "JPEG",
ImageFormat::Gif => "GIF",
ImageFormat::Webp => "WebP",
ImageFormat::Bmp => "BMP",
ImageFormat::Other => "image",
}
}
fn parse_png_size(bytes: &[u8]) -> Option<(u32, u32)> {
if bytes.len() < 24 {
return None;
}
if &bytes[0..8] != b"\x89PNG\r\n\x1a\n" {
return None;
}
if &bytes[12..16] != b"IHDR" {
return None;
}
let w = u32::from_be_bytes([bytes[16], bytes[17], bytes[18], bytes[19]]);
let h = u32::from_be_bytes([bytes[20], bytes[21], bytes[22], bytes[23]]);
Some((w, h))
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum ImageFormat {
Png,
Jpeg,
Gif,
Webp,
Bmp,
Other,
}
impl ImageFormat {
pub fn from_path(path: &Path) -> Self {
let ext = path
.extension()
.and_then(|s| s.to_str())
.map(str::to_ascii_lowercase);
match ext.as_deref() {
Some("png") => ImageFormat::Png,
Some("jpg") | Some("jpeg") => ImageFormat::Jpeg,
Some("gif") => ImageFormat::Gif,
Some("webp") => ImageFormat::Webp,
Some("bmp") => ImageFormat::Bmp,
_ => ImageFormat::Other,
}
}
}
pub fn load(path: &Path) -> Result<ImageData, String> {
const MAX_BYTES: u64 = 50 * 1024 * 1024; let meta = std::fs::metadata(path).map_err(|e| format!("stat: {e}"))?;
if meta.len() > MAX_BYTES {
return Err(format!(
"file too large ({} MB > 50 MB cap)",
meta.len() / 1_048_576
));
}
let bytes = std::fs::read(path).map_err(|e| format!("read: {e}"))?;
Ok(ImageData {
path: path.to_path_buf(),
bytes,
format: ImageFormat::from_path(path),
png_bytes: None,
pixel_size: None,
})
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn format_from_path_picks_known_extensions() {
assert_eq!(ImageFormat::from_path(Path::new("a.png")), ImageFormat::Png);
assert_eq!(
ImageFormat::from_path(Path::new("a.JPG")),
ImageFormat::Jpeg
);
assert_eq!(
ImageFormat::from_path(Path::new("a.jpeg")),
ImageFormat::Jpeg
);
assert_eq!(ImageFormat::from_path(Path::new("a.gif")), ImageFormat::Gif);
assert_eq!(
ImageFormat::from_path(Path::new("a.webp")),
ImageFormat::Webp
);
assert_eq!(ImageFormat::from_path(Path::new("a.bmp")), ImageFormat::Bmp);
assert_eq!(
ImageFormat::from_path(Path::new("a.tif")),
ImageFormat::Other
);
assert_eq!(
ImageFormat::from_path(Path::new("noext")),
ImageFormat::Other
);
}
fn round_trip(format: image::ImageFormat, our_format: ImageFormat) {
let raw = image::RgbImage::from_pixel(2, 2, image::Rgb([255, 0, 0]));
let mut encoded = Vec::new();
image::DynamicImage::ImageRgb8(raw)
.write_to(&mut std::io::Cursor::new(&mut encoded), format)
.expect("encode test fixture");
let mut data = ImageData {
path: PathBuf::from("x"),
bytes: encoded,
format: our_format,
png_bytes: None,
pixel_size: None,
};
let png = data.ensure_png_bytes().expect("decode + reencode");
assert_eq!(&png[0..8], b"\x89PNG\r\n\x1a\n", "{our_format:?} → PNG");
assert_eq!(data.pixel_size, Some((2, 2)));
}
#[test]
fn jpeg_decodes_and_reencodes_to_png() {
round_trip(image::ImageFormat::Jpeg, ImageFormat::Jpeg);
}
#[test]
fn gif_decodes_and_reencodes_to_png() {
round_trip(image::ImageFormat::Gif, ImageFormat::Gif);
}
#[test]
fn webp_decodes_and_reencodes_to_png() {
round_trip(image::ImageFormat::WebP, ImageFormat::Webp);
}
#[test]
fn bmp_decodes_and_reencodes_to_png() {
round_trip(image::ImageFormat::Bmp, ImageFormat::Bmp);
}
#[test]
fn png_source_zero_copies_through_ensure_png_bytes() {
let raw = image::RgbImage::from_pixel(2, 2, image::Rgb([0, 255, 0]));
let mut encoded = Vec::new();
image::DynamicImage::ImageRgb8(raw)
.write_to(
&mut std::io::Cursor::new(&mut encoded),
image::ImageFormat::Png,
)
.unwrap();
let mut data = ImageData {
path: PathBuf::from("x"),
bytes: encoded.clone(),
format: ImageFormat::Png,
png_bytes: None,
pixel_size: None,
};
let png = data.ensure_png_bytes().unwrap();
assert_eq!(&*png, &encoded, "PNG source should be reused verbatim");
assert_eq!(data.pixel_size, Some((2, 2)));
}
}