use std::fmt::Write as _;
use std::io::IsTerminal;
use base64::Engine as _;
use base64::engine::general_purpose::STANDARD as BASE64;
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Protocol {
Iterm2,
Kitty,
}
impl Protocol {
#[must_use]
pub fn carries(self, kind: Kind) -> bool {
match self {
Self::Kitty => kind == Kind::Png,
Self::Iterm2 => true,
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Kind {
Png,
Jpeg,
Gif,
Webp,
}
impl Kind {
#[must_use]
pub fn of(bytes: &[u8]) -> Option<Self> {
const PNG: &[u8] = b"\x89PNG\r\n\x1a\n";
if bytes.starts_with(PNG) {
return Some(Self::Png);
}
if bytes.starts_with(&[0xFF, 0xD8, 0xFF]) {
return Some(Self::Jpeg);
}
if bytes.starts_with(b"GIF87a") || bytes.starts_with(b"GIF89a") {
return Some(Self::Gif);
}
if bytes.len() >= 12 && bytes.starts_with(b"RIFF") && &bytes[8..12] == b"WEBP" {
return Some(Self::Webp);
}
None
}
#[must_use]
pub fn name(self) -> &'static str {
match self {
Self::Png => "PNG",
Self::Jpeg => "JPEG",
Self::Gif => "GIF",
Self::Webp => "WebP",
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Refusal {
NotATerminal,
Multiplexer,
Unrecognised,
}
#[must_use]
pub fn protocol() -> Option<Protocol> {
if !std::io::stdout().is_terminal() {
tracing::debug!("stdout is not a terminal; no inline image");
return None;
}
match decide(&|name| std::env::var(name).ok()) {
Ok(protocol) => {
tracing::debug!(?protocol, "drawing inline");
Some(protocol)
}
Err(refusal) => {
tracing::debug!(
?refusal,
term = std::env::var("TERM").unwrap_or_default(),
term_program = std::env::var("TERM_PROGRAM").unwrap_or_default(),
"no inline image protocol"
);
None
}
}
}
fn decide(var: &dyn Fn(&str) -> Option<String>) -> Result<Protocol, Refusal> {
if var("TMUX").is_some() || var("STY").is_some() {
return Err(Refusal::Multiplexer);
}
let program = var("TERM_PROGRAM").unwrap_or_default();
let term = var("TERM").unwrap_or_default();
if var("KITTY_WINDOW_ID").is_some()
|| var("GHOSTTY_RESOURCES_DIR").is_some()
|| var("GHOSTTY_BIN_DIR").is_some()
|| program == "ghostty"
|| term == "xterm-kitty"
|| term == "xterm-ghostty"
{
return Ok(Protocol::Kitty);
}
if var("WEZTERM_PANE").is_some() || program == "WezTerm" {
return Ok(Protocol::Iterm2);
}
if program == "iTerm.app" {
return Ok(Protocol::Iterm2);
}
Err(Refusal::Unrecognised)
}
#[derive(Debug, Clone)]
pub struct Picture {
pub escape: String,
pub caption: String,
}
#[derive(Debug, Clone, Default)]
pub struct Inline(std::collections::BTreeMap<String, Picture>);
impl Inline {
pub fn insert(&mut self, url: String, picture: Picture) {
self.0.insert(url, picture);
}
#[must_use]
pub fn get(&self, url: &str) -> Option<&Picture> {
self.0.get(url)
}
#[must_use]
pub fn is_empty(&self) -> bool {
self.0.is_empty()
}
}
#[must_use]
pub fn draw(protocol: Protocol, bytes: &[u8], name: &str, columns: usize) -> String {
let encoded = BASE64.encode(bytes);
let columns = columns.max(1);
match protocol {
Protocol::Iterm2 => {
let label = BASE64.encode(name.as_bytes());
format!(
"\x1b]1337;File=name={label};size={};inline=1;width={columns};preserveAspectRatio=1:{encoded}\x07\n",
bytes.len()
)
}
Protocol::Kitty => {
let mut out = String::with_capacity(encoded.len() + 256);
let chunks: Vec<&str> = encoded
.as_bytes()
.chunks(4096)
.map(|chunk| std::str::from_utf8(chunk).unwrap_or_default())
.collect();
for (index, chunk) in chunks.iter().enumerate() {
let more = u8::from(index + 1 < chunks.len());
if index == 0 {
let _ = write!(out, "\x1b_Gf=100,a=T,c={columns},m={more};{chunk}\x1b\\");
} else {
let _ = write!(out, "\x1b_Gm={more};{chunk}\x1b\\");
}
}
out.push('\n');
out
}
}
}
#[cfg(test)]
mod tests {
use super::*;
fn env(pairs: &[(&'static str, &'static str)]) -> impl Fn(&str) -> Option<String> + use<> {
let pairs: Vec<(String, String)> = pairs
.iter()
.map(|(k, v)| ((*k).to_owned(), (*v).to_owned()))
.collect();
move |name: &str| {
pairs
.iter()
.find(|(key, _)| key == name)
.map(|(_, value)| value.clone())
}
}
#[test]
fn each_terminal_gets_the_protocol_it_implements() {
for markers in [
vec![("KITTY_WINDOW_ID", "1")],
vec![("TERM", "xterm-kitty")],
vec![("GHOSTTY_RESOURCES_DIR", "/x")],
vec![("GHOSTTY_BIN_DIR", "/x/bin")],
vec![("TERM_PROGRAM", "ghostty")],
vec![("TERM", "xterm-ghostty")],
] {
assert_eq!(
decide(&env(&markers)),
Ok(Protocol::Kitty),
"{markers:?} should be kitty graphics"
);
}
for markers in [
vec![("TERM_PROGRAM", "iTerm.app")],
vec![("WEZTERM_PANE", "0")],
vec![("TERM_PROGRAM", "WezTerm")],
] {
assert_eq!(
decide(&env(&markers)),
Ok(Protocol::Iterm2),
"{markers:?} should be the iTerm2 protocol"
);
}
}
#[test]
fn ghostty_is_recognised_without_its_shell_integration() {
assert_eq!(
decide(&env(&[("TERM", "xterm-ghostty")])),
Ok(Protocol::Kitty)
);
}
#[test]
fn a_multiplexer_is_never_assumed_to_pass_graphics_through() {
assert_eq!(
decide(&env(&[("KITTY_WINDOW_ID", "1"), ("TMUX", "/tmp/s")])),
Err(Refusal::Multiplexer)
);
assert_eq!(
decide(&env(&[("TERM", "xterm-ghostty"), ("STY", "1.pts-0")])),
Err(Refusal::Multiplexer)
);
}
#[test]
fn anything_unrecognised_gets_nothing() {
assert_eq!(
decide(&env(&[("TERM", "xterm-256color")])),
Err(Refusal::Unrecognised)
);
assert_eq!(
decide(&env(&[("TERM", "kitty-like")])),
Err(Refusal::Unrecognised)
);
assert_eq!(decide(&env(&[])), Err(Refusal::Unrecognised));
}
#[test]
fn formats_are_read_from_the_bytes_not_the_name() {
assert_eq!(Kind::of(b"\x89PNG\r\n\x1a\n\x00"), Some(Kind::Png));
assert_eq!(Kind::of(&[0xFF, 0xD8, 0xFF, 0xE0]), Some(Kind::Jpeg));
assert_eq!(Kind::of(b"GIF89a..."), Some(Kind::Gif));
assert_eq!(Kind::of(b"RIFF\0\0\0\0WEBPVP8 "), Some(Kind::Webp));
assert_eq!(Kind::of(b"%PDF-1.7"), None);
assert_eq!(Kind::of(b""), None);
}
#[test]
fn kitty_takes_png_only() {
assert!(Protocol::Kitty.carries(Kind::Png));
assert!(!Protocol::Kitty.carries(Kind::Jpeg));
assert!(Protocol::Iterm2.carries(Kind::Jpeg));
}
#[test]
fn a_payload_over_the_chunk_limit_is_split_and_terminated() {
let bytes = vec![0u8; 8000];
let drawn = draw(Protocol::Kitty, &bytes, "big.png", 40);
assert!(drawn.starts_with("\x1b_Gf=100,a=T,c=40,m=1;"));
assert!(drawn.contains("\x1b_Gm=0;"));
assert!(drawn.ends_with("\x1b\\\n"));
}
#[test]
fn the_iterm_sequence_carries_the_size_and_draws_inline() {
let drawn = draw(Protocol::Iterm2, b"1234", "a.png", 40);
assert!(drawn.contains("size=4"));
assert!(drawn.contains("inline=1"));
assert!(drawn.contains("width=40"));
}
#[test]
fn every_protocol_is_given_a_width_to_fit_into() {
assert!(draw(Protocol::Kitty, b"x", "a.png", 72).contains("c=72"));
assert!(draw(Protocol::Iterm2, b"x", "a.png", 72).contains("width=72"));
assert!(draw(Protocol::Kitty, b"x", "a.png", 0).contains("c=1"));
}
}