diff --git a/.agents/docs/ink-divergences.md b/.agents/docs/ink-divergences.md index cfb5d0f..fb01f5c 100644 --- a/.agents/docs/ink-divergences.md +++ b/.agents/docs/ink-divergences.md @@ -40,14 +40,38 @@ deliberate. Divergences fall into a few kinds: - **Why:** intentionally minimal, single-purpose composables. `waitUntilRenderFlush` is deliberately **not** exposed. -### No named type / prop re-exports +### Named type / prop re-exports -- **Ink:** re-exports `BoxProps`, `TextProps`, `StaticProps`, `TransformProps`, - `NewlineProps`, `WindowSize`, `CursorPosition`, `DOMElement`, `RenderOptions`, `Instance`, - `App/Stdin/Stdout/StderrProps`. -- **vue-tui:** does not re-export those names; exposes its own type surface - (`TuiApp`, `MountOptions`, …). -- **Why:** avoid leaking React-shaped type names; present a Vue-native type surface. +- **Ink:** re-exports its component prop types plus a few data/handle types: + `BoxProps`, `TextProps`, `StaticProps`, `TransformProps`, `NewlineProps`, + `WindowSize`, `CursorPosition`, `DOMElement`, `RenderOptions`, `Instance`, + `AppProps`, `StdinProps`, `StdoutProps`, `StderrProps`. +- **vue-tui:** re-exports the framework-neutral ones under the **same names** — + `BoxProps`, `TextProps`, `StaticProps`, `TransformProps`, `NewlineProps`, + `WindowSize` (`{ columns, rows }`) and `CursorPosition` (`{ x, y }`). These are + **not** divergences: a `` has props in Vue exactly as in React, so the names + carry over. They are derived from the runtime `props` objects via Vue's + `ExtractPublicPropTypes`, so they never drift from the components' real props. + Only the remaining few genuinely differ, each for a concrete reason — never merely + to "avoid React-shaped names": + - `DOMElement` → **`TuiNode`**. The one genuinely DOM-shaped type: Ink's + `DOMElement` models a DOM-emulation node (`nodeName` / `attributes` / + `childNodes`). vue-tui's host tree is a different representation + (`TuiContainer | TuiTextLeaf | TuiComment`), exported as `TuiNode` from + `@vue-tui/runtime/internal`. + - `RenderOptions` / `Instance` → **`MountOptions`** / **`TuiApp`**. Downstream of + the `createApp()` entry above — vue-tui mounts a Vue app, so the options bag and + the returned handle are Vue-shaped, not `render()`-shaped. + - `AppProps` / `StdinProps` / `StdoutProps` / `StderrProps` → **N/A**. These are the + props of Ink's internal React _context-provider components_ (``, + ``, …). vue-tui has no such components — that state is reached via + `createApp` plus the `useStdin` / `useStdout` / `useStderr` composables — so there + is nothing to name. +- **Why:** the earlier blanket "expose a Vue-native type surface, don't leak + React-shaped names" over-reached — it withheld names like `BoxProps` that have no + React vs Vue content at all. The rule is narrower: mirror Ink's names wherever the + underlying type is framework-neutral; reshape only where Vue genuinely has a + different thing (a host node, a mounted app) or no thing at all. ## Additive features (vue-tui is a strict superset) diff --git a/packages/runtime-tests/integration/public-types.test-d.ts b/packages/runtime-tests/integration/public-types.test-d.ts new file mode 100644 index 0000000..1eb0811 --- /dev/null +++ b/packages/runtime-tests/integration/public-types.test-d.ts @@ -0,0 +1,38 @@ +// Type-level guarantees for the public *named* type surface. +// +// vue-tui tracks Ink, and Ink re-exports its component prop types and a couple of +// framework-neutral data shapes under stable names. These names (BoxProps, TextProps, +// …, WindowSize, CursorPosition) have nothing to do with React vs Vue — a has +// props in Vue exactly as in React — so vue-tui re-exports them too, letting consumers +// name a component's props the same way they would in Ink. See +// `.agents/docs/ink-divergences.md` ("Named type / prop re-exports"). +// +// These assertions are erased at runtime; the real gate is `tsc --noEmit` (the package's +// `check:type` script). This file is named `*.test-d.ts` on purpose so vitest does NOT +// pick it up as a runtime test (its include is `*.test.ts`), while tsc still checks it. +import { expectTypeOf } from "vite-plus/test"; +import type { + BoxProps, + TextProps, + StaticProps, + TransformProps, + NewlineProps, + WindowSize, + CursorPosition, +} from "@vue-tui/runtime"; + +// Prop types carry their component's real, declared props. +expectTypeOf().toEqualTypeOf< + "row" | "row-reverse" | "column" | "column-reverse" | undefined +>(); +expectTypeOf().toEqualTypeOf(); +expectTypeOf().toEqualTypeOf(); +expectTypeOf().toEqualTypeOf(); +expectTypeOf().toEqualTypeOf< + (line: string, lineIndex: number) => string +>(); +expectTypeOf().toEqualTypeOf(); + +// Framework-neutral data shapes, mirrored from Ink exactly. +expectTypeOf().toEqualTypeOf<{ readonly columns: number; readonly rows: number }>(); +expectTypeOf().toEqualTypeOf<{ x: number; y: number }>(); diff --git a/packages/runtime/src/components/Box.ts b/packages/runtime/src/components/Box.ts index ce1034d..1d461c2 100644 --- a/packages/runtime/src/components/Box.ts +++ b/packages/runtime/src/components/Box.ts @@ -1,4 +1,4 @@ -import { defineComponent, h, inject, type PropType } from "vue"; +import { defineComponent, h, inject, type ExtractPublicPropTypes, type PropType } from "vue"; import type cliBoxes from "cli-boxes"; import { AppContextKey } from "../context.ts"; import type { WithChildren } from "./with-children.ts"; @@ -69,94 +69,96 @@ export interface AriaState { selected?: boolean; } +const boxProps = { + flexDirection: String as PropType, + flexGrow: Number, + flexShrink: Number, + flexBasis: [Number, String], + flexWrap: String as PropType, + alignItems: String as PropType, + alignSelf: String as PropType, + justifyContent: String as PropType, + gap: Number, + columnGap: Number, + rowGap: Number, + + width: [Number, String], + height: [Number, String], + minWidth: [Number, String], + minHeight: [Number, String], + maxWidth: [Number, String], + maxHeight: [Number, String], + aspectRatio: Number, + alignContent: String as PropType, + position: String as PropType<"absolute" | "relative" | "static">, + top: [Number, String], + right: [Number, String], + bottom: [Number, String], + left: [Number, String], + + margin: Number as PropType, + marginX: Number, + marginY: Number, + marginTop: Number, + marginBottom: Number, + marginLeft: Number, + marginRight: Number, + padding: Number, + paddingX: Number, + paddingY: Number, + paddingTop: Number, + paddingBottom: Number, + paddingLeft: Number, + paddingRight: Number, + + // Accept either a preset name string or a full custom BoxStyle object (Ink parity, G13). + // Ink types borderStyle as `keyof Boxes | BoxStyle`; we mirror that here. + borderStyle: [String, Object] as PropType, + borderColor: [String, Array], + // `default: undefined` is intentional and load-bearing: Vue's boolean-casting + // rule coerces absent Boolean props to `false` only when there is no explicit + // default. Adding `default: undefined` suppresses that coercion so absent + // per-edge dim props arrive in the paint pass as `undefined`, not `false`. + // This lets `edgeDim = (perEdge ?? generalDim)` correctly fall back to the + // general value only when the per-edge prop was truly omitted — mirroring + // Ink render-border.ts:54 which uses real-undefined via React's prop model + // (G16). The `Boolean` type is kept so Vue still accepts bare-attribute + // `` in templates (coerces `""` → `true`) and passes + // TypeScript type-checking for consumers. + borderDimColor: { type: Boolean as PropType, default: undefined }, + borderTopDimColor: { type: Boolean as PropType, default: undefined }, + borderBottomDimColor: { type: Boolean as PropType, default: undefined }, + borderLeftDimColor: { type: Boolean as PropType, default: undefined }, + borderRightDimColor: { type: Boolean as PropType, default: undefined }, + borderTop: { type: Boolean, default: true }, + borderBottom: { type: Boolean, default: true }, + borderLeft: { type: Boolean, default: true }, + borderRight: { type: Boolean, default: true }, + borderTopColor: [String, Array], + borderBottomColor: [String, Array], + borderLeftColor: [String, Array], + borderRightColor: [String, Array], + borderBackgroundColor: [String, Array], + borderTopBackgroundColor: [String, Array], + borderBottomBackgroundColor: [String, Array], + borderLeftBackgroundColor: [String, Array], + borderRightBackgroundColor: [String, Array], + + backgroundColor: [String, Array], + overflow: String as PropType<"visible" | "hidden">, + overflowX: String as PropType<"visible" | "hidden">, + overflowY: String as PropType<"visible" | "hidden">, + display: String as PropType<"flex" | "none">, + + ariaLabel: String, + ariaHidden: Boolean, + ariaRole: String as PropType, + ariaState: Object as PropType, +}; + const BoxImpl = defineComponent({ name: "Box", - props: { - flexDirection: String as PropType, - flexGrow: Number, - flexShrink: Number, - flexBasis: [Number, String], - flexWrap: String as PropType, - alignItems: String as PropType, - alignSelf: String as PropType, - justifyContent: String as PropType, - gap: Number, - columnGap: Number, - rowGap: Number, - - width: [Number, String], - height: [Number, String], - minWidth: [Number, String], - minHeight: [Number, String], - maxWidth: [Number, String], - maxHeight: [Number, String], - aspectRatio: Number, - alignContent: String as PropType, - position: String as PropType<"absolute" | "relative" | "static">, - top: [Number, String], - right: [Number, String], - bottom: [Number, String], - left: [Number, String], - - margin: Number as PropType, - marginX: Number, - marginY: Number, - marginTop: Number, - marginBottom: Number, - marginLeft: Number, - marginRight: Number, - padding: Number, - paddingX: Number, - paddingY: Number, - paddingTop: Number, - paddingBottom: Number, - paddingLeft: Number, - paddingRight: Number, - - // Accept either a preset name string or a full custom BoxStyle object (Ink parity, G13). - // Ink types borderStyle as `keyof Boxes | BoxStyle`; we mirror that here. - borderStyle: [String, Object] as PropType, - borderColor: [String, Array], - // `default: undefined` is intentional and load-bearing: Vue's boolean-casting - // rule coerces absent Boolean props to `false` only when there is no explicit - // default. Adding `default: undefined` suppresses that coercion so absent - // per-edge dim props arrive in the paint pass as `undefined`, not `false`. - // This lets `edgeDim = (perEdge ?? generalDim)` correctly fall back to the - // general value only when the per-edge prop was truly omitted — mirroring - // Ink render-border.ts:54 which uses real-undefined via React's prop model - // (G16). The `Boolean` type is kept so Vue still accepts bare-attribute - // `` in templates (coerces `""` → `true`) and passes - // TypeScript type-checking for consumers. - borderDimColor: { type: Boolean as PropType, default: undefined }, - borderTopDimColor: { type: Boolean as PropType, default: undefined }, - borderBottomDimColor: { type: Boolean as PropType, default: undefined }, - borderLeftDimColor: { type: Boolean as PropType, default: undefined }, - borderRightDimColor: { type: Boolean as PropType, default: undefined }, - borderTop: { type: Boolean, default: true }, - borderBottom: { type: Boolean, default: true }, - borderLeft: { type: Boolean, default: true }, - borderRight: { type: Boolean, default: true }, - borderTopColor: [String, Array], - borderBottomColor: [String, Array], - borderLeftColor: [String, Array], - borderRightColor: [String, Array], - borderBackgroundColor: [String, Array], - borderTopBackgroundColor: [String, Array], - borderBottomBackgroundColor: [String, Array], - borderLeftBackgroundColor: [String, Array], - borderRightBackgroundColor: [String, Array], - - backgroundColor: [String, Array], - overflow: String as PropType<"visible" | "hidden">, - overflowX: String as PropType<"visible" | "hidden">, - overflowY: String as PropType<"visible" | "hidden">, - display: String as PropType<"flex" | "none">, - - ariaLabel: String, - ariaHidden: Boolean, - ariaRole: String as PropType, - ariaState: Object as PropType, - }, + props: boxProps, setup(props, { slots }) { const appCtx = inject(AppContextKey, null); @@ -177,3 +179,6 @@ const BoxImpl = defineComponent({ }); export const Box = BoxImpl as WithChildren; + +/** Props accepted by `` — the vue-tui analogue of Ink's `BoxProps`. */ +export type BoxProps = ExtractPublicPropTypes; diff --git a/packages/runtime/src/components/Newline.ts b/packages/runtime/src/components/Newline.ts index 8c36321..38f4a15 100644 --- a/packages/runtime/src/components/Newline.ts +++ b/packages/runtime/src/components/Newline.ts @@ -1,8 +1,10 @@ -import { defineComponent, getCurrentInstance, h } from "vue"; +import { defineComponent, getCurrentInstance, h, type ExtractPublicPropTypes } from "vue"; + +const newlineProps = { count: { type: Number, default: 1 } }; export const Newline = defineComponent({ name: "Newline", - props: { count: { type: Number, default: 1 } }, + props: newlineProps, setup(props) { return () => { const content = "\n".repeat(props.count); @@ -30,3 +32,6 @@ function isInsideText(): boolean { } return false; } + +/** Props accepted by `` — the vue-tui analogue of Ink's `NewlineProps`. */ +export type NewlineProps = ExtractPublicPropTypes; diff --git a/packages/runtime/src/components/Static.ts b/packages/runtime/src/components/Static.ts index d3474de..fa6d165 100644 --- a/packages/runtime/src/components/Static.ts +++ b/packages/runtime/src/components/Static.ts @@ -1,12 +1,25 @@ -import { defineComponent, h, shallowRef, watch, type PropType } from "vue"; +import { + defineComponent, + h, + shallowRef, + watch, + type ExtractPublicPropTypes, + type PropType, +} from "vue"; import type { WithChildren } from "./with-children.ts"; +const staticProps = { + // `required: true as const` (not bare `true`): a standalone `const` widens + // `true` → `boolean`, which would drop `items` from ExtractPublicPropTypes' + // required keys (and from the component's own `props.items` typing). The + // literal keeps `items` required, matching Ink's `StaticProps`. + items: { type: Array as PropType, required: true as const }, + style: { type: Object as PropType>, default: undefined }, +}; + const StaticImpl = defineComponent({ name: "Static", - props: { - items: { type: Array as PropType, required: true }, - style: { type: Object as PropType>, default: undefined }, - }, + props: staticProps, setup(props, { slots }) { const defaultStyle: Record = { position: "absolute", @@ -64,3 +77,6 @@ const StaticImpl = defineComponent({ }); export const Static = StaticImpl as WithChildren; + +/** Props accepted by `` — the vue-tui analogue of Ink's `StaticProps`. */ +export type StaticProps = ExtractPublicPropTypes; diff --git a/packages/runtime/src/components/Text.ts b/packages/runtime/src/components/Text.ts index ec24d89..eda4fb3 100644 --- a/packages/runtime/src/components/Text.ts +++ b/packages/runtime/src/components/Text.ts @@ -1,4 +1,11 @@ -import { defineComponent, getCurrentInstance, h, inject, type PropType } from "vue"; +import { + defineComponent, + getCurrentInstance, + h, + inject, + type ExtractPublicPropTypes, + type PropType, +} from "vue"; import { AppContextKey } from "../context.ts"; import type { WithChildren } from "./with-children.ts"; @@ -11,21 +18,23 @@ type WrapMode = | "truncate-middle" | "truncate-start"; +const textProps = { + color: [String, Array] as PropType, + backgroundColor: [String, Array] as PropType, + dimColor: Boolean, + bold: Boolean, + italic: Boolean, + underline: Boolean, + strikethrough: Boolean, + inverse: Boolean, + wrap: { type: String as PropType, default: "wrap" }, + ariaLabel: String, + ariaHidden: Boolean, +}; + const TextImpl = defineComponent({ name: "Text", - props: { - color: [String, Array] as PropType, - backgroundColor: [String, Array] as PropType, - dimColor: Boolean, - bold: Boolean, - italic: Boolean, - underline: Boolean, - strikethrough: Boolean, - inverse: Boolean, - wrap: { type: String as PropType, default: "wrap" }, - ariaLabel: String, - ariaHidden: Boolean, - }, + props: textProps, setup(props, { slots }) { const appCtx = inject(AppContextKey, null); @@ -57,6 +66,9 @@ const TextImpl = defineComponent({ export const Text = TextImpl as WithChildren; +/** Props accepted by `` — the vue-tui analogue of Ink's `TextProps`. */ +export type TextProps = ExtractPublicPropTypes; + function isInsideText(): boolean { let parent = getCurrentInstance()?.parent; while (parent) { diff --git a/packages/runtime/src/components/Transform.ts b/packages/runtime/src/components/Transform.ts index 8853c94..8800c7d 100644 --- a/packages/runtime/src/components/Transform.ts +++ b/packages/runtime/src/components/Transform.ts @@ -1,15 +1,20 @@ -import { defineComponent, h, inject, type PropType } from "vue"; +import { defineComponent, h, inject, type ExtractPublicPropTypes, type PropType } from "vue"; import { AppContextKey } from "../context.ts"; import type { WithChildren } from "./with-children.ts"; type TransformFn = (line: string, lineIndex: number) => string; +const transformProps = { + // `required: true as const` keeps `transform` a required key once the props + // object lives in a standalone `const` (which would otherwise widen `true` → + // `boolean`). Matches Ink's `TransformProps`. + transform: { type: Function as PropType, required: true as const }, + accessibilityLabel: String, +}; + const TransformImpl = defineComponent({ name: "Transform", - props: { - transform: { type: Function as PropType, required: true }, - accessibilityLabel: String, - }, + props: transformProps, setup(props, { slots }) { const appCtx = inject(AppContextKey, null); @@ -28,3 +33,6 @@ const TransformImpl = defineComponent({ }); export const Transform = TransformImpl as WithChildren; + +/** Props accepted by `` — the vue-tui analogue of Ink's `TransformProps`. */ +export type TransformProps = ExtractPublicPropTypes; diff --git a/packages/runtime/src/composables/useCursor.ts b/packages/runtime/src/composables/useCursor.ts index 2bd5d3a..0a64fcf 100644 --- a/packages/runtime/src/composables/useCursor.ts +++ b/packages/runtime/src/composables/useCursor.ts @@ -1,6 +1,14 @@ import { inject, shallowRef, watch, onScopeDispose } from "vue"; import { AppContextKey } from "../context.ts"; +/** + * A cursor position in output-origin coordinates. Mirrors Ink's `CursorPosition`. + */ +export interface CursorPosition { + x: number; + y: number; +} + /** * Returns `setCursorPosition` so a component can control the terminal cursor. * @@ -12,9 +20,9 @@ export function useCursor() { const ctx = inject(AppContextKey); if (!ctx) throw new Error("useCursor() must be called inside a vue-tui render tree"); - const positionRef = shallowRef<{ x: number; y: number } | undefined>(undefined); + const positionRef = shallowRef(undefined); - function setCursorPosition(position: { x: number; y: number } | undefined) { + function setCursorPosition(position: CursorPosition | undefined) { positionRef.value = position; } diff --git a/packages/runtime/src/composables/useTerminalSize.ts b/packages/runtime/src/composables/useTerminalSize.ts index 5b6b52e..ac0bf22 100644 --- a/packages/runtime/src/composables/useTerminalSize.ts +++ b/packages/runtime/src/composables/useTerminalSize.ts @@ -2,13 +2,28 @@ import { inject, onScopeDispose, shallowRef, type ShallowRef } from "vue"; import terminalSize from "terminal-size"; import { AppContextKey } from "../context.ts"; +/** + * A terminal's character-cell dimensions. Mirrors Ink's `WindowSize` exactly + * (`columns`/`rows`, not `width`/`height`). + * + * Note the Vue-vs-React shape: Ink's `useWindowSize()` returns a `WindowSize` + * snapshot, whereas vue-tui's `useWindowSize()` / `useTerminalSize()` return + * reactive **refs** of these dimensions + * (`{ columns: ShallowRef; rows: ShallowRef }`). The data shape + * is the same; the reactivity wrapper is the framework difference. + */ +export interface WindowSize { + readonly columns: number; + readonly rows: number; +} + /** * Resolve terminal dimensions with a fallback chain: * 1. stdout.columns / stdout.rows (available in TTY mode) * 2. terminal-size package (works even when stdout is redirected) * 3. Hardcoded defaults (80x24) */ -export function resolveSize(stdout: NodeJS.WriteStream): { columns: number; rows: number } { +export function resolveSize(stdout: NodeJS.WriteStream): WindowSize { const cols = stdout.columns; const rowsVal = stdout.rows; if (cols && rowsVal) return { columns: cols, rows: rowsVal }; diff --git a/packages/runtime/src/index.ts b/packages/runtime/src/index.ts index 48bcd40..b042681 100644 --- a/packages/runtime/src/index.ts +++ b/packages/runtime/src/index.ts @@ -1,12 +1,18 @@ export { createApp, type TuiApp, type MountOptions } from "./render.ts"; export { renderToString, type RenderToStringOptions } from "./render-to-string.ts"; -export { Box, type AriaRole, type AriaState, type BoxStyle } from "./components/Box.ts"; -export { Text } from "./components/Text.ts"; -export { Newline } from "./components/Newline.ts"; +export { + Box, + type AriaRole, + type AriaState, + type BoxStyle, + type BoxProps, +} from "./components/Box.ts"; +export { Text, type TextProps } from "./components/Text.ts"; +export { Newline, type NewlineProps } from "./components/Newline.ts"; export { Spacer } from "./components/Spacer.ts"; -export { Static } from "./components/Static.ts"; -export { Transform } from "./components/Transform.ts"; +export { Static, type StaticProps } from "./components/Static.ts"; +export { Transform, type TransformProps } from "./components/Transform.ts"; export { useExit } from "./composables/useExit.ts"; export { useInput, type Key, type UseInputOptions } from "./composables/useInput.ts"; @@ -16,8 +22,8 @@ export { useFocusManager } from "./composables/useFocusManager.ts"; export { useStdin } from "./composables/useStdin.ts"; export { useStdout } from "./composables/useStdout.ts"; export { useStderr } from "./composables/useStderr.ts"; -export { useTerminalSize, useWindowSize } from "./composables/useTerminalSize.ts"; -export { useCursor } from "./composables/useCursor.ts"; +export { useTerminalSize, useWindowSize, type WindowSize } from "./composables/useTerminalSize.ts"; +export { useCursor, type CursorPosition } from "./composables/useCursor.ts"; export { useIsScreenReaderEnabled } from "./composables/useIsScreenReaderEnabled.ts"; export { useAnimation,