#![forbid(unsafe_code)]
use std::env;
use std::io::Cursor;
use base64::{Engine as _, engine::general_purpose::STANDARD};
use ftui_core::terminal_capabilities::TerminalCapabilities;
use image::{DynamicImage, GenericImageView, ImageFormat, imageops::FilterType};
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
pub enum ImageProtocol {
Kitty,
Iterm2,
Sixel,
Ascii,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
pub enum ImageFit {
None,
Contain,
Cover,
Stretch,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
pub enum Iterm2Dimension {
Cells(u32),
Pixels(u32),
Percent(u8),
Auto,
}
impl Iterm2Dimension {
fn encode(self) -> String {
match self {
Self::Cells(value) => value.to_string(),
Self::Pixels(value) => format!("{value}px"),
Self::Percent(value) => format!("{value}%"),
Self::Auto => "auto".to_string(),
}
}
}
#[derive(Debug, Clone)]
pub struct Iterm2Options {
pub width: Option<Iterm2Dimension>,
pub height: Option<Iterm2Dimension>,
pub preserve_aspect_ratio: bool,
pub inline: bool,
pub name: Option<String>,
}
impl Default for Iterm2Options {
fn default() -> Self {
Self {
width: None,
height: None,
preserve_aspect_ratio: true,
inline: true,
name: None,
}
}
}
#[derive(Debug, Clone, Default)]
pub struct DetectionHints {
pub term: Option<String>,
pub term_program: Option<String>,
pub kitty_graphics: Option<bool>,
pub sixel: Option<bool>,
pub iterm2_inline: Option<bool>,
}
impl DetectionHints {
#[must_use]
pub fn from_env() -> Self {
let term = env::var("TERM").ok();
let term_program = env::var("TERM_PROGRAM").ok();
let kitty_graphics = if env::var("KITTY_WINDOW_ID").is_ok() {
Some(true)
} else {
None
};
Self {
term,
term_program,
kitty_graphics,
sixel: None,
iterm2_inline: None,
}
}
#[must_use]
pub fn with_kitty_graphics(mut self, supported: bool) -> Self {
self.kitty_graphics = Some(supported);
self
}
#[must_use]
pub fn with_sixel(mut self, supported: bool) -> Self {
self.sixel = Some(supported);
self
}
#[must_use]
pub fn with_iterm2_inline(mut self, supported: bool) -> Self {
self.iterm2_inline = Some(supported);
self
}
}
#[derive(Debug, Default)]
pub struct ProtocolCache {
cached: Option<ImageProtocol>,
}
impl ProtocolCache {
#[must_use]
pub const fn new() -> Self {
Self { cached: None }
}
#[must_use]
pub fn detect(&mut self, caps: TerminalCapabilities, hints: &DetectionHints) -> ImageProtocol {
if let Some(protocol) = self.cached {
return protocol;
}
let protocol = detect_protocol(caps, hints);
self.cached = Some(protocol);
protocol
}
}
#[must_use]
pub fn detect_protocol(_caps: TerminalCapabilities, hints: &DetectionHints) -> ImageProtocol {
let term = hints.term.as_deref().unwrap_or_default();
let term_program = hints.term_program.as_deref().unwrap_or_default();
let kitty_from_env = term.contains("kitty");
if hints.kitty_graphics.unwrap_or(kitty_from_env) {
return ImageProtocol::Kitty;
}
let iterm_from_env = term_program.contains("iTerm.app");
if hints.iterm2_inline.unwrap_or(iterm_from_env) {
return ImageProtocol::Iterm2;
}
let sixel_from_env = term.contains("sixel");
if hints.sixel.unwrap_or(sixel_from_env) {
return ImageProtocol::Sixel;
}
ImageProtocol::Ascii
}
#[derive(Debug, Clone)]
pub struct Image {
image: DynamicImage,
}
impl Image {
pub fn from_bytes(bytes: &[u8]) -> Result<Self, ImageError> {
let image = image::load_from_memory(bytes)?;
Ok(Self { image })
}
pub fn to_png_bytes(
&self,
max_width: Option<u32>,
max_height: Option<u32>,
fit: ImageFit,
) -> Result<Vec<u8>, ImageError> {
let resized = resize_image(&self.image, max_width, max_height, fit);
let mut out = Cursor::new(Vec::new());
resized
.write_to(&mut out, ImageFormat::Png)
.map_err(ImageError::Encode)?;
Ok(out.into_inner())
}
pub fn encode_kitty(
&self,
max_width: Option<u32>,
max_height: Option<u32>,
fit: ImageFit,
) -> Result<Vec<String>, ImageError> {
let png = self.to_png_bytes(max_width, max_height, fit)?;
Ok(encode_kitty_png(&png))
}
pub fn encode_iterm2(
&self,
max_width: Option<u32>,
max_height: Option<u32>,
fit: ImageFit,
options: &Iterm2Options,
) -> Result<String, ImageError> {
let png = self.to_png_bytes(max_width, max_height, fit)?;
Ok(encode_iterm2_png(&png, options))
}
#[must_use]
pub fn render_ascii(&self, width: u32, height: u32, fit: ImageFit) -> Vec<String> {
render_ascii(&self.image, width, height, fit)
}
}
#[must_use]
pub fn encode_kitty_png(png_bytes: &[u8]) -> Vec<String> {
let encoded = STANDARD.encode(png_bytes);
let mut chunks = Vec::new();
let mut offset = 0usize;
let chunk_size = 4096usize;
let mut first = true;
while offset < encoded.len() {
let end = (offset + chunk_size).min(encoded.len());
let chunk = &encoded[offset..end];
let more = end < encoded.len();
let metadata = if first { "a=T,f=100," } else { "" };
let m_value = if more { 1 } else { 0 };
let seq = format!("\x1b_G{metadata}m={m_value};{chunk}\x1b\\");
chunks.push(seq);
offset = end;
first = false;
}
if chunks.is_empty() {
chunks.push("\x1b_Ga=T,f=100,m=0;\x1b\\".to_string());
}
chunks
}
#[must_use]
pub fn encode_iterm2_png(png_bytes: &[u8], options: &Iterm2Options) -> String {
let mut args = Vec::new();
if options.inline {
args.push("inline=1".to_string());
}
args.push(format!("size={}", png_bytes.len()));
if let Some(width) = options.width {
args.push(format!("width={}", width.encode()));
}
if let Some(height) = options.height {
args.push(format!("height={}", height.encode()));
}
if !options.preserve_aspect_ratio {
args.push("preserveAspectRatio=0".to_string());
}
if let Some(name) = &options.name {
let encoded_name = STANDARD.encode(name.as_bytes());
args.push(format!("name={encoded_name}"));
}
let header = format!("\x1b]1337;File={}:", args.join(";"));
let payload = STANDARD.encode(png_bytes);
format!("{header}{payload}\x07")
}
fn resize_image(
image: &DynamicImage,
max_width: Option<u32>,
max_height: Option<u32>,
fit: ImageFit,
) -> DynamicImage {
if matches!(fit, ImageFit::None) || (max_width.is_none() && max_height.is_none()) {
return image.clone();
}
let (orig_w, orig_h) = image.dimensions();
let target_w = max_width.unwrap_or(orig_w).max(1);
let target_h = max_height.unwrap_or(orig_h).max(1);
let (new_w, new_h) = match fit {
ImageFit::Stretch => (target_w, target_h),
ImageFit::Contain => scale_to_fit(orig_w, orig_h, target_w, target_h, false),
ImageFit::Cover => scale_to_fit(orig_w, orig_h, target_w, target_h, true),
ImageFit::None => (orig_w, orig_h),
};
if new_w == orig_w && new_h == orig_h {
image.clone()
} else {
image.resize_exact(new_w, new_h, FilterType::Triangle)
}
}
fn scale_to_fit(
width: u32,
height: u32,
max_width: u32,
max_height: u32,
cover: bool,
) -> (u32, u32) {
if width == 0 || height == 0 {
return (max_width.max(1), max_height.max(1));
}
let width_f = width as f32;
let height_f = height as f32;
let max_w = max_width as f32;
let max_h = max_height as f32;
let scale_w = max_w / width_f;
let scale_h = max_h / height_f;
let scale = if cover {
scale_w.max(scale_h)
} else {
scale_w.min(scale_h)
};
let new_w = (width_f * scale).round().max(1.0) as u32;
let new_h = (height_f * scale).round().max(1.0) as u32;
(new_w, new_h)
}
fn render_ascii(image: &DynamicImage, width: u32, height: u32, fit: ImageFit) -> Vec<String> {
let resized = resize_image(image, Some(width), Some(height), fit);
let grayscale = resized.to_luma8();
let ramp = b" .:-=+*#%@";
let mut lines = Vec::with_capacity(grayscale.height() as usize);
for y in 0..grayscale.height() {
let mut line = String::with_capacity(grayscale.width() as usize);
for x in 0..grayscale.width() {
let luma = grayscale.get_pixel(x, y)[0] as usize;
let idx = (luma * (ramp.len() - 1)) / 255;
line.push(ramp[idx] as char);
}
lines.push(line);
}
lines
}
#[derive(Debug)]
pub enum ImageError {
Decode(image::ImageError),
Encode(image::ImageError),
}
impl From<image::ImageError> for ImageError {
fn from(err: image::ImageError) -> Self {
Self::Decode(err)
}
}
impl std::fmt::Display for ImageError {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
Self::Decode(err) => write!(f, "image decode error: {err}"),
Self::Encode(err) => write!(f, "image encode error: {err}"),
}
}
}
impl std::error::Error for ImageError {}
#[cfg(test)]
mod tests {
use super::*;
const PNG_1X1_BASE64: &str = "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR4nGP4z8DwHwAFAAH/iZk9HQAAAABJRU5ErkJggg==";
const GIF_1X1_BASE64: &str = "R0lGODdhAQABAIEAAP8AAAAAAAAAAAAAACwAAAAAAQABAAAIBAABBAQAOw==";
fn decode_fixture_bytes(label: &str, data_b64: &str) -> Vec<u8> {
STANDARD
.decode(data_b64)
.unwrap_or_else(|err| panic!("fixture {label} base64 decode failed: {err}"))
}
fn encode_bytes(format: ImageFormat, width: u32, height: u32) -> Vec<u8> {
let image = DynamicImage::new_rgba8(width, height);
let mut out = Cursor::new(Vec::new());
image.write_to(&mut out, format).expect("encode test image");
out.into_inner()
}
#[test]
fn detects_kitty_from_env_hint() {
let caps = TerminalCapabilities::basic();
let hints = DetectionHints {
term: Some("xterm-kitty".to_string()),
..DetectionHints::default()
};
assert_eq!(detect_protocol(caps, &hints), ImageProtocol::Kitty);
}
#[test]
fn iterm2_dimensions_encode() {
assert_eq!(Iterm2Dimension::Cells(10).encode(), "10");
assert_eq!(Iterm2Dimension::Pixels(120).encode(), "120px");
assert_eq!(Iterm2Dimension::Percent(50).encode(), "50%");
assert_eq!(Iterm2Dimension::Auto.encode(), "auto");
}
#[test]
fn kitty_chunks_include_metadata_once() {
let payload = vec![0u8; 32];
let encoded = encode_kitty_png(&payload);
assert!(encoded.first().unwrap().contains("a=T,f=100"));
for chunk in encoded.iter().skip(1) {
assert!(!chunk.contains("a=T,f=100"));
}
}
#[test]
fn kitty_empty_payload_emits_single_chunk() {
let encoded = encode_kitty_png(&[]);
assert_eq!(encoded.len(), 1, "empty payload should emit a single chunk");
assert!(encoded[0].contains("a=T,f=100"), "metadata must be present");
assert!(encoded[0].contains("m=0;"), "final chunk marker required");
}
#[test]
fn iterm2_encodes_inline_sequence() {
let payload = vec![1u8; 4];
let seq = encode_iterm2_png(&payload, &Iterm2Options::default());
assert!(seq.starts_with("\x1b]1337;File="));
assert!(seq.ends_with('\x07'));
assert!(seq.contains("inline=1"));
}
#[test]
fn ascii_fallback_renders_lines() {
let image = DynamicImage::new_rgb8(4, 4);
let lines = render_ascii(&image, 4, 4, ImageFit::None);
assert_eq!(lines.len(), 4);
assert_eq!(lines[0].len(), 4);
}
#[test]
fn ascii_fallback_contain_preserves_aspect_ratio() {
let image = DynamicImage::new_rgb8(4, 2);
let lines = render_ascii(&image, 4, 4, ImageFit::Contain);
assert_eq!(
lines.len(),
2,
"contain fit should preserve aspect ratio height"
);
assert_eq!(lines[0].len(), 4, "contain fit should keep width");
}
#[test]
fn scale_to_fit_handles_zero_dimensions() {
let (w, h) = scale_to_fit(0, 0, 0, 0, false);
assert_eq!((w, h), (1, 1), "zero sizes clamp to 1x1");
}
#[test]
fn decode_png_roundtrip_preserves_dimensions() {
let bytes = encode_bytes(ImageFormat::Png, 3, 2);
let image = Image::from_bytes(&bytes).expect("decode png");
let out = image
.to_png_bytes(None, None, ImageFit::None)
.expect("encode png");
let decoded = image::load_from_memory(&out).expect("decode roundtrip");
assert_eq!(decoded.dimensions(), (3, 2));
}
#[test]
fn decode_png_fixture_resize_stretch() {
let bytes = decode_fixture_bytes("png_1x1", PNG_1X1_BASE64);
let image = Image::from_bytes(&bytes).expect("decode png fixture");
let out = image
.to_png_bytes(Some(3), Some(2), ImageFit::Stretch)
.expect("resize stretch");
let decoded = image::load_from_memory(&out).expect("decode resized png");
assert_eq!(decoded.dimensions(), (3, 2), "stretch should hit bounds");
}
#[test]
fn decode_gif_fixture_resize_contain() {
let bytes = decode_fixture_bytes("gif_1x1", GIF_1X1_BASE64);
let image = Image::from_bytes(&bytes).expect("decode gif fixture");
let out = image
.to_png_bytes(Some(2), Some(2), ImageFit::Contain)
.expect("encode png");
let decoded = image::load_from_memory(&out).expect("decode roundtrip");
assert_eq!(decoded.dimensions(), (2, 2), "contain should scale up");
}
#[test]
fn decode_jpeg_format_roundtrip() {
let bytes = encode_bytes(ImageFormat::Jpeg, 2, 2);
let image = Image::from_bytes(&bytes).expect("decode jpeg");
let out = image
.to_png_bytes(None, None, ImageFit::None)
.expect("encode png");
let decoded = image::load_from_memory(&out).expect("decode roundtrip");
assert_eq!(decoded.dimensions(), (2, 2));
}
#[test]
fn decode_invalid_bytes_returns_error() {
let err = Image::from_bytes(b"not an image").expect_err("expected decode error");
assert!(matches!(err, ImageError::Decode(_)));
}
#[test]
fn detect_protocol_iterm2() {
let caps = TerminalCapabilities::basic();
let hints = DetectionHints::default().with_iterm2_inline(true);
assert_eq!(detect_protocol(caps, &hints), ImageProtocol::Iterm2);
}
#[test]
fn detect_protocol_sixel() {
let caps = TerminalCapabilities::basic();
let hints = DetectionHints::default().with_sixel(true);
assert_eq!(detect_protocol(caps, &hints), ImageProtocol::Sixel);
}
#[test]
fn detect_protocol_ascii_fallback() {
let caps = TerminalCapabilities::basic();
let hints = DetectionHints::default();
assert_eq!(detect_protocol(caps, &hints), ImageProtocol::Ascii);
}
#[test]
fn protocol_cache_returns_cached() {
let caps = TerminalCapabilities::basic();
let hints = DetectionHints::default().with_kitty_graphics(true);
let mut cache = ProtocolCache::new();
let first = cache.detect(caps, &hints);
assert_eq!(first, ImageProtocol::Kitty);
let other_hints = DetectionHints::default().with_sixel(true);
let second = cache.detect(caps, &other_hints);
assert_eq!(second, ImageProtocol::Kitty);
}
#[test]
fn iterm2_options_defaults() {
let opts = Iterm2Options::default();
assert!(opts.width.is_none());
assert!(opts.height.is_none());
assert!(opts.preserve_aspect_ratio);
assert!(opts.inline);
assert!(opts.name.is_none());
}
#[test]
fn image_error_display() {
let err = Image::from_bytes(b"bad").unwrap_err();
let msg = format!("{err}");
assert!(msg.contains("decode error"), "got: {msg}");
}
#[test]
fn scale_to_fit_contain_smaller_than_max() {
let (w, h) = scale_to_fit(2, 4, 10, 10, false);
assert_eq!((w, h), (5, 10));
}
#[test]
fn scale_to_fit_cover_fills_box() {
let (w, h) = scale_to_fit(2, 4, 10, 10, true);
assert_eq!((w, h), (10, 20));
}
#[test]
fn detect_protocol_kitty_explicit_hint() {
let caps = TerminalCapabilities::basic();
let hints = DetectionHints::default().with_kitty_graphics(true);
assert_eq!(detect_protocol(caps, &hints), ImageProtocol::Kitty);
}
#[test]
fn detect_protocol_kitty_beats_iterm2() {
let caps = TerminalCapabilities::basic();
let hints = DetectionHints::default()
.with_kitty_graphics(true)
.with_iterm2_inline(true);
assert_eq!(detect_protocol(caps, &hints), ImageProtocol::Kitty);
}
#[test]
fn detect_protocol_iterm2_beats_sixel() {
let caps = TerminalCapabilities::basic();
let hints = DetectionHints::default()
.with_iterm2_inline(true)
.with_sixel(true);
assert_eq!(detect_protocol(caps, &hints), ImageProtocol::Iterm2);
}
#[test]
fn detect_protocol_iterm2_from_term_program() {
let caps = TerminalCapabilities::basic();
let hints = DetectionHints {
term_program: Some("iTerm.app".to_string()),
..DetectionHints::default()
};
assert_eq!(detect_protocol(caps, &hints), ImageProtocol::Iterm2);
}
#[test]
fn detect_protocol_sixel_from_term() {
let caps = TerminalCapabilities::basic();
let hints = DetectionHints {
term: Some("xterm-sixel".to_string()),
..DetectionHints::default()
};
assert_eq!(detect_protocol(caps, &hints), ImageProtocol::Sixel);
}
#[test]
fn detect_protocol_explicit_false_overrides_env() {
let caps = TerminalCapabilities::basic();
let hints = DetectionHints {
term: Some("xterm-kitty".to_string()),
kitty_graphics: Some(false),
..DetectionHints::default()
};
assert_ne!(detect_protocol(caps, &hints), ImageProtocol::Kitty);
}
#[test]
fn detection_hints_default_all_none() {
let hints = DetectionHints::default();
assert!(hints.term.is_none());
assert!(hints.term_program.is_none());
assert!(hints.kitty_graphics.is_none());
assert!(hints.sixel.is_none());
assert!(hints.iterm2_inline.is_none());
}
#[test]
fn detection_hints_builders_chain() {
let hints = DetectionHints::default()
.with_kitty_graphics(true)
.with_sixel(false)
.with_iterm2_inline(true);
assert_eq!(hints.kitty_graphics, Some(true));
assert_eq!(hints.sixel, Some(false));
assert_eq!(hints.iterm2_inline, Some(true));
}
#[test]
fn protocol_cache_new_is_empty() {
let cache = ProtocolCache::new();
assert!(cache.cached.is_none());
}
#[test]
fn iterm2_encoding_with_dimensions() {
let payload = vec![0u8; 4];
let opts = Iterm2Options {
width: Some(Iterm2Dimension::Cells(80)),
height: Some(Iterm2Dimension::Pixels(400)),
..Iterm2Options::default()
};
let seq = encode_iterm2_png(&payload, &opts);
assert!(seq.contains("width=80"), "Should contain width: {seq}");
assert!(
seq.contains("height=400px"),
"Should contain height in pixels: {seq}"
);
}
#[test]
fn iterm2_encoding_with_name() {
let payload = vec![0u8; 4];
let opts = Iterm2Options {
name: Some("test.png".to_string()),
..Iterm2Options::default()
};
let seq = encode_iterm2_png(&payload, &opts);
let expected_name = STANDARD.encode(b"test.png");
assert!(
seq.contains(&format!("name={expected_name}")),
"Should contain encoded name: {seq}"
);
}
#[test]
fn iterm2_encoding_no_preserve_aspect() {
let payload = vec![0u8; 4];
let opts = Iterm2Options {
preserve_aspect_ratio: false,
..Iterm2Options::default()
};
let seq = encode_iterm2_png(&payload, &opts);
assert!(
seq.contains("preserveAspectRatio=0"),
"Should contain aspect ratio override: {seq}"
);
}
#[test]
fn iterm2_encoding_not_inline() {
let payload = vec![0u8; 4];
let opts = Iterm2Options {
inline: false,
..Iterm2Options::default()
};
let seq = encode_iterm2_png(&payload, &opts);
assert!(
!seq.contains("inline=1"),
"Should not contain inline when false: {seq}"
);
}
#[test]
fn iterm2_encoding_percent_dimension() {
let payload = vec![0u8; 4];
let opts = Iterm2Options {
width: Some(Iterm2Dimension::Percent(50)),
height: Some(Iterm2Dimension::Auto),
..Iterm2Options::default()
};
let seq = encode_iterm2_png(&payload, &opts);
assert!(seq.contains("width=50%"), "Percent width: {seq}");
assert!(seq.contains("height=auto"), "Auto height: {seq}");
}
#[test]
fn kitty_large_payload_multiple_chunks() {
let payload = vec![0u8; 4000];
let chunks = encode_kitty_png(&payload);
assert!(
chunks.len() > 1,
"Large payload should produce multiple chunks, got {}",
chunks.len()
);
assert!(chunks[0].contains("a=T,f=100"));
assert!(chunks[0].contains("m=1"));
let last = chunks.last().unwrap();
assert!(last.contains("m=0"), "Last chunk should have m=0: {last}");
}
#[test]
fn image_encode_kitty_produces_chunks() {
let bytes = encode_bytes(ImageFormat::Png, 2, 2);
let img = Image::from_bytes(&bytes).expect("decode");
let chunks = img.encode_kitty(None, None, ImageFit::None).expect("kitty");
assert!(!chunks.is_empty());
assert!(chunks[0].contains("a=T,f=100"));
}
#[test]
fn image_encode_iterm2_produces_sequence() {
let bytes = encode_bytes(ImageFormat::Png, 2, 2);
let img = Image::from_bytes(&bytes).expect("decode");
let seq = img
.encode_iterm2(None, None, ImageFit::None, &Iterm2Options::default())
.expect("iterm2");
assert!(seq.starts_with("\x1b]1337;File="));
assert!(seq.ends_with('\x07'));
}
#[test]
fn image_render_ascii_returns_lines() {
let bytes = encode_bytes(ImageFormat::Png, 4, 3);
let img = Image::from_bytes(&bytes).expect("decode");
let lines = img.render_ascii(4, 3, ImageFit::Stretch);
assert_eq!(lines.len(), 3);
assert_eq!(lines[0].len(), 4);
}
#[test]
fn resize_none_preserves_original() {
let bytes = encode_bytes(ImageFormat::Png, 5, 3);
let img = Image::from_bytes(&bytes).expect("decode");
let out = img
.to_png_bytes(Some(10), Some(10), ImageFit::None)
.expect("encode");
let decoded = image::load_from_memory(&out).expect("decode");
assert_eq!(
decoded.dimensions(),
(5, 3),
"None fit should preserve size"
);
}
#[test]
fn resize_only_max_width() {
let bytes = encode_bytes(ImageFormat::Png, 10, 5);
let img = Image::from_bytes(&bytes).expect("decode");
let out = img
.to_png_bytes(Some(5), None, ImageFit::Contain)
.expect("encode");
let decoded = image::load_from_memory(&out).expect("decode");
let (w, h) = decoded.dimensions();
assert!(w <= 5, "Width should be at most 5, got {w}");
assert!(h <= 5, "Height should be bounded, got {h}");
}
#[test]
fn resize_only_max_height() {
let bytes = encode_bytes(ImageFormat::Png, 5, 10);
let img = Image::from_bytes(&bytes).expect("decode");
let out = img
.to_png_bytes(None, Some(5), ImageFit::Contain)
.expect("encode");
let decoded = image::load_from_memory(&out).expect("decode");
let (_w, h) = decoded.dimensions();
assert!(h <= 5, "Height should be at most 5, got {h}");
}
#[test]
fn resize_no_constraints_preserves_original() {
let bytes = encode_bytes(ImageFormat::Png, 7, 3);
let img = Image::from_bytes(&bytes).expect("decode");
let out = img
.to_png_bytes(None, None, ImageFit::Contain)
.expect("encode");
let decoded = image::load_from_memory(&out).expect("decode");
assert_eq!(decoded.dimensions(), (7, 3));
}
#[test]
fn resize_cover_exceeds_box() {
let bytes = encode_bytes(ImageFormat::Png, 10, 5);
let img = Image::from_bytes(&bytes).expect("decode");
let out = img
.to_png_bytes(Some(4), Some(4), ImageFit::Cover)
.expect("encode");
let decoded = image::load_from_memory(&out).expect("decode");
let (w, h) = decoded.dimensions();
assert!(w >= 4 || h >= 4, "Cover should fill box: {w}x{h}");
}
#[test]
fn scale_to_fit_width_zero_only() {
let (w, h) = scale_to_fit(0, 10, 5, 5, false);
assert_eq!((w, h), (5, 5), "Zero width returns max dimensions");
}
#[test]
fn scale_to_fit_height_zero_only() {
let (w, h) = scale_to_fit(10, 0, 5, 5, false);
assert_eq!((w, h), (5, 5), "Zero height returns max dimensions");
}
#[test]
fn scale_to_fit_already_fits() {
let (w, h) = scale_to_fit(5, 5, 10, 10, false);
assert_eq!((w, h), (10, 10));
}
#[test]
fn scale_to_fit_wide_image_contain() {
let (w, h) = scale_to_fit(20, 5, 10, 10, false);
assert_eq!(w, 10);
assert!(h <= 10);
}
#[test]
fn scale_to_fit_tall_image_cover() {
let (w, h) = scale_to_fit(5, 20, 10, 10, true);
assert_eq!((w, h), (10, 40));
}
#[test]
fn ascii_stretch_ignores_aspect() {
let image = DynamicImage::new_rgb8(4, 2);
let lines = render_ascii(&image, 8, 8, ImageFit::Stretch);
assert_eq!(lines.len(), 8, "Stretch should match target height");
assert_eq!(lines[0].len(), 8, "Stretch should match target width");
}
#[test]
fn ascii_cover_fills_at_least_one_dim() {
let image = DynamicImage::new_rgb8(4, 2);
let lines = render_ascii(&image, 4, 4, ImageFit::Cover);
assert!(lines.len() >= 4, "Cover should fill height");
}
#[test]
fn ascii_ramp_black_is_space() {
let image = DynamicImage::new_rgb8(1, 1);
let lines = render_ascii(&image, 1, 1, ImageFit::None);
assert_eq!(lines[0], " ", "Black pixel should be space");
}
#[test]
fn image_error_display_decode() {
let err = Image::from_bytes(b"bad").unwrap_err();
let msg = format!("{err}");
assert!(msg.contains("decode"), "got: {msg}");
}
#[test]
fn image_error_is_std_error() {
let err = Image::from_bytes(b"bad").unwrap_err();
let _: &dyn std::error::Error = &err;
}
#[test]
fn image_error_from_image_error() {
let decode_err = image::load_from_memory(b"bad").unwrap_err();
let err: ImageError = decode_err.into();
assert!(matches!(err, ImageError::Decode(_)));
}
#[test]
fn image_protocol_derive_traits() {
let p = ImageProtocol::Kitty;
let cloned = p;
assert_eq!(p, cloned);
let debug = format!("{p:?}");
assert!(debug.contains("Kitty"));
let mut set = std::collections::HashSet::new();
set.insert(p);
assert!(set.contains(&ImageProtocol::Kitty));
}
#[test]
fn image_fit_derive_traits() {
let f = ImageFit::Contain;
let cloned = f;
assert_eq!(f, cloned);
let debug = format!("{f:?}");
assert!(debug.contains("Contain"));
let mut set = std::collections::HashSet::new();
set.insert(f);
assert!(set.contains(&ImageFit::Contain));
}
#[test]
fn iterm2_dimension_derive_traits() {
let d = Iterm2Dimension::Cells(10);
let cloned = d;
assert_eq!(d, cloned);
let debug = format!("{d:?}");
assert!(debug.contains("Cells"));
let mut set = std::collections::HashSet::new();
set.insert(d);
assert!(set.contains(&Iterm2Dimension::Cells(10)));
}
#[test]
fn iterm2_options_debug_clone() {
let opts = Iterm2Options::default();
let cloned = opts.clone();
assert!(cloned.inline);
let debug = format!("{opts:?}");
assert!(debug.contains("Iterm2Options"));
}
#[test]
fn image_debug_clone() {
let bytes = encode_bytes(ImageFormat::Png, 1, 1);
let img = Image::from_bytes(&bytes).expect("decode");
let cloned = img.clone();
let debug = format!("{img:?}");
assert!(debug.contains("Image"));
let out1 = img
.to_png_bytes(None, None, ImageFit::None)
.expect("encode");
let out2 = cloned
.to_png_bytes(None, None, ImageFit::None)
.expect("encode clone");
assert_eq!(out1, out2);
}
#[test]
fn protocol_cache_debug_default() {
let cache = ProtocolCache::default();
assert!(cache.cached.is_none());
let debug = format!("{cache:?}");
assert!(debug.contains("ProtocolCache"));
}
#[test]
fn detection_hints_debug_clone() {
let hints = DetectionHints {
term: Some("xterm".to_string()),
..DetectionHints::default()
};
let cloned = hints.clone();
assert_eq!(cloned.term.as_deref(), Some("xterm"));
let debug = format!("{hints:?}");
assert!(debug.contains("DetectionHints"));
}
}