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:
@@ -63,7 +63,7 @@ test("Text inherits parent Box background color", async () => {
|
|||||||
)),
|
)),
|
||||||
{ columns: 100 },
|
{ 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 () => {
|
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(`"[41m[44mHello World[49m[41m[49m"`);
|
expect(lastFrame()).toMatchInlineSnapshot(`"[44mHello World[49m"`);
|
||||||
});
|
});
|
||||||
|
|
||||||
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(`"[44m[44mHello World[49m[44m[49m"`);
|
expect(lastFrame()).toMatchInlineSnapshot(`"[44mHello World[49m"`);
|
||||||
});
|
});
|
||||||
|
|
||||||
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(`"[43mHello World[49m"`);
|
||||||
`"[43m[43mHello [49m[43m[49m[43m[43mWorld[49m[43m[49m"`,
|
|
||||||
);
|
|
||||||
});
|
});
|
||||||
|
|
||||||
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(`"[42mInherited No BG [41mRed BG[49m"`);
|
||||||
`"[42m[42mInherited [49m[42m[49m[42mNo BG [49m[42m[41mRed BG[49m[42m[49m"`,
|
|
||||||
);
|
|
||||||
});
|
});
|
||||||
|
|
||||||
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(`"[43mOuter: [44mInner: [41mExplicit[49m"`);
|
||||||
`"[43m[43mOuter: [49m[43m[49m[44m[44mInner: [49m[44m[49m[44m[41mExplicit[49m[44m[49m"`,
|
|
||||||
);
|
|
||||||
});
|
});
|
||||||
|
|
||||||
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(`"[41m[41mHello[49m[41m[49m"`);
|
expect(lastFrame()).toMatchInlineSnapshot(`"[41mHello[49m"`);
|
||||||
});
|
});
|
||||||
|
|
||||||
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(`"[48;2;255;0;0mHello[49m"`);
|
||||||
`"[48;2;255;0;0m[48;2;255;0;0mHello[49m[48;2;255;0;0m[49m"`,
|
|
||||||
);
|
|
||||||
});
|
});
|
||||||
|
|
||||||
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(`"[48;2;255;0;0mHello[49m"`);
|
||||||
`"[48;2;255;0;0m[48;2;255;0;0mHello[49m[48;2;255;0;0m[49m"`,
|
|
||||||
);
|
|
||||||
});
|
});
|
||||||
|
|
||||||
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(`"[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 () => {
|
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(`"[43m[43mこんにちは[49m[43m[49m"`);
|
expect(lastFrame()).toMatchInlineSnapshot(`"[43mこんにちは[49m"`);
|
||||||
});
|
});
|
||||||
|
|
||||||
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(`"[41m[41m🎉🎊[49m[41m[49m"`);
|
expect(lastFrame()).toMatchInlineSnapshot(`"[41m🎉🎊[49m"`);
|
||||||
});
|
});
|
||||||
|
|
||||||
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(`
|
||||||
"[41m[41mHello[49m[41m[49m [49m
|
"[41mHello [49m
|
||||||
[41m [49m
|
[41m [49m
|
||||||
[41m [49m"
|
[41m [49m"
|
||||||
`);
|
`);
|
||||||
@@ -312,9 +302,9 @@ test("Box background with border fills content area", async () => {
|
|||||||
);
|
);
|
||||||
expect(lastFrame()).toMatchInlineSnapshot(`
|
expect(lastFrame()).toMatchInlineSnapshot(`
|
||||||
"[46m╭────────╮[49m
|
"[46m╭────────╮[49m
|
||||||
[46m│[49m[46m[46mHi[49m[46m[49m [49m[46m│[49m
|
[46m│Hi │[49m
|
||||||
[46m│[49m[46m [49m[46m│[49m
|
[46m│ │[49m
|
||||||
[46m│[49m[46m [49m[46m│[49m
|
[46m│ │[49m
|
||||||
[46m╰────────╯[49m"
|
[46m╰────────╯[49m"
|
||||||
`);
|
`);
|
||||||
});
|
});
|
||||||
@@ -330,7 +320,7 @@ test("Box background with padding fills entire padded area", async () => {
|
|||||||
);
|
);
|
||||||
expect(lastFrame()).toMatchInlineSnapshot(`
|
expect(lastFrame()).toMatchInlineSnapshot(`
|
||||||
"[45m [49m
|
"[45m [49m
|
||||||
[45m [45m[45mHi[49m[45m[49m [49m
|
[45m Hi [49m
|
||||||
[45m [49m
|
[45m [49m
|
||||||
[45m [49m
|
[45m [49m
|
||||||
[45m [49m"
|
[45m [49m"
|
||||||
@@ -353,7 +343,7 @@ test("Box background with center alignment fills entire area", async () => {
|
|||||||
{ columns: 100 },
|
{ columns: 100 },
|
||||||
);
|
);
|
||||||
expect(lastFrame()).toMatchInlineSnapshot(`
|
expect(lastFrame()).toMatchInlineSnapshot(`
|
||||||
"[44m [44m[44mHi[49m[44m[49m [49m
|
"[44m Hi [49m
|
||||||
[44m [49m
|
[44m [49m
|
||||||
[44m [49m"
|
[44m [49m"
|
||||||
`);
|
`);
|
||||||
@@ -376,8 +366,8 @@ test("Box background with column layout fills entire area", async () => {
|
|||||||
{ columns: 100 },
|
{ columns: 100 },
|
||||||
);
|
);
|
||||||
expect(lastFrame()).toMatchInlineSnapshot(`
|
expect(lastFrame()).toMatchInlineSnapshot(`
|
||||||
"[42m[42mLine 1[49m[42m [49m
|
"[42mLine 1 [49m
|
||||||
[42m[42mLine 2[49m[42m [49m
|
[42mLine 2 [49m
|
||||||
[42m [49m
|
[42m [49m
|
||||||
[42m [49m
|
[42m [49m
|
||||||
[42m [49m"
|
[42m [49m"
|
||||||
@@ -400,7 +390,7 @@ test("Box background updates on rerender", async () => {
|
|||||||
|
|
||||||
bgColor.value = "green";
|
bgColor.value = "green";
|
||||||
await nextTick();
|
await nextTick();
|
||||||
expect(lastFrame()).toMatchInlineSnapshot(`"[42m[42mHello[49m[42m[49m"`);
|
expect(lastFrame()).toMatchInlineSnapshot(`"[42mHello[49m"`);
|
||||||
|
|
||||||
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(`
|
||||||
"[41m[41mHello [49m[41m [49m [49m
|
"[41mHello [49m
|
||||||
[41m[41mWorld!![49m[41m[49m [49m"
|
[41mWorld!! [49m"
|
||||||
`);
|
`);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -31,9 +31,9 @@ test("single node - full width box with colorful border", async () => {
|
|||||||
{ columns: 100 },
|
{ columns: 100 },
|
||||||
);
|
);
|
||||||
expect(lastFrame()).toMatchInlineSnapshot(`
|
expect(lastFrame()).toMatchInlineSnapshot(`
|
||||||
"[32m╭──────────────────────────────────────────────────────────────────────────────────────────────────╮
|
"[32m╭──────────────────────────────────────────────────────────────────────────────────────────────────╮[39m
|
||||||
[32m│[39mHello World [32m│
|
[32m│[39mHello World [32m│[39m
|
||||||
[32m╰──────────────────────────────────────────────────────────────────────────────────────────────────╯"
|
[32m╰──────────────────────────────────────────────────────────────────────────────────────────────────╯[39m"
|
||||||
`);
|
`);
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -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(`
|
||||||
"[32m╭──────────────────────────────────────────────────────────────────────────────────────────────────╮
|
"[32m╭──────────────────────────────────────────────────────────────────────────────────────────────────╮[39m
|
||||||
[32m│[39mHello World [32m│
|
[32m│[39mHello World [32m│[39m
|
||||||
[32m╰──────────────────────────────────────────────────────────────────────────────────────────────────╯"
|
[32m╰──────────────────────────────────────────────────────────────────────────────────────────────────╯[39m"
|
||||||
`);
|
`);
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -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(`
|
||||||
"[32m╭──────────────────────────────────────────────────────────────────────────────────────────────────╮
|
"[32m╭──────────────────────────────────────────────────────────────────────────────────────────────────╮[39m
|
||||||
[32m│[39mHello World [32m│
|
[32m│[39mHello World [32m│[39m
|
||||||
[32m╰──────────────────────────────────────────────────────────────────────────────────────────────────╯"
|
[32m╰──────────────────────────────────────────────────────────────────────────────────────────────────╯[39m"
|
||||||
`);
|
`);
|
||||||
|
|
||||||
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(`
|
||||||
"[2m╭──────────────────────────────────────────────────────────────────────────────────────────────────╮
|
"[2m╭──────────────────────────────────────────────────────────────────────────────────────────────────╮[22m
|
||||||
[2m│[22mContent [2m│
|
[2m│[22mContent [2m│[22m
|
||||||
[2m╰──────────────────────────────────────────────────────────────────────────────────────────────────╯"
|
[2m╰──────────────────────────────────────────────────────────────────────────────────────────────────╯[22m"
|
||||||
`);
|
`);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -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);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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(
|
||||||
|
|||||||
Generated
+8
-14
@@ -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
@@ -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
|
||||||
|
|||||||
Reference in New Issue
Block a user