Skip to main content

fm/io/
chafa.rs

1use std::io::{stdout, Write};
2
3use anyhow::{Context, Result};
4use crossterm::{
5    cursor::{MoveTo, RestorePosition, SavePosition},
6    execute,
7    terminal::{disable_raw_mode, enable_raw_mode},
8};
9use ratatui::layout::Rect;
10
11use crate::{common::CHAFA, io::ImageDisplayer};
12use crate::{io::execute_and_capture_output_with_path, modes::DisplayedImage};
13
14/// Holds the path of the image and a rect surrounding its display position.
15/// It's used to:
16/// - avoid drawing the same image over and over,
17/// - know where to draw the new image,
18/// - know where to erase the last image.
19#[derive(Debug)]
20struct PathRect {
21    path: String,
22    rect: Rect,
23}
24
25impl PathRect {
26    fn new(path: String, rect: Rect) -> Self {
27        Self { path, rect }
28    }
29
30    /// true iff the displayed image path and its rect haven't changed
31    fn is_same(&self, path: &str, rect: Rect) -> bool {
32        self.path == path && self.rect == rect
33    }
34}
35
36/// Which image was displayed, where on the screen and is it displayed ?
37#[derive(Default, Debug)]
38pub struct Chafa {
39    last_displayed: Option<PathRect>,
40    is_displaying: bool,
41}
42
43impl ImageDisplayer for Chafa {
44    /// Draws the image to the terminal using [chafa](<https://hpjansson.org/chafa/>).
45    ///
46    /// The drawing is done using the first method supported by the terminal (iterm2, kitty, sixel or symbols).
47    /// It requires a string to be "written" to the terminal itself.
48    fn draw(&mut self, image: &DisplayedImage, rect: Rect) -> Result<()> {
49        let path = &image.selected_path();
50        if self.image_can_be_reused(path, rect) {
51            return Ok(());
52        }
53        let image_string = Self::encode_chafa(path, rect)?;
54        let image_encoded = image_string.as_bytes();
55        Self::write_image_to_term(image_encoded, rect)?;
56        self.is_displaying = true;
57        self.last_displayed = Some(PathRect::new(path.to_string(), rect));
58        Ok(())
59    }
60
61    /// Clear the last displayed image.
62    /// Alias to clear_all.
63    ///
64    /// If an image is currently displayed, write lines of " " in all its rect.
65    fn clear(&mut self, _: &DisplayedImage) -> Result<()> {
66        self.clear_all()
67    }
68
69    /// Clear the last displayed image.
70    /// If an image is currently displayed, write lines of " " in all its rect.
71    fn clear_all(&mut self) -> Result<()> {
72        if let Some(PathRect { path: _, rect }) = self.last_displayed {
73            Self::clear_image_rect(rect)?;
74        }
75        self.is_displaying = false;
76        self.last_displayed = None;
77        Ok(())
78    }
79}
80
81impl Chafa {
82    /// True iff the image already drawned can be reused.
83    /// Two conditions must be true:
84    /// - we are displaying something (is_displaying is true)
85    /// - the image itself and its position haven't changed (path and rect haven't changed)
86    fn image_can_be_reused<P>(&self, path: P, rect: Rect) -> bool
87    where
88        P: AsRef<str>,
89    {
90        if !self.is_displaying {
91            return false;
92        }
93        if let Some(path_rect) = &self.last_displayed {
94            path_rect.is_same(path.as_ref(), rect)
95        } else {
96            false
97        }
98    }
99
100    /// Encode an image to a string using iterm2 inline image protocol.
101    fn encode_chafa<P>(path: P, rect: Rect) -> Result<String>
102    where
103        P: AsRef<str>,
104    {
105        Self::write_chafa(path.as_ref(), rect.width, rect.height)
106    }
107
108    /// To draw an image on the terminal using Inline Image Protocol,
109    /// We must :
110    /// - disable raw mode,
111    /// - move to the position,
112    /// - write the encoded bytes to stdout,
113    /// - enable raw mode.
114    ///
115    /// Heavily inspired by Yazi.
116    fn write_image_to_term(encoded_image: &[u8], rect: Rect) -> std::io::Result<()> {
117        disable_raw_mode()?;
118        execute!(stdout(), MoveTo(rect.x, rect.y))?;
119        stdout().write_all(encoded_image)?;
120        enable_raw_mode()
121    }
122
123    /// Clear the rect where the last image were drawned.
124    /// Simply write `height` empty lines of length `width`.
125    fn clear_image_rect(rect: Rect) -> std::io::Result<()> {
126        let empty_line = " ".repeat(rect.width as usize);
127        let empty_bytes = empty_line.as_bytes();
128        disable_raw_mode()?;
129        execute!(stdout(), SavePosition)?;
130        for y in rect.top()..rect.bottom() {
131            execute!(stdout(), MoveTo(rect.x, y))?;
132            stdout().write_all(empty_bytes)?;
133        }
134        execute!(stdout(), RestorePosition)?;
135        enable_raw_mode()
136    }
137
138    /// Creates the chafa string. We force a view-size of the surrounding rect and ensure "relative" is on. It allows
139    /// the image to be displayed properly in its position.
140    ///
141    /// The resizing must be done by the terminal emulator itself.
142    fn write_chafa(path: &str, width: u16, height: u16) -> Result<String> {
143        let output = execute_and_capture_output_with_path(
144            CHAFA,
145            std::path::Path::new(path)
146                .parent()
147                .context("no parent of image path")?,
148            &[
149                "--view-size",
150                &format!("{width}x{height}"),
151                "--relative",
152                "on",
153                path,
154            ],
155        )?;
156
157        Ok(output)
158    }
159}