Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
48 changes: 47 additions & 1 deletion packages/core/src/app/createApp.ts
Original file line number Diff line number Diff line change
Expand Up @@ -29,7 +29,15 @@ import {
type RuntimeBackend,
} from "../backend.js";
import type { UiEvent } from "../events.js";
import type { App, AppConfig, DrawFn, EventHandler, ViewFn } from "../index.js";
import type {
App,
AppConfig,
AppLayoutSnapshot,
AppRenderMetrics,
DrawFn,
EventHandler,
ViewFn,
} from "../index.js";
import type {
BindingMap,
KeyContext,
Expand Down Expand Up @@ -71,6 +79,8 @@ type ResolvedAppConfig = Readonly<{
drawlistReuseOutputBuffer: boolean;
drawlistEncodedStringCacheCap: number;
maxFramesInFlight: number;
internal_onRender?: ((metrics: AppRenderMetrics) => void) | undefined;
internal_onLayout?: ((snapshot: AppLayoutSnapshot) => void) | undefined;
}>;

/** Default configuration values. */
Expand All @@ -83,6 +93,8 @@ const DEFAULT_CONFIG: ResolvedAppConfig = Object.freeze({
drawlistReuseOutputBuffer: true,
drawlistEncodedStringCacheCap: 1024,
maxFramesInFlight: 1,
internal_onRender: undefined,
internal_onLayout: undefined,
});
const SYNC_FRAME_ACK_MARKER = "__reziSyncFrameAck";

Expand Down Expand Up @@ -157,6 +169,10 @@ export function resolveAppConfig(config: AppConfig | undefined): ResolvedAppConf
config.maxFramesInFlight === undefined
? DEFAULT_CONFIG.maxFramesInFlight
: Math.min(4, Math.max(1, requirePositiveInt("maxFramesInFlight", config.maxFramesInFlight)));
const internal_onRender =
typeof config.internal_onRender === "function" ? config.internal_onRender : undefined;
const internal_onLayout =
typeof config.internal_onLayout === "function" ? config.internal_onLayout : undefined;

return Object.freeze({
fpsCap,
Expand All @@ -167,6 +183,8 @@ export function resolveAppConfig(config: AppConfig | undefined): ResolvedAppConf
drawlistReuseOutputBuffer,
drawlistEncodedStringCacheCap,
maxFramesInFlight,
internal_onRender,
internal_onLayout,
});
}

Expand Down Expand Up @@ -676,6 +694,28 @@ export function createApp<S>(
}
}

function emitInternalRenderMetrics(renderTime: number): boolean {
if (config.internal_onRender === undefined) return true;
try {
config.internal_onRender({ renderTime: Math.max(0, renderTime) });
return true;
} catch (e: unknown) {
fatalNowOrEnqueue("ZRUI_USER_CODE_THROW", `onRender callback threw: ${describeThrown(e)}`);
return false;
}
}

function emitInternalLayoutSnapshot(): boolean {
if (config.internal_onLayout === undefined) return true;
try {
config.internal_onLayout({ idRects: widgetRenderer.getRectByIdIndex() });
return true;
} catch (e: unknown) {
fatalNowOrEnqueue("ZRUI_USER_CODE_THROW", `onLayout callback threw: ${describeThrown(e)}`);
return false;
}
}

function tryRenderOnce(): void {
if (sm.state !== "Running") return;
// During stop(), we may still receive a few late event batches, but we must not
Expand Down Expand Up @@ -708,13 +748,15 @@ export function createApp<S>(
const df = drawFn;
if (!df) return;

const renderStart = perfNow();
const submitToken = perfMarkStart("submit_frame");
const res = rawRenderer.submitFrame(df, hooks);
perfMarkEnd("submit_frame", submitToken);
if (!res.ok) {
fatalNowOrEnqueue(res.code, res.detail);
return;
}
if (!emitInternalRenderMetrics(perfNow() - renderStart)) return;

submitFrameStartMs = PERF_ENABLED ? submitToken : null;
const buildEndMs = PERF_ENABLED ? perfNow() : null;
Expand Down Expand Up @@ -750,13 +792,17 @@ export function createApp<S>(
(pendingDirtyFlags & DIRTY_VIEW) !== 0,
};

const renderStart = perfNow();
const submitToken = perfMarkStart("submit_frame");
const res = widgetRenderer.submitFrame(vf, snapshot, viewport, theme, hooks, plan);
perfMarkEnd("submit_frame", submitToken);
if (!res.ok) {
fatalNowOrEnqueue(res.code, res.detail);
return;
}
if (!emitInternalRenderMetrics(perfNow() - renderStart)) return;
if (!emitInternalLayoutSnapshot()) return;

submitFrameStartMs = PERF_ENABLED ? submitToken : null;
const buildEndMs = PERF_ENABLED ? perfNow() : null;
framesInFlight++;
Expand Down
7 changes: 7 additions & 0 deletions packages/core/src/app/widgetRenderer.ts
Original file line number Diff line number Diff line change
Expand Up @@ -479,6 +479,13 @@ export class WidgetRenderer<S> {
return this.focusState.focusedId;
}

/**
* Get the latest committed id->rect layout index.
*/
getRectByIdIndex(): ReadonlyMap<string, Rect> {
return this.rectById;
}

/**
* Determine whether a key event should bypass the keybinding system.
*
Expand Down
11 changes: 11 additions & 0 deletions packages/core/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -716,13 +716,16 @@ export type { BindingMap, ModeBindingMap } from "./keybindings/index.js";
import type { DrawApi } from "./drawApi.js";
import type { UiEvent } from "./events.js";
import type { BindingMap, KeyContext, ModeBindingMap } from "./keybindings/index.js";
import type { Rect } from "./layout/types.js";
import type { Theme } from "./theme/theme.js";
import type { ThemeDefinition } from "./theme/tokens.js";
import type { VNode } from "./widgets/types.js";

export type ViewFn<S> = (state: Readonly<S>) => VNode;
export type DrawFn = (g: DrawApi) => void;
export type EventHandler = (ev: UiEvent) => void;
export type AppRenderMetrics = Readonly<{ renderTime: number }>;
export type AppLayoutSnapshot = Readonly<{ idRects: ReadonlyMap<string, Rect> }>;

export type AppConfig = Readonly<{
fpsCap?: number;
Expand Down Expand Up @@ -750,6 +753,14 @@ export type AppConfig = Readonly<{
* Default: 1 (no pipelining). Max: 4.
*/
maxFramesInFlight?: number;
/**
* @internal Called after a frame is rendered/submitted.
*/
internal_onRender?: (metrics: AppRenderMetrics) => void;
/**
* @internal Called with the latest widget id->rect layout snapshot.
*/
internal_onLayout?: (snapshot: AppLayoutSnapshot) => void;
}>;

export interface App<S> {
Expand Down
30 changes: 30 additions & 0 deletions packages/ink-compat/src/__tests__/api.surface.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
import { assert, describe, test } from "@rezi-ui/testkit";
import {
type RenderOptions,
ResizeObserver,
getBoundingBox,
getScrollHeight,
render,
} from "../index.js";

describe("api surface", () => {
test("exports measurement and observer APIs", () => {
assert.equal(typeof getBoundingBox, "function");
assert.equal(typeof getScrollHeight, "function");
assert.equal(typeof ResizeObserver, "function");
});

test("RenderOptions includes Ink-like parity fields", () => {
const opts: RenderOptions = {
onRender: (metrics) => {
assert.ok(metrics.renderTime >= 0);
},
isScreenReaderEnabled: true,
alternateBuffer: true,
incrementalRendering: true,
};

assert.equal(opts.isScreenReaderEnabled, true);
assert.equal(typeof render, "function");
});
});
82 changes: 82 additions & 0 deletions packages/ink-compat/src/__tests__/compat.thirdparty.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,82 @@
import { assert, describe, test } from "@rezi-ui/testkit";
import type React from "react";
import { useEffect, useState } from "react";
import { Text, Transform, render } from "../index.js";
import {
StubBackend,
encodeZrevBatchV1,
flushMicrotasks,
makeBackendBatch,
} from "./testBackend.js";

async function pushInitialResize(backend: StubBackend): Promise<void> {
backend.pushBatch(
makeBackendBatch(
encodeZrevBatchV1({ events: [{ kind: "resize", timeMs: 1, cols: 80, rows: 24 }] }),
),
);
await flushMicrotasks(10);
}

describe("third-party compatibility smoke", () => {
test("ink-spinner pattern (stateful interval + <Text>) triggers render updates", async () => {
function SpinnerLike() {
const frames = ["-", "\\", "|", "/"] as const;
const [frame, setFrame] = useState(0);

useEffect(() => {
const timer = setInterval(() => {
setFrame((prev) => (prev + 1) % frames.length);
}, 10);
return () => clearInterval(timer);
}, [frames.length]);

return <Text>{frames[frame]}</Text>;
}

const backend = new StubBackend();
const inst = render(<SpinnerLike />, { internal_backend: backend, exitOnCtrlC: false });

await flushMicrotasks(10);
await pushInitialResize(backend);

const initialFrames = backend.requestedFrames.length;
await new Promise<void>((resolve) => setTimeout(resolve, 40));
await flushMicrotasks(10);

assert.ok(backend.requestedFrames.length > initialFrames);

inst.unmount();
await inst.waitUntilExit();
});

test("ink-gradient pattern (Transform transform callback) receives flattened text", async () => {
let lastInput = "";

function GradientLike(props: Readonly<{ children: React.ReactNode }>) {
const transform = (children: string) => {
lastInput = children;
return `\u001B[31m${children}\u001B[39m`;
};

return <Transform transform={transform}>{props.children}</Transform>;
}

const backend = new StubBackend();
const inst = render(
<GradientLike>
<Text>rainbow</Text>
</GradientLike>,
{ internal_backend: backend, exitOnCtrlC: false },
);

await flushMicrotasks(10);
await pushInitialResize(backend);

assert.equal(lastInput, "rainbow");
assert.ok(backend.requestedFrames.length >= 1);

inst.unmount();
await inst.waitUntilExit();
});
});
113 changes: 113 additions & 0 deletions packages/ink-compat/src/__tests__/measurement.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,113 @@
import { assert, describe, test } from "@rezi-ui/testkit";
import React from "react";
import {
Box,
type DOMElement,
Text,
getBoundingBox,
getScrollHeight,
measureElement,
render,
} from "../index.js";
import {
StubBackend,
encodeZrevBatchV1,
flushMicrotasks,
makeBackendBatch,
} from "./testBackend.js";

async function pushInitialResize(backend: StubBackend): Promise<void> {
backend.pushBatch(
makeBackendBatch(
encodeZrevBatchV1({ events: [{ kind: "resize", timeMs: 1, cols: 80, rows: 24 }] }),
),
);
await flushMicrotasks(10);
}

describe("measurement", () => {
test("measureElement/getBoundingBox use committed layout and update on rerender", async () => {
const backend = new StubBackend();
const ref = React.createRef<DOMElement>();

const inst = render(
<Box width={30} height={10}>
<Box ref={ref} width={4} height={2}>
<Text>x</Text>
</Box>
</Box>,
{ internal_backend: backend, exitOnCtrlC: false },
);

await flushMicrotasks(10);
await pushInitialResize(backend);

const node = ref.current;
assert.ok(node);

const measured = measureElement(node);
assert.equal(measured.width, 4);
assert.equal(measured.height, 2);

const box = getBoundingBox(node);
assert.equal(box.width, 4);
assert.equal(box.height, 2);
assert.ok(box.x >= 0);
assert.ok(box.y >= 0);

inst.rerender(
<Box width={30} height={10}>
<Box ref={ref} width={7} height={3}>
<Text>x</Text>
</Box>
</Box>,
);

await flushMicrotasks(10);

const measured2 = measureElement(node);
assert.equal(measured2.width, 7);
assert.equal(measured2.height, 3);

inst.unmount();
await inst.waitUntilExit();
});

test("getScrollHeight reflects committed layout and updates across rerenders", async () => {
const backend = new StubBackend();
const parentRef = React.createRef<DOMElement>();

const inst = render(
<Box width={10} height={2} ref={parentRef}>
<Box width={10} height={6}>
<Text>content</Text>
</Box>
</Box>,
{ internal_backend: backend, exitOnCtrlC: false },
);

await flushMicrotasks(10);
await pushInitialResize(backend);

const parent = parentRef.current;
assert.ok(parent);

const measured = measureElement(parent);
assert.equal(measured.height, 2);
assert.equal(getScrollHeight(parent), 2);

inst.rerender(
<Box width={10} height={4} ref={parentRef}>
<Box width={10} height={6}>
<Text>content</Text>
</Box>
</Box>,
);
await flushMicrotasks(10);

assert.equal(getScrollHeight(parent), 4);

inst.unmount();
await inst.waitUntilExit();
});
});
Loading
Loading