feat(runtime): Vue terminal renderer with Ink-aligned API
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) <noreply@anthropic.com>
This commit is contained in:
@@ -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<VueApp<TuiNode>, "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<TuiNode> {
|
||||
mount(options?: MountOptions): ComponentPublicInstance;
|
||||
unmount(): void;
|
||||
waitUntilExit(): Promise<void>;
|
||||
}
|
||||
|
||||
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.
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
@@ -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<FlexDirection>,
|
||||
flexGrow: Number,
|
||||
flexShrink: Number,
|
||||
flexBasis: [Number, String],
|
||||
flexWrap: String as PropType<FlexWrap>,
|
||||
alignItems: String as PropType<Align>,
|
||||
alignSelf: String as PropType<AlignSelf>,
|
||||
justifyContent: String as PropType<Justify>,
|
||||
gap: Number,
|
||||
columnGap: Number,
|
||||
rowGap: Number,
|
||||
|
||||
width: [Number, String],
|
||||
height: [Number, String],
|
||||
minWidth: [Number, String],
|
||||
minHeight: [Number, String],
|
||||
|
||||
margin: Number as PropType<Spacing>,
|
||||
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<BorderStyle>,
|
||||
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?.());
|
||||
},
|
||||
});
|
||||
@@ -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));
|
||||
},
|
||||
});
|
||||
@@ -0,0 +1,8 @@
|
||||
import { defineComponent, h } from "vue";
|
||||
|
||||
export const Spacer = defineComponent({
|
||||
name: "Spacer",
|
||||
setup() {
|
||||
return () => h("box", { flexGrow: 1, flexShrink: 1 });
|
||||
},
|
||||
});
|
||||
@@ -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 })),
|
||||
);
|
||||
},
|
||||
});
|
||||
@@ -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<Color>,
|
||||
backgroundColor: [String, Array] as PropType<Color>,
|
||||
dimColor: Boolean,
|
||||
bold: Boolean,
|
||||
italic: Boolean,
|
||||
underline: Boolean,
|
||||
strikethrough: Boolean,
|
||||
inverse: Boolean,
|
||||
wrap: { type: String as PropType<WrapMode>, 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;
|
||||
}
|
||||
@@ -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<TransformFn>, required: true },
|
||||
},
|
||||
setup(props, { slots }) {
|
||||
return () => h("transform", { transform: props.transform }, slots.default?.());
|
||||
},
|
||||
});
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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<boolean>;
|
||||
id?: string;
|
||||
}
|
||||
|
||||
export function useFocus(options: UseFocusOptions = {}): {
|
||||
isFocused: ShallowRef<boolean>;
|
||||
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 };
|
||||
}
|
||||
@@ -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<string | null>;
|
||||
} {
|
||||
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,
|
||||
};
|
||||
}
|
||||
@@ -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<boolean>;
|
||||
}
|
||||
|
||||
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);
|
||||
}
|
||||
@@ -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) };
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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) };
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
import { inject, onScopeDispose, shallowRef, type ShallowRef } from "vue";
|
||||
import { AppContextKey } from "../context.ts";
|
||||
|
||||
export function useTerminalSize(): { columns: ShallowRef<number>; rows: ShallowRef<number> } {
|
||||
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 };
|
||||
}
|
||||
@@ -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<string | null>;
|
||||
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<AppContext> = Symbol("vue-tui:app");
|
||||
export const FocusContextKey: InjectionKey<FocusContext> = Symbol("vue-tui:focus");
|
||||
export const StdinContextKey: InjectionKey<StdinContext> = Symbol("vue-tui:stdin");
|
||||
@@ -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<TuiNode, TuiNode> {
|
||||
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<string, unknown> }).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<string, unknown> }).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<string, unknown> }).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<string, unknown>)[key] = next;
|
||||
onCommit();
|
||||
}
|
||||
}
|
||||
|
||||
const nodeOps: RendererOptions<TuiNode, TuiNode> = {
|
||||
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;
|
||||
}
|
||||
@@ -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);
|
||||
});
|
||||
@@ -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";
|
||||
}
|
||||
@@ -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…"]);
|
||||
});
|
||||
@@ -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<TextProps["wrap"]>;
|
||||
|
||||
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,
|
||||
};
|
||||
}
|
||||
@@ -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);
|
||||
});
|
||||
@@ -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<string, (n: YogaNode, v: unknown) => 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();
|
||||
}
|
||||
@@ -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";
|
||||
@@ -0,0 +1 @@
|
||||
export { yogaNodeTracker } from "./host/yoga.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"]);
|
||||
});
|
||||
@@ -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();
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -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);
|
||||
});
|
||||
@@ -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 };
|
||||
}
|
||||
@@ -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<string, BoxStyle | undefined>)[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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
});
|
||||
@@ -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<string, ChalkInstance>)[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);
|
||||
}
|
||||
@@ -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<VueApp<TuiNode>, "mount"> {
|
||||
mount(options?: MountOptions): ComponentPublicInstance;
|
||||
waitUntilExit(): Promise<void>;
|
||||
}
|
||||
|
||||
type RootProps = Record<string, unknown>;
|
||||
|
||||
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<void>((res, rej) => {
|
||||
exitResolve = res;
|
||||
exitReject = rej;
|
||||
});
|
||||
exitPromise.catch(() => {});
|
||||
|
||||
let mountedRoot: TuiRoot | null = null;
|
||||
let mountedWriter: ReturnType<typeof createFrameWriter> | 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<TuiNode, TuiNode>(
|
||||
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<void> {
|
||||
return exitPromise;
|
||||
};
|
||||
|
||||
return app;
|
||||
}
|
||||
|
||||
// --- Focus controller ----------------------------------------------------
|
||||
|
||||
interface Focusable {
|
||||
readonly id: string;
|
||||
isActive: boolean;
|
||||
}
|
||||
|
||||
function createFocusController(): FocusContext {
|
||||
const focusables: Focusable[] = [];
|
||||
const subs = new Map<string, Set<(focused: boolean) => void>>();
|
||||
let activeId: string | null = null;
|
||||
const activeIdRef = shallowRef<string | null>(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<NodeJS.ReadStream, RawModeState>();
|
||||
|
||||
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;
|
||||
}
|
||||
}
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
import { queuePostFlushCb } from "@vue/runtime-core";
|
||||
|
||||
export interface CommitScheduler {
|
||||
schedule: () => void;
|
||||
flush: () => Promise<void>;
|
||||
}
|
||||
|
||||
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<void> {
|
||||
if (!scheduled) return Promise.resolve();
|
||||
return new Promise<void>((resolve) => {
|
||||
resolveFlush = resolve;
|
||||
});
|
||||
}
|
||||
|
||||
return { schedule, flush };
|
||||
}
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
@@ -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: {},
|
||||
});
|
||||
Reference in New Issue
Block a user