Skip to main content

ytcli/render/
image.rs

1//! Drawing an image in terminals that can draw one.
2//!
3//! Screenshots are most of what gets attached to a bug, and the alternative to
4//! this is leaving the terminal to look at one. The risk is the opposite
5//! failure: a few kilobytes of escape codes dumped into a session that cannot
6//! render them is worse than never trying, so every rule here errs towards
7//! printing nothing.
8//!
9//! **Support is decided by what the terminal says it is, never by a pattern in
10//! `TERM`.** Kitty, Ghostty, `WezTerm` and iTerm2 each export a variable of their
11//! own; a marker either matches exactly or the answer is no.
12//!
13//! **A multiplexer means no.** `TMUX` or `screen` can inherit those variables
14//! from the terminal they were started in while passing none of the graphics
15//! through, which is exactly how the escape codes end up on screen as text.
16//!
17//! **The protocol decides the formats, not us.** Kitty's direct transmission
18//! takes PNG only; iTerm2's takes whatever it can decode. Anything else falls
19//! back to the filename and the download command.
20
21use std::fmt::Write as _;
22use std::io::IsTerminal;
23
24use base64::Engine as _;
25use base64::engine::general_purpose::STANDARD as BASE64;
26
27/// An inline-image protocol a terminal understands.
28#[derive(Debug, Clone, Copy, PartialEq, Eq)]
29pub enum Protocol {
30    /// iTerm2's OSC 1337 inline image, also implemented by `WezTerm`.
31    Iterm2,
32    /// The Kitty graphics protocol, also implemented by Ghostty and `WezTerm`.
33    Kitty,
34}
35
36impl Protocol {
37    /// Whether this protocol can carry a file of this type as it stands.
38    #[must_use]
39    pub fn carries(self, kind: Kind) -> bool {
40        match self {
41            // Direct transmission takes PNG; anything else would have to be
42            // decoded to raw pixels first, which is a dependency this tool does
43            // not need to carry for a convenience.
44            Self::Kitty => kind == Kind::Png,
45            Self::Iterm2 => true,
46        }
47    }
48}
49
50/// The image formats worth recognising, identified by their own bytes rather
51/// than by a name somebody else chose.
52#[derive(Debug, Clone, Copy, PartialEq, Eq)]
53pub enum Kind {
54    Png,
55    Jpeg,
56    Gif,
57    Webp,
58}
59
60impl Kind {
61    #[must_use]
62    pub fn of(bytes: &[u8]) -> Option<Self> {
63        const PNG: &[u8] = b"\x89PNG\r\n\x1a\n";
64        if bytes.starts_with(PNG) {
65            return Some(Self::Png);
66        }
67        if bytes.starts_with(&[0xFF, 0xD8, 0xFF]) {
68            return Some(Self::Jpeg);
69        }
70        if bytes.starts_with(b"GIF87a") || bytes.starts_with(b"GIF89a") {
71            return Some(Self::Gif);
72        }
73        if bytes.len() >= 12 && bytes.starts_with(b"RIFF") && &bytes[8..12] == b"WEBP" {
74            return Some(Self::Webp);
75        }
76        None
77    }
78
79    #[must_use]
80    pub fn name(self) -> &'static str {
81        match self {
82            Self::Png => "PNG",
83            Self::Jpeg => "JPEG",
84            Self::Gif => "GIF",
85            Self::Webp => "WebP",
86        }
87    }
88}
89
90/// Why a terminal is getting no picture. Reported under `-v`, because "it
91/// printed a filename instead of my screenshot" is otherwise unanswerable
92/// without reading this file.
93#[derive(Debug, Clone, Copy, PartialEq, Eq)]
94pub enum Refusal {
95    /// stdout is a pipe or a file. Escape codes would be somebody's data.
96    NotATerminal,
97    /// tmux or screen: the terminal's variables are inherited, its graphics are
98    /// not necessarily passed through.
99    Multiplexer,
100    /// Nothing here says what this terminal is.
101    Unrecognised,
102}
103
104/// The protocol this terminal supports, if any.
105///
106/// Reads the environment rather than querying the terminal: a capability query
107/// means putting the tty into raw mode and waiting for a reply that may never
108/// come, on a tool whose whole argument is that it starts instantly.
109#[must_use]
110pub fn protocol() -> Option<Protocol> {
111    if !std::io::stdout().is_terminal() {
112        tracing::debug!("stdout is not a terminal; no inline image");
113        return None;
114    }
115
116    match decide(&|name| std::env::var(name).ok()) {
117        Ok(protocol) => {
118            tracing::debug!(?protocol, "drawing inline");
119            Some(protocol)
120        }
121        Err(refusal) => {
122            tracing::debug!(
123                ?refusal,
124                term = std::env::var("TERM").unwrap_or_default(),
125                term_program = std::env::var("TERM_PROGRAM").unwrap_or_default(),
126                "no inline image protocol"
127            );
128            None
129        }
130    }
131}
132
133/// The decision itself, over a lookup rather than the process environment, so
134/// the rules can be tested without setting variables for every other test in
135/// the binary.
136///
137/// Every marker below is a value a terminal sets about itself and matches
138/// exactly — `TERM` included, where `xterm-kitty` and `xterm-ghostty` are the
139/// terminfo names those terminals install and set. That is not the same thing
140/// as guessing from a substring of `TERM`, which is how a tool ends up printing
141/// escape codes into an `xterm-256color` that merely sounded promising.
142fn decide(var: &dyn Fn(&str) -> Option<String>) -> Result<Protocol, Refusal> {
143    // Inside a multiplexer the terminal's own variables are inherited but its
144    // graphics are not necessarily passed through.
145    if var("TMUX").is_some() || var("STY").is_some() {
146        return Err(Refusal::Multiplexer);
147    }
148
149    let program = var("TERM_PROGRAM").unwrap_or_default();
150    let term = var("TERM").unwrap_or_default();
151
152    // Kitty and Ghostty. The dedicated variables come from shell integration,
153    // which a user can turn off or a shell can fail to load; TERM and
154    // TERM_PROGRAM come from the terminal itself and survive that.
155    if var("KITTY_WINDOW_ID").is_some()
156        || var("GHOSTTY_RESOURCES_DIR").is_some()
157        || var("GHOSTTY_BIN_DIR").is_some()
158        || program == "ghostty"
159        || term == "xterm-kitty"
160        || term == "xterm-ghostty"
161    {
162        return Ok(Protocol::Kitty);
163    }
164
165    // WezTerm speaks both; its iTerm2 support covers more formats.
166    if var("WEZTERM_PANE").is_some() || program == "WezTerm" {
167        return Ok(Protocol::Iterm2);
168    }
169
170    if program == "iTerm.app" {
171        return Ok(Protocol::Iterm2);
172    }
173
174    Err(Refusal::Unrecognised)
175}
176
177/// A picture ready to be written, and the caption that goes under it.
178///
179/// The caption is separate because the block it lands in decides how it is
180/// marked: inside a quoted description every line carries the margin bar,
181/// including this one.
182#[derive(Debug, Clone)]
183pub struct Picture {
184    pub escape: String,
185    pub caption: String,
186}
187
188/// Pictures to substitute for the markdown image references that name them,
189/// keyed by the URL exactly as it appears in the text.
190#[derive(Debug, Clone, Default)]
191pub struct Inline(std::collections::BTreeMap<String, Picture>);
192
193impl Inline {
194    pub fn insert(&mut self, url: String, picture: Picture) {
195        self.0.insert(url, picture);
196    }
197
198    #[must_use]
199    pub fn get(&self, url: &str) -> Option<&Picture> {
200        self.0.get(url)
201    }
202
203    #[must_use]
204    pub fn is_empty(&self) -> bool {
205        self.0.is_empty()
206    }
207}
208
209/// The escape sequence that draws these bytes, ready to write to stdout.
210///
211/// `columns` bounds the width in cells. Both protocols scale to fit and keep
212/// the aspect ratio, which matters more than it sounds: a screenshot is
213/// commonly two thousand pixels wide, and drawn at its natural size it takes
214/// the whole window and several screens of scrollback with it.
215#[must_use]
216pub fn draw(protocol: Protocol, bytes: &[u8], name: &str, columns: usize) -> String {
217    let encoded = BASE64.encode(bytes);
218    let columns = columns.max(1);
219    match protocol {
220        Protocol::Iterm2 => {
221            // `inline=1` draws it rather than offering it as a download; the
222            // name is only a label, and the terminal never writes a file.
223            let label = BASE64.encode(name.as_bytes());
224            format!(
225                "\x1b]1337;File=name={label};size={};inline=1;width={columns};preserveAspectRatio=1:{encoded}\x07\n",
226                bytes.len()
227            )
228        }
229        Protocol::Kitty => {
230            // Chunked, because the protocol caps one escape sequence at 4096
231            // base64 characters.
232            let mut out = String::with_capacity(encoded.len() + 256);
233            let chunks: Vec<&str> = encoded
234                .as_bytes()
235                .chunks(4096)
236                .map(|chunk| std::str::from_utf8(chunk).unwrap_or_default())
237                .collect();
238
239            for (index, chunk) in chunks.iter().enumerate() {
240                let more = u8::from(index + 1 < chunks.len());
241                if index == 0 {
242                    // f=100: the payload is a file in a format kitty decodes.
243                    // a=T: transmit and display at once. c= without r= bounds
244                    // the width and lets the height follow the aspect ratio.
245                    let _ = write!(out, "\x1b_Gf=100,a=T,c={columns},m={more};{chunk}\x1b\\");
246                } else {
247                    let _ = write!(out, "\x1b_Gm={more};{chunk}\x1b\\");
248                }
249            }
250            out.push('\n');
251            out
252        }
253    }
254}
255
256#[cfg(test)]
257mod tests {
258    use super::*;
259
260    fn env(pairs: &[(&'static str, &'static str)]) -> impl Fn(&str) -> Option<String> + use<> {
261        let pairs: Vec<(String, String)> = pairs
262            .iter()
263            .map(|(k, v)| ((*k).to_owned(), (*v).to_owned()))
264            .collect();
265        move |name: &str| {
266            pairs
267                .iter()
268                .find(|(key, _)| key == name)
269                .map(|(_, value)| value.clone())
270        }
271    }
272
273    #[test]
274    fn each_terminal_gets_the_protocol_it_implements() {
275        for markers in [
276            vec![("KITTY_WINDOW_ID", "1")],
277            vec![("TERM", "xterm-kitty")],
278            vec![("GHOSTTY_RESOURCES_DIR", "/x")],
279            vec![("GHOSTTY_BIN_DIR", "/x/bin")],
280            vec![("TERM_PROGRAM", "ghostty")],
281            vec![("TERM", "xterm-ghostty")],
282        ] {
283            assert_eq!(
284                decide(&env(&markers)),
285                Ok(Protocol::Kitty),
286                "{markers:?} should be kitty graphics"
287            );
288        }
289
290        for markers in [
291            vec![("TERM_PROGRAM", "iTerm.app")],
292            vec![("WEZTERM_PANE", "0")],
293            vec![("TERM_PROGRAM", "WezTerm")],
294        ] {
295            assert_eq!(
296                decide(&env(&markers)),
297                Ok(Protocol::Iterm2),
298                "{markers:?} should be the iTerm2 protocol"
299            );
300        }
301    }
302
303    /// Ghostty sets `GHOSTTY_RESOURCES_DIR` from its shell integration, which a
304    /// user can turn off and a shell can fail to load. `TERM` comes from the
305    /// terminal itself, so it has to be enough on its own — this is the case
306    /// that shipped broken.
307    #[test]
308    fn ghostty_is_recognised_without_its_shell_integration() {
309        assert_eq!(
310            decide(&env(&[("TERM", "xterm-ghostty")])),
311            Ok(Protocol::Kitty)
312        );
313    }
314
315    /// The failure this guard exists for: escape codes printed as text.
316    #[test]
317    fn a_multiplexer_is_never_assumed_to_pass_graphics_through() {
318        assert_eq!(
319            decide(&env(&[("KITTY_WINDOW_ID", "1"), ("TMUX", "/tmp/s")])),
320            Err(Refusal::Multiplexer)
321        );
322        assert_eq!(
323            decide(&env(&[("TERM", "xterm-ghostty"), ("STY", "1.pts-0")])),
324            Err(Refusal::Multiplexer)
325        );
326    }
327
328    /// An unknown terminal is not a terminal that might work, and a `TERM` that
329    /// merely sounds promising is not a capability.
330    #[test]
331    fn anything_unrecognised_gets_nothing() {
332        assert_eq!(
333            decide(&env(&[("TERM", "xterm-256color")])),
334            Err(Refusal::Unrecognised)
335        );
336        assert_eq!(
337            decide(&env(&[("TERM", "kitty-like")])),
338            Err(Refusal::Unrecognised)
339        );
340        assert_eq!(decide(&env(&[])), Err(Refusal::Unrecognised));
341    }
342
343    #[test]
344    fn formats_are_read_from_the_bytes_not_the_name() {
345        assert_eq!(Kind::of(b"\x89PNG\r\n\x1a\n\x00"), Some(Kind::Png));
346        assert_eq!(Kind::of(&[0xFF, 0xD8, 0xFF, 0xE0]), Some(Kind::Jpeg));
347        assert_eq!(Kind::of(b"GIF89a..."), Some(Kind::Gif));
348        assert_eq!(Kind::of(b"RIFF\0\0\0\0WEBPVP8 "), Some(Kind::Webp));
349        assert_eq!(Kind::of(b"%PDF-1.7"), None);
350        assert_eq!(Kind::of(b""), None);
351    }
352
353    /// Kitty's direct transmission decodes PNG and nothing else, so a JPEG has
354    /// to fall back rather than be sent and silently dropped.
355    #[test]
356    fn kitty_takes_png_only() {
357        assert!(Protocol::Kitty.carries(Kind::Png));
358        assert!(!Protocol::Kitty.carries(Kind::Jpeg));
359        assert!(Protocol::Iterm2.carries(Kind::Jpeg));
360    }
361
362    #[test]
363    fn a_payload_over_the_chunk_limit_is_split_and_terminated() {
364        let bytes = vec![0u8; 8000];
365        let drawn = draw(Protocol::Kitty, &bytes, "big.png", 40);
366
367        assert!(drawn.starts_with("\x1b_Gf=100,a=T,c=40,m=1;"));
368        assert!(drawn.contains("\x1b_Gm=0;"));
369        assert!(drawn.ends_with("\x1b\\\n"));
370    }
371
372    #[test]
373    fn the_iterm_sequence_carries_the_size_and_draws_inline() {
374        let drawn = draw(Protocol::Iterm2, b"1234", "a.png", 40);
375        assert!(drawn.contains("size=4"));
376        assert!(drawn.contains("inline=1"));
377        assert!(drawn.contains("width=40"));
378    }
379
380    /// A screenshot drawn at its natural size takes the window and several
381    /// screens of scrollback with it, so both protocols are told a width.
382    #[test]
383    fn every_protocol_is_given_a_width_to_fit_into() {
384        assert!(draw(Protocol::Kitty, b"x", "a.png", 72).contains("c=72"));
385        assert!(draw(Protocol::Iterm2, b"x", "a.png", 72).contains("width=72"));
386        // A zero-width window would otherwise ask for a zero-column image.
387        assert!(draw(Protocol::Kitty, b"x", "a.png", 0).contains("c=1"));
388    }
389}