#[derive(Clone, Debug)]
#[allow(dead_code)]
pub struct InlineImage {
pub line_idx: usize,
pub col: usize,
pub height_rows: usize,
pub width_cols: usize,
pub data: Vec<u8>,
pub protocol: ImageProtocol,
pub sixel_row_count: usize,
pub sixel_data_start: usize,
pub sixel_row_offsets: Vec<usize>,
pub sixel_color_defs: Vec<Vec<u8>>,
}
#[derive(Clone, Debug, PartialEq, Eq)]
pub enum ImageProtocol {
Sixel,
Kitty,
}
struct LineScanResult {
cleaned: String,
images: Vec<ExtractedImage>,
}
struct ExtractedImage {
col: usize,
width_cols: usize,
height_rows: usize,
data: Vec<u8>,
protocol: ImageProtocol,
sixel_row_count: usize,
sixel_data_start: usize,
sixel_row_offsets: Vec<usize>,
sixel_color_defs: Vec<Vec<u8>>,
}
pub fn query_cell_size() -> (usize, usize) {
#[cfg(unix)]
{
if let Some(size) = query_cell_size_ioctl() {
return size;
}
if let Some(size) = query_cell_size_escape() {
return size;
}
}
(8, 16)
}
#[cfg(unix)]
fn query_cell_size_ioctl() -> Option<(usize, usize)> {
use std::mem::MaybeUninit;
for fd in [1i32, 2, 0] {
let mut ws = MaybeUninit::<[u16; 4]>::uninit();
let ret = unsafe { libc::ioctl(fd, libc::TIOCGWINSZ, ws.as_mut_ptr()) };
if ret == 0 {
let ws = unsafe { ws.assume_init() };
let rows = ws[0] as usize;
let cols = ws[1] as usize;
let xpix = ws[2] as usize;
let ypix = ws[3] as usize;
if xpix > 0 && ypix > 0 && rows > 0 && cols > 0 {
return Some((xpix / cols, ypix / rows));
}
}
}
None
}
#[cfg(unix)]
fn query_cell_size_escape() -> Option<(usize, usize)> {
use std::io::{Read, Write};
let mut tty = match std::fs::OpenOptions::new()
.read(true)
.write(true)
.open("/dev/tty")
{
Ok(f) => f,
Err(_) => return None,
};
let tty_fd = {
use std::os::unix::io::AsRawFd;
tty.as_raw_fd()
};
let mut old_termios = std::mem::MaybeUninit::<libc::termios>::uninit();
if unsafe { libc::tcgetattr(tty_fd, old_termios.as_mut_ptr()) } != 0 {
return None;
}
let old_termios = unsafe { old_termios.assume_init() };
let mut raw = old_termios;
raw.c_lflag &= !(libc::ICANON | libc::ECHO);
raw.c_cc[libc::VMIN] = 0;
raw.c_cc[libc::VTIME] = 1; if unsafe { libc::tcsetattr(tty_fd, libc::TCSANOW, &raw) } != 0 {
return None;
}
let _ = unsafe { libc::tcflush(tty_fd, libc::TCIFLUSH) };
let wrote = tty.write(b"\x1b[16t").ok();
let _ = tty.flush();
let result = if wrote.is_some() {
let mut buf = [0u8; 64];
let mut total = 0usize;
for _ in 0..10 {
match tty.read(&mut buf[total..]) {
Ok(0) => break,
Ok(n) => {
total += n;
if buf[..total].contains(&b't') {
break;
}
}
Err(_) => break,
}
}
parse_cell_size_response(&buf[..total])
} else {
None
};
unsafe { libc::tcsetattr(tty_fd, libc::TCSANOW, &old_termios) };
result
}
#[cfg(unix)]
fn parse_cell_size_response(buf: &[u8]) -> Option<(usize, usize)> {
let esc_pos = buf.iter().position(|&b| b == 0x1b)?;
if esc_pos + 1 >= buf.len() || buf[esc_pos + 1] != b'[' {
return None;
}
let after_csi = &buf[esc_pos + 2..];
let t_pos = after_csi.iter().position(|&b| b == b't')?;
let params_str = std::str::from_utf8(&after_csi[..t_pos]).ok()?;
let parts: Vec<&str> = params_str.split(';').collect();
if parts.len() >= 3 && parts[0] == "6" {
let cell_h = parts[1].parse::<usize>().ok()?;
let cell_w = parts[2].parse::<usize>().ok()?;
if cell_h > 0 && cell_w > 0 {
return Some((cell_w, cell_h));
}
}
None
}
fn scan_line_for_images(line: &str, cell_w: usize, cell_h: usize) -> LineScanResult {
let bytes = line.as_bytes();
let mut cleaned = String::with_capacity(line.len());
let mut images = Vec::new();
let mut i = 0;
let mut col = 0usize;
while i < bytes.len() {
if bytes[i] == 0x1b && i + 1 < bytes.len() {
if bytes[i + 1] == b'P' {
if let Some((end, data)) = find_sixel_end(bytes, i) {
let info = analyze_sixel(&data, cell_w, cell_h);
let placeholder_width = info.width_cols.max(1);
for _ in 0..placeholder_width {
cleaned.push(' ');
}
images.push(ExtractedImage {
col,
width_cols: placeholder_width,
height_rows: info.height_rows.max(1),
data,
protocol: ImageProtocol::Sixel,
sixel_row_count: info.sixel_row_count,
sixel_data_start: info.sixel_data_start,
sixel_row_offsets: info.sixel_row_offsets,
sixel_color_defs: info.sixel_color_defs,
});
col += placeholder_width;
i = end;
continue;
}
}
if bytes[i + 1] == b'_' {
if let Some((end, data)) = find_kitty_end(bytes, i) {
let (w, h) = parse_kitty_dimensions(&data, cell_w, cell_h);
let placeholder_width = w.max(1);
for _ in 0..placeholder_width {
cleaned.push(' ');
}
images.push(ExtractedImage {
col,
width_cols: placeholder_width,
height_rows: h.max(1),
data,
protocol: ImageProtocol::Kitty,
sixel_row_count: 0,
sixel_data_start: 0,
sixel_row_offsets: Vec::new(),
sixel_color_defs: Vec::new(),
});
col += placeholder_width;
i = end;
continue;
}
}
}
let ch = line[i..].chars().next().unwrap_or(' ');
let len = ch.len_utf8();
let w = unicode_width::UnicodeWidthChar::width(ch).unwrap_or(0);
cleaned.push(ch);
col += w;
i += len;
}
LineScanResult { cleaned, images }
}
struct SixelInfo {
width_cols: usize,
height_rows: usize,
sixel_row_count: usize,
sixel_data_start: usize,
sixel_row_offsets: Vec<usize>,
sixel_color_defs: Vec<Vec<u8>>,
}
fn analyze_sixel(data: &[u8], cell_w: usize, cell_h: usize) -> SixelInfo {
let s = String::from_utf8_lossy(data);
let mut pixel_w = 0usize;
let mut pixel_h = 0usize;
let q_byte_pos = data.iter().position(|&b| b == b'q');
if let Some(q_pos) = s.find('q') {
let after_q = &s[q_pos + 1..];
if let Some(stripped) = after_q.strip_prefix('"') {
let raster_end = stripped.find(|c: char| {
c == '#' || c == '!' || c == '$' || c == '-' || ('?'..='~').contains(&c)
});
if let Some(end) = raster_end {
let raster = &stripped[..end];
let parts: Vec<&str> = raster.split(';').collect();
if parts.len() >= 4 {
pixel_w = parts[2].parse().unwrap_or(0);
pixel_h = parts[3].parse().unwrap_or(0);
}
}
}
}
let mut row_offsets = Vec::new();
let mut color_defs: Vec<Vec<u8>> = Vec::new();
let sixel_data_start = q_byte_pos.map(|p| p + 1).unwrap_or(0);
let mut rows = 1usize;
let mut max_width_pixels = 0usize;
let mut current_width = 0usize;
let mut i = sixel_data_start;
if i < data.len() && data[i] == b'"' {
while i < data.len()
&& data[i] != b'#'
&& !(data[i] >= b'?' && data[i] <= b'~')
&& data[i] != b'!'
&& data[i] != b'-'
&& data[i] != b'$'
{
i += 1;
}
}
let data_region_start = i;
while i < data.len() {
match data[i] {
b'\x1b' => break, b'#' => {
let hash_pos = i;
i += 1;
while i < data.len() && (data[i].is_ascii_digit() || data[i] == b';') {
i += 1;
}
let fragment = &data[hash_pos..i];
if fragment.contains(&b';') {
color_defs.push(fragment.to_vec());
}
continue;
}
b'-' => {
if current_width > max_width_pixels {
max_width_pixels = current_width;
}
current_width = 0;
row_offsets.push(i);
rows += 1;
}
b'$' => {
if current_width > max_width_pixels {
max_width_pixels = current_width;
}
current_width = 0;
}
b'!' => {
i += 1;
let mut count = 0usize;
while i < data.len() && data[i].is_ascii_digit() {
count = count * 10 + (data[i] - b'0') as usize;
i += 1;
}
if i < data.len() && data[i] >= b'?' && data[i] <= b'~' {
current_width += count.max(1);
}
}
b'?'..=b'~' => {
current_width += 1;
}
_ => {}
}
i += 1;
}
if current_width > max_width_pixels {
max_width_pixels = current_width;
}
if pixel_w == 0 {
pixel_w = max_width_pixels;
}
if pixel_h == 0 {
pixel_h = rows * 6;
}
let width_cols = pixel_w.div_ceil(cell_w);
let height_rows = pixel_h.div_ceil(cell_h);
SixelInfo {
width_cols: width_cols.max(1),
height_rows: height_rows.max(1),
sixel_row_count: rows,
sixel_data_start: data_region_start,
sixel_row_offsets: row_offsets,
sixel_color_defs: color_defs,
}
}
pub fn clip_sixel(
img: &InlineImage,
skip_top: usize,
keep_rows: usize,
cell_h: usize,
) -> Option<Vec<u8>> {
if keep_rows == 0 || skip_top >= img.height_rows {
return None;
}
let visible_rows = keep_rows.min(img.height_rows - skip_top);
if skip_top == 0 && visible_rows >= img.height_rows {
return Some(img.data.clone());
}
let skip_sixel_top = (skip_top * cell_h) / 6;
let keep_pixels = visible_rows * cell_h;
let keep_sixel_rows = keep_pixels.div_ceil(6);
if skip_sixel_top >= img.sixel_row_count {
return None;
}
let end_sixel_row = (skip_sixel_top + keep_sixel_rows).min(img.sixel_row_count);
if end_sixel_row <= skip_sixel_top {
return None;
}
let data_start = if skip_sixel_top == 0 {
img.sixel_data_start
} else if skip_sixel_top <= img.sixel_row_offsets.len() {
img.sixel_row_offsets[skip_sixel_top - 1] + 1
} else {
return None;
};
let st_start = find_st_position(&img.data)?;
let data_end = if end_sixel_row >= img.sixel_row_count {
st_start
} else if end_sixel_row <= img.sixel_row_offsets.len() {
img.sixel_row_offsets[end_sixel_row - 1]
} else {
st_start
};
if data_start >= data_end {
return None;
}
let kept_data = &img.data[data_start..data_end];
let header = &img.data[..img.sixel_data_start];
let new_pixel_h = visible_rows * cell_h;
let adjusted_header = adjust_sixel_raster_height(header, new_pixel_h);
let color_defs_size: usize = img.sixel_color_defs.iter().map(|d| d.len()).sum();
let mut result =
Vec::with_capacity(adjusted_header.len() + color_defs_size + kept_data.len() + 2);
result.extend_from_slice(&adjusted_header);
for def in &img.sixel_color_defs {
result.extend_from_slice(def);
}
result.extend_from_slice(kept_data);
result.push(0x1b);
result.push(b'\\');
Some(result)
}
fn find_st_position(data: &[u8]) -> Option<usize> {
let len = data.len();
if len >= 2 && data[len - 2] == 0x1b && data[len - 1] == b'\\' {
Some(len - 2)
} else if len >= 1 && data[len - 1] == 0x9c {
Some(len - 1)
} else {
for i in (0..len.saturating_sub(1)).rev() {
if data[i] == 0x1b && i + 1 < len && data[i + 1] == b'\\' {
return Some(i);
}
}
None
}
}
fn adjust_sixel_raster_height(header: &[u8], new_pixel_h: usize) -> Vec<u8> {
let s = String::from_utf8_lossy(header);
if let Some(q_pos) = s.find('q') {
let after_q = &s[q_pos + 1..];
if let Some(stripped) = after_q.strip_prefix('"') {
if let Some(raster_end) = stripped.find(|c: char| {
c == '#' || c == '!' || c == '$' || c == '-' || ('?'..='~').contains(&c)
}) {
let raster = &stripped[..raster_end];
let parts: Vec<&str> = raster.split(';').collect();
if parts.len() >= 4 {
let new_raster =
format!("{};{};{};{}", parts[0], parts[1], parts[2], new_pixel_h);
let prefix = &s[..q_pos + 1]; let suffix = &stripped[raster_end..]; let result = format!("{}\"{}{}", prefix, new_raster, suffix);
return result.into_bytes();
}
}
}
}
header.to_vec()
}
fn find_sixel_end(bytes: &[u8], start: usize) -> Option<(usize, Vec<u8>)> {
let mut i = start + 2; while i < bytes.len() {
if bytes[i] == 0x1b && i + 1 < bytes.len() && bytes[i + 1] == b'\\' {
let end = i + 2;
return Some((end, bytes[start..end].to_vec()));
}
if bytes[i] == 0x9c {
let end = i + 1;
return Some((end, bytes[start..end].to_vec()));
}
i += 1;
}
None
}
fn find_kitty_end(bytes: &[u8], start: usize) -> Option<(usize, Vec<u8>)> {
let mut i = start + 2;
let mut data = Vec::new();
loop {
if i >= bytes.len() {
return None;
}
if bytes[i] == 0x1b && i + 1 < bytes.len() && bytes[i + 1] == b'\\' {
let end = i + 2;
data.extend_from_slice(&bytes[start..end]);
let chunk = &bytes[start..end];
if kitty_chunk_has_more(chunk) {
let mut next_start = end;
loop {
if let Some((next_end, _)) = find_single_kitty_chunk(bytes, next_start) {
data.extend_from_slice(&bytes[next_start..next_end]);
if !kitty_chunk_has_more(&bytes[next_start..next_end]) {
return Some((next_end, data));
}
next_start = next_end;
} else {
return Some((end, bytes[start..end].to_vec()));
}
}
}
return Some((end, data));
}
i += 1;
}
}
fn find_single_kitty_chunk(bytes: &[u8], start: usize) -> Option<(usize, Vec<u8>)> {
if start + 2 >= bytes.len() || bytes[start] != 0x1b || bytes[start + 1] != b'_' {
return None;
}
let mut i = start + 2;
while i < bytes.len() {
if bytes[i] == 0x1b && i + 1 < bytes.len() && bytes[i + 1] == b'\\' {
let end = i + 2;
return Some((end, bytes[start..end].to_vec()));
}
i += 1;
}
None
}
fn kitty_chunk_has_more(chunk: &[u8]) -> bool {
let s = String::from_utf8_lossy(chunk);
if let Some(rest) = s.strip_prefix("\x1b_G") {
let control = if let Some(semi_pos) = rest.find(';') {
&rest[..semi_pos]
} else if let Some(esc_pos) = rest.find('\x1b') {
&rest[..esc_pos]
} else {
rest
};
for kv in control.split(',') {
if let Some((key, value)) = kv.split_once('=') {
if key == "m" && value == "1" {
return true;
}
}
}
}
false
}
fn parse_kitty_dimensions(data: &[u8], cell_w: usize, cell_h: usize) -> (usize, usize) {
let s = String::from_utf8_lossy(data);
let control = if let Some(rest) = s.strip_prefix("\x1b_G") {
if let Some(semi_pos) = rest.find(';') {
&rest[..semi_pos]
} else if let Some(esc_pos) = rest.find('\x1b') {
&rest[..esc_pos]
} else {
rest
}
} else {
return (1, 1);
};
let mut cols = 0usize;
let mut rows = 0usize;
let mut pixel_w = 0usize;
let mut pixel_h = 0usize;
for kv in control.split(',') {
if let Some((key, value)) = kv.split_once('=') {
match key {
"c" => cols = value.parse().unwrap_or(0),
"r" => rows = value.parse().unwrap_or(0),
"s" => pixel_w = value.parse().unwrap_or(0),
"v" => pixel_h = value.parse().unwrap_or(0),
_ => {}
}
}
}
if cols > 0 && rows > 0 {
return (cols, rows);
}
if pixel_w > 0 || pixel_h > 0 {
if cols == 0 && pixel_w > 0 {
cols = pixel_w.div_ceil(cell_w);
}
if rows == 0 && pixel_h > 0 {
rows = pixel_h.div_ceil(cell_h);
}
}
(cols.max(1), rows.max(1))
}
fn merge_image_lines(input: &str) -> Vec<String> {
let mut result = Vec::new();
let mut pending: Option<String> = None;
for line in input.lines() {
if let Some(ref mut p) = pending {
p.push_str(line);
if sequence_complete(p) {
result.push(std::mem::take(p));
pending = None;
}
} else if has_incomplete_sequence(line) {
pending = Some(line.to_string());
} else {
result.push(line.to_string());
}
}
if let Some(p) = pending {
result.push(p);
}
if result.is_empty() && !input.is_empty() {
result.push(String::new());
}
result
}
fn has_incomplete_sequence(line: &str) -> bool {
let bytes = line.as_bytes();
let mut i = 0;
while i < bytes.len() {
if bytes[i] == 0x1b && i + 1 < bytes.len() {
if bytes[i + 1] == b'P' && find_sixel_end(bytes, i).is_none() {
return true;
}
if bytes[i + 1] == b'_' && find_kitty_end(bytes, i).is_none() {
return true;
}
}
i += 1;
}
false
}
fn sequence_complete(line: &str) -> bool {
!has_incomplete_sequence(line)
}
pub fn process_input(input: &str, cell_w: usize, cell_h: usize) -> (Vec<String>, Vec<InlineImage>) {
let merged = merge_image_lines(input);
struct LineResult {
cleaned: String,
images: Vec<ExtractedImage>,
}
let mut line_results: Vec<LineResult> = Vec::with_capacity(merged.len());
for line in &merged {
let scan = scan_line_for_images(line, cell_w, cell_h);
line_results.push(LineResult {
cleaned: scan.cleaned,
images: scan.images,
});
}
let mut expanded_lines: Vec<String> = Vec::new();
let mut all_images: Vec<InlineImage> = Vec::new();
for lr in line_results {
let current_expanded_idx = expanded_lines.len();
expanded_lines.push(lr.cleaned);
let max_image_height = lr
.images
.iter()
.map(|img| img.height_rows)
.max()
.unwrap_or(0);
for img in lr.images {
all_images.push(InlineImage {
line_idx: current_expanded_idx,
col: img.col,
height_rows: img.height_rows,
width_cols: img.width_cols,
data: img.data,
protocol: img.protocol,
sixel_row_count: img.sixel_row_count,
sixel_data_start: img.sixel_data_start,
sixel_row_offsets: img.sixel_row_offsets,
sixel_color_defs: img.sixel_color_defs,
});
}
if max_image_height > 1 {
for _ in 0..max_image_height - 1 {
expanded_lines.push(String::new());
}
}
}
(expanded_lines, all_images)
}
pub fn visible_images(
images: &[InlineImage],
scroll_offset: usize,
viewport_height: usize,
) -> Vec<&InlineImage> {
let viewport_end = scroll_offset + viewport_height;
images
.iter()
.filter(|img| {
let img_end = img.line_idx + img.height_rows;
img.line_idx < viewport_end && img_end > scroll_offset
})
.collect()
}