1use std::fmt::Write as _;
22use std::io::IsTerminal;
23
24use base64::Engine as _;
25use base64::engine::general_purpose::STANDARD as BASE64;
26
27#[derive(Debug, Clone, Copy, PartialEq, Eq)]
29pub enum Protocol {
30 Iterm2,
32 Kitty,
34}
35
36impl Protocol {
37 #[must_use]
39 pub fn carries(self, kind: Kind) -> bool {
40 match self {
41 Self::Kitty => kind == Kind::Png,
45 Self::Iterm2 => true,
46 }
47 }
48}
49
50#[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#[derive(Debug, Clone, Copy, PartialEq, Eq)]
94pub enum Refusal {
95 NotATerminal,
97 Multiplexer,
100 Unrecognised,
102}
103
104#[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
133fn decide(var: &dyn Fn(&str) -> Option<String>) -> Result<Protocol, Refusal> {
143 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 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 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#[derive(Debug, Clone)]
183pub struct Picture {
184 pub escape: String,
185 pub caption: String,
186}
187
188#[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#[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 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 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 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 #[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 #[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 #[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 #[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 #[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 assert!(draw(Protocol::Kitty, b"x", "a.png", 0).contains("c=1"));
388 }
389}