Skip to content

Commit af9ecf3

Browse files
committed
feat(ink-compat): add getInnerHeight and Box scroll compatibility
1 parent 045a076 commit af9ecf3

13 files changed

Lines changed: 912 additions & 227 deletions

File tree

package-lock.json

Lines changed: 386 additions & 177 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

packages/ink-compat/package.json

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -35,6 +35,9 @@
3535
"devDependencies": {
3636
"@rezi-ui/testkit": "0.1.0-alpha.0",
3737
"@types/react": "^18.0.0",
38-
"@types/react-reconciler": "^0.28.9"
38+
"@types/react-reconciler": "^0.28.9",
39+
"ink": "^4.4.1",
40+
"ink-gradient": "^3.0.0",
41+
"ink-spinner": "^5.0.0"
3942
}
4043
}

packages/ink-compat/src/__tests__/api.surface.test.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,13 +3,15 @@ import {
33
type RenderOptions,
44
ResizeObserver,
55
getBoundingBox,
6+
getInnerHeight,
67
getScrollHeight,
78
render,
89
} from "../index.js";
910

1011
describe("api surface", () => {
1112
test("exports measurement and observer APIs", () => {
1213
assert.equal(typeof getBoundingBox, "function");
14+
assert.equal(typeof getInnerHeight, "function");
1315
assert.equal(typeof getScrollHeight, "function");
1416
assert.equal(typeof ResizeObserver, "function");
1517
});

packages/ink-compat/src/__tests__/compat.thirdparty.test.tsx

Lines changed: 65 additions & 32 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,9 @@
11
import { assert, describe, test } from "@rezi-ui/testkit";
2-
import type React from "react";
3-
import { useEffect, useState } from "react";
4-
import { Text, Transform, render } from "../index.js";
2+
import Gradient from "ink-gradient";
3+
import { Transform } from "ink";
4+
import Spinner from "ink-spinner";
5+
import React from "react";
6+
import { Box, type DOMElement, Text, getInnerHeight, getScrollHeight, render } from "../index.js";
57
import {
68
StubBackend,
79
encodeZrevBatchV1,
@@ -18,30 +20,31 @@ async function pushInitialResize(backend: StubBackend): Promise<void> {
1820
await flushMicrotasks(10);
1921
}
2022

21-
describe("third-party compatibility smoke", () => {
22-
test("ink-spinner pattern (stateful interval + <Text>) triggers render updates", async () => {
23-
function SpinnerLike() {
24-
const frames = ["-", "\\", "|", "/"] as const;
25-
const [frame, setFrame] = useState(0);
23+
async function renderToLastFrameBytes(tree: React.ReactNode): Promise<Uint8Array> {
24+
const backend = new StubBackend();
25+
const inst = render(tree, { internal_backend: backend, exitOnCtrlC: false });
2626

27-
useEffect(() => {
28-
const timer = setInterval(() => {
29-
setFrame((prev) => (prev + 1) % frames.length);
30-
}, 10);
31-
return () => clearInterval(timer);
32-
}, [frames.length]);
27+
await flushMicrotasks(10);
28+
await pushInitialResize(backend);
29+
30+
const bytes = backend.requestedFrames[backend.requestedFrames.length - 1] ?? new Uint8Array();
3331

34-
return <Text>{frames[frame]}</Text>;
35-
}
32+
inst.unmount();
33+
await inst.waitUntilExit();
3634

35+
return bytes;
36+
}
37+
38+
describe("third-party compatibility (real packages)", () => {
39+
test("ink-spinner renders and advances frames over time", async () => {
3740
const backend = new StubBackend();
38-
const inst = render(<SpinnerLike />, { internal_backend: backend, exitOnCtrlC: false });
41+
const inst = render(<Spinner type="dots" />, { internal_backend: backend, exitOnCtrlC: false });
3942

4043
await flushMicrotasks(10);
4144
await pushInitialResize(backend);
4245

4346
const initialFrames = backend.requestedFrames.length;
44-
await new Promise<void>((resolve) => setTimeout(resolve, 40));
47+
await new Promise<void>((resolve) => setTimeout(resolve, 140));
4548
await flushMicrotasks(10);
4649

4750
assert.ok(backend.requestedFrames.length > initialFrames);
@@ -50,30 +53,60 @@ describe("third-party compatibility smoke", () => {
5053
await inst.waitUntilExit();
5154
});
5255

53-
test("ink-gradient pattern (Transform transform callback) receives flattened text", async () => {
54-
let lastInput = "";
55-
56-
function GradientLike(props: Readonly<{ children: React.ReactNode }>) {
57-
const transform = (children: string) => {
58-
lastInput = children;
59-
return `\u001B[31m${children}\u001B[39m`;
60-
};
56+
test("ink-gradient uses Ink Transform behavior with real package runtime", async () => {
57+
const gradientElement = (Gradient as unknown as (props: {
58+
name: string;
59+
children: React.ReactNode;
60+
}) => React.ReactElement<{
61+
transform: (input: string) => string;
62+
children: React.ReactNode;
63+
}>)({
64+
name: "rainbow",
65+
children: <Text>rainbow</Text>,
66+
});
67+
68+
assert.equal(gradientElement.type, Transform);
69+
assert.equal(typeof gradientElement.props.transform, "function");
70+
71+
const frame = await renderToLastFrameBytes(
72+
<Gradient name="rainbow">
73+
<Text>rainbow</Text>
74+
</Gradient>,
75+
);
6176

62-
return <Transform transform={transform}>{props.children}</Transform>;
63-
}
77+
assert.ok(frame.length > 0);
78+
});
6479

80+
test("third-party components remain compatible with scroll measurement semantics", async () => {
6581
const backend = new StubBackend();
82+
const containerRef = React.createRef<DOMElement>();
83+
6684
const inst = render(
67-
<GradientLike>
68-
<Text>rainbow</Text>
69-
</GradientLike>,
85+
<Box
86+
ref={containerRef}
87+
width={20}
88+
height={1}
89+
flexDirection="column"
90+
overflowY="scroll"
91+
overflowX="hidden"
92+
scrollTop={1}
93+
>
94+
<Spinner type="line" />
95+
<Gradient name="pastel">
96+
<Text>line-2</Text>
97+
</Gradient>
98+
<Text>line-3</Text>
99+
</Box>,
70100
{ internal_backend: backend, exitOnCtrlC: false },
71101
);
72102

73103
await flushMicrotasks(10);
74104
await pushInitialResize(backend);
75105

76-
assert.equal(lastInput, "rainbow");
106+
const container = containerRef.current;
107+
assert.ok(container);
108+
assert.equal(getInnerHeight(container), 1);
109+
assert.ok(getScrollHeight(container) >= 3);
77110
assert.ok(backend.requestedFrames.length >= 1);
78111

79112
inst.unmount();

packages/ink-compat/src/__tests__/measurement.test.tsx

Lines changed: 137 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ import {
55
type DOMElement,
66
Text,
77
getBoundingBox,
8+
getInnerHeight,
89
getScrollHeight,
910
measureElement,
1011
render,
@@ -110,4 +111,140 @@ describe("measurement", () => {
110111
inst.unmount();
111112
await inst.waitUntilExit();
112113
});
114+
115+
test("getInnerHeight matches inner/client height semantics and updates on rerender", async () => {
116+
const backend = new StubBackend();
117+
const ref = React.createRef<DOMElement>();
118+
119+
const inst = render(
120+
<Box width={12} height={6} borderStyle="single" ref={ref}>
121+
<Text>body</Text>
122+
</Box>,
123+
{ internal_backend: backend, exitOnCtrlC: false },
124+
);
125+
126+
await flushMicrotasks(10);
127+
await pushInitialResize(backend);
128+
129+
const node = ref.current;
130+
assert.ok(node);
131+
assert.equal(measureElement(node).height, 6);
132+
assert.equal(getInnerHeight(node), 4);
133+
134+
inst.rerender(
135+
<Box width={12} height={8} borderStyle="single" ref={ref}>
136+
<Text>body</Text>
137+
</Box>,
138+
);
139+
await flushMicrotasks(10);
140+
141+
assert.equal(measureElement(node).height, 8);
142+
assert.equal(getInnerHeight(node), 6);
143+
144+
inst.unmount();
145+
await inst.waitUntilExit();
146+
});
147+
148+
test("overflowY=scroll and scrollTop update visible viewport in Gemini-like container", async () => {
149+
const backend = new StubBackend();
150+
const containerRef = React.createRef<DOMElement>();
151+
const itemRefs = [
152+
React.createRef<DOMElement>(),
153+
React.createRef<DOMElement>(),
154+
React.createRef<DOMElement>(),
155+
React.createRef<DOMElement>(),
156+
];
157+
158+
const tree = (scrollTop: number) => (
159+
<Box
160+
ref={containerRef}
161+
width={10}
162+
height={2}
163+
flexDirection="column"
164+
overflowY="scroll"
165+
overflowX="hidden"
166+
scrollTop={scrollTop}
167+
>
168+
<Box ref={itemRefs[0]!} height={1}>
169+
<Text>row-1</Text>
170+
</Box>
171+
<Box ref={itemRefs[1]!} height={1}>
172+
<Text>row-2</Text>
173+
</Box>
174+
<Box ref={itemRefs[2]!} height={1}>
175+
<Text>row-3</Text>
176+
</Box>
177+
<Box ref={itemRefs[3]!} height={1}>
178+
<Text>row-4</Text>
179+
</Box>
180+
</Box>
181+
);
182+
183+
const inst = render(tree(0), { internal_backend: backend, exitOnCtrlC: false });
184+
await flushMicrotasks(10);
185+
await pushInitialResize(backend);
186+
187+
const container = containerRef.current;
188+
const first = itemRefs[0]!.current;
189+
const second = itemRefs[1]!.current;
190+
const third = itemRefs[2]!.current;
191+
assert.ok(container);
192+
assert.ok(first);
193+
assert.ok(second);
194+
assert.ok(third);
195+
196+
assert.equal(getScrollHeight(container), 4);
197+
assert.equal(getBoundingBox(first).y, 0);
198+
assert.equal(getBoundingBox(second).y, 1);
199+
assert.equal(measureElement(third).height, 0);
200+
201+
inst.rerender(tree(1));
202+
await flushMicrotasks(10);
203+
204+
assert.equal(getScrollHeight(container), 4);
205+
assert.equal(measureElement(first).height, 0);
206+
assert.equal(getBoundingBox(second).y, 0);
207+
assert.equal(getBoundingBox(third).y, 1);
208+
209+
inst.unmount();
210+
await inst.waitUntilExit();
211+
});
212+
213+
test("scrollbarThumbColor is accepted as a renderer no-op without affecting scroll metrics", async () => {
214+
const backend = new StubBackend();
215+
const containerRef = React.createRef<DOMElement>();
216+
217+
const inst = render(
218+
<Box
219+
ref={containerRef}
220+
width={8}
221+
height={2}
222+
flexDirection="column"
223+
overflowY="scroll"
224+
scrollbarThumbColor="magenta"
225+
>
226+
<Box height={1}>
227+
<Text>a</Text>
228+
</Box>
229+
<Box height={1}>
230+
<Text>b</Text>
231+
</Box>
232+
<Box height={1}>
233+
<Text>c</Text>
234+
</Box>
235+
</Box>,
236+
{ internal_backend: backend, exitOnCtrlC: false },
237+
);
238+
239+
await flushMicrotasks(10);
240+
await pushInitialResize(backend);
241+
242+
const container = containerRef.current;
243+
assert.ok(container);
244+
assert.equal(getScrollHeight(container), 3);
245+
assert.ok(backend.requestedFrames.length >= 1);
246+
247+
inst.unmount();
248+
await inst.waitUntilExit();
249+
});
113250
});

packages/ink-compat/src/__tests__/props.test.ts

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -108,6 +108,20 @@ describe("props: mapBoxProps()", () => {
108108
const out = mapBoxProps({ display: "none" });
109109
assert.equal(out.hidden, true);
110110
});
111+
112+
test("overflowY=scroll and scrollbarThumbColor map into runtime scroll metadata", () => {
113+
const out = mapBoxProps({
114+
overflow: "hidden",
115+
overflowY: "scroll",
116+
scrollTop: 7,
117+
scrollbarThumbColor: "yellow",
118+
});
119+
120+
assert.equal(out.overflow, "hidden");
121+
assert.equal(out.overflowY, "scroll");
122+
assert.equal(out.scrollTop, 7);
123+
assert.equal(out.scrollbarThumbColor, "yellow");
124+
});
111125
});
112126

113127
describe("props: mapTextProps()", () => {

packages/ink-compat/src/index.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -23,7 +23,7 @@ export { render } from "./render.js";
2323

2424
// Measurement
2525
export { default as measureElement } from "./measureElement.js";
26-
export { getBoundingBox, getScrollHeight, getScrollWidth } from "./measureElement.js";
26+
export { getBoundingBox, getInnerHeight, getScrollHeight, getScrollWidth } from "./measureElement.js";
2727
export { default as ResizeObserver, ResizeObserverEntry } from "./resizeObserver.js";
2828

2929
// Types

packages/ink-compat/src/measureElement.ts

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,12 @@
11
import {
22
getBoundingBox,
3+
getInnerHeight,
34
getScrollHeight,
45
getScrollWidth,
56
measureElementFromLayout,
67
} from "./measurement.js";
78
import type { DOMElement } from "./types.js";
8-
export { getBoundingBox, getScrollHeight, getScrollWidth } from "./measurement.js";
9+
export { getBoundingBox, getInnerHeight, getScrollHeight, getScrollWidth } from "./measurement.js";
910

1011
/**
1112
* Measure the dimensions of a `<Box>` element.

0 commit comments

Comments
 (0)