use std::fmt::Write as _;
use std::io::Write as _;
use std::sync::atomic::{AtomicU32, Ordering};
use base64::Engine as _;
use image::RgbaImage;
use ratatui::buffer::{Buffer, CellDiffOption};
use ratatui::layout::Rect;
const UNIT_WIDTH: CellDiffOption =
CellDiffOption::ForcedWidth(std::num::NonZeroU16::new(1).unwrap());
static NEXT_ID: AtomicU32 = AtomicU32::new(1);
pub fn next_id() -> u32 {
let id = NEXT_ID.fetch_add(1, Ordering::Relaxed);
if id == 0 {
NEXT_ID.fetch_add(1, Ordering::Relaxed)
} else {
id
}
}
fn zlib_compress(raw: &[u8]) -> Vec<u8> {
let mut enc = flate2::write::ZlibEncoder::new(Vec::new(), flate2::Compression::new(6));
let _ = enc.write_all(raw);
enc.finish().unwrap_or_default()
}
#[derive(Clone)]
pub struct KittyImage {
transmit: String,
transmitted: std::sync::Arc<std::sync::atomic::AtomicBool>,
cols: u16,
rows: u16,
id_color: String,
id_extra: u16,
}
impl KittyImage {
pub fn build(rgba: &RgbaImage, cols: u16, rows: u16, id: u32, is_tmux: bool) -> Self {
let transmit = build_transmit(rgba, id, is_tmux);
let [id_extra, r, g, b] = id.to_be_bytes();
Self {
transmit,
transmitted: std::sync::Arc::new(std::sync::atomic::AtomicBool::new(false)),
cols,
rows,
id_color: format!("\x1b[38;2;{r};{g};{b}m"),
id_extra: u16::from(id_extra),
}
}
#[cfg(test)]
pub fn cell_size(&self) -> (u16, u16) {
(self.cols, self.rows)
}
pub fn render(&self, area: Rect, buf: &mut Buffer) {
if area.width == 0 || area.height == 0 {
return;
}
let full_width = area.width.min(self.cols);
let width_usize = usize::from(full_width);
if width_usize == 0 {
return;
}
let already = self
.transmitted
.swap(true, std::sync::atomic::Ordering::SeqCst);
let mut seq: Option<&str> = if already {
None
} else {
Some(self.transmit.as_str())
};
let row_diacritics: String =
std::iter::repeat_n('\u{10EEEE}', width_usize.saturating_sub(1)).collect();
let right = area.width - 1;
let down = area.height - 1;
let restore_cursor = format!("\x1b[u\x1b[{right}C\x1b[{down}B");
let height = area.height.min(self.rows).min(DIACRITICS.len() as u16);
let mut symbol = String::new();
for y in 0..height {
symbol.clear();
if let Some(s) = seq.take() {
symbol.push_str(s);
}
let _ = write!(
symbol,
"\x1b[s{}\u{10EEEE}{}{}{}",
self.id_color,
diacritic(y),
diacritic(0),
diacritic(self.id_extra),
);
symbol.push_str(&row_diacritics);
for x in 1..full_width {
if let Some(cell) = buf.cell_mut((area.left() + x, area.top() + y)) {
cell.set_diff_option(CellDiffOption::Skip);
}
}
symbol.push_str(&restore_cursor);
if let Some(cell) = buf.cell_mut((area.left(), area.top() + y)) {
cell.set_symbol(&symbol).set_diff_option(UNIT_WIDTH);
}
}
}
}
pub fn build_from_source(
src: &image::DynamicImage,
crop_rect: (u32, u32, u32, u32),
cols: u16,
rows: u16,
font: ratatui_image::FontSize,
is_tmux: bool,
) -> Option<KittyImage> {
if cols == 0 || rows == 0 {
return None;
}
let px_w = u32::from(cols) * u32::from(font.width.max(1));
let px_h = u32::from(rows) * u32::from(font.height.max(1));
let (x0, y0, cw, ch) = crop_rect;
let resized = src
.crop_imm(x0, y0, cw, ch)
.resize_exact(px_w, px_h, image::imageops::FilterType::Lanczos3)
.to_rgba8();
Some(KittyImage::build(&resized, cols, rows, next_id(), is_tmux))
}
pub fn build_transmit(img: &RgbaImage, id: u32, is_tmux: bool) -> String {
let (w, h) = (img.width(), img.height());
let compressed = zlib_compress(img.as_raw());
let b64 = base64::engine::general_purpose::STANDARD.encode(&compressed);
let (start, escape, end) = tmux_wrap(is_tmux);
const CHARS_PER_CHUNK: usize = 4096;
let chunk_count = b64.len().div_ceil(CHARS_PER_CHUNK).max(1);
let mut data = String::with_capacity(b64.len() + chunk_count * (16 + escape.len() * 2));
let bytes = b64.as_bytes();
for i in 0..chunk_count {
let lo = i * CHARS_PER_CHUNK;
let hi = (lo + CHARS_PER_CHUNK).min(b64.len());
data.push_str(start);
let _ = write!(data, "{escape}_Gq=2,");
if i == 0 {
let _ = write!(data, "i={id},a=T,U=1,f=32,o=z,t=d,s={w},v={h},");
}
let more = u8::from(i + 1 < chunk_count);
let _ = write!(data, "m={more};");
data.push_str(std::str::from_utf8(&bytes[lo..hi]).unwrap_or(""));
let _ = write!(data, "{escape}\\");
data.push_str(end);
}
data
}
fn tmux_wrap(is_tmux: bool) -> (&'static str, &'static str, &'static str) {
if is_tmux {
("\x1bPtmux;", "\x1b\x1b", "\x1b\\")
} else {
("", "\x1b", "")
}
}
fn diacritic(i: u16) -> char {
*DIACRITICS.get(usize::from(i)).unwrap_or(&DIACRITICS[0])
}
include!("kitty_diacritics.rs");
#[cfg(test)]
mod tests {
use super::*;
use image::{Rgba, RgbaImage};
fn solid(w: u32, h: u32, px: [u8; 4]) -> RgbaImage {
RgbaImage::from_pixel(w, h, Rgba(px))
}
fn payload_b64(transmit: &str) -> String {
let mut out = String::new();
for chunk in transmit.split("\x1b_G").skip(1) {
let after_header = &chunk[chunk.find(';').expect("chunk has a header") + 1..];
let body = &after_header[..after_header.find('\x1b').expect("chunk terminator")];
out.push_str(body);
}
out
}
#[test]
fn transmit_round_trips_through_zlib() {
let mut img = RgbaImage::new(20, 10);
for (x, y, p) in img.enumerate_pixels_mut() {
*p = Rgba([(x * 11) as u8, (y * 23) as u8, (x + y) as u8, 255]);
}
let t = build_transmit(&img, 7, false);
assert!(t.contains("i=7,a=T,U=1,f=32,o=z,t=d,s=20,v=10,"));
let b64 = payload_b64(&t);
let compressed = base64::engine::general_purpose::STANDARD
.decode(b64.as_bytes())
.expect("valid base64");
let mut dec = flate2::read::ZlibDecoder::new(&compressed[..]);
let mut raw = Vec::new();
std::io::Read::read_to_end(&mut dec, &mut raw).expect("valid zlib");
assert_eq!(raw, *img.as_raw(), "decompressed pixels match the source");
}
#[test]
fn compression_shrinks_flat_images() {
let img = solid(400, 300, [30, 40, 55, 255]);
let raw_b64 = img.as_raw().len().div_ceil(3) * 4;
let compressed_b64 = payload_b64(&build_transmit(&img, 1, false)).len();
assert!(
compressed_b64 * 10 < raw_b64,
"flat image compresses >10x: {compressed_b64} vs {raw_b64}"
);
}
#[test]
fn tmux_wraps_each_chunk() {
let img = solid(200, 200, [1, 2, 3, 255]);
let t = build_transmit(&img, 3, true);
assert!(t.starts_with("\x1bPtmux;"), "tmux passthrough start");
assert!(t.contains("\x1b\x1b_G"), "escapes are doubled inside tmux");
}
#[test]
fn render_transmits_once_then_placeholders_only() {
let img = solid(30, 20, [200, 100, 50, 255]);
let ki = KittyImage::build(&img, 3, 2, 9, false);
let area = Rect::new(0, 0, 3, 2);
let mut buf = Buffer::empty(area);
ki.render(area, &mut buf);
let first: String = buf.content.iter().map(|c| c.symbol()).collect();
assert!(first.contains("o=z"), "first render carries the transmit");
assert!(first.contains('\u{10EEEE}'), "placeholders are drawn");
let mut buf2 = Buffer::empty(area);
ki.render(area, &mut buf2);
let second: String = buf2.content.iter().map(|c| c.symbol()).collect();
assert!(
!second.contains("o=z"),
"second render is placeholders only"
);
assert!(second.contains('\u{10EEEE}'), "placeholders still drawn");
}
#[test]
fn diacritics_table_is_complete() {
assert_eq!(DIACRITICS.len(), 297);
assert_eq!(diacritic(0), DIACRITICS[0]);
assert_eq!(diacritic(296), DIACRITICS[296]);
assert_eq!(diacritic(999), DIACRITICS[0], "overflow clamps to first");
}
#[test]
fn fresh_ids_are_nonzero_and_unique() {
let a = next_id();
let b = next_id();
assert_ne!(a, 0);
assert_ne!(a, b);
}
#[test]
fn cell_size_reports_build_dimensions() {
let img = solid(30, 20, [10, 20, 30, 255]);
let ki = KittyImage::build(&img, 7, 4, 1, false);
assert_eq!(ki.cell_size(), (7, 4));
}
#[test]
fn render_clamps_to_area_smaller_than_image() {
let img = solid(40, 40, [5, 5, 5, 255]);
let ki = KittyImage::build(&img, 10, 8, 2, false);
let area = Rect::new(0, 0, 3, 2); let mut buf = Buffer::empty(Rect::new(0, 0, 10, 8));
ki.render(area, &mut buf);
let outside = buf.cell((0, 5)).map(|c| c.symbol().to_string());
assert_eq!(
outside.as_deref(),
Some(" "),
"no placeholder beyond the area"
);
}
}