superlighttui 0.20.1

Super Light TUI - A lightweight, ergonomic terminal UI library
Documentation
//! Half-block image rendering for terminals with truecolor support.
//!
//! Uses `▀` (upper half block) with foreground/background colors to render
//! two vertical pixels per terminal cell, achieving 2x vertical resolution.

#[cfg(feature = "image")]
use image::DynamicImage;

use crate::style::Color;

/// A terminal-renderable image stored as a grid of [`Color`] values.
///
/// Each cell contains a foreground color (upper pixel) and background color
/// (lower pixel), rendered using the `▀` half-block character.
///
/// Create from an [`image::DynamicImage`] with [`HalfBlockImage::from_dynamic`]
/// (requires `image` feature), or construct manually from raw RGB data with
/// [`HalfBlockImage::from_rgb`].
pub struct HalfBlockImage {
    /// Width in terminal columns.
    pub width: u32,
    /// Height in terminal rows (each row = 2 image pixels).
    pub height: u32,
    /// Row-major pairs of (upper_color, lower_color) for each cell.
    pub pixels: Vec<(Color, Color)>,
}

#[cfg(feature = "image")]
impl HalfBlockImage {
    /// Create a half-block image from a [`DynamicImage`], resized to fit
    /// the given terminal cell dimensions.
    ///
    /// The image is resized to `width x (height * 2)` pixels using Lanczos3
    /// filtering, then each pair of vertically adjacent pixels is packed
    /// into one terminal cell.
    ///
    /// If `width * height * 2` would overflow or exceed the crate-internal
    /// image pixel cap (~16M pixels), returns an empty image rather than
    /// panicking or allocating gigabytes. Legitimate terminal-scale inputs
    /// are far below this cap.
    pub fn from_dynamic(img: &DynamicImage, width: u32, height: u32) -> Self {
        let Some(pixel_height) = height.checked_mul(2) else {
            return Self::empty(width, height);
        };
        let pixels_total = u64::from(width).saturating_mul(u64::from(pixel_height));
        if pixels_total == 0 || pixels_total > crate::buffer::MAX_IMAGE_PIXELS {
            return Self::empty(width, height);
        }
        let resized = img.resize_exact(width, pixel_height, image::imageops::FilterType::Lanczos3);
        let rgba = resized.to_rgba8();

        let mut pixels = Vec::with_capacity((width as usize) * (height as usize));
        for row in 0..height {
            for col in 0..width {
                let upper_y = row * 2;
                let lower_y = row * 2 + 1;

                let up = rgba.get_pixel(col, upper_y);
                let lo = rgba.get_pixel(col, lower_y);

                let upper = Color::Rgb(up[0], up[1], up[2]);
                let lower = Color::Rgb(lo[0], lo[1], lo[2]);
                pixels.push((upper, lower));
            }
        }

        Self {
            width,
            height,
            pixels,
        }
    }
}

impl HalfBlockImage {
    /// Create a half-block image from raw RGB pixel data.
    ///
    /// `rgb_data` must contain `width x pixel_height x 3` bytes in row-major
    /// RGB order, where `pixel_height = height * 2`. Oversized inputs that
    /// would overflow or exceed the crate-internal image pixel cap are
    /// returned as an empty image instead of allocating.
    pub fn from_rgb(rgb_data: &[u8], width: u32, height: u32) -> Self {
        let Some(pixel_height) = height.checked_mul(2) else {
            return Self::empty(width, height);
        };
        let pixels_total = u64::from(width).saturating_mul(u64::from(pixel_height));
        if pixels_total == 0 || pixels_total > crate::buffer::MAX_IMAGE_PIXELS {
            return Self::empty(width, height);
        }
        let Some(stride) = (width as usize).checked_mul(3) else {
            return Self::empty(width, height);
        };
        let mut pixels = Vec::with_capacity((width as usize) * (height as usize));

        for row in 0..height {
            for col in 0..width {
                let upper_y = (row * 2) as usize;
                let lower_y = (row * 2 + 1) as usize;
                let x = (col * 3) as usize;

                let (ur, ug, ub) = if upper_y < pixel_height as usize {
                    let offset = upper_y * stride + x;
                    if offset + 2 < rgb_data.len() {
                        (rgb_data[offset], rgb_data[offset + 1], rgb_data[offset + 2])
                    } else {
                        (0, 0, 0)
                    }
                } else {
                    (0, 0, 0)
                };

                let (lr, lg, lb) = if lower_y < pixel_height as usize {
                    let offset = lower_y * stride + x;
                    if offset + 2 < rgb_data.len() {
                        (rgb_data[offset], rgb_data[offset + 1], rgb_data[offset + 2])
                    } else {
                        (0, 0, 0)
                    }
                } else {
                    (0, 0, 0)
                };

                pixels.push((Color::Rgb(ur, ug, ub), Color::Rgb(lr, lg, lb)));
            }
        }

        Self {
            width,
            height,
            pixels,
        }
    }

    fn empty(width: u32, height: u32) -> Self {
        Self {
            width,
            height,
            pixels: Vec::new(),
        }
    }
}

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn from_rgb_rejects_oversized_dimensions() {
        // 10_000 × 10_000 × 2 = 2×10^8 pixels — well above MAX_IMAGE_PIXELS.
        // Must not allocate gigabytes; must return an empty image.
        let img = HalfBlockImage::from_rgb(&[], 10_000, 10_000);
        assert!(img.pixels.is_empty());
    }

    #[test]
    fn from_rgb_rejects_overflowing_height() {
        let img = HalfBlockImage::from_rgb(&[], 1, u32::MAX);
        assert!(img.pixels.is_empty());
    }
}