Skip to main content

ratatui_image/protocol/
sixel.rs

1//! Sixel protocol implementations.
2//! Uses [`icy_sixel`] to draw image pixels, if the terminal [supports] the [Sixel] protocol.
3//! Needs the `sixel` feature.
4//!
5//! [`icy_sixel`]: https://github.com/mkrueger/icy_sixel
6//! [supports]: https://arewesixelyet.com
7//! [Sixel]: https://en.wikipedia.org/wiki/Sixel
8use icy_sixel::{EncodeOptions, sixel_encode};
9use image::DynamicImage;
10use ratatui::{buffer::Buffer, layout::Rect};
11use std::{cmp::min, fmt::Write};
12
13use super::{ProtocolTrait, StatefulProtocolTrait};
14use crate::{Result, errors::Errors, picker::cap_parser::Parser};
15
16// Fixed sixel protocol
17#[derive(Clone, Default)]
18pub struct Sixel {
19    pub data: String,
20    pub area: Rect,
21    pub is_tmux: bool,
22}
23
24impl Sixel {
25    pub fn new(image: DynamicImage, area: Rect, is_tmux: bool) -> Result<Self> {
26        let data = encode(&image, area, is_tmux)?;
27        Ok(Self {
28            data,
29            area,
30            is_tmux,
31        })
32    }
33}
34
35// TODO: change E to sixel_rs::status::Error and map when calling
36fn encode(img: &DynamicImage, area: Rect, is_tmux: bool) -> Result<String> {
37    let (w, h) = (img.width(), img.height());
38    let img_rgba8 = img.to_rgba8();
39    let bytes = img_rgba8.as_raw();
40    let (start, escape, end) = Parser::escape_tmux(is_tmux);
41
42    // Transparency needs explicit erasing of stale characters, or they stay behind the rendered
43    // image due to skipping of the following characters _in the buffer_.
44    // See comment in iterm2::encode about why we use ECH and movements instead of DECERA.
45    // TODO: unify this with iterm2
46    let width = area.width;
47    let height = area.height;
48
49    let sixel_data = sixel_encode(bytes, w as usize, h as usize, &EncodeOptions::default())
50        .map_err(|err| Errors::Sixel(format!("sixel encoding error: {err}")))?;
51
52    let mut data = String::new();
53    if is_tmux {
54        if !sixel_data.starts_with('\x1b') {
55            return Err(Errors::Tmux("sixel string did not start with escape"));
56        }
57        // The clear sequence must be inside the tmux passthrough since it uses
58        // doubled escapes.
59        data.push_str(start);
60        for _ in 0..height {
61            write!(data, "{escape}[{width}X{escape}[1B").unwrap();
62        }
63        write!(data, "{escape}[{height}A").unwrap();
64        data.push_str(escape);
65        data.push_str(&sixel_data[1..]);
66        data.push_str(end);
67    } else {
68        for _ in 0..height {
69            write!(data, "{escape}[{width}X{escape}[1B").unwrap();
70        }
71        write!(data, "{escape}[{height}A").unwrap();
72        data.push_str(&sixel_data);
73    }
74    Ok(data)
75}
76
77impl ProtocolTrait for Sixel {
78    fn render(&self, area: Rect, buf: &mut Buffer) {
79        render(self.area, &self.data, area, buf, false)
80    }
81
82    fn area(&self) -> Rect {
83        self.area
84    }
85}
86
87fn render(rect: Rect, data: &str, area: Rect, buf: &mut Buffer, overdraw: bool) {
88    let render_area = match render_area(rect, area, overdraw) {
89        None => {
90            // If we render out of area, then the buffer will attempt to write regular text (or
91            // possibly other sixels) over the image.
92            //
93            // On some implementations (e.g. Xterm), this actually works but the image is
94            // forever overwritten since we won't write out the same sixel data for the same
95            // (col,row) position again (see buffer diffing).
96            // Thus, when the area grows, the newly available cells will skip rendering and
97            // leave artifacts instead of the image data.
98            //
99            // On some implementations (e.g. ???), only text with its foreground color is
100            // overlayed on the image, also forever overwritten.
101            //
102            // On some implementations (e.g. patched Alactritty), image graphics are never
103            // overwritten and simply draw over other UI elements.
104            //
105            // Note that [ResizeProtocol] forces to ignore this early return, since it will
106            // always resize itself to the area.
107            return;
108        }
109        Some(r) => r,
110    };
111
112    buf.cell_mut(render_area).map(|cell| cell.set_symbol(data));
113    let mut skip_first = false;
114
115    // Skip entire area
116    for y in render_area.top()..render_area.bottom() {
117        for x in render_area.left()..render_area.right() {
118            if !skip_first {
119                skip_first = true;
120                continue;
121            }
122            buf.cell_mut((x, y)).map(|cell| cell.set_skip(true));
123        }
124    }
125}
126
127fn render_area(rect: Rect, area: Rect, overdraw: bool) -> Option<Rect> {
128    if overdraw {
129        return Some(Rect::new(
130            area.x,
131            area.y,
132            min(rect.width, area.width),
133            min(rect.height, area.height),
134        ));
135    }
136
137    if rect.width > area.width || rect.height > area.height {
138        return None;
139    }
140    Some(Rect::new(area.x, area.y, rect.width, rect.height))
141}
142
143impl StatefulProtocolTrait for Sixel {
144    fn resize_encode(&mut self, img: DynamicImage, area: Rect) -> Result<()> {
145        let data = encode(&img, area, self.is_tmux)?;
146        *self = Sixel {
147            data,
148            area,
149            ..*self
150        };
151        Ok(())
152    }
153}