inkferro-core 0.1.0

Layout, text measurement, ANSI render, and frame-diff engine for inkferro — a Rust-backed, byte-for-byte drop-in for the ink terminal UI library.
Documentation
//! Box background-fill rendering — port of ink's `render-background.ts`.
//!
//! Paints a box node's `backgroundColor` as a rectangle FILL over the CONTENT
//! area (the region inside any border cells), row by row. The M1/M2 port only
//! colorized Text spans, so an oversized box (padding, or a sized box larger
//! than its text) left its non-text cells unfilled, diverging from ink which
//! paints the whole content rectangle.
//!
//! # Source (render-background.ts:5-50)
//! ```ts
//! if (!node.style.backgroundColor) return;
//! const width  = node.yogaNode!.getComputedWidth();
//! const height = node.yogaNode!.getComputedHeight();
//! const leftBorderWidth    = node.style.borderStyle && node.style.borderLeft   !== false ? 1 : 0;
//! const rightBorderWidth   = node.style.borderStyle && node.style.borderRight  !== false ? 1 : 0;
//! const topBorderHeight    = node.style.borderStyle && node.style.borderTop    !== false ? 1 : 0;
//! const bottomBorderHeight = node.style.borderStyle && node.style.borderBottom !== false ? 1 : 0;
//! const contentWidth  = width  - leftBorderWidth - rightBorderWidth;
//! const contentHeight = height - topBorderHeight - bottomBorderHeight;
//! if (!(contentWidth > 0 && contentHeight > 0)) return;
//! const backgroundLine = colorize(' '.repeat(contentWidth), node.style.backgroundColor, 'background');
//! for (let row = 0; row < contentHeight; row++) {
//!   output.write(x + leftBorderWidth, y + topBorderHeight + row, backgroundLine, {transformers: []});
//! }
//! ```
//!
//! # Border insets — shared formula
//! The per-edge inset (`borderStyle present && border{Edge} !== false ? 1 : 0`)
//! is byte-identical to [`Style::border_edges`] / `render_border`'s edge-show
//! logic (border.rs:187-190). Computing it inline here keeps the 1:1 mapping to
//! the ink source visible; both reduce to the same predicate.
//!
//! # Ordering (render-node-to-output.ts:163-164)
//! ink calls `renderBackground` THEN `renderBorder`; the walk mirrors that. The
//! fill covers the content area inside borders and the border draws the edges —
//! disjoint regions — but background-before-border is the defensive order: a
//! last-writer-wins grid lets the border correct any off-by-one inset. Children
//! (Text) recurse AFTER both, so for a content-sized box the text fully overwrites
//! the fill and the frame is unchanged; the fill is visible only in padding /
//! oversized cells.

use crate::dom::Style;
use crate::render::colorize::{ColorLevel, Kind, colorize};
use crate::render::grid::Grid;

/// Draw a box node's `backgroundColor` rectangle FILL into `grid`.
///
/// `(x, y)` is the box top-left (absolute grid coords); `width` / `height` are
/// the full computed dimensions INCLUDING border cells. No-op when the node has
/// no `backgroundColor`, or when borders consume the entire content area
/// (`contentWidth <= 0 || contentHeight <= 0`). Port of render-background.ts.
///
/// `level` is the detected color level: at [`ColorLevel::None`] the fill line's
/// `colorize` is a no-op, so the spaces carry no SGR (they then trim away as
/// blank pad) — matching ink/chalk in a non-color terminal.
pub fn render_background(
    x: i32,
    y: i32,
    width: u16,
    height: u16,
    style: &Style,
    grid: &mut Grid,
    level: ColorLevel,
) {
    // render-background.ts:11-13: absent backgroundColor → no-op.
    let Some(bg) = style.background_color.as_deref() else {
        return;
    };

    // render-background.ts:19-26: per-edge border insets — a border cell is
    // excluded so the FILL covers the content area inside borders. Mirrors the
    // edge-show predicate in border.rs:187-190 (`borderStyle present &&
    // border{Edge} != false`).
    let has_border = style.border_style.is_some();
    let left = i32::from(has_border && style.border_left != Some(false));
    let right = i32::from(has_border && style.border_right != Some(false));
    let top = i32::from(has_border && style.border_top != Some(false));
    let bottom = i32::from(has_border && style.border_bottom != Some(false));

    // render-background.ts:28-29.
    let content_width = width as i32 - left - right;
    let content_height = height as i32 - top - bottom;

    // render-background.ts:31-33: nothing to fill if either dimension collapses.
    if content_width <= 0 || content_height <= 0 {
        return;
    }

    // render-background.ts:36-40: one colorized line of spaces (fg=None, Kind::Bg),
    // the same `colorize(seg, bg, Kind::Bg)` pass border.rs's stylePiece uses.
    let bg_line = colorize(
        &" ".repeat(content_width as usize),
        Some(bg),
        Kind::Bg,
        level,
    );

    // render-background.ts:42-49: write the fill into each content row.
    for row in 0..content_height {
        grid.write(x + left, y + top + row, &bg_line);
    }
}

// ─── Tests ───────────────────────────────────────────────────────────────────

#[cfg(test)]
mod tests {
    use super::*;
    use crate::dom::{BorderStyle, Style};
    use crate::render::border::render_border;
    use crate::render::grid::{Clip, Grid};

    // Oversized box, NO border: a 4-wide × 2-tall box with backgroundColor=red
    // and no text fills the whole 4×2 rectangle with red-bg spaces. The fill line
    // is `colorize("    ", Some("red"), Kind::Bg)` → `\x1b[41m    \x1b[49m`; the
    // grid serializes each row as that single closed span (trailing-space trim
    // does not fire — the spaces are inside the SGR pair, not unstyled pad).
    #[test]
    fn box_background_fills_content_rect() {
        let style = Style {
            background_color: Some("red".to_owned()),
            ..Style::default()
        };
        let mut g = Grid::new(2, 4);
        render_background(0, 0, 4, 2, &style, &mut g, ColorLevel::Truecolor);
        let out = g.get().0;
        // Two identical rows of a red-bg 4-space span.
        assert_eq!(out, "\x1b[41m    \x1b[49m\n\x1b[41m    \x1b[49m");
    }

    // Box + round border + bg (the inset canary): a 5×3 box with a round border
    // and backgroundColor=blue. contentWidth = 5-1-1 = 3, contentHeight = 3-1-1
    // = 1 → the fill is ONE blue-bg row of 3 spaces at (x+1, y+1). render_border
    // then draws the round edges. The border cells carry the box chars (NO blue
    // bg); only the single interior row's middle three cells carry the blue fill.
    // Call order matches walk.rs: background THEN border.
    #[test]
    fn box_background_inside_border() {
        let style = Style {
            border_style: Some(BorderStyle::Named("round".to_owned())),
            background_color: Some("blue".to_owned()),
            ..Style::default()
        };
        let mut g = Grid::new(3, 5);
        render_background(0, 0, 5, 3, &style, &mut g, ColorLevel::Truecolor);
        render_border(0, 0, 5, 3, &style, &mut g, ColorLevel::Truecolor);
        let out = g.get().0;
        // Row 0: round top `╭───╮` (no bg). Row 1: `│` + blue-bg 3 spaces + `│`.
        // Row 2: round bottom `╰───╯` (no bg).
        assert_eq!(out, "╭───╮\n\x1b[44m   \x1b[49m│\n╰───╯");
        // The interior fill is blue bg (44/49), NOT on the border cells.
        let row1 = out.lines().nth(1).unwrap();
        assert!(
            row1.contains("\x1b[44m"),
            "interior fill carries blue bg open"
        );
        assert!(
            row1.starts_with(''),
            "left border cell has no bg, just `│`"
        );
        assert!(row1.ends_with(''), "right border cell has no bg, just `│`");
    }

    // Degenerate: a 2×2 box whose border insets consume the entire content area
    // (contentWidth = 2-1-1 = 0) → render_background writes NOTHING. The grid
    // stays blank (all rows trim to empty). Pins the `content_width <= 0` guard.
    #[test]
    fn box_background_zero_content_noop() {
        let style = Style {
            border_style: Some(BorderStyle::Named("single".to_owned())),
            background_color: Some("red".to_owned()),
            ..Style::default()
        };
        let mut g = Grid::new(2, 2);
        render_background(0, 0, 2, 2, &style, &mut g, ColorLevel::Truecolor);
        let out = g.get().0;
        // No fill written → two all-space rows → both trim to empty → "\n".
        assert_eq!(out, "\n");
        assert!(
            !out.contains('\x1b'),
            "zero-content fill must emit no SGR bytes"
        );
    }

    // #78(a) — clip+fill differential. The prior #76 review covered the
    // fill/clip interaction only by a CODE-ORDERING argument (render_background
    // writes via grid.write, which applies the active clip). This pins it
    // EMPIRICALLY against the ink oracle.
    //
    // Tree: an outer `<Box overflow="hidden" width=3 height=2>` wrapping an inner
    // `<Box backgroundColor="red" width=5 height=4>` (the inner FILL OVERFLOWS the
    // outer on BOTH axes). In walk.rs the outer pushes its clip BEFORE recursing,
    // so the inner box's render_background runs INSIDE that clip. The clip coords
    // are exactly what walk.rs:291-302 computes for a borderless overflow:hidden
    // 3×2 box: x1=0, x2=0+3-0=3, y1=0, y2=0+2-0=2.
    //
    // Oracle ground truth (ink 7.0.5, chalk.level=3):
    //   renderToString(<Box overflow="hidden" w=3 h=2>
    //                    <Box backgroundColor="red" w=5 h=4/></Box>)
    //   === "\x1b[41m   \x1b[49m\n\x1b[41m   \x1b[49m"
    // i.e. the 5×4 red fill is SLICED to the 3×2 visible region: each surviving
    // row is a red-bg 3-space span; the 2 overflow columns and the 2 overflow rows
    // are dropped.
    //
    // Discriminating defect: "render_background bypasses the clip stack → the fill
    // spills past the overflow:hidden region." If render_background wrote outside
    // the clip, each row would be 5 wide and there would be 4 rows. The grid is
    // sized 4×5 (LARGER than the clip) so the slicing is the CLIP's doing, not
    // grid-bounds truncation — verified by the mutation check (drop push_clip →
    // "\x1b[41m     \x1b[49m" × 4 rows, the unclipped 5×4 fill).
    #[test]
    fn box_background_fill_is_clipped() {
        let style = Style {
            background_color: Some("red".to_owned()),
            ..Style::default()
        };
        // Grid wide+tall enough to hold the FULL unclipped 5×4 inner fill, so any
        // truncation seen is the clip's, not the grid's.
        let mut g = Grid::new(4, 5);
        // The outer overflow:hidden 3×2 (no border) clip, per walk.rs:291-302.
        g.push_clip(Clip {
            x1: Some(0),
            x2: Some(3),
            y1: Some(0),
            y2: Some(2),
        });
        // Inner box fill: 5 wide × 4 tall — overflows the clip on both axes.
        render_background(0, 0, 5, 4, &style, &mut g, ColorLevel::Truecolor);
        g.pop_clip();
        let out = g.get().0;
        // The grid is 4 rows; the vertical clip (y2=2) drops the fill from rows
        // 2-3, which serialize as two trailing blank rows (`\n\n`) — exactly as the
        // existing `box_background_zero_content_noop` pins its blank grid. The two
        // SURVIVING rows are byte-identical to the ink oracle (the load-bearing
        // claim): each red fill is sliced 5→3 wide by the horizontal clip (x2=3).
        assert_eq!(out, "\x1b[41m   \x1b[49m\n\x1b[41m   \x1b[49m\n\n");
        // Oracle-exact: the visible region (trailing clipped-blank rows stripped)
        // matches ink's renderToString byte-for-byte.
        assert_eq!(
            out.trim_end_matches('\n'),
            "\x1b[41m   \x1b[49m\n\x1b[41m   \x1b[49m"
        );
        // Both surviving rows are 3-wide (sliced from 5), proving the clip — NOT
        // the grid (which is 5 wide) — bounded the fill. Under the mutation that
        // drops push_clip each row is the unclipped 5-space span `\x1b[41m     \x1b[49m`.
        for row in out.lines().filter(|r| !r.is_empty()) {
            assert_eq!(
                row, "\x1b[41m   \x1b[49m",
                "each surviving fill row is clipped to 3 cells"
            );
        }
    }

    // #78(b) — partial-border inset. A `<Box backgroundColor="blue"
    // borderStyle="single" borderTop={false} width=6 height=4>`. With borderTop
    // SUPPRESSED the top inset is 0 (render-background.ts:23-24:
    // `borderStyle && borderTop !== false ? 1 : 0`), so the fill's FIRST content
    // row sits at y+0 — where the (absent) top border would be — and the fill is
    // contentWidth = 6-1-1 = 4 by contentHeight = 4-0-1 = 3. render_border then
    // draws the left/right/bottom edges (NO top), matching the oracle.
    //
    // Oracle ground truth (ink 7.0.5, chalk.level=3):
    //   renderToString(<Box backgroundColor="blue" borderStyle="single"
    //                       borderTop={false} width=6 height=4/>)
    //   === "│\x1b[44m    \x1b[49m│\n│\x1b[44m    \x1b[49m│\n│\x1b[44m    \x1b[49m│\n└────┘"
    // Rows 0-2: `│` + blue-bg 4 spaces + `│`. Row 3: bottom border `└────┘`.
    //
    // Discriminating defect: "top inset stuck at 1 despite borderTop=false." That
    // mutation makes content_height 2 and starts the fill at y+1, so the TOP row
    // (row 0) loses its blue fill and the fill stops one row short — the full
    // string assert goes red. Verified by mutation check (force top=1 → row 0 is
    // `│    │` with no SGR, the fill shifts down, assert fails).
    #[test]
    fn box_background_fill_partial_border_inset() {
        let style = Style {
            border_style: Some(BorderStyle::Named("single".to_owned())),
            border_top: Some(false),
            background_color: Some("blue".to_owned()),
            ..Style::default()
        };
        let mut g = Grid::new(4, 6);
        // Call order matches walk.rs: background THEN border.
        render_background(0, 0, 6, 4, &style, &mut g, ColorLevel::Truecolor);
        render_border(0, 0, 6, 4, &style, &mut g, ColorLevel::Truecolor);
        let out = g.get().0;
        assert_eq!(
            out,
            "\x1b[44m    \x1b[49m│\n\x1b[44m    \x1b[49m│\n\x1b[44m    \x1b[49m│\n└────┘"
        );
        // The TOP content row (row 0) carries the blue fill — proves top inset is 0.
        let row0 = out.lines().next().unwrap();
        assert!(
            row0.contains("\x1b[44m"),
            "row 0 carries the blue fill (borderTop=false → top inset 0)"
        );
        assert!(
            row0.starts_with('') && row0.ends_with(''),
            "row 0 has side borders but NO top edge"
        );
    }
}