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
563 changes: 386 additions & 177 deletions package-lock.json

Large diffs are not rendered by default.

5 changes: 4 additions & 1 deletion packages/ink-compat/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,9 @@
"devDependencies": {
"@rezi-ui/testkit": "0.1.0-alpha.0",
"@types/react": "^18.0.0",
"@types/react-reconciler": "^0.28.9"
"@types/react-reconciler": "^0.28.9",
"ink": "^4.4.1",
"ink-gradient": "^3.0.0",
"ink-spinner": "^5.0.0"
}
}
2 changes: 2 additions & 0 deletions packages/ink-compat/src/__tests__/api.surface.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,13 +3,15 @@ import {
type RenderOptions,
ResizeObserver,
getBoundingBox,
getInnerHeight,
getScrollHeight,
render,
} from "../index.js";

describe("api surface", () => {
test("exports measurement and observer APIs", () => {
assert.equal(typeof getBoundingBox, "function");
assert.equal(typeof getInnerHeight, "function");
assert.equal(typeof getScrollHeight, "function");
assert.equal(typeof ResizeObserver, "function");
});
Expand Down
99 changes: 67 additions & 32 deletions packages/ink-compat/src/__tests__/compat.thirdparty.test.tsx
Original file line number Diff line number Diff line change
@@ -1,7 +1,9 @@
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 { Transform } from "ink";
import Gradient from "ink-gradient";
import Spinner from "ink-spinner";
import React from "react";
import { Box, type DOMElement, Text, getInnerHeight, getScrollHeight, render } from "../index.js";
import {
StubBackend,
encodeZrevBatchV1,
Expand All @@ -18,30 +20,31 @@ async function pushInitialResize(backend: StubBackend): Promise<void> {
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);
async function renderToLastFrameBytes(tree: React.ReactNode): Promise<Uint8Array> {
const backend = new StubBackend();
const inst = render(tree, { internal_backend: backend, exitOnCtrlC: false });

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

const bytes = backend.requestedFrames[backend.requestedFrames.length - 1] ?? new Uint8Array();

return <Text>{frames[frame]}</Text>;
}
inst.unmount();
await inst.waitUntilExit();

return bytes;
}

describe("third-party compatibility (real packages)", () => {
test("ink-spinner renders and advances frames over time", async () => {
const backend = new StubBackend();
const inst = render(<SpinnerLike />, { internal_backend: backend, exitOnCtrlC: false });
const inst = render(<Spinner type="dots" />, { 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 new Promise<void>((resolve) => setTimeout(resolve, 140));
await flushMicrotasks(10);

assert.ok(backend.requestedFrames.length > initialFrames);
Expand All @@ -50,30 +53,62 @@ describe("third-party compatibility smoke", () => {
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`;
};
test("ink-gradient uses Ink Transform behavior with real package runtime", async () => {
const gradientElement = (
Gradient as unknown as (props: {
name: string;
children: React.ReactNode;
}) => React.ReactElement<{
transform: (input: string) => string;
children: React.ReactNode;
}>
)({
name: "rainbow",
children: <Text>rainbow</Text>,
});

assert.equal(gradientElement.type, Transform);
assert.equal(typeof gradientElement.props.transform, "function");

const frame = await renderToLastFrameBytes(
<Gradient name="rainbow">
<Text>rainbow</Text>
</Gradient>,
);

return <Transform transform={transform}>{props.children}</Transform>;
}
assert.ok(frame.length > 0);
});

test("third-party components remain compatible with scroll measurement semantics", async () => {
const backend = new StubBackend();
const containerRef = React.createRef<DOMElement>();

const inst = render(
<GradientLike>
<Text>rainbow</Text>
</GradientLike>,
<Box
ref={containerRef}
width={20}
height={1}
flexDirection="column"
overflowY="scroll"
overflowX="hidden"
scrollTop={1}
>
<Spinner type="line" />
<Gradient name="pastel">
<Text>line-2</Text>
</Gradient>
<Text>line-3</Text>
</Box>,
{ internal_backend: backend, exitOnCtrlC: false },
);

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

assert.equal(lastInput, "rainbow");
const container = containerRef.current;
assert.ok(container);
assert.equal(getInnerHeight(container), 1);
assert.ok(getScrollHeight(container) >= 3);
assert.ok(backend.requestedFrames.length >= 1);

inst.unmount();
Expand Down
135 changes: 135 additions & 0 deletions packages/ink-compat/src/__tests__/measurement.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import {
type DOMElement,
Text,
getBoundingBox,
getInnerHeight,
getScrollHeight,
measureElement,
render,
Expand Down Expand Up @@ -110,4 +111,138 @@ describe("measurement", () => {
inst.unmount();
await inst.waitUntilExit();
});

test("getInnerHeight matches inner/client height semantics and updates on rerender", async () => {
const backend = new StubBackend();
const ref = React.createRef<DOMElement>();

const inst = render(
<Box width={12} height={6} borderStyle="single" ref={ref}>
<Text>body</Text>
</Box>,
{ internal_backend: backend, exitOnCtrlC: false },
);

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

const node = ref.current;
assert.ok(node);
assert.equal(measureElement(node).height, 6);
assert.equal(getInnerHeight(node), 4);

inst.rerender(
<Box width={12} height={8} borderStyle="single" ref={ref}>
<Text>body</Text>
</Box>,
);
await flushMicrotasks(10);

assert.equal(measureElement(node).height, 8);
assert.equal(getInnerHeight(node), 6);

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

test("overflowY=scroll and scrollTop update visible viewport in Gemini-like container", async () => {
const backend = new StubBackend();
const containerRef = React.createRef<DOMElement>();
const firstRef = React.createRef<DOMElement>();
const secondRef = React.createRef<DOMElement>();
const thirdRef = React.createRef<DOMElement>();
const fourthRef = React.createRef<DOMElement>();

const tree = (scrollTop: number) => (
<Box
ref={containerRef}
width={10}
height={2}
flexDirection="column"
overflowY="scroll"
overflowX="hidden"
scrollTop={scrollTop}
>
<Box ref={firstRef} height={1}>
<Text>row-1</Text>
</Box>
<Box ref={secondRef} height={1}>
<Text>row-2</Text>
</Box>
<Box ref={thirdRef} height={1}>
<Text>row-3</Text>
</Box>
<Box ref={fourthRef} height={1}>
<Text>row-4</Text>
</Box>
</Box>
);

const inst = render(tree(0), { internal_backend: backend, exitOnCtrlC: false });
await flushMicrotasks(10);
await pushInitialResize(backend);

const container = containerRef.current;
const first = firstRef.current;
const second = secondRef.current;
const third = thirdRef.current;
assert.ok(container);
assert.ok(first);
assert.ok(second);
assert.ok(third);

assert.equal(getScrollHeight(container), 4);
assert.equal(getBoundingBox(first).y, 0);
assert.equal(getBoundingBox(second).y, 1);
assert.equal(measureElement(third).height, 0);

inst.rerender(tree(1));
await flushMicrotasks(10);

assert.equal(getScrollHeight(container), 4);
assert.equal(measureElement(first).height, 0);
assert.equal(getBoundingBox(second).y, 0);
assert.equal(getBoundingBox(third).y, 1);

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

test("scrollbarThumbColor is accepted as a renderer no-op without affecting scroll metrics", async () => {
const backend = new StubBackend();
const containerRef = React.createRef<DOMElement>();

const inst = render(
<Box
ref={containerRef}
width={8}
height={2}
flexDirection="column"
overflowY="scroll"
scrollbarThumbColor="magenta"
>
<Box height={1}>
<Text>a</Text>
</Box>
<Box height={1}>
<Text>b</Text>
</Box>
<Box height={1}>
<Text>c</Text>
</Box>
</Box>,
{ internal_backend: backend, exitOnCtrlC: false },
);

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

const container = containerRef.current;
assert.ok(container);
assert.equal(getScrollHeight(container), 3);
assert.ok(backend.requestedFrames.length >= 1);

inst.unmount();
await inst.waitUntilExit();
});
});
14 changes: 14 additions & 0 deletions packages/ink-compat/src/__tests__/props.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -108,6 +108,20 @@ describe("props: mapBoxProps()", () => {
const out = mapBoxProps({ display: "none" });
assert.equal(out.hidden, true);
});

test("overflowY=scroll and scrollbarThumbColor map into runtime scroll metadata", () => {
const out = mapBoxProps({
overflow: "hidden",
overflowY: "scroll",
scrollTop: 7,
scrollbarThumbColor: "yellow",
});

assert.equal(out.overflow, "hidden");
assert.equal(out.overflowY, "scroll");
assert.equal(out.scrollTop, 7);
assert.equal(out.scrollbarThumbColor, "yellow");
});
});

describe("props: mapTextProps()", () => {
Expand Down
7 changes: 6 additions & 1 deletion packages/ink-compat/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -23,7 +23,12 @@ export { render } from "./render.js";

// Measurement
export { default as measureElement } from "./measureElement.js";
export { getBoundingBox, getScrollHeight, getScrollWidth } from "./measureElement.js";
export {
getBoundingBox,
getInnerHeight,
getScrollHeight,
getScrollWidth,
} from "./measureElement.js";
export { default as ResizeObserver, ResizeObserverEntry } from "./resizeObserver.js";

// Types
Expand Down
3 changes: 2 additions & 1 deletion packages/ink-compat/src/measureElement.ts
Original file line number Diff line number Diff line change
@@ -1,11 +1,12 @@
import {
getBoundingBox,
getInnerHeight,
getScrollHeight,
getScrollWidth,
measureElementFromLayout,
} from "./measurement.js";
import type { DOMElement } from "./types.js";
export { getBoundingBox, getScrollHeight, getScrollWidth } from "./measurement.js";
export { getBoundingBox, getInnerHeight, getScrollHeight, getScrollWidth } from "./measurement.js";

/**
* Measure the dimensions of a `<Box>` element.
Expand Down
Loading
Loading