tui-lipan 0.1.0

Opinionated, component-based TUI framework for Rust - declarative components, reconciliation, layout engine, focus, overlays, and rich widgets on top of ratatui.
Documentation
use std::path::Path;
use std::sync::{Mutex, OnceLock};

use image::GenericImageView;
use unicode_width::UnicodeWidthStr;

use super::{Image, ImageSource};
use crate::style::Length;

const DEFAULT_CELL_WIDTH_PX: u32 = 8;
const DEFAULT_CELL_HEIGHT_PX: u32 = 16;

#[derive(Default)]
struct ImageMeasureCache {
    entries: Vec<(u64, (u16, u16))>,
}

impl ImageMeasureCache {
    fn get(&self, key: u64) -> Option<(u16, u16)> {
        self.entries
            .iter()
            .find(|(k, _)| *k == key)
            .map(|(_, v)| *v)
    }

    fn insert(&mut self, key: u64, value: (u16, u16)) {
        if let Some(idx) = self.entries.iter().position(|(k, _)| *k == key) {
            self.entries.remove(idx);
        }
        self.entries.push((key, value));
        if self.entries.len() > 128 {
            self.entries.remove(0);
        }
    }
}

fn measure_cache() -> &'static Mutex<ImageMeasureCache> {
    static CACHE: OnceLock<Mutex<ImageMeasureCache>> = OnceLock::new();
    CACHE.get_or_init(|| Mutex::new(ImageMeasureCache::default()))
}

pub(crate) fn pixels_to_cells(width: u32, height: u32) -> (u16, u16) {
    let cells_w = width.div_ceil(DEFAULT_CELL_WIDTH_PX).min(u16::MAX as u32) as u16;
    let cells_h = height.div_ceil(DEFAULT_CELL_HEIGHT_PX).min(u16::MAX as u32) as u16;
    (cells_w, cells_h)
}

pub(crate) fn source_natural_size(source: &ImageSource, key: u64) -> Option<(u16, u16)> {
    if let Ok(cache) = measure_cache().lock()
        && let Some(size) = cache.get(key)
    {
        return Some(size);
    }

    let dims = match source {
        ImageSource::Path(path) => image::ImageReader::open(Path::new(path.as_ref()))
            .ok()?
            .decode()
            .ok()
            .map(|img| img.dimensions()),
        ImageSource::Bytes(bytes) => image::load_from_memory(bytes.as_ref())
            .ok()
            .map(|img| img.dimensions()),
    }?;

    let cells = pixels_to_cells(dims.0, dims.1);

    if let Ok(mut cache) = measure_cache().lock() {
        cache.insert(key, cells);
    }

    Some(cells)
}

pub fn measure_image(image: &Image) -> (u16, u16) {
    let source_hash = super::node::source_hash(&image.source);
    let natural = source_natural_size(&image.source, source_hash).unwrap_or((0, 0));

    let alt_w = image
        .alt
        .as_ref()
        .map(|alt| UnicodeWidthStr::width(alt.as_ref()).min(u16::MAX as usize) as u16)
        .unwrap_or(0);

    let w = match image.width {
        Length::Px(px) => px,
        _ => natural.0.max(alt_w),
    };
    let h = match image.height {
        Length::Px(px) => px,
        _ => {
            if natural.1 > 0 {
                natural.1
            } else if alt_w > 0 {
                1
            } else {
                0
            }
        }
    };

    (w, h)
}

#[cfg(test)]
mod tests {
    use std::io::Cursor;

    use image::{DynamicImage, ImageFormat, Rgba, RgbaImage};

    use super::measure_image;
    use crate::style::Length;
    use crate::widgets::Image;

    fn png_bytes(width: u32, height: u32) -> Vec<u8> {
        let rgba = RgbaImage::from_pixel(width, height, Rgba([0x22, 0x44, 0x88, 0xFF]));
        let image = DynamicImage::ImageRgba8(rgba);
        let mut out = Cursor::new(Vec::new());
        image
            .write_to(&mut out, ImageFormat::Png)
            .expect("png encoding should succeed");
        out.into_inner()
    }

    #[test]
    fn auto_size_uses_natural_dimensions() {
        let image = Image::from_bytes(png_bytes(9, 17));
        assert_eq!(measure_image(&image), (2, 2));
    }

    #[test]
    fn alt_text_measures_when_decode_fails() {
        let image = Image::from_bytes(vec![1, 2, 3]).alt("broken");
        assert_eq!(measure_image(&image), (6, 1));
    }

    #[test]
    fn fixed_px_overrides_natural_measurement() {
        let image = Image::from_bytes(png_bytes(9, 17))
            .width(Length::Px(10))
            .height(Length::Px(4));
        assert_eq!(measure_image(&image), (10, 4));
    }
}