Skip to content

Commit 5e31a07

Browse files
committed
Support overflow scrolling.
1 parent 927d748 commit 5e31a07

File tree

12 files changed

+1409
-104
lines changed

12 files changed

+1409
-104
lines changed

examples/scroll/index.ts

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
import React from 'react';
2+
import {render} from '../../src/index.js';
3+
import ScrollableContent from './scroll.js';
4+
5+
render(React.createElement(ScrollableContent));

examples/scroll/scroll.tsx

Lines changed: 207 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,207 @@
1+
import process from 'node:process';
2+
import React, {useState, useRef, useEffect, useLayoutEffect} from 'react';
3+
import {Box, Text, useInput, measureElement} from '../../src/index.js';
4+
5+
type ScrollMode = 'vertical' | 'horizontal' | 'both' | 'hidden';
6+
7+
const items = Array.from({length: 100}).map((_, i) => ({
8+
id: i,
9+
text: `Line ${i} ${'-'.repeat(i)}`,
10+
}));
11+
12+
export function useTerminalSize(): {columns: number; rows: number} {
13+
const [size, setSize] = useState({
14+
columns: process.stdout.columns || 80,
15+
rows: process.stdout.rows || 20,
16+
});
17+
18+
useEffect(() => {
19+
const updateSize = () => {
20+
setSize({
21+
columns: process.stdout.columns || 80,
22+
rows: process.stdout.rows || 20,
23+
});
24+
};
25+
26+
process.stdout.on('resize', updateSize);
27+
28+
return () => {
29+
process.stdout.off('resize', updateSize);
30+
};
31+
}, []);
32+
33+
return size;
34+
}
35+
36+
const scrollModes: ScrollMode[] = ['vertical', 'horizontal', 'both', 'hidden'];
37+
38+
function ScrollableContent() {
39+
const [scrollMode, setScrollMode] = useState<ScrollMode>('vertical');
40+
const [scrollTop, setScrollTop] = useState(0);
41+
const [scrollLeft, setScrollLeft] = useState(0);
42+
const reference = useRef<any>(null);
43+
const {columns, rows} = useTerminalSize();
44+
const [size, setSize] = useState({
45+
innerHeight: 0,
46+
scrollHeight: 0,
47+
innerWidth: 0,
48+
scrollWidth: 0,
49+
});
50+
51+
const sizeRef = useRef(size);
52+
useEffect(() => {
53+
sizeRef.current = size;
54+
}, [size]);
55+
56+
const scrollIntervalRef = useRef<NodeJS.Timeout | null>(null);
57+
58+
useEffect(() => {
59+
return () => {
60+
if (scrollIntervalRef.current) {
61+
clearInterval(scrollIntervalRef.current);
62+
}
63+
};
64+
}, []);
65+
66+
useLayoutEffect(() => {
67+
if (reference.current) {
68+
const {innerHeight, scrollHeight, innerWidth, scrollWidth} =
69+
measureElement(reference.current);
70+
71+
if (
72+
size.innerHeight !== innerHeight ||
73+
size.scrollHeight !== scrollHeight ||
74+
size.innerWidth !== innerWidth ||
75+
size.scrollWidth !== scrollWidth
76+
) {
77+
setSize({innerHeight, scrollHeight, innerWidth, scrollWidth});
78+
}
79+
}
80+
});
81+
82+
useInput((input, key) => {
83+
if (input === 'm') {
84+
setScrollMode(previousMode => {
85+
const currentIndex = scrollModes.indexOf(previousMode);
86+
const nextIndex = (currentIndex + 1) % scrollModes.length;
87+
return scrollModes[nextIndex]!;
88+
});
89+
return;
90+
}
91+
92+
if (
93+
!key.upArrow &&
94+
!key.downArrow &&
95+
!key.leftArrow &&
96+
!key.rightArrow
97+
) {
98+
return;
99+
}
100+
101+
if (scrollIntervalRef.current) {
102+
clearInterval(scrollIntervalRef.current);
103+
}
104+
105+
const scroll = (
106+
setter: React.Dispatch<React.SetStateAction<number>>,
107+
getNewValue: (current: number) => number,
108+
) => {
109+
let frame = 0;
110+
const frames = 10;
111+
scrollIntervalRef.current = setInterval(() => {
112+
if (frame < frames) {
113+
setter(s => getNewValue(s));
114+
frame++;
115+
} else if (scrollIntervalRef.current) {
116+
clearInterval(scrollIntervalRef.current);
117+
scrollIntervalRef.current = null;
118+
}
119+
}, 16);
120+
};
121+
122+
if (key.upArrow) {
123+
scroll(setScrollTop, s => Math.max(0, s - 1));
124+
}
125+
126+
if (key.downArrow) {
127+
scroll(setScrollTop, s =>
128+
Math.min(
129+
s + 1,
130+
sizeRef.current.scrollHeight - sizeRef.current.innerHeight,
131+
),
132+
);
133+
}
134+
135+
if (key.leftArrow) {
136+
scroll(setScrollLeft, s => Math.max(0, s - 1));
137+
}
138+
139+
if (key.rightArrow) {
140+
scroll(setScrollLeft, s =>
141+
Math.min(
142+
s + 1,
143+
sizeRef.current.scrollWidth - sizeRef.current.innerWidth,
144+
),
145+
);
146+
}
147+
});
148+
149+
const overflowX =
150+
scrollMode === 'horizontal' || scrollMode === 'both' ? 'scroll' : 'hidden';
151+
const overflowY =
152+
scrollMode === 'vertical' || scrollMode === 'both' ? 'scroll' : 'hidden';
153+
154+
return (
155+
<Box flexDirection="column" height={rows - 2} width={columns}>
156+
<Box flexDirection="column" flexShrink={0} overflow="hidden">
157+
<Text>This is a demo showing a scrollable box.</Text>
158+
<Text>Press up/down arrow to scroll vertically.</Text>
159+
<Text>Press left/right arrow to scroll horizontally.</Text>
160+
<Text>
161+
Press 'm' to cycle through scroll modes (current: {scrollMode})
162+
</Text>
163+
<Text>ScrollTop: {scrollTop}</Text>
164+
<Text>ScrollLeft: {scrollLeft}</Text>
165+
<Text>
166+
Size: {size.innerWidth}x{size.innerHeight}
167+
</Text>
168+
<Text>
169+
Inner scrollable size: {size.scrollWidth}x{size.scrollHeight}
170+
</Text>
171+
</Box>
172+
<Box
173+
ref={reference}
174+
borderStyle="round"
175+
flexShrink={1}
176+
width="80%"
177+
flexDirection="column"
178+
overflowX={overflowX}
179+
overflowY={overflowY}
180+
paddingLeft={1}
181+
paddingRight={1}
182+
scrollTop={scrollTop}
183+
scrollLeft={scrollLeft}
184+
>
185+
<Box
186+
flexDirection="column"
187+
flexShrink={0}
188+
width={
189+
scrollMode === 'horizontal' || scrollMode === 'both' ? 120 : 'auto'
190+
}
191+
>
192+
{items.map(item => (
193+
<Text key={item.id}>{item.text}</Text>
194+
))}
195+
<Text key="last-line" color="yellow">
196+
This is the last line.
197+
</Text>
198+
</Box>
199+
</Box>
200+
<Box flexShrink={0} overflow="hidden">
201+
<Text>Example footer</Text>
202+
</Box>
203+
</Box>
204+
);
205+
}
206+
207+
export default ScrollableContent;

src/components/Box.tsx

Lines changed: 44 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
import React, {forwardRef, useContext, type PropsWithChildren} from 'react';
2-
import {type Except} from 'type-fest';
2+
import {type Except, type LiteralUnion} from 'type-fest';
3+
import {type ForegroundColorName} from 'ansi-styles';
34
import {type Styles} from '../styles.js';
45
import {type DOMElement} from '../dom.js';
56
import {accessibilityContext} from './AccessibilityContext.js';
@@ -53,6 +54,46 @@ export type Props = Except<Styles, 'textWrap'> & {
5354
readonly required?: boolean;
5455
readonly selected?: boolean;
5556
};
57+
58+
/**
59+
* Set the vertical scroll position.
60+
*/
61+
readonly scrollTop?: number;
62+
63+
/**
64+
* Set the horizontal scroll position.
65+
*/
66+
readonly scrollLeft?: number;
67+
68+
/**
69+
* Set the initial vertical scroll position.
70+
* @default 'top'
71+
*/
72+
readonly initialScrollPosition?: 'top' | 'bottom';
73+
74+
/**
75+
* Character to render for the scrollbar thumb.
76+
* @default '█'
77+
*/
78+
readonly scrollbarThumbCharacter?: string;
79+
80+
/**
81+
* Character to render for the scrollbar track.
82+
* @default '│'
83+
*/
84+
readonly scrollbarTrackCharacter?: string;
85+
86+
/**
87+
* Color of the scrollbar thumb.
88+
* @default 'white'
89+
*/
90+
readonly scrollbarThumbColor?: LiteralUnion<ForegroundColorName, string>;
91+
92+
/**
93+
* Color of the scrollbar track.
94+
* @default 'gray'
95+
*/
96+
readonly scrollbarTrackColor?: LiteralUnion<ForegroundColorName, string>;
5697
};
5798

5899
/**
@@ -67,6 +108,7 @@ const Box = forwardRef<DOMElement, PropsWithChildren<Props>>(
67108
'aria-hidden': ariaHidden,
68109
'aria-role': role,
69110
'aria-state': ariaState,
111+
initialScrollPosition = 'top',
70112
...style
71113
},
72114
ref,
@@ -86,6 +128,7 @@ const Box = forwardRef<DOMElement, PropsWithChildren<Props>>(
86128
flexGrow: 0,
87129
flexShrink: 1,
88130
...style,
131+
initialScrollPosition,
89132
backgroundColor,
90133
overflowX: style.overflowX ?? style.overflow ?? 'visible',
91134
overflowY: style.overflowY ?? style.overflow ?? 'visible',

src/dom.ts

Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -67,6 +67,9 @@ export type DOMElement = {
6767
onComputeLayout?: () => void;
6868
onRender?: () => void;
6969
onImmediateRender?: () => void;
70+
internal_scrollTop?: number;
71+
internal_scrollHeight?: number;
72+
internal_clientHeight?: number;
7073
} & InkNode;
7174

7275
export type TextNode = {
@@ -262,3 +265,42 @@ export const setTextNodeValue = (node: TextNode, text: string): void => {
262265
node.nodeValue = text;
263266
markNodeAsDirty(node);
264267
};
268+
269+
export function getScrollHeight(yogaNode: YogaNode | undefined): number {
270+
if (!yogaNode) {
271+
return 0;
272+
}
273+
274+
let scrollHeight = 0;
275+
for (let i = 0; i < yogaNode.getChildCount(); i++) {
276+
const child = yogaNode.getChild(i);
277+
scrollHeight += child.getComputedMargin(Yoga.EDGE_TOP);
278+
scrollHeight += child.getComputedHeight();
279+
scrollHeight += child.getComputedMargin(Yoga.EDGE_BOTTOM);
280+
}
281+
282+
scrollHeight +=
283+
yogaNode.getComputedPadding(Yoga.EDGE_TOP) +
284+
yogaNode.getComputedPadding(Yoga.EDGE_BOTTOM);
285+
286+
return scrollHeight;
287+
}
288+
289+
export function getScrollWidth(yogaNode: YogaNode | undefined): number {
290+
if (!yogaNode) {
291+
return 0;
292+
}
293+
294+
let scrollWidth = 0;
295+
for (let i = 0; i < yogaNode.getChildCount(); i++) {
296+
const child = yogaNode.getChild(i);
297+
scrollWidth += child.getComputedMargin(Yoga.EDGE_LEFT);
298+
scrollWidth += child.getComputedWidth();
299+
scrollWidth += child.getComputedMargin(Yoga.EDGE_RIGHT);
300+
}
301+
302+
scrollWidth +=
303+
yogaNode.getComputedPadding(Yoga.EDGE_LEFT) +
304+
yogaNode.getComputedPadding(Yoga.EDGE_RIGHT);
305+
return scrollWidth;
306+
}

0 commit comments

Comments
 (0)