refactor(runtime)!: prefix host primitive tags with tui- (align to Ink, drop *Impl)
The renderer's intrinsic elements were named with bare words (box/text/static/
transform/virtual-text), which collide with the same-named public components: a
template `<box>` PascalCase-resolves to `<Box>` under vue-tsc (no isCustomElement
at the type layer), forcing the BoxImpl/TextImpl/StaticImpl workaround.
Prefix the 5 host elements to `tui-*` (mirroring Ink's `ink-box`/`ink-text`):
the prefix + hyphen keeps them in their own namespace, so the components keep
their real names (Box/Text/Static) with no self-recursion — the *Impl rename is
removed. root/text-leaf/comment stay unprefixed (not template tags, not elements).
Mechanics: renamed the TuiNode discriminant literals + factories first, then let
vue-tsc enumerate all 145 stale `node.type === "box"` comparisons (the type-
driven finder also kept `position: "static"` and the ansi-tokenizer's separate
`type: "text"` union untouched). Updated createElement cases, HOST_TAGS,
the .vue templates, transform.ts h(), and raw `h("box")` host-op tests.
Two non-type-checked contaminations the sed caused were caught by tests and fixed:
- patchProp's `key === "transform"` (the PROP name, not the node type) must stay
"transform" — the sed wrongly prefixed it, dropping the transform fn (identity).
- text-measure's `token.type === "text"` is an AnsiToken, not a TuiNode — reverted.
BREAKING CHANGE: the internal host element names are now tui-box/tui-text/
tui-virtual-text/tui-static/tui-transform. Public components (Box/Text/Static/
Spacer/Newline/Transform) and their props/types are unchanged; only raw host-op
callers (h("box") -> h("tui-box")) are affected.
vp run ready green: fmt, lint 0/0, vue-tsc, tests (runtime 350, integration 1161,
PTY 129).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -73,7 +73,7 @@ and fixed at the root:
|
||||
`fresh.length > 0` gate and `paintIsolated` painted the container's **padding** as stray
|
||||
blank lines — while `findStatics` in the same file already skipped `text-leaf`/`comment`.
|
||||
Fix: `paintStaticNode` skips inert anchors too (safe: `node-ops.ts` forbids non-empty bare
|
||||
text under `<static>`, so the only `text-leaf` there is an empty anchor).
|
||||
text under `<tui-static>`, so the only `text-leaf` there is an empty anchor).
|
||||
- **Text — `<slot/>` fragment anchors shifted the transform line-index, exposing an Ink-parity
|
||||
bug.** A `<slot/>` mounts as a Fragment whose boundary anchors are empty `text-leaf`s; the
|
||||
squash loops that give a nested `<Transform>` its positional line index counted every
|
||||
@@ -98,18 +98,23 @@ and fixed at the root:
|
||||
verbatim — so `:flex-grow="1"` reaches the renderer as `flex-grow` and is rejected. Use
|
||||
`:flexGrow="1"`, or `v-bind="someObject"` (object keys are preserved). `Box`/`Text`/`Static`
|
||||
bind a whole props/style object with `v-bind`; `Spacer` uses explicit camelCase.
|
||||
- **Name a component differently from any host tag it renders** (`BoxImpl`/`TextImpl`/
|
||||
`StaticImpl` via `defineOptions`). vue-tsc 3.3.4 self-recurses a `<box>` tag to a component
|
||||
named "Box" (it has no `isCustomElement` at the type layer). Public names (`Box`/`Text`/
|
||||
`Static`) come from `index.ts`.
|
||||
- **Host primitive tags are `tui-`-prefixed** (`tui-box`/`tui-text`/`tui-virtual-text`/
|
||||
`tui-static`/`tui-transform`), mirroring Ink's `ink-box`/`ink-text`. The prefix keeps the
|
||||
renderer's intrinsic elements in their own namespace, so a template `<tui-box>` never
|
||||
resolves to the public `<Box>` component — the components keep their real `name`
|
||||
(`Box`/`Text`/`Static`) with no vue-tsc self-recursion. (Earlier the tags were bare
|
||||
`box`/`text`/…, which collided with the same-named components and forced an `*Impl` internal
|
||||
rename to dodge it; the prefix removed that workaround. vue-tsc has no `isCustomElement` at
|
||||
the type layer, so a bare lowercase tag would PascalCase-resolve to the component — the
|
||||
hyphenated `tui-` name sidesteps that entirely.)
|
||||
- **Don't reintroduce parent-walking or `parent.type.name` matching for context** — use
|
||||
provide/inject (`.name` is also fragile under minification).
|
||||
- **Don't force child-vnode inspection into a template** (the double-materialization wart). If
|
||||
a new component needs it, make it a render function — where the whole ecosystem draws the line.
|
||||
- The host elements (`box`, `text`, `virtual-text`, `static`, `transform`) compile to raw
|
||||
element vnodes via the build's `isCustomElement` option and are an **internal** detail.
|
||||
Consumers use `<Box>` / `<Text>`, never `<box>`. SFC templates may reference the host tags
|
||||
directly; their loose typing under `vue-tsc` (no `strictTemplates`) is intentional.
|
||||
- The host elements (`tui-box`, `tui-text`, `tui-virtual-text`, `tui-static`, `tui-transform`)
|
||||
compile to raw element vnodes via the build's `isCustomElement` option and are an **internal**
|
||||
detail. Consumers use `<Box>` / `<Text>`, never `<tui-box>`. SFC templates may reference the
|
||||
host tags directly; their loose typing under `vue-tsc` (no `strictTemplates`) is intentional.
|
||||
- Components export typed props (`ExtractPublicPropTypes` over the runtime props object) and
|
||||
keep the `WithChildren` shim (`with-children.ts`): Vue's automatic JSX runtime routes
|
||||
children to a `children` prop that declared slots do NOT provide, so the shim is required for
|
||||
|
||||
@@ -809,8 +809,10 @@ test("non-string host Box backgroundColor does not override inherited background
|
||||
const { lastFrame } = await render(
|
||||
defineComponent(
|
||||
() => () =>
|
||||
h("box", { backgroundColor: "red", width: 5, height: 2 }, [
|
||||
h("box", { backgroundColor: [0, 0, 255], width: 5, height: 2 }, [h("text", null, "Hi")]),
|
||||
h("tui-box", { backgroundColor: "red", width: 5, height: 2 }, [
|
||||
h("tui-box", { backgroundColor: [0, 0, 255], width: 5, height: 2 }, [
|
||||
h("tui-text", null, "Hi"),
|
||||
]),
|
||||
]),
|
||||
),
|
||||
{ columns: 100 },
|
||||
@@ -828,8 +830,8 @@ test("non-string host Text backgroundColor does not override inherited backgroun
|
||||
const { lastFrame } = await render(
|
||||
defineComponent(
|
||||
() => () =>
|
||||
h("box", { backgroundColor: "red", alignSelf: "flex-start" }, [
|
||||
h("text", { backgroundColor: [0, 0, 255] }, "Hi"),
|
||||
h("tui-box", { backgroundColor: "red", alignSelf: "flex-start" }, [
|
||||
h("tui-text", { backgroundColor: [0, 0, 255] }, "Hi"),
|
||||
]),
|
||||
),
|
||||
{ columns: 100 },
|
||||
|
||||
@@ -884,7 +884,7 @@ test("non-string host borderTopColor falls back to general borderColor", async (
|
||||
const { lastFrame } = await render(
|
||||
defineComponent(
|
||||
() => () =>
|
||||
h("box", {
|
||||
h("tui-box", {
|
||||
borderStyle: "single",
|
||||
borderColor: "red",
|
||||
borderTopColor: [0, 0, 255],
|
||||
@@ -1189,7 +1189,7 @@ test("non-string host borderTopBackgroundColor falls back to general borderBackg
|
||||
const { lastFrame } = await render(
|
||||
defineComponent(
|
||||
() => () =>
|
||||
h("box", {
|
||||
h("tui-box", {
|
||||
borderStyle: "single",
|
||||
borderBackgroundColor: "red",
|
||||
borderTopBackgroundColor: [0, 0, 255],
|
||||
|
||||
@@ -455,7 +455,7 @@ function mountWithInput(kittyKeyboard: { mode: "auto" | "enabled" }) {
|
||||
useInput((input) => {
|
||||
inputs.push(input);
|
||||
});
|
||||
return () => h("text", null, "x");
|
||||
return () => h("tui-text", null, "x");
|
||||
});
|
||||
|
||||
const app = createApp(App);
|
||||
|
||||
@@ -101,7 +101,7 @@ export function assertBoxValid(props: BoxProps): true {
|
||||
}
|
||||
|
||||
// NOTE: this component-level validation covers the public `<Box>`/`<Text>`
|
||||
// API only. A raw host-op call (`h("box", { backgroundColor: "bold" })`)
|
||||
// API only. A raw host-op call (`h("tui-box", { backgroundColor: "bold" })`)
|
||||
// bypasses it; the paint layer keeps its silent degrade-to-bare-text there
|
||||
// rather than throwing (a throw in the post-flush paint pass wedges Vue's
|
||||
// scheduler). Same accepted limitation as the borderStyle fix (#124).
|
||||
|
||||
@@ -4,9 +4,10 @@ import { AppContextKey } from "../context.ts";
|
||||
import { boxProps } from "./box-props.ts";
|
||||
import { assertBoxValid } from "./box-validate.ts";
|
||||
|
||||
// Internal name != "Box" to avoid vue-tsc self-recursion on the `<box>` host tag.
|
||||
// The public export name "Box" comes from index.ts.
|
||||
defineOptions({ name: "BoxImpl" });
|
||||
// Renders the `<tui-box>` host primitive. The host tag's `tui-` prefix keeps it out
|
||||
// of the component namespace, so the component can take its real name "Box" with no
|
||||
// vue-tsc self-recursion on the tag. Public export wired in index.ts.
|
||||
defineOptions({ name: "Box" });
|
||||
const props = defineProps(boxProps);
|
||||
defineSlots<{ default?: () => unknown }>();
|
||||
const appCtx = inject(AppContextKey, null);
|
||||
@@ -21,8 +22,8 @@ const srHidden = computed(() => srEnabled.value && props.ariaHidden);
|
||||
colorizes). Under a screen reader with an ariaLabel, render the label text
|
||||
instead of the slot. The root `v-if` makes this a fragment, but $el still
|
||||
resolves to the real `box` host node, so measureElement/useBoxMetrics work. -->
|
||||
<box v-if="!srHidden && assertBoxValid(props)" v-bind="props">
|
||||
<text v-if="srEnabled && props.ariaLabel">{{ props.ariaLabel }}</text>
|
||||
<tui-box v-if="!srHidden && assertBoxValid(props)" v-bind="props">
|
||||
<tui-text v-if="srEnabled && props.ariaLabel">{{ props.ariaLabel }}</tui-text>
|
||||
<slot v-else />
|
||||
</box>
|
||||
</tui-box>
|
||||
</template>
|
||||
|
||||
@@ -12,6 +12,6 @@ const content = computed(() => "\n".repeat(props.count));
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<virtual-text v-if="insideText">{{ content }}</virtual-text>
|
||||
<text v-else>{{ content }}</text>
|
||||
<tui-virtual-text v-if="insideText">{{ content }}</tui-virtual-text>
|
||||
<tui-text v-else>{{ content }}</tui-text>
|
||||
</template>
|
||||
|
||||
@@ -3,5 +3,5 @@ defineOptions({ name: "Spacer" });
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<box :flexGrow="1" :flexShrink="1" />
|
||||
<tui-box :flexGrow="1" :flexShrink="1" />
|
||||
</template>
|
||||
|
||||
@@ -2,10 +2,10 @@
|
||||
import { computed, shallowRef, watch } from "vue";
|
||||
import { staticProps } from "./static-props.ts";
|
||||
|
||||
// Internal name deliberately != "Static": vue-tsc 3.3.4 would bind the `<static>`
|
||||
// host tag below to this component (self-recursion) if they matched. Public export
|
||||
// name is "Static" (index.ts).
|
||||
defineOptions({ name: "StaticImpl" });
|
||||
// Renders the `<tui-static>` host primitive. The host tag's `tui-` prefix keeps it out
|
||||
// of the component namespace, so the component can take its real name "Static" with no
|
||||
// vue-tsc self-recursion on the tag. Public export wired in index.ts.
|
||||
defineOptions({ name: "Static" });
|
||||
const props = defineProps(staticProps);
|
||||
defineSlots<{ default?: (slotProps: { item: unknown; index: number }) => unknown }>();
|
||||
|
||||
@@ -36,9 +36,9 @@ const itemsToRender = computed(() => (props.items as unknown[]).slice(cursor.val
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<static v-bind="merged">
|
||||
<tui-static v-bind="merged">
|
||||
<template v-for="(item, i) in itemsToRender" :key="cursor + i">
|
||||
<slot :item="item" :index="cursor + i" />
|
||||
</template>
|
||||
</static>
|
||||
</tui-static>
|
||||
</template>
|
||||
|
||||
@@ -4,9 +4,10 @@ import { AppContextKey, TextContextKey } from "../context.ts";
|
||||
import { assertValidBackgroundColor, assertValidForegroundColor } from "../paint/text-style.ts";
|
||||
import { textProps } from "./text-props.ts";
|
||||
|
||||
// Internal name != "Text" to avoid vue-tsc self-recursion on the `<text>` host tag.
|
||||
// The public export name "Text" comes from index.ts.
|
||||
defineOptions({ name: "TextImpl" });
|
||||
// Renders the `<tui-text>` / `<tui-virtual-text>` host primitives. The `tui-` prefix
|
||||
// keeps the host tags out of the component namespace, so the component can take its
|
||||
// real name "Text" with no vue-tsc self-recursion. Public export wired in index.ts.
|
||||
defineOptions({ name: "Text" });
|
||||
const props = defineProps(textProps);
|
||||
const slots = defineSlots<{ default?: () => unknown }>();
|
||||
|
||||
@@ -39,15 +40,15 @@ function validate(): true {
|
||||
|
||||
<template>
|
||||
<template v-if="!srHidden && validate() && hasContent">
|
||||
<virtual-text v-if="insideText" v-bind="props">
|
||||
<tui-virtual-text v-if="insideText" v-bind="props">
|
||||
<template v-if="srLabel">{{ srLabel }}</template>
|
||||
<slot v-else />
|
||||
</virtual-text>
|
||||
</tui-virtual-text>
|
||||
<!-- Match Ink's <Text> defaults: flexShrink=1 so text nodes shrink when they
|
||||
overflow their container (e.g. in no-wrap flex rows). -->
|
||||
<text v-else v-bind="{ ...props, flexShrink: 1 }">
|
||||
<tui-text v-else v-bind="{ ...props, flexShrink: 1 }">
|
||||
<template v-if="srLabel">{{ srLabel }}</template>
|
||||
<slot v-else />
|
||||
</text>
|
||||
</tui-text>
|
||||
</template>
|
||||
</template>
|
||||
|
||||
@@ -71,10 +71,10 @@ const TransformImpl = defineComponent({
|
||||
// When screen reader is enabled and accessibilityLabel is set,
|
||||
// render the label text instead of children.
|
||||
if (isScreenReaderEnabled && props.accessibilityLabel) {
|
||||
return h("transform", { transform: props.transform }, props.accessibilityLabel);
|
||||
return h("tui-transform", { transform: props.transform }, props.accessibilityLabel);
|
||||
}
|
||||
|
||||
return h("transform", { transform: props.transform }, children);
|
||||
return h("tui-transform", { transform: props.transform }, children);
|
||||
};
|
||||
},
|
||||
});
|
||||
|
||||
@@ -8,20 +8,20 @@ type ContainerWithChildren = TuiRoot | TuiBox | TuiText | TuiStatic | TuiTransfo
|
||||
function hasYoga(node: TuiNode): node is YogaCarrier {
|
||||
return (
|
||||
node.type === "root" ||
|
||||
node.type === "box" ||
|
||||
node.type === "text" ||
|
||||
node.type === "static" ||
|
||||
node.type === "transform"
|
||||
node.type === "tui-box" ||
|
||||
node.type === "tui-text" ||
|
||||
node.type === "tui-static" ||
|
||||
node.type === "tui-transform"
|
||||
);
|
||||
}
|
||||
|
||||
function hasChildren(node: TuiNode): node is ContainerWithChildren {
|
||||
return (
|
||||
node.type === "root" ||
|
||||
node.type === "box" ||
|
||||
node.type === "text" ||
|
||||
node.type === "static" ||
|
||||
node.type === "transform"
|
||||
node.type === "tui-box" ||
|
||||
node.type === "tui-text" ||
|
||||
node.type === "tui-static" ||
|
||||
node.type === "tui-transform"
|
||||
);
|
||||
}
|
||||
|
||||
@@ -60,7 +60,7 @@ function applyZeroContentGuards(node: TuiNode, guarded: Map<YogaNode, number>):
|
||||
if (hasYoga(node) && node.yoga.getDisplay() === Yoga.DISPLAY_NONE) return false;
|
||||
|
||||
let changed = false;
|
||||
if (node.type === "box") {
|
||||
if (node.type === "tui-box") {
|
||||
const inner = getBoxInnerSize(node);
|
||||
if (inner.width === 0 || inner.height === 0) {
|
||||
for (const child of node.children) {
|
||||
|
||||
@@ -97,7 +97,11 @@ function findRoot(node: TuiNode): TuiRoot | null {
|
||||
function isInsideTextOrTransformContext(node: TuiContainer): boolean {
|
||||
let current: TuiContainer | null = node;
|
||||
while (current) {
|
||||
if (current.type === "text" || current.type === "virtual-text" || current.type === "transform")
|
||||
if (
|
||||
current.type === "tui-text" ||
|
||||
current.type === "tui-virtual-text" ||
|
||||
current.type === "tui-transform"
|
||||
)
|
||||
return true;
|
||||
current = current.parent;
|
||||
}
|
||||
@@ -121,8 +125,8 @@ function isInsideTextOrTransformContext(node: TuiContainer): boolean {
|
||||
function findMeasureOwner(start: TuiNode | null): TuiNode | null {
|
||||
let p: TuiNode | null = start;
|
||||
while (p) {
|
||||
if (p.type === "text") return p;
|
||||
if (p.type === "transform") {
|
||||
if (p.type === "tui-text") return p;
|
||||
if (p.type === "tui-transform") {
|
||||
const inlineInTextContext =
|
||||
p.parent != null &&
|
||||
isContainer(p.parent) &&
|
||||
@@ -144,12 +148,16 @@ function findMeasureOwner(start: TuiNode | null): TuiNode | null {
|
||||
* directly, no measure func to invalidate).
|
||||
*/
|
||||
function dirtyTextMeasureOwner(parent: TuiNode): void {
|
||||
if (parent.type !== "text" && parent.type !== "virtual-text" && parent.type !== "transform") {
|
||||
if (
|
||||
parent.type !== "tui-text" &&
|
||||
parent.type !== "tui-virtual-text" &&
|
||||
parent.type !== "tui-transform"
|
||||
) {
|
||||
return;
|
||||
}
|
||||
const owner = findMeasureOwner(parent);
|
||||
if (owner?.type === "transform") markTransformDirty(owner);
|
||||
else if (owner?.type === "text") markTextDirty(owner);
|
||||
if (owner?.type === "tui-transform") markTransformDirty(owner);
|
||||
else if (owner?.type === "tui-text") markTextDirty(owner);
|
||||
}
|
||||
|
||||
export function buildNodeOps(options: TtyRendererOptions): RendererOptions<TuiNode, TuiNode> {
|
||||
@@ -157,25 +165,25 @@ export function buildNodeOps(options: TtyRendererOptions): RendererOptions<TuiNo
|
||||
|
||||
function createElement(type: string): TuiNode {
|
||||
switch (type) {
|
||||
case "box": {
|
||||
case "tui-box": {
|
||||
const n = createBox();
|
||||
attachYoga(n);
|
||||
return n;
|
||||
}
|
||||
case "text": {
|
||||
case "tui-text": {
|
||||
const n = createText();
|
||||
attachYoga(n);
|
||||
bindTextMeasure(n);
|
||||
return n;
|
||||
}
|
||||
case "virtual-text":
|
||||
case "tui-virtual-text":
|
||||
return createVirtualText();
|
||||
case "static": {
|
||||
case "tui-static": {
|
||||
const n = createStatic();
|
||||
attachYoga(n);
|
||||
return n;
|
||||
}
|
||||
case "transform": {
|
||||
case "tui-transform": {
|
||||
const n = createTransform((line) => line); // overwritten by patchProp
|
||||
attachYoga(n);
|
||||
return n;
|
||||
@@ -208,9 +216,9 @@ export function buildNodeOps(options: TtyRendererOptions): RendererOptions<TuiNo
|
||||
// transform (inside a <Text> or another <Transform>) does NOT, so we must keep
|
||||
// climbing to the enclosing <Text>/standalone-transform measure owner. (G58)
|
||||
const owner = findMeasureOwner(node.parent as TuiNode | null);
|
||||
if (owner?.type === "text") {
|
||||
if (owner?.type === "tui-text") {
|
||||
markTextDirty(owner);
|
||||
} else if (owner?.type === "transform") {
|
||||
} else if (owner?.type === "tui-transform") {
|
||||
markTransformDirty(owner);
|
||||
}
|
||||
onCommit();
|
||||
@@ -221,7 +229,7 @@ export function buildNodeOps(options: TtyRendererOptions): RendererOptions<TuiNo
|
||||
// Remove existing children first (copy since remove mutates the array).
|
||||
for (const child of Array.from(el.children)) remove(child);
|
||||
insert(createTextLeaf(text), el, null);
|
||||
if (el.type === "text") {
|
||||
if (el.type === "tui-text") {
|
||||
markTextDirty(el);
|
||||
}
|
||||
}
|
||||
@@ -239,7 +247,7 @@ export function buildNodeOps(options: TtyRendererOptions): RendererOptions<TuiNo
|
||||
// `hostContext.isInsideText && originalType === 'ink-box'`). A standalone
|
||||
// <Transform> is a text context here (G58), so we use the transform-aware
|
||||
// context check to mirror Ink exactly. (G58 should-fix)
|
||||
if (child.type === "box" && isInsideTextOrTransformContext(parentC)) {
|
||||
if (child.type === "tui-box" && isInsideTextOrTransformContext(parentC)) {
|
||||
throw new Error("<Box> can’t be nested inside <Text> component");
|
||||
}
|
||||
|
||||
@@ -248,7 +256,7 @@ export function buildNodeOps(options: TtyRendererOptions): RendererOptions<TuiNo
|
||||
if (
|
||||
child.type === "text-leaf" &&
|
||||
child.value !== "" &&
|
||||
(parentC.type === "box" || parentC.type === "root" || parentC.type === "static") &&
|
||||
(parentC.type === "tui-box" || parentC.type === "root" || parentC.type === "tui-static") &&
|
||||
!isInsideTextOrTransformContext(parentC)
|
||||
) {
|
||||
throw new Error(`Text string "${child.value}" must be rendered inside <Text> component`);
|
||||
@@ -288,7 +296,7 @@ export function buildNodeOps(options: TtyRendererOptions): RendererOptions<TuiNo
|
||||
dirtyTextMeasureOwner(parentC);
|
||||
|
||||
// Track static node identity on the root (mirrors Ink's reconciler).
|
||||
if (child.type === "static") {
|
||||
if (child.type === "tui-static") {
|
||||
const root = findRoot(child);
|
||||
if (root) root.staticNode = child;
|
||||
}
|
||||
@@ -303,7 +311,7 @@ export function buildNodeOps(options: TtyRendererOptions): RendererOptions<TuiNo
|
||||
// Track static node removal: clear root.staticNode only if it still
|
||||
// points at this node. On key-driven remounts, insert() already
|
||||
// registered the new instance before the old one is removed.
|
||||
if (child.type === "static") {
|
||||
if (child.type === "tui-static") {
|
||||
const root = findRoot(child);
|
||||
if (root && root.staticNode === child) {
|
||||
root.staticNode = undefined;
|
||||
@@ -336,10 +344,10 @@ export function buildNodeOps(options: TtyRendererOptions): RendererOptions<TuiNo
|
||||
}
|
||||
}
|
||||
if (
|
||||
node.type === "box" ||
|
||||
node.type === "text" ||
|
||||
node.type === "static" ||
|
||||
node.type === "transform"
|
||||
node.type === "tui-box" ||
|
||||
node.type === "tui-text" ||
|
||||
node.type === "tui-static" ||
|
||||
node.type === "tui-transform"
|
||||
) {
|
||||
detachYoga(node);
|
||||
}
|
||||
@@ -358,21 +366,26 @@ export function buildNodeOps(options: TtyRendererOptions): RendererOptions<TuiNo
|
||||
}
|
||||
|
||||
function patchProp(el: TuiNode, key: string, prev: unknown, next: unknown): void {
|
||||
if (el.type === "transform") {
|
||||
if (el.type === "tui-transform") {
|
||||
if (key === "transform" && typeof next === "function") {
|
||||
el.transform = next as (line: string, idx: number) => string;
|
||||
}
|
||||
onCommit();
|
||||
return;
|
||||
}
|
||||
if (el.type === "static" && key === "internal_onWritten") {
|
||||
if (el.type === "tui-static" && key === "internal_onWritten") {
|
||||
// Callback the renderer invokes post-commit to advance the <Static>
|
||||
// component's cursor so written items unmount. Not styling/layout.
|
||||
el.onWritten = typeof next === "function" ? (next as () => void) : undefined;
|
||||
onCommit();
|
||||
return;
|
||||
}
|
||||
if (el.type === "box" || el.type === "text" || el.type === "static" || el.type === "root") {
|
||||
if (
|
||||
el.type === "tui-box" ||
|
||||
el.type === "tui-text" ||
|
||||
el.type === "tui-static" ||
|
||||
el.type === "root"
|
||||
) {
|
||||
if (isYogaProp(key)) {
|
||||
applyYogaProp(el, key, next, prev);
|
||||
// Some yoga props also need to be stored in el.props for the paint pass.
|
||||
@@ -402,12 +415,12 @@ export function buildNodeOps(options: TtyRendererOptions): RendererOptions<TuiNo
|
||||
} else if (STYLE_PROPS.has(key)) {
|
||||
(el as { props: Record<string, unknown> }).props[key] = next;
|
||||
} else if (key === "aria-role" || key === "ariaRole") {
|
||||
if (el.type === "box") {
|
||||
if (el.type === "tui-box") {
|
||||
el.internal_accessibility ??= {};
|
||||
el.internal_accessibility.role = next as string;
|
||||
}
|
||||
} else if (key === "aria-state" || key === "ariaState") {
|
||||
if (el.type === "box") {
|
||||
if (el.type === "tui-box") {
|
||||
el.internal_accessibility ??= {};
|
||||
el.internal_accessibility.state = next as Record<string, boolean>;
|
||||
}
|
||||
@@ -429,7 +442,7 @@ export function buildNodeOps(options: TtyRendererOptions): RendererOptions<TuiNo
|
||||
onCommit();
|
||||
return;
|
||||
}
|
||||
if (el.type === "virtual-text" && STYLE_PROPS.has(key)) {
|
||||
if (el.type === "tui-virtual-text" && STYLE_PROPS.has(key)) {
|
||||
(el.props as Record<string, unknown>)[key] = next;
|
||||
onCommit();
|
||||
}
|
||||
|
||||
@@ -4,7 +4,7 @@ import { buildNodeOps } from "./node-ops.ts";
|
||||
|
||||
test("createBox returns shape with empty children + paintDirty true", () => {
|
||||
const box = createBox();
|
||||
expect(box.type).toBe("box");
|
||||
expect(box.type).toBe("tui-box");
|
||||
expect(box.children).toEqual([]);
|
||||
expect(box.paintDirty).toBe(true);
|
||||
expect(box.parent).toBe(null);
|
||||
@@ -36,7 +36,7 @@ test("setText coerces a non-string value to a string (Ink setTextNodeValue)", ()
|
||||
test("createTransform stores its transform function", () => {
|
||||
const fn = (line: string) => line.toUpperCase();
|
||||
const node = createTransform(fn);
|
||||
expect(node.type).toBe("transform");
|
||||
expect(node.type).toBe("tui-transform");
|
||||
expect(node.transform).toBe(fn);
|
||||
});
|
||||
|
||||
|
||||
@@ -40,7 +40,7 @@ export interface TuiRoot extends NodeBase {
|
||||
}
|
||||
|
||||
export interface TuiBox extends NodeBase {
|
||||
type: "box";
|
||||
type: "tui-box";
|
||||
children: TuiNode[];
|
||||
yoga: YogaNodeRef;
|
||||
props: BoxProps;
|
||||
@@ -52,7 +52,7 @@ export interface TuiBox extends NodeBase {
|
||||
}
|
||||
|
||||
export interface TuiText extends NodeBase {
|
||||
type: "text";
|
||||
type: "tui-text";
|
||||
children: TuiInlineNode[];
|
||||
yoga: YogaNodeRef;
|
||||
props: TextProps;
|
||||
@@ -60,7 +60,7 @@ export interface TuiText extends NodeBase {
|
||||
}
|
||||
|
||||
export interface TuiVirtualText extends NodeBase {
|
||||
type: "virtual-text";
|
||||
type: "tui-virtual-text";
|
||||
// A <Newline>/<Text> directly inside a standalone <Transform> renders inline,
|
||||
// so a virtual-text can also be parented by a transform (G58).
|
||||
parent: TuiText | TuiVirtualText | TuiTransform | null;
|
||||
@@ -83,7 +83,7 @@ export interface TuiComment extends NodeBase {
|
||||
}
|
||||
|
||||
export interface TuiStatic extends NodeBase {
|
||||
type: "static";
|
||||
type: "tui-static";
|
||||
children: TuiNode[];
|
||||
yoga: YogaNodeRef;
|
||||
props: BoxProps;
|
||||
@@ -109,7 +109,7 @@ export interface TuiStatic extends NodeBase {
|
||||
}
|
||||
|
||||
export interface TuiTransform extends NodeBase {
|
||||
type: "transform";
|
||||
type: "tui-transform";
|
||||
children: TuiNode[];
|
||||
yoga: YogaNodeRef;
|
||||
transform: (line: string, lineIndex: number) => string;
|
||||
@@ -154,7 +154,7 @@ export function emitLayoutListeners(root: TuiRoot): void {
|
||||
|
||||
export function createBox(): TuiBox {
|
||||
return {
|
||||
type: "box",
|
||||
type: "tui-box",
|
||||
parent: null,
|
||||
children: [],
|
||||
yoga: UNATTACHED_YOGA,
|
||||
@@ -165,7 +165,7 @@ export function createBox(): TuiBox {
|
||||
|
||||
export function createText(): TuiText {
|
||||
return {
|
||||
type: "text",
|
||||
type: "tui-text",
|
||||
parent: null,
|
||||
children: [],
|
||||
yoga: UNATTACHED_YOGA,
|
||||
@@ -175,7 +175,7 @@ export function createText(): TuiText {
|
||||
|
||||
export function createVirtualText(): TuiVirtualText {
|
||||
return {
|
||||
type: "virtual-text",
|
||||
type: "tui-virtual-text",
|
||||
parent: null,
|
||||
children: [],
|
||||
props: {},
|
||||
@@ -197,7 +197,7 @@ export function createTextLeaf(value: string): TuiTextLeaf {
|
||||
|
||||
export function createStatic(): TuiStatic {
|
||||
return {
|
||||
type: "static",
|
||||
type: "tui-static",
|
||||
parent: null,
|
||||
children: [],
|
||||
yoga: UNATTACHED_YOGA,
|
||||
@@ -208,7 +208,7 @@ export function createStatic(): TuiStatic {
|
||||
|
||||
export function createTransform(fn: (line: string, lineIndex: number) => string): TuiTransform {
|
||||
return {
|
||||
type: "transform",
|
||||
type: "tui-transform",
|
||||
parent: null,
|
||||
children: [],
|
||||
yoga: UNATTACHED_YOGA,
|
||||
|
||||
@@ -69,10 +69,10 @@ function squashTransformChild(child: TuiNode, index: number): string {
|
||||
if (child.type === "text-leaf") {
|
||||
return child.value;
|
||||
}
|
||||
if (child.type === "virtual-text" || child.type === "text") {
|
||||
if (child.type === "tui-virtual-text" || child.type === "tui-text") {
|
||||
return flattenLeaves(child);
|
||||
}
|
||||
if (child.type === "transform") {
|
||||
if (child.type === "tui-transform") {
|
||||
let innerText = "";
|
||||
// Recursive twin of the G52 fix in flattenLeaves: a grandchild's positional
|
||||
// index must skip Vue comment nodes (null/v-if/false renders) so a `{null}`
|
||||
|
||||
@@ -52,10 +52,10 @@ export const yogaNodeTracker = {
|
||||
function hasYoga(node: TuiNode): node is YogaCarrier {
|
||||
return (
|
||||
node.type === "root" ||
|
||||
node.type === "box" ||
|
||||
node.type === "text" ||
|
||||
node.type === "static" ||
|
||||
node.type === "transform"
|
||||
node.type === "tui-box" ||
|
||||
node.type === "tui-text" ||
|
||||
node.type === "tui-static" ||
|
||||
node.type === "tui-transform"
|
||||
);
|
||||
}
|
||||
|
||||
@@ -63,7 +63,7 @@ export function attachYoga(node: YogaCarrier): void {
|
||||
node.yoga = createYogaNode();
|
||||
// Static nodes are painted via a separate channel (paintIsolated), so they
|
||||
// must not occupy space in the dynamic frame's yoga layout.
|
||||
if (node.type === "static") {
|
||||
if (node.type === "tui-static") {
|
||||
(node.yoga as YogaNode).setDisplay(Yoga.DISPLAY_NONE);
|
||||
}
|
||||
// Box nodes match Ink's defaults: row direction, shrinkable, no wrap.
|
||||
@@ -71,7 +71,7 @@ export function attachYoga(node: YogaCarrier): void {
|
||||
// are passed through Vue's reactive system (which may include undefined
|
||||
// overrides or border defaults). User-provided props override these via
|
||||
// patchProp which runs after attachYoga.
|
||||
if (node.type === "box") {
|
||||
if (node.type === "tui-box") {
|
||||
(node.yoga as YogaNode).setFlexDirection(Yoga.FLEX_DIRECTION_ROW);
|
||||
(node.yoga as YogaNode).setFlexShrink(1);
|
||||
(node.yoga as YogaNode).setFlexWrap(Yoga.WRAP_NO_WRAP);
|
||||
@@ -80,7 +80,7 @@ export function attachYoga(node: YogaCarrier): void {
|
||||
// Text nodes match Ink's <ink-text> defaults: row direction, shrinkable.
|
||||
// Although text nodes rarely have yoga-carrying children, this ensures
|
||||
// consistent layout behavior matching Ink.
|
||||
if (node.type === "text") {
|
||||
if (node.type === "tui-text") {
|
||||
(node.yoga as YogaNode).setFlexDirection(Yoga.FLEX_DIRECTION_ROW);
|
||||
(node.yoga as YogaNode).setFlexShrink(1);
|
||||
(node.yoga as YogaNode).setFlexGrow(0);
|
||||
@@ -88,7 +88,7 @@ export function attachYoga(node: YogaCarrier): void {
|
||||
// Transform nodes match Ink's Transform which renders as ink-text:
|
||||
// flexShrink=1, flexDirection='row'. This makes transform a yoga carrier
|
||||
// so it participates in layout (multi-line text gets proper height).
|
||||
if (node.type === "transform") {
|
||||
if (node.type === "tui-transform") {
|
||||
(node.yoga as YogaNode).setFlexDirection(Yoga.FLEX_DIRECTION_ROW);
|
||||
(node.yoga as YogaNode).setFlexShrink(1);
|
||||
(node.yoga as YogaNode).setFlexGrow(0);
|
||||
@@ -120,13 +120,15 @@ function yogaIndexFor(parent: TuiContainer, child: TuiNode): number {
|
||||
// ink-text), so a transform child of it is inline and excluded from yoga —
|
||||
// same as for a text/virtual-text parent. (G58 MF2)
|
||||
const isTextParent =
|
||||
parent.type === "text" || parent.type === "virtual-text" || parent.type === "transform";
|
||||
parent.type === "tui-text" ||
|
||||
parent.type === "tui-virtual-text" ||
|
||||
parent.type === "tui-transform";
|
||||
let yIdx = 0;
|
||||
for (const sibling of parent.children) {
|
||||
if (sibling === child) return yIdx;
|
||||
if (hasYoga(sibling)) {
|
||||
// Transform nodes inside text/transform parents are not in the yoga tree.
|
||||
if (isTextParent && sibling.type === "transform") continue;
|
||||
if (isTextParent && sibling.type === "tui-transform") continue;
|
||||
yIdx++;
|
||||
}
|
||||
}
|
||||
@@ -145,14 +147,17 @@ export function insertYogaChild(parent: TuiContainer, child: TuiNode, _domIndex:
|
||||
// because a transform-in-transform inside a <Text> was already excluded as a
|
||||
// child of an inline transform here.)
|
||||
// (VirtualText parents are already excluded by the hasYoga check above.)
|
||||
if (child.type === "transform" && (parent.type === "text" || parent.type === "transform")) {
|
||||
if (
|
||||
child.type === "tui-transform" &&
|
||||
(parent.type === "tui-text" || parent.type === "tui-transform")
|
||||
) {
|
||||
return;
|
||||
}
|
||||
// A transform with a yoga-carrying child (e.g. <Transform><Text>…) lays out
|
||||
// from that child, not from a measure func. Yoga forbids a node having both a
|
||||
// measure func and children, so clear the standalone-transform measure func
|
||||
// before inserting the child. (G58)
|
||||
if (parent.type === "transform") {
|
||||
if (parent.type === "tui-transform") {
|
||||
(parent.yoga as YogaNode).unsetMeasureFunc();
|
||||
}
|
||||
const yIdx = yogaIndexFor(parent, child);
|
||||
@@ -163,14 +168,17 @@ export function removeYogaChild(parent: TuiContainer, child: TuiNode): void {
|
||||
if (!hasYoga(parent) || !hasYoga(child)) return;
|
||||
// Transform nodes inside a text/transform parent were never inserted into yoga
|
||||
// (mirror of insertYogaChild's inline-transform skip). (G58 MF2)
|
||||
if (child.type === "transform" && (parent.type === "text" || parent.type === "transform")) {
|
||||
if (
|
||||
child.type === "tui-transform" &&
|
||||
(parent.type === "tui-text" || parent.type === "tui-transform")
|
||||
) {
|
||||
return;
|
||||
}
|
||||
(parent.yoga as YogaNode).removeChild(child.yoga as YogaNode);
|
||||
// If removing the last yoga child from a transform, restore the inline-text
|
||||
// measure func so the transform can still size its direct text-leaf children
|
||||
// (it has become a standalone inline-text transform again). (G58)
|
||||
if (parent.type === "transform" && (parent.yoga as YogaNode).getChildCount() === 0) {
|
||||
if (parent.type === "tui-transform" && (parent.yoga as YogaNode).getChildCount() === 0) {
|
||||
bindTransformMeasure(parent as TuiTransform);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -404,7 +404,7 @@ function squashInlineChildren(children: readonly TuiNode[], inheritedBg: unknown
|
||||
// with no yoga-carrying children) as if it were an inline text node — its
|
||||
// direct text-leaf / virtual-text / <Newline> children are squashed into a
|
||||
// string. The transform's OWN fn is intentionally NOT applied here: it is pushed
|
||||
// as a line-transformer onto the Output write (paintNode "transform" case), so
|
||||
// as a line-transformer onto the Output write (paintNode "tui-transform" case), so
|
||||
// it applies per LINE at paint time, matching Ink where internal_transform runs
|
||||
// in the Output, never in squashTextNodes for the node it lives on. (G58)
|
||||
function renderTransformAsText(node: TuiTransform, inheritedBg?: unknown): string {
|
||||
@@ -435,10 +435,10 @@ function squashTransformChild(child: TuiNode, index: number, inheritedBg: unknow
|
||||
if (child.type === "text-leaf") {
|
||||
return child.value;
|
||||
}
|
||||
if (child.type === "virtual-text" || child.type === "text") {
|
||||
if (child.type === "tui-virtual-text" || child.type === "tui-text") {
|
||||
return renderTextWithInlineStyles(child, inheritedBg);
|
||||
}
|
||||
if (child.type === "transform") {
|
||||
if (child.type === "tui-transform") {
|
||||
let innerText = "";
|
||||
// Recursive twin of the G52 fix in renderTextWithInlineStyles: a grandchild's
|
||||
// positional index must skip Vue comment nodes (null/v-if/false renders),
|
||||
@@ -631,7 +631,7 @@ function paintNode(
|
||||
for (const child of node.children) paintNode(child, output, x0, y0, transformers);
|
||||
return;
|
||||
}
|
||||
case "box": {
|
||||
case "tui-box": {
|
||||
const layout = node.yoga.getComputedLayout();
|
||||
const x = x0 + layout.left;
|
||||
const y = y0 + layout.top;
|
||||
@@ -709,7 +709,7 @@ function paintNode(
|
||||
if (clipped) output.unclip();
|
||||
return;
|
||||
}
|
||||
case "text": {
|
||||
case "tui-text": {
|
||||
const layout = node.yoga.getComputedLayout();
|
||||
// Thread the INHERITED Box bg (NOT a pre-computed effective bg) into the
|
||||
// squash. The Text's own backgroundColor — including an explicit "" opt-out —
|
||||
@@ -753,12 +753,12 @@ function paintNode(
|
||||
output.write(x0 + layout.left, y0 + layout.top, wrapped, transformers);
|
||||
return;
|
||||
}
|
||||
case "static": {
|
||||
case "tui-static": {
|
||||
// Static is rendered through the static channel (written before frame), so
|
||||
// it does not contribute to the dynamic frame paint.
|
||||
return;
|
||||
}
|
||||
case "transform": {
|
||||
case "tui-transform": {
|
||||
const layout = node.yoga.getComputedLayout();
|
||||
const x = x0 + layout.left;
|
||||
const y = y0 + layout.top;
|
||||
@@ -787,7 +787,7 @@ function paintNode(
|
||||
for (const child of node.children) paintNode(child, output, x, y, next, inheritedBg);
|
||||
return;
|
||||
}
|
||||
case "virtual-text":
|
||||
case "tui-virtual-text":
|
||||
case "text-leaf":
|
||||
case "comment":
|
||||
// virtual-text and text-leaf are handled inside renderTextWithInlineStyles.
|
||||
|
||||
@@ -18,10 +18,10 @@ function squashChildSR(child: TuiNode, index: number): string {
|
||||
if (child.type === "text-leaf") {
|
||||
return child.value;
|
||||
}
|
||||
if (child.type === "virtual-text" || child.type === "text") {
|
||||
if (child.type === "tui-virtual-text" || child.type === "tui-text") {
|
||||
return squashTextContent(child);
|
||||
}
|
||||
if (child.type === "transform") {
|
||||
if (child.type === "tui-transform") {
|
||||
let innerText = "";
|
||||
// Recurse into the transform's children (each may itself be a transform,
|
||||
// recursed to any depth), skipping Vue comment nodes and empty text-leaves
|
||||
@@ -119,16 +119,16 @@ export interface ScreenReaderOptions {
|
||||
*/
|
||||
export function renderScreenReaderOutput(node: TuiNode, options: ScreenReaderOptions = {}): string {
|
||||
// Skip static elements if requested
|
||||
if (options.skipStaticElements && node.type === "static") {
|
||||
if (options.skipStaticElements && node.type === "tui-static") {
|
||||
return "";
|
||||
}
|
||||
|
||||
// If display: none, return empty
|
||||
if (
|
||||
(node.type === "box" ||
|
||||
node.type === "text" ||
|
||||
(node.type === "tui-box" ||
|
||||
node.type === "tui-text" ||
|
||||
node.type === "root" ||
|
||||
node.type === "transform") &&
|
||||
node.type === "tui-transform") &&
|
||||
node.yoga.getDisplay() === Yoga.DISPLAY_NONE
|
||||
) {
|
||||
return "";
|
||||
@@ -136,14 +136,15 @@ export function renderScreenReaderOutput(node: TuiNode, options: ScreenReaderOpt
|
||||
|
||||
let output = "";
|
||||
|
||||
if (node.type === "text") {
|
||||
if (node.type === "tui-text") {
|
||||
output = squashTextContent(node);
|
||||
} else if (node.type === "box" || node.type === "root") {
|
||||
} else if (node.type === "tui-box" || node.type === "root") {
|
||||
// Determine separator based on flex direction (resolved from yoga so the
|
||||
// Box default of row yields a space separator, matching Ink — see
|
||||
// resolveBoxFlexDirection / G39). Root keeps undefined → "\n" (Ink's column
|
||||
// default root).
|
||||
const flexDirection = node.type === "box" ? resolveBoxFlexDirection(node as TuiBox) : undefined;
|
||||
const flexDirection =
|
||||
node.type === "tui-box" ? resolveBoxFlexDirection(node as TuiBox) : undefined;
|
||||
|
||||
const separator = flexDirection === "row" || flexDirection === "row-reverse" ? " " : "\n";
|
||||
|
||||
@@ -170,7 +171,7 @@ export function renderScreenReaderOutput(node: TuiNode, options: ScreenReaderOpt
|
||||
)
|
||||
.filter(Boolean)
|
||||
.join(separator);
|
||||
} else if (node.type === "transform") {
|
||||
} else if (node.type === "tui-transform") {
|
||||
// Transform nodes: CONCATENATE children with "" (not newline-join), matching
|
||||
// Ink's squashTextNodes (squash-text-nodes.ts:42, `text += nodeText`). In Ink
|
||||
// a <Transform> is an `ink-text` node, so the SR path squashes it via
|
||||
@@ -209,7 +210,7 @@ export function renderScreenReaderOutput(node: TuiNode, options: ScreenReaderOpt
|
||||
}
|
||||
|
||||
// Add accessibility annotations
|
||||
if (node.type === "box") {
|
||||
if (node.type === "tui-box") {
|
||||
const accessibility = node.internal_accessibility;
|
||||
if (accessibility) {
|
||||
const { role, state } = accessibility;
|
||||
|
||||
@@ -26,7 +26,7 @@ function resolvedFlexDirection(stat: TuiStatic): string {
|
||||
}
|
||||
|
||||
export function findStatics(root: TuiNode, out: TuiStatic[] = []): TuiStatic[] {
|
||||
if (root.type === "static") out.push(root);
|
||||
if (root.type === "tui-static") out.push(root);
|
||||
if (root.type !== "text-leaf" && root.type !== "comment") {
|
||||
const containerChildren = (root as { children: TuiNode[] }).children;
|
||||
for (const child of containerChildren) findStatics(child, out);
|
||||
|
||||
@@ -3,7 +3,11 @@ import vueJsx from "@vitejs/plugin-vue-jsx";
|
||||
import Vue from "unplugin-vue/rolldown";
|
||||
import VueVite from "unplugin-vue/vite";
|
||||
|
||||
const HOST_TAGS = ["box", "text", "virtual-text", "static", "transform"];
|
||||
// Host primitive tags carry a `tui-` prefix (mirroring Ink's `ink-box`/`ink-text`):
|
||||
// it keeps the renderer's intrinsic elements out of the component namespace so a
|
||||
// template `<tui-box>` never collides with the public `<Box>` component (no vue-tsc
|
||||
// self-recursion). The hyphen also makes them valid custom-element names.
|
||||
const HOST_TAGS = ["tui-box", "tui-text", "tui-virtual-text", "tui-static", "tui-transform"];
|
||||
|
||||
export default defineConfig({
|
||||
// `VueVite` parses `.vue` SFCs in the TEST/dev graph (unit tests may import the
|
||||
|
||||
Reference in New Issue
Block a user