diff --git a/src/image_backends/mod.rs b/src/image_backends/mod.rs index 0e0b3e421..9290ce5cd 100644 --- a/src/image_backends/mod.rs +++ b/src/image_backends/mod.rs @@ -2,6 +2,8 @@ use image::DynamicImage; #[cfg(target_os = "linux")] mod kitty; +#[cfg(target_os = "linux")] +mod sixel; pub trait ImageBackend { fn add_image(&self, lines: Vec, image: &DynamicImage) -> String; @@ -11,6 +13,8 @@ pub trait ImageBackend { pub fn get_best_backend() -> Option> { if kitty::KittyBackend::supported() { Some(Box::new(kitty::KittyBackend::new())) + } else if sixel::SixelBackend::supported() { + Some(Box::new(sixel::SixelBackend::new())) } else { None } diff --git a/src/image_backends/sixel.rs b/src/image_backends/sixel.rs new file mode 100644 index 000000000..363aaeec1 --- /dev/null +++ b/src/image_backends/sixel.rs @@ -0,0 +1,161 @@ +use image::{ + imageops::{colorops, FilterType}, + math::nq::NeuQuant, + DynamicImage, GenericImageView, ImageBuffer, Pixel, Rgb, +}; +use libc::{ + c_void, ioctl, poll, pollfd, read, tcgetattr, tcsetattr, termios, winsize, ECHO, ICANON, + POLLIN, STDIN_FILENO, STDOUT_FILENO, TCSANOW, TIOCGWINSZ, +}; +use std::time::Instant; + +pub struct SixelBackend {} + +impl SixelBackend { + pub fn new() -> Self { + Self {} + } + + pub fn supported() -> bool { + // save terminal attributes and disable canonical input processing mode + let old_attributes = unsafe { + let mut old_attributes: termios = std::mem::zeroed(); + tcgetattr(STDIN_FILENO, &mut old_attributes); + + let mut new_attributes = old_attributes; + new_attributes.c_lflag &= !ICANON; + new_attributes.c_lflag &= !ECHO; + tcsetattr(STDIN_FILENO, TCSANOW, &new_attributes); + old_attributes + }; + + // ask for the primary device attribute string + println!("\x1B[c"); + + let start_time = Instant::now(); + let mut stdin_pollfd = pollfd { + fd: STDIN_FILENO, + events: POLLIN, + revents: 0, + }; + let mut buf = Vec::::new(); + loop { + // check for timeout while polling to avoid blocking the main thread + while unsafe { poll(&mut stdin_pollfd, 1, 0) < 1 } { + if start_time.elapsed().as_millis() > 50 { + unsafe { + tcsetattr(STDIN_FILENO, TCSANOW, &old_attributes); + } + return false; + } + } + let mut byte = 0; + unsafe { + read(STDIN_FILENO, &mut byte as *mut _ as *mut c_void, 1); + } + buf.push(byte); + if buf.starts_with(&[0x1B, b'[', b'?']) && buf.ends_with(&[b'c']) { + unsafe { + tcsetattr(STDIN_FILENO, TCSANOW, &old_attributes); + } + return true; + } + } + } +} + +impl super::ImageBackend for SixelBackend { + fn add_image(&self, lines: Vec, image: &DynamicImage) -> String { + let tty_size = unsafe { + let tty_size: winsize = std::mem::zeroed(); + ioctl(STDOUT_FILENO, TIOCGWINSZ, &tty_size); + tty_size + }; + let width_ratio = tty_size.ws_col as f64 / tty_size.ws_xpixel as f64; + let height_ratio = tty_size.ws_row as f64 / tty_size.ws_ypixel as f64; + + // resize image to fit the text height with the Lanczos3 algorithm + let image = image.resize( + u32::max_value(), + (lines.len() as f64 / height_ratio) as u32, + FilterType::Lanczos3, + ); + let image_columns = width_ratio * image.width() as f64; + let image_rows = height_ratio * image.height() as f64; + + let rgba_image = image.to_rgba(); // convert the image to rgba samples + let flat_samples = rgba_image.as_flat_samples(); + let mut rgba_image = rgba_image.clone(); + // reduce the amount of colors using dithering + colorops::dither( + &mut rgba_image, + &NeuQuant::new(10, 16, flat_samples.image_slice().unwrap()), + ); + let rgb_image = ImageBuffer::from_fn(rgba_image.width(), rgba_image.height(), |x, y| { + let rgba_pixel = rgba_image.get_pixel(x, y); + let mut rgb_pixel = rgba_pixel.to_rgb(); + for subpixel in &mut rgb_pixel.0 { + *subpixel = (*subpixel as f32 / 255.0 * rgba_pixel[3] as f32) as u8; + } + rgb_pixel + }); + + let mut image_data = Vec::::new(); + image_data.extend("\x1BPq".as_bytes()); // start sixel data + image_data.extend(format!("\"1;1;{};{}", image.width(), image.height()).as_bytes()); + + let mut colors = std::collections::HashMap::, u8>::new(); + // subtract 1 -> divide -> add 1 to round up the integer division + for i in 0..((rgb_image.height() - 1) / 6 + 1) { + let sixel_row = rgb_image.view( + 0, + i * 6, + rgb_image.width(), + std::cmp::min(6, rgb_image.height() - i * 6), + ); + for (_, _, pixel) in sixel_row.pixels() { + if !colors.contains_key(&pixel) { + // sixel uses percentages for rgb values + let color_multiplier = 100.0 / 255.0; + image_data.extend( + format!( + "#{};2;{};{};{};", + colors.len(), + (pixel[0] as f32 * color_multiplier) as u32, + (pixel[1] as f32 * color_multiplier) as u32, + (pixel[2] as f32 * color_multiplier) as u32 + ) + .as_bytes(), + ); + colors.insert(pixel, colors.len() as u8); + } + } + for (color, color_index) in &colors { + let mut sixel_samples = Vec::::with_capacity(sixel_row.width() as usize); + sixel_samples.resize(sixel_row.width() as usize, 0); + for (x, y, pixel) in sixel_row.pixels() { + if color == &pixel { + sixel_samples[x as usize] = sixel_samples[x as usize] | (1 << y); + } + } + image_data.extend(format!("#{}", color_index).bytes()); + image_data.extend(sixel_samples.iter().map(|x| x + 0x3F)); + image_data.push('$' as u8); + } + image_data.push('-' as u8); + } + image_data.extend("\x1B\\".as_bytes()); + + image_data.extend(format!("\x1B[{}A", image_rows as u32 + 2).as_bytes()); // move cursor to top-left corner + image_data.extend(format!("\x1B[{}C", image_columns as u32 + 1).as_bytes()); // move cursor to top-right corner of image + let mut i = 0; + for line in &lines { + image_data.extend(format!("\x1B[s{}\x1B[u\x1B[1B", line).as_bytes()); + i += 1; + } + image_data + .extend(format!("\n\x1B[{}B", lines.len().max(image_rows as usize) - i).as_bytes()); // move cursor to end of image + + String::from_utf8(image_data).unwrap() + } +}