Skip to content

Commit a322970

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

File tree

8 files changed

+670
-104
lines changed

8 files changed

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

src/measure-element.ts

Lines changed: 54 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
1-
import {type DOMElement} from './dom.js';
1+
import Yoga from 'yoga-layout';
2+
import {getScrollHeight, getScrollWidth, type DOMElement} from './dom.js';
23

34
type Output = {
45
/**
@@ -10,14 +11,61 @@ type Output = {
1011
Element height.
1112
*/
1213
height: number;
14+
15+
/**
16+
* Element width without padding and borders.
17+
*/
18+
innerWidth: number;
19+
20+
/**
21+
* Element height without padding and borders.
22+
*/
23+
innerHeight: number;
24+
25+
/**
26+
* The entire height of an elements content.
27+
*/
28+
scrollHeight: number;
29+
30+
/**
31+
* The entire width of an elements content.
32+
*/
33+
scrollWidth: number;
1334
};
1435

1536
/**
1637
Measure the dimensions of a particular `<Box>` element.
17-
*/
18-
const measureElement = (node: DOMElement): Output => ({
19-
width: node.yogaNode?.getComputedWidth() ?? 0,
20-
height: node.yogaNode?.getComputedHeight() ?? 0,
21-
});
38+
*/
39+
const measureElement = (node: DOMElement): Output => {
40+
const {yogaNode} = node;
41+
42+
if (!yogaNode) {
43+
return {
44+
width: 0,
45+
height: 0,
46+
innerWidth: 0,
47+
innerHeight: 0,
48+
scrollHeight: 0,
49+
scrollWidth: 0,
50+
};
51+
}
52+
53+
const width = yogaNode.getComputedWidth() ?? 0;
54+
const height = yogaNode.getComputedHeight() ?? 0;
55+
56+
const borderLeft = yogaNode.getComputedBorder(Yoga.EDGE_LEFT);
57+
const borderRight = yogaNode.getComputedBorder(Yoga.EDGE_RIGHT);
58+
const borderTop = yogaNode.getComputedBorder(Yoga.EDGE_TOP);
59+
const borderBottom = yogaNode.getComputedBorder(Yoga.EDGE_BOTTOM);
60+
61+
return {
62+
width,
63+
height,
64+
innerWidth: width - borderLeft - borderRight,
65+
innerHeight: height - borderTop - borderBottom,
66+
scrollHeight: getScrollHeight(yogaNode),
67+
scrollWidth: getScrollWidth(yogaNode),
68+
};
69+
};
2270

2371
export default measureElement;

0 commit comments

Comments
 (0)