diff --git a/packages/core/src/app/createApp.ts b/packages/core/src/app/createApp.ts index 39261ad8..06fa2afb 100644 --- a/packages/core/src/app/createApp.ts +++ b/packages/core/src/app/createApp.ts @@ -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, @@ -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. */ @@ -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"; @@ -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, @@ -167,6 +183,8 @@ export function resolveAppConfig(config: AppConfig | undefined): ResolvedAppConf drawlistReuseOutputBuffer, drawlistEncodedStringCacheCap, maxFramesInFlight, + internal_onRender, + internal_onLayout, }); } @@ -676,6 +694,28 @@ export function createApp( } } + 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 @@ -708,6 +748,7 @@ export function createApp( const df = drawFn; if (!df) return; + const renderStart = perfNow(); const submitToken = perfMarkStart("submit_frame"); const res = rawRenderer.submitFrame(df, hooks); perfMarkEnd("submit_frame", submitToken); @@ -715,6 +756,7 @@ export function createApp( fatalNowOrEnqueue(res.code, res.detail); return; } + if (!emitInternalRenderMetrics(perfNow() - renderStart)) return; submitFrameStartMs = PERF_ENABLED ? submitToken : null; const buildEndMs = PERF_ENABLED ? perfNow() : null; @@ -750,6 +792,7 @@ export function createApp( (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); @@ -757,6 +800,9 @@ export function createApp( 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++; diff --git a/packages/core/src/app/widgetRenderer.ts b/packages/core/src/app/widgetRenderer.ts index 8f5ef12a..bab3ded5 100644 --- a/packages/core/src/app/widgetRenderer.ts +++ b/packages/core/src/app/widgetRenderer.ts @@ -479,6 +479,13 @@ export class WidgetRenderer { return this.focusState.focusedId; } + /** + * Get the latest committed id->rect layout index. + */ + getRectByIdIndex(): ReadonlyMap { + return this.rectById; + } + /** * Determine whether a key event should bypass the keybinding system. * diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index c8658e36..f1c637ca 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -716,6 +716,7 @@ 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"; @@ -723,6 +724,8 @@ import type { VNode } from "./widgets/types.js"; export type ViewFn = (state: Readonly) => 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 }>; export type AppConfig = Readonly<{ fpsCap?: number; @@ -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 { diff --git a/packages/ink-compat/src/__tests__/api.surface.test.ts b/packages/ink-compat/src/__tests__/api.surface.test.ts new file mode 100644 index 00000000..b1765595 --- /dev/null +++ b/packages/ink-compat/src/__tests__/api.surface.test.ts @@ -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"); + }); +}); diff --git a/packages/ink-compat/src/__tests__/compat.thirdparty.test.tsx b/packages/ink-compat/src/__tests__/compat.thirdparty.test.tsx new file mode 100644 index 00000000..b5567cad --- /dev/null +++ b/packages/ink-compat/src/__tests__/compat.thirdparty.test.tsx @@ -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 { + 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 + ) 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 {frames[frame]}; + } + + const backend = new StubBackend(); + const inst = render(, { internal_backend: backend, exitOnCtrlC: false }); + + await flushMicrotasks(10); + await pushInitialResize(backend); + + const initialFrames = backend.requestedFrames.length; + await new Promise((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 {props.children}; + } + + const backend = new StubBackend(); + const inst = render( + + rainbow + , + { 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(); + }); +}); diff --git a/packages/ink-compat/src/__tests__/measurement.test.tsx b/packages/ink-compat/src/__tests__/measurement.test.tsx new file mode 100644 index 00000000..90f79cf6 --- /dev/null +++ b/packages/ink-compat/src/__tests__/measurement.test.tsx @@ -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 { + 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(); + + const inst = render( + + + x + + , + { 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( + + + x + + , + ); + + 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(); + + const inst = render( + + + content + + , + { 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( + + + content + + , + ); + await flushMicrotasks(10); + + assert.equal(getScrollHeight(parent), 4); + + inst.unmount(); + await inst.waitUntilExit(); + }); +}); diff --git a/packages/ink-compat/src/__tests__/render.parity.test.tsx b/packages/ink-compat/src/__tests__/render.parity.test.tsx new file mode 100644 index 00000000..0e773e2b --- /dev/null +++ b/packages/ink-compat/src/__tests__/render.parity.test.tsx @@ -0,0 +1,173 @@ +import { assert, describe, test } from "@rezi-ui/testkit"; +import React from "react"; +import { Text, render, useIsScreenReaderEnabled, useStdin } from "../index.js"; +import { + StubBackend, + encodeZrevBatchV1, + flushMicrotasks, + makeBackendBatch, +} from "./testBackend.js"; + +async function pushInitialResize(backend: StubBackend): Promise { + backend.pushBatch( + makeBackendBatch( + encodeZrevBatchV1({ events: [{ kind: "resize", timeMs: 1, cols: 80, rows: 24 }] }), + ), + ); + await flushMicrotasks(10); +} + +describe("render parity", () => { + test("onRender metrics + alternateBuffer + screen-reader context", async () => { + const backend = new StubBackend(); + const metrics: number[] = []; + const writes: string[] = []; + let isScreenReader: boolean | null = null; + + const stdout = { + isTTY: true, + columns: 80, + rows: 24, + write(data: string) { + writes.push(data); + return true; + }, + on() {}, + off() {}, + } as unknown as NodeJS.WriteStream; + + function Probe() { + isScreenReader = useIsScreenReaderEnabled(); + return probe; + } + + const inst = render(, { + internal_backend: backend, + exitOnCtrlC: false, + patchConsole: false, + stdout, + onRender: (m) => metrics.push(m.renderTime), + isScreenReaderEnabled: true, + alternateBuffer: true, + incrementalRendering: true, + }); + + await flushMicrotasks(10); + await pushInitialResize(backend); + + assert.equal(isScreenReader, true); + assert.ok(metrics.length >= 1); + assert.ok(metrics[0] !== undefined && metrics[0] >= 0); + assert.ok(writes.includes("\u001B[?1049h")); + + inst.unmount(); + await inst.waitUntilExit(); + + assert.ok(writes.includes("\u001B[?1049l")); + }); + + test("useStdin exposes meaningful raw-mode support and ref-counted toggles", async () => { + const backend = new StubBackend(); + const calls: string[] = []; + let stdinHook: + | { + setRawMode: (enabled: boolean) => void; + isRawModeSupported: boolean; + } + | undefined; + + const stdin = { + isTTY: true, + setRawMode(value: boolean) { + calls.push(`setRawMode:${String(value)}`); + }, + setEncoding(value: string) { + calls.push(`setEncoding:${value}`); + }, + ref() { + calls.push("ref"); + }, + unref() { + calls.push("unref"); + }, + } as unknown as NodeJS.ReadStream; + + function Probe() { + stdinHook = useStdin(); + return stdin; + } + + const inst = render(, { + internal_backend: backend, + exitOnCtrlC: false, + stdin, + patchConsole: false, + }); + + await flushMicrotasks(10); + await pushInitialResize(backend); + + const hook = stdinHook as { + setRawMode: (enabled: boolean) => void; + isRawModeSupported: boolean; + }; + assert.equal(hook.isRawModeSupported, true); + + hook.setRawMode(true); + hook.setRawMode(true); + hook.setRawMode(false); + hook.setRawMode(false); + + assert.deepEqual(calls, [ + "setEncoding:utf8", + "ref", + "setRawMode:true", + "setRawMode:false", + "unref", + ]); + + inst.unmount(); + await inst.waitUntilExit(); + }); + + test("useStdin throws on unsupported raw mode", async () => { + const backend = new StubBackend(); + let stdinHook: + | { + setRawMode: (enabled: boolean) => void; + isRawModeSupported: boolean; + } + | undefined; + + const stdin = { + isTTY: false, + } as unknown as NodeJS.ReadStream; + + function Probe() { + stdinHook = useStdin(); + return stdin; + } + + const inst = render(, { + internal_backend: backend, + exitOnCtrlC: false, + stdin, + patchConsole: false, + }); + + await flushMicrotasks(10); + await pushInitialResize(backend); + + const hook = stdinHook as { + setRawMode: (enabled: boolean) => void; + isRawModeSupported: boolean; + }; + assert.equal(hook.isRawModeSupported, false); + assert.throws(() => { + hook.setRawMode(true); + }, /Raw mode is not supported/); + + inst.unmount(); + await inst.waitUntilExit(); + }); +}); diff --git a/packages/ink-compat/src/__tests__/resizeObserver.test.tsx b/packages/ink-compat/src/__tests__/resizeObserver.test.tsx new file mode 100644 index 00000000..c6c80913 --- /dev/null +++ b/packages/ink-compat/src/__tests__/resizeObserver.test.tsx @@ -0,0 +1,97 @@ +import { assert, describe, test } from "@rezi-ui/testkit"; +import React from "react"; +import { Box, type DOMElement, ResizeObserver, Text, render } from "../index.js"; +import { + StubBackend, + encodeZrevBatchV1, + flushMicrotasks, + makeBackendBatch, +} from "./testBackend.js"; + +async function pushInitialResize(backend: StubBackend): Promise { + backend.pushBatch( + makeBackendBatch( + encodeZrevBatchV1({ events: [{ kind: "resize", timeMs: 1, cols: 80, rows: 24 }] }), + ), + ); + await flushMicrotasks(10); +} + +describe("ResizeObserver", () => { + test("observe/unobserve/disconnect on committed layout changes", async () => { + const backend = new StubBackend(); + const ref = React.createRef(); + const seen: Array> = []; + + const inst = render( + + + x + + , + { internal_backend: backend, exitOnCtrlC: false }, + ); + + await flushMicrotasks(10); + await pushInitialResize(backend); + + const node = ref.current; + assert.ok(node); + + const observer = new ResizeObserver((entries) => { + for (const entry of entries) { + seen.push(entry.contentRect); + } + }); + + observer.observe(node); + assert.equal(seen.length, 1); + assert.equal(seen[0]?.width, 4); + assert.equal(seen[0]?.height, 2); + + inst.rerender( + + + x + + , + ); + await flushMicrotasks(10); + + assert.equal(seen.length, 2); + assert.equal(seen[1]?.width, 8); + assert.equal(seen[1]?.height, 3); + + observer.unobserve(node); + inst.rerender( + + + x + + , + ); + await flushMicrotasks(10); + + assert.equal(seen.length, 2); + + observer.observe(node); + assert.equal(seen.length, 3); + assert.equal(seen[2]?.width, 10); + assert.equal(seen[2]?.height, 4); + + observer.disconnect(); + inst.rerender( + + + x + + , + ); + await flushMicrotasks(10); + + assert.equal(seen.length, 3); + + inst.unmount(); + await inst.waitUntilExit(); + }); +}); diff --git a/packages/ink-compat/src/context/AccessibilityContext.ts b/packages/ink-compat/src/context/AccessibilityContext.ts new file mode 100644 index 00000000..66e8f665 --- /dev/null +++ b/packages/ink-compat/src/context/AccessibilityContext.ts @@ -0,0 +1,5 @@ +import React from "react"; + +const AccessibilityContext = React.createContext(false); + +export default AccessibilityContext; diff --git a/packages/ink-compat/src/hooks/useIsScreenReaderEnabled.ts b/packages/ink-compat/src/hooks/useIsScreenReaderEnabled.ts new file mode 100644 index 00000000..2bcb9768 --- /dev/null +++ b/packages/ink-compat/src/hooks/useIsScreenReaderEnabled.ts @@ -0,0 +1,6 @@ +import React from "react"; +import AccessibilityContext from "../context/AccessibilityContext.js"; + +export default function useIsScreenReaderEnabled(): boolean { + return React.useContext(AccessibilityContext); +} diff --git a/packages/ink-compat/src/index.ts b/packages/ink-compat/src/index.ts index 0d40c241..6f64cb6e 100644 --- a/packages/ink-compat/src/index.ts +++ b/packages/ink-compat/src/index.ts @@ -16,12 +16,15 @@ export { default as useStdout } from "./hooks/useStdout.js"; export { default as useStderr } from "./hooks/useStderr.js"; export { default as useFocus } from "./hooks/useFocus.js"; export { default as useFocusManager } from "./hooks/useFocusManager.js"; +export { default as useIsScreenReaderEnabled } from "./hooks/useIsScreenReaderEnabled.js"; // Render export { render } from "./render.js"; // Measurement export { default as measureElement } from "./measureElement.js"; +export { getBoundingBox, getScrollHeight, getScrollWidth } from "./measureElement.js"; +export { default as ResizeObserver, ResizeObserverEntry } from "./resizeObserver.js"; // Types export type { Instance, RenderOptions } from "./types.js"; diff --git a/packages/ink-compat/src/measureElement.ts b/packages/ink-compat/src/measureElement.ts index f5b7674f..682f0142 100644 --- a/packages/ink-compat/src/measureElement.ts +++ b/packages/ink-compat/src/measureElement.ts @@ -1,38 +1,17 @@ -import { warnOnce } from "./internal/warn.js"; +import { + getBoundingBox, + getScrollHeight, + getScrollWidth, + measureElementFromLayout, +} from "./measurement.js"; import type { DOMElement } from "./types.js"; - -type Output = { - width: number; - height: number; -}; -type MeasuredAttrs = { - width?: unknown; - height?: unknown; -}; +export { getBoundingBox, getScrollHeight, getScrollWidth } from "./measurement.js"; /** * Measure the dimensions of a `` element. * - * In Ink this reads computed layout from the Yoga node. In ink-compat, - * layout is computed by the Zireael C engine and not exposed back to JS, - * so this function returns explicitly-set `width`/`height` props when - * available, or `0` with a one-time warning. + * In ink-compat this reads committed layout from the latest renderer frame. */ -export default function measureElement(node: DOMElement): Output { - const attrs = ((node as { props?: Record }).props ?? - node.attributes) as MeasuredAttrs; - - const widthValue = attrs.width; - const heightValue = attrs.height; - const width = typeof widthValue === "number" ? widthValue : 0; - const height = typeof heightValue === "number" ? heightValue : 0; - - if (width === 0 && height === 0) { - warnOnce( - "measureElement: Rezi computes layout in the native engine. " + - "Measurements are only available when explicit width/height props are set on the .", - ); - } - - return { width, height }; +export default function measureElement(node: DOMElement): { width: number; height: number } { + return measureElementFromLayout(node); } diff --git a/packages/ink-compat/src/measurement.ts b/packages/ink-compat/src/measurement.ts new file mode 100644 index 00000000..da5b96f1 --- /dev/null +++ b/packages/ink-compat/src/measurement.ts @@ -0,0 +1,168 @@ +import type { HostElement, HostLayoutRect, HostRoot } from "./reconciler/types.js"; +import { ResizeObserverEntry } from "./resizeObserver.js"; +import type { DOMElement } from "./types.js"; + +type CoreRect = Readonly<{ x: number; y: number; w: number; h: number }>; + +type ResizeObserverLike = Readonly<{ + internalTrigger: (entries: ResizeObserverEntry[]) => void; +}>; + +const ZERO_LAYOUT: HostLayoutRect = Object.freeze({ x: 0, y: 0, width: 0, height: 0 }); + +function toLayout(rect: CoreRect | undefined): HostLayoutRect { + if (!rect) return ZERO_LAYOUT; + const width = Number.isFinite(rect.w) ? Math.max(0, rect.w) : 0; + const height = Number.isFinite(rect.h) ? Math.max(0, rect.h) : 0; + return { + x: Number.isFinite(rect.x) ? rect.x : 0, + y: Number.isFinite(rect.y) ? rect.y : 0, + width, + height, + }; +} + +function readLayout(node: DOMElement): HostLayoutRect { + const layout = (node as HostElement).internal_layout; + return layout ?? ZERO_LAYOUT; +} + +export function measureElementFromLayout( + node: DOMElement, +): Readonly<{ width: number; height: number }> { + const layout = readLayout(node); + return { width: layout.width, height: layout.height }; +} + +export function getBoundingBox(node: DOMElement): Readonly<{ + x: number; + y: number; + width: number; + height: number; +}> { + const layout = readLayout(node); + return { + x: layout.x, + y: layout.y, + width: layout.width, + height: layout.height, + }; +} + +export function getScrollHeight(node: DOMElement): number { + const st = (node as HostElement).internal_scrollState; + return st?.scrollHeight ?? 0; +} + +export function getScrollWidth(node: DOMElement): number { + const st = (node as HostElement).internal_scrollState; + return st?.scrollWidth ?? 0; +} + +function collectObserverEntry( + batches: Map, + observer: ResizeObserverLike, + entry: ResizeObserverEntry, +): void { + const prev = batches.get(observer); + if (prev) { + prev.push(entry); + return; + } + batches.set(observer, [entry]); +} + +function updateNodeLayout( + node: HostElement, + idRects: ReadonlyMap, + observerBatches: Map, +): HostLayoutRect { + let layout = node.type === "ink-box" ? toLayout(idRects.get(node.internal_id)) : ZERO_LAYOUT; + + let haveChildBounds = false; + let minChildX = 0; + let minChildY = 0; + let maxChildX = 0; + let maxChildY = 0; + + let maxRelRight = 0; + let maxRelBottom = 0; + + for (const child of node.children) { + if (child.kind !== "element") continue; + + const childRect = updateNodeLayout(child, idRects, observerBatches); + + if (!haveChildBounds) { + haveChildBounds = true; + minChildX = childRect.x; + minChildY = childRect.y; + maxChildX = childRect.x + childRect.width; + maxChildY = childRect.y + childRect.height; + } else { + minChildX = Math.min(minChildX, childRect.x); + minChildY = Math.min(minChildY, childRect.y); + maxChildX = Math.max(maxChildX, childRect.x + childRect.width); + maxChildY = Math.max(maxChildY, childRect.y + childRect.height); + } + + const relRight = childRect.x - layout.x + childRect.width; + const relBottom = childRect.y - layout.y + childRect.height; + if (relRight > maxRelRight) maxRelRight = relRight; + if (relBottom > maxRelBottom) maxRelBottom = relBottom; + } + + if (node.type !== "ink-box") { + layout = haveChildBounds + ? { + x: minChildX, + y: minChildY, + width: Math.max(0, maxChildX - minChildX), + height: Math.max(0, maxChildY - minChildY), + } + : ZERO_LAYOUT; + } + + node.internal_layout = layout; + + const clientWidth = layout.width; + const clientHeight = layout.height; + node.internal_scrollState = { + scrollHeight: Math.max(clientHeight, maxRelBottom), + scrollWidth: Math.max(clientWidth, maxRelRight), + clientHeight, + clientWidth, + }; + + const nextSize = { width: layout.width, height: layout.height }; + const lastSize = node.internal_lastMeasuredSize; + if (!lastSize || lastSize.width !== nextSize.width || lastSize.height !== nextSize.height) { + node.internal_lastMeasuredSize = nextSize; + + if (node.resizeObservers && node.resizeObservers.size > 0) { + const entry = new ResizeObserverEntry(node as unknown as DOMElement, nextSize); + for (const observer of node.resizeObservers) { + collectObserverEntry(observerBatches, observer as unknown as ResizeObserverLike, entry); + } + } + } + + return layout; +} + +export function applyLayoutSnapshot(root: HostRoot, idRects: ReadonlyMap): void { + const observerBatches = new Map(); + + for (const child of root.children) { + if (child.kind !== "element") continue; + updateNodeLayout(child, idRects, observerBatches); + } + + for (const [observer, entries] of observerBatches) { + try { + observer.internalTrigger(entries); + } catch { + // ignore observer callback failures + } + } +} diff --git a/packages/ink-compat/src/reconciler/convert.ts b/packages/ink-compat/src/reconciler/convert.ts index f9735763..dee962e1 100644 --- a/packages/ink-compat/src/reconciler/convert.ts +++ b/packages/ink-compat/src/reconciler/convert.ts @@ -37,12 +37,17 @@ function convertNode(node: HostNode, ctx: ConvertCtx): VNode | null { if (isStatic && childVNodes.length === 0) return null; if (mapped.reverseChildren) childVNodes.reverse(); + const measuredId = node.internal_id; + const stackPropsWithId = mapped.wrapper + ? mapped.stackProps + : { ...mapped.stackProps, id: measuredId }; const stack = mapped.stackKind === "row" - ? ui.row(mapped.stackProps, childVNodes) - : ui.column(mapped.stackProps, childVNodes); + ? ui.row(stackPropsWithId, childVNodes) + : ui.column(stackPropsWithId, childVNodes); - const vnode = mapped.wrapper ? ui.box(mapped.wrapper, [stack]) : stack; + const wrapperWithId = mapped.wrapper ? { ...mapped.wrapper, id: measuredId } : null; + const vnode = wrapperWithId ? ui.box(wrapperWithId, [stack]) : stack; if (isStatic) { ctx.staticVNodes.push(vnode); diff --git a/packages/ink-compat/src/reconciler/hostConfig.ts b/packages/ink-compat/src/reconciler/hostConfig.ts index 81560b9b..0689e0dc 100644 --- a/packages/ink-compat/src/reconciler/hostConfig.ts +++ b/packages/ink-compat/src/reconciler/hostConfig.ts @@ -9,6 +9,7 @@ import { type HostRoot, type HostText, type HostType, + allocateNodeId, appendChildNode, insertBeforeNode, removeChildNode, @@ -61,7 +62,7 @@ const reconciler = createReconciler< // ------------------- // Instance Create // ------------------- - createInstance(originalType, newProps, _root, hostContext) { + createInstance(originalType, newProps, root, hostContext) { if (hostContext.isInsideText && originalType === "ink-box") { throw new InkCompatError("INK_COMPAT_INVALID_PROPS", " can't be nested inside "); } @@ -69,7 +70,18 @@ const reconciler = createReconciler< const type: HostType = originalType === "ink-text" && hostContext.isInsideText ? "ink-virtual-text" : originalType; - return { kind: "element", type, props: { ...newProps }, children: [] }; + const props = { ...newProps }; + const children: HostNode[] = []; + return { + kind: "element", + type, + nodeName: type, + props, + attributes: props, + children, + childNodes: children, + internal_id: allocateNodeId(root), + }; }, createTextInstance(text, _root, hostContext) { @@ -79,7 +91,7 @@ const reconciler = createReconciler< `Text string "${text}" must be rendered inside component`, ); } - return { kind: "text", text }; + return { kind: "text", text, nodeName: "#text", nodeValue: text }; }, // ------------------- @@ -101,10 +113,13 @@ const reconciler = createReconciler< return newProps; }, commitUpdate(instance, updatePayload) { - instance.props = { ...updatePayload }; + const props = { ...updatePayload }; + instance.props = props; + instance.attributes = props; }, commitTextUpdate(textInstance, _oldText, newText) { textInstance.text = newText; + textInstance.nodeValue = newText; }, // ------------------- @@ -114,9 +129,11 @@ const reconciler = createReconciler< unhideInstance() {}, hideTextInstance(textInstance) { textInstance.text = ""; + textInstance.nodeValue = ""; }, unhideTextInstance(textInstance, text) { textInstance.text = text; + textInstance.nodeValue = text; }, // ------------------- diff --git a/packages/ink-compat/src/reconciler/types.ts b/packages/ink-compat/src/reconciler/types.ts index d2e7cf5b..3f4e179e 100644 --- a/packages/ink-compat/src/reconciler/types.ts +++ b/packages/ink-compat/src/reconciler/types.ts @@ -2,16 +2,46 @@ import type { VNode } from "@rezi-ui/core"; export type HostType = "ink-box" | "ink-text" | "ink-virtual-text" | "ink-spacer"; +export type HostLayoutRect = Readonly<{ + x: number; + y: number; + width: number; + height: number; +}>; + +export type HostScrollState = Readonly<{ + scrollHeight: number; + scrollWidth: number; + clientHeight: number; + clientWidth: number; +}>; + +export type HostResizeObserverLike = Readonly<{ + internalTrigger: (entries: readonly unknown[]) => void; +}>; + export type HostText = { kind: "text"; text: string; + nodeName: "#text"; + nodeValue: string; + parentNode?: HostElement; }; export type HostElement = { kind: "element"; type: HostType; + nodeName: HostType; props: Record; - children: Array; + attributes: Record; + children: HostNode[]; + childNodes: HostNode[]; + parentNode?: HostElement; + internal_id: string; + internal_layout?: HostLayoutRect; + internal_scrollState?: HostScrollState; + resizeObservers?: Set; + internal_lastMeasuredSize?: Readonly<{ width: number; height: number }>; }; export type HostNode = HostElement | HostText; @@ -24,16 +54,25 @@ export type HostRoot = { * Items are appended on each commit and remain for the lifetime of the render() root. */ staticVNodes: VNode[]; + internal_nextNodeId?: number; onCommit: (vnode: VNode | null) => void; }; export type HostContext = Readonly<{ isInsideText: boolean }>; +export function allocateNodeId(root: HostRoot): string { + const next = root.internal_nextNodeId ?? 1; + root.internal_nextNodeId = next + 1; + return `ink-compat-${String(next)}`; +} + export function appendChildNode( parent: HostRoot | HostElement, child: HostElement | HostText, ): void { parent.children.push(child); + if (parent.kind === "element") child.parentNode = parent; + else Reflect.deleteProperty(child, "parentNode"); } export function insertBeforeNode( @@ -44,9 +83,13 @@ export function insertBeforeNode( const idx = parent.children.indexOf(before); if (idx < 0) { parent.children.push(child); + if (parent.kind === "element") child.parentNode = parent; + else Reflect.deleteProperty(child, "parentNode"); return; } parent.children.splice(idx, 0, child); + if (parent.kind === "element") child.parentNode = parent; + else Reflect.deleteProperty(child, "parentNode"); } export function removeChildNode( @@ -56,4 +99,5 @@ export function removeChildNode( const idx = parent.children.indexOf(child); if (idx < 0) return; parent.children.splice(idx, 1); + Reflect.deleteProperty(child, "parentNode"); } diff --git a/packages/ink-compat/src/render.ts b/packages/ink-compat/src/render.ts index 952c5f67..892bae8c 100644 --- a/packages/ink-compat/src/render.ts +++ b/packages/ink-compat/src/render.ts @@ -1,11 +1,13 @@ import { type RuntimeBackend, type UiEvent, type VNode, createApp, ui } from "@rezi-ui/core"; import { createNodeBackend } from "@rezi-ui/node"; import React from "react"; +import AccessibilityContext from "./context/AccessibilityContext.js"; import AppContext from "./context/AppContext.js"; import FocusProvider from "./context/FocusProvider.js"; import StdioContext, { type StdioContextValue } from "./context/StdioContext.js"; import { createInputEventEmitter } from "./internal/emitter.js"; import { enableWarnOnce } from "./internal/warn.js"; +import { applyLayoutSnapshot } from "./measurement.js"; import reconciler, { type HostRoot } from "./reconciler.js"; import { createConsoleCapture } from "./render/consoleCapture.js"; import { deferred } from "./render/deferred.js"; @@ -14,6 +16,29 @@ import type { Instance, RenderOptions } from "./types.js"; type AppState = Readonly<{ vnode: VNode; consoleLines: readonly string[] }>; +const ANSI_ALTERNATE_BUFFER_ENTER = "\u001B[?1049h"; +const ANSI_ALTERNATE_BUFFER_EXIT = "\u001B[?1049l"; + +function hasRawMode(stdin: NodeJS.ReadStream): stdin is NodeJS.ReadStream & { + isTTY: true; + setRawMode: (value: boolean) => void; + ref: () => void; + unref: () => void; +} { + return ( + (stdin as unknown as { isTTY?: unknown }).isTTY === true && + typeof (stdin as unknown as { setRawMode?: unknown }).setRawMode === "function" + ); +} + +function writeBestEffort(stream: NodeJS.WriteStream, data: string): void { + try { + void stream.write(data); + } catch { + // ignore + } +} + export function render( tree: React.ReactNode, options?: RenderOptions | NodeJS.WriteStream, @@ -22,23 +47,45 @@ export function render( if (opts.debug === true) enableWarnOnce(); - // We currently can't plumb stdio into the Rezi backend without core/node changes. - // We still expose the streams for compatibility with Ink hooks. const stdin = opts.stdin ?? process.stdin; const stdout = opts.stdout ?? process.stdout; const stderr = opts.stderr ?? process.stderr; const exitOnCtrlC = opts.exitOnCtrlC !== false; const maxFps = opts.maxFps ?? 60; + const isScreenReaderEnabled = + opts.isScreenReaderEnabled ?? + // biome-ignore lint/complexity/useLiteralKeys: process.env is typed with an index signature under our TS config. + process.env["INK_SCREEN_READER"] === "true"; + + let alternateBufferActive = opts.alternateBufferAlreadyActive === true; + const canUseAlternateBuffer = + opts.alternateBuffer === true && (stdout as unknown as { isTTY?: unknown }).isTTY === true; + + if (canUseAlternateBuffer && !alternateBufferActive) { + writeBestEffort(stdout, ANSI_ALTERNATE_BUFFER_ENTER); + alternateBufferActive = true; + } const eventEmitter = createInputEventEmitter(); + let rootRef: HostRoot | null = null; + const backend = ((opts as { internal_backend?: unknown }).internal_backend ?? createNodeBackend({ fpsCap: maxFps })) as RuntimeBackend; const app = createApp({ backend, initialState: { vnode: ui.text(""), consoleLines: [] }, - config: { fpsCap: maxFps }, + config: { + fpsCap: maxFps, + internal_onRender: (metrics) => { + opts.onRender?.(metrics); + }, + internal_onLayout: (snapshot) => { + if (!rootRef) return; + applyLayoutSnapshot(rootRef, snapshot.idRects); + }, + }, }); app.view((s) => { if (s.consoleLines.length === 0) return s.vnode; @@ -54,6 +101,10 @@ export function render( let restoreConsole: (() => void) | null = null; let unsubEvents: (() => void) | null = null; + const supportsRawMode = hasRawMode(stdin); + const backendOwnsRawMode = stdin === process.stdin; + let rawModeEnabledCount = 0; + const cleanupPatchedConsole = () => { if (restoreConsole === null) return; try { @@ -74,6 +125,29 @@ export function render( unsubEvents = null; }; + const cleanupRawMode = () => { + if (rawModeEnabledCount <= 0) return; + if (supportsRawMode && !backendOwnsRawMode) { + try { + stdin.setRawMode(false); + } catch { + // ignore + } + try { + stdin.unref(); + } catch { + // ignore + } + } + rawModeEnabledCount = 0; + }; + + const cleanupAlternateBuffer = () => { + if (!canUseAlternateBuffer || !alternateBufferActive) return; + writeBestEffort(stdout, ANSI_ALTERNATE_BUFFER_EXIT); + alternateBufferActive = false; + }; + // Console patching: best-effort Ink compatibility. // We intentionally disable in debug mode and in Node's test runner. const shouldPatchConsole = @@ -90,6 +164,8 @@ export function render( exited = true; cleanupPatchedConsole(); cleanupEventSubscription(); + cleanupRawMode(); + cleanupAlternateBuffer(); void Promise.resolve() .then(() => app.stop()) .catch(() => { @@ -121,9 +197,42 @@ export function render( stdin, stdout, stderr, - // Rezi owns terminal mode; Ink-style raw mode toggling is intentionally a no-op. - setRawMode: () => {}, - isRawModeSupported: false, + setRawMode: (enabled: boolean) => { + if (!supportsRawMode) { + if (stdin === process.stdin) { + throw new Error( + "Raw mode is not supported on the current process.stdin, which Ink uses as input stream by default.", + ); + } + throw new Error("Raw mode is not supported on the stdin provided to Ink."); + } + + if (enabled) { + if (rawModeEnabledCount === 0 && !backendOwnsRawMode) { + stdin.setEncoding("utf8"); + try { + stdin.ref(); + } catch { + // ignore + } + stdin.setRawMode(true); + } + rawModeEnabledCount++; + return; + } + + if (rawModeEnabledCount <= 0) return; + rawModeEnabledCount--; + if (rawModeEnabledCount === 0 && !backendOwnsRawMode) { + stdin.setRawMode(false); + try { + stdin.unref(); + } catch { + // ignore + } + } + }, + isRawModeSupported: supportsRawMode, internal_exitOnCtrlC: exitOnCtrlC, internal_eventEmitter: eventEmitter, }); @@ -133,9 +242,13 @@ export function render( AppContext.Provider, { value: { exit: requestExit } }, React.createElement( - StdioContext.Provider, - { value: stdioValue }, - React.createElement(FocusProvider, null, node), + AccessibilityContext.Provider, + { value: isScreenReaderEnabled }, + React.createElement( + StdioContext.Provider, + { value: stdioValue }, + React.createElement(FocusProvider, null, node), + ), ), ); @@ -147,6 +260,7 @@ export function render( app.update((prev) => ({ ...prev, vnode: vnode ?? ui.text("") })); }, }; + rootRef = root; const container = reconciler.createContainer(root, 0, null, false, null, "id", () => {}, null); @@ -156,6 +270,8 @@ export function render( } catch (error) { cleanupPatchedConsole(); cleanupEventSubscription(); + cleanupRawMode(); + cleanupAlternateBuffer(); try { app.dispose(); } catch { diff --git a/packages/ink-compat/src/resizeObserver.ts b/packages/ink-compat/src/resizeObserver.ts new file mode 100644 index 00000000..64dcd6ab --- /dev/null +++ b/packages/ink-compat/src/resizeObserver.ts @@ -0,0 +1,71 @@ +import type { DOMElement } from "./types.js"; + +export type ResizeObserverCallback = ( + entries: ResizeObserverEntry[], + observer: ResizeObserver, +) => void; + +export class ResizeObserverEntry { + readonly target: DOMElement; + readonly contentRect: Readonly<{ width: number; height: number }>; + + constructor(target: DOMElement, contentRect: Readonly<{ width: number; height: number }>) { + this.target = target; + this.contentRect = contentRect; + } +} + +export default class ResizeObserver { + private readonly callback: ResizeObserverCallback; + private readonly observedElements = new Set(); + + constructor(callback: ResizeObserverCallback) { + this.callback = callback; + } + + observe(element: DOMElement): void { + if (this.observedElements.has(element)) return; + + this.observedElements.add(element); + const target = element as DOMElement & { + resizeObservers?: Set; + internal_lastMeasuredSize?: Readonly<{ width: number; height: number }>; + }; + if (!target.resizeObservers) target.resizeObservers = new Set(); + target.resizeObservers.add(this as unknown); + + const size = target.internal_lastMeasuredSize; + if (size) { + this.safeTrigger([new ResizeObserverEntry(element, size)]); + } + } + + unobserve(element: DOMElement): void { + this.observedElements.delete(element); + + const target = element as DOMElement & { resizeObservers?: Set }; + target.resizeObservers?.delete(this as unknown); + } + + disconnect(): void { + for (const element of this.observedElements) { + const target = element as DOMElement & { resizeObservers?: Set }; + target.resizeObservers?.delete(this as unknown); + } + this.observedElements.clear(); + } + + internalTrigger(entries: ResizeObserverEntry[]): void { + this.safeTrigger(entries); + } + + private safeTrigger(entries: ResizeObserverEntry[]): void { + try { + this.callback(entries, this); + } catch (error) { + // Keep observer failures isolated, matching Ink's behavior. + // eslint-disable-next-line no-console + console.error(error); + } + } +} diff --git a/packages/ink-compat/src/types.ts b/packages/ink-compat/src/types.ts index 065f95f2..fb3b4d88 100644 --- a/packages/ink-compat/src/types.ts +++ b/packages/ink-compat/src/types.ts @@ -116,6 +116,11 @@ export type RenderOptions = Readonly<{ exitOnCtrlC?: boolean; patchConsole?: boolean; maxFps?: number; + onRender?: (metrics: Readonly<{ renderTime: number }>) => void; + isScreenReaderEnabled?: boolean; + alternateBuffer?: boolean; + alternateBufferAlreadyActive?: boolean; + incrementalRendering?: boolean; /** * @internal Test-only hook. When provided, `render()` uses this backend instead of * `createNodeBackend()`. @@ -181,12 +186,21 @@ export type NewlineProps = Readonly<{ * Opaque handle returned by a Box `ref`. * * In Ink this is a full DOM node with a yogaNode for layout queries. - * In ink-compat it wraps the reconciler's HostElement — layout is - * computed by the Zireael C engine, so `measureElement` returns - * explicitly-set dimensions or zero with a warning. + * In ink-compat it wraps the reconciler's HostElement and carries + * committed layout metadata populated after each render frame. */ export type DOMElement = { nodeName: string; attributes: Record; childNodes: readonly DOMElement[]; + parentNode?: DOMElement; + internal_layout?: Readonly<{ x: number; y: number; width: number; height: number }>; + internal_scrollState?: Readonly<{ + scrollHeight: number; + scrollWidth: number; + clientHeight: number; + clientWidth: number; + }>; + resizeObservers?: Set; + internal_lastMeasuredSize?: Readonly<{ width: number; height: number }>; };