use base64::{
engine::general_purpose::{STANDARD as BASE64, STANDARD_NO_PAD},
Engine,
};
use smallvec::SmallVec;
use std::collections::HashMap;
use sugarloaf::{ColorType, GraphicData, GraphicId, ResizeCommand, ResizeParameter};
use tracing::debug;
#[derive(Debug, Default)]
pub struct KittyGraphicsState {
incomplete_images: HashMap<u32, KittyGraphicsCommand>,
current_transmission_key: u32,
}
#[derive(Debug)]
pub struct KittyGraphicsResponse {
pub graphic_data: Option<GraphicData>,
pub placement_request: Option<PlacementRequest>,
pub delete_request: Option<DeleteRequest>,
pub response: Option<String>,
}
#[derive(Debug)]
pub struct PlacementRequest {
pub image_id: u32,
pub placement_id: u32,
pub x: u32,
pub y: u32,
pub width: u32,
pub height: u32,
pub columns: u32,
pub rows: u32,
pub z_index: i32,
pub unicode_placeholder: u32,
pub cursor_movement: u8, }
#[derive(Debug)]
pub struct DeleteRequest {
pub action: u8,
pub image_id: u32,
pub placement_id: u32,
pub x: u32,
pub y: u32,
pub z_index: i32,
pub delete_data: bool,
}
#[derive(Debug, Clone, Copy, PartialEq)]
enum Action {
Transmit,
TransmitAndDisplay,
Query,
Put,
Delete,
Frame,
Animate,
Compose,
}
#[derive(Debug, Clone, Copy, PartialEq)]
enum Format {
Gray, GrayAlpha, Rgb24, Rgba32, Png,
}
#[derive(Debug, Clone, Copy, PartialEq)]
enum TransmissionMedium {
Direct,
File,
TempFile,
SharedMemory,
}
#[derive(Debug, Clone, Copy, PartialEq)]
enum Compression {
None,
Zlib,
}
#[derive(Debug, Clone)]
pub struct KittyGraphicsCommand {
action: Action,
quiet: u8,
format: Format,
medium: TransmissionMedium,
width: u32,
height: u32,
size: u32,
offset: u32,
image_id: u32,
image_number: u32,
placement_id: u32,
compression: Compression,
more: bool,
source_x: u32,
source_y: u32,
source_width: u32,
source_height: u32,
cell_x_offset: u32,
cell_y_offset: u32,
columns: u32,
rows: u32,
cursor_movement: u8,
virtual_placement: bool,
z_index: i32,
parent_id: u32,
parent_placement_id: u32,
relative_x: i32,
relative_y: i32,
frame_number: u32,
base_frame: u32,
frame_gap: i32,
composition_mode: u8,
background_color: u32,
animation_state: u8,
loop_count: u32,
current_frame: u32,
delete_action: u8,
unicode_placeholder: u32,
payload: SmallVec<[u8; 64]>,
}
impl Default for KittyGraphicsCommand {
fn default() -> Self {
Self {
action: Action::Transmit,
quiet: 0,
format: Format::Rgba32,
medium: TransmissionMedium::Direct,
width: 0,
height: 0,
size: 0,
offset: 0,
image_id: 0,
image_number: 0,
placement_id: 0,
compression: Compression::None,
more: false,
source_x: 0,
source_y: 0,
source_width: 0,
source_height: 0,
cell_x_offset: 0,
cell_y_offset: 0,
columns: 0,
rows: 0,
cursor_movement: 0,
virtual_placement: false,
z_index: 0,
parent_id: 0,
parent_placement_id: 0,
relative_x: 0,
relative_y: 0,
frame_number: 0,
base_frame: 0,
frame_gap: 0,
composition_mode: 0,
background_color: 0,
animation_state: 0,
loop_count: 0,
current_frame: 0,
delete_action: b'a',
unicode_placeholder: 0,
payload: SmallVec::new(),
}
}
}
pub fn parse(
params: &[&[u8]],
state: &mut KittyGraphicsState,
) -> Option<KittyGraphicsResponse> {
let Some(&b"G") = params.first() else {
debug!("Kitty graphics parse failed: first param is not 'G'");
return None;
};
debug!(
"Kitty graphics parse: starting with {} params",
params.len()
);
for (i, param) in params.iter().enumerate() {
debug!(
" param[{}] length={}, preview={:?}",
i,
param.len(),
std::str::from_utf8(¶m[..param.len().min(50)])
.unwrap_or("(invalid utf8)")
);
}
let mut cmd = KittyGraphicsCommand::default();
if let Some(control) = params.get(1) {
if !control.is_empty() {
let control_data = std::str::from_utf8(control).ok()?;
parse_control_data(&mut cmd, control_data);
}
}
if let Some(payload) = params.get(2) {
if !payload.is_empty() {
cmd.payload = SmallVec::from_slice(payload);
}
}
if cmd.action == Action::Query {
let response = if cmd.quiet < 2 {
format!("\x1b_Gi={};OK\x1b\\", cmd.image_id)
} else {
String::new()
};
return Some(KittyGraphicsResponse {
graphic_data: None,
placement_request: None,
delete_request: None,
response: Some(response),
});
}
let image_key = if cmd.image_id > 0 || cmd.image_number > 0 {
let key = if cmd.image_id > 0 {
cmd.image_id
} else {
cmd.image_number
};
state.current_transmission_key = key;
key
} else {
state.current_transmission_key
};
if cmd.more {
use std::collections::hash_map::Entry;
match state.incomplete_images.entry(image_key) {
Entry::Vacant(e) => {
let expected_size = cmd.size as usize;
if expected_size > 0 && cmd.payload.capacity() < expected_size {
cmd.payload
.reserve(expected_size.saturating_sub(cmd.payload.len()));
debug!(
"First chunk for image key {}: {} bytes, reserved {} bytes total",
image_key,
cmd.payload.len(),
expected_size
);
} else {
debug!(
"First chunk for image key {}: {} bytes",
image_key,
cmd.payload.len()
);
}
e.insert(cmd);
}
Entry::Occupied(mut e) => {
let stored_cmd = e.get_mut();
stored_cmd.payload.extend_from_slice(&cmd.payload);
debug!(
"Appended chunk for image key {}: {} bytes accumulated",
image_key,
stored_cmd.payload.len()
);
}
}
return None;
} else {
if let Some(mut stored_cmd) = state.incomplete_images.remove(&image_key) {
stored_cmd.payload.extend_from_slice(&cmd.payload);
cmd = stored_cmd; debug!(
"Retrieved accumulated image key {}: total {} bytes",
image_key,
cmd.payload.len()
);
state.current_transmission_key = 0;
}
}
debug!("Kitty graphics action: {:?}, format={:?}, width={}, height={}, image_id={}, payload_len={}",
cmd.action, cmd.format, cmd.width, cmd.height, cmd.image_id, cmd.payload.len());
match cmd.action {
Action::Transmit | Action::TransmitAndDisplay => {
debug!("Creating graphic data: format={:?}, medium={:?}, compression={:?}, width={}, height={}, payload_len={}",
cmd.format, cmd.medium, cmd.compression, cmd.width, cmd.height, cmd.payload.len());
let graphic_data = create_graphic_data(&cmd)?;
debug!(
"Graphic data created successfully: {}x{}",
graphic_data.width, graphic_data.height
);
let response = if cmd.quiet == 0 && (cmd.image_id > 0 || cmd.image_number > 0)
{
let id_part = if cmd.image_id > 0 {
format!("i={}", cmd.image_id)
} else {
format!("i={},I={}", graphic_data.id.get(), cmd.image_number)
};
Some(format!("\x1b_G{};OK\x1b\\", id_part))
} else {
None
};
let placement_request = if cmd.action == Action::TransmitAndDisplay {
Some(PlacementRequest {
image_id: cmd.image_id,
placement_id: cmd.placement_id,
x: cmd.source_x,
y: cmd.source_y,
width: cmd.source_width,
height: cmd.source_height,
columns: cmd.columns,
rows: cmd.rows,
z_index: cmd.z_index,
unicode_placeholder: cmd.unicode_placeholder,
cursor_movement: cmd.cursor_movement,
})
} else {
None
};
Some(KittyGraphicsResponse {
graphic_data: Some(graphic_data),
placement_request,
delete_request: None,
response,
})
}
Action::Put => {
let placement = PlacementRequest {
image_id: cmd.image_id,
placement_id: cmd.placement_id,
x: cmd.source_x,
y: cmd.source_y,
width: cmd.source_width,
height: cmd.source_height,
columns: cmd.columns,
rows: cmd.rows,
z_index: cmd.z_index,
unicode_placeholder: cmd.unicode_placeholder,
cursor_movement: cmd.cursor_movement,
};
let response = if cmd.quiet == 0 && cmd.image_id > 0 {
let id_part = if cmd.placement_id > 0 {
format!("i={},p={}", cmd.image_id, cmd.placement_id)
} else {
format!("i={}", cmd.image_id)
};
Some(format!("\x1b_G{};OK\x1b\\", id_part))
} else {
None
};
Some(KittyGraphicsResponse {
graphic_data: None,
placement_request: Some(placement),
delete_request: None,
response,
})
}
Action::Delete => {
let delete_data = cmd.delete_action.is_ascii_uppercase();
let delete = DeleteRequest {
action: cmd.delete_action.to_ascii_lowercase(),
image_id: cmd.image_id,
placement_id: cmd.placement_id,
x: cmd.source_x,
y: cmd.source_y,
z_index: cmd.z_index,
delete_data,
};
Some(KittyGraphicsResponse {
graphic_data: None,
placement_request: None,
delete_request: Some(delete),
response: None,
})
}
_ => {
None
}
}
}
fn parse_control_data(cmd: &mut KittyGraphicsCommand, control_data: &str) {
for pair in control_data.split(',') {
if let Some((key, value)) = pair.split_once('=') {
if key == "a" {
cmd.action = parse_action(value);
break;
}
}
}
for pair in control_data.split(',') {
if let Some((key, value)) = pair.split_once('=') {
match key {
"a" => {}
"q" => cmd.quiet = value.parse().unwrap_or(0),
"f" => cmd.format = parse_format(value),
"t" => cmd.medium = parse_transmission_medium(value),
"s" => match cmd.action {
Action::Animate => cmd.animation_state = value.parse().unwrap_or(0),
_ => cmd.width = value.parse().unwrap_or(0),
},
"v" => match cmd.action {
Action::Animate => cmd.loop_count = value.parse().unwrap_or(0),
_ => cmd.height = value.parse().unwrap_or(0),
},
"S" => cmd.size = value.parse().unwrap_or(0),
"O" => cmd.offset = value.parse().unwrap_or(0),
"i" => cmd.image_id = value.parse().unwrap_or(0),
"I" => cmd.image_number = value.parse().unwrap_or(0),
"p" => cmd.placement_id = value.parse().unwrap_or(0),
"o" => cmd.compression = parse_compression(value),
"m" => cmd.more = value == "1",
"x" => match cmd.action {
Action::Delete => cmd.source_x = value.parse().unwrap_or(0),
_ => cmd.source_x = value.parse().unwrap_or(0),
},
"y" => match cmd.action {
Action::Delete => cmd.source_y = value.parse().unwrap_or(0),
_ => cmd.source_y = value.parse().unwrap_or(0),
},
"w" => cmd.source_width = value.parse().unwrap_or(0),
"h" => cmd.source_height = value.parse().unwrap_or(0),
"X" => match cmd.action {
Action::Frame | Action::Compose => {
cmd.composition_mode = value.parse().unwrap_or(0)
}
_ => cmd.cell_x_offset = value.parse().unwrap_or(0),
},
"Y" => match cmd.action {
Action::Frame => cmd.background_color = value.parse().unwrap_or(0),
_ => cmd.cell_y_offset = value.parse().unwrap_or(0),
},
"c" => match cmd.action {
Action::Frame | Action::Compose => {
cmd.base_frame = value.parse().unwrap_or(0)
}
Action::Animate => cmd.current_frame = value.parse().unwrap_or(0),
_ => cmd.columns = value.parse().unwrap_or(0),
},
"r" => match cmd.action {
Action::Frame | Action::Compose | Action::Animate => {
cmd.frame_number = value.parse().unwrap_or(0)
}
_ => cmd.rows = value.parse().unwrap_or(0),
},
"z" => match cmd.action {
Action::Frame | Action::Animate => {
cmd.frame_gap = value.parse().unwrap_or(0)
}
_ => cmd.z_index = value.parse().unwrap_or(0),
},
"C" => cmd.cursor_movement = value.parse().unwrap_or(0),
"U" => cmd.virtual_placement = value == "1",
"P" => cmd.parent_id = value.parse().unwrap_or(0),
"Q" => cmd.parent_placement_id = value.parse().unwrap_or(0),
"H" => cmd.relative_x = value.parse().unwrap_or(0),
"V" => cmd.relative_y = value.parse().unwrap_or(0),
"d" => {
cmd.delete_action = value.as_bytes().first().copied().unwrap_or(b'a')
}
"u" => cmd.unicode_placeholder = value.parse().unwrap_or(0),
_ => {} }
}
}
}
fn parse_action(value: &str) -> Action {
match value {
"t" => Action::Transmit,
"T" => Action::TransmitAndDisplay,
"q" => Action::Query,
"p" => Action::Put,
"d" => Action::Delete,
"f" => Action::Frame,
"a" => Action::Animate,
"c" => Action::Compose,
_ => Action::Transmit,
}
}
fn parse_format(value: &str) -> Format {
match value {
"8" => Format::Gray,
"16" => Format::GrayAlpha,
"24" => Format::Rgb24,
"32" => Format::Rgba32,
"100" => Format::Png,
_ => Format::Rgba32,
}
}
fn parse_transmission_medium(value: &str) -> TransmissionMedium {
match value {
"d" => TransmissionMedium::Direct,
"f" => TransmissionMedium::File,
"t" => TransmissionMedium::TempFile,
"s" => TransmissionMedium::SharedMemory,
_ => TransmissionMedium::Direct,
}
}
fn parse_compression(value: &str) -> Compression {
match value {
"z" => Compression::Zlib,
_ => Compression::None,
}
}
fn create_graphic_data(cmd: &KittyGraphicsCommand) -> Option<GraphicData> {
let raw_data = match cmd.medium {
TransmissionMedium::Direct => {
debug!("Decoding base64 payload, length={}", cmd.payload.len());
match BASE64.decode(&cmd.payload) {
Ok(data) => {
debug!("Base64 decoded successfully: {} bytes", data.len());
data
}
Err(e) => {
debug!("Base64 decode failed: {:?}", e);
return None;
}
}
}
TransmissionMedium::File | TransmissionMedium::TempFile => {
use std::fs::File;
use std::io::Read;
use std::path::Path;
debug!(
"Decoding base64 file path, payload length={}",
cmd.payload.len()
);
let path_bytes = match BASE64.decode(&cmd.payload) {
Ok(bytes) => {
debug!(
"Base64 decoded file path with padding: {} bytes",
bytes.len()
);
bytes
}
Err(_) => {
match STANDARD_NO_PAD.decode(&cmd.payload) {
Ok(bytes) => {
debug!(
"Base64 decoded file path without padding: {} bytes",
bytes.len()
);
bytes
}
Err(e) => {
debug!("Base64 decode failed (both with and without padding): {:?}", e);
return None;
}
}
}
};
let path_str = std::str::from_utf8(&path_bytes).ok()?;
debug!("File path: {}", path_str);
let path = Path::new(path_str);
if !path.is_file() {
return None;
}
let path_str_lower = path_str.to_lowercase();
if path_str_lower.contains("/proc/")
|| path_str_lower.contains("/sys/")
|| path_str_lower.contains("/dev/")
{
return None;
}
if cmd.medium == TransmissionMedium::TempFile
&& !path_str.contains("tty-graphics-protocol")
{
return None;
}
let mut file = File::open(path).ok()?;
let mut data = Vec::new();
if cmd.size > 0 {
if cmd.offset > 0 {
use std::io::Seek;
file.seek(std::io::SeekFrom::Start(cmd.offset as u64))
.ok()?;
}
data.resize(cmd.size as usize, 0);
file.read_exact(&mut data).ok()?;
} else {
file.read_to_end(&mut data).ok()?;
}
if cmd.medium == TransmissionMedium::TempFile {
let _ = std::fs::remove_file(path);
}
data
}
TransmissionMedium::SharedMemory => {
#[cfg(unix)]
{
use std::ffi::CString;
use std::os::unix::io::RawFd;
debug!(
"Decoding shared memory name from base64, payload length={}",
cmd.payload.len()
);
let shm_name_bytes = match BASE64.decode(&cmd.payload) {
Ok(bytes) => {
debug!("Base64 decoded shm name: {} bytes", bytes.len());
bytes
}
Err(e) => {
debug!("Failed to decode shm name from base64: {:?}", e);
return None;
}
};
let shm_name_str = std::str::from_utf8(&shm_name_bytes).ok()?;
let shm_name = CString::new(shm_name_str).ok()?;
debug!(
"Opening shared memory: {}, expected size: {}",
shm_name_str,
cmd.width as usize * cmd.height as usize * 3 );
unsafe {
let fd: RawFd = libc::shm_open(shm_name.as_ptr(), libc::O_RDONLY, 0);
if fd < 0 {
let err = std::io::Error::last_os_error();
let errno = err.raw_os_error().unwrap_or(-1);
debug!(
"Failed to open shared memory '{}': {} (errno: {})",
shm_name_str, err, errno
);
return None;
}
let mut stat: libc::stat = std::mem::zeroed();
if libc::fstat(fd, &mut stat) < 0 {
libc::close(fd);
libc::shm_unlink(shm_name.as_ptr());
debug!("Failed to fstat shared memory");
return None;
}
let shm_size = stat.st_size as usize;
debug!("Shared memory size: {} bytes", shm_size);
let data_size = if cmd.size > 0 {
cmd.size as usize
} else {
shm_size
};
if data_size > shm_size {
libc::close(fd);
libc::shm_unlink(shm_name.as_ptr());
debug!(
"Requested size {} exceeds shared memory size {}",
data_size, shm_size
);
return None;
}
let ptr = libc::mmap(
std::ptr::null_mut(),
data_size,
libc::PROT_READ,
libc::MAP_SHARED,
fd,
cmd.offset as i64,
);
if ptr == libc::MAP_FAILED {
libc::close(fd);
debug!("Failed to mmap shared memory");
return None;
}
let data =
std::slice::from_raw_parts(ptr as *const u8, data_size).to_vec();
libc::munmap(ptr, data_size);
libc::close(fd);
libc::shm_unlink(shm_name.as_ptr());
debug!("Successfully read {} bytes from shared memory", data.len());
data
}
}
#[cfg(windows)]
{
use std::ffi::OsStr;
use std::os::windows::ffi::OsStrExt;
use windows_sys::Win32::Foundation::CloseHandle;
use windows_sys::Win32::System::Memory::OpenFileMappingW;
use windows_sys::Win32::System::Memory::{
MapViewOfFile, UnmapViewOfFile, VirtualQuery, FILE_MAP_READ,
MEMORY_BASIC_INFORMATION,
};
debug!(
"Decoding shared memory name from base64, payload length={}",
cmd.payload.len()
);
let shm_name_bytes = match BASE64.decode(&cmd.payload) {
Ok(bytes) => {
debug!("Base64 decoded shm name: {} bytes", bytes.len());
bytes
}
Err(e) => {
debug!("Failed to decode shm name from base64: {:?}", e);
return None;
}
};
let shm_name_str = std::str::from_utf8(&shm_name_bytes).ok()?;
debug!("Opening shared memory: {}", shm_name_str);
unsafe {
let wide_name: Vec<u16> = OsStr::new(shm_name_str)
.encode_wide()
.chain(std::iter::once(0))
.collect();
let handle = OpenFileMappingW(FILE_MAP_READ, 0, wide_name.as_ptr());
if handle.is_null() {
let err = std::io::Error::last_os_error();
debug!(
"Failed to open shared memory '{}': {}",
shm_name_str, err
);
return None;
}
let base_ptr = MapViewOfFile(handle, FILE_MAP_READ, 0, 0, 0);
if base_ptr.Value.is_null() {
let err = std::io::Error::last_os_error();
debug!("Failed to map view of file: {}", err);
CloseHandle(handle);
return None;
}
let mut mem_info: MEMORY_BASIC_INFORMATION = std::mem::zeroed();
if VirtualQuery(
base_ptr.Value,
&mut mem_info,
std::mem::size_of::<MEMORY_BASIC_INFORMATION>(),
) == 0
{
debug!("Failed to query memory information");
UnmapViewOfFile(base_ptr);
CloseHandle(handle);
return None;
}
let shm_size = mem_info.RegionSize;
debug!("Shared memory size: {} bytes", shm_size);
let data_size = if cmd.size > 0 {
cmd.size as usize
} else {
shm_size
};
if cmd.offset as usize + data_size > shm_size {
debug!(
"Requested offset {} + size {} exceeds shared memory size {}",
cmd.offset, data_size, shm_size
);
UnmapViewOfFile(base_ptr);
CloseHandle(handle);
return None;
}
let data_ptr = (base_ptr.Value as *const u8).add(cmd.offset as usize);
let data = std::slice::from_raw_parts(data_ptr, data_size).to_vec();
UnmapViewOfFile(base_ptr);
CloseHandle(handle);
debug!("Successfully read {} bytes from shared memory", data.len());
data
}
}
#[cfg(not(any(unix, windows)))]
{
debug!("SharedMemory transmission not supported on this platform");
return None;
}
}
};
let pixel_data = match cmd.compression {
Compression::None => raw_data,
Compression::Zlib => {
use flate2::read::ZlibDecoder;
use std::io::Read;
let mut decoder = ZlibDecoder::new(&raw_data[..]);
let mut decompressed = Vec::new();
decoder.read_to_end(&mut decompressed).ok()?;
decompressed
}
};
match cmd.format {
Format::Png => {
use image_rs::ImageFormat;
debug!("Decoding PNG, pixel_data length: {}", pixel_data.len());
let img = match image_rs::load_from_memory_with_format(
&pixel_data,
ImageFormat::Png,
) {
Ok(img) => {
debug!("PNG decoded successfully: {}x{}", img.width(), img.height());
img
}
Err(e) => {
debug!("PNG decode failed: {:?}", e);
return None;
}
};
let rgba_img = img.to_rgba8();
let (width, height) = (rgba_img.width() as usize, rgba_img.height() as usize);
let pixels = rgba_img.into_raw();
let is_opaque = pixels.chunks(4).all(|chunk| chunk[3] == 255);
let resize = if cmd.columns > 0 || cmd.rows > 0 {
let both_specified = cmd.columns > 0 && cmd.rows > 0;
Some(ResizeCommand {
width: if cmd.columns > 0 {
ResizeParameter::Cells(cmd.columns)
} else {
ResizeParameter::Auto
},
height: if cmd.rows > 0 {
ResizeParameter::Cells(cmd.rows)
} else {
ResizeParameter::Auto
},
preserve_aspect_ratio: !both_specified,
})
} else {
None
};
Some(GraphicData {
id: GraphicId::new(cmd.image_id as u64),
width,
height,
color_type: ColorType::Rgba,
pixels,
is_opaque,
resize,
display_width: None,
display_height: None,
transmit_time: std::time::Instant::now(),
})
}
Format::Gray | Format::GrayAlpha | Format::Rgb24 | Format::Rgba32 => {
let bytes_per_pixel = match cmd.format {
Format::Gray => 1,
Format::GrayAlpha => 2,
Format::Rgb24 => 3,
Format::Rgba32 => 4,
_ => unreachable!(),
};
let expected_size =
cmd.width as usize * cmd.height as usize * bytes_per_pixel;
if pixel_data.len() < expected_size {
debug!(
"Pixel data size insufficient: got {} bytes, expected at least {}",
pixel_data.len(),
expected_size
);
return None;
}
let pixel_data = if pixel_data.len() > expected_size {
pixel_data[..expected_size].to_vec()
} else {
pixel_data
};
let (pixels, is_opaque) = match cmd.format {
Format::Gray => {
let mut rgba =
Vec::with_capacity(cmd.width as usize * cmd.height as usize * 4);
for &g in &pixel_data {
rgba.extend_from_slice(&[g, g, g, 255]);
}
(rgba, true)
}
Format::GrayAlpha => {
let mut rgba =
Vec::with_capacity(cmd.width as usize * cmd.height as usize * 4);
let mut opaque = true;
for chunk in pixel_data.chunks_exact(2) {
let g = chunk[0];
let a = chunk[1];
if a != 255 {
opaque = false;
}
rgba.extend_from_slice(&[g, g, g, a]);
}
(rgba, opaque)
}
Format::Rgb24 => {
let mut rgba =
Vec::with_capacity(cmd.width as usize * cmd.height as usize * 4);
for chunk in pixel_data.chunks_exact(3) {
rgba.extend_from_slice(&[chunk[0], chunk[1], chunk[2], 255]);
}
(rgba, true)
}
Format::Rgba32 => {
let is_opaque = pixel_data.chunks(4).all(|chunk| chunk[3] == 255);
(pixel_data, is_opaque)
}
_ => unreachable!(),
};
let resize = if cmd.columns > 0 || cmd.rows > 0 {
let both_specified = cmd.columns > 0 && cmd.rows > 0;
Some(ResizeCommand {
width: if cmd.columns > 0 {
ResizeParameter::Cells(cmd.columns)
} else {
ResizeParameter::Auto
},
height: if cmd.rows > 0 {
ResizeParameter::Cells(cmd.rows)
} else {
ResizeParameter::Auto
},
preserve_aspect_ratio: !both_specified,
})
} else {
None
};
Some(GraphicData {
id: GraphicId::new(cmd.image_id as u64),
width: cmd.width as usize,
height: cmd.height as usize,
color_type: ColorType::Rgba, pixels,
is_opaque,
resize,
display_width: None,
display_height: None,
transmit_time: std::time::Instant::now(),
})
}
}
}
#[cfg(test)]
mod tests {
use super::*;
fn parse_kitty_graphics_protocol(
keys: &str,
payload: &str,
) -> Option<KittyGraphicsResponse> {
let params = if keys.is_empty() && payload.is_empty() {
vec![b"G".as_ref()]
} else if payload.is_empty() {
vec![b"G".as_ref(), keys.as_bytes()]
} else {
vec![b"G".as_ref(), keys.as_bytes(), payload.as_bytes()]
};
let mut state = KittyGraphicsState::default();
parse(¶ms, &mut state)
}
#[test]
fn test_parse_basic_transmit() {
let payload = "/wAA/w==";
let result = parse_kitty_graphics_protocol("a=t,f=32,s=1,v=1", payload);
assert!(result.is_some());
let response = result.unwrap();
assert!(response.graphic_data.is_some());
assert!(response.placement_request.is_none());
assert!(response.delete_request.is_none());
}
#[test]
fn test_parse_transmit_and_display() {
let payload = "/wAA/w==";
let result = parse_kitty_graphics_protocol("a=T,f=32,s=1,v=1,i=1", payload);
assert!(result.is_some());
let response = result.unwrap();
assert!(response.graphic_data.is_some());
assert!(response.placement_request.is_some());
let placement = response.placement_request.unwrap();
assert_eq!(placement.image_id, 1);
}
#[test]
fn test_parse_placement() {
let result = parse_kitty_graphics_protocol("a=p,i=1,x=10,y=20,c=5,r=3,z=2", "");
assert!(result.is_some());
let response = result.unwrap();
assert!(response.graphic_data.is_none());
assert!(response.placement_request.is_some());
let placement = response.placement_request.unwrap();
assert_eq!(placement.image_id, 1);
assert_eq!(placement.x, 10);
assert_eq!(placement.y, 20);
assert_eq!(placement.columns, 5);
assert_eq!(placement.rows, 3);
assert_eq!(placement.z_index, 2);
}
#[test]
fn test_parse_delete() {
let result = parse_kitty_graphics_protocol("a=d,d=i,i=1", "");
assert!(result.is_some());
let response = result.unwrap();
assert!(response.delete_request.is_some());
let delete = response.delete_request.unwrap();
assert_eq!(delete.action, b'i');
assert_eq!(delete.image_id, 1);
assert!(!delete.delete_data);
}
#[test]
fn test_parse_delete_uppercase() {
let result = parse_kitty_graphics_protocol("a=d,d=I,i=1", "");
assert!(result.is_some());
let response = result.unwrap();
assert!(response.delete_request.is_some());
let delete = response.delete_request.unwrap();
assert_eq!(delete.action, b'i');
assert_eq!(delete.image_id, 1);
assert!(delete.delete_data);
}
#[test]
fn test_parse_query() {
let result = parse_kitty_graphics_protocol("a=q,i=1", "");
assert!(result.is_some());
let response = result.unwrap();
assert!(response.response.is_some());
assert!(response.response.unwrap().contains("OK"));
}
#[test]
fn test_parse_with_compression() {
let payload = "eJz7z8DwHwAE/wH/";
let result = parse_kitty_graphics_protocol("a=t,f=32,s=1,v=1,o=z", payload);
assert!(result.is_some());
let response = result.unwrap();
assert!(response.graphic_data.is_some());
}
#[test]
fn test_parse_with_unicode_placeholder() {
let result = parse_kitty_graphics_protocol("a=p,i=1,u=128512", ""); assert!(result.is_some());
let response = result.unwrap();
assert!(response.placement_request.is_some());
let placement = response.placement_request.unwrap();
assert_eq!(placement.unicode_placeholder, 128512);
}
#[test]
fn test_parse_png_format() {
let png_data = "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mP8/5+hHgAHggJ/PchI7wAAAABJRU5ErkJggg==";
let result = parse_kitty_graphics_protocol("a=t,f=100,i=1", png_data);
assert!(result.is_some());
let response = result.unwrap();
assert!(response.graphic_data.is_some());
}
#[test]
fn test_parse_multi_frame() {
let payload = "AAAA";
let result = parse_kitty_graphics_protocol("a=f,i=1,r=2,s=1,v=1,f=32", payload);
assert!(result.is_none());
}
#[test]
fn test_parse_invalid_action() {
let result = parse_kitty_graphics_protocol("a=x", "");
assert!(result.is_some()); }
#[test]
fn test_parse_empty_keys() {
let mut state = KittyGraphicsState::default();
let result = parse(&[], &mut state);
assert!(result.is_none());
let result = parse(&[b"G"], &mut state);
assert!(result.is_some());
let response = result.unwrap();
assert!(response.graphic_data.is_some());
let graphic = response.graphic_data.unwrap();
assert_eq!(graphic.width, 0);
assert_eq!(graphic.height, 0);
assert!(graphic.pixels.is_empty());
let result = parse(&[b"G", b""], &mut state);
assert!(result.is_some());
}
#[test]
fn test_incomplete_image_accumulation() {
let mut state = KittyGraphicsState::default();
let params1 = vec![b"G".as_ref(), b"a=t,f=32,s=1,v=1,m=1,i=100", b"/wA"];
let result1 = parse(¶ms1, &mut state);
assert!(result1.is_none());
let params2 = vec![b"G".as_ref(), b"a=t,m=1,i=100", b"A/"];
let result2 = parse(¶ms2, &mut state);
assert!(result2.is_none());
let params3 = vec![b"G".as_ref(), b"a=t,f=32,s=1,v=1,m=0,i=100", b"w=="];
let result3 = parse(¶ms3, &mut state);
assert!(result3.is_some());
let response = result3.unwrap();
assert!(response.graphic_data.is_some());
}
#[test]
fn test_file_transmission_medium() {
use std::io::Write;
let temp_path = std::env::temp_dir().join("test_kitty_image.rgba");
let temp_path = temp_path.to_str().unwrap();
let mut file = std::fs::File::create(temp_path).unwrap();
file.write_all(&[255, 0, 0, 255]).unwrap(); drop(file);
let encoded_path = BASE64.encode(temp_path.as_bytes());
let result =
parse_kitty_graphics_protocol("a=t,t=f,f=32,s=1,v=1,i=1", &encoded_path);
assert!(result.is_some());
let response = result.unwrap();
assert!(response.graphic_data.is_some());
let _ = std::fs::remove_file(temp_path);
}
#[test]
fn test_temp_file_transmission_medium() {
use std::io::Write;
let temp_path = std::env::temp_dir().join("tty-graphics-protocol-test.rgba");
let temp_path = temp_path.to_str().unwrap();
let mut file = std::fs::File::create(temp_path).unwrap();
file.write_all(&[255, 0, 0, 255]).unwrap(); drop(file);
let encoded_path = BASE64.encode(temp_path.as_bytes());
let result =
parse_kitty_graphics_protocol("a=t,t=t,f=32,s=1,v=1,i=1", &encoded_path);
assert!(!std::path::Path::new(temp_path).exists());
assert!(result.is_some());
let response = result.unwrap();
assert!(response.graphic_data.is_some());
}
#[test]
fn test_security_checks() {
let proc_path = BASE64.encode("/proc/self/environ".as_bytes());
let result = parse_kitty_graphics_protocol("a=t,t=f,f=32,s=1,v=1", &proc_path);
assert!(result.is_none());
let sys_path = BASE64.encode("/sys/class/net".as_bytes());
let result = parse_kitty_graphics_protocol("a=t,t=f,f=32,s=1,v=1", &sys_path);
assert!(result.is_none());
let dev_path = BASE64.encode("/dev/null".as_bytes());
let result = parse_kitty_graphics_protocol("a=t,t=f,f=32,s=1,v=1", &dev_path);
assert!(result.is_none());
}
#[test]
fn test_quiet_mode() {
let result = parse_kitty_graphics_protocol("a=p,i=1,q=1", "");
assert!(result.is_some());
let response = result.unwrap();
assert!(response.response.is_none());
let result = parse_kitty_graphics_protocol("a=q,i=1,q=2", "");
assert!(result.is_some());
let response = result.unwrap();
assert_eq!(response.response, Some(String::new()));
}
}