device-envoy-core 0.1.0

Shared traits and data types for device-envoy platform crates
Documentation
#![allow(missing_docs)]
#![cfg(feature = "host")]

use device_envoy_core::led2d::{Frame2d, Led2dFont, render_text_to_frame};
use png::{BitDepth, ColorType, Decoder, Encoder};
use smart_leds::{RGB8, colors};
use std::fs::File;
use std::io::{BufReader, BufWriter};
use std::path::{Path, PathBuf};

const REFERENCE_DIR: &str = "tests/data/text_render";

#[test]
fn font3x4_on_12x4_matches_reference() {
    run_render_test::<12, 4>(
        "font3x4_12x4",
        Led2dFont::Font3x4Trim,
        "RUST",
        &four_colors(),
    );
}

#[test]
fn font4x6_on_12x4_clips_bottom_matches_reference() {
    run_render_test::<12, 4>(
        "font4x6_12x4",
        Led2dFont::Font4x6,
        "RUST\ntwo",
        &four_colors(),
    );
}

#[test]
fn font6x10_on_24x16_clips_and_colors_cycle() {
    run_render_test::<24, 16>(
        "font6x10_24x16",
        Led2dFont::Font6x10,
        "Hello Rust\nWrap me",
        &[colors::CYAN, colors::MAGENTA],
    );
}

#[test]
fn font5x8_on_600x800_fibonacci() {
    run_render_test_heap::<800, 600>(
        "font5x8_600x800_fibonacci",
        Led2dFont::Font5x8,
        "1\n1\n2\n3\n5\n8\n13\n21\n34\n55\n89\n144\n233\n377\n610\n987\n1597\n2584\n4181\n6765",
        &[colors::GREEN, colors::YELLOW, colors::ORANGE],
    );
}

#[test]
fn font3x4_on_12x4_no_colors_defaults_to_white() {
    run_render_test::<12, 4>("font3x4_12x4_white", Led2dFont::Font3x4Trim, "RUST", &[]);
}

fn run_render_test<const W: usize, const H: usize>(
    name: &str,
    font: Led2dFont,
    text: &str,
    colors: &[RGB8],
) {
    let mut frame: Frame2d<W, H> = Frame2d::new();
    render_text_to_frame(&mut frame, &font.to_font(), text, colors, (0, 0));

    if let Some(dir) = generation_dir() {
        let output_path = dir.join(format!("{name}.png"));
        write_png(&frame, &output_path);
        println!("wrote {name} to {}", output_path.display());
        return;
    }

    let reference_path = Path::new(env!("CARGO_MANIFEST_DIR"))
        .join(REFERENCE_DIR)
        .join(format!("{name}.png"));
    let reference = read_png::<W, H>(&reference_path);
    assert_eq!(
        frame_pixels(&frame),
        reference,
        "rendered output for {name} did not match reference at {}",
        reference_path.display()
    );
}

#[expect(unsafe_code, reason = "heap allocation for large test frames")]
fn run_render_test_heap<const W: usize, const H: usize>(
    name: &str,
    font: Led2dFont,
    text: &str,
    colors: &[RGB8],
) {
    // Allocate on heap to handle large frames that would overflow the stack
    let frame_vec: Vec<RGB8> = vec![smart_leds::RGB8::default(); H * W];
    let mut frame_box = frame_vec.into_boxed_slice();
    let frame_ptr = frame_box.as_mut_ptr() as *mut [[RGB8; W]; H];
    let frame_ref: &mut Frame2d<W, H> = unsafe { &mut *(frame_ptr as *mut Frame2d<W, H>) };

    render_text_to_frame(frame_ref, &font.to_font(), text, colors, (0, 0));

    if let Some(dir) = generation_dir() {
        let output_path = dir.join(format!("{name}.png"));
        write_png(frame_ref, &output_path);
        println!("wrote {name} to {}", output_path.display());
        return;
    }

    let reference_path = Path::new(env!("CARGO_MANIFEST_DIR"))
        .join(REFERENCE_DIR)
        .join(format!("{name}.png"));
    let reference = read_png::<W, H>(&reference_path);
    assert_eq!(
        frame_pixels(frame_ref),
        reference,
        "rendered output for {name} did not match reference at {}",
        reference_path.display()
    );
}

fn generation_dir() -> Option<PathBuf> {
    let env_value = std::env::var("DEVICE_ENVOY_GENERATE_TEXT_PNGS").ok()?;
    let dir = if env_value.is_empty() {
        let mut path = std::env::temp_dir();
        path.push("device-envoy-text-pngs");
        path
    } else {
        PathBuf::from(env_value)
    };
    std::fs::create_dir_all(&dir).expect("failed to create PNG output directory");
    Some(dir)
}

fn write_png<const W: usize, const H: usize>(frame: &Frame2d<W, H>, path: &Path) {
    let file = File::create(path).expect("failed to create PNG file");
    let mut encoder = Encoder::new(BufWriter::new(file), W as u32, H as u32);
    encoder.set_color(ColorType::Rgb);
    encoder.set_depth(BitDepth::Eight);
    let mut writer = encoder.write_header().expect("failed to write PNG header");
    writer
        .write_image_data(&frame_pixels(frame))
        .expect("failed to write PNG data");
}

fn read_png<const W: usize, const H: usize>(path: &Path) -> Vec<u8> {
    let file =
        File::open(path).unwrap_or_else(|_| panic!("missing reference PNG at {}", path.display()));
    let decoder = Decoder::new(BufReader::new(file));
    let mut reader = decoder.read_info().expect("failed to read PNG");
    let output_buffer_size = reader
        .output_buffer_size()
        .expect("PNG output buffer size should be known after read_info");
    let mut buffer = vec![0; output_buffer_size];
    let info = reader
        .next_frame(&mut buffer)
        .expect("failed to decode PNG");
    assert_eq!(info.width, W as u32, "reference PNG width mismatch");
    assert_eq!(info.height, H as u32, "reference PNG height mismatch");
    buffer[..info.buffer_size()].to_vec()
}

fn frame_pixels<const W: usize, const H: usize>(frame: &Frame2d<W, H>) -> Vec<u8> {
    let mut bytes = Vec::with_capacity(H * W * 3);
    for row in 0..H {
        for col in 0..W {
            let pixel = frame.0[row][col];
            bytes.push(pixel.r);
            bytes.push(pixel.g);
            bytes.push(pixel.b);
        }
    }
    bytes
}

fn four_colors() -> [RGB8; 4] {
    [colors::RED, colors::GREEN, colors::BLUE, colors::YELLOW]
}