use std::io::IsTerminal;
use std::sync::OnceLock;
use image::DynamicImage;
use ratatui::{Frame, layout::Rect, style::Style, widgets::Paragraph};
use ratatui_image::{Resize, StatefulImage, picker::Picker};
use crate::options::OptionSchema;
use crate::payload::{Body, ImageData};
use crate::theme::{self, ColorKey, Theme};
use super::{Registry, RenderOptions, Renderer, Shape};
#[derive(Debug, Clone, Default, serde::Deserialize)]
#[serde(deny_unknown_fields)]
struct Options {
#[serde(default)]
pub fit: Option<String>,
#[serde(default)]
pub max_width: Option<u16>,
#[serde(default)]
pub max_height: Option<u16>,
}
const COLOR_KEYS: &[ColorKey] = &[theme::TEXT];
const OPTION_SCHEMAS: &[OptionSchema] = &[
OptionSchema {
name: "align",
type_hint: "\"left\" | \"center\" | \"right\"",
required: false,
default: Some("\"left\""),
description: "Horizontal placement within the available cell when the image is narrower (after `max_width` / `fit = \"contain\"`).",
},
OptionSchema {
name: "fit",
type_hint: "\"contain\" | \"cover\" | \"stretch\"",
required: false,
default: Some("\"contain\""),
description: "How the image is sized into its box. `contain` preserves aspect; `cover` fills the box and crops overflow; `stretch` warps to fit.",
},
OptionSchema {
name: "max_width",
type_hint: "cells (u16)",
required: false,
default: Some("area width"),
description: "Upper bound on image width in cells.",
},
OptionSchema {
name: "max_height",
type_hint: "cells (u16)",
required: false,
default: Some("area height"),
description: "Upper bound on image height in cells.",
},
OptionSchema {
name: "padding",
type_hint: "cells (u16)",
required: false,
default: Some("0"),
description: "Uniform inset inside the image cell (all four sides). Applied before alignment and sizing.",
},
];
pub struct MediaImageRenderer;
impl Renderer for MediaImageRenderer {
fn name(&self) -> &str {
"media_image"
}
fn description(&self) -> &'static str {
"Embeds a raster image using the best protocol the terminal advertises (kitty, sixel, iTerm2) and falls back to half-block ASCII elsewhere. Honours `fit` (contain / cover / stretch) plus `max_width` / `max_height` / `padding` for placement."
}
fn accepts(&self) -> &[Shape] {
&[Shape::Image]
}
fn color_keys(&self) -> &[ColorKey] {
COLOR_KEYS
}
fn option_schemas(&self) -> &[OptionSchema] {
OPTION_SCHEMAS
}
fn render(
&self,
frame: &mut Frame,
area: Rect,
body: &Body,
opts: &RenderOptions,
theme: &Theme,
_registry: &Registry,
) {
if let Body::Image(d) = body {
render_image_payload(frame, area, d, opts, theme);
}
}
}
fn render_image_payload(
frame: &mut Frame,
area: Rect,
data: &ImageData,
opts: &RenderOptions,
theme: &Theme,
) {
let target = compute_target(area, opts);
match render_image(frame, target, &data.path, opts) {
Ok(()) => {}
Err(msg) => frame.render_widget(
Paragraph::new(msg).style(Style::default().fg(theme.text)),
target,
),
}
}
fn render_image(
frame: &mut Frame,
area: Rect,
path: &str,
opts: &RenderOptions,
) -> Result<(), String> {
let specific: Options = opts.parse_specific();
let img = load_image(path)?;
let mut protocol = picker().new_resize_protocol(img);
let widget = StatefulImage::default().resize(resize_mode(specific.fit.as_deref()));
frame.render_stateful_widget(widget, area, &mut protocol);
Ok(())
}
fn compute_target(area: Rect, opts: &RenderOptions) -> Rect {
let specific: Options = opts.parse_specific();
let padded = apply_padding(area, opts.padding.unwrap_or(0));
let capped_w = specific
.max_width
.map_or(padded.width, |m| m.min(padded.width));
let capped_h = specific
.max_height
.map_or(padded.height, |m| m.min(padded.height));
let x_offset = match opts.align.as_deref() {
Some("center") => padded.width.saturating_sub(capped_w) / 2,
Some("right") => padded.width.saturating_sub(capped_w),
_ => 0,
};
Rect {
x: padded.x + x_offset,
y: padded.y,
width: capped_w,
height: capped_h,
}
}
fn apply_padding(area: Rect, padding: u16) -> Rect {
let pad_x = padding.min(area.width / 2);
let pad_y = padding.min(area.height / 2);
Rect {
x: area.x + pad_x,
y: area.y + pad_y,
width: area.width.saturating_sub(pad_x * 2),
height: area.height.saturating_sub(pad_y * 2),
}
}
fn resize_mode(fit: Option<&str>) -> Resize {
match fit {
Some("cover") => Resize::Crop(None),
Some("stretch") => Resize::Scale(None),
_ => Resize::Fit(None),
}
}
fn picker() -> Picker {
static CACHED: OnceLock<Picker> = OnceLock::new();
CACHED.get_or_init(detect_picker).clone()
}
fn detect_picker() -> Picker {
if std::io::stdin().is_terminal() {
Picker::from_query_stdio().unwrap_or_else(|_| Picker::halfblocks())
} else {
Picker::halfblocks()
}
}
fn load_image(path: &str) -> Result<DynamicImage, String> {
image::ImageReader::open(path)
.map_err(|e| format!("[image: {e}]"))?
.decode()
.map_err(|e| format!("[image decode: {e}]"))
}
#[cfg(test)]
mod tests {
use image::{Rgba, RgbaImage};
use ratatui::{Terminal, backend::TestBackend};
use super::*;
use crate::payload::{ImageData, Payload, TextData};
use crate::render::test_utils::{line_text, render_to_buffer};
use crate::theme::Theme;
fn payload(path: &str) -> Payload {
Payload {
icon: None,
status: None,
format: None,
body: Body::Image(ImageData { path: path.into() }),
}
}
#[test]
fn renderer_contract_exposes_image_surface() {
let renderer = MediaImageRenderer;
assert_eq!(renderer.name(), "media_image");
assert!(renderer.description().contains("kitty"));
assert_eq!(renderer.accepts(), &[Shape::Image]);
assert_eq!(renderer.color_keys().len(), 1);
assert_eq!(renderer.color_keys()[0].name, theme::TEXT.name);
assert_eq!(renderer.option_schemas().len(), 5);
assert_eq!(renderer.option_schemas()[0].name, "align");
assert_eq!(renderer.option_schemas()[1].name, "fit");
}
#[test]
fn falls_back_to_text_when_path_missing() {
let buf = render_to_buffer(&payload("/does/not/exist.png"), 40, 5);
let content: String = (0..5)
.map(|y| line_text(&buf, y))
.collect::<Vec<_>>()
.join(" ");
assert!(content.contains("image"));
}
#[test]
fn render_ignores_non_image_body() {
let backend = TestBackend::new(8, 3);
let mut terminal = Terminal::new(backend).unwrap();
terminal
.draw(|frame| {
MediaImageRenderer.render(
frame,
frame.area(),
&Body::Text(TextData {
value: "ignored".into(),
}),
&RenderOptions::default(),
&Theme::default(),
&Registry::with_builtins(),
);
})
.unwrap();
let rendered = terminal.backend().buffer().clone();
let content: String = (0..rendered.area.height)
.map(|y| line_text(&rendered, y))
.collect::<Vec<_>>()
.join("");
assert!(content.trim().is_empty());
}
#[test]
fn valid_png_renders_without_error_fallback() {
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("sample.png");
RgbaImage::from_pixel(2, 2, Rgba([255, 0, 0, 255]))
.save(&path)
.unwrap();
let backend = TestBackend::new(8, 4);
let mut terminal = Terminal::new(backend).unwrap();
terminal
.draw(|frame| {
assert!(
render_image(
frame,
frame.area(),
path.to_str().unwrap(),
&RenderOptions::default()
)
.is_ok()
);
})
.unwrap();
let rendered = terminal.backend().buffer().clone();
let content: String = (0..rendered.area.height)
.map(|y| line_text(&rendered, y))
.collect::<Vec<_>>()
.join(" ");
let painted = (0..rendered.area.height).any(|y| {
(0..rendered.area.width).any(|x| {
let style = rendered.cell((x, y)).unwrap().style();
style.fg.is_some() || style.bg.is_some()
})
});
assert!(!content.contains("[image"));
assert!(painted);
}
#[test]
fn load_image_reports_decode_errors() {
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("not-an-image.png");
std::fs::write(&path, "plain text").unwrap();
let err = load_image(path.to_str().unwrap()).unwrap_err();
assert!(err.contains("image decode"));
}
#[test]
fn padding_shrinks_area_uniformly() {
let area = Rect {
x: 0,
y: 0,
width: 20,
height: 10,
};
let opts = RenderOptions {
padding: Some(2),
..RenderOptions::default()
};
let out = compute_target(area, &opts);
assert_eq!(
out,
Rect {
x: 2,
y: 2,
width: 16,
height: 6
}
);
}
#[test]
fn max_width_caps_and_center_align_positions_box() {
let area = Rect {
x: 0,
y: 0,
width: 30,
height: 10,
};
let opts = RenderOptions {
align: Some("center".into()),
..RenderOptions::default()
}
.with_extra("max_width", 10u16);
let out = compute_target(area, &opts);
assert_eq!(out.width, 10);
assert_eq!(out.x, 10);
}
#[test]
fn max_height_caps_below_area_height() {
let area = Rect {
x: 0,
y: 0,
width: 20,
height: 20,
};
let opts = RenderOptions::default().with_extra("max_height", 5u16);
let out = compute_target(area, &opts);
assert_eq!(out.height, 5);
}
#[test]
fn right_align_pushes_box_to_far_edge() {
let area = Rect {
x: 0,
y: 0,
width: 30,
height: 10,
};
let opts = RenderOptions {
align: Some("right".into()),
..RenderOptions::default()
}
.with_extra("max_width", 10u16);
let out = compute_target(area, &opts);
assert_eq!(out.x, 20);
assert_eq!(out.width, 10);
}
#[test]
fn resize_mode_maps_fit_option() {
assert!(matches!(resize_mode(Some("cover")), Resize::Crop(_)));
assert!(matches!(resize_mode(Some("stretch")), Resize::Scale(_)));
assert!(matches!(resize_mode(Some("contain")), Resize::Fit(_)));
assert!(matches!(resize_mode(None), Resize::Fit(_)));
}
}