diff --git a/packages/runtime-tests/integration/components/background-color.test.tsx b/packages/runtime-tests/integration/components/background-color.test.tsx
index 6c3786e..fd3133b 100644
--- a/packages/runtime-tests/integration/components/background-color.test.tsx
+++ b/packages/runtime-tests/integration/components/background-color.test.tsx
@@ -63,7 +63,7 @@ test("Text inherits parent Box background color", async () => {
)),
{ columns: 100 },
);
- expect(lastFrame()).toMatchInlineSnapshot(`"[42m[42mHello World[49m[42m[49m"`);
+ expect(lastFrame()).toMatchInlineSnapshot(`"[42mHello World[49m"`);
});
test("Text explicit background color overrides inherited", async () => {
@@ -75,7 +75,7 @@ test("Text explicit background color overrides inherited", async () => {
)),
{ columns: 100 },
);
- expect(lastFrame()).toMatchInlineSnapshot(`"[41m[44mHello World[49m[41m[49m"`);
+ expect(lastFrame()).toMatchInlineSnapshot(`"[44mHello World[49m"`);
});
test("Nested Box background inheritance", async () => {
@@ -89,7 +89,7 @@ test("Nested Box background inheritance", async () => {
)),
{ columns: 100 },
);
- expect(lastFrame()).toMatchInlineSnapshot(`"[44m[44mHello World[49m[44m[49m"`);
+ expect(lastFrame()).toMatchInlineSnapshot(`"[44mHello World[49m"`);
});
test("Text without parent Box background has no inheritance", async () => {
@@ -114,9 +114,7 @@ test("Multiple Text elements inherit same background", async () => {
)),
{ columns: 100 },
);
- expect(lastFrame()).toMatchInlineSnapshot(
- `"[43m[43mHello [49m[43m[49m[43m[43mWorld[49m[43m[49m"`,
- );
+ expect(lastFrame()).toMatchInlineSnapshot(`"[43mHello World[49m"`);
});
test("Mixed text with and without background inheritance", async () => {
@@ -130,9 +128,7 @@ test("Mixed text with and without background inheritance", async () => {
)),
{ columns: 100 },
);
- expect(lastFrame()).toMatchInlineSnapshot(
- `"[42m[42mInherited [49m[42m[49m[42mNo BG [49m[42m[41mRed BG[49m[42m[49m"`,
- );
+ expect(lastFrame()).toMatchInlineSnapshot(`"[42mInherited No BG [41mRed BG[49m"`);
});
test("Complex nested structure with background inheritance", async () => {
@@ -150,9 +146,7 @@ test("Complex nested structure with background inheritance", async () => {
)),
{ columns: 100 },
);
- expect(lastFrame()).toMatchInlineSnapshot(
- `"[43m[43mOuter: [49m[43m[49m[44m[44mInner: [49m[44m[49m[44m[41mExplicit[49m[44m[49m"`,
- );
+ expect(lastFrame()).toMatchInlineSnapshot(`"[43mOuter: [44mInner: [41mExplicit[49m"`);
});
test("Box background with standard color", async () => {
@@ -164,7 +158,7 @@ test("Box background with standard color", async () => {
)),
{ columns: 100 },
);
- expect(lastFrame()).toMatchInlineSnapshot(`"[41m[41mHello[49m[41m[49m"`);
+ expect(lastFrame()).toMatchInlineSnapshot(`"[41mHello[49m"`);
});
test("Box background with hex color", async () => {
@@ -176,9 +170,7 @@ test("Box background with hex color", async () => {
)),
{ columns: 100 },
);
- expect(lastFrame()).toMatchInlineSnapshot(
- `"[48;2;255;0;0m[48;2;255;0;0mHello[49m[48;2;255;0;0m[49m"`,
- );
+ expect(lastFrame()).toMatchInlineSnapshot(`"[48;2;255;0;0mHello[49m"`);
});
test("Box background with rgb color", async () => {
@@ -190,9 +182,7 @@ test("Box background with rgb color", async () => {
)),
{ columns: 100 },
);
- expect(lastFrame()).toMatchInlineSnapshot(
- `"[48;2;255;0;0m[48;2;255;0;0mHello[49m[48;2;255;0;0m[49m"`,
- );
+ expect(lastFrame()).toMatchInlineSnapshot(`"[48;2;255;0;0mHello[49m"`);
});
test("Box background with ansi256 color", async () => {
@@ -204,7 +194,7 @@ test("Box background with ansi256 color", async () => {
)),
{ columns: 100 },
);
- expect(lastFrame()).toMatchInlineSnapshot(`"[48;5;9m[48;5;9mHello[49m[48;5;9m[49m"`);
+ expect(lastFrame()).toMatchInlineSnapshot(`"[48;5;9mHello[49m"`);
});
test("Box background with wide characters", async () => {
@@ -216,7 +206,7 @@ test("Box background with wide characters", async () => {
)),
{ columns: 100 },
);
- expect(lastFrame()).toMatchInlineSnapshot(`"[43m[43mこんにちは[49m[43m[49m"`);
+ expect(lastFrame()).toMatchInlineSnapshot(`"[43mこんにちは[49m"`);
});
test("Box background with emojis", async () => {
@@ -228,7 +218,7 @@ test("Box background with emojis", async () => {
)),
{ columns: 100 },
);
- expect(lastFrame()).toMatchInlineSnapshot(`"[41m[41m🎉🎊[49m[41m[49m"`);
+ expect(lastFrame()).toMatchInlineSnapshot(`"[41m🎉🎊[49m"`);
});
test("Box background fills entire area with standard color", async () => {
@@ -241,7 +231,7 @@ test("Box background fills entire area with standard color", async () => {
{ columns: 100 },
);
expect(lastFrame()).toMatchInlineSnapshot(`
- "[41m[41mHello[49m[41m[49m [49m
+ "[41mHello [49m
[41m [49m
[41m [49m"
`);
@@ -312,9 +302,9 @@ test("Box background with border fills content area", async () => {
);
expect(lastFrame()).toMatchInlineSnapshot(`
"[46m╭────────╮[49m
- [46m│[49m[46m[46mHi[49m[46m[49m [49m[46m│[49m
- [46m│[49m[46m [49m[46m│[49m
- [46m│[49m[46m [49m[46m│[49m
+ [46m│Hi │[49m
+ [46m│ │[49m
+ [46m│ │[49m
[46m╰────────╯[49m"
`);
});
@@ -330,7 +320,7 @@ test("Box background with padding fills entire padded area", async () => {
);
expect(lastFrame()).toMatchInlineSnapshot(`
"[45m [49m
- [45m [45m[45mHi[49m[45m[49m [49m
+ [45m Hi [49m
[45m [49m
[45m [49m
[45m [49m"
@@ -353,7 +343,7 @@ test("Box background with center alignment fills entire area", async () => {
{ columns: 100 },
);
expect(lastFrame()).toMatchInlineSnapshot(`
- "[44m [44m[44mHi[49m[44m[49m [49m
+ "[44m Hi [49m
[44m [49m
[44m [49m"
`);
@@ -376,8 +366,8 @@ test("Box background with column layout fills entire area", async () => {
{ columns: 100 },
);
expect(lastFrame()).toMatchInlineSnapshot(`
- "[42m[42mLine 1[49m[42m [49m
- [42m[42mLine 2[49m[42m [49m
+ "[42mLine 1 [49m
+ [42mLine 2 [49m
[42m [49m
[42m [49m
[42m [49m"
@@ -400,7 +390,7 @@ test("Box background updates on rerender", async () => {
bgColor.value = "green";
await nextTick();
- expect(lastFrame()).toMatchInlineSnapshot(`"[42m[42mHello[49m[42m[49m"`);
+ expect(lastFrame()).toMatchInlineSnapshot(`"[42mHello[49m"`);
bgColor.value = undefined;
await nextTick();
@@ -417,8 +407,8 @@ test("Box backgroundColor fills full width on every line when text wraps", async
{ columns: 100 },
);
expect(lastFrame()).toMatchInlineSnapshot(`
- "[41m[41mHello [49m[41m [49m [49m
- [41m[41mWorld!![49m[41m[49m [49m"
+ "[41mHello [49m
+ [41mWorld!! [49m"
`);
});
diff --git a/packages/runtime-tests/integration/components/borders.test.tsx b/packages/runtime-tests/integration/components/borders.test.tsx
index dd32ac5..1a01faf 100644
--- a/packages/runtime-tests/integration/components/borders.test.tsx
+++ b/packages/runtime-tests/integration/components/borders.test.tsx
@@ -31,9 +31,9 @@ test("single node - full width box with colorful border", async () => {
{ columns: 100 },
);
expect(lastFrame()).toMatchInlineSnapshot(`
- "[32m╭──────────────────────────────────────────────────────────────────────────────────────────────────╮
- [32m│[39mHello World [32m│
- [32m╰──────────────────────────────────────────────────────────────────────────────────────────────────╯"
+ "[32m╭──────────────────────────────────────────────────────────────────────────────────────────────────╮[39m
+ [32m│[39mHello World [32m│[39m
+ [32m╰──────────────────────────────────────────────────────────────────────────────────────────────────╯[39m"
`);
});
@@ -100,7 +100,7 @@ test("single node - fit-content box with variation selector emojis", async () =>
);
expect(lastFrame()).toMatchInlineSnapshot(`
"╭──────╮
- │🌡️⚠️✅
+ │🌡️⚠️✅│
╰──────╯"
`);
});
@@ -272,9 +272,9 @@ test("multiple nodes - full width box with colorful border", async () => {
{ columns: 100 },
);
expect(lastFrame()).toMatchInlineSnapshot(`
- "[32m╭──────────────────────────────────────────────────────────────────────────────────────────────────╮
- [32m│[39mHello World [32m│
- [32m╰──────────────────────────────────────────────────────────────────────────────────────────────────╯"
+ "[32m╭──────────────────────────────────────────────────────────────────────────────────────────────────╮[39m
+ [32m│[39mHello World [32m│[39m
+ [32m╰──────────────────────────────────────────────────────────────────────────────────────────────────╯[39m"
`);
});
@@ -636,9 +636,9 @@ test("render border after update", async () => {
borderColor.value = "green";
await nextTick();
expect(lastFrame()).toMatchInlineSnapshot(`
- "[32m╭──────────────────────────────────────────────────────────────────────────────────────────────────╮
- [32m│[39mHello World [32m│
- [32m╰──────────────────────────────────────────────────────────────────────────────────────────────────╯"
+ "[32m╭──────────────────────────────────────────────────────────────────────────────────────────────────╮[39m
+ [32m│[39mHello World [32m│[39m
+ [32m╰──────────────────────────────────────────────────────────────────────────────────────────────────╯[39m"
`);
borderColor.value = undefined;
@@ -947,9 +947,9 @@ test("custom border style", async () => {
{ columns: 100 },
);
expect(lastFrame()).toMatchInlineSnapshot(`
- "↘↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓
+ "↘↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓↙
→Content ←
- ↗↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑"
+ ↗↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↖"
`);
});
@@ -964,9 +964,9 @@ test("dim border color", async () => {
{ columns: 100 },
);
expect(lastFrame()).toMatchInlineSnapshot(`
- "[2m╭──────────────────────────────────────────────────────────────────────────────────────────────────╮
- [2m│[22mContent [2m│
- [2m╰──────────────────────────────────────────────────────────────────────────────────────────────────╯"
+ "[2m╭──────────────────────────────────────────────────────────────────────────────────────────────────╮[22m
+ [2m│[22mContent [2m│[22m
+ [2m╰──────────────────────────────────────────────────────────────────────────────────────────────────╯[22m"
`);
});
diff --git a/packages/runtime-tests/integration/paint/output-emoji.test.tsx b/packages/runtime-tests/integration/paint/output-emoji.test.tsx
new file mode 100644
index 0000000..de67610
--- /dev/null
+++ b/packages/runtime-tests/integration/paint/output-emoji.test.tsx
@@ -0,0 +1,49 @@
+import { defineComponent } from "vue";
+import { describe, test, expect } from "vite-plus/test";
+import { render } from "@vue-tui/testing";
+import { Box, Text } from "@vue-tui/runtime";
+
+describe("Output character model", () => {
+ test("variation selector emoji characters align correctly within borders", async () => {
+ const App = defineComponent(() => () => (
+
+ 🌡️⚠️✅
+
+ ));
+ const { lastFrame } = await render(App, { columns: 100 });
+ const frame = lastFrame()!;
+ const lines = frame.split("\n");
+ // The text line (line 1) must have a closing right border "│"
+ // With the old placeLine implementation, variation selector emojis
+ // miscount visual width and the right border is lost.
+ expect(lines[1]).toContain("│");
+ expect(lines[1]!.endsWith("│")).toBe(true);
+ });
+
+ test("wide CJK characters occupy two cells", async () => {
+ const App = defineComponent(() => () => (
+
+ 你好世
+
+ ));
+ const { lastFrame } = await render(App, { columns: 100 });
+ const frame = lastFrame()!;
+ const lines = frame.split("\n");
+ expect(lines[1]!.endsWith("│")).toBe(true);
+ });
+
+ test("simple emoji characters align within borders", async () => {
+ const App = defineComponent(() => () => (
+
+ 🦾🌏😋
+
+ ));
+ const { lastFrame } = await render(App, { columns: 100 });
+ const frame = lastFrame()!;
+ const lines = frame.split("\n");
+ // All lines must end with the right border character
+ expect(lines[0]!.endsWith("╮")).toBe(true);
+ expect(lines[1]!.endsWith("│")).toBe(true);
+ expect(lines[2]!.endsWith("╯")).toBe(true);
+ });
+});
diff --git a/packages/runtime/src/paint/paint.ts b/packages/runtime/src/paint/paint.ts
index e800ad6..af36e42 100644
--- a/packages/runtime/src/paint/paint.ts
+++ b/packages/runtime/src/paint/paint.ts
@@ -1,6 +1,12 @@
import stringWidth from "string-width";
import sliceAnsi from "slice-ansi";
import cliBoxes from "cli-boxes";
+import {
+ type StyledChar,
+ styledCharsFromTokens,
+ styledCharsToString,
+ tokenize,
+} from "@alcalzone/ansi-tokenize";
import { applyChalk } from "./text-style.ts";
import Yoga from "yoga-layout";
import type {
@@ -43,10 +49,48 @@ interface UnclipOp {
type Op = WriteOp | ClipOp | UnclipOp;
+class OutputCaches {
+ private widths = new Map();
+ private blockWidths = new Map();
+ private styledCharsCache = new Map();
+
+ getStyledChars(line: string): StyledChar[] {
+ let cached = this.styledCharsCache.get(line);
+ if (cached === undefined) {
+ cached = styledCharsFromTokens(tokenize(line));
+ this.styledCharsCache.set(line, cached);
+ }
+ return cached;
+ }
+
+ getStringWidth(text: string): number {
+ let cached = this.widths.get(text);
+ if (cached === undefined) {
+ cached = stringWidth(text);
+ this.widths.set(text, cached);
+ }
+ return cached;
+ }
+
+ getWidestLine(text: string): number {
+ let cached = this.blockWidths.get(text);
+ if (cached === undefined) {
+ let lineWidth = 0;
+ for (const line of text.split("\n")) {
+ lineWidth = Math.max(lineWidth, this.getStringWidth(line));
+ }
+ cached = lineWidth;
+ this.blockWidths.set(text, cached);
+ }
+ return cached;
+ }
+}
+
class Output {
readonly width: number;
readonly height: number;
private ops: Op[] = [];
+ private readonly caches: OutputCaches = new OutputCaches();
constructor(width: number, height: number) {
this.width = width;
@@ -65,11 +109,17 @@ class Output {
this.ops.push({ type: "unclip" });
}
- get(): string {
- // Build a sparse grid of cells, write each op left-to-right.
- const grid: string[][] = Array.from({ length: this.height }, () =>
- Array.from({ length: this.width }, () => " "),
- );
+ get(): { output: string; height: number } {
+ // Initialize output grid with StyledChar cells
+ const output: StyledChar[][] = [];
+ for (let y = 0; y < this.height; y++) {
+ const row: StyledChar[] = [];
+ for (let x = 0; x < this.width; x++) {
+ row.push({ type: "char", value: " ", fullWidth: false, styles: [] });
+ }
+ output.push(row);
+ }
+
const clips: ClipRect[] = [];
for (const op of this.ops) {
@@ -83,48 +133,39 @@ class Output {
}
// op.type === "write"
+ const { transformers } = op;
let { x, y } = op;
let lines = op.lines;
- // Apply transformers
- lines = lines.map((line, idx) => {
- let l = line;
- for (const tf of op.transformers) l = tf(l, idx);
- return l;
- });
-
- // Apply active clip rect — intersect ALL clips in the stack so nested
- // overflow:hidden boxes correctly constrain children.
- if (clips.length > 0) {
- const clip = clips.reduce(
- (acc, c) => ({
- x1: acc.x1 != null && c.x1 != null ? Math.max(acc.x1, c.x1) : (acc.x1 ?? c.x1),
- x2: acc.x2 != null && c.x2 != null ? Math.min(acc.x2, c.x2) : (acc.x2 ?? c.x2),
- y1: acc.y1 != null && c.y1 != null ? Math.max(acc.y1, c.y1) : (acc.y1 ?? c.y1),
- y2: acc.y2 != null && c.y2 != null ? Math.min(acc.y2, c.y2) : (acc.y2 ?? c.y2),
- }),
- { x1: undefined, x2: undefined, y1: undefined, y2: undefined },
- );
+ // Only the most recent clip applies (top-only, matching Ink behavior)
+ const clip = clips.at(-1);
+ if (clip) {
const clipH = typeof clip.x1 === "number" && typeof clip.x2 === "number";
const clipV = typeof clip.y1 === "number" && typeof clip.y2 === "number";
- // If the intersection is empty, skip the write entirely
- if (clipH && clip.x1! >= clip.x2!) continue;
- if (clipV && clip.y1! >= clip.y2!) continue;
+ // If text is positioned outside of clipping area altogether, skip
+ if (clipH) {
+ const text = lines.join("\n");
+ const width = this.caches.getWidestLine(text);
+ if (x + width < clip.x1! || x > clip.x2!) continue;
+ }
- // Skip entirely out-of-bounds writes
if (clipV) {
const height = lines.length;
- if (y + height <= clip.y1! || y >= clip.y2!) continue;
- }
- if (clipH) {
- // Quick check: if write is entirely to the right of clip or entirely
- // to the left, skip it
- if (x >= clip.x2!) continue;
+ if (y + height < clip.y1! || y > clip.y2!) continue;
+ }
+
+ if (clipH) {
+ lines = lines.map((line) => {
+ const from = x < clip.x1! ? clip.x1! - x : 0;
+ const lineWidth = this.caches.getStringWidth(line);
+ const to = x + lineWidth > clip.x2! ? clip.x2! - x : lineWidth;
+ return sliceAnsi(line, from, to);
+ });
+ if (x < clip.x1!) x = clip.x1!;
}
- // Vertical clipping: slice lines array
if (clipV) {
const from = y < clip.y1! ? clip.y1! - y : 0;
const height = lines.length;
@@ -132,61 +173,88 @@ class Output {
lines = lines.slice(from, to);
if (y < clip.y1!) y = clip.y1!;
}
+ }
- // Horizontal clipping: slice each line
- if (clipH) {
- lines = lines.map((line) => {
- const lineWidth = stringWidth(line);
- const from = x < clip.x1! ? clip.x1! - x : 0;
- const to = x + lineWidth > clip.x2! ? clip.x2! - x : lineWidth;
- return sliceAnsi(line, from, to);
- });
- if (x < clip.x1!) x = clip.x1!;
+ let offsetY = 0;
+
+ for (let [index, line] of lines.entries()) {
+ const currentLine = output[y + offsetY];
+
+ // Line can be missing if text is taller than pre-initialized output
+ if (!currentLine) {
+ continue;
}
- }
- for (let lineIdx = 0; lineIdx < lines.length; lineIdx++) {
- placeLine(grid, x, y + lineIdx, lines[lineIdx]!);
- }
- }
- return grid.map((row) => row.join("").trimEnd()).join("\n");
- }
-}
+ for (const transformer of transformers) {
+ line = transformer(line, index);
+ }
-function placeLine(grid: string[][], x: number, y: number, line: string): void {
- if (y < 0 || y >= grid.length) return;
- const row = grid[y]!;
- // We walk the line as visual cells. ANSI sequences are kept attached to the
- // cell that follows them; emoji/wide chars consume two cells.
- let col = x;
- let i = 0;
- let pendingAnsi = "";
- while (i < line.length && col < row.length) {
- const ch = line[i]!;
- // ANSI CSI sequence: ESC[...m — accumulate as a prefix for the next cell.
- if (ch === "\x1b" && line[i + 1] === "[") {
- const end = line.indexOf("m", i);
- if (end >= 0) {
- pendingAnsi += line.slice(i, end + 1);
- i = end + 1;
- continue;
+ const characters = this.caches.getStyledChars(line);
+ let offsetX = x;
+
+ // Nothing to write (e.g. line was clipped away)
+ if (characters.length === 0) {
+ offsetY++;
+ continue;
+ }
+
+ const spaceCell: StyledChar = {
+ type: "char",
+ value: " ",
+ fullWidth: false,
+ styles: [],
+ };
+
+ // Wide characters (e.g. CJK) occupy two cells: a leading cell with
+ // the character and a trailing placeholder with value ''. When an
+ // overlapping write lands in the middle of a wide character, the
+ // boundary cells need cleanup so the terminal never renders a
+ // half-visible wide character.
+ if (
+ currentLine[offsetX]?.value === "" &&
+ offsetX > 0 &&
+ this.caches.getStringWidth(currentLine[offsetX - 1]?.value ?? "") > 1
+ ) {
+ currentLine[offsetX - 1] = spaceCell;
+ }
+
+ for (const character of characters) {
+ currentLine[offsetX] = character;
+
+ // Determine printed width using string-width to align with measurement
+ const characterWidth = Math.max(1, this.caches.getStringWidth(character.value));
+
+ // For multi-column characters, clear following cells to avoid stray spaces/artifacts
+ if (characterWidth > 1) {
+ for (let i = 1; i < characterWidth; i++) {
+ currentLine[offsetX + i] = {
+ type: "char",
+ value: "",
+ fullWidth: false,
+ styles: character.styles,
+ };
+ }
+ }
+
+ offsetX += characterWidth;
+ }
+
+ if (currentLine[offsetX]?.value === "") {
+ currentLine[offsetX] = spaceCell;
+ }
+
+ offsetY++;
}
}
- const segment = ch;
- const w = stringWidth(segment);
- if (col >= 0 && col < row.length) {
- row[col] = pendingAnsi + segment;
- pendingAnsi = "";
- }
- if (w === 2 && col + 1 < row.length) row[col + 1] = "";
- col += Math.max(1, w);
- i++;
- }
- // If the line ended with a trailing escape (e.g. reset), attach it to the
- // last written cell so it's not lost.
- if (pendingAnsi && col > x) {
- const lastCol = Math.min(col - 1, row.length - 1);
- if (lastCol >= 0) row[lastCol] = (row[lastCol] ?? "") + pendingAnsi;
+
+ const generatedOutput = output
+ .map((line) => {
+ const lineWithoutEmptyItems = line.filter((item) => item !== undefined);
+ return styledCharsToString(lineWithoutEmptyItems).trimEnd();
+ })
+ .join("\n");
+
+ return { output: generatedOutput, height: output.length };
}
}
@@ -304,9 +372,9 @@ export function paint(root: TuiNode): string {
const layout = root.yoga.getComputedLayout();
const width = Math.max(1, Math.floor(layout.width));
const height = Math.max(1, Math.floor(layout.height));
- const output = new Output(width, height);
- paintNode(root, output, 0, 0, []);
- return output.get();
+ const out = new Output(width, height);
+ paintNode(root, out, 0, 0, []);
+ return out.get().output;
}
function paintNode(
diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml
index 910bd76..bcb81d0 100644
--- a/pnpm-lock.yaml
+++ b/pnpm-lock.yaml
@@ -7,8 +7,8 @@ settings:
catalogs:
default:
'@alcalzone/ansi-tokenize':
- specifier: ^0.1.3
- version: 0.1.3
+ specifier: ^0.3.0
+ version: 0.3.0
'@types/node':
specifier: ^24.12.4
version: 24.12.4
@@ -145,7 +145,7 @@ importers:
dependencies:
'@alcalzone/ansi-tokenize':
specifier: 'catalog:'
- version: 0.1.3
+ version: 0.3.0
'@vue/runtime-core':
specifier: ^3.4.0
version: 3.5.34
@@ -241,9 +241,9 @@ importers:
packages:
- '@alcalzone/ansi-tokenize@0.1.3':
- resolution: {integrity: sha512-3yWxPTq3UQ/FY9p1ErPxIyfT64elWaMvM9lIHnaqpyft63tkxodF5aUElYHrdisWve5cETkh1+KBw1yJuW0aRw==}
- engines: {node: '>=14.13.1'}
+ '@alcalzone/ansi-tokenize@0.3.0':
+ resolution: {integrity: sha512-p+CMKJ93HFmLkjXKlXiVGlMQEuRb6H0MokBSwUsX+S6BRX8eV5naFZpQJFfJHjRZY0Hmnqy1/r6UWl3x+19zYA==}
+ engines: {node: '>=18'}
'@babel/code-frame@7.29.0':
resolution: {integrity: sha512-9NhCeYjq9+3uxgdtp20LSiJXJvN0FeCtNGpJxuMFZ1Kv3cWUNb6DOhJwUvcVCzKGR66cw4njwM6hrJLqgOwbcw==}
@@ -1354,10 +1354,6 @@ packages:
humanize-ms@1.2.1:
resolution: {integrity: sha512-Fl70vYtsAFb/C06PTS9dZBo7ihau+Tu/DNCk/OyHhea07S+aeMWpFFkUaXRa8fI+ScZbEI8dfSxwY7gxZ9SAVQ==}
- is-fullwidth-code-point@4.0.0:
- resolution: {integrity: sha512-O4L094N2/dZ7xqVdrXhh9r1KODPJpFms8B5sGdJLPy664AgvXsreZUyCQQNItZRDlYug4xStLjNp/sz3HvBowQ==}
- engines: {node: '>=12'}
-
is-fullwidth-code-point@5.1.0:
resolution: {integrity: sha512-5XHYaSyiqADb4RnZ1Bdad6cPp8Toise4TzEjcOYDHZkTCbKgiUl7WTUCpNWHuxmDt91wnsZBc9xinNzopv3JMQ==}
engines: {node: '>=18'}
@@ -1762,10 +1758,10 @@ packages:
snapshots:
- '@alcalzone/ansi-tokenize@0.1.3':
+ '@alcalzone/ansi-tokenize@0.3.0':
dependencies:
ansi-styles: 6.2.3
- is-fullwidth-code-point: 4.0.0
+ is-fullwidth-code-point: 5.1.0
'@babel/code-frame@7.29.0':
dependencies:
@@ -2741,8 +2737,6 @@ snapshots:
dependencies:
ms: 2.1.3
- is-fullwidth-code-point@4.0.0: {}
-
is-fullwidth-code-point@5.1.0:
dependencies:
get-east-asian-width: 1.6.0
diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml
index 6292678..61217bc 100644
--- a/pnpm-workspace.yaml
+++ b/pnpm-workspace.yaml
@@ -7,7 +7,7 @@ packages:
catalogMode: prefer
catalog:
- "@alcalzone/ansi-tokenize": ^0.1.3
+ "@alcalzone/ansi-tokenize": ^0.3.0
"@types/node": ^24.12.4
typescript: ^5
vite: npm:@voidzero-dev/vite-plus-core@latest