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}