fm/io/
inline_image_protocol.rs

1use std::{
2    fmt::Write as FmtWrite,
3    fs::File,
4    io::{stdout, Read, Write},
5};
6
7use anyhow::Result;
8use base64::{
9    encoded_len as base64_encoded_len,
10    engine::{general_purpose::STANDARD, Config},
11    Engine,
12};
13use crossterm::{
14    cursor::{MoveTo, RestorePosition, SavePosition},
15    execute,
16    terminal::{disable_raw_mode, enable_raw_mode},
17};
18use ratatui::layout::Rect;
19
20use crate::io::ImageDisplayer;
21use crate::modes::DisplayedImage;
22
23/// Holds the path of the image and a rect surrounding its display position.
24/// It's used to:
25/// - avoid drawing the same image over and over,
26/// - know where to draw the new image,
27/// - know where to erase the last image.
28#[derive(Debug)]
29struct PathRect {
30    path: String,
31    rect: Rect,
32}
33
34impl PathRect {
35    fn new(path: String, rect: Rect) -> Self {
36        Self { path, rect }
37    }
38
39    /// true iff the displayed image path and its rect haven't changed
40    fn is_same(&self, path: &str, rect: Rect) -> bool {
41        self.path == path && self.rect == rect
42    }
43}
44
45/// Which image was displayed, where on the screen and is it displayed ?
46#[derive(Default, Debug)]
47pub struct InlineImage {
48    last_displayed: Option<PathRect>,
49    is_displaying: bool,
50}
51
52impl ImageDisplayer for InlineImage {
53    /// Draws the image to the terminal using [iterm2 Inline Image Protocol](https://iterm2.com/documentation-images.html).
54    ///
55    /// The drawing itself is done by the terminal emulator.
56    /// It requires a string to be "written" to the terminal itself which will parse it and display the image.
57    fn draw(&mut self, image: &DisplayedImage, rect: Rect) -> Result<()> {
58        let path = &image.selected_path();
59        if self.image_can_be_reused(path, rect) {
60            return Ok(());
61        }
62        let image_string = Self::encode_to_string(path, rect)?;
63        let image_encoded = image_string.as_bytes();
64        Self::write_image_to_term(image_encoded, rect)?;
65        self.is_displaying = true;
66        self.last_displayed = Some(PathRect::new(path.to_string(), rect));
67        Ok(())
68    }
69
70    /// Clear the last displayed image.
71    /// Alias to clear_all.
72    ///
73    /// If an image is currently displayed, write lines of " " in all its rect.
74    fn clear(&mut self, _: &DisplayedImage) -> Result<()> {
75        self.clear_all()
76    }
77
78    /// Clear the last displayed image.
79    /// If an image is currently displayed, write lines of " " in all its rect.
80    fn clear_all(&mut self) -> Result<()> {
81        if let Some(PathRect { path: _, rect }) = self.last_displayed {
82            Self::clear_image_rect(rect)?;
83        }
84        self.is_displaying = false;
85        self.last_displayed = None;
86        Ok(())
87    }
88}
89
90impl InlineImage {
91    /// True iff the image already drawned can be reused.
92    /// Two conditions must be true:
93    /// - we are displaying something (is_displaying is true)
94    /// - the image itself and its position haven't changed (path and rect haven't changed)
95    fn image_can_be_reused<P>(&self, path: P, rect: Rect) -> bool
96    where
97        P: AsRef<str>,
98    {
99        if !self.is_displaying {
100            return false;
101        }
102        if let Some(path_rect) = &self.last_displayed {
103            path_rect.is_same(path.as_ref(), rect)
104        } else {
105            false
106        }
107    }
108
109    /// Read a file from its path to a vector of bytes.
110    fn read_as_bytes<P>(path: P) -> std::io::Result<Vec<u8>>
111    where
112        P: AsRef<str>,
113    {
114        let mut f = File::open(path.as_ref())?;
115        let mut buf = Vec::new();
116        f.read_to_end(&mut buf)?;
117        Ok(buf)
118    }
119
120    /// Encode an image to a string using iterm2 inline image protocol.
121    fn encode_to_string<P>(path: P, rect: Rect) -> Result<String>
122    where
123        P: AsRef<str>,
124    {
125        Self::write_inline_image_string(
126            &Self::read_as_bytes(path)?,
127            rect.width.saturating_sub(1),
128            rect.height.saturating_sub(4),
129        )
130    }
131
132    /// To draw an image on the terminal using Inline Image Protocol,
133    /// We must :
134    /// - disable raw mode,
135    /// - move to the position,
136    /// - write the encoded bytes to stdout,
137    /// - enable raw mode.
138    ///
139    /// Heavily inspired by Yazi.
140    fn write_image_to_term(encoded_image: &[u8], rect: Rect) -> std::io::Result<()> {
141        disable_raw_mode()?;
142        execute!(stdout(), MoveTo(rect.x, rect.y))?;
143        stdout().write_all(encoded_image)?;
144        enable_raw_mode()
145    }
146
147    /// Clear the rect where the last image were drawned.
148    /// Simply write `height` empty lines of length `width`.
149    fn clear_image_rect(rect: Rect) -> std::io::Result<()> {
150        let empty_line = " ".repeat(rect.width as usize);
151        let empty_bytes = empty_line.as_bytes();
152        disable_raw_mode()?;
153        execute!(stdout(), SavePosition)?;
154        for y in rect.top()..rect.bottom() {
155            execute!(stdout(), MoveTo(rect.x, y))?;
156            stdout().write_all(empty_bytes)?;
157        }
158        execute!(stdout(), RestorePosition)?;
159        enable_raw_mode()
160    }
161
162    /// Creates the [iterm2 Inline Image Protocol string](https://iterm2.com/documentation-images.html)
163    /// It sets an image size, a cell width, a cell height, doNotMoveCursor to 1 and preserveAspectRatio to 1.
164    ///
165    /// The resizing must be done by the terminal emulator itself.
166    /// For [WezTerm](https://wezterm.org/) it's faster this way. Hasn't been tested on other terminal emulator.
167    fn write_inline_image_string(buffer: &[u8], width: u16, height: u16) -> Result<String> {
168        let mut string = String::with_capacity(Self::guess_string_capacity(buffer));
169        write!(
170            string,
171            "\x1b]1337;File=inline=1;size={size};width={width};height={height};doNotMoveCursor=1;preserveAspectRatio=1:",
172            size = buffer.len(),
173        )?;
174        STANDARD.encode_string(buffer, &mut string);
175        write!(string, "\u{0007}")?;
176        Ok(string)
177    }
178
179    fn guess_string_capacity(buffer: &[u8]) -> usize {
180        200 + base64_encoded_len(buffer.len(), STANDARD.config().encode_padding()).unwrap_or(0)
181    }
182}