Skip to content

Commit 19c190a

Browse files
Merge pull request #31 from RtlZeroMemory/codex/ink-scroll-compat-gaps
feat(ink-compat): add getInnerHeight and Box scroll compatibility
2 parents 045a076 + e64e51d commit 19c190a

13 files changed

Lines changed: 949 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: 67 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 { Transform } from "ink";
3+
import Gradient from "ink-gradient";
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,62 @@ 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 = (
58+
Gradient as unknown as (props: {
59+
name: string;
60+
children: React.ReactNode;
61+
}) => React.ReactElement<{
62+
transform: (input: string) => string;
63+
children: React.ReactNode;
64+
}>
65+
)({
66+
name: "rainbow",
67+
children: <Text>rainbow</Text>,
68+
});
69+
70+
assert.equal(gradientElement.type, Transform);
71+
assert.equal(typeof gradientElement.props.transform, "function");
72+
73+
const frame = await renderToLastFrameBytes(
74+
<Gradient name="rainbow">
75+
<Text>rainbow</Text>
76+
</Gradient>,
77+
);
6178

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

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

73105
await flushMicrotasks(10);
74106
await pushInitialResize(backend);
75107

76-
assert.equal(lastInput, "rainbow");
108+
const container = containerRef.current;
109+
assert.ok(container);
110+
assert.equal(getInnerHeight(container), 1);
111+
assert.ok(getScrollHeight(container) >= 3);
77112
assert.ok(backend.requestedFrames.length >= 1);
78113

79114
inst.unmount();

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

Lines changed: 135 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,138 @@ 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 firstRef = React.createRef<DOMElement>();
152+
const secondRef = React.createRef<DOMElement>();
153+
const thirdRef = React.createRef<DOMElement>();
154+
const fourthRef = React.createRef<DOMElement>();
155+
156+
const tree = (scrollTop: number) => (
157+
<Box
158+
ref={containerRef}
159+
width={10}
160+
height={2}
161+
flexDirection="column"
162+
overflowY="scroll"
163+
overflowX="hidden"
164+
scrollTop={scrollTop}
165+
>
166+
<Box ref={firstRef} height={1}>
167+
<Text>row-1</Text>
168+
</Box>
169+
<Box ref={secondRef} height={1}>
170+
<Text>row-2</Text>
171+
</Box>
172+
<Box ref={thirdRef} height={1}>
173+
<Text>row-3</Text>
174+
</Box>
175+
<Box ref={fourthRef} height={1}>
176+
<Text>row-4</Text>
177+
</Box>
178+
</Box>
179+
);
180+
181+
const inst = render(tree(0), { internal_backend: backend, exitOnCtrlC: false });
182+
await flushMicrotasks(10);
183+
await pushInitialResize(backend);
184+
185+
const container = containerRef.current;
186+
const first = firstRef.current;
187+
const second = secondRef.current;
188+
const third = thirdRef.current;
189+
assert.ok(container);
190+
assert.ok(first);
191+
assert.ok(second);
192+
assert.ok(third);
193+
194+
assert.equal(getScrollHeight(container), 4);
195+
assert.equal(getBoundingBox(first).y, 0);
196+
assert.equal(getBoundingBox(second).y, 1);
197+
assert.equal(measureElement(third).height, 0);
198+
199+
inst.rerender(tree(1));
200+
await flushMicrotasks(10);
201+
202+
assert.equal(getScrollHeight(container), 4);
203+
assert.equal(measureElement(first).height, 0);
204+
assert.equal(getBoundingBox(second).y, 0);
205+
assert.equal(getBoundingBox(third).y, 1);
206+
207+
inst.unmount();
208+
await inst.waitUntilExit();
209+
});
210+
211+
test("scrollbarThumbColor is accepted as a renderer no-op without affecting scroll metrics", async () => {
212+
const backend = new StubBackend();
213+
const containerRef = React.createRef<DOMElement>();
214+
215+
const inst = render(
216+
<Box
217+
ref={containerRef}
218+
width={8}
219+
height={2}
220+
flexDirection="column"
221+
overflowY="scroll"
222+
scrollbarThumbColor="magenta"
223+
>
224+
<Box height={1}>
225+
<Text>a</Text>
226+
</Box>
227+
<Box height={1}>
228+
<Text>b</Text>
229+
</Box>
230+
<Box height={1}>
231+
<Text>c</Text>
232+
</Box>
233+
</Box>,
234+
{ internal_backend: backend, exitOnCtrlC: false },
235+
);
236+
237+
await flushMicrotasks(10);
238+
await pushInitialResize(backend);
239+
240+
const container = containerRef.current;
241+
assert.ok(container);
242+
assert.equal(getScrollHeight(container), 3);
243+
assert.ok(backend.requestedFrames.length >= 1);
244+
245+
inst.unmount();
246+
await inst.waitUntilExit();
247+
});
113248
});

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: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -23,7 +23,12 @@ 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 {
27+
getBoundingBox,
28+
getInnerHeight,
29+
getScrollHeight,
30+
getScrollWidth,
31+
} from "./measureElement.js";
2732
export { default as ResizeObserver, ResizeObserverEntry } from "./resizeObserver.js";
2833

2934
// 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)