use base64::Engine as _;
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum ImageFormat {
Rgb,
Rgba,
Png,
}
impl ImageFormat {
fn kitty_code(self) -> u32 {
match self {
Self::Rgb => 24,
Self::Rgba => 32,
Self::Png => 100,
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum GraphicsProtocol {
Kitty,
Sixel,
Iterm2,
}
#[derive(Debug, Clone)]
pub struct GraphicsImage<'a> {
pub format: ImageFormat,
pub width: u32,
pub height: u32,
pub data: &'a [u8],
}
const KITTY_CHUNK: usize = 4096;
pub fn kitty_write(image: &GraphicsImage, id: u32) -> Vec<u8> {
let encoded = base64::engine::general_purpose::STANDARD.encode(image.data);
let bytes = encoded.as_bytes();
let mut out = Vec::with_capacity(bytes.len() + 64);
if bytes.is_empty() {
return out;
}
let chunks: Vec<&[u8]> = bytes.chunks(KITTY_CHUNK).collect();
let last = chunks.len() - 1;
for (i, chunk) in chunks.iter().enumerate() {
out.extend_from_slice(b"\x1b_G");
if i == 0 {
let more = if last == 0 { 0 } else { 1 };
let control =
format!("a=T,f={},s={},v={},i={},m={}", image.format.kitty_code(), image.width, image.height, id, more);
out.extend_from_slice(control.as_bytes());
} else {
let more = if i == last { 0 } else { 1 };
out.extend_from_slice(format!("m={more}").as_bytes());
}
out.push(b';');
out.extend_from_slice(chunk);
out.extend_from_slice(b"\x1b\\");
}
out
}
pub fn kitty_delete(id: u32) -> Vec<u8> {
format!("\x1b_Ga=d,d=i,i={id}\x1b\\").into_bytes()
}
pub fn kitty_delete_all() -> Vec<u8> {
b"\x1b_Ga=d,d=A\x1b\\".to_vec()
}
pub fn sixel_write(image: &GraphicsImage) -> Vec<u8> {
let bpp = match image.format {
ImageFormat::Rgb => 3,
ImageFormat::Rgba => 4,
ImageFormat::Png => return Vec::new(),
};
let (w, h) = (image.width as usize, image.height as usize);
if w == 0 || h == 0 || image.data.len() < w * h * bpp {
return Vec::new();
}
let mut palette: Vec<(u8, u8, u8)> = Vec::new();
let mut indices = vec![0u16; w * h];
for (i, px) in indices.iter_mut().enumerate() {
let o = i * bpp;
let color = (image.data[o], image.data[o + 1], image.data[o + 2]);
let idx = match palette.iter().position(|&c| c == color) {
Some(p) => p,
None if palette.len() < 256 => {
palette.push(color);
palette.len() - 1
}
None => 0, };
*px = idx as u16;
}
let mut out = Vec::new();
out.extend_from_slice(b"\x1bPq");
for (n, &(r, g, b)) in palette.iter().enumerate() {
let rp = (r as u32 * 100) / 255;
let gp = (g as u32 * 100) / 255;
let bp = (b as u32 * 100) / 255;
out.extend_from_slice(format!("#{n};2;{rp};{gp};{bp}").as_bytes());
}
let mut y = 0;
while y < h {
let band = (h - y).min(6);
for (n, _) in palette.iter().enumerate() {
out.extend_from_slice(format!("#{n}").as_bytes());
for x in 0..w {
let mut bits = 0u8;
for row in 0..band {
if indices[(y + row) * w + x] as usize == n {
bits |= 1 << row;
}
}
out.push(0x3f + bits); }
out.push(b'$'); }
out.push(b'-'); y += band;
}
out.extend_from_slice(b"\x1b\\"); out
}
pub fn iterm_write(file_bytes: &[u8], name: Option<&str>, width: Option<u32>, height: Option<u32>) -> Vec<u8> {
let encoded = base64::engine::general_purpose::STANDARD.encode(file_bytes);
let mut args = format!("size={};inline=1", file_bytes.len());
if let Some(n) = name {
let enc_name = base64::engine::general_purpose::STANDARD.encode(n.as_bytes());
args = format!("name={enc_name};{args}");
}
if let Some(w) = width {
args.push_str(&format!(";width={w}"));
}
if let Some(h) = height {
args.push_str(&format!(";height={h}"));
}
format!("\x1b]1337;File={args}:{encoded}\x07").into_bytes()
}
pub fn graphics_query() -> Vec<u8> {
let mut out = Vec::new();
out.extend_from_slice(b"\x1b_Gi=1,a=q;\x1b\\");
out.extend_from_slice(b"\x1b[c");
out
}
#[cfg(test)]
mod tests {
use super::*;
fn rgb_image<'a>(data: &'a [u8], w: u32, h: u32) -> GraphicsImage<'a> {
GraphicsImage { format: ImageFormat::Rgb, width: w, height: h, data }
}
#[test]
fn kitty_write_single_chunk() {
let data = [255u8, 0, 0]; let seq = kitty_write(&rgb_image(&data, 1, 1), 7);
let s = String::from_utf8_lossy(&seq);
assert!(s.starts_with("\x1b_G"), "starts with APC _G: {s:?}");
assert!(s.contains("a=T"), "transmit+display action");
assert!(s.contains("f=24"), "RGB format code");
assert!(s.contains("i=7"), "carries the id");
assert!(s.contains("m=0"), "single chunk is final");
assert!(s.ends_with("\x1b\\"), "ends with ST");
}
#[test]
fn kitty_write_chunks_large_payload() {
let data = vec![1u8; 4096 * 3];
let seq = kitty_write(&rgb_image(&data, 4096, 1), 1);
let s = String::from_utf8_lossy(&seq);
assert!(s.matches("\x1b_G").count() >= 2, "multi-chunk emits multiple escapes");
assert!(s.contains("m=1"), "non-final chunk sets m=1");
assert!(s.trim_end_matches("\x1b\\").ends_with(|c: char| c.is_ascii()), "ends cleanly");
}
#[test]
fn kitty_delete_sequences() {
assert_eq!(kitty_delete(3), b"\x1b_Ga=d,d=i,i=3\x1b\\");
assert_eq!(kitty_delete_all(), b"\x1b_Ga=d,d=A\x1b\\");
}
#[test]
fn sixel_write_emits_dcs_and_palette() {
let data = [255u8, 0, 0, 0, 255, 0];
let seq = sixel_write(&GraphicsImage { format: ImageFormat::Rgb, width: 2, height: 1, data: &data });
let s = String::from_utf8_lossy(&seq);
assert!(s.starts_with("\x1bPq"), "starts with DCS q: {s:?}");
assert!(s.contains("#0;2;100;0;0"), "red palette entry");
assert!(s.contains("#1;2;0;100;0"), "green palette entry");
assert!(s.ends_with("\x1b\\"), "ends with ST");
}
#[test]
fn sixel_rejects_png() {
let img = GraphicsImage { format: ImageFormat::Png, width: 1, height: 1, data: &[0, 1, 2] };
assert!(sixel_write(&img).is_empty());
}
#[test]
fn sixel_rejects_short_buffer() {
let data = [1u8, 2, 3]; let img = GraphicsImage { format: ImageFormat::Rgb, width: 2, height: 2, data: &data };
assert!(sixel_write(&img).is_empty());
}
#[test]
fn iterm_write_encodes_file() {
let seq = iterm_write(b"PNGDATA", Some("a.png"), Some(10), None);
let s = String::from_utf8_lossy(&seq);
assert!(s.starts_with("\x1b]1337;File="), "OSC 1337 File: {s:?}");
assert!(s.contains("inline=1"));
assert!(s.contains("size=7"));
assert!(s.contains("width=10"));
assert!(!s.contains("height="), "height omitted when None");
assert!(s.contains("UE5HREFUQQ=="), "payload base64: {s:?}");
assert!(s.ends_with('\x07'), "BEL terminator");
}
#[test]
fn graphics_query_probes_kitty_and_sixel() {
let seq = graphics_query();
let s = String::from_utf8_lossy(&seq);
assert!(s.contains("\x1b_Gi=1,a=q;"), "kitty query");
assert!(s.contains("\x1b[c"), "DA1 for sixel");
}
}