use super::super::images::{ImagePlacement, PreparedImage};
use std::{
cell::RefCell,
io::{self, IsTerminal, Write},
sync::Arc,
};
const FIRST_IMAGE_ID: usize = 1_900_000_000;
const IMAGE_SLOTS: usize = 16;
static GRAPHICS_ACTIVE: std::sync::atomic::AtomicBool = std::sync::atomic::AtomicBool::new(false);
thread_local! {
static UPLOADED: RefCell<Vec<Arc<PreparedImage>>> = const { RefCell::new(Vec::new()) };
}
pub(in crate::tui) fn detect_cell_pixels() -> Option<(u16, u16)> {
let term = std::env::var("TERM").unwrap_or_default();
let program = std::env::var("TERM_PROGRAM").unwrap_or_default();
if !io::stdout().is_terminal()
|| !io::stdin().is_terminal()
|| !supported_terminal(
&term,
&program,
std::env::var_os("TMUX").is_some(),
std::env::var_os("STY").is_some(),
)
{
return None;
}
current_cell_pixels()
}
pub(in crate::tui) fn current_cell_pixels() -> Option<(u16, u16)> {
let size = crossterm::terminal::window_size().ok()?;
let width = size.width.checked_div(size.columns)?;
let height = size.height.checked_div(size.rows)?;
(width > 0 && height > 0).then_some((width, height))
}
fn supported_terminal(term: &str, program: &str, tmux: bool, screen: bool) -> bool {
!tmux && !screen && (term == "xterm-kitty" || program == "ghostty")
}
pub(in crate::tui) fn clear_placements() -> io::Result<()> {
UPLOADED.with_borrow(|images| {
let mut out = io::stdout().lock();
for index in 0..images.len() {
write!(out, "\x1b_Ga=d,d=i,i={},q=2;\x1b\\", FIRST_IMAGE_ID + index)?;
}
out.flush()
})
}
pub(in crate::tui) fn draw_images(placements: &[ImagePlacement]) -> io::Result<()> {
if placements.is_empty() {
return Ok(());
}
GRAPHICS_ACTIVE.store(true, std::sync::atomic::Ordering::Relaxed);
UPLOADED.with_borrow_mut(|images| {
let mut out = io::stdout().lock();
crossterm::queue!(out, crossterm::cursor::SavePosition)?;
for (placement_index, placement) in placements.iter().enumerate() {
let slot = if let Some(index) = images
.iter()
.position(|image| Arc::ptr_eq(image, &placement.image))
{
index
} else {
let index = if images.len() < IMAGE_SLOTS {
images.len()
} else {
let Some(index) = images
.iter()
.position(|image| !placements.iter().any(|p| Arc::ptr_eq(image, &p.image)))
else {
continue;
};
write!(out, "\x1b_Ga=d,d=I,i={},q=2;\x1b\\", FIRST_IMAGE_ID + index)?;
index
};
upload(&mut out, FIRST_IMAGE_ID + index, &placement.image)?;
if index == images.len() {
images.push(Arc::clone(&placement.image));
} else {
images[index] = Arc::clone(&placement.image);
}
index
};
place(
&mut out,
FIRST_IMAGE_ID + slot,
placement_index + 1,
placement,
)?;
}
crossterm::queue!(out, crossterm::cursor::RestorePosition)?;
out.flush()
})
}
fn upload(out: &mut impl Write, id: usize, image: &PreparedImage) -> io::Result<()> {
let mut chunks = image.encoded.as_bytes().chunks(4096).peekable();
let mut first = true;
while let Some(chunk) = chunks.next() {
if first {
write!(
out,
"\x1b_Ga=t,t=d,f=32,i={id},s={},v={},q=2,m={};",
image.width,
image.height,
usize::from(chunks.peek().is_some())
)?;
first = false;
} else {
write!(out, "\x1b_Gq=2,m={};", usize::from(chunks.peek().is_some()))?;
}
out.write_all(chunk)?;
out.write_all(b"\x1b\\")?;
}
Ok(())
}
fn place(
out: &mut impl Write,
id: usize,
placement_id: usize,
placement: &ImagePlacement,
) -> io::Result<()> {
crossterm::queue!(
out,
crossterm::cursor::MoveTo(placement.area.x, placement.area.y)
)?;
write!(
out,
"\x1b_Ga=p,i={id},p={placement_id},q=2,C=1,c={},r=1,x=0,y={},w={},h={};\x1b\\",
placement.area.width, placement.source_y, placement.image.width, placement.source_height
)
}
fn delete_image_data(out: &mut impl Write) {
for index in 0..IMAGE_SLOTS {
let _ = write!(out, "\x1b_Ga=d,d=I,i={},q=2;\x1b\\", FIRST_IMAGE_ID + index);
}
}
pub(in crate::tui) fn cleanup() {
if !GRAPHICS_ACTIVE.swap(false, std::sync::atomic::Ordering::Relaxed) {
return;
}
let mut out = io::stdout().lock();
delete_image_data(&mut out);
let _ = out.flush();
let _ = UPLOADED.try_with(|images| {
if let Ok(mut images) = images.try_borrow_mut() {
images.clear();
}
});
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn detection_rejects_multiplexers_and_unknown_terminals() {
assert!(supported_terminal("xterm-kitty", "", false, false));
assert!(supported_terminal(
"xterm-256color",
"ghostty",
false,
false
));
assert!(!supported_terminal("xterm-kitty", "", true, false));
assert!(!supported_terminal("xterm-kitty", "", false, true));
assert!(!supported_terminal(
"xterm-256color",
"iTerm.app",
false,
false
));
}
#[test]
fn upload_chunks_are_bounded_and_do_not_request_terminal_file_access() {
let image = PreparedImage {
width: 20,
height: 100,
encoded: "A".repeat(10_668),
};
let mut out = Vec::new();
upload(&mut out, FIRST_IMAGE_ID, &image).unwrap();
let text = String::from_utf8(out).unwrap();
assert!(text.starts_with("\x1b_Ga=t,t=d,f=32,"));
let commands: Vec<_> = text.split("\x1b\\").filter(|s| !s.is_empty()).collect();
assert_eq!(commands.len(), 3);
for command in &commands {
assert!(command.split_once(';').unwrap().1.len() <= 4096);
}
assert!(commands.last().unwrap().starts_with("\x1b_Gq=2,m=0;"));
}
#[test]
fn scrolled_row_uses_source_crop_and_never_moves_cursor() {
let placement = ImagePlacement {
image: Arc::new(PreparedImage {
width: 100,
height: 80,
encoded: String::new(),
}),
area: ratatui::layout::Rect::new(2, 3, 20, 1),
source_y: 30,
source_height: 10,
};
let mut out = Vec::new();
place(&mut out, 1, 4, &placement).unwrap();
assert_eq!(
String::from_utf8(out).unwrap(),
"\x1b[4;3H\x1b_Ga=p,i=1,p=4,q=2,C=1,c=20,r=1,x=0,y=30,w=100,h=10;\x1b\\"
);
}
}
#[cfg(test)]
#[test]
fn image_cleanup_deletes_owned_ids_without_clearing_other_terminal_images() {
let mut output = Vec::new();
delete_image_data(&mut output);
let text = String::from_utf8(output).unwrap();
let commands: Vec<_> = text
.split("\x1b\\")
.filter(|command| !command.is_empty())
.collect();
assert_eq!(commands.len(), IMAGE_SLOTS);
for (index, command) in commands.iter().enumerate() {
assert_eq!(
*command,
format!("\x1b_Ga=d,d=I,i={},q=2;", FIRST_IMAGE_ID + index)
);
}
}