onefetch_image/
sixel.rs

1use anyhow::{Context as _, Result};
2use color_quant::NeuQuant;
3use image::{
4    DynamicImage, GenericImageView, ImageBuffer, Pixel, Rgb,
5    imageops::{FilterType, colorops},
6};
7
8use rustix::event::{PollFd, PollFlags, Timespec, poll};
9use rustix::io::read;
10use rustix::termios::{LocalModes, OptionalActions, tcgetattr, tcgetwinsize, tcsetattr};
11
12use std::io::{Write, stdout};
13use std::os::fd::AsFd;
14use std::time::Instant;
15
16pub struct SixelBackend;
17
18impl SixelBackend {
19    pub fn supported() -> Result<bool> {
20        let stdin = std::io::stdin();
21        // save terminal attributes and disable canonical input processing mode
22        let old_attributes = {
23            let old = tcgetattr(&stdin).context("Failed to recieve terminal attibutes")?;
24
25            let mut new = old.clone();
26            new.local_modes &= !LocalModes::ICANON;
27            new.local_modes &= !LocalModes::ECHO;
28            tcsetattr(&stdin, OptionalActions::Now, &new)
29                .context("Failed to update terminal attributes")?;
30            old
31        };
32
33        // ask for the primary device attribute string
34        print!("\x1B[c");
35        stdout().flush()?;
36
37        let start_time = Instant::now();
38        let stdin_fd = stdin.as_fd();
39        let mut stdin_pollfd = [PollFd::new(&stdin_fd, PollFlags::IN)];
40        let mut buf = Vec::<u8>::new();
41        loop {
42            // check for timeout while polling to avoid blocking the main thread
43            while poll(&mut stdin_pollfd, Some(&Timespec::default()))? < 1 {
44                if start_time.elapsed().as_millis() > 50 {
45                    tcsetattr(stdin, OptionalActions::Now, &old_attributes)
46                        .context("Failed to update terminal attributes")?;
47                    return Ok(false);
48                }
49            }
50            let mut byte = [0];
51            read(&stdin, &mut byte)?;
52            buf.push(byte[0]);
53            if buf.starts_with(&[0x1B, b'[', b'?']) && buf.ends_with(b"c") {
54                for attribute in buf[3..(buf.len() - 1)].split(|x| *x == b';') {
55                    if attribute == [b'4'] {
56                        tcsetattr(stdin, OptionalActions::Now, &old_attributes)
57                            .context("Failed to update terminal attributes")?;
58                        return Ok(true);
59                    }
60                }
61            }
62        }
63    }
64}
65
66impl super::ImageBackend for SixelBackend {
67    #[allow(clippy::map_entry)]
68    fn add_image(&self, lines: Vec<String>, image: &DynamicImage, colors: usize) -> Result<String> {
69        let tty_size = tcgetwinsize(std::io::stdin())?;
70        let cw = tty_size.ws_xpixel / tty_size.ws_col;
71        let lh = tty_size.ws_ypixel / tty_size.ws_row;
72        let width_ratio = 1.0 / cw as f64;
73        let height_ratio = 1.0 / lh as f64;
74
75        // resize image to fit the text height with the Lanczos3 algorithm
76        let image = image.resize(
77            u32::MAX,
78            (lines.len() as f64 / height_ratio) as u32,
79            FilterType::Lanczos3,
80        );
81        let image_columns = width_ratio * image.width() as f64;
82        let image_rows = height_ratio * image.height() as f64;
83
84        let rgba_image = image.to_rgba8(); // convert the image to rgba samples
85        let flat_samples = rgba_image.as_flat_samples();
86        let mut rgba_image = rgba_image.clone();
87        // reduce the amount of colors using dithering
88        let pixels = flat_samples
89            .image_slice()
90            .context("Error while slicing the image")?;
91        colorops::dither(&mut rgba_image, &NeuQuant::new(10, colors, pixels));
92
93        let rgb_image = ImageBuffer::from_fn(rgba_image.width(), rgba_image.height(), |x, y| {
94            let rgba_pixel = rgba_image.get_pixel(x, y);
95            let mut rgb_pixel = rgba_pixel.to_rgb();
96            for subpixel in &mut rgb_pixel.0 {
97                *subpixel = (*subpixel as f32 / 255.0 * rgba_pixel[3] as f32) as u8;
98            }
99            rgb_pixel
100        });
101
102        let mut image_data = Vec::<u8>::new();
103        image_data.extend(b"\x1BPq"); // start sixel data
104        image_data.extend(format!("\"1;1;{};{}", image.width(), image.height()).as_bytes());
105
106        let mut colors = std::collections::HashMap::<Rgb<u8>, u8>::new();
107        // subtract 1 -> divide -> add 1 to round up the integer division
108        for i in 0..((rgb_image.height() - 1) / 6 + 1) {
109            let sixel_row = rgb_image.view(
110                0,
111                i * 6,
112                rgb_image.width(),
113                std::cmp::min(6, rgb_image.height() - i * 6),
114            );
115            for (_, _, pixel) in sixel_row.pixels() {
116                if !colors.contains_key(&pixel) {
117                    // sixel uses percentages for rgb values
118                    let color_multiplier = 100.0 / 255.0;
119                    image_data.extend(
120                        format!(
121                            "#{};2;{};{};{}",
122                            colors.len(),
123                            (pixel[0] as f32 * color_multiplier) as u32,
124                            (pixel[1] as f32 * color_multiplier) as u32,
125                            (pixel[2] as f32 * color_multiplier) as u32
126                        )
127                        .as_bytes(),
128                    );
129                    colors.insert(pixel, colors.len() as u8);
130                }
131            }
132            for (color, color_index) in &colors {
133                let mut sixel_samples = vec![0; sixel_row.width() as usize];
134                sixel_samples.resize(sixel_row.width() as usize, 0);
135                for (x, y, pixel) in sixel_row.pixels() {
136                    if color == &pixel {
137                        sixel_samples[x as usize] |= 1 << y;
138                    }
139                }
140                image_data.extend(format!("#{color_index}").bytes());
141                image_data.extend(sixel_samples.iter().map(|x| x + 0x3F));
142                image_data.push(b'$');
143            }
144            image_data.push(b'-');
145        }
146        image_data.extend(b"\x1B\\");
147
148        image_data.extend(format!("\x1B[{}A", image_rows as u32 - 1).as_bytes()); // move cursor to top-left corner
149        image_data.extend(format!("\x1B[{}C", image_columns as u32 + 1).as_bytes()); // move cursor to top-right corner of image
150        let mut i = 0;
151        for line in &lines {
152            image_data.extend(format!("\x1B[s{line}\x1B[u\x1B[1B").as_bytes());
153            i += 1;
154        }
155        image_data
156            .extend(format!("\n\x1B[{}B", lines.len().max(image_rows as usize) - i).as_bytes()); // move cursor to end of image
157
158        Ok(String::from_utf8(image_data)?)
159    }
160}