Skip to main content

inkferro_core/render/
background.rs

1//! Box background-fill rendering — port of ink's `render-background.ts`.
2//!
3//! Paints a box node's `backgroundColor` as a rectangle FILL over the CONTENT
4//! area (the region inside any border cells), row by row. The M1/M2 port only
5//! colorized Text spans, so an oversized box (padding, or a sized box larger
6//! than its text) left its non-text cells unfilled, diverging from ink which
7//! paints the whole content rectangle.
8//!
9//! # Source (render-background.ts:5-50)
10//! ```ts
11//! if (!node.style.backgroundColor) return;
12//! const width  = node.yogaNode!.getComputedWidth();
13//! const height = node.yogaNode!.getComputedHeight();
14//! const leftBorderWidth    = node.style.borderStyle && node.style.borderLeft   !== false ? 1 : 0;
15//! const rightBorderWidth   = node.style.borderStyle && node.style.borderRight  !== false ? 1 : 0;
16//! const topBorderHeight    = node.style.borderStyle && node.style.borderTop    !== false ? 1 : 0;
17//! const bottomBorderHeight = node.style.borderStyle && node.style.borderBottom !== false ? 1 : 0;
18//! const contentWidth  = width  - leftBorderWidth - rightBorderWidth;
19//! const contentHeight = height - topBorderHeight - bottomBorderHeight;
20//! if (!(contentWidth > 0 && contentHeight > 0)) return;
21//! const backgroundLine = colorize(' '.repeat(contentWidth), node.style.backgroundColor, 'background');
22//! for (let row = 0; row < contentHeight; row++) {
23//!   output.write(x + leftBorderWidth, y + topBorderHeight + row, backgroundLine, {transformers: []});
24//! }
25//! ```
26//!
27//! # Border insets — shared formula
28//! The per-edge inset (`borderStyle present && border{Edge} !== false ? 1 : 0`)
29//! is byte-identical to [`Style::border_edges`] / `render_border`'s edge-show
30//! logic (border.rs:187-190). Computing it inline here keeps the 1:1 mapping to
31//! the ink source visible; both reduce to the same predicate.
32//!
33//! # Ordering (render-node-to-output.ts:163-164)
34//! ink calls `renderBackground` THEN `renderBorder`; the walk mirrors that. The
35//! fill covers the content area inside borders and the border draws the edges —
36//! disjoint regions — but background-before-border is the defensive order: a
37//! last-writer-wins grid lets the border correct any off-by-one inset. Children
38//! (Text) recurse AFTER both, so for a content-sized box the text fully overwrites
39//! the fill and the frame is unchanged; the fill is visible only in padding /
40//! oversized cells.
41
42use crate::dom::Style;
43use crate::render::colorize::{ColorLevel, Kind, colorize};
44use crate::render::grid::Grid;
45
46/// Draw a box node's `backgroundColor` rectangle FILL into `grid`.
47///
48/// `(x, y)` is the box top-left (absolute grid coords); `width` / `height` are
49/// the full computed dimensions INCLUDING border cells. No-op when the node has
50/// no `backgroundColor`, or when borders consume the entire content area
51/// (`contentWidth <= 0 || contentHeight <= 0`). Port of render-background.ts.
52///
53/// `level` is the detected color level: at [`ColorLevel::None`] the fill line's
54/// `colorize` is a no-op, so the spaces carry no SGR (they then trim away as
55/// blank pad) — matching ink/chalk in a non-color terminal.
56pub fn render_background(
57    x: i32,
58    y: i32,
59    width: u16,
60    height: u16,
61    style: &Style,
62    grid: &mut Grid,
63    level: ColorLevel,
64) {
65    // render-background.ts:11-13: absent backgroundColor → no-op.
66    let Some(bg) = style.background_color.as_deref() else {
67        return;
68    };
69
70    // render-background.ts:19-26: per-edge border insets — a border cell is
71    // excluded so the FILL covers the content area inside borders. Mirrors the
72    // edge-show predicate in border.rs:187-190 (`borderStyle present &&
73    // border{Edge} != false`).
74    let has_border = style.border_style.is_some();
75    let left = i32::from(has_border && style.border_left != Some(false));
76    let right = i32::from(has_border && style.border_right != Some(false));
77    let top = i32::from(has_border && style.border_top != Some(false));
78    let bottom = i32::from(has_border && style.border_bottom != Some(false));
79
80    // render-background.ts:28-29.
81    let content_width = width as i32 - left - right;
82    let content_height = height as i32 - top - bottom;
83
84    // render-background.ts:31-33: nothing to fill if either dimension collapses.
85    if content_width <= 0 || content_height <= 0 {
86        return;
87    }
88
89    // render-background.ts:36-40: one colorized line of spaces (fg=None, Kind::Bg),
90    // the same `colorize(seg, bg, Kind::Bg)` pass border.rs's stylePiece uses.
91    let bg_line = colorize(
92        &" ".repeat(content_width as usize),
93        Some(bg),
94        Kind::Bg,
95        level,
96    );
97
98    // render-background.ts:42-49: write the fill into each content row.
99    for row in 0..content_height {
100        grid.write(x + left, y + top + row, &bg_line);
101    }
102}
103
104// ─── Tests ───────────────────────────────────────────────────────────────────
105
106#[cfg(test)]
107mod tests {
108    use super::*;
109    use crate::dom::{BorderStyle, Style};
110    use crate::render::border::render_border;
111    use crate::render::grid::{Clip, Grid};
112
113    // Oversized box, NO border: a 4-wide × 2-tall box with backgroundColor=red
114    // and no text fills the whole 4×2 rectangle with red-bg spaces. The fill line
115    // is `colorize("    ", Some("red"), Kind::Bg)` → `\x1b[41m    \x1b[49m`; the
116    // grid serializes each row as that single closed span (trailing-space trim
117    // does not fire — the spaces are inside the SGR pair, not unstyled pad).
118    #[test]
119    fn box_background_fills_content_rect() {
120        let style = Style {
121            background_color: Some("red".to_owned()),
122            ..Style::default()
123        };
124        let mut g = Grid::new(2, 4);
125        render_background(0, 0, 4, 2, &style, &mut g, ColorLevel::Truecolor);
126        let out = g.get().0;
127        // Two identical rows of a red-bg 4-space span.
128        assert_eq!(out, "\x1b[41m    \x1b[49m\n\x1b[41m    \x1b[49m");
129    }
130
131    // Box + round border + bg (the inset canary): a 5×3 box with a round border
132    // and backgroundColor=blue. contentWidth = 5-1-1 = 3, contentHeight = 3-1-1
133    // = 1 → the fill is ONE blue-bg row of 3 spaces at (x+1, y+1). render_border
134    // then draws the round edges. The border cells carry the box chars (NO blue
135    // bg); only the single interior row's middle three cells carry the blue fill.
136    // Call order matches walk.rs: background THEN border.
137    #[test]
138    fn box_background_inside_border() {
139        let style = Style {
140            border_style: Some(BorderStyle::Named("round".to_owned())),
141            background_color: Some("blue".to_owned()),
142            ..Style::default()
143        };
144        let mut g = Grid::new(3, 5);
145        render_background(0, 0, 5, 3, &style, &mut g, ColorLevel::Truecolor);
146        render_border(0, 0, 5, 3, &style, &mut g, ColorLevel::Truecolor);
147        let out = g.get().0;
148        // Row 0: round top `╭───╮` (no bg). Row 1: `│` + blue-bg 3 spaces + `│`.
149        // Row 2: round bottom `╰───╯` (no bg).
150        assert_eq!(out, "╭───╮\n│\x1b[44m   \x1b[49m│\n╰───╯");
151        // The interior fill is blue bg (44/49), NOT on the border cells.
152        let row1 = out.lines().nth(1).unwrap();
153        assert!(
154            row1.contains("\x1b[44m"),
155            "interior fill carries blue bg open"
156        );
157        assert!(
158            row1.starts_with('│'),
159            "left border cell has no bg, just `│`"
160        );
161        assert!(row1.ends_with('│'), "right border cell has no bg, just `│`");
162    }
163
164    // Degenerate: a 2×2 box whose border insets consume the entire content area
165    // (contentWidth = 2-1-1 = 0) → render_background writes NOTHING. The grid
166    // stays blank (all rows trim to empty). Pins the `content_width <= 0` guard.
167    #[test]
168    fn box_background_zero_content_noop() {
169        let style = Style {
170            border_style: Some(BorderStyle::Named("single".to_owned())),
171            background_color: Some("red".to_owned()),
172            ..Style::default()
173        };
174        let mut g = Grid::new(2, 2);
175        render_background(0, 0, 2, 2, &style, &mut g, ColorLevel::Truecolor);
176        let out = g.get().0;
177        // No fill written → two all-space rows → both trim to empty → "\n".
178        assert_eq!(out, "\n");
179        assert!(
180            !out.contains('\x1b'),
181            "zero-content fill must emit no SGR bytes"
182        );
183    }
184
185    // #78(a) — clip+fill differential. The prior #76 review covered the
186    // fill/clip interaction only by a CODE-ORDERING argument (render_background
187    // writes via grid.write, which applies the active clip). This pins it
188    // EMPIRICALLY against the ink oracle.
189    //
190    // Tree: an outer `<Box overflow="hidden" width=3 height=2>` wrapping an inner
191    // `<Box backgroundColor="red" width=5 height=4>` (the inner FILL OVERFLOWS the
192    // outer on BOTH axes). In walk.rs the outer pushes its clip BEFORE recursing,
193    // so the inner box's render_background runs INSIDE that clip. The clip coords
194    // are exactly what walk.rs:291-302 computes for a borderless overflow:hidden
195    // 3×2 box: x1=0, x2=0+3-0=3, y1=0, y2=0+2-0=2.
196    //
197    // Oracle ground truth (ink 7.0.5, chalk.level=3):
198    //   renderToString(<Box overflow="hidden" w=3 h=2>
199    //                    <Box backgroundColor="red" w=5 h=4/></Box>)
200    //   === "\x1b[41m   \x1b[49m\n\x1b[41m   \x1b[49m"
201    // i.e. the 5×4 red fill is SLICED to the 3×2 visible region: each surviving
202    // row is a red-bg 3-space span; the 2 overflow columns and the 2 overflow rows
203    // are dropped.
204    //
205    // Discriminating defect: "render_background bypasses the clip stack → the fill
206    // spills past the overflow:hidden region." If render_background wrote outside
207    // the clip, each row would be 5 wide and there would be 4 rows. The grid is
208    // sized 4×5 (LARGER than the clip) so the slicing is the CLIP's doing, not
209    // grid-bounds truncation — verified by the mutation check (drop push_clip →
210    // "\x1b[41m     \x1b[49m" × 4 rows, the unclipped 5×4 fill).
211    #[test]
212    fn box_background_fill_is_clipped() {
213        let style = Style {
214            background_color: Some("red".to_owned()),
215            ..Style::default()
216        };
217        // Grid wide+tall enough to hold the FULL unclipped 5×4 inner fill, so any
218        // truncation seen is the clip's, not the grid's.
219        let mut g = Grid::new(4, 5);
220        // The outer overflow:hidden 3×2 (no border) clip, per walk.rs:291-302.
221        g.push_clip(Clip {
222            x1: Some(0),
223            x2: Some(3),
224            y1: Some(0),
225            y2: Some(2),
226        });
227        // Inner box fill: 5 wide × 4 tall — overflows the clip on both axes.
228        render_background(0, 0, 5, 4, &style, &mut g, ColorLevel::Truecolor);
229        g.pop_clip();
230        let out = g.get().0;
231        // The grid is 4 rows; the vertical clip (y2=2) drops the fill from rows
232        // 2-3, which serialize as two trailing blank rows (`\n\n`) — exactly as the
233        // existing `box_background_zero_content_noop` pins its blank grid. The two
234        // SURVIVING rows are byte-identical to the ink oracle (the load-bearing
235        // claim): each red fill is sliced 5→3 wide by the horizontal clip (x2=3).
236        assert_eq!(out, "\x1b[41m   \x1b[49m\n\x1b[41m   \x1b[49m\n\n");
237        // Oracle-exact: the visible region (trailing clipped-blank rows stripped)
238        // matches ink's renderToString byte-for-byte.
239        assert_eq!(
240            out.trim_end_matches('\n'),
241            "\x1b[41m   \x1b[49m\n\x1b[41m   \x1b[49m"
242        );
243        // Both surviving rows are 3-wide (sliced from 5), proving the clip — NOT
244        // the grid (which is 5 wide) — bounded the fill. Under the mutation that
245        // drops push_clip each row is the unclipped 5-space span `\x1b[41m     \x1b[49m`.
246        for row in out.lines().filter(|r| !r.is_empty()) {
247            assert_eq!(
248                row, "\x1b[41m   \x1b[49m",
249                "each surviving fill row is clipped to 3 cells"
250            );
251        }
252    }
253
254    // #78(b) — partial-border inset. A `<Box backgroundColor="blue"
255    // borderStyle="single" borderTop={false} width=6 height=4>`. With borderTop
256    // SUPPRESSED the top inset is 0 (render-background.ts:23-24:
257    // `borderStyle && borderTop !== false ? 1 : 0`), so the fill's FIRST content
258    // row sits at y+0 — where the (absent) top border would be — and the fill is
259    // contentWidth = 6-1-1 = 4 by contentHeight = 4-0-1 = 3. render_border then
260    // draws the left/right/bottom edges (NO top), matching the oracle.
261    //
262    // Oracle ground truth (ink 7.0.5, chalk.level=3):
263    //   renderToString(<Box backgroundColor="blue" borderStyle="single"
264    //                       borderTop={false} width=6 height=4/>)
265    //   === "│\x1b[44m    \x1b[49m│\n│\x1b[44m    \x1b[49m│\n│\x1b[44m    \x1b[49m│\n└────┘"
266    // Rows 0-2: `│` + blue-bg 4 spaces + `│`. Row 3: bottom border `└────┘`.
267    //
268    // Discriminating defect: "top inset stuck at 1 despite borderTop=false." That
269    // mutation makes content_height 2 and starts the fill at y+1, so the TOP row
270    // (row 0) loses its blue fill and the fill stops one row short — the full
271    // string assert goes red. Verified by mutation check (force top=1 → row 0 is
272    // `│    │` with no SGR, the fill shifts down, assert fails).
273    #[test]
274    fn box_background_fill_partial_border_inset() {
275        let style = Style {
276            border_style: Some(BorderStyle::Named("single".to_owned())),
277            border_top: Some(false),
278            background_color: Some("blue".to_owned()),
279            ..Style::default()
280        };
281        let mut g = Grid::new(4, 6);
282        // Call order matches walk.rs: background THEN border.
283        render_background(0, 0, 6, 4, &style, &mut g, ColorLevel::Truecolor);
284        render_border(0, 0, 6, 4, &style, &mut g, ColorLevel::Truecolor);
285        let out = g.get().0;
286        assert_eq!(
287            out,
288            "│\x1b[44m    \x1b[49m│\n│\x1b[44m    \x1b[49m│\n│\x1b[44m    \x1b[49m│\n└────┘"
289        );
290        // The TOP content row (row 0) carries the blue fill — proves top inset is 0.
291        let row0 = out.lines().next().unwrap();
292        assert!(
293            row0.contains("\x1b[44m"),
294            "row 0 carries the blue fill (borderTop=false → top inset 0)"
295        );
296        assert!(
297            row0.starts_with('│') && row0.ends_with('│'),
298            "row 0 has side borders but NO top edge"
299        );
300    }
301}