splashboard 1.2.0

A customizable terminal splash screen with plugin-based data sources
Documentation
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(())
}

/// Apply padding, `max_width`/`max_height`, and alignment to produce the final cell rect the
/// image protocol draws into. Padding shrinks first — it's "inner margin" the whole layout sees
/// as empty — then max_* caps the body, then alignment places that box inside what remains of
/// the original area.
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(_)));
    }
}