localharness 0.23.0

A Rust-native agent SDK with pluggable LLM backends (Gemini today). Streaming, custom tools, safety policies, background triggers — zero external binaries.
Documentation
//! Pure, native-testable framebuffer rasterization with a viewport — the
//! geometry foundation for `host::compose` (roadmap Phase 0a, `design/host-
//! compose.md`).
//!
//! A [`Viewport`] offsets and clips a cartridge's draw calls into a sub-
//! rectangle of the shared RGBA framebuffer. The single-cartridge path uses
//! [`Viewport::full`] — an identity transform that reproduces the pre-refactor
//! behavior byte-for-byte. Because these functions operate on a plain `&mut
//! [u8]` (not a web-sys canvas), they are unit-tested natively here — closing
//! the gap the design flagged, where the wasm-only display closures can't be
//! exercised by `cargo test`. `src/app/display.rs` calls into these from its
//! host-import closures.
//!
//! Scope note (Phase 0a): `clear` / `set_pixel` / `fill_rect` are wired here;
//! glyph blitting (`draw_char`/`draw_number`) and the present-ownership
//! inversion are deferred to the follow-up, which lands them under a real
//! wasm-instantiation render test (the part a pure unit test can't prove).

/// A sub-rectangle of the shared framebuffer a cartridge draws into. Child-
/// local coordinates are translated by `(ox, oy)` and clipped to
/// `[0, w) x [0, h)` (the viewport) and then to the framebuffer bounds.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub struct Viewport {
    pub ox: i32,
    pub oy: i32,
    pub w: i32,
    pub h: i32,
}

impl Viewport {
    /// The whole framebuffer — the identity transform (single-cartridge path).
    pub fn full(fb_w: i32, fb_h: i32) -> Self {
        Self { ox: 0, oy: 0, w: fb_w, h: fb_h }
    }
}

/// Write one opaque RGBA pixel at child-local `(x, y)`, translated and clipped
/// by `vp` and the framebuffer width. No-op if out of the viewport or buffer.
#[inline]
pub fn set_pixel(buf: &mut [u8], fb_w: i32, vp: &Viewport, x: i32, y: i32, rgb: (u8, u8, u8)) {
    if x < 0 || y < 0 || x >= vp.w || y >= vp.h {
        return;
    }
    let gx = vp.ox + x;
    let gy = vp.oy + y;
    if gx < 0 || gy < 0 || gx >= fb_w {
        return;
    }
    let idx = ((gy as usize) * (fb_w as usize) + gx as usize) * 4;
    if idx + 3 >= buf.len() {
        return;
    }
    buf[idx] = rgb.0;
    buf[idx + 1] = rgb.1;
    buf[idx + 2] = rgb.2;
    buf[idx + 3] = 255;
}

/// Fill a child-local rectangle, clipped to the viewport. Routes every pixel
/// through [`set_pixel`] so translation/clipping stay consistent.
#[allow(clippy::too_many_arguments)] // low-level raster primitive: fb + viewport + rect + color
pub fn fill_rect(
    buf: &mut [u8],
    fb_w: i32,
    vp: &Viewport,
    x: i32,
    y: i32,
    w: i32,
    h: i32,
    rgb: (u8, u8, u8),
) {
    let x0 = x.max(0);
    let y0 = y.max(0);
    let x1 = x.saturating_add(w).min(vp.w);
    let y1 = y.saturating_add(h).min(vp.h);
    let mut yy = y0;
    while yy < y1 {
        let mut xx = x0;
        while xx < x1 {
            set_pixel(buf, fb_w, vp, xx, yy, rgb);
            xx += 1;
        }
        yy += 1;
    }
}

/// Clear the viewport (not the whole framebuffer — that distinction is what
/// lets multiple modules share one canvas). For [`Viewport::full`] this fills
/// the entire framebuffer, exactly as the old whole-buffer clear did.
pub fn clear(buf: &mut [u8], fb_w: i32, vp: &Viewport, rgb: (u8, u8, u8)) {
    fill_rect(buf, fb_w, vp, 0, 0, vp.w, vp.h, rgb);
}

/// Blit a single 5x7 glyph at child-local `(x, y)`, integer-scaled, every lit
/// pixel routed through [`set_pixel`] (so it translates+clips by the viewport).
#[allow(clippy::too_many_arguments)] // raster primitive: fb + viewport + pos + glyph + color + scale
pub fn blit_glyph(
    buf: &mut [u8],
    fb_w: i32,
    vp: &Viewport,
    x: i32,
    y: i32,
    code: u32,
    color: (u8, u8, u8),
    scale: i32,
) {
    let glyph = glyph_5x7(code);
    let scale = scale.max(1);
    for (row, bits) in glyph.iter().enumerate() {
        for col in 0..5i32 {
            if (bits >> (4 - col)) & 1 == 0 {
                continue;
            }
            for dy in 0..scale {
                for dx in 0..scale {
                    set_pixel(buf, fb_w, vp, x + col * scale + dx, y + row as i32 * scale + dy, color);
                }
            }
        }
    }
}

/// Draw a base-10 signed integer at child-local `(x, y)`, integer-scaled, via
/// [`blit_glyph`] (so it honors the viewport). Advance is 6px per glyph scaled.
#[allow(clippy::too_many_arguments)] // raster primitive: fb + viewport + pos + value + color + scale
pub fn draw_number(
    buf: &mut [u8],
    fb_w: i32,
    vp: &Viewport,
    x: i32,
    y: i32,
    value: i32,
    color: (u8, u8, u8),
    scale: i32,
) {
    let s = scale.max(1);
    let advance = 6 * s; // 5px glyph + 1px gap, scaled
    let mut cx = x;
    let mut n = (value as i64).unsigned_abs();
    if value < 0 {
        blit_glyph(buf, fb_w, vp, cx, y, '-' as u32, color, s);
        cx += advance;
    }
    // Collect digits (least-significant first), then draw reversed.
    let mut digits = [0u8; 20];
    let mut count = 0;
    if n == 0 {
        digits[0] = b'0';
        count = 1;
    } else {
        while n > 0 {
            digits[count] = b'0' + (n % 10) as u8;
            n /= 10;
            count += 1;
        }
    }
    for i in (0..count).rev() {
        blit_glyph(buf, fb_w, vp, cx, y, digits[i] as u32, color, s);
        cx += advance;
    }
}

/// 5x7 bitmap font. Each row's low 5 bits are pixels (bit 4 = leftmost).
/// Covers digits, A-Z, a-z, space, and common punctuation; unknown codes
/// render as a hollow box. Hand-encoded (no font dep).
pub fn glyph_5x7(c: u32) -> [u8; 7] {
    match c {
        0x20 => [0, 0, 0, 0, 0, 0, 0],                       // space
        0x30 => [0x0E, 0x11, 0x13, 0x15, 0x19, 0x11, 0x0E],  // 0
        0x31 => [0x04, 0x0C, 0x04, 0x04, 0x04, 0x04, 0x0E],  // 1
        0x32 => [0x0E, 0x11, 0x01, 0x02, 0x04, 0x08, 0x1F],  // 2
        0x33 => [0x1E, 0x01, 0x01, 0x0E, 0x01, 0x01, 0x1E],  // 3
        0x34 => [0x02, 0x06, 0x0A, 0x12, 0x1F, 0x02, 0x02],  // 4
        0x35 => [0x1F, 0x10, 0x1E, 0x01, 0x01, 0x11, 0x0E],  // 5
        0x36 => [0x0E, 0x10, 0x10, 0x1E, 0x11, 0x11, 0x0E],  // 6
        0x37 => [0x1F, 0x01, 0x02, 0x04, 0x08, 0x08, 0x08],  // 7
        0x38 => [0x0E, 0x11, 0x11, 0x0E, 0x11, 0x11, 0x0E],  // 8
        0x39 => [0x0E, 0x11, 0x11, 0x0F, 0x01, 0x01, 0x0E],  // 9
        0x21 => [0x04, 0x04, 0x04, 0x04, 0x04, 0x00, 0x04],  // !
        0x22 => [0x0A, 0x0A, 0x0A, 0x00, 0x00, 0x00, 0x00],  // "
        0x23 => [0x0A, 0x0A, 0x1F, 0x0A, 0x1F, 0x0A, 0x0A],  // #
        0x25 => [0x18, 0x19, 0x02, 0x04, 0x08, 0x13, 0x03],  // %
        0x26 => [0x0C, 0x12, 0x14, 0x08, 0x15, 0x12, 0x0D],  // &
        0x27 => [0x04, 0x04, 0x08, 0x00, 0x00, 0x00, 0x00],  // '
        0x28 => [0x04, 0x08, 0x10, 0x10, 0x10, 0x08, 0x04],  // (
        0x29 => [0x04, 0x02, 0x01, 0x01, 0x01, 0x02, 0x04],  // )
        0x2A => [0x00, 0x04, 0x15, 0x0E, 0x15, 0x04, 0x00],  // *
        0x2B => [0x00, 0x04, 0x04, 0x1F, 0x04, 0x04, 0x00],  // +
        0x2C => [0x00, 0x00, 0x00, 0x00, 0x06, 0x04, 0x08],  // ,
        0x2D => [0x00, 0x00, 0x00, 0x1F, 0x00, 0x00, 0x00],  // -
        0x2E => [0x00, 0x00, 0x00, 0x00, 0x00, 0x06, 0x06],  // .
        0x2F => [0x01, 0x01, 0x02, 0x04, 0x08, 0x10, 0x10],  // /
        0x3A => [0x00, 0x06, 0x06, 0x00, 0x06, 0x06, 0x00],  // :
        0x3B => [0x00, 0x06, 0x06, 0x00, 0x06, 0x04, 0x08],  // ;
        0x3C => [0x02, 0x04, 0x08, 0x10, 0x08, 0x04, 0x02],  // <
        0x3D => [0x00, 0x00, 0x1F, 0x00, 0x1F, 0x00, 0x00],  // =
        0x3E => [0x08, 0x04, 0x02, 0x01, 0x02, 0x04, 0x08],  // >
        0x3F => [0x0E, 0x11, 0x01, 0x02, 0x04, 0x00, 0x04],  // ?
        0x40 => [0x0E, 0x11, 0x17, 0x15, 0x17, 0x10, 0x0E],  // @
        0x5B => [0x0E, 0x08, 0x08, 0x08, 0x08, 0x08, 0x0E],  // [
        0x5D => [0x0E, 0x02, 0x02, 0x02, 0x02, 0x02, 0x0E],  // ]
        0x5F => [0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x1F],  // _
        0x41 => [0x0E, 0x11, 0x11, 0x1F, 0x11, 0x11, 0x11],  // A
        0x42 => [0x1E, 0x11, 0x11, 0x1E, 0x11, 0x11, 0x1E],  // B
        0x43 => [0x0E, 0x11, 0x10, 0x10, 0x10, 0x11, 0x0E],  // C
        0x44 => [0x1E, 0x11, 0x11, 0x11, 0x11, 0x11, 0x1E],  // D
        0x45 => [0x1F, 0x10, 0x10, 0x1E, 0x10, 0x10, 0x1F],  // E
        0x46 => [0x1F, 0x10, 0x10, 0x1E, 0x10, 0x10, 0x10],  // F
        0x47 => [0x0E, 0x11, 0x10, 0x17, 0x11, 0x11, 0x0E],  // G
        0x48 => [0x11, 0x11, 0x11, 0x1F, 0x11, 0x11, 0x11],  // H
        0x49 => [0x0E, 0x04, 0x04, 0x04, 0x04, 0x04, 0x0E],  // I
        0x4A => [0x07, 0x02, 0x02, 0x02, 0x12, 0x12, 0x0C],  // J
        0x4B => [0x11, 0x12, 0x14, 0x18, 0x14, 0x12, 0x11],  // K
        0x4C => [0x10, 0x10, 0x10, 0x10, 0x10, 0x10, 0x1F],  // L
        0x4D => [0x11, 0x1B, 0x15, 0x15, 0x11, 0x11, 0x11],  // M
        0x4E => [0x11, 0x11, 0x19, 0x15, 0x13, 0x11, 0x11],  // N
        0x4F => [0x0E, 0x11, 0x11, 0x11, 0x11, 0x11, 0x0E],  // O
        0x50 => [0x1E, 0x11, 0x11, 0x1E, 0x10, 0x10, 0x10],  // P
        0x51 => [0x0E, 0x11, 0x11, 0x11, 0x15, 0x12, 0x0D],  // Q
        0x52 => [0x1E, 0x11, 0x11, 0x1E, 0x14, 0x12, 0x11],  // R
        0x53 => [0x0F, 0x10, 0x10, 0x0E, 0x01, 0x01, 0x1E],  // S
        0x54 => [0x1F, 0x04, 0x04, 0x04, 0x04, 0x04, 0x04],  // T
        0x55 => [0x11, 0x11, 0x11, 0x11, 0x11, 0x11, 0x0E],  // U
        0x56 => [0x11, 0x11, 0x11, 0x11, 0x11, 0x0A, 0x04],  // V
        0x57 => [0x11, 0x11, 0x11, 0x15, 0x15, 0x1B, 0x11],  // W
        0x58 => [0x11, 0x11, 0x0A, 0x04, 0x0A, 0x11, 0x11],  // X
        0x59 => [0x11, 0x11, 0x0A, 0x04, 0x04, 0x04, 0x04],  // Y
        0x5A => [0x1F, 0x01, 0x02, 0x04, 0x08, 0x10, 0x1F],  // Z
        0x61 => [0x00, 0x00, 0x0E, 0x01, 0x0F, 0x11, 0x0F],  // a
        0x62 => [0x10, 0x10, 0x16, 0x19, 0x11, 0x11, 0x1E],  // b
        0x63 => [0x00, 0x00, 0x0E, 0x10, 0x10, 0x11, 0x0E],  // c
        0x64 => [0x01, 0x01, 0x0D, 0x13, 0x11, 0x11, 0x0F],  // d
        0x65 => [0x00, 0x00, 0x0E, 0x11, 0x1F, 0x10, 0x0E],  // e
        0x66 => [0x06, 0x09, 0x08, 0x1C, 0x08, 0x08, 0x08],  // f
        0x67 => [0x00, 0x0F, 0x11, 0x11, 0x0F, 0x01, 0x0E],  // g
        0x68 => [0x10, 0x10, 0x16, 0x19, 0x11, 0x11, 0x11],  // h
        0x69 => [0x04, 0x00, 0x0C, 0x04, 0x04, 0x04, 0x0E],  // i
        0x6A => [0x02, 0x00, 0x06, 0x02, 0x02, 0x12, 0x0C],  // j
        0x6B => [0x10, 0x10, 0x12, 0x14, 0x18, 0x14, 0x12],  // k
        0x6C => [0x0C, 0x04, 0x04, 0x04, 0x04, 0x04, 0x0E],  // l
        0x6D => [0x00, 0x00, 0x1A, 0x15, 0x15, 0x11, 0x11],  // m
        0x6E => [0x00, 0x00, 0x16, 0x19, 0x11, 0x11, 0x11],  // n
        0x6F => [0x00, 0x00, 0x0E, 0x11, 0x11, 0x11, 0x0E],  // o
        0x70 => [0x00, 0x1E, 0x11, 0x11, 0x1E, 0x10, 0x10],  // p
        0x71 => [0x00, 0x0F, 0x11, 0x11, 0x0F, 0x01, 0x01],  // q
        0x72 => [0x00, 0x00, 0x16, 0x19, 0x10, 0x10, 0x10],  // r
        0x73 => [0x00, 0x00, 0x0F, 0x10, 0x0E, 0x01, 0x1E],  // s
        0x74 => [0x08, 0x08, 0x1C, 0x08, 0x08, 0x09, 0x06],  // t
        0x75 => [0x00, 0x00, 0x11, 0x11, 0x11, 0x13, 0x0D],  // u
        0x76 => [0x00, 0x00, 0x11, 0x11, 0x11, 0x0A, 0x04],  // v
        0x77 => [0x00, 0x00, 0x11, 0x11, 0x15, 0x15, 0x0A],  // w
        0x78 => [0x00, 0x00, 0x11, 0x0A, 0x04, 0x0A, 0x11],  // x
        0x79 => [0x00, 0x11, 0x11, 0x11, 0x0F, 0x01, 0x0E],  // y
        0x7A => [0x00, 0x00, 0x1F, 0x02, 0x04, 0x08, 0x1F],  // z
        _ => [0x1F, 0x11, 0x11, 0x11, 0x11, 0x11, 0x1F],     // unknown -> box
    }
}

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

    fn fb(w: i32, h: i32) -> Vec<u8> {
        vec![0u8; (w * h * 4) as usize]
    }

    #[test]
    fn identity_viewport_set_pixel_matches_direct_index() {
        let (w, h) = (256, 144);
        let mut buf = fb(w, h);
        let vp = Viewport::full(w, h);
        set_pixel(&mut buf, w, &vp, 10, 20, (1, 2, 3));
        let idx = ((20 * w + 10) * 4) as usize;
        assert_eq!(&buf[idx..idx + 4], &[1, 2, 3, 255]);
    }

    #[test]
    fn offset_viewport_translates_into_subrect() {
        let (w, h) = (256, 144);
        let mut buf = fb(w, h);
        let vp = Viewport { ox: 100, oy: 50, w: 64, h: 32 };
        set_pixel(&mut buf, w, &vp, 5, 5, (9, 9, 9));
        let idx = (((50 + 5) * w + (100 + 5)) * 4) as usize;
        assert_eq!(&buf[idx..idx + 4], &[9, 9, 9, 255]);
    }

    #[test]
    fn viewport_clips_child_coords_outside_its_bounds() {
        let (w, h) = (256, 144);
        let mut buf = fb(w, h);
        let vp = Viewport { ox: 100, oy: 50, w: 64, h: 32 };
        set_pixel(&mut buf, w, &vp, 64, 0, (9, 9, 9)); // x == vp.w → clipped
        set_pixel(&mut buf, w, &vp, -1, 0, (9, 9, 9)); // x < 0 → clipped
        set_pixel(&mut buf, w, &vp, 0, 32, (9, 9, 9)); // y == vp.h → clipped
        assert!(buf.iter().all(|&b| b == 0), "no pixel should have been written");
    }

    #[test]
    fn full_viewport_clear_fills_entire_framebuffer() {
        let (w, h) = (8, 4);
        let mut buf = fb(w, h);
        clear(&mut buf, w, &Viewport::full(w, h), (5, 5, 5));
        for px in buf.chunks(4) {
            assert_eq!(px, &[5, 5, 5, 255]);
        }
    }

    #[test]
    fn blit_glyph_identity_renders_lit_pixels_in_place() {
        let (w, h) = (32, 16);
        let mut buf = fb(w, h);
        // '-' (0x2D) lights all 5 pixels of row 3 only.
        blit_glyph(&mut buf, w, &Viewport::full(w, h), 0, 0, 0x2D, (9, 9, 9), 1);
        for col in 0..5 {
            let idx = (((3 * w) + col) * 4) as usize;
            assert_eq!(&buf[idx..idx + 4], &[9, 9, 9, 255], "row 3 col {col} lit");
        }
        assert_eq!(&buf[0..4], &[0, 0, 0, 0], "row 0 is blank for '-'");
    }

    #[test]
    fn blit_glyph_offset_viewport_translates_glyph() {
        let (w, h) = (64, 64);
        let mut buf = fb(w, h);
        let vp = Viewport { ox: 10, oy: 20, w: 16, h: 16 };
        blit_glyph(&mut buf, w, &vp, 0, 0, 0x2D, (1, 2, 3), 1);
        let idx = (((23 * w) + 10) * 4) as usize; // glyph row 3 → global y=23, x=10
        assert_eq!(&buf[idx..idx + 4], &[1, 2, 3, 255]);
    }

    #[test]
    fn draw_number_negative_blits_minus_then_digit() {
        let (w, h) = (64, 16);
        let mut buf = fb(w, h);
        draw_number(&mut buf, w, &Viewport::full(w, h), 0, 0, -5, (5, 5, 5), 1);
        let minus = ((3 * w) * 4) as usize; // '-' row 3, col 0
        assert_eq!(&buf[minus..minus + 4], &[5, 5, 5, 255]);
        let five_top = (6 * 4) as usize; // '5' starts at advance=6, row 0 col 6 lit
        assert_eq!(&buf[five_top..five_top + 4], &[5, 5, 5, 255]);
    }

    #[test]
    fn offset_clear_stays_inside_viewport() {
        let (w, h) = (16, 16);
        let mut buf = fb(w, h);
        let vp = Viewport { ox: 4, oy: 4, w: 4, h: 4 };
        clear(&mut buf, w, &vp, (7, 7, 7)); // child clears its whole surface
        let inside = (((4 * w) + 4) * 4) as usize;
        assert_eq!(&buf[inside..inside + 4], &[7, 7, 7, 255]);
        assert_eq!(&buf[0..4], &[0, 0, 0, 0], "origin is outside the viewport");
        let past = (((8 * w) + 8) * 4) as usize;
        assert_eq!(&buf[past..past + 4], &[0, 0, 0, 0], "just past the viewport");
    }
}