fix(paint): backgroundColor now visible with borders and text

Root causes:
- drawBorder wrote plain characters, overwriting fillBackground's ANSI codes
- renderTextWithInlineStyles spread node.props over inherited bg, but
  undefined props from Vue clobbered the inherited backgroundColor
- Child text nodes did not inherit backgroundColor from parent Box

Fixes:
- drawBorder applies backgroundColor (and borderColor) to border chars
- Filter out undefined props before merging in renderTextWithInlineStyles
- Pass inheritedBg through the paint tree to text nodes

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Yunfei He
2026-05-25 11:11:54 +08:00
parent e850cb15ee
commit 8e6c4c5c2b
4 changed files with 75 additions and 12 deletions
@@ -0,0 +1,47 @@
import chalk from "chalk";
import { afterEach, beforeEach, expect, test } from "vite-plus/test";
import { render } from "@vue-tui/testing";
import { Box, Text } from "@vue-tui/runtime";
const BG_BLUE = "\x1b[44m";
let prevLevel: typeof chalk.level;
beforeEach(() => {
prevLevel = chalk.level;
chalk.level = 1;
});
afterEach(() => {
chalk.level = prevLevel;
});
test("Box backgroundColor produces ANSI background codes", async () => {
const { frames } = await render(
() => <Box backgroundColor="blue" width={5} height={1} />,
{ columns: 10 },
);
expect(frames.at(-1)).toContain(BG_BLUE);
});
test("Box backgroundColor survives border rendering", async () => {
const { frames } = await render(
() => <Box backgroundColor="blue" borderStyle="single" width={6} height={3} />,
{ columns: 10 },
);
const raw = frames.at(-1)!;
expect(raw).toContain(BG_BLUE);
expect(raw).toContain("┌");
});
test("child Text inherits backgroundColor from parent Box", async () => {
const { frames } = await render(
() => (
<Box backgroundColor="blue" width={10} height={1}>
<Text>hello</Text>
</Box>
),
{ columns: 20 },
);
const raw = frames.at(-1)!;
expect(raw).toContain("hello");
expect(raw).toContain(BG_BLUE);
});
+1
View File
@@ -12,6 +12,7 @@
"@vitejs/plugin-vue-jsx": "catalog:", "@vitejs/plugin-vue-jsx": "catalog:",
"@vue-tui/runtime": "workspace:*", "@vue-tui/runtime": "workspace:*",
"@vue-tui/testing": "workspace:*", "@vue-tui/testing": "workspace:*",
"chalk": "catalog:",
"typescript": "^6.0.3", "typescript": "^6.0.3",
"vite-plus": "^0.1.20", "vite-plus": "^0.1.20",
"vue": "^3.4.0" "vue": "^3.4.0"
+21 -12
View File
@@ -91,7 +91,10 @@ function placeLine(grid: string[][], x: number, y: number, line: string): void {
} }
function renderTextWithInlineStyles(node: TuiText | TuiVirtualText, acc: TextProps = {}): string { function renderTextWithInlineStyles(node: TuiText | TuiVirtualText, acc: TextProps = {}): string {
const merged: TextProps = { ...acc, ...node.props }; const defined = Object.fromEntries(
Object.entries(node.props).filter(([, v]) => v !== undefined),
);
const merged: TextProps = { ...acc, ...defined };
let out = ""; let out = "";
for (const child of node.children) { for (const child of node.children) {
if (child.type === "text-leaf") { if (child.type === "text-leaf") {
@@ -120,28 +123,31 @@ function drawBorder(
if (!chars) return; if (!chars) return;
if (w < 2 || h < 2) return; if (w < 2 || h < 2) return;
// Per-edge toggles default to true when borderStyle is set.
const top = props["borderTop"] !== false; const top = props["borderTop"] !== false;
const bottom = props["borderBottom"] !== false; const bottom = props["borderBottom"] !== false;
const left = props["borderLeft"] !== false; const left = props["borderLeft"] !== false;
const right = props["borderRight"] !== false; const right = props["borderRight"] !== false;
// Corners require both adjacent edges to be enabled; otherwise the const borderColor = props["borderColor"] as string | undefined;
// adjacent edge character is used as a "stub" so the visible edge still const bgColor = props["backgroundColor"] as string | undefined;
// terminates cleanly. const colorProps: TextProps = {};
if (borderColor) colorProps.color = borderColor;
if (bgColor) colorProps.backgroundColor = bgColor;
const colorize = (s: string) => (borderColor || bgColor ? applyChalk(s, colorProps) : s);
if (top) { if (top) {
const tl = left ? chars.topLeft : chars.top; const tl = left ? chars.topLeft : chars.top;
const tr = right ? chars.topRight : chars.top; const tr = right ? chars.topRight : chars.top;
output.write(x, y, [tl + chars.top.repeat(w - 2) + tr], transformers); output.write(x, y, [colorize(tl + chars.top.repeat(w - 2) + tr)], transformers);
} }
if (bottom) { if (bottom) {
const bl = left ? chars.bottomLeft : chars.bottom; const bl = left ? chars.bottomLeft : chars.bottom;
const br = right ? chars.bottomRight : chars.bottom; const br = right ? chars.bottomRight : chars.bottom;
output.write(x, y + h - 1, [bl + chars.bottom.repeat(w - 2) + br], transformers); output.write(x, y + h - 1, [colorize(bl + chars.bottom.repeat(w - 2) + br)], transformers);
} }
for (let i = 1; i < h - 1; i++) { for (let i = 1; i < h - 1; i++) {
if (left) output.write(x, y + i, [chars.left], transformers); if (left) output.write(x, y + i, [colorize(chars.left)], transformers);
if (right) output.write(x + w - 1, y + i, [chars.right], transformers); if (right) output.write(x + w - 1, y + i, [colorize(chars.right)], transformers);
} }
} }
@@ -175,6 +181,7 @@ function paintNode(
x0: number, x0: number,
y0: number, y0: number,
transformers: Transformer[], transformers: Transformer[],
inheritedBg?: string,
): void { ): void {
switch (node.type) { switch (node.type) {
case "root": { case "root": {
@@ -187,18 +194,20 @@ function paintNode(
const y = y0 + layout.top; const y = y0 + layout.top;
const w = Math.max(0, Math.floor(layout.width)); const w = Math.max(0, Math.floor(layout.width));
const h = Math.max(0, Math.floor(layout.height)); const h = Math.max(0, Math.floor(layout.height));
const bg = (node.props["backgroundColor"] as string | undefined) ?? inheritedBg;
if (node.props["backgroundColor"]) { if (node.props["backgroundColor"]) {
fillBackground(output, x, y, w, h, node.props["backgroundColor"], transformers); fillBackground(output, x, y, w, h, node.props["backgroundColor"], transformers);
} }
if (node.props["borderStyle"]) { if (node.props["borderStyle"]) {
drawBorder(output, x, y, w, h, node.props, transformers); drawBorder(output, x, y, w, h, node.props, transformers);
} }
for (const child of node.children) paintNode(child, output, x, y, transformers); for (const child of node.children) paintNode(child, output, x, y, transformers, bg);
return; return;
} }
case "text": { case "text": {
const layout = node.yoga.getComputedLayout(); const layout = node.yoga.getComputedLayout();
const text = renderTextWithInlineStyles(node); const bgProps: TextProps = inheritedBg ? { backgroundColor: inheritedBg } : {};
const text = renderTextWithInlineStyles(node, bgProps);
const wrapped = wrapText( const wrapped = wrapText(
text, text,
Math.max(1, Math.floor(layout.width)), Math.max(1, Math.floor(layout.width)),
@@ -214,7 +223,7 @@ function paintNode(
} }
case "transform": { case "transform": {
const next = [...transformers, node.transform]; const next = [...transformers, node.transform];
for (const child of node.children) paintNode(child, output, x0, y0, next); for (const child of node.children) paintNode(child, output, x0, y0, next, inheritedBg);
return; return;
} }
case "virtual-text": case "virtual-text":
+6
View File
@@ -12,6 +12,9 @@ catalogs:
'@vitejs/plugin-vue-jsx': '@vitejs/plugin-vue-jsx':
specifier: ^5.1.5 specifier: ^5.1.5
version: 5.1.5 version: 5.1.5
chalk:
specifier: ^5.6.2
version: 5.6.2
vite-plus: vite-plus:
specifier: latest specifier: latest
version: 0.1.22 version: 0.1.22
@@ -186,6 +189,9 @@ importers:
'@vue-tui/testing': '@vue-tui/testing':
specifier: workspace:* specifier: workspace:*
version: link:../testing version: link:../testing
chalk:
specifier: 'catalog:'
version: 5.6.2
typescript: typescript:
specifier: ^6.0.3 specifier: ^6.0.3
version: 6.0.3 version: 6.0.3