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:
Yunfei He
2026-06-14 11:38:46 +08:00
parent 9fe6d7cc44
commit ca63a5d04c
21 changed files with 162 additions and 127 deletions
+14 -9
View File
@@ -73,7 +73,7 @@ and fixed at the root:
`fresh.length > 0` gate and `paintIsolated` painted the container's **padding** as stray `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`. 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 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 - **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 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 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 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` `: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. 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`/ - **Host primitive tags are `tui-`-prefixed** (`tui-box`/`tui-text`/`tui-virtual-text`/
`StaticImpl` via `defineOptions`). vue-tsc 3.3.4 self-recurses a `<box>` tag to a component `tui-static`/`tui-transform`), mirroring Ink's `ink-box`/`ink-text`. The prefix keeps the
named "Box" (it has no `isCustomElement` at the type layer). Public names (`Box`/`Text`/ renderer's intrinsic elements in their own namespace, so a template `<tui-box>` never
`Static`) come from `index.ts`. 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 - **Don't reintroduce parent-walking or `parent.type.name` matching for context** — use
provide/inject (`.name` is also fragile under minification). provide/inject (`.name` is also fragile under minification).
- **Don't force child-vnode inspection into a template** (the double-materialization wart). If - **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. 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 - The host elements (`tui-box`, `tui-text`, `tui-virtual-text`, `tui-static`, `tui-transform`)
element vnodes via the build's `isCustomElement` option and are an **internal** detail. compile to raw element vnodes via the build's `isCustomElement` option and are an **internal**
Consumers use `<Box>` / `<Text>`, never `<box>`. SFC templates may reference the host tags detail. Consumers use `<Box>` / `<Text>`, never `<tui-box>`. SFC templates may reference the
directly; their loose typing under `vue-tsc` (no `strictTemplates`) is intentional. host tags directly; their loose typing under `vue-tsc` (no `strictTemplates`) is intentional.
- Components export typed props (`ExtractPublicPropTypes` over the runtime props object) and - Components export typed props (`ExtractPublicPropTypes` over the runtime props object) and
keep the `WithChildren` shim (`with-children.ts`): Vue's automatic JSX runtime routes 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 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( const { lastFrame } = await render(
defineComponent( defineComponent(
() => () => () => () =>
h("box", { backgroundColor: "red", width: 5, height: 2 }, [ h("tui-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: [0, 0, 255], width: 5, height: 2 }, [
h("tui-text", null, "Hi"),
]),
]), ]),
), ),
{ columns: 100 }, { columns: 100 },
@@ -828,8 +830,8 @@ test("non-string host Text backgroundColor does not override inherited backgroun
const { lastFrame } = await render( const { lastFrame } = await render(
defineComponent( defineComponent(
() => () => () => () =>
h("box", { backgroundColor: "red", alignSelf: "flex-start" }, [ h("tui-box", { backgroundColor: "red", alignSelf: "flex-start" }, [
h("text", { backgroundColor: [0, 0, 255] }, "Hi"), h("tui-text", { backgroundColor: [0, 0, 255] }, "Hi"),
]), ]),
), ),
{ columns: 100 }, { columns: 100 },
@@ -884,7 +884,7 @@ test("non-string host borderTopColor falls back to general borderColor", async (
const { lastFrame } = await render( const { lastFrame } = await render(
defineComponent( defineComponent(
() => () => () => () =>
h("box", { h("tui-box", {
borderStyle: "single", borderStyle: "single",
borderColor: "red", borderColor: "red",
borderTopColor: [0, 0, 255], borderTopColor: [0, 0, 255],
@@ -1189,7 +1189,7 @@ test("non-string host borderTopBackgroundColor falls back to general borderBackg
const { lastFrame } = await render( const { lastFrame } = await render(
defineComponent( defineComponent(
() => () => () => () =>
h("box", { h("tui-box", {
borderStyle: "single", borderStyle: "single",
borderBackgroundColor: "red", borderBackgroundColor: "red",
borderTopBackgroundColor: [0, 0, 255], borderTopBackgroundColor: [0, 0, 255],
@@ -455,7 +455,7 @@ function mountWithInput(kittyKeyboard: { mode: "auto" | "enabled" }) {
useInput((input) => { useInput((input) => {
inputs.push(input); inputs.push(input);
}); });
return () => h("text", null, "x"); return () => h("tui-text", null, "x");
}); });
const app = createApp(App); const app = createApp(App);
@@ -101,7 +101,7 @@ export function assertBoxValid(props: BoxProps): true {
} }
// NOTE: this component-level validation covers the public `<Box>`/`<Text>` // 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 // 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 // rather than throwing (a throw in the post-flush paint pass wedges Vue's
// scheduler). Same accepted limitation as the borderStyle fix (#124). // scheduler). Same accepted limitation as the borderStyle fix (#124).
+7 -6
View File
@@ -4,9 +4,10 @@ import { AppContextKey } from "../context.ts";
import { boxProps } from "./box-props.ts"; import { boxProps } from "./box-props.ts";
import { assertBoxValid } from "./box-validate.ts"; import { assertBoxValid } from "./box-validate.ts";
// Internal name != "Box" to avoid vue-tsc self-recursion on the `<box>` host tag. // Renders the `<tui-box>` host primitive. The host tag's `tui-` prefix keeps it out
// The public export name "Box" comes from index.ts. // of the component namespace, so the component can take its real name "Box" with no
defineOptions({ name: "BoxImpl" }); // vue-tsc self-recursion on the tag. Public export wired in index.ts.
defineOptions({ name: "Box" });
const props = defineProps(boxProps); const props = defineProps(boxProps);
defineSlots<{ default?: () => unknown }>(); defineSlots<{ default?: () => unknown }>();
const appCtx = inject(AppContextKey, null); 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 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 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. --> resolves to the real `box` host node, so measureElement/useBoxMetrics work. -->
<box v-if="!srHidden && assertBoxValid(props)" v-bind="props"> <tui-box v-if="!srHidden && assertBoxValid(props)" v-bind="props">
<text v-if="srEnabled && props.ariaLabel">{{ props.ariaLabel }}</text> <tui-text v-if="srEnabled && props.ariaLabel">{{ props.ariaLabel }}</tui-text>
<slot v-else /> <slot v-else />
</box> </tui-box>
</template> </template>
+2 -2
View File
@@ -12,6 +12,6 @@ const content = computed(() => "\n".repeat(props.count));
</script> </script>
<template> <template>
<virtual-text v-if="insideText">{{ content }}</virtual-text> <tui-virtual-text v-if="insideText">{{ content }}</tui-virtual-text>
<text v-else>{{ content }}</text> <tui-text v-else>{{ content }}</tui-text>
</template> </template>
+1 -1
View File
@@ -3,5 +3,5 @@ defineOptions({ name: "Spacer" });
</script> </script>
<template> <template>
<box :flexGrow="1" :flexShrink="1" /> <tui-box :flexGrow="1" :flexShrink="1" />
</template> </template>
+6 -6
View File
@@ -2,10 +2,10 @@
import { computed, shallowRef, watch } from "vue"; import { computed, shallowRef, watch } from "vue";
import { staticProps } from "./static-props.ts"; import { staticProps } from "./static-props.ts";
// Internal name deliberately != "Static": vue-tsc 3.3.4 would bind the `<static>` // Renders the `<tui-static>` host primitive. The host tag's `tui-` prefix keeps it out
// host tag below to this component (self-recursion) if they matched. Public export // of the component namespace, so the component can take its real name "Static" with no
// name is "Static" (index.ts). // vue-tsc self-recursion on the tag. Public export wired in index.ts.
defineOptions({ name: "StaticImpl" }); defineOptions({ name: "Static" });
const props = defineProps(staticProps); const props = defineProps(staticProps);
defineSlots<{ default?: (slotProps: { item: unknown; index: number }) => unknown }>(); defineSlots<{ default?: (slotProps: { item: unknown; index: number }) => unknown }>();
@@ -36,9 +36,9 @@ const itemsToRender = computed(() => (props.items as unknown[]).slice(cursor.val
</script> </script>
<template> <template>
<static v-bind="merged"> <tui-static v-bind="merged">
<template v-for="(item, i) in itemsToRender" :key="cursor + i"> <template v-for="(item, i) in itemsToRender" :key="cursor + i">
<slot :item="item" :index="cursor + i" /> <slot :item="item" :index="cursor + i" />
</template> </template>
</static> </tui-static>
</template> </template>
+8 -7
View File
@@ -4,9 +4,10 @@ import { AppContextKey, TextContextKey } from "../context.ts";
import { assertValidBackgroundColor, assertValidForegroundColor } from "../paint/text-style.ts"; import { assertValidBackgroundColor, assertValidForegroundColor } from "../paint/text-style.ts";
import { textProps } from "./text-props.ts"; import { textProps } from "./text-props.ts";
// Internal name != "Text" to avoid vue-tsc self-recursion on the `<text>` host tag. // Renders the `<tui-text>` / `<tui-virtual-text>` host primitives. The `tui-` prefix
// The public export name "Text" comes from index.ts. // keeps the host tags out of the component namespace, so the component can take its
defineOptions({ name: "TextImpl" }); // real name "Text" with no vue-tsc self-recursion. Public export wired in index.ts.
defineOptions({ name: "Text" });
const props = defineProps(textProps); const props = defineProps(textProps);
const slots = defineSlots<{ default?: () => unknown }>(); const slots = defineSlots<{ default?: () => unknown }>();
@@ -39,15 +40,15 @@ function validate(): true {
<template> <template>
<template v-if="!srHidden && validate() && hasContent"> <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> <template v-if="srLabel">{{ srLabel }}</template>
<slot v-else /> <slot v-else />
</virtual-text> </tui-virtual-text>
<!-- Match Ink's <Text> defaults: flexShrink=1 so text nodes shrink when they <!-- Match Ink's <Text> defaults: flexShrink=1 so text nodes shrink when they
overflow their container (e.g. in no-wrap flex rows). --> 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> <template v-if="srLabel">{{ srLabel }}</template>
<slot v-else /> <slot v-else />
</text> </tui-text>
</template> </template>
</template> </template>
+2 -2
View File
@@ -71,10 +71,10 @@ const TransformImpl = defineComponent({
// When screen reader is enabled and accessibilityLabel is set, // When screen reader is enabled and accessibilityLabel is set,
// render the label text instead of children. // render the label text instead of children.
if (isScreenReaderEnabled && props.accessibilityLabel) { 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);
}; };
}, },
}); });
+9 -9
View File
@@ -8,20 +8,20 @@ type ContainerWithChildren = TuiRoot | TuiBox | TuiText | TuiStatic | TuiTransfo
function hasYoga(node: TuiNode): node is YogaCarrier { function hasYoga(node: TuiNode): node is YogaCarrier {
return ( return (
node.type === "root" || node.type === "root" ||
node.type === "box" || node.type === "tui-box" ||
node.type === "text" || node.type === "tui-text" ||
node.type === "static" || node.type === "tui-static" ||
node.type === "transform" node.type === "tui-transform"
); );
} }
function hasChildren(node: TuiNode): node is ContainerWithChildren { function hasChildren(node: TuiNode): node is ContainerWithChildren {
return ( return (
node.type === "root" || node.type === "root" ||
node.type === "box" || node.type === "tui-box" ||
node.type === "text" || node.type === "tui-text" ||
node.type === "static" || node.type === "tui-static" ||
node.type === "transform" 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; if (hasYoga(node) && node.yoga.getDisplay() === Yoga.DISPLAY_NONE) return false;
let changed = false; let changed = false;
if (node.type === "box") { if (node.type === "tui-box") {
const inner = getBoxInnerSize(node); const inner = getBoxInnerSize(node);
if (inner.width === 0 || inner.height === 0) { if (inner.width === 0 || inner.height === 0) {
for (const child of node.children) { for (const child of node.children) {
+41 -28
View File
@@ -97,7 +97,11 @@ function findRoot(node: TuiNode): TuiRoot | null {
function isInsideTextOrTransformContext(node: TuiContainer): boolean { function isInsideTextOrTransformContext(node: TuiContainer): boolean {
let current: TuiContainer | null = node; let current: TuiContainer | null = node;
while (current) { 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; return true;
current = current.parent; current = current.parent;
} }
@@ -121,8 +125,8 @@ function isInsideTextOrTransformContext(node: TuiContainer): boolean {
function findMeasureOwner(start: TuiNode | null): TuiNode | null { function findMeasureOwner(start: TuiNode | null): TuiNode | null {
let p: TuiNode | null = start; let p: TuiNode | null = start;
while (p) { while (p) {
if (p.type === "text") return p; if (p.type === "tui-text") return p;
if (p.type === "transform") { if (p.type === "tui-transform") {
const inlineInTextContext = const inlineInTextContext =
p.parent != null && p.parent != null &&
isContainer(p.parent) && isContainer(p.parent) &&
@@ -144,12 +148,16 @@ function findMeasureOwner(start: TuiNode | null): TuiNode | null {
* directly, no measure func to invalidate). * directly, no measure func to invalidate).
*/ */
function dirtyTextMeasureOwner(parent: TuiNode): void { 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; return;
} }
const owner = findMeasureOwner(parent); const owner = findMeasureOwner(parent);
if (owner?.type === "transform") markTransformDirty(owner); if (owner?.type === "tui-transform") markTransformDirty(owner);
else if (owner?.type === "text") markTextDirty(owner); else if (owner?.type === "tui-text") markTextDirty(owner);
} }
export function buildNodeOps(options: TtyRendererOptions): RendererOptions<TuiNode, TuiNode> { export function buildNodeOps(options: TtyRendererOptions): RendererOptions<TuiNode, TuiNode> {
@@ -157,25 +165,25 @@ export function buildNodeOps(options: TtyRendererOptions): RendererOptions<TuiNo
function createElement(type: string): TuiNode { function createElement(type: string): TuiNode {
switch (type) { switch (type) {
case "box": { case "tui-box": {
const n = createBox(); const n = createBox();
attachYoga(n); attachYoga(n);
return n; return n;
} }
case "text": { case "tui-text": {
const n = createText(); const n = createText();
attachYoga(n); attachYoga(n);
bindTextMeasure(n); bindTextMeasure(n);
return n; return n;
} }
case "virtual-text": case "tui-virtual-text":
return createVirtualText(); return createVirtualText();
case "static": { case "tui-static": {
const n = createStatic(); const n = createStatic();
attachYoga(n); attachYoga(n);
return n; return n;
} }
case "transform": { case "tui-transform": {
const n = createTransform((line) => line); // overwritten by patchProp const n = createTransform((line) => line); // overwritten by patchProp
attachYoga(n); attachYoga(n);
return 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 // transform (inside a <Text> or another <Transform>) does NOT, so we must keep
// climbing to the enclosing <Text>/standalone-transform measure owner. (G58) // climbing to the enclosing <Text>/standalone-transform measure owner. (G58)
const owner = findMeasureOwner(node.parent as TuiNode | null); const owner = findMeasureOwner(node.parent as TuiNode | null);
if (owner?.type === "text") { if (owner?.type === "tui-text") {
markTextDirty(owner); markTextDirty(owner);
} else if (owner?.type === "transform") { } else if (owner?.type === "tui-transform") {
markTransformDirty(owner); markTransformDirty(owner);
} }
onCommit(); onCommit();
@@ -221,7 +229,7 @@ export function buildNodeOps(options: TtyRendererOptions): RendererOptions<TuiNo
// Remove existing children first (copy since remove mutates the array). // Remove existing children first (copy since remove mutates the array).
for (const child of Array.from(el.children)) remove(child); for (const child of Array.from(el.children)) remove(child);
insert(createTextLeaf(text), el, null); insert(createTextLeaf(text), el, null);
if (el.type === "text") { if (el.type === "tui-text") {
markTextDirty(el); markTextDirty(el);
} }
} }
@@ -239,7 +247,7 @@ export function buildNodeOps(options: TtyRendererOptions): RendererOptions<TuiNo
// `hostContext.isInsideText && originalType === 'ink-box'`). A standalone // `hostContext.isInsideText && originalType === 'ink-box'`). A standalone
// <Transform> is a text context here (G58), so we use the transform-aware // <Transform> is a text context here (G58), so we use the transform-aware
// context check to mirror Ink exactly. (G58 should-fix) // 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"); throw new Error("<Box> can’t be nested inside <Text> component");
} }
@@ -248,7 +256,7 @@ export function buildNodeOps(options: TtyRendererOptions): RendererOptions<TuiNo
if ( if (
child.type === "text-leaf" && child.type === "text-leaf" &&
child.value !== "" && child.value !== "" &&
(parentC.type === "box" || parentC.type === "root" || parentC.type === "static") && (parentC.type === "tui-box" || parentC.type === "root" || parentC.type === "tui-static") &&
!isInsideTextOrTransformContext(parentC) !isInsideTextOrTransformContext(parentC)
) { ) {
throw new Error(`Text string "${child.value}" must be rendered inside <Text> component`); 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); dirtyTextMeasureOwner(parentC);
// Track static node identity on the root (mirrors Ink's reconciler). // Track static node identity on the root (mirrors Ink's reconciler).
if (child.type === "static") { if (child.type === "tui-static") {
const root = findRoot(child); const root = findRoot(child);
if (root) root.staticNode = 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 // Track static node removal: clear root.staticNode only if it still
// points at this node. On key-driven remounts, insert() already // points at this node. On key-driven remounts, insert() already
// registered the new instance before the old one is removed. // registered the new instance before the old one is removed.
if (child.type === "static") { if (child.type === "tui-static") {
const root = findRoot(child); const root = findRoot(child);
if (root && root.staticNode === child) { if (root && root.staticNode === child) {
root.staticNode = undefined; root.staticNode = undefined;
@@ -336,10 +344,10 @@ export function buildNodeOps(options: TtyRendererOptions): RendererOptions<TuiNo
} }
} }
if ( if (
node.type === "box" || node.type === "tui-box" ||
node.type === "text" || node.type === "tui-text" ||
node.type === "static" || node.type === "tui-static" ||
node.type === "transform" node.type === "tui-transform"
) { ) {
detachYoga(node); detachYoga(node);
} }
@@ -358,21 +366,26 @@ export function buildNodeOps(options: TtyRendererOptions): RendererOptions<TuiNo
} }
function patchProp(el: TuiNode, key: string, prev: unknown, next: unknown): void { 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") { if (key === "transform" && typeof next === "function") {
el.transform = next as (line: string, idx: number) => string; el.transform = next as (line: string, idx: number) => string;
} }
onCommit(); onCommit();
return; 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> // Callback the renderer invokes post-commit to advance the <Static>
// component's cursor so written items unmount. Not styling/layout. // component's cursor so written items unmount. Not styling/layout.
el.onWritten = typeof next === "function" ? (next as () => void) : undefined; el.onWritten = typeof next === "function" ? (next as () => void) : undefined;
onCommit(); onCommit();
return; 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)) { if (isYogaProp(key)) {
applyYogaProp(el, key, next, prev); applyYogaProp(el, key, next, prev);
// Some yoga props also need to be stored in el.props for the paint pass. // 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)) { } else if (STYLE_PROPS.has(key)) {
(el as { props: Record<string, unknown> }).props[key] = next; (el as { props: Record<string, unknown> }).props[key] = next;
} else if (key === "aria-role" || key === "ariaRole") { } else if (key === "aria-role" || key === "ariaRole") {
if (el.type === "box") { if (el.type === "tui-box") {
el.internal_accessibility ??= {}; el.internal_accessibility ??= {};
el.internal_accessibility.role = next as string; el.internal_accessibility.role = next as string;
} }
} else if (key === "aria-state" || key === "ariaState") { } else if (key === "aria-state" || key === "ariaState") {
if (el.type === "box") { if (el.type === "tui-box") {
el.internal_accessibility ??= {}; el.internal_accessibility ??= {};
el.internal_accessibility.state = next as Record<string, boolean>; el.internal_accessibility.state = next as Record<string, boolean>;
} }
@@ -429,7 +442,7 @@ export function buildNodeOps(options: TtyRendererOptions): RendererOptions<TuiNo
onCommit(); onCommit();
return; 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; (el.props as Record<string, unknown>)[key] = next;
onCommit(); onCommit();
} }
+2 -2
View File
@@ -4,7 +4,7 @@ import { buildNodeOps } from "./node-ops.ts";
test("createBox returns shape with empty children + paintDirty true", () => { test("createBox returns shape with empty children + paintDirty true", () => {
const box = createBox(); const box = createBox();
expect(box.type).toBe("box"); expect(box.type).toBe("tui-box");
expect(box.children).toEqual([]); expect(box.children).toEqual([]);
expect(box.paintDirty).toBe(true); expect(box.paintDirty).toBe(true);
expect(box.parent).toBe(null); 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", () => { test("createTransform stores its transform function", () => {
const fn = (line: string) => line.toUpperCase(); const fn = (line: string) => line.toUpperCase();
const node = createTransform(fn); const node = createTransform(fn);
expect(node.type).toBe("transform"); expect(node.type).toBe("tui-transform");
expect(node.transform).toBe(fn); expect(node.transform).toBe(fn);
}); });
+10 -10
View File
@@ -40,7 +40,7 @@ export interface TuiRoot extends NodeBase {
} }
export interface TuiBox extends NodeBase { export interface TuiBox extends NodeBase {
type: "box"; type: "tui-box";
children: TuiNode[]; children: TuiNode[];
yoga: YogaNodeRef; yoga: YogaNodeRef;
props: BoxProps; props: BoxProps;
@@ -52,7 +52,7 @@ export interface TuiBox extends NodeBase {
} }
export interface TuiText extends NodeBase { export interface TuiText extends NodeBase {
type: "text"; type: "tui-text";
children: TuiInlineNode[]; children: TuiInlineNode[];
yoga: YogaNodeRef; yoga: YogaNodeRef;
props: TextProps; props: TextProps;
@@ -60,7 +60,7 @@ export interface TuiText extends NodeBase {
} }
export interface TuiVirtualText extends NodeBase { export interface TuiVirtualText extends NodeBase {
type: "virtual-text"; type: "tui-virtual-text";
// A <Newline>/<Text> directly inside a standalone <Transform> renders inline, // A <Newline>/<Text> directly inside a standalone <Transform> renders inline,
// so a virtual-text can also be parented by a transform (G58). // so a virtual-text can also be parented by a transform (G58).
parent: TuiText | TuiVirtualText | TuiTransform | null; parent: TuiText | TuiVirtualText | TuiTransform | null;
@@ -83,7 +83,7 @@ export interface TuiComment extends NodeBase {
} }
export interface TuiStatic extends NodeBase { export interface TuiStatic extends NodeBase {
type: "static"; type: "tui-static";
children: TuiNode[]; children: TuiNode[];
yoga: YogaNodeRef; yoga: YogaNodeRef;
props: BoxProps; props: BoxProps;
@@ -109,7 +109,7 @@ export interface TuiStatic extends NodeBase {
} }
export interface TuiTransform extends NodeBase { export interface TuiTransform extends NodeBase {
type: "transform"; type: "tui-transform";
children: TuiNode[]; children: TuiNode[];
yoga: YogaNodeRef; yoga: YogaNodeRef;
transform: (line: string, lineIndex: number) => string; transform: (line: string, lineIndex: number) => string;
@@ -154,7 +154,7 @@ export function emitLayoutListeners(root: TuiRoot): void {
export function createBox(): TuiBox { export function createBox(): TuiBox {
return { return {
type: "box", type: "tui-box",
parent: null, parent: null,
children: [], children: [],
yoga: UNATTACHED_YOGA, yoga: UNATTACHED_YOGA,
@@ -165,7 +165,7 @@ export function createBox(): TuiBox {
export function createText(): TuiText { export function createText(): TuiText {
return { return {
type: "text", type: "tui-text",
parent: null, parent: null,
children: [], children: [],
yoga: UNATTACHED_YOGA, yoga: UNATTACHED_YOGA,
@@ -175,7 +175,7 @@ export function createText(): TuiText {
export function createVirtualText(): TuiVirtualText { export function createVirtualText(): TuiVirtualText {
return { return {
type: "virtual-text", type: "tui-virtual-text",
parent: null, parent: null,
children: [], children: [],
props: {}, props: {},
@@ -197,7 +197,7 @@ export function createTextLeaf(value: string): TuiTextLeaf {
export function createStatic(): TuiStatic { export function createStatic(): TuiStatic {
return { return {
type: "static", type: "tui-static",
parent: null, parent: null,
children: [], children: [],
yoga: UNATTACHED_YOGA, yoga: UNATTACHED_YOGA,
@@ -208,7 +208,7 @@ export function createStatic(): TuiStatic {
export function createTransform(fn: (line: string, lineIndex: number) => string): TuiTransform { export function createTransform(fn: (line: string, lineIndex: number) => string): TuiTransform {
return { return {
type: "transform", type: "tui-transform",
parent: null, parent: null,
children: [], children: [],
yoga: UNATTACHED_YOGA, yoga: UNATTACHED_YOGA,
+2 -2
View File
@@ -69,10 +69,10 @@ function squashTransformChild(child: TuiNode, index: number): string {
if (child.type === "text-leaf") { if (child.type === "text-leaf") {
return child.value; return child.value;
} }
if (child.type === "virtual-text" || child.type === "text") { if (child.type === "tui-virtual-text" || child.type === "tui-text") {
return flattenLeaves(child); return flattenLeaves(child);
} }
if (child.type === "transform") { if (child.type === "tui-transform") {
let innerText = ""; let innerText = "";
// Recursive twin of the G52 fix in flattenLeaves: a grandchild's positional // 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}` // index must skip Vue comment nodes (null/v-if/false renders) so a `{null}`
+22 -14
View File
@@ -52,10 +52,10 @@ export const yogaNodeTracker = {
function hasYoga(node: TuiNode): node is YogaCarrier { function hasYoga(node: TuiNode): node is YogaCarrier {
return ( return (
node.type === "root" || node.type === "root" ||
node.type === "box" || node.type === "tui-box" ||
node.type === "text" || node.type === "tui-text" ||
node.type === "static" || node.type === "tui-static" ||
node.type === "transform" node.type === "tui-transform"
); );
} }
@@ -63,7 +63,7 @@ export function attachYoga(node: YogaCarrier): void {
node.yoga = createYogaNode(); node.yoga = createYogaNode();
// Static nodes are painted via a separate channel (paintIsolated), so they // Static nodes are painted via a separate channel (paintIsolated), so they
// must not occupy space in the dynamic frame's yoga layout. // 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); (node.yoga as YogaNode).setDisplay(Yoga.DISPLAY_NONE);
} }
// Box nodes match Ink's defaults: row direction, shrinkable, no wrap. // 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 // are passed through Vue's reactive system (which may include undefined
// overrides or border defaults). User-provided props override these via // overrides or border defaults). User-provided props override these via
// patchProp which runs after attachYoga. // 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).setFlexDirection(Yoga.FLEX_DIRECTION_ROW);
(node.yoga as YogaNode).setFlexShrink(1); (node.yoga as YogaNode).setFlexShrink(1);
(node.yoga as YogaNode).setFlexWrap(Yoga.WRAP_NO_WRAP); (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. // Text nodes match Ink's <ink-text> defaults: row direction, shrinkable.
// Although text nodes rarely have yoga-carrying children, this ensures // Although text nodes rarely have yoga-carrying children, this ensures
// consistent layout behavior matching Ink. // 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).setFlexDirection(Yoga.FLEX_DIRECTION_ROW);
(node.yoga as YogaNode).setFlexShrink(1); (node.yoga as YogaNode).setFlexShrink(1);
(node.yoga as YogaNode).setFlexGrow(0); (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: // Transform nodes match Ink's Transform which renders as ink-text:
// flexShrink=1, flexDirection='row'. This makes transform a yoga carrier // flexShrink=1, flexDirection='row'. This makes transform a yoga carrier
// so it participates in layout (multi-line text gets proper height). // 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).setFlexDirection(Yoga.FLEX_DIRECTION_ROW);
(node.yoga as YogaNode).setFlexShrink(1); (node.yoga as YogaNode).setFlexShrink(1);
(node.yoga as YogaNode).setFlexGrow(0); (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 — // ink-text), so a transform child of it is inline and excluded from yoga —
// same as for a text/virtual-text parent. (G58 MF2) // same as for a text/virtual-text parent. (G58 MF2)
const isTextParent = 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; let yIdx = 0;
for (const sibling of parent.children) { for (const sibling of parent.children) {
if (sibling === child) return yIdx; if (sibling === child) return yIdx;
if (hasYoga(sibling)) { if (hasYoga(sibling)) {
// Transform nodes inside text/transform parents are not in the yoga tree. // 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++; 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 // because a transform-in-transform inside a <Text> was already excluded as a
// child of an inline transform here.) // child of an inline transform here.)
// (VirtualText parents are already excluded by the hasYoga check above.) // (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; return;
} }
// A transform with a yoga-carrying child (e.g. <Transform><Text>…) lays out // 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 // 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 // measure func and children, so clear the standalone-transform measure func
// before inserting the child. (G58) // before inserting the child. (G58)
if (parent.type === "transform") { if (parent.type === "tui-transform") {
(parent.yoga as YogaNode).unsetMeasureFunc(); (parent.yoga as YogaNode).unsetMeasureFunc();
} }
const yIdx = yogaIndexFor(parent, child); const yIdx = yogaIndexFor(parent, child);
@@ -163,14 +168,17 @@ export function removeYogaChild(parent: TuiContainer, child: TuiNode): void {
if (!hasYoga(parent) || !hasYoga(child)) return; if (!hasYoga(parent) || !hasYoga(child)) return;
// Transform nodes inside a text/transform parent were never inserted into yoga // Transform nodes inside a text/transform parent were never inserted into yoga
// (mirror of insertYogaChild's inline-transform skip). (G58 MF2) // (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; return;
} }
(parent.yoga as YogaNode).removeChild(child.yoga as YogaNode); (parent.yoga as YogaNode).removeChild(child.yoga as YogaNode);
// If removing the last yoga child from a transform, restore the inline-text // 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 // measure func so the transform can still size its direct text-leaf children
// (it has become a standalone inline-text transform again). (G58) // (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); bindTransformMeasure(parent as TuiTransform);
} }
} }
+8 -8
View File
@@ -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 // 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 // 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 // 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 // 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) // in the Output, never in squashTextNodes for the node it lives on. (G58)
function renderTransformAsText(node: TuiTransform, inheritedBg?: unknown): string { function renderTransformAsText(node: TuiTransform, inheritedBg?: unknown): string {
@@ -435,10 +435,10 @@ function squashTransformChild(child: TuiNode, index: number, inheritedBg: unknow
if (child.type === "text-leaf") { if (child.type === "text-leaf") {
return child.value; 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); return renderTextWithInlineStyles(child, inheritedBg);
} }
if (child.type === "transform") { if (child.type === "tui-transform") {
let innerText = ""; let innerText = "";
// Recursive twin of the G52 fix in renderTextWithInlineStyles: a grandchild's // Recursive twin of the G52 fix in renderTextWithInlineStyles: a grandchild's
// positional index must skip Vue comment nodes (null/v-if/false renders), // 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); for (const child of node.children) paintNode(child, output, x0, y0, transformers);
return; return;
} }
case "box": { case "tui-box": {
const layout = node.yoga.getComputedLayout(); const layout = node.yoga.getComputedLayout();
const x = x0 + layout.left; const x = x0 + layout.left;
const y = y0 + layout.top; const y = y0 + layout.top;
@@ -709,7 +709,7 @@ function paintNode(
if (clipped) output.unclip(); if (clipped) output.unclip();
return; return;
} }
case "text": { case "tui-text": {
const layout = node.yoga.getComputedLayout(); const layout = node.yoga.getComputedLayout();
// Thread the INHERITED Box bg (NOT a pre-computed effective bg) into the // Thread the INHERITED Box bg (NOT a pre-computed effective bg) into the
// squash. The Text's own backgroundColor — including an explicit "" opt-out — // 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); output.write(x0 + layout.left, y0 + layout.top, wrapped, transformers);
return; return;
} }
case "static": { case "tui-static": {
// Static is rendered through the static channel (written before frame), so // Static is rendered through the static channel (written before frame), so
// it does not contribute to the dynamic frame paint. // it does not contribute to the dynamic frame paint.
return; return;
} }
case "transform": { case "tui-transform": {
const layout = node.yoga.getComputedLayout(); const layout = node.yoga.getComputedLayout();
const x = x0 + layout.left; const x = x0 + layout.left;
const y = y0 + layout.top; const y = y0 + layout.top;
@@ -787,7 +787,7 @@ function paintNode(
for (const child of node.children) paintNode(child, output, x, y, next, inheritedBg); for (const child of node.children) paintNode(child, output, x, y, next, inheritedBg);
return; return;
} }
case "virtual-text": case "tui-virtual-text":
case "text-leaf": case "text-leaf":
case "comment": case "comment":
// virtual-text and text-leaf are handled inside renderTextWithInlineStyles. // virtual-text and text-leaf are handled inside renderTextWithInlineStyles.
+12 -11
View File
@@ -18,10 +18,10 @@ function squashChildSR(child: TuiNode, index: number): string {
if (child.type === "text-leaf") { if (child.type === "text-leaf") {
return child.value; return child.value;
} }
if (child.type === "virtual-text" || child.type === "text") { if (child.type === "tui-virtual-text" || child.type === "tui-text") {
return squashTextContent(child); return squashTextContent(child);
} }
if (child.type === "transform") { if (child.type === "tui-transform") {
let innerText = ""; let innerText = "";
// Recurse into the transform's children (each may itself be a transform, // Recurse into the transform's children (each may itself be a transform,
// recursed to any depth), skipping Vue comment nodes and empty text-leaves // 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 { export function renderScreenReaderOutput(node: TuiNode, options: ScreenReaderOptions = {}): string {
// Skip static elements if requested // Skip static elements if requested
if (options.skipStaticElements && node.type === "static") { if (options.skipStaticElements && node.type === "tui-static") {
return ""; return "";
} }
// If display: none, return empty // If display: none, return empty
if ( if (
(node.type === "box" || (node.type === "tui-box" ||
node.type === "text" || node.type === "tui-text" ||
node.type === "root" || node.type === "root" ||
node.type === "transform") && node.type === "tui-transform") &&
node.yoga.getDisplay() === Yoga.DISPLAY_NONE node.yoga.getDisplay() === Yoga.DISPLAY_NONE
) { ) {
return ""; return "";
@@ -136,14 +136,15 @@ export function renderScreenReaderOutput(node: TuiNode, options: ScreenReaderOpt
let output = ""; let output = "";
if (node.type === "text") { if (node.type === "tui-text") {
output = squashTextContent(node); 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 // Determine separator based on flex direction (resolved from yoga so the
// Box default of row yields a space separator, matching Ink — see // Box default of row yields a space separator, matching Ink — see
// resolveBoxFlexDirection / G39). Root keeps undefined → "\n" (Ink's column // resolveBoxFlexDirection / G39). Root keeps undefined → "\n" (Ink's column
// default root). // 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"; const separator = flexDirection === "row" || flexDirection === "row-reverse" ? " " : "\n";
@@ -170,7 +171,7 @@ export function renderScreenReaderOutput(node: TuiNode, options: ScreenReaderOpt
) )
.filter(Boolean) .filter(Boolean)
.join(separator); .join(separator);
} else if (node.type === "transform") { } else if (node.type === "tui-transform") {
// Transform nodes: CONCATENATE children with "" (not newline-join), matching // Transform nodes: CONCATENATE children with "" (not newline-join), matching
// Ink's squashTextNodes (squash-text-nodes.ts:42, `text += nodeText`). In Ink // 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 // 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 // Add accessibility annotations
if (node.type === "box") { if (node.type === "tui-box") {
const accessibility = node.internal_accessibility; const accessibility = node.internal_accessibility;
if (accessibility) { if (accessibility) {
const { role, state } = accessibility; const { role, state } = accessibility;
+1 -1
View File
@@ -26,7 +26,7 @@ function resolvedFlexDirection(stat: TuiStatic): string {
} }
export function findStatics(root: TuiNode, out: TuiStatic[] = []): TuiStatic[] { 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") { if (root.type !== "text-leaf" && root.type !== "comment") {
const containerChildren = (root as { children: TuiNode[] }).children; const containerChildren = (root as { children: TuiNode[] }).children;
for (const child of containerChildren) findStatics(child, out); for (const child of containerChildren) findStatics(child, out);
+5 -1
View File
@@ -3,7 +3,11 @@ import vueJsx from "@vitejs/plugin-vue-jsx";
import Vue from "unplugin-vue/rolldown"; import Vue from "unplugin-vue/rolldown";
import VueVite from "unplugin-vue/vite"; 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({ export default defineConfig({
// `VueVite` parses `.vue` SFCs in the TEST/dev graph (unit tests may import the // `VueVite` parses `.vue` SFCs in the TEST/dev graph (unit tests may import the