use std::io::Cursor;
use std::path::Path;
use std::time::Instant;
use image::{GenericImageView, ImageReader, Limits};
use ratatui::{
style::{Color, Style},
text::{Line, Span},
};
#[derive(Debug, Clone, Copy, PartialEq)]
pub enum ImageProtocol {
Kitty,
Iterm2,
Sixel,
Halfblock,
}
pub fn detect_protocol() -> ImageProtocol {
if let Ok(p) = std::env::var("SIGGY_IMAGE_PROTOCOL") {
match p.trim().to_ascii_lowercase().as_str() {
"kitty" => return ImageProtocol::Kitty,
"iterm2" | "iterm" => return ImageProtocol::Iterm2,
"sixel" => return ImageProtocol::Sixel,
"halfblock" | "none" => return ImageProtocol::Halfblock,
_ => {}
}
}
if std::env::var("KITTY_WINDOW_ID").is_ok() {
return ImageProtocol::Kitty;
}
if let Ok(term) = std::env::var("TERM_PROGRAM") {
match term.as_str() {
"ghostty" => return ImageProtocol::Kitty,
"iTerm.app" | "WezTerm" => return ImageProtocol::Iterm2,
_ => {}
}
}
if std::env::var("WT_SESSION").is_ok() {
return ImageProtocol::Sixel;
}
ImageProtocol::Halfblock
}
pub fn in_tmux() -> bool {
std::env::var("TMUX").is_ok_and(|v| !v.is_empty())
}
pub fn wrap_for_tmux(seq: &str) -> String {
let mut out = String::with_capacity(seq.len() + 16);
out.push_str("\x1bPtmux;");
for c in seq.chars() {
if c == '\x1b' {
out.push_str("\x1b\x1b");
} else {
out.push(c);
}
}
out.push_str("\x1b\\");
out
}
pub fn encode_native_png(
path: &Path,
cell_width: u32,
cell_height: u32,
) -> Option<(String, u32, u32)> {
let img = image::open(path).ok()?;
let (orig_w, orig_h) = img.dimensions();
if orig_w == 0 || orig_h == 0 {
return None;
}
let target_w = cell_width * 8;
let target_h = cell_height * 16;
let scale = f64::min(
target_w as f64 / orig_w as f64,
target_h as f64 / orig_h as f64,
)
.min(1.0);
let new_w = ((orig_w as f64 * scale).round() as u32).max(1);
let new_h = ((orig_h as f64 * scale).round() as u32).max(1);
let resized = img.resize_exact(new_w, new_h, image::imageops::FilterType::Triangle);
let mut buf = Cursor::new(Vec::new());
resized.write_to(&mut buf, image::ImageFormat::Png).ok()?;
use base64::Engine;
Some((
base64::engine::general_purpose::STANDARD.encode(buf.into_inner()),
new_w,
new_h,
))
}
pub fn detect_cell_pixel_size() -> (u16, u16) {
if let Ok(ws) = crossterm::terminal::window_size()
&& ws.width > 0
&& ws.height > 0
&& ws.columns > 0
&& ws.rows > 0
{
return (ws.width / ws.columns, ws.height / ws.rows);
}
if std::env::var("WT_SESSION").is_ok() {
(10, 20) } else {
(8, 16)
}
}
pub fn encode_sixel(
b64_png: &str,
width_cells: u16,
height_cells: u16,
cell_px: (u16, u16),
) -> Option<String> {
use base64::Engine;
let bytes = base64::engine::general_purpose::STANDARD
.decode(b64_png)
.ok()?;
let img = image::load_from_memory(&bytes).ok()?;
let (w, h) = img.dimensions();
if w == 0 || h == 0 {
return None;
}
let target_w = (width_cells as u32 * cell_px.0 as u32).max(1);
let target_h = (height_cells as u32 * cell_px.1 as u32).max(1);
let scale = f64::min(target_w as f64 / w as f64, target_h as f64 / h as f64);
let new_w = ((w as f64 * scale).round() as u32).max(1);
let new_h = ((h as f64 * scale).round() as u32).max(1);
let resized = img.resize_exact(new_w, new_h, image::imageops::FilterType::Triangle);
let rgba = resized.to_rgba8().into_raw();
icy_sixel::sixel_encode(
&rgba,
new_w as usize,
new_h as usize,
&icy_sixel::EncodeOptions::default(),
)
.ok()
}
pub fn slice_sixel_bands(
full_sixel: &str,
cell_px_h: u16,
_full_height_cells: u16,
crop_top_cells: u16,
visible_height_cells: u16,
) -> Option<String> {
let body_start = full_sixel.find('q')? + 1;
let body_end = full_sixel.rfind("\x1b\\")?;
if body_start >= body_end {
return None;
}
let dcs_header = &full_sixel[..body_start]; let body = &full_sixel[body_start..body_end];
let preamble_end = find_preamble_end(body);
let preamble = &body[..preamble_end];
let pixel_data = &body[preamble_end..];
let bands: Vec<&str> = pixel_data.split('-').filter(|b| !b.is_empty()).collect();
let total_bands = bands.len();
if total_bands == 0 {
return None;
}
let skip_px = crop_top_cells as usize * cell_px_h as usize;
let vis_px = visible_height_cells as usize * cell_px_h as usize;
let skip_bands = skip_px / 6;
let take_bands = vis_px / 6;
let start = skip_bands.min(total_bands);
let end = (start + take_bands).min(total_bands);
if start >= end {
return None;
}
let mut result = String::with_capacity(full_sixel.len());
result.push_str(dcs_header);
if let Some(raster_content) = preamble.strip_prefix('"') {
let raster_len = raster_content
.find(|c: char| !c.is_ascii_digit() && c != ';')
.unwrap_or(raster_content.len());
result.push_str(&raster_content[raster_len..]);
} else {
result.push_str(preamble);
}
for (i, band) in bands[start..end].iter().enumerate() {
if i > 0 {
result.push('-');
}
result.push_str(band);
}
result.push_str("\x1b\\");
Some(result)
}
fn find_preamble_end(body: &str) -> usize {
let bytes = body.as_bytes();
let mut i = 0;
if i < bytes.len() && bytes[i] == b'"' {
i += 1;
while i < bytes.len() && (bytes[i].is_ascii_digit() || bytes[i] == b';') {
i += 1;
}
}
while i < bytes.len() && bytes[i] == b'#' {
let mark = i;
i += 1;
while i < bytes.len() && bytes[i].is_ascii_digit() {
i += 1;
}
if i < bytes.len() && bytes[i] == b';' {
while i < bytes.len() && (bytes[i].is_ascii_digit() || bytes[i] == b';') {
i += 1;
}
} else {
return mark;
}
}
i
}
pub fn crop_png_vertical(
b64_full: &str,
px_h: u32,
full_height_cells: u16,
crop_top_cells: u16,
visible_height_cells: u16,
) -> Option<String> {
use base64::Engine;
let bytes = base64::engine::general_purpose::STANDARD
.decode(b64_full)
.ok()?;
let img = image::load_from_memory(&bytes).ok()?;
let (w, _) = img.dimensions();
let y_px = if full_height_cells > 0 {
crop_top_cells as u32 * px_h / full_height_cells as u32
} else {
0
};
let h_px = if full_height_cells > 0 {
(visible_height_cells as u32 * px_h / full_height_cells as u32).max(1)
} else {
px_h
};
let h_px = h_px.min(px_h.saturating_sub(y_px));
let cropped = img.crop_imm(0, y_px, w, h_px);
let mut buf = Cursor::new(Vec::new());
cropped.write_to(&mut buf, image::ImageFormat::Png).ok()?;
Some(base64::engine::general_purpose::STANDARD.encode(buf.into_inner()))
}
const DIACRITICS: [char; 256] = [
'\u{0305}', '\u{030D}', '\u{030E}', '\u{0310}', '\u{0312}', '\u{033D}', '\u{033E}', '\u{033F}', '\u{0346}', '\u{034A}', '\u{034B}', '\u{034C}', '\u{0350}', '\u{0351}', '\u{0352}', '\u{0357}', '\u{035B}', '\u{0363}', '\u{0364}', '\u{0365}', '\u{0366}', '\u{0367}', '\u{0368}', '\u{0369}', '\u{036A}', '\u{036B}', '\u{036C}', '\u{036D}', '\u{036E}', '\u{036F}', '\u{0483}', '\u{0484}', '\u{0485}', '\u{0486}', '\u{0487}', '\u{0592}', '\u{0593}', '\u{0594}', '\u{0595}', '\u{0597}', '\u{0598}', '\u{0599}', '\u{059C}', '\u{059D}', '\u{059E}', '\u{059F}', '\u{05A0}', '\u{05A1}', '\u{05A8}', '\u{05A9}', '\u{05AB}', '\u{05AC}', '\u{05AF}', '\u{05C4}', '\u{0610}', '\u{0611}', '\u{0612}', '\u{0613}', '\u{0614}', '\u{0615}', '\u{0616}', '\u{0617}', '\u{0657}', '\u{0658}', '\u{0659}', '\u{065A}', '\u{065B}', '\u{065D}', '\u{065E}', '\u{06D6}', '\u{06D7}', '\u{06D8}', '\u{06D9}', '\u{06DA}', '\u{06DB}', '\u{06DC}', '\u{06DF}', '\u{06E0}', '\u{06E1}', '\u{06E2}', '\u{06E4}', '\u{06E7}', '\u{06E8}', '\u{06EB}', '\u{06EC}', '\u{0730}', '\u{0732}', '\u{0733}', '\u{0735}', '\u{0736}', '\u{073A}', '\u{073D}', '\u{073F}', '\u{0740}', '\u{0741}', '\u{0743}', '\u{0745}', '\u{0747}', '\u{0749}', '\u{074A}', '\u{07EB}', '\u{07EC}', '\u{07ED}', '\u{07EE}', '\u{07EF}', '\u{07F0}', '\u{07F1}', '\u{07F3}', '\u{0816}', '\u{0817}', '\u{0818}', '\u{0819}', '\u{081B}', '\u{081C}', '\u{081D}', '\u{081E}', '\u{081F}', '\u{0820}', '\u{0821}', '\u{0822}', '\u{0823}', '\u{0825}', '\u{0826}', '\u{0827}', '\u{0829}', '\u{082A}', '\u{082B}', '\u{082C}', '\u{082D}', '\u{0951}', '\u{0953}', '\u{0954}', '\u{0F82}', '\u{0F83}', '\u{0F86}', '\u{0F87}', '\u{135D}', '\u{135E}', '\u{135F}', '\u{17DD}', '\u{193A}', '\u{1A17}', '\u{1A75}', '\u{1A76}', '\u{1A77}', '\u{1A78}', '\u{1A79}', '\u{1A7A}', '\u{1A7B}', '\u{1A7C}', '\u{1B6B}', '\u{1B6D}', '\u{1B6E}', '\u{1B6F}', '\u{1B70}', '\u{1B71}', '\u{1B72}', '\u{1B73}', '\u{1CD0}', '\u{1CD1}', '\u{1CD2}', '\u{1CDA}', '\u{1CDB}', '\u{1CE0}', '\u{1DC0}', '\u{1DC1}', '\u{1DC3}', '\u{1DC4}', '\u{1DC5}', '\u{1DC6}', '\u{1DC7}', '\u{1DC8}', '\u{1DC9}', '\u{1DCB}', '\u{1DCC}', '\u{1DD1}', '\u{1DD2}', '\u{1DD3}', '\u{1DD4}', '\u{1DD5}', '\u{1DD6}', '\u{1DD7}', '\u{1DD8}', '\u{1DD9}', '\u{1DDA}', '\u{1DDB}', '\u{1DDC}', '\u{1DDD}', '\u{1DDE}', '\u{1DDF}', '\u{1DE0}', '\u{1DE1}', '\u{1DE2}', '\u{1DE3}', '\u{1DE4}', '\u{1DE5}', '\u{1DE6}', '\u{1DFE}', '\u{20D0}', '\u{20D1}', '\u{20D4}', '\u{20D5}', '\u{20D6}', '\u{20D7}', '\u{20DB}', '\u{20DC}', '\u{20E1}', '\u{20E7}', '\u{20E9}', '\u{20F0}', '\u{2CEF}', '\u{2CF0}', '\u{2CF1}', '\u{2DE0}', '\u{2DE1}', '\u{2DE2}', '\u{2DE3}', '\u{2DE4}', '\u{2DE5}', '\u{2DE6}', '\u{2DE7}', '\u{2DE8}', '\u{2DE9}', '\u{2DEA}', '\u{2DEB}', '\u{2DEC}', '\u{2DED}', '\u{2DEE}', '\u{2DEF}', '\u{2DF0}', '\u{2DF1}', '\u{2DF2}', '\u{2DF3}', '\u{2DF4}', '\u{2DF5}', '\u{2DF6}', '\u{2DF7}', '\u{2DF8}', '\u{2DF9}', '\u{2DFA}', '\u{2DFB}', '\u{2DFC}', '\u{2DFD}', '\u{2DFE}', '\u{2DFF}', '\u{A66F}', '\u{A67C}', '\u{A67D}', '\u{A6F0}', '\u{A6F1}', '\u{A8E0}', '\u{A8E1}', '\u{A8E2}', '\u{A8E3}', '\u{A8E4}', '\u{A8E5}', ];
pub fn placeholder_symbol(row: usize, col: usize) -> String {
let row_d = DIACRITICS[row.min(255)];
let col_d = DIACRITICS[col.min(255)];
format!("\u{10EEEE}{row_d}{col_d}")
}
pub fn kitty_id_color(image_id: u32) -> Color {
let r = ((image_id >> 16) & 0xFF) as u8;
let g = ((image_id >> 8) & 0xFF) as u8;
let b = (image_id & 0xFF) as u8;
Color::Rgb(r, g, b)
}
pub fn render_image(path: &Path, max_width: u32) -> Option<Vec<Line<'static>>> {
const MAX_INPUT_DIM: u32 = 8192;
let start = Instant::now();
crate::debug_log::logf(format_args!(
"render_image start: path={} max_width={max_width}",
path.display()
));
let mut reader = ImageReader::open(path).ok()?.with_guessed_format().ok()?;
let mut limits = Limits::default();
limits.max_image_width = Some(MAX_INPUT_DIM);
limits.max_image_height = Some(MAX_INPUT_DIM);
reader.limits(limits);
let img = match reader.decode() {
Ok(img) => img,
Err(e) => {
crate::debug_log::logf(format_args!(
"render_image decode failed: path={} err={e} elapsed_ms={}",
path.display(),
start.elapsed().as_millis()
));
return None;
}
};
let cap_width = max_width;
let cap_height: u32 = 60;
let (orig_w, orig_h) = img.dimensions();
if orig_w == 0 || orig_h == 0 {
return None;
}
let scale = f64::min(
cap_width as f64 / orig_w as f64,
cap_height as f64 / orig_h as f64,
)
.min(1.0);
let new_w = ((orig_w as f64 * scale).round() as u32).max(1);
let new_h = ((orig_h as f64 * scale).round() as u32).max(1);
let resized = img.resize_exact(new_w, new_h, image::imageops::FilterType::Triangle);
let rgba = resized.to_rgba8();
let (w, h) = rgba.dimensions();
let row_pairs = h.div_ceil(2);
let mut lines: Vec<Line<'static>> = Vec::with_capacity(row_pairs as usize);
for row in 0..row_pairs {
let y_top = row * 2;
let y_bot = y_top + 1;
let mut spans: Vec<Span<'static>> = Vec::with_capacity(w as usize + 1);
spans.push(Span::raw(" "));
for x in 0..w {
let top_pixel = rgba.get_pixel(x, y_top);
let fg = if top_pixel[3] < 128 {
Color::Reset
} else {
Color::Rgb(top_pixel[0], top_pixel[1], top_pixel[2])
};
let bg = if y_bot < h {
let bot_pixel = rgba.get_pixel(x, y_bot);
if bot_pixel[3] < 128 {
Color::Reset
} else {
Color::Rgb(bot_pixel[0], bot_pixel[1], bot_pixel[2])
}
} else {
Color::Reset
};
spans.push(Span::styled("â–€", Style::default().fg(fg).bg(bg)));
}
lines.push(Line::from(spans));
}
let elapsed_ms = start.elapsed().as_millis();
crate::debug_log::logf(format_args!(
"render_image done: path={} elapsed_ms={elapsed_ms} src={}x{} out={}x{}",
path.display(),
orig_w,
orig_h,
new_w,
new_h
));
if elapsed_ms > SLOW_DECODE_WARN_MS {
crate::debug_log::warnf(format_args!(
"slow image decode: path={} elapsed_ms={elapsed_ms} src={}x{} out={}x{}",
path.display(),
orig_w,
orig_h,
new_w,
new_h
));
}
Some(lines)
}
const SLOW_DECODE_WARN_MS: u128 = 5_000;
#[cfg(test)]
mod tests {
use super::*;
fn make_sixel(num_bands: usize) -> String {
let mut s = String::from("\x1bPq\"1;1;10;");
s.push_str(&(num_bands * 6).to_string()); s.push_str("#0;2;100;0;0#1;2;0;100;0"); for i in 0..num_bands {
if i > 0 {
s.push('-');
}
s.push_str("#0???");
}
s.push_str("\x1b\\");
s
}
#[test]
fn slice_no_crop_exact_bands() {
let sixel = make_sixel(10);
let result = slice_sixel_bands(&sixel, 6, 10, 0, 10).unwrap();
let body_start = result.find('q').unwrap() + 1;
let body_end = result.rfind("\x1b\\").unwrap();
let body = &result[body_start..body_end];
let preamble_end = find_preamble_end(body);
let pixel_data = &body[preamble_end..];
assert_eq!(pixel_data.split('-').count(), 10);
assert!(!result.contains("\"1;1;"));
assert!(result.contains("#0;2;100;0;0"));
}
#[test]
fn slice_crop_top() {
let sixel = make_sixel(10);
let result = slice_sixel_bands(&sixel, 6, 10, 3, 7).unwrap();
let body_start = result.find('q').unwrap() + 1;
let body_end = result.rfind("\x1b\\").unwrap();
let body = &result[body_start..body_end];
let preamble_end = find_preamble_end(body);
let pixel_data = &body[preamble_end..];
let band_count = pixel_data.split('-').count();
assert_eq!(band_count, 7);
}
#[test]
fn slice_crop_bottom() {
let sixel = make_sixel(10);
let result = slice_sixel_bands(&sixel, 6, 10, 0, 4).unwrap();
let body_start = result.find('q').unwrap() + 1;
let body_end = result.rfind("\x1b\\").unwrap();
let body = &result[body_start..body_end];
let preamble_end = find_preamble_end(body);
let pixel_data = &body[preamble_end..];
let band_count = pixel_data.split('-').count();
assert_eq!(band_count, 4);
}
#[test]
fn slice_middle() {
let sixel = make_sixel(20);
let result = slice_sixel_bands(&sixel, 6, 20, 5, 8).unwrap();
let body_start = result.find('q').unwrap() + 1;
let body_end = result.rfind("\x1b\\").unwrap();
let body = &result[body_start..body_end];
let preamble_end = find_preamble_end(body);
let pixel_data = &body[preamble_end..];
let band_count = pixel_data.split('-').count();
assert_eq!(band_count, 8);
}
#[test]
fn slice_preserves_color_defs() {
let sixel = make_sixel(5);
let result = slice_sixel_bands(&sixel, 6, 5, 2, 3).unwrap();
assert!(result.contains("#0;2;100;0;0"));
assert!(result.contains("#1;2;0;100;0"));
}
#[test]
fn slice_empty_crop_returns_none() {
let sixel = make_sixel(5);
let result = slice_sixel_bands(&sixel, 6, 5, 5, 0);
assert!(result.is_none());
}
#[test]
fn find_preamble_end_basic() {
let body = "\"1;1;10;60#0;2;100;0;0#1;2;0;100;0#0???-#0???";
let end = find_preamble_end(body);
assert_eq!(&body[end..end + 2], "#0");
assert_eq!(body.as_bytes()[end + 2], b'?');
}
#[test]
fn slice_with_larger_cell_px() {
let sixel = make_sixel(17);
let result = slice_sixel_bands(&sixel, 20, 5, 2, 3).unwrap();
let body_start = result.find('q').unwrap() + 1;
let body_end = result.rfind("\x1b\\").unwrap();
let body = &result[body_start..body_end];
let preamble_end = find_preamble_end(body);
let pixel_data = &body[preamble_end..];
let band_count = pixel_data.split('-').count();
assert_eq!(band_count, 10);
}
#[test]
fn slice_clips_overflow_band() {
let sixel = make_sixel(61); let result = slice_sixel_bands(&sixel, 20, 18, 0, 18).unwrap();
let body_start = result.find('q').unwrap() + 1;
let body_end = result.rfind("\x1b\\").unwrap();
let body = &result[body_start..body_end];
let preamble_end = find_preamble_end(body);
let pixel_data = &body[preamble_end..];
let band_count = pixel_data.split('-').count();
assert_eq!(band_count, 60);
}
#[test]
fn slice_clips_full_image_overflow() {
let sixel = make_sixel(101);
let result = slice_sixel_bands(&sixel, 20, 30, 0, 30).unwrap();
let body_start = result.find('q').unwrap() + 1;
let body_end = result.rfind("\x1b\\").unwrap();
let body = &result[body_start..body_end];
let preamble_end = find_preamble_end(body);
let pixel_data = &body[preamble_end..];
let band_count = pixel_data.split('-').count();
assert_eq!(band_count, 100);
}
#[test]
fn wrap_for_tmux_kitty_graphics() {
let inner = "\x1b_Gf=100,a=t,i=1;abcd\x1b\\";
let wrapped = wrap_for_tmux(inner);
assert_eq!(
wrapped,
"\x1bPtmux;\x1b\x1b_Gf=100,a=t,i=1;abcd\x1b\x1b\\\x1b\\"
);
}
#[test]
fn wrap_for_tmux_iterm2_osc_with_bel() {
let inner = "\x1b]1337;File=inline=1:abc\x07";
let wrapped = wrap_for_tmux(inner);
assert_eq!(
wrapped,
"\x1bPtmux;\x1b\x1b]1337;File=inline=1:abc\x07\x1b\\"
);
}
#[test]
fn wrap_for_tmux_passes_non_escape_bytes_through() {
let wrapped = wrap_for_tmux("plain ascii");
assert_eq!(wrapped, "\x1bPtmux;plain ascii\x1b\\");
}
}