From ff82f6e064cd312b46d9fea98674b04d1bb8bc2b Mon Sep 17 00:00:00 2001 From: Yunfei He Date: Sun, 24 May 2026 18:13:33 +0800 Subject: [PATCH] feat(runtime): Vue terminal renderer with Ink-aligned API MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Custom Vue 3 renderer for terminal UIs, built on yoga-layout for flexbox and chalk for styling. Components: Box, Text, Newline, Spacer, Static, Transform Composables: useExit, useInput, useFocus, useFocusManager, useStdin, useStdout, useStderr, useTerminalSize Entry: createApp(root) → app.mount(options) → app.waitUntilExit() Focus: Ink-aligned Tab/Shift+Tab/Escape with Focusable[] ring Internal subpath: @vue-tui/runtime/internal (yogaNodeTracker) Co-Authored-By: Claude Opus 4.7 (1M context) --- packages/runtime/README.md | 107 +++++ packages/runtime/package.json | 45 ++ packages/runtime/src/components/Box.ts | 81 ++++ packages/runtime/src/components/Newline.ts | 9 + packages/runtime/src/components/Spacer.ts | 8 + packages/runtime/src/components/Static.ts | 16 + packages/runtime/src/components/Text.ts | 36 ++ packages/runtime/src/components/Transform.ts | 13 + packages/runtime/src/composables/useExit.ts | 13 + packages/runtime/src/composables/useFocus.ts | 75 ++++ .../src/composables/useFocusManager.ts | 22 + packages/runtime/src/composables/useInput.ts | 51 +++ packages/runtime/src/composables/useStderr.ts | 8 + packages/runtime/src/composables/useStdin.ts | 8 + packages/runtime/src/composables/useStdout.ts | 8 + .../src/composables/useTerminalSize.ts | 16 + packages/runtime/src/context.ts | 42 ++ packages/runtime/src/host/node-ops.ts | 245 +++++++++++ packages/runtime/src/host/nodes.test.ts | 30 ++ packages/runtime/src/host/nodes.ts | 159 +++++++ .../runtime/src/host/text-measure.test.ts | 34 ++ packages/runtime/src/host/text-measure.ts | 58 +++ packages/runtime/src/host/yoga.test.ts | 9 + packages/runtime/src/host/yoga.ts | 213 +++++++++ packages/runtime/src/index.ts | 17 + packages/runtime/src/internal.ts | 1 + packages/runtime/src/io/frame-writer.test.ts | 17 + packages/runtime/src/io/frame-writer.ts | 35 ++ packages/runtime/src/io/key-parser.test.ts | 16 + packages/runtime/src/io/key-parser.ts | 74 ++++ packages/runtime/src/paint/paint.ts | 310 +++++++++++++ packages/runtime/src/paint/static-channel.ts | 21 + packages/runtime/src/paint/text-style.test.ts | 53 +++ packages/runtime/src/paint/text-style.ts | 45 ++ packages/runtime/src/render.ts | 406 ++++++++++++++++++ packages/runtime/src/scheduler.ts | 35 ++ packages/runtime/tsconfig.json | 22 + packages/runtime/vite.config.ts | 15 + 38 files changed, 2373 insertions(+) create mode 100644 packages/runtime/README.md create mode 100644 packages/runtime/package.json create mode 100644 packages/runtime/src/components/Box.ts create mode 100644 packages/runtime/src/components/Newline.ts create mode 100644 packages/runtime/src/components/Spacer.ts create mode 100644 packages/runtime/src/components/Static.ts create mode 100644 packages/runtime/src/components/Text.ts create mode 100644 packages/runtime/src/components/Transform.ts create mode 100644 packages/runtime/src/composables/useExit.ts create mode 100644 packages/runtime/src/composables/useFocus.ts create mode 100644 packages/runtime/src/composables/useFocusManager.ts create mode 100644 packages/runtime/src/composables/useInput.ts create mode 100644 packages/runtime/src/composables/useStderr.ts create mode 100644 packages/runtime/src/composables/useStdin.ts create mode 100644 packages/runtime/src/composables/useStdout.ts create mode 100644 packages/runtime/src/composables/useTerminalSize.ts create mode 100644 packages/runtime/src/context.ts create mode 100644 packages/runtime/src/host/node-ops.ts create mode 100644 packages/runtime/src/host/nodes.test.ts create mode 100644 packages/runtime/src/host/nodes.ts create mode 100644 packages/runtime/src/host/text-measure.test.ts create mode 100644 packages/runtime/src/host/text-measure.ts create mode 100644 packages/runtime/src/host/yoga.test.ts create mode 100644 packages/runtime/src/host/yoga.ts create mode 100644 packages/runtime/src/index.ts create mode 100644 packages/runtime/src/internal.ts create mode 100644 packages/runtime/src/io/frame-writer.test.ts create mode 100644 packages/runtime/src/io/frame-writer.ts create mode 100644 packages/runtime/src/io/key-parser.test.ts create mode 100644 packages/runtime/src/io/key-parser.ts create mode 100644 packages/runtime/src/paint/paint.ts create mode 100644 packages/runtime/src/paint/static-channel.ts create mode 100644 packages/runtime/src/paint/text-style.test.ts create mode 100644 packages/runtime/src/paint/text-style.ts create mode 100644 packages/runtime/src/render.ts create mode 100644 packages/runtime/src/scheduler.ts create mode 100644 packages/runtime/tsconfig.json create mode 100644 packages/runtime/vite.config.ts diff --git a/packages/runtime/README.md b/packages/runtime/README.md new file mode 100644 index 0000000..d8a8930 --- /dev/null +++ b/packages/runtime/README.md @@ -0,0 +1,107 @@ +# @vue-tui/runtime + +Vue-idiomatic terminal renderer in the spirit of [React Ink](https://github.com/vadimdemedes/ink). Platform-specific runtime parallel to `@vue/runtime-dom`. + +## Install + +```bash +pnpm add @vue-tui/runtime vue +``` + +## Quickstart + +```ts +import { defineComponent, h, ref } from "vue"; +import { createApp, Box, Text, useInput } from "@vue-tui/runtime"; + +const Counter = defineComponent({ + setup() { + const count = ref(0); + useInput((input) => { + if (input === "+") count.value++; + if (input === "-") count.value--; + }); + return () => h(Box, null, h(Text, null, `Count: ${count.value}`)); + }, +}); + +createApp(Counter).mount(); +``` + +## API + +Single entry point — `createApp(root, rootProps?)`. Returns a `TuiApp` (`Omit, "mount">` plus four TUI methods). + +```ts +import { createApp } from "@vue-tui/runtime"; + +const app = createApp(App, { initialState }).use(myPlugin).provide(themeKey, dark); + +app.mount(); // all defaults (process.*) +app.mount({ debug: true }); // just flags +app.mount({ stdout: customWritable }); // partial stream override +app.mount({ stdout, stdin, stderr }); // explicit streams (testing) + +await app.waitUntilExit(); +app.unmount(); // optional — see "Cleanup" below +``` + +### Cleanup + +`mount()` registers a `process.on("exit")` listener that runs the same teardown +as `unmount()`. So you only need `app.unmount()` if you want to tear down the +app before the process is ready to exit (mid-program, in tests, or when one +process hosts multiple UIs sequentially). For a normal CLI that exits when the +user quits or the script ends, cleanup happens automatically. + +### `TuiApp` interface + +```ts +interface TuiApp extends VueApp { + mount(options?: MountOptions): ComponentPublicInstance; + unmount(): void; + waitUntilExit(): Promise; +} + +interface MountOptions { + stdout?: NodeJS.WriteStream; // default: process.stdout + stdin?: NodeJS.ReadStream; // default: process.stdin + stderr?: NodeJS.WriteStream; // default: process.stderr + debug?: boolean; // default: false + exitOnCtrlC?: boolean; // default: true +} +``` + +All fields optional with per-field fallback. Matches Ink's `RenderOptions` shape. + +### Components + +`Box`, `Text`, `Newline`, `Spacer`, `Static`, `Transform`. + +### Composables + +`useExit`, `useInput`, `useFocus`, `useFocusManager`, `useStdin`, `useStdout`, `useStderr`, `useTerminalSize`. + +Tab / Shift+Tab / Escape are handled automatically when any component uses `useFocus`. `useFocus` returns `{ isFocused, focus }` and manages raw mode. `useFocusManager` exposes `activeId` in addition to `focusNext` / `focusPrevious` / `focus` / `enableFocus` / `disableFocus`. + +## Waiting on exit / handling errors + +```ts +// Fire-and-forget (most common): +createApp(App).mount(); + +// Wait for the app to exit: +const app = createApp(App); +app.mount(); +await app.waitUntilExit(); + +// Catch errors thrown from setup / render / useExit(err): +const app = createApp(App); +app.mount(); +app.waitUntilExit().catch((err) => { + console.error(err); + process.exitCode = 1; +}); +``` + +See `docs/superpowers/specs/2026-05-18-vue-tui-core-design.md` for the full design. diff --git a/packages/runtime/package.json b/packages/runtime/package.json new file mode 100644 index 0000000..9e9b2ef --- /dev/null +++ b/packages/runtime/package.json @@ -0,0 +1,45 @@ +{ + "name": "@vue-tui/runtime", + "version": "0.0.0", + "description": "Vue-idiomatic terminal renderer in the spirit of React Ink.", + "license": "MIT", + "files": [ + "dist" + ], + "type": "module", + "exports": { + ".": "./dist/index.mjs", + "./internal": "./dist/internal.mjs", + "./package.json": "./package.json" + }, + "publishConfig": { + "access": "public" + }, + "scripts": { + "build": "vp pack", + "dev": "vp pack --watch", + "test": "vp test", + "check": "vp check", + "prepublishOnly": "vp run build" + }, + "dependencies": { + "@vue/runtime-core": "^3.4.0", + "chalk": "^5.3.0", + "cli-boxes": "^3.0.0", + "log-update": "^6.0.0", + "slice-ansi": "^7.1.0", + "string-width": "^7.2.0", + "wrap-ansi": "^9.0.0", + "yoga-layout": "^3.1.0" + }, + "devDependencies": { + "@types/node": "^25.6.2", + "@vitejs/plugin-vue-jsx": "catalog:", + "typescript": "^6.0.3", + "vite-plus": "^0.1.20", + "vue": "^3.4.0" + }, + "peerDependencies": { + "vue": "^3.4.0" + } +} diff --git a/packages/runtime/src/components/Box.ts b/packages/runtime/src/components/Box.ts new file mode 100644 index 0000000..9db8305 --- /dev/null +++ b/packages/runtime/src/components/Box.ts @@ -0,0 +1,81 @@ +import { defineComponent, h, type PropType } from "vue"; + +type Spacing = number; +type FlexDirection = "row" | "row-reverse" | "column" | "column-reverse"; +type FlexWrap = "nowrap" | "wrap" | "wrap-reverse"; +type Align = "flex-start" | "center" | "flex-end" | "stretch"; +type AlignSelf = "auto" | "flex-start" | "center" | "flex-end"; +type Justify = + | "flex-start" + | "center" + | "flex-end" + | "space-between" + | "space-around" + | "space-evenly"; +type BorderStyle = + | "single" + | "double" + | "round" + | "bold" + | "singleDouble" + | "doubleSingle" + | "classic" + | "arrow"; + +export const Box = 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], + + 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, + + borderStyle: String as PropType, + borderColor: [String, Array], + borderDimColor: Boolean, + 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], + + 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">, + }, + setup(props, { slots }) { + return () => h("box", props as never, slots.default?.()); + }, +}); diff --git a/packages/runtime/src/components/Newline.ts b/packages/runtime/src/components/Newline.ts new file mode 100644 index 0000000..8fa9637 --- /dev/null +++ b/packages/runtime/src/components/Newline.ts @@ -0,0 +1,9 @@ +import { defineComponent, h } from "vue"; + +export const Newline = defineComponent({ + name: "Newline", + props: { count: { type: Number, default: 1 } }, + setup(props) { + return () => h("virtual-text", {}, "\n".repeat(props.count)); + }, +}); diff --git a/packages/runtime/src/components/Spacer.ts b/packages/runtime/src/components/Spacer.ts new file mode 100644 index 0000000..02a43e4 --- /dev/null +++ b/packages/runtime/src/components/Spacer.ts @@ -0,0 +1,8 @@ +import { defineComponent, h } from "vue"; + +export const Spacer = defineComponent({ + name: "Spacer", + setup() { + return () => h("box", { flexGrow: 1, flexShrink: 1 }); + }, +}); diff --git a/packages/runtime/src/components/Static.ts b/packages/runtime/src/components/Static.ts new file mode 100644 index 0000000..3ebfd2c --- /dev/null +++ b/packages/runtime/src/components/Static.ts @@ -0,0 +1,16 @@ +import { defineComponent, h } from "vue"; + +export const Static = defineComponent({ + name: "Static", + props: { + items: { type: Array, required: true }, + }, + setup(props, { slots }) { + return () => + h( + "static", + {}, + (props.items as unknown[]).map((item, index) => slots.default?.({ item, index })), + ); + }, +}); diff --git a/packages/runtime/src/components/Text.ts b/packages/runtime/src/components/Text.ts new file mode 100644 index 0000000..ffd7dd9 --- /dev/null +++ b/packages/runtime/src/components/Text.ts @@ -0,0 +1,36 @@ +import { defineComponent, getCurrentInstance, h, type PropType } from "vue"; + +type Color = string | [number, number, number]; +type WrapMode = "wrap" | "truncate" | "truncate-end" | "truncate-middle" | "truncate-start"; + +export const Text = 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" }, + }, + setup(props, { slots }) { + return () => { + const insideText = isInsideText(); + const elementType = insideText ? "virtual-text" : "text"; + return h(elementType, props as never, slots.default?.()); + }; + }, +}); + +function isInsideText(): boolean { + let parent = getCurrentInstance()?.parent; + while (parent) { + const name = parent.type && (parent.type as { name?: string }).name; + if (name === "Text") return true; + parent = parent.parent; + } + return false; +} diff --git a/packages/runtime/src/components/Transform.ts b/packages/runtime/src/components/Transform.ts new file mode 100644 index 0000000..93bd5ee --- /dev/null +++ b/packages/runtime/src/components/Transform.ts @@ -0,0 +1,13 @@ +import { defineComponent, h, type PropType } from "vue"; + +type TransformFn = (line: string, lineIndex: number) => string; + +export const Transform = defineComponent({ + name: "Transform", + props: { + transform: { type: Function as PropType, required: true }, + }, + setup(props, { slots }) { + return () => h("transform", { transform: props.transform }, slots.default?.()); + }, +}); diff --git a/packages/runtime/src/composables/useExit.ts b/packages/runtime/src/composables/useExit.ts new file mode 100644 index 0000000..e2cac8d --- /dev/null +++ b/packages/runtime/src/composables/useExit.ts @@ -0,0 +1,13 @@ +import { inject } from "vue"; +import { AppContextKey } from "../context.ts"; + +/** + * Returns the exit function so a component can end the app from inside the + * tree. Pass an Error to reject `app.waitUntilExit()` (and any awaiter); call + * with no args to resolve cleanly. + */ +export function useExit(): (error?: Error) => void { + const ctx = inject(AppContextKey); + if (!ctx) throw new Error("useExit() must be called inside a vue-tui render tree"); + return ctx.exit; +} diff --git a/packages/runtime/src/composables/useFocus.ts b/packages/runtime/src/composables/useFocus.ts new file mode 100644 index 0000000..bb5fb39 --- /dev/null +++ b/packages/runtime/src/composables/useFocus.ts @@ -0,0 +1,75 @@ +import { + inject, + onScopeDispose, + shallowRef, + toValue, + watch, + type MaybeRefOrGetter, + type ShallowRef, +} from "vue"; +import { FocusContextKey, StdinContextKey } from "../context.ts"; + +let nextAutoId = 0; + +export interface UseFocusOptions { + autoFocus?: boolean; + isActive?: MaybeRefOrGetter; + id?: string; +} + +export function useFocus(options: UseFocusOptions = {}): { + isFocused: ShallowRef; + focus: (id: string) => void; +} { + const ctx = inject(FocusContextKey); + const stdin = inject(StdinContextKey); + if (!ctx) throw new Error("useFocus() must be called inside a vue-tui render tree"); + + const id = options.id ?? `__auto-${nextAutoId++}`; + const isFocused = shallowRef(false); + + const unsubscribe = ctx.subscribe(id, (v) => { + isFocused.value = v; + }); + + ctx.add(id, { autoFocus: options.autoFocus }); + + const isActive = options.isActive ?? true; + let rawModeAcquired = false; + + function acquireRaw() { + if (!rawModeAcquired && stdin) { + stdin.acquireRawMode(); + rawModeAcquired = true; + } + } + + function releaseRaw() { + if (rawModeAcquired && stdin) { + stdin.releaseRawMode(); + rawModeAcquired = false; + } + } + + watch( + () => toValue(isActive), + (active) => { + if (active) { + ctx.activate(id); + acquireRaw(); + } else { + ctx.deactivate(id); + releaseRaw(); + } + }, + { immediate: true, flush: "sync" }, + ); + + onScopeDispose(() => { + unsubscribe(); + ctx.remove(id); + releaseRaw(); + }); + + return { isFocused, focus: ctx.focus }; +} diff --git a/packages/runtime/src/composables/useFocusManager.ts b/packages/runtime/src/composables/useFocusManager.ts new file mode 100644 index 0000000..d8856c6 --- /dev/null +++ b/packages/runtime/src/composables/useFocusManager.ts @@ -0,0 +1,22 @@ +import { inject, type ShallowRef } from "vue"; +import { FocusContextKey } from "../context.ts"; + +export function useFocusManager(): { + enableFocus: () => void; + disableFocus: () => void; + focusNext: () => void; + focusPrevious: () => void; + focus: (id: string) => void; + activeId: ShallowRef; +} { + const ctx = inject(FocusContextKey); + if (!ctx) throw new Error("useFocusManager() must be called inside a vue-tui render tree"); + return { + enableFocus: ctx.enableFocus.bind(ctx), + disableFocus: ctx.disableFocus.bind(ctx), + focusNext: ctx.focusNext.bind(ctx), + focusPrevious: ctx.focusPrevious.bind(ctx), + focus: ctx.focus.bind(ctx), + activeId: ctx.activeIdRef, + }; +} diff --git a/packages/runtime/src/composables/useInput.ts b/packages/runtime/src/composables/useInput.ts new file mode 100644 index 0000000..3890b6a --- /dev/null +++ b/packages/runtime/src/composables/useInput.ts @@ -0,0 +1,51 @@ +import { inject, onScopeDispose, toValue, watch, type MaybeRefOrGetter } from "vue"; +import { AppContextKey, StdinContextKey } from "../context.ts"; +import { parseKey, type Key } from "../io/key-parser.ts"; + +export type { Key }; + +export interface UseInputOptions { + isActive?: MaybeRefOrGetter; +} + +export function useInput( + handler: (input: string, key: Key) => void, + options: UseInputOptions = {}, +): void { + const app = inject(AppContextKey); + const stdin = inject(StdinContextKey); + if (!app || !stdin) throw new Error("useInput() must be called inside a vue-tui render tree"); + + let attached = false; + + function listener(chunk: string) { + const { input, key } = parseKey(chunk); + handler(input, key); + } + + function attach() { + if (attached) return; + attached = true; + stdin!.acquireRawMode(); + stdin!.internal_eventEmitter.on("data", listener); + } + + function detach() { + if (!attached) return; + attached = false; + stdin!.internal_eventEmitter.off("data", listener); + stdin!.releaseRawMode(); + } + + const isActive = options.isActive ?? true; + watch( + () => toValue(isActive), + (value) => { + if (value) attach(); + else detach(); + }, + { immediate: true, flush: "sync" }, + ); + + onScopeDispose(detach); +} diff --git a/packages/runtime/src/composables/useStderr.ts b/packages/runtime/src/composables/useStderr.ts new file mode 100644 index 0000000..a73c5ff --- /dev/null +++ b/packages/runtime/src/composables/useStderr.ts @@ -0,0 +1,8 @@ +import { inject } from "vue"; +import { AppContextKey } from "../context.ts"; + +export function useStderr(): { stderr: NodeJS.WriteStream; write: (data: string) => void } { + const ctx = inject(AppContextKey); + if (!ctx) throw new Error("useStderr() must be called inside a vue-tui render tree"); + return { stderr: ctx.stderr, write: (data) => ctx.stderr.write(data) }; +} diff --git a/packages/runtime/src/composables/useStdin.ts b/packages/runtime/src/composables/useStdin.ts new file mode 100644 index 0000000..e0eeb18 --- /dev/null +++ b/packages/runtime/src/composables/useStdin.ts @@ -0,0 +1,8 @@ +import { inject } from "vue"; +import { StdinContextKey, type StdinContext } from "../context.ts"; + +export function useStdin(): StdinContext { + const ctx = inject(StdinContextKey); + if (!ctx) throw new Error("useStdin() must be called inside a vue-tui render tree"); + return ctx; +} diff --git a/packages/runtime/src/composables/useStdout.ts b/packages/runtime/src/composables/useStdout.ts new file mode 100644 index 0000000..e6d73f3 --- /dev/null +++ b/packages/runtime/src/composables/useStdout.ts @@ -0,0 +1,8 @@ +import { inject } from "vue"; +import { AppContextKey } from "../context.ts"; + +export function useStdout(): { stdout: NodeJS.WriteStream; write: (data: string) => void } { + const ctx = inject(AppContextKey); + if (!ctx) throw new Error("useStdout() must be called inside a vue-tui render tree"); + return { stdout: ctx.stdout, write: (data) => ctx.stdout.write(data) }; +} diff --git a/packages/runtime/src/composables/useTerminalSize.ts b/packages/runtime/src/composables/useTerminalSize.ts new file mode 100644 index 0000000..cf79ab3 --- /dev/null +++ b/packages/runtime/src/composables/useTerminalSize.ts @@ -0,0 +1,16 @@ +import { inject, onScopeDispose, shallowRef, type ShallowRef } from "vue"; +import { AppContextKey } from "../context.ts"; + +export function useTerminalSize(): { columns: ShallowRef; rows: ShallowRef } { + const ctx = inject(AppContextKey); + if (!ctx) throw new Error("useTerminalSize() must be called inside a vue-tui render tree"); + const columns = shallowRef(ctx.stdout.columns ?? 80); + const rows = shallowRef(ctx.stdout.rows ?? 24); + function onResize() { + columns.value = ctx!.stdout.columns ?? 80; + rows.value = ctx!.stdout.rows ?? 24; + } + ctx.stdout.on("resize", onResize); + onScopeDispose(() => ctx.stdout.off("resize", onResize)); + return { columns, rows }; +} diff --git a/packages/runtime/src/context.ts b/packages/runtime/src/context.ts new file mode 100644 index 0000000..d413a29 --- /dev/null +++ b/packages/runtime/src/context.ts @@ -0,0 +1,42 @@ +import type { InjectionKey, ShallowRef } from "vue"; +import type { EventEmitter } from "node:events"; + +export interface AppContext { + exit: (error?: Error) => void; + stdout: NodeJS.WriteStream; + stderr: NodeJS.WriteStream; + stdin: NodeJS.ReadStream; + debug: boolean; + isRawModeSupported: boolean; + setRawMode: (mode: boolean) => void; +} + +export interface FocusContext { + activeId: string | null; + activeIdRef: ShallowRef; + enabled: boolean; + enableFocus: () => void; + disableFocus: () => void; + focusNext: () => void; + focusPrevious: () => void; + focus: (id: string) => void; + blur: () => void; + add: (id: string, options: { autoFocus?: boolean }) => void; + remove: (id: string) => void; + activate: (id: string) => void; + deactivate: (id: string) => void; + subscribe: (id: string, fn: (focused: boolean) => void) => () => void; +} + +export interface StdinContext { + stdin: NodeJS.ReadStream; + setRawMode: (mode: boolean) => void; + isRawModeSupported: boolean; + internal_eventEmitter: EventEmitter; + acquireRawMode: () => void; + releaseRawMode: () => void; +} + +export const AppContextKey: InjectionKey = Symbol("vue-tui:app"); +export const FocusContextKey: InjectionKey = Symbol("vue-tui:focus"); +export const StdinContextKey: InjectionKey = Symbol("vue-tui:stdin"); diff --git a/packages/runtime/src/host/node-ops.ts b/packages/runtime/src/host/node-ops.ts new file mode 100644 index 0000000..7f8e831 --- /dev/null +++ b/packages/runtime/src/host/node-ops.ts @@ -0,0 +1,245 @@ +import { type RendererOptions } from "@vue/runtime-core"; +import { + createBox, + createComment as createCommentNode, + createStatic, + createText, + createTextLeaf, + createTransform, + createVirtualText, + isContainer, + type TuiContainer, + type TuiNode, +} from "./nodes.ts"; +import { + attachYoga, + detachYoga, + insertYogaChild, + removeYogaChild, + applyYogaProp, + isYogaProp, + bindTextMeasure, + markTextDirty, +} from "./yoga.ts"; + +export interface TtyRendererOptions { + onCommit: () => void; +} + +const STYLE_PROPS = new Set([ + "color", + "backgroundColor", + "dimColor", + "bold", + "italic", + "underline", + "strikethrough", + "inverse", + "wrap", + // Border visual style — also a yoga prop (sets border widths); stored here + // so the paint pass can look up borderStyle from el.props. + "borderStyle", + "borderColor", + "borderDimColor", + "borderTopColor", + "borderBottomColor", + "borderLeftColor", + "borderRightColor", + // Per-edge toggles are dual: yoga uses them to size border space, paint + // uses them to decide which edges to draw. + "borderTop", + "borderBottom", + "borderLeft", + "borderRight", +]); + +export function buildNodeOps(options: TtyRendererOptions): RendererOptions { + const { onCommit } = options; + + function createElement(type: string): TuiNode { + switch (type) { + case "box": { + const n = createBox(); + attachYoga(n); + return n; + } + case "text": { + const n = createText(); + attachYoga(n); + bindTextMeasure(n); + return n; + } + case "virtual-text": + return createVirtualText(); + case "static": { + const n = createStatic(); + attachYoga(n); + return n; + } + case "transform": + return createTransform((line) => line); // overwritten by patchProp + default: + throw new Error(`Unknown vue-tui element type: ${type}`); + } + } + + function createTextNode(text: string): TuiNode { + return createTextLeaf(text); + } + + function setText(node: TuiNode, text: string): void { + if (node.type !== "text-leaf") { + throw new Error(`Cannot setText on ${node.type}`); + } + node.value = text; + // Bubble dirty up to nearest TuiText so yoga remeasures. + let p = node.parent; + while (p && p.type !== "text") p = p.parent; + if (p) markTextDirty(p); + onCommit(); + } + + function setElementText(el: TuiNode, text: string): void { + if (!isContainer(el)) return; + // 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") { + markTextDirty(el); + } + } + + function insert(child: TuiNode, parent: TuiNode, anchor: TuiNode | null): void { + if (!isContainer(parent)) { + throw new Error(`Cannot insert into ${parent.type}`); + } + const parentC = parent as TuiContainer; + + // Move semantics: if the child is already mounted (Vue's keyed reorder + // emits insert(existingChild, parent, newAnchor) without a prior remove), + // detach it from its current DOM and yoga positions before re-inserting. + if (child.parent) { + const oldParent = child.parent; + const oldIdx = oldParent.children.indexOf(child as never); + if (oldIdx >= 0) oldParent.children.splice(oldIdx, 1); + removeYogaChild(oldParent, child); + } + + const idx = anchor ? parentC.children.indexOf(anchor as never) : parentC.children.length; + parentC.children.splice(idx < 0 ? parentC.children.length : idx, 0, child as never); + child.parent = parentC as never; + insertYogaChild(parentC, child, idx); + onCommit(); + } + + function remove(child: TuiNode): void { + const parent = child.parent; + if (!parent) return; + const idx = parent.children.indexOf(child as never); + if (idx >= 0) parent.children.splice(idx, 1); + removeYogaChild(parent, child); + // Free yoga nodes for this subtree (descendants first, then this node). + freeSubtreeYoga(child); + child.parent = null as never; + onCommit(); + } + + /** Recursively free yoga nodes for all yoga-carrying descendants, then the node itself. */ + function freeSubtreeYoga(node: TuiNode): void { + if (isContainer(node)) { + for (const child of (node as { children: TuiNode[] }).children) { + freeSubtreeYoga(child); + } + } + if (node.type === "box" || node.type === "text" || node.type === "static") { + detachYoga(node); + } + } + + function parentNode(node: TuiNode): TuiNode | null { + return node.parent ?? null; + } + + function nextSibling(node: TuiNode): TuiNode | null { + const p = node.parent; + if (!p) return null; + const i = p.children.indexOf(node as never); + if (i < 0) return null; + return (p.children[i + 1] as TuiNode | undefined) ?? null; + } + + function patchProp(el: TuiNode, key: string, _prev: unknown, next: unknown): void { + if (el.type === "transform") { + if (key === "transform" && typeof next === "function") { + el.transform = next as (line: string, idx: number) => string; + } + onCommit(); + return; + } + if (el.type === "box" || el.type === "text" || el.type === "static" || el.type === "root") { + if (isYogaProp(key)) { + applyYogaProp(el, key, next); + // Some yoga props also need to be stored in el.props for the paint pass. + if (STYLE_PROPS.has(key)) { + (el as { props: Record }).props[key] = next; + } + // Special case: borderStyle resets all four yoga border-edge widths to + // 1 (or 0). If per-edge toggles (borderTop/Bottom/Left/Right) were + // already applied before this patch, their values were clobbered. + // Re-apply any per-edge toggles that are stored in el.props so that + // yoga reflects the user's explicit per-edge settings. + // + // Only re-apply when `next` is truthy (i.e. a border style is actually + // being set). When borderStyle is cleared/undefined, applyYogaProp sets + // all edges to 0 which is the correct final state — there is nothing to + // restore, and re-applying per-edge defaults (e.g. borderTop:true from + // Box component defaults) would incorrectly reserve border space even + // though no border is drawn. + if (key === "borderStyle" && next) { + const props = (el as { props: Record }).props; + for (const edge of ["borderTop", "borderBottom", "borderLeft", "borderRight"] as const) { + if (props[edge] !== undefined) { + applyYogaProp(el, edge, props[edge]); + } + } + } + } else if (STYLE_PROPS.has(key)) { + (el as { props: Record }).props[key] = next; + } else if (key === "key" || key === "ref" || key.startsWith("on")) { + // Reserved by Vue / event keys, ignore. + } else if (process.env["NODE_ENV"] !== "production") { + // eslint-disable-next-line no-console + console.warn(`[vue-tui] unknown prop "${key}" on <${el.type}>`); + } + onCommit(); + return; + } + if (el.type === "virtual-text" && STYLE_PROPS.has(key)) { + (el.props as Record)[key] = next; + onCommit(); + } + } + + const nodeOps: RendererOptions = { + createElement: createElement as never, + createText: createTextNode as never, + createComment: (text: string) => createCommentNode(text) as never, + setText: setText as never, + setElementText: setElementText as never, + patchProp: patchProp as never, + insert: insert as never, + remove: remove as never, + parentNode: parentNode as never, + nextSibling: nextSibling as never, + querySelector: () => null, + setScopeId: () => {}, + cloneNode: () => { + throw new Error("cloneNode not supported by @vue-tui/runtime"); + }, + insertStaticContent: () => { + throw new Error("insertStaticContent not supported by @vue-tui/runtime"); + }, + }; + + return nodeOps; +} diff --git a/packages/runtime/src/host/nodes.test.ts b/packages/runtime/src/host/nodes.test.ts new file mode 100644 index 0000000..fc7b15c --- /dev/null +++ b/packages/runtime/src/host/nodes.test.ts @@ -0,0 +1,30 @@ +import { expect, test } from "vite-plus/test"; +import { createBox, createTextLeaf, createTransform, isContainer } from "./nodes.ts"; + +test("createBox returns shape with empty children + paintDirty true", () => { + const box = createBox(); + expect(box.type).toBe("box"); + expect(box.children).toEqual([]); + expect(box.paintDirty).toBe(true); + expect(box.parent).toBe(null); + expect(box.props).toEqual({}); +}); + +test("createTextLeaf carries its value", () => { + const leaf = createTextLeaf("hello"); + expect(leaf.type).toBe("text-leaf"); + expect(leaf.value).toBe("hello"); + expect(leaf.parent).toBe(null); +}); + +test("createTransform stores its transform function", () => { + const fn = (line: string) => line.toUpperCase(); + const node = createTransform(fn); + expect(node.type).toBe("transform"); + expect(node.transform).toBe(fn); +}); + +test("isContainer rejects text-leaf and accepts box", () => { + expect(isContainer(createBox())).toBe(true); + expect(isContainer(createTextLeaf("x"))).toBe(false); +}); diff --git a/packages/runtime/src/host/nodes.ts b/packages/runtime/src/host/nodes.ts new file mode 100644 index 0000000..df1b5e8 --- /dev/null +++ b/packages/runtime/src/host/nodes.ts @@ -0,0 +1,159 @@ +import type { AppContext } from "../context.ts"; +import type { Node as YogaNode } from "yoga-layout"; + +export type YogaNodeRef = YogaNode; + +export interface BoxProps { + [k: string]: unknown; +} + +export interface TextProps { + color?: unknown; + backgroundColor?: unknown; + dimColor?: boolean; + bold?: boolean; + italic?: boolean; + underline?: boolean; + strikethrough?: boolean; + inverse?: boolean; + wrap?: "wrap" | "truncate" | "truncate-end" | "truncate-middle" | "truncate-start"; +} + +interface NodeBase { + parent: TuiContainer | null; +} + +export interface TuiRoot extends NodeBase { + type: "root"; + parent: null; + children: TuiNode[]; + yoga: YogaNodeRef; + appContext: AppContext; +} + +export interface TuiBox extends NodeBase { + type: "box"; + children: TuiNode[]; + yoga: YogaNodeRef; + props: BoxProps; + paintDirty: boolean; +} + +export interface TuiText extends NodeBase { + type: "text"; + children: TuiInlineNode[]; + yoga: YogaNodeRef; + props: TextProps; + measuredCache?: string; +} + +export interface TuiVirtualText extends NodeBase { + type: "virtual-text"; + parent: TuiText | TuiVirtualText | null; + children: TuiInlineNode[]; + props: TextProps; +} + +export interface TuiTextLeaf extends NodeBase { + type: "text-leaf"; + parent: TuiText | TuiVirtualText | null; + value: string; +} + +/** Placeholder comment node used by Vue's renderer for v-if / null renders. */ +export interface TuiComment extends NodeBase { + type: "comment"; + value: string; +} + +export interface TuiStatic extends NodeBase { + type: "static"; + children: TuiNode[]; + yoga: YogaNodeRef; + writtenCount: number; +} + +export interface TuiTransform extends NodeBase { + type: "transform"; + children: TuiNode[]; + transform: (line: string, lineIndex: number) => string; +} + +export type TuiInlineNode = TuiVirtualText | TuiTextLeaf; +export type TuiContainer = TuiRoot | TuiBox | TuiStatic | TuiTransform | TuiText | TuiVirtualText; +export type TuiNode = TuiContainer | TuiTextLeaf | TuiComment; + +// Constructors take the bare minimum and leave yoga binding to yoga.ts. +// The `yoga` field is set to a sentinel and replaced by `attachYoga(node)`. +const UNATTACHED_YOGA = Symbol("vue-tui:yoga-unattached") as unknown as YogaNodeRef; + +export function createRoot(appContext: AppContext): TuiRoot { + return { + type: "root", + parent: null, + children: [], + yoga: UNATTACHED_YOGA, + appContext, + }; +} + +export function createBox(): TuiBox { + return { + type: "box", + parent: null, + children: [], + yoga: UNATTACHED_YOGA, + props: {}, + paintDirty: true, + }; +} + +export function createText(): TuiText { + return { + type: "text", + parent: null, + children: [], + yoga: UNATTACHED_YOGA, + props: {}, + }; +} + +export function createVirtualText(): TuiVirtualText { + return { + type: "virtual-text", + parent: null, + children: [], + props: {}, + }; +} + +export function createTextLeaf(value: string): TuiTextLeaf { + return { type: "text-leaf", parent: null, value }; +} + +export function createStatic(): TuiStatic { + return { + type: "static", + parent: null, + children: [], + yoga: UNATTACHED_YOGA, + writtenCount: 0, + }; +} + +export function createTransform(fn: (line: string, lineIndex: number) => string): TuiTransform { + return { + type: "transform", + parent: null, + children: [], + transform: fn, + }; +} + +export function createComment(value: string): TuiComment { + return { type: "comment", parent: null, value }; +} + +export function isContainer(node: TuiNode): node is TuiContainer { + return node.type !== "text-leaf" && node.type !== "comment"; +} diff --git a/packages/runtime/src/host/text-measure.test.ts b/packages/runtime/src/host/text-measure.test.ts new file mode 100644 index 0000000..38d655d --- /dev/null +++ b/packages/runtime/src/host/text-measure.test.ts @@ -0,0 +1,34 @@ +import { expect, test } from "vite-plus/test"; +import { createText, createTextLeaf, createVirtualText } from "./nodes.ts"; +import { flattenLeaves, wrapText } from "./text-measure.ts"; + +test("flattenLeaves concatenates a flat text node", () => { + const t = createText(); + const a = createTextLeaf("hello "); + const b = createTextLeaf("world"); + a.parent = t; + b.parent = t; + t.children = [a, b]; + expect(flattenLeaves(t)).toBe("hello world"); +}); + +test("flattenLeaves recurses into virtual-text", () => { + const t = createText(); + const v = createVirtualText(); + const a = createTextLeaf("a"); + const b = createTextLeaf("b"); + v.children = [b]; + b.parent = v; + v.parent = t; + t.children = [a, v]; + a.parent = t; + expect(flattenLeaves(t)).toBe("ab"); +}); + +test("wrapText splits on width", () => { + expect(wrapText("hello world", 5, "wrap")).toEqual(["hello", "world"]); +}); + +test("wrapText truncate-end cuts with ellipsis", () => { + expect(wrapText("abcdefgh", 5, "truncate-end")).toEqual(["abcd…"]); +}); diff --git a/packages/runtime/src/host/text-measure.ts b/packages/runtime/src/host/text-measure.ts new file mode 100644 index 0000000..7ca0533 --- /dev/null +++ b/packages/runtime/src/host/text-measure.ts @@ -0,0 +1,58 @@ +import sliceAnsi from "slice-ansi"; +import stringWidth from "string-width"; +import wrapAnsi from "wrap-ansi"; +import type { TextProps, TuiText, TuiVirtualText } from "./nodes.ts"; + +export function flattenLeaves(node: TuiText | TuiVirtualText): string { + let out = ""; + for (const child of node.children) { + if (child.type === "text-leaf") { + out += child.value; + } else { + out += flattenLeaves(child); + } + } + return out; +} + +export type WrapMode = NonNullable; + +export function wrapText(text: string, width: number, mode: WrapMode = "wrap"): string[] { + if (width <= 0) return [""]; + + if (mode === "wrap") { + return wrapAnsi(text, width, { hard: true, trim: true, wordWrap: true }).split("\n"); + } + + // truncate variants: collapse newlines, then slice from the appropriate side. + const single = text.replace(/\n/g, " "); + if (stringWidth(single) <= width) return [single]; + + const ellipsis = "…"; + const room = Math.max(0, width - stringWidth(ellipsis)); + switch (mode) { + case "truncate": + case "truncate-end": + return [sliceAnsi(single, 0, room) + ellipsis]; + case "truncate-start": + return [ellipsis + sliceAnsi(single, stringWidth(single) - room)]; + case "truncate-middle": { + const half = Math.floor(room / 2); + const left = sliceAnsi(single, 0, half); + const right = sliceAnsi(single, stringWidth(single) - (room - half)); + return [left + ellipsis + right]; + } + } +} + +export function measureText( + text: string, + width: number, + mode: WrapMode = "wrap", +): { width: number; height: number } { + const wrapped = wrapText(text, width, mode); + return { + width: wrapped.reduce((max, line) => Math.max(max, stringWidth(line)), 0), + height: wrapped.length, + }; +} diff --git a/packages/runtime/src/host/yoga.test.ts b/packages/runtime/src/host/yoga.test.ts new file mode 100644 index 0000000..9cb6ae8 --- /dev/null +++ b/packages/runtime/src/host/yoga.test.ts @@ -0,0 +1,9 @@ +import { expect, test } from "vite-plus/test"; +import { isYogaProp } from "./yoga.ts"; + +test("isYogaProp recognises layout props and rejects style props", () => { + expect(isYogaProp("padding")).toBe(true); + expect(isYogaProp("flexDirection")).toBe(true); + expect(isYogaProp("color")).toBe(false); + expect(isYogaProp("bold")).toBe(false); +}); diff --git a/packages/runtime/src/host/yoga.ts b/packages/runtime/src/host/yoga.ts new file mode 100644 index 0000000..9b6c996 --- /dev/null +++ b/packages/runtime/src/host/yoga.ts @@ -0,0 +1,213 @@ +import Yoga from "yoga-layout"; +import type { Node as YogaNode, Align, FlexDirection, Justify, Wrap } from "yoga-layout"; +import type { TuiBox, TuiContainer, TuiNode, TuiRoot, TuiStatic, TuiText } from "./nodes.ts"; + +type YogaCarrier = TuiRoot | TuiBox | TuiText | TuiStatic; + +// --- yoga node lifecycle seam -------------------------------------------- + +let _createCount = 0; +let _freeCount = 0; + +export function createYogaNode(): YogaNode { + _createCount++; + return Yoga.Node.create(); +} + +export function freeYogaNode(node: YogaNode): void { + _freeCount++; + node.free(); +} + +export const yogaNodeTracker = { + reset(): void { + _createCount = 0; + _freeCount = 0; + }, + snapshot(): { created: number; freed: number; live: number } { + return { + created: _createCount, + freed: _freeCount, + live: _createCount - _freeCount, + }; + }, +}; + +// ------------------------------------------------------------------------- + +function hasYoga(node: TuiNode): node is YogaCarrier { + return ( + node.type === "root" || node.type === "box" || node.type === "text" || node.type === "static" + ); +} + +export function attachYoga(node: YogaCarrier): void { + node.yoga = createYogaNode(); +} + +export function detachYoga(node: YogaCarrier): void { + freeYogaNode(node.yoga as YogaNode); +} + +// Returns the yoga index a child should occupy when added to `parent`. +// Skips any siblings that don't carry a yoga node (virtual-text, transform). +function yogaIndexFor(parent: TuiContainer, child: TuiNode): number { + let yIdx = 0; + for (const sibling of parent.children) { + if (sibling === child) return yIdx; + if (hasYoga(sibling)) yIdx++; + } + return yIdx; +} + +export function insertYogaChild(parent: TuiContainer, child: TuiNode, _domIndex: number): void { + if (!hasYoga(parent) || !hasYoga(child)) return; + const yIdx = yogaIndexFor(parent, child); + (parent.yoga as YogaNode).insertChild(child.yoga as YogaNode, yIdx); +} + +export function removeYogaChild(parent: TuiContainer, child: TuiNode): void { + if (!hasYoga(parent) || !hasYoga(child)) return; + (parent.yoga as YogaNode).removeChild(child.yoga as YogaNode); +} + +// --- prop application ---------------------------------------------------- + +const YOGA_PROP_SETTERS: Record void> = { + width: (n, v) => n.setWidth(v as number | "auto" | `${number}%`), + height: (n, v) => n.setHeight(v as number | "auto" | `${number}%`), + minWidth: (n, v) => n.setMinWidth(v as number | `${number}%`), + minHeight: (n, v) => n.setMinHeight(v as number | `${number}%`), + flexGrow: (n, v) => n.setFlexGrow(v as number), + flexShrink: (n, v) => n.setFlexShrink(v as number), + flexBasis: (n, v) => n.setFlexBasis(v as number | "auto" | `${number}%`), + flexDirection: (n, v) => n.setFlexDirection(toFlexDirection(v as string)), + flexWrap: (n, v) => n.setFlexWrap(toFlexWrap(v as string)), + alignItems: (n, v) => n.setAlignItems(toAlign(v as string)), + alignSelf: (n, v) => n.setAlignSelf(toAlign(v as string)), + justifyContent: (n, v) => n.setJustifyContent(toJustify(v as string)), + gap: (n, v) => n.setGap(Yoga.GUTTER_ALL, v as number), + columnGap: (n, v) => n.setGap(Yoga.GUTTER_COLUMN, v as number), + rowGap: (n, v) => n.setGap(Yoga.GUTTER_ROW, v as number), + + margin: (n, v) => n.setMargin(Yoga.EDGE_ALL, v as number), + marginX: (n, v) => { + n.setMargin(Yoga.EDGE_LEFT, v as number); + n.setMargin(Yoga.EDGE_RIGHT, v as number); + }, + marginY: (n, v) => { + n.setMargin(Yoga.EDGE_TOP, v as number); + n.setMargin(Yoga.EDGE_BOTTOM, v as number); + }, + marginTop: (n, v) => n.setMargin(Yoga.EDGE_TOP, v as number), + marginBottom: (n, v) => n.setMargin(Yoga.EDGE_BOTTOM, v as number), + marginLeft: (n, v) => n.setMargin(Yoga.EDGE_LEFT, v as number), + marginRight: (n, v) => n.setMargin(Yoga.EDGE_RIGHT, v as number), + + padding: (n, v) => n.setPadding(Yoga.EDGE_ALL, v as number), + paddingX: (n, v) => { + n.setPadding(Yoga.EDGE_LEFT, v as number); + n.setPadding(Yoga.EDGE_RIGHT, v as number); + }, + paddingY: (n, v) => { + n.setPadding(Yoga.EDGE_TOP, v as number); + n.setPadding(Yoga.EDGE_BOTTOM, v as number); + }, + paddingTop: (n, v) => n.setPadding(Yoga.EDGE_TOP, v as number), + paddingBottom: (n, v) => n.setPadding(Yoga.EDGE_BOTTOM, v as number), + paddingLeft: (n, v) => n.setPadding(Yoga.EDGE_LEFT, v as number), + paddingRight: (n, v) => n.setPadding(Yoga.EDGE_RIGHT, v as number), + + borderStyle: (n, v) => { + // Border occupies 1 cell on every side when a style is set. + const w = v ? 1 : 0; + n.setBorder(Yoga.EDGE_TOP, w); + n.setBorder(Yoga.EDGE_BOTTOM, w); + n.setBorder(Yoga.EDGE_LEFT, w); + n.setBorder(Yoga.EDGE_RIGHT, w); + }, + borderTop: (n, v) => n.setBorder(Yoga.EDGE_TOP, v ? 1 : 0), + borderBottom: (n, v) => n.setBorder(Yoga.EDGE_BOTTOM, v ? 1 : 0), + borderLeft: (n, v) => n.setBorder(Yoga.EDGE_LEFT, v ? 1 : 0), + borderRight: (n, v) => n.setBorder(Yoga.EDGE_RIGHT, v ? 1 : 0), + + display: (n, v) => n.setDisplay(v === "none" ? Yoga.DISPLAY_NONE : Yoga.DISPLAY_FLEX), + overflow: (n, v) => n.setOverflow(v === "hidden" ? Yoga.OVERFLOW_HIDDEN : Yoga.OVERFLOW_VISIBLE), + // Yoga does not support per-axis overflow; these are accepted silently. + overflowX: (_n, _v) => {}, + overflowY: (_n, _v) => {}, +}; + +function toFlexDirection(v: string): FlexDirection { + return { + row: Yoga.FLEX_DIRECTION_ROW, + "row-reverse": Yoga.FLEX_DIRECTION_ROW_REVERSE, + column: Yoga.FLEX_DIRECTION_COLUMN, + "column-reverse": Yoga.FLEX_DIRECTION_COLUMN_REVERSE, + }[v]!; +} + +function toFlexWrap(v: string): Wrap { + return { + nowrap: Yoga.WRAP_NO_WRAP, + wrap: Yoga.WRAP_WRAP, + "wrap-reverse": Yoga.WRAP_WRAP_REVERSE, + }[v]!; +} + +function toAlign(v: string): Align { + return { + auto: Yoga.ALIGN_AUTO, + "flex-start": Yoga.ALIGN_FLEX_START, + center: Yoga.ALIGN_CENTER, + "flex-end": Yoga.ALIGN_FLEX_END, + stretch: Yoga.ALIGN_STRETCH, + }[v]!; +} + +function toJustify(v: string): Justify { + return { + "flex-start": Yoga.JUSTIFY_FLEX_START, + center: Yoga.JUSTIFY_CENTER, + "flex-end": Yoga.JUSTIFY_FLEX_END, + "space-between": Yoga.JUSTIFY_SPACE_BETWEEN, + "space-around": Yoga.JUSTIFY_SPACE_AROUND, + "space-evenly": Yoga.JUSTIFY_SPACE_EVENLY, + }[v]!; +} + +export function isYogaProp(key: string): boolean { + return key in YOGA_PROP_SETTERS; +} + +export function applyYogaProp(node: YogaCarrier, key: string, value: unknown): void { + const setter = YOGA_PROP_SETTERS[key]; + if (!setter) return; + // Vue calls patchProp with `undefined` for every declared prop a user + // didn't set. Forwarding undefined to yoga's setters corrupts state: + // setAlignItems / setFlexDirection / setJustifyContent → 0 (AUTO/COLUMN/ + // FLEX_START), and the dimension setters (setWidth, setMargin, setBorder, + // …) write NaN. Skip undefined so yoga keeps its documented defaults. + // + // Exception: borderStyle is the one prop with intentional undefined + // semantics — undefined means "no border", which the setter implements + // by zeroing all four edge widths. + if (value === undefined && key !== "borderStyle") return; + setter(node.yoga as YogaNode, value); +} + +// --- text measure binding ------------------------------------------------ + +import { flattenLeaves, measureText } from "./text-measure.ts"; + +export function bindTextMeasure(text: TuiText): void { + text.yoga.setMeasureFunc((availableWidth) => { + const raw = flattenLeaves(text); + text.measuredCache = raw; + return measureText(raw, availableWidth, text.props.wrap ?? "wrap"); + }); +} + +export function markTextDirty(text: TuiText): void { + text.yoga.markDirty(); +} diff --git a/packages/runtime/src/index.ts b/packages/runtime/src/index.ts new file mode 100644 index 0000000..cb84b35 --- /dev/null +++ b/packages/runtime/src/index.ts @@ -0,0 +1,17 @@ +export { createApp, type TuiApp, type MountOptions } from "./render.ts"; + +export { Box } from "./components/Box.ts"; +export { Text } from "./components/Text.ts"; +export { Newline } from "./components/Newline.ts"; +export { Spacer } from "./components/Spacer.ts"; +export { Static } from "./components/Static.ts"; +export { Transform } from "./components/Transform.ts"; + +export { useExit } from "./composables/useExit.ts"; +export { useInput, type Key, type UseInputOptions } from "./composables/useInput.ts"; +export { useFocus, type UseFocusOptions } from "./composables/useFocus.ts"; +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 } from "./composables/useTerminalSize.ts"; diff --git a/packages/runtime/src/internal.ts b/packages/runtime/src/internal.ts new file mode 100644 index 0000000..960175f --- /dev/null +++ b/packages/runtime/src/internal.ts @@ -0,0 +1 @@ +export { yogaNodeTracker } from "./host/yoga.ts"; diff --git a/packages/runtime/src/io/frame-writer.test.ts b/packages/runtime/src/io/frame-writer.test.ts new file mode 100644 index 0000000..7e758bb --- /dev/null +++ b/packages/runtime/src/io/frame-writer.test.ts @@ -0,0 +1,17 @@ +import { PassThrough } from "node:stream"; +import { expect, test } from "vite-plus/test"; +import { createFrameWriter } from "./frame-writer.ts"; + +test("debug mode writes complete frames terminated by newline", () => { + const writes: string[] = []; + const stream = new PassThrough() as unknown as NodeJS.WriteStream; + Object.assign(stream, { columns: 80, rows: 24, isTTY: true }); + stream.on("data", (chunk) => writes.push(chunk.toString())); + + const writer = createFrameWriter(stream, { debug: true }); + writer.write("hello"); + writer.write("hello"); // identical frame skipped + writer.write("world"); + + expect(writes).toEqual(["hello\n", "world\n"]); +}); diff --git a/packages/runtime/src/io/frame-writer.ts b/packages/runtime/src/io/frame-writer.ts new file mode 100644 index 0000000..639fed6 --- /dev/null +++ b/packages/runtime/src/io/frame-writer.ts @@ -0,0 +1,35 @@ +import { createLogUpdate } from "log-update"; + +export interface FrameWriter { + write: (frame: string) => void; + done: () => void; + clear: () => void; +} + +export function createFrameWriter( + stream: NodeJS.WriteStream, + options: { debug?: boolean }, +): FrameWriter { + let lastFrame = ""; + const debug = options.debug ?? false; + const update = debug ? null : createLogUpdate(stream); + + return { + write(frame: string) { + if (frame === lastFrame) return; + lastFrame = frame; + if (debug) { + stream.write(frame + "\n"); + } else { + update!(frame); + } + }, + done() { + if (update) update.done(); + }, + clear() { + lastFrame = ""; + if (update) update.clear(); + }, + }; +} diff --git a/packages/runtime/src/io/key-parser.test.ts b/packages/runtime/src/io/key-parser.test.ts new file mode 100644 index 0000000..115b2c2 --- /dev/null +++ b/packages/runtime/src/io/key-parser.test.ts @@ -0,0 +1,16 @@ +import { expect, test } from "vite-plus/test"; +import { parseKey } from "./key-parser.ts"; + +test("Shift+Tab (\\x1b[Z) sets tab=true and shift=true", () => { + const result = parseKey("\x1b[Z"); + expect(result.input).toBe(""); + expect(result.key.tab).toBe(true); + expect(result.key.shift).toBe(true); +}); + +test("plain Tab (\\t) sets tab=true, shift=false", () => { + const result = parseKey("\t"); + expect(result.input).toBe(""); + expect(result.key.tab).toBe(true); + expect(result.key.shift).toBe(false); +}); diff --git a/packages/runtime/src/io/key-parser.ts b/packages/runtime/src/io/key-parser.ts new file mode 100644 index 0000000..bdf06b8 --- /dev/null +++ b/packages/runtime/src/io/key-parser.ts @@ -0,0 +1,74 @@ +export interface Key { + upArrow: boolean; + downArrow: boolean; + leftArrow: boolean; + rightArrow: boolean; + pageDown: boolean; + pageUp: boolean; + return: boolean; + escape: boolean; + ctrl: boolean; + shift: boolean; + tab: boolean; + backspace: boolean; + delete: boolean; + meta: boolean; +} + +const empty: Key = { + upArrow: false, + downArrow: false, + leftArrow: false, + rightArrow: false, + pageDown: false, + pageUp: false, + return: false, + escape: false, + ctrl: false, + shift: false, + tab: false, + backspace: false, + delete: false, + meta: false, +}; + +export function parseKey(input: string): { input: string; key: Key } { + const key: Key = { ...empty }; + if (input === "\r" || input === "\n") { + key.return = true; + return { input: "", key }; + } + if (input === "\x1b") { + key.escape = true; + return { input: "", key }; + } + if (input === "\t") { + key.tab = true; + return { input: "", key }; + } + if (input === "\x7f" || input === "\b") { + key.backspace = true; + return { input: "", key }; + } + if (input === "\x1b[A") return { input: "", key: { ...key, upArrow: true } }; + if (input === "\x1b[B") return { input: "", key: { ...key, downArrow: true } }; + if (input === "\x1b[C") return { input: "", key: { ...key, rightArrow: true } }; + if (input === "\x1b[D") return { input: "", key: { ...key, leftArrow: true } }; + if (input === "\x1b[5~") return { input: "", key: { ...key, pageUp: true } }; + if (input === "\x1b[6~") return { input: "", key: { ...key, pageDown: true } }; + if (input === "\x1b[3~") return { input: "", key: { ...key, delete: true } }; + if (input === "\x1b[Z") return { input: "", key: { ...key, tab: true, shift: true } }; + // Bare forms (no leading ESC) for test pipelines that strip it. + if (input === "[A") return { input: "", key: { ...key, upArrow: true } }; + if (input === "[B") return { input: "", key: { ...key, downArrow: true } }; + if (input === "[C") return { input: "", key: { ...key, rightArrow: true } }; + if (input === "[D") return { input: "", key: { ...key, leftArrow: true } }; + // Ctrl+letter -> codes 1..26 + if (input.length === 1 && input.charCodeAt(0) >= 1 && input.charCodeAt(0) <= 26) { + return { + input: String.fromCharCode(input.charCodeAt(0) + 96), + key: { ...key, ctrl: true }, + }; + } + return { input, key }; +} diff --git a/packages/runtime/src/paint/paint.ts b/packages/runtime/src/paint/paint.ts new file mode 100644 index 0000000..9e0d820 --- /dev/null +++ b/packages/runtime/src/paint/paint.ts @@ -0,0 +1,310 @@ +import stringWidth from "string-width"; +import cliBoxes from "cli-boxes"; +import { applyChalk } from "./text-style.ts"; +import Yoga from "yoga-layout"; +import type { + TuiNode, + TuiContainer, + TextProps, + TuiText, + TuiVirtualText, + BoxProps, +} from "../host/nodes.ts"; +import { createRoot as createIsoRoot } from "../host/nodes.ts"; +import { wrapText } from "../host/text-measure.ts"; +import { attachYoga, detachYoga } from "../host/yoga.ts"; + +export type Transformer = (line: string, lineIndex: number) => string; + +interface WriteOp { + x: number; + y: number; + lines: string[]; + transformers: Transformer[]; +} + +class Output { + readonly width: number; + readonly height: number; + private ops: WriteOp[] = []; + + constructor(width: number, height: number) { + this.width = width; + this.height = height; + } + + write(x: number, y: number, lines: string[], transformers: Transformer[]): void { + this.ops.push({ x, y, lines, transformers }); + } + + get(): string { + // Build a sparse grid of cells, write each op left-to-right. + const grid: string[][] = Array.from({ length: this.height }, () => + Array.from({ length: this.width }, () => " "), + ); + for (const op of this.ops) { + for (let lineIdx = 0; lineIdx < op.lines.length; lineIdx++) { + let line = op.lines[lineIdx]!; + for (const tf of op.transformers) line = tf(line, lineIdx); + placeLine(grid, op.x, op.y + lineIdx, line); + } + } + return grid.map((row) => row.join("").trimEnd()).join("\n"); + } +} + +function placeLine(grid: string[][], x: number, y: number, line: string): void { + if (y < 0 || y >= grid.length) return; + const row = grid[y]!; + // We walk the line as visual cells. ANSI sequences are kept attached to the + // cell that follows them; emoji/wide chars consume two cells. + let col = x; + let i = 0; + let pendingAnsi = ""; + while (i < line.length && col < row.length) { + const ch = line[i]!; + // ANSI CSI sequence: ESC[...m — accumulate as a prefix for the next cell. + if (ch === "\x1b" && line[i + 1] === "[") { + const end = line.indexOf("m", i); + if (end >= 0) { + pendingAnsi += line.slice(i, end + 1); + i = end + 1; + continue; + } + } + const segment = ch; + const w = stringWidth(segment); + if (col >= 0 && col < row.length) { + row[col] = pendingAnsi + segment; + pendingAnsi = ""; + } + if (w === 2 && col + 1 < row.length) row[col + 1] = ""; + col += Math.max(1, w); + i++; + } + // If the line ended with a trailing escape (e.g. reset), attach it to the + // last written cell so it's not lost. + if (pendingAnsi && col > x) { + const lastCol = Math.min(col - 1, row.length - 1); + if (lastCol >= 0) row[lastCol] = (row[lastCol] ?? "") + pendingAnsi; + } +} + +function renderTextWithInlineStyles(node: TuiText | TuiVirtualText, acc: TextProps = {}): string { + const merged: TextProps = { ...acc, ...node.props }; + let out = ""; + for (const child of node.children) { + if (child.type === "text-leaf") { + out += applyChalk(child.value, merged); + } else { + out += renderTextWithInlineStyles(child, merged); + } + } + return out; +} + +type BoxStyle = (typeof cliBoxes)[keyof cliBoxes.Boxes]; + +function drawBorder( + output: Output, + x: number, + y: number, + w: number, + h: number, + props: BoxProps, + transformers: Transformer[], +): void { + const style = props["borderStyle"] as string | undefined; + if (!style) return; + const chars = (cliBoxes as unknown as Record)[style]; + if (!chars) return; + if (w < 2 || h < 2) return; + + // Per-edge toggles default to true when borderStyle is set. + const top = props["borderTop"] !== false; + const bottom = props["borderBottom"] !== false; + const left = props["borderLeft"] !== false; + const right = props["borderRight"] !== false; + + // Corners require both adjacent edges to be enabled; otherwise the + // adjacent edge character is used as a "stub" so the visible edge still + // terminates cleanly. + if (top) { + const tl = left ? chars.topLeft : chars.top; + const tr = right ? chars.topRight : chars.top; + output.write(x, y, [tl + chars.top.repeat(w - 2) + tr], transformers); + } + if (bottom) { + const bl = left ? chars.bottomLeft : chars.bottom; + const br = right ? chars.bottomRight : chars.bottom; + output.write(x, y + h - 1, [bl + chars.bottom.repeat(w - 2) + br], transformers); + } + for (let i = 1; i < h - 1; i++) { + if (left) output.write(x, y + i, [chars.left], transformers); + if (right) output.write(x + w - 1, y + i, [chars.right], transformers); + } +} + +function fillBackground( + output: Output, + x: number, + y: number, + w: number, + h: number, + color: unknown, + transformers: Transformer[], +): void { + if (!color) return; + const line = applyChalk(" ".repeat(w), { backgroundColor: color }); + for (let i = 0; i < h; i++) output.write(x, y + i, [line], transformers); +} + +export function paint(root: TuiNode): string { + if (root.type !== "root") throw new Error("paint expects TuiRoot"); + const layout = root.yoga.getComputedLayout(); + const width = Math.max(1, Math.floor(layout.width)); + const height = Math.max(1, Math.floor(layout.height)); + const output = new Output(width, height); + paintNode(root, output, 0, 0, []); + return output.get(); +} + +function paintNode( + node: TuiNode, + output: Output, + x0: number, + y0: number, + transformers: Transformer[], +): void { + switch (node.type) { + case "root": { + for (const child of node.children) paintNode(child, output, x0, y0, transformers); + return; + } + case "box": { + const layout = node.yoga.getComputedLayout(); + const x = x0 + layout.left; + const y = y0 + layout.top; + const w = Math.max(0, Math.floor(layout.width)); + const h = Math.max(0, Math.floor(layout.height)); + if (node.props["backgroundColor"]) { + fillBackground(output, x, y, w, h, node.props["backgroundColor"], transformers); + } + if (node.props["borderStyle"]) { + drawBorder(output, x, y, w, h, node.props, transformers); + } + for (const child of node.children) paintNode(child, output, x, y, transformers); + return; + } + case "text": { + const layout = node.yoga.getComputedLayout(); + const text = renderTextWithInlineStyles(node); + const wrapped = wrapText( + text, + Math.max(1, Math.floor(layout.width)), + node.props.wrap ?? "wrap", + ); + output.write(x0 + layout.left, y0 + layout.top, wrapped, transformers); + return; + } + case "static": { + // Static is rendered through the static channel (written before frame), so + // it does not contribute to the dynamic frame paint. + return; + } + case "transform": { + const next = [...transformers, node.transform]; + for (const child of node.children) paintNode(child, output, x0, y0, next); + return; + } + case "virtual-text": + case "text-leaf": + case "comment": + // virtual-text and text-leaf are handled inside renderTextWithInlineStyles. + // Comments are invisible. + return; + } +} + +export function paintContainer(container: TuiContainer): string { + // Used by Static channel and tests. + if (container.type === "root") return paint(container); + throw new Error("paintContainer currently only supports root"); +} + +export function paintIsolated(nodes: TuiNode[], width: number): string { + const iso = createIsoRoot({} as never); + attachYoga(iso); + iso.yoga.setWidth(width); + + // Track which nodes we successfully added to iso's yoga tree so we can + // remove them afterwards. Nodes that are already parented in another yoga + // tree are first removed from that parent before insertion. + // + // IMPORTANT: We deliberately do NOT mutate each node's DOM .parent field. + // The children remain logically owned by their original Static parent — only + // yoga parentage is temporarily transferred to iso for layout calculation. + // Mutating .parent would leave the original tree with broken back-links and + // cause renderer.remove() to skip yoga cleanup (seeing parent === null). + type YogaCarrier = { yoga: import("yoga-layout").Node }; + const yogaAdded: Array<{ + yc: YogaCarrier; + origParent: import("yoga-layout").Node | null; + origIndex: number; + }> = []; + + // yIdx tracks only yoga-carrying nodes; DOM-only nodes (text-leaf, comment, + // fragment anchors) do not contribute a yoga slot and must not advance it. + let yIdx = 0; + for (let i = 0; i < nodes.length; i++) { + const node = nodes[i]!; + // Add to iso.children for paint() traversal, but do NOT change node.parent. + iso.children.push(node); + + const yCarrier = node as unknown as YogaCarrier; + // Skip nodes that carry no yoga node (text-leaf, comment, fragment anchors). + if (!yCarrier.yoga || typeof yCarrier.yoga === "symbol") continue; + + // If the node already has a yoga parent, temporarily remove it so we can + // re-insert it under iso for layout calculation. + const yParent = (yCarrier.yoga as unknown as { getParent(): import("yoga-layout").Node | null }) + .getParent + ? (yCarrier.yoga as unknown as { getParent(): import("yoga-layout").Node | null }).getParent() + : null; + const origIndex = yParent ? findYogaIndex(yParent, yCarrier.yoga) : 0; + if (yParent) { + yParent.removeChild(yCarrier.yoga); + } + iso.yoga.insertChild(yCarrier.yoga, yIdx); + yogaAdded.push({ yc: yCarrier, origParent: yParent, origIndex }); + yIdx++; + } + + try { + iso.yoga.calculateLayout(width, undefined, Yoga.DIRECTION_LTR); + return paint(iso); + } finally { + // Restore yoga parents in reverse order so earlier indices remain stable. + for (const { yc, origParent, origIndex } of yogaAdded.slice().reverse()) { + iso.yoga.removeChild(yc.yoga); + if (origParent) { + origParent.insertChild(yc.yoga, origIndex); + } + } + + // Remove children from iso without touching their .parent pointers — they + // still belong to the original Static node in the live DOM tree. + iso.children.length = 0; + detachYoga(iso); + } +} + +function findYogaIndex( + parent: import("yoga-layout").Node, + child: import("yoga-layout").Node, +): number { + for (let i = 0; i < parent.getChildCount(); i++) { + if (parent.getChild(i) === child) return i; + } + return 0; +} diff --git a/packages/runtime/src/paint/static-channel.ts b/packages/runtime/src/paint/static-channel.ts new file mode 100644 index 0000000..2b21dce --- /dev/null +++ b/packages/runtime/src/paint/static-channel.ts @@ -0,0 +1,21 @@ +import type { TuiNode, TuiStatic } from "../host/nodes.ts"; +import { paintIsolated } from "./paint.ts"; + +export function findStatics(root: TuiNode, out: TuiStatic[] = []): TuiStatic[] { + if (root.type === "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); + } + return out; +} + +export function flushStatic(root: TuiNode, stream: NodeJS.WriteStream): void { + for (const stat of findStatics(root)) { + const fresh = stat.children.slice(stat.writtenCount); + if (fresh.length === 0) continue; + const frame = paintIsolated(fresh, stream.columns ?? 80); + if (frame.length > 0) stream.write(frame + "\n"); + stat.writtenCount = stat.children.length; + } +} diff --git a/packages/runtime/src/paint/text-style.test.ts b/packages/runtime/src/paint/text-style.test.ts new file mode 100644 index 0000000..edbfdd6 --- /dev/null +++ b/packages/runtime/src/paint/text-style.test.ts @@ -0,0 +1,53 @@ +import chalk from "chalk"; +import { expect, test } from "vite-plus/test"; +import { applyChalk } from "./text-style.ts"; + +test("named color applies chalk method", () => { + const prev = chalk.level; + chalk.level = 1; + try { + expect(applyChalk("x", { color: "red" })).toBe(chalk.red("x")); + } finally { + chalk.level = prev; + } +}); + +test("hex color applies chalk.hex", () => { + const prev = chalk.level; + chalk.level = 1; + try { + expect(applyChalk("x", { color: "#ff0000" })).toBe(chalk.hex("#ff0000")("x")); + } finally { + chalk.level = prev; + } +}); + +test("rgb tuple applies chalk.rgb", () => { + const prev = chalk.level; + chalk.level = 1; + try { + expect(applyChalk("x", { color: [255, 0, 0] })).toBe(chalk.rgb(255, 0, 0)("x")); + } finally { + chalk.level = prev; + } +}); + +test("unknown color name falls back to no color", () => { + const prev = chalk.level; + chalk.level = 1; + try { + expect(applyChalk("x", { color: "not-a-real-color" })).toBe("x"); + } finally { + chalk.level = prev; + } +}); + +test("multiple modifiers chain", () => { + const prev = chalk.level; + chalk.level = 1; + try { + expect(applyChalk("x", { bold: true, underline: true })).toBe(chalk.bold.underline("x")); + } finally { + chalk.level = prev; + } +}); diff --git a/packages/runtime/src/paint/text-style.ts b/packages/runtime/src/paint/text-style.ts new file mode 100644 index 0000000..f33f79b --- /dev/null +++ b/packages/runtime/src/paint/text-style.ts @@ -0,0 +1,45 @@ +import chalk, { type ChalkInstance } from "chalk"; +import type { TextProps } from "../host/nodes.ts"; + +export function applyColor( + c: ChalkInstance, + color: string | [number, number, number], + bg: boolean, +): ChalkInstance { + if (Array.isArray(color)) { + return bg ? c.bgRgb(color[0], color[1], color[2]) : c.rgb(color[0], color[1], color[2]); + } + if (typeof color !== "string") return c; + if (color.startsWith("#")) return bg ? c.bgHex(color) : c.hex(color); + if (color.startsWith("rgb(")) { + const m = color.match(/rgb\(\s*(\d+)\s*,\s*(\d+)\s*,\s*(\d+)\s*\)/); + if (m) { + const [r, g, b] = [+m[1]!, +m[2]!, +m[3]!]; + return bg ? c.bgRgb(r, g, b) : c.rgb(r, g, b); + } + } + if (color.startsWith("ansi(")) { + const n = +color.slice(5, -1); + return bg ? c.bgAnsi256(n) : c.ansi256(n); + } + const key = bg ? bgKey(color) : color; + const fn = (c as never as Record)[key]; + return fn ?? c; +} + +function bgKey(name: string): string { + return "bg" + name.charAt(0).toUpperCase() + name.slice(1); +} + +export function applyChalk(text: string, props: TextProps): string { + let style: ChalkInstance = chalk; + if (props.color) style = applyColor(style, props.color as never, false); + if (props.backgroundColor) style = applyColor(style, props.backgroundColor as never, true); + if (props.dimColor) style = style.dim; + if (props.bold) style = style.bold; + if (props.italic) style = style.italic; + if (props.underline) style = style.underline; + if (props.strikethrough) style = style.strikethrough; + if (props.inverse) style = style.inverse; + return style(text); +} diff --git a/packages/runtime/src/render.ts b/packages/runtime/src/render.ts new file mode 100644 index 0000000..6e4fafc --- /dev/null +++ b/packages/runtime/src/render.ts @@ -0,0 +1,406 @@ +import Yoga from "yoga-layout"; +import { type Component, type ComponentPublicInstance, type App as VueApp, shallowRef } from "vue"; +import { createRenderer } from "@vue/runtime-core"; +import { EventEmitter } from "node:events"; +import { createRoot, type TuiRoot, type TuiNode } from "./host/nodes.ts"; +import { attachYoga, detachYoga } from "./host/yoga.ts"; +import { buildNodeOps } from "./host/node-ops.ts"; +import { createCommitScheduler } from "./scheduler.ts"; +import { paint } from "./paint/paint.ts"; +import { flushStatic } from "./paint/static-channel.ts"; +import { createFrameWriter } from "./io/frame-writer.ts"; +import { + AppContextKey, + FocusContextKey, + StdinContextKey, + type AppContext, + type FocusContext, + type StdinContext, +} from "./context.ts"; + +export interface MountOptions { + stdout?: NodeJS.WriteStream; + stdin?: NodeJS.ReadStream; + stderr?: NodeJS.WriteStream; + debug?: boolean; + exitOnCtrlC?: boolean; +} + +export interface TuiApp extends Omit, "mount"> { + mount(options?: MountOptions): ComponentPublicInstance; + waitUntilExit(): Promise; +} + +type RootProps = Record; + +export function createApp(root: Component, rootProps?: RootProps | null): TuiApp { + // exit promise — created at createApp time so waitUntilExit() works even + // before mount (it just hangs until mount + exit). + let exitResolve!: () => void; + let exitReject!: (e: Error) => void; + const exitPromise = new Promise((res, rej) => { + exitResolve = res; + exitReject = rej; + }); + exitPromise.catch(() => {}); + + let mountedRoot: TuiRoot | null = null; + let mountedWriter: ReturnType | null = null; + let mountedStdinController: StdinController | null = null; + let mountedAppContext: AppContext | null = null; + let mountedSigintHandler: (() => void) | null = null; + let mountedResizeHandler: (() => void) | null = null; + let mountedExitListener: (() => void) | null = null; + let mountedFocusListener: (() => void) | null = null; + let mountedDebug = false; + + // The renderer's onCommit closure is wired at createApp time but only does + // real work after mount swaps in scheduler.schedule. One renderer per app + // even though it's not used until mount. + let scheduledCommit: () => void = () => {}; + + let teardownStarted = false; + function teardown() { + if (teardownStarted) return; + teardownStarted = true; + scheduledCommit = () => {}; + try { + originalUnmount(); + } catch { + // Vue's unmount may throw on double-unmount; swallow for idempotency. + } + if (mountedWriter && !mountedDebug) mountedWriter.done(); + if (mountedRoot) detachYoga(mountedRoot); + if (mountedResizeHandler && mountedAppContext) { + mountedAppContext.stdout.off("resize", mountedResizeHandler); + } + if (mountedSigintHandler) { + process.off("SIGINT", mountedSigintHandler); + } + if (mountedExitListener) { + process.off("exit", mountedExitListener); + } + if (mountedFocusListener) { + mountedFocusListener(); + } + if (mountedStdinController) { + mountedStdinController.dispose(); + } + } + + const renderer = createRenderer( + buildNodeOps({ onCommit: () => scheduledCommit() }), + ); + const baseApp = renderer.createApp(root, rootProps ?? undefined); + const originalMount = baseApp.mount.bind(baseApp); + const originalUnmount = baseApp.unmount.bind(baseApp); + + const app = baseApp as unknown as TuiApp; + + app.mount = function mount(options: MountOptions = {}): ComponentPublicInstance { + const stdout = options.stdout ?? process.stdout; + const stdin = options.stdin ?? process.stdin; + const stderr = options.stderr ?? process.stderr; + const debug = options.debug ?? false; + const exitOnCtrlC = options.exitOnCtrlC ?? true; + mountedDebug = debug; + + const appContext: AppContext = { + exit(err?: Error) { + if (err) exitReject(err); + // Defer teardown to a microtask: exit() is frequently called from + // inside the Vue update cycle (useInput handler, setup(), errorHandler) + // and unmounting synchronously would tear Vue down mid-flush. + queueMicrotask(() => { + teardown(); + exitResolve(); + }); + }, + stdout, + stderr, + stdin, + debug, + isRawModeSupported: !!(stdin as { isTTY?: boolean }).isTTY, + setRawMode(mode: boolean) { + if ( + typeof (stdin as { setRawMode?: (mode: boolean) => unknown }).setRawMode === "function" + ) { + (stdin as { setRawMode: (mode: boolean) => unknown }).setRawMode(mode); + } + }, + }; + mountedAppContext = appContext; + + const focusContext: FocusContext = createFocusController(); + const stdinController = createStdinController(stdin, appContext); + mountedStdinController = stdinController; + + const tuiRoot = createRoot(appContext); + attachYoga(tuiRoot); + tuiRoot.yoga.setWidth(stdout.columns ?? 80); + mountedRoot = tuiRoot; + + const writer = createFrameWriter(stdout, { debug }); + mountedWriter = writer; + + function commit() { + flushStatic(tuiRoot, stdout); + const w = stdout.columns ?? 80; + tuiRoot.yoga.setWidth(w); + tuiRoot.yoga.calculateLayout(w, undefined, Yoga.DIRECTION_LTR); + const frame = paint(tuiRoot); + writer.write(frame); + } + + const scheduler = createCommitScheduler(commit); + scheduledCommit = scheduler.schedule; + + // Internal provides — set before the actual mount so components can inject + // them. User .use/.provide calls made earlier on the chain stay intact; + // our keys are Symbols so there's no collision risk. + baseApp.provide(AppContextKey, appContext); + baseApp.provide(FocusContextKey, focusContext); + baseApp.provide(StdinContextKey, stdinController); + + let proxy: ComponentPublicInstance; + try { + proxy = originalMount(tuiRoot) as unknown as ComponentPublicInstance; + } catch (mountError) { + stdinController.dispose(); + detachYoga(tuiRoot); + throw mountError; + } + + // errorHandler installed AFTER mount so sync mount errors still throw normally. + // Async errors (Vue's flushJobs scheduler) get routed through appContext.exit + // instead of surfacing as unhandled rejections. + baseApp.config.errorHandler = (err) => { + appContext.exit(err instanceof Error ? err : new Error(String(err))); + }; + + // Built-in Tab / Shift+Tab / Escape focus navigation (matches Ink). + // Placed AFTER mount so a sync mount failure doesn't leak the listener. + const focusInputListener = (chunk: Buffer | string) => { + const data = chunk.toString(); + if (data === "\t") focusContext.focusNext(); + else if (data === "\x1b[Z") focusContext.focusPrevious(); + else if (data === "\x1b") focusContext.blur(); + }; + stdin.on("data", focusInputListener); + mountedFocusListener = () => stdin.off("data", focusInputListener); + + const onResize = () => scheduler.schedule(); + stdout.on("resize", onResize); + mountedResizeHandler = onResize; + + if (exitOnCtrlC) { + const handler = () => appContext.exit(); + process.once("SIGINT", handler); + mountedSigintHandler = handler; + } + + // Auto-cleanup on process exit (process.exit, event-loop drain, uncaught + // exception — anything that fires Node's 'exit' event). teardown() is + // sync and idempotent, safe to call from this hook. If the user already + // called unmount() / useExit(), this is a no-op. + const exitListener = () => teardown(); + process.on("exit", exitListener); + mountedExitListener = exitListener; + + return proxy; + }; + + app.unmount = function unmount(): void { + teardown(); + exitResolve(); + }; + + app.waitUntilExit = function waitUntilExit(): Promise { + return exitPromise; + }; + + return app; +} + +// --- Focus controller ---------------------------------------------------- + +interface Focusable { + readonly id: string; + isActive: boolean; +} + +function createFocusController(): FocusContext { + const focusables: Focusable[] = []; + const subs = new Map void>>(); + let activeId: string | null = null; + const activeIdRef = shallowRef(null); + let enabled = true; + + function notify(id: string, focused: boolean) { + subs.get(id)?.forEach((fn) => fn(focused)); + } + + function setActive(next: string | null) { + if (activeId === next) return; + const prev = activeId; + activeId = next; + ctx.activeId = activeId; + activeIdRef.value = activeId; + if (prev) notify(prev, false); + if (next) notify(next, true); + } + + function findNextActive(startIdx: number, direction: 1 | -1): string | null { + const len = focusables.length; + for (let i = 0; i < len; i++) { + const idx = (startIdx + direction * (i + 1) + len * len) % len; + if (focusables[idx]!.isActive) return focusables[idx]!.id; + } + return null; + } + + const ctx: FocusContext = { + activeId: null, + activeIdRef, + enabled: true, + enableFocus() { + enabled = true; + ctx.enabled = true; + }, + disableFocus() { + enabled = false; + ctx.enabled = false; + }, + focusNext() { + if (!enabled || focusables.length === 0) return; + const idx = activeId ? focusables.findIndex((f) => f.id === activeId) : -1; + const next = findNextActive(idx, 1); + if (next) setActive(next); + }, + focusPrevious() { + if (!enabled || focusables.length === 0) return; + const idx = activeId ? focusables.findIndex((f) => f.id === activeId) : focusables.length; + const prev = findNextActive(idx, -1); + if (prev) setActive(prev); + }, + focus(id) { + const entry = focusables.find((f) => f.id === id); + if (entry) setActive(id); + }, + blur() { + setActive(null); + }, + add(id, options) { + if (!focusables.some((f) => f.id === id)) { + focusables.push({ id, isActive: true }); + } + if (options.autoFocus && activeId == null) { + setActive(id); + } + }, + remove(id) { + const idx = focusables.findIndex((f) => f.id === id); + if (idx >= 0) focusables.splice(idx, 1); + if (activeId === id) setActive(null); + }, + activate(id) { + const entry = focusables.find((f) => f.id === id); + if (entry) entry.isActive = true; + }, + deactivate(id) { + const entry = focusables.find((f) => f.id === id); + if (entry) { + entry.isActive = false; + if (activeId === id) setActive(null); + } + }, + subscribe(id, fn) { + let set = subs.get(id); + if (!set) { + set = new Set(); + subs.set(id, set); + } + set.add(fn); + return () => set!.delete(fn); + }, + }; + + return ctx; +} + +// --- Stdin controller ---------------------------------------------------- + +interface StdinController extends StdinContext { + dispose: () => void; +} + +interface RawModeState { + refs: number; + prevRaw: boolean | null; +} +const rawModeRegistry = new WeakMap(); + +function getRawModeState(stdin: NodeJS.ReadStream): RawModeState { + let state = rawModeRegistry.get(stdin); + if (!state) { + state = { refs: 0, prevRaw: null }; + rawModeRegistry.set(stdin, state); + } + return state; +} + +function createStdinController(stdin: NodeJS.ReadStream, appCtx: AppContext): StdinController { + const emitter = new EventEmitter(); + const listener = (chunk: Buffer | string) => { + emitter.emit("data", chunk.toString()); + }; + stdin.on("data", listener); + + let localRefs = 0; + + return { + stdin, + setRawMode: appCtx.setRawMode, + isRawModeSupported: appCtx.isRawModeSupported, + internal_eventEmitter: emitter, + acquireRawMode() { + if (!appCtx.isRawModeSupported) return; + const state = getRawModeState(stdin); + if (state.refs === 0) { + state.prevRaw = (stdin as { isRaw?: boolean }).isRaw ?? false; + appCtx.setRawMode(true); + } + state.refs++; + localRefs++; + }, + releaseRawMode() { + if (!appCtx.isRawModeSupported) return; + if (localRefs === 0) return; + const state = getRawModeState(stdin); + state.refs = Math.max(0, state.refs - 1); + localRefs = Math.max(0, localRefs - 1); + if (state.refs === 0 && state.prevRaw !== null) { + // Defer the actual disable: when components swap (v-if key change), + // Vue unmounts the old before mounting the new, so refs briefly hits 0. + // Disabling synchronously would drop raw mode between the two mounts. + queueMicrotask(() => { + if (state.refs > 0 || state.prevRaw === null) return; + appCtx.setRawMode(state.prevRaw); + state.prevRaw = null; + }); + } + }, + dispose() { + stdin.off("data", listener); + if (localRefs > 0 && appCtx.isRawModeSupported) { + const state = getRawModeState(stdin); + state.refs = Math.max(0, state.refs - localRefs); + localRefs = 0; + if (state.refs === 0 && state.prevRaw !== null) { + appCtx.setRawMode(state.prevRaw); + state.prevRaw = null; + } + } + }, + }; +} diff --git a/packages/runtime/src/scheduler.ts b/packages/runtime/src/scheduler.ts new file mode 100644 index 0000000..91f6953 --- /dev/null +++ b/packages/runtime/src/scheduler.ts @@ -0,0 +1,35 @@ +import { queuePostFlushCb } from "@vue/runtime-core"; + +export interface CommitScheduler { + schedule: () => void; + flush: () => Promise; +} + +export function createCommitScheduler(commit: () => void): CommitScheduler { + let scheduled = false; + let resolveFlush: (() => void) | null = null; + + function schedule() { + if (scheduled) return; + scheduled = true; + queuePostFlushCb(() => { + scheduled = false; + try { + commit(); + } finally { + const r = resolveFlush; + resolveFlush = null; + r?.(); + } + }); + } + + function flush(): Promise { + if (!scheduled) return Promise.resolve(); + return new Promise((resolve) => { + resolveFlush = resolve; + }); + } + + return { schedule, flush }; +} diff --git a/packages/runtime/tsconfig.json b/packages/runtime/tsconfig.json new file mode 100644 index 0000000..5baeccb --- /dev/null +++ b/packages/runtime/tsconfig.json @@ -0,0 +1,22 @@ +{ + "compilerOptions": { + "target": "esnext", + "lib": ["es2023"], + "moduleDetection": "force", + "module": "nodenext", + "moduleResolution": "nodenext", + "resolveJsonModule": true, + "types": ["node"], + "strict": true, + "noUnusedLocals": true, + "declaration": true, + "noEmit": true, + "allowImportingTsExtensions": true, + "esModuleInterop": true, + "isolatedModules": true, + "verbatimModuleSyntax": true, + "skipLibCheck": true, + "jsx": "preserve", + "jsxImportSource": "vue" + } +} diff --git a/packages/runtime/vite.config.ts b/packages/runtime/vite.config.ts new file mode 100644 index 0000000..acbfeae --- /dev/null +++ b/packages/runtime/vite.config.ts @@ -0,0 +1,15 @@ +import { defineConfig } from "vite-plus"; +import vueJsx from "@vitejs/plugin-vue-jsx"; + +export default defineConfig({ + plugins: [vueJsx()], + pack: { + entry: ["src/index.ts", "src/internal.ts"], + dts: { tsgo: true }, + exports: true, + }, + lint: { + options: { typeAware: true, typeCheck: true }, + }, + fmt: {}, +});