use std::env;
use std::sync::{Mutex, MutexGuard, OnceLock, PoisonError};
use image::DynamicImage;
use ratatui::layout::Rect;
use ratatui_image::picker::{Picker, ProtocolType};
use ratatui_image::protocol::StatefulProtocol;
pub const DEFAULT_CELL_SIZE: (u16, u16) = (7, 14);
pub const AUTO_CELL_SIZE: (u16, u16) = (0, 0);
const MAX_SOURCE_EDGE: u32 = 1920;
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum ImageProtocol {
Kitty,
Iterm2,
Sixel,
Halfblocks,
None,
}
impl ImageProtocol {
pub fn label(self) -> &'static str {
match self {
ImageProtocol::Kitty => "kitty",
ImageProtocol::Iterm2 => "iterm2",
ImageProtocol::Sixel => "sixel",
ImageProtocol::Halfblocks => "halfblocks",
ImageProtocol::None => "none",
}
}
pub fn parse(value: &str) -> Option<ImageProtocol> {
match value.trim().to_ascii_lowercase().as_str() {
"kitty" => Some(ImageProtocol::Kitty),
"iterm2" | "iterm" => Some(ImageProtocol::Iterm2),
"sixel" => Some(ImageProtocol::Sixel),
"halfblocks" | "halfblock" => Some(ImageProtocol::Halfblocks),
"none" | "off" => Some(ImageProtocol::None),
_ => None,
}
}
pub fn draws_over_cells(self) -> bool {
matches!(
self,
ImageProtocol::Kitty | ImageProtocol::Iterm2 | ImageProtocol::Sixel
)
}
fn picker_type(self) -> Option<ProtocolType> {
match self {
ImageProtocol::Kitty => Some(ProtocolType::Kitty),
ImageProtocol::Iterm2 => Some(ProtocolType::Iterm2),
ImageProtocol::Sixel => Some(ProtocolType::Sixel),
ImageProtocol::Halfblocks => Some(ProtocolType::Halfblocks),
ImageProtocol::None => None,
}
}
}
#[derive(Debug, Default, Clone)]
struct TerminalEnv {
term: String,
term_program: String,
lc_terminal: String,
kitty_window_id: bool,
iterm_session_id: bool,
wezterm_executable: bool,
}
impl TerminalEnv {
fn from_env() -> TerminalEnv {
TerminalEnv {
term: lowercased("TERM"),
term_program: lowercased("TERM_PROGRAM"),
lc_terminal: lowercased("LC_TERMINAL"),
kitty_window_id: is_set("KITTY_WINDOW_ID"),
iterm_session_id: is_set("ITERM_SESSION_ID"),
wezterm_executable: is_set("WEZTERM_EXECUTABLE"),
}
}
}
pub fn detect_from_env() -> ImageProtocol {
detect(&TerminalEnv::from_env())
}
fn detect(env: &TerminalEnv) -> ImageProtocol {
if env.kitty_window_id || env.term.contains("kitty") {
return ImageProtocol::Kitty;
}
if env.iterm_session_id
|| env.term_program.contains("iterm")
|| env.lc_terminal.contains("iterm")
{
return ImageProtocol::Iterm2;
}
if env.wezterm_executable || env.term_program.contains("wezterm") {
return ImageProtocol::Iterm2;
}
if ["mintty", "vscode", "tabby", "hyper", "rio", "warpterminal"]
.iter()
.any(|name| env.term_program.contains(name))
{
return ImageProtocol::Iterm2;
}
if env.term.contains("sixel")
|| env.term == "mlterm"
|| env.term == "yaft-256color"
|| env.term == "foot"
{
return ImageProtocol::Sixel;
}
ImageProtocol::Halfblocks
}
fn lowercased(name: &str) -> String {
env::var(name).unwrap_or_default().to_ascii_lowercase()
}
fn is_set(name: &str) -> bool {
env::var_os(name).is_some_and(|value| !value.is_empty())
}
pub fn measure_cell_size() -> Option<(u16, u16)> {
let size = crossterm::terminal::window_size().ok()?;
if size.columns == 0 || size.rows == 0 {
return None;
}
let cell = (size.width / size.columns, size.height / size.rows);
(cell.0 > 0 && cell.1 > 0).then_some(cell)
}
fn resolve_cell_size(configured: (u16, u16)) -> (u16, u16) {
resolve_cell_size_with(configured, measure_cell_size())
}
fn resolve_cell_size_with(configured: (u16, u16), measured: Option<(u16, u16)>) -> (u16, u16) {
let (auto_width, auto_height) = measured.unwrap_or(DEFAULT_CELL_SIZE);
(
if configured.0 == 0 {
auto_width
} else {
configured.0
},
if configured.1 == 0 {
auto_height
} else {
configured.1
},
)
}
fn in_tmux() -> bool {
is_set("TMUX")
|| env::var("TERM").unwrap_or_default().starts_with("tmux")
|| env::var("TERM_PROGRAM").unwrap_or_default() == "tmux"
}
struct Graphics {
protocol: ImageProtocol,
cell_size: (u16, u16),
picker: Option<Picker>,
}
impl Graphics {
fn new(preference: &str, cell_size: (u16, u16)) -> Graphics {
let cell_size = resolve_cell_size(cell_size);
let protocol = if preference.trim().eq_ignore_ascii_case("auto") {
detect_from_env()
} else {
ImageProtocol::parse(preference).unwrap_or_else(detect_from_env)
};
let picker = protocol.picker_type().map(|protocol_type| {
let mut picker = Picker::new(cell_size);
picker.protocol_type = protocol_type;
picker.is_tmux = in_tmux();
picker
});
tracing::info!(
protocol = protocol.label(),
cell_width = cell_size.0,
cell_height = cell_size.1,
preference,
"resolved inline image protocol"
);
Graphics {
protocol,
cell_size,
picker,
}
}
}
static GRAPHICS: OnceLock<Mutex<Graphics>> = OnceLock::new();
fn graphics() -> MutexGuard<'static, Graphics> {
GRAPHICS
.get_or_init(|| Mutex::new(Graphics::new("auto", AUTO_CELL_SIZE)))
.lock()
.unwrap_or_else(PoisonError::into_inner)
}
pub fn configure(preference: &str, cell_size: (u16, u16)) {
let next = Graphics::new(preference, cell_size);
if let Err(next) = GRAPHICS.set(Mutex::new(next)) {
*graphics() = next.into_inner().unwrap_or_else(PoisonError::into_inner);
}
}
pub fn active() -> ImageProtocol {
graphics().protocol
}
pub fn cell_size() -> (u16, u16) {
graphics().cell_size
}
pub fn build(image: DynamicImage) -> Option<Box<dyn StatefulProtocol>> {
let image = downscale(image);
let mut state = graphics();
let picker = state.picker.as_mut()?;
Some(picker.new_resize_protocol(image))
}
fn downscale(image: DynamicImage) -> DynamicImage {
let (width, height) = (image.width(), image.height());
if width <= MAX_SOURCE_EDGE && height <= MAX_SOURCE_EDGE {
return image;
}
image.resize(
MAX_SOURCE_EDGE,
MAX_SOURCE_EDGE,
image::imageops::FilterType::Triangle,
)
}
#[derive(Clone)]
pub struct ImagePreview {
pub protocol: Box<dyn StatefulProtocol>,
pub caption: String,
pub draws_over_cells: bool,
pub placed_at: Option<Rect>,
}
impl ImagePreview {
pub fn new(protocol: Box<dyn StatefulProtocol>, caption: String) -> ImagePreview {
ImagePreview {
protocol,
caption,
draws_over_cells: active().draws_over_cells(),
placed_at: None,
}
}
}
impl std::fmt::Debug for ImagePreview {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_struct("ImagePreview")
.field("protocol", &"<encoder>")
.field("caption", &self.caption)
.field("draws_over_cells", &self.draws_over_cells)
.field("placed_at", &self.placed_at)
.finish()
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn protocol_labels_round_trip_through_parse() {
for protocol in [
ImageProtocol::Kitty,
ImageProtocol::Iterm2,
ImageProtocol::Sixel,
ImageProtocol::Halfblocks,
ImageProtocol::None,
] {
assert_eq!(ImageProtocol::parse(protocol.label()), Some(protocol));
}
}
#[test]
fn parse_rejects_unknown_names() {
assert_eq!(ImageProtocol::parse("sixels"), None);
assert_eq!(ImageProtocol::parse(""), None);
assert_eq!(ImageProtocol::parse("auto"), None);
}
#[test]
fn parse_is_case_and_whitespace_insensitive() {
assert_eq!(ImageProtocol::parse(" KiTTy "), Some(ImageProtocol::Kitty));
}
#[test]
fn detection_never_reports_no_support() {
assert_ne!(detect_from_env(), ImageProtocol::None);
}
fn env_with(term: &str, term_program: &str) -> TerminalEnv {
TerminalEnv {
term: term.to_owned(),
term_program: term_program.to_owned(),
..TerminalEnv::default()
}
}
#[test]
fn wezterm_gets_pixel_previews_on_every_platform() {
let by_executable = TerminalEnv {
term: "xterm-256color".to_owned(),
wezterm_executable: true,
..TerminalEnv::default()
};
assert_eq!(detect(&by_executable), ImageProtocol::Iterm2);
assert_eq!(
detect(&env_with("xterm-256color", "wezterm")),
ImageProtocol::Iterm2
);
}
#[test]
fn kitty_wins_over_a_terminal_that_also_speaks_iterm2() {
let both = TerminalEnv {
term: "xterm-256color".to_owned(),
term_program: "wezterm".to_owned(),
kitty_window_id: true,
wezterm_executable: true,
..TerminalEnv::default()
};
assert_eq!(detect(&both), ImageProtocol::Kitty);
assert_eq!(detect(&env_with("xterm-kitty", "")), ImageProtocol::Kitty);
}
#[test]
fn iterm2_family_terminals_are_recognised() {
for program in ["iterm.app", "mintty", "vscode", "tabby", "hyper", "rio"] {
assert_eq!(
detect(&env_with("xterm-256color", program)),
ImageProtocol::Iterm2,
"expected iterm2 for TERM_PROGRAM={program}"
);
}
}
#[test]
fn sixel_terminals_are_recognised_from_term() {
for term in ["mlterm", "foot", "xterm-sixel"] {
assert_eq!(
detect(&env_with(term, "")),
ImageProtocol::Sixel,
"expected sixel for TERM={term}"
);
}
}
#[test]
fn an_unknown_terminal_floors_at_halfblocks() {
assert_eq!(
detect(&env_with("xterm-256color", "")),
ImageProtocol::Halfblocks
);
assert_eq!(detect(&TerminalEnv::default()), ImageProtocol::Halfblocks);
}
#[test]
fn downscale_leaves_small_images_untouched() {
let image = DynamicImage::new_rgb8(64, 32);
let out = downscale(image);
assert_eq!((out.width(), out.height()), (64, 32));
}
#[test]
fn downscale_bounds_the_longest_edge_and_keeps_aspect() {
let image = DynamicImage::new_rgb8(MAX_SOURCE_EDGE * 2, MAX_SOURCE_EDGE);
let out = downscale(image);
assert_eq!(out.width(), MAX_SOURCE_EDGE);
assert_eq!(out.height(), MAX_SOURCE_EDGE / 2);
}
#[test]
fn disabled_protocol_builds_no_encoder() {
let state = Graphics::new("none", DEFAULT_CELL_SIZE);
assert_eq!(state.protocol, ImageProtocol::None);
assert!(state.picker.is_none());
}
#[test]
fn unmeasurable_terminal_falls_back_to_the_default_cell_size() {
assert_eq!(
resolve_cell_size_with(AUTO_CELL_SIZE, None),
DEFAULT_CELL_SIZE
);
}
#[test]
fn a_measured_cell_size_beats_the_default() {
assert_eq!(
resolve_cell_size_with(AUTO_CELL_SIZE, Some((9, 19))),
(9, 19)
);
}
#[test]
fn configured_axes_win_and_are_resolved_independently() {
assert_eq!(resolve_cell_size_with((8, 0), Some((9, 19))), (8, 19));
assert_eq!(resolve_cell_size_with((0, 16), Some((9, 19))), (9, 16));
assert_eq!(resolve_cell_size_with((8, 16), Some((9, 19))), (8, 16));
assert_eq!(resolve_cell_size_with((8, 16), None), (8, 16));
}
#[test]
fn the_default_cell_size_errs_small() {
const SMALLEST_REALISTIC_CELL: (u16, u16) = (7, 14);
assert!(DEFAULT_CELL_SIZE.0 <= SMALLEST_REALISTIC_CELL.0);
assert!(DEFAULT_CELL_SIZE.1 <= SMALLEST_REALISTIC_CELL.1);
}
#[test]
fn explicit_preference_overrides_the_environment() {
let state = Graphics::new("halfblocks", (7, 15));
assert_eq!(state.protocol, ImageProtocol::Halfblocks);
assert_eq!(state.cell_size, (7, 15));
assert!(state.picker.is_some());
}
}