feat: rewrite Output class with StyledChar[][] grid for correct emoji/wide char handling

Port Ink's output character model using @alcalzone/ansi-tokenize. Each cell is now a
StyledChar with proper grapheme clustering. Fixes emoji alignment, wide char boundaries,
and changes clipping to top-only (matching Ink). Upgraded ansi-tokenize to ^0.3.0.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Yunfei He
2026-05-26 13:19:50 +08:00
parent 8b834c2b7f
commit 957d30f43a
6 changed files with 251 additions and 150 deletions
@@ -63,7 +63,7 @@ test("Text inherits parent Box background color", async () => {
)), )),
{ columns: 100 }, { columns: 100 },
); );
expect(lastFrame()).toMatchInlineSnapshot(`"Hello World"`); expect(lastFrame()).toMatchInlineSnapshot(`"Hello World"`);
}); });
test("Text explicit background color overrides inherited", async () => { test("Text explicit background color overrides inherited", async () => {
@@ -75,7 +75,7 @@ test("Text explicit background color overrides inherited", async () => {
)), )),
{ columns: 100 }, { columns: 100 },
); );
expect(lastFrame()).toMatchInlineSnapshot(`"Hello World"`); expect(lastFrame()).toMatchInlineSnapshot(`"Hello World"`);
}); });
test("Nested Box background inheritance", async () => { test("Nested Box background inheritance", async () => {
@@ -89,7 +89,7 @@ test("Nested Box background inheritance", async () => {
)), )),
{ columns: 100 }, { columns: 100 },
); );
expect(lastFrame()).toMatchInlineSnapshot(`"Hello World"`); expect(lastFrame()).toMatchInlineSnapshot(`"Hello World"`);
}); });
test("Text without parent Box background has no inheritance", async () => { test("Text without parent Box background has no inheritance", async () => {
@@ -114,9 +114,7 @@ test("Multiple Text elements inherit same background", async () => {
)), )),
{ columns: 100 }, { columns: 100 },
); );
expect(lastFrame()).toMatchInlineSnapshot( expect(lastFrame()).toMatchInlineSnapshot(`"Hello World"`);
`"Hello World"`,
);
}); });
test("Mixed text with and without background inheritance", async () => { test("Mixed text with and without background inheritance", async () => {
@@ -130,9 +128,7 @@ test("Mixed text with and without background inheritance", async () => {
)), )),
{ columns: 100 }, { columns: 100 },
); );
expect(lastFrame()).toMatchInlineSnapshot( expect(lastFrame()).toMatchInlineSnapshot(`"Inherited No BG Red BG"`);
`"Inherited No BG Red BG"`,
);
}); });
test("Complex nested structure with background inheritance", async () => { test("Complex nested structure with background inheritance", async () => {
@@ -150,9 +146,7 @@ test("Complex nested structure with background inheritance", async () => {
)), )),
{ columns: 100 }, { columns: 100 },
); );
expect(lastFrame()).toMatchInlineSnapshot( expect(lastFrame()).toMatchInlineSnapshot(`"Outer: Inner: Explicit"`);
`"Outer: Inner: Explicit"`,
);
}); });
test("Box background with standard color", async () => { test("Box background with standard color", async () => {
@@ -164,7 +158,7 @@ test("Box background with standard color", async () => {
)), )),
{ columns: 100 }, { columns: 100 },
); );
expect(lastFrame()).toMatchInlineSnapshot(`"Hello"`); expect(lastFrame()).toMatchInlineSnapshot(`"Hello"`);
}); });
test("Box background with hex color", async () => { test("Box background with hex color", async () => {
@@ -176,9 +170,7 @@ test("Box background with hex color", async () => {
)), )),
{ columns: 100 }, { columns: 100 },
); );
expect(lastFrame()).toMatchInlineSnapshot( expect(lastFrame()).toMatchInlineSnapshot(`"Hello"`);
`"Hello"`,
);
}); });
test("Box background with rgb color", async () => { test("Box background with rgb color", async () => {
@@ -190,9 +182,7 @@ test("Box background with rgb color", async () => {
)), )),
{ columns: 100 }, { columns: 100 },
); );
expect(lastFrame()).toMatchInlineSnapshot( expect(lastFrame()).toMatchInlineSnapshot(`"Hello"`);
`"Hello"`,
);
}); });
test("Box background with ansi256 color", async () => { test("Box background with ansi256 color", async () => {
@@ -204,7 +194,7 @@ test("Box background with ansi256 color", async () => {
)), )),
{ columns: 100 }, { columns: 100 },
); );
expect(lastFrame()).toMatchInlineSnapshot(`"Hello"`); expect(lastFrame()).toMatchInlineSnapshot(`"Hello"`);
}); });
test("Box background with wide characters", async () => { test("Box background with wide characters", async () => {
@@ -216,7 +206,7 @@ test("Box background with wide characters", async () => {
)), )),
{ columns: 100 }, { columns: 100 },
); );
expect(lastFrame()).toMatchInlineSnapshot(`"こんにちは"`); expect(lastFrame()).toMatchInlineSnapshot(`"こんにちは"`);
}); });
test("Box background with emojis", async () => { test("Box background with emojis", async () => {
@@ -228,7 +218,7 @@ test("Box background with emojis", async () => {
)), )),
{ columns: 100 }, { columns: 100 },
); );
expect(lastFrame()).toMatchInlineSnapshot(`"🎉🎊"`); expect(lastFrame()).toMatchInlineSnapshot(`"🎉🎊"`);
}); });
test("Box background fills entire area with standard color", async () => { 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 }, { columns: 100 },
); );
expect(lastFrame()).toMatchInlineSnapshot(` expect(lastFrame()).toMatchInlineSnapshot(`
"Hello  "Hello 
   
 "  "
`); `);
@@ -312,9 +302,9 @@ test("Box background with border fills content area", async () => {
); );
expect(lastFrame()).toMatchInlineSnapshot(` expect(lastFrame()).toMatchInlineSnapshot(`
"╭────────╮ "╭────────╮
│Hi │ │Hi │
│ │ │ │
│ │ │ │
╰────────╯" ╰────────╯"
`); `);
}); });
@@ -330,7 +320,7 @@ test("Box background with padding fills entire padded area", async () => {
); );
expect(lastFrame()).toMatchInlineSnapshot(` expect(lastFrame()).toMatchInlineSnapshot(`
"  " 
 Hi   Hi 
   
   
 "  "
@@ -353,7 +343,7 @@ test("Box background with center alignment fills entire area", async () => {
{ columns: 100 }, { columns: 100 },
); );
expect(lastFrame()).toMatchInlineSnapshot(` expect(lastFrame()).toMatchInlineSnapshot(`
" Hi  " Hi 
   
 "  "
`); `);
@@ -376,8 +366,8 @@ test("Box background with column layout fills entire area", async () => {
{ columns: 100 }, { columns: 100 },
); );
expect(lastFrame()).toMatchInlineSnapshot(` expect(lastFrame()).toMatchInlineSnapshot(`
"Line 1  "Line 1 
Line 2  Line 2 
   
   
 "  "
@@ -400,7 +390,7 @@ test("Box background updates on rerender", async () => {
bgColor.value = "green"; bgColor.value = "green";
await nextTick(); await nextTick();
expect(lastFrame()).toMatchInlineSnapshot(`"Hello"`); expect(lastFrame()).toMatchInlineSnapshot(`"Hello"`);
bgColor.value = undefined; bgColor.value = undefined;
await nextTick(); await nextTick();
@@ -417,8 +407,8 @@ test("Box backgroundColor fills full width on every line when text wraps", async
{ columns: 100 }, { columns: 100 },
); );
expect(lastFrame()).toMatchInlineSnapshot(` expect(lastFrame()).toMatchInlineSnapshot(`
"Hello    "Hello 
World!! " World!! "
`); `);
}); });
@@ -31,9 +31,9 @@ test("single node - full width box with colorful border", async () => {
{ columns: 100 }, { columns: 100 },
); );
expect(lastFrame()).toMatchInlineSnapshot(` expect(lastFrame()).toMatchInlineSnapshot(`
"╭──────────────────────────────────────────────────────────────────────────────────────────────────╮ "╭──────────────────────────────────────────────────────────────────────────────────────────────────╮
│Hello World │ │Hello World │
╰──────────────────────────────────────────────────────────────────────────────────────────────────╯" ╰──────────────────────────────────────────────────────────────────────────────────────────────────╯"
`); `);
}); });
@@ -100,7 +100,7 @@ test("single node - fit-content box with variation selector emojis", async () =>
); );
expect(lastFrame()).toMatchInlineSnapshot(` expect(lastFrame()).toMatchInlineSnapshot(`
"╭──────╮ "╭──────╮
│🌡️⚠️✅ │🌡️⚠️✅│
╰──────╯" ╰──────╯"
`); `);
}); });
@@ -272,9 +272,9 @@ test("multiple nodes - full width box with colorful border", async () => {
{ columns: 100 }, { columns: 100 },
); );
expect(lastFrame()).toMatchInlineSnapshot(` expect(lastFrame()).toMatchInlineSnapshot(`
"╭──────────────────────────────────────────────────────────────────────────────────────────────────╮ "╭──────────────────────────────────────────────────────────────────────────────────────────────────╮
│Hello World │ │Hello World │
╰──────────────────────────────────────────────────────────────────────────────────────────────────╯" ╰──────────────────────────────────────────────────────────────────────────────────────────────────╯"
`); `);
}); });
@@ -636,9 +636,9 @@ test("render border after update", async () => {
borderColor.value = "green"; borderColor.value = "green";
await nextTick(); await nextTick();
expect(lastFrame()).toMatchInlineSnapshot(` expect(lastFrame()).toMatchInlineSnapshot(`
"╭──────────────────────────────────────────────────────────────────────────────────────────────────╮ "╭──────────────────────────────────────────────────────────────────────────────────────────────────╮
│Hello World │ │Hello World │
╰──────────────────────────────────────────────────────────────────────────────────────────────────╯" ╰──────────────────────────────────────────────────────────────────────────────────────────────────╯"
`); `);
borderColor.value = undefined; borderColor.value = undefined;
@@ -947,9 +947,9 @@ test("custom border style", async () => {
{ columns: 100 }, { columns: 100 },
); );
expect(lastFrame()).toMatchInlineSnapshot(` expect(lastFrame()).toMatchInlineSnapshot(`
"↘↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓ "↘↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓↙
→Content ← →Content ←
↗↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑" ↗↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↖"
`); `);
}); });
@@ -964,9 +964,9 @@ test("dim border color", async () => {
{ columns: 100 }, { columns: 100 },
); );
expect(lastFrame()).toMatchInlineSnapshot(` expect(lastFrame()).toMatchInlineSnapshot(`
"╭──────────────────────────────────────────────────────────────────────────────────────────────────╮ "╭──────────────────────────────────────────────────────────────────────────────────────────────────╮
│Content │ │Content │
╰──────────────────────────────────────────────────────────────────────────────────────────────────╯" ╰──────────────────────────────────────────────────────────────────────────────────────────────────╯"
`); `);
}); });
@@ -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(() => () => (
<Box borderStyle="round" alignSelf="flex-start">
<Text>🌡️⚠️✅</Text>
</Box>
));
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(() => () => (
<Box borderStyle="single" width={8}>
<Text>你好世</Text>
</Box>
));
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(() => () => (
<Box borderStyle="round" alignSelf="flex-start">
<Text>🦾🌏😋</Text>
</Box>
));
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);
});
});
+155 -87
View File
@@ -1,6 +1,12 @@
import stringWidth from "string-width"; import stringWidth from "string-width";
import sliceAnsi from "slice-ansi"; import sliceAnsi from "slice-ansi";
import cliBoxes from "cli-boxes"; import cliBoxes from "cli-boxes";
import {
type StyledChar,
styledCharsFromTokens,
styledCharsToString,
tokenize,
} from "@alcalzone/ansi-tokenize";
import { applyChalk } from "./text-style.ts"; import { applyChalk } from "./text-style.ts";
import Yoga from "yoga-layout"; import Yoga from "yoga-layout";
import type { import type {
@@ -43,10 +49,48 @@ interface UnclipOp {
type Op = WriteOp | ClipOp | UnclipOp; type Op = WriteOp | ClipOp | UnclipOp;
class OutputCaches {
private widths = new Map<string, number>();
private blockWidths = new Map<string, number>();
private styledCharsCache = new Map<string, StyledChar[]>();
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 { class Output {
readonly width: number; readonly width: number;
readonly height: number; readonly height: number;
private ops: Op[] = []; private ops: Op[] = [];
private readonly caches: OutputCaches = new OutputCaches();
constructor(width: number, height: number) { constructor(width: number, height: number) {
this.width = width; this.width = width;
@@ -65,11 +109,17 @@ class Output {
this.ops.push({ type: "unclip" }); this.ops.push({ type: "unclip" });
} }
get(): string { get(): { output: string; height: number } {
// Build a sparse grid of cells, write each op left-to-right. // Initialize output grid with StyledChar cells
const grid: string[][] = Array.from({ length: this.height }, () => const output: StyledChar[][] = [];
Array.from({ length: this.width }, () => " "), 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[] = []; const clips: ClipRect[] = [];
for (const op of this.ops) { for (const op of this.ops) {
@@ -83,48 +133,39 @@ class Output {
} }
// op.type === "write" // op.type === "write"
const { transformers } = op;
let { x, y } = op; let { x, y } = op;
let lines = op.lines; let lines = op.lines;
// Apply transformers // Only the most recent clip applies (top-only, matching Ink behavior)
lines = lines.map((line, idx) => { const clip = clips.at(-1);
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<ClipRect>(
(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 },
);
if (clip) {
const clipH = typeof clip.x1 === "number" && typeof clip.x2 === "number"; const clipH = typeof clip.x1 === "number" && typeof clip.x2 === "number";
const clipV = typeof clip.y1 === "number" && typeof clip.y2 === "number"; const clipV = typeof clip.y1 === "number" && typeof clip.y2 === "number";
// If the intersection is empty, skip the write entirely // If text is positioned outside of clipping area altogether, skip
if (clipH && clip.x1! >= clip.x2!) continue; if (clipH) {
if (clipV && clip.y1! >= clip.y2!) continue; 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) { if (clipV) {
const height = lines.length; const height = lines.length;
if (y + height <= clip.y1! || y >= clip.y2!) continue; if (y + height < clip.y1! || y > clip.y2!) continue;
} }
if (clipH) {
// Quick check: if write is entirely to the right of clip or entirely if (clipH) {
// to the left, skip it lines = lines.map((line) => {
if (x >= clip.x2!) continue; 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) { if (clipV) {
const from = y < clip.y1! ? clip.y1! - y : 0; const from = y < clip.y1! ? clip.y1! - y : 0;
const height = lines.length; const height = lines.length;
@@ -132,61 +173,88 @@ class Output {
lines = lines.slice(from, to); lines = lines.slice(from, to);
if (y < clip.y1!) y = clip.y1!; if (y < clip.y1!) y = clip.y1!;
} }
}
// Horizontal clipping: slice each line let offsetY = 0;
if (clipH) {
lines = lines.map((line) => { for (let [index, line] of lines.entries()) {
const lineWidth = stringWidth(line); const currentLine = output[y + offsetY];
const from = x < clip.x1! ? clip.x1! - x : 0;
const to = x + lineWidth > clip.x2! ? clip.x2! - x : lineWidth; // Line can be missing if text is taller than pre-initialized output
return sliceAnsi(line, from, to); if (!currentLine) {
}); continue;
if (x < clip.x1!) x = clip.x1!;
} }
}
for (let lineIdx = 0; lineIdx < lines.length; lineIdx++) { for (const transformer of transformers) {
placeLine(grid, x, y + lineIdx, lines[lineIdx]!); line = transformer(line, index);
} }
}
return grid.map((row) => row.join("").trimEnd()).join("\n");
}
}
function placeLine(grid: string[][], x: number, y: number, line: string): void { const characters = this.caches.getStyledChars(line);
if (y < 0 || y >= grid.length) return; let offsetX = x;
const row = grid[y]!;
// We walk the line as visual cells. ANSI sequences are kept attached to the // Nothing to write (e.g. line was clipped away)
// cell that follows them; emoji/wide chars consume two cells. if (characters.length === 0) {
let col = x; offsetY++;
let i = 0; continue;
let pendingAnsi = ""; }
while (i < line.length && col < row.length) {
const ch = line[i]!; const spaceCell: StyledChar = {
// ANSI CSI sequence: ESC[...m — accumulate as a prefix for the next cell. type: "char",
if (ch === "\x1b" && line[i + 1] === "[") { value: " ",
const end = line.indexOf("m", i); fullWidth: false,
if (end >= 0) { styles: [],
pendingAnsi += line.slice(i, end + 1); };
i = end + 1;
continue; // 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); const generatedOutput = output
if (col >= 0 && col < row.length) { .map((line) => {
row[col] = pendingAnsi + segment; const lineWithoutEmptyItems = line.filter((item) => item !== undefined);
pendingAnsi = ""; return styledCharsToString(lineWithoutEmptyItems).trimEnd();
} })
if (w === 2 && col + 1 < row.length) row[col + 1] = ""; .join("\n");
col += Math.max(1, w);
i++; return { output: generatedOutput, height: output.length };
}
// 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;
} }
} }
@@ -304,9 +372,9 @@ export function paint(root: TuiNode): string {
const layout = root.yoga.getComputedLayout(); const layout = root.yoga.getComputedLayout();
const width = Math.max(1, Math.floor(layout.width)); const width = Math.max(1, Math.floor(layout.width));
const height = Math.max(1, Math.floor(layout.height)); const height = Math.max(1, Math.floor(layout.height));
const output = new Output(width, height); const out = new Output(width, height);
paintNode(root, output, 0, 0, []); paintNode(root, out, 0, 0, []);
return output.get(); return out.get().output;
} }
function paintNode( function paintNode(
+8 -14
View File
@@ -7,8 +7,8 @@ settings:
catalogs: catalogs:
default: default:
'@alcalzone/ansi-tokenize': '@alcalzone/ansi-tokenize':
specifier: ^0.1.3 specifier: ^0.3.0
version: 0.1.3 version: 0.3.0
'@types/node': '@types/node':
specifier: ^24.12.4 specifier: ^24.12.4
version: 24.12.4 version: 24.12.4
@@ -145,7 +145,7 @@ importers:
dependencies: dependencies:
'@alcalzone/ansi-tokenize': '@alcalzone/ansi-tokenize':
specifier: 'catalog:' specifier: 'catalog:'
version: 0.1.3 version: 0.3.0
'@vue/runtime-core': '@vue/runtime-core':
specifier: ^3.4.0 specifier: ^3.4.0
version: 3.5.34 version: 3.5.34
@@ -241,9 +241,9 @@ importers:
packages: packages:
'@alcalzone/ansi-tokenize@0.1.3': '@alcalzone/ansi-tokenize@0.3.0':
resolution: {integrity: sha512-3yWxPTq3UQ/FY9p1ErPxIyfT64elWaMvM9lIHnaqpyft63tkxodF5aUElYHrdisWve5cETkh1+KBw1yJuW0aRw==} resolution: {integrity: sha512-p+CMKJ93HFmLkjXKlXiVGlMQEuRb6H0MokBSwUsX+S6BRX8eV5naFZpQJFfJHjRZY0Hmnqy1/r6UWl3x+19zYA==}
engines: {node: '>=14.13.1'} engines: {node: '>=18'}
'@babel/code-frame@7.29.0': '@babel/code-frame@7.29.0':
resolution: {integrity: sha512-9NhCeYjq9+3uxgdtp20LSiJXJvN0FeCtNGpJxuMFZ1Kv3cWUNb6DOhJwUvcVCzKGR66cw4njwM6hrJLqgOwbcw==} resolution: {integrity: sha512-9NhCeYjq9+3uxgdtp20LSiJXJvN0FeCtNGpJxuMFZ1Kv3cWUNb6DOhJwUvcVCzKGR66cw4njwM6hrJLqgOwbcw==}
@@ -1354,10 +1354,6 @@ packages:
humanize-ms@1.2.1: humanize-ms@1.2.1:
resolution: {integrity: sha512-Fl70vYtsAFb/C06PTS9dZBo7ihau+Tu/DNCk/OyHhea07S+aeMWpFFkUaXRa8fI+ScZbEI8dfSxwY7gxZ9SAVQ==} 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: is-fullwidth-code-point@5.1.0:
resolution: {integrity: sha512-5XHYaSyiqADb4RnZ1Bdad6cPp8Toise4TzEjcOYDHZkTCbKgiUl7WTUCpNWHuxmDt91wnsZBc9xinNzopv3JMQ==} resolution: {integrity: sha512-5XHYaSyiqADb4RnZ1Bdad6cPp8Toise4TzEjcOYDHZkTCbKgiUl7WTUCpNWHuxmDt91wnsZBc9xinNzopv3JMQ==}
engines: {node: '>=18'} engines: {node: '>=18'}
@@ -1762,10 +1758,10 @@ packages:
snapshots: snapshots:
'@alcalzone/ansi-tokenize@0.1.3': '@alcalzone/ansi-tokenize@0.3.0':
dependencies: dependencies:
ansi-styles: 6.2.3 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': '@babel/code-frame@7.29.0':
dependencies: dependencies:
@@ -2741,8 +2737,6 @@ snapshots:
dependencies: dependencies:
ms: 2.1.3 ms: 2.1.3
is-fullwidth-code-point@4.0.0: {}
is-fullwidth-code-point@5.1.0: is-fullwidth-code-point@5.1.0:
dependencies: dependencies:
get-east-asian-width: 1.6.0 get-east-asian-width: 1.6.0
+1 -1
View File
@@ -7,7 +7,7 @@ packages:
catalogMode: prefer catalogMode: prefer
catalog: catalog:
"@alcalzone/ansi-tokenize": ^0.1.3 "@alcalzone/ansi-tokenize": ^0.3.0
"@types/node": ^24.12.4 "@types/node": ^24.12.4
typescript: ^5 typescript: ^5
vite: npm:@voidzero-dev/vite-plus-core@latest vite: npm:@voidzero-dev/vite-plus-core@latest